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
@@ -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