Status: accepted (2026-07-24)
Extends ADR-0018 (forge is the kernel) and
ADR-0020 (forge is the only kernel). The
exactness charter (ADR-0019) is unchanged and
governs everything below.
The capability matrix (python -m gitcad.bench.capability) measured the kernel
for the first time: **23 seam operations × 15 solid representations = 345
cells, 63% working.** The remaining 127 gaps are not one missing algorithm —
they are the shape of the architecture.
forge grew a separate representation per feature: Solid (planar BSP), Cyl,
Sphere, Cone, AxisStack, DrilledSolid, RevolveSolid, DisjointUnion,
RoundedBox, FilletedBox, MiteredSweep, SphereOverlap, TubeSolid,
LoftSolid, SplinePrism. Each was the fastest way to make one feature exact,
and each implements a different subset of the geometry protocol. So:
every new representation must implement 23 ops;
fundamental holes (STEP export of any drilled part) went unnoticed for months;
Solid API silently duck-typed onto a quadric and raised AttributeError — 55 such crashes, one root cause.
No amount of patching closes a quadratic matrix. Mature kernels (OCCT,
Parasolid) do not have this problem because they have exactly one topology
representation, and every operation is written once against it.
**Introduce a single canonical B-rep, forgekernel.body.Body, and make every
existing representation a CONSTRUCTOR for it rather than a parallel universe.**
Body = [Face]
Face = (Surface, [Loop], sense) # sense: outward normal == surface normal?
Loop = [Edge] # first loop outer, rest are holes
Edge = (Curve, v0, v1) # v0 == v1 for a full circle
Surface ∈ {Plane, Cylinder, Cone, Sphere} (NURBS arrives with K3.7)
Curve ∈ {Line, Circle} (NURBS arrives with K3.7)
Every surface and curve carries exact parameters (rational or ℚ[√d] points,
directions and radii). The canonical form is analytic, never faceted — a hole
is a Cylinder, not a polygon fan.
Three consequences:
transform, bbox, mass_props, entities, tessellate, export_step become one implementation each
against Body, replacing up to fifteen. Work drops from O(ops × reps) to
O(ops) + O(reps) — 23 implementations plus 15 converters instead of 345
cells.
DrilledSolid still computes its volume in ℚ[π] directly; that is exact and
cheap and there is no reason to lose it. What changes is that when a
specialised path has nothing to offer, the op converts to Body and runs the
generic implementation instead of refusing.
intersection is the K3/K7 crown work and does not get easier by having one
representation. Booleans keep their specialised exact paths and their honest
refusals. This ADR is about the other 20 operations, which are the bulk of
the gaps and need no SSI.
Unchanged: no float decides topology. Specifically —
Fraction or SurdVal; transforms of them are exact (a rotation by a ℚ[√d] angle maps a Cylinder to a Cylinder).
analytically: a planar face contributes (1/3)·(n·p)·Area, a cylindrical band
contributes a closed form in ℚ[π], and so on. No integration by sampling.
legal (ADR-0019), and now have one implementation instead of fifteen.
stage**. A canonical representation does not license approximation.
non-boolean cell should become ✅, and the roadmap collapses from 127
scattered gaps to a short list of genuinely-hard boolean/blend work.
against the representation it replaces — the shadow-run below is not optional.
TubeSolid carries a certified CInterval volume; converting it to Body and
re-integrating must reproduce the same interval or the converter is wrong.
refuses it (an elliptical cylinder is a new surface type, deferred) rather
than silently approximating — the same honesty rule as everywhere else.
body.py behind the existing seam — no caller changes while itlands.
Body and diff thegeometry against the specialised implementation — exact volume equality,
bbox equality, and mesh-volume agreement. A converter ships only when its
representation's own numbers reproduce.
Body when a specialised op refuses; neverthe reverse (fast exact paths keep priority). **A fallback must preserve the
FAILURE behaviour too, not just the successes** — routing entities through
the canonical form changed the exception type raised for a representation
with no converter yet (NotImplementedError → KernelError) and broke five
passing tests that depended on it. "No regression" means observable
behaviour, including how an operation declines.
(pinned by tests/invariants/test_seam_enforcement.py).
implementation only once its shadow-run is clean.