← hilos.simmer.io

Decisions

The design of the TypeScript build: what was chosen, why, and the numbers behind it. It was the spec the agents built from, kept up to date as things were measured and changed.

Status key: βœ… decided and built Β· πŸ“ an estimate, not yet measured

Constraints we build: #5 (nothing lost, required), #6 (sole stays put), #4 (iPad memory). #1 (full mesh) comes free: we edit the real 700k-face mesh, no proxy. #2 (next frame) and #3 (picking) are measured and reported, built as far as the design allows.

Measured facts about the shoe

From brief/shoe/shoe.glb (script: scripts/measure.mjs, to be committed):

Fact Value
Upper 185,465 verts, 340,838 tris, Uint32 indices
Sole (midsole) 189,981 verts, 359,160 tris, Uint32 indices
Upper verts within a 15 / 30 / 60 / 80 mm sphere median 3.9k / 15k / 62k / 95k; p95 8k / 27k / 86k / 120k
Upper verts sitting exactly on sole positions (the seam) 3,161
Height upper y 32.0–128.6 mm, sole y 0–55.9 mm

A stroke at Ghost's default 60 mm radius moves about a third of the upper. That number drives the undo and save format below.


1. The tool

βœ… One tool: Grab. It's Ghost's Morph done properly, using the same conventions (G key, radius in mm, a ring cursor lying on the surface) so a designer doesn't relearn anything.

Additional feature (one only): βœ… an "original" overlay toggle that shows the untouched shoe as a faint ghost, so you can compare before and after.

Left out, and why (this goes in the write-up):

2. Source of truth

βœ… There is one owner: a ShoeModel class, plain TypeScript, no React.

βœ… Only two things may write positions: applyEdit/revertEdit, and the live preview of the stroke in progress. The preview writes base + (acc + wΓ—D_now) Γ— 0.01 and never touches acc until the stroke is committed. That means cancelling a stroke (for example on context loss) restores the shape exactly.

βœ… The sole is never written. Its arrays are read-only after loading.

3. What an edit is

βœ… An edit record contains:

{ id: uuid,
  mask: bitset over upper verts (185,465 bits = 23 KB),
  weights: Uint16 per set bit,
  D: Int32Γ—3 (in 0.01 mm) }

Each vertex moves by round(w/65535 Γ— D), and that delta is added to acc.

Size per edit, πŸ“ estimated from the measured vertex counts:

Radius Typical (median) Worst (p95)
30 mm 53 KB n/a
60 mm 147 KB n/a
80 mm 213 KB 263 KB

100 steps of undo at worst β‰ˆ 26 MB.

Why this format:

βœ… Why changes per edit, not full snapshots or pure replay:

Approach What's stored per step 100 steps Cost of one undo
Full snapshot of the upper 2.2 MB ~220 MB, over budget instant
Changes per edit (ours) 150–260 KB 15–26 MB instant: one pass over the region
Pure replay (press point, radius, vector) ~40 bytes ~4 KB rebuild from the original shoe through every earlier stroke

Memory budget for #4, πŸ“ to be verified on device:

Item MB
GPU: positions, normals and indices for both parts 17.4
CPU copies (needed for the brush, the spatial index, normals and re-upload) 17.4
Spatial index for picking (three-mesh-bvh), both parts ~5
Upper: base positions, accumulator, vertex→triangle adjacency, seam distance ~10
Stroke scratch space ~1
Shoe total, no undo ~51
100 undo steps at worst ~26
Total ~77 of 96

Two caveats:

4. Save and sync

βœ… The log is append-only. It holds three kinds of operation: add(edit), undo(editId) and redo(editId).

βœ… Server sync: built and deployed. One Cloudflare Worker serves the built app as static assets and runs the sync API, with one SQLite-backed Durable Object per shoe holding the ordered log ops(version, op_id, bytes) (worker/). One Worker rather than Pages plus a separate Durable Object Worker: one deploy, one live version, same origin.

The same shoe in two tabs: both append to the shared IndexedDB log and outbox, one syncs at a time (Web Lock), and each sees the other's edits on reload. Live updates over BroadcastChannel were planned and not built. An edit computed in one tab was weighted against geometry the other tab hadn't seen yet; that's acceptable for a grab, because edits commute.

5. When things fail

Failure Behaviour
GPU context lost Cancel the stroke in progress (allowed), and on restore finish owed work so the re-upload matches the CPU arrays. Nothing else is lost. βœ… three r186 rebuilds WebGLAttributes on restore, so every buffer is re-uploaded in full from our arrays. e2e/6 checks it: after restore the canvas matches a pre-loss screenshot within 0.2% of pixels (one edit changes about 1.7%), and editing continues.
Reload Load the base mesh from IndexedDB (or the network), replay the operation log, recompute normals for touched vertices, refit the spatial index. Replaying 1,000 edits is about 60M integer adds, πŸ“ ~100 ms. Snapshots of acc only if that's measured as slow.
iPad kills the tab Same as a reload. Risk: the IndexedDB transaction on pen-up is still in flight. Window is a few milliseconds, at most the last stroke. Test on a device.
Save cut off halfway Each operation is its own atomic record, and the server deduplicates by id. A half-sent batch is simply sent again from the IndexedDB outbox.

6. Budgets and tests

The tests are the spec. HILOS reads them first. βœ… A few sharp tests, one per guarantee:

  1. The sole stays put (#6): after 50 random strokes, including some at the seam, the sole's positions and indices are byte-identical to the GLB. So are the upper's 3,161 seam vertices, and the minimum y stays at 0.
  2. Undo is exact: 50 random strokes, then undo all of them, gives upper positions bit-identical to the loaded shoe. A redo/undo round trip is also identical.
  3. Memory budget (#4): memoryReport() sums every retained buffer after 100 strokes at 80 mm, which must stay at or under 96 MB minus the framebuffer allowance. Cross-checked by hand with the Safari Web Inspector on an iPad.
  4. Reload loses nothing (#5): run strokes, kill the in-memory model, rebuild from IndexedDB (fake-indexeddb), and positions are identical. A stroke in progress at the kill is the only thing missing.
  5. Saves never resend the mesh (#5): the bytes saved per stroke are far below the mesh size, and the saved payload never contains the base positions.
  6. Context loss (#5, end-to-end): in Playwright, start a stroke, lose and restore the context, finish the stroke; the pixels and positions are correct and editing continues.
  7. Frame budget (#2): a benchmark of the per-frame update at 60 and 80 mm stays at or under 10 ms on CI. Real numbers come from a desktop and an iPad, using the pointer event's timeStamp to time the work, reported in an on-screen performance HUD.
  8. Picking after an edit (#3): a raycast at a moved point hits the new surface, not the old one.
  9. No tears: the weight's slope along every edge stays bounded, so thin details don't tear.
  10. Sync loses nothing (#5, across devices): against the real Worker logic over node:sqlite, two devices converge with an interleaved undo, offline ops survive a reload and go out later, a lost response stores nothing twice, and a /demo copy leaves the demo alone. e2e/10 repeats the offline case and /demo in Chromium against wrangler dev.
  11. Cross-build fixture: recorded op logs and the shape hash after each op (tests/fixtures/cross-build/), so the Rust/wasm build can prove it computes bit-identical positions.

Per-frame work for #2: the first plan updated about 62k vertices and their normals on the CPU every frame (estimated 2–5 ms). Measured, it took 17–25 ms, so the plan B became the build: the displacement runs in the vertex shader during a stroke, and the spatial index is refit only for the dirty nodes. The measured breakdown is in "Frame budget" below.

7. Bigger meshes (10Γ—: 7M faces)

Not built. How I'd approach it:


Frame budget: how each frame stays under 10 ms (measured, desktop Node, densest 80 mm spot)

βœ… Decided and built:

Moment Work ms
Pointer move set one drag vector; the GPU draws pos + wΒ·D with analytically bent normals ~0
Press one scan of the upper, filling per-vertex grab data (weight + gradient) 6–7
Release integer commit into acc + edit record (the truth, at once) 5–7
Settle positions β†’ picking index (partial refit) β†’ face normals β†’ vertex normals, in ~2 ms steps, 4 ms budget per frame, while the GPU keeps the preview; upload and end of preview happen together worst slice ~8, done in ~30–40 frames

Threads and GPU

βœ… Background threads (workers):

βœ… GPU:

Stack and structure

βœ… Vite, React, TypeScript, React Three Fiber and drei (their stack), plus three-mesh-bvh, Vitest, fake-indexeddb and Playwright.

src/core/      ShoeModel, edit records, falloff, seam   (no React, no three renderer)
src/persist/   IndexedDB op log + outbox, replay by edit id
src/sync/      wire framing, sync client
src/view/      R3F scene, brush cursor, input, perf HUD
worker/        sync Worker + one Durable Object per shoe
tests/         the tests above (e2e/ for the browser ones)

βœ… Hosting: one Cloudflare Worker (static assets + sync API) at hilos.simmer.io (custom domain; the workers.dev URL also works), with X-Robots-Tag: noindex. The GLB is served from the same origin, never from a public repo. The Rust/wasm web build is going on its own static Worker at wasm.simmer.io.

Questions for Beichen

None sent. Instead, the write-up states our assumptions: