Skip to content

Commit ef970f3

Browse files
Krandeclaude
andcommitted
feat(cad): adacpp backend builds natively; keep pythonocc/adacpp independent
Make the adacpp CAD backend stand on its own — no pythonocc fallback (it also targets wasm, where pythonocc doesn't exist). Two parts: 1. Correct the backend boundary. Phases 3 & 5 routed several ada.occ *internal* helpers (transform_shape/_to_pos, rotate_shp_3_axis, apply_booleans, apply_geom_booleans, get_points_from_occ_shape, get_face_normal, iter_faces_with_normal, serialize_shape, the STEP-writer validity check) through active_backend(). Those run on raw pythonocc TopoDS shapes mid-construction — a no-op under OccBackend but broken under adacpp. Reverted them to pythonocc-direct: they are OccBackend's private implementation. The backend *verbs* stay and are used only on final handles (cache build, bbox, clash distance) and the tessellation seam. 2. AdacppBackend.build() now dispatches ada.geom natively: Box/Cylinder/Sphere/Cone → adacpp.cad.build_* (+ booleans via adacpp.cad.boolean); unported types raise NotImplementedError (no fallback). transform/face_plane signatures fixed to the C++ surface; is_handle uses the real ShapeHandle type. Tessellation routes adacpp handles through the active backend's tessellate verb (adacpp's native ShapeTesselator port), pythonocc handles keep the rich ShapeTesselator path; vertex normals computed for the lean backend mesh. End-to-end under ADAPY_CAD_BACKEND=adacpp: build + bbox + native tessellate + GLB + boolean-cut all work for primitives, fully independent of pythonocc. OccBackend unchanged: tests/core 655 pass (same 4 pre-existing failures), ruff clean. Toward full ada.geom parity (ExtrudedAreaSolid + surfaces next). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 637c8d4 commit ef970f3

6 files changed

Lines changed: 156 additions & 60 deletions

File tree

src/ada/cad/__init__.py

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,44 @@ def __init__(self) -> None:
8585
self._cad = cad
8686

8787
def build(self, geometry: "Geometry") -> ShapeHandle:
88-
# The full ada.geom.Geometry construction funnel is not ported to
89-
# adacpp.cad yet — it currently exposes only primitive constructors
90-
# (make_box/cylinder/sphere). Lands in a later phase of the CAD
91-
# backend abstraction; until then callers fall back to OccBackend.
92-
raise NotImplementedError(
93-
"AdacppBackend.build() is not implemented yet — adacpp.cad only "
94-
"supports primitive constructors so far."
95-
)
88+
# Native adacpp construction — NO pythonocc fallback. adacpp and the
89+
# pythonocc backend must work independently (adacpp also targets wasm,
90+
# where pythonocc does not exist). The ada.geom construction funnel is
91+
# being ported to adacpp C++ incrementally; types not yet ported raise
92+
# NotImplementedError rather than borrowing pythonocc. End goal: full
93+
# parity with OccBackend. See dap plan/v3 Phase 7.
94+
import ada.geom.solids as so
95+
96+
g = geometry.geometry
97+
98+
def _axis(d, default):
99+
return list(d) if d is not None else list(default)
100+
101+
if isinstance(g, so.Box):
102+
p = g.position
103+
shape = self._cad.build_box(
104+
list(p.location), _axis(p.axis, (0, 0, 1)), _axis(p.ref_direction, (1, 0, 0)),
105+
g.x_length, g.y_length, g.z_length,
106+
)
107+
elif isinstance(g, so.Cylinder):
108+
p = g.position
109+
shape = self._cad.build_cylinder(list(p.location), _axis(p.axis, (0, 0, 1)), g.radius, g.height)
110+
elif isinstance(g, so.Sphere):
111+
shape = self._cad.build_sphere(list(g.center), g.radius)
112+
elif isinstance(g, so.Cone):
113+
p = g.position
114+
shape = self._cad.build_cone(list(p.location), _axis(p.axis, (0, 0, 1)), g.bottom_radius, g.height)
115+
else:
116+
raise NotImplementedError(
117+
f"AdacppBackend.build: ada.geom type {type(g).__name__!r} is not yet ported to "
118+
"adacpp (no pythonocc fallback by design). Use ADAPY_CAD_BACKEND=occ for it, or "
119+
"extend adacpp.cad."
120+
)
121+
122+
# Apply booleans natively (operands built recursively in adacpp).
123+
for op in geometry.bool_operations:
124+
shape = self.boolean(op.operator, shape, self.build(op.second_operand))
125+
return shape
96126

97127
def make_box(self, dx: float, dy: float, dz: float) -> ShapeHandle:
98128
return self._cad.make_box(dx, dy, dz)
@@ -124,25 +154,25 @@ def write_glb_bytes(self, shape: ShapeHandle, linear_deflection: float = 0.1) ->
124154
def is_handle(self, obj: Any) -> bool:
125155
# Recognise an adacpp-native shape so callers can keep handle-type
126156
# introspection out of their own code (e.g. the tessellator's
127-
# raw-import fast path). The wasm/native builds expose the concrete
128-
# type as `adacpp.cad.Shape`; if a build omits it we conservatively
129-
# report False (an unrecognised value is treated as "not a handle").
130-
handle_t = getattr(self._cad, "Shape", None)
157+
# raw-import fast path). adacpp.cad exposes the concrete type as
158+
# `ShapeHandle`; if a build omits it we conservatively report False.
159+
handle_t = getattr(self._cad, "ShapeHandle", None)
131160
return handle_t is not None and isinstance(obj, handle_t)
132161

133162
def boolean(self, op: "BoolOpEnum", a: ShapeHandle, b: ShapeHandle) -> ShapeHandle:
134-
# Lands with the Phase 7 adacpp.cad CSG surface; OccBackend covers
135-
# native today.
136163
fn = getattr(self._cad, "boolean", None)
137164
if fn is None:
138165
raise NotImplementedError("adacpp.cad.boolean is not available in this build")
139-
return fn(str(op.value), a, b)
166+
return fn(op.value, a, b)
140167

141168
def transform(self, shape: ShapeHandle, matrix: "np.ndarray", copy: bool = True) -> ShapeHandle:
142169
fn = getattr(self._cad, "transform", None)
143170
if fn is None:
144171
raise NotImplementedError("adacpp.cad.transform is not available in this build")
145-
return fn(shape, matrix, copy)
172+
# adacpp.cad.transform takes the top 3 rows of the 4x4 as 12 row-major
173+
# doubles (implicit bottom row [0,0,0,1]).
174+
m = [float(matrix[i][j]) for i in range(3) for j in range(4)]
175+
return fn(shape, m, copy)
146176

147177
def distance(self, a: ShapeHandle, b: ShapeHandle) -> float:
148178
fn = getattr(self._cad, "distance", None)
@@ -178,7 +208,16 @@ def face_plane(self, face: ShapeHandle) -> "tuple[Point, Direction] | None":
178208
fn = getattr(self._cad, "face_plane", None)
179209
if fn is None:
180210
raise NotImplementedError("adacpp.cad.face_plane is not available in this build")
181-
return fn(face)
211+
res = fn(face)
212+
if res is None:
213+
return None
214+
# adacpp returns ((ox,oy,oz),(nx,ny,nz)); wrap into ada.geom types to
215+
# match OccBackend.face_plane.
216+
from ada.geom.direction import Direction
217+
from ada.geom.points import Point
218+
219+
origin, normal = res
220+
return Point(*origin), Direction(*normal)
182221

183222
def from_topods_pointer(self, ptr: int) -> ShapeHandle:
184223
"""Wrap an OCCT TopoDS_Shape addressed by a raw pointer.

src/ada/cadit/step/write/writer.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
BRepBuilderAPI_MakeEdge2d,
1212
BRepBuilderAPI_MakeVertex,
1313
)
14+
from OCC.Core.BRepCheck import BRepCheck_Analyzer
1415
from OCC.Core.Geom import Geom_Curve
1516
from OCC.Core.Geom2d import Geom2d_BSplineCurve
1617
from OCC.Core.gp import gp_Pnt, gp_Pnt2d
@@ -22,7 +23,6 @@
2223
from OCC.Core.XSControl import XSControl_WorkSession
2324

2425
from ada.base.units import Units
25-
from ada.cad import active_backend
2626
from ada.config import logger
2727
from ada.occ.xcaf_utils import set_color, set_name
2828
from ada.visit.colors import Color
@@ -85,14 +85,17 @@ def add_shape(self, shape: Any, name: str, rgb_color=None, parent=None):
8585
logger.warning(f"Skipping NULL shape '{name}' (IsNull == True)")
8686
return
8787

88-
# Validate topology via the CAD backend. If invalid, skip with a warning.
88+
# Validate topology with BRepCheck. The STEP/XCAF writer is the
89+
# OCC/pythonocc DocBackend implementation — it operates on raw
90+
# TopoDS shapes, so it checks with OCC directly, not the active
91+
# CAD backend. If invalid, skip with a warning.
8992
try:
90-
if not active_backend().is_valid(shape):
91-
logger.warning(f"Skipping invalid shape '{name}' (is_valid == False)")
93+
if not BRepCheck_Analyzer(shape, True).IsValid():
94+
logger.warning(f"Skipping invalid shape '{name}' (BRepCheck_Analyzer.IsValid == False)")
9295
return
9396
except Exception as ex:
94-
# If the validity check fails unexpectedly, log and continue without validation
95-
logger.debug(f"is_valid check failed for shape '{name}': {ex}")
97+
# If BRepCheck fails unexpectedly, log and continue without validation
98+
logger.debug(f"BRepCheck_Analyzer failed for shape '{name}': {ex}")
9699

97100
self.comp_builder.Add(self.comp, shape)
98101
except Exception as ex:

src/ada/occ/geom/boolean.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
12
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace
23
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
34
from OCC.Core.gp import gp_Dir, gp_Pln, gp_Pnt
45
from OCC.Core.TopoDS import TopoDS_Shape
56

6-
from ada.cad import active_backend
77
from ada.geom.booleans import BooleanOperation, BoolOpEnum
88
from ada.geom.surfaces import HalfSpaceSolid, Plane
99

1010

1111
def apply_geom_booleans(geom: TopoDS_Shape, booleans: list[BooleanOperation]) -> TopoDS_Shape:
12+
# OCC-internal: part of the pythonocc construction funnel (geom_to_occ_geom),
13+
# so it applies booleans with OCC directly rather than the active backend.
1214
from ada.occ.geom import geom_to_occ_geom
1315

14-
backend = active_backend()
1516
for boolean in booleans:
1617
if isinstance(boolean.second_operand.geometry, HalfSpaceSolid):
1718
solid_geom = boolean.second_operand.geometry
@@ -39,9 +40,17 @@ def apply_geom_booleans(geom: TopoDS_Shape, booleans: list[BooleanOperation]) ->
3940
half_space = half_space_maker.Solid()
4041

4142
# Apply the boolean cut operation (half-space is typically used for cutting)
42-
geom = backend.boolean(BoolOpEnum.DIFFERENCE, geom, half_space)
43+
geom = BRepAlgoAPI_Cut(geom, half_space).Shape()
4344

4445
continue
45-
geom = backend.boolean(boolean.operator, geom, geom_to_occ_geom(boolean.second_operand))
46+
operand = geom_to_occ_geom(boolean.second_operand)
47+
if boolean.operator == BoolOpEnum.DIFFERENCE:
48+
geom = BRepAlgoAPI_Cut(geom, operand).Shape()
49+
elif boolean.operator == BoolOpEnum.UNION:
50+
geom = BRepAlgoAPI_Fuse(geom, operand).Shape()
51+
elif boolean.operator == BoolOpEnum.INTERSECTION:
52+
geom = BRepAlgoAPI_Common(geom, operand).Shape()
53+
else:
54+
raise NotImplementedError(f"Boolean operation {boolean.operator} not implemented")
4655

4756
return geom

src/ada/occ/serializers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from OCC.Core.BRepTools import breptools
12
from OCC.Core.TopoDS import TopoDS_Shape
23

3-
from ada.cad import active_backend
4-
54

65
def serialize_shape(shape: TopoDS_Shape) -> str:
7-
return active_backend().serialize(shape)
6+
# OCC-internal: operates on raw pythonocc TopoDS shapes (STEP→IFC path),
7+
# so it serializes with OCC directly rather than the active CAD backend.
8+
breptools.Clean(shape)
9+
return breptools.WriteToString(shape)

src/ada/occ/tessellating.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ class LineMesh:
4949
normals: np.ndarray = None
5050

5151

52+
def _vertex_normals(positions: np.ndarray, faces: np.ndarray) -> np.ndarray:
53+
"""Per-vertex normals (flat float32, len == len(positions)) from triangle
54+
faces by area-weighted accumulation + normalization. Used for backend
55+
tessellators that return positions + indices but no normals."""
56+
verts = positions.reshape(-1, 3)
57+
tris = faces.reshape(-1, 3).astype(np.int64)
58+
normals = np.zeros_like(verts)
59+
fn = np.cross(verts[tris[:, 1]] - verts[tris[:, 0]], verts[tris[:, 2]] - verts[tris[:, 0]])
60+
for k in range(3):
61+
np.add.at(normals, tris[:, k], fn)
62+
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
63+
lengths[lengths == 0] = 1.0
64+
return (normals / lengths).reshape(-1).astype("float32")
65+
66+
5267
def tessellate_edges(shape: TopoDS_Edge, deflection=0.01) -> LineMesh:
5368
points = discretize_edge(shape, deflection=deflection)
5469

@@ -146,7 +161,7 @@ def tessellate_occ_geom(
146161
tess_shape = tessellate_edges(occ_geom)
147162
indices = tess_shape.indices
148163
else:
149-
tess_shape = tessellate_shape(occ_geom, self.quality, self.render_edges, self.parallel)
164+
tess_shape = self._triangulate(occ_geom)
150165
indices = tess_shape.faces
151166

152167
mat_id = self.material_store.get(geom_color, None)
@@ -165,6 +180,26 @@ def tessellate_occ_geom(
165180
geom_ref,
166181
)
167182

183+
def _triangulate(self, occ_geom) -> TriangleMesh:
184+
"""Triangle-mesh a body. pythonocc TopoDS handles use the rich
185+
ShapeTesselator (normals + edges). Any other handle (e.g. an adacpp
186+
ShapeHandle) is tessellated through the active backend's tessellate
187+
verb and adapted to a TriangleMesh — keeping the tessellation path
188+
backend-independent. Normals are computed from the triangles since the
189+
lean backend Mesh carries only positions + indices."""
190+
from OCC.Core.TopoDS import TopoDS_Shape
191+
192+
if isinstance(occ_geom, TopoDS_Shape):
193+
return tessellate_shape(occ_geom, self.quality, self.render_edges, self.parallel)
194+
195+
from ada.cad import active_backend
196+
197+
mesh = active_backend().tessellate(occ_geom)
198+
positions = np.ascontiguousarray(mesh.positions, dtype="float32")
199+
faces = np.ascontiguousarray(mesh.indices, dtype="uint32")
200+
normals = _vertex_normals(positions, faces)
201+
return TriangleMesh(positions, faces, None, normals)
202+
168203
def tessellate_geom(
169204
self,
170205
geom: Geometry,

0 commit comments

Comments
 (0)