"""CAD-friendly visualization: topological edges + per-face tessellation.
CAD kernels store *exact* B-rep geometry (analytic / NURBS surfaces,
trimmed faces, parametric edge curves). A GPU only draws triangles, so a
CAD viewer tessellates each topological face to a tolerance and draws
the topological *edges* as separate polylines on top. The triangle-mesh
edges a generic mesh viewer shows (``show_edges=True``) are arbitrary
tessellation artifacts and meaningless to a CAD user; the edges they
care about are the feature curves of the model.
This module reproduces that pipeline:
* :func:`topods_to_edges` — extract topological edges, *reusing* the
face triangulation (no extra meshing pass), so edge polylines lie
exactly on the rendered facets.
* :func:`topods_to_multiblock` — per-face :class:`pyvista.PolyData`
blocks (preserving face identity) carrying analytic point normals
for smooth shading, plus a sibling edges block.
* :func:`flatten_to_cad_polydata` — collapse the per-face MultiBlock to
a single :class:`pyvista.PolyData` with a ``cad.face_id`` cell array
and the edges stashed as a companion attribute.
All ``OCP`` imports are lazy: ``import pyvista_cad`` must not load OCP.
"""
from typing import Any, cast
import warnings
import numpy as np
import pyvista as pv
from pyvista_cad._conversion import _register_brep_cache, get_cached_topods, unwrap_to_topods
from pyvista_cad._errors import CadWarning, TessellationError
# Companion-attribute name: the edges PolyData stashed on a flattened
# face PolyData. Mirrors the ``_cad_topods_shape`` stash convention in
# :mod:`pyvista_cad._conversion` (avoids an ``id()``-recycle cache).
_EDGES_ATTR = '_cad_edges'
# field_data marker set on a faces MultiBlock whose blocks carry
# trustworthy per-point *analytic* surface normals (the B-rep
# tessellation path). When present, smooth shading is correct as-is and
# vertex-splitting at creases would discard those normals. When absent
# (a plain mesh / faceted fallback), smooth shading must split sharp
# edges so hard corners do not melt into rounded blobs.
_ANALYTIC_NORMALS_FLAG = 'cad.analytic_normals'
# GeomAbs_CurveType ordinal -> human label. OCCT enum order is stable
# across the supported OCP / OCCT 7.x line.
_CURVE_KINDS: tuple[str, ...] = (
'line',
'circle',
'ellipse',
'hyperbola',
'parabola',
'bezier',
'bspline',
'offset',
'other',
)
def _geom_errors() -> tuple[type[BaseException], type[BaseException], type[BaseException]]:
"""Exception types a failing OCCT geometry query may raise.
In this OCP build the OCCT ``Standard_*`` exceptions do not share a
common ``Standard_Failure`` base: ``Standard_NullObject`` (raised for
an edge with no 3D curve, the IGES/wireframe case) derives directly
from :class:`Exception`, not from ``Standard_Failure``. Catching only
``Standard_Failure`` would let it escape, so both are returned
alongside ``TypeError`` (a non-shape / wrong-argument call). Imported
lazily: ``import pyvista_cad`` must not load OCP.
"""
from OCP.Standard import Standard_Failure, Standard_NullObject
return (Standard_Failure, Standard_NullObject, TypeError)
def _ensure_mesh(shape: Any, linear_deflection: float, angular_deflection: float) -> None:
"""Run OCCT incremental meshing in place (idempotent if already meshed)."""
from OCP.BRepMesh import BRepMesh_IncrementalMesh
try:
BRepMesh_IncrementalMesh(shape, linear_deflection, False, angular_deflection, True)
except _geom_errors() as exc:
# OCCT ``Standard_*`` (``Exception`` subclasses, not
# ``RuntimeError``, in this OCP build) on un-meshable geometry;
# ``TypeError`` if ``shape`` is not a TopoDS_Shape. Re-raised as a
# package error, never swallowed.
msg = f'OCCT tessellation failed: {exc}'
raise TessellationError(msg) from exc
def _curve_kind(edge: Any) -> int:
"""Return the :data:`_CURVE_KINDS` ordinal for ``edge``'s geometry."""
from OCP.BRepAdaptor import BRepAdaptor_Curve
try:
code = int(BRepAdaptor_Curve(edge).GetType())
except _geom_errors():
# OCCT raises ``Standard_NullObject`` (an ``Exception`` subclass,
# not ``RuntimeError``) for an edge with no well-defined 3D curve;
# classify it as 'other'. This is a label fallback only, not
# geometry loss, so no warning is warranted.
return len(_CURVE_KINDS) - 1 # 'other'
return code if 0 <= code < len(_CURVE_KINDS) else len(_CURVE_KINDS) - 1
def _kind_legend() -> str:
"""JSON-ish legend mapping ``cad.edge_kind`` codes to labels."""
return ';'.join(f'{i}={name}' for i, name in enumerate(_CURVE_KINDS))
def _sample_free_edge(edge: Any, deflection: float) -> np.ndarray | None:
"""Sample a face-less edge along its 3D curve (fallback path).
Used only for wireframe edges that belong to no face and therefore
have no ``PolygonOnTriangulation`` (e.g. IGES construction curves).
Returns an ``(n, 3)`` point array, an empty array when the edge is
too short to draw, or ``None`` when OCCT could not sample the curve
at all (the edge is then lost from the rendered wireframe; the
caller aggregates these into a single diagnostic).
"""
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_TangentialDeflection
try:
curve = BRepAdaptor_Curve(edge)
sampler = GCPnts_TangentialDeflection(curve, deflection, deflection)
n = sampler.NbPoints()
if n < 2:
return np.empty((0, 3), dtype=float)
pts = np.empty((n, 3), dtype=float)
for i in range(1, n + 1):
p = sampler.Value(i)
pts[i - 1] = (p.X(), p.Y(), p.Z())
except _geom_errors():
# OCCT ``Standard_NullObject`` (an ``Exception`` subclass, not
# ``RuntimeError``, in this OCP build): the edge has no continuous
# 3D curve. This is the IGES/wireframe construction-curve case;
# the caller aggregates it into one ``CadWarning``.
return None
return pts
def _face_unit_normal_at_edge(face: Any, edge: Any) -> np.ndarray | None:
"""Analytic unit surface normal of ``face`` at ``edge``'s midpoint.
Orientation-corrected (a ``REVERSED`` face flips the normal).
Returns ``None`` if the normal is undefined (e.g. degenerate pcurve).
"""
from OCP.BRepAdaptor import BRepAdaptor_Curve2d, BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane
from OCP.gp import gp_Dir, gp_Pnt, gp_Vec
try:
adaptor = BRepAdaptor_Surface(face)
reversed_face = face.Orientation() == 1 # TopAbs_REVERSED
# Fast path: a planar face (every triangle of a sewn faceted
# solid is one) has a constant normal. Read the analytic plane
# straight from the adaptor: no Geom downcast (absent in this OCP
# build) and no pcurve needed.
if adaptor.GetType() == GeomAbs_Plane:
ax = adaptor.Plane().Axis().Direction()
n = np.array([ax.X(), ax.Y(), ax.Z()], dtype=float)
return -n if reversed_face else n
# Curved face: find the UV at the edge midpoint, then take the
# normal as the normalized cross product of the surface partials
# there (du x dv), orientation-corrected. Use the 2D pcurve
# adaptor (it carries its own parameter range, avoiding the
# by-reference First/Last out-args of ``CurveOnSurface_s`` that
# this OCP build does not return). The sole caller
# (_tangent_internal_edges) only ever passes an edge that bounds
# ``face``, so its pcurve always has a non-empty range; a
# collapsed range means OCCT could not build the pcurve, which
# surfaces as ``Standard_NullObject`` from ``Value`` below and is
# handled by the ``except`` (no usable normal -> keep the edge).
pc = BRepAdaptor_Curve2d(edge, face)
uv = pc.Value(0.5 * (pc.FirstParameter() + pc.LastParameter()))
p = gp_Pnt()
du = gp_Vec()
dv = gp_Vec()
adaptor.D1(uv.X(), uv.Y(), p, du, dv)
cross = du.Crossed(dv)
if cross.Magnitude() < 1e-12:
# Degenerate point (e.g. a cone apex): no defined normal.
return None
d = gp_Dir(cross)
if reversed_face:
d.Reverse()
return np.array([d.X(), d.Y(), d.Z()], dtype=float)
except _geom_errors():
# OCCT ``Standard_*`` (``Exception`` subclasses, not
# ``RuntimeError``, in this OCP build): the normal is undefined at
# this parameter (degenerate pcurve / singular point). ``None`` is
# the documented "no usable normal" signal; the caller
# (_tangent_internal_edges) simply keeps the edge, so this is
# control flow, not data loss.
return None
def _tangent_internal_edges(shape: Any, tol_deg: float) -> Any:
"""Map of edges to *suppress*: shared by two G1-tangent faces.
An edge between two faces whose surface normals are within
``tol_deg`` is either a smooth/tangent transition (hidden by
convention in CAD viewers) or — for a faceted solid where every
triangle is its own planar face — pure tessellation noise. Real
feature edges (sharp corners, silhouettes against a boundary) sit
between faces at a meaningful angle and are kept. Edges that are
not shared by exactly two faces (true boundaries, non-manifold)
are never suppressed.
"""
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCP.TopExp import TopExp
from OCP.TopoDS import TopoDS
from OCP.TopTools import (
TopTools_IndexedDataMapOfShapeListOfShape,
TopTools_MapOfShape,
)
skip = TopTools_MapOfShape()
amap = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(shape, TopAbs_EDGE, TopAbs_FACE, amap)
cos_tol = np.cos(np.deg2rad(tol_deg))
for i in range(1, amap.Extent() + 1):
faces = amap.FindFromIndex(i)
if faces.Extent() != 2:
continue
edge = TopoDS.Edge_s(amap.FindKey(i))
f1 = TopoDS.Face_s(faces.First())
f2 = TopoDS.Face_s(faces.Last())
n1 = _face_unit_normal_at_edge(f1, edge)
n2 = _face_unit_normal_at_edge(f2, edge)
if n1 is None or n2 is None:
continue
if abs(float(np.dot(n1, n2))) >= cos_tol:
skip.Add(edge)
return skip
[docs]
def topods_to_edges(
shape: Any,
*,
linear_deflection: float = 0.1,
angular_deflection: float = 0.5,
tangent_tol_deg: float = 8.0,
) -> pv.PolyData:
"""Extract topological edges of ``shape`` as a polyline mesh.
Edges are recovered from the *existing* face triangulation via
OCCT's ``PolygonOnTriangulation`` — no extra meshing pass — so the
polylines lie exactly on the tessellated facets. Each topological
edge becomes a single polyline cell, deduplicated across the faces
that share it. Face-less edges (wireframe geometry) fall back to
direct 3D curve sampling.
Edges shared by two faces whose surface normals agree to within
``tangent_tol_deg`` are dropped: these are smooth/tangent
transitions (hidden by convention in CAD viewers) or, for a
faceted solid where every triangle is its own planar face, pure
tessellation noise. Real sharp edges and open boundaries are kept.
Parameters
----------
shape : OCP.TopoDS.TopoDS_Shape
Shape to extract edges from. ``build123d`` / ``cadquery``
wrappers are unwrapped automatically.
linear_deflection : float, default: 0.1
Chordal deviation used if ``shape`` is not already meshed.
angular_deflection : float, default: 0.5
Angular deviation (radians) used if not already meshed.
tangent_tol_deg : float, default: 8.0
Adjacent-face angle below which a shared edge is treated as a
tangent/coplanar internal edge and suppressed. Set to ``0`` to
keep every topological edge.
Returns
-------
pyvista.PolyData
Line-cell mesh. Cell arrays: ``cad.edge_id`` (int, per edge)
and ``cad.edge_kind`` (int code; see ``field_data
['cad.edge_kind_legend']``).
Raises
------
pyvista_cad.TessellationError
If OCCT meshing fails.
Examples
--------
Extract feature edges from a build123d box (requires the
``[step]`` extra):
>>> import build123d as b3d
>>> from pyvista_cad import topods_to_edges
>>> edges = topods_to_edges(b3d.Box(10, 10, 10).wrapped)
>>> type(edges).__name__
'PolyData'
>>> edges.n_cells
12
"""
from OCP.BRep import BRep_Tool
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS
from OCP.TopTools import TopTools_MapOfShape
shape = unwrap_to_topods(shape)
_ensure_mesh(shape, linear_deflection, angular_deflection)
skip_edges = _tangent_internal_edges(shape, tangent_tol_deg) if tangent_tol_deg > 0 else None
points: list[tuple[float, float, float]] = []
lines: list[int] = []
edge_ids: list[int] = []
edge_kinds: list[int] = []
seen = TopTools_MapOfShape()
next_id = 0
fexp = TopExp_Explorer(shape, TopAbs_FACE)
while fexp.More():
face = TopoDS.Face_s(fexp.Current())
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation_s(face, loc)
if tri is None:
fexp.Next()
continue
trsf = loc.Transformation()
eexp = TopExp_Explorer(face, TopAbs_EDGE)
while eexp.More():
edge = TopoDS.Edge_s(eexp.Current())
if not seen.Add(edge): # already emitted via another face
eexp.Next()
continue
if skip_edges is not None and skip_edges.Contains(edge):
eexp.Next()
continue
poly = BRep_Tool.PolygonOnTriangulation_s(edge, tri, loc)
if poly is None:
eexp.Next()
continue
node_idx = poly.Nodes()
n = node_idx.Length()
if n < 2:
eexp.Next()
continue
offset = len(points)
for k in range(node_idx.Lower(), node_idx.Upper() + 1):
node = tri.Node(node_idx.Value(k))
node.Transform(trsf)
points.append((node.X(), node.Y(), node.Z()))
lines.append(n)
lines.extend(range(offset, offset + n))
edge_ids.append(next_id)
edge_kinds.append(_curve_kind(edge))
next_id += 1
eexp.Next()
fexp.Next()
# Face-less wireframe edges (belong to no face triangulation).
abandoned_edges = 0
eexp = TopExp_Explorer(shape, TopAbs_EDGE)
while eexp.More():
edge = TopoDS.Edge_s(eexp.Current())
if seen.Add(edge):
pts = _sample_free_edge(edge, linear_deflection)
if pts is None:
abandoned_edges += 1
elif len(pts) >= 2:
offset = len(points)
points.extend(map(tuple, pts))
lines.append(len(pts))
lines.extend(range(offset, offset + len(pts)))
edge_ids.append(next_id)
edge_kinds.append(_curve_kind(edge))
next_id += 1
eexp.Next()
if abandoned_edges:
warnings.warn(
f'{abandoned_edges} face-less edge(s) could not be sampled '
'from their analytic curve and are missing from the CAD edge '
'overlay; the rendered wireframe is incomplete.',
CadWarning,
stacklevel=2,
)
edges = pv.PolyData()
if points:
edges.points = np.asarray(points, dtype=float)
edges.lines = np.asarray(lines, dtype=np.int64)
edges.cell_data['cad.edge_id'] = np.asarray(edge_ids, dtype=np.int64)
edges.cell_data['cad.edge_kind'] = np.asarray(edge_kinds, dtype=np.int64)
edges.field_data['cad.edge_kind_legend'] = np.array([_kind_legend()])
return edges
def _face_normals(face: Any, tri: Any, trsf: Any, n_nodes: int) -> np.ndarray | None:
"""Return transformed per-node *analytic* surface normals, or ``None``.
VTK's facet normals make a coarse CAD mesh look polygonal. Recovering
the true surface normal at each triangulation node (evaluated from
the analytic surface at the node's UV) is the single biggest
CAD-look improvement: a 12-facet cylinder shades perfectly round.
Uses cached triangulation normals when OCCT stored them; otherwise
evaluates the underlying ``Geom_Surface`` at the per-node UV.
"""
# Fast path: triangulation already carries normals.
try:
if tri.HasNormals():
normals = np.empty((n_nodes, 3), dtype=float)
for i in range(1, n_nodes + 1):
d = tri.Normal(i).Transformed(trsf)
normals[i - 1] = (d.X(), d.Y(), d.Z())
return normals
except _geom_errors():
# Cached-normal read failed; fall through to the analytic path.
pass
# Analytic path: evaluate surface normal at each node's UV.
try:
if not tri.HasUVNodes():
return None
from OCP.BRep import BRep_Tool
from OCP.GeomLProp import GeomLProp_SLProps
surface = BRep_Tool.Surface_s(face)
if surface is None:
return None
reversed_face = face.Orientation() == 1 # TopAbs_REVERSED
normals = np.empty((n_nodes, 3), dtype=float)
for i in range(1, n_nodes + 1):
uv = tri.UVNode(i)
props = GeomLProp_SLProps(surface, uv.X(), uv.Y(), 1, 1e-6)
if not props.IsNormalDefined():
return None
d = props.Normal()
if reversed_face:
d.Reverse()
d = d.Transformed(trsf)
normals[i - 1] = (d.X(), d.Y(), d.Z())
except _geom_errors():
# OCCT ``Standard_*`` (``Exception`` subclasses, not
# ``RuntimeError``) while evaluating the analytic surface: the
# face falls back to VTK facet normals (faceted shading).
# The caller aggregates this into one per-call diagnostic.
return None
return normals
[docs]
def topods_to_multiblock(
shape: Any,
*,
linear_deflection: float = 0.1,
angular_deflection: float = 0.5,
) -> pv.MultiBlock:
"""Tessellate ``shape`` preserving per-face identity and edges.
Returns a two-block :class:`pyvista.MultiBlock`::
MultiBlock
|-- "faces" -> MultiBlock(one PolyData per topological face)
|-- "edges" -> PolyData (see :func:`topods_to_edges`)
Each face block carries a ``cad.face_id`` cell array and analytic
point ``Normals`` (smooth shading on a coarse mesh). A single
``BRepMesh`` pass feeds both blocks.
Parameters
----------
shape : OCP.TopoDS.TopoDS_Shape
Shape to tessellate. ``build123d`` / ``cadquery`` wrappers are
unwrapped automatically.
linear_deflection : float, default: 0.1
Maximum chordal deviation from the analytic surface.
angular_deflection : float, default: 0.5
Maximum angular deviation (radians) between adjacent triangles.
Returns
-------
pyvista.MultiBlock
``{'faces': MultiBlock, 'edges': PolyData}``.
Raises
------
pyvista_cad.TessellationError
If OCCT meshing fails.
Warns
-----
pyvista_cad.CadWarning
When the analytic surface normal cannot be evaluated for one or
more faces, those faces fall back to VTK facet normals and shade
faceted rather than smooth. A single aggregated warning is
emitted per call (never one per face).
Notes
-----
Smooth shading relies on per-node analytic surface normals. For a
face whose analytic surface evaluation fails (degenerate pcurve,
singular surface point, or a triangulation that carries no UV
nodes), the block is emitted without a ``Normals`` array and the
renderer falls back to faceted facet normals for that face. The
geometry itself is unaffected; only the shading degrades.
Examples
--------
Split a build123d box into per-face and edge blocks (requires the
``[step]`` extra):
>>> import build123d as b3d
>>> from pyvista_cad import topods_to_multiblock
>>> mb = topods_to_multiblock(b3d.Box(10, 10, 10).wrapped)
>>> sorted(mb.keys())
['edges', 'faces']
>>> mb['faces'].n_blocks
6
"""
from OCP.BRep import BRep_Tool
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS
shape = unwrap_to_topods(shape)
_ensure_mesh(shape, linear_deflection, angular_deflection)
faces = pv.MultiBlock()
fexp = TopExp_Explorer(shape, TopAbs_FACE)
face_id = 0
faceted_faces = 0
while fexp.More():
face = TopoDS.Face_s(fexp.Current())
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation_s(face, loc)
if tri is None:
fexp.Next()
continue
trsf = loc.Transformation()
n_nodes = tri.NbNodes()
pts = np.empty((n_nodes, 3), dtype=float)
for i in range(1, n_nodes + 1):
node = tri.Node(i)
node.Transform(trsf)
pts[i - 1] = (node.X(), node.Y(), node.Z())
reversed_face = face.Orientation() == 1 # TopAbs_REVERSED
n_tri = tri.NbTriangles()
conn = np.empty((n_tri, 4), dtype=np.int64)
for i in range(1, n_tri + 1):
i1, i2, i3 = tri.Triangle(i).Get()
if reversed_face:
i2, i3 = i3, i2
conn[i - 1] = (3, i1 - 1, i2 - 1, i3 - 1)
block = pv.PolyData(pts, conn.ravel() if n_tri else None)
block.cell_data['cad.face_id'] = np.full(max(n_tri, 0), face_id, dtype=np.int64)
normals = _face_normals(face, tri, trsf, n_nodes)
if normals is not None:
block.point_data['Normals'] = normals
block.point_data.active_normals_name = 'Normals'
elif tri.HasUVNodes():
# The face had UV nodes (so the analytic path was attempted)
# yet produced no normals: it shades faceted, not smooth.
faceted_faces += 1
faces.append(block, name=f'face_{face_id:05d}')
face_id += 1
fexp.Next()
if faceted_faces:
warnings.warn(
f'{faceted_faces} face(s) abandoned the analytic-normal path; '
'they fall back to faceted facet normals and the rendered CAD '
'view shades coarser than the B-rep supports.',
CadWarning,
stacklevel=2,
)
edges = topods_to_edges(
shape,
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
)
if faceted_faces == 0 and face_id > 0:
# Every face carries analytic per-point normals: smooth shading
# is exact, no crease-splitting needed (and splitting would
# throw the analytic normals away).
faces.field_data[_ANALYTIC_NORMALS_FLAG] = np.array([1], dtype=np.int64)
mb = pv.MultiBlock()
mb['faces'] = faces
mb['edges'] = edges
_register_brep_cache(mb, shape) # type: ignore[arg-type]
return mb
[docs]
def flatten_to_cad_polydata(mb: pv.MultiBlock) -> pv.PolyData:
"""Collapse a :func:`topods_to_multiblock` result to one PolyData.
Per-face blocks are merged into a single :class:`pyvista.PolyData`
carrying a ``cad.face_id`` cell array; the edges block is stashed as
a companion attribute and is recoverable via :func:`get_cad_edges`.
Parameters
----------
mb : pyvista.MultiBlock
A ``{'faces', 'edges'}`` block from :func:`topods_to_multiblock`.
Returns
-------
pyvista.PolyData
Merged faces with ``cad.face_id``; edges available through
:func:`get_cad_edges`.
Examples
--------
Flatten a per-face block tree to one PolyData (requires the
``[step]`` extra):
>>> import build123d as b3d
>>> from pyvista_cad import topods_to_multiblock, flatten_to_cad_polydata
>>> mb = topods_to_multiblock(b3d.Box(10, 10, 10).wrapped)
>>> poly = flatten_to_cad_polydata(mb)
>>> type(poly).__name__
'PolyData'
>>> 'cad.face_id' in poly.cell_data
True
"""
# NOTE: ``name in MultiBlock`` tests block *data*, not names; the
# block-name list is what we want here.
names = mb.keys()
faces = cast('pv.MultiBlock', mb['faces']) if 'faces' in names else mb
merged = faces.combine(merge_points=False) if len(faces) else pv.PolyData()
if isinstance(merged, pv.PolyData):
poly = merged
else:
poly = merged.extract_surface(algorithm='dataset_surface')
edges = mb['edges'] if 'edges' in names else None
if edges is not None:
_stash_edges(poly, cast('pv.PolyData', edges))
cached = get_cached_topods(mb) # type: ignore[arg-type]
if cached is not None:
_register_brep_cache(poly, cached)
return poly
def _stash_edges(poly: pv.PolyData, edges: pv.PolyData) -> None:
"""Attach an edges PolyData to ``poly`` as a recoverable companion."""
import contextlib
with contextlib.suppress(AttributeError, TypeError):
setattr(poly, _EDGES_ATTR, edges)
[docs]
def get_cad_edges(poly: pv.PolyData) -> pv.PolyData | None:
"""Return the edges PolyData stashed on ``poly``, if any.
Parameters
----------
poly : pyvista.PolyData
A mesh produced by :func:`flatten_to_cad_polydata`.
Returns
-------
pyvista.PolyData or None
The companion edges mesh, or ``None``.
Examples
--------
Recover the edges companion after flattening (requires the
``[step]`` extra):
>>> import build123d as b3d
>>> from pyvista_cad import (
... topods_to_multiblock,
... flatten_to_cad_polydata,
... get_cad_edges,
... )
>>> mb = topods_to_multiblock(b3d.Box(10, 10, 10).wrapped)
>>> poly = flatten_to_cad_polydata(mb)
>>> edges = get_cad_edges(poly)
>>> type(edges).__name__
'PolyData'
"""
return getattr(poly, _EDGES_ATTR, None)
def _shape_has_faces(shape: Any) -> bool:
"""Return ``True`` if ``shape`` contains at least one ``TopoDS_Face``."""
try:
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
return bool(TopExp_Explorer(shape, TopAbs_FACE).More())
except (ImportError, RuntimeError, TypeError):
# No OCP, or ``shape`` is not a TopoDS_Shape: treat as "no
# trustworthy B-rep faces" so the caller uses the mesh path.
return False
def _count_faces(shape: Any) -> int:
"""Return the number of ``TopoDS_Face`` in ``shape`` (0 if unknown)."""
try:
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
exp = TopExp_Explorer(shape, TopAbs_FACE)
n = 0
while exp.More():
n += 1
exp.Next()
except (ImportError, RuntimeError, TypeError):
# No OCP, or ``shape`` is not a TopoDS_Shape.
return 0
return n
def _feature_edge_fallback(mesh: pv.PolyData, feature_angle: float) -> pv.PolyData:
"""Crease + boundary edges for a mesh with no usable B-rep origin.
A heavily tessellated surface (e.g. a dense IGES B-spline import)
produces a feature-edge set so dense it paints the whole model
black. When the extracted edges rival the mesh's own cell count
they are tessellation noise, not features — drop the overlay and
let the shaded faces speak.
"""
try:
feat = mesh.extract_feature_edges(
feature_angle=feature_angle,
boundary_edges=True,
feature_edges=True,
non_manifold_edges=False,
manifold_edges=False,
)
except (ValueError, RuntimeError, TypeError) as exc:
# VTK rejected the mesh (empty / non-surface input). With no
# B-rep origin and no extractable feature edges there is no edge
# overlay at all; the CAD view degrades to a plain shaded mesh.
warnings.warn(
f'feature-edge extraction failed ({exc}) and the mesh has no '
'B-rep origin; the CAD view is rendered without any edge '
'overlay.',
CadWarning,
stacklevel=3,
)
return pv.PolyData()
if mesh.n_cells and feat.n_cells > 0.25 * mesh.n_cells:
return pv.PolyData()
return feat
def _faces_have_analytic_normals(face_obj: Any) -> bool:
"""Return ``True`` if ``face_obj`` carries trustworthy analytic normals.
Only the B-rep tessellation path (:func:`topods_to_multiblock`)
stamps the :data:`_ANALYTIC_NORMALS_FLAG` field-data marker. A plain
mesh — even one whose PLY/STL ships baked vertex normals — does not,
because those normals are smooth-averaged across hard creases and
melt corners under smooth shading.
"""
return isinstance(face_obj, pv.MultiBlock) and _ANALYTIC_NORMALS_FLAG in face_obj.field_data
def _resolve_cad(
obj: Any,
*,
linear_deflection: float,
angular_deflection: float,
feature_angle: float,
) -> tuple[Any, Any]:
"""Resolve ``obj`` to ``(faces, edges)`` for rendering.
``edges`` is a :class:`pyvista.PolyData` (or ``None``); typed
``Any`` because PyVista's ``MultiBlock.__getitem__`` stub widens to
``MultiBlock | DataSet | None``.
Resolution order:
1. ``MultiBlock`` with ``faces`` / ``edges`` keys -> used directly.
2. generic ``MultiBlock`` (assembly) -> each block resolved
recursively; faces aggregated, edges merged.
3. ``PolyData`` with stashed companion edges -> used directly.
4. ``PolyData`` / wrapper with a cached originating ``TopoDS`` ->
:func:`topods_to_multiblock`.
5. raw ``TopoDS`` / ``build123d`` / ``cadquery`` ->
:func:`topods_to_multiblock`.
6. plain ``PolyData`` (no B-rep) -> faces as-is, edges via
:meth:`~pyvista.PolyData.extract_feature_edges` (a documented
approximation: no topology is available).
"""
if isinstance(obj, pv.MultiBlock):
# ``name in MultiBlock`` tests block data; use the name list.
names = obj.keys()
if 'faces' in names:
return obj['faces'], (obj['edges'] if 'edges' in names else None)
agg_faces = pv.MultiBlock()
edge_parts: list[pv.PolyData] = []
all_analytic = True
n_blocks = 0
for i, block in enumerate(obj):
if block is None:
continue
bf, be = _resolve_cad(
block,
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
feature_angle=feature_angle,
)
agg_faces.append(bf, name=f'block_{i:05d}')
all_analytic &= _faces_have_analytic_normals(bf)
n_blocks += 1
if be is not None and be.n_cells:
edge_parts.append(be)
if all_analytic and n_blocks:
agg_faces.field_data[_ANALYTIC_NORMALS_FLAG] = np.array([1], dtype=np.int64)
merged_edges = pv.merge(edge_parts) if edge_parts else None
return agg_faces, merged_edges
if isinstance(obj, pv.PolyData):
stashed = get_cad_edges(obj)
if stashed is not None:
return obj, stashed
cached = get_cached_topods(obj)
# Only trust the cached B-rep when it actually has faces. Some
# readers (e.g. FCStd) stash a bare sketch/wire — or attach one
# to an empty placeholder block — as the origin; its edges have
# nothing to do with the displayed solid and would render as a
# stray ring far outside the mesh.
if cached is not None and _shape_has_faces(cached):
# Keep the *existing* mesh as the face layer: it already
# carries the caller's scalars / colours and is tessellated.
# Only the topological edges are recovered from the cached
# B-rep (no re-tessellation, edges sit on the same facets).
# For a pristine analytic per-face mesh with smooth normals,
# call :func:`topods_to_multiblock` directly.
edges = topods_to_edges(
cached,
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
)
# A *faceted* B-rep (e.g. a mesh round-tripped through
# write_brep) is a triangle soup: every facet is its own
# planar face, so its "topological" edges are just the
# triangle wireframe. Detect it by face count, not edge
# count: a soup has ~one B-rep face per triangle, whereas a
# genuine B-rep has far fewer faces than triangles even when
# coarsely tessellated (a Box is 6 faces / 12 triangles, so
# an edge:triangle ratio test would wrongly flag it). When
# the face count rivals the triangle count the "topological"
# edges are tessellation noise — fall back to crease
# extraction, which yields the same clean outline.
if obj.n_cells and _count_faces(cached) > 0.5 * obj.n_cells:
return obj, _feature_edge_fallback(obj, feature_angle)
return obj, edges
return obj, _feature_edge_fallback(obj, feature_angle)
if isinstance(obj, pv.DataSet):
surf = obj.extract_surface(algorithm='dataset_surface')
return _resolve_cad(
surf,
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
feature_angle=feature_angle,
)
# raw TopoDS / build123d / cadquery
mb = topods_to_multiblock(
obj,
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
)
return mb['faces'], mb['edges']
[docs]
def as_cad_multiblock(
obj: Any,
*,
linear_deflection: float = 0.1,
angular_deflection: float = 0.5,
feature_angle: float = 30.0,
) -> pv.MultiBlock:
"""Resolve ``obj`` to a ``{'faces', 'edges'}`` :class:`pyvista.MultiBlock`.
The same resolution :func:`add_cad` performs, but returned as data
instead of rendered: ``faces`` is a per-face / per-block MultiBlock
carrying analytic normals, ``edges`` the topological B-rep curves
(or feature edges for a mesh with no B-rep origin).
Parameters
----------
obj : Any
Anything :func:`add_cad` accepts.
linear_deflection, angular_deflection : float
Tessellation tolerances if ``obj`` is not yet meshed.
feature_angle : float, default: 30.0
Crease angle for the no-B-rep feature-edge fallback.
Returns
-------
pyvista.MultiBlock
``{'faces': MultiBlock | PolyData, 'edges': PolyData}``.
Examples
--------
Resolve a build123d box to faces/edges blocks (requires the
``[step]`` extra):
>>> import build123d as b3d
>>> from pyvista_cad import as_cad_multiblock
>>> cad = as_cad_multiblock(b3d.Box(10, 10, 10).wrapped)
>>> type(cad).__name__
'MultiBlock'
>>> sorted(cad.keys())
['edges', 'faces']
"""
faces, edges = _resolve_cad(
obj,
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
feature_angle=feature_angle,
)
out = pv.MultiBlock()
out['faces'] = faces
out['edges'] = edges if edges is not None else pv.PolyData()
return out
[docs]
def add_cad(
plotter: pv.Plotter,
obj: Any,
*,
color: Any = None,
edges: bool = True,
edge_color: Any = None,
line_width: float = 2.0,
render_lines_as_tubes: bool = False,
silhouette: bool = False,
smooth: bool = True,
feature_angle: float = 30.0,
linear_deflection: float = 0.1,
angular_deflection: float = 0.5,
**mesh_kwargs: Any,
) -> dict[str, Any]:
"""Add ``obj`` to ``plotter`` rendered CAD-style.
Faces are drawn smooth-shaded with their triangle edges hidden;
topological edges are overlaid as theme-coloured lines with a depth
bias so they never z-fight the surface. This is the rendering
primitive behind :meth:`pyvista.Plotter.cad.add` and the ``.cad``
accessor's ``plot()``.
Parameters
----------
plotter : pyvista.Plotter
Target plotter.
obj : Any
``MultiBlock`` / ``PolyData`` / ``DataSet`` / raw ``TopoDS`` /
``build123d`` / ``cadquery`` object. A plain mesh with no B-rep
origin falls back to feature-edge extraction (approximate).
color : pyvista.ColorLike, optional
Face colour. Defaults to the active theme colour.
edges : bool, default: True
Draw the topological edge overlay.
edge_color : pyvista.ColorLike, optional
Edge colour. Defaults to ``pyvista.global_theme.edge_color``.
line_width : float, default: 2.0
Edge line width.
render_lines_as_tubes : bool, default: False
Render edges as tubes (nicer at thick widths).
silhouette : bool, default: False
Add a camera-tracking silhouette outline of the faces.
smooth : bool, default: True
Use analytic normals for smooth (non-faceted) shading.
feature_angle : float, default: 30.0
Crease angle for the no-B-rep feature-edge fallback.
linear_deflection, angular_deflection : float
Tessellation tolerances if ``obj`` is not yet meshed.
**mesh_kwargs
Forwarded to :meth:`~pyvista.Plotter.add_mesh` for the faces.
Returns
-------
dict
``{'faces': <Actor>, 'edges': <Actor or None>}``.
Examples
--------
Add a build123d box to a plotter CAD-style (requires the
``[step]`` extra):
>>> import pyvista as pv
>>> pv.OFF_SCREEN = True
>>> import build123d as b3d
>>> from pyvista_cad import add_cad
>>> pl = pv.Plotter()
>>> actors = add_cad(pl, b3d.Box(10, 10, 10).wrapped)
>>> sorted(actors.keys())
['edges', 'faces']
>>> pl.close()
"""
face_obj, edge_obj = _resolve_cad(
obj,
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
feature_angle=feature_angle,
)
# If the caller drives face colour by data (scalars / rgb /
# multi_colors), do not inject a default solid colour — it would
# override the mapping in ``add_mesh``.
scalar_driven = any(k in mesh_kwargs for k in ('scalars', 'rgb', 'rgba', 'multi_colors'))
if color is None and not scalar_driven:
color = pv.global_theme.color
if edge_color is None:
edge_color = pv.global_theme.edge_color
# Smooth shading is only correct when the faces carry analytic
# per-point normals (the B-rep path). For a plain mesh the stored /
# VTK-computed normals are averaged across hard creases, so smooth
# shading melts every sharp corner into a rounded blob. Splitting
# vertices at sharp edges keeps each facet region's own normal and
# restores the crisp CAD silhouette without adding any geometry.
analytic_normals = _faces_have_analytic_normals(face_obj)
split_sharp_edges = smooth and not analytic_normals
face_kwargs: dict[str, Any] = {
'show_edges': False,
'smooth_shading': smooth,
'split_sharp_edges': split_sharp_edges,
'silhouette': silhouette,
**mesh_kwargs,
}
if color is not None:
face_kwargs['color'] = color
face_actor = plotter.add_mesh(face_obj, **face_kwargs)
edge_actor = None
if edges and edge_obj is not None and edge_obj.n_cells:
edge_actor = plotter.add_mesh(
edge_obj,
color=edge_color,
line_width=line_width,
render_lines_as_tubes=render_lines_as_tubes,
pickable=False,
lighting=False,
)
# Pull edges slightly toward the camera so they never z-fight
# the coincident facets they were sampled from.
mapper = getattr(edge_actor, 'mapper', None)
if mapper is not None:
try:
mapper.SetResolveCoincidentTopologyToPolygonOffset()
mapper.SetRelativeCoincidentTopologyLineOffsetParameters(-1.0, -1.0)
except (AttributeError, TypeError):
# Older VTK mapper without the relative-offset API: edges
# may z-fight slightly. Cosmetic only, no data loss, so
# no warning is warranted.
pass
return {'faces': face_actor, 'edges': edge_actor}
[docs]
def plot_cad(obj: Any, **kwargs: Any) -> Any:
"""One-shot CAD-friendly plot of ``obj`` (mirrors ``mesh.plot()``).
Builds a :class:`pyvista.Plotter`, calls :func:`add_cad`, and shows
it. ``show``-level keywords (``screenshot``, ``cpos``,
``off_screen``, ``window_size``, ``jupyter_backend``, ``return_cpos``)
are split out and forwarded to :meth:`pyvista.Plotter.show`;
everything else goes to :func:`add_cad`.
Parameters
----------
obj : Any
Anything :func:`add_cad` accepts.
**kwargs
Split between :func:`add_cad` and
:meth:`pyvista.Plotter.show`.
Returns
-------
Any
Whatever :meth:`pyvista.Plotter.show` returns.
Examples
--------
One-shot CAD plot of a build123d box (requires the ``[step]``
extra):
>>> import pyvista as pv
>>> pv.OFF_SCREEN = True
>>> import build123d as b3d
>>> from pyvista_cad import plot_cad
>>> cpos = plot_cad(b3d.Box(10, 10, 10).wrapped, return_cpos=True)
>>> type(cpos).__name__
'CameraPosition'
"""
show_keys = {
'screenshot',
'cpos',
'off_screen',
'window_size',
'jupyter_backend',
'return_cpos',
'full_screen',
'interactive',
'auto_close',
'title',
}
show_kwargs = {k: kwargs.pop(k) for k in list(kwargs) if k in show_keys}
off_screen = show_kwargs.pop('off_screen', None)
plotter = pv.Plotter(off_screen=off_screen)
add_cad(plotter, obj, **kwargs)
return plotter.show(**show_kwargs)
def register_cad_plotter_component() -> None:
"""Register the ``plotter.cad`` component (idempotent).
Called from :mod:`pyvista_cad` import and resolvable lazily via the
``pyvista.plotter_components`` entry point.
"""
# ``register_plotter_component`` / ``registered_plotter_components``
# are public since pyvista 0.48 (the dependency floor), so no
# missing-attribute fallback is needed.
register = pv.register_plotter_component
registered = pv.registered_plotter_components
try:
already = any(getattr(c, 'name', None) == 'cad' for c in registered())
except (TypeError, AttributeError):
# ``registered_plotter_components`` shape varies across pyvista
# versions; if it cannot be probed, fall through and let the
# decorator's own idempotency guard handle a double-register.
already = False
if already:
return
@register('cad')
class CadPlotterComponent:
"""The ``plotter.cad`` component: CAD-friendly rendering."""
def __init__(self, plotter: pv.Plotter) -> None:
self._plotter = plotter
def add(self, obj: Any, **kwargs: Any) -> dict[str, Any]:
"""Add ``obj`` CAD-style. See :func:`add_cad`.
Parameters
----------
obj : Any
Anything :func:`add_cad` accepts.
**kwargs
Forwarded to :func:`add_cad`.
Returns
-------
dict
``{'faces': <Actor>, 'edges': <Actor or None>}``.
"""
return add_cad(self._plotter, obj, **kwargs)
# Some pyvista builds expose the decorator directly; register eagerly so
# ``plotter.cad`` works after ``import pyvista_cad`` without relying on
# the entry-point group being discovered.
register_cad_plotter_component()