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.
157 lines
5.7 KiB
Python
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())
|