design.md: record what the increments settled

- GIL policy gains the XSTEP exception (STEP/IGES hold it; RWStl releases).
- A new rule, learned three times the hard way: an unregistered type cannot be
  a default argument, because nanobind converts defaults at binding time and
  the failure is a bare std::bad_cast at import with no file or line.
- A section on matching upstream: what sigdiff exists for, why static analysis
  cannot see instance methods, and why the app's suite cannot gate a single
  increment.
- An OCCT 8.0 watchlist, so the 10E bump is a known quantity.
- S5 marked settled: no CSF_* resources needed in the wheel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6
This commit is contained in:
2026-08-10 20:32:39 +02:00
parent 01a5bcf188
commit 6b419c1c81

View File

@@ -103,8 +103,16 @@ as a class carrying the `_s` statics, and the app calls `TopoDS.Face_s(...)`, so
## GIL policy ## GIL policy
Released around calls that stay inside the kernel and cannot re-enter Python: Released around calls that stay inside the kernel and cannot re-enter Python:
`Build`/`Perform`, meshing, `BRepCheck_Analyzer`, file readers and writers, and `Build`/`Perform`, meshing, `BRepCheck_Analyzer`, `RWStl`, and every
every `n3xd_ocp` bulk API. Applied from an explicit list, never blanket. `n3xd_ocp` bulk API. Applied from an explicit list, never blanket.
**The XSTEP readers and writers are the exception**, amending what this section
said before Inc 3 landed. STEP and IGES read and write through the
process-global `Interface_Static` settings table, the IGES reader is documented
as not thread-safe, and the app already serialises every import behind a lock —
so holding the GIL costs nothing there and removes a whole class of question.
Upstream releases nowhere, so this also stays closer to it. `RWStl` touches no
global state and does release.
`BinTools` is *also* GIL-free for the kernel half, which the original plan `BinTools` is *also* GIL-free for the kernel half, which the original plan
assumed impossible. Rather than bridging a `streambuf` that calls back into assumed impossible. Rather than bridging a `streambuf` that calls back into
@@ -113,6 +121,19 @@ the kernel a pure C++ stream. That is correct regardless of how BinTools seeks,
and costs one extra copy of the payload — which `n3xd_ocp.bintools` avoids and costs one extra copy of the payload — which `n3xd_ocp.bintools` avoids
entirely for the pool paths that care. entirely for the pool paths that care.
**An unregistered type cannot be a default argument.** nanobind converts
defaults to Python objects at *binding* time, so a `.def(..., "Algo"_a =
Extrema_ExtAlgo_Grad)` for an enum this binding does not register fails the
whole extension's import with a bare `std::bad_cast` — no file, no line. It
happened three times while writing Inc 1 and 2. Where the trailing argument is
one the app never overrides, the fix is to leave it off and let OCCT apply its
own default: `GeomAPI_ProjectPointOnSurf` (Extrema algo),
`BRepFilletAPI_MakeFillet` (`ChFi3d_FilletShape`),
`BRepOffsetAPI_MakeThickSolid.MakeThickSolidByJoin` (mode and join type) and
`BRepExtrema_DistShapeShape` (Extrema flags) all do. `tools/sigdiff.py` reports
each of them, which is the point — they are the only four places the bound
surface deliberately differs from upstream's.
**None as an argument** is rejected before the caster for simple overloads, so **None as an argument** is rejected before the caster for simple overloads, so
handle parameters that legitimately accept a null handle need an explicit handle parameters that legitimately accept a null handle need an explicit
`nb::arg("x").none()`. Null *returns* map to `None` unconditionally. The app `nb::arg("x").none()`. Null *returns* map to `None` unconditionally. The app
@@ -185,11 +206,64 @@ def set_signal(arm_fpe: bool = False) -> None
The backend adopts these after cutover, one call site at a time. The backend adopts these after cutover, one call site at a time.
## Matching upstream, and how that is checked
`inventory.py --check` answers "does the symbol exist". It cannot answer "does
it mean the same thing", and the gap between those two is where a binding does
real damage. `nb::init<TopoDS_Shape, gp_Vec, bool, bool, bool>` for
`BRepPrimAPI_MakePrism` compiled cleanly and bound OCCT's *semi-infinite*
overload, because that one takes a `gp_Dir` and `gp_Dir` converts implicitly
from `gp_Vec` — so the flags shifted one position along and the result was a
valid solid of the wrong shape. The fixture digests caught it;
`tools/sigdiff.py` (`make sigdiff`) finds the class of bug directly, by diffing
every bound constructor and member against the stock wheel.
Two further limits are worth stating, because they shaped how the increments
were gated:
- **Static analysis cannot see instance methods.** A method called on an object
(`vec.Reverse()`) appears in no import, so `--check` is blind to it and
`--methods` only guesses. Six such gaps survived to the end of Inc 4 and the
app's suite found all six in one run — one of them, `gp_Vec.Reverse`, failing
311 tests by itself. The suite is the only real net here.
- **The app's suite cannot gate a single increment.** `backend/tests/
conftest.py` imports `n3xd.main`, so every test fails at collection until the
last module is bound. Increments are gated instead on reference values
`tools/gen_fixtures.py` records from the stock wheel — measurements, per-face
area and centroid in map order, mesh counts, and the `Modified`/`Generated`/
`IsDeleted` maps compared exactly, since that is the substrate the app's
topological naming is built on.
## OCCT 8.0 watchlist
Recorded as each module landed, so the 10E bump is a known quantity:
- `Standard_Failure` derives `std::exception`, and the `Raise`/`Throw`/
`Instance` static helpers are gone. None of them is bound, so the exception
table should port unchanged.
- `GeomLProp` is superseded by the new `GeomProp`/`BRepProp` packages, whose
results come back as structs with `IsDefined` flags instead of throwing. Only
`GeomLProp_SLProps.Normal` is bound, so this is a one-line move.
- `StdPrs_BRepFont` and `StdPrs_BRepTextBuilder` become deprecated typedefs of
`Font_BRepFont`/`Font_BRepTextBuilder`. They are real classes in 7.9.3 and are
bound as such; 8.0 makes it a rename behind the same `OCP.StdPrs` names.
- Methods that returned handles through an out-parameter return them by value.
`BRep_Tool.Surface`/`Curve` and the adaptors are the ones this binding
touches.
- The global math wrappers are deprecated in favour of `std::`. Nothing here
binds them.
- The byte-identity fixtures retire at the bump: a different kernel legitimately
writes different BREP bytes. The measurement and history blocks stay.
## Open questions ## Open questions
- **S5** — whether STEP needs `CSF_*` resource files shipped in the wheel. - ~~**S5** — whether STEP needs `CSF_*` resource files shipped in the wheel.~~
Modern OCCT code-initialises most `Interface_Static` defaults; settle it when **Settled at Inc 3: it does not.** `tests/test_inc3_io.py` asserts that no
Inc 3 (I/O) lands, in a container without system OCCT resources. `CSF_*` variable is set and then round-trips STEP and IGES, reading the
declared units back off both — which is exactly the resource-less container
the question was about. Both controllers initialise and the readers resolve
millimetres. The wheel ships no `share/` tree.
- **Byte-identity beyond 7.9.3** — the gate compares against the stock wheel, so - **Byte-identity beyond 7.9.3** — the gate compares against the stock wheel, so
it necessarily retires at the OCCT 8.0 bump (roadmap 10E), where the fixtures it necessarily retires at the OCCT 8.0 bump (roadmap 10E), where the fixtures
are re-blessed deliberately alongside assay's goldens. are re-blessed deliberately alongside assay's goldens. See the 8.0 watchlist
above for what else moves.