Files
cadSet/SimpleCADAPI/src/simplecadapi/_mesh.py
T
2026-07-22 19:38:36 +08:00

165 lines
5.2 KiB
Python

"""Internal mesh companion data for SimpleCAD geometry.
This module intentionally stays out of the public API. Users should not need to
extract or manage meshes directly; future structural checks will consume this
cache internally.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Tuple
import numpy as np
from .kernel.ocp_mesh import tessellate_face
DEFAULT_LINEAR_TOLERANCE = 0.35
DEFAULT_ANGULAR_TOLERANCE = 0.22
MESH_RUNTIME_KEY = "mesh.default"
MESH_ERROR_RUNTIME_KEY = "mesh.error"
@dataclass(frozen=True)
class FaceTriangleRange:
"""Internal mapping from a source face to a contiguous triangle range."""
face_index: int
start: int
count: int
source_topo_id: str | None = None
@dataclass(frozen=True)
class TriMesh:
"""Internal triangle mesh data attached to OCP-backed solids."""
vertices: np.ndarray
triangles: np.ndarray
face_triangle_ranges: Tuple[FaceTriangleRange, ...]
linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE
angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE
def __post_init__(self) -> None:
vertices = np.asarray(self.vertices, dtype=float)
triangles = np.asarray(self.triangles, dtype=np.int64)
if vertices.ndim != 2 or vertices.shape[1] != 3:
raise ValueError("TriMesh vertices must have shape (n, 3)")
if triangles.ndim != 2 or triangles.shape[1] != 3:
raise ValueError("TriMesh triangles must have shape (m, 3)")
if not np.all(np.isfinite(vertices)):
raise ValueError("TriMesh vertices must be finite")
if len(triangles) and (
int(triangles.min()) < 0 or int(triangles.max()) >= len(vertices)
):
raise ValueError("TriMesh triangle indices are out of bounds")
object.__setattr__(self, "vertices", vertices)
object.__setattr__(self, "triangles", triangles)
@property
def vertex_count(self) -> int:
return int(self.vertices.shape[0])
@property
def triangle_count(self) -> int:
return int(self.triangles.shape[0])
@property
def bounds(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
if self.vertex_count == 0:
zero = (0.0, 0.0, 0.0)
return zero, zero
lower = self.vertices.min(axis=0)
upper = self.vertices.max(axis=0)
return tuple(float(v) for v in lower), tuple(float(v) for v in upper)
def build_solid_trimesh(
solid: Any,
*,
linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE,
angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE,
) -> TriMesh:
"""Build an internal triangle mesh from an OCP-backed Solid wrapper."""
all_vertices: list[tuple[float, float, float]] = []
all_triangles: list[tuple[int, int, int]] = []
face_ranges: list[FaceTriangleRange] = []
for face_index, face in enumerate(solid.get_faces()):
start = len(all_triangles)
vertices, triangles = tessellate_face(
face.wrapped,
tolerance=linear_tolerance,
angular_tolerance=angular_tolerance,
)
vertex_offset = len(all_vertices)
all_vertices.extend((float(x), float(y), float(z)) for x, y, z in vertices)
all_triangles.extend(
(
int(a) + vertex_offset,
int(b) + vertex_offset,
int(c) + vertex_offset,
)
for a, b, c in triangles
)
face_ranges.append(
FaceTriangleRange(
face_index=face_index,
start=start,
count=len(all_triangles) - start,
source_topo_id=getattr(face, "topo_id", None),
)
)
return TriMesh(
vertices=np.asarray(all_vertices, dtype=float).reshape((-1, 3)),
triangles=np.asarray(all_triangles, dtype=np.int64).reshape((-1, 3)),
face_triangle_ranges=tuple(face_ranges),
linear_tolerance=float(linear_tolerance),
angular_tolerance=float(angular_tolerance),
)
def attach_default_mesh(solid: Any) -> None:
"""Attach the default internal mesh cache to a Solid wrapper if possible."""
try:
mesh = build_solid_trimesh(solid)
except Exception as exc:
_set_runtime(solid, MESH_ERROR_RUNTIME_KEY, str(exc))
return
_set_runtime(solid, MESH_RUNTIME_KEY, mesh)
_set_runtime(solid, MESH_ERROR_RUNTIME_KEY, None)
def cached_mesh(solid: Any) -> TriMesh | None:
"""Return the internal mesh cache for tests and future checkers."""
mesh = _get_runtime(solid, MESH_RUNTIME_KEY, None)
return mesh if isinstance(mesh, TriMesh) else None
def mesh_error(solid: Any) -> str | None:
"""Return the internal mesh diagnostic for tests and future checkers."""
value = _get_runtime(solid, MESH_ERROR_RUNTIME_KEY, None)
return str(value) if value else None
def _set_runtime(shape: Any, key: str, value: Any) -> None:
setter = getattr(shape, "_set_runtime", None)
if callable(setter):
setter(key, value)
def _get_runtime(shape: Any, key: str, default: Any) -> Any:
getter = getattr(shape, "_get_runtime", None)
if callable(getter):
return getter(key, default)
return default