From fb0b78c091b8e7576fa4238217fcffcc84413562 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 8 Jul 2026 14:12:49 -0400 Subject: [PATCH] =?UTF-8?q?fix(editor):=20floorplan=20reference=20pass=20?= =?UTF-8?q?=E2=80=94=20north=20convention,=20cursors,=20R/T,=20Set=20Scale?= =?UTF-8?q?=20UX=20(#475)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/src/utils/heal-scene-graph.test.ts | 45 +++++++ packages/core/src/utils/heal-scene-graph.ts | 54 ++++++-- .../src/components/editor/floorplan-panel.tsx | 119 ++++++++++++++++-- .../editor/src/components/editor/index.tsx | 9 ++ .../ui/action-menu/camera-actions.tsx | 37 +++--- .../components/ui/panels/reference-panel.tsx | 73 ++++++++--- packages/editor/src/hooks/use-keyboard.ts | 50 ++++++++ packages/editor/src/lib/floorplan/geometry.ts | 8 +- packages/editor/src/lib/world-grid-snap.ts | 4 +- packages/editor/src/store/use-editor.tsx | 7 ++ packages/mcp/README.md | 3 +- 11 files changed, 345 insertions(+), 64 deletions(-) diff --git a/packages/core/src/utils/heal-scene-graph.test.ts b/packages/core/src/utils/heal-scene-graph.test.ts index 6b64658e..699d9ce1 100644 --- a/packages/core/src/utils/heal-scene-graph.test.ts +++ b/packages/core/src/utils/heal-scene-graph.test.ts @@ -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) + }) }) diff --git a/packages/core/src/utils/heal-scene-graph.ts b/packages/core/src/utils/heal-scene-graph.ts index 9ada1400..80780b2b 100644 --- a/packages/core/src/utils/heal-scene-graph.ts +++ b/packages/core/src/utils/heal-scene-graph.ts @@ -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): 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 = {} 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() + 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), children: cleaned } continue } @@ -79,5 +107,5 @@ export function healSceneNodes(input: Record): HealSceneResult nodes[id] = node } - return { nodes, droppedWallIds, strippedChildRefs } + return { nodes, droppedWallIds, strippedChildRefs, strippedStaleChildRefs } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 5aaf03f6..dd29ee8d 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -78,6 +78,7 @@ import { buildFloorplanItemEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry, collectLevelDescendants, + FLOORPLAN_VIEW_ROTATION_DEG, floorplanLocalToWorldPoint, getFloorplanWall as getSharedFloorplanWall, rotatePlanVector as rotateSharedPlanVector, @@ -248,7 +249,6 @@ const FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET = 72 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92 const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48 const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15 -const FLOORPLAN_VIEW_ROTATION_DEG = 90 const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35 const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90 const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005 @@ -1125,9 +1125,28 @@ function getResizeCursorForAngle(angle: number) { return 'nesw-resize' } -function getGuideResizeCursor(corner: GuideCorner, rotationSvg: number) { +function getGuideResizeCursorAngle(corner: GuideCorner, aspectRatio: number, rotationSvg: number) { const signs = guideCornerSigns[corner] - return getResizeCursorForAngle(Math.atan2(signs.y, signs.x) + rotationSvg) + // Screen-space direction from the guide center toward the dragged corner: + // the corner diagonal depends on the image aspect, not a fixed 45°. + return Math.atan2(signs.y, signs.x * aspectRatio) + rotationSvg +} + +function getGuideResizeCursor(angle: number, isDarkMode: boolean) { + const strokeColor = isDarkMode ? '#ffffff' : '#09090b' + const outlineColor = isDarkMode ? '#0a0e1b' : '#ffffff' + const degrees = Math.round((angle * 180) / Math.PI) + const arrowPath = 'M5 12h14M8.5 8.5 5 12l3.5 3.5M15.5 8.5 19 12l-3.5 3.5' + const svgMarkup = ` + + + + + + + `.trim() + + return buildCursorUrl(svgMarkup, 12, 12, getResizeCursorForAngle(angle)) } function buildCursorUrl(svgMarkup: string, hotspotX: number, hotspotY: number, fallback: string) { @@ -3021,6 +3040,7 @@ function useGuideImageDimensions(url: string | null) { function FloorplanGuideImage({ guide, isInteractive, + isLocked, isSelected, activeInteractionMode, onGuideSelect, @@ -3028,6 +3048,10 @@ function FloorplanGuideImage({ }: { guide: GuideNode isInteractive: boolean + // Locked guides stay CLICKABLE (select → panel → unlock / edit scale) but + // never start a translate drag. Removing the hit rect entirely made a + // scale-calibrated (auto-locked) reference unselectable until reload. + isLocked: boolean isSelected: boolean activeInteractionMode: GuideInteractionMode | null onGuideSelect: (guideId: GuideNode['id']) => void @@ -3063,15 +3087,18 @@ function FloorplanGuideImage({ onPointerDown={(event) => { if (event.button === 0) { event.stopPropagation() - if (isSelected) { + if (isSelected && !isLocked) { onGuideTranslateStart(guide, event) } } }} pointerEvents="all" style={{ - cursor: - isSelected && activeInteractionMode === 'translate' + cursor: isLocked + ? isSelected + ? 'default' + : 'pointer' + : isSelected && activeInteractionMode === 'translate' ? 'grabbing' : isSelected ? 'grab' @@ -3275,7 +3302,8 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({ activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null } guide={guide} - isInteractive={isInteractive && guideUi[guide.id]?.locked !== true} + isInteractive={isInteractive} + isLocked={guideUi[guide.id]?.locked === true} isSelected={selectedGuideId === guide.id} key={guide.id} onGuideSelect={onGuideSelect} @@ -3460,6 +3488,7 @@ function FloorplanGuideSelectionOverlay({ guide, isDarkMode, rotationModifierPressed, + sceneRotationDeg, showHandles, onCornerHoverChange, onCornerPointerDown, @@ -3467,6 +3496,7 @@ function FloorplanGuideSelectionOverlay({ guide: GuideNode | null isDarkMode: boolean rotationModifierPressed: boolean + sceneRotationDeg: number showHandles: boolean onCornerHoverChange: (corner: GuideCorner | null) => void onCornerPointerDown: ( @@ -3546,7 +3576,18 @@ function FloorplanGuideSelectionOverlay({ style={{ cursor: rotationModifierPressed ? getGuideRotateCursor(isDarkMode) - : getGuideResizeCursor(corner, getGuideSvgRotation(guide.rotation[1])), + : getGuideResizeCursor( + getGuideResizeCursorAngle( + corner, + planWidth / planHeight, + // The overlay renders inside the scene , so the + // on-screen corner direction carries the view + // rotation on top of the guide's own rotation. + getGuideSvgRotation(guide.rotation[1]) + + (sceneRotationDeg * Math.PI) / 180, + ), + isDarkMode, + ), }} vectorEffect="non-scaling-stroke" /> @@ -3563,11 +3604,13 @@ function FloorplanGuideHandleHint({ isDarkMode, isMacPlatform, rotationModifierPressed, + showScaleHint, }: { anchor: GuideHandleHintAnchor | null isDarkMode: boolean isMacPlatform: boolean rotationModifierPressed: boolean + showScaleHint: boolean }) { if (!anchor) { return null @@ -3622,6 +3665,14 @@ function FloorplanGuideHandleHint({ icon="ph:mouse-left-click-fill" /> + + {showScaleHint && ( +
+ set scale +
+ )} ) @@ -5167,6 +5218,18 @@ export function FloorplanPanel({ const [pendingReferenceScale, setPendingReferenceScale] = useState( null, ) + // Mirror the in-flight scale flow to the store — the reference panel's + // Set Scale button flips into Cancel while it's active. + useEffect(() => { + useEditor + .getState() + .setReferenceScaleActiveGuideId( + referenceScaleDraft?.guideId ?? pendingReferenceScale?.guideId ?? null, + ) + }, [referenceScaleDraft, pendingReferenceScale]) + useEffect(() => { + return () => useEditor.getState().setReferenceScaleActiveGuideId(null) + }, []) const [referenceScaleValue, setReferenceScaleValue] = useState('1') const [referenceScaleUnit, setReferenceScaleUnit] = useState( unit === 'imperial' ? 'feet' : 'meters', @@ -9504,7 +9567,17 @@ export function FloorplanPanel({ end: planPoint, measuredLengthUnits, }) - setReferenceScaleValue(formatNumber(measuredLengthUnits, 2)) + // Pre-fill with the drawn length in the pre-selected unit, so + // confirming without editing is a no-op instead of a surprise + // rescale (plan units are meters; convert when defaulting to feet). + setReferenceScaleValue( + formatNumber( + unit === 'imperial' + ? measuredLengthUnits / linearUnitToMeters(1, 'imperial') + : measuredLengthUnits, + 2, + ), + ) setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') setReferenceScaleDraft(null) setCursorPoint(null) @@ -10004,7 +10077,16 @@ export function FloorplanPanel({ document.body.style.userSelect = 'none' document.body.style.cursor = shouldRotate ? getGuideRotateCursor(isDark) - : getGuideResizeCursor(corner, rotationSvg) + : getGuideResizeCursor( + getGuideResizeCursorAngle( + corner, + aspectRatio, + // Screen space includes the scene 's view rotation on top of + // the guide's own rotation. + rotationSvg + (floorplanSceneRotationDeg * Math.PI) / 180, + ), + isDark, + ) const nextDraft: GuideTransformDraft = { guideId: guide.id, @@ -10016,7 +10098,7 @@ export function FloorplanPanel({ guideTransformDraftRef.current = nextDraft setGuideTransformDraft(nextDraft) }, - [canInteractWithGuides, guideUi, handleGuideSelect, isDark], + [canInteractWithGuides, floorplanSceneRotationDeg, guideUi, handleGuideSelect, isDark], ) const handleGuideTranslateStart = useCallback( (guide: GuideNode, event: ReactPointerEvent) => { @@ -10667,6 +10749,7 @@ export function FloorplanPanel({ isDarkMode={isDark} isMacPlatform={isMacPlatform} rotationModifierPressed={rotationModifierPressed} + showScaleHint={!selectedGuide.scaleReference} /> )} {/* Floating Move / Duplicate / Delete buttons for registered @@ -10693,14 +10776,23 @@ export function FloorplanPanel({ {referenceScaleDraft && (
{referenceScaleDraft.start - ? 'Click the end of the known distance' - : 'Click the start of a known distance'} + ? 'Click the other end of that distance' + : 'Click one end of a distance you know — e.g. a dimension printed on the plan'}
)} {pendingReferenceScale && (
{ + // The focused length input keeps Escape from reaching the global + // handler — cancel the flow from here too. + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + guideEmitter.emit('guide:cancel-reference-scale') + } + }} onSubmit={(event) => { event.preventDefault() handleReferenceScaleConfirm() @@ -11114,6 +11206,7 @@ export function FloorplanPanel({ onCornerHoverChange={setHoveredGuideCorner} onCornerPointerDown={handleGuideCornerPointerDown} rotationModifierPressed={rotationModifierPressed} + sceneRotationDeg={floorplanSceneRotationDeg} showHandles={canInteractWithGuides && guideUi[selectedGuide.id]?.locked !== true} /> )} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d12e33f9..37fbf95e 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -165,6 +165,10 @@ export interface EditorProps { // Loading indicator (e.g. project fetching in community mode) isLoading?: boolean + // Fires when the full-screen scene loader shows/hides — lets hosts measure + // open-to-interactive time without reaching into internal loader state. + onLoaderChange?: (visible: boolean) => void + // Thumbnail onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void @@ -1093,6 +1097,7 @@ export default function Editor({ previewScene, isVersionPreviewMode = false, isLoading = false, + onLoaderChange, onThumbnailCapture, sidebarOverlay, viewerBanner, @@ -1229,6 +1234,10 @@ export default function Editor({ const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady + useEffect(() => { + onLoaderChange?.(showLoader) + }, [showLoader, onLoaderChange]) + const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) const wasFirstPersonModeRef = useRef(isFirstPersonMode) diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx index 6cf34650..5455095f 100644 --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -2,9 +2,14 @@ import { emitter } from '@pascal-app/core' import Image from 'next/image' +import useEditor from '../../../store/use-editor' import { ActionButton } from './action-button' export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { + // Orbit stays useful in 2D-only (it spins the synced floorplan view), but + // top view only tilts the hidden 3D camera — pointless without the canvas. + const is2dOnly = useEditor((s) => s.viewMode === '2d') + const goToTopView = () => { emitter.emit('camera-controls:top-view') } @@ -58,21 +63,23 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { )} {/* Top View */} - - Top View - + {!is2dOnly && ( + + Top View + + )} ) } diff --git a/packages/editor/src/components/ui/panels/reference-panel.tsx b/packages/editor/src/components/ui/panels/reference-panel.tsx index dec46f9a..069a5128 100644 --- a/packages/editor/src/components/ui/panels/reference-panel.tsx +++ b/packages/editor/src/components/ui/panels/reference-panel.tsx @@ -22,6 +22,7 @@ import { import { useCallback, useEffect, useRef, useState } from 'react' import { guideEmitter } from '../../../lib/guide-events' import { getGuideImageName } from '../../../lib/local-guide-image' +import { cn } from '../../../lib/utils' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { PanelSection } from '../controls/panel-section' @@ -60,6 +61,9 @@ export function ReferencePanel() { ? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined) : undefined, ) + const isScaleFlowActive = useEditor( + (s) => s.referenceScaleActiveGuideId !== null && s.referenceScaleActiveGuideId === node?.id, + ) const handleUpdate = useCallback( (updates: Partial) => { @@ -98,6 +102,9 @@ export function ReferencePanel() { } as Partial, ) setGuideScaleReferenceVisible(selectedReferenceId, true) + // The new image starts uncalibrated — drop the calibration auto-lock + // so it can be resized/rotated right away. + setGuideLocked(selectedReferenceId, false) } catch { setReplaceError('Could not replace that image.') } finally { @@ -123,6 +130,13 @@ export function ReferencePanel() { return } + // The scale line is drawn on the 2D plan — starting from a 3D-only view + // would arm the flow invisibly inside the hidden floorplan panel. + const editor = useEditor.getState() + if (editor.viewMode === '3d') { + editor.setViewMode('2d') + } + guideEmitter.emit('guide:set-reference-scale', { guideId: node.id }) }, [node]) @@ -233,33 +247,54 @@ export function ReferencePanel() {
- + {scaleStatus}
- - - - + {!node.scaleReference && ( +

+ {isScaleFlowActive + ? 'Click both ends of a known distance on the plan, then type its real length.' + : 'Draw a line over a known dimension on the plan, then type its real length to scale the image exactly.'} +

+ )} { - if (!node.scaleReference) return - setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible) - }} - /> - handleUpdate({ scaleReference: null } as Partial)} + className={cn( + !node.scaleReference && + !isScaleFlowActive && + 'border-primary/50 bg-primary/15 text-primary hover:bg-primary/25 active:bg-primary/25', + )} + label={ + isScaleFlowActive ? 'Cancel' : node.scaleReference ? 'Edit Scale' : 'Set Scale' + } + onClick={isScaleFlowActive ? handleCancelScale : handleStartScale} /> + + {node.scaleReference && ( + + setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)} + /> + { + handleUpdate({ scaleReference: null } as Partial) + // Calibrating auto-locked the guide; clearing the scale + // returns it to a freely-editable reference. + setGuideLocked(node.id, false) + }} + /> + + )}
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index e5b60275..0a7955b9 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { steppedRotation } from '../components/tools/item/placement-math' import { toggleDoorOpenState } from '../lib/door-interaction' +import { guideEmitter } from '../lib/guide-events' import { runRedo, runUndo } from '../lib/history' import { copySelectedNodesToEditorClipboard, @@ -13,6 +14,18 @@ import { toggleWindowOpenState } from '../lib/window-interaction' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor' import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope' +// References (guide/scan) are selected via `useEditor.selectedReferenceId`, not +// the viewer selection, so selection-based key arms (R/T rotate) need this +// separate lookup. Locked guides don't rotate, matching direct manipulation. +function getRotatableSelectedReference() { + const refId = useEditor.getState().selectedReferenceId + if (!refId) return null + const node = useScene.getState().nodes[refId as AnyNodeId] + if (!node || (node.type !== 'guide' && node.type !== 'scan')) return null + if (useEditor.getState().guideUi[refId]?.locked === true) return null + return node +} + // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. let _toolCancelConsumed = false @@ -141,6 +154,14 @@ export const useKeyboard = ({ if (e.key === 'Escape') { e.preventDefault() + + // An in-flight reference-scale measurement swallows Escape whole: + // cancel the flow but keep the reference selected and its panel open. + if (useEditor.getState().referenceScaleActiveGuideId) { + guideEmitter.emit('guide:cancel-reference-scale') + return + } + _toolCancelConsumed = false emitter.emit('tool:cancel') @@ -302,6 +323,22 @@ export const useKeyboard = ({ // (`isPlacingOpening`): the placement tool owns R then (flip the draft // before commit), and the user can have a node selected at the same // time — without this guard both would fire (double flip + sfx). + // + // References (guide/scan) live in `selectedReferenceId`, not the viewer + // selection — check them first, like the Delete arm below. + const rotatableReference = getRotatableSelectedReference() + if (rotatableReference) { + e.preventDefault() + useScene.getState().updateNode(rotatableReference.id, { + rotation: [ + rotatableReference.rotation[0], + steppedRotation(rotatableReference.rotation[1], 1), + rotatableReference.rotation[2], + ], + }) + sfxEmitter.emit('sfx:item-rotate') + return + } const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] @@ -357,6 +394,19 @@ export const useKeyboard = ({ } } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) { // Rotate selected node counter-clockwise + const rotatableReference = getRotatableSelectedReference() + if (rotatableReference) { + e.preventDefault() + useScene.getState().updateNode(rotatableReference.id, { + rotation: [ + rotatableReference.rotation[0], + steppedRotation(rotatableReference.rotation[1], -1), + rotatableReference.rotation[2], + ], + }) + sfxEmitter.emit('sfx:item-rotate') + return + } const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] diff --git a/packages/editor/src/lib/floorplan/geometry.ts b/packages/editor/src/lib/floorplan/geometry.ts index 565dec60..0f0f50e2 100644 --- a/packages/editor/src/lib/floorplan/geometry.ts +++ b/packages/editor/src/lib/floorplan/geometry.ts @@ -6,7 +6,13 @@ import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types' // `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF // export mirrors the aligned-to-north case (user offset 0) so an export points // the same way as the app's north-aligned view. -export const FLOORPLAN_VIEW_ROTATION_DEG = 90 +// +// North is world −Z: with a 0 baseline a rotation-0 reference image (top = +// −Z) reads upright in the north-aligned view, and "align north" maps to a +// 3D camera azimuth of 0. The old 90° baseline made north world −X, so +// north-up floorplan scans displayed sideways whenever the compass claimed +// the view was aligned. +export const FLOORPLAN_VIEW_ROTATION_DEG = 0 export function clampPlanValue(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) diff --git a/packages/editor/src/lib/world-grid-snap.ts b/packages/editor/src/lib/world-grid-snap.ts index acda5a95..1e255a6c 100644 --- a/packages/editor/src/lib/world-grid-snap.ts +++ b/packages/editor/src/lib/world-grid-snap.ts @@ -114,10 +114,10 @@ export function projectAlignmentGuidesWorldToActiveBuildingLocal( /** * Baseline rotation the floor-plan view applies on top of the building - * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx — + * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 0` in lib/floorplan/geometry.ts — * the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`. */ -const FLOORPLAN_VIEW_ROTATION_RAD = Math.PI / 2 +const FLOORPLAN_VIEW_ROTATION_RAD = 0 function rotateAnchorsBy( anchors: readonly AlignmentAnchor[], diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index ec9a9eab..9891a6f9 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -340,6 +340,11 @@ type EditorState = { setCanFindNode: (canFind: boolean) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void + // Guide id with an in-flight reference-scale measurement (line drawing or + // length dialog). Owned by the floorplan panel; mirrored here so the + // reference panel can flip its Set Scale button into a Cancel. + referenceScaleActiveGuideId: string | null + setReferenceScaleActiveGuideId: (id: string | null) => void guideUi: Record setGuideLocked: (guideId: string, locked: boolean) => void setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void @@ -987,6 +992,8 @@ const useEditor = create()( setCanFindNode: (canFind) => set({ canFindNode: canFind }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + referenceScaleActiveGuideId: null, + setReferenceScaleActiveGuideId: (id) => set({ referenceScaleActiveGuideId: id }), guideUi: {}, setGuideLocked: (guideId, locked) => set((state) => ({ diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 4c8af92e..2b9aac96 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -251,7 +251,8 @@ plus the element's own height; slabs additionally carry an absolute **Heads-up when you compute coordinates outside the editor.** Pascal's viewports apply their own rotations on top of the world axes: the 2-D plan -panel wraps its content in a 90° rotation (`FLOORPLAN_VIEW_ROTATION_DEG`), and +panel rotates its content by the user's view rotation (north-aligned = 0°, +`FLOORPLAN_VIEW_ROTATION_DEG` baseline, north = world −Z), and the 3-D "top-down" snap preserves the camera's current azimuth, so when invoked from the iso default position, world and screen axes are offset by ~45° until you orbit to an axis-aligned view. So a layout authored as if