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(strippedChildRefs).toBe(0)
|
||||||
expect(nodes.wall_a).toBe(input.wall_a)
|
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
|
// 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
|
// saved scenes still load. Known kinds of damage:
|
||||||
// capture wall-merge before it was fixed:
|
|
||||||
//
|
//
|
||||||
// 1. A `children` array containing a non-string entry. The merge re-attached a
|
// 1. A `children` array containing a non-string entry. The capture wall-merge
|
||||||
// wall-hosted item without minting an id, so `undefined` was pushed into the
|
// re-attached a wall-hosted item without minting an id, so `undefined` was
|
||||||
// wall's children — which serializes to `[null]`. The wall schema rejects
|
// pushed into the wall's children — which serializes to `[null]`. The wall
|
||||||
// `null` children, so the whole scene fails to load.
|
// 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
|
// 2. A zero-length wall (start === end). It renders nothing, but lingers as a
|
||||||
// junk node and is a foot-gun for snapping/mitering.
|
// 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
|
// All are also prevented at the source now; this is the load-time safety net
|
||||||
// miter limit); this is the load-time safety net for already-saved scenes.
|
// for already-saved scenes.
|
||||||
|
|
||||||
const ZERO_LENGTH_EPS = 1e-6
|
const ZERO_LENGTH_EPS = 1e-6
|
||||||
|
|
||||||
@@ -20,6 +25,11 @@ export interface HealSceneResult {
|
|||||||
droppedWallIds: string[]
|
droppedWallIds: string[]
|
||||||
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
|
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
|
||||||
strippedChildRefs: number
|
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] } {
|
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)
|
const dropped = new Set(droppedWallIds)
|
||||||
let strippedChildRefs = 0
|
let strippedChildRefs = 0
|
||||||
|
let strippedStaleChildRefs = 0
|
||||||
|
|
||||||
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug)
|
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]`
|
||||||
// and references to walls we just removed.
|
// 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> = {}
|
const nodes: Record<string, unknown> = {}
|
||||||
for (const [id, node] of Object.entries(kept)) {
|
for (const [id, node] of Object.entries(kept)) {
|
||||||
const children = (node as { children?: unknown })?.children
|
const children = (node as { children?: unknown })?.children
|
||||||
if (Array.isArray(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) {
|
if (cleaned.length !== children.length) {
|
||||||
strippedChildRefs += children.length - cleaned.length
|
|
||||||
nodes[id] = { ...(node as Record<string, unknown>), children: cleaned }
|
nodes[id] = { ...(node as Record<string, unknown>), children: cleaned }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -79,5 +107,5 @@ export function healSceneNodes(input: Record<string, unknown>): HealSceneResult
|
|||||||
nodes[id] = node
|
nodes[id] = node
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, droppedWallIds, strippedChildRefs }
|
return { nodes, droppedWallIds, strippedChildRefs, strippedStaleChildRefs }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import {
|
|||||||
buildFloorplanItemEntry,
|
buildFloorplanItemEntry,
|
||||||
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
||||||
collectLevelDescendants,
|
collectLevelDescendants,
|
||||||
|
FLOORPLAN_VIEW_ROTATION_DEG,
|
||||||
floorplanLocalToWorldPoint,
|
floorplanLocalToWorldPoint,
|
||||||
getFloorplanWall as getSharedFloorplanWall,
|
getFloorplanWall as getSharedFloorplanWall,
|
||||||
rotatePlanVector as rotateSharedPlanVector,
|
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_X = 92
|
||||||
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
|
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
|
||||||
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15
|
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15
|
||||||
const FLOORPLAN_VIEW_ROTATION_DEG = 90
|
|
||||||
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
|
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
|
||||||
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
|
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
|
||||||
const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005
|
const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005
|
||||||
@@ -1125,9 +1125,28 @@ function getResizeCursorForAngle(angle: number) {
|
|||||||
return 'nesw-resize'
|
return 'nesw-resize'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGuideResizeCursor(corner: GuideCorner, rotationSvg: number) {
|
function getGuideResizeCursorAngle(corner: GuideCorner, aspectRatio: number, rotationSvg: number) {
|
||||||
const signs = guideCornerSigns[corner]
|
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) {
|
function buildCursorUrl(svgMarkup: string, hotspotX: number, hotspotY: number, fallback: string) {
|
||||||
@@ -3021,6 +3040,7 @@ function useGuideImageDimensions(url: string | null) {
|
|||||||
function FloorplanGuideImage({
|
function FloorplanGuideImage({
|
||||||
guide,
|
guide,
|
||||||
isInteractive,
|
isInteractive,
|
||||||
|
isLocked,
|
||||||
isSelected,
|
isSelected,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
onGuideSelect,
|
onGuideSelect,
|
||||||
@@ -3028,6 +3048,10 @@ function FloorplanGuideImage({
|
|||||||
}: {
|
}: {
|
||||||
guide: GuideNode
|
guide: GuideNode
|
||||||
isInteractive: boolean
|
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
|
isSelected: boolean
|
||||||
activeInteractionMode: GuideInteractionMode | null
|
activeInteractionMode: GuideInteractionMode | null
|
||||||
onGuideSelect: (guideId: GuideNode['id']) => void
|
onGuideSelect: (guideId: GuideNode['id']) => void
|
||||||
@@ -3063,15 +3087,18 @@ function FloorplanGuideImage({
|
|||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
if (event.button === 0) {
|
if (event.button === 0) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (isSelected) {
|
if (isSelected && !isLocked) {
|
||||||
onGuideTranslateStart(guide, event)
|
onGuideTranslateStart(guide, event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
pointerEvents="all"
|
pointerEvents="all"
|
||||||
style={{
|
style={{
|
||||||
cursor:
|
cursor: isLocked
|
||||||
isSelected && activeInteractionMode === 'translate'
|
? isSelected
|
||||||
|
? 'default'
|
||||||
|
: 'pointer'
|
||||||
|
: isSelected && activeInteractionMode === 'translate'
|
||||||
? 'grabbing'
|
? 'grabbing'
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'grab'
|
? 'grab'
|
||||||
@@ -3275,7 +3302,8 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({
|
|||||||
activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null
|
activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null
|
||||||
}
|
}
|
||||||
guide={guide}
|
guide={guide}
|
||||||
isInteractive={isInteractive && guideUi[guide.id]?.locked !== true}
|
isInteractive={isInteractive}
|
||||||
|
isLocked={guideUi[guide.id]?.locked === true}
|
||||||
isSelected={selectedGuideId === guide.id}
|
isSelected={selectedGuideId === guide.id}
|
||||||
key={guide.id}
|
key={guide.id}
|
||||||
onGuideSelect={onGuideSelect}
|
onGuideSelect={onGuideSelect}
|
||||||
@@ -3460,6 +3488,7 @@ function FloorplanGuideSelectionOverlay({
|
|||||||
guide,
|
guide,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
rotationModifierPressed,
|
rotationModifierPressed,
|
||||||
|
sceneRotationDeg,
|
||||||
showHandles,
|
showHandles,
|
||||||
onCornerHoverChange,
|
onCornerHoverChange,
|
||||||
onCornerPointerDown,
|
onCornerPointerDown,
|
||||||
@@ -3467,6 +3496,7 @@ function FloorplanGuideSelectionOverlay({
|
|||||||
guide: GuideNode | null
|
guide: GuideNode | null
|
||||||
isDarkMode: boolean
|
isDarkMode: boolean
|
||||||
rotationModifierPressed: boolean
|
rotationModifierPressed: boolean
|
||||||
|
sceneRotationDeg: number
|
||||||
showHandles: boolean
|
showHandles: boolean
|
||||||
onCornerHoverChange: (corner: GuideCorner | null) => void
|
onCornerHoverChange: (corner: GuideCorner | null) => void
|
||||||
onCornerPointerDown: (
|
onCornerPointerDown: (
|
||||||
@@ -3546,7 +3576,18 @@ function FloorplanGuideSelectionOverlay({
|
|||||||
style={{
|
style={{
|
||||||
cursor: rotationModifierPressed
|
cursor: rotationModifierPressed
|
||||||
? getGuideRotateCursor(isDarkMode)
|
? 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"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
@@ -3563,11 +3604,13 @@ function FloorplanGuideHandleHint({
|
|||||||
isDarkMode,
|
isDarkMode,
|
||||||
isMacPlatform,
|
isMacPlatform,
|
||||||
rotationModifierPressed,
|
rotationModifierPressed,
|
||||||
|
showScaleHint,
|
||||||
}: {
|
}: {
|
||||||
anchor: GuideHandleHintAnchor | null
|
anchor: GuideHandleHintAnchor | null
|
||||||
isDarkMode: boolean
|
isDarkMode: boolean
|
||||||
isMacPlatform: boolean
|
isMacPlatform: boolean
|
||||||
rotationModifierPressed: boolean
|
rotationModifierPressed: boolean
|
||||||
|
showScaleHint: boolean
|
||||||
}) {
|
}) {
|
||||||
if (!anchor) {
|
if (!anchor) {
|
||||||
return null
|
return null
|
||||||
@@ -3622,6 +3665,14 @@ function FloorplanGuideHandleHint({
|
|||||||
icon="ph:mouse-left-click-fill"
|
icon="ph:mouse-left-click-fill"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -5167,6 +5218,18 @@ export function FloorplanPanel({
|
|||||||
const [pendingReferenceScale, setPendingReferenceScale] = useState<PendingReferenceScale | null>(
|
const [pendingReferenceScale, setPendingReferenceScale] = useState<PendingReferenceScale | null>(
|
||||||
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 [referenceScaleValue, setReferenceScaleValue] = useState('1')
|
||||||
const [referenceScaleUnit, setReferenceScaleUnit] = useState<ReferenceScaleUnit>(
|
const [referenceScaleUnit, setReferenceScaleUnit] = useState<ReferenceScaleUnit>(
|
||||||
unit === 'imperial' ? 'feet' : 'meters',
|
unit === 'imperial' ? 'feet' : 'meters',
|
||||||
@@ -9504,7 +9567,17 @@ export function FloorplanPanel({
|
|||||||
end: planPoint,
|
end: planPoint,
|
||||||
measuredLengthUnits,
|
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')
|
setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters')
|
||||||
setReferenceScaleDraft(null)
|
setReferenceScaleDraft(null)
|
||||||
setCursorPoint(null)
|
setCursorPoint(null)
|
||||||
@@ -10004,7 +10077,16 @@ export function FloorplanPanel({
|
|||||||
document.body.style.userSelect = 'none'
|
document.body.style.userSelect = 'none'
|
||||||
document.body.style.cursor = shouldRotate
|
document.body.style.cursor = shouldRotate
|
||||||
? getGuideRotateCursor(isDark)
|
? 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 = {
|
const nextDraft: GuideTransformDraft = {
|
||||||
guideId: guide.id,
|
guideId: guide.id,
|
||||||
@@ -10016,7 +10098,7 @@ export function FloorplanPanel({
|
|||||||
guideTransformDraftRef.current = nextDraft
|
guideTransformDraftRef.current = nextDraft
|
||||||
setGuideTransformDraft(nextDraft)
|
setGuideTransformDraft(nextDraft)
|
||||||
},
|
},
|
||||||
[canInteractWithGuides, guideUi, handleGuideSelect, isDark],
|
[canInteractWithGuides, floorplanSceneRotationDeg, guideUi, handleGuideSelect, isDark],
|
||||||
)
|
)
|
||||||
const handleGuideTranslateStart = useCallback(
|
const handleGuideTranslateStart = useCallback(
|
||||||
(guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => {
|
(guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => {
|
||||||
@@ -10667,6 +10749,7 @@ export function FloorplanPanel({
|
|||||||
isDarkMode={isDark}
|
isDarkMode={isDark}
|
||||||
isMacPlatform={isMacPlatform}
|
isMacPlatform={isMacPlatform}
|
||||||
rotationModifierPressed={rotationModifierPressed}
|
rotationModifierPressed={rotationModifierPressed}
|
||||||
|
showScaleHint={!selectedGuide.scaleReference}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Floating Move / Duplicate / Delete buttons for registered
|
{/* Floating Move / Duplicate / Delete buttons for registered
|
||||||
@@ -10693,14 +10776,23 @@ export function FloorplanPanel({
|
|||||||
{referenceScaleDraft && (
|
{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">
|
<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
|
{referenceScaleDraft.start
|
||||||
? 'Click the end of the known distance'
|
? 'Click the other end of that distance'
|
||||||
: 'Click the start of a known distance'}
|
: 'Click one end of a distance you know — e.g. a dimension printed on the plan'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingReferenceScale && (
|
{pendingReferenceScale && (
|
||||||
<form
|
<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"
|
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) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleReferenceScaleConfirm()
|
handleReferenceScaleConfirm()
|
||||||
@@ -11114,6 +11206,7 @@ export function FloorplanPanel({
|
|||||||
onCornerHoverChange={setHoveredGuideCorner}
|
onCornerHoverChange={setHoveredGuideCorner}
|
||||||
onCornerPointerDown={handleGuideCornerPointerDown}
|
onCornerPointerDown={handleGuideCornerPointerDown}
|
||||||
rotationModifierPressed={rotationModifierPressed}
|
rotationModifierPressed={rotationModifierPressed}
|
||||||
|
sceneRotationDeg={floorplanSceneRotationDeg}
|
||||||
showHandles={canInteractWithGuides && guideUi[selectedGuide.id]?.locked !== true}
|
showHandles={canInteractWithGuides && guideUi[selectedGuide.id]?.locked !== true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ export interface EditorProps {
|
|||||||
// Loading indicator (e.g. project fetching in community mode)
|
// Loading indicator (e.g. project fetching in community mode)
|
||||||
isLoading?: boolean
|
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
|
// Thumbnail
|
||||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||||
|
|
||||||
@@ -1093,6 +1097,7 @@ export default function Editor({
|
|||||||
previewScene,
|
previewScene,
|
||||||
isVersionPreviewMode = false,
|
isVersionPreviewMode = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
onLoaderChange,
|
||||||
onThumbnailCapture,
|
onThumbnailCapture,
|
||||||
sidebarOverlay,
|
sidebarOverlay,
|
||||||
viewerBanner,
|
viewerBanner,
|
||||||
@@ -1229,6 +1234,10 @@ export default function Editor({
|
|||||||
|
|
||||||
const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady
|
const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLoaderChange?.(showLoader)
|
||||||
|
}, [showLoader, onLoaderChange])
|
||||||
|
|
||||||
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
|
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
|
||||||
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
|
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
import { emitter } from '@pascal-app/core'
|
import { emitter } from '@pascal-app/core'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
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 = () => {
|
const goToTopView = () => {
|
||||||
emitter.emit('camera-controls:top-view')
|
emitter.emit('camera-controls:top-view')
|
||||||
}
|
}
|
||||||
@@ -58,6 +63,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top View */}
|
{/* Top View */}
|
||||||
|
{!is2dOnly && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className="group hover:bg-white/5"
|
className="group hover:bg-white/5"
|
||||||
label="Top View"
|
label="Top View"
|
||||||
@@ -73,6 +79,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
|||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { guideEmitter } from '../../../lib/guide-events'
|
import { guideEmitter } from '../../../lib/guide-events'
|
||||||
import { getGuideImageName } from '../../../lib/local-guide-image'
|
import { getGuideImageName } from '../../../lib/local-guide-image'
|
||||||
|
import { cn } from '../../../lib/utils'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
@@ -60,6 +61,9 @@ export function ReferencePanel() {
|
|||||||
? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
|
? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
const isScaleFlowActive = useEditor(
|
||||||
|
(s) => s.referenceScaleActiveGuideId !== null && s.referenceScaleActiveGuideId === node?.id,
|
||||||
|
)
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
(updates: Partial<ReferenceNode>) => {
|
(updates: Partial<ReferenceNode>) => {
|
||||||
@@ -98,6 +102,9 @@ export function ReferencePanel() {
|
|||||||
} as Partial<GuideNode>,
|
} as Partial<GuideNode>,
|
||||||
)
|
)
|
||||||
setGuideScaleReferenceVisible(selectedReferenceId, true)
|
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 {
|
} catch {
|
||||||
setReplaceError('Could not replace that image.')
|
setReplaceError('Could not replace that image.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -123,6 +130,13 @@ export function ReferencePanel() {
|
|||||||
return
|
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 })
|
guideEmitter.emit('guide:set-reference-scale', { guideId: node.id })
|
||||||
}, [node])
|
}, [node])
|
||||||
|
|
||||||
@@ -233,33 +247,54 @@ export function ReferencePanel() {
|
|||||||
|
|
||||||
<PanelSection title="Reference Scale">
|
<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">
|
<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>
|
<span className="truncate text-muted-foreground">{scaleStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActionGroup>
|
{!node.scaleReference && (
|
||||||
<ActionButton
|
<p className="px-0.5 text-muted-foreground text-xs leading-snug">
|
||||||
label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
|
{isScaleFlowActive
|
||||||
onClick={handleStartScale}
|
? '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.'}
|
||||||
<ActionButton label="Cancel" onClick={handleCancelScale} />
|
</p>
|
||||||
</ActionGroup>
|
)}
|
||||||
|
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
disabled={!node.scaleReference}
|
className={cn(
|
||||||
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
|
!node.scaleReference &&
|
||||||
onClick={() => {
|
!isScaleFlowActive &&
|
||||||
if (!node.scaleReference) return
|
'border-primary/50 bg-primary/15 text-primary hover:bg-primary/25 active:bg-primary/25',
|
||||||
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
|
)}
|
||||||
}}
|
label={
|
||||||
/>
|
isScaleFlowActive ? 'Cancel' : node.scaleReference ? 'Edit Scale' : 'Set Scale'
|
||||||
<ActionButton
|
}
|
||||||
disabled={!node.scaleReference}
|
onClick={isScaleFlowActive ? handleCancelScale : handleStartScale}
|
||||||
label="Clear Scale"
|
|
||||||
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
|
|
||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</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>
|
||||||
|
|
||||||
<PanelSection title="Quick Actions">
|
<PanelSection title="Quick Actions">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { steppedRotation } from '../components/tools/item/placement-math'
|
import { steppedRotation } from '../components/tools/item/placement-math'
|
||||||
import { toggleDoorOpenState } from '../lib/door-interaction'
|
import { toggleDoorOpenState } from '../lib/door-interaction'
|
||||||
|
import { guideEmitter } from '../lib/guide-events'
|
||||||
import { runRedo, runUndo } from '../lib/history'
|
import { runRedo, runUndo } from '../lib/history'
|
||||||
import {
|
import {
|
||||||
copySelectedNodesToEditorClipboard,
|
copySelectedNodesToEditorClipboard,
|
||||||
@@ -13,6 +14,18 @@ import { toggleWindowOpenState } from '../lib/window-interaction'
|
|||||||
import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
|
import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
|
||||||
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
|
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,
|
// 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.
|
// so that the global Escape handler knows not to also switch to select mode.
|
||||||
let _toolCancelConsumed = false
|
let _toolCancelConsumed = false
|
||||||
@@ -141,6 +154,14 @@ export const useKeyboard = ({
|
|||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
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
|
_toolCancelConsumed = false
|
||||||
emitter.emit('tool:cancel')
|
emitter.emit('tool:cancel')
|
||||||
|
|
||||||
@@ -302,6 +323,22 @@ export const useKeyboard = ({
|
|||||||
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
|
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
|
||||||
// before commit), and the user can have a node selected at the same
|
// before commit), and the user can have a node selected at the same
|
||||||
// time — without this guard both would fire (double flip + sfx).
|
// 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[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedNodeIds.length === 1) {
|
if (selectedNodeIds.length === 1) {
|
||||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||||
@@ -357,6 +394,19 @@ export const useKeyboard = ({
|
|||||||
}
|
}
|
||||||
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
|
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
|
||||||
// Rotate selected node counter-clockwise
|
// 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[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedNodeIds.length === 1) {
|
if (selectedNodeIds.length === 1) {
|
||||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
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
|
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF
|
||||||
// export mirrors the aligned-to-north case (user offset 0) so an export points
|
// export mirrors the aligned-to-north case (user offset 0) so an export points
|
||||||
// the same way as the app's north-aligned view.
|
// 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) {
|
export function clampPlanValue(value: number, min: number, max: number) {
|
||||||
return Math.min(Math.max(value, min), max)
|
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
|
* 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`.
|
* 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(
|
function rotateAnchorsBy(
|
||||||
anchors: readonly AlignmentAnchor[],
|
anchors: readonly AlignmentAnchor[],
|
||||||
|
|||||||
@@ -340,6 +340,11 @@ type EditorState = {
|
|||||||
setCanFindNode: (canFind: boolean) => void
|
setCanFindNode: (canFind: boolean) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
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>
|
guideUi: Record<string, GuideUiState>
|
||||||
setGuideLocked: (guideId: string, locked: boolean) => void
|
setGuideLocked: (guideId: string, locked: boolean) => void
|
||||||
setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void
|
setGuideScaleReferenceVisible: (guideId: string, visible: boolean) => void
|
||||||
@@ -987,6 +992,8 @@ const useEditor = create<EditorState>()(
|
|||||||
setCanFindNode: (canFind) => set({ canFindNode: canFind }),
|
setCanFindNode: (canFind) => set({ canFindNode: canFind }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
|
referenceScaleActiveGuideId: null,
|
||||||
|
setReferenceScaleActiveGuideId: (id) => set({ referenceScaleActiveGuideId: id }),
|
||||||
guideUi: {},
|
guideUi: {},
|
||||||
setGuideLocked: (guideId, locked) =>
|
setGuideLocked: (guideId, locked) =>
|
||||||
set((state) => ({
|
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
|
**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
|
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
|
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
|
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
|
you orbit to an axis-aligned view. So a layout authored as if
|
||||||
|
|||||||
Reference in New Issue
Block a user