Problem
Every structural glyph mutation (addPointToContour, insertPointBefore, addContour, closeContour, etc.) goes through NativeBridge.#dispatch(), which:
- Calls Rust via NAPI
- Rust serializes the entire glyph snapshot as JSON (all 50K+ points)
- JS
JSON.parses the full response
- JS reconciles the entire reactive model via
glyph.apply(snapshot) → #syncFromSnapshot()
At 50K points this costs ~80ms p50 / ~400ms p95 per point addition (#37).
Meanwhile, the draft system (createDraft / setPositions / finish) is fast (~2ms at 50K) because it skips NAPI during the hot loop and syncs once at the end. But tool authors have to know to use it — and it only works for position changes, not structural mutations.
Current API surface (too many patterns)
| Pattern |
When to use |
Cost |
glyph.addPointToContour() |
Structural change |
O(N) full snapshot round-trip |
glyph.movePointTo() |
Absolute position set |
O(N) snapshot + O(N) findPoint + NAPI |
editor.createDraft() → draft.setPositions() |
Position drag hot path |
O(change) JS-only |
glyph.movePoints() |
Delta position change |
O(N) full snapshot round-trip |
Tool and plugin authors shouldn't need to remember which of these to call. glyph.addPointToContour() should just be fast.
Design goals
- Single ergonomic API on Glyph — tool authors call
glyph.addPoint(), glyph.movePointTo(), etc. and the system picks the fast path
- Structural mutations should be O(1), not O(N) — adding one point to a 50K-point glyph shouldn't serialize all 50K points
- Draft pattern should be internal — tools shouldn't need to manually create/finish/discard drafts for common operations
- Plugin authors get the same API — no "if you're writing a plugin, use this other lower-level thing"
Possible approaches
A: Local-first Glyph model + lazy Rust sync
Glyph model applies mutations locally in JS (assigns temp IDs), queues them, and batch-syncs to Rust on the next frame or at explicit checkpoints (undo recording, save). Rust becomes the persistence layer, not the authority for every mutation.
B: Lightweight Rust responses
Add NAPI methods that return just the assigned point ID (as f64) instead of the full CommandResult JSON. JS patches the model locally with the real ID. Rust stays authoritative but the response is O(1).
C: Batched command protocol
Accumulate multiple structural mutations into a single NAPI call that returns one snapshot. #createHandles currently does 2-4 separate round-trips — a batch would make it 1.
D: Hybrid (B + C)
Lightweight responses for individual mutations (pen clicks), batched protocol for compound operations (handle creation). Draft system stays for position-only drags but is hidden behind the Glyph API.
Context
Files
apps/desktop/src/renderer/src/bridge/NativeBridge.ts — #dispatch, #execute, movePointTo
apps/desktop/src/renderer/src/lib/model/Glyph.ts — reactive model, apply(), #syncFromSnapshot()
apps/desktop/src/renderer/src/lib/editor/Editor.ts — createDraft()
apps/desktop/src/renderer/src/types/draft.ts — GlyphDraft interface
crates/shift-node/src/font_engine.rs — NAPI command helpers, CommandResult serialization
crates/shift-core/src/snapshot.rs — CommandResult::success() always includes full snapshot
Problem
Every structural glyph mutation (
addPointToContour,insertPointBefore,addContour,closeContour, etc.) goes throughNativeBridge.#dispatch(), which:JSON.parses the full responseglyph.apply(snapshot)→#syncFromSnapshot()At 50K points this costs ~80ms p50 / ~400ms p95 per point addition (#37).
Meanwhile, the draft system (
createDraft/setPositions/finish) is fast (~2ms at 50K) because it skips NAPI during the hot loop and syncs once at the end. But tool authors have to know to use it — and it only works for position changes, not structural mutations.Current API surface (too many patterns)
glyph.addPointToContour()glyph.movePointTo()editor.createDraft()→draft.setPositions()glyph.movePoints()Tool and plugin authors shouldn't need to remember which of these to call.
glyph.addPointToContour()should just be fast.Design goals
glyph.addPoint(),glyph.movePointTo(), etc. and the system picks the fast pathPossible approaches
A: Local-first Glyph model + lazy Rust sync
Glyph model applies mutations locally in JS (assigns temp IDs), queues them, and batch-syncs to Rust on the next frame or at explicit checkpoints (undo recording, save). Rust becomes the persistence layer, not the authority for every mutation.
B: Lightweight Rust responses
Add NAPI methods that return just the assigned point ID (as
f64) instead of the fullCommandResultJSON. JS patches the model locally with the real ID. Rust stays authoritative but the response is O(1).C: Batched command protocol
Accumulate multiple structural mutations into a single NAPI call that returns one snapshot.
#createHandlescurrently does 2-4 separate round-trips — a batch would make it 1.D: Hybrid (B + C)
Lightweight responses for individual mutations (pen clicks), batched protocol for compound operations (handle creation). Draft system stays for position-only drags but is hidden behind the Glyph API.
Context
movePointToon NativeBridge is a known trap (snapshot + findPoint + dispatch) — should be removed or fixed as part of thisFiles
apps/desktop/src/renderer/src/bridge/NativeBridge.ts—#dispatch,#execute,movePointToapps/desktop/src/renderer/src/lib/model/Glyph.ts— reactive model,apply(),#syncFromSnapshot()apps/desktop/src/renderer/src/lib/editor/Editor.ts—createDraft()apps/desktop/src/renderer/src/types/draft.ts—GlyphDraftinterfacecrates/shift-node/src/font_engine.rs— NAPI command helpers,CommandResultserializationcrates/shift-core/src/snapshot.rs—CommandResult::success()always includes full snapshot