@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.
Files changed (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. 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.7",
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.7",
22
- "@pascal-app/viewer": "^1.0.7",
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.7",
38
- "@pascal-app/ifc-converter": "^1.0.7",
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.7",
73
- "@pascal-app/viewer": "^1.0.7",
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 — the camera must pan to where the
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
- if (firstLoad.current) {
630
- firstLoad.current = false
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
- if (isFirstPersonMode || !controls.current || isPreviewMode) return
1392
- const groundY = orbitGroundY(lastLevelId.current)
1393
- if (!bounds) {
1394
- // Restore default framing pose when no bounds were computed.
1395
- controls.current.setLookAt(
1396
- 20,
1397
- keepEyeOverGround(20, 20 + groundY, 20, 20),
1398
- 20,
1399
- 0,
1400
- groundY,
1401
- 0,
1402
- true,
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
- const [cx, cz] = bounds.center
1407
- const [w, d] = bounds.size
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
- }, [camera, cancelPoseApplication, focusNode, isPreviewMode, isFirstPersonMode])
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 '../../lib/window-interaction'
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
+ })