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)
|
||||
|
||||
Reference in New Issue
Block a user