fix(editor): floorplan reference pass — north convention, cursors, R/T, Set Scale UX (#475)
Fix pass over 2D reference-image (guide) handling plus two adjacent repairs: - North is now world −Z (FLOORPLAN_VIEW_ROTATION_DEG 90 → 0, mirrored in world-grid-snap + mcp README; floorplan-panel de-dupes its local copy). A rotation-0 north-up scan reads upright when the compass says aligned, and "align north" maps to camera azimuth 0 instead of a 90° jump. - Guide resize cursor: custom double-arrow cursor aimed at the dragged corner in screen space — accounts for image aspect (was atan2(±1,±1), square-only), the guide's own rotation, and the floorplan view rotation the overlay group renders inside (was ignored entirely). - R/T rotate a selected reference (guide/scan) in ±45° steps; references live in selectedReferenceId, not the viewer selection, so both arms get the reference-first branch the Delete arm already had. Locked guides skip. - 2D-only mode hides the Top View button (drives the display:none 3D camera); orbit stays — it spins the synced floorplan view. - Set Scale UX: locked guides stay clickable (calibration auto-lock made them unselectable until reload); starting the flow from 3D switches to 2D; the length input pre-fills the drawn length in the pre-selected unit (imperial pre-filled meters labeled feet); Set Scale flips into Cancel while the flow runs (mirrored via referenceScaleActiveGuideId); Hide/ Clear Scale only render once calibrated; Escape cancels (global arm + dialog); Clear Scale and Replace Image drop the calibration auto-lock; discoverability: corner-hint row + panel nudge for uncalibrated guides. - healSceneNodes: strip child refs whose child's parentId names another parent (stale reparent leftovers rendered a window twice — duplicate React keys in 2D, doubled hosted geometry in 3D) and same-array dupes. - Editor accepts onLoaderChange so hosts can measure open-to-interactive time (community wires it to a PostHog timing event). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2cfce3f6b5
commit
fb0b78c091
@@ -47,4 +47,49 @@ describe('healSceneNodes', () => {
|
||||
expect(strippedChildRefs).toBe(0)
|
||||
expect(nodes.wall_a).toBe(input.wall_a)
|
||||
})
|
||||
|
||||
test('drops a stale child reference left behind by a reparent', () => {
|
||||
// window's parentId says wall_b, but wall_a still lists it — the exact
|
||||
// corruption that rendered a window twice (duplicate React keys in 2D).
|
||||
const { nodes, strippedStaleChildRefs } = healSceneNodes({
|
||||
wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['window_1'] },
|
||||
wall_b: { id: 'wall_b', type: 'wall', start: [2, 0], end: [4, 0], children: ['window_1'] },
|
||||
window_1: { id: 'window_1', type: 'window', parentId: 'wall_b' },
|
||||
})
|
||||
expect(strippedStaleChildRefs).toBe(1)
|
||||
expect((nodes.wall_a as { children: string[] }).children).toEqual([])
|
||||
expect((nodes.wall_b as { children: string[] }).children).toEqual(['window_1'])
|
||||
})
|
||||
|
||||
test('collapses same-array duplicate child references', () => {
|
||||
const { nodes, strippedStaleChildRefs } = healSceneNodes({
|
||||
wall_a: {
|
||||
id: 'wall_a',
|
||||
type: 'wall',
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
children: ['door_1', 'door_1'],
|
||||
},
|
||||
door_1: { id: 'door_1', type: 'door', parentId: 'wall_a' },
|
||||
})
|
||||
expect(strippedStaleChildRefs).toBe(1)
|
||||
expect((nodes.wall_a as { children: string[] }).children).toEqual(['door_1'])
|
||||
})
|
||||
|
||||
test('keeps children whose parentId matches or is absent', () => {
|
||||
const input = {
|
||||
wall_a: {
|
||||
id: 'wall_a',
|
||||
type: 'wall',
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
children: ['door_1', 'item_legacy'],
|
||||
},
|
||||
door_1: { id: 'door_1', type: 'door', parentId: 'wall_a' },
|
||||
item_legacy: { id: 'item_legacy', type: 'item' },
|
||||
}
|
||||
const { nodes, strippedStaleChildRefs } = healSceneNodes(input)
|
||||
expect(strippedStaleChildRefs).toBe(0)
|
||||
expect(nodes.wall_a).toBe(input.wall_a)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
// Repairs scene-graph corruption that pre-dates the source fixes, so existing
|
||||
// saved scenes still load. Two known kinds of damage, both produced by the
|
||||
// capture wall-merge before it was fixed:
|
||||
// saved scenes still load. Known kinds of damage:
|
||||
//
|
||||
// 1. A `children` array containing a non-string entry. The merge re-attached a
|
||||
// wall-hosted item without minting an id, so `undefined` was pushed into the
|
||||
// wall's children — which serializes to `[null]`. The wall schema rejects
|
||||
// `null` children, so the whole scene fails to load.
|
||||
// 1. A `children` array containing a non-string entry. The capture wall-merge
|
||||
// re-attached a wall-hosted item without minting an id, so `undefined` was
|
||||
// pushed into the wall's children — which serializes to `[null]`. The wall
|
||||
// schema rejects `null` children, so the whole scene fails to load.
|
||||
// 2. A zero-length wall (start === end). It renders nothing, but lingers as a
|
||||
// junk node and is a foot-gun for snapping/mitering.
|
||||
// 3. A child referenced by a parent it no longer belongs to: the child's
|
||||
// `parentId` points at node B while node A's `children` still lists it
|
||||
// (stale leftover from a reparent that didn't clean the old parent). The
|
||||
// duplicate reference renders the child twice (duplicate React keys in the
|
||||
// 2D plan, doubled hosted geometry in 3D). Same-array duplicates are
|
||||
// collapsed too.
|
||||
//
|
||||
// Both are also prevented at the source now (see merge-walls.ts and the wall
|
||||
// miter limit); this is the load-time safety net for already-saved scenes.
|
||||
// All are also prevented at the source now; this is the load-time safety net
|
||||
// for already-saved scenes.
|
||||
|
||||
const ZERO_LENGTH_EPS = 1e-6
|
||||
|
||||
@@ -20,6 +25,11 @@ export interface HealSceneResult {
|
||||
droppedWallIds: string[]
|
||||
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
|
||||
strippedChildRefs: number
|
||||
/**
|
||||
* Count of child references removed because the child's `parentId` points at
|
||||
* a different node (stale reparent leftovers), plus same-array duplicates.
|
||||
*/
|
||||
strippedStaleChildRefs: number
|
||||
}
|
||||
|
||||
function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } {
|
||||
@@ -62,16 +72,34 @@ export function healSceneNodes(input: Record<string, unknown>): HealSceneResult
|
||||
|
||||
const dropped = new Set(droppedWallIds)
|
||||
let strippedChildRefs = 0
|
||||
let strippedStaleChildRefs = 0
|
||||
|
||||
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug)
|
||||
// and references to walls we just removed.
|
||||
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]`
|
||||
// bug), references to walls we just removed, same-array duplicates, and
|
||||
// stale references whose child's `parentId` names a different parent.
|
||||
const nodes: Record<string, unknown> = {}
|
||||
for (const [id, node] of Object.entries(kept)) {
|
||||
const children = (node as { children?: unknown })?.children
|
||||
if (Array.isArray(children)) {
|
||||
const cleaned = children.filter((c): c is string => typeof c === 'string' && !dropped.has(c))
|
||||
const seen = new Set<string>()
|
||||
const cleaned = children.filter((c): c is string => {
|
||||
if (typeof c !== 'string' || dropped.has(c)) {
|
||||
strippedChildRefs++
|
||||
return false
|
||||
}
|
||||
if (seen.has(c)) {
|
||||
strippedStaleChildRefs++
|
||||
return false
|
||||
}
|
||||
seen.add(c)
|
||||
const child = kept[c] as { parentId?: unknown } | undefined
|
||||
if (child && typeof child.parentId === 'string' && child.parentId !== id) {
|
||||
strippedStaleChildRefs++
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (cleaned.length !== children.length) {
|
||||
strippedChildRefs += children.length - cleaned.length
|
||||
nodes[id] = { ...(node as Record<string, unknown>), children: cleaned }
|
||||
continue
|
||||
}
|
||||
@@ -79,5 +107,5 @@ export function healSceneNodes(input: Record<string, unknown>): HealSceneResult
|
||||
nodes[id] = node
|
||||
}
|
||||
|
||||
return { nodes, droppedWallIds, strippedChildRefs }
|
||||
return { nodes, droppedWallIds, strippedChildRefs, strippedStaleChildRefs }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user