Compare commits

..

6 commits

Author SHA1 Message Date
jaseg
82eccbad1d Alternative approach works but looks bad 2025-12-17 10:22:19 +01:00
jaseg
87033c116f Improve spiral layout for round-ish shapes 2025-12-16 15:28:50 +01:00
jaseg
556707dc35 WIP 2025-12-16 14:26:50 +01:00
jaseg
3e6c7d6f57 WIP 2025-12-16 13:51:19 +01:00
jaseg
5bdf4d3274 package.py: Adjust kicad packaging message 2025-12-15 23:09:07 +01:00
jaseg
7bfaabc839 Bump version to v0.9.0 2025-12-15 23:08:19 +01:00
11 changed files with 181 additions and 578 deletions

View file

@ -52,8 +52,7 @@ int main()
}
if (!poly.is_counterclockwise_oriented()) {
std::cerr << "Error: Polygon must be counter-clockwise" << std::endl;
return EXIT_FAILURE;
poly.reverse_orientation();
}
SsPtr ss = CGAL::create_interior_straight_skeleton_2(poly.vertices_begin(), poly.vertices_end(), K());

Binary file not shown.

Binary file not shown.

View file

@ -1,8 +1,8 @@
{
"$schema": "https://go.kicad.org/pcm/schemas/v1",
"name": "KiCoil",
"description": "Planar inductor supporting arbitrary shapes of spiral coils, toroidal coils, and hybrids",
"description_full": "KiCoil generates planar inductors as footprints. It supports arbitrary shapes with presets such as circles, rectangles, circular sectors, and SVG import for arbitrary shapes. It can generate spiral and toroid inductors as well as arbitrary intermediates between spiral and toroid inductors. By playing around with this, you can create inductors that have lower parasitics and higher self-resonant frequency than standard multilayer spiral inductors.",
"description": "Planar inductor supporting spiral coils, toroidal coils, and hybrids",
"description_full": "KiCoil generates planar inductors as footprints. Currently, circular spiral and toroid inductors are supported. KiCoil supports arbitrary intermediates between spiral and toroid inductors. By playing around with this, you can create inductors that have lower parasitics and higher self-resonant frequency than standard multilayer spiral inductors.",
"identifier": "de.jaseg.kicoil",
"type": "plugin",
"author": {
@ -26,25 +26,7 @@
"download_size": 38942,
"download_url": "https://git.jaseg.de/kimesh.git/plain/de.jaseg.kicoil-v0.9.0.zip?h=v0.9.0",
"install_size": 119659
},
{
"version": "0.11.0",
"status": "stable",
"kicad_version": "9.00",
"download_sha256": "455879f51a288ffc399f62556b1d83dc4988349f1759ddb283d711f72406310a",
"download_size": 1886869,
"download_url": "https://git.jaseg.de/kimesh.git/plain/de.jaseg.kicoil-v0.11.0.zip?h=v0.11.0",
"install_size": 14353567
},
{
"version": "0.11.1",
"status": "stable",
"kicad_version": "9.00",
"download_sha256": "8494927da4d4aca48baf13d6c8a162dc910c0be1294e5a44517b7d27a086a140",
"download_size": 1886906,
"download_url": "https://git.jaseg.de/kimesh.git/plain/de.jaseg.kicoil-v0.11.1.zip?h=v0.11.1",
"install_size": 14353726
}
],
"runtime": "ipc"
}
}

View file

@ -79,18 +79,16 @@ def do_release(dry_run):
for path in files:
path = root / path
out_path = plugin_dir / path.relative_to(module_sources)
content = path.read_text()
if path.name == '__init__.py':
content = path.read_text()
lines = content.splitlines()
lines_out = []
for line in lines:
if line.startswith('__version__ = version('):
line = f'__version__ = {version!r}'
lines_out.append(line)
content = '\n'.join(lines_out).encode('utf-8')
else:
content = path.read_bytes()
out_path.write_bytes(content)
content = '\n'.join(lines_out)
out_path.write_text(content)
zip_fn = Path(shutil.make_archive(f'{pkg_dir.name}-v{version}', 'zip', pkg_dir, '.'))
if not dry_run:

View file

@ -1,6 +1,6 @@
[project]
name = "kicoil"
version = "0.11.1"
version = "0.10.0"
description = "Planar Inductor Generator"
readme = "README.rst"
license = "Apache-2.0"
@ -48,7 +48,6 @@ kicoil-gui = "kicoil.gui:main"
[dependency-groups]
dev = [
"ipykernel>=7.1.0",
"matplotlib>=3.10.8",
]
gui = ["cairosvg", "pillow"]
gds = ["gdstk"]

View file

@ -90,16 +90,15 @@ def circle_center_to_tangents(center, a, b):
@click.option('--footprint-name', help="Name for the generated footprint. Default: Output file name sans extension.")
@click.option('--cell-name', help="Name for the generated cell when exporting GDSII. Default: Output file name sans extension.")
@click.option('--layer-pair', default='F.Cu,B.Cu', help="Target KiCad layer pair for the generated footprint, comma-separated. Default: F.Cu/B.Cu.")
@click.option('--geometry-debug-file', type=click.Path(writable=True), help='Render geometry debug information to a PDF file with the given name')
@click.version_option()
@click.pass_context
def cli(ctx, footprint_name, cell_name, clipboard, single_layer, arc_tolerance, circle_segments, format, geometry_debug_file, **kwargs):
def cli(ctx, footprint_name, cell_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, cell_name, geometry_debug_file
nonlocal footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, cell_name
logger = logging.getLogger('kicoil')
if single_layer:
@ -113,7 +112,7 @@ def cli(ctx, footprint_name, cell_name, clipboard, single_layer, arc_tolerance,
if footprint_name is None and outfile:
footprint_name = outfile.stem
footprint = model.render_footprint(footprint_name, arc_tolerance, circle_segments, geometry_debug_file)
footprint = model.render_footprint(footprint_name, arc_tolerance, circle_segments)
except ValueError as e:
#raise click.ClickException(*e.args)

View file

@ -94,15 +94,6 @@ def arc_approximate(points, trace_width, layer, tolerance=0.02, level=0):
yield kicad.make_arc(x2, y2, x0, y0, x1, y1, trace_width, layer)
def polygon_is_clockwise(coords):
# https://en.wikipedia.org/wiki/Curve_orientation
xb, yb, i = min([(x, y, i) for i, (x, y) in enumerate(coords)])
xa, ya = coords[(i-1) % len(coords)]
xc, yc = coords[(i+1) % len(coords)]
det = (xa*yb + xb*yc + xc*ya) - (xa*yc + xb * ya + xc * yb)
return det < 0
class Shape:
pass
@ -128,7 +119,7 @@ class CircleShape(Shape):
return f'{self.outer_diameter:.2f} mm OD, {self.inner_diameter:.2f} mm ID circular'
def compute_spiral(self, a1, a2, fn=64, debug=False):
def compute_spiral(self, a1, a2, fn=64):
r1, r2 = self.outer_radius, self.inner_radius
fn = ceil(fn * abs(a2-a1)/(2*pi))
x0, y0 = cos(a1)*r1, sin(a1)*r1
@ -151,6 +142,8 @@ class CircleShape(Shape):
def project_point(self, r, a, r_ref=None):
return cos(a) * r, sin(a) * r
def map_angle(self, a):
return a
def offset_exterior(self, margin):
r = self.outer_radius + margin
@ -162,8 +155,6 @@ class CircleShape(Shape):
class OffsetShape(Shape):
def __post_init__(self):
self.sk = skeletonator.Skeletonator(self.polygon)
if self.annular_width > self.sk.min_radius:
raise ValueError(f'Annular width ({self.annular_width:.2f}) is too large. Must be less than {self.sk.min_radius:.2f}')
self.outer_radius = self.sk.radius
self.inner_radius = self.sk.radius - self.annular_width
@ -177,11 +168,11 @@ class OffsetShape(Shape):
return f'polygonal (n={len(self.polygon)} point, r={self.radius:.2f} mm radius)'
def compute_spiral(self, a1, a2, fn=None, debug=False):
def compute_spiral(self, a1, a2, fn=None):
# Skeletonator uses a t coordinate from 0 - 1 per revolution instead of a radian angle.
points = []
angle_refs = []
for point, angle_ref in self.sk.do_spiral(a1/(2*pi), a2/(2*pi), self.outer_radius, self.inner_radius, debug=debug):
for point, angle_ref in self.sk.do_spiral(a1/(2*pi), a2/(2*pi), self.outer_radius, self.inner_radius):
points.append(point)
angle_refs.append(angle_ref)
if a2 < a1:
@ -191,6 +182,12 @@ class OffsetShape(Shape):
return points, arm_length, angle_refs
def map_angle(self, a):
a_new = self.sk.map_angle(a / (2*pi), self.outer_radius, self.inner_radius)
print(f'NEW MAPPED {a:.3f} to {a_new:.3f}')
return a_new * 2 * pi
def project_point(self, r, a, r_ref=None):
# Skeletonator uses a t coordinate from 0 - 1 per revolution instead of a radian angle.
return self.sk.project_point(a/(2*pi) % 1, r, r_ref=r_ref)
@ -340,22 +337,17 @@ class SVGShape(OffsetShape):
d = path.attrs['d']
d = d.strip('MmZ ').replace(',', 'L')
coord_pairs = d.split('L')
coords = [tuple(map(float, pair.split())) for pair in coord_pairs]
if polygon_is_clockwise(coords):
coords = coords[::-1]
# 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]
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__()
@ -443,10 +435,10 @@ class PlanarInductor():
else:
if round(self.trace_width, 2) > round(self.projected_spiral_pitch, 2):
raise ValueError(f'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.')
raise click.ClickException(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.')
clearance_actual = self.projected_spiral_pitch - self.trace_width
if round(clearance_actual, 3) < round(self.clearance, 3):
raise ValueError(f'Actual clearance for {self.trace_width:.2f} mm trace is {clearance_actual:.2f} mm, which is lower than the given clearance of {self.clearance:.2f} mm.')
raise click.ClickException(f'Error: Actual clearance for {self.trace_width:.2f} mm trace is {clearance_actual:.2f} mm, which is lower than the given clearance of {self.clearance:.2f} mm.')
if round(self.via_diameter, 2) < round(self.trace_width, 2):
self.logger.warning(f'Clipping via diameter from {self.via_diameter:.2f} mm to trace width of {self.trace_width:.2f} mm.')
@ -507,7 +499,7 @@ class PlanarInductor():
def default_footprint_name(self):
return f'planar-coil-{self.shape.slug}-n{self.turns}-k{self.twists}'
def render_footprint(self, name=None, arc_tolerance=0.02, circle_segments=64, geometry_debug_file=None):
def render_footprint(self, name=None, arc_tolerance=0.02, circle_segments=64):
if name is None:
name = self.default_footprint_name
@ -534,8 +526,9 @@ class PlanarInductor():
fold_angle = start_angle + self.sweeping_angle
end_angle = fold_angle + self.sweeping_angle
print(f'### TWIST {i} INWARD ###')
# Handle the spiral arm
points_layer0, arm_length, angle_refs_layer0 = self.shape.compute_spiral(a1=start_angle, a2=fold_angle, fn=circle_segments, debug=True)
points_layer0, arm_length, angle_refs_layer0 = self.shape.compute_spiral(a1=start_angle, a2=fold_angle, fn=circle_segments)
x0, y0 = points_layer0[0]
xn, yn = points_layer0[-1]
if angle_refs_layer0:
@ -549,8 +542,9 @@ class PlanarInductor():
footprint.lines.extend(kicad.make_line(*p1, *p2, self.trace_width, self.layer_pair[0]) for p1, p2 in zip(points_layer0, points_layer0[1:]))
if self.layers > 1:
print(f'### TWIST {i} OUTWARD ###')
# Handle the returning arm on the bottom layer
points_layer1, _, angle_refs_layer1 = self.shape.compute_spiral(a1=end_angle, a2=fold_angle, fn=circle_segments, debug=True)
points_layer1, _, angle_refs_layer1 = self.shape.compute_spiral(a1=end_angle, a2=fold_angle, fn=circle_segments)
points_layer1 = points_layer1[::-1]
if self.approximate_arcs and isinstance(self.shape, CircleShape):
footprint.arcs.extend(arc_approximate(points_layer1, self.trace_width, self.layer_pair[1], arc_tolerance))
@ -584,8 +578,8 @@ class PlanarInductor():
xv, yv = self.shape.project_point(r, fold_angle, r_ref=refs_layer0[-1])
footprint.lines.append(kicad.make_line(*points_layer0[-1], xv, yv, self.trace_width, self.layer_pair[0]))
footprint.lines.append(kicad.make_line(xv, yv, *points_layer1[0], self.trace_width, self.layer_pair[1]))
#footprint.lines.append(kicad.make_line(*points_layer0[-1], xv, yv, self.trace_width, self.layer_pair[0]))
#footprint.lines.append(kicad.make_line(xv, yv, *points_layer1[0], self.trace_width, self.layer_pair[1]))
footprint.pads.append(kicad.make_via(xv, yv,
self.via_diameter, self.via_drill, self.clearance,
@ -602,7 +596,7 @@ class PlanarInductor():
points_layer0, refs_layer0 = arms_layers[0][i]
points_layer1, refs_layer1 = arms_layers[1][(i - self.turns) % self.twists]
xv, yv = self.shape.project_point(r, start_angle, r_ref=refs_layer0[0])
xv, yv = self.shape.project_point(r, self.shape.map_angle(start_angle), r_ref=refs_layer0[0])
footprint.lines.append(kicad.make_line(*points_layer0[0], xv, yv, self.trace_width, self.layer_pair[0]))
footprint.lines.append(kicad.make_line(*points_layer1[-1], xv, yv, self.trace_width, self.layer_pair[1]))
@ -627,7 +621,5 @@ class PlanarInductor():
keepout=ZoneKeepout(copperpour_allowed=False),
polygon=ZonePolygon(pts=[XYCoord(x=x, y=y) for x, y in pts])))
if geometry_debug_file:
self.shape.sk.dump_to_pdf(geometry_debug_file)
return footprint

View file

@ -27,7 +27,7 @@ from pathlib import Path
from contextlib import contextmanager
from io import BytesIO
from .geometry import PlanarInductor, divisors, CircleShape, SectorShape, StarShape, SVGShape, RectangleShape, RegularPolygonShape, TrapezoidShape
from .geometry import PlanarInductor, divisors, CircleShape
from .svg import make_transparent_svg
try:
@ -128,9 +128,6 @@ class KiCoilGUI:
self.root.title("KiCoil - Planar Inductor Generator")
self.root.geometry("1000x650")
# Register validation command for non-negative numbers early
self.validate_nonneg_cmd = self.root.register(self.validate_nonnegative)
style = ttk.Style()
style.theme_use('clam')
@ -162,44 +159,6 @@ class KiCoilGUI:
self.notebook.add(geometry_frame, text="Geometry")
self.create_geometry_params(geometry_frame)
# Shape parameter tabs (will be shown/hidden based on selection)
self.shape_param_frames = {}
circle_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Circle"] = circle_frame
self.notebook.add(circle_frame, text="Circle Parameters")
self.create_circle_params(circle_frame)
rectangle_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Rectangle"] = rectangle_frame
self.notebook.add(rectangle_frame, text="Rectangle Parameters")
self.create_rectangle_params(rectangle_frame)
trapezoid_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Trapezoid"] = trapezoid_frame
self.notebook.add(trapezoid_frame, text="Trapezoid Parameters")
self.create_trapezoid_params(trapezoid_frame)
sector_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Sector"] = sector_frame
self.notebook.add(sector_frame, text="Sector Parameters")
self.create_sector_params(sector_frame)
star_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Star"] = star_frame
self.notebook.add(star_frame, text="Star Parameters")
self.create_star_params(star_frame)
polygon_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Regular Polygon"] = polygon_frame
self.notebook.add(polygon_frame, text="Polygon Parameters")
self.create_polygon_params(polygon_frame)
svg_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["SVG"] = svg_frame
self.notebook.add(svg_frame, text="SVG Parameters")
self.create_svg_params(svg_frame)
traces_frame = ttk.Frame(self.notebook, padding="10")
self.notebook.add(traces_frame, text="Traces")
self.create_trace_params(traces_frame)
@ -255,26 +214,13 @@ class KiCoilGUI:
self.preview_label.pack(fill=tk.BOTH, expand=True)
self.preview_frame.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.E, tk.W), padx=(10, 10), pady=10)
self.current_model = None
self._validation_after_id = None
self.setup_logging()
self.setup_traces()
self.update_shape_tab_visibility() # Initialize tab visibility
self.root.after(100, self.validate_parameters)
def validate_nonnegative(self, value_if_allowed):
"""Validation callback for spinboxes to prevent negative values"""
if value_if_allowed == "" or value_if_allowed == "-":
# Allow empty string (user is typing) but not standalone minus
return value_if_allowed == ""
try:
float_val = float(value_if_allowed)
return float_val >= 0
except ValueError:
return False
def _on_preview_resize(self, event):
# Debounce resize events - only update after resize is complete
if hasattr(self, '_resize_after_id') and self._resize_after_id is not None:
@ -334,23 +280,11 @@ class KiCoilGUI:
def create_geometry_params(self, parent):
row = 0
# Shape Type
ttk.Label(parent, text="Shape Type:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.shape_type_var = tk.StringVar(value="Circle")
shape_combo = ttk.Combobox(parent, textvariable=self.shape_type_var,
values=["Circle", "Rectangle", "Trapezoid", "Sector", "Star", "Regular Polygon", "SVG"],
state='readonly', width=23)
shape_combo.grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Coil outline shape",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Turns
ttk.Label(parent, text="Number of Turns:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.turns_var = tk.IntVar(value=7)
ttk.Spinbox(parent, from_=1, to=100, textvariable=self.turns_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Number of spiral turns",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
@ -359,12 +293,29 @@ class KiCoilGUI:
ttk.Label(parent, text="Twists per Revolution:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.twists_var = tk.IntVar(value=4)
ttk.Spinbox(parent, from_=0, to=50, textvariable=self.twists_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Must be co-prime to turns",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Outer Diameter
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.outer_dia_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Outside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Inner Diameter
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.inner_dia_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Inside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Layer Mode
ttk.Label(parent, text="Layer Mode:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.layer_mode_var = tk.IntVar(value=2)
@ -387,221 +338,6 @@ class KiCoilGUI:
value="clockwise").pack(side=tk.LEFT)
row += 1
def create_circle_params(self, parent):
row = 0
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.circle_outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.circle_outer_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Outside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.circle_inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.circle_inner_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Inside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_rectangle_params(self, parent):
row = 0
ttk.Label(parent, text="Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.rect_width_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.rect_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Height (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.rect_height_var = tk.DoubleVar(value=40.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.rect_height_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.rect_annular_width_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.rect_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_trapezoid_params(self, parent):
row = 0
ttk.Label(parent, text="Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_width_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.trap_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Height (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_height_var = tk.DoubleVar(value=40.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.trap_height_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Offset (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_offset_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=0, to=100, increment=0.5,
textvariable=self.trap_offset_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Corner offset at shorter edge",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_annular_width_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.trap_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_sector_params(self, parent):
row = 0
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.sector_outer_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.sector_inner_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Angle (degrees):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_angle_var = tk.DoubleVar(value=45.0)
ttk.Spinbox(parent, from_=1, to=360, increment=1.0,
textvariable=self.sector_angle_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Sector angle",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_annular_width_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.sector_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_star_params(self, parent):
row = 0
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.star_outer_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.star_inner_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Points:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_points_var = tk.IntVar(value=5)
ttk.Spinbox(parent, from_=3, to=20, textvariable=self.star_points_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Number of star points",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_annular_width_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.star_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_polygon_params(self, parent):
row = 0
ttk.Label(parent, text="Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.poly_diameter_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.poly_diameter_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Corners:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.poly_corners_var = tk.IntVar(value=8)
ttk.Spinbox(parent, from_=3, to=20, textvariable=self.poly_corners_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Number of polygon corners",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.poly_annular_width_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.poly_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_svg_params(self, parent):
row = 0
ttk.Label(parent, text="SVG File:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.svg_filename_var = tk.StringVar(value="")
svg_entry = ttk.Entry(parent, textvariable=self.svg_filename_var, width=30)
svg_entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5)
ttk.Button(parent, text="Browse...", command=self.browse_svg_file, width=10).grid(row=row, column=2, sticky=tk.W, padx=(5, 0), pady=5)
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.svg_annular_width_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.svg_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def browse_svg_file(self):
filename = filedialog.askopenfilename(
title="Select SVG File",
filetypes=[("SVG files", "*.svg"), ("All files", "*.*")]
)
if filename:
self.svg_filename_var.set(filename)
def update_shape_tab_visibility(self, *args):
"""Show only the shape parameter tab for the currently selected shape"""
selected_shape = self.shape_type_var.get()
# Hide all shape parameter tabs
for shape_name, frame in self.shape_param_frames.items():
tab_id = self.notebook.index(frame)
self.notebook.tab(tab_id, state='hidden')
# Show only the selected shape's tab
if selected_shape in self.shape_param_frames:
frame = self.shape_param_frames[selected_shape]
tab_id = self.notebook.index(frame)
self.notebook.tab(tab_id, state='normal')
def create_trace_params(self, parent):
row = 0
@ -621,8 +357,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Copper Thickness (µm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.copper_thickness_var = tk.DoubleVar(value=35.0) # 35µm = 0.035mm = 1 Oz
ttk.Spinbox(parent, from_=1, to=1000, increment=1, format="%.1f",
textvariable=self.copper_thickness_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.copper_thickness_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="35µm = 1 Oz copper",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
@ -635,8 +370,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Via Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.via_diameter_var = tk.DoubleVar(value=0.6)
ttk.Spinbox(parent, from_=0.1, to=5.0, increment=0.1, format="%.2f",
textvariable=self.via_diameter_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.via_diameter_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
# Via Drill
@ -695,8 +429,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Circle Segments:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.circle_segments_var = tk.IntVar(value=64)
ttk.Spinbox(parent, from_=8, to=360, textvariable=self.circle_segments_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Points per 360° for arc interpolation",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
@ -705,8 +438,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Arc Tolerance (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.arc_tolerance_var = tk.DoubleVar(value=0.02)
ttk.Spinbox(parent, from_=0.001, to=1.0, increment=0.001, format="%.3f",
textvariable=self.arc_tolerance_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.arc_tolerance_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
# Keepout Zone
@ -720,65 +452,19 @@ class KiCoilGUI:
ttk.Label(parent, text="Keepout Margin (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.keepout_margin_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=0, to=50, increment=0.5,
textvariable=self.keepout_margin_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.keepout_margin_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Margin around coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def get_parameters(self):
# Create the appropriate shape based on selection
shape_type = self.shape_type_var.get()
if shape_type == "Circle":
shape = CircleShape(
outer_diameter=self.circle_outer_dia_var.get(),
inner_diameter=self.circle_inner_dia_var.get()
)
elif shape_type == "Rectangle":
shape = RectangleShape(
width=self.rect_width_var.get(),
height=self.rect_height_var.get(),
annular_width=self.rect_annular_width_var.get()
)
elif shape_type == "Trapezoid":
shape = TrapezoidShape(
width=self.trap_width_var.get(),
height=self.trap_height_var.get(),
offset=self.trap_offset_var.get(),
annular_width=self.trap_annular_width_var.get()
)
elif shape_type == "Sector":
import math
shape = SectorShape(
inner_diameter=self.sector_inner_dia_var.get(),
outer_diameter=self.sector_outer_dia_var.get(),
angle=math.radians(self.sector_angle_var.get()),
annular_width=self.sector_annular_width_var.get()
)
elif shape_type == "Star":
shape = StarShape(
inner_diameter=self.star_inner_dia_var.get(),
outer_diameter=self.star_outer_dia_var.get(),
points=self.star_points_var.get(),
annular_width=self.star_annular_width_var.get()
)
elif shape_type == "Regular Polygon":
shape = RegularPolygonShape(
diameter=self.poly_diameter_var.get(),
corners=self.poly_corners_var.get(),
annular_width=self.poly_annular_width_var.get()
)
elif shape_type == "SVG":
shape = SVGShape(
filename=self.svg_filename_var.get(),
annular_width=self.svg_annular_width_var.get()
)
else:
raise ValueError(f"Unknown shape type: {shape_type}")
shape_params = {
'outer_diameter' : self.outer_dia_var.get(),
'inner_diameter' : self.inner_dia_var.get(),
}
params = {
'shape' : shape,
'shape' : CircleShape(**shape_params),
'turns' : self.turns_var.get(),
'layers' : self.layer_mode_var.get(),
'twists' : self.twists_var.get(),
@ -831,12 +517,10 @@ class KiCoilGUI:
warnings.showwarning = old_showwarning
def setup_traces(self):
# Shape type needs special handling for tab visibility
self.shape_type_var.trace_add('write', self.update_shape_tab_visibility)
self.shape_type_var.trace_add('write', self._on_parameter_change)
for var in [
self.turns_var,
self.outer_dia_var,
self.inner_dia_var,
self.layer_mode_var,
self.direction_var,
self.twists_var,
@ -847,44 +531,13 @@ class KiCoilGUI:
self.stagger_inner_var,
self.stagger_outer_var,
self.top_layer_var,
self.bottom_layer_var,
# Circle shape params
self.circle_outer_dia_var,
self.circle_inner_dia_var,
# Rectangle shape params
self.rect_width_var,
self.rect_height_var,
self.rect_annular_width_var,
# Trapezoid shape params
self.trap_width_var,
self.trap_height_var,
self.trap_offset_var,
self.trap_annular_width_var,
# Sector shape params
self.sector_outer_dia_var,
self.sector_inner_dia_var,
self.sector_angle_var,
self.sector_annular_width_var,
# Star shape params
self.star_outer_dia_var,
self.star_inner_dia_var,
self.star_points_var,
self.star_annular_width_var,
# Polygon shape params
self.poly_diameter_var,
self.poly_corners_var,
self.poly_annular_width_var,
# SVG shape params
self.svg_annular_width_var]:
self.bottom_layer_var]:
var.trace_add('write', self._on_parameter_change)
for entry in [self.trace_width_entry, self.clearance_entry,
self.via_drill_entry, self.via_offset_entry,
self.footprint_name_entry]:
entry.bind('<KeyRelease>', lambda e: self._on_parameter_change())
# SVG filename entry needs special handling
self.svg_filename_var.trace_add('write', self._on_parameter_change)
def _on_parameter_change(self, *args):
# Schedule validation to avoid too many rapid calls
@ -908,21 +561,15 @@ class KiCoilGUI:
return True
except ValueError as e:
self.output_text['state'] = 'normal'
self.output_text.insert(tk.END, f"ERROR: {e}\n", 'error')
self.output_text.see(tk.END)
tb = traceback.format_exc()
print(tb, file=sys.stderr)
self.current_model = None
self.update_placeholders()
self.update_preview()
return False
except Exception as e:
tb = traceback.format_exc()
self.output_text['state'] = 'normal'
self.output_text.insert(tk.END, f"Unexpected error:\n{tb}\n", 'error')
self.output_text.see(tk.END)
@ -930,8 +577,7 @@ class KiCoilGUI:
self.current_model = None
self.update_placeholders()
self.update_preview()
return False
return True
finally:
self.output_text['state'] = 'disabled'
@ -964,13 +610,6 @@ class KiCoilGUI:
if not HAS_PREVIEW:
return
if self.current_model is None:
# Clear preview when model is invalid
self.preview_canvas.delete("all")
self.preview_raw_image = None
self.preview_image = None
return
arc_tolerance = self.arc_tolerance_var.get()
circle_segments = self.circle_segments_var.get()

View file

@ -11,6 +11,8 @@ import sys
import hashlib
import platformdirs
import matplotlib.pyplot as plt
from matplotlib.backends.backend_pdf import PdfPages
import wasmtime
@ -29,6 +31,14 @@ def interpolate(p1, p2, t, t_start=0, t_end=1):
return (x1 + t*dx, y1 + t*dy)
def interpolate_1d(a, b, t, t_start=0, t_end=1):
if math.isclose(t_start, t_end):
return a
t_range = t_end - t_start
t = (t - t_start) / t_range
return a + (b-a) * t
def approx_in_range(value, lower, upper):
""" Approximate range check """
if math.isclose(value, lower) or math.isclose(value, upper):
@ -109,7 +119,7 @@ class WasmApp:
self.app.exports(store)["_start"](store)
except wasmtime.ExitTrap as trap:
if trap.code != 0:
raise RuntimeError('Error computing straight skeleton.')
raise
return 0, stdout_f.read()
@ -187,13 +197,15 @@ class Skeletonator:
for x, y in poly:
p = (round(x, 6), round(y, 6))
self.node_map[(x, y)] = coord_map[p]
self.debug_arms = []
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
@ -241,121 +253,108 @@ class Skeletonator:
arcs.append(arc)
points.append(pt)
return arcs, points
def do_spiral(self, t1, t2, r1=None, r2=None, debug=False):
def map_angle(self, t, r1, r2):
r_ref = min(r1, r2)
_ic_arcs, inner_circumference = self.map_circumference(r_ref)
inner_circumference_sum = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(inner_circumference))
angle = 0
point_angles = [0]
for p1, p2 in edge_cycle(inner_circumference):
edge_angle = math.dist(p1, p2) / inner_circumference_sum
angle += edge_angle
point_angles.append(angle)
_oc_arcs, outer_circumference = self.map_circumference(max(r1, r2))
outer_circumference_sum = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(outer_circumference))
angle = 0
point_angles_outer = [0]
for p1, p2 in edge_cycle(outer_circumference):
edge_angle = math.dist(p1, p2) / outer_circumference_sum
angle += edge_angle
point_angles_outer.append(angle)
t_map_int = math.floor(t)
t %= 1.0
for ia1, ia2, oa1, oa2 in zip(point_angles, point_angles[1:] + [1], point_angles_outer, point_angles_outer[1:] + [1]):
if approx_in_range(t, oa1, oa2):
if oa1 == oa2:
return t_map_int + ia2
else:
return t_map_int + ia1 + (ia2 - ia1) * ((t - oa1) / (oa2 - oa1))
def do_spiral(self, t1, t2, r1=None, r2=None):
print(f' {t1=:.5f} {t2=:.5f} {r1=:.2f} {r2=:.2f}')
if r1 is None:
r1 = self.radius
if r2 is None:
r2 = self.min_radius
direction = True
if t2 < t1:
direction = False
t1, t2 = t2, t1
r1, r2 = r2, r1
def r_interpolate(t):
t = max(t1, min(t2, t)) # Clip to start/end of spiral
f = (t - t1) / (t2 - t1)
return r1 + (r2 - r1) * f
debug_arm = []
for t_start in range(math.ceil(t2-t1)):
t_start += t1
t_end = t_start + 1
r_outer = r_interpolate(t_start)
r_inner = r_interpolate(t_end)
r_ref = min(r_inner, r_outer) # Handle outward spirals where the radii are swapped
_ic_arcs, inner_circumference = self.map_circumference(r_ref)
angle_map = []
circumferences = []
n = 100
radius_steps = [r1 + (r2 - r1) * i/(n-1) for i in range(n)]
angle_steps = [t1 + (t2 - t1) * i/(n-1) for i in range(n)]
for r in radius_steps:
_ic_arcs, circumference = self.map_circumference(r)
circumference_sum = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(circumference))
circumferences.append(circumference_sum)
angle = math.floor(t_start)
circumference_angles = []
inner_circumference_sum = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(inner_circumference))
point_angles = []
for p1, p2 in edge_cycle(inner_circumference):
edge_angle = math.dist(p1, p2) / inner_circumference_sum
point_angles.append(angle)
angle = 0
point_angles = [0]
for p1, p2 in edge_cycle(circumference):
edge_angle = math.dist(p1, p2) / circumference_sum
angle += edge_angle
point_angles += [a+1 for a in point_angles]
point_angles += [point_angles[0] + 2]
point_angles.append(angle)
angle_map.append(point_angles)
i = 0
for (p1, p2), (tp1, tp2) in zip(self.poly_edges * 3, itertools.pairwise(point_angles)):
i += 1
rp1 = r_interpolate(tp1)
rp2 = r_interpolate(tp2)
_arc, p1_proj = self.project_arc(p1, rp1)
_arc, p2_proj = self.project_arc(p2, rp2)
if tp2 < t_start and not math.isclose(tp2, t_start):
continue
if tp1 > t_end and not math.isclose(tp1, t_end):
continue
for r, t, point_angles in zip(radius_steps, angle_steps, angle_map):
for (p1, p2), (tp1, tp2) in zip(self.poly_edges, itertools.pairwise(point_angles + point_angles[:1])):
_arc, p1_proj = self.project_arc(p1, r)
_arc, p2_proj = self.project_arc(p2, r)
if approx_in_range(t1, tp1, tp2):
_arc, p2_proj_r1 = self.project_arc(p2, r1)
p_out = interpolate(p1_proj, p2_proj_r1, t1, tp1, tp2)
debug_arm.append(p_out)
yield p_out, r_ref
if approx_in_range(t2, tp1, tp2):
_arc, p1_proj_r2 = self.project_arc(p1, r2)
p_out = interpolate(p1_proj_r2, p2_proj, t2, tp1, tp2)
debug_arm.append(p_out)
yield p_out, r_ref
elif approx_in_range(tp2, t1, t2):
debug_arm.append(p2_proj)
yield p2_proj, r_ref
if debug:
self.debug_arms.append((debug_arm, direction, t1, t2))
if approx_in_range(t%1, tp1, tp2):
yield interpolate(p1_proj, p2_proj, t%1, tp1, tp2), r
def dump_to_pdf(self, filename):
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 10))
with PdfPages(filename) as pdf:
fig, ax = plt.subplots(figsize=(10, 10))
# polygon outline
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, '-', color='black', linewidth=.5, label='Polygon')
# polygon outliner
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)
# skeleton edges
for node1, node2 in self.skeleton_edges:
ax.plot([node1.x, node2.x], [node1.y, node2.y], '-', color='gray', linewidth=.5, alpha=0.7)
# skeleton edges
for node1, node2 in self.skeleton_edges:
ax.plot([node1.x, node2.x], [node1.y, node2.y], 'r-', linewidth=1, alpha=0.7)
# skeleton nodes
for n in self.skeleton_nodes:
if n in self.divergent:
ax.plot(n.x, n.y, 'o', markerfacecolor='none', markeredgecolor='green', markersize=4)
elif n in self.arc_map:
ax.plot(n.x, n.y, 'o', color='black', markersize=3, alpha=0.5)
else:
ax.plot(n.x, n.y, 'o', markerfacecolor='none', markeredgecolor='magenta', markersize=4)
# skeleton nodes
for n in self.skeleton_nodes:
if n in self.divergent:
ax.plot(n.x, n.y, 'go', markersize=6)
elif n in self.arc_map:
ax.plot(n.x, n.y, 'ro', markersize=3, alpha=0.5)
else:
ax.plot(n.x, n.y, 'o', color='magenta', markersize=6)
count = {True: 0, False: 0}
for arm, direction, t1, t2 in self.debug_arms:
xs = [x for x, y in arm]
ys = [y for x, y in arm]
ax.plot(xs, ys, linewidth=.2, color='red' if direction else 'blue')
align = 'left' if direction else 'right'
ax.text(xs[-1], ys[-1], f'{count[direction]}', size=3, horizontalalignment=align)
ax.text(xs[0], ys[0], f'{count[direction]}', size=3, horizontalalignment=align, color='gray')
count[direction] += 1
xs, ys = [], []
for i in range(100):
r = self.radius - (i/99) * self.min_radius
arc, (px, py) = self.project_arc(self.poly[0], r)
xs.append(px)
ys.append(py)
ax.plot(xs, ys, '--', linewidth=.5, color='black')
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')
ax.invert_yaxis()
fig.savefig(filename, bbox_inches='tight', dpi=600)
plt.close(fig)
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')
ax.invert_yaxis()
pdf.savefig(fig, bbox_inches='tight')
plt.close(fig)

8
uv.lock generated
View file

@ -569,7 +569,7 @@ wheels = [
[[package]]
name = "kicoil"
version = "0.11.1"
version = "0.10.0"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },
@ -585,7 +585,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "ipykernel" },
{ name = "matplotlib" },
]
gds = [
{ name = "gdstk" },
@ -608,10 +607,7 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [
{ name = "ipykernel", specifier = ">=7.1.0" },
{ name = "matplotlib", specifier = ">=3.10.8" },
]
dev = [{ name = "ipykernel", specifier = ">=7.1.0" }]
gds = [{ name = "gdstk" }]
gui = [
{ name = "cairosvg" },