diff --git a/pyproject.toml b/pyproject.toml index d804169..7752eb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,9 @@ dependencies = [ "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", ] authors = [{ name = "jaseg" }] maintainers = [ diff --git a/src/kicoil/skeletonator.py b/src/kicoil/skeletonator.py index 221fe03..14a896f 100644 --- a/src/kicoil/skeletonator.py +++ b/src/kicoil/skeletonator.py @@ -2,10 +2,18 @@ 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 matplotlib.pyplot as plt from matplotlib.backends.backend_pdf import PdfPages +import wasmtime def interpolate(p1, p2, t, t_start=0, t_end=1): @@ -37,6 +45,53 @@ def edge_cycle(points): return itertools.pairwise(itertools.chain(points, points[:1])) +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 + return 0, stdout_f.read() + + @dataclass(frozen=True) class SkeletonNode: x: float @@ -56,38 +111,22 @@ def polygon_is_clockwise(points): return det < 0 -def compute_skeleton_cli(exterior): - # Find the skeleton_cli binary - # Look in project root directory - cli_path = Path(__file__).parent.parent.parent / 'skeleton_cli' - if not cli_path.exists(): - raise FileNotFoundError(f"skeleton_cli binary not found at {cli_path}") +skeleton_wasm = WasmApp('skeleton.wasm') - # Prepare input: one point per line +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) - - # Run the CLI program - try: - result = subprocess.run( - [str(cli_path)], - input=input_data, - capture_output=True, - text=True, - check=True - ) - except subprocess.CalledProcessError as e: - raise ValueError(f'Error computing polygon straight skeleton. CGAL says: {e.stdout.rstrip()}\n{e.stderr.rstrip()}') + + rc, data = skeleton_wasm.run(input_data) # Parse output: each line is "x1 y1 x2 y2 t1 t2" - node_dict = {} # Map (x, y, t) to SkeletonNode + node_map = {} # Map (x, y, t) to SkeletonNode edges = [] - for line in result.stdout.strip().split('\n'): + for line in data.strip().split('\n'): if not line: continue @@ -97,21 +136,17 @@ def compute_skeleton_cli(exterior): x1, y1, x2, y2, t1, t2 = map(float, parts) - # Create or get nodes - key1 = (x1, y1, t1) - key2 = (x2, y2, t2) + 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) - if key1 not in node_dict: - node_dict[key1] = SkeletonNode(x1, y1, t1) - if key2 not in node_dict: - node_dict[key2] = SkeletonNode(x2, y2, t2) + edges.append((node_map[n1], node_map[n2])) - node1 = node_dict[key1] - node2 = node_dict[key2] - - edges.append((node1, node2)) - - nodes = list(node_dict.values()) + nodes = list(node_map.values()) return nodes, edges @@ -120,7 +155,7 @@ class Skeletonator: 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_cli(exterior=poly, holes=[]) + 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) @@ -263,7 +298,7 @@ class Skeletonator: elif n in self.arc_map: ax.plot(n.x, n.y, 'ro', markersize=3, alpha=0.5) else: - ax.plot(node.x, node.y, 'o', color='magenta', markersize=6) + ax.plot(n.x, n.y, 'o', color='magenta', markersize=6) ax.set_aspect('equal', adjustable='box') ax.grid(True, alpha=0.3) @@ -271,6 +306,6 @@ class Skeletonator: 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.inver_yaxis() + ax.invert_yaxis() pdf.savefig(fig, bbox_inches='tight') plt.close(fig) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 16225f2..424a655 100644 --- a/uv.lock +++ b/uv.lock @@ -394,6 +394,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + [[package]] name = "ipykernel" version = "7.1.0" @@ -536,7 +545,9 @@ dependencies = [ { name = "gerbonara" }, { name = "kicad-python" }, { name = "lxml" }, + { name = "platformdirs" }, { name = "py-straight-skeleton" }, + { name = "wasmtime" }, ] [package.dev-dependencies] @@ -555,7 +566,9 @@ requires-dist = [ { name = "gerbonara", specifier = ">=1.6.0" }, { name = "kicad-python", specifier = ">=0.5.0" }, { name = "lxml", specifier = ">=6.0.2" }, + { name = "platformdirs", specifier = ">=4.5.1" }, { name = "py-straight-skeleton", specifier = ">=0.1.0" }, + { name = "wasmtime", specifier = ">=39.0.0" }, ] [package.metadata.requires-dev] @@ -1273,6 +1286,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "wasmtime" +version = "39.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/7c/da1dff86d6d66cd95ab17241e6aa3aef5f8fb316eec8fb956ca23c000347/wasmtime-39.0.0.tar.gz", hash = "sha256:30a27221b3fac84bc6247b34339ff6f417b989728513fa4b957a26742651ff7c", size = 117253, upload-time = "2025-11-20T21:13:01.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/2d/820cc89e430e97bc2760b96f2728feb049ec625bbcf0ec1be9c949f65019/wasmtime-39.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:8ddd8905b7786b791bae5413d86c42e89e2f846bdbc66b307a1d56841bf97b2b", size = 6839712, upload-time = "2025-11-20T21:12:41.853Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/8bef06fc7c0ab4c521f5f3864f362ddde99294cfcca21bb621a8d7b61241/wasmtime-39.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:1b699b59a443f4688b49f2e4d19895b08783ca1a0151c4009e5fa6e06766c869", size = 7672122, upload-time = "2025-11-20T21:12:43.842Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/48abeb238baa42e7cfc41fc3e67676130804842e7269169af963d91d02f1/wasmtime-39.0.0-py3-none-any.whl", hash = "sha256:d5e60ffb196bac6e96f4f7c796aa592e647179ff8aa7da97b3c77a40a59dfde7", size = 6255336, upload-time = "2025-11-20T21:12:45.125Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/1c53a16c39de3dbcfa70342d3550e162bc5fa347ab5eb8c55478d40b5702/wasmtime-39.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:78bd4965b66d98ffae444784fcd70c0390c59fb0a04813c5526731a8bc80c029", size = 7492414, upload-time = "2025-11-20T21:12:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/9b/56/211bb7b1eeb949406854ae22d838d4ffab97e683958420dd08369394933b/wasmtime-39.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:24525b09e077f67311310503b0e5d08d9887f4eb79ac1b9ffe5cb5c348f8a412", size = 6492408, upload-time = "2025-11-20T21:12:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/52/ca/eaa71d487fe87d342d26de5186587a31fc978ed42d8c44087cf45351b528/wasmtime-39.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:bc5a9dfeeb692877bb5c38439e11253d1553a9d2631e8421552f9bba04af6360", size = 7753991, upload-time = "2025-11-20T21:12:50.792Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/49284533cb9331f3d906de80893e5750b661cd45e1923b5628da4abe45c8/wasmtime-39.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:b1572becb900e50f63c604fba53d10ce58877d122c802f6302e07dbcb4dd8ca6", size = 6754932, upload-time = "2025-11-20T21:12:52.569Z" }, + { url = "https://files.pythonhosted.org/packages/26/b1/93745d0d3b5d1a1481f9826f530d03e2f338c4e7d5cfe21857bddd114d97/wasmtime-39.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e82d4b1a406cd34c19bd3c531084347f0fc7a0b4b4393530e833c9eaad459bbc", size = 6818651, upload-time = "2025-11-20T21:12:54.545Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ce/576077a17e48f6645943c7c607ac22b9d51521261a6dafa7881a9a151506/wasmtime-39.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:adb94db5b013ebcbd27fb891015de22e21352c5a7a3b28d3d08fc627bdb082f4", size = 7779203, upload-time = "2025-11-20T21:12:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/20/40/24af9eab59a4169f390e3e00b09998943bf22ee2f67eca4e7b11560601d1/wasmtime-39.0.0-py3-none-win_amd64.whl", hash = "sha256:b3db32e65660bc3f245636b2919455af69bd8e754458bc18a5126565b0cd3d9a", size = 6255344, upload-time = "2025-11-20T21:12:57.686Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/4ac0e00183cce085e44ea0cf78f628c9ef33cb8f9bf8fe6f97e3118be4b1/wasmtime-39.0.0-py3-none-win_arm64.whl", hash = "sha256:d4254bae165b71d1dd344dbec3b465206467319d17220d60b3efefb72a5483a8", size = 5371096, upload-time = "2025-11-20T21:12:59.976Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14"