Skip to content

perf: faster UI response to taps (Deviation, Section, Split, map changes) - #98

Open
Jozkah wants to merge 38 commits into
CNCKitchen:mainfrom
Jozkah:perf/ui-latency
Open

Jozkah wants to merge 38 commits into
CNCKitchen:mainfrom
Jozkah:perf/ui-latency

Conversation

@Jozkah

@Jozkah Jozkah commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Closes #91.

Taps that open or change a panel — Deviation, Map, Split, Section view, the element target and facing limit — were slow to show their result, most visibly on phones. This branch removes the main-thread work behind that, on phone and desktop alike. The findings are in docs/superpowers/specs/2026-11-04-mobile-perf-findings.md and the plan in docs/superpowers/plans/2026-11-04-mobile-perf-fixes.md.

What changed

  • Measurement: scripts/perf-probe.mjs takes medians over several runs, has a richer test scenario, and checks budgets (PERF_ASSERT=1). It also counts frames drawn while idle.
  • Skipping work that changes nothing:
    • The top bar no longer re-fits on every tap.
    • Map colours are not repainted or re-uploaded when they are unchanged.
    • Histograms and stats are cached per map version.
    • Overlays are not rebuilt when their data is unchanged, and their materials are reused.
  • Shaders: the Section view and map materials compile while the app is idle, not on the first tap.
  • Views kept alive: after first use, the split view and 2D view keep their WebGL contexts, so reopening them costs nothing.
  • Moved to the mesh worker:
    • the deformation-animation motion;
    • the deviation map against an element;
    • the scan's and reference's bounds trees (BVH), now indirect so the render index is never reordered.
  • React:
    • Panels subscribe only to the store fields they read.
    • The hint ladder is computed once.
    • Scene sync lives in its own leaf component, so store changes no longer re-render the whole workbench.
  • Idle:
    • The render loop sleeps after 30 idle frames; input, invalidation, resize and links between views wake it.
    • The canvas size is cached, so per-frame clientWidth reads (forced layout) are gone.
  • Misc:
    • Alignment refits the bounds tree instead of rebuilding it.
    • The hint pulse animates only transform and opacity.

Commands that read the element map now wait for it to settle (mapSettled). A command waiting a frame wakes the sleeping viewport first.

Results

4x CPU throttle, 288k-triangle scan, BASE 5763504 vs HEAD. Production build unless marked dev.

What Before After
Frames drawn in 3 s idle ~430 0
Open scan, longest task 286 ms 95 ms
Load reference, longest task 308 ms 81 ms
Section view on, shader links 6 0
Split view on, INP 72 ms 32 ms
Deviation animate off, upload 7.6 MB 0
Deviation animate on, longest task (dev, rich) 91 ms 0
Element facing limit, INP (dev, rich) 192 ms ~70 ms
Change element target, longest task (dev, rich) ~180 ms 82–89 ms
Tab to 3D Measure, shader links (dev, rich) 5 0
Volume show / scan key, INP (dev, rich) 76–84 ms 24 ms

The full per-row tables (INP, longest task, links, upload MB, contexts) are in the probe output.

Verification

  • npm run build (tsc + vite build): passes.
  • npx vitest run --maxWorkers=8: 3223 passed, 24 skipped.
  • npm run e2e:ci: all checks pass except the six that need the MCP server's dependencies (npm --prefix mcp ci), which aren't installed on the machine I ran them on (e2e-agent, e2e-agent-warning, e2e-agent-mesh, e2e-agent-reverse, e2e-note-from-file, e2e-reverse-notes). e2e-agent and e2e-agent-warning fail the same way on the base commit. e2e-patch-network skipped (fixture missing).
  • New e2e: e2e:sleep checks the view wakes after going idle for wheel, pinch, animation and split view. e2e-mesh-flip gained a check that a map survives switching through Mesh Tools with an edited copy.
  • Some e2e scripts need fixtures that weren't on the machine (ballbar.stl, block-marius.stl, side bracket left.stl, fridgeBracket.ply). They were run with a substitute model on both BASE and HEAD, with identical results.
  • Perf probe PERF_ASSERT=1 PERF_RUNS=5: bare scenario on the production build passes. The rich scenario on the dev build has two rows over budget; both were already over at BASE:
    • Opening a scan on the dev build takes 172 ms against a 150 ms budget; the rest is shader linking. Production comes in at 95 ms.
    • Switching to Surface Deviation with a map already shown uploads 0.6 MB: one per-vertex float attribute, not the colour buffer. This is left over budget as a follow-up.

Known trade-offs and follow-ups

  • Leaving Deviation after using the animation frees its GPU buffer. In three.js that re-uploads the whole mesh once (7.6 MB), where BASE paid that on every "animate off".
  • For a moment after a scan opens (~65 ms on desktop), its bounds tree is still being built. Hover ignores the scan until it lands, and brush strokes are held and laid when it arrives.
  • Plugin effects that must change the scene before scene sync paints have to be layout effects; the mesh-tools edited-copy swap is one. This is documented in src/app/SceneSync.tsx.
  • Not in this PR: switching workspace tabs still re-renders the whole main screen (the panels need wrapping in memo).

Real-device check (not done yet)

On a phone, with Chrome remote debugging (Android) or Safari Web Inspector (iPhone), Performance panel: tap Surface Deviation, Map, Split and Section, and confirm no task runs over 100 ms.

🤖 Generated with Claude Code

Jozkah and others added 30 commits November 4, 2026 12:01
Refs CNCKitchen#91

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PERF_RUNS repeats each tap (toggles alternate, tabs go round) and reports the
median with the profiler off plus one profiled run for the hot list; the UI
appears column is gone. Rows now carry budgets (links, uploadMB, contexts,
longMs, inpMs) checked under PERF_ASSERT=1. PERF_SCENARIO=rich fits elements,
dimensions, a section and probes through the in-page command registry on the
dev server. Production hot lists are named through the build's source maps.
New rows: thickness view-bar key, bands, histograms, range slider, tolerance,
first show of the reference, element target, brush stroke. The spec's baseline
section records the v2 medians.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
inpMs budgets are round(0.7 x median) of the saved baseline, stated once next
to them; a budgeted row that took no sample, a rich project that did not come
out whole, and a budgeted tap no input event reached now fail the run; the
profiler is stopped in a finally; the first tap is run 1 even when it failed;
the browser closes in a finally; the first-show row says only INP first is the
first show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tab tap re-renders the bar for the highlight alone, and each render ran the
fold loop, which forces the page's layout once per fold. useFit now skips a
render-driven fit when the bar's text, child count and class are as the last
fit left them; the resize and font-ready paths always fit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
From the fourth fold on only the open tab shows its word, so a tab tap
changes the row's width with no change of text, child count or class. The
signature now names the open tab, so such a render fits again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tab or map tap re-ran paintField over every vertex and re-uploaded the
colour buffer even when nothing the map is read from had changed. The paint
effect now remembers, per colour buffer, the version, scale and ramp it last
painted under (paintKey) and skips the pass when they match; RegionColors
skips laying the same field on again, and the scene its upload, until a
layer changes or the array is repainted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
applyTransform baked the alignment into the scan's positions and then built
the bounds tree over them from scratch on the main thread. The triangles
are the same after a rigid move, so refitAfterRigid rewrites the boxes in
place instead; it renews the bounding box and sphere with it, and builds a
tree only where the geometry has none.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ger says rebuild

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The section view's first tap compiled six programs and searched the
part's closed shells on the main thread. The programs are now drawn once
in idle time into a one-pixel scissor, in a throwaway scene lit like the
stage, and their materials kept so the live ones hit the program cache;
the closed shells are found by the mesh worker as a part loads and primed
into the cap's cache, keyed by the geometry asked about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker's closed mask was per triangle in its own order, while a
bounds tree reorders the render index before the part is shown and the
reference's surface keeps an index of its own: a part with closed and
open shells got another part's mask. The worker now answers a vertex at
a time over the render layout (its vertices, then its shading copies),
which no tree reorders, and the main thread reads each triangle through
its index as it stands. A warm job now compiles one program at most,
and the repaint and disposal hold if a job throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the split view or the 2D Measure tab again made a new WebGL context
(and, for the split, a 6.8 MB upload and a shader link). Both viewports are
now mounted at their first opening, hidden and paused while closed, and shown
again with the camera link and the flat sheet re-laid. The reference half is
released when its reference geometry is replaced or cleared. OrthoViewport
ignores a 0x0 container once it has a size, and gives its context back on
dispose.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…hout re-laying

The kept split state held the old reference geometry until the split was next
opened; it is now dropped as soon as the reference changes while the split is
closed. A kept 2D viewport shown again is only unpaused and resized unless its
sheet was laid anew since (image, subject or section cut), so the image is not
uploaded again and the user's pan and zoom survive a tab round trip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A tab or the map's switch rebuilt every element overlay, re-ran
evaluateDimensions outside the workspace that shows its output, and disposed
and recreated the overlay materials, which released their shader programs to
be compiled again on the next tap into 3D Measure.

- Overlays.updateOverlays draws nothing when the input says what is drawn
  already (compared by content, since it is built afresh each time) and
  reports whether it drew.
- An element's materials live as long as the element, drawn or not, with
  colour and opacity set in place; the callout lines and the selection
  strokes share one material each.
- The same for a finished section's materials, and the volume layer's two.
- evaluateDimensions runs only in the measure workspace.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThicknessPanel, MarkTools, the split picker and its pick tools read their
whole store; each now picks only the fields it draws. The hint ladder was
built by every usePulse caller; a HintsProvider above the app builds it
once and usePulse/useHintChip read it from context.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The element map was a pass over every scan vertex on the main thread, a long
task on a phone that the next tap waited behind. It now goes to the mesh
worker as the motion did: a request keyed on the scan token (StaleScan on a
scan moved or replaced, one retry, then the main thread), the render thread's
normals always sent as they are not the ones its sharp edges were shaded
with, its positions only where the worker's may not match. Requests coalesce
to the newest, a stale reply is dropped, and the status can never be left at
running. The panel, view bar and hover keep the map in hand while it is
re-measured so a dragged slider is not unmounted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The map against an element lands a worker round trip after its inputs move, so a
command that read it in the frame after saw "running" and answered "no map", an
empty list of patches, or a report pairing the old figures with the new settings.
mapSettled (a frame, then not running) is awaited by each reader: hotspots, feature
patches, export, the relief and height reads, the picture's map note, session state
and the report. set_target throws at once when the map could not be measured instead
of waiting out 30 s. Copy report is off while the map is measured, an unmounted hook
no longer settles, and a worker error falling back to the main thread is logged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSceneSync and useElementField subscribe to some seventy store fields; called from the Workbench every write to one re-rendered the whole workspace. They now live in a memoised SceneSync that draws nothing, mounted after the viewport.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The viewport loop asked for a frame 60 times a second per viewport even
with nothing to draw (1305 frame callbacks in 3 s idle on the probe).
It now sleeps after 30 idle frames; invalidate, canvas pointer/touch/
wheel, window keys, resize, tab shown, SpaceMouse attach, animateLook,
un-pause and a linked view's invalidate wake it. keepAwake holds it for
the deviation motion, a queued stroke and a pending section dressing.

The canvas size is cached in resize() (0x0 never cached over a real
size), so worldPerPixel, the frame, the frustum and the ticks read no
layout; followView no longer forces a layout pass per frame. spreadLabels
measures a label again only when its text or classes change; the
navigator's rect cache also expires per task; hover hit tests use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- labelSpread: sizes re-measured after web fonts finish loading; labels
  cut away by the section view are left out, and nothing is measured
  with the Labels key off.
- nextFrame wakes the viewport (SceneManager.wakeFrame) before asking
  for its own frame, so the viewport's frame is queued first, as before
  the loop could sleep.
- shown needs both sides and is read-only; onTick / SceneLayer.tick docs
  say ticks stop while asleep; keepAwake's belt-and-braces noted.
- Tests: CameraLink wake/dispose, labels measured at nothing not kept,
  cut-away and Labels-off skipped, nextFrame wake order.
- scripts/e2e-sleep.mjs (npm run e2e:sleep): idle frames, wheel and
  pinch after sleep, the deviation motion, the split view link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scan's bounds tree was the longest single task in opening it. The
mesh worker now builds it (indirect, over copies of the render positions
and index, crease copies included) once the part is in place, and the
viewport lays it over the geometry with MeshBVH.deserialize, setIndex
off: the drawn index is never reordered. A tree landing on a geometry
laid out again, given a tree of its own or taken off the stage is turned
away; one landing after a rigid move is refitted. Until it lands, the
pointer tests a frame makes skip the scan (sparingRays) while clicks
fall back to three's raycast, and the tree's arrival wakes the loop to
look at the hover and the pins again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jozkah and others added 8 commits November 5, 2026 02:03
The reference is laid out with its tree built in the mesh worker, as the
scan is, and its pending tree is forgotten when it is taken away.

While a part's tree is on its way it hides nothing behind it either: the
hover steps see no cursor and an orbit pivot finds nothing on the stage.
The split picker hears the tree land and looks at its cursor again; the
reverse network's drag step is a frame's test and spares the part too.
Every tree built here as a fallback is indirect, and one from the worker
that cannot be laid over the geometry is built here instead, with a
warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ring keyframes animated border-color and box-shadow, which repaint every
frame while a hint shows. The ring now only changes opacity and the halo is its
own pseudo-element with a static shadow that scales and fades.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
SceneSync's effects run ahead of the Workbench's since Task 12, and with
them the paint ran before Mesh Tools swapped its copy in or out: a map
coming back to Deviation or Thickness was laid on the copy and the scan
came back bare. The swap is a layout effect now, ahead of every plain
effect. e2e-mesh-flip checks the round trip for both maps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
session.state and report.get waited strictly for the element map, and a
map queued behind a long job in the mesh worker made a read fail after
30 s. They now answer anyway, the map said to be running; the commands
that act on the map still wait strictly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A warming run now disposes the keepers of kinds the stage no longer has
(a scan closed, a reference cleared), which outlived them before. A scan
laid out again (resplit, replaceScan) forgets a tree still on its way
for the layout it replaces, as disposeMesh does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A brush dab on a scan whose bounds tree was still being built off the
thread was dropped without a word. It is held now, as the ray it was cast
along, and laid when the tree lands (or on the next dab, if the tree came
another way), so a view turned meanwhile does not move it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Commands wait for the element map only when it is the map they read:
  with the reference map shown, the element map re-measured behind it no
  longer stalls or times out hotspots, export, heightmap and feature
  patches. set_target still waits, as it asked for that map itself.
- view.render waits for the map only in the deviation workspace, within
  the picture's own time budget, and past it pictures the map as still
  being measured instead of failing the call.
- SceneSync compares the plugin runtime by what scene sync reads of it
  (pluginReads), so Mesh Tools and Reverse no longer re-render it on
  every Workbench render.
- The orbit pivot keeps finding parts that have their bounds tree while
  another part's tree is still being built; it stays off only where that
  part's box could lie in front of the hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slow UI response to some buttons (most noticeable on mobile): Deviation tab, Section view, Split view

1 participant