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 }
}
@@ -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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<g transform="rotate(${degrees} 12 12)">
<path d="${arrowPath}" stroke="${outlineColor}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="${arrowPath}" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>
`.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 <g>, 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"
/>
</div>
{showScaleHint && (
<div className="flex items-center gap-1.5 opacity-40">
<span className="font-medium text-[11px] lowercase leading-none">set scale</span>
<Ruler aria-hidden="true" className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<span className="font-medium text-[11px] lowercase leading-none">panel</span>
</div>
)}
</div>
</div>
)
@@ -5167,6 +5218,18 @@ export function FloorplanPanel({
const [pendingReferenceScale, setPendingReferenceScale] = useState<PendingReferenceScale | null>(
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<ReferenceScaleUnit>(
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 <g>'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<SVGRectElement>) => {
@@ -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 && (
<div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm">
{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'}
</div>
)}
{pendingReferenceScale && (
<form
className="absolute top-1/2 left-1/2 z-40 w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background/95 p-3.5 text-foreground shadow-2xl backdrop-blur-md"
onKeyDown={(event) => {
// 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}
/>
)}
@@ -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)
@@ -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,6 +63,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
)}
{/* Top View */}
{!is2dOnly && (
<ActionButton
className="group hover:bg-white/5"
label="Top View"
@@ -73,6 +79,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
width={28}
/>
</ActionButton>
)}
</div>
)
}
@@ -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<ReferenceNode>) => {
@@ -98,6 +102,9 @@ export function ReferencePanel() {
} as Partial<GuideNode>,
)
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() {
<PanelSection title="Reference Scale">
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2.5 py-2 text-sm">
<Ruler className="h-4 w-4 shrink-0 text-primary" />
<Ruler
className={cn(
'h-4 w-4 shrink-0',
node.scaleReference ? 'text-primary' : 'text-amber-600 dark:text-amber-400',
)}
/>
<span className="truncate text-muted-foreground">{scaleStatus}</span>
</div>
<ActionGroup>
<ActionButton
label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
onClick={handleStartScale}
/>
<ActionButton label="Cancel" onClick={handleCancelScale} />
</ActionGroup>
{!node.scaleReference && (
<p className="px-0.5 text-muted-foreground text-xs leading-snug">
{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.'}
</p>
)}
<ActionGroup>
<ActionButton
disabled={!node.scaleReference}
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
onClick={() => {
if (!node.scaleReference) return
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
}}
/>
<ActionButton
disabled={!node.scaleReference}
label="Clear Scale"
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
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}
/>
</ActionGroup>
{node.scaleReference && (
<ActionGroup>
<ActionButton
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
onClick={() => setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)}
/>
<ActionButton
label="Clear Scale"
onClick={() => {
handleUpdate({ scaleReference: null } as Partial<GuideNode>)
// Calibrating auto-locked the guide; clearing the scale
// returns it to a freely-editable reference.
setGuideLocked(node.id, false)
}}
/>
</ActionGroup>
)}
</PanelSection>
<PanelSection title="Quick Actions">
+50
View File
@@ -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]!]
@@ -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)
+2 -2
View File
@@ -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[],
+7
View File
@@ -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<string, GuideUiState>
setGuideLocked: (guideId: string, locked: boolean) => void
setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void
@@ -987,6 +992,8 @@ const useEditor = create<EditorState>()(
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) => ({
+2 -1
View File
@@ -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