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:
Wassim SAMAD
2026-07-08 14:12:49 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 2cfce3f6b5
commit fb0b78c091
11 changed files with 345 additions and 64 deletions
@@ -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)
})
})
+41 -13
View File
@@ -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 }
}