119 of the app's 138 symbols now resolve; only the I/O modules and the Inc 4 tail are left. New: GCE2d, GCPnts, BRepFilletAPI, BRepOffsetAPI, ShapeAnalysis, ShapeFix, ShapeUpgrade, BRepCheck, BRepTools, BRepLib, BRepExtrema, BRepClass3d, IntCurvesFace, plus TopTools_HSequenceOfShape. The fillet and chamfer builders derive BRepBuilderAPI_MakeShape, so their history comes from the base bound in Inc 1 — which is what lets the app read a blend's provenance the same way it reads a boolean's. The Inc 2 gate compares that history exactly, alongside the splitter's, which is what sketch-region attribution depends on. Two shapes of deviation, both commented where they are bound: - Trailing enum arguments are left off four constructors (fillet's ChFi3d_FilletShape, MakeThickSolidByJoin's mode/join pair, BRepExtrema's Extrema flags, and Inc 1's GeomAPI_ProjectPointOnSurf). An unregistered enum cannot serve as a default argument — nanobind converts defaults at binding time, so it fails the whole module's import with std::bad_cast. The app never overrides any of them, so OCCT's own defaults apply and behaviour is unchanged. - ShapeAnalysis_FreeBounds.ConnectEdgesToWires reassigns a Handle& out parameter, which the caster cannot honour because it hands C++ a copy of the handle. The lambda splices the result into the sequence the caller passed, so the pass-empty-then-read shape the app uses still works. This is the one place upstream's holder semantics and ours differ observably. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
457 lines
15 KiB
Python
457 lines
15 KiB
Python
"""Generate the .brep byte-identity fixtures.
|
|
|
|
Runs under the STOCK cadquery-ocp-novtk wheel, not this binding — the fixtures
|
|
are the reference the Inc 0 gate compares against. From the app checkout:
|
|
|
|
app/.venv/bin/python ../ocp/tools/gen_fixtures.py
|
|
|
|
Two properties are recorded, and the first is checked here rather than assumed:
|
|
|
|
* stock is idempotent — reading a shape and writing it back reproduces the
|
|
same bytes. Without that the gate would be comparing against a moving
|
|
target, and a mismatch would say nothing.
|
|
* the digest of those bytes, which is what the gate reproduces. This matters
|
|
beyond IPC: cad/derive.py content-addresses BREP payloads as
|
|
payloads/derived/brep/<sha256>.brep and stores the ref in the document, so
|
|
a binding that serialises differently rewrites every derived payload.
|
|
|
|
Face counts are recorded alongside, pinning the map ordering that face and edge
|
|
identity depend on throughout the topology code.
|
|
|
|
From Inc 1 on, the manifest also carries a per-increment reference block, since
|
|
the app's own tests cannot gate an increment: backend/tests/conftest.py imports
|
|
n3xd.main, so every one of them fails at collection until the last module is
|
|
bound. What the blocks record is chosen to be robust: counts and boolean
|
|
history maps are topological and compare exactly, measurements compare at a
|
|
relative tolerance. See docs/adding-symbols.md.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
from OCP.BinTools import BinTools
|
|
from OCP.BRep import BRep_Builder
|
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
|
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
|
from OCP.TopAbs import TopAbs_FACE
|
|
from OCP.TopExp import TopExp
|
|
from OCP.TopLoc import TopLoc_Location
|
|
from OCP.TopoDS import TopoDS_Compound, TopoDS_Shape
|
|
from OCP.TopTools import TopTools_IndexedMapOfShape
|
|
|
|
OUT = pathlib.Path(__file__).resolve().parent.parent / "tests" / "data"
|
|
|
|
|
|
def _box(dx=10.0, dy=20.0, dz=30.0) -> TopoDS_Shape:
|
|
return BRepPrimAPI_MakeBox(dx, dy, dz).Shape()
|
|
|
|
|
|
def _meshed_box() -> TopoDS_Shape:
|
|
shape = _box()
|
|
BRepMesh_IncrementalMesh(shape, 0.1, False, 0.5, True)
|
|
return shape
|
|
|
|
|
|
def _fused() -> TopoDS_Shape:
|
|
op = BRepAlgoAPI_Fuse()
|
|
from OCP.TopTools import TopTools_ListOfShape
|
|
|
|
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
|
|
args.Append(_box())
|
|
tools.Append(
|
|
BRepPrimAPI_MakeBox(gp_Pnt(5, 5, 5), 20.0, 20.0, 20.0).Shape()
|
|
)
|
|
op.SetArguments(args)
|
|
op.SetTools(tools)
|
|
op.Build()
|
|
return op.Shape()
|
|
|
|
|
|
def _cut_cylinder_op():
|
|
"""The deferred cut, returned unbuilt operands and all, so the Inc 1 block
|
|
can read its history against the same operand shapes."""
|
|
from OCP.TopTools import TopTools_ListOfShape
|
|
|
|
op = BRepAlgoAPI_Cut()
|
|
base = _box()
|
|
tool = BRepPrimAPI_MakeCylinder(
|
|
gp_Ax2(gp_Pnt(5, 10, 0), gp_Dir(0, 0, 1)), 3.0, 30.0
|
|
).Shape()
|
|
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
|
|
args.Append(base)
|
|
tools.Append(tool)
|
|
op.SetArguments(args)
|
|
op.SetTools(tools)
|
|
op.Build()
|
|
return op, base, tool
|
|
|
|
|
|
def _cut_cylinder() -> TopoDS_Shape:
|
|
op, _base, _tool = _cut_cylinder_op()
|
|
return op.Shape()
|
|
|
|
|
|
def _located_compound() -> TopoDS_Shape:
|
|
"""Exercises the location/TShape sharing part of the format."""
|
|
builder = BRep_Builder()
|
|
comp = TopoDS_Compound()
|
|
builder.MakeCompound(comp)
|
|
|
|
base = _box()
|
|
builder.Add(comp, base)
|
|
|
|
trsf = gp_Trsf()
|
|
trsf.SetTranslation(gp_Vec(50.0, 0.0, 0.0))
|
|
builder.Add(comp, base.Moved(TopLoc_Location(trsf)))
|
|
return comp
|
|
|
|
|
|
def _empty_compound() -> TopoDS_Shape:
|
|
builder = BRep_Builder()
|
|
comp = TopoDS_Compound()
|
|
builder.MakeCompound(comp)
|
|
return comp
|
|
|
|
|
|
SHAPES = {
|
|
"box": _box,
|
|
"box_meshed": _meshed_box,
|
|
"fused": _fused,
|
|
"cut_cylinder": _cut_cylinder,
|
|
"located_compound": _located_compound,
|
|
"empty_compound": _empty_compound,
|
|
}
|
|
|
|
|
|
def write_bytes(shape: TopoDS_Shape) -> bytes:
|
|
buf = io.BytesIO()
|
|
BinTools.Write_s(shape, buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
def read_shape(data: bytes) -> TopoDS_Shape:
|
|
shape = TopoDS_Shape()
|
|
BinTools.Read_s(shape, io.BytesIO(data))
|
|
return shape
|
|
|
|
|
|
def face_count(shape: TopoDS_Shape) -> int:
|
|
faces = TopTools_IndexedMapOfShape()
|
|
TopExp.MapShapes_s(shape, TopAbs_FACE, faces)
|
|
return faces.Extent()
|
|
|
|
|
|
def _sub_shapes(shape: TopoDS_Shape, kind):
|
|
smap = TopTools_IndexedMapOfShape()
|
|
TopExp.MapShapes_s(shape, kind, smap)
|
|
return [smap.FindKey(i) for i in range(1, smap.Extent() + 1)]
|
|
|
|
|
|
def _measure(shape: TopoDS_Shape) -> dict:
|
|
"""Volume, area and bbox, all through the default (non-eps) quadrature."""
|
|
from OCP.Bnd import Bnd_Box
|
|
from OCP.BRepBndLib import BRepBndLib
|
|
from OCP.BRepGProp import BRepGProp
|
|
from OCP.GProp import GProp_GProps
|
|
from OCP.TopAbs import TopAbs_EDGE, TopAbs_SOLID
|
|
|
|
vol, area = GProp_GProps(), GProp_GProps()
|
|
BRepGProp.VolumeProperties_s(shape, vol)
|
|
BRepGProp.SurfaceProperties_s(shape, area)
|
|
|
|
box = Bnd_Box()
|
|
BRepBndLib.Add_s(shape, box)
|
|
bbox = None if box.IsVoid() else list(box.Get())
|
|
|
|
return {
|
|
"volume": vol.Mass(),
|
|
"area": area.Mass(),
|
|
"bbox": bbox,
|
|
"n_faces": face_count(shape),
|
|
"n_edges": len(_sub_shapes(shape, TopAbs_EDGE)),
|
|
"n_solids": len(_sub_shapes(shape, TopAbs_SOLID)),
|
|
}
|
|
|
|
|
|
def _face_props(shape: TopoDS_Shape) -> list[list[float]]:
|
|
"""Per-face [area, cx, cy, cz] in MapShapes(FACE) order.
|
|
|
|
This is the reference for n3xd_ocp.measure.face_surface_props, and the
|
|
ordering contract the whole anchor path depends on.
|
|
"""
|
|
from OCP.BRepGProp import BRepGProp
|
|
from OCP.GProp import GProp_GProps
|
|
|
|
out = []
|
|
for face in _sub_shapes(shape, TopAbs_FACE):
|
|
props = GProp_GProps()
|
|
BRepGProp.SurfaceProperties_s(face, props)
|
|
c = props.CentreOfMass()
|
|
out.append([props.Mass(), c.X(), c.Y(), c.Z()])
|
|
return out
|
|
|
|
|
|
def _history(builder, pre: TopoDS_Shape, post: TopoDS_Shape) -> list[dict]:
|
|
"""The Modified/Generated/IsDeleted map, keyed by pre-shape face index.
|
|
|
|
Recorded as *post-shape sub-shape indices* rather than shapes, so the
|
|
comparison is purely topological — this is the substrate the app's
|
|
topological naming is built on, and it must not drift by a single entry.
|
|
Indices come from the untyped MapShapes, because a builder may generate a
|
|
shape of any type (MakePrism answers a face with the solid it swept).
|
|
"""
|
|
all_post = TopTools_IndexedMapOfShape()
|
|
TopExp.MapShapes_s(post, all_post)
|
|
|
|
def index_of(shape: TopoDS_Shape) -> int:
|
|
return all_post.FindIndex(shape) - 1 # -1 when absent, else 0-based
|
|
|
|
rows = []
|
|
for i, face in enumerate(_sub_shapes(pre, TopAbs_FACE)):
|
|
rows.append(
|
|
{
|
|
"pre": i,
|
|
"deleted": bool(builder.IsDeleted(face)),
|
|
"modified": sorted(index_of(s) for s in builder.Modified(face)),
|
|
"generated": sorted(index_of(s) for s in builder.Generated(face)),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _mesh_counts(shape: TopoDS_Shape) -> list[list[int]]:
|
|
"""Per-face [nb_nodes, nb_triangles], skipping faces with no triangulation.
|
|
|
|
Mirrors what cad/tessellation.py extracts, including the "faces without a
|
|
mesh leave a gap in the id sequence" rule.
|
|
"""
|
|
from OCP.BRep import BRep_Tool
|
|
from OCP.TopoDS import TopoDS
|
|
|
|
out = []
|
|
for i, face in enumerate(_sub_shapes(shape, TopAbs_FACE)):
|
|
loc = TopLoc_Location()
|
|
tri = BRep_Tool.Triangulation_s(TopoDS.Face_s(face), loc)
|
|
if tri is None:
|
|
continue
|
|
out.append([i, tri.NbNodes(), tri.NbTriangles()])
|
|
return out
|
|
|
|
|
|
def _constructions() -> dict:
|
|
"""Small shapes built through the Inc 1 constructors the app uses.
|
|
|
|
Recorded by BREP digest plus a measurement, so the test can rebuild them
|
|
and compare both the bytes and the geometry.
|
|
"""
|
|
from OCP.BRepBuilderAPI import (
|
|
BRepBuilderAPI_MakeEdge,
|
|
BRepBuilderAPI_MakeFace,
|
|
BRepBuilderAPI_MakePolygon,
|
|
)
|
|
from OCP.BRepGProp import BRepGProp
|
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
|
from OCP.GC import GC_MakeArcOfCircle
|
|
from OCP.Geom import Geom_BSplineCurve
|
|
from OCP.GProp import GProp_GProps
|
|
from OCP.TColgp import TColgp_Array1OfPnt
|
|
from OCP.TColStd import TColStd_Array1OfInteger, TColStd_Array1OfReal
|
|
|
|
def length(shape: TopoDS_Shape) -> float:
|
|
props = GProp_GProps()
|
|
BRepGProp.LinearProperties_s(shape, props)
|
|
return props.Mass()
|
|
|
|
arc = GC_MakeArcOfCircle(
|
|
gp_Pnt(0, 0, 0), gp_Pnt(5, 5, 0), gp_Pnt(10, 0, 0)
|
|
).Value()
|
|
arc_edge = BRepBuilderAPI_MakeEdge(arc).Edge()
|
|
|
|
# A clamped cubic through four poles — the shape sketch_builder/edges.py
|
|
# builds for a spline element.
|
|
poles = TColgp_Array1OfPnt(1, 4)
|
|
for i, (x, y) in enumerate([(0, 0), (3, 6), (7, -4), (10, 2)], start=1):
|
|
poles.SetValue(i, gp_Pnt(float(x), float(y), 0.0))
|
|
knots = TColStd_Array1OfReal(1, 2)
|
|
knots.SetValue(1, 0.0)
|
|
knots.SetValue(2, 1.0)
|
|
mults = TColStd_Array1OfInteger(1, 2)
|
|
mults.SetValue(1, 4)
|
|
mults.SetValue(2, 4)
|
|
spline_edge = BRepBuilderAPI_MakeEdge(
|
|
Geom_BSplineCurve(poles, knots, mults, 3)
|
|
).Edge()
|
|
|
|
poly = BRepBuilderAPI_MakePolygon(
|
|
gp_Pnt(0, 0, 0), gp_Pnt(10, 0, 0), gp_Pnt(10, 6, 0), gp_Pnt(0, 6, 0), True
|
|
).Wire()
|
|
prism_face = BRepBuilderAPI_MakeFace(poly, True).Face()
|
|
prism_maker = BRepPrimAPI_MakePrism(prism_face, gp_Vec(0.0, 0.0, 4.0))
|
|
prism = prism_maker.Shape()
|
|
|
|
return {
|
|
"arc_edge": {
|
|
"sha256": hashlib.sha256(write_bytes(arc_edge)).hexdigest(),
|
|
"length": length(arc_edge),
|
|
},
|
|
"spline_edge": {
|
|
"sha256": hashlib.sha256(write_bytes(spline_edge)).hexdigest(),
|
|
"length": length(spline_edge),
|
|
},
|
|
"prism": {
|
|
"sha256": hashlib.sha256(write_bytes(prism)).hexdigest(),
|
|
**_measure(prism),
|
|
# The prism's history is what an extrude's provenance reads.
|
|
"history": _history(prism_maker, prism_face, prism),
|
|
},
|
|
}
|
|
|
|
|
|
def _inc2_reference() -> dict:
|
|
"""Fillet, chamfer, splitter, healing and the query classes.
|
|
|
|
The fillet history is the point of this block: a blend's provenance is read
|
|
exactly like a boolean's, and it is what attributes the new faces back to
|
|
the feature that made them.
|
|
"""
|
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter
|
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
|
from OCP.BRepClass3d import BRepClass3d_SolidClassifier
|
|
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
|
from OCP.BRepTools import BRepTools
|
|
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
|
from OCP.TopAbs import TopAbs_EDGE
|
|
from OCP.TopoDS import TopoDS
|
|
from OCP.TopTools import TopTools_ListOfShape
|
|
|
|
box = _box()
|
|
edges = _sub_shapes(box, TopAbs_EDGE)
|
|
|
|
fillet = BRepFilletAPI_MakeFillet(box)
|
|
fillet.Add(2.0, TopoDS.Edge_s(edges[0]))
|
|
fillet.Build()
|
|
filleted = fillet.Shape()
|
|
|
|
# The edge must belong to the very shape the builder was given, not an
|
|
# equal one built separately — OCCT matches by identity.
|
|
chamfer_box = _box()
|
|
chamfer = BRepFilletAPI_MakeChamfer(chamfer_box)
|
|
chamfer.Add(1.5, TopoDS.Edge_s(_sub_shapes(chamfer_box, TopAbs_EDGE)[0]))
|
|
chamfer.Build()
|
|
chamfered = chamfer.Shape()
|
|
|
|
# A splitter over an edge, mirroring how sketch regions are cut apart.
|
|
splitter = BRepAlgoAPI_Splitter()
|
|
split_box = _box()
|
|
args, tools = TopTools_ListOfShape(), TopTools_ListOfShape()
|
|
args.Append(split_box)
|
|
tools.Append(
|
|
BRepPrimAPI_MakeBox(gp_Pnt(-5, 5, -5), 20.0, 1.0, 40.0).Shape()
|
|
)
|
|
splitter.SetArguments(args)
|
|
splitter.SetTools(tools)
|
|
splitter.Build()
|
|
|
|
unify = ShapeUpgrade_UnifySameDomain(_fused(), True, True, True)
|
|
unify.Build()
|
|
|
|
dist = BRepExtrema_DistShapeShape(
|
|
_box(), BRepPrimAPI_MakeBox(gp_Pnt(40, 0, 0), 5.0, 5.0, 5.0).Shape()
|
|
)
|
|
|
|
inside = BRepClass3d_SolidClassifier(box, gp_Pnt(5, 10, 15), 1e-7)
|
|
outside = BRepClass3d_SolidClassifier(box, gp_Pnt(50, 10, 15), 1e-7)
|
|
|
|
face0 = TopoDS.Face_s(_sub_shapes(box, TopAbs_FACE)[0])
|
|
|
|
return {
|
|
"fillet": {
|
|
"sha256": hashlib.sha256(write_bytes(filleted)).hexdigest(),
|
|
**_measure(filleted),
|
|
"history": _history(fillet, box, filleted),
|
|
},
|
|
"chamfer": {
|
|
"sha256": hashlib.sha256(write_bytes(chamfered)).hexdigest(),
|
|
**_measure(chamfered),
|
|
},
|
|
"splitter": {
|
|
**_measure(splitter.Shape()),
|
|
"history": _history(splitter, split_box, splitter.Shape()),
|
|
},
|
|
"unified": _measure(unify.Shape()),
|
|
"distance": {
|
|
"is_done": bool(dist.IsDone()),
|
|
"value": dist.Value(),
|
|
"n_solutions": dist.NbSolution(),
|
|
},
|
|
"classifier": {"inside": int(inside.State()), "outside": int(outside.State())},
|
|
"uv_bounds": list(BRepTools.UVBounds_s(face0)),
|
|
"outer_wire_edges": len(
|
|
_sub_shapes(BRepTools.OuterWire_s(face0), TopAbs_EDGE)
|
|
),
|
|
}
|
|
|
|
|
|
def _inc1_reference() -> dict:
|
|
op, base, _tool = _cut_cylinder_op()
|
|
return {
|
|
"measure": {name: _measure(build()) for name, build in SHAPES.items()},
|
|
"face_props": {
|
|
name: _face_props(build())
|
|
for name, build in SHAPES.items()
|
|
if name != "empty_compound"
|
|
},
|
|
"cut_cylinder_history": _history(op, base, op.Shape()),
|
|
"box_meshed_counts": _mesh_counts(_meshed_box()),
|
|
"constructions": _constructions(),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
import OCP
|
|
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
manifest: dict = {
|
|
"generated_by": "cadquery-ocp-novtk",
|
|
"occt_version": getattr(OCP, "__version__", "unknown"),
|
|
"shapes": {},
|
|
}
|
|
|
|
for name, build in SHAPES.items():
|
|
shape = build()
|
|
data = write_bytes(shape)
|
|
|
|
# The gate is "our rewrite == stock rewrite". If stock itself is not
|
|
# idempotent for a fixture, that fixture cannot serve as a reference.
|
|
rewritten = write_bytes(read_shape(data))
|
|
if rewritten != data:
|
|
print(f"FAIL {name}: stock is not idempotent", file=sys.stderr)
|
|
return 1
|
|
|
|
(OUT / f"{name}.brep").write_bytes(data)
|
|
manifest["shapes"][name] = {
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"size": len(data),
|
|
"faces": face_count(shape),
|
|
}
|
|
print(f"{name}: {len(data)} bytes, {manifest['shapes'][name]['faces']} faces")
|
|
|
|
manifest["inc1"] = _inc1_reference()
|
|
manifest["inc2"] = _inc2_reference()
|
|
|
|
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
|
print(f"\nwrote {len(SHAPES)} fixtures + manifest to {OUT}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|