Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions engibench/transforms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Transforms for converting EngiBench designs to other representations."""
18 changes: 18 additions & 0 deletions engibench/transforms/xeltofab/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
109 changes: 109 additions & 0 deletions engibench/transforms/xeltofab/_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Core bridge between EngiBench density-field problems and xeltofab mesh generation."""

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))
41 changes: 41 additions & 0 deletions engibench/transforms/xeltofab/_presets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""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 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},
}
108 changes: 108 additions & 0 deletions engibench/transforms/xeltofab/_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Input and output validation for the xeltofab mesh transform."""

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
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down Expand Up @@ -277,6 +278,8 @@ module = [
"multipoint",
"pygeo",
"napari",
"pyamg"
"pyamg",
"xeltofab",
"xeltofab.*",
]
ignore_missing_imports = true
Empty file added tests/transforms/__init__.py
Empty file.
Loading
Loading