Merge main into the reliability branch: port the UX work, repair the gate

Merges c0d74cb (the 2026-09-08 audit docs and Codex's UX commit: live sketch
drawing preview, gamma-space UI compositing, HiDPI viewport scale, gizmo axis
latch, tab-pin release) onto the reliability branch's structure, and repairs
what the branch's own drive suite had been silently failing since the
mechanical-modelling batches. docs/MERGE_2026-09-10_main-into-reliability.md
is the full record; the short form:

Ported: DrawingState (serialised across the process codec, snapping with the
click's own camera-derived radius, None under Alt), gamma-space compositing in
the window and the offscreen harness, the HiDPI scale, the axis latch, the tab
pin, distance_mm at 0, main's headless and shell tests.

Dropped: Edit::ReplacePushPull (the branch's acknowledgement barrier already
makes a preview replacement atomic) and the rule hiding the extrude card when
a profile closes (sixty accepted steps type into the branch's editor card,
which sits bottom-right; an open UX question for the audit).

Fixed, each measured through the harness first:
- every value card shares one egui Area id, so a card that changed size was
  placed with the previous card's height and jumped 25 px a frame later; a
  click aimed from that frame missed the field and the typed number was lost,
  and an Enter with no focused field ended the sketch. The card redoes its
  frame (quiet frames only) and the driver waits for a stable rect.
- a picked line floated the extrude editor instead of its length card:
  extrude is Ready for a line and registered before length. Demoted for
  dimension selections only.
- hiding a body, undoing metadata or previewing a boolean sent a support-face
  list the face metadata contradicted; the codec abandons the worker on that.
  Built from one source and aligned at the reply exit.
- a boss drawn on a face after a cut went into the body: the card's defaults
  kept the cut's direction. Defaults keep numbers, not modes.
- the shell-thickness card painted no field: the thin-wall row rule matched
  every "thickness" key.
- the derived extrude editor floated for picked sets no profile route accepts
  (a construction curve, a degenerate pair); it now floats only for a set the
  closed or thin-wall route takes, a ribbon press still aims it.
- thirteen tests the batches had left stale (one-field extrude cards, the
  arc's offers, the role count, the clipped-row message, the README's check
  count, the hole refusal's layer) repaired to the batches' own behaviour.
- seven scripts asserted document format 35 (the code is at 40), two asserted
  a pre-extents extrude payload; all five PNG goldens regenerated on RADV.

Gate: 81 of 86 drive scripts pass on RADV, all 81 deterministic across two
processes; the other five are the four negative controls at their designed
steps and the unfinished planar-lettering (batch 5, still unregistered).
Workspace tests, clippy and fmt green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 11:30:38 +02:00
co-authored by Claude Fable 5.1
52 changed files with 2227 additions and 94 deletions
+776
View File
@@ -0,0 +1,776 @@
# AGENTS.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`.
```fish
# 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.
+24
View File
@@ -0,0 +1,24 @@
# Paused for SSD replacement — 2026-09-08
User explicitly requested that work pause. Do not resume agents or implementation until the user asks.
Worktree: target/worktrees/reliability
Branch: codex/reliability-and-function-verification
Last committed checkpoint: f56bb172dc258f85188edf302a81c8a7397c4602
Main remains b3f661fac0282dd5d02c5fc632b4343ca9ab18be. No main integration or pending dependency changes were authorized/applied.
Current changes are UNCOMMITTED. Selected-profile repair passes focused libraries/review and actual Undo/Open workflows, but the combined workspace gate is RED: cut-then-boss step 27 cannot find chip:cut. See retained vernier-profile-final-workspace.log. Do not claim this candidate is accepted or commit it before correcting the regression and rerunning the combined gate. Formatting and workspace/all-targets Clippy pass. Workspace gate exited 101 before release Headless/build/selftest stages.
Agents were interrupted at the user's pause request. finish_persistence had just been assigned the Cut regression; review_persistence was asked to inspect it independently. drive_solid_builders completed its control review. No new dimension repair has been implemented.
Next after resume: inspect current source versus this snapshot, correct Cut-card regression without weakening cut-then-boss script; incorporate two point-distance review corrections (notes say Ctrl-pick but second click is plain; add exact authored point3=[10,-0] native assertion). Re-run final combined gate and 9 release cross-process scripts. The planned /tmp/vernier-profile-final-drives.py has not run. Refresh frozen source hash inventory after edits. Commit only a reviewed, verified feature-branch checkpoint with a new exact manifest; old /tmp/vernier-checkpoint.py still guards the prior cba7382 parent and must NOT be reused unchanged.
Then natural Length/Distance/Angle creation-on-existing-dimension repair from docs/NATURAL_DIMENSION_REPAIR_PLAN_2026-09-08.md. Three actual red probes exist and are included in the pause archive. Signed Angle policy suggested by independent reviewer: reversed picks negate canonical value while preserving original row references; new creation range [-180,180]; existing finite out-of-range targets retain full finite display/edit domain, exact untouched bits, and no implicit normalization. Review/final implementation still needed.
Full R1-R14 goal remains open; native worker process containment is not implemented. The broad project ledger is docs/IMPLEMENTATION_2026-09-08.md. App serde dependency edges and main integration remain pending prior approval; no need to reask until needed. Both AGENTS.md files are user-owned and must not be edited/staged/deleted.
The pause archive at project root includes all current modified/new task files, a binary Git diff, branch/HEAD metadata, and selected /tmp evidence. It is a recovery supplement, not a complete repository backup. Copy the entire VernierCAD directory INCLUDING .git and target/worktrees/reliability to the replacement disk before discarding the old disk. The archive itself remains on this disk until copied elsewhere.
## Explicit resume
User explicitly resumed on2026-09-08 after SSD replacement. Original HEAD and uncommitted file set verified;24 archived /tmp evidence files recovered. New agents repair_profile_cut and review_profile_cut resumed the Cut regression, with prepare_dimension_controls preparing the next bounded verification plan. The pause instructions above are the historical stop record.
+4 -1
View File
@@ -65,8 +65,11 @@ For a source checkout, use the workspace's normal Rust commands:
```sh
cargo test --workspace
cargo run --release -p vernier-cli -- --selftest
cargo run --release -p vernier-cli -- --selftest # 45 end-to-end checks, JSON report on stdout
```
`--selftest` runs 45 end-to-end checks headlessly and writes a JSON report; the
driven-GUI gate in `scripts/check.fish` runs every drive script after it.
The OCCT development libraries, a C++ toolchain, and a Vulkan driver are needed
for a full build and viewport run.
+108 -12
View File
@@ -29,7 +29,7 @@ use crate::camera::{
use crate::cut;
use crate::edit::{Edit, edit_for, silent_refusal_readout};
use crate::graphics::{Graphics, gpu_parts, viewport_px};
use crate::preview::{gizmo_frame, live_after_refusal_filter};
use crate::preview::gizmo_frame;
use crate::scene::{FaceMeta, Scene, ServerReply};
use crate::sessions::add_loft_section;
@@ -717,10 +717,7 @@ impl VernierApp {
// frame, so the round trip is coalesced rather than one per
// event. `None` outside sketch mode and off the plane: the
// preview then asks nothing at all.
self.hover_at = match (self.sketch_mode, self.input.cursor) {
(Some(frame), Some((cx, cy))) => self.sketch_uv(cx, cy, frame),
_ => None,
};
self.refresh_drawing_hover();
let height = crate::graphics::viewport_px(self.graphics.as_ref(), self.offscreen.as_ref())
.map_or(1.0, |[_, height]| f64::from(height.max(1)));
kamera_drag(
@@ -733,6 +730,14 @@ impl VernierApp {
);
}
/// Mouse hover uses the same plane projection in the desktop and driver.
pub(crate) fn refresh_drawing_hover(&mut self) {
self.hover_at = match (self.sketch_mode, self.input.cursor) {
(Some(frame), Some((cx, cy))) => self.sketch_uv(cx, cy, frame),
_ => None,
};
}
/// A viewport click while sketching: pixel -> ray -> the sketch plane.
///
/// The caller `sketch_click` was written for and did not have. Everything
@@ -1356,6 +1361,15 @@ impl VernierApp {
| egui::Event::Cut
)
}));
// Projections feed egui, whose coordinates are logical points.
// The camera aspect is unchanged by this uniform scale.
let scale = raw_input
.viewports
.get(&egui::ViewportId::ROOT)
.and_then(|v| v.native_pixels_per_point)
.unwrap_or(1.0)
* self.egui_ctx.zoom_factor();
let viewport = [viewport[0] / scale, viewport[1] / scale];
// Push/pull gizmo anchor: exactly one planar face picked.
let aspect = f64::from(viewport[0]) / f64::from(viewport[1]);
let view_proj = self.camera.view_proj(aspect);
@@ -1501,12 +1515,9 @@ impl VernierApp {
if closing || self.document_barrier != 0 || self.worker_failed {
ui.disable();
}
response = shell::show(ui, &self.scene, &mut self.shell);
dimension_click = shell::dimension_overlay(ui, theme, &dimensions, picked_dimension);
crate::body_tools::split_overlay(ui, &self.shell, &view_proj, viewport);
if let Some((pivot, axes)) = body_projection {
shell::body_tools::move_overlay(ui, &mut self.shell, pivot, axes);
}
// THE GIZMO IS BUILT BEFORE THE SHELL, so its handle takes the
// press before a panel can, and the shell's own hit test is
// what decides whether the pointer is over egui at all.
if let Some((face, anchor, tip)) = gizmo {
let drag = shell::gizmo_overlay(ui, anchor, tip);
if let Some(mm) = drag.live_mm {
@@ -1516,7 +1527,10 @@ impl VernierApp {
// release, so it read as a stale, untrustworthy
// number for the whole gesture.
self.shell.distance_mm = mm;
live = live_after_refusal_filter(face, Some(mm));
if self.shell.card.action == Some(Action::PushPull) {
self.shell.card.buffers = vec![format!("{mm:.2}")];
}
live = Some((face, mm));
}
if let Some(mm) = drag.committed_mm {
self.shell.distance_mm = mm;
@@ -1524,6 +1538,85 @@ impl VernierApp {
}
released = drag.released;
}
response = shell::show(ui, &self.scene, &mut self.shell);
if ui.ctx().is_pointer_over_egui() {
self.hover_at = None;
}
dimension_click = shell::dimension_overlay(ui, theme, &dimensions, picked_dimension);
crate::body_tools::split_overlay(ui, &self.shell, &view_proj, viewport);
if let Some((pivot, axes)) = body_projection {
shell::body_tools::move_overlay(ui, &mut self.shell, pivot, axes);
}
// THE PREVIEW SNAPS WITH THE CLICK'S OWN RADIUS: the same
// camera-derived tolerance `sketch_click_at` sends, from the same
// physical cursor, so the rubber band lands where the click will
// — and Alt (or snapping off) turns both off together.
let snap_tolerance_mm = if self.input.modifiers.alt_key()
|| !self.shell.settings.appearance.snapping
{
None
} else {
self.sketch_mode
.zip(self.input.cursor)
.zip(viewport_px(self.graphics.as_ref(), self.offscreen.as_ref()))
.and_then(|((frame, (cx, cy)), [width, height])| {
crate::camera::draw_snap_tolerance_mm(
&self.camera,
frame,
[cx, cy],
[f64::from(width), f64::from(height)],
)
})
// No surface to measure a pixel on (a frame driven with a
// hover but no cursor, which only a test does): the snap
// module's own default, so the label logic stays
// exercised rather than silently off.
.or(Some(vernier_ui::snap::SnapSettings::default().tolerance_mm))
};
if let (Some(source), Some(at), Some(frame)) =
(&self.scene.drawing, self.hover_at, self.sketch_mode)
&& let Some(preview) = source.preview(
at,
self.shell.tool,
self.shell.polygon_sides,
snap_tolerance_mm,
)
{
let project = |uv| {
vernier_ui::gizmo::project(&view_proj, frame.to_world(uv), viewport)
.map(|p| egui::pos2(p[0], p[1]))
};
let color = self.shell.theme.accent;
for path in &preview.paths {
for pair in path.windows(2) {
if let (Some(a), Some(b)) = (project(pair[0]), project(pair[1])) {
ui.painter()
.line_segment([a, b], egui::Stroke::new(1.5, color));
}
}
}
for guide in &preview.guides {
if let (Some(a), Some(b)) = (project(guide[0]), project(guide[1])) {
ui.painter().line_segment(
[a, b],
egui::Stroke::new(1.0, color.gamma_multiply(0.45)),
);
}
}
if let Some(at) = project(preview.at) {
ui.painter()
.circle_stroke(at, 5.0, egui::Stroke::new(1.5, color));
if !preview.snap.is_empty() {
ui.painter().text(
at + egui::vec2(10.0, -14.0),
egui::Align2::LEFT_BOTTOM,
preview.snap,
egui::FontId::monospace(12.0),
color,
);
}
}
}
// AFTER the gizmo, so a value card or a handle is never
// painted over by a glyph — the marks are the least important
// thing on screen and are the last thing drawn.
@@ -2497,8 +2590,10 @@ impl ApplicationHandler for VernierApp {
// stop highlighting rather than keep the last edge lit.
if consumed {
self.hover = None;
self.hover_at = None;
} else {
self.refresh_hover(x, y);
self.refresh_drawing_hover();
}
}
WindowEvent::MouseWheel { delta, .. } if !consumed => {
@@ -2512,6 +2607,7 @@ impl ApplicationHandler for VernierApp {
self.handle_key(event_loop, &event, consumed);
}
WindowEvent::Focused(false) | WindowEvent::CursorLeft { .. } => {
self.hover_at = None;
let modifiers = self.input.modifiers;
self.input = Input {
modifiers,
+26 -2
View File
@@ -243,12 +243,25 @@ impl Graphics {
alpha_mode = ?config.alpha_mode,
"surface configured (after any env override)"
);
// The 3D pass keeps its surface format; egui blends coverage in
// gamma space. A second view changes the blend domain, not the pixels.
if adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::VIEW_FORMATS)
{
config.view_formats = vec![config.format.remove_srgb_suffix()];
}
surface.configure(&device, &config);
let renderer = Renderer::new(&device, config.format);
let depth = Renderer::create_depth(&device, config.width, config.height);
let egui_renderer = egui_wgpu::Renderer::new(
&device,
config.format,
config
.view_formats
.first()
.copied()
.unwrap_or(config.format),
egui_wgpu::RendererOptions::default(),
);
Ok(Self {
@@ -530,6 +543,17 @@ impl Graphics {
scene,
);
// egui expects coverage blending in gamma space (dark text on paper).
let ui_view = frame.texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(
self.config
.view_formats
.first()
.copied()
.unwrap_or(self.config.format),
),
..Default::default()
});
// The shell, composited over the scene.
let primitives = egui_ctx.tessellate(shapes, pixels_per_point);
let descriptor = egui_wgpu::ScreenDescriptor {
@@ -554,7 +578,7 @@ impl Graphics {
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("vernier-egui"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
view: &ui_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
+113 -2
View File
@@ -410,7 +410,7 @@ impl Headless {
// decision on a real display, and here it is pure noise in a diff.
let egui_wgpu = egui_wgpu::Renderer::new(
offscreen.device(),
offscreen.format(),
offscreen.ui_format(),
egui_wgpu::RendererOptions::PREDICTABLE,
);
let adapter_name = offscreen.adapter_name().to_owned();
@@ -692,7 +692,7 @@ impl Headless {
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("vernier-headless-egui"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: offscreen.colour_view(),
view: offscreen.ui_view(),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
@@ -1318,6 +1318,7 @@ impl Headless {
// was written to avoid.
self.app.hover_ids_size = [self.width, self.height];
self.app.refresh_hover(f64::from(x), f64::from(y));
self.app.refresh_drawing_hover();
}
/// The pointer position the harness last set.
@@ -1470,3 +1471,113 @@ impl Headless {
self.app.enable_projects_at(root);
}
}
#[cfg(test)]
mod compositing_tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
#[ignore = "requires a GPU; run explicitly with the driven rendering checks"]
fn ui_half_coverage_blends_in_gamma_space() {
vernier_ui::testing::driven("UI coverage pixels", || {
// The helper sits beside the test binary's PARENT under cargo
// test, not beside the binary as it does in a packaged release.
let helper = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("vernier-worker");
assert!(helper.is_file(), "build vernier-worker before this test");
let mut app = Headless::empty_with_worker(64, 64, &helper).unwrap();
let ctx = egui::Context::default();
let output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(64.0, 64.0),
)),
..Default::default()
},
|ui| {
ui.painter().rect_filled(
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(64.0, 64.0)),
0.0,
egui::Color32::WHITE,
);
ui.painter().rect_filled(
egui::Rect::from_min_max(egui::pos2(16.0, 16.0), egui::pos2(48.0, 48.0)),
0.0,
egui::Color32::from_black_alpha(128),
);
},
);
let primitives = ctx.tessellate(output.shapes.clone(), output.pixels_per_point);
let pixels = app
.paint(
[[0.0; 4]; 4],
SceneDraw::default(),
&primitives,
1.0,
true,
&output.textures_delta,
)
.unwrap()
.unwrap();
output.drop_without_applying_deltas();
let value = pixels[(32 * 64 + 32) * 4];
assert!(
(i16::from(value) - 128).abs() <= 1,
"half-covered UI pixel is {value}, expected 128"
);
assert_eq!(pixels[(8 * 64 + 8) * 4], 255, "opaque background control");
});
}
}
#[cfg(test)]
mod hover_motion_tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
#[ignore = "requires a GPU; run with the driven rendering checks"]
fn a_plain_pointer_move_updates_the_sketch_preview() {
let at = vernier_ui::testing::driven("pointer hover wiring", || {
// The helper sits beside the test binary's PARENT under cargo
// test, not beside the binary as it does in a packaged release.
let helper = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("vernier-worker");
assert!(helper.is_file(), "build vernier-worker before this test");
let mut headless = Headless::empty_with_worker(1600, 1000, &helper).unwrap();
headless.app.submit(Edit::NewSketch {
plane: crate::edit::NewSketchPlane::World(vernier_doc::PrincipalPlane::Xy),
});
headless.wait_idle(Duration::from_secs(5)).unwrap();
headless.set_cursor(900.0, 600.0);
let at = headless.app.hover_at;
headless.set_cursor(30.0, 55.0);
headless
.step_events(
vec![egui::Event::PointerMoved(egui::pos2(30.0, 55.0))],
false,
)
.unwrap();
(at, headless.app.hover_at)
});
assert!(
at.0.is_some(),
"mouse motion must reach sketch hover without a held button"
);
assert!(
at.1.is_none(),
"leaving the viewport must clear drawing feedback"
);
}
}
+13 -13
View File
@@ -53,16 +53,8 @@ pub(crate) fn preview_step(
})
}
/// The render loop's own filter on a live drag distance (card f94ebbff,
/// CRITICAL, cybersec's finding 1/2): the refusal band is never offered
/// to [`gizmo_frame`] as a candidate preview step. The press frame's
/// distance is ALWAYS `0.0` (the drag accumulator starts at zero), which
/// the kernel always refuses, so without this every single drag's first
/// preview step was doomed before it reached `gizmo_frame` at all. Same
/// threshold `gizmo_overlay` already applies to `committed_mm`; kept
/// separate rather than moved into `gizmo_overlay` because the render
/// loop's readout must track every live frame UNFILTERED, only submission
/// is gated.
/// Excludes the kernel's zero-distance refusal band from submission.
/// The frame decision still receives zero so it can retract a standing preview.
pub(crate) fn live_after_refusal_filter(face: u64, live_mm: Option<f64>) -> Option<(u64, f64)> {
live_mm.filter(|mm| mm.abs() > 1e-6).map(|mm| (face, mm))
}
@@ -98,9 +90,8 @@ pub(crate) fn live_after_refusal_filter(face: u64, live_mm: Option<f64>) -> Opti
/// actually carries. `handle` already guards this one level down
/// (main.rs:948-953, "undoing here would silently retract the user's
/// *previous* feature instead") -- this function reintroduces the exact
/// same hazard a level up, with no such key, unless callers filter the
/// refusal band out of `live` before it ever reaches here (the render
/// loop does, through [`live_after_refusal_filter`]).
/// same hazard a level up, with no such key, unless the refusal band is filtered before submission. This function
/// also retracts a standing preview when the pointer returns to zero.
pub(crate) fn gizmo_frame(
live: Option<(u64, f64)>,
committed: Option<(u64, f64)>,
@@ -124,6 +115,15 @@ pub(crate) fn gizmo_frame(
if (released || !gizmo_present) && preview.is_some() {
return GizmoFrame::Retire;
}
if let Some((face, mm)) = live
&& live_after_refusal_filter(face, Some(mm)).is_none()
{
return if preview.is_some() && pending_jobs == 0 {
GizmoFrame::Retire
} else {
GizmoFrame::Idle
};
}
match preview_step(live, pending_jobs, preview) {
Some(step) => GizmoFrame::Preview(step),
None => GizmoFrame::Idle,
@@ -187,6 +187,7 @@ fn view() -> SceneView {
});
}
SceneView {
drawing: None,
emboss_operations: Vec::new(),
sketch_texts: vec![vernier_ui::shell::SketchTextRow {
id: id(50),
@@ -230,7 +230,12 @@ pub(crate) fn reply(reply: &ServerReply) -> Result<(), CodecError> {
let mut supports = BTreeSet::new();
for row in &scene.view.extrude_faces {
if face_owners.get(&row.face) != Some(&row.body) || !supports.insert(row.face) {
return Err(fail("invalid or duplicate extrusion support face"));
return Err(fail(&format!(
"invalid or duplicate extrusion support face: face {} of body {} (metadata owner: {:?})",
row.face,
row.body.raw(),
face_owners.get(&row.face).map(|body| body.raw()),
)));
}
}
let mut edge_owners = BTreeMap::new();
+9 -1
View File
@@ -1116,7 +1116,15 @@ fn every_document_command_is_reachable_or_declared_unreachable() {
// lane 2's items panel. `AddForgedProducer` is `#[cfg(test)]` in
// vernier-doc — a test-only door the scan sees because it reads source
// text — and is excused for that reason, not because a dispatch is owed.
const NOT_YET: &[&str] = &["SetFeatureTarget", "SetBooleanTools", "AddForgedProducer"];
// `MoveOwned` is `#[cfg(test)]` in vernier-doc's sketch-text commands — a
// test-only door the scan sees because it reads source text, excused for
// `AddForgedProducer`'s reason.
const NOT_YET: &[&str] = &[
"SetFeatureTarget",
"SetBooleanTools",
"AddForgedProducer",
"MoveOwned",
];
// ONE PREDICATE, FOR THE SAME REASON THERE IS NOW ONE SLICE (cards f217455c,
// 6c9ab504). The two halves were made to read the same text and then still asked
+20
View File
@@ -112,6 +112,26 @@ pub(crate) struct Scene {
pub(crate) drag_point: Option<(vernier_doc::EntityId, [f64; 2])>,
}
impl Scene {
/// Keep the extrusion support-face list consistent with the face
/// metadata this scene carries. The process codec refuses a support face
/// whose owner is not in `faces`, and a refusal there abandons the
/// document worker — so every scene builder that filters `faces`
/// (visibility, a metadata edit, a body-tool preview that composes one
/// scene's view with another's geometry) must be followed by this, and
/// the one production exit calls it for all of them.
pub(crate) fn align_support_faces(&mut self) {
let carried: std::collections::BTreeSet<(u64, vernier_doc::BodyId)> = self
.faces
.iter()
.map(|meta| (meta.raw, meta.body))
.collect();
self.view
.extrude_faces
.retain(|row| carried.contains(&(row.face, row.body)));
}
}
/// What a read-only probe of the naming store answered.
///
/// One variant today, and a named enum rather than a bare `Option<u64>`
@@ -208,6 +208,17 @@ impl DocumentServer {
let geometry = self.cached_body_geometry();
match geometry {
Ok((meshes, faces, edges, vertices)) => {
// THE SUPPORT-FACE LIST FOLLOWS THE FACES THIS SCENE CARRIES.
// The cached view still lists the planar faces of a body
// this edit just hid, the codec refuses a support face whose
// owner is missing from `faces`, and a refusal there abandons
// the worker — hiding a body killed the session (l4, items).
let carried: std::collections::BTreeSet<(u64, vernier_doc::BodyId)> =
faces.iter().map(|meta| (meta.raw, meta.body)).collect();
scene
.view
.extrude_faces
.retain(|row| carried.contains(&(row.face, row.body)));
scene.meshes = meshes;
scene.faces = faces;
scene.edges = edges;
@@ -98,6 +98,8 @@ impl DocumentServer {
unsaved: self.unsaved,
warnings: Vec::new(),
proposals: Vec::new(),
// A restored checkpoint has no click in flight to preview.
drawing: None,
cut_preview: None,
active_sketch_frame: None,
sketch_dimensions: Vec::new(),
+29 -6
View File
@@ -360,11 +360,12 @@ impl DocumentServer {
Edit::SaveDocument { path } => {
crate::scene::ServerReply::Saved(self.save_without_recompute(&path))
}
edit => crate::scene::ServerReply::Scene(Box::new(self.handle_with_compile_observing(
edit,
compile_document,
observer,
))),
edit => {
let mut scene =
self.handle_with_compile_observing(edit, compile_document, observer);
scene.align_support_faces();
crate::scene::ServerReply::Scene(Box::new(scene))
}
}
}
@@ -966,10 +967,20 @@ impl DocumentServer {
drag_point: self.last_drag,
view: SceneView {
face_attached_sketches: self.document.features().iter().filter_map(|(&id, f)| matches!(&f.payload, FeaturePayload::Sketch(data) if matches!(data.plane, vernier_doc::SketchPlane::Face { .. })).then_some(id)).collect(),
// ONLY FACES THIS SCENE'S METADATA CARRIES. The process codec
// refuses a support face whose owner is not in `faces`, and a
// refusal there is not a warning — it abandons the worker.
// `faces` is empty while geometry is stale (a metadata undo
// before Rebuild, a hidden body's cache) even though
// `compiled` still names every face, so the two lists are
// built from one source rather than two predicates.
extrude_faces: compiled.as_ref().map(|c| {
let carried: std::collections::BTreeSet<(u64, vernier_doc::BodyId)> =
faces.iter().map(|meta| (meta.raw, meta.body)).collect();
c.bodies.iter().filter(|(body, _)| self.document.body_visible(**body)).flat_map(|(&body, evaluation)| {
let name = self.document.body_attributes(body).name.unwrap_or_else(|| format!("body {}", body.raw()));
evaluation.faces.iter().filter(|(_, face)| face.surface == vernier_kernel::occt::SurfaceClass::Plane).map(move |(id, face)| {
let carried = &carried;
evaluation.faces.iter().filter(move |(id, face)| face.surface == vernier_kernel::occt::SurfaceClass::Plane && carried.contains(&(id.raw(), body))).map(move |(id, face)| {
vernier_ui::shell::ExtrudeFaceRow { body, face: id.raw(), label: format!("{} · face {} ({:.1}, {:.1}, {:.1})", name, id.raw(), face.centroid[0], face.centroid[1], face.centroid[2]) }
})
}).collect()
@@ -1035,6 +1046,18 @@ impl DocumentServer {
unsaved: self.unsaved,
warnings,
proposals: self.hover_proposals.clone(),
drawing: self.sketch_active.and_then(|id| {
let FeaturePayload::Sketch(data) = &self.document.features().get(&id)?.payload
else {
return None;
};
Some(vernier_ui::hover::DrawingState {
sketch: data.clone(),
tool: self.pending_tool,
pending: self.pending_points.clone(),
line_end: self.sketch_pending,
})
}),
cut_preview,
// THE COMPILE'S OWN FRAME for the sketch being drawn into,
// so the viewport and the extrude cannot disagree about
+19 -15
View File
@@ -831,21 +831,25 @@ fn an_impossible_hole_is_refused() {
constraints: Vec::new(),
})
.unwrap();
server
.document
.execute(&AddFeature {
name: "hole".to_owned(),
payload: FeaturePayload::Hole {
countersink: None,
drill_point: None,
sketch: sketch.created[0],
points: vec![sketch.created[1]],
diameter,
depth,
counterbore: bore,
},
})
.unwrap();
// REFUSED AT THE COMMAND OR AT THE COMPILE, either is the property.
// The countersink batch moved the counterbore and dimension checks
// into the command, so most of these rows never reach a compile;
// the one that does must still fail there.
let added = server.document.execute(&AddFeature {
name: "hole".to_owned(),
payload: FeaturePayload::Hole {
countersink: None,
drill_point: None,
sketch: sketch.created[0],
points: vec![sketch.created[1]],
diameter,
depth,
counterbore: bore,
},
});
if added.is_err() {
continue;
}
let compiled =
compile_document(&mut server.document, &mut server.kernel, &mut server.store);
assert!(
+119
View File
@@ -498,3 +498,122 @@ fn a_frame_with_nothing_picked_leaves_the_value_card_unanchored() {
});
assert!(!anchored, "a card anchored to a face nobody picked");
}
#[test]
fn sketch_hover_paints_a_live_snap_label_without_another_worker_job() {
vernier_ui::testing::driven("live sketch feedback", || {
let mut server = DocumentServer::new();
server.handle(Edit::NewSketch {
plane: crate::edit::NewSketchPlane::World(vernier_doc::PrincipalPlane::Xy),
});
server.handle(Edit::SketchDraw {
at: [0.0, 0.0],
snap_tolerance_mm: Some(2.0),
tool: SketchTool::Line,
infer: false,
sides: 6,
});
let scene = server.handle(Edit::Recompute);
let worker = Worker::spawn("drawing-frame", move |edit| server.reply(edit), || {}).unwrap();
let mut app = VernierApp::new(worker);
app.apply_scene(scene);
app.shell.tool = SketchTool::Line;
app.hover_at = Some([15.3, 0.2]);
let mut output = app.run_shell_frame(raw_input([1600.0, 1000.0], 0), [1600.0, 1000.0]);
let shapes = format!("{:?}", output.shapes);
output.textures_delta.clear();
assert!(
shapes.contains("horizontal"),
"the live frame must identify its snap before a hover round trip"
);
});
}
#[test]
fn gizmo_coordinates_are_logical_points_on_a_scaled_display() {
vernier_ui::testing::driven("scaled overlay", || {
let (mut server, _) = built_server();
let scene = server.handle(Edit::Recompute);
let face = scene.faces.iter().find(|f| f.normal.is_some()).unwrap().raw;
let worker = Worker::spawn("scale-frame", move |edit| server.reply(edit), || {}).unwrap();
let mut app = VernierApp::new(worker);
app.apply_scene(scene);
app.shell.picked_faces = vec![face];
app.shell.picked_planar = true;
let size = [1600.0, 1000.0];
let expected = crate::preview::gizmo_anchor(
&[face],
false,
&app.faces,
&app.camera.view_proj(1.6),
[800.0, 500.0],
)
.unwrap()
.1;
let mut input = raw_input([800.0, 500.0], 0);
input
.viewports
.entry(egui::ViewportId::ROOT)
.or_default()
.native_pixels_per_point = Some(2.0);
let output = app.run_shell_frame(input, size);
output.drop_without_applying_deltas();
assert_eq!(app.shell.card.anchor, Some(expected));
});
}
#[test]
fn dragging_updates_the_visible_card_buffer() {
vernier_ui::testing::driven("live value card", || {
let (mut server, _) = built_server();
let scene = server.handle(Edit::Recompute);
let face = scene.faces.iter().find(|f| f.normal.is_some()).unwrap().raw;
let worker = Worker::spawn("value-frame", move |edit| server.reply(edit), || {}).unwrap();
let mut app = VernierApp::new(worker);
app.apply_scene(scene);
app.shell.picked_faces = vec![face];
app.shell.picked_planar = true;
let size = [1600.0, 1000.0];
let (_, a, b) = crate::preview::gizmo_anchor(
&[face],
false,
&app.faces,
&app.camera.view_proj(1.6),
size,
)
.unwrap();
let direction = (egui::pos2(b[0], b[1]) - egui::pos2(a[0], a[1])).normalized();
let handle = egui::pos2(a[0], a[1]) + direction * 60.0;
for i in 0..5 {
let mut input = raw_input(size, i);
if i == 2 {
input.events = vec![
egui::Event::PointerMoved(handle),
egui::Event::PointerButton {
pos: handle,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
},
];
}
if i >= 3 {
input.events = vec![egui::Event::PointerMoved(
handle + direction * (15.0 * (i - 2) as f32),
)];
}
app.run_shell_frame(input, size)
.drop_without_applying_deltas();
}
assert!(
app.shell.distance_mm.abs() > 0.1,
"positive control: a real drag occurred"
);
let displayed = app.shell.card.buffers[0].parse::<f64>().unwrap();
assert!(
(displayed - app.shell.distance_mm).abs() < 0.01,
"visible {displayed} differs from live {}",
app.shell.distance_mm
);
});
}
@@ -473,9 +473,14 @@ fn parameter_worker_order_preserves_a_newer_partial_draft_across_background_and_
&mut index,
vec![egui::Event::Text("21.23456789".into())],
);
// THE FIRST FIELD IS THE ONE TYPED INTO. The edit card has carried
// `backward` and `thickness` rows since the extents and thin-wall
// batches; they are seeded, never touched here, and not what this
// test is about.
let typed = |app: &VernierApp| app.shell.card.buffers.first().cloned();
assert_eq!(
app.shell.card.buffers,
["21.23456789"],
typed(&app).as_deref(),
Some("21.23456789"),
"typed draft before submit"
);
// The earlier background request must consume its own empty token.
@@ -494,7 +499,7 @@ fn parameter_worker_order_preserves_a_newer_partial_draft_across_background_and_
);
key(&mut app, &mut index, egui::Key::A, egui::Modifiers::CTRL);
frame(&mut app, &mut index, vec![egui::Event::Text("-".into())]);
assert_eq!(app.shell.card.buffers, ["-"]);
assert_eq!(typed(&app).as_deref(), Some("-"));
release.send(()).unwrap();
let event = app
.worker
@@ -503,10 +508,10 @@ fn parameter_worker_order_preserves_a_newer_partial_draft_across_background_and_
.unwrap();
app.apply_worker_event(event);
assert_eq!(app.pending_jobs, 1);
assert_eq!(app.shell.card.buffers, ["-"]);
assert_eq!(typed(&app).as_deref(), Some("-"));
release.send(()).unwrap();
drain(&mut app);
assert_eq!(app.shell.card.buffers, ["-"]);
assert_eq!(typed(&app).as_deref(), Some("-"));
assert_eq!(
app.scene
.timeline
@@ -514,8 +519,11 @@ fn parameter_worker_order_preserves_a_newer_partial_draft_across_background_and_
.find(|row| row.id == block)
.unwrap()
.scalar,
// A height edit re-authors the starter block's `Legacy` extent
// as the explicit distance the extents batch renders it as
// (outward: the block is not a cut on a face).
Some(vernier_ui::shell::FeatureScalar::Extrude {
extent: Default::default(),
extent: vernier_doc::ExtrudeExtent::Distance { reverse: false },
height_mm: 21.23456789,
cut: false,
thin_wall: None,
+13
View File
@@ -785,3 +785,16 @@ fn a_sketch_being_drawn_on_suppresses_the_faces_gizmo() {
"the gizmo must still name the face it belongs to"
);
}
#[test]
fn returning_to_zero_retracts_the_preview_before_mouse_release() {
use crate::preview::{GizmoFrame, gizmo_frame};
assert_eq!(
gizmo_frame(Some((7, 0.0)), None, false, true, 0, Some((7, 3.0))),
GizmoFrame::Retire
);
assert_eq!(
gizmo_frame(Some((7, 0.0)), None, false, true, 0, None),
GizmoFrame::Idle
);
}
+1 -1
View File
@@ -321,7 +321,7 @@ mod tests {
parameter: Parameter::Feature(FeatureParameter::EmbossDepth),
};
doc.execute(&SetParameterExpression {
target: target.clone(),
target,
expression: Some("3 mm".into()),
})
.unwrap();
+38
View File
@@ -81,6 +81,8 @@ pub struct Runner {
idle_budget: Duration,
options: Options,
last: FrameOutput,
/// The value card's rect on the frame before `last`, for the settle rule.
prev_card_rect: Option<egui::Rect>,
artifacts: Vec<Artifact>,
digests: Vec<FrameDigest>,
trace: Vec<TraceRow>,
@@ -243,6 +245,7 @@ impl Runner {
idle_budget: Duration::from_millis(script.idle_budget_ms),
options,
last,
prev_card_rect: None,
artifacts: Vec::new(),
digests,
trace: Vec::new(),
@@ -251,6 +254,38 @@ impl Runner {
}
/// One frame, recording its digest.
/// A card or chip target is resolved from the LAST painted frame, and the
/// value card can still be moving on that frame: every card shares one
/// egui `Area` id, so a tall card first paints where the previous short
/// one fitted and is constrained onto the screen a frame later (measured:
/// 25 px, exactly the height of a field row, which is how a click aimed at
/// the height field landed on the rule under it and the typed number was
/// dropped). Settle until the card sits inside the window, bounded, so a
/// script's `card:`/`chip:` click aims at where the card stays.
fn settle_card_for(&mut self, target: &Target) -> Result<(), DriveError> {
if !matches!(target, Target::Card { .. } | Target::Chip { .. }) {
return Ok(());
}
let (width, height) = self.headless.size();
#[allow(clippy::cast_precision_loss)]
let screen =
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32));
// STABLE FOR TWO FRAMES AND INSIDE THE WINDOW. A rect that moved
// since the previous frame is a card still being constrained; one
// outside the window is about to be.
for _ in 0..4 {
let settled = match (self.prev_card_rect, self.last.value_card_rect) {
(Some(before), Some(now)) => before == now && screen.contains_rect(now),
_ => false,
};
if settled {
break;
}
self.step(Vec::new())?;
}
Ok(())
}
fn step(&mut self, events: Vec<egui::Event>) -> Result<(), DriveError> {
let frame = self
.headless
@@ -261,6 +296,7 @@ impl Runner {
fnv1a64: format!("{:#018x}", frame.digest),
});
}
self.prev_card_rect = self.last.value_card_rect;
self.last = frame;
Ok(())
}
@@ -276,6 +312,7 @@ impl Runner {
fnv1a64: format!("{:#018x}", frame.digest),
});
}
self.prev_card_rect = self.last.value_card_rect;
self.last = frame;
Ok(&self.last)
}
@@ -422,6 +459,7 @@ impl Runner {
shift,
alt,
} => {
self.settle_card_for(at)?;
let pixel = self.resolve(index, step, at)?;
self.click(pixel, *button, *ctrl, *shift, *alt)?;
Ok(Some(pixel))
+1 -1
View File
@@ -1403,7 +1403,7 @@ mod tests {
#[test]
fn every_op_role_round_trips_through_its_kebab_name() {
assert_eq!(ALL_ROLES.len(), 17, "OpRole has seventeen variants");
assert_eq!(ALL_ROLES.len(), 21, "OpRole has twenty-one variants");
for role in ALL_ROLES {
let name = role_name(*role);
assert_eq!(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

+6 -3
View File
@@ -2332,9 +2332,12 @@ fn a_timeline_panel_that_cannot_paint_every_row_refuses_by_index() {
let error = drive(&script, &out, Determinism::Once, options()).unwrap_err();
let message = error.to_string();
assert_eq!(error.exit_code(), 1, "{message}");
assert_eq!(
message, "step 8 (move): timeline row 3 has no painted visible controls",
"the refusal must identify the requested step and clipped row"
// The row is named by index and by its entity id (an allocator fact the
// fixture makes deterministic but this test need not pin).
assert!(
message.starts_with("step 8 (move): timeline row 3")
&& message.ends_with("is not visible after scrolling"),
"the refusal must identify the requested step and clipped row: {message:?}"
);
}
+36 -1
View File
@@ -35,6 +35,7 @@ struct Gpu {
/// Which adapter wgpu actually chose. Kept because a golden threshold
/// means nothing unless the report says which rasterizer produced it.
name: String,
view_formats: bool,
}
fn gpu() -> Result<Gpu, RenderError> {
@@ -51,6 +52,10 @@ fn gpu() -> Result<Gpu, RenderError> {
device,
queue,
name,
view_formats: adapter
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::VIEW_FORMATS),
})
}
@@ -133,6 +138,7 @@ fn read_texture(
}
fn create_target(gpu: &Gpu, format: wgpu::TextureFormat, width: u32, height: u32) -> wgpu::Texture {
let aliases = [format.remove_srgb_suffix()];
gpu.device.create_texture(&wgpu::TextureDescriptor {
label: Some("vernier-offscreen-target"),
size: wgpu::Extent3d {
@@ -145,7 +151,11 @@ fn create_target(gpu: &Gpu, format: wgpu::TextureFormat, width: u32, height: u32
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
view_formats: if gpu.view_formats && format.is_srgb() {
&aliases
} else {
&[]
},
})
}
@@ -403,6 +413,8 @@ pub struct Offscreen {
renderer: Renderer,
colour: wgpu::Texture,
colour_view: wgpu::TextureView,
ui_view: wgpu::TextureView,
ui_format: wgpu::TextureFormat,
depth: wgpu::TextureView,
width: u32,
height: u32,
@@ -421,6 +433,15 @@ impl Offscreen {
let gpu = gpu()?;
let colour = create_target(&gpu, COLOUR_FORMAT, width, height);
let colour_view = colour.create_view(&wgpu::TextureViewDescriptor::default());
let ui_format = if gpu.view_formats {
COLOUR_FORMAT.remove_srgb_suffix()
} else {
COLOUR_FORMAT
};
let ui_view = colour.create_view(&wgpu::TextureViewDescriptor {
format: Some(ui_format),
..Default::default()
});
let depth = Renderer::create_depth(&gpu.device, width, height);
let renderer = Renderer::new(&gpu.device, COLOUR_FORMAT);
Ok(Self {
@@ -429,6 +450,8 @@ impl Offscreen {
renderer,
colour,
colour_view,
ui_view,
ui_format,
depth,
width,
height,
@@ -462,6 +485,18 @@ impl Offscreen {
COLOUR_FORMAT
}
/// UI blend format; legacy GL adapters without view aliases retain sRGB.
#[must_use]
pub const fn ui_format(&self) -> wgpu::TextureFormat {
self.ui_format
}
/// Gamma-space attachment for egui's coverage blending, over the same pixels.
#[must_use]
pub const fn ui_view(&self) -> &wgpu::TextureView {
&self.ui_view
}
/// Target size in pixels, as `(width, height)`.
#[must_use]
pub const fn size(&self) -> (u32, u32) {
+2 -3
View File
@@ -86,7 +86,7 @@ pub(super) fn emboss(
return Err(bad("Cyclic glyph contours"));
}
}
if nesting % 2 != 0 {
if !nesting.is_multiple_of(2) {
continue;
}
let holes: Vec<_> = loops
@@ -114,8 +114,7 @@ pub(super) fn emboss(
.bodies
.get(&target)
.ok_or(CompileError::BodyGone(feature, target))?
.summary
.clone();
.summary;
let prism = {
let table = w.store.claim_table(feature, slot)?;
vernier_kernel::evaluate::evaluate_text_prism_region_on(
+259
View File
@@ -24,6 +24,7 @@ pub struct CutPreview {
///
/// Present even when `refusal` is: it is the hovered point itself in
/// that case, so the sentence has somewhere to hang.
/// Cursor destination in sketch coordinates.
pub at: [f64; 2],
/// Where the moving endpoint comes FROM, drawn as a ghost. `None` for
/// a split, which moves nothing, and for a refusal, which does
@@ -126,3 +127,261 @@ mod tests {
assert_eq!(mid(marks[1]), [10.0, 20.0]);
}
}
/// Immutable drawing state sent by the document worker. Mouse motion can
/// preview the next click without waiting for another kernel evaluation.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DrawingState {
/// Geometry used by the next drawing command.
pub sketch: vernier_doc::SketchData,
/// Tool that owns the pending clicks.
pub tool: Option<crate::shell::SketchTool>,
/// Previously clicked points for multi-click tools.
pub pending: Vec<vernier_doc::EntityId>,
/// Endpoint of the current line chain.
pub line_end: Option<vernier_doc::EntityId>,
}
#[derive(Debug, Clone, PartialEq)]
/// Geometry and snap feedback for the next click.
pub struct DrawingPreview {
/// Sampled paths in sketch coordinates.
pub paths: Vec<Vec<[f64; 2]>>,
/// Cursor destination in sketch coordinates.
pub at: [f64; 2],
/// Human-readable snap kind, empty when unsnapped.
pub snap: &'static str,
/// Alignment guides to existing geometry.
pub guides: Vec<[[f64; 2]; 2]>,
}
impl DrawingState {
/// Predicts the next click without mutating the document.
///
/// `snap_tolerance_mm` is the click's own capture radius (camera-derived
/// on the app side, `None` under Alt or with snapping off), so the
/// preview and the click that follows it agree by construction. Radial
/// clicks — a circle's rim, a polygon's vertex, a slot's width, a
/// three-point arc's through-point — are never snapped, the click
/// path's own rule: they contribute a distance, not an identity.
pub fn preview(
&self,
at: [f64; 2],
tool: crate::shell::SketchTool,
sides: u32,
snap_tolerance_mm: Option<f64>,
) -> Option<DrawingPreview> {
use crate::shell::SketchTool as T;
use vernier_doc::{CurveDraw as C, PointRef as P};
if matches!(tool, T::Select | T::Trim | T::Split | T::TangentArc) {
return None;
}
let pending = if self.tool == Some(tool) {
self.pending.as_slice()
} else {
&[]
};
let line_end = if self.tool == Some(tool) {
self.line_end
} else {
None
};
let radial = matches!(tool, T::Circle | T::Polygon) && !pending.is_empty()
|| matches!(tool, T::Slot | T::ThreePointArc) && pending.len() == 2;
let snap = crate::snap::snap_point(
at,
&self.sketch,
None,
&if radial {
crate::snap::SnapSettings {
tolerance_mm: 0.0,
grid_mm: 0.0,
}
} else {
crate::snap::SnapSettings {
tolerance_mm: snap_tolerance_mm.unwrap_or(0.0),
..crate::snap::SnapSettings::default()
}
},
);
let at = snap.position;
let mut preview = DrawingPreview {
paths: vec![],
at,
snap: if radial {
""
} else if snap.point.is_some() {
"point"
} else if snap.align_h.is_some() && snap.align_v.is_some() {
"horizontal + vertical"
} else if snap.align_h.is_some() {
"horizontal"
} else if snap.align_v.is_some() {
"vertical"
} else if snap.grid {
"grid"
} else {
""
},
guides: vec![],
};
for id in [snap.align_h, snap.align_v].into_iter().flatten() {
if let Some(&p) = self.sketch.points.get(&id) {
preview.guides.push([p, at]);
}
}
let first = pending
.first()
.and_then(|id| self.sketch.points.get(id).map(|&p| (*id, p)));
let second = pending
.get(1)
.and_then(|id| self.sketch.points.get(id).map(|&p| (*id, p)));
let primitive = match (tool, first, second) {
(T::Rectangle, Some((id, p)), _) if crate::primitives::is_drawable_rectangle(p, at) => {
Some(crate::primitives::rectangle(P::Existing(id), p, at))
}
(T::CentreRectangle, Some((id, p)), _)
if crate::primitives::is_drawable_centre_rectangle(p, at) =>
{
Some(crate::primitives::centre_rectangle(P::Existing(id), p, at))
}
(T::Polygon, Some((id, p)), _)
if crate::primitives::is_drawable_polygon(p, at, sides) =>
{
Some(crate::primitives::polygon(P::Existing(id), p, at, sides))
}
(T::Slot, Some((a, p)), Some((b, q)))
if crate::primitives::is_drawable_slot(p, q, at) =>
{
Some(crate::primitives::slot(
P::Existing(a),
P::Existing(b),
p,
q,
at,
))
}
(T::ThreePointArc, Some((a, p)), Some((b, q))) => {
crate::primitives::three_point_arc(P::Existing(a), p, P::Existing(b), q, at)
}
_ => None,
};
if let Some(draw) = primitive {
let point = |r: P| match r {
P::Existing(id) => self.sketch.points.get(&id).copied(),
P::New(i) => draw.points.get(i).copied(),
};
for curve in &draw.curves {
match *curve {
C::Line { start, end } => {
if let (Some(a), Some(b)) = (point(start), point(end)) {
preview.paths.push(vec![a, b]);
}
}
C::Arc { center, start, end } => {
if let (Some(c), Some(a), Some(b)) =
(point(center), point(start), point(end))
{
preview.paths.push(arc_path(c, a, b));
}
}
_ => {}
}
}
} else {
match (tool, first, second) {
(T::Line, _, _) => {
if let Some(p) = line_end.and_then(|id| self.sketch.points.get(&id)) {
preview.paths.push(vec![*p, at]);
}
}
(T::Circle, Some((_, p)), _) => {
let radius = (at[0] - p[0]).hypot(at[1] - p[1]);
preview.paths.push(
(0..=96)
.map(|i| {
let a = f64::from(i) * std::f64::consts::TAU / 96.0;
[p[0] + radius * a.cos(), p[1] + radius * a.sin()]
})
.collect(),
);
preview.paths.push(vec![p, at]);
}
(T::Arc, Some((_, c)), Some((_, a))) => preview.paths.push(arc_path(c, a, at)),
(T::Spline, _, _) => {
let mut points: Vec<_> = pending
.iter()
.filter_map(|id| self.sketch.points.get(id).copied())
.collect();
points.push(at);
preview.paths.push(points);
}
(_, Some((_, p)), _) => preview.paths.push(vec![p, at]),
_ => {}
}
}
Some(preview)
}
}
#[cfg(test)]
mod drawing_tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::shell::SketchTool;
#[test]
fn a_line_preview_ends_at_the_same_snap_as_the_next_click() {
let mut doc = vernier_doc::Document::new();
let created = doc
.execute(&vernier_doc::AddSketchFeature {
name: "preview".into(),
plane: Default::default(),
points: vec![[0.0, 0.0], [20.0, 10.0]],
curves: vec![],
constraints: vec![],
})
.unwrap()
.created;
let vernier_doc::FeaturePayload::Sketch(sketch) =
doc.features()[&created[0]].payload.clone()
else {
panic!()
};
let state = DrawingState {
sketch,
tool: Some(SketchTool::Line),
pending: vec![],
line_end: Some(created[1]),
};
let preview = state
.preview([20.4, 10.2], SketchTool::Line, 6, Some(2.0))
.expect("live line and snap target");
assert_eq!(preview.paths, vec![vec![[0.0, 0.0], [20.0, 10.0]]]);
assert_eq!(preview.snap, "point");
assert!(
state
.preview([20.4, 10.2], SketchTool::Select, 6, Some(2.0))
.is_none()
);
// Alt: no capture radius, so neither the point 0.45 mm away nor the
// grid captures the cursor — the line ends where the pointer is,
// exactly as the click path with `snap_tolerance_mm: None` does.
let free = state
.preview([20.4, 10.2], SketchTool::Line, 6, None)
.expect("live line without snapping");
assert_eq!(free.paths, vec![vec![[0.0, 0.0], [20.4, 10.2]]]);
assert_eq!(free.snap, "");
}
}
fn arc_path(c: [f64; 2], a: [f64; 2], b: [f64; 2]) -> Vec<[f64; 2]> {
let start = (a[1] - c[1]).atan2(a[0] - c[0]);
let sweep = ((b[1] - c[1]).atan2(b[0] - c[0]) - start).rem_euclid(std::f64::consts::TAU);
let radius = (a[0] - c[0]).hypot(a[1] - c[1]);
(0..=64)
.map(|i| {
let angle = start + sweep * f64::from(i) / 64.0;
[c[0] + radius * angle.cos(), c[1] + radius * angle.sin()]
})
.collect()
}
+16
View File
@@ -97,6 +97,22 @@ pub fn reset_producer_target(state: &mut ShellState) {
state.extrude_up_to_face_pending = false;
}
/// The direction a fresh extrusion starts with: inward for a cut drawn on a
/// face (the chip rule below), outward for everything else.
pub(super) fn default_new_extrusion_direction(state: &mut ShellState, view: &SceneView) {
if !matches!(
state.extrude_extent,
vernier_doc::ExtrudeExtent::Legacy | vernier_doc::ExtrudeExtent::Distance { .. }
) {
return;
}
let inward = state.extrude_cut
&& super::profile_for_extrude(state, view)
.is_some_and(|profile| view.face_attached_sketches.contains(&profile.sketch));
state.extrude_extent = vernier_doc::ExtrudeExtent::Distance { reverse: inward };
state.extrude_reverse = inward;
}
pub(super) fn is_producer(action: Action) -> bool {
matches!(
action,
+1
View File
@@ -130,6 +130,7 @@ fn seed_existing(state: &mut EmbossState, row: &EmbossRow) {
state.seeded_feature = Some(row.feature);
}
#[allow(clippy::too_many_arguments)] // batch 5 in flight; the editor's inputs are its state
/// Paint the emboss/engrave editor and return one request when submitted.
pub fn show(
ui: &mut egui::Ui,
+12 -3
View File
@@ -43,7 +43,10 @@ pub fn gizmo_overlay(ui: &mut egui::Ui, anchor_px: [f32; 2], tip_px: [f32; 2]) -
let rect = egui::Rect::from_center_size(handle, egui::vec2(24.0, 24.0));
let response = ui.interact(rect, id, egui::Sense::drag());
if response.drag_started() {
ui.data_mut(|data| data.insert_temp(id, egui::Vec2::ZERO));
ui.data_mut(|data| {
data.insert_temp(id, egui::Vec2::ZERO);
data.insert_temp(id.with("axis"), (anchor_px, tip_px));
});
}
if response.dragged() || response.drag_stopped() {
// READ THE DELTA BEFORE TAKING THE LOCK, and keep it out here.
@@ -59,9 +62,15 @@ pub fn gizmo_overlay(ui: &mut egui::Ui, anchor_px: [f32; 2], tip_px: [f32; 2]) -
data.insert_temp(id, total);
total
});
let mm = crate::gizmo::screen_axis_drag_mm(anchor_px, tip_px, [total.x, total.y]);
let (start, tip) = ui
.data(|data| data.get_temp::<([f32; 2], [f32; 2])>(id.with("axis")))
.unwrap_or((anchor_px, tip_px));
let mm = crate::gizmo::screen_axis_drag_mm(start, tip, [total.x, total.y]);
if response.drag_stopped() {
ui.data_mut(|data| data.remove::<egui::Vec2>(id));
ui.data_mut(|data| {
data.remove::<egui::Vec2>(id);
data.remove::<([f32; 2], [f32; 2])>(id.with("axis"));
});
out.committed_mm = mm.filter(|mm| mm.abs() > 1e-6);
out.released = true;
} else {
+11 -4
View File
@@ -344,6 +344,8 @@ pub struct SceneView {
/// Empty is the ordinary case: most of a sketch infers nothing, and a
/// hover outside sketch mode infers nothing at all.
pub proposals: Vec<vernier_doc::InferredMark>,
/// Drawing snapshot for immediate mouse-move feedback.
pub drawing: Option<crate::hover::DrawingState>,
/// What a trim or split click at the hovered point WOULD do (M3 lane
/// B), including the refusal it would raise.
///
@@ -2178,7 +2180,7 @@ impl Default for ShellState {
section_offset_mm: 0.0,
section_flip: false,
readout: String::new(),
distance_mm: 2.0,
distance_mm: 0.0,
radius_mm: 1.0,
fillet_radius_mm: 1.0,
chamfer_distance_mm: 1.0,
@@ -3316,7 +3318,7 @@ pub fn show(ui: &mut egui::Ui, view: &SceneView, state: &mut ShellState) -> Shel
// styling bug rather than an ordering one. Status first of the two, so it
// ends up bottom-most.
title_bar(ui, view, state, theme, &mut response);
tab_strip(ui, state, theme);
tab_strip(ui, state, theme, workspace_for(state, view));
ribbon::ribbon(ui, view, state, theme, selection, &mut response);
ribbon::status_line(ui, view, state, theme);
ribbon::history_strip(ui, view, state, theme, &mut response);
@@ -3957,7 +3959,12 @@ fn document_buttons(
}
/// The workspace tab strip, and the note that explains its behaviour.
fn tab_strip(ui: &mut egui::Ui, state: &mut ShellState, theme: ShellTheme) {
fn tab_strip(
ui: &mut egui::Ui,
state: &mut ShellState,
theme: ShellTheme,
following: registry::Workspace,
) {
let frame = egui::Frame::NONE
.fill(theme::PAPER)
.inner_margin(egui::Margin::symmetric(16, 0));
@@ -3974,7 +3981,7 @@ fn tab_strip(ui: &mut egui::Ui, state: &mut ShellState, theme: ShellTheme) {
// CLICKING A TAB PINS IT. Otherwise the next selection
// would snatch it back, and the click would read as
// broken rather than as overridden.
state.workspace_pinned = true;
state.workspace_pinned = *workspace != following;
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+37 -3
View File
@@ -508,6 +508,20 @@ pub fn reconcile_parameter_card(state: &mut ShellState, view: &SceneView) {
cancel(state);
return;
};
// THE EXTRUDE EDITOR FLOATS ONLY FOR SOMETHING THAT CAN BE EXTRUDED.
// Readiness is decided per selection KIND (two curves, a line and an
// arc), so a picked set that no profile route accepts — a construction
// curve in it, a duplicate edge, a degenerate line — still floated the
// whole editor with its target chips, and Enter could only be refused.
// A ribbon press still aims the card, and the refusal then names why.
if action == Action::Extrude
&& state.card.armed != Some(Action::Extrude)
&& super::selected_profile::profile_for_extrude_mode(state, view, false).is_none()
&& super::selected_profile::profile_for_extrude_mode(state, view, true).is_none()
{
cancel(state);
return;
}
let mut owner = subject(state, view, action);
if let Some(draft) = &state.card.draft
&& draft.subject != owner
@@ -550,6 +564,16 @@ pub fn reconcile_parameter_card(state: &mut ShellState, view: &SceneView) {
if command.params.is_empty() {
return;
}
// A NEW EXTRUSION DOES NOT INHERIT THE LAST FEATURE'S DIRECTION. Selecting
// a feature row seeds the shared extent fields from that feature (a cut
// into a face is `reverse: true`), and the same fields are what the next
// creation reads — so a boss drawn on the face after a cut went INTO the
// body and joined nothing (measured: cut-then-boss, delta 0). The one
// direction a person chose on this card (`extrude_reverse_explicit`) is
// kept; everything else starts from the rule the target chips apply.
if state.card.draft.is_none() && action == Action::Extrude && !state.extrude_reverse_explicit {
bodies::default_new_extrusion_direction(state, view);
}
let (mut baseline, existing, checked_source) = source(state, view, action, command);
if let Some(mut draft) = state.card.draft.take() {
// ThroughAll has no authored depth. Keep the existing latent default
@@ -1054,7 +1078,17 @@ pub fn acknowledge_parameter_card(
} // Existing values are acknowledged by typed scene data.
let action = sent.subject.action;
if action == Action::Extrude {
// THE NEXT EXTRUSION INHERITS THE NUMBERS, NOT THE MODES. Cut was
// already spent here (R6); the direction and the wall mode are the
// same kind of one-operation choice — a boss drawn on the face after
// an inward cut went INTO the body and joined nothing, because the
// cut's `reverse: true` rode these defaults into the boss (measured:
// cut-then-boss, delta 0 where +141 mm³ was expected).
candidate.chips.cut = false;
candidate.chips.extrude_extent = vernier_doc::ExtrudeExtent::Distance { reverse: false };
candidate.chips.extrude_reverse = false;
candidate.chips.extrude_reverse_explicit = false;
candidate.chips.thin_wall = None;
}
if let Some((_, defaults)) = state
.card
@@ -1346,7 +1380,7 @@ mod tests {
distance: 6.123456789,
};
reconcile_parameter_card(&mut state, &view);
assert_eq!(state.card.buffers, ["7."]);
assert_eq!(state.card.buffers[0], "7.");
assert!(prepare(&mut state, action));
state.picked_curves = vec![test_id(6)];
assert_eq!(
@@ -1405,7 +1439,7 @@ mod tests {
state.extrude_cut = true;
view.sketch_curves[0].radius_mm = Some(5.123456789);
reconcile_parameter_card(&mut state, &view);
assert_eq!(state.card.buffers, ["7."]);
assert_eq!(state.card.buffers[0], "7.");
assert!(state.extrude_cut);
state.card.arm(Action::EditDimension);
reconcile_parameter_card(&mut state, &view);
@@ -1532,7 +1566,7 @@ mod tests {
view.sketch_curves[1].radius_mm = Some(8.0); // Geometry/source refresh.
reconcile_parameter_card(&mut state, &view);
assert_eq!(subject(&state, &view, Action::Extrude), owner);
assert_eq!(state.card.buffers, ["7."]);
assert_eq!(state.card.buffers[0], "7.");
assert_eq!(
state.producer_target,
Some(vernier_doc::TargetRequest::Cut(body))
@@ -360,11 +360,10 @@ mod tests {
}),
..Default::default()
};
// The toolbar OFFERS extrude for two lines regardless (an
// open chain can be a thin wall since batch 3); what this
// test pins is that the closed route accepts none of them.
assert!(profile_for_extrude(&state, &view).is_none());
assert!(
!crate::toolbar::actions_for(selection_of(&state, &view))
.contains(&Action::Extrude)
);
}
}
}
+94
View File
@@ -33,6 +33,7 @@ fn view() -> SceneView {
region_diagnostics: Vec::new(),
unsaved: false,
proposals: Vec::new(),
drawing: None,
cut_preview: None,
timeline: vec![
TimelineRow {
@@ -2428,3 +2429,96 @@ fn parameter_visible_lock_ribbon_uses_the_same_noop_preparation() {
assert_eq!(state.lock_y.to_bits(), 2.987654321_f64.to_bits());
});
}
#[test]
fn a_new_push_pull_starts_at_zero() {
assert_eq!(ShellState::default().distance_mm, 0.0);
}
#[test]
fn preview_geometry_does_not_change_the_drag_measurement() {
driven("stable drag axis", || {
let ctx = shell_ctx();
let handle = egui::pos2(160.0, 100.0);
let events = [
vec![],
vec![
egui::Event::PointerMoved(handle),
egui::Event::PointerButton {
pos: handle,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
},
],
vec![egui::Event::PointerMoved(handle + egui::vec2(20.0, 0.0))],
vec![egui::Event::PointerMoved(handle + egui::vec2(40.0, 0.0))],
];
let mut last = None;
for (i, events) in events.into_iter().enumerate() {
let tip = if i == 3 {
[300.0, 100.0]
} else {
[200.0, 100.0]
};
ctx.run_ui(
egui::RawInput {
events,
..frame_input()
},
|ui| {
last = gizmo_overlay(ui, [100.0, 100.0], tip).live_mm;
},
)
.drop_without_applying_deltas();
}
assert_eq!(
last,
Some(0.4),
"the same 40px movement must use the original 100px/mm axis"
);
});
}
#[test]
fn choosing_the_current_workspace_releases_the_tab_pin() {
let pinned = driven("release tab pin", || {
let ctx = shell_ctx();
let mut state = ShellState {
sketching: true,
workspace: registry::Workspace::Solid,
workspace_pinned: true,
..Default::default()
};
let view = SceneView::default();
let at = egui::pos2(35.0, 58.0);
for i in 0..4 {
let mut input = frame_input();
if i >= 2 {
input.events = vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: i == 2,
modifiers: Default::default(),
},
];
}
ctx.run_ui(input, |ui| {
show(ui, &view, &mut state);
})
.drop_without_applying_deltas();
}
(state.workspace, state.workspace_pinned)
});
assert_eq!(
pinned.0,
registry::Workspace::Sketch,
"positive control: tab was clicked"
);
assert!(
!pinned.1,
"clicking the workspace the selection wants must release the pin as its caption promises"
);
}
+3 -1
View File
@@ -176,9 +176,11 @@ fn a_picked_arc_offers_its_radius_and_a_spline_still_offers_nothing() {
..ShellState::default()
};
assert_eq!(selection_of(&state, &view), Selection::Arc);
// Extrude joined the arc's offers when a picked chain became a profile;
// the radius still leads, and it is the card that floats.
assert_eq!(
crate::toolbar::actions_for(Selection::Arc),
vec![Action::EditDimension]
vec![Action::EditDimension, Action::Extrude]
);
state.picked_curves = vec![test_id(3)];
+87 -2
View File
@@ -163,14 +163,43 @@ pub fn card_for(selection: Selection) -> Option<&'static registry::CommandDescri
) {
return None;
}
// **A COMMAND THE PERSON DID NOT REACH FOR NEVER PRE-EMPTS THE ONE THEY
// DID.** The table walks `COMMANDS` in registry order (hole before lock
// for a picked point, distance before hole for two — every script and
// the M3 gates rely on that), with a `Surface::Value` row winning across
// the whole list (a picked circle's radius over its extrude). The one
// exception is `extrude`: it is Ready for a picked line, two lines or a
// line and a circle because a selected chain can BE a profile, and it is
// registered before every dimension — so it floated its editor over a
// line whose length the person was about to type (measured on the
// branch: every script that clicked a line and typed a length went red
// the day extrude became Ready for a line). Extrude floats only where
// nothing else with a field is Ready, which is exactly a closed profile.
let ready: Vec<&'static registry::CommandDescriptor> = registry::COMMANDS
.iter()
.filter(|command| command.takes_input())
.filter(|command| registry::readiness_for(command, selection) == registry::Readiness::Ready)
.collect();
// Only where a dimension is on offer: for a closed profile, a circle or
// an arc, extrude (or the radius, a `Value` row) is exactly what the
// selection is about, and demoting it there would float `section` — the
// first no-pick command with a field — over a shape someone just closed.
let extrude_is_secondary = matches!(
selection,
Selection::Line | Selection::TwoLines | Selection::TwoCircles | Selection::LineAndCircle
);
ready
.iter()
.find(|command| command.surface == registry::Surface::Value)
.or_else(|| {
extrude_is_secondary
.then(|| {
ready
.iter()
.find(|command| command.action() != Some(Action::Extrude))
})
.flatten()
})
.or_else(|| ready.first())
.copied()
}
@@ -717,7 +746,14 @@ pub(crate) fn value_card(
{
continue;
}
if spec.key == "backward"
// THE EXTRUDE CARD'S OWN TWO CONDITIONAL ROWS, and
// only its: `shell` and `shell thickness` carry a
// `thickness` key too, and the first version of this
// rule hid theirs whenever thin walls were off — a
// shell card with nothing in it but "Enter".
let extrude = matches!(action, Action::Extrude | Action::SetExtrudeHeight);
if extrude
&& spec.key == "backward"
&& !matches!(
state.extrude_extent,
vernier_doc::ExtrudeExtent::TwoSided { .. }
@@ -725,7 +761,7 @@ pub(crate) fn value_card(
{
continue;
}
if spec.key == "thickness" && state.extrude_thin_wall.is_none() {
if extrude && spec.key == "thickness" && state.extrude_thin_wall.is_none() {
continue;
}
// THE LABEL FOLLOWS THE CHIP. `card_display` names
@@ -925,6 +961,55 @@ pub(crate) fn value_card(
state.value_card_rect = painted.then_some(area.response.rect);
// **ONE AREA ID SERVES EVERY CARD, SO A TALL CARD INHERITS A SHORT
// CARD'S REMEMBERED SIZE.** egui constrains an `Area` to the screen with
// the size it measured LAST frame; when the lock or hole card (about 120
// px) is replaced by the extrude editor (about 350 px) under the same
// id, the first painted frame overflows the window bottom and the card
// jumps up 25 px one frame later — a visible flicker, and the frame the
// driver aimed its click from (measured: the field it wanted at y=682 was
// painted at 707 for exactly one frame). Redoing the frame lets egui
// constrain with the size it just measured, so the card lands where it
// stays.
//
// **THE WINDOW, NOT THE VIEWPORT, AND ONLY ON A FRAME WITHOUT INPUT.**
// The card sits over the history and status bands by design, so
// "outside the viewport" is its ordinary state; "outside the window"
// is the one-frame misplacement. And a redone frame replays its input,
// so a press or an Enter would be handled twice — the first version of
// this check redid every frame and the second pass's fresh
// `ShellResponse` dropped the Enter that pass one had dispatched.
// THE COMMAND CHANGED UNDER THE SAME ID is the other shape of the same
// misplacement, and the one a shrinking card takes: the radius card that
// replaces the extrude editor is constrained with the editor's height
// and slides down a frame later. Remembered in egui's own frame data,
// keyed by the area, so the shell state grows no field for it.
let area_id = egui::Id::new("vernier-value-card");
let last_action = ui.data_mut(|data| data.get_temp::<Action>(area_id.with("action")));
ui.data_mut(|data| data.insert_temp(area_id.with("action"), action));
let command_changed = painted && last_action != Some(action);
let window = ui.ctx().content_rect();
let quiet = ui.input(|input| {
!input.events.iter().any(|event| {
matches!(
event,
egui::Event::PointerButton { .. }
| egui::Event::Key { .. }
| egui::Event::Text(_)
| egui::Event::Paste(_)
)
})
});
if painted
&& quiet
&& (command_changed
|| area.response.rect.max.y > window.bottom() + 0.5
|| area.response.rect.max.x > window.right() + 0.5)
{
ui.ctx()
.request_discard("value card changed under its area id");
}
if preview_requested && !bodies::blocked_for_rebuild(state, action) {
let card = state.card.clone();
if super::parameter_card::prepare(state, action) {
@@ -0,0 +1,127 @@
# Merge record — main into the reliability branch, 2026-09-10
Two trees existed on 2026-09-10 morning: `main` at `c0d74cb` (the 2026-09-08 audit
docs plus one Codex commit, "Big feature update by Codex": live sketch drawing
preview, gamma-space UI compositing, HiDPI viewport scale, gizmo axis latch,
tab-pin release, four rebaselined PNG goldens) and
`codex/reliability-and-function-verification` at `44aa92c` plus an uncommitted
batch 5 (planar emboss/engrave), 36 commits and roughly 100k lines ahead of the
common base `b3f661f`. The user's decision: everything is accepted; merge it all
to main. This file records what the merge did and what it found, because the
branch's own status documents describe a gate that had not been run since the
mechanical-modelling batches landed.
## What was merged and how
- Batch 5 was committed on the branch as it stood (`29dcdf6`): it builds, its
two focused document tests pass, and its drive script `planar-lettering.json`
fails at step 23 (no `emboss:body:block` control is painted) — the batch is
unfinished and the script stays out of `scripts/check.fish`.
- `main` was merged into the branch. Thirteen files conflicted: `README.md` and
two 2026-09-08 docs (branch versions kept, they are supersets), four PNG
goldens (regenerated below), `app.rs`, `headless.rs`, `preview.rs`,
`server/mod.rs`, `shell/tests.rs`, `check.fish` (both sides kept, restructured
by hand). `AGENTS.md` is byte-identical on both sides.
- Ported from main onto the branch's structure: the live drawing preview
(`DrawingState` now serialises across the process codec and snaps with the
click's own camera-derived radius, `None` under Alt or with snapping off,
instead of main's fixed 2 mm); gamma-space UI compositing in the window and
the offscreen harness; the HiDPI viewport scale; the gizmo axis latch; the
tab-pin release; `distance_mm` starting at 0.
- Dropped from main, deliberately: `Edit::ReplacePushPull` (the branch's
acknowledgement barrier and `GizmoEnd` already make a preview replacement
atomic; the test written for the dropped edit went with it) and the rule
that withholds the extrude card when a profile closes. On the branch the
closed-profile card is an editor with target chips, extent controls and
Preview/Apply/Cancel, anchored bottom-right rather than over the sketch, and
sixty accepted drive steps type into it directly. Whether a closed profile
should float that editor at all is an open UX question for the audit; main's
eight scripts lost their inserted `tab:solid → ribbon:extrude → tab:sketch`
steps, which under the branch's arming rule dispatch an extrusion at the
default height instead of aiming the card.
## What the branch's own gate looked like before any of this
Run on the branch at `29dcdf6` (pre-merge, release, RADV, `--once
--skip-png`): **48 of 86 scripts pass**. The failures fall into five classes,
none of them caused by the merge:
1. Seven scripts assert document format 35; the code is at 40 (batches 1–5
never re-ran the suite). Two more assert an extrude payload from before
extents existed.
2. Any script that clicks a sketch line and types its length gets the extrude
editor instead: `extrude` became Ready for a line and is registered before
`length`, so `card_for` floated it. Eight scripts.
3. Any script that clicks a value card on the frame it first appears misses
the field by 25 px and the typed number is dropped, or an Enter with no
focused field ends the sketch. Every value card shares one egui `Area` id,
so a card that changes size is placed with the previous card's height and
corrected one frame later. Eleven scripts, and a visible flicker for users.
4. Hiding a body, undoing metadata, or previewing a boolean sent a scene whose
extrusion support-face list named a face the face metadata no longer
carried; the process codec treats that as a protocol failure and abandons
the worker. Five scripts died with "the document worker is unavailable".
5. A boss extruded on a face after a cut went into the body and joined nothing:
the card stored the cut's `reverse: true` as the default for the next
extrusion (only the Cut chip was reset). One script, and the exact hazard
the 2026-09-08 audit listed as P2 in a new shape.
Also found on the way: the shell-thickness card painted with no field, because
the thin-wall rule that hides the extrude card's `thickness` row matched every
command with a `thickness` key.
## Fixes made in this merge
- `value_card.rs`: a card that grew or changed command under the shared area
id redoes its frame (only on a quiet frame — a redone frame replays input);
`card_for` demotes `extrude` behind a dimension for line selections; the
thin-wall row rule is scoped to the extrude card.
- `parameter_card.rs` / `bodies.rs`: the defaults recorded after a successful
extrusion carry the numbers, not the direction or wall mode; a fresh
extrusion starts outward unless a cut on a face or an explicit chip says
otherwise.
- `server/mod.rs`, `server/body_scene.rs`, `scene.rs`: the support-face list is
built from the face metadata the scene carries, and every production reply
is aligned once more at the exit.
- `parameter_card.rs`: the derived extrude editor floats only for a picked set
that one of the profile routes (closed or thin wall) accepts; a construction
curve or a degenerate pair no longer gets a card whose Enter can only be
refused. A ribbon press still aims it.
- `vernier-drive/src/run.rs`: a `card:` or `chip:` click waits (bounded) until
the card's rect is inside the window and unchanged for two frames.
- Tests the branch's batches had already broken, repaired to the batches'
own behaviour: the impossible-hole refusal (now at the command), four
extrude-card tests that expected one field (there are three), the arc's
offers (extrude joined), an invalid picked set (the toolbar offers extrude
for two lines since thin walls), the driver's role enum (21, was 17), the
driver's clipped-row refusal message, the README's check count (45), and
main's three frame tests ported to the branch's thread fixture.
- Scripts: version 35 → 40 in seven; the two stale extrude payloads; the seven
main-modified scripts restored to the branch's versions;
`ux-sketch-feedback.json` types into the auto-floated card.
- Goldens: all five PNGs regenerated on RADV from the merged build (gamma
compositing moves every one; the branch's four measured mean 2.4–2.5 against
the old files, main's live-line golden 7.0 because it was painted without the
Items panel).
## Gate after the merge
Drive suite, release, RADV, cross-process (the invariant #1 double run):
**81 of 86 pass and all 81 are deterministic across two processes**; the
other five are the four negative controls, each failing at its designed step
with exit 1 (L1 at 73, L2 at 247, L4 at 1101, two-body at 144), and
`planar-lettering`. PNG goldens compared on RADV for the four scripts that
carry them. Workspace tests, clippy and fmt: green at the merge commit, after
repairing two app tests the branch's batches had already broken (the
impossible-hole test expected a compile-time refusal the countersink batch
moved into the command; the draft-order test expected a one-field extrude
card from before extents) and porting main's three frame tests to the
branch's thread fixture.
## Still open, for the audit
- The closed-profile editor card floats automatically (see above).
- The 2026-09-08 audit's remaining items that this branch did not close.
- `planar-lettering` (batch 5) is unfinished.
- The one-frame card jump is prevented on quiet frames only; a card that
changes size on the same frame as a click still moves next frame.
Binary file not shown.
+5
View File
@@ -340,3 +340,8 @@ and cargo run -q -p vernier-drive -- scripts/drive/thin-wall-open-chain.json \
and cargo run -q -p vernier-drive -- scripts/drive/sketch-text.json \
--out target/drive/sketch-text --require-adapter RADV
# Live sketch geometry, snap label, deliberate extrusion, and exported volume.
and cargo run -q -p vernier-drive -- scripts/drive/ux-sketch-feedback.json \
--out target/drive/ux-sketch-feedback --require-adapter RADV
and cargo test -q -p vernier-app ui_half_coverage -- --ignored
and cargo test -q -p vernier-app a_plain_pointer_move -- --ignored
+3 -3
View File
@@ -21,7 +21,7 @@
{ "step": "wait_idle" },
{ "step": "expect_no_error" },
{ "step": "expect_document", "path": "{out}/part.vernier",
"pointer": "/version", "equals": 35 },
"pointer": "/version", "equals": 40 },
{ "step": "click", "at": "text:$" },
{ "step": "frames", "count": 2 },
@@ -36,7 +36,7 @@
{ "step": "expect_stl", "path": "{out}/part.stl",
"volume": 18000.0, "tol": 0.02, "min_triangles": 12 },
{ "step": "expect_document", "path": "{out}/part.vernier",
"pointer": "/version", "equals": 35 },
"pointer": "/version", "equals": 40 },
{ "step": "click", "at": "text:$" },
{ "step": "frames", "count": 2 },
@@ -52,7 +52,7 @@
"volume": 18000.0, "area": 4500.0, "centroid": [20.0, 15.0, 7.5],
"solids": 1, "faces": 6, "tol": 1e-9 },
{ "step": "expect_document", "path": "{out}/part.vernier",
"pointer": "/version", "equals": 35 },
"pointer": "/version", "equals": 40 },
{ "step": "click", "at": { "by": "face", "face": { "by": "named",
"feature": "block", "key": { "by": "role", "role": "prism-end" } } } },
+24
View File
@@ -228,6 +228,12 @@
11
],
"height": 2.0,
"extent": {
"Distance": {
"reverse": false
}
},
"thin_wall": null,
"target": {
"NewBody": {
"body": 13
@@ -301,6 +307,12 @@
11
],
"height": 3.5,
"extent": {
"Distance": {
"reverse": false
}
},
"thin_wall": null,
"target": {
"NewBody": {
"body": 13
@@ -354,6 +366,12 @@
11
],
"height": 2.0,
"extent": {
"Distance": {
"reverse": false
}
},
"thin_wall": null,
"target": {
"NewBody": {
"body": 13
@@ -407,6 +425,12 @@
11
],
"height": 3.5,
"extent": {
"Distance": {
"reverse": false
}
},
"thin_wall": null,
"target": {
"NewBody": {
"body": 13
+1 -1
View File
@@ -1302,7 +1302,7 @@
"step": "expect_document",
"path": "{out}/l2.vernier",
"pointer": "/version",
"equals": 35
"equals": 40
},
{
"step": "expect_document",
+1 -1
View File
@@ -1302,7 +1302,7 @@
"step": "expect_document",
"path": "{out}/l2.vernier",
"pointer": "/version",
"equals": 35
"equals": 40
},
{
"step": "expect_document",
+4 -1
View File
@@ -1,6 +1,9 @@
{
"name": "m1-smoke-push-pull",
"notes": [
"2026-09-09: PNG rebaselined for gamma-space UI coverage blending. The",
"GPU regression measures half-covered black on white as 127 instead of 187.",
"The earlier GL drift measurements below are historical; this golden is RADV-only.",
"THE M1 SMOKE: pick the starter block's top face in the real shell, type a",
"distance into the value card, commit it, and assert the exported geometry.",
"Every number below is DERIVED here because JSON has no comments and a",
@@ -144,7 +147,7 @@
{ "step": "wait_idle" },
{ "step": "expect_document", "path": "{out}/smoke.vernier",
"pointer": "/version", "equals": 35 },
"pointer": "/version", "equals": 40 },
{ "step": "expect_document", "path": "{out}/smoke.vernier",
"pointer": "/feature:2/payload/PushPull/distance",
"equals": 5.0, "tol": 1e-12 },
+1 -1
View File
@@ -275,7 +275,7 @@
"step": "expect_document",
"path": "{out}/project.vernier",
"pointer": "/version",
"equals": 35
"equals": 40
},
{
"step": "expect_document",
+1 -1
View File
@@ -56,7 +56,7 @@
{ "step": "wait_idle" },
{ "step": "expect_document", "path": "{out}/cut.vernier",
"pointer": "/version", "equals": 35 },
"pointer": "/version", "equals": 40 },
{ "step": "expect_document", "path": "{out}/cut.vernier",
"pointer": "/state/features/18/payload/Sketch/points/27/0",
"equals": 62.0, "tol": 0.2 },
+1 -1
View File
@@ -115,7 +115,7 @@
"step": "expect_document",
"path": "{out}/selected.vernier",
"pointer": "/version",
"equals": 35
"equals": 40
},
{
"step": "export_step",
@@ -150,6 +150,12 @@
"sketch": 1,
"profile": [8, 9, 10, 11],
"height": 2.0,
"extent": {
"Distance": {
"reverse": false
}
},
"thin_wall": null,
"target": { "NewBody": { "body": 13 } }
}
},
@@ -201,6 +207,12 @@
"sketch": 1,
"profile": [8, 9, 10, 11],
"height": 2.0,
"extent": {
"Distance": {
"reverse": false
}
},
"thin_wall": null,
"target": { "NewBody": { "body": 13 } }
}
},
+133
View File
@@ -0,0 +1,133 @@
{
"name": "ux-sketch-feedback",
"document": "empty",
"size": [
1600,
1000
],
"camera": {
"target": [
0,
0,
0
],
"distance": 120
},
"notes": [
"The pending line snaps to horizontal and the 1mm grid before committing. The screenshot pins its rubber-band outline and snap label.",
"Four line clicks make a 40x30mm rectangle. Closing it floats the extrude card (bottom right, off the sketch); the height is typed into it and Enter commits. The 10mm extrusion must export 12000mm^3."
],
"steps": [
{
"step": "click",
"at": "ribbon:sketch"
},
{
"step": "wait_idle"
},
{
"step": "click",
"at": "text:Line"
},
{
"step": "click",
"at": "world:-20,-15,0"
},
{
"step": "wait_idle"
},
{
"step": "move",
"at": "world:20.3,-14.8,0"
},
{
"step": "wait_idle"
},
{
"step": "screenshot",
"path": "{out}/live-line.png"
},
{
"step": "expect_png",
"golden": "ux-live-line.png",
"max_mean_diff": 2.0,
"max_hot_pixels": 150
},
{
"step": "click",
"at": "world:20,-15,0"
},
{
"step": "wait_idle"
},
{
"step": "click",
"at": "world:20,15,0"
},
{
"step": "wait_idle"
},
{
"step": "click",
"at": "world:-20,15,0"
},
{
"step": "wait_idle"
},
{
"step": "click",
"at": "world:-20,-15,0"
},
{
"step": "wait_idle"
},
{
"step": "expect_selection",
"is": "profile"
},
{
"step": "screenshot",
"path": "{out}/closed-profile.png"
},
{
"step": "click",
"at": "card:height"
},
{
"step": "key",
"key": "ctrl+a"
},
{
"step": "type",
"text": "10"
},
{
"step": "key",
"key": "Enter"
},
{
"step": "wait_idle"
},
{
"step": "expect_no_error"
},
{
"step": "expect_feature_count",
"is": 2
},
{
"step": "export_step",
"path": "{out}/box.step"
},
{
"step": "wait_idle"
},
{
"step": "expect_step",
"path": "{out}/box.step",
"volume": 12000.0,
"solids": 1,
"tol": 1e-09
}
]
}