Compare commits

...
Sign in to create a new pull request.

19 commits

Author SHA1 Message Date
jaseg
7fe1f457dd Update kicad addon metadata description 2025-12-18 16:59:06 +01:00
jaseg
800d705e02 KiCad package version 0.11.1 2025-12-18 16:52:38 +01:00
jaseg
eed50a097e Bump version to v0.11.1 2025-12-18 16:52:23 +01:00
jaseg
c712473064 Fix missing vertex bug when turns align with start 2025-12-18 16:51:41 +01:00
jaseg
67eb82fab5 KiCad package version 0.11.0 2025-12-18 00:04:43 +01:00
jaseg
c04ae73986 Fix packaging script to handle WASM module 2025-12-18 00:04:35 +01:00
jaseg
5893ef234e Bump version to v0.11.0 2025-12-18 00:02:44 +01:00
jaseg
7cac7b1041 Improve GUI parameter validation 2025-12-17 23:57:11 +01:00
jaseg
df2e0c7bcf Add new shapes to GUI 2025-12-17 23:41:11 +01:00
jaseg
6666e665e2 Improve CLI 2025-12-17 23:01:40 +01:00
jaseg
1140c5bca3 Add matplotlib to dev dependencies 2025-12-17 12:48:16 +01:00
jaseg
2b64ee5081 Do not require matplotlib unless debug output is requested 2025-12-17 12:47:39 +01:00
jaseg
e54517544a Remove debug prints, add debug output 2025-12-17 12:47:06 +01:00
jaseg
57e8a52e1f Fix skipping issue 2025-12-17 12:37:41 +01:00
jaseg
b25b32e98f Fix remaining reflection issue 2025-12-17 12:30:04 +01:00
jaseg
96d06a8dc3 Fix multiturn layout broken in last commit 2025-12-17 12:25:48 +01:00
jaseg
b0b942431e Fix rollover problem 2025-12-17 12:18:28 +01:00
jaseg
82d107fc82 package.py: Adjust kicad packaging message 2025-12-17 10:36:37 +01:00
jaseg
4295b2d9f6 Bump version to v0.10.0 2025-12-17 10:36:34 +01:00
11 changed files with 550 additions and 110 deletions

View file

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

BIN
de.jaseg.kicoil-v0.11.0.zip Normal file

Binary file not shown.

BIN
de.jaseg.kicoil-v0.11.1.zip Normal file

Binary file not shown.

View file

@ -1,8 +1,8 @@
{ {
"$schema": "https://go.kicad.org/pcm/schemas/v1", "$schema": "https://go.kicad.org/pcm/schemas/v1",
"name": "KiCoil", "name": "KiCoil",
"description": "Planar inductor supporting spiral coils, toroidal coils, and hybrids", "description": "Planar inductor supporting arbitrary shapes of 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.", "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.",
"identifier": "de.jaseg.kicoil", "identifier": "de.jaseg.kicoil",
"type": "plugin", "type": "plugin",
"author": { "author": {
@ -26,7 +26,25 @@
"download_size": 38942, "download_size": 38942,
"download_url": "https://git.jaseg.de/kimesh.git/plain/de.jaseg.kicoil-v0.9.0.zip?h=v0.9.0", "download_url": "https://git.jaseg.de/kimesh.git/plain/de.jaseg.kicoil-v0.9.0.zip?h=v0.9.0",
"install_size": 119659 "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" "runtime": "ipc"
} }

View file

@ -79,16 +79,18 @@ def do_release(dry_run):
for path in files: for path in files:
path = root / path path = root / path
out_path = plugin_dir / path.relative_to(module_sources) out_path = plugin_dir / path.relative_to(module_sources)
content = path.read_text()
if path.name == '__init__.py': if path.name == '__init__.py':
content = path.read_text()
lines = content.splitlines() lines = content.splitlines()
lines_out = [] lines_out = []
for line in lines: for line in lines:
if line.startswith('__version__ = version('): if line.startswith('__version__ = version('):
line = f'__version__ = {version!r}' line = f'__version__ = {version!r}'
lines_out.append(line) lines_out.append(line)
content = '\n'.join(lines_out) content = '\n'.join(lines_out).encode('utf-8')
out_path.write_text(content) else:
content = path.read_bytes()
out_path.write_bytes(content)
zip_fn = Path(shutil.make_archive(f'{pkg_dir.name}-v{version}', 'zip', pkg_dir, '.')) zip_fn = Path(shutil.make_archive(f'{pkg_dir.name}-v{version}', 'zip', pkg_dir, '.'))
if not dry_run: if not dry_run:
@ -111,7 +113,7 @@ def do_release(dry_run):
if not dry_run: if not dry_run:
print('Create git commit') print('Create git commit')
subprocess.run(['git', 'commit', '-m', f'Version {version}', '--no-edit'], check=True, capture_output=True) subprocess.run(['git', 'commit', '-m', f'KiCad package version {version}', '--no-edit'], check=True, capture_output=True)
res = subprocess.run('git rev-parse --short HEAD'.split(), check=True, capture_output=True, text=True) res = subprocess.run('git rev-parse --short HEAD'.split(), check=True, capture_output=True, text=True)
print(f'Created commit {res.stdout.strip()}') print(f'Created commit {res.stdout.strip()}')
print(f'Creating and signing version tag v{version}') print(f'Creating and signing version tag v{version}')

View file

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

View file

@ -90,15 +90,16 @@ 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('--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('--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('--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.version_option()
@click.pass_context @click.pass_context
def cli(ctx, footprint_name, cell_name, clipboard, single_layer, arc_tolerance, circle_segments, format, **kwargs): def cli(ctx, footprint_name, cell_name, clipboard, single_layer, arc_tolerance, circle_segments, format, geometry_debug_file, **kwargs):
ctx.ensure_object(dict) ctx.ensure_object(dict)
logger = logging.getLogger('kicoil') logger = logging.getLogger('kicoil')
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
def write(shape, outfile): def write(shape, outfile):
nonlocal footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, cell_name nonlocal footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, cell_name, geometry_debug_file
logger = logging.getLogger('kicoil') logger = logging.getLogger('kicoil')
if single_layer: if single_layer:
@ -112,7 +113,7 @@ def cli(ctx, footprint_name, cell_name, clipboard, single_layer, arc_tolerance,
if footprint_name is None and outfile: if footprint_name is None and outfile:
footprint_name = outfile.stem footprint_name = outfile.stem
footprint = model.render_footprint(footprint_name, arc_tolerance, circle_segments) footprint = model.render_footprint(footprint_name, arc_tolerance, circle_segments, geometry_debug_file)
except ValueError as e: except ValueError as e:
#raise click.ClickException(*e.args) #raise click.ClickException(*e.args)

View file

@ -94,6 +94,15 @@ 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) 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: class Shape:
pass pass
@ -119,7 +128,7 @@ class CircleShape(Shape):
return f'{self.outer_diameter:.2f} mm OD, {self.inner_diameter:.2f} mm ID circular' return f'{self.outer_diameter:.2f} mm OD, {self.inner_diameter:.2f} mm ID circular'
def compute_spiral(self, a1, a2, fn=64): def compute_spiral(self, a1, a2, fn=64, debug=False):
r1, r2 = self.outer_radius, self.inner_radius r1, r2 = self.outer_radius, self.inner_radius
fn = ceil(fn * abs(a2-a1)/(2*pi)) fn = ceil(fn * abs(a2-a1)/(2*pi))
x0, y0 = cos(a1)*r1, sin(a1)*r1 x0, y0 = cos(a1)*r1, sin(a1)*r1
@ -153,6 +162,8 @@ class CircleShape(Shape):
class OffsetShape(Shape): class OffsetShape(Shape):
def __post_init__(self): def __post_init__(self):
self.sk = skeletonator.Skeletonator(self.polygon) 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.outer_radius = self.sk.radius
self.inner_radius = self.sk.radius - self.annular_width self.inner_radius = self.sk.radius - self.annular_width
@ -166,11 +177,11 @@ class OffsetShape(Shape):
return f'polygonal (n={len(self.polygon)} point, r={self.radius:.2f} mm radius)' return f'polygonal (n={len(self.polygon)} point, r={self.radius:.2f} mm radius)'
def compute_spiral(self, a1, a2, fn=None): def compute_spiral(self, a1, a2, fn=None, debug=False):
# Skeletonator uses a t coordinate from 0 - 1 per revolution instead of a radian angle. # Skeletonator uses a t coordinate from 0 - 1 per revolution instead of a radian angle.
points = [] points = []
angle_refs = [] angle_refs = []
for point, angle_ref in self.sk.do_spiral(a1/(2*pi), a2/(2*pi), self.outer_radius, self.inner_radius): for point, angle_ref in self.sk.do_spiral(a1/(2*pi), a2/(2*pi), self.outer_radius, self.inner_radius, debug=debug):
points.append(point) points.append(point)
angle_refs.append(angle_ref) angle_refs.append(angle_ref)
if a2 < a1: if a2 < a1:
@ -329,17 +340,22 @@ class SVGShape(OffsetShape):
d = path.attrs['d'] d = path.attrs['d']
d = d.strip('MmZ ').replace(',', 'L') d = d.strip('MmZ ').replace(',', 'L')
coord_pairs = d.split('L') coord_pairs = d.split('L')
coords = list(reversed([tuple(map(float, pair.split())) for pair in coord_pairs])) coords = [tuple(map(float, pair.split())) for pair in coord_pairs]
# Calculate bounding box
min_x = min(x for x, _y in coords) if polygon_is_clockwise(coords):
min_y = max(x for x, _y in coords) coords = coords[::-1]
max_x = min(y for _x, y in coords)
max_y = max(y for _x, y in coords) # Calculate bounding box
if max_x < 0 or max_y < 0 or min_x > 0 or min_y > 0: min_x = min(x for x, _y in coords)
# (0, 0) is not within the polygon's axis-aligned bounding box, recenter. min_y = max(x for x, _y in coords)
ox, oy = skeletonator.polygon_center_of_mass(coords) max_x = min(y for _x, y in 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})') max_y = max(y for _x, y in coords)
coords = [(x-ox, y-oy) 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 self.polygon = coords
super().__post_init__() super().__post_init__()
@ -427,10 +443,10 @@ class PlanarInductor():
else: else:
if round(self.trace_width, 2) > round(self.projected_spiral_pitch, 2): if round(self.trace_width, 2) > round(self.projected_spiral_pitch, 2):
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.') 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.')
clearance_actual = self.projected_spiral_pitch - self.trace_width clearance_actual = self.projected_spiral_pitch - self.trace_width
if round(clearance_actual, 3) < round(self.clearance, 3): if round(clearance_actual, 3) < round(self.clearance, 3):
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.') 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.')
if round(self.via_diameter, 2) < round(self.trace_width, 2): 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.') self.logger.warning(f'Clipping via diameter from {self.via_diameter:.2f} mm to trace width of {self.trace_width:.2f} mm.')
@ -491,7 +507,7 @@ class PlanarInductor():
def default_footprint_name(self): def default_footprint_name(self):
return f'planar-coil-{self.shape.slug}-n{self.turns}-k{self.twists}' 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): def render_footprint(self, name=None, arc_tolerance=0.02, circle_segments=64, geometry_debug_file=None):
if name is None: if name is None:
name = self.default_footprint_name name = self.default_footprint_name
@ -519,7 +535,7 @@ class PlanarInductor():
end_angle = fold_angle + self.sweeping_angle end_angle = fold_angle + self.sweeping_angle
# Handle the spiral arm # Handle the spiral arm
points_layer0, arm_length, angle_refs_layer0 = self.shape.compute_spiral(a1=start_angle, a2=fold_angle, fn=circle_segments) points_layer0, arm_length, angle_refs_layer0 = self.shape.compute_spiral(a1=start_angle, a2=fold_angle, fn=circle_segments, debug=True)
x0, y0 = points_layer0[0] x0, y0 = points_layer0[0]
xn, yn = points_layer0[-1] xn, yn = points_layer0[-1]
if angle_refs_layer0: if angle_refs_layer0:
@ -534,7 +550,7 @@ class PlanarInductor():
if self.layers > 1: if self.layers > 1:
# Handle the returning arm on the bottom layer # 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) points_layer1, _, angle_refs_layer1 = self.shape.compute_spiral(a1=end_angle, a2=fold_angle, fn=circle_segments, debug=True)
points_layer1 = points_layer1[::-1] points_layer1 = points_layer1[::-1]
if self.approximate_arcs and isinstance(self.shape, CircleShape): 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)) footprint.arcs.extend(arc_approximate(points_layer1, self.trace_width, self.layer_pair[1], arc_tolerance))
@ -611,5 +627,7 @@ class PlanarInductor():
keepout=ZoneKeepout(copperpour_allowed=False), keepout=ZoneKeepout(copperpour_allowed=False),
polygon=ZonePolygon(pts=[XYCoord(x=x, y=y) for x, y in pts]))) 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 return footprint

View file

@ -27,7 +27,7 @@ from pathlib import Path
from contextlib import contextmanager from contextlib import contextmanager
from io import BytesIO from io import BytesIO
from .geometry import PlanarInductor, divisors, CircleShape from .geometry import PlanarInductor, divisors, CircleShape, SectorShape, StarShape, SVGShape, RectangleShape, RegularPolygonShape, TrapezoidShape
from .svg import make_transparent_svg from .svg import make_transparent_svg
try: try:
@ -128,6 +128,9 @@ class KiCoilGUI:
self.root.title("KiCoil - Planar Inductor Generator") self.root.title("KiCoil - Planar Inductor Generator")
self.root.geometry("1000x650") 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 = ttk.Style()
style.theme_use('clam') style.theme_use('clam')
@ -159,6 +162,44 @@ class KiCoilGUI:
self.notebook.add(geometry_frame, text="Geometry") self.notebook.add(geometry_frame, text="Geometry")
self.create_geometry_params(geometry_frame) 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") traces_frame = ttk.Frame(self.notebook, padding="10")
self.notebook.add(traces_frame, text="Traces") self.notebook.add(traces_frame, text="Traces")
self.create_trace_params(traces_frame) self.create_trace_params(traces_frame)
@ -214,13 +255,26 @@ class KiCoilGUI:
self.preview_label.pack(fill=tk.BOTH, expand=True) 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.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.current_model = None
self._validation_after_id = None self._validation_after_id = None
self.setup_logging() self.setup_logging()
self.setup_traces() self.setup_traces()
self.update_shape_tab_visibility() # Initialize tab visibility
self.root.after(100, self.validate_parameters) 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): def _on_preview_resize(self, event):
# Debounce resize events - only update after resize is complete # Debounce resize events - only update after resize is complete
if hasattr(self, '_resize_after_id') and self._resize_after_id is not None: if hasattr(self, '_resize_after_id') and self._resize_after_id is not None:
@ -280,11 +334,23 @@ class KiCoilGUI:
def create_geometry_params(self, parent): def create_geometry_params(self, parent):
row = 0 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 # Turns
ttk.Label(parent, text="Number of Turns:").grid(row=row, column=0, sticky=tk.W, pady=5) ttk.Label(parent, text="Number of Turns:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.turns_var = tk.IntVar(value=7) self.turns_var = tk.IntVar(value=7)
ttk.Spinbox(parent, from_=1, to=100, textvariable=self.turns_var, ttk.Spinbox(parent, from_=1, to=100, textvariable=self.turns_var,
width=15).grid(row=row, column=1, sticky=tk.W, pady=5) 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 spiral turns", ttk.Label(parent, text="Number of spiral turns",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1 row += 1
@ -293,29 +359,12 @@ class KiCoilGUI:
ttk.Label(parent, text="Twists per Revolution:").grid(row=row, column=0, sticky=tk.W, pady=5) ttk.Label(parent, text="Twists per Revolution:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.twists_var = tk.IntVar(value=4) self.twists_var = tk.IntVar(value=4)
ttk.Spinbox(parent, from_=0, to=50, textvariable=self.twists_var, ttk.Spinbox(parent, from_=0, to=50, textvariable=self.twists_var,
width=15).grid(row=row, column=1, sticky=tk.W, pady=5) width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Must be co-prime to turns", ttk.Label(parent, text="Must be co-prime to turns",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1 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 # Layer Mode
ttk.Label(parent, text="Layer Mode:").grid(row=row, column=0, sticky=tk.W, pady=5) ttk.Label(parent, text="Layer Mode:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.layer_mode_var = tk.IntVar(value=2) self.layer_mode_var = tk.IntVar(value=2)
@ -338,6 +387,221 @@ class KiCoilGUI:
value="clockwise").pack(side=tk.LEFT) value="clockwise").pack(side=tk.LEFT)
row += 1 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): def create_trace_params(self, parent):
row = 0 row = 0
@ -357,7 +621,8 @@ class KiCoilGUI:
ttk.Label(parent, text="Copper Thickness (µm):").grid(row=row, column=0, sticky=tk.W, pady=5) 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 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", ttk.Spinbox(parent, from_=1, to=1000, increment=1, format="%.1f",
textvariable=self.copper_thickness_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) 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)
ttk.Label(parent, text="35µm = 1 Oz copper", ttk.Label(parent, text="35µm = 1 Oz copper",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1 row += 1
@ -370,7 +635,8 @@ class KiCoilGUI:
ttk.Label(parent, text="Via Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) 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) self.via_diameter_var = tk.DoubleVar(value=0.6)
ttk.Spinbox(parent, from_=0.1, to=5.0, increment=0.1, format="%.2f", ttk.Spinbox(parent, from_=0.1, to=5.0, increment=0.1, format="%.2f",
textvariable=self.via_diameter_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) 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)
row += 1 row += 1
# Via Drill # Via Drill
@ -429,7 +695,8 @@ class KiCoilGUI:
ttk.Label(parent, text="Circle Segments:").grid(row=row, column=0, sticky=tk.W, pady=5) ttk.Label(parent, text="Circle Segments:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.circle_segments_var = tk.IntVar(value=64) self.circle_segments_var = tk.IntVar(value=64)
ttk.Spinbox(parent, from_=8, to=360, textvariable=self.circle_segments_var, ttk.Spinbox(parent, from_=8, to=360, textvariable=self.circle_segments_var,
width=15).grid(row=row, column=1, sticky=tk.W, pady=5) width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Points per 360° for arc interpolation", ttk.Label(parent, text="Points per 360° for arc interpolation",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1 row += 1
@ -438,7 +705,8 @@ class KiCoilGUI:
ttk.Label(parent, text="Arc Tolerance (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) 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) self.arc_tolerance_var = tk.DoubleVar(value=0.02)
ttk.Spinbox(parent, from_=0.001, to=1.0, increment=0.001, format="%.3f", ttk.Spinbox(parent, from_=0.001, to=1.0, increment=0.001, format="%.3f",
textvariable=self.arc_tolerance_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) 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)
row += 1 row += 1
# Keepout Zone # Keepout Zone
@ -452,19 +720,65 @@ class KiCoilGUI:
ttk.Label(parent, text="Keepout Margin (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) 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) self.keepout_margin_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=0, to=50, increment=0.5, ttk.Spinbox(parent, from_=0, to=50, increment=0.5,
textvariable=self.keepout_margin_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=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)
ttk.Label(parent, text="Margin around coil", ttk.Label(parent, text="Margin around coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1 row += 1
def get_parameters(self): def get_parameters(self):
shape_params = { # Create the appropriate shape based on selection
'outer_diameter' : self.outer_dia_var.get(), shape_type = self.shape_type_var.get()
'inner_diameter' : self.inner_dia_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}")
params = { params = {
'shape' : CircleShape(**shape_params), 'shape' : shape,
'turns' : self.turns_var.get(), 'turns' : self.turns_var.get(),
'layers' : self.layer_mode_var.get(), 'layers' : self.layer_mode_var.get(),
'twists' : self.twists_var.get(), 'twists' : self.twists_var.get(),
@ -517,10 +831,12 @@ class KiCoilGUI:
warnings.showwarning = old_showwarning warnings.showwarning = old_showwarning
def setup_traces(self): 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 [ for var in [
self.turns_var, self.turns_var,
self.outer_dia_var,
self.inner_dia_var,
self.layer_mode_var, self.layer_mode_var,
self.direction_var, self.direction_var,
self.twists_var, self.twists_var,
@ -531,13 +847,44 @@ class KiCoilGUI:
self.stagger_inner_var, self.stagger_inner_var,
self.stagger_outer_var, self.stagger_outer_var,
self.top_layer_var, self.top_layer_var,
self.bottom_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]:
var.trace_add('write', self._on_parameter_change) var.trace_add('write', self._on_parameter_change)
for entry in [self.trace_width_entry, self.clearance_entry, for entry in [self.trace_width_entry, self.clearance_entry,
self.via_drill_entry, self.via_offset_entry, self.via_drill_entry, self.via_offset_entry,
self.footprint_name_entry]: self.footprint_name_entry]:
entry.bind('<KeyRelease>', lambda e: self._on_parameter_change()) 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): def _on_parameter_change(self, *args):
# Schedule validation to avoid too many rapid calls # Schedule validation to avoid too many rapid calls
@ -561,15 +908,21 @@ class KiCoilGUI:
return True return True
except ValueError as e: except ValueError as e:
self.output_text['state'] = 'normal'
self.output_text.insert(tk.END, f"ERROR: {e}\n", 'error') self.output_text.insert(tk.END, f"ERROR: {e}\n", 'error')
self.output_text.see(tk.END) self.output_text.see(tk.END)
tb = traceback.format_exc()
print(tb, file=sys.stderr)
self.current_model = None self.current_model = None
self.update_placeholders() self.update_placeholders()
self.update_preview()
return False return False
except Exception as e: except Exception as e:
tb = traceback.format_exc() 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.insert(tk.END, f"Unexpected error:\n{tb}\n", 'error')
self.output_text.see(tk.END) self.output_text.see(tk.END)
@ -577,7 +930,8 @@ class KiCoilGUI:
self.current_model = None self.current_model = None
self.update_placeholders() self.update_placeholders()
return True self.update_preview()
return False
finally: finally:
self.output_text['state'] = 'disabled' self.output_text['state'] = 'disabled'
@ -610,6 +964,13 @@ class KiCoilGUI:
if not HAS_PREVIEW: if not HAS_PREVIEW:
return 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() arc_tolerance = self.arc_tolerance_var.get()
circle_segments = self.circle_segments_var.get() circle_segments = self.circle_segments_var.get()

View file

@ -11,8 +11,6 @@ import sys
import hashlib import hashlib
import platformdirs import platformdirs
import matplotlib.pyplot as plt
from matplotlib.backends.backend_pdf import PdfPages
import wasmtime import wasmtime
@ -111,7 +109,7 @@ class WasmApp:
self.app.exports(store)["_start"](store) self.app.exports(store)["_start"](store)
except wasmtime.ExitTrap as trap: except wasmtime.ExitTrap as trap:
if trap.code != 0: if trap.code != 0:
raise raise RuntimeError('Error computing straight skeleton.')
return 0, stdout_f.read() return 0, stdout_f.read()
@ -189,15 +187,13 @@ class Skeletonator:
for x, y in poly: for x, y in poly:
p = (round(x, 6), round(y, 6)) p = (round(x, 6), round(y, 6))
self.node_map[(x, y)] = coord_map[p] self.node_map[(x, y)] = coord_map[p]
self.dump_to_pdf('/tmp/test.pdf') self.debug_arms = []
def iter_arcs(self, p): def iter_arcs(self, p):
i = 0 i = 0
start = self.node_map[p] 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: while start in self.arc_map and not start in self.divergent:
end = self.arc_map[start] end = self.arc_map[start]
#print('end', i, end)
i += 1 i += 1
yield start, end yield start, end
start = end start = end
@ -246,13 +242,15 @@ class Skeletonator:
points.append(pt) points.append(pt)
return arcs, points return arcs, points
def do_spiral(self, t1, t2, r1=None, r2=None): def do_spiral(self, t1, t2, r1=None, r2=None, debug=False):
if r1 is None: if r1 is None:
r1 = self.radius r1 = self.radius
if r2 is None: if r2 is None:
r2 = self.min_radius r2 = self.min_radius
direction = True
if t2 < t1: if t2 < t1:
direction = False
t1, t2 = t2, t1 t1, t2 = t2, t1
r1, r2 = r2, r1 r1, r2 = r2, r1
@ -260,15 +258,17 @@ class Skeletonator:
t = max(t1, min(t2, t)) # Clip to start/end of spiral t = max(t1, min(t2, t)) # Clip to start/end of spiral
f = (t - t1) / (t2 - t1) f = (t - t1) / (t2 - t1)
return r1 + (r2 - r1) * f return r1 + (r2 - r1) * f
for t_start in range(math.floor(t1), math.ceil(t2)): debug_arm = []
for t_start in range(math.ceil(t2-t1)):
t_start += t1
t_end = t_start + 1 t_end = t_start + 1
r_outer = r_interpolate(t_start) r_outer = r_interpolate(t_start)
r_inner = r_interpolate(t_end) r_inner = r_interpolate(t_end)
r_ref = min(r_inner, r_outer) # Handle outward spirals where the radii are swapped r_ref = min(r_inner, r_outer) # Handle outward spirals where the radii are swapped
_ic_arcs, inner_circumference = self.map_circumference(r_ref) _ic_arcs, inner_circumference = self.map_circumference(r_ref)
angle = t_start angle = math.floor(t_start)
circumference_angles = [] circumference_angles = []
inner_circumference_sum = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(inner_circumference)) inner_circumference_sum = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(inner_circumference))
point_angles = [] point_angles = []
@ -276,52 +276,86 @@ class Skeletonator:
edge_angle = math.dist(p1, p2) / inner_circumference_sum edge_angle = math.dist(p1, p2) / inner_circumference_sum
point_angles.append(angle) point_angles.append(angle)
angle += edge_angle angle += edge_angle
point_angles.append(t_end) point_angles += [a+1 for a in point_angles]
point_angles += [point_angles[0] + 2]
for (p1, p2), (tp1, tp2) in zip(self.poly_edges, itertools.pairwise(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) rp1 = r_interpolate(tp1)
rp2 = r_interpolate(tp2) rp2 = r_interpolate(tp2)
_arc, p1_proj = self.project_arc(p1, rp1) _arc, p1_proj = self.project_arc(p1, rp1)
_arc, p2_proj = self.project_arc(p2, rp2) _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
if approx_in_range(t1, tp1, tp2): if approx_in_range(t1, tp1, tp2):
_arc, p2_proj_r1 = self.project_arc(p2, r1) _arc, p2_proj_r1 = self.project_arc(p2, r1)
yield interpolate(p1_proj, p2_proj_r1, t1, tp1, tp2), r_ref 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): if approx_in_range(t2, tp1, tp2):
_arc, p1_proj_r2 = self.project_arc(p1, r2) _arc, p1_proj_r2 = self.project_arc(p1, r2)
yield interpolate(p1_proj_r2, p2_proj, t2, tp1, tp2), r_ref 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): elif approx_in_range(tp2, t1, t2):
debug_arm.append(p2_proj)
yield p2_proj, r_ref yield p2_proj, r_ref
if debug:
self.debug_arms.append((debug_arm, direction, t1, t2))
def dump_to_pdf(self, filename): def dump_to_pdf(self, filename):
with PdfPages(filename) as pdf: import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 10))
fig, ax = plt.subplots(figsize=(10, 10))
# polygon outline # polygon outline
poly_x = [p[0] for p in self.poly] + [self.poly[0][0]] 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]] 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, '-', color='black', linewidth=.5, label='Polygon')
ax.plot(poly_x, poly_y, 'bo', markersize=4)
# skeleton edges # skeleton edges
for node1, node2 in self.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) ax.plot([node1.x, node2.x], [node1.y, node2.y], '-', color='gray', linewidth=.5, alpha=0.7)
# skeleton nodes # skeleton nodes
for n in self.skeleton_nodes: for n in self.skeleton_nodes:
if n in self.divergent: if n in self.divergent:
ax.plot(n.x, n.y, 'go', markersize=6) ax.plot(n.x, n.y, 'o', markerfacecolor='none', markeredgecolor='green', markersize=4)
elif n in self.arc_map: elif n in self.arc_map:
ax.plot(n.x, n.y, 'ro', markersize=3, alpha=0.5) ax.plot(n.x, n.y, 'o', color='black', markersize=3, alpha=0.5)
else: else:
ax.plot(n.x, n.y, 'o', color='magenta', markersize=6) ax.plot(n.x, n.y, 'o', markerfacecolor='none', markeredgecolor='magenta', markersize=4)
ax.set_aspect('equal', adjustable='box') count = {True: 0, False: 0}
ax.grid(True, alpha=0.3) for arm, direction, t1, t2 in self.debug_arms:
ax.legend() xs = [x for x, y in arm]
ax.set_title(f'Polygon Skeleton (radius: {self.radius:.3f}, min_radius: {self.min_radius:.3f})') ys = [y for x, y in arm]
ax.set_xlabel('X') ax.plot(xs, ys, linewidth=.2, color='red' if direction else 'blue')
ax.set_ylabel('Y') align = 'left' if direction else 'right'
ax.invert_yaxis() ax.text(xs[-1], ys[-1], f'{count[direction]}', size=3, horizontalalignment=align)
pdf.savefig(fig, bbox_inches='tight') ax.text(xs[0], ys[0], f'{count[direction]}', size=3, horizontalalignment=align, color='gray')
plt.close(fig) 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)

8
uv.lock generated
View file

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