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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
type AttachError,
|
||||
type AttachResult,
|
||||
canAttach,
|
||||
canHostOnTop,
|
||||
clampYToHostTop,
|
||||
getSurface,
|
||||
getTopSurfaceHeight,
|
||||
|
||||
Reference in New Issue
Block a user