@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.
- package/package.json +7 -6
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/editor-layout-v2.tsx +8 -3
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/first-person-controls.tsx +2 -45
- package/src/components/editor/floating-action-menu.tsx +13 -4
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +5 -45
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/index.tsx +112 -25
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +29 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +28 -5
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
- package/src/components/editor/thumbnail-generator.tsx +27 -11
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
- package/src/components/tools/item/placement-strategies.test.ts +167 -0
- package/src/components/tools/item/placement-strategies.ts +57 -22
- package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
- package/src/components/tools/shared/placement-box.tsx +9 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/wall/wall-drafting.test.ts +207 -2
- package/src/components/tools/wall/wall-drafting.ts +84 -400
- package/src/components/ui/command-palette/editor-commands.tsx +3 -1
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
- package/src/components/ui/sidebar/tab-bar.tsx +7 -1
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +218 -0
- package/src/components/viewer-overlay.tsx +12 -8
- package/src/hooks/use-auto-save.test.ts +50 -1
- package/src/hooks/use-auto-save.ts +44 -9
- package/src/hooks/use-keyboard.ts +35 -1
- package/src/index.tsx +27 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +84 -0
- package/src/lib/history.test.ts +75 -10
- package/src/lib/history.ts +58 -13
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/selection-routing.test.ts +20 -5
- package/src/lib/selection-routing.ts +15 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- 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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
})
|