bundle: update (2026-01-18)
This commit is contained in:
436
extensions/km-hatch/km_hatch.py
Normal file
436
extensions/km-hatch/km_hatch.py
Normal file
@@ -0,0 +1,436 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user