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 }
|
||||
}
|
||||
|
||||
@@ -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,21 +63,23 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
||||
)}
|
||||
|
||||
{/* Top View */}
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Top View"
|
||||
onClick={goToTopView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Top View"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/topview.webp"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
{!is2dOnly && (
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Top View"
|
||||
onClick={goToTopView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Top View"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/topview.webp"
|
||||
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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user