From 0d44db13988e091b51930d5ffb41ccc101b987eb Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 13 Dec 2025 15:20:05 +0100 Subject: [PATCH] Arbitrary shape WIP --- src/kicoil/cli.py | 2 + src/kicoil/geometry.py | 8 +- src/kicoil/skeletonator.py | 171 +++++++++++++++++++++++++++++++++++-- 3 files changed, 170 insertions(+), 11 deletions(-) diff --git a/src/kicoil/cli.py b/src/kicoil/cli.py index 8153559..437802e 100644 --- a/src/kicoil/cli.py +++ b/src/kicoil/cli.py @@ -69,6 +69,8 @@ def print_valid_twists(ctx, param, value): @click.pass_context def cli(ctx, footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, **kwargs): ctx.ensure_object(dict) + logger = logging.getLogger('kicoil') + logger.setLevel(logging.INFO) def write(shape, outfile): nonlocal footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format diff --git a/src/kicoil/geometry.py b/src/kicoil/geometry.py index 8ef15d4..12321ee 100644 --- a/src/kicoil/geometry.py +++ b/src/kicoil/geometry.py @@ -287,11 +287,11 @@ class SVGShape(OffsetShape): @property def slug(self): - return f'svg_{self.outer_diameter:.2f}x{self.inner_diameter:.2f}' + return f'svg_{len(self.polygon)}n' @property def desc(self): - return f'{self.outer_diameter:.2f} x {self.inner_diameter:.2f} mm imported SVG shape' + return f'{len(self.polygon)} node imported SVG shape' @dataclass class PlanarInductor(): @@ -358,13 +358,13 @@ class PlanarInductor(): if self.trace_width is None: if round(self.clearance, 3) > round(self.projected_spiral_pitch, 3): - raise ValueError(f'Error: Given clearance of {clearance:.2f} mm is larger than the projected spiral pitch of {projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.') + warnings.warn(f'Error: Given clearance of {clearance:.2f} mm is larger than the projected spiral pitch of {projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.') self.trace_width = self.projected_spiral_pitch - self.clearance self.logger.info(f'Calculated trace width for {self.clearance:.2f} mm clearance is {self.trace_width:.2f} mm.') elif self.clearance is None: if round(self.trace_width, 2) > round(self.projected_spiral_pitch, 2): - raise ValueError(f'Error: Given trace width of {self.trace_width:.2f} mm is larger than the projected spiral pitch of {self.projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.') + warnings.warn(f'Error: Given trace width of {self.trace_width:.2f} mm is larger than the projected spiral pitch of {self.projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.') self.clearance = self.projected_spiral_pitch - self.trace_width self.logger.info(f'Calculated clearance for {self.trace_width:.2f} mm trace width is {self.clearance:.2f} mm.') diff --git a/src/kicoil/skeletonator.py b/src/kicoil/skeletonator.py index 3fbfb15..90c14e5 100644 --- a/src/kicoil/skeletonator.py +++ b/src/kicoil/skeletonator.py @@ -1,7 +1,10 @@ import math import itertools - -from py_straight_skeleton import compute_skeleton +import subprocess +import os +from pathlib import Path +import matplotlib.pyplot as plt +from matplotlib.backends.backend_pdf import PdfPages def interpolate(p1, p2, t, t_start=0, t_end=1): @@ -33,12 +36,113 @@ def edge_cycle(points): return itertools.pairwise(itertools.chain(points, points[:1])) +class SkeletonNode: + """Wrapper class for skeleton nodes to match py_straight_skeleton interface""" + def __init__(self, x, y, time): + self.position = type('Position', (), {'x': x, 'y': y})() + self.time = time + self.x = x + self.y = y + + def __hash__(self): + return hash((self.x, self.y, self.time)) + + def __eq__(self, other): + return (self.x, self.y, self.time) == (other.x, other.y, other.time) + + def __repr__(self): + return f'SkeletonNode({self.x}, {self.y}, t={self.time})' + + +class SkeletonWrapper: + """Wrapper class for skeleton to match py_straight_skeleton interface""" + def __init__(self, nodes, edges): + self.nodes = nodes + self.edges = edges + + def arc_iterator(self): + """Iterate through skeleton edges as node pairs""" + return iter(self.edges) + + +def compute_skeleton_cli(exterior, holes=None): + """ + Compute straight skeleton using the CLI program instead of py_straight_skeleton. + + Args: + exterior: List of (x, y) tuples representing the polygon vertices + holes: Not supported in this implementation + + Returns: + SkeletonWrapper object compatible with py_straight_skeleton interface + """ + if holes: + raise NotImplementedError("Holes are not supported in CLI implementation") + + # Find the skeleton_cli binary + # Look in project root directory + cli_path = Path(__file__).parent.parent.parent / 'skeleton_cli' + if not cli_path.exists(): + raise FileNotFoundError(f"skeleton_cli binary not found at {cli_path}") + + # Prepare input: one point per line + points_deduplicated = [] + for p1, p2 in edge_cycle(exterior): + if p2 != p1: + points_deduplicated.append(p1) + input_data = '\n'.join(f'{x} {y}' for x, y in points_deduplicated) + Path('/tmp/debug.txt').write_text(input_data) + + # Run the CLI program + try: + result = subprocess.run( + [str(cli_path)], + input=input_data, + capture_output=True, + text=True, + check=True + ) + except subprocess.CalledProcessError as e: + raise ValueError(f'Error computing polygon straight skeleton. CGAL says: {e.stdout.rstrip()}\n{e.stderr.rstrip()}') + + # Parse output: each line is "x1 y1 x2 y2 t1 t2" + node_dict = {} # Map (x, y, t) to SkeletonNode + edges = [] + + for line in result.stdout.strip().split('\n'): + if not line: + continue + + parts = line.split() + if len(parts) != 6: + continue + + x1, y1, x2, y2, t1, t2 = map(float, parts) + + # Create or get nodes + key1 = (x1, y1, t1) + key2 = (x2, y2, t2) + + if key1 not in node_dict: + node_dict[key1] = SkeletonNode(x1, y1, t1) + if key2 not in node_dict: + node_dict[key2] = SkeletonNode(x2, y2, t2) + + node1 = node_dict[key1] + node2 = node_dict[key2] + + edges.append((node1, node2)) + + nodes = list(node_dict.values()) + return SkeletonWrapper(nodes, edges) + + class Skeletonator: def __init__(self, poly): self.poly = poly self.poly_edges = list(zip(poly, poly[1:] + poly[:1])) self.circumference = sum(math.dist(a, b) for a, b in self.poly_edges) - self.skeleton = compute_skeleton(exterior=poly, holes=[]) + self.skeleton = compute_skeleton_cli(exterior=poly, holes=[]) self.arc_map = {} self.divergent = set() self.radius = max(n.time for n in self.skeleton.nodes) @@ -48,12 +152,24 @@ class Skeletonator: self.divergent.add(n1) self.min_radius = min(n1.time, self.radius) self.arc_map[n1] = n2 - self.node_map = dict(zip(poly, self.skeleton.nodes)) + coord_map = {} + for n in self.skeleton.nodes: + p = (round(n.position.x, 6), round(n.position.y, 6)) + coord_map[p] = n + self.node_map = {} + for x, y in poly: + p = (round(x, 6), round(y, 6)) + self.node_map[(x, y)] = coord_map[p] + self.dump_to_pdf('/tmp/test.pdf') def iter_arcs(self, p): + i = 0 start = self.node_map[p] + #print('start', start, start in self.arc_map, start in self.divergent) while start in self.arc_map and not start in self.divergent: end = self.arc_map[start] + #print('end', i, end) + i += 1 yield start, end start = end @@ -142,8 +258,49 @@ class Skeletonator: _arc, p2_proj = self.project_arc(p2, rp2) if approx_in_range(t1, tp1, tp2): - yield interpolate(p1_proj, p2_proj, t1, tp1, tp2), r_ref + _arc, p2_proj_r1 = self.project_arc(p2, r1) + yield interpolate(p1_proj, p2_proj_r1, t1, tp1, tp2), r_ref if approx_in_range(t2, tp1, tp2): - yield interpolate(p1_proj, p2_proj, t2, tp1, tp2), r_ref + _arc, p1_proj_r2 = self.project_arc(p1, r2) + yield interpolate(p1_proj_r2, p2_proj, t2, tp1, tp2), r_ref elif approx_in_range(tp2, t1, t2): - yield p2_proj, r_ref \ No newline at end of file + yield p2_proj, r_ref + + def dump_to_pdf(self, filename): + """ + Dump the polygon and its computed skeleton to a PDF file using matplotlib. + """ + with PdfPages(filename) as pdf: + fig, ax = plt.subplots(figsize=(10, 10)) + + # Plot the polygon + poly_x = [p[0] for p in self.poly] + [self.poly[0][0]] + poly_y = [p[1] for p in self.poly] + [self.poly[0][1]] + ax.plot(poly_x, poly_y, 'b-', linewidth=2, label='Polygon') + ax.plot(poly_x, poly_y, 'bo', markersize=4) + + # Plot the skeleton edges + for node1, node2 in self.skeleton.arc_iterator(): + ax.plot([node1.x, node2.x], [node1.y, node2.y], 'r-', linewidth=1, alpha=0.7) + + # Plot skeleton nodes + for node in self.skeleton.nodes: + ax.plot(node.x, node.y, 'ro', markersize=3, alpha=0.5) + + # Plot divergent nodes with a different marker + for node in self.divergent: + ax.plot(node.x, node.y, 'go', markersize=6, label='Divergent' if node == list(self.divergent)[0] else '') + + for node in self.skeleton.nodes: + if node not in self.arc_map and node not in self.divergent: + ax.plot(node.x, node.y, 'o', color='magenta', markersize=6, label='Divergent' if self.divergent and node == list(self.divergent)[0] else '') + + ax.set_aspect('equal', adjustable='box') + ax.grid(True, alpha=0.3) + ax.legend() + ax.set_title(f'Polygon Skeleton (radius: {self.radius:.3f}, min_radius: {self.min_radius:.3f})') + ax.set_xlabel('X') + ax.set_ylabel('Y') + + pdf.savefig(fig, bbox_inches='tight') + plt.close(fig) \ No newline at end of file