Evaluating B-Rep Solids with OpenCASCADE in Python

A boundary-representation kernel models exact curved surfaces and performs boolean operations on solids without triangulating first, which is what makes it necessary for exact solid work and expensive for everything else. Check the shape, run the operation, test the result for nullity as well as for exceptions, then tessellate at a deflection you chose. This page belongs to Choosing a Geometry Engine for Python Pipelines.

What a Kernel Does Differently

A mesh library approximates a cylinder with triangles and then computes on the triangles. A kernel keeps the cylinder as a cylinder — a surface with an axis and a radius — and computes intersections analytically. The difference shows up in three places.

What the kernel keeps that a mesh library discards Two representations of the same cylindrical wall. The kernel holds it as a surface with an axis and a radius, so a cut through it produces a true circular edge that can be cut again without accumulating error. A mesh library holds an approximation, so every operation compounds the approximation already made. B-rep kernel — cylinder stays a cylinder — exact circular cut edges — cuttable again without drift — fails rather than approximates Mesh library — triangulated up front — polygonal cut edges — error compounds per operation — nearly always returns something Exactness is worth its deployment weight only when operations chain.

Exactness. A cut through a cylindrical wall produces a true circular edge rather than a polygonal approximation of one, so the result can be cut again without accumulating approximation error.

Failure behaviour. A mesh boolean nearly always returns something; a kernel boolean returns an exact answer or fails. In an automated pipeline that is usually preferable, because a wrong-but-plausible mesh is harder to detect than a null result. The complication is that the kernel’s failure signalling is not uniform: it raises sometimes and returns a null or invalid shape other times, so both have to be checked.

Tessellation as a separate, controlled step. Because the kernel holds exact surfaces, meshing is something you ask for with an explicit tolerance rather than something you inherit. The linear deflection is in model units and behaves exactly like the sag tolerance described in Tessellating SPLINE Entities with ezdxf.

Production-Ready Script

# pythonocc-core>=7.7, numpy>=1.24, Python 3.9+
from __future__ import annotations

from dataclasses import dataclass
import numpy as np

from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.BRep import BRep_Tool
from OCC.Core.TopAbs import TopAbs_FACE
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import topods


class KernelError(RuntimeError):
    pass


@dataclass(frozen=True)
class Tessellation:
    vertices: np.ndarray      # (n, 3)
    faces: np.ndarray         # (m, 3) indices
    deflection: float


def require_valid(shape, what: str):
    """The kernel does not check its inputs — so we do."""
    if shape is None or shape.IsNull():
        raise KernelError(f"{what} is a null shape")
    if not BRepCheck_Analyzer(shape).IsValid():
        raise KernelError(f"{what} is topologically invalid")
    return shape


def cut(solid, tool):
    """Solid subtraction with the post-conditions the kernel will not assert."""
    require_valid(solid, "solid")
    require_valid(tool, "tool")
    algo = BRepAlgoAPI_Cut(solid, tool)
    algo.Build()
    if not algo.IsDone():
        raise KernelError("cut did not complete")
    return require_valid(algo.Shape(), "cut result")


def fuse(a, b):
    require_valid(a, "a")
    require_valid(b, "b")
    algo = BRepAlgoAPI_Fuse(a, b)
    algo.Build()
    if not algo.IsDone():
        raise KernelError("fuse did not complete")
    return require_valid(algo.Shape(), "fuse result")


def tessellate(shape, deflection: float = 0.01, angular: float = 0.5) -> Tessellation:
    """Mesh at an explicit linear deflection, in MODEL UNITS."""
    require_valid(shape, "shape to tessellate")
    BRepMesh_IncrementalMesh(shape, deflection, False, angular, True)

    verts: list[tuple[float, float, float]] = []
    faces: list[tuple[int, int, int]] = []
    explorer = TopExp_Explorer(shape, TopAbs_FACE)
    while explorer.More():
        face = topods.Face(explorer.Current())
        location = TopLoc_Location()
        triangulation = BRep_Tool.Triangulation(face, location)
        if triangulation is None:
            explorer.Next()
            continue                       # a face the mesher could not handle
        transform = location.Transformation()
        base = len(verts)
        for i in range(1, triangulation.NbNodes() + 1):
            p = triangulation.Node(i).Transformed(transform)
            verts.append((p.X(), p.Y(), p.Z()))
        for i in range(1, triangulation.NbTriangles() + 1):
            a, b, c = triangulation.Triangle(i).Get()
            faces.append((base + a - 1, base + b - 1, base + c - 1))
        explorer.Next()

    if not faces:
        raise KernelError("tessellation produced no triangles")
    return Tessellation(np.array(verts, dtype=float),
                        np.array(faces, dtype=np.int32), deflection)
Checking the inputs and the result Four stages around one operation. Both inputs are analysed before the operation, because the kernel does not check them. The algorithm is asked whether it completed. The result is tested for nullity and analysed again, because an algorithm can report completion and return an invalid or empty shape. Only then is the result usable. Validate the inputs the kernel will not 1 null and topology checks Run the algorithm cut, fuse, common 2 may raise Check completion IsDone() 3 separate from nullity Validate the result null? invalid? 4 completion is not success

Key implementation notes:

  • require_valid is applied to inputs and to results. Checking only the inputs misses the failure mode where the operation completes and returns something invalid.
  • IsDone and nullity are separate checks. An algorithm can report completion and return a null shape.
  • Faces whose triangulation is absent are skipped and would be worth counting; a shape where most faces skip has tessellated in name only.
  • Vertex indices are rebased per face because each face carries its own node numbering. Getting this wrong produces a mesh whose triangles reference the wrong vertices — visually chaotic, and a common first bug.
  • The deflection is recorded on the result, because a mesh without its tolerance cannot be compared with another one.

Compatibility Matrix

Component Supported range Notes
pythonocc-core >=7.7 Triangulation node API changed in 7.x
OpenCASCADE 7.6+ bundled with the wheel
numpy >=1.24 array assembly
Deployment large image hundreds of megabytes; measure cold start
Thread safety not guaranteed parallelise across processes, not threads

Fallback Strategies

1. A boolean returns a null shape. Validate both inputs first; the usual cause is an invalid solid the kernel accepted. Where the inputs are valid, try healing the shape before the operation rather than repeating it.

Linear deflection is in model units The same deflection value applied to models authored in three different units, with the real-world tolerance each produces. The value is a distance in the model coordinate system, so a constant chosen for a model in metres is a thousand times tighter on the same geometry in millimetres — and produces a thousandfold triangle count. Model unit deflection 0.01 Triangle count metres 10 mm reasonable centimetres 0.1 mm high millimetres 0.01 mm unusable Derive the deflection from the resolved model unit, never as a constant.

2. Tessellation produces very few triangles. The deflection is large relative to the model units — a value chosen for metres applied to a model in millimetres. Derive it from the resolved model unit rather than hard-coding it.

3. Tessellation is enormous. The converse. Cap the resulting triangle count and re-mesh at a coarser deflection when the cap is exceeded, so one pathological face cannot produce a million triangles.

4. Faces missing from the mesh. Some faces failed to triangulate. Count them; a handful on a complex shape is normal, a majority means the shape needs healing.

5. Intermittent failures under parallelism. The kernel is not reliably thread-safe. Use a process pool rather than threads.

FAQ

How does OpenCASCADE signal that an operation failed?

Inconsistently, which is the practical problem. It may raise, it may return a null shape, or it may return a shape that is topologically invalid. Code that assumes a result and reads its faces gets zero faces from a null shape, and that flows onward as an element with no geometry. Test the result for nullity and run the analyser on it before using it.

What does the linear deflection actually control?

The maximum distance between the tessellated surface and the true surface — the same idea as a sag tolerance on a curve, applied to a face. It trades vertex count against fidelity, and it is in model units, so a value chosen for a model in metres is a thousand times tighter on the same model in millimetres.

Do I need OpenCASCADE if I already use ifcopenshell?

You already have it — the geometry kernel behind ifcopenshell is OpenCASCADE. What you may not have is direct access to it. If your pipeline only consumes the triangulated output ifcopenshell produces, the kernel work is already done and reaching for the kernel directly adds nothing. Use it directly when you need exact solids rather than meshes.