Files
ocp/tools/sigdiff.py
stroblme 2c2c4e4712 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
2026-08-10 19:52:58 +02:00

157 lines
5.7 KiB
Python

"""Compare this binding's bound surface against the stock wheel's.
`inventory.py --check` answers "does the symbol exist"; this answers "does it
mean the same thing". The gap between those two is where a binding does real
damage: an `nb::init<...>` whose types differ from the constructor you meant
can bind a *different overload* through an implicit conversion and build valid
but wrong geometry, silently. That happened once — BRepPrimAPI_MakePrism's
gp_Vec form resolving to the semi-infinite gp_Dir one, because gp_Dir converts
from gp_Vec — and the fixture digests are what caught it. This finds the class
of bug directly.
Two runs, then a diff:
# under the stock wheel, from the app checkout
uv run --project backend python ../ocp/tools/sigdiff.py --dump stock.json
# under ours
make shell # then: /cache/venv/bin/python /io/tools/sigdiff.py --dump /io/ours.json
python tools/sigdiff.py --compare stock.json ours.json
The comparison is one-sided on purpose: binding *fewer* overloads than stock is
the normal state of this project, so only surface we expose that stock does not
is reported. pybind11 spells the receiver as `self: Cls` and nanobind as plain
`self`, so the leading class name is dropped before comparing.
"""
from __future__ import annotations
import argparse
import importlib
import json
import pathlib
import re
import sys
import types
HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_INVENTORY = HERE.parent / "inventory.json"
#: Parameter annotations, as both wrappers write them: `name: Some.Type`.
_ANNOTATION = re.compile(r":\s*([A-Za-z_][\w.]*)")
def _signatures(owner, name: str) -> list[tuple[str, ...]]:
"""Every overload of `name`, reduced to its parameter type names."""
doc = getattr(getattr(owner, name, None), "__doc__", None)
if not doc:
return []
out = set()
for line in doc.splitlines():
line = re.sub(r"^\d+\.\s*", "", line.strip())
if "(" not in line or ")" not in line:
continue
inner = line[line.index("(") + 1 : line.rindex(")")]
types = tuple(t.split(".")[-1] for t in _ANNOTATION.findall(inner))
out.add(types)
return sorted(out)
def dump(inventory: pathlib.Path, out: pathlib.Path) -> int:
data = json.loads(inventory.read_text())
result: dict = {}
for mod, symbols in data["modules"].items():
try:
module = importlib.import_module(f"OCP.{mod}")
except ImportError:
continue
for name in symbols:
obj = getattr(module, name, None)
if obj is None:
continue
# OCCT namespaces (TopoDS, BRepGProp, ...) have no class to hang
# their statics on: upstream exposes them as a module, this binding
# as an empty carrier class. Both answer `TopoDS.Face_s(...)`, so
# compare their members either way.
if not isinstance(obj, (type, types.ModuleType)):
continue
# An nb::is_arithmetic() enum derives from int, so dir() carries
# int's methods; pybind11's enum does not. Not surface we bound.
inherited = set(dir(int)) if isinstance(obj, type) and issubclass(
obj, int) else set()
result[f"{mod}.{name}"] = {
"init": _signatures(obj, "__init__"),
"members": sorted(
m for m in dir(obj)
if not m.startswith("_") and m not in inherited
),
}
out.write_text(json.dumps(result, indent=1) + "\n")
print(f"{len(result)} classes -> {out}")
return 0
def compare(stock_path: pathlib.Path, ours_path: pathlib.Path) -> int:
stock = json.loads(stock_path.read_text())
ours = json.loads(ours_path.read_text())
problems = 0
def normalized(entry: dict, cls: str) -> set[tuple[str, ...]]:
out = set()
for sig in entry.get("init", []):
sig = list(sig)
if sig and sig[0] == cls: # pybind11's `self: Cls`
sig = sig[1:]
out.add(tuple(sig))
return out
for key, mine in sorted(ours.items()):
theirs = stock.get(key)
if theirs is None:
print(f"{key}: not present in the stock wheel")
problems += 1
continue
cls = key.split(".", 1)[1]
extra_init = normalized(mine, cls) - normalized(theirs, cls)
if extra_init:
problems += 1
print(f"{key}: constructor overloads stock does not have")
for sig in sorted(extra_init):
print(f" ours : ({', '.join(sig)})")
for sig in sorted(normalized(theirs, cls)):
print(f" stock: ({', '.join(sig)})")
extra_members = set(mine["members"]) - set(theirs["members"])
if extra_members:
problems += 1
print(f"{key}: members stock does not have: {sorted(extra_members)}")
if problems:
print(f"\n{problems} difference(s) — each is either a mis-bound overload "
f"or a deliberate deviation worth a comment in its module")
return 1
print(f"{len(ours)} classes, no surface we expose that stock does not")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--dump", type=pathlib.Path, help="write this wheel's surface")
ap.add_argument("--compare", nargs=2, type=pathlib.Path,
metavar=("STOCK", "OURS"))
ap.add_argument("--inventory", type=pathlib.Path, default=DEFAULT_INVENTORY)
args = ap.parse_args()
if args.dump:
return dump(args.inventory, args.dump)
if args.compare:
return compare(*args.compare)
ap.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())