Files
knoxmakers-inkscape/extensions/km-hatch/km_hatch.py
2026-01-18 02:57:46 +00:00

437 lines
13 KiB
Python

#!/usr/bin/env python3
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deps'))
import math
from lxml import etree
import inkex
from inkex import Transform, BoundingBox, Path, CubicSuperPath, PathElement, Group
from inkex.elements import ShapeElement, Rectangle, Circle, Ellipse, Polygon, Polyline, Line
TOLERANCE = 1e-10
MIN_HATCH_LENGTH_AS_FRACTION = 0.25
RECURSION_LIMIT = 500
BEZIER_OVERSHOOT = 0.75
def distance_squared(p1, p2):
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
return dx * dx + dy * dy
def distance(p1, p2):
return math.sqrt(distance_squared(p1, p2))
def intersect_lines(p1, p2, p3, p4):
d21x = p2[0] - p1[0]
d21y = p2[1] - p1[1]
d43x = p4[0] - p3[0]
d43y = p4[1] - p3[1]
denom = d21x * d43y - d21y * d43x
if abs(denom) < TOLERANCE:
return -1.0
num_a = (p1[1] - p3[1]) * d43x - (p1[0] - p3[0]) * d43y
num_b = (p1[1] - p3[1]) * d21x - (p1[0] - p3[0]) * d21y
sa = num_a / denom
sb = num_b / denom
if 0.0 <= sa <= 1.0 and 0.0 <= sb <= 1.0:
return sa
return -1.0
def subdivide_cubic_path(sp, flatness):
while True:
changed = False
i = 1
while i < len(sp):
p0 = sp[i - 1][1]
p1 = sp[i - 1][2]
p2 = sp[i][0]
p3 = sp[i][1]
b = (p0[0] + p3[0]) / 2.0, (p0[1] + p3[1]) / 2.0
c1 = (p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0
c2 = (p2[0] + p3[0]) / 2.0, (p2[1] + p3[1]) / 2.0
dx1 = c1[0] - b[0]
dy1 = c1[1] - b[1]
dx2 = c2[0] - b[0]
dy2 = c2[1] - b[1]
err = max(abs(dx1), abs(dy1), abs(dx2), abs(dy2))
if err > flatness:
m01 = ((p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0)
m12 = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0)
m23 = ((p2[0] + p3[0]) / 2.0, (p2[1] + p3[1]) / 2.0)
m012 = ((m01[0] + m12[0]) / 2.0, (m01[1] + m12[1]) / 2.0)
m123 = ((m12[0] + m23[0]) / 2.0, (m12[1] + m23[1]) / 2.0)
mid = ((m012[0] + m123[0]) / 2.0, (m012[1] + m123[1]) / 2.0)
sp[i - 1][2] = m01
new_node = [m012, mid, m123]
sp[i][0] = m23
sp.insert(i, new_node)
changed = True
i += 1
if not changed:
break
class HatchFill(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument('--tab', type=str, default='hatch')
pars.add_argument('--hatchSpacing', type=float, default=1.0,
help='Spacing between hatch lines')
pars.add_argument('--hatchAngle', type=float, default=0.0,
help='Angle of hatch lines in degrees')
pars.add_argument('--crossHatch', type=inkex.Boolean, default=False,
help='Generate cross-hatch pattern')
pars.add_argument('--units', type=int, default=2,
help='Units: 1=px, 2=mm, 3=in')
pars.add_argument('--insetDistance', type=float, default=0,
help='Inset distance from edges')
pars.add_argument('--tolerance', type=float, default=1.0,
help='Curve tolerance')
pars.add_argument('--connectEnds', type=inkex.Boolean, default=False,
help='Connect nearby hatch ends')
pars.add_argument('--connectTolerance', type=float, default=1.0,
help='Tolerance for connecting ends')
def effect(self):
unit_factors = {1: 1.0, 2: 96.0 / 25.4, 3: 96.0}
unit_factor = unit_factors.get(self.options.units, 1.0)
self.hatch_spacing = self.options.hatchSpacing * unit_factor
self.hatch_angle = math.radians(self.options.hatchAngle)
self.cross_hatch = self.options.crossHatch
self.inset_distance = self.options.insetDistance * unit_factor
self.tolerance = self.options.tolerance
self.connect_ends = self.options.connectEnds
self.connect_tolerance = self.options.connectTolerance * unit_factor
if self.hatch_spacing < 0.1:
self.hatch_spacing = 0.1
shape_types = (PathElement, Rectangle, Circle, Ellipse, Polygon, Polyline)
if self.svg.selection:
elements = list(self.svg.selection.filter(*shape_types))
else:
elements = list(self.svg.descendants().filter(*shape_types))
if not elements:
inkex.errormsg("No shapes selected. Please select one or more closed shapes.")
return
any_hatches = False
for elem in elements:
self.polygons = []
self.transforms = []
self.process_element(elem)
if not self.polygons:
continue
hatches = self.generate_hatches()
if self.cross_hatch:
original_angle = self.hatch_angle
self.hatch_angle += math.pi / 2
hatches.extend(self.generate_hatches())
self.hatch_angle = original_angle
if not hatches:
continue
if self.connect_ends and self.connect_tolerance > 0:
hatches = self.connect_hatches(hatches)
self.add_hatches_for_element(elem, hatches)
any_hatches = True
if not any_hatches:
inkex.errormsg("No hatch lines were generated. Check that paths are closed.")
def process_element(self, elem):
try:
if hasattr(elem, 'get_path'):
path = elem.get_path()
elif hasattr(elem, 'path'):
path = elem.path
else:
return
if not path:
return
transform = elem.composed_transform()
csp = path.to_superpath()
for subpath in csp:
if len(subpath) < 2:
continue
subdivide_cubic_path(subpath, self.tolerance)
vertices = [(pt[1][0], pt[1][1]) for pt in subpath]
is_closed = False
if len(vertices) >= 3:
d = distance(vertices[0], vertices[-1])
if d < 1.0:
is_closed = True
elif isinstance(elem, (Rectangle, Circle, Ellipse, Polygon)):
is_closed = True
if d >= 1.0:
vertices.append(vertices[0])
if is_closed:
transformed = []
for v in vertices:
pt = transform.apply_to_point(v)
transformed.append((pt.x, pt.y))
self.polygons.append(transformed)
self.transforms.append(transform)
except Exception as e:
pass
def get_bounding_box(self):
if not self.polygons:
return None
min_x = min_y = float('inf')
max_x = max_y = float('-inf')
for poly in self.polygons:
for x, y in poly:
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x)
max_y = max(max_y, y)
return (min_x, min_y, max_x, max_y)
def generate_hatches(self):
bbox = self.get_bounding_box()
if not bbox:
return []
min_x, min_y, max_x, max_y = bbox
cx = (min_x + max_x) / 2
cy = (min_y + max_y) / 2
diagonal = math.sqrt((max_x - min_x) ** 2 + (max_y - min_y) ** 2) / 2
perp_angle = self.hatch_angle + math.pi / 2
cos_a = math.cos(perp_angle)
sin_a = math.sin(perp_angle)
line_cos = math.cos(self.hatch_angle)
line_sin = math.sin(self.hatch_angle)
hatch_lines = []
offset = -diagonal
while offset <= diagonal:
x1 = cx + offset * cos_a - diagonal * line_cos
y1 = cy + offset * sin_a - diagonal * line_sin
x2 = cx + offset * cos_a + diagonal * line_cos
y2 = cy + offset * sin_a + diagonal * line_sin
hatch_lines.append(((x1, y1), (x2, y2)))
offset += self.hatch_spacing
hatches = []
for h_line in hatch_lines:
segments = self.get_hatch_segments(h_line)
hatches.extend(segments)
return hatches
def get_hatch_segments(self, h_line):
p1, p2 = h_line
intersections = []
for poly in self.polygons:
n = len(poly)
for i in range(n):
p3 = poly[i]
p4 = poly[(i + 1) % n]
t = intersect_lines(p1, p2, p3, p4)
if t >= 0:
ix = p1[0] + t * (p2[0] - p1[0])
iy = p1[1] + t * (p2[1] - p1[1])
intersections.append((t, ix, iy))
intersections.sort(key=lambda x: x[0])
filtered = []
prev_t = -1
for item in intersections:
if item[0] - prev_t > TOLERANCE:
filtered.append(item)
prev_t = item[0]
segments = []
i = 0
while i < len(filtered) - 1:
start = filtered[i]
end = filtered[i + 1]
seg_len = distance((start[1], start[2]), (end[1], end[2]))
min_len = self.hatch_spacing * MIN_HATCH_LENGTH_AS_FRACTION
if seg_len >= min_len:
if self.inset_distance > 0 and seg_len > 2 * self.inset_distance:
dx = end[1] - start[1]
dy = end[2] - start[2]
factor = self.inset_distance / seg_len
sx = start[1] + dx * factor
sy = start[2] + dy * factor
ex = end[1] - dx * factor
ey = end[2] - dy * factor
segments.append(((sx, sy), (ex, ey)))
else:
segments.append(((start[1], start[2]), (end[1], end[2])))
i += 2
return segments
def connect_hatches(self, hatches):
if not hatches:
return hatches
result = []
used = [False] * len(hatches)
tol_sq = self.connect_tolerance * self.connect_tolerance
i = 0
while i < len(hatches):
if used[i]:
i += 1
continue
used[i] = True
current_path = [hatches[i][0], hatches[i][1]]
changed = True
iterations = 0
while changed and iterations < RECURSION_LIMIT:
changed = False
iterations += 1
end = current_path[-1]
best_j = -1
best_dist = tol_sq
best_reverse = False
for j, h in enumerate(hatches):
if used[j]:
continue
d1 = distance_squared(end, h[0])
d2 = distance_squared(end, h[1])
if d1 < best_dist:
best_dist = d1
best_j = j
best_reverse = False
if d2 < best_dist:
best_dist = d2
best_j = j
best_reverse = True
if best_j >= 0:
used[best_j] = True
if best_reverse:
current_path.append(hatches[best_j][1])
current_path.append(hatches[best_j][0])
else:
current_path.append(hatches[best_j][0])
current_path.append(hatches[best_j][1])
changed = True
for k in range(0, len(current_path) - 1, 2):
result.append((current_path[k], current_path[k + 1]))
i += 1
return result
def add_hatches_for_element(self, source_elem, hatches):
if not hatches:
return
path_data = []
for start, end in hatches:
path_data.append(f'M {start[0]:.4f},{start[1]:.4f}')
path_data.append(f'L {end[0]:.4f},{end[1]:.4f}')
path_str = ' '.join(path_data)
path_elem = PathElement()
path_elem.path = Path(path_str)
source_style = source_elem.style
hatch_style = {
'fill': 'none',
'stroke-linecap': 'round',
'stroke-linejoin': 'round'
}
if 'stroke' in source_style:
hatch_style['stroke'] = source_style['stroke']
else:
if 'fill' in source_style and source_style['fill'] != 'none':
hatch_style['stroke'] = source_style['fill']
else:
hatch_style['stroke'] = '#000000'
if 'stroke-width' in source_style:
hatch_style['stroke-width'] = source_style['stroke-width']
else:
hatch_style['stroke-width'] = '1'
if 'stroke-opacity' in source_style:
hatch_style['stroke-opacity'] = source_style['stroke-opacity']
if 'opacity' in source_style:
hatch_style['opacity'] = source_style['opacity']
path_elem.style = hatch_style
parent = source_elem.getparent()
if parent is not None:
index = list(parent).index(source_elem)
parent.insert(index + 1, path_elem)
else:
self.svg.get_current_layer().append(path_elem)
if __name__ == '__main__':
HatchFill().run()