diff --git a/pyproject.toml b/pyproject.toml
index 453bb7f..499d209 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ description = "Planar Inductor Generator"
readme = "README.rst"
license = "Apache-2.0"
requires-python = ">=3.13"
-dependencies = ["click", "gerbonara"]
+dependencies = ["click", "gerbonara>=1.6.0"]
authors = [{ name = "jaseg" }]
maintainers = [
{ name = "Kicoil maintainers", email = "kicoil@jaseg.de" },
diff --git a/src/kicoil/__init__.py b/src/kicoil/__init__.py
new file mode 100644
index 0000000..bc23f58
--- /dev/null
+++ b/src/kicoil/__init__.py
@@ -0,0 +1,6 @@
+
+from importlib.metadata import version
+
+from .geometry import PlanarInductor
+
+__version__ = version('kicoil')
diff --git a/src/kicoil/cli.py b/src/kicoil/cli.py
new file mode 100644
index 0000000..d9dd55f
--- /dev/null
+++ b/src/kicoil/cli.py
@@ -0,0 +1,130 @@
+
+import logging
+import subprocess
+import webbrowser
+import tempfile
+import os
+import sys
+from pathlib import Path
+import warnings
+
+import click
+from gerbonara.layers import LayerStack
+
+from .geometry import PlanarInductor
+from .kicad import footprint_to_board
+from .svg import make_transparent_svg
+
+
+def print_valid_twists(ctx, param, value):
+ if not value or ctx.resilient_parsing:
+ return
+
+ print(f'Valid twist counts for {value} turns:', file=sys.stderr)
+ for d in divisors(value, value):
+ print(f' {d}', file=sys.stderr)
+
+ click.echo()
+ ctx.exit()
+
+
+@click.command()
+@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
+@click.option('--footprint-name', help="Name for the generated footprint. 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('--turns', type=int, default=5, help='Number of turns')
+@click.option('--outer-diameter', type=float, default=50, help='Outer diameter [mm]')
+@click.option('--inner-diameter', type=float, default=25, help='Inner diameter [mm]')
+@click.option('--stagger-inner-vias/--no-stagger-inner-vias', default=False, help='Stagger inner via ring')
+@click.option('--stagger-outer-vias/--no-stagger-outer-vias', default=False, help='Stagger outer via ring')
+@click.option('--trace-width', type=float, default=None)
+@click.option('--via-diameter', type=float, default=0.6)
+@click.option('--via-drill', type=float, default=None)
+@click.option('--via-offset', type=float, default=None, help='Radially offset vias from trace endpoints [mm]')
+@click.option('--keepout-zone/--no-keepout-zone', default=True, help='Add a keepout are to the footprint (default: yes)')
+@click.option('--keepout-margin', type=float, default=5, help='Margin between outside of coil and keepout area (mm, default: 5)')
+@click.option('--copper-thickness', type=float, default=0.035, help='Copper thickness for resistance calculation, in mm. Default: 0.035mm ^= 1 Oz')
+@click.option('--twists', type=int, default=1, help='Number of twists per revolution. Note that this number must be co-prime to the number of turns. Run with --show-twists to list valid values. (default: 1)')
+@click.option('--circle-segments', type=int, default=64, help='When not using arcs, the number of points to use for arc interpolation per 360 degrees.')
+@click.option('--show-twists', callback=print_valid_twists, expose_value=False, type=int, is_eager=True, help='Calculate and show valid --twists counts for the given number of turns. Takes the number of turns as a value.')
+@click.option('--clearance', type=float, default=None)
+@click.option('--arc-tolerance', type=float, default=0.02)
+@click.option('--format', type=click.Choice(['svg', 'gerber', 'kicad-footprint', 'kicad-pcb', 'show']), default='kicad-footprint')
+@click.option('--clipboard/--no-clipboard', help='Use clipboard integration (requires wl-clipboard)')
+@click.option('--clockwise/--counter-clockwise', help='Direction of generated spiral. Default: counter-clockwise when wound from the inside.')
+@click.option('--single-layer/--two-layer', help='Single-layer mode. This just forces twists to 0.')
+@click.version_option()
+def cli(outfile, footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, **kwargs):
+ logger = logging.getLogger('kicoil')
+
+ if single_layer:
+ kwargs['twists'] = 0
+ kwargs['layers'] = 1
+ else:
+ kwargs['layers'] = 2
+
+ try:
+ model = PlanarInductor(**kwargs)
+
+ if footprint_name is None and outfile:
+ footprint_name = outfile.stem
+
+ footprint = model.render_footprint(footprint_name, arc_tolerance, circle_segments)
+
+ except ValueError as e:
+ raise click.ClickException(*e.args)
+
+ data = None
+ if format == 'kicad-footprint':
+ data = footprint.serialize()
+
+ elif format == 'kicad-pcb':
+ data = footprint_to_board(footprint).serialize()
+
+ elif format == 'gerber':
+ stack = LayerStack()
+ footprint.render(stack)
+
+ if not clipboard and outfile and outfile.suffix.lower() != '.zip':
+ stack.save_to_directory(outfile)
+ return
+
+ else:
+ with tempfile.NamedTemporaryFile(delete_on_close=False) as f:
+ f = Path(f.name)
+ stack.save_to_zipfile(f)
+ data = f.read_bytes()
+
+ elif format in ('svg', 'show'):
+ data = str(make_transparent_svg(footprint))
+
+ if format == 'show':
+ with tempfile.NamedTemporaryFile('w', suffix='.svg', delete=False) as f:
+ f.write(data)
+ f.flush()
+ webbrowser.open_new_tab(f'file://{f.name}')
+ return
+
+ if clipboard:
+ if 'WAYLAND_DISPLAY' in os.environ:
+ copy, paste, cliputil = ['wl-copy'], ['wl-paste'], 'xclip'
+ else:
+ copy, paste, cliputil = ['xclip', '-i', '-sel', 'clipboard'], ['xclip', '-o', '-sel' 'clipboard'], 'wl-clipboard'
+
+ try:
+ logger.info(f'Running {copy[0]}.', file=sys.stderr)
+ proc = subprocess.Popen(copy, stdin=subprocess.PIPE, text=isinstance(data, str))
+ proc.communicate(data)
+
+ except FileNotFoundError:
+ raise click.ClickException(f'Error: --clipboard requires the {copy[0]} and {paste[0]} utilities from {cliputil} to be installed.', file=sys.stderr)
+
+ elif not outfile:
+ if isinstance(data, str):
+ print(data)
+ else:
+ sys.stdout.buffer.write(data)
+
+ else:
+ outfile.write_text(data)
+
diff --git a/src/kicoil/geometry.py b/src/kicoil/geometry.py
index c222aa7..d691dff 100644
--- a/src/kicoil/geometry.py
+++ b/src/kicoil/geometry.py
@@ -1,34 +1,16 @@
-#!/usr/bin/env python3
-
import warnings
-import subprocess
+import logging
+from dataclasses import dataclass, field
import sys
-import math
-import multiprocessing
-import os
from math import *
-from pathlib import Path
-from itertools import cycle
-from contextlib import contextmanager
-from scipy.constants import mu_0
-import numpy as np
-import click
-import matplotlib as mpl
+from gerbonara.cad.kicad.footprints import Footprint
+from gerbonara.cad.kicad.primitives import Zone, Hatch, ZoneKeepout, ZonePolygon, XYCoord
-from gerbonara.cad.kicad import pcb as kicad_pcb
-from gerbonara.cad.kicad import footprints as kicad_fp
-from gerbonara.cad.kicad import graphical_primitives as kicad_gr
-from gerbonara.cad.kicad import primitives as kicad_pr
-from gerbonara.utils import Tag
-from gerbonara import graphic_primitives as gp
-from gerbonara import graphic_objects as go
-
-from . import svg
from . import kicad
-__version__ = '1.0.0'
+mu_0 = 1.25663706127e-06 # from scipy.constants
def point_line_distance(p, l1, l2):
@@ -64,13 +46,11 @@ def farey_sequence(n: int, descending: bool = False) -> None:
a, b, c, d = 0, 1, 1, n
if descending:
a, c = 1, n - 1
- #print(f"{a}/{b}")
yield a, b
while c <= n and not descending or a > 0 and descending:
k = (n + b) // d
a, b, c, d = c, d, k * c - a, k * d - b
- #print(f"{a}/{b}")
yield a, b
@@ -82,19 +62,7 @@ def divisors(n, max_b=10):
yield a
-def print_valid_twists(ctx, param, value):
- if not value or ctx.resilient_parsing:
- return
-
- print(f'Valid twist counts for {value} turns:', file=sys.stderr)
- for d in divisors(value, value):
- print(f' {d}', file=sys.stderr)
-
- click.echo()
- ctx.exit()
-
-
-def arc_approximate(points, layer, tolerance=0.02, level=0):
+def arc_approximate(points, trace_width, layer, tolerance=0.02, level=0):
""" Approximate spiral arm using circular arcs. This results in a smoother output using less segments than if we
approximate the arc using straight line segments.
@@ -111,7 +79,7 @@ def arc_approximate(points, layer, tolerance=0.02, level=0):
x2, y2 = points[-1]
if len(points) < 5:
- yield make_arc(x0, y0, x2, y2, x1, y1, layer)
+ yield kicad.make_arc(x0, y0, x2, y2, x1, y1, trace_width, layer)
# https://stackoverflow.com/questions/56224824/how-do-i-find-the-circumcenter-of-the-triangle-using-python-without-external-lib
d = 2 * (x0 * (y2 - y1) + x2 * (y1 - y0) + x1 * (y0 - y2))
@@ -119,14 +87,14 @@ def arc_approximate(points, layer, tolerance=0.02, level=0):
cy = ((x0 * x0 + y0 * y0) * (x1 - x2) + (x2 * x2 + y2 * y2) * (x0 - x1) + (x1 * x1 + y1 * y1) * (x2 - x0)) / d
r = dist((cx, cy), (x1, y1))
if any(abs(dist((px, py), (cx, cy)) - r) > tolerance for px, py in points):
- yield from arc_approximate(points[:i_mid+1], layer, tolerance, level+1)
- yield from arc_approximate(points[i_mid:], layer, tolerance, level+1)
+ yield from arc_approximate(points[:i_mid+1], trace_width, layer, tolerance, level+1)
+ yield from arc_approximate(points[i_mid:], trace_width, layer, tolerance, level+1)
else:
- yield make_arc(x0, y0, x2, y2, x1, y1, layer)
+ yield kicad.make_arc(x0, y0, x2, y2, x1, y1, trace_width, layer)
-def compute_spiral(r1, r2, a1, a2, start_frac, end_frac, fn=64):
+def compute_spiral(r1, r2, a1, a2, fn=64):
fn = ceil(fn * (a2-a1)/(2*pi))
x0, y0 = cos(a1)*r1, sin(a1)*r1
dr = 3 if r2 < r1 else -3
@@ -144,250 +112,242 @@ def compute_spiral(r1, r2, a1, a2, start_frac, end_frac, fn=64):
return points, sum(dists)
-@click.command()
-@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
-@click.option('--footprint-name', help="Name for the generated footprint. 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('--turns', type=int, default=5, help='Number of turns')
-@click.option('--outer-diameter', type=float, default=50, help='Outer diameter [mm]')
-@click.option('--inner-diameter', type=float, default=25, help='Inner diameter [mm]')
-@click.option('--stagger-inner-vias/--no-stagger-inner-vias', default=False, help='Stagger inner via ring')
-@click.option('--stagger-outer-vias/--no-stagger-outer-vias', default=False, help='Stagger outer via ring')
-@click.option('--trace-width', type=float, default=None)
-@click.option('--via-diameter', type=float, default=0.6)
-@click.option('--two-layer/--single-layer', default=True)
-@click.option('--via-drill', type=float, default=0.3)
-@click.option('--via-offset', type=float, default=None, help='Radially offset vias from trace endpoints [mm]')
-@click.option('--keepout-zone/--no-keepout-zone', default=True, help='Add a keepout are to the footprint (default: yes)')
-@click.option('--keepout-margin', type=float, default=5, help='Margin between outside of coil and keepout area (mm, default: 5)')
-@click.option('--copper-thickness', type=float, default=0.035, help='Copper thickness for resistance calculation, in mm. Default: 0.035mm ^= 1 Oz')
-@click.option('--twists', type=int, default=1, help='Number of twists per revolution. Note that this number must be co-prime to the number of turns. Run with --show-twists to list valid values. (default: 1)')
-@click.option('--circle-segments', type=int, default=64, help='When not using arcs, the number of points to use for arc interpolation per 360 degrees.')
-@click.option('--show-twists', callback=print_valid_twists, expose_value=False, type=int, is_eager=True, help='Calculate and show valid --twists counts for the given number of turns. Takes the number of turns as a value.')
-@click.option('--clearance', type=float, default=None)
-@click.option('--arc-tolerance', type=float, default=0.02)
-@click.option('--format', type=click.Choice(['svg', 'gerber', 'kicad-footprint', 'kicad-pcb', 'show']), default='kicad-footprint')
-@click.option('--clipboard/--no-clipboard', help='Use clipboard integration (requires wl-clipboard)')
-@click.option('--counter-clockwise/--clockwise', help='Direction of generated spiral. Default: clockwise when wound from the inside.')
-@click.version_option()
-def generate(outfile, turns, outer_diameter, inner_diameter, via_diameter, via_drill, via_offset, trace_width,
- clearance, footprint_name, layer_pair, twists, clipboard, counter_clockwise, keepout_zone, keepout_margin,
- arc_tolerance, circle_segments, copper_thickness, format, two_layer, stagger_inner_vias,
- stagger_outer_vias):
- if 'WAYLAND_DISPLAY' in os.environ:
- copy, paste, cliputil = ['wl-copy'], ['wl-paste'], 'xclip'
- else:
- copy, paste, cliputil = ['xclip', '-i', '-sel', 'clipboard'], ['xclip', '-o', '-sel' 'clipboard'], 'wl-clipboard'
+@dataclass
+class PlanarInductor():
+ outer_diameter: float
+ inner_diameter: float
+ turns: int
+ twists: int
+ trace_width: float
+ clearance: float = None
+ layers: int = 2
+ via_diameter: float = 0.6
+ via_drill: float = None
+ via_offset: float = None
+ stagger_inner_vias: bool = False
+ stagger_outer_vias: bool = False
+ keepout_zone: bool = True
+ keepout_margin: float = 0.0
+ copper_thickness: float = 0.035
+ layer_pair: str = 'F.Cu,B.Cu'
+ clockwise: bool = False
- if gcd(twists, turns) != 1:
- raise click.ClickException(f'For the geometry to work out, the --twists parameter must be co-prime to --turns, i.e. the two must have 1 as their greatest common divisor. You can print valid values for --twists by running this command with --show-twists [turns number].\n\n'
- f'Right now, both are divisible by {gcd(twists, turns)}.\n'
- f'Valid twist counts for n={turns} turns are: {list(divisors(turns, max(turns, 25)))}'
- f'Valid turn counts for k={twists} twists are: {list(divisors(twists, max(twists, 25)))}')
+ def __post_init__(self):
+ self.logger = logging.getLogger('kicoil')
+ self.outer_radius = self.outer_diameter/2
+ self.inner_radius = self.inner_diameter/2
+ self.turns_per_layer = self.turns/self.layers
+ self.sector_angle = 2*pi / self.twists
+ self.sweeping_angle = self.sector_angle * self.turns_per_layer
+ self.spiral_pitch = (self.outer_radius-self.inner_radius) / self.turns_per_layer
+ self.layers = 2 if self.twists > 0 else 1
+ self.R = None # will be calculated during render
- if (stagger_inner_vias or stagger_outer_vias) and twists%2 != 0:
- raise click.ClickException('For --stagger-inner/outer-vias to work, --twists must be even and --turns must be odd.')
+ c1 = self.inner_radius
+ c2 = self.inner_radius + self.spiral_pitch
+ alpha1 = atan((self.outer_radius - self.inner_radius) / self.sweeping_angle / c1)
+ alpha2 = atan((self.outer_radius - self.inner_radius) / self.sweeping_angle / c2)
+ alpha = (alpha1+alpha2)/2
+ self.projected_spiral_pitch = self.spiral_pitch*cos(alpha)
- outer_radius = outer_diameter/2
- inner_radius = inner_diameter/2
- turns_per_layer = turns/2 if two_layer else turns
- sweeping_angle = 2*pi * turns_per_layer / twists
- spiral_pitch = (outer_radius-inner_radius) / turns_per_layer
- c1 = inner_radius
- c2 = inner_radius + spiral_pitch
- alpha1 = atan((outer_radius - inner_radius) / sweeping_angle / c1)
- alpha2 = atan((outer_radius - inner_radius) / sweeping_angle / c2)
- alpha = (alpha1+alpha2)/2
- projected_spiral_pitch = spiral_pitch*cos(alpha)
+ if self.turns < 1:
+ raise ValueError(f'Error: PlanarInductor.turns must be 1 or more')
- if trace_width is None and clearance is None:
- trace_width = 0.15
- print(f'Warning: Defaulting to {trace_width:.2f} mm trace width.', file=sys.stderr)
+ if self.twists < 0:
+ raise ValueError(f'Error: PlanarInductor.turns must be 0 or more')
- if trace_width is None:
- if round(clearance, 3) > round(projected_spiral_pitch, 3):
- raise click.ClickException(f'Error: Given clearance of {clearance:.2f} mm is larger than the projected spiral pitch of {projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
- trace_width = projected_spiral_pitch - clearance
- print(f'Calculated trace width for {clearance:.2f} mm clearance is {trace_width:.2f} mm.', file=sys.stderr)
+ if gcd(self.twists, self.turns) != 1:
+ raise ValueError(f'For the geometry to work out, the twists parameter must be co-prime to turns, i.e. the two must have 1 as their greatest common divisor. You can print valid values for twists by running the kicoil CLI with --show-twists [turns number].\n\n'
+ f'Right now, both are divisible by {gcd(self.twists, self.turns)}.\n'
+ f'Valid twist counts for n={self.turns} turns are: {list(divisors(self.turns, max(self.turns, 25)))}'
+ f'Valid turn counts for k={self.twists} twists are: {list(divisors(self.twists, max(self.twists, 25)))}')
- elif clearance is None:
- if round(trace_width, 2) > round(projected_spiral_pitch, 2):
- raise click.ClickException(f'Error: Given trace width of {trace_width:.2f} mm is larger than the projected spiral pitch of {projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
- clearance = projected_spiral_pitch - trace_width
- print(f'Calculated clearance for {trace_width:.2f} mm trace width is {clearance:.2f} mm.', file=sys.stderr)
+ if (self.stagger_inner_vias or self.stagger_outer_vias) and self.twists%2 != 0:
+ raise ValueError('For via staggering to work, twists must be even and turns must be odd.')
- else:
- if round(trace_width, 2) > round(projected_spiral_pitch, 2):
- raise click.ClickException(f'Error: Given trace width of {trace_width:.2f} mm is larger than the projected spiral pitch of {projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
- clearance_actual = projected_spiral_pitch - trace_width
- if round(clearance_actual, 3) < round(clearance, 3):
- raise click.ClickException(f'Error: Actual clearance for {trace_width:.2f} mm trace is {clearance_actual:.2f} mm, which is lower than the given clearance of {clearance:.2f} mm.')
+ if self.trace_width is None and self.clearance is None:
+ self.clearance = 0.15
+ warnings.warn(f'Warning: Neither trace width nor clearance given. Defaulting to {self.clearance:.2f} mm clearance.')
- if round(via_diameter, 2) < round(trace_width, 2):
- print(f'Clipping via diameter from {via_diameter:.2f} mm to trace width of {trace_width:.2f} mm.', file=sys.stderr)
- via_diameter = trace_width
+ if self.trace_width is None:
+ if round(self.clearance, 3) > round(self.projected_spiral_pitch, 3):
+ raise ValueError(f'Error: Given clearance of {clearance:.2f} mm is larger than the projected spiral pitch of {projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
+ self.trace_width = self.projected_spiral_pitch - self.clearance
+ self.logger.info(f'Calculated trace width for {self.clearance:.2f} mm clearance is {self.trace_width:.2f} mm.')
- if via_offset is None:
- via_offset = max(0, (via_diameter-trace_width)/2)
- print(f'Autocalculated via offset {via_offset:.2f} mm', file=sys.stderr)
-
- inner_via_ring_radius = inner_radius - via_offset
- #print(f'{inner_radius=} {via_offset=} {via_diameter=}', file=sys.stderr)
- inner_via_angle = 2*asin((via_diameter + clearance)/2 / inner_via_ring_radius)
-
- outer_via_ring_radius = outer_radius + via_offset
- outer_via_angle = 2*asin((via_diameter + clearance)/2 / outer_via_ring_radius)
-
- print(f'Inner via ring @r={inner_via_ring_radius:.2f} mm (from {inner_radius:.2f} mm)', file=sys.stderr)
- print(f' {degrees(inner_via_angle):.1f} deg / via', file=sys.stderr)
- print(f'Outer via ring @r={outer_via_ring_radius:.2f} mm (from {outer_radius:.2f} mm)', file=sys.stderr)
- print(f' {degrees(outer_via_angle):.1f} deg / via', file=sys.stderr)
-
- # Check if the vias of the inner ring are so large that they would overlap
- if inner_via_angle*twists > (4*pi if stagger_inner_vias else 2*pi):
- min_dia = 2*((via_diameter + clearance) / (2*sin(pi / twists * (2 if stagger_inner_vias else 1))) + via_offset)
- warnings.warn(f'Overlapping vias in inner via ring. Calculated minimum inner diameter is {min_dia:.2f} mm.')
-
- pitch = clearance + trace_width
- t, _, b = layer_pair.partition(',')
- layer_pair = (t.strip(), b.strip())
- rainbow = '#817 #a35 #c66 #e94 #ed0 #9d5 #4d8 #2cb #0bc #09c #36b #639'.split()
- rainbow = rainbow[2::3] + rainbow[1::3] + rainbow[0::3]
- n = 5
- rainbow = rainbow[n:] + rainbow[:n]
- out_paths = []
- svg_stuff = [*out_paths]
-
- # For fill factor & inductance formulas, See https://coil32.net/pcb-coil.html for details
- d_avg = (outer_diameter + inner_diameter)/2
- phi = (outer_diameter - inner_diameter) / (outer_diameter + inner_diameter)
- c1, c2, c3, c4 = 1.00, 2.46, 0.00, 0.20
- L = mu_0 * turns**2 * d_avg*1e3 * c1 / 2 * (log(c2/phi) + c3*phi + c4*phi**2)
- print(f'Outer diameter: {outer_diameter:g} mm', file=sys.stderr)
- print(f'Average diameter: {d_avg:g} mm', file=sys.stderr)
- print(f'Inner diameter: {inner_diameter:g} mm', file=sys.stderr)
- print(f'Fill factor: {phi:g}', file=sys.stderr)
- print(f'Approximate inductance: {L:g} µH', file=sys.stderr)
-
- if footprint_name:
- name = footprint_name
- elif outfile:
- name = outfile.stem,
- else:
- name = f'generated-coil-{outer_diameter:.2f}x{inner_diameter:.2f}-n{turns}-k{twists}'
-
- footprint = kicad_fp.Footprint(
- name=name,
- generator=kicad_fp.Atom('KicoilV1'),
- layer='F.Cu',
- descr=f"{turns} turn {outer_diameter:.2f} mm diameter twisted coil footprint, inductance approximately {L:.6f} µH. Generated by gerbonara'c Twisted Coil generator, version {__version__}.",
- clearance=clearance,
- zone_connect=0)
-
- sector_angle = 2*pi / twists
- total_angle = twists*2*sweeping_angle if two_layer else twists*sweeping_angle
-
- inverse = {}
- for i in range(twists):
- inverse[i*turns%twists] = i
-
- # Array where we collect all gerbonara kicad line and arc objects
- for i in range(twists):
- start_angle = i*sector_angle
- fold_angle = start_angle + sweeping_angle
- end_angle = fold_angle + sweeping_angle
-
- # Handle the spiral arm
- x = inverse[i]*floor(2*sweeping_angle / (2*pi)) * 2*pi
- points_layer0, arm_length = compute_spiral(outer_radius, inner_radius, start_angle, fold_angle, (x + start_angle)/total_angle, (x + fold_angle)/total_angle, circle_segments)
- x0, y0 = points_layer0[0]
- xn, yn = points_layer0[-1]
-
- if two_layer:
- # Handle the returning arm on the bottom layer
- points_layer1, _ = compute_spiral(inner_radius, outer_radius, fold_angle, end_angle, (x + fold_angle)/total_angle, (x + end_angle)/total_angle, circle_segments)
+ elif self.clearance is None:
+ if round(self.trace_width, 2) > round(self.projected_spiral_pitch, 2):
+ raise ValueError(f'Error: Given trace width of {self.trace_width:.2f} mm is larger than the projected spiral pitch of {self.projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
+ self.clearance = self.projected_spiral_pitch - self.trace_width
+ self.logger.info(f'Calculated clearance for {self.trace_width:.2f} mm trace width is {self.clearance:.2f} mm.')
else:
- # Add a straight connecting segment connecting the inner point to the outside of the spiral.
- dr = outer_radius - inner_radius
- xq = xn + cos(fold_angle) * dr
- yq = yn - sin(fold_angle) * dr
- points_layer1 = [(xn, yn), (xq, yq)]
+ 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.')
+ clearance_actual = self.projected_spiral_pitch - self.trace_width
+ 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.')
- footprint.arcs.extend(arc_approximate(points_layer0, layer_pair[0], arc_tolerance))
- footprint.arcs.extend(arc_approximate(points_layer1, layer_pair[1], arc_tolerance))
+ 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.via_diameter = self.trace_width
- # Handle inner via ring and process staggering if enabled
- r = inner_via_ring_radius
- if stagger_inner_vias:
- if i%2 != 0:
- r -= 2*via_offset
+ if self.via_drill is None:
+ self.via_drill = max(self.via_diameter / 2, self.via_diameter - 1.2)
+ self.logger.warning(f'No via drill given, defaulting to {self.via_drill:.2f} mm based on via diameter.')
- xv, yv = r*cos(fold_angle), r*sin(fold_angle)
+ if self.via_offset is None:
+ self.via_offset = max(0, (self.via_diameter - self.trace_width)/2)
+ self.logger.info(f'Autocalculated via offset {self.via_offset:.2f} mm')
- if not isclose(via_offset, 0, abs_tol=1e-6):
- footprint.lines.append(make_line(*points_layer0[-1], xv, yv, layer_pair[0]))
- footprint.lines.append(make_line(xv, yv, *points_layer1[0], layer_pair[1]))
+ if isclose(self.via_offset, 0, abs_tol=1e-6):
+ self.via_offset = 0
- # Handle outer via ring and process staggering if enabled unless we are at the start of the coil, where we will
- # place pads below.
- if i > 0:
- r = outer_via_ring_radius
+ self.inner_via_ring_radius = self.inner_radius - self.via_offset
+ inner_via_angle = 2*asin((self.via_diameter + self.clearance)/2 / self.inner_via_ring_radius)
- if stagger_outer_vias:
+ self.outer_via_ring_radius = self.outer_radius + self.via_offset
+ outer_via_angle = 2*asin((self.via_diameter + self.clearance)/2 / self.outer_via_ring_radius)
+
+ self.logger.info(f'Inner via ring @r={self.inner_via_ring_radius:.2f} mm (from {self.inner_radius:.2f} mm)')
+ self.logger.info(f' {degrees(inner_via_angle):.1f} deg / via')
+ self.logger.info(f'Outer via ring @r={self.outer_via_ring_radius:.2f} mm (from {self.outer_radius:.2f} mm)')
+ self.logger.info(f' {degrees(outer_via_angle):.1f} deg / via')
+
+ # Check if the vias of the inner ring are so large that they would overlap
+ if inner_via_angle*self.twists > (4*pi if self.stagger_inner_vias else 2*pi):
+ min_dia = 2*((self.via_diameter + self.clearance) / (2*sin(pi / self.twists * (2 if self.stagger_inner_vias else 1))) + self.via_offset)
+ warnings.warn(f'Overlapping vias in inner via ring. Calculated minimum inner diameter is {min_dia:.2f} mm.')
+
+ t, _, b = self.layer_pair.partition(',')
+ self.layer_pair = (t.strip(), b.strip())
+
+ # For fill factor & inductance formulas, See https://coil32.net/pcb-coil.html for details
+ d_avg = (self.outer_diameter + self.inner_diameter)/2
+ phi = (self.outer_diameter - self.inner_diameter) / (self.outer_diameter + self.inner_diameter)
+ c1, c2, c3, c4 = 1.00, 2.46, 0.00, 0.20
+ self.L = mu_0 * self.turns**2 * d_avg*1e3 * c1 / 2 * (log(c2/phi) + c3*phi + c4*phi**2)
+ self.logger.info(f'Outer diameter: {self.outer_diameter:g} mm')
+ self.logger.info(f'Average diameter: {d_avg:g} mm')
+ self.logger.info(f'Inner diameter: {self.inner_diameter:g} mm')
+ self.logger.info(f'Fill factor: {phi:g}')
+ self.logger.info(f'Approximate inductance: {self.L:g} µH')
+
+ _points, arm_length = compute_spiral(r1=self.outer_radius, r2=self.inner_radius,
+ a1=0, a2=self.sector_angle,
+ fn=256)
+
+ self.track_length = arm_length*self.twists*self.layers
+ self.logger.info(f'Approximate track length: {self.track_length:.2f} mm')
+
+ A = self.copper_thickness/1e3 * self.trace_width/1e3 # trace cross-section area
+ rho = 1.68e-8 # specific resistivity of copper
+ self.R = self.track_length/1e3 * rho / A
+ self.logger.info(f'Approximate resistance: {self.R:g} Ω')
+
+
+ def render_footprint(self, name=None, arc_tolerance=0.02, circle_segments=64):
+ if name is None:
+ name = f'generated-coil-{self.outer_diameter:.2f}x{self.inner_diameter:.2f}-n{self.turns}-k{self.twists}'
+
+ from . import __version__
+ footprint = Footprint(
+ name=name,
+ generator=kicad.Atom('kicoil'),
+ generator_version=__version__,
+ layer='F.Cu',
+ descr=f"{self.turns} turn {self.outer_diameter:.2f} mm diameter twisted coil footprint, inductance approximately {self.L:.6f} µH. Generated by kicoil, version {__version__}.",
+ clearance=self.clearance,
+ zone_connect=0)
+
+ total_angle = self.twists*2*self.sweeping_angle*self.layers
+
+ inverse = {}
+ for i in range(self.twists):
+ inverse[i*self.turns%self.twists] = i
+
+ # Array where we collect all gerbonara kicad line and arc objects
+ for i in range(self.twists):
+ start_angle = i*self.sector_angle
+ fold_angle = start_angle + self.sweeping_angle
+ end_angle = fold_angle + self.sweeping_angle
+
+ # Handle the spiral arm
+ x = inverse[i]*floor(2*self.sweeping_angle / (2*pi)) * 2*pi
+ points_layer0, arm_length = compute_spiral(r1=self.outer_radius, r2=self.inner_radius,
+ a1=start_angle, a2=fold_angle,
+ fn=circle_segments)
+ x0, y0 = points_layer0[0]
+ xn, yn = points_layer0[-1]
+
+ if self.twists > 0:
+ # Handle the returning arm on the bottom layer
+ points_layer1, _ = compute_spiral(r1=self.inner_radius, r2=self.outer_radius,
+ a1=fold_angle, a2=end_angle,
+ fn=circle_segments)
+
+ else:
+ # Add a straight connecting segment connecting the inner point to the outside of the spiral.
+ dr = self.outer_radius - self.inner_radius
+ xq = xn + cos(fold_angle) * dr
+ yq = yn - sin(fold_angle) * dr
+ points_layer1 = [(xn, yn), (xq, yq)]
+
+ footprint.arcs.extend(arc_approximate(points_layer0, self.trace_width, self.layer_pair[0], arc_tolerance))
+ footprint.arcs.extend(arc_approximate(points_layer1, self.trace_width, self.layer_pair[1], arc_tolerance))
+
+ # Handle inner via ring and process staggering if enabled
+ r = self.inner_via_ring_radius
+ if self.stagger_inner_vias:
if i%2 != 0:
- r += 2*via_offset
+ r -= 2*self.via_offset
- xv, yv = r*cos(start_angle), r*sin(start_angle)
+ xv, yv = r*cos(fold_angle), r*sin(fold_angle)
- if not isclose(via_offset, 0, abs_tol=1e-6):
- footprint.lines.append(make_line(x0, y0, xv, yv, layer_pair[0]))
- footprint.lines.append(make_line(x0, y0, xv, yv, layer_pair[1]))
+ if self.via_offset:
+ 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]))
- l_total = arm_length*twists*(2 if two_layer else 1)
- print(f'Approximate track length: {l_total:.2f} mm', file=sys.stderr)
+ footprint.pads.append(kicad.make_via(xv, yv,
+ self.via_diameter, self.via_drill, self.clearance,
+ self.layer_pair))
- A = copper_thickness/1e3 * trace_width/1e3
- rho = 1.68e-8
- R = l_total/1e3 * rho / A
- print(f'Approximate resistance: {R:g} Ω', file=sys.stderr)
+ # Handle outer via ring and process staggering if enabled unless we are at the start of the coil, where we will
+ # place pads below.
+ if i > 0:
+ r = self.outer_via_ring_radius
- # Place the pads on the outer radius
- top_pad = make_pad(1, [layer_pair[0]], outer_radius, 0)
- pads.append(top_pad)
- bottom_pad = make_pad(2, [layer_pair[1]], outer_radius, 0)
- pads.append(bottom_pad)
+ if self.stagger_outer_vias:
+ if i%2 != 0:
+ r += 2*self.via_offset
- if keepout_zone:
- r = outer_diameter/2 + keepout_margin
- tol = 0.05 # mm
- n = ceil(pi / acos(1 - tol/r))
- pts = [(r*cos(a*2*pi/n), r*sin(a*2*pi/n)) for a in range(n)]
- footprint.zones.append(kicad_pr.Zone(layers=['*.Cu'],
- hatch=kicad_pr.Hatch(),
- filled_areas_thickness=False,
- keepout=kicad_pr.ZoneKeepout(copperpour_allowed=False),
- polygon=kicad_pr.ZonePolygon(pts=[kicad_pr.XYCoord(x=x, y=y) for x, y in pts])))
+ xv, yv = r*cos(start_angle), r*sin(start_angle)
- if format == 'kicad-footprint':
- data = footprint.serialize()
- elif format == 'kicad-pcb':
- data = footprint_to_board(footprint).serialize()
- elif format == 'gerber':
+ if self.via_offset:
+ footprint.lines.append(kicad.make_line(x0, y0, xv, yv, self.trace_width, self.layer_pair[0]))
+ footprint.lines.append(kicad.make_line(x0, y0, xv, yv, self.trace_width, self.layer_pair[1]))
- if clipboard:
- try:
- print(f'Running {copy[0]}.', file=sys.stderr)
- proc = subprocess.Popen(copy, stdin=subprocess.PIPE, text=True)
- proc.communicate(data)
- except FileNotFoundError:
- print(f'Error: --clipboard requires the {copy[0]} and {paste[0]} utilities from {cliputil} to be installed.', file=sys.stderr)
- elif not outfile:
- print(data)
- else:
- outfile.write_text(data)
+ footprint.pads.append(kicad.make_via(xv, yv,
+ self.via_diameter, self.via_drill, self.clearance,
+ self.layer_pair))
+
+ # Place the pads on the outer radius
+ footprint.pads.extend([
+ kicad.make_pad(1, [self.layer_pair[0]], self.outer_radius, 0, self.trace_width, self.clearance),
+ kicad.make_pad(2, [self.layer_pair[1]], self.outer_radius, 0, self.trace_width, self.clearance)])
+
+ if self.keepout_zone:
+ r = self.outer_diameter/2 + self.keepout_margin
+ tol = 0.05 # mm
+ n = ceil(pi / acos(1 - tol/r))
+ pts = [(r*cos(a*2*pi/n), r*sin(a*2*pi/n)) for a in range(n)]
+ footprint.zones.append(Zone(layers=['*.Cu'],
+ hatch=Hatch(),
+ filled_areas_thickness=False,
+ keepout=ZoneKeepout(copperpour_allowed=False),
+ polygon=ZonePolygon(pts=[XYCoord(x=x, y=y) for x, y in pts])))
+
+ return footprint
-if __name__ == '__main__':
- generate()
diff --git a/src/kicoil/kicad.py b/src/kicoil/kicad.py
index 897026c..222a737 100644
--- a/src/kicoil/kicad.py
+++ b/src/kicoil/kicad.py
@@ -1,48 +1,49 @@
-from gerbonara.cad.kicad import footprints as kicad_fp
+from gerbonara.cad.kicad.pcb import Board, TrackSegment, Via
+from gerbonara.cad.kicad.footprints import Atom, AtPos, XYCoord, Pad, Line, Arc, Stroke, Drill
-def make_pad(num, layer, x, y):
- return kicad_fp.Pad(
+def make_pad(num, layer, x, y, diameter, clearance):
+ return Pad(
number=str(num),
- type=kicad_fp.Atom.smd,
- shape=kicad_fp.Atom.circle,
- at=kicad_fp.AtPos(x=x, y=y),
- size=kicad_fp.XYCoord(x=trace_width, y=trace_width),
+ type=Atom.smd,
+ shape=Atom.circle,
+ at=AtPos(x=x, y=y),
+ size=XYCoord(x=diameter, y=diameter),
layers=layer,
clearance=clearance,
zone_connect=0)
-def make_line(x1, y1, x2, y2, layer):
- return kicad_fp.Line(
- start=kicad_fp.XYCoord(x=x1, y=y1),
- end=kicad_fp.XYCoord(x=x2, y=y2),
+def make_line(x1, y1, x2, y2, trace_width, layer):
+ return Line(
+ start=XYCoord(x=x1, y=y1),
+ end=XYCoord(x=x2, y=y2),
layer=layer,
- stroke=kicad_fp.Stroke(width=trace_width))
+ stroke=Stroke(width=trace_width))
-def make_arc(x1, y1, x2, y2, xm, ym, layer):
- return kicad_fp.Arc(
- start=kicad_fp.XYCoord(x=x1, y=y1),
- mid=kicad_fp.XYCoord(x=xm, y=ym),
- end=kicad_fp.XYCoord(x=x2, y=y2),
+def make_arc(x1, y1, x2, y2, xm, ym, trace_width, layer):
+ return Arc(
+ start=XYCoord(x=x1, y=y1),
+ mid=XYCoord(x=xm, y=ym),
+ end=XYCoord(x=x2, y=y2),
layer=layer,
- stroke=kicad_fp.Stroke(width=trace_width))
+ stroke=Stroke(width=trace_width))
-def make_via(x, y, layers):
- return kicad_fp.Pad(number="NC",
- type=kicad_fp.Atom.thru_hole,
- shape=kicad_fp.Atom.circle,
- at=kicad_fp.AtPos(x=x, y=y),
- size=kicad_fp.XYCoord(x=via_diameter, y=via_diameter),
- drill=kicad_fp.Drill(diameter=via_drill),
+def make_via(x, y, diameter, drill, clearance, layers):
+ return Pad(number="NC",
+ type=Atom.thru_hole,
+ shape=Atom.circle,
+ at=AtPos(x=x, y=y),
+ size=XYCoord(x=diameter, y=diameter),
+ drill=Drill(diameter=drill),
layers=layers,
clearance=clearance,
zone_connect=0)
def footprint_to_board(footprint):
- return kicad_pcb.Board.empty_board(
+ return Board.empty_board(
zones=zones,
- track_segments=[kicad_pcb.TrackSegment.from_footprint_line(line) for line in lines],
- vias=[kicad_pcb.Via.from_pad(pad) for pad in pads if pad.type == kicad_pcb.Atom.thru_hole])
+ track_segments=[TrackSegment.from_footprint_line(line) for line in lines],
+ vias=[Via.from_pad(pad) for pad in pads if pad.type == Atom.thru_hole])
diff --git a/src/kicoil/svg.py b/src/kicoil/svg.py
index 028f705..0d54a63 100644
--- a/src/kicoil/svg.py
+++ b/src/kicoil/svg.py
@@ -1,84 +1,20 @@
-class SVGPath:
- def __init__(self, **attrs):
- self.d = ''
- self.attrs = attrs
+import textwrap
- def line(self, x, y):
- self.d += f'L {x} {y} '
+from gerbonara.utils import Tag
+from gerbonara.layers import LayerStack
- def move(self, x, y):
- self.d += f'M {x} {y} '
+def make_transparent_svg(footprint):
+ stack = LayerStack()
+ footprint.render(stack)
+ root_tag = stack.to_svg(margin=5, colors={
+ 'top copper': 'hsl(240 90% 65%)',
+ 'bottom copper': 'hsl( 0 90% 65%)',
+ 'drill pth': 'hsl(120 60% 50%)',
+ })
- def arc(self, x, y, r, large, sweep):
- self.d += f'A {r} {r} 0 {int(large)} {int(sweep)} {x} {y} '
+ root_tag.children[0].attrs['opacity'] = '98%'
+ root_tag.children[1].attrs['opacity'] = '98%'
+ root_tag.children[1].attrs['style'] = 'mix-blend-mode: multiply'
+ return root_tag
- def close(self):
- self.d += 'Z '
-
- def __str__(self):
- attrs = ' '.join(f'{key.replace("_", "-")}="{value}"' for key, value in self.attrs.items())
- return f''
-
-class SVGCircle:
- def __init__(self, r, cx, cy, **attrs):
- self.r = r
- self.cx, self.cy = cx, cy
- self.attrs = attrs
-
- def __str__(self):
- attrs = ' '.join(f'{key.replace("_", "-")}="{value}"' for key, value in self.attrs.items())
- return f''
-
-def svg_file(fn, stuff, vbw, vbh, vbx=0, vby=0):
- with open(fn, 'w') as f:
- f.write('\n')
- f.write('\n')
- f.write(f'\n')
-
-
-# This function was extruded using claude.ai.
-def plasma_colormap(value):
- """
- Calculate RGB color values according to matplotlib's plasma colormap.
-
- Args:
- value: Float in range [0, 1]
-
- Returns:
- tuple: (r, g, b) where each component is a float in range [0, 1]
- """
- # Clamp value to [0, 1]
- value = max(0.0, min(1.0, value))
-
- # Key color points sampled from matplotlib's plasma colormap
- # Format: (position, (r, g, b))
- colors = [
- (0.000, (0.050383, 0.029803, 0.527975)),
- (0.125, (0.302735, 0.009615, 0.621789)),
- (0.250, (0.489503, 0.011728, 0.656614)),
- (0.375, (0.652325, 0.120106, 0.589517)),
- (0.500, (0.789412, 0.275191, 0.472919)),
- (0.625, (0.894832, 0.446214, 0.361309)),
- (0.750, (0.965203, 0.627007, 0.262295)),
- (0.875, (0.992373, 0.811467, 0.200941)),
- (1.000, (0.940015, 0.975158, 0.131326))
- ]
-
- # Find the two color points to interpolate between
- for i in range(len(colors) - 1):
- pos1, color1 = colors[i]
- pos2, color2 = colors[i + 1]
-
- if pos1 <= value <= pos2:
- # Linear interpolation
- t = (value - pos1) / (pos2 - pos1)
- r = color1[0] + t * (color2[0] - color1[0])
- g = color1[1] + t * (color2[1] - color1[1])
- b = color1[2] + t * (color2[2] - color1[2])
- return (r, g, b)
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..a60d493
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,285 @@
+version = 1
+revision = 3
+requires-python = ">=3.13"
+
+[[package]]
+name = "aiofiles"
+version = "25.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
+]
+
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "flask"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blinker" },
+ { name = "click" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
+]
+
+[[package]]
+name = "gerbonara"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "quart" },
+ { name = "rtree" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/d3/9ca276b8294e6aac53ab4f4c4d4f693187706e03bba55e9fbdbd7c06826a/gerbonara-1.6.0.tar.gz", hash = "sha256:f5b0016ea034fa6901182db3ed3699f58453b5130eb9518c775b135da4cc1e04", size = 1069607, upload-time = "2025-12-08T12:52:46.289Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/e2/a37dd288edc2584f83ed242a68a3c4ce5ea36cbda90064a38d4c6d472c6b/gerbonara-1.6.0-py3-none-any.whl", hash = "sha256:dde93c260b881ea4777f92ded320127976487b7c04fb3276130ae5193167cc6f", size = 1089543, upload-time = "2025-12-08T12:52:44.636Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "h2"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
+]
+
+[[package]]
+name = "hpack"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
+]
+
+[[package]]
+name = "hypercorn"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+ { name = "h2" },
+ { name = "priority" },
+ { name = "wsproto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+]
+
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "kicoil"
+version = "0.9.0"
+source = { editable = "." }
+dependencies = [
+ { name = "click" },
+ { name = "gerbonara" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "click" },
+ { name = "gerbonara", specifier = ">=1.6.0" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "priority"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
+]
+
+[[package]]
+name = "quart"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiofiles" },
+ { name = "blinker" },
+ { name = "click" },
+ { name = "flask" },
+ { name = "hypercorn" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" },
+]
+
+[[package]]
+name = "rtree"
+version = "1.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/09/7302695875a019514de9a5dd17b8320e7a19d6e7bc8f85dcfb79a4ce2da3/rtree-1.4.1.tar.gz", hash = "sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46", size = 52425, upload-time = "2025-08-13T19:32:01.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/d9/108cd989a4c0954e60b3cdc86fd2826407702b5375f6dfdab2802e5fed98/rtree-1.4.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4", size = 468484, upload-time = "2025-08-13T19:31:50.593Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/cf/2710b6fd6b07ea0aef317b29f335790ba6adf06a28ac236078ed9bd8a91d/rtree-1.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d", size = 436325, upload-time = "2025-08-13T19:31:52.367Z" },
+ { url = "https://files.pythonhosted.org/packages/55/e1/4d075268a46e68db3cac51846eb6a3ab96ed481c585c5a1ad411b3c23aad/rtree-1.4.1-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65", size = 459789, upload-time = "2025-08-13T19:31:53.926Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c", size = 507644, upload-time = "2025-08-13T19:31:55.164Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/85/b8684f769a142163b52859a38a486493b05bafb4f2fb71d4f945de28ebf9/rtree-1.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967", size = 1454478, upload-time = "2025-08-13T19:31:56.808Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a4/c2292b95246b9165cc43a0c3757e80995d58bc9b43da5cb47ad6e3535213/rtree-1.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc", size = 1555140, upload-time = "2025-08-13T19:31:58.031Z" },
+ { url = "https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.whl", hash = "sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489", size = 389358, upload-time = "2025-08-13T19:31:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" },
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/45/ea/b0f8eeb287f8df9066e56e831c7824ac6bab645dd6c7a8f4b2d767944f9b/werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e", size = 864687, upload-time = "2025-11-29T02:15:22.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" },
+]
+
+[[package]]
+name = "wsproto"
+version = "1.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
+]