Commit graph

95 commits

Author SHA1 Message Date
Arena Agent
bdf882b817 perf(cad): regenerate the parts script on drag commit, not per frame (3.6)
Some checks are pending
nigig-build (CAD) / cad-module (push) Waiting to run
nigig-build (CAD) / full-crate-check (push) Waiting to run
repo hygiene / hygiene (push) Waiting to run
nigig-build (CAD) / supply-chain (push) Has started running
`script_dirty` was set on every MouseMove and FingerMove of a part drag.
That makes `sync_parts_from_any_dirty_viewport` call
`generate_parts_script()` -- formatting every part into a String -- and
the workspace then replaces the entire editor document via
`set_editor_text_all`. Per motion event.

Measured before changing it: 42 us at 50 parts, 170 us at 200, 425 us at
500, and that is the string formatting alone, before the code editor's
own work. See bench_parts_script_regeneration_per_drag_frame.

The reason it was set mid-drag no longer holds. The comment said it kept
the split 2D/3D viewports in sync while dragging -- true when each
viewport owned its own parts list, but since Phase 5.2 all three share
one CadDocument. A move IS their state the moment it happens; they need
a repaint, not a resync, and they get one.

Both commit paths already set the flag: the MouseUp arm for mouse
drags, and finish_part_drag for touch (reached from three places). So
the script still regenerates exactly when it needs to -- once, when the
edit is final.

a_drag_regenerates_the_script_on_commit_not_per_frame pins it. It walks
every arm that calls move_selected and asserts none of them set
script_dirty, then asserts the commit paths still exist -- because the
failure mode of this change is not "slow", it is "the script never
updates at all", and a test that only checked the first half would miss
it. Negative test: putting the assignment back fails it with the line
number.

782 lib + 154 integration tests pass. All 13 CI gates pass.
2026-08-02 10:05:45 +00:00
Arena Agent
84ed7d2e43 perf(cad): drop 53 redundant cx.redraw_all() calls (Phase 3.4)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
cx.redraw_all() sets a flag that repaints every widget in the
application. Every one of the 52 calls in viewport.rs, and the 1 in
viewport_2d.rs, sat DIRECTLY after `self.area.redraw(cx)` -- the
targeted redraw was already there and the full-app repaint added
nothing. Verified mechanically before deleting: a scan for
`cx.redraw_all()` not preceded by `area.redraw(cx)` returns zero hits in
both files.

On a drag this ran per motion event: a whole-application relayout to
move one part.

What I did NOT touch, and why:
  workspace.rs (15)      cross-widget coordination. Both viewport sync
                         paths end in a redraw of the OTHER viewports,
                         and that is what makes removing the viewport's
                         own calls safe. Removing these would be a
                         different change with a different argument.
  viewport_input.rs (28) event paths; 13 are not paired with an
                         area.redraw at all, so each needs reading on
                         its own terms rather than a bulk edit.
  cad_editor_sheet.rs (2) not the viewport.

The risk here is a missed repaint, which no test can see, so I checked
the mechanism rather than relying on the suite staying green: cross-
viewport repaint runs through sync_parts_from_any_dirty_viewport (which
calls vp.redraw on each destination) and
sync_view_from_any_dirty_viewport (which ends in its own redraw_all).
Both live in workspace.rs and are untouched.

the_viewport_does_not_ask_the_whole_app_to_repaint pins it as a source
check, because asserting on repaints needs a live Cx the suite does not
have. Negative test: reintroducing one pairing in viewport_2d.rs fails
it with the file and line named.

781 lib + 154 integration tests pass. All 13 CI gates pass.
2026-08-02 10:02:20 +00:00
Arena Agent
73c4c49cb9 perf(cad): cache the world AABB per placement -- 230us -> 39us (Phase 3.2)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Measured before building, because I had previously dismissed this item
as "smaller" without checking. It is 5.9x at 500 parts, and hover
picking runs on mouse-move, so it is a per-frame cost.

pick_part's broad phase transformed 8 local corners by the model matrix
for every part on every pick. Phase 5.3 had already removed the
expensive half (it no longer re-meshes to read bounds), leaving 8 matrix
multiplies per part -- cheap individually, 230 us/frame at 500 parts.
SceneCache::world_aabb_for now memoises the result.

The key is a NEW type, PlacedHash, not the existing ParamHash. This is
the whole subtlety of the change: ParamHash deliberately excludes the
transform, because a local-space mesh cannot change when a part moves
(Phase 5.4). A world-space AABB is exactly the opposite -- moving the
part is the entire point. Reusing ParamHash here would serve a stale box
after every drag and make parts unpickable at their new position, which
is the picking equivalent of the stale part_geoms bug.

Making it a distinct type rather than "ParamHash plus a flag" means the
two cannot be confused at a call site.

a_move_invalidates_the_world_aabb_even_though_it_keeps_the_mesh pins the
asymmetry directly: the same move that rebuilds the AABB must still hit
the mesh cache. Negative test: making PlacedHash ignore the transform --
i.e. reverting it to ParamHash -- fails that test. Restored and green.

retain_world_aabbs is paired with every retain_meshes call site, for the
same reason that one exists: the map is keyed by NodeId and nothing
drops an entry when its node is deleted, so without it the map grows for
the session.

775 lib + 154 integration tests pass. All 13 CI gates pass.
2026-08-02 09:58:23 +00:00
arena-agent
54e1148b39 feat(doc): round-trip tabular clipboard payloads with RFC-4180 quoting
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
doc-engine / engine (push) Successful in 16s
doc-engine / consumer (push) Successful in 3m58s
Cells holding tabs, newlines, CRs, or quotes re-distributed across the
grid on a copy/paste cycle — the documented caveat of every tabular
clipboard milestone. This closes it on both sides:

- table_grid_tsv quotes such fields on the way out (wrapped in double
  quotes, inner quotes doubled) via a new quote_tabular_field helper;
  plain and empty fields stay raw, so payloads stay byte-compatible
  with spreadsheets and plain text editors. The cell-range payload and
  the block-span document payload share the one builder, so both
  inherit the quoting at once.
- paste_table_payload replaces its split('\n')/split('\t') walk with
  split_tabular_payload, an RFC-4180-style tokenizer: quotes open only
  at field start (mid-field quotes are literal), doubled quotes read
  as one, tabs/newlines/CRs inside quotes are literal field text,
  CRLF rows outside quotes keep their tolerance, an unterminated
  quote reads to the end as best effort, and a single trailing
  newline adds no phantom row (a deliberate empty row survives).
- Caret parking, no-op skipping, empty-field clears, and the one-undo
  grouped write semantics of the raw paste milestone are unchanged;
  the caret offset in a multi-line value counts the newline too.

Tests: unit coverage for the writer and the tokenizer (every quoting
rule plus the quote/split round-trip property), runtime coverage of
copy quoting, paste restoring embedded tab/newline values verbatim
with one-undo and redo, and an end-to-end copy-cut-paste cycle; a
doc-engine materialize test pins special-character cell text
surviving peer sync and undo/redo verbatim. README caveats updated
and the milestone documented.
2026-08-02 09:54:40 +00:00
Arena Agent
07cef07dfb feat(cad): async exports with a save dialog (Phase 2.6 + 3.7)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Both plan items are the same call, so doing them separately would mean
writing the plumbing twice: the file picker's `save_data` takes owned
bytes, and serialising off the UI thread is what produces owned bytes.

Before: every export serialised on the UI thread straight into a File at
a hardcoded path -- generated/stl/model.stl, generated/3d/model.glb,
generated/floor_plan.pdf. A large scene froze the editor for the whole
serialise, and the second export of a session silently destroyed the
first.

Now:
  PDF, STL   spawn_export_to_target -> worker thread -> save dialog
  SVG        stays sync (the preview widget needs the bytes on the UI
             thread, so there is nothing to move) but gains the dialog
  CLI        already a String in memory; gains the dialog
  3D viewer  async, but stays directory-based ON PURPOSE: it writes TWO
             files and viewer.html references model.glb by relative
             name, so renaming the GLB through a picker would break it
  Bake       stays directory-based: writes the parts.obj/parts.cad pair,
             a workspace artefact rather than a document, and
             Solid::write_obj takes a path not a writer

Results come back through an mpsc channel drained on NextFrame, the same
mechanism the rebuild worker already uses -- not a second bespoke one.
The status says "exporting…" while in flight, and the completion message
for a dialog export says "ready — choose where to save" rather than
claiming the file is written, because at that point it is not. Reporting
success before the write is the exact bug fixed in the Save buttons
earlier.

The 3D companion HTML is now only written if the GLB actually landed.
Previously a truncated GLB still got a viewer.html beside it, which is
how "export succeeded" turned into a blank page.

ExportTarget::suggest gives each export a distinct default name
(<stem>-<project>-<counter>.<ext>) so successive exports do not propose
to overwrite each other.

sanitize_file_stem exists because a project name is user text that ends
up in a save dialog. It can contain a path separator, "..", a NUL, a
leading dash, or 300 characters of emoji. The test found a real bug in
my first version: replacing "/" with "_" turns "../../etc/passwd" into
"_.._.._etc_passwd", so trimming leading dots BEFORE the replacement
leaves "_.." behind. Trim after, and include "_".

Deleted in the same commit as their cause: write_floor_plan_pdf and
export_to_file, both now unreachable. Verified no callers remain
anywhere in crates/.

New tests: bytes land at the requested path; a failed write is reported
rather than swallowed (parent is a regular file, so create_dir_all
cannot succeed); an empty export still produces a file; the hostile
project-name corpus; successive suggestions do not collide.

772 lib + 154 integration tests pass. All 13 CI gates pass.
2026-08-02 09:52:27 +00:00
arena-agent
a66d7d2198 feat(doc): carry table grids in document-level clipboard payloads
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
A block-span selection — Shift+Arrow across a table, select-all,
any multi-block drag — used to copy blank lines where tables sat,
so copying a document lost every table's content. Table blocks now
contribute their whole grid at their block position as tab/newline
lines, built by the same table_grid_tsv helper the cell-range
payload uses (extracted from cell_range_clipboard_text): one
builder, one convention, no drift between "copy a range" and
"copy across a table". Stored cell text exports verbatim — a
merge's covered cells keep their hidden values — and empty tables
still contribute nothing.

Cutting such a span was already structurally correct through
replace_block_range / CancelBlockRange, so the milestone is
payload-only: the payload now matches what actually disappears —
verified by a cut over [paragraph, 2x2 table, paragraph] draining
the document with "lead\na\tbc\nd\te\ntail" in the payload and one
undo restoring blocks AND every cell value. Also covered:
select-all through the TextCopy hit, and a partial mid-paragraph
span splicing the grid between its text fragments in order. The
stale "tables are skipped" select-all bullet is retired.
2026-08-02 09:35:13 +00:00
Arena Agent
c6e7635c5a test(cad): fuzz the coordinate parser; audit all 84 indexing sites
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
I dismissed indexing_slicing as "mechanical churn" without checking.
That was an unchecked claim about 105 reported panic sites, which is
exactly what I have been objecting to elsewhere in this codebase. Audited
all of them.

105 clippy hits are 84 unique lines. Every one is bounded:

  43  fixed-size arrays indexed by a literal or a 0..N loop whose bound
      matches the array -- mat4_mul, mat4_inverse, ray_aabb_intersect,
      the 8-corner projected box. Unindexable by construction.
  ~30 behind an explicit length check in the same function:
      verts.len() >= 12 (I-beam web), >= 8 (HSS inner wall),
      pts.len() == 4, chamfer_rect's `< 4` early return, polygon_area's
      `n < 3`, pick_part's per-triangle bounds test.
   ~11 `% len()` on a non-empty slice, or selection[i] over
      0..selection.len().min(3).

Converting these to .get() adds ~84 `else { continue }` arms guarding
conditions the compiler or an adjacent check already rules out, each one
a place to get the fallback subtly wrong. Not gated; the audit is
recorded in ARCHITECTURE.md 2c so the next reader gets the evidence
rather than the dismissal.

The one genuinely input-facing site is fuzzed instead.
parse_coord_input takes raw text from the coordinate box on every
keystroke and indexes parts[0..2] after a split. Two new tests: an
adversarial corpus (multi-byte leading characters, lone separators, RTL
and combining marks, 500-char inputs, inf/NaN/1e400) and every
char-boundary prefix of a valid input, because the box parses as you
type.

Building the corpus is where the actual work was. My first version --
garbage strings -- passed even with the length guards deleted, because a
first component that fails to parse makes `?` return before the second
index is evaluated. It looked like a strong test and tested nothing. The
cases that reach the guards are the ones whose FIRST component is valid:
"@5", "5<", "@5<45".

Negative test, per guard:
  spherical  parts.len() == 3 removed -> FAILS, index out of bounds
  polar      parts.len() == 2 removed -> FAILS, index out of bounds
  relative   parts.len() >= 2 removed -> still passes, and that is
             correct: the branch is gated on contains(','), and a string
             containing a comma always splits into at least two parts,
             so the check is redundant. Verified rather than assumed;
             left in place because it states intent locally.

763 lib + 154 integration tests pass. All 13 CI gates pass.
2026-08-02 09:33:31 +00:00
Arena Agent
072dcde979 docs(cad): record Phase 5/6 outcomes, including the two rejections
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Updates the file table in ARCHITECTURE.md for the three new files and
corrects the viewport.rs/workspace.rs descriptions, which still claimed
to hold the rendering and input code that moved out. A doc that lies is
worse than no doc.

Marks the plan items honestly:

  5.5 DONE   viewport.rs 8,023 -> 4,796. The original six-file target was
             not met and is not being pursued: the remaining 4,796 lines
             have no seam comparable to the two that were taken.
  5.6 DONE   workspace.rs 3,991 -> 3,273.
  5.7 REJECTED after counting. "Branched on in 40 methods" was 14, and
             21 of the 37 branches were in one function.
  5.8 REJECTED. The premise is false -- kind is not derivable from the
             solid, because six PartKinds share CadSolid::Box.

Phase 6 marked partially done, with what is left stated plainly: the
indexing_slicing ratchet is 105 sites in CAD, mechanical churn rather
than defect-finding, and the panic family it was really aimed at is now
at zero and gated.

Recording the rejections in the plan matters as much as the completions:
both items would have destroyed something real, and the next reader
should find the counter-evidence rather than the instruction.
2026-08-02 09:21:22 +00:00
Arena Agent
015cf44422 fix(cad): remove the reachable panics; gate unwrap/expect at 5
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Phase 6, scoped to what is provable rather than a blanket -D warnings.

Measured the CAD module first: ~200 clippy warnings, but the panic
family -- the part the plan actually cared about, copying the pay
crates' ratchet -- was only 8: 2 unwrap, 6 expect, 0 panic!. Three were
real, five are genuine constructor invariants.

Fixed:

- code_editor.rs x2. `lazy_init_session(); self.session.as_mut().unwrap()`
  in both draw_walk and handle_event. Correct today, but the guarantee
  lived across a function boundary the compiler cannot see, so an
  unwrap sat on a widget draw path waiting for a third caller to forget
  the prologue -- and a panic there kills the editor with unsaved work
  in it. Added `editor_and_session()`, which splits the borrow and
  returns Option, so both sites take an early return instead.

  I first tried folding init into `get_or_insert_with`. That silently
  dropped the `keep_cursor_in_view = Once` side effect, which only
  happens on the create path. Caught it by grepping for the field rather
  than trusting the refactor; reverted.

- cad_scene.rs x1. MeshCache::get_or_build did
  `.write().expect("mesh cache poisoned")` while every other method on
  the type already degraded with `if let Ok(..)`. Reachable: the export
  path calls get_or_build on a spawned thread, so one panicking worker
  poisoned the lock and the next draw took the UI thread down with it.
  The cache is pure derived data -- every entry rebuilds from its node
  -- so a poisoned lock now costs memoisation, not correctness. The mesh
  is built before the lock is taken, and the double-check still prefers
  a racing thread's entry so Arc::ptr_eq comparisons stay consistent.

Left alone, with reasons: 4 x cad_scene "default material/layer always
exists" (SceneBuilder::new inserts both; verified) and 1 x arch_gltf
serde_json::to_vec over a Value built in that file.

New gate: "No new unwrap/expect in CAD production code", allowlist of 5.
A bare count drifts upward quietly and a blanket ban just gets
#[allow]-ed, so the count is pinned and each exemption is named in the
comment.

The gate skips #[cfg(test)] by BRACE DEPTH rather than stopping at the
first one. That matters: arch_gltf.rs has production code after two test
modules, so the existing panicking-macro gate's "stop at first
#[cfg(test)]" awk cannot see line 850 at all. My first attempt used the
same awk idiom and reported 4 of 5 -- I only noticed because the number
disagreed with clippy. Verified the older macro gate is not currently
hiding anything, but it is hiding it by luck.

Both negative tests pass: an unwrap added to viewport.rs is caught, and
one added to arch_gltf.rs *after* its test modules -- the exact blind
spot -- is also caught, named with file and line.

13 gates now, all green. 761 lib + 154 integration tests pass.
2026-08-02 09:20:12 +00:00
arena-agent
4df2193859 docs(doc): retire stale in-cell paste and migration-status claims
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 15s
doc-engine / consumer (push) Has been cancelled
Tabular paste superseded the clipboard section's "in-cell pastes
always stay a single whole-cell write, newlines included", and the
doc-engine README still framed the controller migration as "next"
even though the workspace navigation already runs on CrdtDocEditor
(the classic editor stays registered as a fallback). Found during
the phase audit.
2026-08-02 09:17:57 +00:00
Arena Agent
b26f4c8b91 refactor(cad): split handle_event into pointer and touch dispatch
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Phase 5.7. handle_event was 952 lines holding two independent dispatch
loops -- one over raw Event (mouse buttons, motion, wheel), one over
hit-tested Hit (finger down/move/up/scroll). They share no locals, so
the seam was already there.

The plan item asked to "consider making 2D/3D two widgets" because the
view mode was "branched on in 40 methods". I counted before acting: 14
methods, 37 branches, and 21 of those branches are inside handle_event
alone. The remainder are mostly one-liners picking a pan axis or a zoom
helper.

Two widgets would have duplicated the entire input layer to delete a
handful of matches! guards, and doubled the surface the shared
CadDocument must keep coherent -- which is precisely what Phase 5.2 was
spent removing. The size problem was the event handler, not the enum.
Split the handler; keep the enum. Reasoning recorded in the module doc
so the plan item is not re-attempted from its original framing.

Verified as a move rather than a rewrite: the sequence of Event::/Hit::
match arms across viewport.rs + viewport_input.rs is identical to HEAD's
-- 41 arms, same order -- and the 3D-only desktop camera fall-through
appears exactly once, as before. Order between the two loops is
preserved and load-bearing: pointer arms set drag state the hit arms
read.

viewport.rs: 5,702 -> 4,796 lines (8,023 at the start of Phase 5.5).

761 lib + 154 integration tests pass. All 12 CI gates pass.
2026-08-02 09:14:22 +00:00
Arena Agent
f02de5645f refactor(cad): split handle_actions into three per-panel handlers
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
Phase 5.6. `CadWorkspace::handle_actions` was a single 1,133-line
method: one flat sequence of `if button.clicked(actions)` arms covering
every control in the editor. Adding a control meant reading all of it to
find where the related ones lived.

Split along the seams the comments already marked -- the panel groupings
existed, they just were not expressed in the code:

  handle_view_and_plane_actions    work/inclined planes, per-axis grids,
                                   reference and clip planes,
                                   construction toggles, coordinate input
  handle_export_and_file_actions   CLI/OBJ/PDF/3D/STL/SVG export, the AI
                                   attach-image and model controls,
                                   open/save/save-as
  handle_properties_panel_actions  colour swatches, layer/material
                                   dropdowns, per-axis position/rotation/
                                   size inputs, DOF toggles

Order is the risk in splitting a sequential handler: several arms depend
on running after an earlier one has updated state. Verified mechanically
rather than by reading -- the full sequence of `ids!(..)` widget
references through handle_actions plus the three extracted bodies is
byte-identical to HEAD's, 147 references in the same order.

19 helpers became pub(super). That is not a widening: they were already
reachable from anywhere in the cad module, and pub(super) keeps them
exactly there. Nothing was made `pub`.

workspace.rs: 3,991 -> 3,273 lines.

761 lib + 154 integration tests pass. All 12 CI gates pass.
2026-08-02 09:11:20 +00:00
Arena Agent
1bc5d792a8 refactor(cad): extract the 25 draw_* methods into viewport_render.rs
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
Phase 5.5, first cut. viewport.rs was 8,023 lines with a single
5,930-line `impl CadViewport`. The drawing methods are the largest
coherent seam in it: 25 methods, 2,321 lines, that only read editor
state and emit draw calls.

Rust merges inherent impl blocks for the same type across files, so this
is a pure move -- no signature changed and no caller was touched.
Verified mechanically rather than by eye: the set of method names across
viewport.rs + viewport_render.rs is byte-identical to the set in
viewport.rs at HEAD, 187 before and 187 after, none lost, none gained.

Two items needed pub(super) -- `part_model_matrix_cadnode` and
`DrawCadMesh::draw`. That is not a widening: both were already reachable
from anywhere in the cad module, and pub(super) keeps them there. No
item was made `pub`.

The module doc states the rule for what belongs in the file: a method
lives here if it takes &mut Cx2d/&mut Cx3d and emits drawing commands.
Anything that DECIDES what to draw -- picking, snapping, hit-testing,
tool state -- stays put. Deliberately mechanical, because the previous
attempts to split this file were judgement calls and did not hold.

viewport.rs: 8,023 -> 5,702 lines.

---

Phase 5.8 (remove kind_hint): REJECTED, with the counter-example
recorded as a test rather than a comment.

The plan item says to "derive kind from the solid variant". That is not
possible. Six PartKinds -- Cube, Wall, Slab, Door, Window, Beam -- all
build CadSolid::Box, and Cylinder/Column both build CadSolid::Cylinder.
kind_hint is not duplicated state; it is the only record of which one
the user asked for. Removing it would silently downgrade every Wall,
Slab, Door, Window and Beam to a Cube, taking the properties panel's
thickness controls and the Extrude tool's kind filter with it.

several_part_kinds_share_one_solid_variant_so_kind_is_not_derivable
asserts the collision directly, so the next person to read that plan
item finds the evidence instead of executing it.

I also built a script_tag/from_script_tag round-trip so the kind could
survive a save, then deleted it before committing: nothing reads
parts.cad back, so it would have been a write-only annotation -- the
exact "add an abstraction, declare the migration done" pattern this
phase exists to stop. The real gap is that the parts list has no
serialisation at all, which is a feature, not a cleanup.

756 lib + 154 integration tests pass. All 12 CI gates pass.
2026-08-02 09:08:27 +00:00
arena-agent
c5c1e0a3e9 feat(doc): paste tabular payloads across table cells
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 15s
doc-engine / consumer (push) Successful in 3m55s
A TextInput carrying tabs or newlines while the caret lives in a
table now pastes the spreadsheet way — one cell per tab stop, one
row per line — starting at the caret cell, or at the armed range's
normalized top-left (consuming the range like any paste-over-
selection). The distribution rides the grouped set_table_cells from
the previous milestone so the rectangle un-pastes in one undo step,
and the caret parks at the last cell the payload touched. Rows or
columns past the table edge clip, CRLF strips per line like
text-block pastes, empty fields clear their targets, and unchanged
cells are skipped so a paste lands neither redundant LWW ops nor
dead group members. Plain payloads keep the whole-cell char splice,
and cells still never spawn blocks.

The pre-existing in-cell paste test asserting the old "newline stays
embedded in the cell" behavior is rewritten to the distribution
semantics; new runtime tests cover the 2x2 distribution with caret
parking and the one-undo/redo round trip, edge clipping without
wrap-around, backward-spanned ranges pasting from their top-left,
CRLF with empty-field clears, and the mobile menu's Paste action
distributing from the pressed cell. Engine integration coverage
replays a grouped multi-cell write into a peer and asserts the
projections converge.
2026-08-02 09:02:05 +00:00
Arena Agent
5ffc515f91 perf(cad): build the command scene snapshot lazily -- 254us -> 0.8us
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
CadCommandCtx::new eagerly called scene_cache.scene_for(parts), which is
O(nodes): every node cloned, every material re-registered. No production
command reads that scene -- MoveNode, ResizeNode, RotateNode, YawNode,
ModifyNode, CreateNode and DeleteNode all address a node by id. Only
tests and the benchmark call ctx.scene().

Every command mutation bumps the PartsStore generation, so the next
context construction was a guaranteed cache miss. move_selected issues
one command per selected part per frame, making a drag O(commands x
nodes) for work that is O(1) per command. This arrived with Phase 5.1,
when the generation counter turned a previously accidental cache hit
into a guaranteed miss; the 0.4us baseline predated it.

The snapshot is now built on the first scene() call and memoised.
CommandContext::scene() returns Arc<CadScene> rather than &CadScene so
an implementation can build on demand instead of keeping one alive for
the context's lifetime.

This was also a latent CORRECTNESS bug, not only waste. The eager
snapshot was captured BEFORE the command ran, so a command that mutated
and then read scene() saw its own edit missing. Every mutating method
now drops the memo; those invalidate_snapshot() calls are placed
directly beneath the existing scene_cache.mark_dirty() calls so a new
mutator cannot silently miss one.

Two tests, both verified to fail against the old code:
- a_command_that_never_reads_the_scene_does_not_build_one asserts on
  SceneCache::rebuild_count (a new #[cfg(test)] counter) rather than
  wall-clock, so it is deterministic rather than machine-dependent.
  Fails 20-vs-0 when construction is made eager again.
- a_lazily_built_scene_reflects_edits_made_earlier_in_the_same_context
  reads the scene BEFORE mutating, then again after. Reading only after
  the mutation passes even with invalidate_snapshot() gutted -- the lazy
  build simply happens later -- so the first read is what gives the test
  teeth. I checked: the obvious version of this test was vacuous.

The benchmark was also measuring the wrong thing. It called ctx.scene()
each iteration to read the start position, which no production path
does: move_selected and finish_part_drag both read p.pos() off the parts
list. Fixing the lazy build alone moved undo 228us -> 0.5us but left
execute at 231us, because the benchmark was timing its own scene read.
It now mirrors the production callers.

Measured: execute 254us -> 0.8us, undo 246us -> 0.5us (1000 commands
over a 1000-node scene), ~300x. No other benchmark regressed.

755 lib + 154 integration tests pass. Test-name list diffed: +2, nothing
dropped. All 12 CI gates pass.
2026-08-02 08:57:10 +00:00
Arena Agent
11ef0fbf67 fix(cad): GPU buffers self-invalidate; stop re-meshing on every drag frame
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Phase 5.4. Two defects with one root cause: part_geoms was keyed on the
raw node id, so staleness was invisible to the type and correctness rested
on seventeen scattered `part_geoms.remove(&id)` calls at the edit sites.
All seventeen are deleted; the map is now keyed on (id, ParamHash), the
same key MeshCache uses, and an entry whose hash no longer matches its
node is simply never read.

1. subdivide_selected drew the wrong geometry. It replaced each selected
   part's solid with a fresh Csg and invalidated the MESH cache, but never
   removed the part's part_geoms entry -- so the stale uploaded buffer
   stayed a hit and the viewport kept drawing the un-subdivided shape.
   An audit of every mutation path found this was the only edit site
   missing its manual eviction, which is exactly the failure mode a manual
   protocol produces.

2. ParamHash covered the transform and the material, so a pure move
   invalidated the mesh. It should not: build_mesh reads neither. The
   cached TriMesh is in the node's LOCAL space, and all four consumers --
   the draw loop, pick_part, the STL and glTF exporters -- apply the model
   matrix themselves. Dragging a part therefore re-triangulated it on
   every frame to produce byte-identical triangles, at up to 886 ns per
   extruded part per frame, per selected part. The hash now covers the
   solid parameters and nothing else.

Two existing tests asserted the old behaviour and were INVERTED, not
deleted -- they described what the code did rather than what it needed to
do:
  cache_detects_transform_edits_via_param_hash
    -> a_transform_edit_reuses_the_cached_local_space_mesh
  mesh_cache_self_invalidates_on_parameter_and_transform_edits
    -> mesh_cache_self_invalidates_on_solid_parameter_edits

New: build_mesh_output_does_not_depend_on_the_transform guards the
assumption the key now rests on -- if anyone makes build_mesh bake the
transform in, it fails and the key must grow it back. Plus
mesh_cache_hits_on_a_pure_transform_edit, mesh_cache_hits_on_a_material_edit
and an_uploaded_buffer_is_not_reused_after_the_solid_changes. Negative
test: restoring the transform to the hash makes the first two fail;
restored and green.

Deletion is still explicit (`retain` on the live id set) because it is the
one case a content hash cannot express -- there is no node left to hash.

ParamHash is pub(crate), not pub: it is how the caches agree on staleness,
not a consumer contract.

Also recorded in BENCH_BASELINE.md, not fixed here: bench_command_execute
_overhead is 227 us/cmd against a stale recorded 0.4 us. Verified
pre-existing -- 254 us on the commit before this branch, so this work
slightly improves it. CadCommandCtx::new eagerly builds a scene snapshot
that most commands never read, and every command bumps the generation, so
it is O(commands x nodes). The fix is to make that snapshot lazy; it is a
separate change and gets its own commit.

753 lib + 154 integration tests pass. Test-name list diffed, not just the
count. All 12 CI gates pass.
2026-08-02 08:44:42 +00:00
Arena Agent
139f6de25a refactor(cad): one CadDocument shared by all three viewports
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
The three CadViewport instances each owned a private PartsStore, kept in
step by copying the whole list every frame (parts_snapshot /
replace_parts_snapshot). Both are now deleted. They share one
Rc<RefCell<CadDocument>>: an edit in any viewport IS the edit in all of
them, so there is nothing to reconcile.

Copying was not an implementation detail, it was the source of a class
of bugs that were individually fixable and collectively unfixable:

- Only one viewport wins a sync frame, so the loser's id allocations
  were absent from the winner's snapshot and the assignment moved the
  counter BACKWARDS. Two parts drawn in different views between syncs
  got the same NodeId -- which keys MeshCache, part_geoms and every
  command. (Bounded earlier by sharing the allocator; now moot.)
- Breaking on the first dirty viewport left later ones dirty, so the
  loser overwrote the winner's edit on the next frame.
- Each sync deep-copied a Vec<CadNode> per destination and forced a
  full scene rebuild in each: ~98.5 us/frame at 100 parts, sustained
  for the whole of a drag.

sync_parts_from_any_dirty_viewport survives but no longer moves data. It
clears every script_dirty flag in one pass, returns the reporting
viewport's script, and asks the others to repaint.

Reads go through vp.parts() -> Ref<PartsStore>, writes through
vp.parts_mut() -> RefMut. Both borrow self, so "never hold a read guard
across a write" is checked by the compiler rather than left to reviewer
discipline. Where a read loop's body needs &mut self (a draw call), the
handle is cloned first via document() + read_parts() so the guard is
tied to the local Rc instead of to self -- restoring the disjoint-field
borrows the plain struct member used to give for free.

Deleted in the same commit as their cause:
- parts_snapshot / replace_parts_snapshot (40 lines)
- split_for_command + CommandBorrows, which existed only to prove to the
  borrow checker that parts, scene_cache and command_stack were distinct
  fields. The parts list is no longer a field at all;
  with_command_ctx replaces them.
- PartsStore::as_mut_vec, the last migration escape hatch. Its two
  remaining callers only wanted to edit fields, so they use a new
  iter_mut(); structural changes go through the specific methods, which
  is what makes "the generation moved" mean something.

share_document runs from request_rebuild as well as
initialize_split_viewports: the split viewports are created lazily, and
one that missed the join would quietly edit a document nobody can see.

Selection, part_geoms, scene_cache and camera state stay per-viewport by
design -- what is highlighted is a property of a view, not the model.

Test swap, verified by diffing the full test-name list rather than the
count (both are 761): command_borrows_fields_accessible is gone -- it
constructed a struct literal and read its fields back, asserting
something the compiler already guarantees, and would have passed against
every sync bug this removes. In its place,
an_edit_through_one_document_handle_is_visible_through_the_others
asserts the actual property. Negative test: replacing the shared handles
with three independent documents makes it fail (left: 0, right: 1);
restored and green.

746 lib + 154 integration tests pass. All 12 CI gates pass.
2026-08-02 08:30:09 +00:00
arena-agent
08c3eed18d feat(doc): copy and clear cell ranges through the clipboard
Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
An armed table cell range now copies as tab/newline text (rows top
to bottom, cells left to right, the spreadsheet convention) through
the same copyable_selection_text the TextCopy/TextCut hits answer
with, so the long-press cell menu floats the full action set instead
of a paste-only one. Cut and Backspace/Delete clear every non-empty
spanned cell via the engine's new set_table_cells, which pops each
sub-write's compensation into one Compensation::Group — the span
un-clears in a single undo step where Backspace previously dropped
the range and edited only the caret cell. Single writes keep the
leaf compensation and empty write lists record nothing.

Grouping cell writes surfaced a real undo asymmetry: an undo after a
redo re-applied the redone cell text, because redo pushed the write's
own text as its undo compensation (inverse() only swaps the verb,
keeping the stale text) while undo's leaf special-case captured live
cell state only in one direction. undo()/redo() now resolve
cell-text inverses per compensation member against the projected
text at both transition boundaries (redo_compensation/
undo_compensation), so leaves and groups round-trip through
arbitrary cycles and the "cells stay out of groups" restriction is
gone.
2026-08-02 08:21:31 +00:00
Arena Agent
c83f8e083f refactor(cad): commands mutate through PartsStore, not a bare Vec
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
First migration step of Phase 5.2, and it closes a real hazard rather
than only moving types around.

`CadCommandCtx` held `&mut Vec<CadNode>`, taken out of the store by
`split_for_command` via `as_mut_vec()`. Every command therefore edited
the parts list *underneath* `PartsStore`, so none of their mutations
bumped its generation. `SceneCache::scene_for` compares generations, so
the only thing preventing a permanently stale scene was each command
remembering to call `mark_dirty()` by hand -- the exact discipline-based
invalidation the generation counter was introduced to replace.

Every command happens to call it today. One omission in a new command
would have produced a scene that never rebuilds, with no failing test.

`CadCommandCtx` and `CommandBorrows` now carry `&mut PartsStore`.
move_node and update_node go through `get_mut_by_raw_id`; create,
delete and insert already used store methods once the type changed.
The generation now moves on its own and the `mark_dirty()` calls become
belt-and-braces instead of load-bearing. `CadCommandCtx::new` also
builds its scene with `scene_for`, so it shares the cache rather than
rebuilding unconditionally.

Four tests. Three exercise the real `CadCommandCtx` against a real
`PartsStore` -- the mock keeps its own `Vec` and structurally cannot
observe the generation, which is why the gap survived this long. The
fourth pins the hazard itself: a mutation that skips the generation
still needs `mark_dirty()`, demonstrated through a `#[cfg(test)]`
`as_mut_vec_without_bump` that models the old path. Negative-tested by
restoring the bypass: "a command edit must move the generation, not
rely on a manual mark_dirty()".

Scope kept deliberately narrow: commands.rs only, ~93 sites left in
viewport.rs and workspace.rs. ARCHITECTURE.md updated with what is done
and what remains.

746 lib + 154 integration, 0 failed. All twelve gates pass, benchmarks
still build and run.
2026-08-02 08:01:19 +00:00
arena-agent
00b3eb6e98 feat(doc): select the whole editing context with Ctrl/Cmd+A
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
Keyboard select-all lands in the CRDT editor, mirroring makepad's
text_input. A parked cell cursor selects its whole cell text (the
in-cell character span, collapsing any armed merge range); otherwise
the selection spans the first layout glyph to the last with the
caret following the focus, so Copy, Cut, style toggles and
Backspace-over-span all treat it like a maximal Shift+Arrow
selection. Table blocks ride the range like any middle block: the
clipboard payload keeps skipping their cells while a cut drains them
through replace_block_range, so one undo restores the document.

Touch sessions in Edit mode float the platform clipboard menu on the
fresh selection (the keyboard-select-all pattern from text_input),
mirrored through clipboard_menu like the long-press request; the
cell-rect lookup it shares is factored into cursor_cell_rect. A
desktop Ctrl+A never makes a menu request, and a document without
glyphs leaves the caret put. copyable_selection_text goes
crate-visible so hosts rendering their own copy affordances read the
same payload the hits answer with.
2026-08-02 07:47:06 +00:00
Arena Agent
0d05a5e62f feat(cad): add the shared CadDocument (Phase 5.2 foundation)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Introduces `CadDocument` and `SharedCadDocument`
(`Rc<RefCell<CadDocument>>`) -- one owner for the parts list, its
generation counter and the id allocator -- with the semantics the
viewports will move onto.

Deliberately NOT wiring `CadViewport` onto it in this commit. That is
103 call sites (73 viewport.rs, 20 workspace.rs, 8 commands.rs), each
needing its borrow scope checked individually, and bundling it with the
type definition would produce a diff nobody can review and a bisect
target nobody can isolate. This lands the destination, proven by test;
the migration follows one file per commit.

Four tests, each negative-tested:

- an edit through one handle is visible through the other -- the whole
  point of the phase;
- the generation counter is shared, so SceneCache::scene_for cannot
  serve a stale scene to a viewport that has not synced yet;
- two separately constructed documents do NOT share, so the first two
  cannot pass for the wrong reason;
- sequential borrows are fine and overlapping ones are rejected. That
  one states the borrow discipline as an executable rule rather than a
  comment, using try_borrow_mut so it documents the failure without
  aborting.

Verified before designing, rather than assumed:

- Every `self.parts` read in viewport.rs is a short-lived expression or
  a loop, and only two loops mutate `self` at all (~6231, ~6265) -- both
  touching `selection`, a different field, so neither becomes a
  BorrowMutError.
- `split_for_command` hands out `&mut Vec<CadNode>` tied to `&mut self`,
  which is the one construct genuinely awkward under RefCell. Every
  caller already scopes it in a block and drops it before touching
  `self` again, so it maps onto a RefMut guard -- but that holds because
  of how the callers are written, not because the signature enforces it,
  and ARCHITECTURE.md now says to re-check it rather than assume it.

The migration rule -- never hold a `parts()` guard across a call that
can reach `parts_mut()` -- is recorded with the two loops to re-check
first if a BorrowMutError ever appears.

738 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:43:52 +00:00
Arena Agent
5e2d578583 fix(project): port the mobile grid to the shared workspace model
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
origin/main (92b1daf) removed `SpreadsheetGrid.data` in favour of a
`SharedWorkspaceModel`, but did not update this caller, so nigig-build
stopped compiling: five E0609s on `grid.data`.

The desktop/mobile handover open-coded what the model now does:

- Going to mobile it read the saved workbook, deserialized it by hand,
  branched on the multi-sheet vs legacy format, and copied the active
  sheet's data into the grid. That is `WorkspaceModel::load_saved()`,
  which handles both formats.
- Going to desktop it serialized the grid's data, re-read the workbook
  from disk, replaced the active sheet, and wrote the whole thing back.
  The grid now edits the shared model in place, so `model.save()`
  preserves every sheet with no read-modify-write.

The save error was also being discarded with `let _ =` in both branches.
It is now logged: a failed handover save loses whatever the user typed
on mobile, and reporting it costs a line.

Both branches shrink to roughly a third of their previous size, because
the duplicated format-handling was the bulk of them.

734 lib + 154 integration + spreadsheet-ui pass. All twelve gates pass.
2026-08-02 07:36:55 +00:00
Arena Agent
5a4ddc993f refactor(cad): share one part-id allocator across viewports (Phase 5.2)
First piece of state to become genuinely shared rather than copied.

Last commit fixed the id allocator moving backwards during sync by
reconciling the three counters on every push. That bounded the damage
but left the cause: three viewports each owning a `next_part_id`, kept
in step by copying whole snapshots, where only one viewport can win a
frame. A counter duplicated three ways cannot be made correct that way,
only patched -- which is exactly the argument for Phase 5.2, so this
takes the step instead of adding another patch.

`PartIdAllocator` is an `Rc<Cell<u64>>` handed to all three viewports by
`CadWorkspace::share_part_id_allocator`. An id handed out in any view is
never handed out again, with no sync step involved. `Rc<Cell>` rather
than `Arc<Mutex>`: the viewports are UI-thread only, and a `u64` is
`Copy` so no borrow can outlive the call and there is no `RefCell` panic
to reason about.

Removed as a result:
- `next_part_id` from `CadViewport` and all five open-coded
  `let id = self.next_part_id; self.next_part_id += 1;` sites.
- The `next_part_id` element of `parts_snapshot` /
  `replace_parts_snapshot`. The snapshot pair is one field smaller,
  which is the direction Phase 5.2 is going.
- `reconcile_next_part_id` and its four tests, now dead. Deleting the
  workaround in the same commit that removes its cause -- leaving both
  is how this codebase accumulated two of everything.

Kept: ids arriving from outside the allocator (script rebuild, file
load, undo restore) were never issued by it, so `reserve_past_nodes`
still runs on every `replace_parts_snapshot`.

Five tests. The load-bearing one asserts two clones interleave without
collision AND that they alias the same counter; a companion asserts two
*separately constructed* allocators do not share, so the first cannot
pass for the wrong reason. Negative-tested by making `clone` deep-copy:
"ids must be globally unique" fails.

Also covered: reserving past adopted nodes, reserving never lowering the
counter, and saturating at u64::MAX rather than wrapping to 0 and
reissuing live ids.

734 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:36:55 +00:00
Arena Agent
44c87d4f7b fix(cad): viewport sync reissued live part ids and undid edits
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Two defects in sync_parts_from_any_dirty_viewport, both from the same
root cause: three viewports each own state that only one of them can win
with per frame.

1. THE ID ALLOCATOR MOVED BACKWARDS.

   Each CadViewport owns a `next_part_id` counter, and
   replace_parts_snapshot assigned the source's value directly. Only one
   viewport wins a sync frame, so the loser's allocations are absent
   from the winner's snapshot -- and its counter is then reset below
   what it has already handed out.

   Draw a part in the 2D view and one in the 3D view between two syncs:
   both allocate the same id independently, the loser's part is
   discarded, and the next part in that view reuses an id that is still
   live. NodeId is the key for MeshCache, part_geoms and every command,
   so a duplicate silently aliases two parts.

   Now reconciled via scene_holder::reconcile_next_part_id, which takes
   the max of the destination's counter, the source's, and one past the
   highest id actually in the incoming list. It cannot retreat, cannot
   collide with a live id, and saturates rather than wrapping at
   u64::MAX. Four tests, negative-tested.

2. THE LOSING VIEWPORT UNDID THE WINNER'S EDIT.

   The source loop broke on the first dirty viewport. take_script_dirty
   clears the flag as it reads it, so a viewport never visited kept its
   flag set, won the *next* frame, and pushed its own now-stale snapshot
   back over the edit that had just been applied.

   The loop now visits all three and clears every flag in one pass. The
   first dirty one still supplies the snapshot; the others' edits are
   already in it, because a sync pushes the winner's full parts list.

Modelled both interleavings against the real control flow before
changing anything.

These are worth recording as evidence for Phase 5.2 rather than just
fixed: a single counter duplicated three ways cannot be made correct by
copying, only bounded. ARCHITECTURE.md now says so at the point where
someone would otherwise reach for another patch.

730 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:27:01 +00:00
arena-agent
ac1ddfe2c2 feat(doc): float the platform clipboard menu on long-press selections
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
The touch clipboard surface is now complete: a mobile long-press
selection requests the platform clipboard menu
(cx.show_clipboard_actions, iOS/Android backends) right after the
selection lands, and its actions re-enter through the synthesized hits
the editor already answers -- Copy/Cut as TextCopy/TextCut, Paste as
TextInput (which also flows through multi-line block splitting).

- Edit mode only: View keeps the long-press for merge/highlight (its
  hits stay gated), and desktop sessions never reach the touch-driven
  frame clock, so their clipboard story is unchanged.
- has_selection follows the copyable payload exactly like the native
  menu: word selections anchor on the union of their glyph rects and
  offer Copy/Cut, while a cell range carries no payload and gets a
  paste-only menu anchored on the pressed cell's rect.
- The request is mirrored on the widget as pub clipboard_menu:
  makepad's platform op queue is crate-private, so hosts rendering
  their own menu (and tests asserting the request) read it there.

Runtime tests (real TouchUpdate + 24-frame NextFrame clock) cover the
Edit word-menu request with Copy flowing back out, the cell paste-only
menu rect matching the pressed cell with a menu Paste splicing into
the parked cell, and View mode making no request at all.
2026-08-02 07:24:37 +00:00
Arena Agent
5c8ee16cbb fix(cad): a dropped rebuild request left the spinner up forever
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
CadRebuildWorker::request discarded the channel send error:

    pub(crate) fn request(&self, request: CadRebuildRequest) {
        let _ = self.request_tx.send(request);
    }

The caller then unconditionally sets rebuild_pending = true and shows
"Computing 3D model...", and that flag is cleared in exactly one place:
drain_rebuild_results, on a result whose seq matches. So if the worker
thread is gone the request vanishes, no result can ever arrive, and the
spinner stays up for the rest of the session with every later edit
appearing to hang. No error is logged and nothing recovers.

request now returns whether the send succeeded. request_rebuild acts on
it: drops the dead worker so the next edit constructs a fresh one,
clears rebuild_pending, and says "Rebuild worker stopped; retrying on
the next edit" instead of lying about work in progress.

Test builds the worker from raw channel halves so a closed receiver can
be staged without a thread or a Cx. Negative-tested by restoring the
`let _ =`.

Also examined and found sound, recorded because the absence of a bug is
worth knowing:

- The seq protocol. I suspected a stale result could strand the pending
  flag and modelled the interleavings; it cannot. The worker only emits
  seqs the UI requested, seqs are monotonic, and drain keeps the last
  result in the channel -- so the final result always matches the
  current seq. wrapping_add on a u64 needs 2^64 rebuilds.
- Worker panic safety. catch_unwind wraps the evaluation, and both
  save_cad_state and cad_mesh_data_from_solid are inside it, so a panic
  in either is converted to an Error payload rather than killing the
  thread. The dead-worker path this commit handles is therefore
  defensive rather than routine -- but it costs three lines and the
  failure it prevents is unrecoverable without a restart.

Swept the module for other discarded sends: none remain.

726 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:21:49 +00:00
Arena Agent
92e4a2515a fix(cad): a failing undo or redo destroyed the command
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
UndoRedoStack::undo popped the entry and then ran it:

    let cmd = self.undo_stack.pop_back().ok_or(..)?;
    cmd.undo(ctx)?;              // <- early return drops `cmd`
    self.redo_stack.push(cmd);

If `cmd.undo` fails, `?` returns while the command is still a local, so
it is dropped: gone from the undo stack and never reaching the redo
stack. The user silently loses a history entry, and that edit can never
be reversed again. `redo` had the identical shape.

This is reachable, not defensive. Every command's undo bottoms out in
move_node / update_node / delete_node, each of which returns
NodeNotFound when the node is missing -- which is exactly what happens
after a script rebuild replaces the parts list. Undo an edit to a part
the script no longer produces and the entry evaporates.

Fixed by running the command while the stack still owns it (`back()` /
`last()`) and only moving it across after it succeeds. The subsequent
pop cannot fail, but is still handled with `if let` rather than an
unwrap, matching the no-panic rule this module now enforces.

Two tests, negative-tested by restoring the pop-first order: both assert
`undo_depth + redo_depth == 1` after the failure, i.e. the command is
still *somewhere*. Asserting only that undo returns Err would have
passed against the broken code -- it did return Err, while destroying
the entry. The failure message prints both depths, so a regression says
which stack lost it.

Swept the rest of the CAD module for the same pop-then-fallible-call
shape: no other instances.

725 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:16:57 +00:00
Arena Agent
cb5def965b fix(cad): exports reported success on a failed write
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Every file export buffered its output through a BufWriter and never
flushed it. BufWriter flushes on drop and DISCARDS any error it hits
doing so, so a write that fails only when the buffer is pushed to disk --
full disk, revoked permission, a network mount going away mid-export --
returned Ok(()). The status label then said "STL: 11 parts ->
model.stl" while the file on disk was truncated.

Demonstrated before fixing, with a writer that accepts buffered writes
and fails on flush:

    without explicit flush -> Ok(())
    with explicit flush    -> Err("flush failed: disk full")

Three affected paths: PDF (exporters.rs), STL and GLB (workspace.rs).
All now go through exporters::export_to_file, which owns the BufWriter,
flushes it, and maps a failed flush to "<what>: could not write file:
<cause>". Putting the flush in one place is the point -- it was
forgotten three times independently.

The fourth BufWriter, in arch_pdf, writes into a Vec<u8> where flush
cannot fail. Left alone and excluded from the gate with that reason
recorded, rather than churned for uniformity.

Two tests pin the mechanism rather than one exporter: a small write
stays in the buffer, so write_all succeeds and only the flush can report
the failure. One asserts the drop path hides it, the other that an
explicit flush surfaces it. If BufWriter's drop behaviour ever changed,
the first test would fail and tell us the helper is no longer needed.

CI gate added and negative-tested: no BufWriter::new in the CAD module
outside the flushing helper.

Also checked, and found clean: arch_svg still escapes both XML sinks
after the Phase-4 projection rewrite, and all three cargo-deny
exemptions are still live -- removing them makes exactly those three
advisories fire, so none is a stale entry silently widening the policy.

723 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:12:23 +00:00
arena-agent
920827a440 feat(doc): split multi-line paste into blocks with one-step undo
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
A pasted TextInput payload containing newlines used to land as a
single run of text with literal line feed characters. It now splits
block-per-line like a desktop editor: line 0 splices into the caret
block, middle lines become sibling blocks inheriting the caret block's
kind, and the trailing SplitBlock carries the caret block's suffix
onto the last pasted line (ab|XY pasted with "l0\nl1\nl2" yields
abl0 / l1 / l2XY). The caret parks after the last pasted atom.

The whole paste folds into one Compensation::Group (undo applies group
members in reverse, redo applies the inverse group in authored order),
so N lines retract in a single Ctrl+Z. The caret anchor may be
tombstoned (the caret a selection delete leaves behind): the new
CrdtDocument::live_offset_of resolves it to the same live-text offset
RGA splicing uses, so pasting over a deleted selection lands exactly
where a single-line paste would. CRLF payloads strip their \r.

Surfaced and fixed at the source while building this: the synthetic
block a SplitBlock opens was a text dead end -- atoms and style ops
addressed to a split op id landed in the op log but evaporated from
the projection, and the child always spliced directly behind its
parent. Split children are now first-class materialization targets:
suffix atoms re-link head-to-tail (keeping ids and inherited style
runs), child-addressed atoms splice in RGA-wise, and the child's
splice skips the parent's real-chain descendants, keeping the middle
paste lines ahead of it.

Runtime tests cover the split/suffix/caret/undo/redo cycle,
paste-over-selection as two undo steps, and the in-cell newline guard.
Engine tests cover grouped-undo chronology, tombstone anchors, empty
middle lines, CRLF, table refusal, peer convergence, and the
synthetic-child text/style/order regressions underneath.
2026-08-01 05:44:28 +00:00
arena-agent
7e0012b866 feat(doc): undo cross-block cuts losslessly via range tombstones
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
ReplaceBlockRange pushed no undo compensation, so a multi-block cut or
selection delete could never be undone: the range op rewrites only the
projection (drained blocks and every atom beneath the span stay in the
op log untouched), and no op existed to make it stop applying.

Add the CancelBlockRange/RestoreBlockRange op+compensation pair,
resolved chronologically per target like every other tombstone pair
(merge splits, cell styles, blocks, text, rows, columns). Undo of a
cross-block delete tombstones the range op itself, which is lossless:
splices, middle blocks and their texts all re-materialize exactly from
the untouched log, in a single undo step. Redo clears the tombstone.

The controller also refuses spans whose endpoints do not resolve
against the projection in order, so a no-op range never strands an
entry on the undo stack.

Coverage: engine tests for the one-step undo/redo cycle (with a double
undo proving the chronological tombstone), text typed beneath a
cancelled range surviving, and unresolved-span refusal growing no undo
stack. Widget runtime test cuts across three blocks, asserts the joined
payload, then Ctrl+Z restores all three blocks and Ctrl+Shift+Z re-cuts.
2026-08-01 05:02:19 +00:00
Arena Agent
03dea4d14f fix(cad): remove panics from CAD production paths
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Continued auditing the code the KeyCode fix (667f565) made reachable, and
followed the panic sites out from there.

Command::merge's default was `panic!("merge() called on a Command that
didn't override it")`. That is reachable, not defensive: any command that
overrides can_merge but forgets merge hits it. UndoRedoStack::execute
runs it on every frame of a drag, and the code five lines away already
declines to panic on a genuinely unreachable branch, with the comment
"never panic on the undo path". A CAD editor holds unsaved work -- losing
the session is far worse than the mis-merged undo entry being reported.

Now a debug_assert!: loud in development where it is actionable, and
survivable in a shipped build. Proven by test in both configurations --
the release behaviour is pinned by a test marked
`#[cfg_attr(debug_assertions, ignore)]`, since the dev build is *supposed*
to fire the assertion. Negative-tested by restoring the panic.

Also replaced three `unreachable!()`s:

- add_part_command and split_selected_part_at both did
  `position(..)` then `get(idx).unwrap_or_else(|| unreachable!())`. The
  index round-trip is what created the Option they then had to discharge
  with a panic; `find(..).cloned()` and `enumerate().find(..)` express
  the same thing without arming one.
- The DDE digit match's `_ => unreachable!()` arm is genuinely dead (the
  outer arm narrows to Key0..Key9), but a keyboard handler is not worth
  a crash to say so. It swallows the keystroke instead.

CAD production code now contains no panicking macros at all, enforced by
a new CI gate that scans each file up to its first #[cfg(test)] -- test
code may panic freely. Negative-tested.

One test-authoring note worth recording: my first version of the merge
test built five MoveNode commands all with `from: 0`, which tripped
MoveNode::execute's debug_assert that the scene's previous position
matches `from`. The assertion was right and my test was wrong; a real
drag chains each frame's `from` to the previous `to`. The corrected test
now also verifies the thing that assertion protects -- five drag frames
collapsing into one undo entry.

710 lib + 154 integration, 0 failed. All ten gates pass.
2026-08-01 04:56:02 +00:00
arena-agent
a3ff59e512 feat(doc): clipboard copy/cut/paste across blocks and table cells
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
The CRDT editor had no clipboard surface at all. It now answers the
platform clipboard queries exactly like makepad's TextInput:

- Hit::TextCopy / Hit::TextCut fill the response cell with the selection
  payload: the text-block selection joined with newlines, or the in-cell
  character span when the caret lives in a table. Nothing selected
  leaves the response empty, so the platform keeps the clipboard alone.
- Cut removes the payload through the shared selection-deletion path and
  paste flows through the existing TextInput insert path (replace-
  selection semantics for both blocks and cells).

Three latent production bugs uncovered and fixed at the source:

1. delete_selection's applied flag was wired to the empty replacement's
   missing trailing atom, so same-block Backspace-over-selection
   over-deleted by one char and left live anchors behind.
2. engine delete_text pushed no undo compensation, making every
   selection deletion (cut, type-over, Backspace-over) unrecoverable.
   It now pushes the symmetric RestoreText pair.
3. All six engine tombstone pairs (text, blocks, rows, columns, merge
   splits, cell style clears) resolved as union-minus-union: once a
   restore op existed, delete→undo→redo could never re-delete. They now
   resolve chronologically per target in operation order.

13 new tests: 5 clipboard runtime (payload, cut+undo, cell span,
no-payload, newline join), 2 widget regressions (over-delete,
delete-key), 6 engine (delete undo/redo, empty-replace single-step,
block/row/merge/cell-style chronology cycles). nigig-build 704 -> 711,
doc-engine 62 -> 68.
2026-08-01 04:31:06 +00:00
Arena Agent
f54dda90dc fix(cad): direct distance entry lost its direction on 0 and negatives
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Fixing the KeyCode bindings (667f565) made the direct-distance-entry
keyboard path reachable for the first time -- BracketLeft had been an
unguarded catch-all sitting above every numeric arm, so none of it had
ever run. Newly-live code that has never executed is worth auditing, and
dde_live_preview had two defects waiting in it.

Both come from the same mistake: the function derived the draw direction
from `current_world`, and then wrote its own result back to
`current_world`. Each keystroke therefore measured from the previous
keystroke's output.

  Typing "0": the point moves onto the anchor, so the direction becomes
  atan2(0, 0) == 0. Every later digit draws along +X instead of the
  cursor ray, and the original direction is unrecoverable. Typing "0"
  then "5" gave (5, 0) where it should give a point 5 units along the
  ray.

  Negative distance: "-5" flips the point 180 degrees, so the next
  keystroke measures from the flipped ray and the sign cancels itself.
  "-5" then "-50" landed at +50, not -50.

Both reproduced numerically against the original arithmetic before
changing anything.

The direction is now captured once, on the first keystroke of an entry,
and held in DrawingState::dde_ray_deg. It is cleared when the entry
commits (Enter) and when backspace empties the buffer, so the next entry
re-captures from wherever the cursor is then; DrawingState::default()
covers the other resets.

The geometry moved to construction_geometry::dde_resolve_point, which
takes the ray as a parameter -- that is what makes the bug structurally
impossible rather than merely fixed, and it is testable without a Cx.
Five tests: the zero case, the negative case, that polar input ignores
the ray (it carries its own azimuth), relative-cartesian offsetting, and
that Spherical returns None. Negative-tested.

Spherical stays unresolved on purpose. It has an elevation component a
flat plan preview cannot represent, and projecting it as if it were flat
would silently draw the wrong segment. That was already the old
behaviour via a bare `return`; it is now explicit in the return type.

690 lib + 154 integration, 0 failed. All nine gates pass.
2026-08-01 04:24:37 +00:00
nigig-ci
817ba02625 build: drop the unused robius-sms dependency and its two advisories (Phase B)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
nigig-build declared robius-sms and robius-location and called neither:
zero references to robius_sms or robius_location anywhere under
nigig-build/src. That dead dependency pulled in

    robius-sms -> polkit -> gio -> glib -> glib-macros -> proc-macro-error

which is the ONLY reason deny-nigig-build.toml carried

    RUSTSEC-2024-0370  (proc-macro-error, unmaintained)
    RUSTSEC-2024-0429  (glib VariantStrIter unsoundness)

plus an unanswered LGPL-2.1 distribution question about linking polkit
into a shipped mobile binary -- all of it for code that never ran.

Removing the direct dependency alone was NOT enough, which is the part
worth recording. `cargo tree -p nigig-build -i polkit` showed three
paths, not one: the direct declaration, and two more through nigig-core
and nigig-uikit. Both of those also declare robius-sms and also never
use it. All three declarations had to go before polkit left the graph.

nigig-core keeps robius-location: unlike the others it genuinely uses it,
in src/location.rs.

Verified, not assumed:
  - before: cargo tree -p nigig-build -i polkit resolved, three paths
  - after:  polkit, gio and proc-macro-error no longer resolve at all
  - cargo deny check -> "advisories ok, bans ok, licenses ok, sources ok"
    with two fewer ignores (5 -> 3)
  - nigig-build, nigig-core, nigig-uikit and nigig-sms all still compile
  - Cargo.lock loses 5 lines

Also in this commit:

  - A CI gate so the declarations cannot come back. Deliberately scoped
    to robius-sms/robius-location on these three manifests rather than a
    blanket `cargo machete`: five other unused dependencies exist here
    (chrono, futures, postcard, rand, serde_json) and a gate that is red
    on its first run gets switched off. Negative-tested by re-adding the
    dependency and confirming the gate fails.

  - Three pages carried the same placeholder string telling the user
    they were looking at a "RobrixStackNavigationView destination ...
    just like SMS conversation screens". That is user-visible UI copy,
    not a comment. Replaced with text describing the page. The identical
    "This follows the SMS/Home pattern" comment in the same three files
    now says what the code does instead of naming another module.

Note the scope limit: 26 of the 29 crates in this workspace declare
robius-sms and never use it. This commit fixes the three that put
advisories on nigig-build. The rest are the same latent problem and
should be swept separately.
2026-07-31 22:45:58 +00:00
arena-agent
9f0765c6e1 feat(doc): pointer-driven in-cell selection, char-exact caret parking
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
Cell taps parked the caret at the end of the cell text no matter where
the pointer landed, and in-cell character selection was keyboard-only.
Cells now handle the full desktop pointer surface:

- `cell_char_offset_at` maps a layout-space x to the nearest char offset
  on the cell's fixed grid (midpoint-split 7px cells, clamped to the
  text end) — one convention for taps, drags and the caret rect.
- Taps (desktop FingerDown and mobile short tap) park the cell caret on
  the char under the pointer instead of always at the text end.
- A desktop press arms the in-cell drag anchor; a drag spans a
  character selection clamped to the pressed cell — crossing an edge
  clamps at the text ends instead of jumping cells — and reuses the
  existing span machinery, so typing/Backspace/styles consume the
  dragged span exactly like a keyboard-spanned one. Touch keeps its
  passive caret and long-press cell-range path.
- Shift+tap extends a live in-cell selection to the tapped offset,
  mirroring the text-block shift-press.
- Draw-loop fix for armed anchors across real frames: a collapsed
  anchor (fresh press, or a selection stepped back onto itself) draws
  nothing but STAYS armed — pointer drags and further Shift steps
  extend from it; only a stale cell (undo) drops it. Previously the
  highlight pass cleared collapsed anchors between the press and the
  first drag move, which would have silently disarmed desktop drags on
  real frame clocks.

6 new tests: offset math + clamps (pure), tap char parking, drag span +
type-over, backward drag, edge clamping with cell identity, Shift+tap
extension. nigig-build 698 -> 704.
2026-07-31 22:40:24 +00:00
arena-agent
4daaacaed2 feat(doc): in-cell character selection for table cells
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Cell editing knew caret movement, whole-cell styles and mergeable cell
ranges, but no way to select part of a cell's text — Shift+Arrow inside
a cell moved the caret plainly. Cells now span a real character
selection:

- `ProjectionSession::cell_text_anchor` (anchor offset; the cell caret
  is the focus). Shift+Arrow inside a cell begins/extends the span on
  the shared session; at the cell edge the span ends and Shift promotes
  to the mergeable cell range exactly as before, so the two selection
  kinds compose instead of colliding.
- Edits consume the span: typing (`cell_text_replace_range` from the
  TextInput path) replaces it, Backspace/Delete remove it, the caret
  lands on the splice point, and undo restores the prior whole-cell
  text through the existing SetTableCell compensation.
- Style toggles (Ctrl/Cmd+B/I/U and the toolbar) now target the active
  in-cell selection instead of the whole cell, flowing through the
  engine span API added with cell style runs; without a span they keep
  styling the whole cell. Styling keeps the selection armed, matching
  the text-block behavior.
- Rendering: one highlight rect (`cell_text_span_rect`) over the
  selected chars on the cell's fixed text grid, re-resolved against the
  layout every frame; stale cells undo-clear like the cell caret.
- Collapse rules mirror the existing cell-range discipline: plain
  arrows, taps (desktop and mobile), cell hops, Return-row insertion,
  range promotion and staleness all clear the anchor.

8 new tests: replace-range/clamp/unicode helper coverage, span rect
geometry, runtime Shift-span, edge promotion to the cell range,
type-over, backspace-over, selection-scoped Ctrl+B, and plain-move
collapse. nigig-build 690 -> 698.
2026-07-31 22:27:52 +00:00
arena-agent
df2471105b feat(doc): CRDT-native table cell style runs
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
Cell text was plain LWW strings, so style toggles were silently
disabled whenever the caret sat in a table. Cells now carry styled runs
end to end:

- Engine: new `SetTableCellStyle` / `ClearTableCellStyle` /
  `RestoreTableCellStyle` ops. Ranges are char-offset snapshots replayed
  over the settled LWW cell text in operation order (newer patches win
  on overlap, spans clamp when text shrinks but do not stretch when it
  grows). Undo tombstones the style op exactly like a merge split,
  keeping overlapping later styles intact and undo/redo convergent
  across peers. `ProjectedTable::cell_runs` mirrors `cells` — every
  projected cell has at least one default-styled run — built by the new
  `apply_style_offset_range` helper, with `TableCellRef` addressing
  cells in the controller API.
- Layout: `ProjectedTableCellLayout.runs` mirrors the projection runs
  for the renderer.
- Renderer: `draw_table_projection` draws cell text run by run through
  the same regular/bold/italic/bold-italic pens as block text, advancing
  on the shared fixed char grid.
- Widget: `toggle_selection_style` routes by session — a parked cell
  cursor styles the whole cell (cell text has no selection ranges yet),
  a text selection styles its range — so Ctrl/Cmd+B/I/U and the toolbar
  buttons now work in tables. Undo/redo round-trips like any other op.

9 engine tests (split/merge/clamp/overlap/toggle/reject/undo/convergence),
5 widget tests (layout runs, Ctrl+B whole-cell, undo/redo, toolbar path,
empty-cell no-op). doc-engine 53 -> 62, nigig-build 685 -> 690.

The doc README's "cell text carries no style runs yet" gap is closed and
the doc-engine projection invariants document the new semantics.
2026-07-31 22:09:18 +00:00
Arena Bot
ee909a977c fix(ui): restore missing metric_title and metric_value to CostEstimatorMetric instances
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
2026-07-31 21:48:53 +00:00
Arena Agent
5e714577fb ci: require full 40-character SHAs for git dependency revs
Some checks failed
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-map / test (push) Has been cancelled
Upstream adopted the makepad fork fix (8fdff3f) but pinned it as
`rev = "a79f0dc"` -- a 7-character abbreviation. The pinning gate passed,
because it only checked that `rev = ` was present at all. Its own error
message has said "Add rev = \"<full-40-char-sha>\"" since it was written,
without ever enforcing it.

An abbreviated rev resolves only while no other object in the repository
shares its prefix. That is a property of the current object count, not a
guarantee -- it is why git's own auto-abbreviation length grows with a
repo. A short pin therefore degrades on its own over time, and someone
who can push to the fork can attempt to manufacture a colliding prefix.
For a dependency that executes at build time, that is a supply-chain
weakness rather than a style preference.

Checked before assuming: a79f0dc currently resolves uniquely in the fork
(exactly one matching object), so nothing is broken today. This closes it
while it is still cheap.

- All 34 manifests expanded to the full SHA
  a79f0dce4d477e2232344facca0798d3f25043ec. Cargo.lock is unchanged by
  the expansion, confirming it is the same commit and purely notational.
- The gate now also rejects any rev that is not exactly 40 hex chars.
  Negative-tested: restoring the 7-char form makes it fire.

685 lib tests pass; all nine gates pass.
2026-07-31 19:53:49 +00:00
8fdff3ff55 Update makepad fork to a79f0dc (remove duplicate dependencies)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
Commit a79f0dc fixes the duplicate dependency declarations that were
causing TOML parsing errors. This is the correct commit to use after
the parallel fixes in 5eda8056 and 11375214.

All 34 Cargo.toml files updated to reference the correct commit.
2026-07-31 19:43:08 +00:00
Arena Agent
a0dbd192c1 ci: add an unfiltered repo-hygiene workflow
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Every workflow in .forgejo/workflows is scoped with `paths:`. That is
right for build and test jobs -- no reason to compile the CAD module
because a payment file moved -- but it leaves a structural hole: a commit
touching only unfiltered paths runs no checks at all.

Commit 8c9ccb9 fell straight through it, pushing 30 unresolved
conflict-marker lines across five files. The worst of them was
.forgejo/workflows/nigig-build.yml itself: with markers in it the file is
not valid YAML, so all seven CAD gates silently stopped running. A
workflow that does not parse does not fail -- it does not run, which is
strictly worse than a red build because nothing announces it.

`git diff --check` would have caught this and was already a step. It was
in the file the same commit broke, and only ran for that workflow's
filtered paths.

So this workflow has no path filter, no toolchain dependency and no
network dependency -- it cannot be knocked out by an unrelated build
break -- and it validates the CI configuration itself:

1. No conflict markers anywhere in the tree.
2. Every workflow file parses as YAML and has a top-level `jobs:`.
3. Whitespace errors.

Verified against the real failure, not a synthetic one: checked out
8c9ccb9 and confirmed check 1 reports all 30 marker lines and check 2
names both unparseable workflows. Also probed for false positives --
`>>`/`<<` shift operators, `// =======` comments and a "=======" string
literal do not trip it, because the patterns are anchored to column 0 and
the closing marker requires the trailing space git writes.

Also fixes the fifth file from that commit, which I missed while
repairing the others because I only grepped .rs and .yml: ARCHITECTURE.md
still had two live conflicts on main. Both were stale-vs-current
versions of my own lines; kept the current text. While there, refreshed
every LOC figure in the file table from the actual files -- 14 of them
were stale, workspace.rs by 64 lines.
2026-07-31 19:41:10 +00:00
Arena Agent
80425bbfb8 fix: repair the makepad fork and unblock the build
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-map / test (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
The repository has been uncompilable since the d6d1f99c fork bump. Root
cause was in gitdab.com/andodeki/makepad, not here, so the fix went there
first: commit 5eda8056 on portallist_flow_adaptive_view.

TWO defects, both introduced by the "Update fork to upstream dev 5d4483f"
merge, both pure losses rather than intentional changes:

1. widgets/Cargo.toml: the makepad-gltf / makepad-csg / makepad-test
   dependency lines were relocated from [dependencies] to below
   [features]. Cargo then parses each as a feature whose value should be
   an array, giving "invalid type: map, expected a sequence", and the
   gltf/csg/test/maps features cease to exist.

2. widgets/src/lib.rs: the feature-gated re-export block for those same
   crates (plus makepad_fast_inflate and makepad_mbtile_reader) was
   deleted outright. Fixing only the manifest surfaced this as
   "no `makepad_csg` in the root".

Both restored verbatim from 2c5cd97, the last rev that resolved. Neither
is a judgement call: the moved lines are byte-identical and the deleted
block is copied back unchanged.

This repo is then repinned from d6d1f99c to the fixed rev, full 40-char
SHA per the pinning convention CI enforces.

Verified end to end after removing the local git redirect used during
development, so this resolves against the real remote:
  cargo metadata            resolves
  nigig-build --lib         685 passed
  cad_integration           154 passed
  spreadsheet-engine        225 passed
  doc-engine                 53 passed
  nigig-map (maps feature)  compiles
  Cargo.lock                unchanged, --locked passes

Also resolved committed conflict markers in two workflow files, which
had made nigig-build.yml invalid YAML -- the CI config could not be
parsed at all:

- nigig-build.yml: kept --include='*.rs' on the by-value-getter gate.
  Without it the gate scans ARCHITECTURE.md and fails on its own
  documentation, which is the bug fixed in 4f32b1c.
- pdf.yml: kept upstream's side. Enumerating targets via
  `cargo fuzz list` and failing when the list is empty is strictly
  better than a hardcoded target list that silently passes vacuously if
  a target is renamed.

That makes four files in three commits now carrying committed conflict
markers from this merge. Worth checking how they are reaching main --
`git diff --check` catches exactly this and is already a step in the
nigig-build workflow, but it only runs on paths under that workflow's
filter.
2026-07-31 19:32:08 +00:00
4eebae14f2 Update makepad fork to 11375214 (Cargo.toml fix)
Some checks failed
nigig-build.yml / Update makepad fork to 11375214 (Cargo.toml fix) (push) Failing after 0s
pdf.yml / Update makepad fork to 11375214 (Cargo.toml fix) (push) Failing after 0s
nigig-map / test (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
- Fixed TOML parsing error where fork-specific dependencies were in wrong section
- Dependencies now correctly placed in [dependencies] before [features]
- Maps feature should now be properly recognized
2026-07-31 19:24:22 +00:00
32370d84dc fix: resolve runtime DSL errors in cost_estimator and cad modules
Some checks failed
nigig-build.yml / fix: resolve runtime DSL errors in cost_estimator and cad modules (push) Failing after 0s
pdf.yml / fix: resolve runtime DSL errors in cost_estimator and cad modules (push) Failing after 0s
- Remove invalid property overrides on CostEstimatorMetric child widgets
- Remove unsupported 'visible' property from Svg widget
- Fix 'align' type mismatch by using Align{} constructor

These were runtime errors that didn't prevent compilation but caused
warnings in the Makepad script system.
2026-07-31 19:08:34 +00:00
Arena Agent
b4f22d33e1 fix: restore CadWorkspace and resolve committed conflict markers
Some checks failed
nigig-build.yml / fix: restore CadWorkspace and resolve committed conflict markers (push) Failing after 0s
pdf.yml / fix: restore CadWorkspace and resolve committed conflict markers (push) Failing after 0s
origin/main (8c9ccb9) shipped an unfinished merge. Two problems, both in
files it merged from my recent commits.

1. COMMITTED CONFLICT MARKERS. Eleven `<<<<<<< / ======= / >>>>>>>`
   lines were committed in workspace.rs and formula2.rs, so neither file
   parses. The markers are literally in the pushed tree, not a local
   artifact -- `git show origin/main:<file>` contains them.

2. ~3,000 LINES OF CadWorkspace DELETED. workspace.rs went from 3,908
   lines to 883. Every method of `impl CadWorkspace` is gone --
   handle_actions, draw_walk, all five export entry points, bake_parts,
   with_viewport, export_scene_source. The impl block opens at line 185
   and never closes, which is the "unclosed delimiter" the compiler
   reports.

Resolution:

- workspace.rs restored from 36c9c0b, my last verified commit. Checked
  first that upstream's truncated version added nothing: the set of
  function names in it is a strict subset of mine, so nothing of theirs
  is lost by restoring. Both helpers from the conflicting merge --
  save_status_message and ExportBlocked/export_blocked_message -- are
  present and were what the markers were fighting over. Both are kept.

- formula2.rs conflicts were resolved by hand. Two were whitespace-only.
  The other two were the same test data formatted two ways; I kept
  upstream's rustfmt output since they had just run a formatter over the
  crate.

Deliberately NOT changed: the makepad rev stays at d6d1f99c and
Cargo.lock is untouched. The repo still does not resolve, for a separate
reason in the fork (see below), and quietly pinning back to 2c5cd97
would hide their bump rather than fix it. I verified this commit by
temporarily repinning locally, then reverted the manifests -- 685 lib
tests and 225 spreadsheet-engine tests pass against the restored source.

STILL BLOCKED, and it needs a fix in gitdab.com/andodeki/makepad, not
here: at rev d6d1f99c, widgets/Cargo.toml has three dependency lines

    makepad-gltf  = { path = "../libs/gltf",        optional = true }
    makepad-csg   = { path = "../libs/csg/csg",     optional = true }
    makepad-test  = { path = "../libs/makepad_test", optional = true }

sitting BELOW the `[features]` header instead of inside `[dependencies]`.
Cargo parses them as feature definitions, hence "invalid type: map,
expected a sequence", and the gltf/csg/test/maps features then do not
exist. The block was relocated by the "Update fork to upstream dev
5d4483f" merge; at 2c5cd97 the same three lines are inside
[dependencies].

Confirmed the one-block move is the whole fix: with those lines returned
to [dependencies] in a local clone, `cargo metadata --features
maps,csg,gltf,test` on makepad-widgets resolves cleanly.

A [patch] override is not a workaround here -- cargo rejects patching a
source with itself, and pointing at a corrected path clone makes the
patched and unpatched copies coexist, producing E0659 ambiguity across
makepad-ai and makepad-xr.
2026-07-31 19:02:14 +00:00
8c9ccb92cc Update makepad fork to latest dev branch (d6d1f99c)
Some checks failed
nigig-build.yml / Update makepad fork to latest dev branch (d6d1f99c) (push) Failing after 0s
pdf.yml / Update makepad fork to latest dev branch (d6d1f99c) (push) Failing after 0s
nigig-map / test (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
- Sync with upstream commit 5d4483f (latest map improvements + platform updates)
- Include location API, audio echo cancellation, bridge-dz overlay
- Add new libraries: geodata, map_nav, i_float, i_shape, i_tree, converse, llama vision
- Preserve all fork-specific re-exports (gltf, csg, test)
- All 102+ map improvements now available: 2D/3D toggle, shadows, labels, overlays, pattern fills
2026-07-31 18:47:37 +00:00
Arena Agent
36c9c0b0d1 fix(cad): both Save buttons reported success when the write failed
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
Went looking for remaining defects before starting the viewport split and
found silent data loss in the two Save buttons.

    cad_store::save_cad_script(&project.id, &source).ok();
    self.view.label(..).set_text(cx, "Saved");

    save_cad_state(&source, &solid).ok();
    self.view.label(..).set_text(cx, "Saved with mesh");

Both discard a Result carrying a real io::Error and then report success
unconditionally. The user is told their work is safe when nothing was
written.

This is reachable, not theoretical. `cad_store::cad_projects_dir()` only
*logs* a `create_dir_all` failure and returns the path anyway, so the
following `fs::write` fails with a genuine error -- which was thrown
away. An unwritable data directory, a full disk or a permissions problem
all render as "Saved".

Save-As had a third silent failure on the same line of reasoning: if
`bake_parts_solid()` returned None the save never even ran, and it still
said "Saved with mesh".

Fixed via save_status_message(what, result), so the label is derived from
the outcome instead of assumed. Three distinct messages now: the caller's
success wording, "Save failed: <cause>" with the underlying error text
preserved, and a specific message for the no-mesh and no-active-project
cases, which were previously indistinguishable from success.

Also replaced the hand-rolled widget borrow in Save-As with
with_viewport, the helper added in 5.2.

Tests assert the two properties that matter: a failed save must not read
as success and must carry its cause through to the user, and a
successful save must use the caller's wording. Negative-tested.

CI gate added and verified in both directions: greps for a discarded
Result on a save/write/persist/store/export call. A dropped Result is
merely untidy in most places; on a save path it is data loss.

These were the only two instances -- the CAD module now has zero
discarded write Results.

685 lib + 154 integration, 0 failed. All seven gates pass.
2026-07-31 18:42:27 +00:00
bea1fd884e chore: update makepad fork to include upstream map improvements
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-map / test (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Updated all makepad dependencies from rev 2c5cd97 to 817d881 which includes:
- Terrain hillshade landcover draping (drape.rs)
- Route overlays, markers, and position puck (overlay.rs)
- Map icon management system (icons.rs + 50 SVG icons)
- 3D road elevation and seamless joins
- Building shadow geometry and terrain shadows
- Night themes and emissive roads
- Water, grass, and shrub rendering
- Optimized road geometry with 2D/3D mode transitions
- i_overlay library for polygon boolean operations

This brings nigig-map in sync with the latest makepad dev branch improvements.
2026-07-31 18:39:39 +00:00
Arena Agent
fc2e555cf6 refactor(cad): collapse the four duplicated export prologues
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
export_floor_plan_pdf, export_3d_viewer, export_stl and export_svg each
opened with a byte-identical 23-line block: borrow the main viewport,
bail if it is missing, bail if it has no parts, then take the cached
scene, the shared mesh cache and the part count. Verified identical with
a regex over all four -- the only difference was the label prefix
("PDF", "3D", "STL", "SVG"), and even that was consistent within each
copy.

Now one method, export_scene_source(cx, kind), returning Option of the
triple and setting the status label itself. Four call sites, each two
lines.

This is the same duplication shape that hid the last three real bugs in
this module: nine copies concealed the properties-panel no-op, one
uninspected copy concealed the dead Extend tool, and nine keymap arms
concealed the KeyCode bindings. Nobody re-reads the fourth copy of a
23-line block. There is no bug hiding in these four -- I checked -- but
the next edit to one of them would only have landed in one.

The user-visible strings are the behaviour here, so they are pinned
rather than assumed: ExportBlocked + export_blocked_message hold the two
messages, and export_prologue_tests asserts all eight exact strings the
copies produced. A second test asserts the two reasons stay
distinguishable -- "no viewport" is a wiring fault, "no parts" is the
user having drawn nothing, and reporting one as the other sends someone
to debug the wrong thing.

Negative-tested: making NoViewport render the NoParts text fails both.

bake_parts deliberately keeps its own prologue. It needs a different
tuple (solid + script, not scene + cache + count), so folding it in
would mean a helper with an unused half.

683 lib + 154 integration, 0 failed. All six CI gates pass.
2026-07-31 18:35:59 +00:00
arena-agent
27ff51ca47 fix(doc): hit_test yields to table/node hit tests; pointer and IME runtime coverage
Some checks failed
doc-engine / consumer (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
ProjectionLayoutTree::hit_test's nearest-glyph fallback resolved any
point to a glyph, so taps landing inside tables or advanced nodes never
reached the widget's table_hit_test/node_hit_test branches — production
cell taps silently moved the text caret instead of parking in cells.
The fallback now returns None when the point is inside a table or node
rect (those layers own their taps), while empty-space snapping beside
text still places the caret.

Close the "widget input, selection, mobile gestures" test gap with a
draw-free Area::Rect stub harness (stub_hit_area): tests seed the input
area a GPU frame normally fills and mirror the two platform
pre-dispatch steps (first_mouse_button, committed key focus), so raw
MouseDown/MouseMove and IME TextInput dispatch through event.hits
against a real Cx — exactly like a running app. Covered end-to-end:
hit-dispatch sanity, caret placement from a tap, shift-press selection
extension, drag selection over glyphs, TextInput inserting at the caret
and replacing an active selection, and a cell tap + TextInput whole-cell
round trip. The harness also exposed the test fixture actor drifting
from production's single "local" actor (cross-actor mid-run anchoring
is deterministic but not chronological — now a documented doc-engine
projection invariant); the runtime fixture now uses "local" for
production fidelity.

READMEs: doc runtime-tests section documents the stub harness and the
two bugs it unearthed; roadmap item updated (only the renderer draw
pass remains GPU/Studio-bound); doc-engine projection invariants gain
the per-actor counter boundary bullet.

6 new tests, 675 -> 681.
2026-07-29 10:43:07 +00:00