Source code for yapcad.brep

## yapCAD BREP representation
## =====================================

## Copyright (c) 2025 Richard W. DeVaul
## Copyright (c) 2025 yapCAD contributors
## All rights reserved

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""
Core Boundary Representation (BREP) helpers for yapCAD.

These classes wrap `pythonocc-core` objects when that dependency is available.
Importing this module on systems without pythonocc-core should not explode;
instead, we raise a clear runtime error the first time a BREP feature is
requested so users know to activate the conda environment.
"""

import base64
import math
import os
import tempfile
from typing import Any, Optional

try:  # pragma: no cover - exercised indirectly in environments with OCC
    from OCC.Core.TopoDS import (
        TopoDS_Shape,
        TopoDS_Vertex,
        TopoDS_Edge,
        TopoDS_Face,
        TopoDS_Shell,
        TopoDS_Solid,
    )
    from OCC.Core.BRep import BRep_Tool, BRep_Builder
    from OCC.Core.TopExp import TopExp_Explorer
    from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_REVERSED
    from OCC.Core.TopLoc import TopLoc_Location
    from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
    from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
    from OCC.Core.gp import gp_Trsf, gp_Vec, gp_Dir, gp_Pnt, gp_Ax1, gp_Ax2
    from OCC.Core.BRepTools import breptools
    from OCC.Core.TopoDS import topods

    _OCC_IMPORT_ERROR: Optional[Exception] = None
    _HAVE_OCC = True
except ImportError as exc:  # pragma: no cover - handled during runtime detection
    TopoDS_Shape = TopoDS_Vertex = TopoDS_Edge = TopoDS_Face = TopoDS_Shell = TopoDS_Solid = Any
    TopExp_Explorer = TopLoc_Location = BRep_Tool = BRepMesh_IncrementalMesh = Any
    TopAbs_FACE = 0
    TopAbs_REVERSED = -1
    BRep_Builder = Any
    breptools = None
    gp_Trsf = gp_Vec = gp_Dir = gp_Pnt = gp_Ax1 = gp_Ax2 = None
    topods = None
    _OCC_IMPORT_ERROR = exc
    _HAVE_OCC = False

from yapcad.geom import point
from yapcad.metadata import ensure_solid_id, get_solid_metadata

_BREP_SOLID_CACHE: dict[str, "BrepSolid"] = {}


[docs] def occ_available() -> bool: """Return True when pythonocc-core imports succeeded.""" return _HAVE_OCC
[docs] def require_occ() -> None: """ Raise a descriptive error if pythonocc-core is not installed/activated. """ if _HAVE_OCC: return raise RuntimeError( "pythonocc-core is not available. Activate the yapcad-brep conda " "environment (see docs/BREP_integration_strategy.md) before using " "yapCAD's BREP features." ) from _OCC_IMPORT_ERROR
def _shape_to_bytes(shape) -> bytes: require_occ() if breptools is None: raise RuntimeError("BRepTools write support is unavailable") tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".brep") tmp.close() breptools.Write(shape, tmp.name) with open(tmp.name, "rb") as handle: data = handle.read() os.remove(tmp.name) return data def _shape_from_bytes(data: bytes) -> TopoDS_Shape: require_occ() if breptools is None: raise RuntimeError("BRepTools read support is unavailable") tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".brep") tmp.close() with open(tmp.name, "wb") as handle: handle.write(data) builder = BRep_Builder() shape = TopoDS_Shape() breptools.Read(shape, tmp.name, builder) os.remove(tmp.name) return shape
[docs] def attach_brep_to_solid(solid: list, brep: "BrepSolid") -> None: """Embed serialized BREP data into the solid metadata and cache the shape.""" require_occ() meta = get_solid_metadata(solid, create=True) solid_id = ensure_solid_id(solid) encoded = base64.b64encode(_shape_to_bytes(brep.shape)).decode("ascii") meta["brep"] = { "encoding": "brep-ascii-base64", "data": encoded, } _BREP_SOLID_CACHE[solid_id] = brep
[docs] def brep_from_solid(solid: list) -> Optional["BrepSolid"]: """Return the cached BrepSolid for ``solid`` if metadata is present.""" if not occ_available(): return None meta = get_solid_metadata(solid, create=False) if not meta: return None solid_id = meta.get("entityId") if not solid_id: return None cached = _BREP_SOLID_CACHE.get(solid_id) if cached: return cached brep_info = meta.get("brep") if not brep_info: return None data = brep_info.get("data") if not data: return None try: decoded = base64.b64decode(data) except Exception: return None try: shape = _shape_from_bytes(decoded) except Exception: return None # Don't force-cast to Solid - shape may be a Compound (from disconnected unions) # BrepSolid.tessellate() uses TopExp_Explorer which handles both brep = BrepSolid(shape) _BREP_SOLID_CACHE[solid_id] = brep return brep
[docs] def has_brep_data(solid: list) -> bool: meta = get_solid_metadata(solid, create=False) return bool(meta and meta.get("brep"))
def translate_brep_solid(solid: list, delta) -> None: """Apply a translation to the stored BREP shape if present.""" if not occ_available() or gp_Trsf is None or gp_Vec is None: return dx = float(delta[0]) if len(delta) > 0 else 0.0 dy = float(delta[1]) if len(delta) > 1 else 0.0 dz = float(delta[2]) if len(delta) > 2 else 0.0 trsf = gp_Trsf() trsf.SetTranslation(gp_Vec(dx, dy, dz)) _apply_trsf_to_brep(solid, trsf) def rotate_brep_solid(solid: list, ang: float, center, axis) -> None: if not occ_available() or gp_Trsf is None or gp_Pnt is None or gp_Ax1 is None or gp_Dir is None: return ax = axis or point(0, 0, 1) magnitude = math.sqrt(ax[0] ** 2 + ax[1] ** 2 + ax[2] ** 2) if magnitude <= 1e-12: return trsf = gp_Trsf() direction = gp_Dir(ax[0], ax[1], ax[2]) trsf.SetRotation(gp_Ax1(gp_Pnt(center[0], center[1], center[2]), direction), math.radians(ang)) _apply_trsf_to_brep(solid, trsf) def mirror_brep_solid(solid: list, plane: str) -> None: if not occ_available() or gp_Trsf is None or gp_Ax2 is None or gp_Dir is None or gp_Pnt is None: return mapping = { 'xy': (0.0, 0.0, 1.0), 'yz': (1.0, 0.0, 0.0), 'xz': (0.0, 1.0, 0.0), } normal = mapping.get(plane) if normal is None: return trsf = gp_Trsf() trsf.SetMirror(gp_Ax2(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(*normal))) _apply_trsf_to_brep(solid, trsf) def scale_brep_solid(solid: list, factor: float, center=None) -> None: if not occ_available() or gp_Trsf is None or gp_Pnt is None: return if center is None: center = point(0, 0, 0) trsf = gp_Trsf() trsf.SetScale(gp_Pnt(float(center[0]), float(center[1]), float(center[2])), float(factor)) _apply_trsf_to_brep(solid, trsf) def _apply_trsf_to_brep(solid: list, trsf) -> None: if not occ_available() or BRepBuilderAPI_Transform is None: return brep = brep_from_solid(solid) if brep is None: return builder = BRepBuilderAPI_Transform(brep.shape, trsf, True) shape = builder.Shape() if topods is not None: try: shape = topods.Solid(shape) except Exception: pass # Regenerate entity ID to avoid cache collision with original solid # (deepcopy preserves entity ID, but transformed solid should have its own) _regenerate_solid_id(solid) attach_brep_to_solid(solid, BrepSolid(shape)) def _regenerate_solid_id(solid: list) -> str: """Force a new entity ID for a solid, clearing its BREP cache entry.""" import uuid meta = get_solid_metadata(solid, create=True) old_id = meta.get('entityId') if old_id and old_id in _BREP_SOLID_CACHE: del _BREP_SOLID_CACHE[old_id] new_id = str(uuid.uuid4()) meta['entityId'] = new_id meta['id'] = new_id return new_id def transform_brep_shape(brep: "BrepSolid", trsf) -> Optional["BrepSolid"]: """Return a transformed BrepSolid (does not touch metadata).""" if not occ_available() or BRepBuilderAPI_Transform is None: return None builder = BRepBuilderAPI_Transform(brep.shape, trsf, True) shape = builder.Shape() if topods is not None: try: shape = topods.Solid(shape) except Exception: pass return BrepSolid(shape)
[docs] class BrepVertex: """A wrapper for a TopoDS_Vertex.""" def __init__(self, shape: TopoDS_Vertex): require_occ() self._shape = shape @property def shape(self): return self._shape
[docs] class BrepEdge: """A wrapper for a TopoDS_Edge.""" def __init__(self, shape: TopoDS_Edge): require_occ() self._shape = shape @property def shape(self): return self._shape
[docs] class BrepFace: """A wrapper for a TopoDS_Face.""" def __init__(self, shape: TopoDS_Face): require_occ() self._shape = shape @property def shape(self): return self._shape
[docs] class BrepSolid: """A wrapper for a TopoDS_Shape (Solid or Compound of Solids).""" def __init__(self, shape): require_occ() self._shape = shape @property def shape(self): return self._shape
[docs] def tessellate(self, deflection=0.5): """ Generate a faceted representation of the BREP model. This method will use `pythonocc-core`'s meshing capabilities to generate a triangular mesh of the BREP model. The resulting faceted representation will be returned in the same format as `yapcad.geom3d.poly2surface`. """ require_occ() mesh = BRepMesh_IncrementalMesh(self._shape, deflection) all_vertices = [] all_triangles = [] all_normals = [] vertex_offset = 0 explorer = TopExp_Explorer(self._shape, TopAbs_FACE) while explorer.More(): face = explorer.Current() loc = TopLoc_Location() triangulation = BRep_Tool.Triangulation(face, loc) if triangulation: trsf = loc.Transformation() orientation = getattr(face, "Orientation", lambda: None)() reverse = orientation == TopAbs_REVERSED local_vertices = [] for i in range(1, triangulation.NbNodes() + 1): pnt = triangulation.Node(i).Transformed(trsf) vertex_point = point(pnt.X(), pnt.Y(), pnt.Z()) local_vertices.append(vertex_point) all_vertices.append(vertex_point) has_normals = triangulation.HasNormals() local_normals = [] if has_normals: for i in range(1, triangulation.NbNodes() + 1): n = triangulation.Normal(i) vec = gp_Vec(n.X(), n.Y(), n.Z()) vec.Transform(trsf) if reverse: vec = gp_Vec(-vec.X(), -vec.Y(), -vec.Z()) local_normals.append([vec.X(), vec.Y(), vec.Z(), 0.0]) else: accum = [[0.0, 0.0, 0.0] for _ in range(triangulation.NbNodes())] for i in range(1, triangulation.NbTriangles() + 1): t = triangulation.Triangle(i) n1, n2, n3 = t.Get() if reverse: n2, n3 = n3, n2 all_triangles.append((n1 - 1 + vertex_offset, n2 - 1 + vertex_offset, n3 - 1 + vertex_offset)) if not has_normals: v0 = local_vertices[n1 - 1] v1 = local_vertices[n2 - 1] v2 = local_vertices[n3 - 1] nx = ((v1[1] - v0[1]) * (v2[2] - v0[2]) - (v1[2] - v0[2]) * (v2[1] - v0[1])) ny = ((v1[2] - v0[2]) * (v2[0] - v0[0]) - (v1[0] - v0[0]) * (v2[2] - v0[2])) nz = ((v1[0] - v0[0]) * (v2[1] - v0[1]) - (v1[1] - v0[1]) * (v2[0] - v0[0])) accum[n1 - 1][0] += nx accum[n1 - 1][1] += ny accum[n1 - 1][2] += nz accum[n2 - 1][0] += nx accum[n2 - 1][1] += ny accum[n2 - 1][2] += nz accum[n3 - 1][0] += nx accum[n3 - 1][1] += ny accum[n3 - 1][2] += nz if has_normals: all_normals.extend(local_normals) else: for vec in accum: length = math.sqrt(vec[0] ** 2 + vec[1] ** 2 + vec[2] ** 2) if length <= 1e-12: nx, ny, nz = 0.0, 0.0, 1.0 else: nx, ny, nz = vec[0] / length, vec[1] / length, vec[2] / length all_normals.append([nx, ny, nz, 0.0]) vertex_offset += triangulation.NbNodes() explorer.Next() if not all_normals or len(all_normals) != len(all_vertices): default_normal = [0.0, 0.0, 1.0, 0.0] all_normals = [default_normal for _ in all_vertices] triangle_lists = [[tri[0], tri[1], tri[2]] for tri in all_triangles] # Boundary/holes placeholders keep the structure compatible with issurface. return ['surface', all_vertices, all_normals, triangle_lists, [], []]
[docs] def is_brep(obj): """Check if an object is a yapCAD BREP object.""" return isinstance(obj, (BrepVertex, BrepEdge, BrepFace, BrepSolid))
__all__ = [ "BrepVertex", "BrepEdge", "BrepFace", "BrepSolid", "is_brep", "occ_available", "require_occ", "attach_brep_to_solid", "brep_from_solid", "has_brep_data", ]