Files
2026-09-07 10:29:11 +02:00

61 KiB
Raw Permalink Blame History

CLAUDE.md — VernierCAD

Parametric 3D CAD. Rust, OCCT kernel via cxx, wgpu render, egui shell, Linux-native.

Product name is VernierCAD; crate prefix, binary, and repo are plain vernier. Use "VernierCAD" in user-facing strings, docs, and the window title. Use vernier everywhere in code.

Read PROJECT_BRIEF.md before your first substantial change. Read MISTAKES.md before debugging anything that smells familiar.

Sequencing is the plan of 2026-09-06, not the phase table below. docs/PLAN_2026-09-06_shapr3d-competitor.md supersedes the phases for what to build next; the phase record that follows stands as the account of what was built and why. Three things it changes. Multi-body is IN scope, as milestone M5 — the brief's "Phase 8+, if ever" line is amended for multi-body only; assemblies, joints, 2D drawings and rendering/materials stay out, as do CAM, simulation, sheet metal and cloud. The acceptance instrument for every milestone is the driven-GUI harness (vernier-drive, milestone M1): agents drive the real shell headlessly through scripted egui input and assert the exported geometry, because nothing on this machine can capture a real window — external screen capture returns transparent frames on this Wayland session, so "we looked at it" is not available as evidence and a script is the only honest substitute. Four new crates are approved and need no further ask: rfd (native file dialogs), directories (XDG paths), image (PNG only, harness goldens) and rayon (parallel tessellation from M5). Anything else is still a separate discussion. Finally, docs/GAP_MAP_2026-09-06.md and its matrix/findings companions are the authority on what EXISTS / is WIRED / is COVERED as of that date — they were verified against the code line by line, which the prose below has not been since it was written; where the two disagree about the present tense, the gap map wins and the prose is stale.

M0 (foundation) landed 2026-09-06; M1 (the driven-GUI harness) landed the same day except its L1 script. The five monoliths are module trees now — vernier-app/src/{app,camera,edit, graphics,headless,picking,preview,scene,server,sessions,reachability}.rs + tests/, compile/, command/, selftest/, shell/ — every move mechanically verified as a pure move, and vernier-app is a library plus a 12-line bin so a harness can link the app core. reachability.rs lists every app source file in SOURCES with a Role and fails by name when a module is added without a row; a command reached only from a test is still unreachable. The GUI persists the naming store: Save writes the document and a <document>.names sidecar atomically (temp + rename beside the destination, the previous sidecar kept as .bak until both names land), Open loads both and refuses a mismatched or missing store loudly, and "open with fresh identities" is a separate explicit command, never a fallback — the store-sidecar-roundtrip selftest check (the 25th) pins it. Cut-extrude is a chip on the extrude card (Edit::ExtrudeSketch { cut }), and a walk that yields more than one solid raises CompileWarning::MultipleSolids until M5 gives bodies names. A cut whose tool is clear of the body raises CompileWarning::CutRemovesNothing — OCCT calls that boolean a success and returns the body unchanged, so the timeline otherwise gains a row labelled "cut" for a pocket that does not exist; both volumes are already in FeatureEvaluation::summary, so the check costs no kernel call. One Deflection type (vernier_kernel::evaluate::Deflection, default 0.1 mm) replaced the app's 0.1 and the CLI's 0.2 bare floats, section curves included; eight selftest artefact digests moved once, deliberately, and the table is in that commit. vernier-cli --bench is the first wall-clock number this project has had: release, this machine — a full build costs 31 ms / 527 ms / 9.7 s at 10 / 50 / 200 features, which is roughly O(N²) in feature count on a fixture whose topology does not grow, while a steady tail edit costs 4.8 / 20 / 143 ms (the M6 memo, now measured); solve_sketch's analyze() costs ~20× the solve it wraps and runs on every compile. CI asserts one wall-clock ceiling and one memo ratio, nothing finer. Every UI test that drives egui goes through vernier_ui::testing::driven (bounded, panic and hang distinguished), the app crate's push/pull drive included. vernier-drive is a new crate: Headless in vernier-app runs the real shell and document server on scripted egui input with one shared offscreen wgpu device (vernier_render::Offscreen), frames digested per index at a fixed 1/60 s clock, picks through the ID buffer, expectations on selection, timeline, document JSON paths and exported geometry, PNG goldens as a coarse backstop with an absolute hot-pixel budget, and a cross-process double run as the determinism check (two Headless in one process share one hash seed and cannot see invariant #1's canonical failure). scripts/drive/m1-smoke.json is the first gate and scripts/check.fish runs it after --selftest — a recorded deviation from invariant #7 (a second headless entry point), taken because the drive needs a Vulkan adapter and a GPU check inside the CLI gate would fail every machine without an ICD. Of the two app bugs the harness found on its first day, a click on an inert egui region (a caption, a panel heading) is not consumed by the release and reaches pick, selecting whatever face is behind the panel is FIXED (the press's verdict is recorded per button and camera::maus_route is the one rule the app and the harness share); the status readout staying empty after a committed push/pull is untouched and still an open card. The M0/M1 adversarial review has been RUN; its findings are landing lane by lane, and the three that live in vernier-app/src/server.rs (a save during a failed compile losing the "saved to" readout, undo on a freshly opened document marking it unsaved, the missing-sidecar refusal promising a fresh-identity open "will build") are still open.

M1 closed later the same day. scripts/drive/l1-bike-light-mount.json drives the bike-light mount through the real shell (99 steps: clamp opened from a fixture because a circle's radius cannot yet be typed — see the gaps below — arm drawn as a swung parallelogram, drafted, a platform sketched on the drafted face, the clamp edited underneath it) and asserts two analytic deltas plus the drafted face's Generated key surviving the edit; its negative control fails at its own platform step with exit 1 and nothing else. The driver gained positional document pointers (/feature:<n>/, because state.features is a JSON object whose keys are allocator ids), volume_delta_from, expect_no_warnings, a "document": "empty" start, and a timeline target filtered by the panel's own band (the tab strip's sketch label sits in the same column). The L1 gate moved once, deliberately: M3-A's auto-constraints square a freehand parallelogram drawn 0.63° off perpendicular, so the arm total went from 11439.407 to 11439.866 and the script now asserts the three inferred constraints by value rather than pretending the clicks alone fix the shape. Gaps the L1 pass surfaced, all open: the value card offers exactly ONE parameter command per selection (card_for), so a circle's radius, a point's lock and a two-point distance have no typeable field; the value card and the gizmo handle float over the face being sketched on and silently eat sketch clicks; no gesture names a feature at creation and the rename button can only rename a row to its current name; the driver cannot send Alt, so a script cannot suppress inference. Still unmeasurable here: the RADV-to-lavapipe hot-pixel budget (no lavapipe ICD).

M3 (sketching parity) landed 2026-09-07, in a serial prelude plus three parallel lanes. The sketch canvas went from eight tools to fourteen — Select, Line, Circle, Arc, Rectangle, Slot, Spline, Point, and now CentreRectangle, Polygon, ThreePointArc, TangentArc, Trim, Split — and every drawing gesture now COMMITS INFERRED CONSTRAINTS (vernier-solver's infer_*, at all four commit sites), with Alt suppressing, and with three filters that are the difference between a guess and noise: never what the sketch states verbatim, never a primitive's curve against another curve of the same primitive, and never one whose trial solve makes the sketch Redundant — the solver's verdict decides, since a slot stating Parallel itself makes a second Horizontal a duplicate no value comparison can see. Sketch points and curves DRAG against the solver (a continuation drag with an applied echo, so a locked rectangle does not move and a free point tracks). The Project tool pulls a face centroid, an edge's ends or an edge's centre into the sketch as references, ProjectEdgeEnds composing three commands behind one gesture; construction geometry is a per-curve flag with a ┄ tile and a dashed drawing, refused as a profile by name; trim and split cut a curve at a click, with the branch table's own refusals (a click on a curve's own end is refused rather than silently duplicating the shared corner); dimensions gained arc radius, a ⌀ diameter reading, RemoveConstraint, and CLICK-THE-NUMBER — every Distance/Radius/Angle constraint paints its value in the viewport and clicking it opens a card that edits it in place; sketch pattern repeats picked curves linearly or circularly; and construction planes landed as SketchPlane::Offset and SketchPlane::Angled (a hinge of two sketch points plus an angle), with RePlaneSketch moving an existing sketch onto either. SketchData.plane has carried a SketchPlane since 2026-09-02; what M3 adds is that a GESTURE chooses it — the base plane buttons, the offset and angle steppers, and two re-plane rows — so the variants stopped being document-layer-only. Document format 24. The three repoints Lane C wired (SetLoftSections, SetSweepOrientation, SetSweepGuide) were the last entries of reachability.rs's NOT_YET, so the built-but-unreachable list is EMPTY for the first time since it was written and its expiry machinery (Blocker, the UI-enum reader, the prose pin) is deleted; what remains is the guard — SOURCES, both directory walks, and Role::Commands over vernier-doc/src/command/, which counts a command composed inside another command's apply() as reached — transitively, since the post-merge review: an impl Command for X block's body is credited only once X is itself reached, iterated to a fixpoint, because the first version chained that text in unconditionally and so credited one hop as a chain (a leaf composed only inside an unreached command, or two commands constructing each other, scored reachable with zero app dispatch). --selftest holds 41 checks, in lane order A · B · C, with enclosure-shell-volume appended after them (the report digest moved once, deliberately; nothing stores it). The L2 gate is now DRIVEN, not only headless (scripts/drive/l2-enclosure.json + its negative control): the enclosure is built through the real shell from an EMPTY document — centre rectangle, locked centre, typed dimensions, an inferred Horizontal, a trim, the construction toggle, a sketch created on a FACE and re-planed onto an offset plane by the ribbon's stepper, a CUT extrude through the cut chip, a linear pattern, and a dimension edited by clicking its number, which propagates through the cut and all four instances. Its volumes are analytic except the shelled body, which has no closed form and takes the oracle's own plausibility band plus exact face and solid counts, with every later operation asserted as an analytic delta from that export. Those deltas are differences and cancel any drift in the shell exactly, so the band alone would have let a kernel change move that body by up to the final STL's ±222 mm³ in silence — which is what the new enclosure-shell-volume selftest check is for: it builds box → draft → shell headlessly and pins 11591.38578318365 at 1e-9, in one place, with the two analytic inputs asserted first so a failure says which step moved. Recorded M3 deviations. A click within DRAW_SNAP_MM (2 mm) reuses the existing point, so Coincident and Concentric are not what a drawing gesture demonstrates — the draw snap subsumes the 0.5 mm inference tolerance, and two of lane A's own tests were passing on a constraint that made the sketch Redundant. The drag echo is cleared on every refusal path in handle, not in the arm, because a step the COMMAND accepts and the COMPILE refuses is rolled back there. A cut click that lands on a point MARK (±1 mm of model space, arms reaching past the corner) used to resolve to the point and be dropped in silence, so the refusal written for a click on a corner was unreachable for exactly those clicks; cut::curve_at leaves the marks out of a CUT pick only. The hover preview is answered as Ask/Clear/Keep with the armed tool inside the question, because arming Select raises no pointer event and the trim cross went on being painted under a tool that picks. Dimension labels are painted for exactly ONE sketch — the one being drawn into, or the one selected in the timeline — because a label is an interactive target that takes the click before the viewport sees it. The re-plane gestures ride ribbon STEPPERS rather than value-card params, because card_for would float an offset field over an empty viewport. There is no show_datums toggle yet, and a datum resolves a click only where the ID buffer has no face. Five gaps the driven gate found, none of them fixed here. (1) A circular bore cannot be cut BY THE NATURAL GESTURE, in two independent places — and this claim was overstated when first written and corrected at the M3 post-merge review, where the first version said "cannot be cut through the GUI at all". That is false: toolbar::actions_for offers Action::Extrude for Selection::Circle, edit::edit_for has an explicit picked-circle fallback carrying cut: shell.extrude_cut, and the registry's needs string for extrude reads "close a profile, or pick a circle" (all three pinned by the_cut_chip_reaches_the_extrude_dispatch in vernier-app/src/tests/m0_store_cut.rs). What is blocked is the gesture: the cut chip is painted only on the EXTRUDE card and a picked circle floats the RADIUS card instead (card_for prefers a Surface::Value row), so the chip cannot be toggled while a circle is picked; and a lone circle never becomes a closed_profile at all, because DocumentServer::extend_chain returns early for a curve with no endpoints — even though vernier_doc::validate_profile accepts a lone circle as closed. The L2 gate's bolt holes are square pockets drawn as closed chains for that reason. (2) ShellState::extrude_cut is a sticky session mode with no state visible outside its own card: written only by the chip, initialised false once, cleared by nothing after Edit::ExtrudeSketch commits — so every later extrude in that session is silently a cut, and that is also the route by which gap (1) can be reached. (3) A trim leaves its old endpoint behind as a free point of the sketch and no gesture deletes a point, so reaching zero degrees of freedom costs a lock on geometry that draws nothing. (4) A dimension label takes a viewport click before the sketch does (measured: a click on one edge selected the neighbouring edge's label). (5) The ribbon is one non-wrapping row and overflows: the four committed M3 drive scripts run at 1600 × 1000 and four of them are red there, but not for one reason and not at one width — measured, m3-b-construction and m3-c-planes go green at 2600, m3-c-sketch-pattern needs 3200, and m3-b-project does not recover at any width: at 3800 it reaches step 23 and fails with "the frame paints 2 copies of 'project ends'", because its $-command-bar workaround becomes an ambiguous text: match once the ribbon button is painted. So three are width-bound and the fourth has a second, independent defect that a width bump does not fix. At 2400 the sketch workspace clips its last four constraint tiles — lock, unlock, construction, remove — which is why the L2 gate first ran at 2600. Retired 2026-09-07 (ribbon collapse): the ribbon now measures its row and collapses groups from the RIGHT into chevron buttons whose popup paints that group's rows (fixed 66 px band, one rule per frame, no hysteresis; the popup closes when a command is taken or the session moves, so a builder's next pick still reaches the viewport); the sketch tool row does the same; and a drive script keeps saying ribbon:<label>, the driver opening the collapsed group itself. Every drive script runs at 1600 × 1000 now, none was widened, and the measurers of every painter are pinned against what they paint (a POLYGON_GAP of 18 that painted 12 was found by that test). The post-merge lanes closed the L1 gaps the same day: a ribbon press on a command that has a card of its own AIMS the card (Enter runs it; a card the sketch withholds still dispatches), so a circle's radius, a point's lock, a two-point distance and a draft angle are typeable; the picked face's card and gizmo are withheld while a sketch is open, so sketch clicks under them draw; features can be named through the card's text field and new ones take counted defaults (sketch 2, extrude 2), with a rename to the current name refused rather than recorded; a sketch point's row shows its SOLVED coordinate (the server keeps its last Compiled); the $ bar's Enter on an empty query runs nothing; the two hover steps are one function. Still open from that pass: a lone circle never becomes a closed_profile (extend_chain returns early for a curve with no endpoints), extrude_cut is a sticky session mode with no indicator, a drive script cannot send a modifier-reading shortcut (egui reads ModifiersChanged, which the driver never emits), and a number card's Enter on an untouched field still dispatches the unchanged value. One spec step the L2 gate does not carry: §8's ⌀ diameter chip click and its readout, which follows from the square pockets (the chip is painted for Action::EditDimension, and a length dimension on a rectangle edge is not a radius). It is not a gate hole — scripts/drive/m3-c-dimensions.json clicks chip:⌀ diameter and asserts the geometry 12 typed as a diameter produces — but the L2 gate does not exercise it.

M4 and M5 landed partially on 2026-09-07, in a close-out pass, and what did NOT land was cut rather than left floating. Three interrupted lanes were finished to green and merged: M4-R (edge and vertex picking with per-compile ids that are never persisted, hover off the ID buffer already rendered, Selection::Edges/EdgeChain/Vertices, and the section cutaway with an offset plane), M4-K (the kernel and façade half of direct modelling: per-face offsets, defeaturing, face splitting by imprinted edges, and the general transforms, each written against a K0 probe number rather than a guess), and M5-1 (the multi-body compile core: BodyId from the document allocator, FeatureTarget replacing cut: bool, the Boolean feature, Compiled.bodies with body_of_face, body-scoped resolution with BrokenReason::BodyGone, and document format 27 on a two-step migration ladder with naming store v5). The gate is 1306 tests, 44 selftest checks and 18 drive scripts, both negative controls failing at their own step. What is NOT here: M4's document glue, gestures and history tools (lanes D, G, H), M5's scene, items panel and gesture wiring (lanes 2 and 3), and the L3 and L4 ladder gates. The sole_body() shims are deliberate and refuse ambiguity by name rather than guessing a body. Everything the close-out removed or left owed is listed in the session's follow-up notes, and one item is worth repeating here because it was a defect on main rather than any lane's: SilencedMessenger mutated a process-global OCCT singleton with no lock, which corrupted the heap in three of six workspace runs and could make a STEP export silently write no file; it is mutex-guarded now.

M2 (naming completeness) is complete as of 2026-09-07; M2-1/2 and M2-6/7 in brief, M2-3/4/5 below. TopoRef exists as a record in the naming store keyed (holder feature, picked id); payloads keep the bare id; fingerprints are persisted (store v3) and OwnerTable::Live reads the owner slot's live set from this compile, so the m1-profile-extrude and m2-revolve gates now break as Dormant and go red under the prescribed mutations (every computed owner table → Absent: m1, m2 AND m4-loft rebind onto lookalikes; the M1 bait scores 0.752, not NAMING.md's hand-arithmetic 0.885 — adjacency is 1/3 on that geometry). Score weights were CALIBRATED and left unchanged, measured: every grid set met the criteria, so the criteria did not choose and the recorded baits favoured production (m2-score-landscape, a pinned JSON landscape). FaceRecord carries a SurfaceAxis (axis, radius or semi-angle, and the material side as an Option — None for a surface of revolution, whose side nobody can measure); a vertex survey ranks VertexKey{faces, sibling} by position and never fails an evaluation; one edge membership rule and one comparator serve both shape_edges and fillet_edge, seams refused by name. EdgeRef { faces in pick order, sibling } and VertexRef are the document's spellings (format 25: Fillet.edges: Vec<EdgeRef>, projections carry edge: EdgeRef, no migration from the pair form), resolved through face records plus an edge snapshot (store v4, edge_refs) that refuses by reason when the pair's shared count changed, ranks tie, or the geometry matches a sibling rank better — a rank is not a proof; a fillet repoint expands its pairs against the body the fillet is APPLIED TO through compile_document_observing. The naming store migrates from v3 up (the M4/M5 decision record reversed refuse-never-translate for the store; older is stepped forward in its own shape, newer or below v3 refused by name). The hole point cap is 62, measured from the u8 slot stride, refused at command, load and compile. The store prunes ON SAVE (to_json_for: feature-present tables, holder-still-names-it face and edge records, slots never pruned by count) and keeps everything in memory for §6 undo/redo; a pruned owner breaks OwnerGone after reload (m2-store-prune, mutation-verified twice). Bench re-take, 2026-09-07, release: a full 50-feature build fell 527 → 96 ms and a steady tail edit 20 → 2 ms, because Option C unification merges the bench fixture's stacked coplanar caps into one lateral face (its triangle count is now 124 at every size instead of growing) — the O(N²) reading of 2026-09-06 was partly the un-unified face count; analyze() is unchanged at 14.5 ms. Owed: VertexRef carries no snapshot yet (a vertex crossing another of its set re-ranks silently, stated); no delete command exists, so OwnerGone-after-prune is reached in tests by forging the document text until M4's history tools land.

M2-3/4/5 (booleans and seams) landed 2026-09-06. Every production boolean runs simplify: true under Option C — a target face that absorbs tool faces of the same operation keeps its id and the absorbed tool faces are Merged into it; two target faces in one output stay Merged{parents} (NAMING.md §4 tier 3) — at a fuzzy of 1e-7 × the target's bounding-box diagonal, both bounds measured (δ* = 5e-7 mm is the smallest gap OCCT leaves open; Bnd_Box tolerance inflation ~1.6× fuzzy against the split-rebind 1e-6 pin on D; the floor holds for D ≥ 10 mm only, an open §10 question), and push_pull_face unifies by construction: 10 → 6 faces on a pushed box with every id kept, m3-mirror re-based 14 → 12 (its solved mirror plane is tilted ~1.5e-10 rad, so the copy's walls miss the unifier's angular criterion — measured, and not something a fuzzy value reaches), m1-smoke.json at faces 6, three selftest checks added (m2-unify-push-pull, m2-flush-wall, m2-seam-encoding; 40 in all once M3 lanes A, B and C's ten landed beside them). Seams have one spelling (t, t); a one-face (t, 0) row is an open shell, out of contract and refused; rank_sibling_keys is the one ranking rule (lane C1 rebases on it) and the M4-3 probe found no operation that generates a face from a seam edge.

Current phase: 5 gate (L2) PASSED 2026-08-09 — headlessly. The l2-jig selftest check builds an enclosure (box → 2° wall draft → 2 mm shell through the top → linear-patterned bolt holes), then stresses naming: a bolt-radius edit propagates through draft, shell, and every pattern instance with zero identity churn; a pattern shrink keeps survivor ids; the save/load round-trip reproduces the part on a fresh kernel; a lost naming store breaks the face references loudly. All volume asserts are analytic. The Phase 5 row items are built to their headless substance, each with exact tests: measure (face min-distance via BRepExtrema, planar angles, mass properties — the measure-section selftest check), sections (plane × body → exact total length + sampled polylines for the future viewport overlay), direct push/pull as a timeline feature (PushPull payload + SetPushPullDistance; the pushed face keeps its identity on the moved surface — G4 fixture + orchestrator test), snapping (vernier_ui::snap: coincidence > axis alignment > grid, deterministic tie-breaks), gizmo math (vernier_ui::gizmo: ray→axis drag parameter, ray→plane intersection, degenerate cases refuse), and the contextual toolbar model (vernier_ui::toolbar: selection → available actions, only shipped operations offered). The egui shell is built and runs (egui is the brief's locked UI decision; egui-wgpu 0.36 resolves to exactly wgpu 30 — no version split): vernier-app hosts a document server worker owning the Document, NamingStore, and kernel (invariant #6 — the render thread never touches them), with the timeline panel, contextual toolbar, and measure/section readouts from vernier_ui::shell composited over the viewport. Toolbar actions apply real timeline features (push/pull, fillet, chamfer, shell, draft, pattern) with placeholder default parameters; failing edits roll back and report. Smoke-verified on RADV. Gizmo handle rendering shipped (commit da9b371, 2026-08-09): picking a planar face paints an arrow along its normal with a draggable handle, wired to vernier_ui::gizmo's drag math. What remains for Phase 5 is genuinely user-gated feel work, the dogfooding backlog: snap-tolerance tuning, sRGB framebuffer color tweak. The Phase 5 code (shell/draft/pattern/push-pull/measure/shell UI) also still owes its adversarial review — next session's first item. Recorded Phase 5 deviations: a shelled-away face continues as its opening's rim ring (recorded OCCT history, probe-verified — a fillet on the old top edge stays on the outer top edge); patterns repeat extrude features only and cap at 64 instances (per-instance naming slots live in a u8). The shell redesign — landed 2026-08-15, from the briggen.dev handoff (Early alpha UI redesign.zip). Four default-dark egui panels became the designed paper-and-ink shell: title bar 44 · workspace tabs 30 · ribbon 66 · body (262px timeline) · history strip 58 · status 26, one user-settable accent, vendored Silkscreen / IBM Plex Mono / IBM Plex Sans, 43 pixelarticons, a $ command bar on ctrl+k, a gizmo-anchored value card, and a settings screen persisting ~/.config/vernier/ui.json. The load-bearing part is registry.rs. Every command is declared once — action, label, icon, workspace, group, params, and why it is off — and the ribbon, command bar, value card and contextual dimming are four views of that one table. toolbar.rs is UNCHANGED and remains the authority on what a selection allows; readiness_for delegates to actions_for and only decides what to render when the answer is no. Adding a feature is pushing one row in. Alpha honesty is structural, not a label: Dispatch is Run(Action) or NotYet(&str), so a button with no operation has no action to name — "ships a button it cannot press" is unrepresentable. Exactly five are marked: revolve, sweep, loft (kernel and document build all three; nothing creates one, because their inputs are picked on the sketch canvas) and join, cut (there is no boolean feature at all — BooleanKind is internal to shell/pattern/cut-extrude merges). The handoff drew revolve live beside extrude; it is not, and a_not_yet_command_is_never_ready is the regression test. Two further handoff-vs-codebase corrections are recorded in the table: hole is offered by picked POINTS, not by a face (so its group caption is "from points", not "on this face"), and the title bar's commands are outside the selection system entirely, asserted rather than assumed. Icons are baked by build.rs, not rasterised — every pixelarticon is axis-aligned rectangles on a 24×24 grid, so nonzero winding at pixel centres is exact and no dependency was added; an icon carrying a curve fails the build by name rather than being baked as the polygon through its endpoints. The four ad-hoc mode colours are gone (sketch cyan, closed-profile green, unsaved amber, session violet): one accent, and the mode is said in words in an ink pill — which is also what lets the accent become a setting. The brand marks are ported, not approximated (brand.rs). The first pass shipped a placeholder tile and no mascot, having read the .dc.html screens — which import Logo and Nib as design-tool components — without opening _ds_bundle.js beside them, where both sources sit in full. Neither is a picture, which is why no vector existed to look for: the logo is live Silkscreen type ([ ] at 1.5em in the accent, nb between them), so it recolours with the accent and an SVG would have frozen today's amber into it; and Nib is a 16×16 character grid animated pixel by pixel, one letter per palette entry, which paints exactly like a baked icon. All eight animations are ported and two are wired — idle on an empty document, waving on hover — on the same principle as the icon set, where all 43 are vendored and about 36 are used. One measured correction: the design's size * 0.34 type ratio is a browser Silkscreen measurement and asks for a 29.6px lockup inside a 26px tile under egui's shaping, so tile_em honours the ratio wherever it fits and scales it by exactly what does not — clipping a logo would have hidden that, and hard-coding a smaller ratio would have been wrong at another size. Recorded deviations: rollback is drawn but not wired (the strip needs an end marker; the caption says so); and the design's own default accent measures 2.97:1 on paper, just under the 3.0:1 floor it specifies and over it on ink — which is why the Settings readout reports both surfaces rather than a verdict, pinned by the_shipped_accent_sits_just_under_the_paper_floor_and_over_the_ink_one. Two egui behaviours are pinned in tests because both fail silently: an Area paints nothing on its first pass, and RawInput::default() has no screen rect — either would make "the card is absent" pass for the wrong reason. Still owed: the six unbuilt settings panes, and an adversarial review of the redesign itself. M6 — memoized recompute — landed 2026-08-11. compile_document no longer rebuilds the whole timeline for an edit below the top of it. Each entry gets a chained FNV digest of its payload's canonical JSON, so one comparison decides a whole prefix; the compiled state after that prefix — body, sketches, warnings and the verdict chain — is held in the NamingStore and resumed from. The verdict chain is restored exactly, not rebuilt, because a face reference below the prefix resolving against a truncated history is invariant #5's failure. What the memo aims at, and what it costs. It retains the state just before the first entry whose digest moved, because interactive editing hammers one feature — a dimension drag is the same entry over and over. So the first edit of a newly-touched feature still pays full price and leaves the memo pointing below it; every edit after that is cheap. The recompute-cost gate asserts the steady state rather than the first edit, and says so, because asserting the first edit is cheap would claim something the design does not do. Measured on a six-extrude stack: a full build and a root edit each mint 11 shapes; a repeated tail edit mints under half that. Mutation-verified — disabling the resume makes the repeated tail edit mint 11, exactly a full build. The ownership rule, which is the dangerous part. The walk releases each body as the next feature consumes it, so intermediate shapes normally live one step. The memo keeps one alive across compiles, making it the only place a façade handle outlives its walk. Two failures follow and neither shows up in a volume assertion: dropping the handle leaks a registry entry, releasing it while the memo names it leaves a dangling one. Both are contained by single ownership — NamingStore::retained names the one shape the walk may not free (release_superseded is the only release path for a consumed body), and NamingStore::discard_prefix is the only thing that ever frees it. recompute-cost gauges live registry entries across four compiles so a leak is a red test. A store loaded from disk carries no memo: the handle would be meaningless in another process.

M5 — STEP import/export — landed 2026-08-11. export_step / import_step in the façade, OcctKernel::export_step / import_step above them, and the m5-step gate. Round-trip fidelity is exact — the sample part's volume and area come back at relative error 0, face count preserved, units MM by construction (invariant #8). M5 is the first milestone to touch build.rs, and it needs three OCCT toolkits the earlier milestones did not: TKDESTEP, TKDE, TKXSBase. OCCT 7.9 renamed the data-exchange libraries, so the older TKSTEP name in the roadmap does not exist here — the set above is what a linked probe found actually resolves. These are native libraries already installed, not new Rust crates. Determinism (invariant #1) needed real work and the fix is load-bearing. OCCT's STEP writer is not deterministic: FILE_NAME carries a wall-clock timestamp, pinned through APIHeaderSection_MakeHeader, and the PRODUCT entity carries a process-global counter that no Interface_Static parameter reaches (write.step.product.name sets the base, not the counter). The façade normalizes that one field out of the written text — counting what it rewrites and returning kUnexpected if the pattern is not what it expects, so an OCCT upgrade must break loudly rather than quietly restoring non-determinism no test would notice. Verified by mutation: disabling the normalization fails m5-step with "two exports of one shape differ". Recorded M5 deviations: an imported body is opaque — a STEP file carries no VernierCAD entity ids and nothing in it is a stable topological identity across two imports, so an import has no parametric history and no feature may reference its faces (invariants #4/#5); it is geometry to look at, measure, tessellate and export again. There is no timeline feature and no shell wiring for either direction yet — both are kernel-level capabilities with a headless gate, and import in particular has no honest place in the feature DAG until the opacity question above is answered. And the STEP calls run under a scoped messenger guard, because OCCT narrates to stdout and vernier-cli's JSON report lives there (see MISTAKES.md). M4 — loft — kernel half landed 2026-08-10; the document half is below. make_loft → evaluate_loft lofts N ≥ 2 closed sections into a ruled solid, keyed by a new provenance kind: ProvenanceKey::Bridged { sources: BTreeSet<EntityId>, sibling }, because a ruled span is bounded by the corresponding curve of two adjacent sections and no single curve owns it (NAMING.md's loft amendment). It is added, never a widening of Generated, and its dormancy is conclusive — its anchors are sketch curves, which resolution never rebinds. Caps take the reserved LoftStart (13) / LoftEnd (14) roles and, unlike the revolve's, are mandatory. Two measured findings drive the façade and are commented as load-bearing at their call sites: OCCT's Generated(edge) is column-keyed, not span-keyed (GeneratedFace is the span-resolving accessor), and CheckCompatibility(true) is what makes the lower/upper pairing knowable at all — with it off a section drawn from a different corner builds a twisted valid solid of 2666.67 where the answer is 8000. New façade status 7 kSelfIntersecting (BRepAlgoAPI_Check), which catches the class BRepCheck_Analyzer calls valid; applied to the loft only for now. Recorded M4 deviations: evaluate::LoftSection::offset_mm places each section's Z = 0 sketch geometry at a height — a stopgap until sketch planes land, because every sketch lives at Z = 0 and a loft between two coincident sections is one OCCT refuses outright; sibling ships at 0 and is unexercised (the façade fails closed on a collision, and assemble_source has no centroid ranking, so a future collision must lift the ranking loop out of assemble_from_history, not copy it); lofts are ruled only, since a smoothed loft's column is one face spanning every section and adding a section would re-mint all of them. M4 — sweep — kernel half landed 2026-08-10; the document half is below. make_sweep → evaluate_sweep sweeps a closed profile along an open or closed path (BRepOffsetAPI_MakePipeShell), and Bridged carries it unchanged: a side face is the product of one profile curve and one path segment, so sources is that pair — the second consumer is the evidence the key shape was right rather than loft-specific. Caps take the newly declared SweepStart (15) / SweepEnd (16) roles, optional on the revolve's rule (a closed path closes on itself and has none; the two ids go dormant and revive when it opens). Three measured findings are commented as load-bearing at their call sites: SetTransitionMode(RightCorner) — the default Transformed builds 768 at a 90° corner where the mitre is 1920, and RoundCorner adds corner faces belonging to no (profile, path) pair; the attribution is the unique element of Generated(profileEdge) ∩ Generated(pathEdge), since MakePipeShell has no two-argument accessor; and cap tokens are read as FindIndex, never IsNull (on a closed path both are non-null and neither is a sub-shape of the result — the M2 full-turn situation verbatim). BRepAlgoAPI_Check (status 7) now guards the sweep too, and this is where it earns its keep: a circle of radius 5 swept round a bend of radius 2 is a valid solid with the exact Pappus volume of a tube that passes through itself. Recorded sweep deviations: the path is read in the world XZ plane and must start at its origin — a stopgap retiring with sketch planes, structural for the revolve axis's reason (a path in the profile's own plane sweeps a valid solid of zero volume, and OCCT otherwise translates the spine to the profile silently); a full circle is not a legal path (it has no start point, so OCCT's seam parameterization would decide the placement — drawn as arcs the seam is the sketch's, and the torus is unchanged); the spec's arc-radius pre-check for the self-intersecting pipe is not implemented, because it would need a model of OCCT's frame transport and BRepAlgoAPI_Check already refuses that class by name; evaluate_sweep takes path_closed as a parameter rather than deriving it, because closedness is a statement about the chain's point identity that coordinates only corroborate. M4 — the document and shell half — landed 2026-08-10. FeaturePayload::Loft { sections, cut } (each LoftSection naming its own sketch, chain and height) and Sweep { profile_sketch, profile, path_sketch, path, cut }, with AddLoft / SetLoftSections and AddSweep / SetSweepProfile / SetSweepPath, the two orchestrator arms, and the save format at 8 — one bump per payload, since a build that knows lofts and not sweeps is a real generation. validate_loft and validate_sweep (in vernier-doc) state every rule the façade states, in document terms, and both commands and the orchestrator run them: a forged save never met a command. The M2/M3 lesson is carried — commands validate solved sketch geometry, and the sweep is where that is load-bearing rather than cautionary (where a path starts and whether its first move leaves the profile plane are coordinates the solver moves), so the_sweep_is_validated_against_the_solved_sketch skews authored against solved in both directions. The naming store now carries NAMING_STORE_VERSION and refuses a version-less store by name: M4 is the first change to the persisted key vocabulary since the store was written, so "unknown variant Bridged" would otherwise have been the whole diagnosis. Timeline order is enforced in one place, resolve_input, which source_solids (pattern, mirror) and the two new resolvers share — the M3 mirror-plane bug has one home now instead of three. Recorded deviations of this half: a loft section may name a sketch later in the timeline only to be refused (SetLoftSections can be handed one, AddLoft cannot), while a sweep has no such command-level check because its two sketches are fixed when the feature is appended — the orchestrator enforces the order for both, and a doc-side branch nothing can reach was left out rather than written untestable. Two claims here are RETIRED, corrected 2026-09-02 (card c66b4c51). This entry said no toolbar button creates either feature; registry::COMMANDS now dispatches Action::Revolve, Action::Sweep and Action::Loft, plus Action::AddLoftSection, so all three are creatable from the ribbon and not only editable. It also said the m4-loft / m4-sweep gate checks were still owed; both run in the CLI selftest and both pass. What stands is the reasoning behind the original choice: a loft is edited by repointing its sections and a sweep by repointing its chains, and a picked chain is the sketch canvas. M2 — revolve — landed 2026-08-10. A closed line/arc chain now sweeps about an in-plane axis through one general-profile revolve (make_profile_revolve → evaluate_revolve), and it reuses the prism's identity model unchanged: side faces keyed Generated{sketch curve}, caps on the reserved RevolveStart/RevolveEnd roles (NAMING.md §9). What is new is that a revolve's face set is a function of the angle and of where the axis lies, so two of its keys are absent by construction rather than by death — the substrate had not met that before. The M2 gate (m2-revolve) is what makes the claim falsifiable: a turned spool (a 30 × 8 blank with a half-disc groove of radius 5 at x = 15, revolved about the X axis, which is also the profile's own bottom edge) hits its analytic volume, surface and centroid at both 2π and π/2 — Pappus, valid precisely because the axis lies in the profile plane — with the per-curve surface column asserted face by face as (surface class, centroid, area, id), since the two cylinders are area-identical and a total alone would not catch a swap. Both absences are asserted positively: Generated{L_base} is never minted (and the orchestrator says so as a CurveOnAxis warning), and a full turn mints no cap keys at all, while the 2π → π/2 → 2π → π/2 cycle on one store proves the cap ids are minted once, go dormant at the full turn and revive identically. A 30 → 36 lengthening keeps the whole live id set, compared as a set rather than as "the compile succeeded". Two profile swaps break loudly, the bait being a coplanar same-normal annulus at 93.75% of the old area and centroid distance exactly zero (≈0.975 under NAMING.md §5's weights). Verified by mutation: attributing side faces by chain position instead of by curve fails only m2-revolve; letting the resolver rebind onto the nearest lookalike fails m2-revolve and m1-profile-extrude together, which is correct — both gates pin the same resolver contract, so a gate that stayed green under that mutation would be the broken one. The save format is at version 5. Recorded M2 deviations: revolves are un-patternable (Pattern repeats extrudes only; pinned by pattern_refuses_a_revolve_source and by the toolbar, so it is a refusal rather than a surprise); a full turn that pinches the axis is refused — history is complete and correct but the solid is not valid (BRepCheck_NotConnected), and an invalid solid propagates into every downstream boolean and into tessellation. The predicate was measured, not reasoned: 21 profiles probed against OCCT say a full turn is invalid iff the axis-touch set leaves two or more gaps in the ring of side faces and at least one is an isolated point. Both obvious guesses are wrong — a single isolated touch builds fine (a cone, a horn torus), and an on-axis curve does not rescue a pinch elsewhere — and every partial sweep is valid whatever the profile, right up to 2π − 1e-6, because the caps make a pinch's neighbourhood a disc rather than an annulus. RevolveError::PinchesAxis mirrors it at the command boundary and names the point to move; the façade's BRepCheck_Analyzer is now the backstop rather than the primary gate. That check is still a deliberate asymmetry with make_profile_prism, which does not validate its result (adding it could turn an accepted golden red) — but the doc-layer mirror weakens the argument for leaving it, since one could be written for the prism too. Raise it at the next adversarial review rather than quietly closing it; AXIS_CLEARANCE_MM = 1e-6 and the cap-separation guard are scale-absolute, defensible because the Precision::Confusion() they track is itself absolute, but they join fuzzy_mm: 0.0 on the tolerance-calibration item rather than counting as settled; and Shell cannot offset this spool through its toroidal opening — measured during the gate, kOperationFailed at 2.0, 1.0 and 0.5 mm, which is why the gate's plain control is a fillet. That last one is a real capability hole for turned parts with grooves and is worth a look before shell reaches dogfooding. M1 — general 2D profiles — landed 2026-08-09. Sketches hold lines, arcs and circles under eleven constraints through the solver bridge, and a closed line/arc chain extrudes through one general-profile prism (make_profile_prism → evaluate_prism): each side face is keyed Generated{sketch curve} and the two caps take the reserved PrismStart/PrismEnd roles, so a face's identity is a function of which curve drew it rather than of enumeration order. The M1 gate (m1-profile-extrude) is what makes that claim falsifiable: a locked obround (2 lines + 2 arcs) extrudes to its closed-form volume, area and centroid; each of the four side faces is confronted with the table's Generated{curve} entry and each cap with its role; a push/pull on one wall survives the slot being lengthened 20→25 mm with zero identity churn; and two control documents carry the other half — swapping the extrude onto a chain that drops a curve breaks that curve's face reference loudly, including when the new chain offers a coplanar, same-normal wall covering 80% of the old one's area. Verified by mutation: attributing side faces by chain position instead of by curve, and letting the resolver rebind onto the nearest lookalike, each fail only this check — l0/l1/l2 pass through both. Both stopgaps are now retired — the rectangular Box payload (enclosures are line rectangles; the l2-jig builds one) and the circle→cylinder fast path (one construction, one identity: the same solid authored two ways must not carry two identity schemes, invariant #5). Retiring them cost a one-time id churn for circle extrudes and put the save format at version 4. A circle authored as two half-arcs is accepted: a two-curve loop's two traversal orders build the same wire and the same per-curve keys, so the choice is arbitrary rather than undecidable — ProfileError::AmbiguousJoin still fires for three or more curves meeting at one node pair. Recorded M1 deviations: tangency is deferred, so the gate's profile is locked rather than dimensioned — an obround with both arc centres on one horizontal axis is mirror-symmetric, and every non-tangency constraint set that expresses it leaves the two Horizontal rows linearly dependent (dof 1 + Redundant), so the gate drives its edit with SetSketchLock; locking an arc's centre and both endpoints states its radius twice, which the gate accepts as one honest Redundant warning while refusing any under-constrained one; inner loops are deferred — one profile is one outer chain, so a washer is still an outer extrude plus a bore cut, and widening later means profile: Vec<Vec<EntityId>> plus an outer/inner rule; the sketch plane is still XY at Z = 0 with +Z extrusion (sketch planes on faces are a separate milestone) — RETIRED 2026-09-02: SketchData.plane carries a SketchPlane, one of World(PrincipalPlane), Offset or Face. How many of those the app can reach is FUSION_LOG's problem to rank, not this record's; and every extrude/pattern merge still passes fuzzy_mm: 0.0, which is deferred with known risk — a rectangular profile flush with an existing wall is exactly the near-coincident case that tolerance exists for, and line profiles make that geometry ordinary where circles never produced it. Changing the value moves every existing golden, so it is an M2 item with a scale-relative default; there is a pointer beside the zero-volume trap in MISTAKES.md. Two decisions the M1 gate surfaced are owed before the work that makes them live, and both are recorded where they will be found: the gate's loud break is loud only because resolve_face_ref passes fingerprint: None — scored by NAMING.md §5's weights the coplanar lookalike lands at ~0.885, over the 0.85 orphan threshold, so the day TopoRef fingerprint persistence lands this gate goes red and a reference silently rebinds (NAMING.md §10 has the analysis and a proposal: a key that went dormant in its owner's own live table is affirmative death, not an orphan). And merge_into_body passes simplify: false, so a push/pull leaves caps coplanar-but-not-unified with the body's — the gate's third control shows 6 faces becoming 10. That is correct and deterministic, but it means direct push/pull accumulates coplanar slivers on a real part, each a separately pickable identity. That deadline has passed, noted 2026-09-02: the gizmo drag IS wired (vernier_ui::gizmo::plane_drag_point in vernier-app, handle along the picked face normal), so push/pull is already the common edit and this decision is overdue rather than pending. Phase 4 gate (L1) passed 2026-08-09: the l1-fillet-edit selftest check builds the spacer, fillets its top outer edge (referenced as the adjacent-face identity pair — edges carry no ids), edits the outer radius 10→12, and recompiles: the fillet re-finds its edge, keeps its identity, and regenerates; ghost references break loudly. A sketch edit propagates through a fillet without exploding. Deferred from the Phase 4 row (FUSION_LOG-driven backlog): shell, draft, patterns, mirror, dedicated hole feature; chamfer is implemented via the fillet machinery but owes a fixture. An interactive sketch canvas remains the largest gap between the pipeline and real dogfooding. ALL RETIRED 2026-09-02 (card c66b4c51) — the largest stale block in this file. FeaturePayload carries Shell, Draft, Pattern, Mirror and Hole; chamfer has two selftest fixtures of its own (chamfer-edit, chamfer-two-distance-edit); and the sketch canvas exists, with eight SketchTool arms. The largest gap now is not a missing canvas but a missing set of gestures: see FUSION_LOG's reachability section, nine built commands that no gesture reaches. Phase 3/4 adversarial review done 2026-08-09 (17 confirmed findings, all fixed): kernel entity ids now come from the document allocator (the id-space unification the Phase 3 deviation owed — a lost naming store breaks references loudly instead of rebinding them), stores are validated against their document at compile entry and reject duplicate tables, degenerate radius dimensions are rejected at the command and the residual, FromEdge sibling ranks are centroid-ordered, compile releases superseded façade shapes, and under-constrained sketches surface a compile warning. Recorded deviations still open: NameTables live in the NamingStore beside the document and are NOT undo-snapshotted (NAMING.md §6 asks for that; append-only tables + dormant-key revival make undo-then-recompile reproduce ids meanwhile); dead-feature tables are never pruned (unbounded but collision-free growth); a store spliced against a different document whose allocator happens to sit above the store's ids is caught only by reference resolution, not by an identity handshake (a document identity would need entropy — raise before adding one; determinism invariant #1). Phase 0 (foundation) and Phase 1 (OCCT façade, primitives, transforms, booleans with full history, naming substrate per NAMING.md, tessellation, STL export, viewport with ID-buffer picking) completed 2026-08-08. Phase 1 gate held: box − cylinder renders; a click picks the face (proven headlessly via the ID-buffer readback test). Deliberately deferred from Phase 1: the vendored-OCCT submodule build (the risk table's own advice — do not fight the vendored build during development; system OCCT serves both feature configurations until it lands). If asked to build something from a later milestone of the plan (or from a phase the plan has not reached), say so and confirm before starting.


Hard invariants

Violating any of these is a bug even if tests pass.

  1. Determinism is mandatory. Same input → byte-identical output. No HashMap iteration where order reaches output; use IndexMap or sort. No time, no RNG, no address-dependent ordering, no float accumulation whose order varies with thread scheduling. If you cannot make an operation deterministic, stop and raise it.

  2. vernier-occt-sys is the only crate permitted unsafe. Every other crate carries #![forbid(unsafe_code)]. No exceptions without discussion.

  3. Every kernel operation returns a history delta. An operation that returns a bare shape is incomplete. See FeatureEvaluation and VerdictMap in vernier-kernel (OpResult was the Phase 1 sketch of this and was removed in M0, 2026-09-06).

  4. Entity identity never derives from index or pointer. EntityId is allocated by vernier-doc and mapped through the façade. If you find yourself writing shape.face(3) in Rust, you have made a mistake.

  5. Never silently resolve an ambiguous topological reference. Below the fingerprint confidence threshold, mark the reference broken and surface it. A wrong-but-plausible face assignment is worse than an error, because the user ships the part.

  6. The render thread never blocks on the kernel. Kernel work runs on a worker. The viewport orbits at refresh rate during an eight-second recompute or the design is wrong.

  7. No feature is finished without a headless test. If it cannot be exercised through vernier-cli with no display, it is not done.

  8. Units are millimetres, floats are f64, angles are radians internally. Degrees only at the UI boundary.

  9. Target GPU is AMD RX 7800 XT (RDNA3, Mesa/RADV). Never write NVIDIA-specific code or suggest CUDA. Validate against Vulkan via RADV.

  10. Scope is Section 1 of the brief. No CAM, no simulation, no cloud, no sheet metal. Decline and say why.

Crate map

vernier-occt-sys/   cxx bridge + facade.cpp        [unsafe allowed]
vernier-kernel/     OcctKernel, FeatureEvaluation, EntityId, naming substrate
vernier-solver/     2D constraint solver           [no kernel dep]
vernier-doc/        feature DAG, undo, serde       [no kernel dep]
vernier-tess/       mesh → GPU arrays + STL   [no cache; see the crate doc]
vernier-render/     wgpu, ID-buffer picking
vernier-ui/         egui shell: theme, icons, chrome, registry, timeline
vernier-cli/        headless driver
vernier-app/        thin main()

Dependencies point downward only. vernier-solver and vernier-doc importing vernier-kernel is a design failure, not a convenience. If a task seems to require it, the abstraction is wrong — raise it rather than adding the dependency.

Commands

Shell is fish. No bash heredocs. No export VAR=x — use set -x. Write files with printf or multi-line echo, not cat <<EOF.

# fast iteration against system OCCT
cargo build --features system-occt
cargo run -p vernier-app --features system-occt

# headless — the primary verification path
cargo run -p vernier-cli -- --selftest
cargo run -p vernier-cli -- --selftest --json-report /tmp/report.json
cargo run -p vernier-cli -- --l0-spacer /tmp/spacer.stl      # part + document + .names sidecar
cargo run -p vernier-cli -- --export-sample /tmp/out.stl
cargo run -p vernier-cli -- --sketch-debug rectangle /tmp/sketch.svg

# full check, must pass before any commit
cargo fmt --check; and cargo clippy --all-targets -- -D warnings; and cargo test --workspace

# reproducible build (vendored OCCT — lands at the end of Phase 1; until
# then this also links the system OCCT)
cargo build --release

If OCCT headers are somewhere unusual: set -x DEP_OCCT_ROOT /usr/include/opencascade

Testing

Golden-file geometry tests are the backbone. Every kernel operation gets a fixture under tests/golden/. Assert on:

  • topology hash (counts + adjacency signature, stable across runs)
  • volume, surface area, centroid — f64, tolerance 1e-9 relative
  • history deltas (VerdictMap): which entities were generated, modified, deleted

Not on OCCT internals, not on tessellation vertex counts, not on floating-point equality.

Naming tests are adversarial by design. For every feature that references upstream topology, there is a test that edits the upstream sketch in a way that changes face count, and asserts the reference either survives correctly or is marked broken. Never that it "resolves to something."

Render tests use screenshot diffs via lavapipe in CI. Threshold-based, not exact.

Solver tests use property tests: a fully-constrained sketch has zero DOF and does not move under drag; an over-constrained sketch reports conflict rather than converging to garbage.

A test driving UI code through a shared non-reentrant lock must be bounded, never bare. Spawn the drive on its own thread, gate the result on a recv_timeout, and separate a panic (RecvTimeoutError::Disconnected) from a genuine hang (RecvTimeoutError::Timeout) in the failure message — see dragging_the_gizmo_does_not_deadlock_the_ui_thread in vernier-ui/src/shell/tests.rs, and MISTAKES.md's 2026-09-02 entry for the incident and the pre-existing tests still owed a migration. The reason is BOUNDING, not diagnosis: a bare synchronous call to such code (egui's Context today; any future non-reentrant lock reached from application code) still fails visibly in a debug build, but hangs the calling thread forever in release with nothing to bound it — and because cargo test's default harness shares one thread pool across tests, one hung test wedges the entire invocation with no red and no exit code. The Disconnected/Timeout split does not by itself prove which of panic/hang/deadlock fired (card b7531df4) — only that whichever one did is now finite and visible. Print the elapsed time in the failure message so the reader is not left guessing.

Conventions

  • thiserror for library errors, anyhow only in vernier-cli and vernier-app
  • No unwrap() or expect() outside tests and main()
  • tracing for logging, never println!
  • IndexMap over HashMap anywhere ordering could reach output
  • Public API on each crate stays narrow — internals pub(crate)
  • Doc comments on every public item, with a # Panics section if it can
  • One logical change per commit, conventional commit messages

OCCT bridge rules

  • The C++ façade owns a shape registry; Rust holds opaque u64 handles
  • Handle<T> never crosses the FFI boundary. Ever.
  • Every façade function that creates or modifies a shape also returns its history
  • All OCCT exceptions are caught in C++ and converted to result codes. An OCCT exception crossing into Rust is undefined behaviour and will eventually corrupt something quietly.
  • facade.hpp is the complete API surface. If a new OCCT capability is needed, it gets a new façade function — not a leaked type.

Working agreements

  • When an invariant above seems wrong for the task at hand, say so and stop. Do not route around it, do not add a local exception, do not #[allow] it. The invariants are load-bearing; if one is wrong it needs changing here, not bypassing there.
  • When a bug costs more than an hour, add an entry to MISTAKES.md: symptom, root cause, the wrong hypothesis that wasted the time.
  • Explain why alongside any non-obvious change. Directional effects, not just the diff.
  • Prefer showing a small example over describing the approach.
  • Push back on bad instructions. Being agreeable about a design error is not helpful.
  • Do not add dependencies without asking. The dependency tree is a maintenance liability and this project outlives most crates.
  • Do not write speculative abstraction for phases that do not exist yet.