209 lines
8.2 KiB
Python
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()
|