From a4ecfc80da149b6de828a1d587ee81f92cfdf600 Mon Sep 17 00:00:00 2001 From: "Zhao.MA" Date: Tue, 31 Mar 2026 13:52:23 +0200 Subject: [PATCH 1/3] 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+. --- engibench/transforms/__init__.py | 1 + engibench/transforms/xeltofab/__init__.py | 18 ++ engibench/transforms/xeltofab/_core.py | 111 +++++++++++ engibench/transforms/xeltofab/_presets.py | 43 +++++ engibench/transforms/xeltofab/_validate.py | 110 +++++++++++ pyproject.toml | 5 +- tests/transforms/__init__.py | 0 tests/transforms/test_xeltofab.py | 202 +++++++++++++++++++++ 8 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 engibench/transforms/__init__.py create mode 100644 engibench/transforms/xeltofab/__init__.py create mode 100644 engibench/transforms/xeltofab/_core.py create mode 100644 engibench/transforms/xeltofab/_presets.py create mode 100644 engibench/transforms/xeltofab/_validate.py create mode 100644 tests/transforms/__init__.py create mode 100644 tests/transforms/test_xeltofab.py diff --git a/engibench/transforms/__init__.py b/engibench/transforms/__init__.py new file mode 100644 index 00000000..5d416c9c --- /dev/null +++ b/engibench/transforms/__init__.py @@ -0,0 +1 @@ +"""Transforms for converting EngiBench designs to other representations.""" diff --git a/engibench/transforms/xeltofab/__init__.py b/engibench/transforms/xeltofab/__init__.py new file mode 100644 index 00000000..e10b7fef --- /dev/null +++ b/engibench/transforms/xeltofab/__init__.py @@ -0,0 +1,18 @@ +"""Bridge between EngiBench density-field problems and xeltofab mesh generation. + +Example:: + + from engibench.problems.beams2d import Beams2D + from engibench.transforms.xeltofab import to_mesh, save + + problem = Beams2D() + design, _ = problem.random_design() + state = to_mesh(problem, design) + save(state, "beam.stl") # 3-D only +""" + +from engibench.transforms.xeltofab._core import save +from engibench.transforms.xeltofab._core import to_mesh +from engibench.transforms.xeltofab._presets import PROBLEM_PRESETS + +__all__ = ["PROBLEM_PRESETS", "save", "to_mesh"] diff --git a/engibench/transforms/xeltofab/_core.py b/engibench/transforms/xeltofab/_core.py new file mode 100644 index 00000000..b2002ee7 --- /dev/null +++ b/engibench/transforms/xeltofab/_core.py @@ -0,0 +1,111 @@ +"""Core bridge between EngiBench density-field problems and xeltofab mesh generation.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +import numpy as np +import numpy.typing as npt + +from engibench.transforms.xeltofab._presets import PROBLEM_PRESETS +from engibench.transforms.xeltofab._validate import validate_input +from engibench.transforms.xeltofab._validate import validate_output + +if TYPE_CHECKING: + from pathlib import Path + + from engibench.core import Problem + +try: + from xeltofab import PipelineParams + from xeltofab import PipelineState + from xeltofab import process as _process + from xeltofab import save_mesh as _save_mesh + + _HAS_XELTOFAB = True +except ImportError: + _HAS_XELTOFAB = False + + +def _check_xeltofab() -> None: + if not _HAS_XELTOFAB: + msg = ( + "xeltofab >= 0.3.0 is required for mesh transforms. " + "Install with: pip install 'engibench[transforms]' (requires Python >= 3.13)" + ) + raise ImportError(msg) + + +def to_mesh( + problem: Problem, + design: npt.NDArray, + *, + params: PipelineParams | None = None, + validate: bool = True, + volume_tolerance: float = 0.05, + **kwargs: Any, +) -> PipelineState: + """Convert an EngiBench density-field design to a mesh via xeltofab. + + Args: + problem: An EngiBench ``Problem`` instance. Used to look up + per-problem pipeline presets by class name. + design: A 2-D or 3-D numpy array with values in ``[0, 1]`` + representing a density field. + params: Explicit ``PipelineParams``. When provided, presets and + *kwargs* are ignored for pipeline parameters. + validate: Whether to run post-conversion validation checks. + volume_tolerance: Maximum allowed absolute change in volume + fraction between input field and output mesh / contours. + **kwargs: Forwarded to ``PipelineParams(...)`` and merged on top + of the per-problem preset defaults. + + Returns: + A ``PipelineState`` containing the processed mesh (3-D) or + contours (2-D). + + Raises: + TypeError: If *design* is not a numpy array. + ValueError: If *design* is not 2-D or 3-D. + ImportError: If xeltofab is not installed. + """ + _check_xeltofab() + + design = validate_input(design) + + if params is None: + problem_name = type(problem).__name__ + preset = PROBLEM_PRESETS.get(problem_name, {}) + merged = {**preset, **kwargs} + params = PipelineParams(**merged) + + input_vf = float(np.mean(design)) if validate else 0.0 + + # Preserve float32 to avoid doubling memory for large 3D fields. + if not np.issubdtype(design.dtype, np.floating): + design = design.astype(np.float64, copy=False) + + state = PipelineState(field=design, params=params) + state = _process(state) + + if validate: + validate_output(state, input_vf, volume_tolerance) + + return state + + +def save(state: PipelineState, path: str | Path) -> None: + """Save a processed mesh to a file. + + Convenience wrapper around ``xeltofab.save_mesh``. + + Args: + state: A ``PipelineState`` returned by :func:`to_mesh`. + path: Output file path. Format is inferred from the extension + (``.stl``, ``.obj``, ``.ply``). + + Raises: + ImportError: If xeltofab is not installed. + """ + _check_xeltofab() + _save_mesh(state, str(path)) diff --git a/engibench/transforms/xeltofab/_presets.py b/engibench/transforms/xeltofab/_presets.py new file mode 100644 index 00000000..a5dbcd75 --- /dev/null +++ b/engibench/transforms/xeltofab/_presets.py @@ -0,0 +1,43 @@ +"""Per-problem default pipeline parameters for xeltofab mesh generation. + +This module contains no xeltofab imports — it stores plain dicts of kwargs +that are passed to ``PipelineParams(...)`` at runtime. +""" + +from __future__ import annotations + +from typing import Any + +# 2D problems: contour extraction only; repair/remesh/decimate are 3D-only and irrelevant. +# 3D problems: full pipeline with marching cubes, repair, remesh, and decimation. + +_BASE_2D: dict[str, Any] = { + "field_type": "density", + "threshold": 0.5, + "smooth_sigma": 1.0, + "morph_radius": 1, +} + +_BASE_3D: dict[str, Any] = { + **_BASE_2D, + "extraction_method": "mc", + "repair": True, + "remesh": True, + "decimate": True, + "decimate_ratio": 0.5, +} + +PROBLEM_PRESETS: dict[str, dict[str, Any]] = { + # --- 2D density-field problems --- + "Beams2D": {**_BASE_2D}, + "ThermoElastic2D": {**_BASE_2D, "smooth_sigma": 0.8}, + "Photonics2D": { + **_BASE_2D, + "smooth_sigma": 0.5, # lower: photonics designs have sharp features + "morph_radius": 0, # no morphological cleanup for sharp features + }, + "HeatConduction2D": {**_BASE_2D}, + # --- 3D density-field problems --- + "ThermoElastic3D": {**_BASE_3D}, + "HeatConduction3D": {**_BASE_3D}, +} diff --git a/engibench/transforms/xeltofab/_validate.py b/engibench/transforms/xeltofab/_validate.py new file mode 100644 index 00000000..434f7cda --- /dev/null +++ b/engibench/transforms/xeltofab/_validate.py @@ -0,0 +1,110 @@ +"""Input and output validation for the xeltofab mesh transform.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +import warnings + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from xeltofab import PipelineState + + +def validate_input(design: npt.NDArray) -> npt.NDArray: + """Validate and sanitize a density-field design array. + + Args: + design: A numpy array representing a density field. + + Returns: + The (possibly clipped) design array. + + Raises: + TypeError: If *design* is not a numpy array. + ValueError: If *design* is not 2-D or 3-D. + """ + if not isinstance(design, np.ndarray): + msg = f"design must be a numpy ndarray, got {type(design).__name__}" + raise TypeError(msg) + + if not np.issubdtype(design.dtype, np.floating) and not np.issubdtype(design.dtype, np.integer): + msg = f"design must have a numeric dtype, got {design.dtype}" + raise TypeError(msg) + + if design.size == 0: + msg = f"design must be non-empty, got shape {design.shape}" + raise ValueError(msg) + + if design.ndim not in (2, 3): + msg = f"design must be 2-D or 3-D, got {design.ndim}-D with shape {design.shape}" + raise ValueError(msg) + + # Single pass: min/max propagate NaN, so NaN detection comes for free. + vmin, vmax = float(design.min()), float(design.max()) + if np.isnan(vmin) or np.isnan(vmax) or np.isinf(vmin) or np.isinf(vmax): + msg = "design contains non-finite values (NaN or Inf)" + raise ValueError(msg) + + if vmin < 0.0 or vmax > 1.0: + warnings.warn( + f"Design values outside [0, 1] (min={vmin:.4f}, max={vmax:.4f}). Clipping.", + stacklevel=3, + ) + design = np.clip(design, 0.0, 1.0) + + return design + + +def validate_output( + state: PipelineState, + input_volume_fraction: float, + tolerance: float, +) -> list[str]: + """Run post-pipeline validation checks. + + Args: + state: A ``xeltofab.PipelineState`` instance. + input_volume_fraction: Volume fraction of the input design (``np.mean(design)``). + tolerance: Maximum allowed absolute deviation in volume fraction. + + Returns: + A list of warning messages (empty if all checks pass). + + Raises: + RuntimeError: If the pipeline produced no mesh (3-D) or no contours (2-D). + """ + warnings_list: list[str] = [] + + ndim: int = getattr(state, "ndim", 0) + + if ndim == 3: # noqa: PLR2004 + vertices = getattr(state, "vertices", None) + faces = getattr(state, "faces", None) + if vertices is None or faces is None: + msg = "3-D pipeline produced no mesh (vertices or faces are None)." + raise RuntimeError(msg) + elif ndim == 2: # noqa: PLR2004 + contours = getattr(state, "contours", None) + if contours is None or len(contours) == 0: + msg = "2-D pipeline produced no contours." + raise RuntimeError(msg) + else: + msg = f"Unsupported or missing 'ndim' in pipeline state: {ndim!r}. Expected 2 or 3." + raise RuntimeError(msg) + + output_vf = getattr(state, "volume_fraction", None) + if output_vf is not None: + delta = abs(input_volume_fraction - output_vf) + if delta > tolerance: + warnings_list.append( + f"Volume fraction changed by {delta:.4f} " + f"(input={input_volume_fraction:.4f}, output={output_vf:.4f}, " + f"tolerance={tolerance:.4f})." + ) + + for msg in warnings_list: + warnings.warn(msg, stacklevel=3) + + return warnings_list diff --git a/pyproject.toml b/pyproject.toml index b726314e..ff80bd7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ beams2d = ["cvxopt >= 1.3.2", "seaborn", "scipy"] thermoelastic2d = ["cvxopt >= 1.3.2", "mmapy >= 0.3.0"] thermoelastic3d = ["cvxopt >= 1.3.2", "mmapy >= 0.3.0", "napari", "pyamg"] photonics2d = ["ceviche >= 0.1.3"] +transforms = ["xeltofab >= 0.3.0; python_version >= '3.13'"] all = [ # All dependencies above "engibench[airfoil,beams2d,thermoelastic2d,thermoelastic3d,photonics2d,electronics]" @@ -277,6 +278,8 @@ module = [ "multipoint", "pygeo", "napari", - "pyamg" + "pyamg", + "xeltofab", + "xeltofab.*", ] ignore_missing_imports = true diff --git a/tests/transforms/__init__.py b/tests/transforms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/transforms/test_xeltofab.py b/tests/transforms/test_xeltofab.py new file mode 100644 index 00000000..fb67775f --- /dev/null +++ b/tests/transforms/test_xeltofab.py @@ -0,0 +1,202 @@ +"""Tests for the engibench.transforms.xeltofab bridge module.""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest + +pytest.importorskip("xeltofab") + +from xeltofab import PipelineParams + +from engibench.transforms.xeltofab import PROBLEM_PRESETS +from engibench.transforms.xeltofab import save +from engibench.transforms.xeltofab import to_mesh +from engibench.transforms.xeltofab._validate import validate_input + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_EXPECTED_COLS = 3 + + +def _make_circle_2d(shape: tuple[int, int] = (50, 100), radius: float = 0.3) -> np.ndarray: + """Create a synthetic 2-D density field with a filled circle.""" + ny, nx = shape + y, x = np.mgrid[:ny, :nx] + cy, cx = ny / 2, nx / 2 + dist = np.sqrt((x - cx) ** 2 + (y - cy) ** 2) + max_dim = max(ny, nx) + return np.where(dist < radius * max_dim, 1.0, 0.0) + + +def _make_sphere_3d(resolution: int = 20, radius: float = 0.35) -> np.ndarray: + """Create a synthetic 3-D density field with a filled sphere.""" + coords = np.linspace(0, 1, resolution) + z, y, x = np.meshgrid(coords, coords, coords, indexing="ij") + dist = np.sqrt((x - 0.5) ** 2 + (y - 0.5) ** 2 + (z - 0.5) ** 2) + return np.where(dist < radius, 1.0, 0.0) + + +def _make_fake_problem(name: str, volfrac: float | None = None): + """Create a minimal stand-in for an EngiBench Problem. + + Returns a fresh class each time so ``type(obj).__name__`` is isolated. + """ + + class _Conditions: + pass + + cond = _Conditions() + if volfrac is not None: + cond.volfrac = volfrac # type: ignore[attr-defined] + + cls = type(name, (), {"conditions": cond}) + return cls() + + +# --------------------------------------------------------------------------- +# Input validation +# --------------------------------------------------------------------------- + + +class TestValidateInput: + def test_rejects_non_array(self): + with pytest.raises(TypeError, match="numpy ndarray"): + validate_input([1, 2, 3]) + + def test_rejects_1d(self): + with pytest.raises(ValueError, match="2-D or 3-D"): + validate_input(np.array([0.0, 1.0])) + + def test_rejects_4d(self): + with pytest.raises(ValueError, match="2-D or 3-D"): + validate_input(np.zeros((2, 2, 2, 2))) + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="non-empty"): + validate_input(np.zeros((0, 10))) + + def test_rejects_nan(self): + field = np.array([[0.5, float("nan")], [0.1, 0.2]]) + with pytest.raises(ValueError, match="non-finite"): + validate_input(field) + + def test_rejects_non_numeric(self): + field = np.array([["a", "b"], ["c", "d"]]) + with pytest.raises(TypeError, match="numeric dtype"): + validate_input(field) + + def test_clips_out_of_range(self): + field = np.array([[-0.1, 0.5], [0.8, 1.2]]) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = validate_input(field) + assert len(w) == 1 + assert "Clipping" in str(w[0].message) + assert result.min() >= 0.0 + assert result.max() <= 1.0 + + def test_passes_valid(self): + field = np.random.default_rng(0).random((10, 10)) + result = validate_input(field) + np.testing.assert_array_equal(result, field) + + +# --------------------------------------------------------------------------- +# Presets +# --------------------------------------------------------------------------- + + +class TestPresets: + def test_covers_all_density_problems(self): + expected = {"Beams2D", "ThermoElastic2D", "ThermoElastic3D", "Photonics2D", "HeatConduction2D", "HeatConduction3D"} + assert set(PROBLEM_PRESETS.keys()) == expected + + def test_all_presets_have_field_type_density(self): + for name, preset in PROBLEM_PRESETS.items(): + assert preset.get("field_type") == "density", f"{name} preset missing field_type=density" + + +# --------------------------------------------------------------------------- +# to_mesh — 2-D +# --------------------------------------------------------------------------- + + +class TestToMesh2D: + def test_synthetic_circle(self): + problem = _make_fake_problem("Beams2D", volfrac=0.3) + field = _make_circle_2d() + state = to_mesh(problem, field, validate=False) + assert state.contours is not None + assert len(state.contours) > 0 + + def test_with_validation(self): + problem = _make_fake_problem("HeatConduction2D") + field = _make_circle_2d() + state = to_mesh(problem, field, validate=True, volume_tolerance=1.0) + assert state.contours is not None + + +# --------------------------------------------------------------------------- +# to_mesh — 3-D +# --------------------------------------------------------------------------- + + +class TestToMesh3D: + def test_synthetic_sphere(self): + problem = _make_fake_problem("HeatConduction3D") + field = _make_sphere_3d() + state = to_mesh(problem, field, validate=False) + assert state.vertices is not None + assert state.faces is not None + assert state.vertices.shape[1] == _EXPECTED_COLS + assert state.faces.shape[1] == _EXPECTED_COLS + + def test_volume_preservation(self): + problem = _make_fake_problem("HeatConduction3D") + field = _make_sphere_3d(resolution=30, radius=0.35) + state = to_mesh(problem, field, validate=True, volume_tolerance=0.15) + assert state.vertices is not None + + def test_save_stl(self, tmp_path): + problem = _make_fake_problem("HeatConduction3D") + field = _make_sphere_3d() + state = to_mesh(problem, field, validate=False) + out = tmp_path / "test.stl" + save(state, out) + assert out.exists() + assert out.stat().st_size > 0 + + +# --------------------------------------------------------------------------- +# Parameter overrides +# --------------------------------------------------------------------------- + +_CUSTOM_THRESHOLD = 0.3 +_CUSTOM_SIGMA = 2.0 + + +class TestParameterOverrides: + def test_kwargs_override_preset(self): + problem = _make_fake_problem("Beams2D") + field = _make_circle_2d() + state = to_mesh(problem, field, smooth_sigma=0.0, validate=False) + assert state.params.smooth_sigma == 0.0 + + def test_explicit_params_bypass_presets(self): + problem = _make_fake_problem("Beams2D") + field = _make_circle_2d() + custom = PipelineParams(threshold=_CUSTOM_THRESHOLD, smooth_sigma=_CUSTOM_SIGMA) + state = to_mesh(problem, field, params=custom, validate=False) + assert state.params.threshold == _CUSTOM_THRESHOLD + assert state.params.smooth_sigma == _CUSTOM_SIGMA + + def test_unknown_problem_uses_defaults(self): + problem = _make_fake_problem("UnknownProblem") + field = _make_circle_2d() + state = to_mesh(problem, field, validate=False) + assert state.contours is not None From 4066d445f1d891caece91d2aded048917cd0e43f Mon Sep 17 00:00:00 2001 From: "Zhao.MA" Date: Thu, 2 Apr 2026 12:41:10 +0200 Subject: [PATCH 2/3] refactor(transforms): remove redundant future imports --- engibench/transforms/xeltofab/_presets.py | 2 -- tests/transforms/test_xeltofab.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/engibench/transforms/xeltofab/_presets.py b/engibench/transforms/xeltofab/_presets.py index a5dbcd75..947e17af 100644 --- a/engibench/transforms/xeltofab/_presets.py +++ b/engibench/transforms/xeltofab/_presets.py @@ -4,8 +4,6 @@ that are passed to ``PipelineParams(...)`` at runtime. """ -from __future__ import annotations - from typing import Any # 2D problems: contour extraction only; repair/remesh/decimate are 3D-only and irrelevant. diff --git a/tests/transforms/test_xeltofab.py b/tests/transforms/test_xeltofab.py index fb67775f..156ab67c 100644 --- a/tests/transforms/test_xeltofab.py +++ b/tests/transforms/test_xeltofab.py @@ -1,7 +1,5 @@ """Tests for the engibench.transforms.xeltofab bridge module.""" -from __future__ import annotations - import warnings import numpy as np From c314600f82f7d6c25a02edeeaef331428dd70075 Mon Sep 17 00:00:00 2001 From: "Zhao.MA" Date: Thu, 2 Apr 2026 14:49:27 +0200 Subject: [PATCH 3/3] refactor(transforms): quote deferred annotations --- engibench/transforms/xeltofab/_core.py | 10 ++++------ engibench/transforms/xeltofab/_validate.py | 4 +--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/engibench/transforms/xeltofab/_core.py b/engibench/transforms/xeltofab/_core.py index b2002ee7..c206b6c8 100644 --- a/engibench/transforms/xeltofab/_core.py +++ b/engibench/transforms/xeltofab/_core.py @@ -1,7 +1,5 @@ """Core bridge between EngiBench density-field problems and xeltofab mesh generation.""" -from __future__ import annotations - from typing import Any, TYPE_CHECKING import numpy as np @@ -37,14 +35,14 @@ def _check_xeltofab() -> None: def to_mesh( - problem: Problem, + problem: "Problem", design: npt.NDArray, *, - params: PipelineParams | None = None, + params: "PipelineParams | None" = None, validate: bool = True, volume_tolerance: float = 0.05, **kwargs: Any, -) -> PipelineState: +) -> "PipelineState": """Convert an EngiBench density-field design to a mesh via xeltofab. Args: @@ -94,7 +92,7 @@ def to_mesh( return state -def save(state: PipelineState, path: str | Path) -> None: +def save(state: "PipelineState", path: "str | Path") -> None: """Save a processed mesh to a file. Convenience wrapper around ``xeltofab.save_mesh``. diff --git a/engibench/transforms/xeltofab/_validate.py b/engibench/transforms/xeltofab/_validate.py index 434f7cda..c4b8272e 100644 --- a/engibench/transforms/xeltofab/_validate.py +++ b/engibench/transforms/xeltofab/_validate.py @@ -1,7 +1,5 @@ """Input and output validation for the xeltofab mesh transform.""" -from __future__ import annotations - from typing import TYPE_CHECKING import warnings @@ -58,7 +56,7 @@ def validate_input(design: npt.NDArray) -> npt.NDArray: def validate_output( - state: PipelineState, + state: "PipelineState", input_volume_fraction: float, tolerance: float, ) -> list[str]: