437 lines
13 KiB
Python
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()
|