Compare commits

..

No commits in common. "main" and "v0.8.0" have entirely different histories.
main ... v0.8.0

22 changed files with 278 additions and 4001 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
cache

View file

@ -1,67 +0,0 @@
CXX ?= clang++
BUILDDIR ?= build
CACHEDIR ?= cache
CGAL ?= CGAL-6.1
CGAL_URL ?= https://github.com/CGAL/cgal/releases/download/v6.1/CGAL-6.1-library.tar.xz
BOOST ?= boost_1_90_0
BOOST_URL ?= https://archives.boost.io/release/1.90.0/source/boost_1_90_0.tar.gz
WASI_SDK_URL ?= https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-29/wasi-sdk-29.0-x86_64-linux.tar.gz
WASI_SDK ?= $(CACHEDIR)/wasi-sdk-29.0-x86_64-linux
SOURCES := \
skeleton_wrapper.cpp
WASI_SOURCES := \
$(SOURCES) \
exception_stubs.cpp \
INCLUDES := -I$(CACHEDIR)/$(CGAL)/include -I$(CACHEDIR)/$(BOOST)
CXXFLAGS := -std=c++20 -g -Wall -Wextra -O2 -DCGAL_ALWAYS_ROUND_TO_NEAREST
WASI_CXXFLAGS := -DCGAL_ALWAYS_ROUND_TO_NEAREST -DCGAL_CORE_USE_BOOST_BACKEND -DCGAL_DISABLE_GMP -DCGAL_USE_BOOST_MP -DFE_UPWARD=FE_TONEAREST -D_WASI_EMULATED_PROCESS_CLOCKS $(CXXFLAGS)
LDFLAGS :=
HOST_LDFLAGS := $(LDFLAGS) -lgmp -lmpfr
WASI_LDFLAGS := $(LDFLAGS) -lwasi-emulated-process-clocks
WASI_CXX ?= $(WASI_SDK)/bin/clang++
BINARY := skeleton
all: $(BUILDDIR)/$(BINARY) wasm
.PHONY: wasm
wasm: $(BUILDDIR)/$(BINARY).wasm
$(BUILDDIR)/$(BINARY): $(SOURCES)
$(CXX) $(CXXFLAGS) $(HOST_LDFLAGS) -o $@ $^
$(WASI_SDK):
mkdir -p $(dir $@)
cd $(dir $@); curl -L ${WASI_SDK_URL} | tar xzf -
$(CACHEDIR)/$(BOOST):
cd $(dir $@); curl -L ${BOOST_URL} | tar xzf -
$(CACHEDIR)/$(CGAL):
cd $(dir $@); curl -L ${CGAL_URL} | tar xJf -
$(BUILDDIR)/wasi/%.o: %.cpp $(WASI_SDK)
@mkdir -p $(dir $@)
$(WASI_CXX) -c $(WASI_CXXFLAGS) $(INCLUDES) -o $@ $<
$(BUILDDIR)/$(BINARY).wasm: $(patsubst %.cpp,$(BUILDDIR)/wasi/%.o,$(WASI_SOURCES))
@mkdir -p $(dir $@)
$(WASI_CXX) $(WASI_LDFLAGS) -o $@ $^
.PHONY: clean
clean:
rm -rf $(BUILDDIR)
.PHONY: mrproper
mrproper: clean
rm -rf $(CACHEDIR)

View file

@ -1,13 +0,0 @@
#include <stdlib.h>
extern "C" {
void* __cxa_allocate_exception(size_t) {
abort();
}
void __cxa_throw(void *, void *, void (*)(void *)) {
abort();
}
}

View file

@ -1,103 +0,0 @@
#include <CGAL/Simple_cartesian.h>
#include <CGAL/Polygon_2.h>
#include <CGAL/create_straight_skeleton_2.h>
#include <CGAL/number_utils.h>
#include <boost/multiprecision/cpp_int.hpp>
#include <boost/multiprecision/number.hpp>
#include <boost/multiprecision/rational_adaptor.hpp>
#include <iostream>
#include <sstream>
#include <string>
#include <memory>
#include <iomanip>
// Use exact rational arithmetic throughout to avoid filtered predicates
typedef boost::multiprecision::number<boost::multiprecision::rational_adaptor<boost::multiprecision::cpp_int_backend<>>> Exact_NT;
typedef CGAL::Simple_cartesian<Exact_NT> Exact_K;
// Input kernel with doubles for reading coordinates
typedef CGAL::Simple_cartesian<double> Input_K;
// Use exact kernel for computation
typedef Exact_K K;
typedef K::Point_2 Point;
typedef CGAL::Polygon_2<K> Polygon_2;
typedef CGAL::Straight_skeleton_2<K> Ss;
typedef std::shared_ptr<Ss> SsPtr;
int main()
{
Polygon_2 poly;
std::string line;
while (std::getline(std::cin, line)) {
if (line.empty()) {
continue;
}
std::istringstream iss(line);
double x, y;
if (iss >> x >> y) {
// Convert double to exact rational
poly.push_back(Point(Exact_NT(x), Exact_NT(y)));
} else {
std::cerr << "Error: Invalid input line: " << line << std::endl;
return EXIT_FAILURE;
}
}
if (poly.size() < 3) {
std::cerr << "Error: Polygon must have at least 3 vertices" << std::endl;
return EXIT_FAILURE;
}
if (!poly.is_counterclockwise_oriented()) {
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());
if (!ss) {
std::cerr << "Error: Failed to create straight skeleton" << std::endl;
return EXIT_FAILURE;
}
// Output skeleton edges
// Iterate through all halfedges in the skeleton
for (auto he = ss->halfedges_begin(); he != ss->halfedges_end(); ++he) {
// Only output each edge once (skip the opposite halfedge)
if (he->id() < he->opposite()->id()) {
continue;
}
// Get the vertices at both ends of the halfedge
auto v_source = he->vertex();
auto v_target = he->opposite()->vertex();
// Get times (distance from boundary)
// Convert from exact rational to double for output
double t1 = CGAL::to_double(v_source->time());
double t2 = CGAL::to_double(v_target->time());
// Skip contour edges (outline of the input polygon)
// Contour edges have both vertices at time 0
if (t1 == 0.0 && t2 == 0.0) {
continue;
}
// Get coordinates
double x1 = CGAL::to_double(v_source->point().x());
double y1 = CGAL::to_double(v_source->point().y());
double x2 = CGAL::to_double(v_target->point().x());
double y2 = CGAL::to_double(v_target->point().y());
// Output: start_x start_y end_x end_y start_time end_time
std::cout << std::setprecision(12) << x1 << " " << y1 << " "
<< x2 << " " << y2 << " "
<< t1 << " " << t2 << std::endl;
}
return EXIT_SUCCESS;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,406 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="12.5mm"
height="12.5mm"
viewBox="-19.823074 -22.198258 12.5 12.5"
version="1.1"
id="svg38"
sodipodi:docname="icon-dark.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
inkscape:export-filename="icon.png"
inkscape:export-xdpi="130.048"
inkscape:export-ydpi="130.048"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs38" />
<sodipodi:namedview
id="namedview38"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="6.4840427"
inkscape:cx="-1.5422477"
inkscape:cy="-5.6292041"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg38" />
<rect
style="fill:#1b111f;fill-opacity:1;stroke:none;stroke-width:1.41685;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="rect38"
width="12.5"
height="12.5"
x="-19.823074"
y="-22.198257" />
<g
id="g62"
transform="matrix(0.28523786,0,0,0.28523786,-14.168783,-15.866474)">
<path
d="m 8.5867713,5.8416089 a 8.22617,8.22617 0 0 1 -1.80878,5.3403601"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path38"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.7779913,11.181969 a 10.7975,10.7975 0 0 1 -4.68641,3.40964"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path39"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 2.0915813,14.591609 a 13.2639,13.2639 0 0 1 -8.23249,0.31"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path40"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -6.1409087,14.901609 a 15.8862,15.8862 0 0 1 -6.9229103,-4.06"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path41"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -4.4036087,5.8416089 a 8.22617,8.22617 0 0 1 -3.72051,-4.236633"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path42"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -8.1241187,1.6049759 a 10.7975,10.7975 0 0 1 -0.6096,-5.763367"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path43"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -8.7337187,-4.1583911 a 13.2639,13.2639 0 0 1 3.84776,-7.2844999"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path44"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -4.8859587,-11.442891 a 15.8862,15.8862 0 0 1 6.97754,-3.9655"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path45"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 2.0915813,-5.4083911 a 8.22617,8.22617 0 0 1 5.52928,-1.10373"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path46"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 7.6208613,-6.5121211 a 10.7975,10.7975 0 0 1 5.2960197,2.35373"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path47"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 12.916881,-4.1583911 a 13.2639,13.2639 0 0 1 4.3847,6.974543"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path48"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 17.301581,2.8161519 a 15.8862,15.8862 0 0 1 -0.0546,8.0254571"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path49"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 2.0915813,-15.408391 a 15.5807,15.5807 0 0 1 7.9867397,5.0724"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path50"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 10.078321,-10.335991 a 12.9547,12.9547 0 0 1 2.83856,6.1775999"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path51"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 12.916881,-4.1583911 a 10.4951,10.4951 0 0 1 -1.06364,6.715005"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path52"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="M 11.853241,2.5566139 A 7.92896,7.92896 0 0 1 8.5867713,5.8416089"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path53"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 17.246981,10.841609 a 15.5807,15.5807 0 0 1 -8.3861397,4.3805"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path54"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 8.8608413,15.222109 a 12.9547,12.9547 0 0 1 -6.76926,-0.6305"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path55"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 2.0915813,14.591609 a 10.4951,10.4951 0 0 1 -5.28354,-4.27866"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path56"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -3.1919587,10.312949 a 7.92896,7.92896 0 0 1 -1.21165,-4.4713401"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path57"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -13.063819,10.841609 a 15.5807,15.5807 0 0 1 0.3994,-9.4529151"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path58"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -12.664419,1.3886939 a 12.9547,12.9547 0 0 1 3.9307003,-5.547085"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path59"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -8.7337187,-4.1583911 a 10.4951,10.4951 0 0 1 6.34718,-2.43635"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path60"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -2.3865387,-6.5947411 a 7.92896,7.92896 0 0 1 4.47812,1.18635"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path61"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
</g>
<g
id="g38"
style="mix-blend-mode:multiply"
transform="matrix(0.28523786,0,0,0.28523786,-14.168783,-15.866474)">
<g
stroke-linejoin="round"
stroke-linecap="round"
id="l-bottom-copper"
transform="matrix(0,-1,-1,0,2.0915813,2.0916089)"
opacity="0.7"
style="opacity:1;fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1">
<path
d="m -3.75,-6.49519 a 8.22617,8.22617 0 0 0 -5.34036,1.80878"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path1"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -9.09036,-4.68641 A 10.7975,10.7975 0 0 0 -12.5,0"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path2"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -12.5,0 a 13.2639,13.2639 0 0 0 -0.31,8.23249"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path3"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -12.81,8.23249 a 15.8862,15.8862 0 0 0 4.06,6.92291"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path4"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -3.75,6.49519 A 8.22617,8.22617 0 0 0 0.486633,10.2157"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path5"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 0.486633,10.2157 A 10.7975,10.7975 0 0 0 6.25,10.8253"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path6"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 6.25,10.8253 A 13.2639,13.2639 0 0 0 13.5345,6.97754"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path7"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 13.5345,6.97754 A 15.8862,15.8862 0 0 0 17.5,0"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path8"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 7.5,0 A 8.22617,8.22617 0 0 0 8.60373,-5.52928"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path9"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 8.60373,-5.52928 A 10.7975,10.7975 0 0 0 6.25,-10.8253"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path10"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 6.25,-10.8253 A 13.2639,13.2639 0 0 0 -0.724543,-15.21"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path11"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -0.724543,-15.21 A 15.8862,15.8862 0 0 0 -8.75,-15.1554"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path12"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
stroke-linejoin="round"
stroke-linecap="round"
id="l-top-copper"
transform="matrix(0,-1,-1,0,2.0915813,2.0916089)"
opacity="0.7"
style="opacity:1;mix-blend-mode:color-dodge;fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none">
<path
d="M 17.5,0 A 15.5807,15.5807 0 0 0 12.4276,-7.98674"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path17"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 12.4276,-7.98674 A 12.9547,12.9547 0 0 0 6.25,-10.8253"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path18"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="m 6.25,-10.8253 a 10.4951,10.4951 0 0 0 -6.715005,1.06364"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path19"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -0.465005,-9.76166 A 7.92896,7.92896 0 0 0 -3.75,-6.49519"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path20"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="m -8.75,-15.1554 a 15.5807,15.5807 0 0 0 -4.3805,8.38614"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path21"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -13.1305,-6.76926 A 12.9547,12.9547 0 0 0 -12.5,0"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path22"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="m -12.5,0 a 10.4951,10.4951 0 0 0 4.27866,5.28354"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path23"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -8.22134,5.28354 A 7.92896,7.92896 0 0 0 -3.75,6.49519"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path24"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -8.75,15.1554 A 15.5807,15.5807 0 0 0 0.702915,14.756"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path25"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 0.702915,14.756 A 12.9547,12.9547 0 0 0 6.25,10.8253"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path26"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 6.25,10.8253 A 10.4951,10.4951 0 0 0 8.68635,4.47812"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path27"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 8.68635,4.47812 A 7.92896,7.92896 0 0 0 7.5,0"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path28"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -1,406 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="12.5mm"
height="12.5mm"
viewBox="-19.823074 -22.198258 12.5 12.5"
version="1.1"
id="svg38"
sodipodi:docname="icon-light.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
inkscape:export-filename="icon-light.png"
inkscape:export-xdpi="130.048"
inkscape:export-ydpi="130.048"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs38" />
<sodipodi:namedview
id="namedview38"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="6.4840427"
inkscape:cx="-1.5422477"
inkscape:cy="-5.6292041"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg38" />
<rect
style="fill:#f2eff2;fill-opacity:1;stroke:none;stroke-width:1.41685;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="rect38"
width="12.5"
height="12.5"
x="-19.823074"
y="-22.198257" />
<g
id="g62"
transform="matrix(0.28523786,0,0,0.28523786,-14.168783,-15.866474)">
<path
d="m 8.5867713,5.8416089 a 8.22617,8.22617 0 0 1 -1.80878,5.3403601"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path38"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.7779913,11.181969 a 10.7975,10.7975 0 0 1 -4.68641,3.40964"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path39"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 2.0915813,14.591609 a 13.2639,13.2639 0 0 1 -8.23249,0.31"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path40"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -6.1409087,14.901609 a 15.8862,15.8862 0 0 1 -6.9229103,-4.06"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path41"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -4.4036087,5.8416089 a 8.22617,8.22617 0 0 1 -3.72051,-4.236633"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path42"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -8.1241187,1.6049759 a 10.7975,10.7975 0 0 1 -0.6096,-5.763367"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path43"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -8.7337187,-4.1583911 a 13.2639,13.2639 0 0 1 3.84776,-7.2844999"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path44"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -4.8859587,-11.442891 a 15.8862,15.8862 0 0 1 6.97754,-3.9655"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path45"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 2.0915813,-5.4083911 a 8.22617,8.22617 0 0 1 5.52928,-1.10373"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path46"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 7.6208613,-6.5121211 a 10.7975,10.7975 0 0 1 5.2960197,2.35373"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path47"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 12.916881,-4.1583911 a 13.2639,13.2639 0 0 1 4.3847,6.974543"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path48"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 17.301581,2.8161519 a 15.8862,15.8862 0 0 1 -0.0546,8.0254571"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path49"
style="opacity:1;mix-blend-mode:normal;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 2.0915813,-15.408391 a 15.5807,15.5807 0 0 1 7.9867397,5.0724"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path50"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 10.078321,-10.335991 a 12.9547,12.9547 0 0 1 2.83856,6.1775999"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path51"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 12.916881,-4.1583911 a 10.4951,10.4951 0 0 1 -1.06364,6.715005"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path52"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="M 11.853241,2.5566139 A 7.92896,7.92896 0 0 1 8.5867713,5.8416089"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path53"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 17.246981,10.841609 a 15.5807,15.5807 0 0 1 -8.3861397,4.3805"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path54"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 8.8608413,15.222109 a 12.9547,12.9547 0 0 1 -6.76926,-0.6305"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path55"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m 2.0915813,14.591609 a 10.4951,10.4951 0 0 1 -5.28354,-4.27866"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path56"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -3.1919587,10.312949 a 7.92896,7.92896 0 0 1 -1.21165,-4.4713401"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path57"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -13.063819,10.841609 a 15.5807,15.5807 0 0 1 0.3994,-9.4529151"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path58"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -12.664419,1.3886939 a 12.9547,12.9547 0 0 1 3.9307003,-5.547085"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path59"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -8.7337187,-4.1583911 a 10.4951,10.4951 0 0 1 6.34718,-2.43635"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path60"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
<path
d="m -2.3865387,-6.5947411 a 7.92896,7.92896 0 0 1 4.47812,1.18635"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path61"
style="opacity:1;mix-blend-mode:lighten;fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" />
</g>
<g
id="g38"
style="mix-blend-mode:multiply"
transform="matrix(0.28523786,0,0,0.28523786,-14.168783,-15.866474)">
<g
stroke-linejoin="round"
stroke-linecap="round"
id="l-bottom-copper"
transform="matrix(0,-1,-1,0,2.0915813,2.0916089)"
opacity="0.7"
style="opacity:1;fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1">
<path
d="m -3.75,-6.49519 a 8.22617,8.22617 0 0 0 -5.34036,1.80878"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path1"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -9.09036,-4.68641 A 10.7975,10.7975 0 0 0 -12.5,0"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path2"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -12.5,0 a 13.2639,13.2639 0 0 0 -0.31,8.23249"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path3"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m -12.81,8.23249 a 15.8862,15.8862 0 0 0 4.06,6.92291"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path4"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -3.75,6.49519 A 8.22617,8.22617 0 0 0 0.486633,10.2157"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path5"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 0.486633,10.2157 A 10.7975,10.7975 0 0 0 6.25,10.8253"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path6"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 6.25,10.8253 A 13.2639,13.2639 0 0 0 13.5345,6.97754"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path7"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 13.5345,6.97754 A 15.8862,15.8862 0 0 0 17.5,0"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path8"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 7.5,0 A 8.22617,8.22617 0 0 0 8.60373,-5.52928"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path9"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 8.60373,-5.52928 A 10.7975,10.7975 0 0 0 6.25,-10.8253"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path10"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M 6.25,-10.8253 A 13.2639,13.2639 0 0 0 -0.724543,-15.21"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path11"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -0.724543,-15.21 A 15.8862,15.8862 0 0 0 -8.75,-15.1554"
fill="none"
stroke="#5c5ceb"
stroke-width="3.0"
id="path12"
style="fill:none;stroke:#0179ff;stroke-width:5;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
stroke-linejoin="round"
stroke-linecap="round"
id="l-top-copper"
transform="matrix(0,-1,-1,0,2.0915813,2.0916089)"
opacity="0.7"
style="opacity:1;mix-blend-mode:color-dodge;fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none">
<path
d="M 17.5,0 A 15.5807,15.5807 0 0 0 12.4276,-7.98674"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path17"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 12.4276,-7.98674 A 12.9547,12.9547 0 0 0 6.25,-10.8253"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path18"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="m 6.25,-10.8253 a 10.4951,10.4951 0 0 0 -6.715005,1.06364"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path19"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -0.465005,-9.76166 A 7.92896,7.92896 0 0 0 -3.75,-6.49519"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path20"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="m -8.75,-15.1554 a 15.5807,15.5807 0 0 0 -4.3805,8.38614"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path21"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -13.1305,-6.76926 A 12.9547,12.9547 0 0 0 -12.5,0"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path22"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="m -12.5,0 a 10.4951,10.4951 0 0 0 4.27866,5.28354"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path23"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -8.22134,5.28354 A 7.92896,7.92896 0 0 0 -3.75,6.49519"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path24"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M -8.75,15.1554 A 15.5807,15.5807 0 0 0 0.702915,14.756"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path25"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 0.702915,14.756 A 12.9547,12.9547 0 0 0 6.25,10.8253"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path26"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 6.25,10.8253 A 10.4951,10.4951 0 0 0 8.68635,4.47812"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path27"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
<path
d="M 8.68635,4.47812 A 7.92896,7.92896 0 0 0 7.5,0"
fill="none"
stroke="#eb5c5c"
stroke-width="3"
id="path28"
style="fill:none;stroke:#ff0000;stroke-width:5;stroke-dasharray:none" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,10 +0,0 @@
from pathlib import Path
from kipy import KiCad
from kipy.errors import ConnectionError
import kicoil.gui
if __name__ == '__main__':
kicad_inst = KiCad()
kicoil.gui.main(kicad_inst)

View file

@ -1,22 +0,0 @@
{
"$schema": "https://go.kicad.org/api/schemas/v1",
"identifier": "de.jaseg.kicoil",
"name": "KiCoil",
"description": "Planar inductor supporting spiral coils, toroidal coils, and hybrids",
"runtime": {
"type": "python",
"min_version": "3.13"
},
"actions": [
{
"identifier": "kicoil-generator",
"name": "KiCoil Planar Inductor Generator",
"description": "Generates planar spiral inductors, toroidal inductors, and hybrids thereof",
"show-button": true,
"scopes": ["pcb"],
"entrypoint": "kicoil_action.py",
"icons-light": ["icon-light.png"],
"icons-dark": ["icon-dark.png"]
}
]
}

View file

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

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python3
import re
import hashlib
import tomllib
import shutil
import subprocess
import json
@ -13,97 +12,78 @@ def tree_size(path):
return sum(entry.stat().st_size for entry in path.glob('**/*') if entry.is_file())
@click.command()
@click.option('--dry-run', is_flag=True)
def do_release(dry_run):
with open('pyproject.toml', 'rb') as f:
project_file = tomllib.load(f)
version = project_file['project']['version']
@click.option('--major', 'increment', flag_value='major')
@click.option('--minor', 'increment', flag_value='minor', default=True)
@click.option('--patch', 'increment', flag_value='patch', default=True)
@click.argument('version', required=False)
def do_release(version, increment):
if not version:
tag = subprocess.run('git describe --tags --abbrev=0 --match v*'.split(),
check=True, capture_output=True, text=True)
major, minor, patch = map(int, re.fullmatch(r'v([0-9]+)\.([0-9]+)\.([0-9]+)', tag.stdout.strip()).groups())
match increment:
case 'major':
major, minor, patch = (major+1, 0, 0)
case 'minor':
major, minor, patch = (major, minor+1, 0)
case 'patch':
major, minor, patch = (major, minor, patch+1)
version = f'{major}.{minor}.{patch}'
if not dry_run:
res = subprocess.run('git status --porcelain --untracked-files=no'.split(),
check=True, capture_output=True, text=True)
if res.stdout.strip():
raise click.ClickException('There are uncommitted changes in this repository.')
res = subprocess.run('git status --porcelain --untracked-files=no'.split(),
check=True, capture_output=True, text=True)
if res.stdout.strip():
raise click.ClickException('There are uncommitted changes in this repository.')
project_root = Path(__file__).parent
print('Cleaning old footprints')
footprint_dir = Path('de.jaseg.kimesh.footprints') / 'footprints'
shutil.rmtree(footprint_dir, ignore_errors=True)
footprint_dir.mkdir()
print('Re-generating footprints')
for n in range(1, 9):
subprocess.run(['python', '-m', 'footprint_generator',
'-w', '0.100,0.120,0.150,0.200,0.250,0.300,0.350,0.400,0.500,0.600,0.700,0.800,1.000,1.200,1.500,1.800',
'-c', '0.100,0.120,0.150,0.200,0.300,0.400,0.500',
'-n', str(n),
str(footprint_dir / f'kimesh_anchors_{n}wire.pretty')
], check=True)
res = subprocess.run('git ls-files'.split(), check=True, capture_output=True, text=True)
for path in res.stdout.splitlines():
if re.fullmatch(r'de\.jaseg\.kicoil\.[^/]*-v[.0-9]*\.zip', path.strip()):
if re.fullmatch(r'de\.jaseg\.kimesh\.[^/]*-v[.0-9]*\.zip', path.strip()):
print(f'Removing old release zip {path} from git index.')
subprocess.run(['git', 'rm', path], check=True, capture_output=True)
plugin_sources = project_root / 'kicad-plugin'
pkg_dir = project_root / 'de.jaseg.kicoil'
plugin_dir = pkg_dir / 'plugins'
for pkg_dir in Path('de.jaseg.kimesh.plugin'), Path('de.jaseg.kimesh.footprints'):
# NOTE: metadata.json appears twice. In what I believe is a sub-optimal design choice, the variant in the
# archive is only allowed to contain the current version in its version list without its zip file metadata,
# while the variant in the repository index is supposed to contain all past versions including their zip file
# metadata. AFAICT they are the same otherwise.
meta_path = Path(f'{pkg_dir}-repo-metadata.json')
if pkg_dir.is_dir():
shutil.rmtree(pkg_dir)
pkg_dir.mkdir()
print(f'Updating metadata file {meta_path}')
ver_dict = {
'version': version,
'status': 'stable',
'kicad_version': '7.99',
}
shutil.copytree(plugin_sources, plugin_dir)
shutil.copy(project_root / 'LICENSE', plugin_dir)
# Include just the version metadata in the metadata for the archive
meta_file = json.loads(meta_path.read_text())
meta_file['versions'] = [ver_dict]
(pkg_dir / 'metadata.json').write_text(json.dumps(meta_file, indent=4))
meta_path = project_root / 'metadata.json'
print(f'Updating metadata file {meta_path}')
ver_dict = {
'version': version,
'status': 'stable',
'kicad_version': '9.00',
}
meta_file = json.loads(meta_path.read_text())
meta_file['versions'] = [ver_dict]
(pkg_dir / 'metadata.json').write_text(json.dumps(meta_file, indent=4))
res = subprocess.run(['uv', 'export', '--no-hashes', '--no-emit-project', '--format', 'requirements.txt', '--group', 'gui'],
check=True, capture_output=True, text=True)
(plugin_dir / 'requirements.txt').write_text(res.stdout)
(pkg_dir / 'resources').mkdir()
shutil.copy(plugin_sources / 'icon-light.png', pkg_dir / 'resources' / 'icon.png')
module_sources = project_root / 'src'
for root, dirs, files in module_sources.walk(top_down=True):
if root.name == '__pycache__':
continue
for path in dirs:
if path == '__pycache__':
continue
path = root / path
subdir = plugin_dir / path.relative_to(module_sources)
subdir.mkdir()
for path in files:
path = root / path
out_path = plugin_dir / path.relative_to(module_sources)
if path.name == '__init__.py':
content = path.read_text()
lines = content.splitlines()
lines_out = []
for line in lines:
if line.startswith('__version__ = version('):
line = f'__version__ = {version!r}'
lines_out.append(line)
content = '\n'.join(lines_out).encode('utf-8')
else:
content = path.read_bytes()
out_path.write_bytes(content)
zip_fn = Path(shutil.make_archive(f'{pkg_dir.name}-v{version}', 'zip', pkg_dir, '.'))
if not dry_run:
zip_fn = Path(shutil.make_archive(f'{pkg_dir.name}-v{version}', 'zip', pkg_dir, '.'))
print(f'Adding new release zip {zip_fn} to git index.')
subprocess.run(['git', 'add', str(zip_fn)], check=True, capture_output=True)
# Add the zip's metadata to the metadata for the repository
ver_dict['download_sha256'] = hashlib.sha256(zip_fn.read_bytes()).hexdigest()
ver_dict['download_size'] = zip_fn.stat().st_size
ver_dict['download_url'] = f'https://git.jaseg.de/kimesh.git/plain/{zip_fn.name}?h=v{version}'
ver_dict['install_size'] = tree_size(pkg_dir)
# Add the zip's metadata to the metadata for the repository
ver_dict['download_sha256'] = hashlib.sha256(zip_fn.read_bytes()).hexdigest()
ver_dict['download_size'] = zip_fn.stat().st_size
ver_dict['download_url'] = f'https://git.jaseg.de/kimesh.git/plain/{zip_fn.name}?h=v{version}'
ver_dict['install_size'] = tree_size(pkg_dir)
if not dry_run:
meta_file = json.loads(meta_path.read_text())
meta_file['versions'].append(ver_dict)
meta_path.write_text(json.dumps(meta_file, indent=4))
@ -111,17 +91,16 @@ def do_release(dry_run):
print(f'Adding updated metadata file {meta_path} to git index')
subprocess.run(['git', 'add', str(meta_path)], check=True, capture_output=True)
if not dry_run:
print('Create git commit')
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)
print(f'Created commit {res.stdout.strip()}')
print(f'Creating and signing version tag v{version}')
subprocess.run(['git',
'-c', 'user.signingkey=E36F75307F0A0EC2D145FF5CED7A208EEEC76F2D',
'-c', 'user.email=python-mpv@jaseg.de',
'tag', '-s', f'v{version}', '-m', f'Version v{version}'],
check=True)
print('Create git commit')
subprocess.run(['git', 'commit', '-m', f'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)
print(f'Created commit {res.stdout.strip()}')
print(f'Creating and signing version tag v{version}')
subprocess.run(['git',
'-c', 'user.signingkey=E36F75307F0A0EC2D145FF5CED7A208EEEC76F2D',
'-c', 'user.email=python-mpv@jaseg.de',
'tag', '-s', f'v{version}', '-m', f'Version v{version}'],
check=True)
if __name__ == '__main__':
do_release()

View file

@ -1,20 +1,11 @@
[project]
name = "kicoil"
version = "0.11.1"
version = "0.9.0"
description = "Planar Inductor Generator"
readme = "README.rst"
license = "Apache-2.0"
requires-python = ">=3.13"
dependencies = [
"beautifulsoup4>=4.14.3",
"click",
"gerbonara>=1.6.0",
"kicad-python>=0.5.0",
"lxml>=6.0.2",
"platformdirs>=4.5.1",
"py-straight-skeleton>=0.1.0",
"wasmtime>=39.0.0",
]
dependencies = ["click", "gerbonara>=1.6.0"]
authors = [{ name = "jaseg" }]
maintainers = [
{ name = "Kicoil maintainers", email = "kicoil@jaseg.de" },
@ -46,12 +37,7 @@ kicoil = "kicoil.cli:cli"
kicoil-gui = "kicoil.gui:main"
[dependency-groups]
dev = [
"ipykernel>=7.1.0",
"matplotlib>=3.10.8",
]
gui = ["cairosvg", "pillow"]
gds = ["gdstk"]
[build-system]
requires = ["uv-build"]

View file

@ -17,19 +17,15 @@ import logging
import subprocess
import webbrowser
import tempfile
import json
import os
import sys
from pathlib import Path
from collections import defaultdict
import warnings
import math
import click
from gerbonara.layers import LayerStack
from gerbonara.cad.kicad.primitives import kicad_mid_to_center_arc
from .geometry import PlanarInductor, divisors, CircleShape, SectorShape, StarShape, SVGShape, RectangleShape, RegularPolygonShape
from .geometry import PlanarInductor, divisors
from .kicad import footprint_to_board
from .svg import make_transparent_svg
@ -45,300 +41,102 @@ def print_valid_twists(ctx, param, value):
ctx.exit()
def circle_center_to_tangents(center, a, b):
""" Given two points on a circle and the center of the circle, calculate the intersection of two tangents at the two points """
cx, cy = center
ax, ay = a
bx, by = b
dax = ax - cx
day = ay - cy
dbx = bx - cx
dby = by - cy
v = dax*ax + day*ay
w = dbx*bx + dby*by
det = dax*dby - day*dbx
ix = (v*dby - day*w) / det
iy = (dax*w - v*dbx) / det
return ix, iy
@click.group()
@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('--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('--clockwise/--counter-clockwise', help='Direction of generated top layer 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.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('--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('--clearance', 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('--approximate-arcs/--no-approximate-arcs', default=True, help='Use circular arcs to smoothen output shape (default: on)')
@click.option('--format', type=click.Choice(['svg', 'gerber', 'kicad-footprint', 'kicad-pcb', 'json', 'gdsii', 'oasis', 'show']), default='kicad-footprint')
@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('--footprint-name', help="Name for the generated footprint. Default: Output file name sans extension.")
@click.option('--cell-name', help="Name for the generated cell when exporting GDSII. Default: Output file name sans extension.")
@click.option('--layer-pair', default='F.Cu,B.Cu', help="Target KiCad layer pair for the generated footprint, comma-separated. Default: F.Cu/B.Cu.")
@click.option('--geometry-debug-file', type=click.Path(writable=True), help='Render geometry debug information to a PDF file with the given name')
@click.option('--clockwise/--counter-clockwise', help='Direction of generated top layer 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()
@click.pass_context
def cli(ctx, footprint_name, cell_name, clipboard, single_layer, arc_tolerance, circle_segments, format, geometry_debug_file, **kwargs):
ctx.ensure_object(dict)
def cli(outfile, footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, **kwargs):
logger = logging.getLogger('kicoil')
logger.setLevel(logging.INFO)
def write(shape, outfile):
nonlocal footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, cell_name, geometry_debug_file
logger = logging.getLogger('kicoil')
if single_layer:
kwargs['layers'] = 1
else:
kwargs['layers'] = 2
if single_layer:
kwargs['layers'] = 1
else:
kwargs['layers'] = 2
try:
model = PlanarInductor(**kwargs)
try:
model = PlanarInductor(shape=shape, **kwargs)
if footprint_name is None and outfile:
footprint_name = outfile.stem
if footprint_name is None and outfile:
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:
raise click.ClickException(*e.args)
except ValueError as e:
#raise click.ClickException(*e.args)
raise
data = None
if format == 'kicad-footprint':
data = footprint.serialize()
data = None
if format == 'kicad-footprint':
data = footprint.serialize()
elif format == 'kicad-pcb':
data = footprint_to_board(footprint).serialize()
elif format == 'kicad-pcb':
data = footprint_to_board(footprint).serialize()
elif format == 'gerber':
stack = LayerStack()
footprint.render(stack)
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 == 'json':
lines = defaultdict(lambda: [])
for l in footprint.lines:
lines[l.layer].append({
'x1': l.start.x,
'y1': l.start.y,
'x2': l.end.x,
'y2': l.end.y})
arcs = defaultdict(lambda: [])
for a in footprint.arcs:
center, r, direction = kicad_mid_to_center_arc(a.mid, a.start, a.end)
arcs[a.layer].append({
'x1': a.start.x,
'y1': a.start.y,
'x2': a.end.x,
'y2': a.end.y,
'cx': center[0],
'cy': center[1],
})
vias = [{
'x': p.at.x,
'y': p.at.y,
'pad': p.size.x,
'drill': p.drill.diameter,
} for p in footprint.pads if p.number == 'NC']
pads = [{
'x': p.at.x,
'y': p.at.y,
'pad': p.size.x,
} for p in footprint.pads if p.number != 'NC']
d = {
'lines': dict(lines),
'arcs': dict(arcs),
'vias': vias,
'pads': pads
}
data = json.dumps(d, indent=4)
elif format in ('gdsii', 'oasis'):
import gdstk
DRILL_LAYER = 2
lib = gdstk.Library()
if cell_name is None:
if outfile:
cell_name = outfile.stem
else:
cell_name = f'planar_coil'
cell = lib.new_cell(cell_name)
for line in footprint.lines:
layer = model.layer_pair.index(line.layer)
path = gdstk.FlexPath([(line.start.x, line.start.y), (line.end.x, line.end.y)], line.stroke.width, ends=['round'], layer=layer)
cell.add(path)
for arc in footprint.arcs:
layer = model.layer_pair.index(arc.layer)
center, r, _direction = kicad_mid_to_center_arc(arc.mid, arc.start, arc.end)
proj_x, proj_y = circle_center_to_tangents(center, tuple(arc.start), tuple(arc.end))
# multiply r with 0.99 to make sure gdstk's interpolation routine catches on since the arc endpoints are calculated exactly
path = gdstk.FlexPath([(arc.start.x, arc.start.y), (proj_x, proj_y), (arc.end.x, arc.end.y)], arc.stroke.width, bend_radius=r*0.99, ends=['round'], layer=layer)
cell.add(path)
for pad in footprint.pads:
for layer in pad.layers:
layer = model.layer_pair.index(layer)
layer_obj = gdstk.ellipse(tuple(pad.at), pad.size.x/2, layer=layer)
cell.add(layer_obj)
if pad.drill:
drill = gdstk.ellipse(tuple(pad.at), pad.drill.diameter/2, layer=DRILL_LAYER)
cell.add(drill)
if clipboard or not outfile:
raise click.ClickException('outfile is required for GDSII or OASIS export')
if format == 'gdsii':
lib.write_gds(outfile)
else:
lib.write_oas(outfile)
if not clipboard and outfile and outfile.suffix.lower() != '.zip':
stack.save_to_directory(outfile)
return
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)
ctx.obj['write'] = write
with tempfile.NamedTemporaryFile(delete_on_close=False) as f:
f = Path(f.name)
stack.save_to_zipfile(f)
data = f.read_bytes()
@cli.command()
@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.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
@click.pass_context
def circle(ctx, inner_diameter, outer_diameter, outfile):
shape = CircleShape(outer_diameter, inner_diameter)
ctx.obj['write'](shape, outfile)
elif format in ('svg', 'show'):
data = str(make_transparent_svg(footprint))
@cli.command()
@click.option('--width', type=float, default=50, help='Base width [mm]')
@click.option('--height', type=float, default=40, help='Shape height [mm]')
@click.option('--offset', type=float, default=10, help='Offset of each corner at the shorter edge compared to the longer edge [mm]')
@click.option('--annular-width', type=float, default=10, help='Width of the trace area on the outside of the shape [mm]')
@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
@click.pass_context
def trapezoid(ctx, outfile, **kwargs):
shape = TrapezoidShape(**kwargs)
ctx.obj['write'](shape, outfile)
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'
@cli.command()
@click.option('--width', type=float, default=50, help='Width [mm]')
@click.option('--height', type=float, default=40, help='Height [mm]')
@click.option('--annular-width', type=float, default=10, help='Width of the trace area on the outside of the shape [mm]')
@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
@click.pass_context
def rectangle(ctx, outfile, **kwargs):
shape = RectangleShape(**kwargs)
ctx.obj['write'](shape, outfile)
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)
@cli.command()
@click.option('--diameter', type=float, default=50, help='Width [mm]')
@click.option('-n', '--corners', type=int, default=8, help='Number of corners')
@click.option('--annular-width', type=float, default=10, help='Width of the trace area on the outside of the shape [mm]')
@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
@click.pass_context
def regular_polygon(ctx, outfile, **kwargs):
shape = RegularPolygonShape(**kwargs)
ctx.obj['write'](shape, outfile)
elif not outfile:
if isinstance(data, str):
print(data)
else:
sys.stdout.buffer.write(data)
else:
outfile.write_text(data)
@cli.command()
@click.option('--inner-diameter', type=float, default=25, help='Inner diameter [mm]')
@click.option('--outer-diameter', type=float, default=50, help='Outer diameter [mm]')
@click.option('--angle', type=float, default=45, help='Sector angle [deg]')
@click.option('--arc-tolerance', type=float, default=0.05, help='Tolerance for splitting arc into straight segments [mm] (default: 0.05 mm)')
@click.option('--annular-width', type=float, default=5, help='Width of the trace area on the outside of the shape [mm]')
@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
@click.pass_context
def sector(ctx, outfile, angle, **kwargs):
angle = math.radians(angle)
shape = SectorShape(angle=angle, **kwargs)
ctx.obj['write'](shape, outfile)
@cli.command()
@click.option('--inner-diameter', type=float, default=25, help='Inner diameter [mm]')
@click.option('--outer-diameter', type=float, default=50, help='Outer diameter [mm]')
@click.option('--points', type=int, default=5, help='Number of points')
@click.option('--arc-tolerance', type=float, default=0.05, help='Tolerance for splitting arc into straight segments [mm] (default: 0.05 mm)')
@click.option('--annular-width', type=float, default=5, help='Width of the trace area on the outside of the shape [mm]')
@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
@click.pass_context
def star(ctx, outfile, **kwargs):
shape = StarShape(**kwargs)
ctx.obj['write'](shape, outfile)
@cli.command()
@click.option('--arc-tolerance', type=float, default=0.05, help='Tolerance for splitting arc into straight segments [mm] (default: 0.05 mm)')
@click.option('--annular-width', type=float, default=5, help='Width of the trace area on the outside of the shape [mm]')
@click.argument('svg_file', required=False, type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument('outfile', required=False, type=click.Path(writable=True, dir_okay=False, path_type=Path))
@click.pass_context
def svg(ctx, svg_file, outfile, **kwargs):
shape = SVGShape(svg_file, **kwargs)
ctx.obj['write'](shape, outfile)

View file

@ -23,7 +23,7 @@ from math import *
from gerbonara.cad.kicad.footprints import Footprint
from gerbonara.cad.kicad.primitives import Zone, Hatch, ZoneKeepout, ZonePolygon, XYCoord
from . import kicad, skeletonator
from . import kicad
mu_0 = 1.25663706127e-06 # from scipy.constants
@ -67,7 +67,7 @@ def arc_approximate(points, trace_width, layer, tolerance=0.02, level=0):
gerbonara arc objects approximating the input. """
indent = ' ' * level
if len(points) < 3:
raise ValueError('Need at least three points to approximate')
raise ValueError()
i_mid = len(points)//2
@ -94,282 +94,29 @@ def arc_approximate(points, trace_width, layer, tolerance=0.02, level=0):
yield kicad.make_arc(x2, y2, x0, y0, x1, y1, trace_width, layer)
def polygon_is_clockwise(coords):
# https://en.wikipedia.org/wiki/Curve_orientation
xb, yb, i = min([(x, y, i) for i, (x, y) in enumerate(coords)])
xa, ya = coords[(i-1) % len(coords)]
xc, yc = coords[(i+1) % len(coords)]
det = (xa*yb + xb*yc + xc*ya) - (xa*yc + xb * ya + xc * yb)
return det < 0
def compute_spiral(r1, r2, a1, a2, fn=64):
fn = ceil(fn * abs(a2-a1)/(2*pi))
x0, y0 = cos(a1)*r1, sin(a1)*r1
dr = 3 if r2 < r1 else -3
xn, yn = x0, y0
points = [(x0, y0)]
dists = []
for i in range(fn):
xp, yp = xn, yn
r = r1 + (i+1)*(r2-r1)/fn
a = a1 + (i+1)*(a2-a1)/fn
xn, yn = cos(a)*r, sin(a)*r
points.append((xn, yn))
dists.append(dist((xp, yp), (xn, yn)))
class Shape:
pass
return points, sum(dists)
@dataclass
class CircleShape(Shape):
outer_diameter: float
inner_diameter: float
def __post_init__(self):
self.outer_radius = self.outer_diameter / 2
self.inner_radius = self.inner_diameter / 2
@property
def slug(self):
return 'circle_{self.outer_diameter:.2f}x{self.inner_diameter:.2f}'
@property
def desc(self):
return f'{self.outer_diameter:.2f} mm OD, {self.inner_diameter:.2f} mm ID circular'
def compute_spiral(self, a1, a2, fn=64, debug=False):
r1, r2 = self.outer_radius, self.inner_radius
fn = ceil(fn * abs(a2-a1)/(2*pi))
x0, y0 = cos(a1)*r1, sin(a1)*r1
dr = 3 if r2 < r1 else -3
xn, yn = x0, y0
points = [(x0, y0)]
dists = []
for i in range(fn):
xp, yp = xn, yn
r = r1 + (i+1)*(r2-r1)/fn
a = a1 + (i+1)*(a2-a1)/fn
xn, yn = cos(a)*r, sin(a)*r
points.append((xn, yn))
dists.append(dist((xp, yp), (xn, yn)))
return points, sum(dists), [None]*len(points)
def project_point(self, r, a, r_ref=None):
return cos(a) * r, sin(a) * r
def offset_exterior(self, margin):
r = self.outer_radius + margin
tol = 0.05 # mm
n = ceil(pi / acos(1 - tol/r))
return [(r*cos(a*2*pi/n), r*sin(a*2*pi/n)) for a in range(n)]
class OffsetShape(Shape):
def __post_init__(self):
self.sk = skeletonator.Skeletonator(self.polygon)
if self.annular_width > self.sk.min_radius:
raise ValueError(f'Annular width ({self.annular_width:.2f}) is too large. Must be less than {self.sk.min_radius:.2f}')
self.outer_radius = self.sk.radius
self.inner_radius = self.sk.radius - self.annular_width
@property
def slug(self):
return 'polygonal_{len(self.polygon)}pt_r{self.radius:.2f}mm'
@property
def desc(self):
return f'polygonal (n={len(self.polygon)} point, r={self.radius:.2f} mm radius)'
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.
points = []
angle_refs = []
for point, angle_ref in self.sk.do_spiral(a1/(2*pi), a2/(2*pi), self.outer_radius, self.inner_radius, debug=debug):
points.append(point)
angle_refs.append(angle_ref)
if a2 < a1:
points = points[::-1]
angle_refs = angle_refs[::-1]
arm_length = sum(dist(p1, p2) for p1, p2 in zip(points, points[1:]))
return points, arm_length, angle_refs
def project_point(self, r, a, r_ref=None):
# Skeletonator uses a t coordinate from 0 - 1 per revolution instead of a radian angle.
return self.sk.project_point(a/(2*pi) % 1, r, r_ref=r_ref)
def offset_exterior(self, margin):
return self.sk.do_spiral(0, 1, self.outer_radius + margin, self.outer_radius + margin)
@dataclass
class TrapezoidShape(OffsetShape):
width: float
height: float
offset: float
annular_width: float
arc_tolerance: float = 0.05 # mm
polygon: list = field(init=False)
def __post_init__(self):
w, h, d = self.width, self.height, self.offset
self.polygon = [(w/2-d, -h/2), (w/2, h/2), (-w/2, h/2), (-w/2+d, -h/2)]
super().__post_init__()
@property
def slug(self):
return f'trapezoid_{self.width:.2f}-2*{self.offset:.2f}x{self.height:.2f}'
@property
def desc(self):
return f'{self.width:.2f} x {self.height:.2f} mm, {self.offset:.2f} mm offset isosceles trapezoidal'
@dataclass
class RectangleShape(OffsetShape):
width: float
height: float
annular_width: float
arc_tolerance: float = 0.05 # mm
polygon: list = field(init=False)
def __post_init__(self):
w, h = self.width, self.height
self.polygon = [(w/2, -h/2), (w/2, h/2), (-w/2, h/2), (-w/2, -h/2)]
super().__post_init__()
@property
def slug(self):
return f'rectangle_{self.width:.2f}x{self.height:.2f}'
@property
def desc(self):
return f'{self.width:.2f} x {self.height:.2f} mm rectangle'
@dataclass
class SectorShape(OffsetShape):
inner_diameter: float
outer_diameter: float
angle: float
annular_width: float
arc_tolerance: float = 0.05 # mm
polygon: list = field(init=False)
def __post_init__(self):
# Careful: The inner/outer radius properties are relative to the polygon center and are very different from these!
r1, r2 = self.inner_diameter / 2, self.outer_diameter/2
n1 = ceil(pi / acos(1 - self.arc_tolerance/self.inner_diameter) * self.angle / (2*pi))
n2 = ceil(pi / acos(1 - self.arc_tolerance/self.outer_diameter) * self.angle / (2*pi))
# center on y axis
pt = lambda r, a: (r*sin(a), r*cos(a))
self.polygon = [pt(r2, self.angle/2 - i/n1 * self.angle) for i in range(n1+1)]
self.polygon += [pt(r1, i/n1 * self.angle - self.angle/2) for i in range(n1+1)]
super().__post_init__()
@property
def slug(self):
return f'sector_{self.outer_diameter:.2f}x{self.inner_diameter:.2f}_{degrees(self.angle):.0f}deg'
@property
def desc(self):
return f'{self.outer_diameter:.2f} x {self.inner_diameter:.2f} mm {degrees(self.angle):.0f} deg sector'
@dataclass
class StarShape(OffsetShape):
inner_diameter: float
outer_diameter: float
annular_width: float
points: int = 5
arc_tolerance: float = 0.05 # mm
polygon: list = field(init=False)
def __post_init__(self):
# center on y axis
pt = lambda r, a: (-r*sin(a), r*cos(a))
circle = lambda r, n, phase: [pt(r, (i + phase)*2*pi/n) for i in range(n)]
self.polygon = [x for pair in zip(circle(self.outer_diameter/2, self.points, 0), circle(self.inner_diameter/2, self.points, 0.5)) for x in pair]
super().__post_init__()
@property
def slug(self):
return f'star_{self.outer_diameter:.2f}x{self.inner_diameter:.2f}'
@property
def desc(self):
purpose = ', for demonic purposes' if self.points == 5 else ''
return f'{self.outer_diameter:.2f} x {self.inner_diameter:.2f} mm star shape{purpose}'
@dataclass
class RegularPolygonShape(OffsetShape):
diameter: float
annular_width: float
corners: int = 8
arc_tolerance: float = 0.05 # mm
polygon: list = field(init=False)
def __post_init__(self):
# center on y axis
pt = lambda r, a: (-r*sin(a), r*cos(a))
circle = lambda r, n, phase: [pt(r, (i + phase)*2*pi/n) for i in range(n)]
self.polygon = list(circle(self.diameter/2, self.corners, 0))
print(self.polygon)
super().__post_init__()
@property
def slug(self):
return f'regular_{self.corners}gon_{self.diameter:.2f}'
@property
def desc(self):
return f'{self.diameter:.2f} mm diameter {self.corners} corner regular polygon'
@dataclass
class SVGShape(OffsetShape):
filename: str
annular_width: float
arc_tolerance: float = 0.05 # mm
polygon: list = field(init=False)
def __post_init__(self):
# center on y axis
from bs4 import BeautifulSoup
with open(self.filename) as f:
soup = BeautifulSoup(f.read(), features='xml')
path = soup.find('path', recursive=True)
d = path.attrs['d']
d = d.strip('MmZ ').replace(',', 'L')
coord_pairs = d.split('L')
coords = [tuple(map(float, pair.split())) for pair in coord_pairs]
if polygon_is_clockwise(coords):
coords = coords[::-1]
# Calculate bounding box
min_x = min(x for x, _y in coords)
min_y = max(x for x, _y in coords)
max_x = min(y for _x, y in coords)
max_y = max(y for _x, y in coords)
if max_x < 0 or max_y < 0 or min_x > 0 or min_y > 0:
# (0, 0) is not within the polygon's axis-aligned bounding box, recenter.
ox, oy = skeletonator.polygon_center_of_mass(coords)
warnings.warn(f'Polygon looks not centered, bounds are ({min_x:.2f}, {min_y:.2f}), ({max_x:.2f}, {max_y:.2f}). Aligning (0, 0) with polygon centroid at ({ox:.2f}, {oy:.2f})')
coords = [(x-ox, y-oy) for x, y in coords]
self.polygon = coords
super().__post_init__()
@property
def slug(self):
return f'svg_{len(self.polygon)}n'
@property
def desc(self):
return f'{len(self.polygon)} node imported SVG shape'
@dataclass
class PlanarInductor():
shape: Shape
outer_diameter: float
inner_diameter: float
turns: int
twists: int
trace_width: float = None
@ -385,10 +132,11 @@ class PlanarInductor():
copper_thickness: float = 0.035
layer_pair: str = 'F.Cu,B.Cu'
clockwise: bool = False
approximate_arcs: bool = True
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
@ -396,13 +144,13 @@ class PlanarInductor():
self.sector_angle *= -1
self.sweeping_angle = self.sector_angle * self.turns_per_layer
self.spiral_pitch = (self.shape.outer_radius-self.shape.inner_radius) / self.turns_per_layer
self.spiral_pitch = (self.outer_radius-self.inner_radius) / self.turns_per_layer
self.R = None # will be calculated during render
c1 = self.shape.inner_radius
c2 = self.shape.inner_radius + self.spiral_pitch
alpha1 = atan((self.shape.outer_radius - self.shape.inner_radius) / self.sweeping_angle / c1)
alpha2 = atan((self.shape.outer_radius - self.shape.inner_radius) / self.sweeping_angle / c2)
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)
@ -431,22 +179,22 @@ class PlanarInductor():
if self.trace_width is None:
if round(self.clearance, 3) > round(self.projected_spiral_pitch, 3):
warnings.warn(f'Error: Given clearance of {clearance:.2f} mm is larger than the projected spiral pitch of {projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
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.')
elif self.clearance is None:
if round(self.trace_width, 2) > round(self.projected_spiral_pitch, 2):
warnings.warn(f'Error: Given trace width of {self.trace_width:.2f} mm is larger than the projected spiral pitch of {self.projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
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:
if round(self.trace_width, 2) > round(self.projected_spiral_pitch, 2):
raise ValueError(f'Given trace width of {self.trace_width:.2f} mm is larger than the projected spiral pitch of {self.projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
raise click.ClickException(f'Error: Given trace width of {self.trace_width:.2f} mm is larger than the projected spiral pitch of {self.projected_spiral_pitch:.2f} mm. Reduce clearance or increase the size of the coil.')
clearance_actual = self.projected_spiral_pitch - self.trace_width
if round(clearance_actual, 3) < round(self.clearance, 3):
raise ValueError(f'Actual clearance for {self.trace_width:.2f} mm trace is {clearance_actual:.2f} mm, which is lower than the given clearance of {self.clearance:.2f} mm.')
raise click.ClickException(f'Error: Actual clearance for {self.trace_width:.2f} mm trace is {clearance_actual:.2f} mm, which is lower than the given clearance of {self.clearance:.2f} mm.')
if round(self.via_diameter, 2) < round(self.trace_width, 2):
self.logger.warning(f'Clipping via diameter from {self.via_diameter:.2f} mm to trace width of {self.trace_width:.2f} mm.')
@ -463,15 +211,15 @@ class PlanarInductor():
if isclose(self.via_offset, 0, abs_tol=1e-6):
self.via_offset = 0
self.inner_via_ring_radius = self.shape.inner_radius - self.via_offset
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)
self.outer_via_ring_radius = self.shape.outer_radius + self.via_offset
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.shape.inner_radius:.2f} mm)')
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.shape.outer_radius:.2f} mm)')
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
@ -483,17 +231,19 @@ class PlanarInductor():
self.layer_pair = (t.strip(), b.strip())
# For fill factor & inductance formulas, See https://coil32.net/pcb-coil.html for details
d_avg = (2*self.shape.outer_radius + self.shape.inner_radius)/2
phi = (2*self.shape.outer_radius - 2*self.shape.inner_radius) / (2*self.shape.outer_radius + 2*self.shape.inner_radius)
c1, c2, c3, c4 = 1.00, 2.46, 0.00, 0.20 # FIXME for other shapes
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: {2*self.shape.outer_radius:g} mm')
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: {2*self.shape.inner_radius: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, _angle_refs = self.shape.compute_spiral(a1=0, a2=self.sector_angle)
_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')
@ -503,11 +253,12 @@ class PlanarInductor():
self.R = self.track_length/1e3 * rho / A
self.logger.info(f'Approximate resistance: {self.R:g} Ω')
@property
def default_footprint_name(self):
return f'planar-coil-{self.shape.slug}-n{self.turns}-k{self.twists}'
return f'planar-coil-{self.outer_diameter:.2f}x{self.inner_diameter:.2f}-n{self.turns}-k{self.twists}'
def render_footprint(self, name=None, arc_tolerance=0.02, circle_segments=64, geometry_debug_file=None):
def render_footprint(self, name=None, arc_tolerance=0.02, circle_segments=64):
if name is None:
name = self.default_footprint_name
@ -517,7 +268,7 @@ class PlanarInductor():
generator=kicad.Atom('kicoil'),
generator_version=__version__,
layer='F.Cu',
descr=f"{self.turns} turn {self.shape.desc} twisted coil footprint, inductance approximately {self.L:.6f} µH. Generated by kicoil, version {__version__}.",
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)
@ -527,7 +278,6 @@ class PlanarInductor():
for i in range(self.twists):
inverse[i*self.turns%self.twists] = i
arms_layers = [[], []]
# Array where we collect all gerbonara kicad line and arc objects
for i in range(self.twists):
start_angle = i*self.sector_angle
@ -535,57 +285,40 @@ class PlanarInductor():
end_angle = fold_angle + self.sweeping_angle
# Handle the spiral arm
points_layer0, arm_length, angle_refs_layer0 = self.shape.compute_spiral(a1=start_angle, a2=fold_angle, fn=circle_segments, debug=True)
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 angle_refs_layer0:
ref_0, ref_n = angle_refs_layer0[0], angle_refs_layer0[-1]
else:
ref_0, ref_n = None, None
if self.approximate_arcs and isinstance(self.shape, CircleShape):
footprint.arcs.extend(arc_approximate(points_layer0, self.trace_width, self.layer_pair[0], arc_tolerance))
else:
footprint.lines.extend(kicad.make_line(*p1, *p2, self.trace_width, self.layer_pair[0]) for p1, p2 in zip(points_layer0, points_layer0[1:]))
footprint.arcs.extend(arc_approximate(points_layer0, self.trace_width, self.layer_pair[0], arc_tolerance))
if self.layers > 1:
# Handle the returning arm on the bottom layer
points_layer1, _, angle_refs_layer1 = self.shape.compute_spiral(a1=end_angle, a2=fold_angle, fn=circle_segments, debug=True)
points_layer1 = points_layer1[::-1]
if self.approximate_arcs and isinstance(self.shape, CircleShape):
footprint.arcs.extend(arc_approximate(points_layer1, self.trace_width, self.layer_pair[1], arc_tolerance))
else:
footprint.lines.extend(kicad.make_line(*p1, *p2, self.trace_width, self.layer_pair[1]) for p1, p2 in zip(points_layer1, points_layer1[1:]))
points_layer1, _ = compute_spiral(r1=self.inner_radius, r2=self.outer_radius,
a1=fold_angle, a2=end_angle,
fn=circle_segments)
footprint.arcs.extend(arc_approximate(points_layer1, self.trace_width, self.layer_pair[1], arc_tolerance))
else:
# Add a straight connecting segment connecting the inner point to the outside of the spiral.
ref = angle_refs_layer0[-1]
xq, yq = self.shape.project_point(self.shape.outer_radius, fold_angle, r_ref=ref)
angle_refs_layer1 = [ref, ref]
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.lines.append(kicad.make_line(xn, yn, xq, yq, self.trace_width, self.layer_pair[1]))
arms_layers[0].append((points_layer0, angle_refs_layer0))
arms_layers[1].append((points_layer1, angle_refs_layer1))
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 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*self.via_offset
points_layer0, refs_layer0 = arms_layers[0][i]
points_layer1, refs_layer1 = arms_layers[1][i]
xv, yv = r*cos(fold_angle), r*sin(fold_angle)
xv, yv = self.shape.project_point(r, fold_angle, r_ref=refs_layer0[-1])
footprint.lines.append(kicad.make_line(*points_layer0[-1], xv, yv, self.trace_width, self.layer_pair[0]))
footprint.lines.append(kicad.make_line(xv, yv, *points_layer1[0], self.trace_width, self.layer_pair[1]))
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]))
footprint.pads.append(kicad.make_via(xv, yv,
self.via_diameter, self.via_drill, self.clearance,
@ -593,41 +326,38 @@ class PlanarInductor():
# Handle outer via ring and process staggering if enabled unless we are at the start of the coil, where we will
# place pads below.
r = self.outer_via_ring_radius
if self.stagger_outer_vias:
if i%2 != 0:
r += 2*self.via_offset
points_layer0, refs_layer0 = arms_layers[0][i]
points_layer1, refs_layer1 = arms_layers[1][(i - self.turns) % self.twists]
xv, yv = self.shape.project_point(r, start_angle, r_ref=refs_layer0[0])
footprint.lines.append(kicad.make_line(*points_layer0[0], xv, yv, self.trace_width, self.layer_pair[0]))
footprint.lines.append(kicad.make_line(*points_layer1[-1], xv, yv, self.trace_width, self.layer_pair[1]))
if i > 0:
r = self.outer_via_ring_radius
if self.stagger_outer_vias:
if i%2 != 0:
r += 2*self.via_offset
xv, yv = r*cos(start_angle), r*sin(start_angle)
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]))
footprint.pads.append(kicad.make_via(xv, yv,
self.via_diameter, self.via_drill, self.clearance,
self.layer_pair))
else:
# Place the pads on the outer radius
px, py = self.shape.project_point(self.shape.outer_radius, 0)
footprint.pads.extend([
kicad.make_pad(1, [self.layer_pair[0]], px, py, self.trace_width, self.clearance),
kicad.make_pad(2, [self.layer_pair[1]], px, py, self.trace_width, self.clearance)])
# 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:
pts = self.shape.offset_exterior(self.keepout_margin)
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])))
if geometry_debug_file:
self.shape.sk.dump_to_pdf(geometry_debug_file)
return footprint

View file

@ -27,7 +27,7 @@ from pathlib import Path
from contextlib import contextmanager
from io import BytesIO
from .geometry import PlanarInductor, divisors, CircleShape, SectorShape, StarShape, SVGShape, RectangleShape, RegularPolygonShape, TrapezoidShape
from .geometry import PlanarInductor, divisors
from .svg import make_transparent_svg
try:
@ -122,15 +122,11 @@ class EntryWithPlaceholder(tk.Entry):
class KiCoilGUI:
def __init__(self, root, kicad_inst=None):
self.kicad_inst = kicad_inst
def __init__(self, root):
self.root = root
self.root.title("KiCoil - Planar Inductor Generator")
self.root.geometry("1000x650")
# Register validation command for non-negative numbers early
self.validate_nonneg_cmd = self.root.register(self.validate_nonnegative)
style = ttk.Style()
style.theme_use('clam')
@ -162,44 +158,6 @@ class KiCoilGUI:
self.notebook.add(geometry_frame, text="Geometry")
self.create_geometry_params(geometry_frame)
# Shape parameter tabs (will be shown/hidden based on selection)
self.shape_param_frames = {}
circle_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Circle"] = circle_frame
self.notebook.add(circle_frame, text="Circle Parameters")
self.create_circle_params(circle_frame)
rectangle_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Rectangle"] = rectangle_frame
self.notebook.add(rectangle_frame, text="Rectangle Parameters")
self.create_rectangle_params(rectangle_frame)
trapezoid_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Trapezoid"] = trapezoid_frame
self.notebook.add(trapezoid_frame, text="Trapezoid Parameters")
self.create_trapezoid_params(trapezoid_frame)
sector_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Sector"] = sector_frame
self.notebook.add(sector_frame, text="Sector Parameters")
self.create_sector_params(sector_frame)
star_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Star"] = star_frame
self.notebook.add(star_frame, text="Star Parameters")
self.create_star_params(star_frame)
polygon_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["Regular Polygon"] = polygon_frame
self.notebook.add(polygon_frame, text="Polygon Parameters")
self.create_polygon_params(polygon_frame)
svg_frame = ttk.Frame(self.notebook, padding="10")
self.shape_param_frames["SVG"] = svg_frame
self.notebook.add(svg_frame, text="SVG Parameters")
self.create_svg_params(svg_frame)
traces_frame = ttk.Frame(self.notebook, padding="10")
self.notebook.add(traces_frame, text="Traces")
self.create_trace_params(traces_frame)
@ -217,11 +175,8 @@ class KiCoilGUI:
ttk.Button(button_frame, text="Show Valid Twists",
command=self.show_valid_twists, width=20).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Save Footprint File",
command=self.save_footprint_file, width=20).pack(side=tk.LEFT, padx=5)
#if self.kicad_inst: FIXME
# ttk.Button(button_frame, text="Update Footprint on Board",
# command=self.update_board_footprint, width=20).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Generate and Save",
command=self.generate_footprint, width=20).pack(side=tk.LEFT, padx=5)
status_label = ttk.Label(main_frame, text="Output:", font=('Helvetica', 10, 'bold'))
status_label.grid(row=3, column=0, sticky=tk.W, pady=(10, 0))
@ -255,26 +210,13 @@ class KiCoilGUI:
self.preview_label.pack(fill=tk.BOTH, expand=True)
self.preview_frame.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.E, tk.W), padx=(10, 10), pady=10)
self.current_model = None
self._validation_after_id = None
self.setup_logging()
self.setup_traces()
self.update_shape_tab_visibility() # Initialize tab visibility
self.root.after(100, self.validate_parameters)
def validate_nonnegative(self, value_if_allowed):
"""Validation callback for spinboxes to prevent negative values"""
if value_if_allowed == "" or value_if_allowed == "-":
# Allow empty string (user is typing) but not standalone minus
return value_if_allowed == ""
try:
float_val = float(value_if_allowed)
return float_val >= 0
except ValueError:
return False
def _on_preview_resize(self, event):
# Debounce resize events - only update after resize is complete
if hasattr(self, '_resize_after_id') and self._resize_after_id is not None:
@ -334,37 +276,42 @@ class KiCoilGUI:
def create_geometry_params(self, parent):
row = 0
# Shape Type
ttk.Label(parent, text="Shape Type:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.shape_type_var = tk.StringVar(value="Circle")
shape_combo = ttk.Combobox(parent, textvariable=self.shape_type_var,
values=["Circle", "Rectangle", "Trapezoid", "Sector", "Star", "Regular Polygon", "SVG"],
state='readonly', width=23)
shape_combo.grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Coil outline shape",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Turns
ttk.Label(parent, text="Number of Turns:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.turns_var = tk.IntVar(value=7)
self.turns_var = tk.IntVar(value=5)
ttk.Spinbox(parent, from_=1, to=100, textvariable=self.turns_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Number of spiral turns",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Twists
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=1)
ttk.Spinbox(parent, from_=0, to=50, textvariable=self.twists_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Must be co-prime to turns",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Outer Diameter
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.outer_dia_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Outside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Inner Diameter
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.inner_dia_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Inside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
# Layer Mode
ttk.Label(parent, text="Layer Mode:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.layer_mode_var = tk.IntVar(value=2)
@ -387,221 +334,6 @@ class KiCoilGUI:
value="clockwise").pack(side=tk.LEFT)
row += 1
def create_circle_params(self, parent):
row = 0
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.circle_outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.circle_outer_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Outside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.circle_inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.circle_inner_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Inside diameter of coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_rectangle_params(self, parent):
row = 0
ttk.Label(parent, text="Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.rect_width_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.rect_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Height (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.rect_height_var = tk.DoubleVar(value=40.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.rect_height_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.rect_annular_width_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.rect_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_trapezoid_params(self, parent):
row = 0
ttk.Label(parent, text="Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_width_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.trap_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Height (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_height_var = tk.DoubleVar(value=40.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.trap_height_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Offset (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_offset_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=0, to=100, increment=0.5,
textvariable=self.trap_offset_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Corner offset at shorter edge",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.trap_annular_width_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.trap_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_sector_params(self, parent):
row = 0
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.sector_outer_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.sector_inner_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Angle (degrees):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_angle_var = tk.DoubleVar(value=45.0)
ttk.Spinbox(parent, from_=1, to=360, increment=1.0,
textvariable=self.sector_angle_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Sector angle",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.sector_annular_width_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.sector_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_star_params(self, parent):
row = 0
ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_outer_dia_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.star_outer_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_inner_dia_var = tk.DoubleVar(value=25.0)
ttk.Spinbox(parent, from_=0, to=500, increment=0.5,
textvariable=self.star_inner_dia_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Points:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_points_var = tk.IntVar(value=5)
ttk.Spinbox(parent, from_=3, to=20, textvariable=self.star_points_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Number of star points",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.star_annular_width_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.star_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_polygon_params(self, parent):
row = 0
ttk.Label(parent, text="Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.poly_diameter_var = tk.DoubleVar(value=50.0)
ttk.Spinbox(parent, from_=1, to=500, increment=0.5,
textvariable=self.poly_diameter_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
ttk.Label(parent, text="Corners:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.poly_corners_var = tk.IntVar(value=8)
ttk.Spinbox(parent, from_=3, to=20, textvariable=self.poly_corners_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Number of polygon corners",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.poly_annular_width_var = tk.DoubleVar(value=10.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.poly_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def create_svg_params(self, parent):
row = 0
ttk.Label(parent, text="SVG File:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.svg_filename_var = tk.StringVar(value="")
svg_entry = ttk.Entry(parent, textvariable=self.svg_filename_var, width=30)
svg_entry.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5)
ttk.Button(parent, text="Browse...", command=self.browse_svg_file, width=10).grid(row=row, column=2, sticky=tk.W, padx=(5, 0), pady=5)
row += 1
ttk.Label(parent, text="Annular Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.svg_annular_width_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=1, to=100, increment=0.5,
textvariable=self.svg_annular_width_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Width of trace area",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def browse_svg_file(self):
filename = filedialog.askopenfilename(
title="Select SVG File",
filetypes=[("SVG files", "*.svg"), ("All files", "*.*")]
)
if filename:
self.svg_filename_var.set(filename)
def update_shape_tab_visibility(self, *args):
"""Show only the shape parameter tab for the currently selected shape"""
selected_shape = self.shape_type_var.get()
# Hide all shape parameter tabs
for shape_name, frame in self.shape_param_frames.items():
tab_id = self.notebook.index(frame)
self.notebook.tab(tab_id, state='hidden')
# Show only the selected shape's tab
if selected_shape in self.shape_param_frames:
frame = self.shape_param_frames[selected_shape]
tab_id = self.notebook.index(frame)
self.notebook.tab(tab_id, state='normal')
def create_trace_params(self, parent):
row = 0
@ -621,8 +353,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Copper Thickness (µm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.copper_thickness_var = tk.DoubleVar(value=35.0) # 35µm = 0.035mm = 1 Oz
ttk.Spinbox(parent, from_=1, to=1000, increment=1, format="%.1f",
textvariable=self.copper_thickness_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.copper_thickness_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="35µm = 1 Oz copper",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
@ -635,8 +366,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Via Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.via_diameter_var = tk.DoubleVar(value=0.6)
ttk.Spinbox(parent, from_=0.1, to=5.0, increment=0.1, format="%.2f",
textvariable=self.via_diameter_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.via_diameter_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
# Via Drill
@ -695,8 +425,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Circle Segments:").grid(row=row, column=0, sticky=tk.W, pady=5)
self.circle_segments_var = tk.IntVar(value=64)
ttk.Spinbox(parent, from_=8, to=360, textvariable=self.circle_segments_var,
width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Points per 360° for arc interpolation",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
@ -705,8 +434,7 @@ class KiCoilGUI:
ttk.Label(parent, text="Arc Tolerance (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.arc_tolerance_var = tk.DoubleVar(value=0.02)
ttk.Spinbox(parent, from_=0.001, to=1.0, increment=0.001, format="%.3f",
textvariable=self.arc_tolerance_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.arc_tolerance_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
row += 1
# Keepout Zone
@ -720,66 +448,16 @@ class KiCoilGUI:
ttk.Label(parent, text="Keepout Margin (mm):").grid(row=row, column=0, sticky=tk.W, pady=5)
self.keepout_margin_var = tk.DoubleVar(value=5.0)
ttk.Spinbox(parent, from_=0, to=50, increment=0.5,
textvariable=self.keepout_margin_var, width=15,
validate='key', validatecommand=(self.validate_nonneg_cmd, '%P')).grid(row=row, column=1, sticky=tk.W, pady=5)
textvariable=self.keepout_margin_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="Margin around coil",
foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0))
row += 1
def get_parameters(self):
# Create the appropriate shape based on selection
shape_type = self.shape_type_var.get()
if shape_type == "Circle":
shape = CircleShape(
outer_diameter=self.circle_outer_dia_var.get(),
inner_diameter=self.circle_inner_dia_var.get()
)
elif shape_type == "Rectangle":
shape = RectangleShape(
width=self.rect_width_var.get(),
height=self.rect_height_var.get(),
annular_width=self.rect_annular_width_var.get()
)
elif shape_type == "Trapezoid":
shape = TrapezoidShape(
width=self.trap_width_var.get(),
height=self.trap_height_var.get(),
offset=self.trap_offset_var.get(),
annular_width=self.trap_annular_width_var.get()
)
elif shape_type == "Sector":
import math
shape = SectorShape(
inner_diameter=self.sector_inner_dia_var.get(),
outer_diameter=self.sector_outer_dia_var.get(),
angle=math.radians(self.sector_angle_var.get()),
annular_width=self.sector_annular_width_var.get()
)
elif shape_type == "Star":
shape = StarShape(
inner_diameter=self.star_inner_dia_var.get(),
outer_diameter=self.star_outer_dia_var.get(),
points=self.star_points_var.get(),
annular_width=self.star_annular_width_var.get()
)
elif shape_type == "Regular Polygon":
shape = RegularPolygonShape(
diameter=self.poly_diameter_var.get(),
corners=self.poly_corners_var.get(),
annular_width=self.poly_annular_width_var.get()
)
elif shape_type == "SVG":
shape = SVGShape(
filename=self.svg_filename_var.get(),
annular_width=self.svg_annular_width_var.get()
)
else:
raise ValueError(f"Unknown shape type: {shape_type}")
params = {
'shape' : shape,
'turns' : self.turns_var.get(),
'outer_diameter' : self.outer_dia_var.get(),
'inner_diameter' : self.inner_dia_var.get(),
'layers' : self.layer_mode_var.get(),
'twists' : self.twists_var.get(),
'clockwise' : (self.direction_var.get() == "clockwise"),
@ -818,10 +496,10 @@ class KiCoilGUI:
def capture_warnings(self):
"""Context manager to capture kicoil's warnings to the output text widget"""
def show_warning(message, category, filename, lineno, file=None, line=None):
self.output_text['state'] = 'normal'
self.output_text.config(state='normal')
self.output_text.insert(tk.END, f'{message}\n', 'warning')
self.output_text.see(tk.END)
self.output_text['state'] = 'disabled'
self.output_text.config(state='disabled')
self.output_text.update_idletasks()
old_showwarning, warnings.showwarning = warnings.showwarning, show_warning
@ -831,12 +509,10 @@ class KiCoilGUI:
warnings.showwarning = old_showwarning
def setup_traces(self):
# Shape type needs special handling for tab visibility
self.shape_type_var.trace_add('write', self.update_shape_tab_visibility)
self.shape_type_var.trace_add('write', self._on_parameter_change)
for var in [
self.turns_var,
self.outer_dia_var,
self.inner_dia_var,
self.layer_mode_var,
self.direction_var,
self.twists_var,
@ -847,44 +523,13 @@ class KiCoilGUI:
self.stagger_inner_var,
self.stagger_outer_var,
self.top_layer_var,
self.bottom_layer_var,
# Circle shape params
self.circle_outer_dia_var,
self.circle_inner_dia_var,
# Rectangle shape params
self.rect_width_var,
self.rect_height_var,
self.rect_annular_width_var,
# Trapezoid shape params
self.trap_width_var,
self.trap_height_var,
self.trap_offset_var,
self.trap_annular_width_var,
# Sector shape params
self.sector_outer_dia_var,
self.sector_inner_dia_var,
self.sector_angle_var,
self.sector_annular_width_var,
# Star shape params
self.star_outer_dia_var,
self.star_inner_dia_var,
self.star_points_var,
self.star_annular_width_var,
# Polygon shape params
self.poly_diameter_var,
self.poly_corners_var,
self.poly_annular_width_var,
# SVG shape params
self.svg_annular_width_var]:
self.bottom_layer_var]:
var.trace_add('write', self._on_parameter_change)
for entry in [self.trace_width_entry, self.clearance_entry,
self.via_drill_entry, self.via_offset_entry,
self.footprint_name_entry]:
entry.bind('<KeyRelease>', lambda e: self._on_parameter_change())
# SVG filename entry needs special handling
self.svg_filename_var.trace_add('write', self._on_parameter_change)
def _on_parameter_change(self, *args):
# Schedule validation to avoid too many rapid calls
@ -895,7 +540,7 @@ class KiCoilGUI:
def validate_parameters(self):
"""Validate parameters by creating PlanarInductor instance"""
try:
self.output_text['state'] = 'normal'
self.output_text.config(state='normal')
self.output_text.delete('1.0', tk.END)
with self.capture_warnings():
@ -908,21 +553,15 @@ class KiCoilGUI:
return True
except ValueError as e:
self.output_text['state'] = 'normal'
self.output_text.insert(tk.END, f"ERROR: {e}\n", 'error')
self.output_text.see(tk.END)
tb = traceback.format_exc()
print(tb, file=sys.stderr)
self.current_model = None
self.update_placeholders()
self.update_preview()
return False
except Exception as e:
tb = traceback.format_exc()
self.output_text['state'] = 'normal'
self.output_text.insert(tk.END, f"Unexpected error:\n{tb}\n", 'error')
self.output_text.see(tk.END)
@ -930,11 +569,10 @@ class KiCoilGUI:
self.current_model = None
self.update_placeholders()
self.update_preview()
return False
return True
finally:
self.output_text['state'] = 'disabled'
self.output_text.config(state='disabled')
def update_placeholders(self):
if self.current_model is None:
@ -964,13 +602,6 @@ class KiCoilGUI:
if not HAS_PREVIEW:
return
if self.current_model is None:
# Clear preview when model is invalid
self.preview_canvas.delete("all")
self.preview_raw_image = None
self.preview_image = None
return
arc_tolerance = self.arc_tolerance_var.get()
circle_segments = self.circle_segments_var.get()
@ -991,99 +622,20 @@ class KiCoilGUI:
def show_valid_twists(self):
"""Show valid twist counts for current number of turns"""
turns = self.turns_var.get()
valid_twists = list(divisors(turns, turns))
self.output_text['state'] = 'normal'
self.output_text.config(state='normal')
self.output_text.delete('1.0', tk.END)
self.output_text.insert('1.0', f'Valid twist counts for {turns} turns:\n')
for d in valid_twists:
self.output_text.insert(tk.END, f' {d}\n')
self.output_text['state'] = 'disabled'
self.output_text.config(state='disabled')
def update_board_footprint(self):
if not self.validate_parameters():
messagebox.showerror("Error", "Cannot generate model. Please check the output for warnings or errors.")
return
def generate_footprint(self):
"""Generate the KiCad footprint using the validated model"""
from kipy.board_types import FootprintInstance, Footprint, Pad, BoardArc, BoardSegment, FootprintAttributes,\
PadStack, PadStackLayer, PadStackType, PadStackShape, DrillProperties, PadType
from kipy.geometry import Vector2
from kipy.common_types import GraphicAttributes, StrokeAttributes
from kipy.util import from_mm
from kipy.util.board_layer import CANONICAL_LAYER_NAMES
from gerbonara.cad.kicad.footprints import Atom
board = self.kicad_inst.get_board()
selected = [item for item in board.get_selection() if isinstance(item, FootprintInstance)]
if not selected:
messagebox.showerror("Error", "No footprint selected. Select one footprint to replace in KiCad's PCB editor.")
return
elif len(selected) > 1:
messagebox.showerror("Error", "More than one footprint selected. Select only the footprint you want to replace.")
return
selected_footprint, = selected
footprint_name = self.footprint_name_entry.get() or None
arc_tolerance = self.arc_tolerance_var.get()
circle_segments = self.circle_segments_var.get()
self.output_text['state'] = 'normal'
self.output_text.insert(tk.END, "Rendering footprint...\n", 'info')
self.output_text.see(tk.END)
model = self.current_model.render_footprint(footprint_name, arc_tolerance, circle_segments)
selected_footprint.attributes.exclude_from_bill_of_materials = True
layer_map = {v: k for k, v in CANONICAL_LAYER_NAMES.items()}
items = []
for line in model.lines:
seg = BoardSegment()
seg.start = Vector2.from_xy(from_mm(line.start.x), from_mm(line.start.y))
seg.end = Vector2.from_xy(from_mm(line.end.x), from_mm(line.end.y))
seg.attributes.stroke.width = from_mm(line.stroke.width)
seg.layer = layer_map[line.layer]
selected_footprint.definition.add_item(seg)
items.append(seg)
for ref in model.arcs:
arc = BoardArc()
arc.start = Vector2.from_xy(from_mm(ref.start.x), from_mm(ref.start.y))
arc.mid = Vector2.from_xy(from_mm(ref.mid.x), from_mm(ref.mid.y))
arc.end = Vector2.from_xy(from_mm(ref.end.x), from_mm(ref.end.y))
arc.attributes.stroke.width = from_mm(ref.stroke.width)
arc.layer = layer_map[ref.layer]
selected_footprint.definition.add_item(arc)
items.append(arc)
for ref in model.pads:
pad = Pad()
pad.number = ref.number
pad.position = Vector2.from_xy(from_mm(ref.at.x), from_mm(ref.at.y))
pad.type = PadType.PT_SMD if ref.type == Atom.smd else PadType.PT_PTH
pad.padstack.type = PadStackType.PST_NORMAL
pad.padstack.layers = [layer_map[name] for name in ref.layers]
layer = pad.padstack.copper_layers[0]
layer.shape = PadStackShape.PSS_CIRCLE
layer.size = Vector2.from_xy(from_mm(ref.size.x), from_mm(ref.size.y))
layer.layer = layer_map[ref.layers[0]] # ? duplicate
if ref.drill:
pad.padstack.drill.diameter = Vector2.from_xy(from_mm(ref.drill.diameter), from_mm(ref.drill.diameter))
selected_footprint.definition.add_item(pad)
items.append(pad)
commit = board.begin_commit()
board.create_items(items)
board.update_items([selected_footprint])
board.push_commit(commit, 'Updated planar coil footprint')
self.output_text.insert(tk.END, "Done.", 'info')
self.output_text['state'] = 'disabled'
self.output_text.see(tk.END)
def save_footprint_file(self):
if not self.validate_parameters():
messagebox.showerror("Error", "Cannot generate model. Please check the output for warnings or errors.")
return
@ -1093,11 +645,11 @@ class KiCoilGUI:
arc_tolerance = self.arc_tolerance_var.get()
circle_segments = self.circle_segments_var.get()
self.output_text['state'] = 'normal'
self.output_text.config(state='normal')
self.output_text.insert(tk.END, "Rendering footprint...\n\n", 'info')
footprint = self.current_model.render_footprint(footprint_name, arc_tolerance, circle_segments)
default_name = footprint_name or self.current_model.default_footprint_name
default_name = footprint_name or model.default_footprint_name
output_file = filedialog.asksaveasfilename(
title="Save KiCad Footprint",
defaultextension=".kicad_mod",
@ -1114,7 +666,7 @@ class KiCoilGUI:
except Exception as e:
tb = traceback.format_exc()
self.output_text['state'] = 'normal'
self.output_text.config(state='normal')
self.output_text.insert(tk.END, f"\nError generating footprint:\n{tb}\n", 'error')
self.output_text.see(tk.END)
@ -1123,13 +675,13 @@ class KiCoilGUI:
messagebox.showerror("Error", f"Error generating footprint: {e}")
finally:
self.output_text['state'] = 'disabled'
self.output_text.config(state='disabled')
def main(kicad_inst=None):
from kipy import KiCad
from kipy.errors import ConnectionError
kicad_inst = KiCad()
def main():
root = tk.Tk()
app = KiCoilGUI(root, kicad_inst)
app = KiCoilGUI(root)
root.mainloop()
if __name__ == "__main__":
main()

View file

@ -1,361 +0,0 @@
import math
import itertools
import subprocess
import os
from tempfile import NamedTemporaryFile
from dataclasses import dataclass
from pathlib import Path
import importlib.resources
import lzma
import sys
import hashlib
import platformdirs
import wasmtime
def interpolate(p1, p2, t, t_start=0, t_end=1):
""" Interpolate along the line from point p1 to point p2 using t as the interpolation variable.
t_start and t_end set the bounds of t, t_start at p1, and t_end at p2.
When both interval ends coincide, clips and returns p1.
"""
if math.isclose(t_start, t_end):
return p1
t_range = t_end - t_start
t = (t - t_start) / t_range
x1, y1 = p1
x2, y2 = p2
dx, dy = x2 - x1, y2 - y1
return (x1 + t*dx, y1 + t*dy)
def approx_in_range(value, lower, upper):
""" Approximate range check """
if math.isclose(value, lower) or math.isclose(value, upper):
return True
return lower <= value <= upper
def edge_cycle(points):
""" From a list of points return an iterator of all edges assuming they are a closed loop:
[A B C] -> [AB BC CA]
"""
return itertools.pairwise(itertools.chain(points, points[:1]))
def polygon_is_clockwise(points):
(x1, y1, i), *_rest = sorted((x, y, i) for i, (x, y) in enumerate(points))
x0, y0 = points[(i-1)%len(points)]
x2, y2 = points[(i+1)%len(points)]
det = (x0*y1 + x1*y2 + x2*y0) - (x2*y1 + x1*y0 + x0*y2)
return det < 0
def polygon_center_of_mass(polygon):
# https://en.wikipedia.org/wiki/Centroid
total_x, total_y = 0, 0
area = 0
for (x1, y1), (x2, y2) in edge_cycle(polygon):
diff = (x1*y2 - x2*y1)
total_x += (x1 + x2) * diff
total_y += (y1 + y2) * diff
area += diff
area /= 2
total_x /= 6*area
total_y /= 6*area
return total_x, total_y
class WasmApp:
def __init__(self, wasm_filename, cachedir="kicoil"):
module_binary = importlib.resources.read_binary(__package__, wasm_filename)
module_path_digest = hashlib.sha256(__file__.encode()).hexdigest()
module_digest = hashlib.sha256(module_binary).hexdigest()
cache_path = Path(os.getenv("KICOIL_CACHE_DIR", platformdirs.user_cache_dir(cachedir)))
cache_path.mkdir(parents=True, exist_ok=True)
cache_filename = (cache_path / f'{wasm_filename}-{module_path_digest[:8]}-{module_digest[:16]}')
self.engine = wasmtime.Engine()
try:
with cache_filename.open("rb") as cache_file:
self.module = wasmtime.Module.deserialize(self.engine, lzma.decompress(cache_file.read()))
except:
print("Preparing to run {}. This might take a while...".format(wasm_filename), file=sys.stderr)
self.module = wasmtime.Module(self.engine, module_binary)
with cache_filename.open("wb") as cache_file:
cache_file.write(lzma.compress(self.module.serialize(), preset=0))
def run(self, stdin='', argv=[]):
with NamedTemporaryFile('r') as stdout_f, NamedTemporaryFile('w') as stdin_f:
stdin_f.write(stdin)
stdin_f.flush()
wasi_cfg = wasmtime.WasiConfig()
wasi_cfg.argv = argv
wasi_cfg.stdin_file = stdin_f.name
wasi_cfg.stdout_file = stdout_f.name
wasi_cfg.inherit_stderr()
linker = wasmtime.Linker(self.engine)
linker.define_wasi()
store = wasmtime.Store(self.engine)
store.set_wasi(wasi_cfg)
self.app = linker.instantiate(store, self.module)
linker.define_instance(store, "app", self.app)
try:
self.app.exports(store)["_start"](store)
except wasmtime.ExitTrap as trap:
if trap.code != 0:
raise RuntimeError('Error computing straight skeleton.')
return 0, stdout_f.read()
@dataclass(frozen=True)
class SkeletonNode:
x: float
y: float
time: float
@property
def pos(self):
return self.x, self.y
skeleton_wasm = WasmApp('skeleton.wasm')
def compute_skeleton(exterior):
points_deduplicated = []
for p1, p2 in edge_cycle(exterior):
if p2 != p1:
points_deduplicated.append(p1)
input_data = '\n'.join(f'{x} {y}' for x, y in points_deduplicated)
Path('/tmp/debug.txt').write_text(input_data)
rc, data = skeleton_wasm.run(input_data)
# Parse output: each line is "x1 y1 x2 y2 t1 t2"
node_map = {} # Map (x, y, t) to SkeletonNode
edges = []
for line in data.strip().split('\n'):
if not line:
continue
parts = line.split()
if len(parts) != 6:
continue
x1, y1, x2, y2, t1, t2 = map(float, parts)
n1 = (x1, y1, t1)
if n1 not in node_map:
node_map[n1] = SkeletonNode(*n1)
n2 = (x2, y2, t2)
if n2 not in node_map:
node_map[n2] = SkeletonNode(*n2)
edges.append((node_map[n1], node_map[n2]))
nodes = list(node_map.values())
return nodes, edges
class Skeletonator:
def __init__(self, poly):
self.poly = poly
self.poly_edges = list(zip(poly, poly[1:] + poly[:1]))
self.circumference = sum(math.dist(a, b) for a, b in self.poly_edges)
self.skeleton_nodes, self.skeleton_edges = compute_skeleton(exterior=poly)
self.arc_map = {}
self.divergent = set()
self.radius = max(n.time for n in self.skeleton_nodes)
self.min_radius = self.radius
for n1, n2 in self.skeleton_edges:
if n1 in self.arc_map:
self.divergent.add(n1)
self.min_radius = min(n1.time, self.radius)
self.arc_map[n1] = n2
coord_map = {}
for n in self.skeleton_nodes:
p = (round(n.x, 6), round(n.y, 6))
coord_map[p] = n
self.node_map = {}
for x, y in poly:
p = (round(x, 6), round(y, 6))
self.node_map[(x, y)] = coord_map[p]
self.debug_arms = []
def iter_arcs(self, p):
i = 0
start = self.node_map[p]
while start in self.arc_map and not start in self.divergent:
end = self.arc_map[start]
i += 1
yield start, end
start = end
def project_arc(self, p, r):
t = self.radius - r
for n0, n1 in self.iter_arcs(p):
if t < 0 or approx_in_range(t, n0.time, n1.time):
return (n0, n1), interpolate(n0.pos, n1.pos, t, n0.time, n1.time)
else:
raise ValueError(f'{r=:.3f} is out of bounds [0, {self.radius - self.min_radius:.4f}]')
def calc_circumference(self, r):
projected = [self.project_arc(p, r)[1] for p in self.poly]
return sum(math.dist(p1, p2) for p1, p2 in zip(projected, projected[1:] + projected[:1]))
def project_point(self, t, r=None, r_ref=None):
t %= 1
if r is None:
r = self.radius
if r_ref is None:
r_ref = r
t_start = 0
p_cur = None
_arcs, points_at_r = self.map_circumference(r_ref)
circumference_at_r = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(points_at_r))
for (p1, p2), (p1r, p2r) in zip(self.poly_edges, edge_cycle(points_at_r)):
edge_frac = math.dist(p1r, p2r) / circumference_at_r
t_end = t_start + edge_frac
if approx_in_range(t, t_start, t_end):
p1, p2 = self.project_arc(p1, r)[1], self.project_arc(p2, r)[1]
p_cur = interpolate(p1, p2, t, t_start, t_end)
return p_cur
t_start = t_end
def map_circumference(self, r):
points, arcs = [], []
for p in self.poly:
arc, pt = self.project_arc(p, r)
arcs.append(arc)
points.append(pt)
return arcs, points
def do_spiral(self, t1, t2, r1=None, r2=None, debug=False):
if r1 is None:
r1 = self.radius
if r2 is None:
r2 = self.min_radius
direction = True
if t2 < t1:
direction = False
t1, t2 = t2, t1
r1, r2 = r2, r1
def r_interpolate(t):
t = max(t1, min(t2, t)) # Clip to start/end of spiral
f = (t - t1) / (t2 - t1)
return r1 + (r2 - r1) * f
debug_arm = []
for t_start in range(math.ceil(t2-t1)):
t_start += t1
t_end = t_start + 1
r_outer = r_interpolate(t_start)
r_inner = r_interpolate(t_end)
r_ref = min(r_inner, r_outer) # Handle outward spirals where the radii are swapped
_ic_arcs, inner_circumference = self.map_circumference(r_ref)
angle = math.floor(t_start)
circumference_angles = []
inner_circumference_sum = sum(math.dist(p1, p2) for p1, p2 in edge_cycle(inner_circumference))
point_angles = []
for p1, p2 in edge_cycle(inner_circumference):
edge_angle = math.dist(p1, p2) / inner_circumference_sum
point_angles.append(angle)
angle += edge_angle
point_angles += [a+1 for a in point_angles]
point_angles += [point_angles[0] + 2]
i = 0
for (p1, p2), (tp1, tp2) in zip(self.poly_edges * 3, itertools.pairwise(point_angles)):
i += 1
rp1 = r_interpolate(tp1)
rp2 = r_interpolate(tp2)
_arc, p1_proj = self.project_arc(p1, rp1)
_arc, p2_proj = self.project_arc(p2, rp2)
if tp2 < t_start and not math.isclose(tp2, t_start):
continue
if tp1 > t_end and not math.isclose(tp1, t_end):
continue
if approx_in_range(t1, tp1, tp2):
_arc, p2_proj_r1 = self.project_arc(p2, r1)
p_out = interpolate(p1_proj, p2_proj_r1, t1, tp1, tp2)
debug_arm.append(p_out)
yield p_out, r_ref
if approx_in_range(t2, tp1, tp2):
_arc, p1_proj_r2 = self.project_arc(p1, r2)
p_out = interpolate(p1_proj_r2, p2_proj, t2, tp1, tp2)
debug_arm.append(p_out)
yield p_out, r_ref
elif approx_in_range(tp2, t1, t2):
debug_arm.append(p2_proj)
yield p2_proj, r_ref
if debug:
self.debug_arms.append((debug_arm, direction, t1, t2))
def dump_to_pdf(self, filename):
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 10))
# polygon outline
poly_x = [p[0] for p in self.poly] + [self.poly[0][0]]
poly_y = [p[1] for p in self.poly] + [self.poly[0][1]]
ax.plot(poly_x, poly_y, '-', color='black', linewidth=.5, label='Polygon')
# skeleton edges
for node1, node2 in self.skeleton_edges:
ax.plot([node1.x, node2.x], [node1.y, node2.y], '-', color='gray', linewidth=.5, alpha=0.7)
# skeleton nodes
for n in self.skeleton_nodes:
if n in self.divergent:
ax.plot(n.x, n.y, 'o', markerfacecolor='none', markeredgecolor='green', markersize=4)
elif n in self.arc_map:
ax.plot(n.x, n.y, 'o', color='black', markersize=3, alpha=0.5)
else:
ax.plot(n.x, n.y, 'o', markerfacecolor='none', markeredgecolor='magenta', markersize=4)
count = {True: 0, False: 0}
for arm, direction, t1, t2 in self.debug_arms:
xs = [x for x, y in arm]
ys = [y for x, y in arm]
ax.plot(xs, ys, linewidth=.2, color='red' if direction else 'blue')
align = 'left' if direction else 'right'
ax.text(xs[-1], ys[-1], f'{count[direction]}', size=3, horizontalalignment=align)
ax.text(xs[0], ys[0], f'{count[direction]}', size=3, horizontalalignment=align, color='gray')
count[direction] += 1
xs, ys = [], []
for i in range(100):
r = self.radius - (i/99) * self.min_radius
arc, (px, py) = self.project_arc(self.poly[0], r)
xs.append(px)
ys.append(py)
ax.plot(xs, ys, '--', linewidth=.5, color='black')
ax.set_aspect('equal', adjustable='box')
ax.grid(True, alpha=0.3)
ax.legend()
ax.set_title(f'Polygon Skeleton (radius: {self.radius:.3f}, min_radius: {self.min_radius:.3f})')
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.invert_yaxis()
fig.savefig(filename, bbox_inches='tight', dpi=600)
plt.close(fig)

913
uv.lock generated

File diff suppressed because it is too large Load diff