Skip to content

15-H: scene notes v2/v3 - model, screen-space markers, saved framing + follow sessions - #87

Merged
AlexZ005 merged 9 commits into
release/nextfrom
feat/roadmap15-h-notes-v2
Aug 5, 2026
Merged

15-H: scene notes v2/v3 - model, screen-space markers, saved framing + follow sessions#87
AlexZ005 merged 9 commits into
release/nextfrom
feat/roadmap15-h-notes-v2

Conversation

@AlexZ005

@AlexZ005 AlexZ005 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Roadmap 15-H (scene notes v2) plus the three review drops that followed it. Plan
and as-built notes: cloud repo plans-core/pending/15-h-notes-v2.md.

Branched off fix/roadmap16-menus-cameras (now merged here via #86#85#84), so
this PR is the 9 notes commits on top of the current release/next.

What ships

Model (v2) — notes gain name, color, label, shape, authorKey, camera,
follow, objectName; text stays the description. ONE normalizeAnnotation() runs
at every store boundary, so old autosaves, .tpscene sessions and older peers load
with defaults. The wire shape is unchanged ({type:'annotation', op:'set'} still
carries the whole object) and saves spread the base record, so a field a newer peer
added survives an edit by an older one. No handshake change.

Markers (v3) — the desktop marker is now a screen-space badge with a leader line
to the exact 3D point. The old in-scene quads could be cut in half by any surface they
touched and their occlusion was a per-pixel depth test; a DOM badge cannot clip, and
occlusion is one raycast verdict per marker with 8 cm of slack — a note resting on a
surface stays solid, one genuinely behind geometry fades its fill and dashes its
leader while the number stays readable. Overlapping markers cluster into a counted
badge that fans out on click. Hover preview, selected ring, constant screen size,
near-camera fade. The in-scene meshes remain as the VR path (DOM is invisible in a
headset) and their groups stay the anchors in every mode.

Popover + drawer — one card with view/edit faces anchored beside its pin
(continuous projection, so it rides the fly and later orbiting); the drawer gained
#n name — description rows, collapsible label groups with ‹ › traversal in pin
order, and a pins-visibility toggle.

Saved framing + follow (H11) — "Save camera view" stores a pose on the note;
opening flies there instead of the generic approach. Follow is an explicit
per-viewer session that outlives the card (sticky indicator toast, Esc), moving
camera and orbit target by the pin delta so your own orbit offset survives.
Handover is explicit via a new cameraClaim counter bumped by flyTo — deviation
watching cannot tell a user pan from a takeover.

Fixes found on the way

  • "Some notes disappear on reload": annotations were snapshot passengers that never
    marked the autosave dirty. Also scene-root anchors now re-key by object name
    (those objects are rebuilt with fresh uuids each boot) and the orphan prune waits
    out a 3 s grace instead of firing instantly.
  • Marker "jiggle" while orbiting: the overlay owned a private requestAnimationFrame,
    a different callback queue from threlte's scheduler, so it projected last frame's
    camera. Positions now publish from a render-stage task. (Entering/leaving VR
    "fixing" it was the tell — XR swaps the loop and flips the callback order.)
  • Grid look-at follow snapped by one cell, which hopped every thick line; it snaps by
    the section period now.
  • Author display: 'me' is never stored — files and peers see the owner's nickname,
    and 'Me' is a display mapping over a stable per-device key.

New setting — Settings ▸ Interface ▸ "Double-click to open notes" (local, off by
default): single click on a marker and the drawer's group arrows only fly the camera;
the card opens on a double click. Esc order: first press stops following, second
closes the card.

Verification

notes-v2 (new, 90 checks) + annotation-anchor + grid-snapping +
autosave-object-flows + camera-bookmarks + the settings suites green on the merged
tree; npm run build green; npx svelte-check holds the 419/62 baseline.
roadmap-13-notifications-notes fails one unread-badge check identically on HEAD
(known flake, proven by stashing).

The frame-lag guard in notes-v2 was verified to FAIL when a one-frame lag is
reintroduced (residual 0.0px → 3.5px against a 3.9px/frame step) — a check that
cannot fail is not a check.

Manual checks left for the user: VR pins on device, and the feel of a follow
session on a flow-animated object.

🤖 Generated with Claude Code

AlexZ005 and others added 9 commits August 4, 2026 20:23
…t popover, drawer groups w/ traversal + pins toggle

- H1 model: annotations gain optional name/color/label on top of `text` (still
  the description). ONE normalizeAnnotation() at every store boundary (local
  set, remote apply, snapshot, autosave restore) so old autosaves, .tpscene
  sessions and old-version peers load with defaults. Wire shape UNCHANGED
  ({type:'annotation', op:'set'}); saves spread the base so a newer peer's
  fields survive our edit. displayName()/noteNumber() helpers - numbering is the
  GLOBAL 1-based index, matching the in-scene pin labels.
- H2 openAnnotation(id, 'view'|'edit'); activeAnnotation is {id, mode} (a bare
  {id} still means view). Pin clicks open the view face.
- H3 showNotePins LOCAL pref (lightHelpers idiom) gates BOTH the pins render
  and Scene's pin-click raycast branch; toggle lives in the drawer header.
- H4 pin circle + cone take the note color (amber stays the default).
- H5 popover v2: full runes rewrite, one card with a view and an edit face,
  anchored by a continuous rAF screen projection of the pin (it rides the
  openAnnotation fly and later orbiting - flyTo has no completion hook), flips
  sides + clamps to the viewport, centre-fallback when the pin is behind the
  camera, bottom sheet at <=640px (closes the other sheets). Edit fields: name,
  description, fixed bright swatch row, label w/ a datalist of existing labels.
- H6 drawer v2: rows are "#n name - description" (grey, single-line truncated),
  row click = fly + view, per-row Edit = fly + edit, collapsible label groups
  with 'General' first then alphabetical, per-group arrows traversing in pin
  order with wrap; runes flip.
- H7 new suite notes-v2 (29 checks): legacy normalization, anchored popover +
  re-anchor on pin move, draft at the clicked point, edit saves + replicates the
  v2 fields (send spy) and applies from a peer, pin colors, group order,
  numbering vs pin indices, arrow traversal + wrap, collapse, pins toggle. A
  two-peer handshake check is env-gated (TWO_PEER=1). annotation-anchor,
  autosave-object-flows and roadmap-13-notifications-notes stay green;
  build green; svelte-check holds 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ty, persistence fixes

User feedback on the shipped v2 (H8-H10, H12 of the plan; H11 saved-camera/follow
stays for the user to implement).

- H8 pin render pass rework. Every pin now draws TWICE: a depth-tested SOLID pass
  and a depth-test-off GHOST pass at 0.3 opacity, with explicit renderOrders
  (ghost UNDER solid, so a visible pin reads as its own saturated colour instead
  of the ghost border bleeding through the fill). Occluded = dim silhouette, not
  gone and not punching through at full strength. The old single pass was
  depthTest:false with NO renderOrder, so "does this pin draw over that object"
  depended on scene ADD order - that is why your own new pins floated on top
  while the ones received from a peer (objects added after the pins group) were
  covered. The number is one pass, never depth-tested, above every pin mesh, so
  an overlapping neighbour can no longer hide it.
  Two traps worth remembering: the solid pass must WRITE depth (the outline/N8AO
  postprocessing passes read the depth buffer, so a pin that writes none gets the
  AO and selection edges of whatever is behind it painted across its face), and
  the visuals sit 0.06 along the group's local +Z (= toward the viewer after
  lookAt) so a pin anchored flat ON a surface no longer half-sinks into it - the
  pin GROUP itself stays exactly on the anchor, which annotationWorldPosition and
  the anchor suite read.
- H9 pin shapes + a professional look: replicated `shape` (round/star/square,
  shared geometry per shape), a border ring in a darker shade of the note colour,
  slight fill transparency, and a contrast-aware number (YIQ split, so amber and
  green get dark ink, blue and violet get light). Shape selector sits next to the
  colour swatches in the edit face. Colour math runs on sRGB bytes, never through
  THREE.Color (the linear round-trip darkens hexes).
- H10 author identity. `author` stays the DISPLAY name at save time (never the
  literal 'me' any more), and a new `authorKey` - a stable per-device key - says
  whether a note is ours. Peer ids are re-issued on every reconnect and nicknames
  change, so neither could answer that across a rename or a reload. 'Me' is now
  purely a display mapping: a saved .tpscene/autosave shows the owner's nickname
  to everyone else, and renaming yourself re-stamps your own notes on the next
  save. Old notes with no key fall back to the name/peer-id comparison.
- H12 persistence ("some notes disappear on reload"). Three causes fixed:
  annotations were snapshot passengers but never marked the autosave DIRTY (only
  objectsGroup/flowGraphs did), so a note added after the last object change was
  never written; a note anchored on a SCENE-ROOT object (env rig, module content)
  orphaned every boot because those are rebuilt with fresh uuids - notes now
  remember the anchor NAME and re-key in the sweep; and the orphan prune fired
  instantly, so a restore/regeneration race could eat notes - it now needs the
  object to stay missing for 3s, re-arming its own follow-up sweep.
- notes-v2 extended to 51 checks: two-pass structure + renderOrder + always-on-top
  number, shape geometry/border/ink and shape replication, the whole author story
  (stored vs displayed, rename upgrade, foreign-device view), autosave dirty +
  snapshot content, scene-root re-key, prune grace. annotation-anchor and
  autosave-object-flows stay green; build green; svelte-check holds 419/62.
  roadmap-13-notifications-notes' unread-badge check fails identically on HEAD
  (known flake, proven by stashing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The grid "snapped" while panning in look-at follow mode because the centre was
quantised to ONE CELL. A cell-step translation maps the thin lines onto
themselves, but the SECTION lines repeat every `sectionEvery` cells - so every
thick line hopped one cell on each step while the fine grid stood still.

Snapping by the section period (cell x sectionEvery) makes the whole pattern map
onto itself: every line lands exactly where a line already was, so following is
invisible and the lines stay locked to world coordinates (unsnapped following
slides the grid under the objects instead, which is why the snap exists).

The fade circle is deliberately left alone: threlte's Grid already defaults its
fadeOrigin to the camera position projected onto the grid plane, which glides
continuously. Feeding it this snapped anchor is what would re-introduce a
jumping fade ring - the exact regression I4 fixed by dropping the eased fade.
So the grid never disappears during navigation; nothing pops.

grid-snapping gains 3 checks: the centre lands on the section lattice, it still
tracks the look-at target, and the fade uniform stays on the unsnapped camera
point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cclusion, clustering

The in-scene quads could be CUT IN HALF by any surface they touched (a billboard
rotating into a face) and their occlusion was a per-pixel depth test, so a marker
was never reliably "in front" or "behind". Markers are now screen-space DOM
badges with a leader line to the exact 3D point (new
components/menu/AnnotationMarkers.svelte); the in-scene meshes stay as the VR
path only, since DOM is invisible in a headset. The pin GROUP still tracks its
anchor in every mode - annotationWorldPosition, the popover and the anchor suite
all read it. The triangle/cone under the pin is gone from both paths.

- Occlusion is ONE verdict per marker from a raycast (throttled to ~8Hz) with 8cm
  of slack, so a note resting ON a surface stays solid while one genuinely behind
  geometry fades its FILL to 50% and dashes its leader. The number keeps full
  opacity - it is the marker's identity. Nothing can clip a DOM badge, which was
  the actual request.
- Badge style: pill (rounded-rect for the square shape), 26px tall, 13.5px bold
  tabular number, small type icon, 1.5px light outline + layered drop shadow,
  contrast-aware ink. The leader is a 1.25px core over a soft 3px glow ending in
  a dot with a hairline ring exactly on the 3D point.
- Clustering: greedy screen-space grouping at 34px (where badges would actually
  overlap) collapses into one counted badge with a stacked-plates shadow; a click
  fans the members out in an arc, each keeping its own leader to its own point.
- Polish: constant screen size, hover scale + brighter outline + a tooltip with
  name/preview/author/date, a selected ring on the open note, fade under 0.9m and
  hidden under 0.35m, off-screen culling that skips the raycast too, and an
  adaptive outline for light themes / prefers-color-scheme.
- GOTCHA worth keeping: an <svg> is a REPLACED element, so `position:fixed;
  inset:0` still leaves it at its 300x150 intrinsic box and silently clips every
  line away - it needs explicit width/height 100%. Also, app chrome (Connect bar,
  z 300) sits above the marker layer (z 28), so a badge under it is not
  clickable; the suite clicks in page context rather than fighting Playwright's
  actionability check.

notes-v2 is now 63 checks, rewritten around the DOM markers: fill/ink/pill/icon,
leader endpoints matching the projected point, occluded fade + dashed leader,
the on-surface slack, cluster collapse/expand, hover preview, selected ring, and
the pins toggle hiding both paths. annotation-anchor green, build green,
svelte-check holds 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o jiggle), pill badge everywhere

Two follow-ups from the review.

- JIGGLE ROOT CAUSE: the marker layer owned a private requestAnimationFrame
  loop, but threlte's OrbitControls calls controls.update() in a task in the
  MAIN stage. Those are two separate callback queues: whenever the marker loop
  ran before threlte's tick it projected LAST frame's camera, so every badge
  trailed the geometry by one frame while the camera moved. That also explains
  why entering and leaving VR "fixed" it - an XR session swaps the loop to
  renderer.setAnimationLoop and threlte re-registers its own rAF afterwards,
  flipping the callback order so the marker loop happened to read the updated
  camera.
  Fix: the projection + occlusion now run in a useTask in the RENDER stage
  (after the main stage, so the camera is final for the frame) inside
  AnnotationPins.svelte, which is already in the Canvas, and publish to a new
  `noteMarkers` store. AnnotationMarkers.svelte became presentation-only:
  clustering, hover and the badge chrome derive from that store, no loop of its
  own. Positions therefore always come from the very camera pose that rendered
  the frame the badge sits on.
- Pin SHAPE is now a VR-only distinction: every 2D badge is the same pill, so
  the overlay reads consistently (the rounded-rect variant and the per-shape
  icon are gone). The editor's selector stays - it configures the in-scene VR
  pin - and is labelled "Pin shape - VR only" so it does not look inert.

notes-v2 gains a real regression guard for the lag: it spins the camera and
compares each badge against the pin projected with the camera at that instant,
asserting the residual stays far below the per-frame travel. Verified that the
check DISCRIMINATES by temporarily re-introducing a one-frame lag: residual went
0.0px -> 3.5px against a 3.9px/frame step, and the check failed. Plus a check
that a 'star' note renders the same pill in 2D. 65 checks green,
annotation-anchor green, build green, svelte-check holds 419/62.
roadmap-13-notifications-notes' unread-badge check still fails identically on
HEAD (known flake).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the last piece of 15-H. The originally locked fork (follow only while the
popover is open) was dropped on review: nobody keeps a card open to keep
tracking, and closing it to see the scene would kill the thing they wanted.

- FOLLOW IS A SESSION, not a popup side effect. Started explicitly (Follow on the
  note's view card) and it OUTLIVES the card: a sticky info toast "Following note
  #n - the camera rides it as it moves (Esc to stop)" is both the indicator and
  the off-switch (reuses showInfoToast/dismissToastById from 15-L). Local and
  per-viewer - never replicated, because the editor camera belongs to whoever is
  driving it.
- MOTION: each frame we translate camera.position AND orbitControls.target by the
  pin's world delta. Moving both ends preserves the user's own orbit/zoom offset,
  so they keep navigating freely while the camera rides the object. The tick runs
  in a MAIN-stage useTask - before OrbitControls' own task and the render - so the
  pose we write is the pose the frame is composed from, and the render-stage
  marker projection already sees it.
- SAVED POSE = AUTHORED FRAMING. "Save camera view" stores {position, target} on
  the note (replicated on the unchanged annotation message). Opening flies there
  instead of the generic 4m approach; a follow session then keeps that offset
  live as the object moves, so the author's framing stays true. Plus a per-note
  "Follow the pin when opened" hint that auto-starts the viewer's local session.
- HANDOVER is an EXPLICIT signal, not a deviation guess. New `cameraClaim`
  counter in sceneStore (a leaf both modules already import) is bumped by
  objectActions.flyTo, so focus/bookmarks/opening another note end the session
  cleanly - while the user's own PAN, which moves the orbit target exactly like
  our follow step does, no longer looks like someone stealing the camera. First
  attempt used a target-deviation epsilon; it would have broken every pan.
  Sessions also end on Esc, on VR/spectate/play taking the camera, and when the
  followed note or its object goes away.
- Nodes route deliberately NOT taken here: a follow/look-at node belongs to the
  roadmap-16 camera OBJECTS (replicable, watchable via PiP) and is in backlog. A
  peer's graph must never yank your viewpoint.

notes-v2 +16 checks: pose stored and re-flown (comparing against the ACTUAL
parked pose - OrbitControls.update() re-derives position from its spherical
state, so never assert the numbers you asked for), camera+target translate with
the pin, the chosen offset survives, panning keeps the session, closing the card
keeps it, an external flyTo hands over, Esc stops, the flag replicates and
auto-starts, and old payloads normalize to no pose / follow off. 79 checks green;
annotation-anchor, camera-bookmarks, autosave-object-flows green; build green;
svelte-check holds 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…c order

- The "Follow the pin when opened" control is a SWITCH now: it arms a mode, which
  is why Settings uses Toggle for behaviour flags. GOTCHA worth remembering:
  flowbite's plugin emits `[type='checkbox']:checked { background-color:
  currentColor !important }`, so no background-color of ours can ever win the ON
  state - it rendered flowbite blue no matter the specificity. The cure is to work
  WITH that rule and drive the fill through `color`. The suite now asserts the ON
  colour so it cannot drift back to blue.
- NEW SETTING "Double-click to open notes" (Settings > Interface, LOCAL, off by
  default): a single click on a note marker - and the drawer's group arrows -
  then only FLIES the camera to the note, and the card opens on a double click.
  Reviewing a scene full of notes becomes pure navigation with nothing in the way.
  `focusAnnotation(id)` is the go-there-without-opening path (openAnnotation and
  it now share one `flyToAnnotation`, so a saved framing and the follow hint apply
  either way).
- With no card open there was nothing to say where you are, so a new
  `visitedNote` store records the last note travelled to: it highlights the drawer
  row and is what the arrows step from, so every press advances one note and wraps
  instead of restarting at the top of the group.
- ESC ORDER, as asked: the first Esc gives the CAMERA back (stops a follow
  session), the second closes the card. One window-level handler in
  annotationsHandler owns it, replacing the card's own keydown - that only fired
  when focus happened to sit inside the card, so Esc did nothing after a marker
  click - and the follow-only listener that would have raced it. A modal owns Esc
  while it is up.

notes-v2 +11 checks (switch shape + ON colour + stored flag, Esc precedence in
both orders and from outside the card, single-click-flies / double-click-opens
with the setting on and off, arrows travelling without a popup while still
advancing + wrapping, and the row highlight). 90 checks green; annotation-anchor,
autosave-object-flows, camera-bookmarks and all 5 settings suites green; build
green; svelte-check holds 419/62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e trap, camera ownership

- Architecture map: annotationsHandler's full v2/v3 model (text stays the
  description, ONE normalizeAnnotation at every boundary, unchanged wire shape,
  authorKey identity, noteMarkers/followingNote/visitedNote/focusAnnotation,
  sweepAnnotations' re-key + grace) and the notes UI trio (AnnotationMarkers as the
  screen-space layer, AnnotationPins demoted to the VR path, popover + drawer).
- New gotchas, each of which cost real debugging here: a DOM overlay that must agree
  with a threlte frame may NOT own a requestAnimationFrame (OrbitControls updates
  the camera in a main-stage task; project in useTask({stage: renderStage}) instead
  — and note that entering/leaving VR "fixing" a jiggle is the signature of exactly
  this) - OrbitControls.update() re-derives the camera position from its spherical
  state, so direct writes revert and continuous drivers must move camera AND target,
  with cameraClaim as the explicit handover signal because deviation-watching cannot
  tell a user pan from a takeover - an <svg> is a replaced element, so fixed+inset:0
  still clips it to 300x150 - flowbite forces background-color: currentColor
  !important on checked checkboxes, so a custom switch must be coloured through
  `color` - depthWrite:false loses the outline/N8AO passes, which then paint the
  geometry behind a sprite across its face - pattern FOLLOW must snap by the section
  period, not one cell.
- Status entry for the 5-commit 15-H branch.
- e2e-verify: how to read the DOM markers (and why not pinsGroup), chrome above the
  marker layer, clustering hiding individual badges, the frame-lag guard that was
  proven to fail before being trusted, plus the camera-assertion and
  synthetic-click-vs-binding rules.
- peer-feature: how to GROW an existing replicated record without a new message
  type, and where the line sits between replicated authorship and a local view of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/extensions/Grid.svelte
@AlexZ005
AlexZ005 merged commit bf3c801 into release/next Aug 5, 2026
@AlexZ005
AlexZ005 deleted the feat/roadmap15-h-notes-v2 branch August 5, 2026 20:27
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.

1 participant