Skip to content

Commit a4ecfc8

Browse files
committed
feat(transforms): add xeltofab mesh transform bridge
Add engibench.transforms.xeltofab module that converts density-field problem outputs to fabrication-ready meshes via the xeltofab library. Covers 6 of 8 builtin problems (all density-field types). Includes per-problem pipeline presets, input/output validation, and 18 tests. xeltofab is an optional dependency requiring Python 3.13+.
1 parent c4ebcd4 commit a4ecfc8

8 files changed

Lines changed: 489 additions & 1 deletion

File tree

engibench/transforms/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Transforms for converting EngiBench designs to other representations."""
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Bridge between EngiBench density-field problems and xeltofab mesh generation.
2+
3+
Example::
4+
5+
from engibench.problems.beams2d import Beams2D
6+
from engibench.transforms.xeltofab import to_mesh, save
7+
8+
problem = Beams2D()
9+
design, _ = problem.random_design()
10+
state = to_mesh(problem, design)
11+
save(state, "beam.stl") # 3-D only
12+
"""
13+
14+
from engibench.transforms.xeltofab._core import save
15+
from engibench.transforms.xeltofab._core import to_mesh
16+
from engibench.transforms.xeltofab._presets import PROBLEM_PRESETS
17+
18+
__all__ = ["PROBLEM_PRESETS", "save", "to_mesh"]
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Core bridge between EngiBench density-field problems and xeltofab mesh generation."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, TYPE_CHECKING
6+
7+
import numpy as np
8+
import numpy.typing as npt
9+
10+
from engibench.transforms.xeltofab._presets import PROBLEM_PRESETS
11+
from engibench.transforms.xeltofab._validate import validate_input
12+
from engibench.transforms.xeltofab._validate import validate_output
13+
14+
if TYPE_CHECKING:
15+
from pathlib import Path
16+
17+
from engibench.core import Problem
18+
19+
try:
20+
from xeltofab import PipelineParams
21+
from xeltofab import PipelineState
22+
from xeltofab import process as _process
23+
from xeltofab import save_mesh as _save_mesh
24+
25+
_HAS_XELTOFAB = True
26+
except ImportError:
27+
_HAS_XELTOFAB = False
28+
29+
30+
def _check_xeltofab() -> None:
31+
if not _HAS_XELTOFAB:
32+
msg = (
33+
"xeltofab >= 0.3.0 is required for mesh transforms. "
34+
"Install with: pip install 'engibench[transforms]' (requires Python >= 3.13)"
35+
)
36+
raise ImportError(msg)
37+
38+
39+
def to_mesh(
40+
problem: Problem,
41+
design: npt.NDArray,
42+
*,
43+
params: PipelineParams | None = None,
44+
validate: bool = True,
45+
volume_tolerance: float = 0.05,
46+
**kwargs: Any,
47+
) -> PipelineState:
48+
"""Convert an EngiBench density-field design to a mesh via xeltofab.
49+
50+
Args:
51+
problem: An EngiBench ``Problem`` instance. Used to look up
52+
per-problem pipeline presets by class name.
53+
design: A 2-D or 3-D numpy array with values in ``[0, 1]``
54+
representing a density field.
55+
params: Explicit ``PipelineParams``. When provided, presets and
56+
*kwargs* are ignored for pipeline parameters.
57+
validate: Whether to run post-conversion validation checks.
58+
volume_tolerance: Maximum allowed absolute change in volume
59+
fraction between input field and output mesh / contours.
60+
**kwargs: Forwarded to ``PipelineParams(...)`` and merged on top
61+
of the per-problem preset defaults.
62+
63+
Returns:
64+
A ``PipelineState`` containing the processed mesh (3-D) or
65+
contours (2-D).
66+
67+
Raises:
68+
TypeError: If *design* is not a numpy array.
69+
ValueError: If *design* is not 2-D or 3-D.
70+
ImportError: If xeltofab is not installed.
71+
"""
72+
_check_xeltofab()
73+
74+
design = validate_input(design)
75+
76+
if params is None:
77+
problem_name = type(problem).__name__
78+
preset = PROBLEM_PRESETS.get(problem_name, {})
79+
merged = {**preset, **kwargs}
80+
params = PipelineParams(**merged)
81+
82+
input_vf = float(np.mean(design)) if validate else 0.0
83+
84+
# Preserve float32 to avoid doubling memory for large 3D fields.
85+
if not np.issubdtype(design.dtype, np.floating):
86+
design = design.astype(np.float64, copy=False)
87+
88+
state = PipelineState(field=design, params=params)
89+
state = _process(state)
90+
91+
if validate:
92+
validate_output(state, input_vf, volume_tolerance)
93+
94+
return state
95+
96+
97+
def save(state: PipelineState, path: str | Path) -> None:
98+
"""Save a processed mesh to a file.
99+
100+
Convenience wrapper around ``xeltofab.save_mesh``.
101+
102+
Args:
103+
state: A ``PipelineState`` returned by :func:`to_mesh`.
104+
path: Output file path. Format is inferred from the extension
105+
(``.stl``, ``.obj``, ``.ply``).
106+
107+
Raises:
108+
ImportError: If xeltofab is not installed.
109+
"""
110+
_check_xeltofab()
111+
_save_mesh(state, str(path))
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Per-problem default pipeline parameters for xeltofab mesh generation.
2+
3+
This module contains no xeltofab imports — it stores plain dicts of kwargs
4+
that are passed to ``PipelineParams(...)`` at runtime.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any
10+
11+
# 2D problems: contour extraction only; repair/remesh/decimate are 3D-only and irrelevant.
12+
# 3D problems: full pipeline with marching cubes, repair, remesh, and decimation.
13+
14+
_BASE_2D: dict[str, Any] = {
15+
"field_type": "density",
16+
"threshold": 0.5,
17+
"smooth_sigma": 1.0,
18+
"morph_radius": 1,
19+
}
20+
21+
_BASE_3D: dict[str, Any] = {
22+
**_BASE_2D,
23+
"extraction_method": "mc",
24+
"repair": True,
25+
"remesh": True,
26+
"decimate": True,
27+
"decimate_ratio": 0.5,
28+
}
29+
30+
PROBLEM_PRESETS: dict[str, dict[str, Any]] = {
31+
# --- 2D density-field problems ---
32+
"Beams2D": {**_BASE_2D},
33+
"ThermoElastic2D": {**_BASE_2D, "smooth_sigma": 0.8},
34+
"Photonics2D": {
35+
**_BASE_2D,
36+
"smooth_sigma": 0.5, # lower: photonics designs have sharp features
37+
"morph_radius": 0, # no morphological cleanup for sharp features
38+
},
39+
"HeatConduction2D": {**_BASE_2D},
40+
# --- 3D density-field problems ---
41+
"ThermoElastic3D": {**_BASE_3D},
42+
"HeatConduction3D": {**_BASE_3D},
43+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Input and output validation for the xeltofab mesh transform."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
import warnings
7+
8+
import numpy as np
9+
import numpy.typing as npt
10+
11+
if TYPE_CHECKING:
12+
from xeltofab import PipelineState
13+
14+
15+
def validate_input(design: npt.NDArray) -> npt.NDArray:
16+
"""Validate and sanitize a density-field design array.
17+
18+
Args:
19+
design: A numpy array representing a density field.
20+
21+
Returns:
22+
The (possibly clipped) design array.
23+
24+
Raises:
25+
TypeError: If *design* is not a numpy array.
26+
ValueError: If *design* is not 2-D or 3-D.
27+
"""
28+
if not isinstance(design, np.ndarray):
29+
msg = f"design must be a numpy ndarray, got {type(design).__name__}"
30+
raise TypeError(msg)
31+
32+
if not np.issubdtype(design.dtype, np.floating) and not np.issubdtype(design.dtype, np.integer):
33+
msg = f"design must have a numeric dtype, got {design.dtype}"
34+
raise TypeError(msg)
35+
36+
if design.size == 0:
37+
msg = f"design must be non-empty, got shape {design.shape}"
38+
raise ValueError(msg)
39+
40+
if design.ndim not in (2, 3):
41+
msg = f"design must be 2-D or 3-D, got {design.ndim}-D with shape {design.shape}"
42+
raise ValueError(msg)
43+
44+
# Single pass: min/max propagate NaN, so NaN detection comes for free.
45+
vmin, vmax = float(design.min()), float(design.max())
46+
if np.isnan(vmin) or np.isnan(vmax) or np.isinf(vmin) or np.isinf(vmax):
47+
msg = "design contains non-finite values (NaN or Inf)"
48+
raise ValueError(msg)
49+
50+
if vmin < 0.0 or vmax > 1.0:
51+
warnings.warn(
52+
f"Design values outside [0, 1] (min={vmin:.4f}, max={vmax:.4f}). Clipping.",
53+
stacklevel=3,
54+
)
55+
design = np.clip(design, 0.0, 1.0)
56+
57+
return design
58+
59+
60+
def validate_output(
61+
state: PipelineState,
62+
input_volume_fraction: float,
63+
tolerance: float,
64+
) -> list[str]:
65+
"""Run post-pipeline validation checks.
66+
67+
Args:
68+
state: A ``xeltofab.PipelineState`` instance.
69+
input_volume_fraction: Volume fraction of the input design (``np.mean(design)``).
70+
tolerance: Maximum allowed absolute deviation in volume fraction.
71+
72+
Returns:
73+
A list of warning messages (empty if all checks pass).
74+
75+
Raises:
76+
RuntimeError: If the pipeline produced no mesh (3-D) or no contours (2-D).
77+
"""
78+
warnings_list: list[str] = []
79+
80+
ndim: int = getattr(state, "ndim", 0)
81+
82+
if ndim == 3: # noqa: PLR2004
83+
vertices = getattr(state, "vertices", None)
84+
faces = getattr(state, "faces", None)
85+
if vertices is None or faces is None:
86+
msg = "3-D pipeline produced no mesh (vertices or faces are None)."
87+
raise RuntimeError(msg)
88+
elif ndim == 2: # noqa: PLR2004
89+
contours = getattr(state, "contours", None)
90+
if contours is None or len(contours) == 0:
91+
msg = "2-D pipeline produced no contours."
92+
raise RuntimeError(msg)
93+
else:
94+
msg = f"Unsupported or missing 'ndim' in pipeline state: {ndim!r}. Expected 2 or 3."
95+
raise RuntimeError(msg)
96+
97+
output_vf = getattr(state, "volume_fraction", None)
98+
if output_vf is not None:
99+
delta = abs(input_volume_fraction - output_vf)
100+
if delta > tolerance:
101+
warnings_list.append(
102+
f"Volume fraction changed by {delta:.4f} "
103+
f"(input={input_volume_fraction:.4f}, output={output_vf:.4f}, "
104+
f"tolerance={tolerance:.4f})."
105+
)
106+
107+
for msg in warnings_list:
108+
warnings.warn(msg, stacklevel=3)
109+
110+
return warnings_list

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ beams2d = ["cvxopt >= 1.3.2", "seaborn", "scipy"]
3737
thermoelastic2d = ["cvxopt >= 1.3.2", "mmapy >= 0.3.0"]
3838
thermoelastic3d = ["cvxopt >= 1.3.2", "mmapy >= 0.3.0", "napari", "pyamg"]
3939
photonics2d = ["ceviche >= 0.1.3"]
40+
transforms = ["xeltofab >= 0.3.0; python_version >= '3.13'"]
4041
all = [
4142
# All dependencies above
4243
"engibench[airfoil,beams2d,thermoelastic2d,thermoelastic3d,photonics2d,electronics]"
@@ -277,6 +278,8 @@ module = [
277278
"multipoint",
278279
"pygeo",
279280
"napari",
280-
"pyamg"
281+
"pyamg",
282+
"xeltofab",
283+
"xeltofab.*",
281284
]
282285
ignore_missing_imports = true

tests/transforms/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)