diff --git a/README.md b/README.md index c0c0dfe..f4e5611 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,27 @@ xtf process density.npy -o output.stl --threshold 0.4 --sigma 1.5 --viz xtf viz density_2d.npy -o comparison.png ``` +### 2D → 3D extrusion + +```bash +# Turn a 2D density field into a print-ready STL +xtf extrude density_2d.npy -o part.stl --thickness 10 + +# Printability cleanup: drop small islands + pre-smooth +xtf extrude beam.npy -o beam.stl -t 15 --min-component-area 20 --smooth-sigma 0.8 +``` + +From Python: + +```python +import numpy as np +import xeltofab as xtf + +field = np.load("density_2d.npy") +mesh = xtf.extrude_2d(field, thickness=10) +mesh.export("part.stl") +``` + ## Pipeline ``` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 1fd43f3..ce8f06e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -6,6 +6,18 @@ Session log of learnings, failures, solutions discovered, and context gathered d ## Accumulated Project Wisdom +### 2026-04-22 — extrude_2d Traced Binary Mask Instead of Continuous Field + +**Problem:** Extruded meshes produced by `extrude_2d` showed pronounced staircase zigzag on any sidewall not aligned with the pixel grid. Docs images revealed jagged oblique walls despite the mesh being mathematically watertight. + +**Root cause:** `_trace_contours` ran `skimage.measure.find_contours` on the boolean binary mask at iso=0.5. Because the binary only has values 0 or 1, the zero-iso lies exactly at pixel edges — all sub-pixel information from the original continuous field (and any `smooth_sigma`) was thrown away by the threshold step before tracing. The main pipeline (`extract.py::_extract_2d`) already traces the continuous field directly, so the staircase artifact was unique to `extrude_2d`. + +**Resolution:** Added `_build_signed_field(field, binary, *, field_type, level, smooth_sigma)` which rebuilds the continuous signed field (positive inside, zero on boundary) and selectively clamps only the regions that the cleanup steps (`fill_holes`, `min_component_area`) actively removed — leaving natural boundaries symmetric so a solid binary block still traces at integer pixel boundaries. `_trace_contours` now dispatches on dtype: bool → old pixel-aligned behavior (kept for existing tests), float → zero-iso on the signed field with sub-pixel precision. `extrude_2d` uses the float path. All 269 tests still pass. Fix: `a31d6a1`. + +**Prevention:** When a tracer operates on a thresholded mask, it should either be documented as pixel-aligned OR operate on the continuous pre-threshold field. The choice is not neutral — any oblique geometry inherits the tracer's sampling. Cross-check new modules that trace iso-surfaces against the established pipeline: if the main pipeline uses continuous tracing, a new path should too unless there's an explicit reason to diverge. + +--- + ### 2026-04-15 — SDF→Density Converter Added **Problem:** Third-party consumers (EngiBench and density-only TO solvers) needed to feed SDF arrays into the density-mode pipeline, but no explicit conversion utility existed. Callers had to hand-roll thresholds, risking inconsistent iso-surface conventions. @@ -280,3 +292,27 @@ Images output to `website/public/images/getting-started/` and embedded in MDX pa **Resolution:** Updated both locations to use `ayu-light`. Files: `website/source.config.ts` (line 23) and `website/app/(home)/page.tsx` (line 40). **Prevention:** When changing site-wide visual settings (themes, fonts, colors), grep for all occurrences of the current value across the website directory — don't assume a single config file controls everything. Fumadocs' `rehypeCodeOptions` only applies to MDX content, not standalone `codeToHtml()` calls in page components. + +--- + +### 2026-04-21 — 2D Fields Had No Fabrication Output Path + +**Problem:** The repo could extract 2D marching-squares contours, but it had no supported path from a 2D density/SDF field to a fabrication-ready 3D mesh. EngiBench Beams2D-style inputs stopped at contours instead of producing STL/OBJ output. + +**Root cause:** The original pipeline architecture treated 2D extraction as a terminal contour artifact, and there was no standalone extrusion surface bridging 2D fields into the existing mesh export workflow. + +**Resolution:** Added a standalone `extrude_2d()` API plus `xtf extrude` CLI command, backed by binary cleanup, contour tracing, polygonization with hole preservation, earcut cap triangulation, and prism mesh assembly. Also added regression/property/fixture/CLI coverage. Fix: `9e2c69b`. + +**Prevention:** `extrude_2d` is the canonical 2D print path. If `preprocess.py` changes shared density-cleanup behavior, update `_build_binary` and the preprocess parity test in the same change. After shapely cleanup/clipping, normalize polygon winding before using ring orientation for 3D face emission. + +--- + +### 2026-04-21 — `python -m xeltofab.cli` Did Not Run the Click App + +**Problem:** Direct module invocation (`python -m xeltofab.cli extrude ...`) exited without creating output, even though the installed `xtf` console script worked. + +**Root cause:** `src/xeltofab/cli.py` defined the Click group and subcommands but had no `if __name__ == "__main__": main()` guard, so running the module executed only definitions. + +**Resolution:** Added the missing module-entry guard and a regression test covering `python -m xeltofab.cli extrude ...`. Fix: `df74c99`. + +**Prevention:** Any CLI module that is expected to work both as a console script target and via `python -m ...` needs an explicit `__main__` handoff plus a regression test for module invocation. diff --git a/pyproject.toml b/pyproject.toml index 0b54a38..7a42b2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "trimesh", "pydantic>=2.12.5", "matplotlib>=3.10.8", + "mapbox-earcut>=1.0.1", + "shapely>=2.0", "click>=8.3.1", "marimo>=0.20.4", "plotly>=6.6.0", @@ -67,4 +69,5 @@ dev = [ "marimo>=0.20.2", "pytest>=9.0.2", "ruff>=0.15.2", + "ty>=0.0.32", ] diff --git a/scripts/generate_doc_images.py b/scripts/generate_doc_images.py index d882a51..aeed559 100644 --- a/scripts/generate_doc_images.py +++ b/scripts/generate_doc_images.py @@ -5,7 +5,8 @@ Valid --only values: pipeline_diagram, pipeline_stages, field_types, parameter_sensitivity, quality_metrics, hero_overview, - quickstart_2d, quickstart_smoothing, hero_compare + quickstart_2d, quickstart_smoothing, extrude_2d_contour, + extrude_2d_mesh, hero_compare """ from __future__ import annotations @@ -831,6 +832,117 @@ def gen_quickstart_smoothing() -> None: plt.close(fig) +_EXTRUDE_FIXTURE = "data/examples/beams_2d_100x200_sample1.npy" + + +def gen_extrude_2d_contour() -> None: + """2D input field + traced shells/holes for the extrude_2d docs.""" + from matplotlib.lines import Line2D + from shapely.geometry import LinearRing + + from xeltofab.extrude import _build_binary, _build_signed_field, _trace_contours + from xeltofab.io import load_field + + state = load_field(_EXTRUDE_FIXTURE) + field = state.field + + binary = _build_binary( + field, + field_type="density", + level=0.5, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=0, + ) + signed = _build_signed_field( + field, + binary, + field_type="density", + level=0.5, + smooth_sigma=0.0, + ) + contours = _trace_contours(signed) + + fig, (ax_in, ax_out) = plt.subplots(1, 2, figsize=(10, 3.2)) + fig.patch.set_facecolor(BG_COLOR) + + ax_in.imshow(field, cmap="YlOrRd", origin="lower", vmin=0, vmax=1) + ax_in.set_title("Input Field", fontsize=11, fontweight="bold") + ax_in.axis("off") + + ax_out.imshow(binary, cmap="Greys", origin="lower", alpha=0.3) + shell_color = "#265E8A" + hole_color = "#D45087" + for contour in contours: + if len(contour) < 4: + continue + ring = LinearRing(contour) + color = shell_color if ring.is_ccw else hole_color + ax_out.plot(contour[:, 0], contour[:, 1], color=color, linewidth=1.6) + ax_out.legend( + handles=[ + Line2D([], [], color=shell_color, lw=2, label="Shell (CCW)"), + Line2D([], [], color=hole_color, lw=2, label="Hole (CW)"), + ], + loc="upper right", + fontsize=9, + frameon=True, + framealpha=0.9, + ) + ax_out.set_title("Traced Shells & Holes", fontsize=11, fontweight="bold") + ax_out.axis("off") + + fig.tight_layout(pad=1.5) + fig.savefig(OUTPUT_DIR / "extrude-2d-contour.png", dpi=DPI, bbox_inches="tight", facecolor=BG_COLOR) + plt.close(fig) + + +def gen_extrude_2d_mesh() -> None: + """Isometric render of an extruded mesh for the extrude_2d docs.""" + import numpy as np + import pyvista as pv + + from xeltofab.extrude import extrude_2d + from xeltofab.io import load_field + + state = load_field(_EXTRUDE_FIXTURE) + mesh = extrude_2d(state.field, thickness=30.0) + + faces_pv = np.column_stack([np.full(len(mesh.faces), 3), mesh.faces]).ravel() + pv_mesh = pv.PolyData(mesh.vertices.astype(np.float64), faces_pv) + + # Flat shading (smooth_shading=False) with no edges: each cap facet + # uses its own +z face normal so the top reads as uniformly lit, + # revealing that it is actually planar (earcut's fan triangulation is + # geometrically flat; smooth shading averages normals across the + # cap-to-wall seam and falsely bands the cap). + pl = pv.Plotter(off_screen=True, window_size=[960, 540]) + pl.add_mesh( + pv_mesh, + color="#88BDE6", + smooth_shading=False, + ambient=0.25, + diffuse=0.7, + specular=0.15, + specular_power=10, + ) + pl.camera_position = "iso" + pl.camera.zoom(1.25) + pl.set_background(BG_COLOR) + img = pl.screenshot(return_img=True) + pl.close() + + fig, ax = plt.subplots(figsize=(7.5, 4.4)) + fig.patch.set_facecolor(BG_COLOR) + ax.imshow(img) + ax.set_title("Extruded 3D Mesh (thickness = 30)", fontsize=11, fontweight="bold") + ax.axis("off") + + fig.tight_layout(pad=0.6) + fig.savefig(OUTPUT_DIR / "extrude-2d-mesh.png", dpi=DPI, bbox_inches="tight", facecolor=BG_COLOR) + plt.close(fig) + + def gen_hero_compare() -> None: """Hero comparison slider images: before mesh, after mesh, input field.""" from PIL import Image @@ -1094,6 +1206,8 @@ def gen_extraction_gradient_quality() -> None: "hero_overview": gen_hero_overview, "quickstart_2d": gen_quickstart_2d, "quickstart_smoothing": gen_quickstart_smoothing, + "extrude_2d_contour": gen_extrude_2d_contour, + "extrude_2d_mesh": gen_extrude_2d_mesh, "hero_compare": gen_hero_compare, "extraction_comparison": gen_extraction_comparison, "extraction_gradient_quality": gen_extraction_gradient_quality, diff --git a/src/xeltofab/__init__.py b/src/xeltofab/__init__.py index f81f870..b64a812 100644 --- a/src/xeltofab/__init__.py +++ b/src/xeltofab/__init__.py @@ -6,6 +6,7 @@ sdf_to_density, sigmoid, ) +from xeltofab.extrude import extrude_2d from xeltofab.io import ( load_field, save_mesh, @@ -16,6 +17,7 @@ __all__ = [ "PipelineParams", "PipelineState", + "extrude_2d", "heaviside", "linear_ramp", "load_field", diff --git a/src/xeltofab/cli.py b/src/xeltofab/cli.py index 414d0a0..a650415 100644 --- a/src/xeltofab/cli.py +++ b/src/xeltofab/cli.py @@ -3,9 +3,11 @@ from __future__ import annotations from pathlib import Path +from typing import Literal import click +from xeltofab.extrude import extrude_2d from xeltofab.field_plots import plot_comparison from xeltofab.io import load_field, save_mesh from xeltofab.loaders import get_supported_formats @@ -201,3 +203,58 @@ def formats() -> None: exts = ", ".join(f["extensions"]) status = "available" if f["available"] else "missing" click.echo(f"{f['name']:<10} {exts:<20} {status:<12} {f['install_hint']}") + + +@main.command() +@click.argument("input_path", type=click.Path(exists=True, path_type=Path)) +@click.option("-o", "--output", "output_path", type=click.Path(path_type=Path), required=True) +@click.option("-t", "--thickness", type=float, required=True, help="Extrusion height in grid units (pixels)") +@click.option("-f", "--field-name", default=None, help="Field/variable name to extract from container formats") +@click.option("--shape", "shape_str", default=None, help="Grid shape for flat data, e.g. 25x50") +@click.option("--field-type", type=click.Choice(["density", "sdf"]), default="density", help="Input field type") +@click.option("--level", type=float, default=None, help="Threshold override (default: 0.5 density / 0.0 SDF)") +@click.option("--min-component-area", type=int, default=0, help="Drop components smaller than N pixels") +@click.option("--smooth-sigma", type=float, default=0.0, help="Pre-threshold Gaussian sigma (0 = disabled)") +@click.option("--fill-holes", is_flag=True, help="Morphologically close pinholes before tracing") +def extrude( + input_path: Path, + output_path: Path, + thickness: float, + field_name: str | None, + shape_str: str | None, + field_type: Literal["density", "sdf"], + level: float | None, + min_component_area: int, + smooth_sigma: float, + fill_holes: bool, +) -> None: + """Extrude a 2D scalar field into a 3D mesh (STL, OBJ, PLY, ...).""" + shape = _parse_shape(shape_str) if shape_str else None + + try: + state = load_field(input_path, field_name=field_name, shape=shape) + except (ValueError, KeyError, ImportError) as error: + raise click.ClickException(str(error)) from None + + if state.ndim != 2: + raise click.ClickException(f"extrude requires a 2D field; got {state.ndim}D. Use 'xtf process' for 3D inputs.") + + try: + mesh = extrude_2d( + state.field, + thickness=thickness, + field_type=field_type, + level=level, + min_component_area=min_component_area, + smooth_sigma=smooth_sigma, + fill_holes=fill_holes, + ) + except ValueError as error: + raise click.ClickException(str(error)) from None + + mesh.export(output_path) + click.echo(f"Saved extruded mesh to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/src/xeltofab/extrude.py b/src/xeltofab/extrude.py new file mode 100644 index 0000000..9dc0fcd --- /dev/null +++ b/src/xeltofab/extrude.py @@ -0,0 +1,275 @@ +"""2D field → 3D extrusion for fabrication-ready output. + +Turns a 2D density or SDF array into a watertight triangle mesh with +configurable extrusion thickness. Uses marching squares + shapely + +mapbox_earcut internally. Returns a trimesh.Trimesh; caller writes STL/OBJ/PLY +via mesh.export(...). +""" + +from __future__ import annotations + +import warnings +from typing import Literal + +import mapbox_earcut +import numpy as np +import trimesh +from scipy.ndimage import gaussian_filter +from shapely.geometry import GeometryCollection, LinearRing, MultiPolygon, Polygon, box +from shapely.geometry.polygon import orient +from shapely.ops import unary_union +from skimage.measure import find_contours +from skimage.morphology import closing, disk, opening, remove_small_objects + + +def _build_binary( + field: np.ndarray, + *, + field_type: Literal["density", "sdf"], + level: float | None, + smooth_sigma: float, + fill_holes: bool, + min_component_area: int, +) -> np.ndarray: + """Clean a 2D field to a bool binary mask.""" + eff_level = level if level is not None else (0.0 if field_type == "sdf" else 0.5) + smoothed = gaussian_filter(field, sigma=smooth_sigma) if smooth_sigma > 0.0 else field + + binary = smoothed <= eff_level if field_type == "sdf" else smoothed >= eff_level + + if fill_holes: + selem = disk(1) + binary = opening(binary, selem) + binary = closing(binary, selem) + + if min_component_area > 0: + binary = remove_small_objects(binary, max_size=min_component_area - 1) + + if not binary.any(): + raise ValueError("no material above threshold — check field values and level") + + return binary + + +def _trace_contours(data: np.ndarray) -> list[np.ndarray]: + """Trace closed contours in canonical (x, y) coordinates. + + Dispatches on dtype: a bool mask traces the pixel-aligned 0.5-iso of the + mask; a float signed field traces the zero-iso with sub-pixel precision. + ``extrude_2d`` drives the float path so sidewalls follow the continuous + iso-surface rather than pixel staircases. + """ + if data.dtype == bool: + padded = np.pad(data.astype(float), 1, constant_values=0.0) + raw = find_contours(padded, 0.5) + else: + pad_value = min(float(data.min()), 0.0) - 1.0 + padded = np.pad(data, 1, constant_values=pad_value) + raw = find_contours(padded, 0.0) + return [np.column_stack([contour[:, 1] - 1.0, contour[:, 0] - 1.0]) for contour in raw] + + +def _build_signed_field( + field: np.ndarray, + binary: np.ndarray, + *, + field_type: Literal["density", "sdf"], + level: float | None, + smooth_sigma: float, +) -> np.ndarray: + """Build a continuous signed field aligned with the cleaned binary mask. + + The returned array is positive where the material sits (inside the iso- + surface), negative outside, and zero on the boundary. Only pixels that + the cleanup steps (``fill_holes``, ``min_component_area``) actively + removed are clamped below zero — natural boundaries keep their + symmetric sub-pixel positions, so a solid binary block still traces as + an integer-sized rectangle. + """ + eff_level = level if level is not None else (0.0 if field_type == "sdf" else 0.5) + smoothed = gaussian_filter(field, sigma=smooth_sigma) if smooth_sigma > 0.0 else field + signed = (eff_level - smoothed) if field_type == "sdf" else (smoothed - eff_level) + cleanup_removed = (~binary) & (signed > 0.0) + if cleanup_removed.any(): + suppress = -(float(signed.max()) + 1.0) + signed = np.where(cleanup_removed, suppress, signed) + return signed + + +def _collect_polygons(geom: object) -> list[Polygon]: + """Recursively flatten polygonal output from shapely set operations.""" + if isinstance(geom, Polygon): + return [geom] if geom.area > 0 else [] + if isinstance(geom, (MultiPolygon, GeometryCollection)): + polygons: list[Polygon] = [] + for part in geom.geoms: + polygons.extend(_collect_polygons(part)) + return polygons + return [] + + +def _orient_polygon(poly: Polygon) -> Polygon: + """Normalize a polygon to CCW exterior and CW holes.""" + return orient(poly, sign=1.0) + + +def _polygonize( + contours: list[np.ndarray], + *, + height: int, + width: int, +) -> MultiPolygon: + """Build a clean, flush-to-edge MultiPolygon from raw contours.""" + shells: list[LinearRing] = [] + holes: list[LinearRing] = [] + for contour in contours: + if len(contour) < 4: + continue + ring = LinearRing(contour) + if ring.is_ccw: + shells.append(ring) + else: + holes.append(ring) + + if not shells: + raise ValueError("no valid shell polygons after contour tracing") + + shell_polys = [Polygon(shell) for shell in shells] + shell_holes: list[list[np.ndarray]] = [[] for _ in shell_polys] + for hole in holes: + hole_poly = Polygon(hole) + containing = [ + idx for idx, shell_poly in enumerate(shell_polys) if shell_poly.contains(hole_poly.representative_point()) + ] + if not containing: + continue + target = min(containing, key=lambda idx: shell_polys[idx].area) + shell_holes[target].append(np.asarray(hole.coords)) + + polygons = [ + Polygon(np.asarray(shell.coords), holes_for_shell) + for shell, holes_for_shell in zip(shells, shell_holes, strict=True) + ] + merged = unary_union(polygons) + cleaned = merged.buffer(0) + image_rect = box(0, 0, width - 1, height - 1) + snapped = cleaned.intersection(image_rect) + + if isinstance(snapped, Polygon): + return MultiPolygon([_orient_polygon(snapped)]) + if isinstance(snapped, MultiPolygon): + return MultiPolygon([_orient_polygon(poly) for poly in snapped.geoms]) + + polygons = _collect_polygons(snapped) + if not polygons: + raise ValueError("snapped geometry empty — no extrudable region") + return MultiPolygon([_orient_polygon(poly) for poly in polygons]) + + +def _triangulate_polygon(poly: Polygon) -> tuple[np.ndarray, np.ndarray]: + """Triangulate a shapely Polygon (with holes) via mapbox_earcut.""" + + def _ring_coords(ring: LinearRing) -> np.ndarray: + coords = np.asarray(ring.coords, dtype=np.float64) + if len(coords) >= 2 and np.allclose(coords[0], coords[-1]): + coords = coords[:-1] + return coords + + exterior = _ring_coords(poly.exterior) + holes = [_ring_coords(interior) for interior in poly.interiors] + + vertices = np.concatenate([exterior, *holes], axis=0) if holes else exterior + ring_ends = [len(exterior)] + for hole in holes: + ring_ends.append(ring_ends[-1] + len(hole)) + + flat_triangles = mapbox_earcut.triangulate_float64(vertices, np.asarray(ring_ends, dtype=np.uint32)) + triangles = np.asarray(flat_triangles, dtype=np.int64).reshape(-1, 3) + return vertices, triangles + + +def _build_prism_mesh(multi_poly: MultiPolygon, thickness: float) -> trimesh.Trimesh: + """Turn a MultiPolygon + thickness into a watertight trimesh.Trimesh.""" + all_vertices: list[np.ndarray] = [] + all_faces: list[np.ndarray] = [] + vertex_offset = 0 + + for poly in multi_poly.geoms: + vertices_2d, cap_triangles = _triangulate_polygon(poly) + count = len(vertices_2d) + + bottom = np.column_stack([vertices_2d, np.zeros(count)]) + top = np.column_stack([vertices_2d, np.full(count, thickness)]) + poly_vertices = np.vstack([bottom, top]) + + bottom_faces = cap_triangles[:, ::-1].copy() + top_faces = cap_triangles.copy() + count + + wall_faces: list[list[int]] = [] + ring_start = 0 + for ring in [poly.exterior, *poly.interiors]: + coords = np.asarray(ring.coords, dtype=np.float64) + if len(coords) >= 2 and np.allclose(coords[0], coords[-1]): + coords = coords[:-1] + ring_count = len(coords) + for idx in range(ring_count): + idx_next = (idx + 1) % ring_count + bottom_i = ring_start + idx + bottom_next = ring_start + idx_next + top_i = bottom_i + count + top_next = bottom_next + count + wall_faces.append([bottom_i, bottom_next, top_next]) + wall_faces.append([bottom_i, top_next, top_i]) + ring_start += ring_count + + poly_faces = np.vstack([bottom_faces, top_faces, np.asarray(wall_faces, dtype=np.int64)]) + all_vertices.append(poly_vertices) + all_faces.append(poly_faces + vertex_offset) + vertex_offset += len(poly_vertices) + + mesh = trimesh.Trimesh(vertices=np.vstack(all_vertices), faces=np.vstack(all_faces), process=True) + + if not (mesh.is_watertight and mesh.is_winding_consistent): + warnings.warn("extruded mesh is not watertight; printing may fail", stacklevel=2) + + return mesh + + +def extrude_2d( + field: np.ndarray, + thickness: float, + *, + field_type: Literal["density", "sdf"] = "density", + level: float | None = None, + min_component_area: int = 0, + smooth_sigma: float = 0.0, + fill_holes: bool = False, +) -> trimesh.Trimesh: + """Extrude a 2D field into a 3D triangle mesh for fabrication.""" + if field.ndim != 2: + raise ValueError(f"field must be 2D, got shape {field.shape}") + if thickness <= 0: + raise ValueError(f"thickness must be positive, got {thickness}") + if smooth_sigma < 0: + raise ValueError(f"smooth_sigma must be non-negative, got {smooth_sigma}") + if min_component_area < 0: + raise ValueError(f"min_component_area must be non-negative, got {min_component_area}") + binary = _build_binary( + field, + field_type=field_type, + level=level, + smooth_sigma=smooth_sigma, + fill_holes=fill_holes, + min_component_area=min_component_area, + ) + signed = _build_signed_field( + field, + binary, + field_type=field_type, + level=level, + smooth_sigma=smooth_sigma, + ) + contours = _trace_contours(signed) + height, width = binary.shape + multi_poly = _polygonize(contours, height=height, width=width) + return _build_prism_mesh(multi_poly, thickness=thickness) diff --git a/tests/test_cli_extrude.py b/tests/test_cli_extrude.py new file mode 100644 index 0000000..799c3d8 --- /dev/null +++ b/tests/test_cli_extrude.py @@ -0,0 +1,93 @@ +"""Tests for the `xtf extrude` CLI subcommand.""" + +import subprocess +import sys +from pathlib import Path + +import numpy as np +import trimesh +from click.testing import CliRunner + +from xeltofab.cli import main + + +def test_cli_extrude_beam(tmp_path: Path): + """Full CLI round-trip: load .npy → extrude → STL.""" + field = np.zeros((10, 10), dtype=float) + field[2:8, 2:8] = 1.0 + input_path = tmp_path / "field.npy" + np.save(input_path, field) + output_path = tmp_path / "part.stl" + + runner = CliRunner() + result = runner.invoke( + main, + ["extrude", str(input_path), "-o", str(output_path), "-t", "5"], + ) + assert result.exit_code == 0, result.output + assert output_path.exists() + mesh = trimesh.load(output_path, force="mesh") + assert mesh.volume > 0 + + +def test_cli_extrude_obj_suffix(tmp_path: Path): + """Output format follows the file suffix — no --format flag needed.""" + field = np.ones((5, 5), dtype=float) + input_path = tmp_path / "field.npy" + np.save(input_path, field) + output_path = tmp_path / "part.obj" + + runner = CliRunner() + result = runner.invoke( + main, + ["extrude", str(input_path), "-o", str(output_path), "-t", "2"], + ) + assert result.exit_code == 0, result.output + assert output_path.exists() + + +def test_cli_extrude_rejects_3d_field(tmp_path: Path): + """3D input produces a friendly error pointing to the `process` subcommand.""" + field = np.ones((5, 5, 5), dtype=float) + input_path = tmp_path / "volume.npy" + np.save(input_path, field) + output_path = tmp_path / "part.stl" + + runner = CliRunner() + result = runner.invoke( + main, + ["extrude", str(input_path), "-o", str(output_path), "-t", "5"], + ) + assert result.exit_code != 0 + assert "2D" in result.output + assert "process" in result.output + + +def test_module_invocation_extrude_beam(tmp_path: Path): + """`python -m xeltofab.cli` supports the extrude subcommand.""" + field = np.zeros((10, 10), dtype=float) + field[2:8, 2:8] = 1.0 + input_path = tmp_path / "field.npy" + np.save(input_path, field) + output_path = tmp_path / "part.stl" + + result = subprocess.run( + [ + sys.executable, + "-m", + "xeltofab.cli", + "extrude", + str(input_path), + "-o", + str(output_path), + "-t", + "5", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + assert output_path.exists() + mesh = trimesh.load(output_path, force="mesh") + assert mesh.volume > 0 diff --git a/tests/test_extrude.py b/tests/test_extrude.py new file mode 100644 index 0000000..d92642a --- /dev/null +++ b/tests/test_extrude.py @@ -0,0 +1,448 @@ +"""Tests for xeltofab.extrude.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +import trimesh as _trimesh +from shapely.geometry import MultiPolygon, Polygon + +import xeltofab as xtf +from xeltofab.extrude import _build_binary, _build_prism_mesh, _polygonize, _trace_contours, _triangulate_polygon + +FIXTURE_DIR = Path(__file__).parent.parent / "data" / "examples" + + +def test_extrude_module_importable(): + """The extrude_2d symbol is reachable via the package root.""" + assert callable(xtf.extrude_2d) + + +def test_rejects_1d_field(): + with pytest.raises(ValueError, match="2D"): + xtf.extrude_2d(np.ones(10), thickness=5.0) + + +def test_rejects_3d_field(): + with pytest.raises(ValueError, match="2D"): + xtf.extrude_2d(np.ones((4, 4, 4)), thickness=5.0) + + +def test_rejects_zero_thickness(): + with pytest.raises(ValueError, match="thickness"): + xtf.extrude_2d(np.ones((4, 4)), thickness=0.0) + + +def test_rejects_negative_thickness(): + with pytest.raises(ValueError, match="thickness"): + xtf.extrude_2d(np.ones((4, 4)), thickness=-1.0) + + +def test_rejects_negative_smooth_sigma(): + with pytest.raises(ValueError, match="smooth_sigma"): + xtf.extrude_2d(np.ones((4, 4)), thickness=1.0, smooth_sigma=-0.5) + + +def test_rejects_negative_min_component_area(): + with pytest.raises(ValueError, match="min_component_area"): + xtf.extrude_2d(np.ones((4, 4)), thickness=1.0, min_component_area=-1) + + +def test_binary_density_default_level(): + """Density field thresholds at 0.5 by default.""" + field = np.array([[0.2, 0.8], [0.6, 0.4]]) + binary = _build_binary( + field, + field_type="density", + level=None, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=0, + ) + np.testing.assert_array_equal(binary, [[False, True], [True, False]]) + + +def test_binary_density_custom_level(): + """Density field honors explicit level.""" + field = np.array([[0.2, 0.8], [0.6, 0.4]]) + binary = _build_binary( + field, + field_type="density", + level=0.7, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=0, + ) + np.testing.assert_array_equal(binary, [[False, True], [False, False]]) + + +def test_binary_sdf_inside_is_negative(): + """SDF threshold: material where value <= level (default 0.0).""" + field = np.array([[-0.5, 0.5], [-0.1, 0.1]]) + binary = _build_binary( + field, + field_type="sdf", + level=None, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=0, + ) + np.testing.assert_array_equal(binary, [[True, False], [True, False]]) + + +def test_binary_empty_raises(): + """All-below-threshold input raises ValueError.""" + field = np.zeros((4, 4)) + with pytest.raises(ValueError, match="no material"): + _build_binary( + field, + field_type="density", + level=None, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=0, + ) + + +def test_binary_gaussian_smooth_bridges_gap(): + """Smoothing a single-pixel gap between two blocks bridges it before threshold.""" + field = np.zeros((5, 9), dtype=float) + field[:, :4] = 1.0 + field[:, 5:] = 1.0 + b0 = _build_binary( + field, + field_type="density", + level=0.5, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=0, + ) + assert not b0[:, 4].any() + b1 = _build_binary( + field, + field_type="density", + level=0.5, + smooth_sigma=2.0, + fill_holes=False, + min_component_area=0, + ) + assert b1[:, 4].any() + + +def test_binary_fill_holes_closes_pinhole(): + """fill_holes=True morphologically closes a 1-pixel pinhole.""" + field = np.ones((5, 5), dtype=float) + field[2, 2] = 0.0 + b_off = _build_binary( + field, + field_type="density", + level=0.5, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=0, + ) + assert not b_off[2, 2] + b_on = _build_binary( + field, + field_type="density", + level=0.5, + smooth_sigma=0.0, + fill_holes=True, + min_component_area=0, + ) + assert b_on[2, 2] + + +def test_binary_min_component_area_drops_orphan(): + """min_component_area removes small disconnected islands.""" + field = np.zeros((10, 10), dtype=float) + field[1:4, 1:4] = 1.0 + field[7:9, 7:9] = 1.0 + b = _build_binary( + field, + field_type="density", + level=0.5, + smooth_sigma=0.0, + fill_holes=False, + min_component_area=5, + ) + assert b[1:4, 1:4].all() + assert not b[7:9, 7:9].any() + + +def test_trace_contours_single_blob(): + """A single filled rectangle in the interior produces one closed contour.""" + binary = np.zeros((10, 10), dtype=bool) + binary[3:7, 3:7] = True + contours = _trace_contours(binary) + assert len(contours) == 1 + c = contours[0] + np.testing.assert_allclose(c[0], c[-1]) + assert c[:, 0].min() >= 2.4 + assert c[:, 0].max() <= 6.6 + + +def test_trace_contours_two_disjoint_blobs(): + """Two disjoint filled regions produce two contours.""" + binary = np.zeros((10, 20), dtype=bool) + binary[2:5, 2:5] = True + binary[2:5, 12:15] = True + contours = _trace_contours(binary) + assert len(contours) == 2 + + +def test_trace_contours_blob_on_boundary_is_closed(): + """Material touching the image edge still yields a closed contour (via the zero pad).""" + binary = np.zeros((10, 10), dtype=bool) + binary[0:5, 0:5] = True + contours = _trace_contours(binary) + assert len(contours) == 1 + c = contours[0] + np.testing.assert_allclose(c[0], c[-1]) + + +def _contour_square(x0, x1, y0, y1): + """Helper: closed contour for an axis-aligned rectangle in (x, y).""" + return np.array( + [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]], + dtype=float, + ) + + +def test_polygonize_single_blob(): + """A single contour becomes one polygon inside a MultiPolygon.""" + contours = [_contour_square(2, 6, 2, 6)] + mp = _polygonize(contours, height=10, width=10) + assert isinstance(mp, MultiPolygon) + assert len(mp.geoms) == 1 + assert mp.geoms[0].area == pytest.approx(16.0, rel=0.05) + + +def test_polygonize_with_interior_hole(): + """Outer CCW + inner CW rings produce a polygon with one hole.""" + outer = _contour_square(1, 9, 1, 9) + inner = _contour_square(3, 7, 3, 7)[::-1] + mp = _polygonize([outer, inner], height=10, width=10) + assert len(mp.geoms) == 1 + poly = mp.geoms[0] + assert len(poly.interiors) == 1 + assert poly.area == pytest.approx(64 - 16, rel=0.05) + + +def test_polygonize_snaps_to_image_rectangle(): + """When material touches the image edge, the resulting polygon has flush coords.""" + contours = [_contour_square(-0.5, 9.5, -0.5, 9.5)] + mp = _polygonize(contours, height=10, width=10) + assert len(mp.geoms) == 1 + minx, miny, maxx, maxy = mp.geoms[0].bounds + assert minx == pytest.approx(0.0) + assert miny == pytest.approx(0.0) + assert maxx == pytest.approx(9.0) + assert maxy == pytest.approx(9.0) + + +def test_polygonize_two_disjoint(): + """Two separate contours → MultiPolygon with two geoms.""" + contours = [_contour_square(1, 3, 1, 3), _contour_square(1, 3, 6, 8)] + mp = _polygonize(contours, height=10, width=10) + assert len(mp.geoms) == 2 + + +def test_polygonize_corner_hugging_material(): + """Material touching two adjacent image edges snaps flush on both axes.""" + contours = [_contour_square(-0.5, 4.5, -0.5, 4.5)] + mp = _polygonize(contours, height=10, width=10) + assert len(mp.geoms) == 1 + minx, miny, maxx, maxy = mp.geoms[0].bounds + assert minx == pytest.approx(0.0) + assert miny == pytest.approx(0.0) + assert maxx == pytest.approx(4.5) + assert maxy == pytest.approx(4.5) + + +def test_triangulate_simple_square(): + """A 4-vertex square triangulates into 2 triangles, 4 vertices.""" + poly = Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]) + verts, tris = _triangulate_polygon(poly) + assert verts.shape == (4, 2) + assert tris.shape == (2, 3) + assert tris.max() < len(verts) + assert tris.min() >= 0 + + +def test_triangulate_polygon_with_hole(): + """A square with a central hole yields outer+hole vertices and enough triangles.""" + outer = [(0, 0), (10, 0), (10, 10), (0, 10)] + hole = [(3, 3), (7, 3), (7, 7), (3, 7)] + poly = Polygon(outer, [hole]) + verts, tris = _triangulate_polygon(poly) + assert verts.shape == (8, 2) + assert len(tris) >= 8 + assert tris.max() < len(verts) + + +def test_triangulate_skips_closing_duplicate(): + """Shapely exteriors repeat the first point; _triangulate_polygon must drop it.""" + poly = Polygon([(0, 0), (4, 0), (4, 4), (0, 4), (0, 0)]) + verts, _ = _triangulate_polygon(poly) + assert verts.shape == (4, 2) + + +def test_prism_mesh_from_square_is_watertight(): + poly = Polygon([(0, 0), (9, 0), (9, 9), (0, 9)]) + mp = MultiPolygon([poly]) + mesh = _build_prism_mesh(mp, thickness=5.0) + assert mesh.is_watertight + assert mesh.is_winding_consistent + + +def test_prism_mesh_volume_matches_extrusion(): + """Volume = polygon_area * thickness.""" + poly = Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]) + mp = MultiPolygon([poly]) + mesh = _build_prism_mesh(mp, thickness=3.0) + assert mesh.volume == pytest.approx(4 * 4 * 3.0, rel=1e-6) + + +def test_prism_mesh_z_bounds_start_at_zero(): + poly = Polygon([(0, 0), (4, 0), (4, 4), (0, 4)]) + mp = MultiPolygon([poly]) + mesh = _build_prism_mesh(mp, thickness=7.0) + zmin = mesh.vertices[:, 2].min() + zmax = mesh.vertices[:, 2].max() + assert zmin == pytest.approx(0.0) + assert zmax == pytest.approx(7.0) + + +def test_prism_mesh_polygon_with_hole_is_watertight(): + """Annulus extrudes to a watertight genus-1 shell.""" + outer = [(0, 0), (10, 0), (10, 10), (0, 10)] + hole = [(3, 3), (7, 3), (7, 7), (3, 7)][::-1] + poly = Polygon(outer, [hole]) + mp = MultiPolygon([poly]) + mesh = _build_prism_mesh(mp, thickness=2.0) + assert mesh.is_watertight + assert mesh.volume == pytest.approx(168.0, rel=1e-3) + + +def test_prism_mesh_two_disjoint_blobs(): + a = Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) + b = Polygon([(5, 5), (7, 5), (7, 7), (5, 7)]) + mp = MultiPolygon([a, b]) + mesh = _build_prism_mesh(mp, thickness=1.0) + split = mesh.split(only_watertight=True) + assert len(split) == 2 + for piece in split: + assert piece.is_watertight + + +def test_extrude_2d_square_end_to_end(): + field = np.ones((10, 10), dtype=float) + mesh = xtf.extrude_2d(field, thickness=5.0) + assert mesh.is_watertight + assert mesh.volume == pytest.approx(9 * 9 * 5.0, rel=1e-3) + + +def test_extrude_2d_centered_hole(): + field = np.zeros((20, 20), dtype=float) + field[2:18, 2:18] = 1.0 + field[8:12, 8:12] = 0.0 + mesh = xtf.extrude_2d(field, thickness=3.0) + assert mesh.is_watertight + assert mesh.volume > 0 + assert mesh.volume < 20 * 20 * 3.0 + assert mesh.euler_number == 0 + + +def test_extrude_2d_two_disjoint_blobs(): + field = np.zeros((10, 20), dtype=float) + field[2:5, 2:5] = 1.0 + field[2:5, 12:15] = 1.0 + mesh = xtf.extrude_2d(field, thickness=2.0) + split = mesh.split(only_watertight=True) + assert len(split) == 2 + + +def test_extrude_2d_boundary_flush(): + """Full-image square produces a mesh with vertices at exactly x=0 and x=W-1.""" + field = np.ones((10, 10), dtype=float) + mesh = xtf.extrude_2d(field, thickness=1.0) + xs = mesh.vertices[:, 0] + ys = mesh.vertices[:, 1] + assert np.isclose(xs.min(), 0.0) + assert np.isclose(xs.max(), 9.0) + assert np.isclose(ys.min(), 0.0) + assert np.isclose(ys.max(), 9.0) + + +def test_extrude_2d_sdf_input(): + """SDF input with level=0.0 extrudes the inside (negative-value) region.""" + y, x = np.mgrid[-10:10, -10:10].astype(float) + sdf = np.sqrt(x**2 + y**2) - 5.0 + mesh = xtf.extrude_2d(sdf, thickness=2.0, field_type="sdf") + assert mesh.is_watertight + assert mesh.volume == pytest.approx(78.5 * 2.0, rel=0.15) + + +def test_volume_monotone_in_thickness(): + """Same field, different thickness: volume scales linearly.""" + field = np.zeros((10, 10), dtype=float) + field[2:8, 2:8] = 1.0 + v1 = xtf.extrude_2d(field, thickness=1.0).volume + v5 = xtf.extrude_2d(field, thickness=5.0).volume + v10 = xtf.extrude_2d(field, thickness=10.0).volume + assert v5 == pytest.approx(v1 * 5.0, rel=1e-6) + assert v10 == pytest.approx(v1 * 10.0, rel=1e-6) + + +def test_cap_area_tracks_binary_pixel_count(): + """Projected area tracks material pixel count within a loose discretization bound.""" + field = np.zeros((12, 12), dtype=float) + field[3:9, 3:9] = 1.0 + mesh = xtf.extrude_2d(field, thickness=1.0) + projected_area = mesh.volume + assert projected_area == pytest.approx(36.0, rel=0.15) + + +def test_beams2d_25x50_extrusion(tmp_path): + field = np.load(FIXTURE_DIR / "beams_2d_25x50_sample0.npy") + assert field.ndim == 2 + mesh = xtf.extrude_2d(field, thickness=10.0) + assert mesh.volume > 0 + stl_path = tmp_path / "beam.stl" + mesh.export(stl_path) + loaded = _trimesh.load(stl_path, force="mesh") + assert len(loaded.vertices) > 0 + + +def test_beams2d_100x200_extrusion(): + field = np.load(FIXTURE_DIR / "beams_2d_100x200_sample1.npy") + mesh = xtf.extrude_2d(field, thickness=15.0, min_component_area=10) + assert mesh.volume > 0 + assert len(mesh.faces) < 500_000 + + +def test_density_preprocess_parity(): + """extrude_2d's internal binary matches preprocess() when parameters align.""" + from xeltofab.preprocess import preprocess as pipeline_preprocess + from xeltofab.state import PipelineParams, PipelineState + + field = np.load(FIXTURE_DIR / "beams_2d_25x50_sample0.npy") + params = PipelineParams( + threshold=0.5, + smooth_sigma=1.0, + morph_radius=1, + ) + state = pipeline_preprocess(PipelineState(field=field, params=params)) + heur_max_size = max(field.size // 200, 8) - 1 + our_binary = _build_binary( + field, + field_type="density", + level=0.5, + smooth_sigma=1.0, + fill_holes=True, + min_component_area=heur_max_size + 1, + ) + np.testing.assert_array_equal(our_binary, state.binary.astype(bool)) diff --git a/uv.lock b/uv.lock index fa01062..f123c6f 100644 --- a/uv.lock +++ b/uv.lock @@ -484,6 +484,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/8e/13e4564898f15d16a4081aba6397597de319f993ed43181aa5e324bf05db/manifold3d-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:fb530756a54a0a19ff732f35df130ec304db233dbc8d2fcef5a4818fa657b0a6", size = 992597, upload-time = "2026-02-27T10:40:38.507Z" }, ] +[[package]] +name = "mapbox-earcut" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/7b/bbf6b00488662be5d2eb7a188222c264b6f713bac10dc4a77bf37a4cb4b6/mapbox_earcut-2.0.0.tar.gz", hash = "sha256:81eab6b86cf99551deb698b98e3f7502c57900e5c479df15e1bdaf1a57f0f9d6", size = 39934, upload-time = "2025-11-16T18:41:27.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/7c/c5dd5b255b9828ba5df729e62fdd470a322c938f07ef392ca03c0592bb3a/mapbox_earcut-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:582329a81bd36cf0f82e443c395bcb8cfdb10caddafec76acaebac7c20bf1c31", size = 55619, upload-time = "2025-11-16T18:40:44.44Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3f/03f23eac9831e7d0d8da3d6993695a9a3724659c94e9997f6b7aaccc199d/mapbox_earcut-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d2ac5f610b3e44a3a0c4df06b5552d503b4f1c2c409eeca20dbe05112bd60955", size = 52023, upload-time = "2025-11-16T18:40:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/39/f3/a92ccee494b3e437e4bd81ecd358e39d231dc90af010d6c43930506c10ad/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58cc88513b87734b243d86f0d3fb87e96e0a78d9abd8fd615c55f766dd63f949", size = 56357, upload-time = "2025-11-16T18:40:47.27Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/e54ececd0403a5495c340b693075abec92a6d17dc44283b6cb059534f7ed/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40218d887798451932f3c335992834aa807c35cd497c6e0733470fdbd77f9521", size = 59215, upload-time = "2025-11-16T18:40:48.682Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/8fbff13a074c1fbf702b30ce7ec4d878bc664d659c1c2b1697831f4ea3a8/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:39fa5cfa0e855b028ec9b0200c88ebfa252448f343ce2f67b6fc07fe1f22a3ae", size = 152304, upload-time = "2025-11-16T18:40:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c757030b3cb3a9f2278ded6f7312d2b9d3761db6f3da8d395f7f7303dd66/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:476b558473b8a43f238d46e819bc0f830c427842ec5feb19e23b4dcac8ad2455", size = 157270, upload-time = "2025-11-16T18:40:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/96/63/589c6decb1f032d8811f1066da552f0a718830f592e6d6539fa4c3c766b8/mapbox_earcut-2.0.0-cp313-cp313-win32.whl", hash = "sha256:8c2d125c182acbc490b39503c0dec4f937bae180d0849a26bcea0ee4a76024bd", size = 51207, upload-time = "2025-11-16T18:40:52.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/75/a79a6020c46d4f07731e88ec5cc9324f6b43343aba835def1dc0bf59fecf/mapbox_earcut-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e049e6a37c228d7a9cb2f54ae405aa21d35c5175d849530fb32064ddb38ad5ab", size = 56416, upload-time = "2025-11-16T18:40:53.474Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5f/83e878c2b3e9e6db1f60b598a2cc5ed4c2b5bc8d281575c964869414a159/mapbox_earcut-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:8a833d73d63d4b6291bbd8b4d2f551e87f663282cdc547ecbbd9b423849ee996", size = 50103, upload-time = "2025-11-16T18:40:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/f1b74324c83f510213ff91eb8b1d2697ad5a12418c5fba966e80f1104a5f/mapbox_earcut-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad1dc141797037b7d4c9d8d2e52b9665b36294913a8ec31008b282d1a95b9bdc", size = 55728, upload-time = "2025-11-16T18:40:56.098Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/053c04e29c4bd22157d3b6255f1e5c19c46cb7a594c4314298bdcbca723f/mapbox_earcut-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0f0f5c6f5ed8ffdce8efe6a003ba598089d0ee07eabd41868db183be50484f9f", size = 52063, upload-time = "2025-11-16T18:40:57.227Z" }, + { url = "https://files.pythonhosted.org/packages/a6/77/acc2d553c3bb8c769535a280545bb7d9608141e90511a2e6215a54611776/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82cd92775f37fd1e4b8464c5e74a00e87130eecc55ee3df2492b8ca2bdf6ef3e", size = 56522, upload-time = "2025-11-16T18:40:58.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/627dd6defd3c1a2b3069e9e27482aa04d268c841735e576c1e22848a34f6/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:626ffc1310e0cc8910283e4ac3139e5fb0458f18f2c4874162f66159951933ff", size = 59204, upload-time = "2025-11-16T18:41:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3e/819185542ab095ba1244ad65ececb3edcde6fd0111248a0f9318d695bfcf/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea951d764a356cad95b23fef950d8aa3b44b933795ad09d977fea7d4dbe377c3", size = 152550, upload-time = "2025-11-16T18:41:01.233Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ad/85e0f815e4774b90ad6761bce55c80d13ee21b2a24014b0be0d5010b0049/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:df1f217624abb5e02ecabcbd84369de970b8d8bc1e4e7c164c1cfcaddad76ca3", size = 157322, upload-time = "2025-11-16T18:41:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0f56369e7a000d2f3177d17baf34263559b206ae524fcd0c4c5d1d960dab/mapbox_earcut-2.0.0-cp314-cp314-win32.whl", hash = "sha256:6fa61307d38b50fc9bd5449c00dbae46d270a32b372c6fc3b8af4b85c85746e4", size = 52916, upload-time = "2025-11-16T18:41:04.122Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9d/8c557dd9b3d9fe2344f5bd5ff3bb0b2a42ed6addb7e43ca4358051743b04/mapbox_earcut-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:0da20ed3c81b240450118773bcedfac34e70a56998f66147222c46f4356fff67", size = 57713, upload-time = "2025-11-16T18:41:05.204Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ec/678c5553938d3a29d02dd41dd898672267f054afc4e2821958dee6ec86ce/mapbox_earcut-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:847e74bd5878e4c64793dc100f9288f5443f87c55c3fe391fd90509029136ff6", size = 51872, upload-time = "2025-11-16T18:41:06.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/94f2d973669cbfef811e536713fe56ec012ba74e5f8795a832337b1866a3/mapbox_earcut-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ddc9e7175fc903185c64afbbf91febee56b50787dd0962fce2bfb4f20cf80d27", size = 56447, upload-time = "2025-11-16T18:41:07.443Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1c/e0afcc82659cc1727a7e59c4f9e9880bbc3f048a4a5325772b44d4a91dfd/mapbox_earcut-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6dc8a7568066af9a858018d6d92b7e77e164578f9fcd79093f1cbe4ec203461b", size = 53154, upload-time = "2025-11-16T18:41:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2d/9845281c8c35da2bea733b8c2df5b9fe694e73e7b05fe8a1d4c3c439a1bc/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6abc5340edd9b433ab2dab2ee033082a199d5c51cce445124626c0040ec0d81b", size = 56285, upload-time = "2025-11-16T18:41:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/97/8e/eeea762a519490662b8f480e2b35bf03701b0bcc5a446b62a4c5a1500b06/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df7afdd8078a9aa28f469d9242531d304e09a4b14e514f048e021a949f3777b4", size = 58601, upload-time = "2025-11-16T18:41:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/b9/67/932f80aa6af9bc1a317b6119052c74f327d81e00b457003a049e324b810c/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a286f73e612a46cafd6d6c843365265090517af16823e2f37277c13cd8b6f09", size = 154924, upload-time = "2025-11-16T18:41:12.104Z" }, + { url = "https://files.pythonhosted.org/packages/87/38/5db4a91f9f90cbb447be61da5468a2955fad3a840ae4c7dbde789b09d45a/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8d081fe1d00dc553e3e68c02fc395324aad0d8ed955f3ff59289264c9b21ace4", size = 159194, upload-time = "2025-11-16T18:41:13.364Z" }, + { url = "https://files.pythonhosted.org/packages/6b/03/de3843b13fe854a010fb2f8b25551d4d5fe1c879ff2e7c8d7d8d7d735a8e/mapbox_earcut-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:13049ca96431bbc7ef7fd7780dd1872209ca11a5c1977f7aa91a1b574a8af863", size = 54143, upload-time = "2025-11-16T18:41:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/9a/89/fbdee5a56ba51df9be6098b5428636ad75aa994e98d8bec6113d5cba401e/mapbox_earcut-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ace78e4fdba3b8cbb7768d44d77a981698305862a07f94bbb6f5cc16659adb4", size = 60833, upload-time = "2025-11-16T18:41:15.694Z" }, +] + [[package]] name = "marimo" version = "0.20.4" @@ -1233,6 +1270,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/d7/023ba290cfaf97b21c710b675b8a860b97d8226f62e35d7a08e37ddbb6d3/scs-3.2.11-cp314-cp314t-win_amd64.whl", hash = "sha256:7fe26e8a0efc96232f4c5b7649817e48dae04a61be911417e925071091b8cbf6", size = 7570221, upload-time = "2026-01-09T17:53:42.845Z" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1287,6 +1367,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/b9/da09903ea53b677a58ba770112de6fe8b2acb8b4cd9bffae4ff6cfe7c072/trimesh-4.11.2-py3-none-any.whl", hash = "sha256:25e3ab2620f9eca5c9376168c67aabdd32205dad1c4eea09cd45cd4a3edf775a", size = 740328, upload-time = "2026-02-10T16:00:25.246Z" }, ] +[[package]] +name = "ty" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, + { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, + { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, + { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1394,6 +1498,7 @@ version = "0.3.4" source = { editable = "." } dependencies = [ { name = "click" }, + { name = "mapbox-earcut" }, { name = "marimo" }, { name = "matplotlib" }, { name = "numpy" }, @@ -1402,6 +1507,7 @@ dependencies = [ { name = "pyfqmr" }, { name = "scikit-image" }, { name = "scipy" }, + { name = "shapely" }, { name = "trimesh" }, ] @@ -1439,6 +1545,7 @@ dev = [ { name = "marimo" }, { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -1452,6 +1559,7 @@ requires-dist = [ { name = "isoext", marker = "extra == 'cuda'", specifier = ">=0.5" }, { name = "manifold3d", marker = "extra == 'all'", specifier = ">=3.0" }, { name = "manifold3d", marker = "extra == 'manifold'", specifier = ">=3.0" }, + { name = "mapbox-earcut", specifier = ">=1.0.1" }, { name = "marimo", specifier = ">=0.20.4" }, { name = "matplotlib", specifier = ">=3.10.8" }, { name = "numpy" }, @@ -1465,6 +1573,7 @@ requires-dist = [ { name = "pyvista", marker = "extra == 'vtk'", specifier = ">=0.43" }, { name = "scikit-image" }, { name = "scipy" }, + { name = "shapely", specifier = ">=2.0" }, { name = "trimesh" }, ] provides-extras = ["vtk", "hdf5", "mesh-quality", "manifold", "cuda", "all-formats", "all"] @@ -1474,4 +1583,5 @@ dev = [ { name = "marimo", specifier = ">=0.20.2" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "ruff", specifier = ">=0.15.2" }, + { name = "ty", specifier = ">=0.0.32" }, ] diff --git a/website/bun.lock b/website/bun.lock index c619931..49861d5 100644 --- a/website/bun.lock +++ b/website/bun.lock @@ -261,7 +261,7 @@ "@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="], - "@react-three/fiber": ["@react-three/fiber@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA=="], + "@react-three/fiber": ["@react-three/fiber@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA=="], "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], @@ -287,35 +287,35 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.4", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", "tailwindcss": "4.2.4" } }, "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], @@ -335,7 +335,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], @@ -537,29 +537,29 @@ "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], - "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -713,7 +713,7 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], @@ -723,9 +723,9 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-medium-image-zoom": ["react-medium-image-zoom@5.4.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g=="], @@ -807,7 +807,7 @@ "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -837,7 +837,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], diff --git a/website/content/docs/api/extrude-2d.mdx b/website/content/docs/api/extrude-2d.mdx new file mode 100644 index 0000000..d1e4b6f --- /dev/null +++ b/website/content/docs/api/extrude-2d.mdx @@ -0,0 +1,135 @@ +--- +title: extrude_2d +description: Turn a 2D scalar field into a watertight extruded triangle mesh +--- + +2D cantilever beam density field extruded into a 3D mesh + +## Import + +```python +from xeltofab import extrude_2d +``` + +## Signature + +```python +def extrude_2d( + field: np.ndarray, + thickness: float, + *, + field_type: Literal["density", "sdf"] = "density", + level: float | None = None, + min_component_area: int = 0, + smooth_sigma: float = 0.0, + fill_holes: bool = False, +) -> trimesh.Trimesh +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `field` | `ndarray` | — | 2D scalar field. Density in `[0, 1]` or an SDF (unbounded, negative inside). | +| `thickness` | `float` | — | Extrusion height along +z, in the same grid units as x and y. Must be `> 0`. | +| `field_type` | `"density" \| "sdf"` | `"density"` | Selects the threshold direction: `density` keeps `field >= level`; `sdf` keeps `field <= level`. | +| `level` | `float \| None` | `None` | Iso-level override. Defaults to `0.5` for density and `0.0` for SDF. | +| `min_component_area` | `int` | `0` | Drop connected components smaller than this many pixels. `0` disables the filter. | +| `smooth_sigma` | `float` | `0.0` | Pre-threshold Gaussian sigma. `0.0` skips smoothing. Useful for noisy SDFs or speckled density fields. | +| `fill_holes` | `bool` | `False` | Apply a morphological opening+closing with a `disk(1)` kernel before tracing, closing single-pixel pinholes. | + +## Return value + +A [`trimesh.Trimesh`](https://trimesh.org/) instance representing the extruded prism. When the input has clean, non-touching contours the mesh is watertight and winding-consistent; `trimesh`'s mesh processing (vertex merge + winding fix) runs automatically via `process=True`. The caller is responsible for writing the mesh to disk with `mesh.export("part.stl")` (STL, OBJ, PLY, and any other format trimesh supports). + +## How it works + +1. **Binarize** — optional Gaussian smoothing, then threshold according to `field_type` and `level`. Optional `fill_holes` morphology and `min_component_area` cleanup. +2. **Trace** — contours are traced on the **continuous signed field** (positive inside, zero on boundary), not the binary mask. `skimage.measure.find_contours` interpolates the zero-iso with sub-pixel precision so oblique walls follow the true iso-surface rather than pixel-aligned staircases. Regions that cleanup has removed are clamped below zero so no contour re-surfaces there. +3. **Polygonize** — contours are split into shells and holes by orientation; each hole is assigned to its innermost containing shell, and the resulting polygons are merged with `shapely.ops.unary_union` and snapped to the image rectangle. +4. **Triangulate caps** — each polygon (with holes) is triangulated via `mapbox_earcut`, producing the bottom and top face sets. +5. **Stitch walls** — vertical quads between corresponding bottom/top ring vertices are split into two triangles each, yielding a closed prism. + +Input density field on the left, traced shells (CCW, blue) and holes (CW, rose) on the right + +The two panels above correspond to steps 1–3 on a cantilever-beam topology-optimization result: the raw density field is thresholded and morphologically cleaned, then contour tracing yields the shell/hole rings that feed the triangulator. + + +`extrude_2d` is an independent 2D→3D path. It does **not** run Taubin/bilateral smoothing, repair, remeshing, or decimation. The output is the raw extrusion. If you need mesh post-processing afterwards, wrap it yourself via the relevant modules in `xeltofab.smooth` / `xeltofab.remesh` / `xeltofab.decimate`. + + +## When to use this + +Use `extrude_2d` when your input is a 2D field and your output needs to be a 3D solid — typical cases: + +- **Printable parts from 2D topology optimization.** A density field from an EngiBench-style 2D beam solver becomes a 3D STL at a chosen wall thickness. +- **Laser cutting or waterjet prep** from raster density fields — export the mesh, slice at z=0, drive toolpaths off the resulting polygon. +- **Stamped, cast, or extruded profiles** where the part is defined entirely by a 2D cross-section. + +For 2D fields where you just want contours (plotting, vector export, further 2D processing), use [`process()`](/docs/api/process) instead — it produces `state.contours` and skips the 3D construction. + +## Examples + +### Basic density field → STL + +```python +import numpy as np +from xeltofab import extrude_2d + +field = np.load("beam_2d.npy") # shape (H, W), values in [0, 1] +mesh = extrude_2d(field, thickness=10) +mesh.export("beam.stl") +``` + +### SDF input + +For a signed-distance field, set `field_type="sdf"`. The default `level` becomes `0.0` (the zero level set). + +```python +mesh = extrude_2d(sdf_field, thickness=5, field_type="sdf") +mesh.export("shape.stl") +``` + +Use `level` to offset the iso-surface (positive shrinks inward for an SDF, negative grows outward). + +### Cleanup pass for noisy TO output + +```python +mesh = extrude_2d( + noisy_density, + thickness=15, + smooth_sigma=0.8, # smooth speckles before thresholding + fill_holes=True, # close single-pixel pinholes + min_component_area=20, # drop islands smaller than 20 px +) +``` + +Reach for these knobs in this order: `smooth_sigma` first (noise), then `fill_holes` (checkerboard patterns), then `min_component_area` (disconnected islands). + +### Inspecting the result + +```python +mesh = extrude_2d(field, thickness=10) + +print(f"Watertight: {mesh.is_watertight}") +print(f"Volume: {mesh.volume:.2f}") +print(f"Faces: {len(mesh.faces)}") +print(f"Vertices: {len(mesh.vertices)}") +``` + +## Errors and warnings + +- `ValueError` — `field.ndim != 2`. +- `ValueError` — `thickness <= 0`. +- `ValueError` — `"no material above threshold"`: the chosen `level` produced an empty binary mask. Check field values and adjust `level`. +- `ValueError` — `"no valid shell polygons after contour tracing"`: all traced contours collapsed below the 4-vertex minimum. Typically indicates a mask with only single-pixel features. +- `ValueError` — `"snapped geometry empty"`: the merged polygon falls entirely outside the image rectangle. Rare; usually signals a pathological input. +- `UserWarning` — `"extruded mesh is not watertight; printing may fail"`: emitted when the post-`trimesh.process` mesh fails `is_watertight` or `is_winding_consistent`. The mesh is still returned; callers that want to treat this as an error should filter warnings via `warnings.catch_warnings(record=True)`. + +## See also + + + + + + diff --git a/website/content/docs/api/meta.json b/website/content/docs/api/meta.json index 94541db..13db3fb 100644 --- a/website/content/docs/api/meta.json +++ b/website/content/docs/api/meta.json @@ -1,4 +1,4 @@ { "title": "API Reference", - "pages": ["pipeline-params", "pipeline-state", "load-field", "process", "process-from-sdf", "sdf-to-density", "save-mesh", "visualization"] + "pages": ["pipeline-params", "pipeline-state", "load-field", "process", "process-from-sdf", "extrude-2d", "sdf-to-density", "save-mesh", "visualization"] } diff --git a/website/content/docs/cli/extrude.mdx b/website/content/docs/cli/extrude.mdx new file mode 100644 index 0000000..4dc273f --- /dev/null +++ b/website/content/docs/cli/extrude.mdx @@ -0,0 +1,89 @@ +--- +title: xtf extrude +description: Extrude a 2D scalar field into a 3D triangle mesh +--- + +## Usage + +```bash +xtf extrude [OPTIONS] INPUT_PATH +``` + +Load a 2D scalar field, build a watertight prism by marching-squares contour tracing and polygon triangulation, and write the result to an STL, OBJ, or PLY file. + +## Arguments + +| Argument | Description | +|----------|-------------| +| `INPUT_PATH` | Path to the input 2D scalar field file (must exist). Must resolve to a 2D array. | + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `-o`, `--output` | `PATH` | *(required)* | Output mesh file path. Format determined by extension (`.stl`, `.obj`, `.ply`). | +| `-t`, `--thickness` | `FLOAT` | *(required)* | Extrusion height in grid units. Must be `> 0`. | +| `-f`, `--field-name` | `TEXT` | `None` | Variable name inside container formats (`.npz`, `.mat`, `.h5`, `.vtk`). | +| `--shape` | `TEXT` | `None` | Grid shape for flat data, e.g. `25x50` (for CSV/TXT). | +| `--field-type` | `density\|sdf` | `density` | Input field type. `sdf` flips the threshold direction and shifts the default `--level` to `0.0`. | +| `--level` | `FLOAT` | *(auto)* | Iso-level override. Default: `0.5` for density, `0.0` for SDF. | +| `--min-component-area` | `INT` | `0` | Drop connected components smaller than N pixels. | +| `--smooth-sigma` | `FLOAT` | `0.0` | Pre-threshold Gaussian sigma. `0` disables. | +| `--fill-holes` | flag | `False` | Morphologically close single-pixel pinholes before tracing. | + +## Examples + +### Basic density → STL + +```bash +xtf extrude density_2d.npy -o part.stl --thickness 10 +``` + +### SDF input + +```bash +xtf extrude sdf_field.npy -o part.stl -t 10 --field-type sdf +``` + +The `--level` default becomes `0.0` automatically under `--field-type sdf`. + +### Printability cleanup + +```bash +xtf extrude beam.npy -o beam.stl -t 15 \ + --smooth-sigma 0.8 \ + --fill-holes \ + --min-component-area 20 +``` + +Smooths noise, closes pinholes, and drops disconnected specks smaller than 20 pixels. + +### MATLAB input with named variable + +```bash +xtf extrude result.mat -o mesh.stl -t 5 -f xPhys +``` + +If `-f` is omitted, the loader auto-detects common topology-optimization variable names (`xPhys`, `densities`, `x`, `rho`, `dc`, `density`). + +### Flat CSV with explicit shape + +```bash +xtf extrude field_25x50.csv -o mesh.stl -t 8 --shape 25x50 +``` + +### Custom iso-level + +```bash +xtf extrude density.npy -o mesh.stl -t 10 --level 0.4 +``` + +Lowering the level includes more material in the binarized mask — useful when the optimizer output has gray regions you want to retain. + +## See also + + + + + + diff --git a/website/content/docs/cli/meta.json b/website/content/docs/cli/meta.json index 799c318..1f81d5d 100644 --- a/website/content/docs/cli/meta.json +++ b/website/content/docs/cli/meta.json @@ -1,4 +1,4 @@ { "title": "CLI Reference", - "pages": ["process", "viz", "formats"] + "pages": ["process", "extrude", "viz", "formats"] } diff --git a/website/content/docs/getting-started/quick-start.mdx b/website/content/docs/getting-started/quick-start.mdx index 8882856..fd024bb 100644 --- a/website/content/docs/getting-started/quick-start.mdx +++ b/website/content/docs/getting-started/quick-start.mdx @@ -67,6 +67,23 @@ fig.savefig("comparison.png", dpi=150) The pipeline automatically detects 2D vs 3D based on the array dimensions. 2D fields produce contour arrays (stored in `result.contours`), while 3D fields produce triangle meshes (stored in `result.vertices` and `result.faces`). +### 2D → 3D extrusion + +When you want a print-ready 3D part from a 2D density or SDF field — rather than contours for plotting — use `extrude_2d`: + +```python +import numpy as np +import xeltofab as xtf + +field = np.load("density_2d.npy") +mesh = xtf.extrude_2d(field, thickness=10) +mesh.export("part.stl") +``` + +Cantilever-beam density field extruded into a watertight 3D mesh + +This traces the field with marching squares, triangulates the resulting polygons, and stitches vertical walls into a watertight prism. See the [`extrude_2d` API reference](/docs/api/extrude-2d) or [`xtf extrude`](/docs/cli/extrude) for the full option set including SDF inputs, pinhole cleanup, and island removal. + ### SDF fields For signed distance fields (from neural models like DeepSDF, NITO, or NTopo), set `field_type="sdf"`: diff --git a/website/content/docs/guides/pipeline-overview.mdx b/website/content/docs/guides/pipeline-overview.mdx index 0b95da3..d81e971 100644 --- a/website/content/docs/guides/pipeline-overview.mdx +++ b/website/content/docs/guides/pipeline-overview.mdx @@ -94,7 +94,10 @@ The pipeline automatically detects whether the input is 2D or 3D based on `field | 2D | Same (Gaussian, threshold, morphology with `disk` kernel) | `find_contours` (marching squares) | No-op for smooth/repair/remesh/decimate | Contour arrays in `state.contours` | | 3D | Same (Gaussian, threshold, morphology with `ball` kernel) | `marching_cubes` | Taubin or bilateral smoothing, repair, remesh, decimate | Triangle mesh in `state.vertices` / `state.faces` | -For 2D fields, `save_mesh()` is not supported (raises an error). Use the visualization functions (`plot_result`, `plot_comparison`) to render contour output. +For 2D fields, `save_mesh()` is not supported — `process()` produces contours, not a 3D mesh. Two options: + +- To **visualize** the contours, use `plot_result` or `plot_comparison` from `xeltofab.field_plots`. +- To **build a 3D triangle mesh** from the same 2D input (for printing, laser cutting, or downstream CAD), use [`extrude_2d()`](/docs/api/extrude-2d) — an independent 2D→3D path that returns a watertight `trimesh.Trimesh` you can write with `mesh.export(...)`. ## Stage details diff --git a/website/content/docs/index.mdx b/website/content/docs/index.mdx index 3793f32..1aee560 100644 --- a/website/content/docs/index.mdx +++ b/website/content/docs/index.mdx @@ -13,7 +13,7 @@ Given either a scalar field (a numpy array) or an SDF function (analytical or ne 1. **Evaluates SDF functions** *(optional)* — sample analytical or neural SDFs on a uniform or octree-adaptive grid via [`process_from_sdf()`](/docs/api/process-from-sdf) 2. **Preprocesses** grid fields *(optional)* — Gaussian smoothing, thresholding, morphological cleanup -3. **Extracts** geometry — marching cubes, dual contouring, surface nets, or manifold3d (3D → triangle mesh) or marching squares (2D → contours) +3. **Extracts** geometry — marching cubes, dual contouring, surface nets, or manifold3d (3D → triangle mesh) or marching squares (2D → contours, or optionally an extruded 3D prism via [`extrude_2d()`](/docs/api/extrude-2d)) 4. **Smooths** the result — Taubin or bilateral filtering to remove staircase artifacts 5. **Repairs** the mesh *(optional)* — non-manifold fixing for watertight geometry 6. **Remeshes** for quality *(optional)* — isotropic remeshing for FEA-ready elements @@ -24,7 +24,7 @@ Given either a scalar field (a numpy array) or an SDF function (analytical or ne ## Key features - **Multi-format input** — NumPy, MATLAB, VTK, HDF5, CSV -- **2D and 3D** — contour extraction or triangle mesh generation +- **2D and 3D** — contour extraction, 2D→3D extrusion, or native triangle mesh generation - **Multiple field types** — density fields, SDFs, occupancy fields, and neural field outputs - **SDF function evaluation** — process neural models and analytical SDFs directly via [`process_from_sdf()`](/docs/api/process-from-sdf) with optional octree acceleration - **Quality metrics** — aspect ratio, min angle, scaled Jacobian via PyVista diff --git a/website/package.json b/website/package.json index 37398d3..ec6595b 100644 --- a/website/package.json +++ b/website/package.json @@ -11,26 +11,26 @@ }, "dependencies": { "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.5.0", + "@react-three/fiber": "^9.6.0", "fumadocs-core": "16.6.13", "fumadocs-mdx": "14.2.9", "fumadocs-ui": "16.6.13", "lucide-react": "^0.577.0", "next": "16.1.6", - "react": "^19.2.4", - "react-dom": "^19.2.4", + "react": "^19.2.5", + "react-dom": "^19.2.5", "tailwind-merge": "^3.5.0", "three": "^0.183.2" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.1", + "@tailwindcss/postcss": "^4.2.4", "@types/mdx": "^2.0.13", - "@types/node": "^25.3.5", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/three": "^0.183.1", - "postcss": "^8.5.8", - "tailwindcss": "^4.2.1", + "postcss": "^8.5.10", + "tailwindcss": "^4.2.4", "typescript": "^5.9.3" } } \ No newline at end of file diff --git a/website/public/images/guides/extrude-2d-contour.png b/website/public/images/guides/extrude-2d-contour.png new file mode 100644 index 0000000..67b904c Binary files /dev/null and b/website/public/images/guides/extrude-2d-contour.png differ diff --git a/website/public/images/guides/extrude-2d-mesh.png b/website/public/images/guides/extrude-2d-mesh.png new file mode 100644 index 0000000..54ef06e Binary files /dev/null and b/website/public/images/guides/extrude-2d-mesh.png differ