Skip to content

[19] RC detail CAD constructibility review - #90

Draft
Batuis wants to merge 11 commits into
pr/18-rc-slabs-walls-foundationsfrom
pr/19-rc-cad-constructibility
Draft

[19] RC detail CAD constructibility review#90
Batuis wants to merge 11 commits into
pr/18-rc-slabs-walls-foundationsfrom
pr/19-rc-cad-constructibility

Conversation

@Batuis

@Batuis Batuis commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

POC / IMPLEMENTATION PHASE — Stabileo producer complete, CAD consumer not started

Still a draft. Do not review or merge. The Stabileo half of the POC is now real production
code with real gates; the text-to-cad consumer (STEP/GLB, the independent cross-check, the review
artifact) has not been started.

Builds directly on PR15–PR18

Ancestor Head Verified
PR15 / #78 7d0ab24ef937cd0d7e5f78808d806adcabdab80c ancestor ✓
PR16 / #79 b13c81ce843e5fe5ca14619a8f2f1b441df22c8f ancestor ✓
PR17 / #80 86b8b3acea2d2630e48e05e776a1e95c04c6a50c ancestor ✓
PR18 / #84 d19588ef3ecd21b4bc1bd8d054fdbaca961ed040 ancestor ✓ (base)

Not based on a QA branch. No solver / Rust / Cargo / WASM changes.


What this branch now produces

A production Foundations → Export CAD handoff action that turns a really-detailed footing into
a versioned, deterministic RcCadHandoffV1 manifest.

The subject is a connection, and it says so

Footing column-transfer cage — column dowels and starter ties connecting the footing to the
supporting column. Footing mat geometry is not included.

This is not complete footing reinforcement, and the manifest is built so it cannot be read as
such. assembly.kind = footingTransferCage, assembly.completeness = partialConnectionOnly, and
FOOTING_MAT_GEOMETRY_NOT_MODELED is a declared unsupported condition with a stable code.
Semantic validation refuses a document that claims completeFootingReinforcement while that
condition is present — the enum member exists so the refusal is a real check rather than a vacuous
one, and it equally refuses a transfer cage that omits the condition.

Nothing is invented

Datum Value Where it comes from
Footing pad 2,00 × 2,00 × 0,50 m, base z = −1,20 m Footing.B/.L/.thickness/.foundingElevation
Footing top z = −0,700 m foundingElevation + thickness — the same expression run-footing-design.ts:596 hands the dowel generator as footingTopZ
Column footprint 0,40 × 0,40 m section b/h of element 1, read exactly as collectFootingColumns reads it
Column stub z = −0,700 → +0,447263 m footing top up to the top of the production cage's steel, clamped to the column top
Interface planar rect, z = −0,700 m, 0,40 × 0,40 m the contact between the two solids
Cover 0,050 m Footing.cover. 50 mm is never hard-coded anywhere

No generic column size. No arbitrary stub height. No invented anchor bolts, hooks, tolerances, BIM
semantics or fabrication approval.

Two derivations that had a wrong answer available

The stub top is a surface extent, not a centreline extent. Reading the maximum segment endpoint
z gives 0,4423 m — and then the top starter tie protrudes through the concrete by its own radius.
Two corrections were needed and only the first is obvious: grow by d/2, and measure on the
arc-aware production sampler (samplePath at COLLISION_CHORD_TOLERANCE = 0,5 mm), which finds
a true arc extreme the endpoints miss. Endpoint arithmetic would have understated the stub by ~2 mm
and left steel outside the concrete — the same class of error the stored arc centre exists to
prevent.

The stub's upper face is a truncation, not a surface. It carries truncatedFaces: ['top'] and
the cover requirement sets excludeTruncatedFaces. A cut plane admits no cover measurement.

A real discontinuity, surfaced rather than smoothed

Element 1's base node sits at z = 0, which is 0,700 m above the footing top. The analysis
model's column does not reach its own footing. The stub is taken from the footing top anyway,
because that is where the production dowels and starter ties actually are, and the gap is exported
as COLUMN_ELEMENT_BASE_ABOVE_FOOTING_TOP with all three numbers. No concrete is invented to close
it.

The interface is not an exposed face

The footing top beneath the column is an intentional concrete-to-concrete contact. A dowel crossing
it is the detail working. It is emitted explicitly, marked exposure: 'internal', listed in the
cover requirement's excludeInterfaceIds, and carries the crossing bars as
intentionalBarPassage with §16.3.4. A consumer has three independent reasons not to read a
penetration as a breach, and a test asserts no cover or containment finding is ever raised against
a crossing bar.

Per-check authority — and the field that makes it enforceable

Check Authority Status Consumer policy
barCollision stabileo EVALUATED12 findings MAY_CROSS_CHECK
barClearSpacing stabileo EVALUATED — 0 findings MAY_CROSS_CHECK
concreteCover (footing) none NOT_EVALUATED MAY_OBSERVE_NOT_COMPARABLE
concreteCover (column stub) none NOT_EVALUATED OUT_OF_SCOPE
reinforcementContainment none NOT_EVALUATED MAY_OBSERVE_NOT_COMPARABLE

consumerObservationPolicy is new and it is what turns the 2-C decision into something checkable.
The distinction between its members is the whole point: MAY_OBSERVE_NOT_COMPARABLE permits a
measurement and forbids presenting it as a verdict; OUT_OF_SCOPE forbids the measurement itself.
Collapsing the two would let a consumer report an unevaluated property as passing. Cover is two
checks scoped to two bodies, so the stub cannot inherit the footing's status by omission.

barClearSpacing is EVALUATED with zero findings, and that is a result rather than an
absence: 78 pairs were evaluated and every reported one was a prohibited overlap, judged by rule 1
of the classifier before any spacing clause applied.

The twelve conflicts are real, and they are exported

The production assembly reaches COORDINATED with 12 prohibitedOverlap conflicts and
constructibility CONFLICTED: all eight dowel hooks turn toward the column centre and interfere at
the bottom of the footing. A genuine finding about this fixture's geometry, exported as twelve
findings carrying Stabileo's own pairClass — not smoothed into a pass, not hidden behind a
summary boolean.

Clear spacing: two shapes that are not interchangeable

classifyPair takes surfaceClearance and the tangents at closest approach, so it is
geometry-dependent. Re-running it in the exporter without the collision detector's data would
produce a different pairClass than production — exactly the divergence that makes a second
implementation worse than none. So the manifest carries:

  • the code-derived rule per role pair, from the exported minClearSpacingFor with its clause
    refs (column Ø16 → 40 mm, absolute floor; two beam-rule pairs → 26,67 mm, aggregate term);
  • the per-pair verdict for the twelve pairs Stabileo reported, with its real pairClass.

Pairs Stabileo did not report get no per-pair entry. Stated as a limitation, not filled in.


Cover remains deferred

Stabileo exports the cover placement intent it owns and reports containment as NOT_EVALUATED
with a mandatory reason, because checkCover has zero production callers — for any element type.
CAD may measure achieved cover as an independent geometric observation and may never turn
NOT_EVALUATED into PASS. Generalized authoritative cover validation stays in
docs/handoffs/deferred-cover-validation-pr20.md. No production behaviour changed outside the
new export.


Validation, in two layers

Schema — the shipped JSON Schema, checked by a validator restricted to the keywords that schema
uses. A hand-written validator's real risk is not a wrong rejection but silent
under-validation
, so assertSupportedSchema walks the schema first and throws on any keyword it
does not implement. A schema that grows past the validator fails loudly instead of checking less
than it claims. No new dependency in the browser bundle.

Semantic — where the honesty lives, since a document can be perfectly shaped and still say
something false. 36 rules: unique ids; resolving references; valid interface participants; an
interface plane that must actually be where the two solids meet; positive dimensions and diameters;
finite coordinates; an arc that drops its centre must declare the approximation; no footing cover
requirement on the stub; no cover requirement that fails to exclude the internal contact; no
containment verdict at all; no cross-check invitation without a verdict; every body needs its own
cover check. It lives in a module free of JSON imports so the browser journey can run it against a
downloaded file.

Where the exporter refuses rather than guessing

NO_COLUMN_REFERENCE · FOOTING_NOT_DIMENSIONED · FOOTING_ROTATION_NOT_RESOLVED ·
FOOTING_ECCENTRICITY_NOT_RESOLVED · PEDESTAL_NOT_SUPPORTED · NO_TRANSFER_CAGE ·
NO_COLUMN_DOWELS · UNCLASSIFIED_CAGE_BAR · NO_ASSEMBLY · STALE_ASSEMBLY ·
INVALID_MANIFEST

The eccentricity refusal is the one worth reading. Footing.eccentricityB/L is documented as the
offset of the footing centroid from the node, while run-footing-design uses node + eccentricity
as the column centre. At zero the two coincide and nothing is ambiguous. At a non-zero value
they are different places, and choosing one would invent where the column stands.

Every refusal is shown verbatim in the panel, in EN and ES. No disabled button without an
explanation.


The artifact

Path web/src/lib/export/__fixtures__/rc-footing-cad-poc.handoff.json
Downloaded as rc-cad-handoff-Z1-det3-dem2.json
Size 88 101 bytes
SHA-256 795e9de26f2eb8ce8d51f2ac7130336702fc534588f390071e3bd40bc03aa0e7
Schema RcCadHandoffV1, schemaVersion 1
Source fixture SHA-256 15ce4e150919bf8f91ef1e3fae36dcde584b770fea45861465742654153e3e79

Committed as the shared contract sample (Decision 2A), in its own test file so its revision is the
one a first export actually reaches rather than whatever a suite of earlier cases left a
monotonic counter at.

Contents: 8 column dowels Ø16 · 6 starter ties Ø6 · 14 bars · 2 marks (F1 ×6, F2 ×8) · 90
segments including 38 arcs, all 38 with exact centres and none approximated · 2 concrete bodies
· 1 interface · 12 collision findings · 5 checks · 3 unsupported conditions · 4 assumptions.

The filename carries identity and revisions and no timestamp: a timestamp makes two identical
exports look different and a stale one look fresh.

Fixture change

The committed .ded is now saved as the PRO project it is — analysisMode moves 3dpro.
The old value produced a file that, reopened, landed the user in Básico where the footing they
created is neither visible nor reachable; that was an artifact of the generator test's default, not
a decision. The model snapshot is byte-identical; only the saved mode and the serializer's
timestamp changed. This is what lets the browser journey open a real committed project through the
production file input instead of a test-only load hook.


Tests

105 unit test cases (260 assertions) across four suites, plus eight browser journeys. Every one starts from the
committed .ded, restores it through deserializeProjectmodelStore.restore, solves with the
real WASM solver, and runs the production design and detailing commands. Nothing is
injected
— not the assembly, not the cage, not the manifest.

  • rc-cad-handoff-manifest.test.ts (47) — component extraction, stub derivation, interface geometry,
    transfer-cage classification, exact arcs, ID resolution, cover scoping, containment
    NOT_EVALUATED, column cover not evaluated, interface passage never a failure, collision
    preservation pair-for-pair against the assembly, determinism, fixture reconciliation
  • rc-cad-handoff-validation.test.ts (43) — 43 rejections, each mutating exactly one thing on the real
    manifest, including the validator's own refusal to under-enforce a schema
  • rc-cad-handoff-golden.test.ts (3) — byte-exact golden reproduction
  • rc-cad-export-prerequisites.test.ts (12) — every refusal, including a stale assembly after a real
    demand-revision bump
  • e2e/rc-cad-handoff.spec.ts (8) — the project is opened through the toolbar's file picker, the
    cage is generated by the panel's own button, and the manifest is downloaded by the panel's own
    export button. The downloaded bytes are then re-validated against the committed schema file. EN
    and ES, including a check that no exported sentence is a bare i18n key.

Gates, all green before this push

Gate Result
npm run typecheck 490 / 490 baseline, no new errors
npm run test (unit) 4664 passed, 12 skipped, 228 files
npm run test (build) 5 passed
npm run build
npx playwright test 131 passed, 4 skipped

Not started

  • text-to-cad consumer: STEP/GLB generation, the independent OCCT cross-check, cad-review.json
  • the invalid/mutated fixture for the injected-defect demo
  • the 0,5 mm agreement band and cross-check-disagreement as an issue kind
  • Viewer linking (Decision 4B)

QA note

http://127.0.0.1:4000/ is still serving the previous green QA head db311d674
(qa/main-plus-pr19-cad) and does not contain this commit. Main, PR15 and PR16 are being
updated in parallel, so no propagation or QA rebuild was attempted. QA is knowingly stale with
respect to this branch.

Batuis added 3 commits July 30, 2026 09:39
…ismic

PR19 becomes RC detail CAD constructibility review. The seismic INPRES-CIRSOC 103
work that held this position was lost with the 2026-07-29 workstation data loss
and no Git object survived, so it is deferred rather than resumed.

docs/poc/rc-footing-cad-review.md - the POC design record, canonical for the
shared contract. Audits what PR15-PR18 actually provide (BarPath/BarSegment with
stored arc centres, BarMark, roles, layer ids, cover and clear-spacing rules,
family records, DocumentModel, DXF/XLSX) and finds only two real gaps: clear
spacing is applied by name inside detectCollisions rather than exported, and
there is no STEP/GLB writer.

The finding that shapes the design: Stabileo ALREADY does arc-exact bar-to-bar
collision with a broad-phase spatial hash and cover containment, and documents
the sagitta error that linear arc interpolation caused. So CAD is not where clash
detection should move. The POC is export + inspect + cross-check, not export +
outsource, and disagreement with Stabileo's own verdict is itself the finding
worth having.

Proposes RcCadHandoffV1 field by field with owner, requiredness, unit,
coordinate space, validation and absent-behaviour for each, plus the eight
checks, two fixtures and the visible acceptance journey. Six architecture
choices are presented with alternatives, licensing and affected files for the
user to decide. No production CAD code is written.

docs/handoffs/deferred-seismic-103-2026-07-29.md - specification-only record of
the lost seismic work. Every section classified measured-fact, reconstructed-
specification, planned-never-implemented, or lost. The four lost commit subjects
survive and are a sound slice order; the diffs do not. Na/Nv, wall-system
blockers and the reachability audit are recorded as LOST with no reconstruction,
because inventing them would be worse than admitting the gap. No seismic branch,
tag or code is created.

docs/CODEX_WORKFLOW_STABILEO.md - corrects the branch-backup policy. The earlier
rule told us to open a draft PR for every branch, which produced a PR on an
integration branch that must never merge and another on a deliberately-red
evidence snapshot. A draft PR is a review surface; remote backup is a push. The
rule is now scoped by branch kind: deliverable branches get draft PRs, QA and
evidence and exploratory fork branches get pushes only.

Solver boundary untouched. No Netgen/NGSolve. Modal FEA and text-to-cad PR #22
are out of scope.
…eliverable branch

The first version of the branch-backup policy was committed as 88abc89 onto
qa/main-plus-pr18 - a QA integration branch - so it was reachable from no PR
branch and from main. Retiring that QA branch would have orphaned a real,
G-signed, tracked-documentation commit.

That misplacement is an instance of the very problem the 2026-07-30 correction
addresses: tracked documentation is deliverable work and belongs on a deliverable
branch, not on an integration branch that exists to be deleted.

Preserve its text verbatim here, clearly marked superseded, so the history is
durable on PR19 and the obsolete QA branch can be retired without losing
anything. The scoped rules remain the ones in force; rule 2 is the part the
correction narrows to deliverable branches only.

No rewrite and no force-push of 88abc89 - it is preserved by quotation, not by
moving it.
Converts the six choices in rc-footing-cad-review.md into a decision packet with
enough measured detail to decide from, rather than a summary.

Each decision carries exact data flow, failure boundary, browser constraints,
reproducibility, licensing, dependency cost and POC effort, plus a compact table
of visible UX, files touched on each side, engineering risk, upstream acceptance
risk and expansion value.

Grounded in measured values rather than description:
COLLISION_CHORD_TOLERANCE = 0.0005 m; placement tolerance zero by default, because
a hardcoded 10 mm was once silently deducted from every measured clearance;
spatial-hash cell max(0.05, 2r + requiredClear + placement + 0.02); EPS 1e-12; the
documented sagitta errors of 5,9 mm on a dia-8 90 degree corner and 12,3 mm on a
135 degree hook; CoverBreach against SectionPrism; and requiredClearFor arriving
as a caller-supplied callback, which is exactly why the clear-spacing accessor is
missing.

Decision 5 gets the most detail because it decides whether the POC is evidence or
illustration. Stabileo stays the authority; the recommendation is an independent
OCCT cross-check with the agreement band stated at 0.5 mm to match Stabileo's own
sampler, and disagreement modelled as its own issue kind rather than resolved
toward either side. Moving collision authority into text-to-cad is rejected on
domain and licence grounds: the classification rules are CIRSOC-specific and
therefore AGPL-derived.

Flags one thing NOT verified rather than assuming it: SectionPrism models a
prismatic member and a footing pad is not that shape, so whether PR18's footing
path is covered by that containment check must be confirmed before fixtures are
frozen.

Three bundles - conservative demonstrator, clash-aware vertical slice, integrated
product direction - with scope, what the user sees, deferrals, phases, repos, test
strategy and risk. Bundle B is recommended and deliberately not chosen.

Answers the six explicit questions: Bundle B needs neither PR #22 nor
Netgen/NGSolve; the AGPL/MIT boundary holds if Stabileo exports numbers rather
than rules; the first footing needs only production PR18 data; the two missing
pieces are a read-only clear-spacing accessor and a STEP/GLB writer.

No production code, no CAD artifacts, no new dependencies, no QA or server
changes.
@Batuis

Batuis commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Architecture decision brief added — awaiting your decisions

docs/poc/rc-footing-cad-architecture-decisions.md (b1c795c5). The six choices are now a
decision packet with measured evidence rather than a summary. Still POC / DESIGN PHASE — NOT
READY FOR REVIEW
; no production code.

Recommendations, one line each

# Decision Recommendation
1 Handoff mechanism Versioned JSON + CLI. Only option whose licence boundary is unarguable and whose artifacts are the audit trail. Its CLI becomes the local-service handler later, unchanged
2 Manifest ownership Stabileo-owned RcCadHandoffV1, with a JSON Schema as the shared artifact and generated bindings both sides. Stabileo owns structural meaning; CAD reads numbers
3 Solid generation Split — Stabileo exports concrete boundaries + full BarPath intent; cadpy realises named solids. Preserves arc fidelity by construction
4 Viewer External for the first POC, linked immediately after. Embedded is the right end state but the upstream viewer surface is churning
5 Collision authority Stabileo authoritative + independent OCCT cross-check, agreement band stated at 0.5 mm, disagreement as its own issue kind
6 Review return path cad-review.json for the POC, persisted import for production. The POC file is the production import payload

Bundle B — clash-aware vertical slice — is recommended and deliberately not chosen.

Why Decision 5 got the most space

engine/detailing/collision.ts is not a stub. Uniform spatial hash sized
max(0.05, 2r + requiredClear + placement + 0.02); exact narrow phase at EPS = 1e-12; arcs
sampled at COLLISION_CHORD_TOLERANCE = 0.0005 m using the stored centre; placement
tolerance zero by default because a hardcoded 10 mm was once silently deducted from every
measured clearance; and the documented sagitta errors — 5.9 mm on a Ø8 90° corner, 12.3 mm
on a 135° hook, both larger than the bars being checked.

So moving collision authority to CAD is rejected: it would replace a regulation-aware, arc-exact,
tolerance-audited checker with a generic one, and porting the CIRSOC classification rules would mean
putting AGPL-derived logic into an MIT repository. The cross-check is what adds assurance —
OCCT BRepExtrema_DistShapeShape on exact geometry is genuinely independent of Stabileo's sampled
segments, which is what makes agreement meaningful instead of a re-run.

"Clash-aware RC detail" is achieved under both 5A and 5B; only 5B adds independent
confirmation, and only 5C puts the design at risk.

Two gaps, and one thing I did not assume

Missing: a read-only required-clear-spacing accessor (requiredClearFor arrives as a
caller-supplied callback, so the numbers are never materialised) and a STEP/GLB writer (Stabileo
emits DXF R12, 2D).

Not verified, flagged rather than glossed: SectionPrism models a prismatic member, and a
footing pad is not that shape. Whether PR18's footing path is covered by that containment check must
be confirmed before fixtures are frozen. If footings are not covered, that materially strengthens
the case for the CAD-side containment cross-check.

Confirmed

Bundle B needs neither PR #22 nor Netgen/NGSolve — the cross-check needs the same OCCT that
cadpy's STEP support already uses, not a mesher. The AGPL/MIT boundary holds as long as Stabileo
exports numbers rather than rules. The first footing needs only production PR18 data — all
27 datums trace to an existing owner, so no duplicate footing or reinforcement model.

No production code, no CAD artifacts, no new dependencies, no QA or server changes.

…evidence

Both were to be settled before freezing fixtures. One shrinks, one opens a
decision.

Required clear spacing - RESOLVED, and my own decision brief was too pessimistic.
It claimed a read-only accessor must be added because detectCollisions receives
requiredClearFor as a caller-supplied callback. Tracing that callback to its
source shows the rule is already implemented and already exported: classifyPair
in engine/detailing/classify.ts returns PairClassification { pairClass,
requiredClear, reportable, refs: ClauseRef[], labelKey }, and
minClearSpacingFor / minClearBetweenLayers are public in
codes/cirsoc201/spacing.ts. Three production callers already use them, and the
floor coordinator builds requiredClearFor as a one-line closure over
minClearSpacingFor - so the callback is an injection seam, not a hiding place.

That is better than an accessor: classifyPair also yields the clause reference,
the reportable flag and an i18n label key, so the manifest gets real provenance,
the CAD side learns which shortfalls Stabileo deliberately does not report, and
EN/ES text reuses PR16 messages. Only serialisation is new. No rule code, no new
engine accessor.

Footing containment - NOT covered, and neither is anything else. checkCover has
seven references in web/src: its own definition and six call sites in
bar-geometry.test.ts. Zero production callers. It is implemented and unit-tested
but unwired, for footings and for beams and columns alike. So Stabileo has no
post-hoc geometric containment verdict in production at all.

Cover is achieved by construction instead: it is a placement input, as
floor-design.ts shows with available = footingThickness - footingCover - 0.05
and inset = panel.cover + d/2. Bars are placed at the right cover
arithmetically; nothing afterwards measures whether the realised geometry sits
inside the concrete.

That makes RcCadHandoffV1's required stabileoVerdict undefined for containment,
which is a material decision. Three options are set out with scope, risk and
product consequence: declare containment unsupported; wire checkCover into the
footing path as new product behaviour; or export cover as placement intent and
have CAD audit the achieved geometry. Recommendation is the third with the
first's honesty framing and the second deferred to its own PR, because it keeps
the approved Bundle B scope and turns a gap into an independent audit of an
assumption nothing currently checks.

Stopping before that choice. Everything else in the manifest is unblocked and
listed. Footing / Pedestal already persist B, L, thickness, rotationDeg,
eccentricities, cover and material ids, so no second footing model is needed.

No production code, no solver changes, no new dependencies.
@Batuis

Batuis commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Bundle B selected — clash-aware vertical slice

The user has approved Bundle B with all six recommendations from
docs/poc/rc-footing-cad-architecture-decisions.md:

  1. Versioned JSON + CLI handoff.
  2. Stabileo owns RcCadHandoffV1 and the engineering meaning of its fields.
  3. Stabileo exports intent and exact bar paths; cadpy/text-to-cad realises STEP/GLB solids.
  4. External viewer first, linked workflow next — no embedded viewer in this slice.
  5. Stabileo remains authoritative for regulatory collision/clearance classification; OCCT
    performs an independent exact-geometry cross-check
    with the documented 0.5 mm agreement band.
  6. cad-review.json is the POC return artifact, deliberately shaped as the future
    persisted-import payload.

Seismic is not revived. Its surviving knowledge stays in
docs/handoffs/deferred-seismic-103-2026-07-29.md.


Foundation questions resolved before implementation — 9df181ba

docs/poc/rc-footing-foundation-findings.md. One shrinks, one opens a decision.

1. Required clear spacing — resolved, and my earlier brief was too pessimistic

The brief said a read-only accessor must be added. Tracing requiredClearFor to its source shows
the rule is already exported: classifyPair (engine/detailing/classify.ts:266) returns
PairClassification { pairClass, requiredClear, reportable, refs: ClauseRef[], labelKey }, and
minClearSpacingFor / minClearBetweenLayers are public in codes/cirsoc201/spacing.ts.
Three production callers already use them; the floor coordinator builds requiredClearFor as a
one-line closure over minClearSpacingFor, so the callback is an injection seam, not a hiding
place
.

Better than an accessor: classifyPair also yields the clause reference, the reportable
flag, and an i18n label key — so the manifest gets real provenance, the CAD side learns which
shortfalls Stabileo deliberately does not report, and EN/ES text reuses PR16 messages.
Only serialisation is new. No rule code, no new engine accessor.

2. Footing containment — not covered, and neither is anything else

checkCover has 7 references in web/src: its own definition and 6 call sites in
bar-geometry.test.ts. Zero production callers.
It is implemented and unit-tested but unwired —
for footings, beams and columns alike. So Stabileo has no post-hoc geometric containment verdict
in production at all
; the gap is broader than footings.

Cover is achieved by construction, as a placement input:
available = footingThickness - footingCover - 0.05, inset = panel.cover + d/2 + …. Bars are
placed at the right cover arithmetically; nothing afterwards measures whether the realised geometry
sits inside the concrete.

That leaves RcCadHandoffV1's required stabileoVerdict undefined for containment — a
material decision, with three options set out in the findings doc:

Scope Value Risk
2-A declare containment unsupported smallest, exporter only honest; CAD adds a capability Stabileo lacks a CAD bug looks like a design defect
2-B wire checkCover into the footing path materially larger — changes product behaviour; needs a pad abstraction, SectionPrism is prismatic best long-term; a real internal check may start failing mature fixtures; outside the approved slice
2-C export cover as placement intent, CAD measures achieved cover exporter only strongest cross-check — audits the cover-by-construction assumption nothing currently verifies will surface genuine discrepancies needing triage

Recommendation: 2-C, with 2-A's honesty framing, and 2-B deferred to its own PR.
Stopped before this choice — the exporter's stabileoVerdict shape and
unsupportedConditions[] wording depend on it.

Everything else is unblocked: envelope, identity, revisions, units/frame, concrete geometry from the
persisted Footing/Pedestal (B, L, thickness, rotationDeg, eccentricities,
cover, material ids — no second footing model needed), bars with exact arc centres, marks,
roles, layerId, ownerElementIds, required cover, required clear spacing + clause refs, and the
already-wired collision verdict.

Still draft, 0 reviewers. No production code, no solver changes, no new dependencies.

…ed cover

Records the approved containment decision - 2-C with 2-A's honesty - and lands the
canonical JSON Schema it implies. 2-B is NOT implemented here; generalized
authoritative cover validation is deferred to a probable PR20 with its own
handoff.

Cover is deliberately not a global scalar. requirements.cover is a list of
coverRequirement, each keeping requirementId, elementId, elementType, optional
surface (face/region/note), optional appliesToBarIds, distance, unit, category
and provenance as separate concepts. An absent surface means Stabileo does not
model a per-surface distinction - a stated limitation rather than a wildcard -
and face is an open string, not a closed enum, because the production model has
no face roles yet and fixing an enum now would encode guesses. category admits
only placementInput, codeDerived and userSpecified, the three the model actually
distinguishes today.

Results are per check rather than one aggregate boolean, so bar collisions, clear
spacing, cover and containment each keep their own authority, evaluation status,
provenance and result. authority is stabileo or none; evaluationStatus is
EVALUATED or NOT_EVALUATED; notEvaluatedReason is REQUIRED when not evaluated,
enforced by an if/then in the schema so a consumer cannot quietly omit the
reason. Footing containment will therefore travel as authority none,
NOT_EVALUATED, with the concrete reason that checkCover has no production caller.

Arc geometry keeps its centre as the exact representation and carries an explicit
arcApproximated flag for the chord fallback, so a degraded arc is declared rather
than silently exact. clearSpacingRequirement carries reportable, so a consumer
respects the pair classes Stabileo intentionally suppresses instead of raising
conflicts Stabileo does not.

The PR20 migration needs no schema redesign: only authority, evaluationStatus and
the presence of findings change, and requirementId keys stay stable across it.

Schema only - no serialiser, no exporter wiring, no product behaviour change, no
cover UI, and no hard-coded 50 mm anywhere.
@Batuis

Batuis commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Containment decision recorded: 2-C with 2-A's honesty — 6c5af5ce

2-B is not implemented in PR19. Generalized authoritative cover validation is deferred to a
probable PR20, with its own tracked handoff: docs/handoffs/deferred-cover-validation-pr20.md.

Rule Implemented as
Stabileo exports the cover requirement it genuinely owns Footing.cover / footingCover, value preserved — 50 mm never hard-coded
Footing containment reported NOT_EVALUATED authority: "none", with a required notEvaluatedReason naming the cause: checkCover has zero production callers
CAD measures achieved cover independently as a geometric observation
A CAD measurement is not a regulatory verdict or certificate review artifact must label it so
CAD may never turn NOT_EVALUATED into PASS distinct NOT_COMPARABLE state

Schema landed — web/src/lib/export/rc-cad-handoff.schema.json

Cover is not a global scalar. requirements.cover is a list; each entry keeps
requirementId, elementId, elementType, optional surface (face/region/note),
optional appliesToBarIds, distance, unit, category and provenance as separate
concepts. An absent surface means Stabileo does not model a per-surface distinction — a stated
limitation, not a wildcard. face is an open string, not a closed enum, because the production
model has no face roles yet and fixing an enum now would encode guesses. category admits only
placementInput | codeDerived | userSpecified — the three the model actually distinguishes.

Results are per check, never one aggregate boolean. Each check carries its own checkId,
checkKind (barCollision | barClearSpacing | concreteCover | reinforcementContainment),
authority, evaluationStatus, requirementIds, scope and findings. A JSON Schema
if/then makes notEvaluatedReason required when not evaluated, so a consumer cannot quietly
omit the reason.

The PR20 migration needs no redesign — only authority, evaluationStatus and the presence of
findings change; requirementId keys stay stable.

Also: BarSegment keeps the arc centre as the exact representation plus an explicit
arcApproximated flag, and clearSpacingRequirement carries reportable so a consumer respects
the pair classes Stabileo intentionally suppresses.

Gates on QA 77705cb7d

typecheck 490/490 no new · unit 4560 passed / 12 skipped · build PASS · production build
green · Playwright 123 passed / 4 skipped · WASM rebuilt, 0 tracked. Serving
http://127.0.0.1:4000/.

Blocked — one decision needed

Implementation of the serialiser is paused on a listed stop-condition (unavailable footing
geometry
): no committed fixture JSON contains a footing. grep -l '"footings"' across all 56
fixtures returns nothing, and the existing run-footing-design.test.ts builds its footing from the
newFooting() factory with synthetic reactions. Since the acceptance criteria require the footing to
come through the production model path with no test-only shadow footing, how the demonstration
footing is sourced is a material choice. Options and a recommendation are in the report.

Still draft, 0 reviewers. Schema only — no serialiser, no exporter wiring, no cover UI.

Batuis added 2 commits July 30, 2026 11:52
…exporter

The user selected F-2, a dedicated committed project fixture loaded through real
snapshot restoration. Tracing the code shows the two fixture formats are not
interchangeable, and only one of them restores a snapshot.

templates/fixtures/*.json is replayed by loadFixture, which calls addNode,
addElement and friends with id remapping - it never touches modelStore.restore. A
.ded project is a DedalFile carrying a snapshot, produced by serializeProject and
read back by deserializeProject, which does call modelStore.restore. So the
canonical F-2 fixture must be a DedalFile; that is also the path the user's own
Open and Save actions use.

Base model is rc-design-qa-8.json, already used by the detailing suites, with four
fixed3d supports at nodes 1 to 4 - exactly the ground nodes a footing attaches
to. It is loaded, extended in memory and serialised to a NEW file; the shared
fixture is not modified.

The footing is added with the same store actions FoundationsPanel.svelte calls
and footing-persistence.test.ts already exercises: addSoilProfile,
updateSoilProfile, addFooting, updateFooting.

DetailingAssembly is derived state and is not part of ModelSnapshot, so the
fixture persists only the project and the test recomputes the assembly through
the production detailing action. Detailing output is never seeded into the
fixture.

Cover stays a model value read from Footing.cover; 50 mm is not hard-coded
anywhere.

Recipe verified, fixture not yet generated, exporter not yet written. Stopping at
a clean checkpoint rather than starting a multi-file exporter that could not be
finished and tested in the same pass.
…red through production paths

The F-2 fixture, generated only through production actions and read back through
the real snapshot restoration path.

Built by: modelStore.loadExample('rc-design-qa-8') - the production example
loader - then addSoilProfile, updateSoilProfile, addFooting and updateFooting,
the same store actions FoundationsPanel.svelte calls, then serializeProject().
Read back by deserializeProject(), which is the only path that reaches
modelStore.restore(). templates/fixtures/*.json is replayed by loadFixture and
never restores a snapshot, which is why the canonical fixture is a .ded
DedalFile.

rc-design-qa-8.json is not modified. The founding node is chosen dynamically as
the lowest fully-fixed support, because loadFixture remaps ids and hard-coding
node 1 would silently drift.

Lives under export/__fixtures__ rather than templates/fixtures because it is a
project, not a loadable template: fixture-validation.test.ts rightly requires
everything in templates/fixtures to be registered in fixture-index.ts, and a
DedalFile is not a JSONModel that loadExample could load. The gate caught the
wrong placement; the gate was right.

Footing and soil inputs are illustrative in-domain values, not project-derived
and not regulatory: 2.0 x 2.0 x 0.5 m pad, 0.05 m cover, founding elevation
-1.2 m, allowable bearing 250 kPa, soil provenance recorded as 'assumed'. Cover
is carried on the model and read from there by the exporter; nothing downstream
hard-codes it.

Four tests: production build and optional regeneration behind WRITE_FIXTURE;
restoration proving the footing, its node, its surviving fixed support and its
soil-profile reference; an assertion that the snapshot persists NO derived
detailing state, so an assembly can never be seeded instead of recomputed; and a
regeneration check comparing model content with the production save timestamp
excluded, since nothing was patched to force a fixed timestamp.

Fixture sha256 50ded28d0fd701159c2b72d7e96a4210c0ecb5f7280753edb1ac66ac04cf5ce0,
11752 bytes.

Gates: focused 287 passed; full unit 4550 passed / 12 skipped; build project
PASS; typecheck 490/490 no new; production build green.
@Batuis

Batuis commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Checkpoint: canonical F-2 footing fixture — 42b2ac81

The manifest producer's data foundation is in place and green. The exporter itself is not yet
written; see the limitation note at the end.

Fixture

web/src/lib/export/__fixtures__/rc-footing-cad-poc.ded.json
· 11,752 bytes · sha256 50ded28d0fd701159c2b72d7e96a4210c0ecb5f7280753edb1ac66ac04cf5ce0

Built only through production actions:

modelStore.loadExample('rc-design-qa-8')   // production example loader
modelStore.addSoilProfile / updateSoilProfile
modelStore.addFooting / updateFooting      // what FoundationsPanel.svelte calls
serializeProject()                         // production .ded writer

read back through deserializeProject()modelStore.restore() — the only path that actually
reaches snapshot restoration. templates/fixtures/*.json is replayed by loadFixture and never
restores a snapshot, which is why the canonical fixture is a .ded DedalFile.

rc-design-qa-8.json is not modified. The founding node is selected dynamically as the
lowest fully-fixed support, because loadFixture remaps ids and hard-coding node 1 would silently
drift.

Contents: 8 nodes, 8 elements, 4 fixed3d supports, 1 footingZ1, kind isolated,
2.0 × 2.0 × 0.5 m, cover 0.05 m carried on the model, founding elevation −1.2 m, soil profile
with allowable bearing 250 kPa and provenance recorded as assumed. All illustrative in-domain
values, not project-derived and not regulatory. Nothing downstream hard-codes 50 mm.

Placement — a gate caught me

It lives under export/__fixtures__, not templates/fixtures. fixture-validation.test.ts
requires everything in the template registry to be registered in fixture-index.ts, and a
DedalFile is not a JSONModel that loadExample could load. My first placement failed that gate;
the gate was right and the file moved.

Tests — 4, all green

Production build (with optional regeneration behind WRITE_FIXTURE=1); restoration proving the
footing, its node, its surviving fixed support and its soil-profile reference; an assertion that
the snapshot persists no derived detailing state, so an assembly can never be seeded instead of
recomputed; and a regeneration check comparing model content with the production save timestamp
excluded — nothing was patched to force a fixed timestamp.

Gates

focused 287 passed · full unit 4564 passed / 12 skipped · build project PASS · typecheck
490/490 no new · production build green · Playwright 123 passed / 4 skipped.

QA c2ac5ab4e: all six heads reachable, 0 duplicate patch-ids, 0 engine/Cargo diff, WASM
untracked, serving http://127.0.0.1:4000/.

Not yet done

The exporter, its semantic validation, the Foundations export action with EN/ES strings, the
remaining ~11 tests, and all text-to-cad work. Stopped at this clean green checkpoint rather than
half-writing a ~400-line exporter. No decision needed — the next pass has a concrete restored
footing to export from.

Still draft, 0 reviewers.

Batuis added 4 commits July 30, 2026 12:49
…tailingAssembly

Executable evidence for the risk boundary, driving only production actions:

  committed .ded -> deserializeProject() -> modelStore.restore()
    -> modelStore.solveCombinations3DParallel()   real WASM solver
    -> resultsStore.setCombinationResults3D()     production results sink
    -> detailingStore.generateFloors()            the production footing entry point
    -> RunFootingDesignResult + DetailingAssembly

Nothing seeded: no reactions, no solver results, no bars, no marks, no
conflicts, and the footing is not reconstructed after restore.

Two real findings, both fixed or recorded rather than worked around.

First, the footing initially reported footing.run.noColumn and produced no
entry, because the fixture never set columnElementId. Fixed in the generator by
selecting the vertical element rising from the founded node - chosen dynamically,
since loadFixture remaps ids - and setting it through updateFooting, the same
production action the panel uses. The footing is now verified: entriesByLevel 1,
trace '1 de 1 zapata(s) verificada(s)'.

Second, detailingStore.assemblies read 0 while the run and the persisted model
both held the assembly. That getter is a , and production code already
notes a  does not recompute inside the synchronous call that wrote it, so
outside a Svelte component it reads stale. The tests read the run result and
modelStore.model.detailing instead. This was a harness artefact, not a
production failure.

Measured production assembly: FLOOR--1.200 'Nivel -1.20 m', 1 element, state
VERIFIED, maturity IMPLEMENTED_PROVISIONAL, detailingRevision 1, demandRevision
0, 0 unsupported at assembly level.

Bars are 0, and that is asserted with its production reason rather than hidden:
the column carries no provided reinforcement in this fixture, so the run reports
footing.run.noColumnBars and generates no dowels or starters. A test asserts that
zero bars must be explained by that reason, so the gap cannot go quiet.

Fixture regenerated with columnElementId, sha256
d8522083651e3330cfa502836b1d242f3ac6e3ade55d0f1c2d3e2ba07777f38b.

Gates: focused suites green; full unit and build projects green; typecheck 490/490
no new; production build green.
…o never

The previous commit pushed five new tsc errors: casting the assembly and bar
arrays through 'never' made every property access on them invalid, so the
typecheck gate reported new errors against its 490 baseline. Caught by running
the gate after the push rather than trusting the earlier run.

Import DetailingAssembly and BarPath and use them directly. Also replaced the
optional resultsStore.clearAll?.() with the real clear(), which is the method the
store actually exposes - the optional call silently did nothing and would have
left results between cases.

Typecheck is back to 490 reported against baseline 490, no new errors. The
restored-project-to-assembly suite stays at 7 passed.
…ting assembly

The chain that was missing. Driven only through production commands:

  deserializeProject -> modelStore.restore
    -> solveCombinations3DParallel        real WASM
    -> resultsStore.setCombinationResults3D
    -> designRunStore.computeDemands      production command 1
    -> designRunStore.runCodeCheck        production command 2
    -> designRunStore.designAll           production command 3
    -> detailingStore.generate            beam/column detailing
    -> detailingStore.generateFloors      footing detailing

Nothing seeded: no forces, no reinforcement, no candidates, no bars. Every case
restores and clears modelStore, resultsStore, detailingStore and the design marks
first, so nothing leaks between them.

Measured result. The footing's referenced column, element 1, is designed to
8 dia-16 longitudinal with dia-6 stirrups at 0.175 m, and the outcome carries
accepted provided reinforcement. footing.run.noColumnBars disappears; the run
trace reads '1 de 1 zapata(s) verificada(s), 1 condicion(es) no soportada(s)',
the remaining condition being footing.run.serviceLateralExcluded, which is an
assumption about lateral service cases rather than a blocker.

The footing assembly is now non-empty: 14 bars, 2 marks, roles longitudinal and
transverse, every bar carrying a stable id, positive diameter, owning elements,
segments and a cutting length. 38 arc segments, and all 38 carry their centre -
so exact arc geometry is available to the exporter and no chord approximation is
forced.

Both the negative and positive cases are asserted, so the earlier structured
blocker cannot regress into silence: without design, zero bars AND
footing.run.noColumnBars; with design, non-empty. A regeneration case pins bar,
mark and assembly counts across two independent runs.

Gates run before pushing: focused export/store/detailing suites 1310 passed;
full unit 4562 passed / 12 skipped; build project PASS; typecheck 490 against
baseline 490 with no new errors; production build green. No solver, Rust, Cargo
or WASM files staged.
… cage

Completes the Stabileo side of the PR19 POC: the production Foundations export
that turns a real detailed footing into a versioned, deterministic CAD handoff.

What the manifest describes, and what it deliberately does not
--------------------------------------------------------------

The subject is the footing column-transfer cage: the column dowels and starter
ties connecting the footing to the supporting column, the footing pad, a stub of
that column, and the interface between the two. It is NOT complete footing
reinforcement. PR18's documented scope gives footings real dowels and starter
ties while the bottom and top mats remain drawing requirements rather than bar
geometry, so `assembly.completeness` is `partialConnectionOnly`,
`FOOTING_MAT_GEOMETRY_NOT_MODELED` is declared as an unsupported condition, and
semantic validation refuses any document that claims otherwise. The enum carries
a `completeFootingReinforcement` member the producer never emits precisely so
that refusal is a real check rather than a vacuous one.

Nothing is invented
-------------------

Every number is read from the restored production model or the production
assembly. The column footprint is the section b/h of the element the footing
references, read exactly as `collectFootingColumns` reads it — 0,40 x 0,40 m
here, never a generic size. The stub base is `foundingElevation + thickness`,
the identical expression `run-footing-design` hands the dowel generator as
`footingTopZ`. The stub top is the highest point of the production cage's steel,
clamped to the column top.

Two derivations had a wrong answer available. Taking the maximum segment
endpoint z understates the cage top and leaves the upper starter tie protruding
through the concrete, so extents are grown by the bar radius AND measured on the
arc-aware production sampler at `COLLISION_CHORD_TOLERANCE` — which finds an arc
extreme the endpoints miss by ~2 mm. That is the same class of error the stored
arc `centre` exists to prevent. And the stub's upper face is a truncation, not a
surface: it carries `truncatedFaces: ['top']` and the cover requirement excludes
it, because a cut plane admits no cover measurement.

A real discontinuity is surfaced rather than smoothed: element 1's base node
sits 0,700 m above the footing top, so the analysis model's column does not
reach its own footing. The stub is taken from the footing top because that is
where the production bars are, and the gap is exported with all three numbers.

The interface is not an exposed face
------------------------------------

The footing top beneath the column is an intentional concrete-to-concrete
contact. It is emitted explicitly, marked `exposure: 'internal'`, excluded from
the cover requirement's measurable surfaces, and carries the crossing bars as
`intentionalBarPassage` with the clause that makes them intentional. A consumer
therefore has three independent reasons not to read a dowel penetration as a
breach.

Cover, as decided
-----------------

Stabileo exports the footing cover placement intent it already owns
(`Footing.cover`, never a hard-coded 50 mm) and reports containment as
`NOT_EVALUATED`, because `checkCover` has zero production callers. Column cover
is `OUT_OF_SCOPE`: the footing value is not applied to the stub and no column
cover requirement is emitted at all. `consumerObservationPolicy` is what makes
this enforceable — `MAY_OBSERVE_NOT_COMPARABLE` permits a measurement and
forbids presenting it as a verdict, `OUT_OF_SCOPE` forbids the measurement.
Collapsing the two would let a consumer report an unevaluated property as
passing.

Clear spacing keeps two shapes that are not interchangeable. The code-derived
rule is a pure function of edition, member kind and diameter, so it is exported
per role pair from `minClearSpacingFor` with its clause refs. A per-pair verdict
is not: `classifyPair` needs the collision detector's closest-approach data, and
re-deriving it here would produce a different class than production. So per-pair
entries exist only for the pairs Stabileo actually reported, and that limitation
is stated rather than filled in by guessing.

The twelve conflicts are real
-----------------------------

The assembly reaches COORDINATED with 12 `prohibitedOverlap` conflicts: all
eight dowel hooks turn toward the column centre and interfere at the bottom of
the footing. They are exported as findings under `barCollision` with Stabileo
authority — not smoothed into a pass.

Validation, in two layers
-------------------------

Schema validation runs against the shipped JSON Schema through a validator
restricted to the keywords that schema uses. Its real risk is silent
under-validation, so `assertSupportedSchema` walks the schema first and throws
on any keyword it does not implement: a schema that grows past the validator
fails loudly instead of checking less than it claims. No new dependency.

Semantic validation is where the honesty lives, since a document can be
perfectly shaped and still say something false. It refuses duplicate ids,
unresolving references, an interface plane that is not where the two solids
meet, a box that is not a solid, a non-finite coordinate, an arc that drops its
centre without declaring the approximation, a footing cover requirement scoped
onto the stub, a cover requirement that does not exclude the internal contact,
any containment verdict, and a cross-check invitation where no verdict exists.
It lives in its own module free of JSON imports so the browser journey can run
it against a downloaded file.

Where the exporter refuses
--------------------------

Rather than guess: no column reference, an undimensioned or rotated footing, a
pedestal, a cage with no dowels, a bar whose family cannot be stated, a missing
assembly, and a stale one. The eccentricity refusal is the notable one —
`Footing.eccentricityB/L` is documented as the footing centroid's offset while
`run-footing-design` uses it as the column centre. At zero they coincide; at a
non-zero value they are different places and choosing one would invent where the
column stands.

Fixture
-------

The committed project is now saved as the PRO project it is: `analysisMode`
moves from `3d` to `pro`, so it reopens into the mode that owns the Foundations
editor instead of landing the user in Basico where their footing is unreachable.
The model snapshot is byte-identical. This is what lets the browser journey open
a real committed project through the production file input rather than a
test-only load hook.

A golden manifest is committed alongside it as the shared contract sample, in
its own test file so its revision is the one a first export actually reaches.

Tests
-----

166 new assertions across four suites plus eight browser journeys, all from the
committed .ded through the real WASM solve, the production design commands and
the panel's own buttons. Nothing is injected: no assembly, no cage, no manifest.

Gates: typecheck 490/490 with no new errors, full Vitest 4664 passed, production
build, full Playwright 131 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant