feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative interaction-scope state machine plus the catalogued placement/interaction fixes, and split-view floor-plan performance. - Interaction-scope spine (lib/interaction/* + store/use-interaction-scope), driven from central useEditor setters; overlay scoping (zone labels, context badges, floating action menu) reads resolveOverlayPolicy. - Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity, auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset, per-frame jank. - Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier model (Shift=cycle, Alt=free place, Ctrl=grid step). - Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle rig hides during a whole-node move; rotate gizmo advertises Shift=free rotation in the HUD and hides the move cross while rotating. - Floor-plan perf: pause live reactivity while in 3D-only view; per-node geometry cache so only changed nodes rebuild on a drag; hoist wall miters to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b2f1a8432e
commit
f773e6b8c5
@@ -14,6 +14,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
|
||||
| [item-authoring](item-authoring.md) | Content-author contract for catalog item GLBs: `slot_` material naming, authored defaults + `pascal_material` extras, the `cutout` reserved mesh, UV world scale, and the validated Blender/export recipe |
|
||||
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
|
||||
| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults |
|
||||
| [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix |
|
||||
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
|
||||
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
|
||||
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
|
||||
@@ -25,4 +26,5 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
|
||||
## Reading order for an architecture review
|
||||
|
||||
1. [layers](layers.md), [systems](systems.md), [renderers](renderers.md), [tools](tools.md), [viewer-isolation](viewer-isolation.md) — required every review.
|
||||
- When the diff touches placement / move / handle / reshape / box-select / paint or any overlay or picking behaviour, also read [interaction-scope](interaction-scope.md).
|
||||
2. The remaining pages on demand, based on what the diff touches.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Interaction Scope
|
||||
|
||||
*The authoritative interaction state machine ("the spine") — one scope describes "what the user is currently doing".*
|
||||
|
||||
Applies to: `packages/editor/src/lib/interaction/**`, `packages/editor/src/store/use-interaction-scope.ts`.
|
||||
|
||||
Before this, "what is the user doing right now?" was re-derived from 7+ independent
|
||||
`useEditor` flags (`movingNode`, `placementDragMode`, `activeHandleDrag`,
|
||||
`curvingWall`, `curvingFence`, `editingHole`, `movingWallEndpoint`,
|
||||
`movingFenceEndpoint`). Every overlay and pick site re-derived its behaviour from
|
||||
a different subset, so the flags could drift into illegal combinations (moving +
|
||||
curving at once; a stale `movingNode` after a drag ended). The scope collapses
|
||||
them into one discriminated union, making those combinations unrepresentable: a
|
||||
scope is exactly one interaction at a time, and `idle` carries no payload.
|
||||
|
||||
---
|
||||
|
||||
## The model
|
||||
|
||||
`InteractionScope` (`lib/interaction/scope.ts`) is a discriminated union on `kind`:
|
||||
|
||||
| `kind` | Payload | What |
|
||||
|---|---|---|
|
||||
| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. |
|
||||
| `placing` | `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. |
|
||||
| `moving` | `nodeId`, `nodeType`, `view` | Moving an existing node. |
|
||||
| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. |
|
||||
| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). |
|
||||
| `reshaping` | `nodeId`, `reshape`, `holeIndex?` | Reshaping a selected node's geometry. `reshape` is `curve \| hole \| endpoint \| boundary`. |
|
||||
| `box-select` | — | Marquee selection drag. |
|
||||
| `painting` | — | Material paint application. |
|
||||
|
||||
`reshaping` groups endpoint/curve/hole/boundary edits as sub-states of one scope
|
||||
(rather than four sibling kinds) — there is one node and one in-flight reshape,
|
||||
so "curving and hole-editing at once" stays unrepresentable. `view` is `'2d' | '3d'`.
|
||||
|
||||
### Helpers
|
||||
|
||||
- `isIdle(scope)` / `isActive(scope)` — `idle` vs anything else (`ActiveInteractionScope`).
|
||||
- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node.
|
||||
- `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false.
|
||||
|
||||
---
|
||||
|
||||
## The store contract
|
||||
|
||||
`useInteractionScope` (default export of `store/use-interaction-scope.ts`) is the
|
||||
single owner. Exactly one scope at a time; the only writable shape is
|
||||
`InteractionScope`, so there is no setter that can leave a half-state.
|
||||
|
||||
| Method | Behaviour |
|
||||
|---|---|
|
||||
| `begin(scope: ActiveInteractionScope)` | Enter an interaction. If one is already active it is replaced (single owner, no producer races). |
|
||||
| `update(patch)` | Patch the current scope's payload. **Ignored when idle, and ignored when the patch's `kind` differs from the active kind** — payload updates must not change which interaction is running (use `begin` for that). |
|
||||
| `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. |
|
||||
| `endIf(match)` | Return to idle only if the active scope satisfies `match`. |
|
||||
|
||||
**Atomic-end invariant.** `end()` sets the scope back to `IDLE_SCOPE` in one
|
||||
write — no interaction payload can leak past the end of its interaction (no stale
|
||||
`nodeId`, no half-cleared flags). `endIf` exists because scope is currently
|
||||
driven from independent legacy flag clears (below): clearing one flag (e.g. a
|
||||
fence curve) must not stomp an unrelated active scope (e.g. a wall move), so the
|
||||
clear only ends the scope if it owns it.
|
||||
|
||||
---
|
||||
|
||||
## Hot-set: what is raycast-eligible during an interaction
|
||||
|
||||
`lib/interaction/hot-set.ts` answers "which scene objects can the active
|
||||
interaction target?" It is never hand-authored per interaction — it falls out of
|
||||
the node's `asset.attachTo` plus whether a candidate exposes a top surface.
|
||||
|
||||
`attachClassOf(attachTo)` collapses attachment to three `AttachClass` values:
|
||||
|
||||
- `wall` — `attachTo` of `wall` or `wall-side`.
|
||||
- `ceiling` — `attachTo` of `ceiling`.
|
||||
- `surface` — everything else ("floor item" really means *surface-resting*: rests on the floor **or** any host's top surface).
|
||||
|
||||
`isPickableForAttach(placed, candidate)` decides, for a node of attach class
|
||||
`placed`, whether a `HotSetCandidate` is a valid host/surface:
|
||||
|
||||
- `wall` → only `wall` candidates.
|
||||
- `ceiling` → only `ceiling` candidates.
|
||||
- `surface` → the floor (`isFloorLike`), or any candidate that `exposesTop` (registry `capabilities.surfaces.top`) — but **never** a ceiling-mounted host. A floor lamp must not land on a ceiling fan; a ceiling fan's `attachClass` is `ceiling` and is excluded as a host top (Track E).
|
||||
|
||||
`isCandidateInHotSet(scope, placedAttachClass, candidate)` lifts this to a whole scope:
|
||||
|
||||
- `idle` → `true` (selection/phase filtering stays in the selection manager; the hot-set only narrows what an *active* interaction can target).
|
||||
- `placing` / `moving` → `isPickableForAttach`, or `true` when `placedAttachClass` is `null`.
|
||||
- every other active scope → `false`: nothing in the scene is a placement target, so the interaction body's own raycast owns the pointer.
|
||||
|
||||
`HotSetCandidate` (`type`, `isFloorLike`, `exposesTop`, `attachClass`) is derived
|
||||
from the candidate node + its registry definition by the caller, keeping this
|
||||
module pure and unit-testable without the scene or registry.
|
||||
|
||||
---
|
||||
|
||||
## Overlay policy: the scope matrix
|
||||
|
||||
`resolveOverlayPolicy(scope)` (`lib/interaction/overlay-policy.ts`) returns the
|
||||
"Sims-light" overlay behaviour: default-off, opt-in for the active action. During
|
||||
any non-idle scope, scene objects stay visible but non-pickable, and DOM/HUD
|
||||
overlays step back differentiated by how distracting they are.
|
||||
|
||||
| Overlay | Idle | Any active scope |
|
||||
|---|---|---|
|
||||
| Zone labels | shown | hidden (not a primary editing concern) |
|
||||
| Context badges (hover name pills) | shown | faded + `pointer-events: none` |
|
||||
| Conflicting controls (other objects' handles, floating action menu) | shown | hidden |
|
||||
| Scene objects pickable | yes | no (the hot-set owns targeting; context preserved, can't grab the wrong thing) |
|
||||
| Active affordances (ghost, snap guides, dimension labels, the active handle) | shown | shown |
|
||||
| Contextual control HUD interactive | yes | yes (it *is* the active interaction's own controls — exempt from the pointer-events step-back) |
|
||||
|
||||
The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`.
|
||||
|
||||
---
|
||||
|
||||
## Migration status (strangler fig)
|
||||
|
||||
The scope is the target source of truth, but the legacy `useEditor` flags still
|
||||
exist as a mirror and are being retired reader-by-reader. Today the scope is
|
||||
**driven from** the central `useEditor` setters — `setMovingNode`,
|
||||
`setActiveHandleDrag`, `setCurvingWall`/`setCurvingFence`, `setEditingHole`,
|
||||
`setMovingWallEndpoint`/`setMovingFenceEndpoint`, `setMode` (for painting) — and
|
||||
from the box-select tool. Each setter calls `begin`/`end` (and `endIf`, so an
|
||||
independent flag clear can't stomp an unrelated scope) to keep the scope in sync.
|
||||
|
||||
**Contributors:**
|
||||
|
||||
- Add a new interaction by calling `begin(...)` / `end()` on `useInteractionScope`, **not** by adding a new `useEditor` flag.
|
||||
- Read "what the user is doing" through the scope and its helpers (`isActive`, `scopeNodeId`, `selectionEnabled`), not by recombining flags. New readers should consume the scope so the legacy flag can be deleted once it has no readers.
|
||||
- Add a new attach behaviour by setting `attachTo` on the asset — the hot-set follows with zero per-kind wiring.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- **One owner, one scope.** Only `useInteractionScope` writes the scope, and only via `begin`/`update`/`end`/`endIf`. Never reconstruct interaction state from a private combination of flags.
|
||||
- **`end` is atomic and payload-free.** Never leave a `nodeId`/payload behind on idle; commit-vs-revert logic belongs in the interaction body before `end`.
|
||||
- **`update` cannot change `kind`.** Switching interactions is a `begin`, not a patch.
|
||||
- **Hot-set and overlay policy are pure derivations of the scope** (and, for the hot-set, the candidate metadata). Don't branch overlay/picking behaviour on legacy flags — branch on the scope.
|
||||
- **Don't add new `useEditor` interaction flags.** New interactions go through the scope.
|
||||
@@ -36,6 +36,12 @@ return <mesh ref={ref} {...events} />
|
||||
|
||||
Events are suppressed during camera drag (`useViewer.getState().cameraDragging`).
|
||||
|
||||
Selection/hover picking is only meaningful while the interaction scope is `idle`
|
||||
(`selectionEnabled(scope)`). During an active placement/move/etc., the pointer
|
||||
belongs to that interaction's body and the hot-set narrows which scene objects
|
||||
are raycast-eligible — see [interaction-scope](interaction-scope.md) for the
|
||||
hot-set derivation and the overlay scope matrix.
|
||||
|
||||
---
|
||||
|
||||
## Viewer Selection Manager
|
||||
|
||||
@@ -12,6 +12,8 @@ Tools are React components that capture user input (pointer, keyboard) and trans
|
||||
|
||||
See `apps/editor/components/tools/tool-manager.tsx`.
|
||||
|
||||
> **What the user is doing right now** is owned by the interaction state machine, not by tool-local flags. A tool that starts a placement / move / handle / reshape / box-select / paint interaction enters it through `useInteractionScope.begin(...)` and leaves through `end()` — see [interaction-scope](interaction-scope.md). Do not add a new `useEditor` flag for a new interaction.
|
||||
|
||||
## Tool Categories by Phase
|
||||
|
||||
**Site**
|
||||
|
||||
Reference in New Issue
Block a user