@pascal-app/editor 1.0.0-beta.3 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. package/src/store/use-session-groups.ts +103 -0
@@ -0,0 +1,140 @@
1
+ 'use client'
2
+
3
+ import { type CameraPose, emitter } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import type { CameraControlsImpl } from '@react-three/drei'
6
+ import { useThree } from '@react-three/fiber'
7
+ import { type RefObject, useCallback, useEffect, useRef } from 'react'
8
+ import type { Camera, OrthographicCamera, PerspectiveCamera } from 'three'
9
+ import { Vector3 } from 'three'
10
+ import { normalizeCameraPose } from '../../lib/camera-pose'
11
+ import { publishCameraPose } from '../../store/camera-pose-store'
12
+
13
+ const position = new Vector3()
14
+ const target = new Vector3()
15
+
16
+ function isPerspectiveCamera(camera: Camera): camera is PerspectiveCamera {
17
+ return (camera as PerspectiveCamera).isPerspectiveCamera === true
18
+ }
19
+
20
+ function isOrthographicCamera(camera: Camera): camera is OrthographicCamera {
21
+ return (camera as OrthographicCamera).isOrthographicCamera === true
22
+ }
23
+
24
+ function cameraViewWidth(camera: Camera, distance: number, width: number, height: number) {
25
+ if (isPerspectiveCamera(camera)) {
26
+ const fov = (camera.getEffectiveFOV() * Math.PI) / 180
27
+ return Math.max(0.001, 2 * distance * Math.tan(fov / 2) * (width / Math.max(height, 1)))
28
+ }
29
+ if (isOrthographicCamera(camera)) {
30
+ return Math.max(0.001, (camera.right - camera.left) / camera.zoom)
31
+ }
32
+ return Math.max(0.001, distance)
33
+ }
34
+
35
+ function applyViewWidth(
36
+ controls: CameraControlsImpl,
37
+ camera: Camera,
38
+ viewWidth: number,
39
+ width: number,
40
+ height: number,
41
+ ) {
42
+ if (isPerspectiveCamera(camera)) {
43
+ const fov = (camera.getEffectiveFOV() * Math.PI) / 180
44
+ const denominator = 2 * Math.tan(fov / 2) * (width / Math.max(height, 1))
45
+ if (denominator > 0) controls.dollyTo(Math.max(0.001, viewWidth / denominator), false)
46
+ return
47
+ }
48
+ if (isOrthographicCamera(camera) && viewWidth > 0) {
49
+ controls.zoomTo(Math.max(0.001, (camera.right - camera.left) / viewWidth), false)
50
+ }
51
+ }
52
+
53
+ export function useViewerCameraNavigationSync(controls: RefObject<CameraControlsImpl | null>) {
54
+ const camera = useThree((state) => state.camera)
55
+ const size = useThree((state) => state.size)
56
+ const suppressPublish = useRef(false)
57
+ const pendingPose = useRef<CameraPose | null>(null)
58
+
59
+ const publishCurrentPose = useCallback(() => {
60
+ const control = controls.current
61
+ if (!control || suppressPublish.current) return
62
+ control.getPosition(position, false)
63
+ control.getTarget(target, false)
64
+ const projection = isPerspectiveCamera(camera)
65
+ ? 'perspective'
66
+ : isOrthographicCamera(camera)
67
+ ? 'orthographic'
68
+ : null
69
+ if (!projection) return
70
+ const pose = normalizeCameraPose({
71
+ position: [position.x, position.y, position.z],
72
+ target: [target.x, target.y, target.z],
73
+ projection,
74
+ viewWidth: cameraViewWidth(camera, position.distanceTo(target), size.width, size.height),
75
+ ...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
76
+ })
77
+ if (pose) publishCameraPose(pose)
78
+ }, [camera, controls, size.height, size.width])
79
+
80
+ const applyPendingPose = useCallback(() => {
81
+ const control = controls.current
82
+ const pose = pendingPose.current
83
+ if (!(control && pose)) return
84
+ const projectionMatches =
85
+ (pose.projection === 'perspective' && isPerspectiveCamera(camera)) ||
86
+ (pose.projection === 'orthographic' && isOrthographicCamera(camera))
87
+ if (!projectionMatches) return
88
+
89
+ pendingPose.current = null
90
+ suppressPublish.current = true
91
+ try {
92
+ if (pose.fov !== undefined && isPerspectiveCamera(camera)) {
93
+ camera.fov = pose.fov
94
+ camera.updateProjectionMatrix()
95
+ }
96
+ control.setLookAt(
97
+ pose.position[0],
98
+ pose.position[1],
99
+ pose.position[2],
100
+ pose.target[0],
101
+ pose.target[1],
102
+ pose.target[2],
103
+ false,
104
+ )
105
+ if (pose.viewWidth !== undefined) {
106
+ applyViewWidth(control, camera, pose.viewWidth, size.width, size.height)
107
+ }
108
+ control.update(0)
109
+ } finally {
110
+ suppressPublish.current = false
111
+ publishCurrentPose()
112
+ }
113
+ }, [camera, controls, publishCurrentPose, size.height, size.width])
114
+
115
+ useEffect(() => {
116
+ applyPendingPose()
117
+ }, [applyPendingPose])
118
+
119
+ useEffect(() => {
120
+ const applyPose = (value: CameraPose) => {
121
+ const pose = normalizeCameraPose(value)
122
+ if (!pose) return
123
+ pendingPose.current = pose
124
+ if (useViewer.getState().cameraMode !== pose.projection) {
125
+ useViewer.getState().setCameraMode(pose.projection)
126
+ return
127
+ }
128
+ applyPendingPose()
129
+ }
130
+ emitter.on('camera-controls:apply-pose', applyPose)
131
+ return () => emitter.off('camera-controls:apply-pose', applyPose)
132
+ }, [applyPendingPose])
133
+
134
+ useEffect(() => {
135
+ const frame = requestAnimationFrame(publishCurrentPose)
136
+ return () => cancelAnimationFrame(frame)
137
+ }, [publishCurrentPose])
138
+
139
+ return publishCurrentPose
140
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ normalizeViewerStageModes,
4
+ resolveMobileViewerStageMode,
5
+ resolveViewerStageMode,
6
+ viewerStageIncludes3D,
7
+ } from './viewer-stage-modes'
8
+
9
+ describe('viewer stage modes', () => {
10
+ test('supports every ordered combination without duplicates', () => {
11
+ expect(normalizeViewerStageModes(['split', '3d', 'split'])).toEqual(['3d', 'split'])
12
+ expect(normalizeViewerStageModes(['2d'])).toEqual(['2d'])
13
+ expect(normalizeViewerStageModes([])).toEqual(['3d'])
14
+ })
15
+
16
+ test('reuses normalized combinations across inline prop arrays', () => {
17
+ expect(normalizeViewerStageModes(['split', '3d'])).toBe(
18
+ normalizeViewerStageModes(['3d', 'split']),
19
+ )
20
+ })
21
+
22
+ test('falls back to the first enabled mode', () => {
23
+ expect(resolveViewerStageMode('split', ['3d', '2d'])).toBe('3d')
24
+ expect(resolveViewerStageMode(undefined, ['2d', 'split'])).toBe('2d')
25
+ })
26
+
27
+ test('uses an enabled single-pane mode on mobile but preserves split-only embeds', () => {
28
+ expect(resolveMobileViewerStageMode('split', ['3d', 'split'])).toBe('3d')
29
+ expect(resolveMobileViewerStageMode('split', ['2d', 'split'])).toBe('2d')
30
+ expect(resolveMobileViewerStageMode('split', ['split'])).toBe('split')
31
+ })
32
+
33
+ test('does not require a GPU canvas for a 2D-only embed', () => {
34
+ expect(viewerStageIncludes3D(['2d'])).toBe(false)
35
+ expect(viewerStageIncludes3D(['split'])).toBe(true)
36
+ expect(viewerStageIncludes3D(['3d', '2d'])).toBe(true)
37
+ })
38
+ })
@@ -0,0 +1,49 @@
1
+ export const VIEWER_STAGE_MODES = ['3d', '2d', 'split'] as const
2
+
3
+ export type ViewerStageMode = (typeof VIEWER_STAGE_MODES)[number]
4
+
5
+ const THREE_D_MODES = ['3d'] as const
6
+ const TWO_D_MODES = ['2d'] as const
7
+ const SPLIT_MODES = ['split'] as const
8
+ const THREE_D_TWO_D_MODES = ['3d', '2d'] as const
9
+ const THREE_D_SPLIT_MODES = ['3d', 'split'] as const
10
+ const TWO_D_SPLIT_MODES = ['2d', 'split'] as const
11
+
12
+ export function normalizeViewerStageModes(
13
+ modes: readonly ViewerStageMode[] | undefined,
14
+ ): readonly ViewerStageMode[] {
15
+ if (!modes) return VIEWER_STAGE_MODES
16
+
17
+ const has3D = modes.includes('3d')
18
+ const has2D = modes.includes('2d')
19
+ const hasSplit = modes.includes('split')
20
+
21
+ if (has3D && has2D && hasSplit) return VIEWER_STAGE_MODES
22
+ if (has3D && has2D) return THREE_D_TWO_D_MODES
23
+ if (has3D && hasSplit) return THREE_D_SPLIT_MODES
24
+ if (has2D && hasSplit) return TWO_D_SPLIT_MODES
25
+ if (has2D) return TWO_D_MODES
26
+ if (hasSplit) return SPLIT_MODES
27
+ return THREE_D_MODES
28
+ }
29
+
30
+ export function resolveViewerStageMode(
31
+ mode: ViewerStageMode | undefined,
32
+ modes: readonly ViewerStageMode[],
33
+ ): ViewerStageMode {
34
+ return mode && modes.includes(mode) ? mode : (modes[0] ?? '3d')
35
+ }
36
+
37
+ export function resolveMobileViewerStageMode(
38
+ mode: ViewerStageMode,
39
+ modes: readonly ViewerStageMode[],
40
+ ): ViewerStageMode {
41
+ if (mode !== 'split') return mode
42
+ if (modes.includes('2d')) return '2d'
43
+ if (modes.includes('3d')) return '3d'
44
+ return 'split'
45
+ }
46
+
47
+ export function viewerStageIncludes3D(modes: readonly ViewerStageMode[]) {
48
+ return modes.includes('3d') || modes.includes('split')
49
+ }
@@ -0,0 +1,95 @@
1
+ 'use client'
2
+
3
+ import { Box, Columns2, Map as MapIcon } from 'lucide-react'
4
+ import type { ReactNode } from 'react'
5
+ import { cn } from '../../lib/utils'
6
+ import { normalizeViewerStageModes, type ViewerStageMode } from './viewer-stage-modes'
7
+
8
+ export type { ViewerStageMode } from './viewer-stage-modes'
9
+
10
+ export type ViewerStageSwitcherProps = {
11
+ className?: string
12
+ hideSplitOnMobile?: boolean
13
+ mode: ViewerStageMode
14
+ modes?: readonly ViewerStageMode[]
15
+ onChange: (mode: ViewerStageMode) => void
16
+ }
17
+
18
+ export function ViewerStageSwitcher({
19
+ className,
20
+ hideSplitOnMobile = true,
21
+ mode,
22
+ modes,
23
+ onChange,
24
+ }: ViewerStageSwitcherProps) {
25
+ const enabledModes = normalizeViewerStageModes(modes)
26
+
27
+ return (
28
+ <div
29
+ aria-label="Viewer layout"
30
+ className={cn(
31
+ 'dark absolute top-4 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/10 bg-neutral-950/82 p-1 text-white shadow-elevation-4 backdrop-blur-xl',
32
+ className,
33
+ )}
34
+ role="group"
35
+ >
36
+ {enabledModes.includes('3d') ? (
37
+ <StageButton
38
+ active={mode === '3d'}
39
+ icon={<Box />}
40
+ label="3D"
41
+ onClick={() => onChange('3d')}
42
+ />
43
+ ) : null}
44
+ {enabledModes.includes('2d') ? (
45
+ <StageButton
46
+ active={mode === '2d'}
47
+ icon={<MapIcon />}
48
+ label="2D"
49
+ onClick={() => onChange('2d')}
50
+ />
51
+ ) : null}
52
+ {enabledModes.includes('split') ? (
53
+ <StageButton
54
+ active={mode === 'split'}
55
+ className={hideSplitOnMobile && enabledModes.length > 1 ? 'hidden md:flex' : undefined}
56
+ icon={<Columns2 />}
57
+ label="Split"
58
+ onClick={() => onChange('split')}
59
+ />
60
+ ) : null}
61
+ </div>
62
+ )
63
+ }
64
+
65
+ function StageButton({
66
+ active,
67
+ className,
68
+ icon,
69
+ label,
70
+ onClick,
71
+ }: {
72
+ active: boolean
73
+ className?: string
74
+ icon: ReactNode
75
+ label: string
76
+ onClick: () => void
77
+ }) {
78
+ return (
79
+ <button
80
+ aria-pressed={active}
81
+ className={cn(
82
+ 'flex h-8 items-center gap-1.5 rounded-full px-3 font-medium text-xs',
83
+ active
84
+ ? 'bg-white text-neutral-950 shadow-sm'
85
+ : 'text-neutral-300 transition-colors hover:bg-white/10 hover:text-white',
86
+ className,
87
+ )}
88
+ onClick={onClick}
89
+ type="button"
90
+ >
91
+ <span className="[&>svg]:size-3.5">{icon}</span>
92
+ {label}
93
+ </button>
94
+ )
95
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { LevelNode } from '@pascal-app/core/schema'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import type { FloorplanPreviewScene } from './floorplan-preview'
5
+ import { ViewerStage } from './viewer-stage'
6
+
7
+ const level = LevelNode.parse({ id: 'level_ground', type: 'level' })
8
+ const scene: FloorplanPreviewScene = { nodes: { [level.id]: level } }
9
+
10
+ describe('ViewerStage', () => {
11
+ test('owns synchronized 2D and 3D composition by default', () => {
12
+ const markup = renderToStaticMarkup(
13
+ <ViewerStage mode="split" scene={scene} showLevelSelector={false}>
14
+ <div data-test-viewer-content="" />
15
+ </ViewerStage>,
16
+ )
17
+
18
+ expect(markup).toContain('data-pascal-viewer-stage="split"')
19
+ expect(markup).toContain('data-pascal-navigation-sync="on"')
20
+ expect(markup).toContain('data-pascal-viewer-3d="true"')
21
+ expect(markup).toContain('data-floorplan-preview=""')
22
+ expect(markup).toContain('viewBox="0 0 48 48"')
23
+ })
24
+
25
+ test('keeps a 2D-only embed free of a 3D canvas mount', () => {
26
+ const markup = renderToStaticMarkup(
27
+ <ViewerStage mode="2d" modes={['2d']} scene={scene} showLevelSelector={false} />,
28
+ )
29
+
30
+ expect(markup).toContain('data-pascal-viewer-stage="2d"')
31
+ expect(markup).not.toContain('data-pascal-viewer-3d')
32
+ expect(markup).toContain('data-floorplan-preview=""')
33
+ })
34
+
35
+ test('supports an explicit navigation synchronization opt-out', () => {
36
+ const markup = renderToStaticMarkup(
37
+ <ViewerStage
38
+ mode="split"
39
+ scene={scene}
40
+ showLevelSelector={false}
41
+ synchronizeNavigation={false}
42
+ />,
43
+ )
44
+
45
+ expect(markup).toContain('data-pascal-navigation-sync="off"')
46
+ })
47
+ })
@@ -0,0 +1,218 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import type { ReactNode } from 'react'
6
+ import { useCallback, useEffect, useMemo, useState } from 'react'
7
+ import { useShallow } from 'zustand/react/shallow'
8
+ import { cn } from '../../lib/utils'
9
+ import { FloorplanPreview, type FloorplanPreviewScene } from './floorplan-preview'
10
+ import {
11
+ normalizeViewerStageModes,
12
+ resolveMobileViewerStageMode,
13
+ resolveViewerStageMode,
14
+ type ViewerStageMode,
15
+ viewerStageIncludes3D,
16
+ } from './viewer-stage-modes'
17
+ import { ViewerStageSwitcher } from './viewer-stage-switcher'
18
+
19
+ export type ViewerStageProps = {
20
+ children?: ReactNode
21
+ className?: string
22
+ collapseSplitOnMobile?: boolean
23
+ compassHost?: Element | null
24
+ defaultMode?: ViewerStageMode
25
+ floorplanClassName?: string
26
+ levelId?: string | null
27
+ mode?: ViewerStageMode
28
+ modes?: readonly ViewerStageMode[]
29
+ onLevelChange?: (levelId: string) => void
30
+ onModeChange?: (mode: ViewerStageMode) => void
31
+ scene?: FloorplanPreviewScene | null
32
+ showCompass?: boolean
33
+ showLevelSelector?: boolean
34
+ showSwitcher?: boolean
35
+ switcherClassName?: string
36
+ synchronizeNavigation?: boolean
37
+ threeDClassName?: string
38
+ }
39
+
40
+ const EMPTY_LEVEL_IDS: string[] = []
41
+
42
+ function levelNodeIds(nodes: Record<string, AnyNode>) {
43
+ return Object.values(nodes)
44
+ .filter((node) => node.type === 'level')
45
+ .sort(
46
+ (left, right) =>
47
+ ((left as { level?: number }).level ?? 0) - ((right as { level?: number }).level ?? 0),
48
+ )
49
+ .map((node) => node.id)
50
+ }
51
+
52
+ function selectViewerLevel(nodes: Record<string, AnyNode>, levelId: string) {
53
+ const level = nodes[levelId as AnyNodeId]
54
+ if (level?.type !== 'level') return
55
+ const building = level.parentId ? nodes[level.parentId as AnyNodeId] : null
56
+ const viewer = useViewer.getState()
57
+ viewer.setSelection({
58
+ buildingId: building?.type === 'building' ? building.id : null,
59
+ levelId: level.id,
60
+ selectedIds: [],
61
+ zoneId: null,
62
+ })
63
+ viewer.setLevelMode('solo')
64
+ }
65
+
66
+ export function ViewerStage({
67
+ children,
68
+ className,
69
+ collapseSplitOnMobile = true,
70
+ compassHost,
71
+ defaultMode,
72
+ floorplanClassName,
73
+ levelId,
74
+ mode: controlledMode,
75
+ modes,
76
+ onLevelChange,
77
+ onModeChange,
78
+ scene,
79
+ showCompass = true,
80
+ showLevelSelector = true,
81
+ showSwitcher = true,
82
+ switcherClassName,
83
+ synchronizeNavigation = true,
84
+ threeDClassName,
85
+ }: ViewerStageProps) {
86
+ const enabledModes = normalizeViewerStageModes(modes)
87
+ const [internalMode, setInternalMode] = useState(() =>
88
+ resolveViewerStageMode(defaultMode, enabledModes),
89
+ )
90
+ const activeMode = resolveViewerStageMode(controlledMode ?? internalMode, enabledModes)
91
+ const storeLevelIds = useScene(
92
+ useShallow((state) => (scene ? EMPTY_LEVEL_IDS : levelNodeIds(state.nodes))),
93
+ )
94
+ const externalLevelIds = useMemo(() => (scene ? levelNodeIds(scene.nodes) : null), [scene])
95
+ const levelIds = externalLevelIds ?? storeLevelIds
96
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
97
+ const [internalLevelId, setInternalLevelId] = useState<string | null>(null)
98
+ const [internalCompassHost, setInternalCompassHost] = useState<HTMLDivElement | null>(null)
99
+ const resolvedCompassHost = compassHost ?? internalCompassHost
100
+ const floorplanEnabled = enabledModes.includes('2d') || enabledModes.includes('split')
101
+ const threeDEnabled = viewerStageIncludes3D(enabledModes)
102
+ const mountFloorplan = floorplanEnabled || showCompass
103
+
104
+ const requestMode = useCallback(
105
+ (nextMode: ViewerStageMode) => {
106
+ if (!enabledModes.includes(nextMode)) return
107
+ if (controlledMode === undefined) setInternalMode(nextMode)
108
+ onModeChange?.(nextMode)
109
+ },
110
+ [controlledMode, enabledModes, onModeChange],
111
+ )
112
+
113
+ useEffect(() => {
114
+ if (controlledMode !== undefined) {
115
+ if (controlledMode !== activeMode) onModeChange?.(activeMode)
116
+ return
117
+ }
118
+ if (internalMode !== activeMode) setInternalMode(activeMode)
119
+ }, [activeMode, controlledMode, internalMode, onModeChange])
120
+
121
+ useEffect(() => {
122
+ if (!collapseSplitOnMobile) return
123
+ const mediaQuery = window.matchMedia('(max-width: 767px)')
124
+ const resolveMode = () => {
125
+ if (!mediaQuery.matches) return
126
+ const nextMode = resolveMobileViewerStageMode(activeMode, enabledModes)
127
+ if (nextMode !== activeMode) requestMode(nextMode)
128
+ }
129
+ resolveMode()
130
+ mediaQuery.addEventListener('change', resolveMode)
131
+ return () => mediaQuery.removeEventListener('change', resolveMode)
132
+ }, [activeMode, collapseSplitOnMobile, enabledModes, requestMode])
133
+
134
+ const chooseLevel = useCallback(
135
+ (nextLevelId: string, notify = true) => {
136
+ setInternalLevelId(nextLevelId)
137
+ selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
138
+ if (notify) onLevelChange?.(nextLevelId)
139
+ },
140
+ [onLevelChange, scene],
141
+ )
142
+
143
+ useEffect(() => {
144
+ if (activeMode === '3d' || levelIds.length === 0) return
145
+ const nextLevelId =
146
+ (levelId && levelIds.includes(levelId) ? levelId : null) ??
147
+ (selectedLevelId && levelIds.includes(selectedLevelId) ? selectedLevelId : null) ??
148
+ (internalLevelId && levelIds.includes(internalLevelId) ? internalLevelId : null) ??
149
+ levelIds[0] ??
150
+ null
151
+ if (nextLevelId) chooseLevel(nextLevelId, false)
152
+ }, [activeMode, chooseLevel, internalLevelId, levelId, levelIds, selectedLevelId])
153
+
154
+ return (
155
+ <div
156
+ className={cn('relative h-full w-full overflow-hidden bg-neutral-100', className)}
157
+ data-pascal-navigation-sync={synchronizeNavigation ? 'on' : 'off'}
158
+ data-pascal-viewer-stage={activeMode}
159
+ >
160
+ {showCompass && compassHost === undefined ? (
161
+ <div className="pointer-events-none absolute inset-0 z-30" ref={setInternalCompassHost} />
162
+ ) : null}
163
+
164
+ {showSwitcher && enabledModes.length > 1 ? (
165
+ <ViewerStageSwitcher
166
+ className={switcherClassName}
167
+ hideSplitOnMobile={collapseSplitOnMobile}
168
+ mode={activeMode}
169
+ modes={enabledModes}
170
+ onChange={requestMode}
171
+ />
172
+ ) : null}
173
+
174
+ <div
175
+ className={
176
+ activeMode === 'split'
177
+ ? 'absolute inset-0 grid grid-rows-2 md:grid-cols-2 md:grid-rows-1'
178
+ : 'absolute inset-0'
179
+ }
180
+ >
181
+ {threeDEnabled ? (
182
+ <div
183
+ className={cn(
184
+ activeMode === '2d'
185
+ ? 'pointer-events-none invisible absolute inset-0 h-full w-full'
186
+ : 'relative h-full min-h-0 w-full min-w-0',
187
+ threeDClassName,
188
+ )}
189
+ data-pascal-viewer-3d
190
+ >
191
+ {children}
192
+ </div>
193
+ ) : null}
194
+
195
+ {mountFloorplan ? (
196
+ <FloorplanPreview
197
+ className={cn(
198
+ activeMode === '3d'
199
+ ? 'hidden'
200
+ : activeMode === 'split'
201
+ ? 'min-h-0 min-w-0 border-border border-t md:border-t-0 md:border-l'
202
+ : 'h-full w-full',
203
+ floorplanClassName,
204
+ )}
205
+ compassHost={resolvedCompassHost}
206
+ levelId={levelId ?? internalLevelId}
207
+ navigationVisible={activeMode !== '3d'}
208
+ onLevelChange={chooseLevel}
209
+ scene={scene}
210
+ showCompass={showCompass}
211
+ showLevelSelector={showLevelSelector}
212
+ synchronizeNavigation={synchronizeNavigation}
213
+ />
214
+ ) : null}
215
+ </div>
216
+ </div>
217
+ )
218
+ }
@@ -39,6 +39,7 @@ interface ViewerOverlayProps {
39
39
  owner?: ProjectOwner | null
40
40
  canShowScans?: boolean
41
41
  canShowGuides?: boolean
42
+ hideBottomBar?: boolean
42
43
  onBack?: () => void
43
44
  }
44
45
 
@@ -47,17 +48,20 @@ export const ViewerOverlay = ({
47
48
  owner,
48
49
  canShowScans = true,
49
50
  canShowGuides = true,
51
+ hideBottomBar = false,
50
52
  onBack,
51
53
  }: ViewerOverlayProps) => (
52
54
  <>
53
55
  <ViewerSceneHeader onBack={onBack} owner={owner} projectName={projectName} />
54
- <ViewerControlsBar
55
- canShowGuides={canShowGuides}
56
- canShowScans={canShowScans}
57
- onWalkthroughToggle={() => {
58
- flushSync(() => useEditor.getState().setFirstPersonMode(true))
59
- requestWalkthroughPointerLock()
60
- }}
61
- />
56
+ {!hideBottomBar ? (
57
+ <ViewerControlsBar
58
+ canShowGuides={canShowGuides}
59
+ canShowScans={canShowScans}
60
+ onWalkthroughToggle={() => {
61
+ flushSync(() => useEditor.getState().setFirstPersonMode(true))
62
+ requestWalkthroughPointerLock()
63
+ }}
64
+ />
65
+ ) : null}
62
66
  </>
63
67
  )
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { isSuspiciousNodeDrop } from './use-auto-save'
2
+ import { createStoredNodeCountTracker, isSuspiciousNodeDrop } from './use-auto-save'
3
3
 
4
4
  describe('isSuspiciousNodeDrop', () => {
5
5
  test('blocks populated scenes from being flushed as empty skeletons', () => {
@@ -12,3 +12,52 @@ describe('isSuspiciousNodeDrop', () => {
12
12
  expect(isSuspiciousNodeDrop(4, 0)).toBe(false)
13
13
  })
14
14
  })
15
+
16
+ describe('createStoredNodeCountTracker', () => {
17
+ test('adopts a loaded graph as the baseline the guard measures against', () => {
18
+ // The hook mounts before the scene loads, so the tracker starts at the bare
19
+ // scaffold and only learns the real size when the load lands. Without this,
20
+ // every session's first save compares a populated graph against ~0 and the
21
+ // guard is dead for exactly the write that can destroy the most work.
22
+ const tracker = createStoredNodeCountTracker(0)
23
+ tracker.trackLoadedGraph(42)
24
+
25
+ expect(tracker.allowWrite(0)).toBe(false)
26
+ expect(tracker.count).toBe(42)
27
+ })
28
+
29
+ test('keeps blocking after a blocked write instead of adopting the empty graph', () => {
30
+ const tracker = createStoredNodeCountTracker(0)
31
+ tracker.trackLoadedGraph(42)
32
+
33
+ expect(tracker.allowWrite(0)).toBe(false)
34
+ // A debounced retry must not be the thing that finally lets the wipe land.
35
+ expect(tracker.allowWrite(0)).toBe(false)
36
+ })
37
+
38
+ test('lets ordinary edits through and advances the baseline', () => {
39
+ const tracker = createStoredNodeCountTracker(0)
40
+ tracker.trackLoadedGraph(12)
41
+
42
+ expect(tracker.allowWrite(13)).toBe(true)
43
+ expect(tracker.count).toBe(13)
44
+ expect(tracker.allowWrite(5)).toBe(true)
45
+ expect(tracker.count).toBe(5)
46
+ })
47
+
48
+ test('does not treat a deliberately empty new scene as suspicious', () => {
49
+ const tracker = createStoredNodeCountTracker(0)
50
+ tracker.trackLoadedGraph(4)
51
+
52
+ expect(tracker.allowWrite(0)).toBe(true)
53
+ })
54
+
55
+ test('adopts a smaller loaded graph, so restoring an older version still saves', () => {
56
+ // Loading a 3-node version over a 40-node one is not a deletion.
57
+ const tracker = createStoredNodeCountTracker(0)
58
+ tracker.trackLoadedGraph(40)
59
+ tracker.trackLoadedGraph(3)
60
+
61
+ expect(tracker.allowWrite(3)).toBe(true)
62
+ })
63
+ })