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.
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())
|