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:
Wassim SAMAD
2026-06-23 09:04:58 -04:00
co-authored by Claude Opus 4.8
parent b2f1a8432e
commit f773e6b8c5
71 changed files with 2362 additions and 598 deletions
+31 -13
View File
@@ -15,9 +15,8 @@ import type { CloneNodesIntoOptions, Subtree } from './subtree'
// door cutouts read parent wall — use `ctx` to resolve those references
// without importing `useScene`. Builders stay pure and unit-testable.
//
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch
// data (wall mitering across an entire level). Decided alongside the wall
// migration off its dedicated system (Phase 3+).
// `levelData` carries level-scoped batch data (wall mitering across an
// entire level) from registry dispatchers into pure builders.
export type GeometryContext = {
/** Look up any node by ID. Returns undefined if the node doesn't exist. */
@@ -30,18 +29,16 @@ export type GeometryContext = {
parent: AnyNode | null
/**
* Pre-computed level-batch data, populated by the dispatcher when the
* kind declares `def.computeLevelData`. Shared across every
* `def.geometry(node, ctx)` call in the same level batch within a
* single frame, so kinds whose geometry depends on cross-sibling
* data (wall mitering, gradient sky uniforms across a zone, etc.)
* don't pay an O(N²) recomputation cost.
* kind declares `def.computeLevelData` (3D) or
* `def.computeFloorplanLevelData` (2D). Shared across every builder call
* in the same level batch within a single frame/render pass, so kinds
* whose geometry depends on cross-sibling data (wall mitering, gradient
* sky uniforms across a zone, etc.) don't pay an O(N²) recomputation cost.
*
* Typed as `unknown` at the framework boundary — kinds cast to their
* own `LevelData` shape inside `def.geometry` (the same kind owns
* both the `computeLevelData` return shape and the `geometry`
* consumer, so the cast is internal). Only populated for `def.
* geometry` calls today; not used by `def.floorplan` (which already
* has cheap access to siblings through `ctx.siblings`).
* own `LevelData` shape inside `def.geometry` / `def.floorplan` (the
* same kind owns both the compute hook's return shape and the builder
* consumer, so the cast is internal).
*/
levelData?: unknown
/**
@@ -820,6 +817,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* runs once even when many walls are dirty in the same frame.
*/
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
/**
* Floor-plan level-batch precompute hook. The floor-plan layer calls this
* once per level per render pass, de-duplicated by kind, before the
* per-node `def.floorplan` calls. The result lands in `ctx.levelData` for
* every node of this kind in the level.
*
* Used to hoist cross-sibling floor-plan work that would otherwise be
* O(N²) when rebuilding every node in a kind — e.g. wall mitering. `nodes`
* is the live-merged scene snapshot; `siblings` is every node of this kind
* in the level, also live-merged.
*/
computeFloorplanLevelData?: (args: {
siblings: ReadonlyArray<z.infer<S>>
nodes: Record<string, AnyNode>
}) => unknown
/**
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
@@ -877,6 +889,12 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* unset and rely on the generic overlay path.
*/
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
/**
* Geometry reads sibling/parent/child nodes (e.g. wall miters, opening
* dimensions); the floor-plan layer must rebuild it whenever a
* sibling-affecting node is being dragged live.
*/
floorplanDependsOnSiblings?: boolean
/**
* Optional hook letting a kind project the `useLiveNodeOverrides` map
* into a fresh `nodes` snapshot before its `def.floorplan` builder