#!/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()