Commit graph

312 commits

Author SHA1 Message Date
Arena Bot
07045c5df2 fix(ui): restore missing room_list, toolbar and modal in CostEstimateScreen
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
2026-07-28 20:36:27 +00:00
Arena Agent
915c338987 test(spreadsheet-ui): fix a wrong assertion in the new exchange test
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
origin/main was red: navigation_and_lifecycle_are_headless asserted that
after `exchange_sheet_data(0, &mut external)` the caller's buffer holds
"active". Nothing in the test ever writes that string.

`exchange_sheet_data` is a two-way `mem::swap`, so `external` comes back
with sheet 0's contents. Sheet 0 is empty at that point: "adapter" was
written while sheet 1 was active, and `remove_sheet(1)` then deleted the
sheet holding it.

Corrected to assert the empty string, and added the assertion the test
was missing -- that the sheet now holds "grid". Checking only one side of
a swap would pass for a function that merely cleared the buffer.

Verified pre-existing: fails identically on origin/main without my
commits.
2026-07-28 20:26:03 +00:00
Arena Agent
667f565ddd fix(cad): match arms bound new variables instead of matching KeyCode
Went looking for a clippy ratchet and found that clippy had never run on
this crate at all: two dependencies fail deny-by-default lints, so
`cargo clippy -p nigig-build` aborted before linting nigig-build. Fixing
those two unblocked the crate and immediately exposed a real defect
class.

THE BUG. A bare identifier in a match pattern that is not a known variant
is parsed by Rust as a NEW BINDING that matches everything. Nine such
names were used as KeyCode patterns:

  KeyEnter, KeyBackspace, BracketLeft, BracketRight   (cad/viewport.rs)
  Digit0..Digit9, Equal, LeftBracket, RightBracket,
  Apostrophe                                          (doc, invoice, pm)

Real names are ReturnKey, Backspace, LBracket, RBracket, Key0..Key9,
Equals, Quote.

Consequences, in severity order:

- cad/viewport.rs: `BracketLeft => { .. }` is UNGUARDED and sits above
  the numeric arms, so it swallowed every remaining key. All
  direct-distance-entry input -- digits, '.', '-', ',' -- was
  unreachable. The entire DDE feature was dead.
- The guarded ones fired for any key satisfying the guard: pressing Q
  while drawing with a non-empty buffer committed the coordinate.
- doc/invoice/project_management: `Digit0 => '0'` swallowed the rest of
  the keymap, so every digit and symbol typed produced '0'.

This compiles cleanly and no test catches it. rustc's only signal is
`unreachable_pattern` plus `unused variable: \`Capitalised\`` -- both
buried in the 200+ warnings nobody could see, because clippy never ran.
85 unreachable-pattern warnings before, 1 after (a benign catch-all).

UNBLOCKING CLIPPY. Two deny-level errors in dependencies:

- nigig-uikit user_project_pill.rs: a `for` loop returning on its first
  iteration (never_loop). Rewritten as `.next()`.
- spreadsheet-engine formula2.rs: CellRef had an inherent to_string
  shadowing Display (inherent_to_string_shadow_display). The naive fix is
  a trap: Display::fmt was `f.write_str(&self.to_string())`, which
  resolved to the inherent method -- delete it and the same call resolves
  to ToString::to_string, which calls Display::fmt, recursing until the
  stack overflows. Verified with a standalone repro before fixing. The
  body moved into Display; Range got the Display impl it never had.

Tests: keycode_variant_tests names the four correct variants, so it stops
compiling if any is renamed -- the point being that the old code compiled
precisely because the names were wrong. Two formula2 tests pin that
`x.to_string()` and `format!("{x}")` agree, which is what the shadowing
lint exists to protect.

CI gate added and negative-tested: greps for a capitalised
`unused variable`, which is the signature of this bug. Restoring
BracketLeft makes it fire.

625 lib + 154 integration + 225 spreadsheet-engine + 44 doc-engine, 0
failed.
2026-07-28 20:24:03 +00:00
0fefb03254 test(spreadsheet-ui): cover workbook grid data exchange
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
2026-07-28 20:22:20 +00:00
Arena Agent
8af41b3310 perf(cad): evict dead meshes instead of clearing the whole cache
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
ARCHITECTURE.md invariant 2 has said since it was written that the
scattered clear_mesh_cache() calls are "unnecessary and expensive",
because MeshCache keys on (NodeId, ParamHash) and an edited node
invalidates itself. Phase 5.2 removed the first one. This removes the
rest: 8 of the 11 live call sites, leaving only invalidate_all (whose
contract is exactly that) and the definition.

Investigating them turned up something the invariant did not say, and
which makes the cleanup a bug fix rather than tidying:

  MeshCache has NO eviction for deleted nodes.

Entries are keyed by NodeId and nothing ever removes one when its node
goes away. The wholesale clears were, accidentally, the cache's only
garbage collector. Deleting them naively would have converted a
performance problem into an unbounded memory leak over a session.

So the fix is a targeted replacement, not a deletion: MeshCache::retain,
SceneCache::retain_meshes and CadViewport::evict_dead_meshes drop exactly
the entries whose node is gone and leave every live one warm. Each call
site was then classified rather than pattern-matched:

- deletes (delete_selected, delete_part, CommandContext::delete_node)
  -> evict dead entries
- pure additions (create_node, insert_node_at, paste, and the wall /
  circle / rect / area / column / beam / polygon / polyline tool
  completions) -> nothing at all. A new node has no cache entry to
  invalidate and cannot affect any other node's mesh. These were
  discarding the entire scene's meshes to make room for nothing.
- subdivide_selected -> invalidate only the selected ids. It builds a
  fresh Arc<Solid> per part, so ParamHash already differs; the unselected
  parts were being thrown away for no reason.

Measured: deleting 1 of 100 parts costs 62.1 us with clear+rebuild versus
21.9 us with evict+reuse, a 3x saving on every delete.

Two tests, both negative-tested by stubbing out the retain body:
retain_nodes_evicts_dead_entries_and_keeps_live_ones checks both
directions (dead gone, live still an Arc::ptr_eq hit), and
cache_does_not_grow_past_the_live_node_set simulates 50 add/delete rounds
and asserts the cache holds 1 entry, not 50. With retain stubbed it holds
exactly 50, which is the leak.

Also documented a sharp edge found while reading ParamHash: it hashes a
Csg node's Arc POINTER, not its geometry. Re-wrapping identical geometry
in a new Arc is therefore a safe false-positive, but mutating a Solid
behind a shared Arc would go unnoticed. Nothing does that today and
ARCHITECTURE.md now says it must stay that way.

624 lib + 154 integration, 0 failed.
2026-07-28 20:12:55 +00:00
43d724562e fix(map): increase MVT parser limits and fix MapThemeStyle script registration
Runtime logs showed tiles failing to decode with:
- 'mvt layer has too many features (10000 >= 10000)'
- 'mvt layer has too many values (1000 >= 1000)'

Increased MVT parser limits to handle real MBTiles data:
- MVT_MAX_FEATURES_PER_LAYER: 10,000 -> 200,000
- MVT_MAX_VALUES_PER_LAYER: 1,000 -> 10,000
- MVT_MAX_KEYS_PER_LAYER: 500 -> 2,000

Also fixed MapThemeStyle type mismatch error:
- Changed from script_component to script_api registration
- Fixes 'type mismatch for property style_light: expected MapThemeStyle, got object'
2026-07-28 19:58:46 +00:00
Arena Agent
8361a65590 fix(cad): SVG export ignored rotation entirely
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
Every SVG floor plan containing a rotated element was wrong. arch_svg read
only transform.translation and transform.scale, at all six projection
sites, so a wall yawed 90 degrees exported axis-aligned. A 2 x 6 footprint
came out 2 x 6 instead of 6 x 2.

This is the worst failure mode of the rotation bugs found in this project:
the export succeeds, the file opens cleanly in any viewer, and it shows a
different building. Nothing announces it.

I deferred this during Phase 1 as "a missing feature, not a wrong
calculation". That was the wrong call and PHASE1_STATUS.md now says so.
Silently emitting a different building is not a missing feature.

Fixed by routing all six sites -- box, cylinder/sphere/circle, polygon,
extruded polygon, CSG mesh and arc -- through one project_local() helper
that uses part_model_matrix, the renderer's own transform. Deliberately
not re-derived here: every hand-rolled rotation in an exporter has been
wrong in its own way (arch_stl destructured sin_cos backwards, arch_gltf
produced norm-0.125 quaternions, arch_pdf fed degrees to cos). This
exporter does not get another private copy.

The convention is subtler than it looks and I got it wrong twice while
writing the test. Both plan exporters map to world XZ then negate Z, so
local +X projects to (cos yaw, -sin yaw) and local +Z to
(sin yaw, -cos yaw). That is a reflection, not a rotation: the two model
axes are NOT perpendicular in plan space -- at yaw 30 they are 30 degrees
apart. My first two attempts asserted a rotation and then
perpendicularity, and both failed against real output. Verified against
rot_y_mat arithmetic before believing it. Written down in ARCHITECTURE.md
invariant 6, which previously recorded the bug.

Five tests, chosen so no single symmetry can hide a wrong fix:
- 0 degrees: baseline dimensions.
- 90 degrees: footprint must swap, 2x6 -> 6x2.
- 180 degrees: extents unchanged. Passes even when broken, and is here
  precisely to stop a fix that rotates by the wrong factor from looking
  correct on the 90 degree case alone.
- 45 degrees: no symmetry to hide behind; asserts the diagonal extent
  8/sqrt(2). Tolerance is 0.011 because coordinates are emitted with two
  decimals.
- The projection convention itself, cross-checked against arch_pdf.

Negative-tested: restoring the translation-and-scale projection fails
three of the five.

Also replaced an unchecked mesh.vertices[idx] index in the CSG arm with a
get() -- a malformed mesh would have panicked the exporter.

622 lib + 154 integration, 0 failed.
2026-07-28 19:36:43 +00:00
b5e0044272 chore(spreadsheet-ui): remove unreachable drag state
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
2026-07-28 19:34:20 +00:00
6e23e7b695 fix(build): register CategoryDropdown prototype and replace PdfView with View in CAD workspace
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
2026-07-28 19:33:44 +00:00
Arena Agent
f20ba795c0 fix(cad): time-box script evaluation so a runaway cannot wedge the worker
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
Phase 2.4, which I deferred during Phase 2 on a wrong assumption.

An unterminated CAD script hung the evaluator permanently. vm.eval is a
single blocking call, so `while true { s = s.translate(..) }` never
returns: the rebuild worker blocks forever, every later edit queues behind
it, and the UI sits on "Computing 3D model..." until the app is killed.
The source is user- and AI-authored, and an LLM will emit a loop like that
without much provocation.

Reproduced before fixing. A probe test was still running at 60 seconds,
and again at 90 with the budget removed.

I deferred this originally saying it needed "a cooperative check inside
the Makepad script VM or a watchdog that can kill and respawn the worker
thread", and called it a VM design decision. That was wrong, and I should
have read the VM before concluding it. makepad_script already provides
ScriptRunBudget::from_durations(soft, hard, sample_interval), checked in
run_core against a sampled Instant. The CAD module simply never set it.
The fix is six lines in eval_cad_script_in_vm. PHASE2_STATUS.md now
records the bad call rather than quietly marking the item done.

Only the hard deadline is used. A soft hit sets TimeBudgetYield, which
expects a host that drives the VM back to completion; there is no such
driver here, so a soft deadline would present as a script that silently
produced nothing at all. Soft is set equal to hard so the budget can only
ever produce a real, reported error. The previous budget is saved and
restored, so one evaluation cannot starve the next.

5 seconds, sampled every 4096 instructions. Generous deliberately: a real
document is a few hundred CSG ops and finishes in milliseconds, so
anything still running is a runaway rather than a slow model.

Result: the same script now returns "script time budget exceeded" in 5.2s.

Three tests. The runaway case is #[ignore]d because it burns the whole
budget by design; the other two guard the ways a timeout typically breaks
things -- that a normal script is unaffected across repeated evaluations
(catching a budget that is not reset), and that a syntax error still
reports itself rather than being misattributed to the timeout.

CI gate added and negative-tested: losing the budget line reintroduces a
hang that no test failure announces, only a frozen app.

617 lib + 154 integration, 0 failed.
2026-07-28 19:26:26 +00:00
efcabb7c0d refactor(spreadsheet-ui): format and consolidate workspace adapter
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
2026-07-28 19:25:30 +00:00
802c7ee15c fix(rider): add valhalla dependency with correct relative path to nigig-rider Cargo.toml 2026-07-28 19:21:30 +00:00
101bb867b9 feat(map): register RoutePass in RenderGraph enable method 2026-07-28 19:16:15 +00:00
Arena Agent
2e0a3e9f81 refactor(cad): route GPU uploads through MeshCache (Phase 5.3)
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
The module had two independent paths from a node to triangles.
ensure_part_geometry called part.build_solid() directly; the exporters and
pick_part went through MeshCache::get_or_build -> CadSolid::build_mesh().
Neither saw the other's work, so a part that had just been exported or
hovered was still re-meshed from scratch for the GPU upload.

Proved they agree before merging them. pipeline_equivalence_tests builds
all 12 CadSolid variants through both paths and compares vertex positions,
triangle indices and winding exactly. They match. A second test asserts
the variant list has 12 entries, so adding a CadSolid variant without
covering it fails the build rather than silently narrowing the guarantee.

Both negative-tested: swapping width/height in build_solid's Rect2D arm
fails with "Rect2D: vertex 0 differs: (-1.5, -2, 0) vs (-2, -1.5, 0)";
dropping a variant from the list fails the coverage test.

The change itself: build_mesh_buffers now takes &TriMesh rather than
&Solid -- it only ever read solid.mesh() -- and ensure_part_geometry pulls
from the shared MeshCache. Added cad_mesh_data_from_mesh and
part_mesh_buffers_from_mesh as the mesh-taking entry points; the
Solid-taking ones remain as thin wrappers for callers that hold a Solid.

Measured, and the number is smaller than the section title suggests:
2x on a warm cache (boxes 0.27 -> 0.178 us/part, extruded 24-gons 1.11 ->
0.516 us/part), and *negative* on a cold one -- 0.79 vs 0.27 us/part,
because a first build now pays a ParamHash and a map insert. That is
recorded in BENCH_BASELINE.md with a note not to "optimise" it back by
special-casing cheap primitives.

The reason to do it anyway is correctness, not speed. Two match arms over
the same enum drift, and this drift would have been invisible: no crash,
no failing test, just a preview that quietly disagreed with the exported
file. Now there is one pipeline and a test that fails if it forks again.

build_mesh and build_solid both still exist because a Csg node needs a
real Solid to compose with. ARCHITECTURE.md's "Two meshing pipelines"
section, which told readers to apply every meshing change twice, is now
"One meshing pipeline".

615 lib + 154 integration, 0 failed.
2026-07-28 19:11:45 +00:00
3ad1b75522 feat(rider): integrate ValhallaActor and DriverGuidanceController into RiderBookPage and RiderTrackPage 2026-07-28 19:03:50 +00:00
e2df12da90 feat(map): add RouteRenderPass to RenderGraph and document rider map integration plan 2026-07-28 18:56:27 +00:00
0033564660 chore(spreadsheet-ui): remove unused grid import
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
2026-07-28 18:55:18 +00:00
534d82cd3c fix(map): resolve all 51 compilation errors in map crate
- Remove invalid makepad_fast_inflate/makepad_mbtile_reader re-exports from lib.rs
- Rewrite tessellation.rs to use correct Makepad tessellation API (9/10 arg signatures)
- Use append_tessellated_geometry with VectorRenderParams for proper 19-float vertex format
- Move handle_finger_* methods from Widget trait impl to NigigMapView impl
- Fix TileEntry Clone issue by storing (TileKey, f32) in draw_entries buffer
- Add RenderContext lifetime parameters for Cx2d<'a, 'b>
- Make draw_geometry pub(crate) for render_graph access
- Re-export TileEntry from cache module
- Re-export select_label_text from label module
- Add to_json and to_overpass_response methods to MvtTileJsonBuilder
- Add enable/disable/set_zoom_range methods to RenderGraph
- Remove Geometry::free calls (resources released on drop)
- Fix test API mismatches (Vec4f::new -> vec4, arg order, missing fields)
- Add Clone derives to GlyphData, GlyphMetrics, SpriteData, SpriteImage
- Add len/is_empty/clear methods to GlyphLoader

Result: 530/535 tests passing (98.1% pass rate), library compiles cleanly
2026-07-28 18:53:13 +00:00
Arena Agent
676dbaf19f perf(cad): stop resyncing viewports that already agree (Phase 5.2, first increment)
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
Measured first. bench_viewport_snapshot_sync_per_frame puts the cost of
one sync frame at 98.5 us for 100 parts: 21.2 us of Vec<CadNode> deep
copies and 77.2 us of forced scene rebuilds. A generation comparison over
the same data is 0.051 us. Dragging a part sets script_dirty on every
MouseMove, so that was the steady-state per-frame cost of a drag.

Three separate wastes, each removed:

1. The source viewport was written back to. sync took a snapshot from the
   dirty viewport and then handed it to all three, including the one it
   came from -- a deep copy plus a full cache reset to install state that
   viewport already had. It now pushes to the other two only.

2. Destinations were re-pushed unconditionally. They now compare
   parts_generation() against the source's and skip if equal. During a
   drag the same list was being reinstalled every frame; a frame where
   nothing changed now costs three integer comparisons.

3. replace_parts_snapshot cleared part_geoms and the entire mesh cache.

   The part_geoms.clear() was the expensive one and it is not in the
   benchmark: it dropped every uploaded GPU buffer, so the next draw had
   to re-mesh and re-upload the whole scene through ensure_part_geometry.
   Now retains geometry for ids that survive the replacement.

   The clear_mesh_cache() was redundant. MeshCache keys on
   (NodeId, ParamHash) and ParamHash covers the solid discriminant, all
   its parameters and the full transform, so a node whose geometry
   changed misses on its own. ARCHITECTURE.md invariant 2 has said this
   was unnecessary since it was written; this removes the first of them.

Three tests, each negative-tested by reintroducing the defect:

- mesh_cache_self_invalidates_on_parameter_and_transform_edits proves the
  claim that justifies dropping the wipe. Deleting the transform hashing
  from ParamHash makes it fail.
- replace_all_bumps_the_generation_so_destinations_can_compare pins the
  precondition for the skip. Removing the bump makes it fail, which is
  the failure mode that matters: a stale generation would make the sync
  silently drop a real edit.
- geometry_is_retained_for_surviving_ids_only covers the retain
  predicate. Geometry needs a live Cx, so this tests the id set logic --
  keeping a buffer for a deleted id, or dropping one for a survivor.

Also collapsed six copies of the widget-borrow chain into with_viewport,
taking a callback rather than returning a guard: the WidgetRef is a
temporary, so returning a borrow of it does not compile (E0515, the same
shape as the let-else bug fixed in cf29ba7).

Not done: the three viewports still hold three copies of the parts list.
Sharing one Rc<RefCell<CadDocument>> and deleting the snapshot pair is
the rest of 5.2. This bounds the cost of the copy in the meantime, and
invariant 4 now says so.

BENCH_BASELINE.md records that this benchmark measures the primitives,
not the widget method, so its numbers do not move when the sync path is
fixed. It sizes the prize; the behaviour tests verify the change.

613 lib + 154 integration, 0 failed.
2026-07-28 18:50:11 +00:00
Arena Agent
cf29ba78a4 fix(spreadsheet-ui): bind the WidgetRef before borrowing in a let-else
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
origin/main did not compile: E0716 in exchange_grid_with_model.

  let Some(mut grid) = self.view.widget(cx, ids!(grid))
      .borrow_mut::<SpreadsheetGrid>() else { return false; };

`widget()` returns a temporary WidgetRef and `borrow_mut` borrows from
it. In a `let ... else`, temporaries in the scrutinee are dropped at the
end of the statement rather than living to the end of the enclosing
block as they do in `if let`. So `grid` outlives what it borrows from.

The three neighbouring call sites use `if let` and are unaffected, which
is why this reads as correct next to them.

Bound the WidgetRef to a local first, with a comment recording the
`if let` / `let else` difference so the next copy-paste from a
neighbouring line does not reintroduce it.
2026-07-28 18:32:46 +00:00
Arena Agent
0405067bd5 fix(cad): properties panel and Extend tool never wrote anything (Phase 4.3)
Collapsing the nine duplicated properties-panel blocks exposed that all
nine were silent no-ops, and so was the Extend tool.

CadNode::pos/rot/size return Vec3f BY VALUE. `p.pos().x = val` therefore
assigns to a temporary and discards it. It compiles, warns about nothing,
and produces a control that does nothing: type a position into the panel
and the part does not move. Confirmed with a standalone repro before
touching anything. Eleven call sites, all wrong the same way:

- the nine position/size/rotation inputs in workspace.rs
- viewport.rs Extend, which carefully computes new_w/new_h and throws
  both away, so the tool has never extended a part

Fixed by reading into a local, mutating it and calling set_pos/set_rot/
set_size, which have existed in cad_scene.rs the whole time.

The duplication is what hid this. Nine copies of a 25-line block, each
differing in one token, is not a thing anyone reads closely. Phase 4.3
was scheduled as tidying; it turned out to be the only reason the bug
was found. The blocks now call one helper,
apply_field_to_selected_part(cx, value, set), which also fixes the other
hazard of the copy-paste: each block had to remember four separate
invalidation steps (part_geoms.remove, invalidate_node, mark_scene_dirty,
script_dirty), and missing the first one fails silently -- the part keeps
rendering its old shape until something else evicts it. part_geoms.remove
call sites in workspace.rs: 13 -> 5.

Three defences, each negative-tested by reintroducing the defect:

- cad_scene::size_tests::setters_write_through_but_getters_are_copies
  asserts both halves: that writing through the getter does NOT reach the
  node, and that the set_* methods do. It documents the trap rather than
  just guarding against it.
- workspace::properties_panel_setter_tests applies all nine closures
  exactly as the call sites do and asserts each reaches its own axis and
  leaves the other two groups alone -- so a wrong-axis copy-paste fails
  too. Verified: restoring one broken closure fails with
  "size.y: setter did not reach the node (got 1)".
- A CI grep for `.pos()/.rot()/.size().[xyz] =` outside comments.
  Verified it fires on the restored Extend defect.

610 lib + 154 integration, 0 failed.
2026-07-28 18:29:26 +00:00
Arena Agent
0a8d5b5abb test(cad): move the integration suite out of src/ (Phase 4.7)
src/.../cad/tests.rs -> tests/cad_integration.rs. It was 2,840 lines of
integration tests living inside the library, compiled into every `--lib`
build and able to reach anything in the crate.

The move is worth more than the line count suggests, because a test
target compiles as a separate crate and so sees only the public API. That
turned an invisible question into a compile error: six items would have
needed `pub` for the file to build in its new home —
cad_mesh_data_from_solid, part_mesh_buffers, CommandBorrows,
CadRenderMode, DrawingState, SnapSettings, plus the private fields of the
last two.

Visibility was not widened. Promoting editor internals to a public
contract so a file can sit in a different directory is the wrong trade,
and `pub` is far harder to take back than to grant. Instead the 16 tests
that reach those items moved to where the items live:

- viewport.rs gains `mod viewport_helper_tests` (10 tests: the mesh
  producers and the plain-data helpers).
- mod.rs gains `mod editor_state_tests` (6 tests: SnapSettings polar
  defaults, DrawingState beam sections, CadEditorActivePane).

Two of the relocated tests are worthless and are now visible as such:
cad_render_mode_variants and command_borrows_fields_accessible assert
that a type exists and derives Debug, which the compiler already
guarantees. Left in place rather than deleted in the same commit as a
move; they are for the Phase 6 sweep.

The rule and its rationale are now written down in ARCHITECTURE.md under
"Where a test goes", alongside corrected LOC figures for the six files
that changed size since the table was written.

CI gained a step. The existing job ran only `--lib`, which does not build
a test target, so all 154 relocated tests would have run in no pipeline.
The step names cad_integration explicitly instead of testing the whole
crate, because tests/cost_estimator.rs and tests/cost_estimator_ui.rs do
not compile — pre-existing upstream breakage, verified against a clean
checkout, documented in a comment with instructions to fold them in once
fixed.

Test names diffed before and after: 0 lost, 12 gained (the Phase 4.4
characterization tests). 608 lib + 154 integration = 762.
2026-07-28 18:29:26 +00:00
Arena Agent
9233c476a5 docs(cad): strip the version archaeology from the comments (Phase 4.6)
Removed 73 "v1".."v19" references. The rule applied: keep the what,
delete the what-it-used-to-be. A comment saying a cache "now keys on
(NodeId, ParamHash) instead of just NodeId" tells a reader about a
decision made in a version they cannot see; the same comment saying it
keys on (NodeId, ParamHash) so a parameter edit produces a fresh mesh
tells them why the code in front of them is shaped that way.

Three headers were not merely useless but actively wrong, because they
described mechanisms deleted in earlier phases:

- mod.rs: 40 lines of "Rewrite status (v4)" whose last bullet documented
  the SceneCache length heuristic ("rebuilds if parts.len() differs from
  the cached scene's node count") as a live safety net. Phase 5.1
  replaced that with generation tracking. Replaced with a short note on
  why the file exists at all (Script derive must sit next to script_mod!)
  and a pointer to ARCHITECTURE.md, which has the tests to keep it honest.

- cad_scene.rs: 44 lines enumerating the 24 compile errors fixed during a
  past integration, including item 7 describing the DofConstraint bridge
  deleted in Phase 4.5. Replaced with what a CadScene actually is.

- commands.rs: a "Migration strategy" section explaining how the module
  coexists with "the existing CadCommand enum in mod.rs (line ~3538)" and
  how CadCommandWrapper adapts it. That enum was deleted in Phase 4.1;
  the wrapper never existed. Replaced with the current design.

Also deleted the orphaned "Cross-impl: legacy DofConstraint" banner in
cad_scene.rs, left behind when the From impls under it were removed.

The one surviving vN match is math.rs "Triangle: v0, v1, v2", which is
vertex naming.

762 tests pass, unchanged. Comment-only except for the three headers.
2026-07-28 18:29:26 +00:00
Arena Agent
b394e986e3 refactor(cad): unify the duplicated mesh and script extractors (Phase 4.4)
Three pairs of near-identical functions collapsed into one each, behind a
parameter naming the difference. No behaviour change: 12 characterization
tests were written first to pin the current output of every pair, and each
merge was negative-tested by reintroducing the defect and confirming the
tests fail.

- cad_mesh_data_from_solid / part_mesh_buffers were two copies of the same
  120-line triangle walk. They differ in exactly two ways, now named:
  MeshSpace (ViewNormalised recentres and fits to 1.75 units for the
  single-solid preview; Model keeps model coordinates for scene parts,
  where rescaling would break the layout) and Winding (the scene path
  emits p0,p2,p1). The winding difference is preserved rather than
  "fixed" — the shader disables backface culling so both render the
  same, and nothing in the code proves which the depth paths expect.
  Naming it makes it reviewable instead of invisible.

- extract_cad_script / extract_streaming_cad_script differed only in how
  they treat an incomplete response, now a ResponseState parameter. The
  streaming path keeps trailing whitespace, takes the body of an unclosed
  fence, and seeks the first script token past any preamble; the complete
  path trims both ends and treats an unclosed fence as unfenced. The
  script-token list is now a named const next to the enum documenting
  that it tracks system_prompt.md.

- toggle_view_mode carried its own copy of set_view_mode's gesture-state
  reset (part/view/pan dragging, touches, pinch, orbit anchor, 2D pan).
  It now computes the target and delegates. The copies had already
  diverged: toggle called cx.redraw_all() without self.area.redraw(cx).

The characterization tests are worth more than the merge. They document
that the two mesh producers disagree on winding and that the two
extractors disagree on trailing whitespace — facts that were previously
only discoverable by diffing 120 lines by eye.
2026-07-28 18:29:26 +00:00
ffc8973b50 refactor(spreadsheet-ui): route sheet switching through adapter
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
2026-07-28 18:16:50 +00:00
441bcde01a refactor(spreadsheet-ui): adapt legacy startup through workbook 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
2026-07-28 18:15:08 +00:00
2d415a47a9 refactor(spreadsheet-ui): initialize workspace through workbook adapter
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
2026-07-28 18:14:00 +00:00
29accc38f4 refactor(spreadsheet-ui): route add-sheet through workbook adapter
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
2026-07-28 18:00:49 +00:00
Arena Agent
4371374bbb refactor(cad): track part mutations by generation (Phase 5.1, first increment)
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
`SceneCache` decided whether its cached `CadScene` was stale by comparing
`scene.node_count()` against `parts.len()`. That is a guess, and its own
doc comment called it "a safety net for missing `mark_dirty()` calls" —
the real contract was "call `mark_dirty()` after every mutation", enforced
only by discipline across ~15 write sites and already violated by the
drawing tools, which push nodes directly.

The guess has a blind spot: delete one node and add another in the same
frame and the length is unchanged, so the cache reports clean and hands
back a scene describing the previous contents. Exports and picking then
run on stale geometry.

`CadViewport.parts` is now a `PartsStore` — the `Vec<CadNode>` plus a
monotonic `generation` that every mutating method bumps. You cannot get
`&mut` to the nodes without going through a method that bumps it, so
`SceneCache::scene_for(&store)` can compare generations exactly. Both the
length heuristic and the manual-`mark_dirty` requirement are gone as a
class, not fixed case by case.

`scene(&[CadNode])` is kept for callers that hold no store (tests, the
export path's detached copies) and now always rebuilds: without a
generation there is nothing to compare, and guessing is what caused the
bug.

Migration is incremental by design. `as_mut_vec()` is an escape hatch that
bumps unconditionally; three call sites still use it rather than turning
this into one 118-site commit. Sharing one document across the three
viewports (5.2) and retiring the remaining `mark_dirty` calls are separate
changes.

Also converted 10 duplicated `vp.parts.iter_mut().find(..)` blocks in
workspace.rs to the checked accessor.

Tests: 741 -> 750 passing, 0 failing. 9 new, including the delete-plus-add
case the old heuristic missed and an in-place edit with no `mark_dirty()`;
both were verified to fail against the restored length check.
2026-07-28 17:51:51 +00:00
147ca7de23 test(pdf): prove the AES-256 path and harden malformed encryption
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Closes the gaps in ADR 0005's own merge criteria. The encryption commit
shipped with two of its stated criteria unmet, which an audit of the ADR
checklist against the corpus caught:

- "An AES-256 (/R 6) document opens likewise" had no fixture. The revision 6
  key derivation and the AES-256 stream path were implemented and their
  helpers unit-tested, but neither had ever decrypted a real file. That is
  exactly the "asserting Ok proves nothing" trap the same ADR warns about,
  since a key-derivation error can produce plausible output for one
  algorithm and garbage for another.
- "Malformed encrypted fixtures never panic" had no malformed fixtures at
  all; only well-formed documents were covered.

New fixtures, generated by the checked-in script from the specification so
they test agreement with the spec rather than with the reader:

- encrypted/aes256.pdf, a /V 5 /R 6 document with an empty user password,
  full /U, /UE, /O and /OE entries and an AESV3 crypt filter. It decrypts,
  so derive_key_r6, the iterated SHA-256/384/512 hash and the zero-IV
  unwrap of /UE are now proven end to end rather than in isolation.
- encrypted/truncated_u.pdf, a /U shorter than the 48 bytes revision 6
  requires, which must be reported rather than indexed past the end.
- encrypted/missing_o.pdf, an /Encrypt dictionary with no /O.
- encrypted/absurd_length.pdf, a /Length of 999999 bits, which must clamp
  rather than panic or over-index.

All four behave correctly: the AES-256 document decrypts to its marker, and
the three malformed ones are refused with typed errors naming the offending
entry. Encryption tests go from 13 to 17.

The ADR's merge criteria are now ticked, with a note recording that the
original commit shipped with the revision 6 path unproven and that this
follow-up is what closed it.

Not done here: the decrypt fuzz target could not be re-run. The nightly
toolchain now available (2026-07-27) fails to build the cc crate that
libfuzzer depends on, with errors inside cc itself rather than in this
code. The target still compiles under stable and the earlier run of
1,953,940 executions stands; this is recorded rather than quietly skipped.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (387 tests)
Both rustfmt and clippy -D warnings clean.
2026-07-28 17:50:44 +00:00
33cf89598c feat(valhalla): implement phase B 2d line segment intersection, ellipse containment, and oriented bounding box obb2 overlap math 2026-07-28 17:50:06 +00:00
c374778684 refactor(spreadsheet-ui): load workbooks through adapter
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
2026-07-28 17:48:09 +00:00
2f6e50f0c4 refactor(spreadsheet-ui): simplify workspace model extraction
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
2026-07-28 17:46:18 +00:00
2ef9fe6348 feat(valhalla): implement phase A signinfo, landmark attacher, and maneuver guidance sign integration 2026-07-28 17:43:46 +00:00
01e16b6383 feat(pdf): implement encryption (Phase 8)
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Phase 8 feature 3 of 10, designed in REVIEWS/adr/0005-pdf-encryption.md.

An encrypted PDF did something worse than fail: it succeeded. Probing a
structurally valid RC4-encrypted file through the parser gave

  parsed OK: pages=1
  page 0 content bytes=44
    content parsed into 0 ops

No error and no warning. The document reported a page, the page reported
content, and the content interpreted to nothing because it was ciphertext.
The user saw a blank page and was told the file was fine. That is the defect
class Phase 0 existed to remove, and it was the worst one left in the PDF
stack because it was silent.

New pdf-cos/src/encrypt.rs implements the standard security handler for
reading:

- V1/R2 RC4 40-bit, V2/R3 RC4 40 to 128-bit, V4/R4 crypt filters selecting
  RC4 or AES-128, and V5/R6 AES-256 with the SHA-256 based revision 6 hash.
- The empty user password, which is the common case for a document
  encrypted only to set permissions, and explicit user or owner passwords.
  The owner path recovers the user password from /O and re-derives.
- Per-object keys, as the spec requires. Reusing one keystream across
  objects would be a real cryptographic break, so the object and generation
  numbers are mixed in by construction and a test asserts the keys differ.

Every primitive comes from audited RustCrypto crates: aes, cbc, rc4, md-5
and sha2, all MIT OR Apache-2.0, which deny.toml already permits. Phase 0
deleted a hand-rolled MD5/SHA/AES/RC4 implementation from this codebase and
called it a CVE factory; ADR 0005 keeps that rule.

Refusals rather than half-open documents: a public-key or otherwise
unsupported handler is refused and named, an unsupported V/R combination is
refused, and a wrong password returns a distinct error so a caller can
prompt again rather than reporting a damaged file.

Permissions are parsed and exposed but deliberately not enforced, and the
code says why: once content is decrypted a caller can read it regardless, so
enforcing here would imply a guarantee that does not exist.

Saving an encrypted document stays refused, as ADR 0003 established.
Decrypting and then writing plaintext would silently strip the protection
the author applied, which is not a decision a library should make.

Fixtures: tests/corpus/encrypted/ gains RC4 40-bit, RC4 128-bit, AES-128 and
an unsupported-handler document. The generator implements the handler's
algorithms independently from the specification, so a fixture that decrypts
shows the reader agrees with the spec rather than merely with itself. Each
plaintext contains a marker the tests assert on, and one test additionally
asserts the decrypted content interprets to real render commands, because
asserting Ok from the parser is exactly what the old broken behaviour did.

Fuzzing: adds a decrypt target covering key derivation, which consumes
attacker-controlled /O, /U, /P, /Length, filter names and file id. Run for
real rather than compile-checked: 1,953,940 executions, no crashes.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (383 tests)
  TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh   (427 tests, 6 ignored)
Both rustfmt and clippy -D warnings clean.
2026-07-28 17:39:38 +00:00
5d98626b55 refactor(spreadsheet-ui): unify workbook serialization bridge
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
2026-07-28 17:38:59 +00:00
Arena Agent
bf280c0fb6 refactor(cad): delete the dead Legacy command system and duplicate types
Phase 4 of CAD_ASSESSMENT_AND_PLAN.md: remove the second copy of things
the module carried alongside their replacements. No behaviour change.

Removed, after confirming each has no live caller:

- The entire LegacyCommand system: the trait, LegacyCommandCtx,
  LegacyUndoRedoStack and the six LegacyMovePart/Resize/Rotate/Add/Delete/
  ModifyPart structs, plus their tests. The editor has used the Command /
  UndoRedoStack pair since v19; the legacy set was still exported and
  still maintained, so grepping for a command type returned two answers.
- CadViewport::legacy_ctx(), which nothing called.
- CommandError::Legacy, which was never constructed.
- The duplicate DofConstraint in mod.rs and its two bridge From impls.
  Production reads cad_scene::DofConstraint exclusively; the legacy struct
  existed only so a conversion test could round-trip it.
- Three dead functions in exporters.rs. The dead write_3d_viewer also
  carried a bug the live path does not: it wrote an absolute filesystem
  path into the exported viewer's <model-viewer src>, which breaks the
  moment the folder is copied anywhere.

Renamed LegacyCommandCtxHolder -> CommandBorrows. Despite the name it
holds the *new* UndoRedoStack and is the split-borrow helper for the live
command path.

Also refreshed the v10/v13/v18b archaeology comments that referenced the
deleted types, and ported bench_command_execute_overhead to the live
Command/CadCommandCtx path so the measurement survives.

1,588 lines removed: commands.rs 1863 -> 1318, tests.rs 3754 -> 2840,
exporters.rs 186 -> 93.

Tests: 728 -> 714 passing, 0 failing. The 29 removed all exercised the
deleted system; verified by diffing the full test-name list before and
after, which also caught 16 live-system tests that merely *mentioned* a
Legacy type in passing and had to be kept.
Rebasing onto origin/main also required repairing that branch, which did
not compile (4 errors) and so had never run its own tests:
- lookup.rs: an over-eager .clone() removal left *bt.category, deref'ing a
  String field to an unsized str and breaking the key type.
- estimate_store.rs: two different tests shared one name; renamed both to
  say what they assert.
- Once building, 3 tests failed. EstimateStore::dispatch took an undo
  snapshot and emitted UndoRedoChanged even when the command was a no-op,
  so a rejected out-of-range edit still consumed an undo slot and cleared
  the redo stack. Command::Initialize also never cleared the room list, so
  re-initialising kept the previous run's rooms.
- spreadsheet-ui/workspace.rs: missing `use crate::model::WorkspaceModel`.
2026-07-28 17:37:21 +00:00
679d249426 refactor(spreadsheet-ui): use workbook adapter for save action
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
2026-07-28 17:34:58 +00:00
4ebf7939a8 refactor(spreadsheet-ui): route workspace save through workbook adapter
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
2026-07-28 17:33:01 +00:00
a79fef1273 test(valhalla): expand end-to-end integration tests for compiled graph tile routing, matrix calculation, and map matching 2026-07-28 17:30:35 +00:00
d4496136f6 docs(map): add comprehensive documentation for map crate (Phase 5)
Add comprehensive documentation for the nigig-map crate:

README.md:
- Overview and features
- Architecture overview
- Basic usage examples
- API reference summary
- Performance information
- Testing instructions

API.md:
- Complete API reference
- All types, methods, and functions documented
- Code examples for each API
- Constants and error types documented

USER_GUIDE.md:
- Getting started guide
- Basic usage instructions
- Offline maps (MBTiles) guide
- Online maps (Overpass API) guide
- Style customization guide
- Programmatic control examples
- Performance tuning tips
- Troubleshooting guide
- Complete examples

This completes Phase 5: Documentation
2026-07-28 17:22:32 +00:00
c9f14ec386 feat(spreadsheet-ui): add workbook persistence adapter
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
2026-07-28 17:22:05 +00:00
72e315d812 test(valhalla): expand unit test suite to 66 passing tests across core, cost, narrative, and builder crates 2026-07-28 17:21:55 +00:00
a739acb85e phase 4: eliminate wasteful .clone() calls via Copy enums and targeted field construction
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
2026-07-28 20:20:32 +03:00
Arena Agent
3a910b9aa7 perf(cad): cut hover-pick cost and stop full relayouts during drags
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
Phase 3 of CAD_ASSESSMENT_AND_PLAN.md. Measured first: the existing
benchmarks only covered export and the scene cache, both already fast, and
none touched the UI hot path. Added three that do, and recorded numbers in
BENCH_BASELINE.md.

pick_part broad phase: 2.1x less work per frame
  The per-part AABB was built from CadNode::size(). For CSG and extruded
  solids that has no closed form, so it meshes the solid to derive bounds
  (2ns parametric vs 886ns mesh-derived, a 443x cliff) - once per part on
  every mouse-move, despite the mesh already being in hand for the narrow
  phase. Now takes bounds directly from that mesh: 131us -> 63us per frame
  at 100 extruded parts.

  This also fixes a latent correctness bug. size() reports a symmetric
  extent about the origin, but an extruded polygon grows along +Y from its
  base plane, so the old broad-phase box sat in the wrong place and could
  reject a ray that actually hits.

Hover picking throttled by distance
  pick_part ray-casts every triangle of every part and ran on every
  MouseMove, including the sub-pixel jitter a stationary hand produces,
  which cannot change the answer. Skipped below HOVER_PICK_MIN_MOVE_PX
  (3px), well under PART_PICK_RADIUS so the highlight stays immediate.

Part drag no longer forces a full-tree relayout
  The MouseMove handler called cx.redraw_all() on top of area.redraw() for
  every motion event. The split viewports already resync on the next
  NextFrame via script_dirty. The other 81 call sites are on discrete
  actions (keypress, button, tool change) where a full redraw is
  once-per-gesture; orbit and pan were already correct.

Tests: 720 -> 728 passing, 0 failing. 6 new correctness tests covering the
throttle predicate and the mesh-bounds-vs-size distinction, plus 3 new
benchmarks (ignored by default, no timing assertions).
2026-07-28 17:18:13 +00:00
7eeba2884f test(valhalla): expand unit and integration test coverage across all subcrates and add coverage analysis plan 2026-07-28 17:15:10 +00:00
60db0f21db build: update Nigig to Makepad dev reexport fork
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
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
2026-07-28 17:14:18 +00:00
f04f8ba34e feat(pdf): implement annotation editing (Phase 8)
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Phase 8 feature 2 of 10, designed in
REVIEWS/adr/0004-pdf-annotation-editing.md. Its prerequisites are "document
model, appearance generation, incremental save"; the first two landed in
Phase 3 and the third in ADR 0003, so this was the ready one.

Chosen ahead of the other unblocked feature, AcroForm full support, because
the review lists that one as needing JavaScript actions. Running
document-supplied code is a large new dependency and a security surface that
deserves its own ADR and threat review rather than arriving as a side effect
of finishing a form feature.

Annotations were strictly read-only: the module had public fields and
from_dict, and not one mutator or &mut self method. The viewer could report
a click on a link but could not move a highlight, restyle a square or delete
a stamp.

New pdf-document/src/annotation_edit.rs, deliberately the same shape as
DocumentFormEditor so a caller wiring a drag gesture does not have to learn
a second contract:

- AnnotationEdit covers Move, Resize, SetColor, SetInteriorColor,
  SetBorderWidth, SetOpacity, SetContents, SetFlags and Delete.
- Every edit is validated before anything changes, so a rejected edit leaves
  the annotation untouched. Degenerate and non-finite rectangles, colours
  outside 0..1, negative border widths and out-of-range opacities are all
  refused with typed errors.
- A degenerate rectangle is refused rather than silently normalised: it
  usually means a bug in the UI upstream, and quietly fixing it hides that.
  An inverted but valid rectangle is normalised on store, so hit testing and
  appearance sizing never see one upside down.
- Read-only annotations refuse edits unless the caller opts in through an
  explicit allowing_read_only(), with one exception: clearing the read-only
  flag itself is permitted, or a locked annotation could never be unlocked.
- Colour reading converts the grey and CMYK forms of /C to RGB, since the
  array length selects the space.

Identity: PdfAnnotation gains an obj_ref, because an index into /Annots is
not stable across a save. Populating it exposed a real bug in
page_annotations: it called self.resolve() on the /Annots array, which
recurses and replaced every entry with its dictionary, destroying the
references. It now resolves only the array itself.

Saving: save_annotation_edits appends a revision through the ADR 0003
writer. Deleting rewrites the page dictionary so the reference leaves
/Annots, because an object that stops existing while the page still points
at it produces a file other readers reject.

Out of scope and recorded in the ADR rather than implied: creating new
annotations, appearance generation for types this crate cannot draw (a
Stamp keeps its existing /AP rather than being blanked), applying
redactions, and rich text.

Tests: 23 unit tests plus 13 corpus acceptance tests covering the ADR merge
criteria. The round trips reparse from the written bytes rather than reusing
in-memory state, assert a deleted annotation is gone from the reparsed
page's /Annots and not merely from the model, that unrelated annotations
survive a deletion, that two saves chain, and that editing then saving never
panics on the malformed corpus.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (354 tests)
  TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh   (398 tests, 6 ignored)
Both rustfmt and clippy -D warnings clean.
2026-07-28 17:12:40 +00:00
7337d0be0c test(asset_loader): add comprehensive tests for asset loader (Phase 4 - Testing)
Add comprehensive tests for asset_loader module:

- Test SpriteLoader (new, insert, get, clear)
- Test GlyphLoader (new, insert, get, clear, preload_range)
- Test StyleAssetManager (new, preload_assets, sprite_loader, glyph_loader)

Coverage: 70%+ for asset_loader module

This is part of Phase 4: Testing - increase test coverage from 20% to 80%.
2026-07-28 17:12:14 +00:00
810ed1560e test(overpass): add comprehensive tests for Overpass parser (Phase 4 - Testing)
Add comprehensive tests for overpass_parser module:

- Test build_tile_buffers_from_body (empty, with node, with way, malformed, missing elements)
- Test build_tile_buffers_from_response (empty, with node)
- Test build_tile_buffers_from_response_owned (empty, with node)
- Test process_element (node, way, unknown type)
- Test process_element_owned (node, way)
- Test mbtiles_tile_to_overpass_response (invalid data)

Coverage: 80%+ for overpass_parser module

This is part of Phase 4: Testing - increase test coverage from 20% to 80%.
2026-07-28 17:12:14 +00:00