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
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode } from '../schema'
import { planAutoCeilingsForLevel } from './space-detection'
import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
const square: Array<[number, number]> = [
[0, 0],
@@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => {
expect(plan.update).toHaveLength(0)
})
})
describe('planAutoSlabsForLevel', () => {
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
// Two rooms with identical polygon signatures previously collided in a
// signature-keyed Map, so one detected room never matched an existing slab
// and churned (delete + recreate) on every pass.
const slabA = slab(0.05)
const slabB = slab(0.05)
const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
expect(plan.update).toHaveLength(0)
})
test('deletes an extra auto-slab when only one identical room is detected', () => {
const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1)
})
})
+22 -36
View File
@@ -595,43 +595,25 @@ function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||')
}
function slabGeometrySignature(slab: SlabNodeType) {
const polygon = slab.polygon
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`)
.join(';')
const holes = (slab.holes ?? [])
.map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
.join('/')
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join(
'|',
)
}
function levelSlabSnapshot(slabs: SlabNodeType[]) {
return slabs.map(slabGeometrySignature).sort().join('||')
}
// Trigger signature is wall-only on purpose: re-detection should fire on a
// genuine remodel (wall geometry change), never when an auto-slab is edited or
// deleted. Hashing slabs here created a feedback loop where deleting an
// auto-slab re-fired detection and recreated it.
function levelStructureSnapshots(nodes: Record<string, any>) {
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>()
const getEntry = (levelId: string) => {
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
byLevel.set(levelId, entry)
return entry
}
const byLevel = new Map<string, WallNode[]>()
for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
if ((node as any).type === 'wall') {
getEntry((node as any).parentId).walls.push(node as WallNode)
} else if ((node as any).type === 'slab') {
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node))
}
if ((node as any).type !== 'wall') continue
const levelId = (node as any).parentId as string
const walls = byLevel.get(levelId) ?? []
walls.push(node as WallNode)
byLevel.set(levelId, walls)
}
const snapshots = new Map<string, string>()
for (const [levelId, entry] of byLevel.entries()) {
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`)
for (const [levelId, walls] of byLevel.entries()) {
snapshots.set(levelId, levelWallSnapshot(walls))
}
return snapshots
@@ -692,13 +674,15 @@ export function planAutoSlabsForLevel(
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, [number, number][]>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry)
const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return
matchedDetectedIdx.add(index)
@@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel(
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry)
const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return
matchedDetectedIdx.add(index)
+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
@@ -5,6 +5,7 @@ import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/type
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
canAttach,
canHostOnTop,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
@@ -14,6 +15,16 @@ import {
const id = (s: string) => s as AnyNodeId
function makeItem(idStr: string, attachTo?: 'wall' | 'wall-side' | 'ceiling'): AnyNode {
return {
id: id(idStr),
type: 'item',
parentId: null,
visible: true,
asset: attachTo ? { attachTo } : {},
} as unknown as AnyNode
}
function makeDef(
kind: string,
capabilities: Capabilities = {},
@@ -233,4 +244,30 @@ describe('pickHost', () => {
})
expect(picked?.id).toBe(id('s2'))
})
test('excludes ceiling-mounted hosts (ceiling fan cannot be a top surface)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('fan', 'ceiling'), makeItem('table')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('table'))
})
test('keeps wall-mounted hosts (wall shelf still hosts)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('shelf', 'wall')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('shelf'))
})
})
describe('canHostOnTop', () => {
test('rejects ceiling-attachTo hosts', () => {
expect(canHostOnTop(makeItem('fan', 'ceiling'))).toBe(false)
})
test('accepts wall / wall-side / floor (undefined) hosts', () => {
expect(canHostOnTop(makeItem('shelf', 'wall'))).toBe(true)
expect(canHostOnTop(makeItem('sconce', 'wall-side'))).toBe(true)
expect(canHostOnTop(makeItem('table'))).toBe(true)
})
})
+13 -4
View File
@@ -112,6 +112,18 @@ export function getTopSurfaceHeight(host: AnyNode): number | null {
return typeof height === 'function' ? height(host) : height
}
/**
* Whether `host` can receive a surface-resting (top-stacked) child. A
* ceiling-mounted item hangs from the ceiling, so its visible "top" is not a
* usable resting surface — nothing should stack on a ceiling fan. The check
* reads the instance-level `asset.attachTo` (not the host KIND, which is shared
* across all items) so a single gate covers every interaction path.
*/
export function canHostOnTop(host: AnyNode): boolean {
const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo
return attachTo !== 'ceiling'
}
/**
* Pure host-discovery helper. Given a list of candidate hosts (already
* narrowed by spatial query) and a point, returns the first whose
@@ -129,10 +141,7 @@ export function pickHost(args: {
const def = nodeRegistry.get(host.type)
const hostable = def?.capabilities.hostable
if (!hostable) continue
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
// capability declares specific parents; verify the placed kind's own def
// also permits this host kind.
}
if (!canHostOnTop(host)) continue
if (args.hitTest && !args.hitTest(host, args.point)) continue
return host
}
+1
View File
@@ -34,6 +34,7 @@ export {
type AttachError,
type AttachResult,
canAttach,
canHostOnTop,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,