Whole-tree sync: cad-core/cad-ui split sources, nigig-build construction_frame migration, pdf port progress, mpesa/pay/uikit/doc updates, workspace members/profiles/lock, CI workflows and reviews. See individual file history for details.
8 KiB
Plan: 1:1 Port of dart-pdf path.dart → nigig-pdf (Phase 7.5 — Path Object Parity)
Date: 2026-09-06
Reference: dart-pdf/packages/pdf_graphics/lib/src/path.dart:1 (343 LOC), dart-pdf/packages/pdf_graphics/test/path_test.dart:1 (73 LOC)
Nigig baseline: crates/apps/pdf/pdf-graphics/src/recording.rs:11 (RenderCommand path ops), crates/apps/pdf/pdf-graphics/src/lib.rs:1
Master plan: NIGIG_PDF_FEATURE_PARITY_PLAN.md:746 (path_test | DONE — overstated, see §1)
1. Objective
Bring nigig-pdf path object model to 1:1 parity with dart-pdf's path.dart without changing interpreter correctness. RenderCommand stays as the paint-level wire; PdfPath becomes the packed, allocation-conscious path payload that dense CAD streams need and that PdfPathCursor traverses without allocating.
Non-goal: path booleans, stroking, simplification (no dart equivalent).
2. Gap Audit (why DONE is partial)
| Aspect | dart-pdf path.dart |
nigig recording.rs |
Gap |
|---|---|---|---|
| Path type | sealed PdfPathSegment + PdfPath dual storage: List<PdfPathSegment> or packed Uint8List verbs + Float32List coordinates (wire f32 preserved) + segmentCount + Expando materialized cache path.dart:38 |
RenderCommand::MoveTo/LineTo/CurveTo/ClosePath/Rectangle — path implicit as command sequence |
No PdfPath struct, no packed wire, no materialized cache |
| Builder | PdfPathBuilder(verbCapacity=16, coordinateCapacity=48) pools buffers, _ensure grows >>1, takePath() copies exact-size Uint8List/Float64List and reuses scratch (tens of k one-segment CAD paths) path.dart:196 |
RecordingDevice::move_to pushes RenderCommand directly recording.rs:554 |
No pooling, no exact-size copy, no reuse |
| Cursor | PdfPathCursor non-allocating moveNext() over packed or object storage path.dart:106 |
for cmd in &commands over Vec<RenderCommand> |
No cursor, allocates per segment if segments needed |
| Fill rule | enum PdfFillRule { nonzero, evenOdd } path.dart:302 |
Two commands FillWinding/FillEvenOdd recording.rs:21 — no enum |
Missing enum (trivial) |
| Stroke | class PdfStroke { width cap join miterLimit dashArray dashPhase } page-space path.dart:305 |
Five commands SetStrokeWidth/Cap/Join/MiterLimit/Dash recording.rs:27 |
Not bundled |
Correctness is already 1:1 (ops round-trip). Performance + wire exactness is ~60%.
3. Scope
3.1 New file crates/apps/pdf/pdf-graphics/src/path.rs (~350 LOC)
pub enum PdfPathSegment { MoveTo{x:f64,y:f64}, LineTo{x,y}, CubicTo{x1,y1,x2,y2,x3,y3}, Close }
pub struct PdfPath { // dual storage, like dart
segments: Option<Vec<PdfPathSegment>>,
verbs: Option<Vec<u8>>,
coords: Option<Vec<f32>>, // f32 on wire, f64 in builder — mirrors dart Float32List vs Float64List
len: usize,
materialized: OnceCell<Vec<PdfPathSegment>>,
}
impl PdfPath {
pub fn from_segments(Vec<PdfPathSegment>) -> Self;
pub fn packed_float32(Vec<u8>, Vec<f32>, usize) -> Self; // wire exactness
pub fn segments(&self) -> &[PdfPathSegment]; // lazy materialize + cache
pub fn cursor(&self) -> PdfPathCursor;
pub fn segment_count(&self) -> usize;
pub fn is_empty(&self) -> bool;
}
pub enum PdfPathVerb { MoveTo, LineTo, CubicTo, Close }
pub struct PdfPathCursor { verb: PdfPathVerb, x1,y1,x2,y2,x3,y3, move_next() -> bool }
pub struct PdfPathBuilder { verbs: Vec<u8>, coords: Vec<f64>, verb_count, coord_count }
impl PdfPathBuilder {
pub fn new(verb_capacity: usize, coord_capacity: usize) -> Self; // defaults 16/48
pub fn move_to(&mut self, x:f64, y:f64);
pub fn line_to(&mut self, x:f64, y:f64);
pub fn cubic_to(&mut self, x1:f64,y1:f64,x2:f64,y2:f64,x3:f64,y3:f64);
pub fn close(&mut self);
pub fn add_segment(&mut self, PdfPathSegment);
pub fn take_path(&mut self) -> PdfPath; // exact-size copy + reuse scratch
pub fn clear(&mut self);
}
pub enum PdfFillRule { NonZero, EvenOdd }
pub struct PdfStroke { pub width:f64, pub cap:u32, pub join:u32, pub miter_limit:f64, pub dash_array:Vec<f64>, pub dash_phase:f64 }
Constants: _moveTag=0, _lineTag=1, _cubicTag=2, _closeTag=3 path.dart:184, builder defaults 16/48 path.dart:198.
3.2 Modified files
crates/apps/pdf/pdf-graphics/src/recording.rs:11— addRenderCommand::Path(Rc<PdfPath>, PdfFillRule, Option<PdfStroke>)alternative or keepMoveToetc. for back-compat and addPdfPathas optional payload; updateformat_command+wire.rscodec to preserve f32 bit-exact (dart guarantee).crates/apps/pdf/pdf-graphics/src/device.rs:6MakepadPdfDevice— when a path is open, delegatemove_toetc. toPdfPathBuilder, onfill/strokeconsume viatake_path().crates/apps/pdf/pdf-graphics/src/lib.rs:1—pub mod path; pub use path::{PdfPath, PdfPathBuilder, PdfPathCursor, PdfFillRule, PdfStroke, PdfPathVerb};NIGIG_PDF_FEATURE_PARITY_PLAN.md:746— flippath_test | DONE→path_test | Phase 7.5 | packed + cursor + builder pool + stroke/fill_rule, wire f32 exactuntil landed, then back toDONE.
4. Test Port (dart → Rust)
| # | dart pdf_graphics/test/path_test.dart |
Rust crates/apps/pdf/pdf-graphics/src/path.rs or tests/path.rs |
Notes |
|---|---|---|---|
| T1 | packed path cursor and compatibility segments agree — PdfPathBuilder(1,2) moveTo→lineTo→cubicTo→close, cursor.moveNext() verbs/coords, segments lazy + identical cache path_test.dart:5 |
packed_path_cursor_and_compatibility_segments_agree |
Verbatim port, checks segment_count==4, cursor (verb,x1,y1…), segments[0].is::<PdfMoveTo>(), ptr_eq(segments, segments) via Rc::ptr_eq |
| T2 | takePath copies immutable storage and reuses the builder — takePath empties builder, second takePath independent path_test.dart:47 |
take_path_copies_immutable_storage_and_reuses_builder |
Verbatim, checks (segment_count, x1, y1) for both paths |
| T3 | ordinary const paths retain their supplied segment list — const PdfPath(segments) returns identical list path_test.dart:64 |
ordinary_const_paths_retain_supplied_segment_list |
PdfPath::from_segments(Rc::new(vec![])) → ptr_eq |
| T4 | (new, perf) dense CAD does not reallocate per path | dense_cad_does_not_reallocate_per_path |
10k moveTo→lineTo→close via one builder, assert verbs.capacity() amortized, take_path exact-size (verbs.len()==verb_count) |
| T5 | (new, wire) wire round-trip preserves f32 bit-exact | wire_round_trip_preserves_float32_bit_exact |
PdfPath via wire::encode/decode → coords bitwise equal (dart wire guarantee) |
| Tool | pdf_graphics/tool/dump_paths.dart, dump_bar_paths.dart |
cargo run --bin dump_paths (optional) |
Perf dump for dense files, not required for green |
All 3 dart tests must pass as Rust equivalents before 746 flips to DONE.
5. Exit Criteria
- All 5 tests green:
cargo test -p nigig-pdf-graphics --lib path+cargo test -p nigig-pdf-graphics --test path(if separate) + golden. cargo test -p nigig-pdf-graphicsfull suite still green (no interpreter regression).NIGIG_PDF_FEATURE_PARITY_PLAN.md:746updated toDONEwith note— packed + cursor + builder pool + stroke/fill_rule, wire f32 exact.- ADR
REVIEWS/adr/00xx-pdf-path-object-parity.mdrecords whyRenderCommand::Rectanglestays as sugar (PDFreis rect) and why builder coords aref64but wire isf32(mirrors dartFloat64ListvsFloat32List). - No change to
pdf-makepadrendering path except viaPdfPathpayload; existing goldens unchanged (wire is additive).
6. Risks & Mitigations
- Wire breaking change — mitigation: keep
MoveTo/LineTovariants, addPathas new variant; old goldens unchanged; deprecate after one release. - Allocation win not measurable — mitigation: T4 asserts amortized growth; bench with
cargo bench -p nigig-pdf-graphics path_dense. - Const
identicalsemantics — Rustconstnot same as Dartconst; useRc+ptr_eqto emulate.
7. Estimate
- ~1 session, no fork needed, pure
pdf-graphicscrate. - LOC: ~350 new + ~50 modified.
- Dependencies: none new.