Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
06f73d7
feat(extrude): add module skeleton and deps for 2D→3D extrusion
xarthurx Apr 21, 2026
08eb0a7
feat(extrude): add input validation for rank and thickness
xarthurx Apr 21, 2026
ad5fc29
feat(extrude): add _build_binary helper with density/SDF threshold
xarthurx Apr 21, 2026
da2842e
feat(extrude): add _trace_contours helper with boundary-closing pad
xarthurx Apr 21, 2026
e5e45f2
feat(extrude): add _polygonize helper with flush-boundary snap
xarthurx Apr 21, 2026
0fa0eac
feat(extrude): add _triangulate_polygon via mapbox_earcut
xarthurx Apr 21, 2026
8c56c2c
feat(extrude): add _build_prism_mesh (caps + walls) via trimesh
xarthurx Apr 21, 2026
cad5e16
feat(extrude): wire extrude_2d end-to-end with helper chain
xarthurx Apr 21, 2026
724528a
test(extrude): add property tests for volume scaling and area tracking
xarthurx Apr 21, 2026
0a6d908
test(extrude): add EngiBench Beams2D fixtures and preprocess parity test
xarthurx Apr 21, 2026
9e2c69b
feat(cli): add `xtf extrude` subcommand for 2D→3D STL export
xarthurx Apr 21, 2026
e43b062
docs: add 2D→3D extrusion example to README quick start
xarthurx Apr 21, 2026
9a60bfd
docs: record the canonical 2D extrusion print path in project memory
xarthurx Apr 21, 2026
df74c99
fix(cli): support python -m xeltofab.cli invocation
xarthurx Apr 21, 2026
4c1f3c5
docs: record the module-invocation CLI fix in project memory
xarthurx Apr 21, 2026
1181178
docs(website): add extrude_2d API and `xtf extrude` CLI pages
xarthurx Apr 21, 2026
410aee9
chore: pkg update.
xarthurx Apr 22, 2026
2fded82
docs(website): illustrate extrude_2d with contour and 3D mesh images
xarthurx Apr 22, 2026
a31d6a1
fix(extrude): trace continuous field for sub-pixel walls
xarthurx Apr 22, 2026
d9a2ffe
docs(progress): record extrude contour-tracing fix commit hash
xarthurx Apr 22, 2026
6a3f47b
fix(extrude): reject negative smooth_sigma and min_component_area
xarthurx Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
36 changes: 36 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -67,4 +69,5 @@ dev = [
"marimo>=0.20.2",
"pytest>=9.0.2",
"ruff>=0.15.2",
"ty>=0.0.32",
]
116 changes: 115 additions & 1 deletion scripts/generate_doc_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/xeltofab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
sdf_to_density,
sigmoid,
)
from xeltofab.extrude import extrude_2d
from xeltofab.io import (
load_field,
save_mesh,
Expand All @@ -16,6 +17,7 @@
__all__ = [
"PipelineParams",
"PipelineState",
"extrude_2d",
"heaviside",
"linear_ramp",
"load_field",
Expand Down
57 changes: 57 additions & 0 deletions src/xeltofab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Loading
Loading