Problem
At 50K points, nudge (arrow key) takes 34ms p50 per frame while draft translate takes 2.4ms p50 — a 14x difference for what should be similar work.
Measured via Playwright E2E perf tests on the real Electron app with GPU acceleration.
Root Cause
NudgePointsCommand.execute() goes through the full sync pipeline on every keypress:
Glyphs.findPoints(glyph, pointIds) — iterates ALL 50K points to look up each ID via Set. ~5-8ms
.map() to build updates — allocates 50K position update objects. ~2-3ms
glyph.apply(updates) — JS-side position patch (same as draft). ~2ms
this.#$glyph.set(glyph) — fires glyph identity signal, triggers more downstream effects than the contour-only signals draft uses
#syncPositions(updates) — marshals 50K IDs + coords into Float64Arrays, crosses NAPI boundary to Rust. ~10-15ms
commandHistory.execute() — records an undo entry on every single frame
The draft pattern avoids steps 1, 2, 4, 5, 6 entirely — it only calls glyph.apply() per frame and defers the Rust sync to finish().
Proposed Fix
Use the draft pattern for nudge: accumulate position changes in JS, only sync to Rust and record undo when the arrow key is released (or after a debounce). This is the same pattern used for translate/resize/rotate drag operations.
Perf Report (50K points)
| Operation |
p50 (ms) |
p95 (ms) |
p99 (ms) |
| translate-drag (5 pts) |
0.40 |
0.50 |
0.70 |
| translate-drag (1K pts) |
0.60 |
0.70 |
1.00 |
| translate-drag (all pts) |
2.40 |
7.90 |
12.70 |
| nudge (all pts) |
34.20 |
41.40 |
45.50 |
| undo (all pts) |
30.90 |
32.40 |
33.50 |
| pen-tool (100 clicks) |
81.90 |
405.70 |
407.30 |
Files
src/renderer/src/lib/commands/primitives/BezierCommands.ts — NudgePointsCommand
src/renderer/src/lib/editor/Editor.ts:1377 — nudgePoints()
src/renderer/src/bridge/NativeBridge.ts:312 — setNodePositions() (the full sync path)
Problem
At 50K points, nudge (arrow key) takes 34ms p50 per frame while draft translate takes 2.4ms p50 — a 14x difference for what should be similar work.
Measured via Playwright E2E perf tests on the real Electron app with GPU acceleration.
Root Cause
NudgePointsCommand.execute()goes through the full sync pipeline on every keypress:Glyphs.findPoints(glyph, pointIds)— iterates ALL 50K points to look up each ID via Set. ~5-8ms.map()to build updates — allocates 50K position update objects. ~2-3msglyph.apply(updates)— JS-side position patch (same as draft). ~2msthis.#$glyph.set(glyph)— fires glyph identity signal, triggers more downstream effects than the contour-only signals draft uses#syncPositions(updates)— marshals 50K IDs + coords into Float64Arrays, crosses NAPI boundary to Rust. ~10-15mscommandHistory.execute()— records an undo entry on every single frameThe draft pattern avoids steps 1, 2, 4, 5, 6 entirely — it only calls
glyph.apply()per frame and defers the Rust sync tofinish().Proposed Fix
Use the draft pattern for nudge: accumulate position changes in JS, only sync to Rust and record undo when the arrow key is released (or after a debounce). This is the same pattern used for translate/resize/rotate drag operations.
Perf Report (50K points)
Files
src/renderer/src/lib/commands/primitives/BezierCommands.ts—NudgePointsCommandsrc/renderer/src/lib/editor/Editor.ts:1377—nudgePoints()src/renderer/src/bridge/NativeBridge.ts:312—setNodePositions()(the full sync path)