@pascal-app/editor 1.0.7 → 1.1.0
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 -7
- package/src/components/editor/custom-camera-controls.tsx +90 -34
- package/src/components/editor/duplicate-node.ts +2 -1
- package/src/components/editor/first-person-controls.tsx +5 -7
- package/src/components/editor/fit-scene-framing.test.ts +318 -0
- package/src/components/editor/fit-scene-framing.ts +142 -0
- package/src/components/editor/floating-action-menu.tsx +4 -2
- package/src/components/editor/floor-openings-overlay.tsx +1 -1
- package/src/components/editor/floor-region-controls.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +173 -8
- package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
- package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
- package/src/components/editor/footprint-height-handle.tsx +1 -1
- package/src/components/editor/group-transform-shared.ts +3 -1
- package/src/components/editor/index.tsx +20 -0
- package/src/components/editor/measurement-pill.tsx +1 -1
- package/src/components/editor/node-arrow-handles.tsx +1 -1
- package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
- package/src/components/editor/open-wall-ends-hint.tsx +1 -1
- package/src/components/editor/quick-measurement-card.tsx +1 -1
- package/src/components/editor/room-controls.tsx +3 -2
- package/src/components/editor/room-handles-2d.tsx +2 -1
- package/src/components/editor/room-handles.tsx +3 -3
- package/src/components/editor/room-labels.tsx +4 -2
- package/src/components/editor/site-edge-labels.tsx +6 -2
- package/src/components/editor/wall-measurement-label.tsx +30 -14
- package/src/components/editor/wall-move-side-handles.tsx +10 -6
- package/src/components/editor/wall-region-handles.tsx +2 -2
- package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
- package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
- package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
- package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
- package/src/components/systems/roof/roof-edit-system.tsx +30 -19
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
- package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
- package/src/components/tools/item/move-tool.tsx +1 -6
- package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
- package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
- package/src/components/tools/shared/placement-box.tsx +1 -5
- package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
- package/src/components/tools/shared/segment-angle.ts +0 -4
- package/src/components/tools/tool-manager.tsx +1 -24
- package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
- package/src/components/tools/wall/wall-typed-entry.ts +70 -0
- package/src/components/ui/controls/slider-control.tsx +18 -6
- package/src/components/ui/controls/tool-options-panel.tsx +74 -44
- package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
- package/src/components/ui/helpers/helper-manager.tsx +54 -0
- package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
- package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
- package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
- package/src/components/ui/panels/multi-height-mode.tsx +3 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
- package/src/components/ui/panels/panel-manager.tsx +4 -2
- package/src/components/ui/panels/panel-wrapper.tsx +41 -18
- package/src/components/ui/panels/room-construction-rows.tsx +1 -1
- package/src/components/ui/panels/room-openings-field.tsx +1 -1
- package/src/components/ui/panels/room-panel.tsx +1 -1
- package/src/components/ui/right-stack.test.ts +13 -6
- package/src/components/ui/right-stack.tsx +21 -10
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
- package/src/components/viewer/viewer-rooms.tsx +1 -1
- package/src/i18n/locales/en/messages.po +856 -124
- package/src/i18n/locales/en/messages.ts +1 -1
- package/src/i18n/locales/en-XA/messages.ts +1 -1
- package/src/i18n/locales/es/messages.po +2087 -257
- package/src/i18n/locales/fr/messages.po +2087 -257
- package/src/i18n/locales/ja/messages.po +2087 -257
- package/src/i18n/locales/zh-CN/messages.po +2087 -257
- package/src/index.tsx +74 -47
- package/src/lib/contextual-help.ts +6 -0
- package/src/lib/elevation-guides.ts +21 -6
- package/src/lib/elevator-support.ts +1 -72
- package/src/lib/floor-footprints.test.ts +1 -1
- package/src/lib/floor-footprints.ts +1 -9
- package/src/lib/floor-foundation-support.test.ts +8 -3
- package/src/lib/floor-opening-draft.ts +1 -1
- package/src/lib/floor-region-session.ts +1 -1
- package/src/lib/floorplan/open-wall-ends.ts +2 -1
- package/src/lib/gesture-lifecycle.ts +3 -1
- package/src/lib/interaction/scope.ts +1 -0
- package/src/lib/layer-thickness.ts +1 -1
- package/src/lib/level-selection.test.ts +18 -2
- package/src/lib/level-selection.ts +31 -8
- package/src/lib/linear-display.ts +1 -1
- package/src/lib/live-gesture-round-trip.test.ts +1 -1
- package/src/lib/measurement-label.test.ts +1 -1
- package/src/lib/measurements.test.ts +8 -7
- package/src/lib/measurements.ts +1 -218
- package/src/lib/node-action-movement.test.tsx +115 -0
- package/src/lib/node-action-movement.ts +2 -0
- package/src/lib/placement-metadata.ts +4 -0
- package/src/lib/point-ask/describe-target.ts +6 -8
- package/src/lib/point-ask/region.ts +1 -1
- package/src/lib/print-shell-compiler.ts +12 -1
- package/src/lib/print-wall-solids.test.ts +11 -0
- package/src/lib/print-wall-solids.ts +9 -0
- package/src/lib/room-assembly-overlay.ts +1 -1
- package/src/lib/room-built-on.test.ts +2 -5
- package/src/lib/room-built-on.ts +1 -15
- package/src/lib/room-construction-commands.ts +3 -2
- package/src/lib/room-controls-phase6.test.ts +1 -1
- package/src/lib/room-handle-drag.ts +6 -25
- package/src/lib/room-push-dimensions.test.ts +1 -1
- package/src/lib/scene-bounds.test.ts +144 -0
- package/src/lib/scene-bounds.ts +79 -15
- package/src/lib/scene-clipboard.ts +2 -1
- package/src/lib/snapping-mode.test.ts +23 -1
- package/src/lib/viewer-selection.ts +1 -1
- package/src/lib/wall-region-list.ts +3 -1
- package/src/lib/window-interaction.test.ts +4 -2
- package/src/store/use-editor.tsx +5 -0
- package/src/store/use-placement-typing.test.ts +165 -0
- package/src/store/use-placement-typing.ts +186 -0
- package/src/components/tools/elevator/elevator-defaults.ts +0 -8
- package/src/components/tools/elevator/elevator-tool.tsx +0 -372
- package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
- package/src/lib/door-interaction.ts +0 -102
- package/src/lib/measurement-label.ts +0 -68
- package/src/lib/window-interaction.ts +0 -142
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/editor",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Pascal building editor component",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"test": "bun test src"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@pascal-app/core": "^1.0
|
|
22
|
-
"@pascal-app/viewer": "^1.0
|
|
21
|
+
"@pascal-app/core": "^1.1.0",
|
|
22
|
+
"@pascal-app/viewer": "^1.1.0",
|
|
23
23
|
"@react-three/drei": "^10",
|
|
24
24
|
"@react-three/fiber": "^9",
|
|
25
25
|
"next": ">=15",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"@iconify/react": "^6.0.4",
|
|
35
35
|
"@lingui/core": "6.12.0",
|
|
36
36
|
"@number-flow/react": "^0.6.2",
|
|
37
|
-
"@pascal-app/geometry-script": "^1.0
|
|
38
|
-
"@pascal-app/ifc-converter": "^1.0
|
|
37
|
+
"@pascal-app/geometry-script": "^1.1.0",
|
|
38
|
+
"@pascal-app/ifc-converter": "^1.1.0",
|
|
39
39
|
"@pascal-app/lingo": "^0.5.0",
|
|
40
40
|
"@radix-ui/react-alert-dialog": "^1.2.1",
|
|
41
41
|
"@radix-ui/react-context-menu": "^2.4.1",
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
"zustand": "^5.0.16"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
|
-
"@pascal-app/core": "^1.0
|
|
73
|
-
"@pascal-app/viewer": "^1.0
|
|
72
|
+
"@pascal-app/core": "^1.1.0",
|
|
73
|
+
"@pascal-app/viewer": "^1.1.0",
|
|
74
74
|
"@pascal/typescript-config": "*",
|
|
75
75
|
"@react-three/test-renderer": "^9.1.1",
|
|
76
76
|
"@types/blob-stream": "^0.1.33",
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type Camera,
|
|
30
30
|
type OrthographicCamera,
|
|
31
31
|
type PerspectiveCamera,
|
|
32
|
+
Sphere,
|
|
32
33
|
Spherical,
|
|
33
34
|
Vector3,
|
|
34
35
|
} from 'three'
|
|
@@ -74,6 +75,13 @@ import {
|
|
|
74
75
|
} from '../../store/use-interaction-scope'
|
|
75
76
|
import { visibleScene } from '../../store/view-layout'
|
|
76
77
|
import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
|
|
78
|
+
import {
|
|
79
|
+
type FitSceneLookAt,
|
|
80
|
+
type PendingFitScene,
|
|
81
|
+
planFitSceneOnEvent,
|
|
82
|
+
planFitSceneOnOrbitResume,
|
|
83
|
+
planLevelFollow,
|
|
84
|
+
} from './fit-scene-framing'
|
|
77
85
|
import { FloorAwareCameraControls } from './floor-aware-camera-controls'
|
|
78
86
|
import { FloorSurface } from './floor-surface'
|
|
79
87
|
|
|
@@ -84,6 +92,7 @@ const tempDelta = new Vector3()
|
|
|
84
92
|
const tempPosition = new Vector3()
|
|
85
93
|
const tempSize = new Vector3()
|
|
86
94
|
const tempTarget = new Vector3()
|
|
95
|
+
const fitSceneSphere = new Sphere()
|
|
87
96
|
const transitionFreezePosition = new Vector3()
|
|
88
97
|
const transitionFreezeTarget = new Vector3()
|
|
89
98
|
const keyboardPanSpherical = new Spherical()
|
|
@@ -120,6 +129,23 @@ function keepEyeOverGround(x: number, eyeY: number, z: number, previousEyeY = ey
|
|
|
120
129
|
return overviewEyeY(eyeY, previousEyeY, groundY, useViewer.getState().eyeUnderGround)
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
function applyFitLookAt(control: CameraControlsImpl, lookAt: FitSceneLookAt) {
|
|
133
|
+
control.setLookAt(
|
|
134
|
+
lookAt.eyeX,
|
|
135
|
+
keepEyeOverGround(lookAt.eyeX, lookAt.eyeY, lookAt.eyeZ, lookAt.eyeHeight),
|
|
136
|
+
lookAt.eyeZ,
|
|
137
|
+
lookAt.targetX,
|
|
138
|
+
lookAt.targetY,
|
|
139
|
+
lookAt.targetZ,
|
|
140
|
+
true,
|
|
141
|
+
)
|
|
142
|
+
if (isOrthographicCamera(control.camera)) {
|
|
143
|
+
fitSceneSphere.center.set(...lookAt.fitSphere.center)
|
|
144
|
+
fitSceneSphere.radius = lookAt.fitSphere.radius
|
|
145
|
+
control.fitToSphere(fitSceneSphere, true)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
123
149
|
function levelOf(nodeId: string): string | null {
|
|
124
150
|
const nodes = useScene.getState().nodes
|
|
125
151
|
let node: AnyNode | undefined = nodes[nodeId as AnyNodeId]
|
|
@@ -407,6 +433,9 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
|
|
|
407
433
|
if (selection.levelId) lastLevelId.current = selection.levelId
|
|
408
434
|
const currentLevelId = lastLevelId.current
|
|
409
435
|
const firstLoad = useRef(true)
|
|
436
|
+
// Survives first-person (orbit unmounted) so scene-ready fit still applies
|
|
437
|
+
// once CameraControls remount.
|
|
438
|
+
const pendingFitSceneRef = useRef<PendingFitScene | null>(null)
|
|
410
439
|
const floorY = useScene((state) =>
|
|
411
440
|
currentLevelId ? getLevelPresentationY(currentLevelId, state.nodes, levelMode) : 0,
|
|
412
441
|
)
|
|
@@ -615,19 +644,32 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
|
|
|
615
644
|
}
|
|
616
645
|
}, [scene, gl, camera])
|
|
617
646
|
|
|
647
|
+
const previousLevelIdRef = useRef<string | null>(null)
|
|
648
|
+
const previousLevelModeRef = useRef(levelMode)
|
|
649
|
+
const previousCameraRef = useRef(camera)
|
|
618
650
|
// `camera` too: drei builds a fresh controls instance (pivot at the origin)
|
|
619
651
|
// when the default camera mounts or changes; it is rested on the floor too.
|
|
620
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: camera re-runs this on a new instance
|
|
621
652
|
useEffect(() => {
|
|
622
653
|
if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
|
|
623
654
|
// Analytic destination, not `sceneRegistry` mesh position: a level created
|
|
624
655
|
// this frame still sits at y=0 (LevelSystem lerps it later), and a mode
|
|
625
|
-
// switch leaves every level mid-lerp
|
|
656
|
+
// switch leaves every level mid-lerp - the camera must pan to where the
|
|
626
657
|
// level will settle, in the CURRENT presentation mode.
|
|
627
658
|
const targetY = orbitGroundY(currentLevelId, levelMode)
|
|
628
659
|
if (!controls.current) return
|
|
629
|
-
|
|
630
|
-
firstLoad.current
|
|
660
|
+
const plan = planLevelFollow({
|
|
661
|
+
firstLoad: firstLoad.current,
|
|
662
|
+
sceneEmpty: Object.keys(useScene.getState().nodes).length === 0,
|
|
663
|
+
levelChanged: previousLevelIdRef.current !== currentLevelId,
|
|
664
|
+
modeChanged: previousLevelModeRef.current !== levelMode,
|
|
665
|
+
cameraChanged: previousCameraRef.current !== camera,
|
|
666
|
+
})
|
|
667
|
+
firstLoad.current = false
|
|
668
|
+
previousLevelIdRef.current = currentLevelId
|
|
669
|
+
previousLevelModeRef.current = levelMode
|
|
670
|
+
previousCameraRef.current = camera
|
|
671
|
+
if (plan === 'none') return
|
|
672
|
+
if (plan === 'default-pose') {
|
|
631
673
|
controls.current.setLookAt(
|
|
632
674
|
20,
|
|
633
675
|
keepEyeOverGround(20, 20 + targetY, 20, 20),
|
|
@@ -637,6 +679,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
|
|
|
637
679
|
0,
|
|
638
680
|
true,
|
|
639
681
|
)
|
|
682
|
+
return
|
|
640
683
|
}
|
|
641
684
|
controls.current.getOrbitTarget(currentTarget)
|
|
642
685
|
// Idempotence guard: skip when already there — also swallows the thumbnail
|
|
@@ -692,6 +735,19 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
|
|
|
692
735
|
controls.current.floorSurface = diveFloor === null ? floorSurface : null
|
|
693
736
|
}, [camera, currentLevelId, diveFloor, floorMinY, floorSurface, isFirstPersonMode])
|
|
694
737
|
|
|
738
|
+
const flushPendingFitScene = useCallback(() => {
|
|
739
|
+
const plan = planFitSceneOnOrbitResume({
|
|
740
|
+
isPreviewMode,
|
|
741
|
+
isFirstPersonMode: useEditor.getState().isFirstPersonMode,
|
|
742
|
+
hasControls: !!controls.current,
|
|
743
|
+
pending: pendingFitSceneRef.current,
|
|
744
|
+
groundY: orbitGroundY(lastLevelId.current),
|
|
745
|
+
})
|
|
746
|
+
if (plan.action !== 'apply' || !controls.current) return
|
|
747
|
+
pendingFitSceneRef.current = null
|
|
748
|
+
applyFitLookAt(controls.current, plan.lookAt)
|
|
749
|
+
}, [isPreviewMode])
|
|
750
|
+
|
|
695
751
|
const focusNode = useCallback(
|
|
696
752
|
(nodeId: string) => {
|
|
697
753
|
if (isPreviewMode || isFirstPersonMode || !controls.current) return
|
|
@@ -1388,37 +1444,22 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
|
|
|
1388
1444
|
}
|
|
1389
1445
|
|
|
1390
1446
|
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
)
|
|
1447
|
+
const plan = planFitSceneOnEvent({
|
|
1448
|
+
isPreviewMode,
|
|
1449
|
+
isFirstPersonMode,
|
|
1450
|
+
hasControls: !!controls.current,
|
|
1451
|
+
bounds: bounds ?? null,
|
|
1452
|
+
groundY: orbitGroundY(lastLevelId.current),
|
|
1453
|
+
})
|
|
1454
|
+
if (plan.action === 'ignore') return
|
|
1455
|
+
if (plan.action === 'queue') {
|
|
1456
|
+
pendingFitSceneRef.current = plan.pending
|
|
1457
|
+
// Orbit path with a not-yet-attached ref: retry next frame.
|
|
1458
|
+
if (!isFirstPersonMode) requestAnimationFrame(flushPendingFitScene)
|
|
1404
1459
|
return
|
|
1405
1460
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
// Use the longer horizontal extent to size the orbit radius so the whole
|
|
1409
|
-
// footprint sits in view regardless of aspect ratio.
|
|
1410
|
-
const maxExtent = Math.max(w, d)
|
|
1411
|
-
const distance = Math.max(maxExtent * 1.4, 15)
|
|
1412
|
-
const height = Math.max(maxExtent * 0.8, 10)
|
|
1413
|
-
controls.current.setLookAt(
|
|
1414
|
-
cx + distance * 0.7,
|
|
1415
|
-
keepEyeOverGround(cx + distance * 0.7, height + groundY, cz + distance * 0.7, height),
|
|
1416
|
-
cz + distance * 0.7,
|
|
1417
|
-
cx,
|
|
1418
|
-
groundY,
|
|
1419
|
-
cz,
|
|
1420
|
-
true,
|
|
1421
|
-
)
|
|
1461
|
+
pendingFitSceneRef.current = null
|
|
1462
|
+
if (controls.current) applyFitLookAt(controls.current, plan.lookAt)
|
|
1422
1463
|
}
|
|
1423
1464
|
|
|
1424
1465
|
emitter.on('camera-controls:capture', handleNodeCapture)
|
|
@@ -1440,7 +1481,22 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
|
|
|
1440
1481
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
1441
1482
|
emitter.off('camera-controls:fit-scene', handleFitScene)
|
|
1442
1483
|
}
|
|
1443
|
-
}, [
|
|
1484
|
+
}, [
|
|
1485
|
+
camera,
|
|
1486
|
+
cancelPoseApplication,
|
|
1487
|
+
flushPendingFitScene,
|
|
1488
|
+
focusNode,
|
|
1489
|
+
isPreviewMode,
|
|
1490
|
+
isFirstPersonMode,
|
|
1491
|
+
])
|
|
1492
|
+
|
|
1493
|
+
// Apply a fit that arrived while first-person (orbit unmounted). Wait one
|
|
1494
|
+
// frame so CameraControls can remount and attach its ref.
|
|
1495
|
+
useEffect(() => {
|
|
1496
|
+
if (isFirstPersonMode || isPreviewMode || !pendingFitSceneRef.current) return
|
|
1497
|
+
const frame = requestAnimationFrame(flushPendingFitScene)
|
|
1498
|
+
return () => cancelAnimationFrame(frame)
|
|
1499
|
+
}, [flushPendingFitScene, isFirstPersonMode, isPreviewMode])
|
|
1444
1500
|
|
|
1445
1501
|
const onTransitionStart = useCallback(() => {
|
|
1446
1502
|
cameraDraggingLifecycle.begin()
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
FenceNode,
|
|
10
10
|
generateId,
|
|
11
11
|
ItemNode,
|
|
12
|
+
isNodeDuplicable,
|
|
12
13
|
nodeRegistry,
|
|
13
14
|
RoofSegmentNode,
|
|
14
15
|
runSceneHistoryDraftWrite,
|
|
@@ -48,7 +49,7 @@ export function copyCollectionIds(copy: AnyNode): CollectionId[] {
|
|
|
48
49
|
* every inspector's Duplicate. The copy is in the collections its source is in.
|
|
49
50
|
*/
|
|
50
51
|
export function duplicateNodeAndPickUp(node: AnyNode) {
|
|
51
|
-
if (!node.parentId) return
|
|
52
|
+
if (!(node.parentId && isNodeDuplicable(node))) return
|
|
52
53
|
if (startZoneRoomTransform(node, 'duplicate')) return
|
|
53
54
|
sfxEmitter.emit('sfx:item-pick')
|
|
54
55
|
const { setMovingNode } = useEditor.getState()
|
|
@@ -85,18 +85,16 @@ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-
|
|
|
85
85
|
import '../../three-types'
|
|
86
86
|
import {
|
|
87
87
|
closeDoorOpenState,
|
|
88
|
+
closeWindowOpenState,
|
|
88
89
|
DOOR_SWING_OPEN_ANGLE,
|
|
89
90
|
getDisplayedDoorValue,
|
|
90
|
-
isOperationDoorType,
|
|
91
|
-
toggleDoorOpenState,
|
|
92
|
-
} from '../../lib/door-interaction'
|
|
93
|
-
import { isTypingTarget } from '../../lib/typing-target'
|
|
94
|
-
import {
|
|
95
|
-
closeWindowOpenState,
|
|
96
91
|
getDisplayedWindowValue,
|
|
97
92
|
isOperableWindowType,
|
|
93
|
+
isOperationDoorType,
|
|
94
|
+
toggleDoorOpenState,
|
|
98
95
|
toggleWindowOpenState,
|
|
99
|
-
} from '
|
|
96
|
+
} from '@pascal-app/core'
|
|
97
|
+
import { isTypingTarget } from '../../lib/typing-target'
|
|
100
98
|
import useEditor from '../../store/use-editor'
|
|
101
99
|
import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
|
|
102
100
|
import { DroneHud, WalkthroughHud } from '../walkthrough-hud'
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CameraControlsImpl } from '@react-three/drei'
|
|
3
|
+
import * as THREE from 'three'
|
|
4
|
+
import {
|
|
5
|
+
computeFitSceneLookAt,
|
|
6
|
+
type PendingFitScene,
|
|
7
|
+
planFitSceneOnEvent,
|
|
8
|
+
planFitSceneOnOrbitResume,
|
|
9
|
+
planLevelFollow,
|
|
10
|
+
} from './fit-scene-framing'
|
|
11
|
+
|
|
12
|
+
const sampleBounds = {
|
|
13
|
+
center: [10, 20] as [number, number],
|
|
14
|
+
size: [30, 40] as [number, number],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('fit-scene framing', () => {
|
|
18
|
+
test('computes default look-at when bounds are null', () => {
|
|
19
|
+
expect(computeFitSceneLookAt(null)).toEqual({
|
|
20
|
+
eyeX: 20,
|
|
21
|
+
eyeY: 20,
|
|
22
|
+
eyeZ: 20,
|
|
23
|
+
targetX: 0,
|
|
24
|
+
targetY: 0,
|
|
25
|
+
targetZ: 0,
|
|
26
|
+
eyeHeight: 20,
|
|
27
|
+
fitSphere: { center: [0, 0, 0], radius: 15 },
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('computes orbit look-at from scene bounds', () => {
|
|
32
|
+
const lookAt = computeFitSceneLookAt(sampleBounds)
|
|
33
|
+
// Longer extent is depth=40 → distance=56, height=32
|
|
34
|
+
expect(lookAt).toEqual({
|
|
35
|
+
eyeX: 10 + 56 * 0.7,
|
|
36
|
+
eyeY: 32,
|
|
37
|
+
eyeZ: 20 + 56 * 0.7,
|
|
38
|
+
targetX: 10,
|
|
39
|
+
targetY: 0,
|
|
40
|
+
targetZ: 20,
|
|
41
|
+
eyeHeight: 32,
|
|
42
|
+
fitSphere: { center: [10, 0, 20], radius: 25 * 1.3 },
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('rests the pivot and lifts the eye by the level ground height', () => {
|
|
47
|
+
const lookAt = computeFitSceneLookAt(sampleBounds, 3)
|
|
48
|
+
expect(lookAt.targetY).toBe(3)
|
|
49
|
+
expect(lookAt.eyeY).toBe(35)
|
|
50
|
+
expect(lookAt.eyeHeight).toBe(32)
|
|
51
|
+
expect(computeFitSceneLookAt(null, 3)).toMatchObject({ eyeY: 23, targetY: 3 })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('fits the orthographic sphere around the footprint at the pivot', () => {
|
|
55
|
+
const lookAt = computeFitSceneLookAt(sampleBounds, 3)
|
|
56
|
+
expect(lookAt.fitSphere.center).toEqual([lookAt.targetX, lookAt.targetY, lookAt.targetZ])
|
|
57
|
+
const [cx, cz] = sampleBounds.center
|
|
58
|
+
const [w, d] = sampleBounds.size
|
|
59
|
+
for (const [x, z] of [
|
|
60
|
+
[cx - w / 2, cz - d / 2],
|
|
61
|
+
[cx + w / 2, cz + d / 2],
|
|
62
|
+
[cx - w / 2, cz + d / 2],
|
|
63
|
+
] as const) {
|
|
64
|
+
expect(Math.hypot(x - cx, z - cz)).toBeLessThan(lookAt.fitSphere.radius)
|
|
65
|
+
}
|
|
66
|
+
const lone = computeFitSceneLookAt({ center: [0, 0], size: [1, 0.2] })
|
|
67
|
+
expect(lone.fitSphere.radius).toBe(5)
|
|
68
|
+
expect(computeFitSceneLookAt(null, 3).fitSphere).toEqual({ center: [0, 3, 0], radius: 15 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('an orthographic camera fitted to the sphere shows the whole footprint whatever its zoom was', () => {
|
|
72
|
+
const previous = globalThis.DOMRect
|
|
73
|
+
globalThis.DOMRect ??= class {} as typeof DOMRect
|
|
74
|
+
CameraControlsImpl.install({ THREE })
|
|
75
|
+
const camera = new THREE.OrthographicCamera(-8, 8, 5, -5, 0.1, 1000)
|
|
76
|
+
const control = new CameraControlsImpl(camera)
|
|
77
|
+
const bounds = { center: [60, -20] as [number, number], size: [80, 30] as [number, number] }
|
|
78
|
+
try {
|
|
79
|
+
// The previous project left the camera zoomed in on a small room.
|
|
80
|
+
control.zoomTo(12, false)
|
|
81
|
+
const lookAt = computeFitSceneLookAt(bounds)
|
|
82
|
+
control.setLookAt(
|
|
83
|
+
lookAt.eyeX,
|
|
84
|
+
lookAt.eyeY,
|
|
85
|
+
lookAt.eyeZ,
|
|
86
|
+
lookAt.targetX,
|
|
87
|
+
lookAt.targetY,
|
|
88
|
+
lookAt.targetZ,
|
|
89
|
+
false,
|
|
90
|
+
)
|
|
91
|
+
const sphere = new THREE.Sphere(
|
|
92
|
+
new THREE.Vector3(...lookAt.fitSphere.center),
|
|
93
|
+
lookAt.fitSphere.radius,
|
|
94
|
+
)
|
|
95
|
+
control.fitToSphere(sphere, false)
|
|
96
|
+
control.update(0)
|
|
97
|
+
camera.updateMatrixWorld(true)
|
|
98
|
+
|
|
99
|
+
const target = control.getTarget(new THREE.Vector3())
|
|
100
|
+
expect(target.toArray()).toEqual([lookAt.targetX, lookAt.targetY, lookAt.targetZ])
|
|
101
|
+
const [cx, cz] = bounds.center
|
|
102
|
+
const [w, d] = bounds.size
|
|
103
|
+
for (const x of [cx - w / 2, cx + w / 2])
|
|
104
|
+
for (const z of [cz - d / 2, cz + d / 2]) {
|
|
105
|
+
const projected = new THREE.Vector3(x, 0, z).project(camera)
|
|
106
|
+
expect(Math.abs(projected.x)).toBeLessThanOrEqual(1)
|
|
107
|
+
expect(Math.abs(projected.y)).toBeLessThanOrEqual(1)
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
control.dispose()
|
|
111
|
+
globalThis.DOMRect = previous
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('applies immediately when orbit controls are live', () => {
|
|
116
|
+
const plan = planFitSceneOnEvent({
|
|
117
|
+
isPreviewMode: false,
|
|
118
|
+
isFirstPersonMode: false,
|
|
119
|
+
hasControls: true,
|
|
120
|
+
bounds: sampleBounds,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(plan).toEqual({
|
|
124
|
+
action: 'apply',
|
|
125
|
+
lookAt: computeFitSceneLookAt(sampleBounds),
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('ignores fit-scene while preview mode is active', () => {
|
|
130
|
+
const plan = planFitSceneOnEvent({
|
|
131
|
+
isPreviewMode: true,
|
|
132
|
+
isFirstPersonMode: false,
|
|
133
|
+
hasControls: true,
|
|
134
|
+
bounds: sampleBounds,
|
|
135
|
+
})
|
|
136
|
+
expect(plan).toEqual({ action: 'ignore' })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('queues fit-scene while first-person is active (scene-ready during FP)', () => {
|
|
140
|
+
const plan = planFitSceneOnEvent({
|
|
141
|
+
isPreviewMode: false,
|
|
142
|
+
isFirstPersonMode: true,
|
|
143
|
+
hasControls: false,
|
|
144
|
+
bounds: sampleBounds,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
expect(plan).toEqual({
|
|
148
|
+
action: 'queue',
|
|
149
|
+
pending: { bounds: sampleBounds },
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('queues fit-scene when orbit controls are not mounted yet', () => {
|
|
154
|
+
const plan = planFitSceneOnEvent({
|
|
155
|
+
isPreviewMode: false,
|
|
156
|
+
isFirstPersonMode: false,
|
|
157
|
+
hasControls: false,
|
|
158
|
+
bounds: sampleBounds,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(plan).toEqual({
|
|
162
|
+
action: 'queue',
|
|
163
|
+
pending: { bounds: sampleBounds },
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('scene-ready during first-person then return to orbit applies the pending frame', () => {
|
|
168
|
+
// Mirrors the blocking lifecycle: fit arrives while FP is active, then
|
|
169
|
+
// orbit remounts and the queued frame must still apply.
|
|
170
|
+
let pending: PendingFitScene | null = null
|
|
171
|
+
|
|
172
|
+
const duringFirstPerson = planFitSceneOnEvent({
|
|
173
|
+
isPreviewMode: false,
|
|
174
|
+
isFirstPersonMode: true,
|
|
175
|
+
hasControls: false,
|
|
176
|
+
bounds: sampleBounds,
|
|
177
|
+
})
|
|
178
|
+
expect(duringFirstPerson.action).toBe('queue')
|
|
179
|
+
if (duringFirstPerson.action === 'queue') {
|
|
180
|
+
pending = duringFirstPerson.pending
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Still in first-person: controls are not ready, keep the pending frame.
|
|
184
|
+
expect(
|
|
185
|
+
planFitSceneOnOrbitResume({
|
|
186
|
+
isPreviewMode: false,
|
|
187
|
+
isFirstPersonMode: true,
|
|
188
|
+
hasControls: false,
|
|
189
|
+
pending,
|
|
190
|
+
}),
|
|
191
|
+
).toEqual({ action: 'noop' })
|
|
192
|
+
|
|
193
|
+
// Leave first-person; orbit controls remount and can apply the frame.
|
|
194
|
+
const afterOrbitResume = planFitSceneOnOrbitResume({
|
|
195
|
+
isPreviewMode: false,
|
|
196
|
+
isFirstPersonMode: false,
|
|
197
|
+
hasControls: true,
|
|
198
|
+
pending,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
expect(afterOrbitResume).toEqual({
|
|
202
|
+
action: 'apply',
|
|
203
|
+
lookAt: computeFitSceneLookAt(sampleBounds),
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('latest fit-scene while first-person wins when orbit resumes', () => {
|
|
208
|
+
let pending: PendingFitScene | null = null
|
|
209
|
+
const first = planFitSceneOnEvent({
|
|
210
|
+
isPreviewMode: false,
|
|
211
|
+
isFirstPersonMode: true,
|
|
212
|
+
hasControls: false,
|
|
213
|
+
bounds: null,
|
|
214
|
+
})
|
|
215
|
+
if (first.action === 'queue') pending = first.pending
|
|
216
|
+
|
|
217
|
+
const secondBounds = {
|
|
218
|
+
center: [0, 0] as [number, number],
|
|
219
|
+
size: [10, 10] as [number, number],
|
|
220
|
+
}
|
|
221
|
+
const second = planFitSceneOnEvent({
|
|
222
|
+
isPreviewMode: false,
|
|
223
|
+
isFirstPersonMode: true,
|
|
224
|
+
hasControls: false,
|
|
225
|
+
bounds: secondBounds,
|
|
226
|
+
})
|
|
227
|
+
if (second.action === 'queue') pending = second.pending
|
|
228
|
+
|
|
229
|
+
expect(
|
|
230
|
+
planFitSceneOnOrbitResume({
|
|
231
|
+
isPreviewMode: false,
|
|
232
|
+
isFirstPersonMode: false,
|
|
233
|
+
hasControls: true,
|
|
234
|
+
pending,
|
|
235
|
+
}),
|
|
236
|
+
).toEqual({
|
|
237
|
+
action: 'apply',
|
|
238
|
+
lookAt: computeFitSceneLookAt(secondBounds),
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('does not flush a pending frame into preview mode', () => {
|
|
243
|
+
expect(
|
|
244
|
+
planFitSceneOnOrbitResume({
|
|
245
|
+
isPreviewMode: true,
|
|
246
|
+
isFirstPersonMode: false,
|
|
247
|
+
hasControls: true,
|
|
248
|
+
pending: { bounds: sampleBounds },
|
|
249
|
+
}),
|
|
250
|
+
).toEqual({ action: 'noop' })
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('level follow', () => {
|
|
255
|
+
type Inputs = { levelId: string | null; levelMode: string; camera: object; sceneEmpty: boolean }
|
|
256
|
+
|
|
257
|
+
// The effect's bookkeeping: one instance per mounted CustomCameraControls.
|
|
258
|
+
function mountLevelFollow(initial: Inputs) {
|
|
259
|
+
let firstLoad = true
|
|
260
|
+
let previous = { ...initial }
|
|
261
|
+
return (next: Inputs) => {
|
|
262
|
+
const plan = planLevelFollow({
|
|
263
|
+
firstLoad,
|
|
264
|
+
sceneEmpty: next.sceneEmpty,
|
|
265
|
+
levelChanged: previous.levelId !== next.levelId,
|
|
266
|
+
modeChanged: previous.levelMode !== next.levelMode,
|
|
267
|
+
cameraChanged: previous.camera !== next.camera,
|
|
268
|
+
})
|
|
269
|
+
firstLoad = false
|
|
270
|
+
previous = { ...next }
|
|
271
|
+
return plan
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const perspective = {}
|
|
276
|
+
const loaded: Inputs = {
|
|
277
|
+
levelId: 'level_1',
|
|
278
|
+
levelMode: 'stacked',
|
|
279
|
+
camera: perspective,
|
|
280
|
+
sceneEmpty: false,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
test('an empty first load takes the default pose', () => {
|
|
284
|
+
const run = mountLevelFollow({ ...loaded, levelId: null, sceneEmpty: true })
|
|
285
|
+
expect(run({ ...loaded, levelId: null, sceneEmpty: true })).toBe('default-pose')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('a remount over a loaded scene rests the fresh pivot on the level', () => {
|
|
289
|
+
const run = mountLevelFollow(loaded)
|
|
290
|
+
expect(run(loaded)).toBe('rest-pivot')
|
|
291
|
+
expect(run(loaded)).toBe('none')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('a level-mode change pans the pivot', () => {
|
|
295
|
+
const run = mountLevelFollow(loaded)
|
|
296
|
+
run(loaded)
|
|
297
|
+
expect(run({ ...loaded, levelMode: 'exploded' })).toBe('rest-pivot')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('the first pick after a site-phase load pans to the level', () => {
|
|
301
|
+
const site = { ...loaded, levelId: null }
|
|
302
|
+
const run = mountLevelFollow(site)
|
|
303
|
+
run(site)
|
|
304
|
+
expect(run(loaded)).toBe('rest-pivot')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('re-picking the same level does not move the pivot', () => {
|
|
308
|
+
const run = mountLevelFollow(loaded)
|
|
309
|
+
run(loaded)
|
|
310
|
+
expect(run({ ...loaded })).toBe('none')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('a camera swap re-rests the new controls pivot', () => {
|
|
314
|
+
const run = mountLevelFollow(loaded)
|
|
315
|
+
run(loaded)
|
|
316
|
+
expect(run({ ...loaded, camera: {} })).toBe('rest-pivot')
|
|
317
|
+
})
|
|
318
|
+
})
|