Files
2026-01-16 00:15:37 +00:00

209 lines
8.2 KiB
Python

#!/usr/bin/env python3
#################################################
#
# KM Living Hinge
# A highly opinionated living hinge generator
#
# With love, by Jondale
#
#################################################
import math
import inkex
from inkex import ShapeElement, bezier
from shapes import get_shape, get_config
from livinghinge import generate_hinge, segments_to_svg_paths
class KMLivingHinge(inkex.EffectExtension):
def add_arguments(self, pars) -> None:
pars.add_argument("--units", default="mm")
pars.add_argument("--angle", type=float, default=0.0)
pars.add_argument("--type", default="line")
pars.add_argument("--line_height_pct", type=int, default=80)
pars.add_argument("--line_x_spacing", type=float, default=2.0)
pars.add_argument("--line_y_spacing", type=float, default=2.0)
pars.add_argument("--fishbone_height", type=float, default=10.0)
pars.add_argument("--fishbone_width", type=float, default=5.0)
pars.add_argument("--fishbone_x_spacing", type=float, default=2.0)
pars.add_argument("--fishbone_y_spacing", type=float, default=2.0)
pars.add_argument("--cross_height", type=float, default=10.0)
pars.add_argument("--cross_width", type=float, default=5.0)
pars.add_argument("--bezier_height", type=float, default=10.0)
pars.add_argument("--bezier_width", type=float, default=5.0)
pars.add_argument("--bezier_x_spacing", type=float, default=2.0)
pars.add_argument("--bezier_y_spacing", type=float, default=2.0)
pars.add_argument("--wave_height", type=float, default=10.0)
pars.add_argument("--wave_width", type=float, default=5.0)
pars.add_argument("--fabric_height", type=float, default=10.0)
pars.add_argument("--fabric_width", type=float, default=5.0)
pars.add_argument("--circle_height", type=float, default=5.0)
pars.add_argument("--circle_width", type=float, default=5.0)
pars.add_argument("--circle_x_spacing", type=float, default=2.0)
pars.add_argument("--circle_y_spacing", type=float, default=2.0)
def effect(self) -> None:
opts = self.options
pattern_type = opts.type or "line"
if pattern_type not in ("line", "fishbone", "cross", "bezier", "wave", "fabric", "circle"):
pattern_type = "line"
selection = getattr(self.svg, "selection", None)
if not selection or len(selection) == 0:
raise inkex.AbortExtension(
"Select a shape (path, rectangle, circle, etc.); the hinge fills and is trimmed to your selection."
)
selected_elems = list(selection)
def _uu(value, unit):
return float(self.svg.unittouu(f"{value}{unit}"))
def _superpath_to_polygons(superpath):
polys = []
for sub in superpath:
if not sub:
continue
poly = []
for node in sub:
if len(node) >= 2 and len(node[1]) == 2:
poly.append((node[1][0], node[1][1]))
if len(poly) >= 3:
if poly[0] != poly[-1]:
poly.append(poly[0])
polys.append(poly)
return polys
seen_shapes = set()
shapes_to_process = []
def _iter_shapes(elem):
if isinstance(elem, ShapeElement) and not isinstance(elem, inkex.Group):
yield elem
if isinstance(elem, inkex.Group):
for child in elem.iterdescendants():
if isinstance(child, ShapeElement) and not isinstance(child, inkex.Group):
yield child
return
for child in elem.iterdescendants():
if isinstance(child, ShapeElement) and not isinstance(child, inkex.Group):
yield child
for elem in selected_elems:
for shape in _iter_shapes(elem):
key = getattr(shape, "get_id", lambda: None)() or shape.get("id") or id(shape)
if key in seen_shapes:
continue
seen_shapes.add(key)
shapes_to_process.append(shape)
if not shapes_to_process:
raise inkex.AbortExtension("Unable to read the selected shape geometry.")
min_x, min_y, max_x, max_y = float('inf'), float('inf'), float('-inf'), float('-inf')
for shape in shapes_to_process:
bbox = shape.bounding_box(shape.composed_transform())
if bbox:
min_x = min(min_x, bbox.left)
min_y = min(min_y, bbox.top)
max_x = max(max_x, bbox.right)
max_y = max(max_y, bbox.bottom)
bbox_width = max_x - min_x
bbox_height = max_y - min_y
bbox = (min_x, min_y, bbox_width, bbox_height)
unit = opts.units
angle_rad = math.radians(float(opts.angle))
if pattern_type == "line":
height = opts.line_height_pct / 100.0
width = 0
x_spacing = _uu(opts.line_x_spacing, unit)
y_spacing = _uu(opts.line_y_spacing, unit)
elif pattern_type == "fishbone":
height = _uu(opts.fishbone_height, unit)
width = _uu(opts.fishbone_width, unit)
x_spacing = _uu(opts.fishbone_x_spacing, unit)
y_spacing = _uu(opts.fishbone_y_spacing, unit)
elif pattern_type == "cross":
height = _uu(opts.cross_height, unit)
width = _uu(opts.cross_width, unit)
x_spacing = 0
y_spacing = 0
elif pattern_type == "bezier":
height = _uu(opts.bezier_height, unit)
width = _uu(opts.bezier_width, unit)
x_spacing = _uu(opts.bezier_x_spacing, unit)
y_spacing = _uu(opts.bezier_y_spacing, unit)
elif pattern_type == "wave":
height = _uu(opts.wave_height, unit)
width = _uu(opts.wave_width, unit)
x_spacing = 0
y_spacing = 0
elif pattern_type == "fabric":
height = _uu(opts.fabric_height, unit)
width = _uu(opts.fabric_width, unit)
x_spacing = 0
y_spacing = 0
elif pattern_type == "circle":
height = _uu(opts.circle_height, unit)
width = _uu(opts.circle_width, unit)
x_spacing = _uu(opts.circle_x_spacing, unit)
y_spacing = _uu(opts.circle_y_spacing, unit)
else:
height = opts.line_height_pct / 100.0
width = 0
x_spacing = _uu(opts.line_x_spacing, unit)
y_spacing = _uu(opts.line_y_spacing, unit)
config_fn = get_config(pattern_type)
height, width, x_spacing, y_spacing, y_offset = config_fn(
height, width, x_spacing, y_spacing, bbox
)
if height <= 0:
raise inkex.AbortExtension("Height must be greater than zero.")
if width < 0:
raise inkex.AbortExtension("Width must not be negative.")
stroke_width = float(self.svg.unittouu("0.25mm"))
stroke_style = {
"stroke": "#000000",
"stroke-width": str(stroke_width),
"fill": "none",
"stroke-linecap": "round",
}
parent = self.svg.get_current_layer()
hinge_group = inkex.Group(id=self.svg.get_unique_id("km-living-hinge"))
hinge_group.set("{http://www.inkscape.org/namespaces/inkscape}label", "KM Living Hinge")
parent.add(hinge_group)
shape_fn = get_shape(pattern_type)
for shape in shapes_to_process:
shape_path = shape.to_path_element().path.transform(shape.composed_transform())
csp = shape_path.to_superpath()
bezier.cspsubdiv(csp, 0.25)
polygons = _superpath_to_polygons(csp)
if not polygons:
continue
segments = generate_hinge(
polygons=polygons,
shape_fn=shape_fn,
height=height,
width=width,
x_spacing=x_spacing,
y_spacing=y_spacing,
y_offset=y_offset,
angle_rad=angle_rad,
)
segments_to_svg_paths(segments, stroke_style, hinge_group)
if __name__ == "__main__":
KMLivingHinge().run()