From 6157ce4983582c704bcf8fed0d28860552cfaa34 Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 15 Dec 2025 15:31:47 +0100 Subject: [PATCH] The CGAL WASM build crashes for some simple n-gons --- src/kicoil/cli.py | 24 ++++++++++++++- src/kicoil/geometry.py | 61 ++++++++++++++++++++++++++++++++++++-- src/kicoil/skeletonator.py | 32 +++++++++++++++----- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/kicoil/cli.py b/src/kicoil/cli.py index 437802e..394b995 100644 --- a/src/kicoil/cli.py +++ b/src/kicoil/cli.py @@ -26,7 +26,7 @@ import math import click from gerbonara.layers import LayerStack -from .geometry import PlanarInductor, divisors, CircleShape, SectorShape, StarShape, SVGShape +from .geometry import PlanarInductor, divisors, CircleShape, SectorShape, StarShape, SVGShape, RectangleShape, RegularPolygonShape from .kicad import footprint_to_board from .svg import make_transparent_svg @@ -170,6 +170,28 @@ def trapezoid(ctx, outfile, **kwargs): ctx.obj['write'](shape, outfile) +@cli.command() +@click.option('--width', type=float, default=50, help='Width [mm]') +@click.option('--height', type=float, default=40, help='Height [mm]') +@click.option('--annular-width', type=float, default=10, help='Width of the trace area on the outside of the shape [mm]') +@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path)) +@click.pass_context +def rectangle(ctx, outfile, **kwargs): + shape = RectangleShape(**kwargs) + ctx.obj['write'](shape, outfile) + + +@cli.command() +@click.option('--diameter', type=float, default=50, help='Width [mm]') +@click.option('-n', '--corners', type=int, default=8, help='Number of corners') +@click.option('--annular-width', type=float, default=10, help='Width of the trace area on the outside of the shape [mm]') +@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path)) +@click.pass_context +def regular_polygon(ctx, outfile, **kwargs): + shape = RegularPolygonShape(**kwargs) + ctx.obj['write'](shape, outfile) + + @cli.command() @click.option('--inner-diameter', type=float, default=25, help='Inner diameter [mm]') @click.option('--outer-diameter', type=float, default=50, help='Outer diameter [mm]') diff --git a/src/kicoil/geometry.py b/src/kicoil/geometry.py index 13f80a7..23c27de 100644 --- a/src/kicoil/geometry.py +++ b/src/kicoil/geometry.py @@ -195,11 +195,12 @@ class TrapezoidShape(OffsetShape): height: float offset: float annular_width: float + arc_tolerance: float = 0.05 # mm polygon: list = field(init=False) def __post_init__(self): w, h, d = self.width, self.height, self.offset - self.polygon = [(w/2-d, 0), (w/2, h), (-w/2, h), (-w/2+d, 0)] + self.polygon = [(w/2-d, -h/2), (w/2, h/2), (-w/2, h/2), (-w/2+d, -h/2)] super().__post_init__() @property @@ -211,6 +212,27 @@ class TrapezoidShape(OffsetShape): return f'{self.width:.2f} x {self.height:.2f} mm, {self.offset:.2f} mm offset isosceles trapezoidal' +@dataclass +class RectangleShape(OffsetShape): + width: float + height: float + annular_width: float + arc_tolerance: float = 0.05 # mm + polygon: list = field(init=False) + + def __post_init__(self): + w, h = self.width, self.height + self.polygon = [(w/2, -h/2), (w/2, h/2), (-w/2, h/2), (-w/2, -h/2)] + super().__post_init__() + + @property + def slug(self): + return f'rectangle_{self.width:.2f}x{self.height:.2f}' + + @property + def desc(self): + return f'{self.width:.2f} x {self.height:.2f} mm rectangle' + @dataclass class SectorShape(OffsetShape): inner_diameter: float @@ -265,6 +287,32 @@ class StarShape(OffsetShape): purpose = ', for demonic purposes' if self.points == 5 else '' return f'{self.outer_diameter:.2f} x {self.inner_diameter:.2f} mm star shape{purpose}' + +@dataclass +class RegularPolygonShape(OffsetShape): + diameter: float + annular_width: float + corners: int = 8 + arc_tolerance: float = 0.05 # mm + polygon: list = field(init=False) + + def __post_init__(self): + # center on y axis + pt = lambda r, a: (-r*sin(a), r*cos(a)) + circle = lambda r, n, phase: [pt(r, (i + phase)*2*pi/n) for i in range(n)] + self.polygon = list(circle(self.diameter/2, self.corners, 0)) + print(self.polygon) + super().__post_init__() + + @property + def slug(self): + return f'regular_{self.corners}gon_{self.diameter:.2f}' + + @property + def desc(self): + return f'{self.diameter:.2f} mm diameter {self.corners} corner regular polygon' + + @dataclass class SVGShape(OffsetShape): filename: str @@ -282,6 +330,16 @@ class SVGShape(OffsetShape): d = d.strip('MmZ ').replace(',', 'L') coord_pairs = d.split('L') coords = list(reversed([tuple(map(float, pair.split())) for pair in coord_pairs])) + # Calculate bounding box + min_x = min(x for x, _y in coords) + min_y = max(x for x, _y in coords) + max_x = min(y for _x, y in coords) + max_y = max(y for _x, y in coords) + if max_x < 0 or max_y < 0 or min_x > 0 or min_y > 0: + # (0, 0) is not within the polygon's axis-aligned bounding box, recenter. + ox, oy = skeletonator.polygon_center_of_mass(coords) + warnings.warn(f'Polygon looks not centered, bounds are ({min_x:.2f}, {min_y:.2f}), ({max_x:.2f}, {max_y:.2f}). Aligning (0, 0) with polygon centroid at ({ox:.2f}, {oy:.2f})') + coords = [(x-ox, y-oy) for x, y in coords] self.polygon = coords super().__post_init__() @@ -331,7 +389,6 @@ class PlanarInductor(): alpha2 = atan((self.shape.outer_radius - self.shape.inner_radius) / self.sweeping_angle / c2) alpha = (alpha1+alpha2)/2 self.projected_spiral_pitch = self.spiral_pitch*cos(alpha) - print(self.shape.inner_radius, self.shape.outer_radius, self.spiral_pitch, self.turns_per_layer) if self.layers == 1 and self.twists > 1: warnings.warn('Warning: Twists set to a value other than 1, but single-layer mode is enabled. The twists value will be ignored.') diff --git a/src/kicoil/skeletonator.py b/src/kicoil/skeletonator.py index 14a896f..cb56db1 100644 --- a/src/kicoil/skeletonator.py +++ b/src/kicoil/skeletonator.py @@ -45,6 +45,29 @@ def edge_cycle(points): return itertools.pairwise(itertools.chain(points, points[:1])) +def polygon_is_clockwise(points): + (x1, y1, i), *_rest = sorted((x, y, i) for i, (x, y) in enumerate(points)) + x0, y0 = points[(i-1)%len(points)] + x2, y2 = points[(i+1)%len(points)] + det = (x0*y1 + x1*y2 + x2*y0) - (x2*y1 + x1*y0 + x0*y2) + return det < 0 + + +def polygon_center_of_mass(polygon): + # https://en.wikipedia.org/wiki/Centroid + total_x, total_y = 0, 0 + area = 0 + for (x1, y1), (x2, y2) in edge_cycle(polygon): + diff = (x1*y2 - x2*y1) + total_x += (x1 + x2) * diff + total_y += (y1 + y2) * diff + area += diff + area /= 2 + total_x /= 6*area + total_y /= 6*area + return total_x, total_y + + class WasmApp: def __init__(self, wasm_filename, cachedir="kicoil"): module_binary = importlib.resources.read_binary(__package__, wasm_filename) @@ -103,14 +126,6 @@ class SkeletonNode: return self.x, self.y -def polygon_is_clockwise(points): - x1, y1, i = sorted((x, y, i) for x, y in points) - x0, y0 = points[(i-1)%len(points)] - x2, y2 = points[(i+1)%len(points)] - det = (x1*y2 + x2*y3 + x3*y1) - (x3*y2 + x2*y1 + x1*y3) - return det < 0 - - skeleton_wasm = WasmApp('skeleton.wasm') def compute_skeleton(exterior): @@ -119,6 +134,7 @@ def compute_skeleton(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) rc, data = skeleton_wasm.run(input_data)