10C Inc 1: core modeling
Binds the modeling core the app builds every feature out of — 93 of its 138 symbols now resolve, up from 34. New modules: GeomAbs, Geom2d, Geom, TCollection, TColgp, TColStd, GProp, BRepGProp, Bnd, BRepBndLib, Adaptor3d, BRepAdaptor, GeomLProp, GeomAPI, BRepBuilderAPI, BOPAlgo, BRepAlgoAPI, BRepPrimAPI, GC, BRepMesh; gp and BRep_Tool completed. Three structural decisions: - BRepBuilderAPI_MakeShape carries Build/Shape/Generated/Modified/IsDeleted for every maker in the binding, so the booleans, the primitives and (later) the fillet builders all answer the app's duck-typed provenance layer through ordinary virtual dispatch. History lists come back copied, so they outlive the builder. - The executing two-argument BRepAlgoAPI constructors stay unbound; operands go in through SetArguments/SetTools. Section keeps Init1/Init2, which are plain setters. BOPAlgo moved up from Inc 2 — SetGlue needs its enum. - Adaptor3d is registered although the app never imports it: every method it calls on BRepAdaptor_Curve/Surface is a virtual declared there, so binding them once on the bases leaves mod_BRepAdaptor.cpp with just constructors. Gate: tests/test_inc1_modeling.py against reference values gen_fixtures.py now records from the stock wheel — measurements, per-face area/centroid in map order, mesh counts, and the boolean history map compared exactly, since that is the substrate the app's topological naming is built on. tools/sigdiff.py compares our bound surface against stock's, because a wrong nb::init<> is silent: MakePrism's five-argument form bound OCCT's semi-infinite gp_Dir overload (gp_Dir converts from gp_Vec), producing a valid solid of the wrong shape with the flags shifted along. The fixture digest caught it; sigdiff finds the class of bug directly, and now reports only one deliberate deviation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
This commit is contained in:
@@ -17,6 +17,13 @@ Two properties are recorded, and the first is checked here rather than assumed:
|
||||
|
||||
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
|
||||
@@ -67,20 +74,27 @@ def _fused() -> TopoDS_Shape:
|
||||
return op.Shape()
|
||||
|
||||
|
||||
def _cut_cylinder() -> TopoDS_Shape:
|
||||
op = BRepAlgoAPI_Cut()
|
||||
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(_box())
|
||||
tools.Append(
|
||||
BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(5, 10, 0), gp_Dir(0, 0, 1)), 3.0, 30.0
|
||||
).Shape()
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -134,6 +148,187 @@ def face_count(shape: TopoDS_Shape) -> int:
|
||||
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 _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
|
||||
|
||||
@@ -163,6 +358,8 @@ def main() -> int:
|
||||
}
|
||||
print(f"{name}: {len(data)} bytes, {manifest['shapes'][name]['faces']} faces")
|
||||
|
||||
manifest["inc1"] = _inc1_reference()
|
||||
|
||||
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(f"\nwrote {len(SHAPES)} fixtures + manifest to {OUT}")
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user