@pascal-app/editor 1.0.0-beta.4 → 1.0.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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -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
+ }
@@ -9,7 +9,7 @@ import {
9
9
  useScene,
10
10
  type ZoneNode,
11
11
  } from '@pascal-app/core'
12
- import { useViewer } from '@pascal-app/viewer'
12
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
13
13
  import { ArrowLeft, ChevronRight, Layers } from 'lucide-react'
14
14
  import Link from 'next/link'
15
15
  import type { ReactNode } from 'react'
@@ -76,6 +76,7 @@ export const ViewerSceneHeader = ({
76
76
 
77
77
  const handleLevelClick = (levelId: LevelNode['id']) => {
78
78
  // When switching levels, deselect zone and items
79
+ if (levelId !== selection.levelId) markPerfAction('level-switch', levelId)
79
80
  useViewer.getState().setSelection({ levelId })
80
81
  }
81
82
 
@@ -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,221 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { markPerfAction, 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
+ if (notify && nextLevelId !== useViewer.getState().selection.levelId) {
138
+ markPerfAction('level-switch', nextLevelId)
139
+ }
140
+ selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
141
+ if (notify) onLevelChange?.(nextLevelId)
142
+ },
143
+ [onLevelChange, scene],
144
+ )
145
+
146
+ useEffect(() => {
147
+ if (activeMode === '3d' || levelIds.length === 0) return
148
+ const nextLevelId =
149
+ (levelId && levelIds.includes(levelId) ? levelId : null) ??
150
+ (selectedLevelId && levelIds.includes(selectedLevelId) ? selectedLevelId : null) ??
151
+ (internalLevelId && levelIds.includes(internalLevelId) ? internalLevelId : null) ??
152
+ levelIds[0] ??
153
+ null
154
+ if (nextLevelId) chooseLevel(nextLevelId, false)
155
+ }, [activeMode, chooseLevel, internalLevelId, levelId, levelIds, selectedLevelId])
156
+
157
+ return (
158
+ <div
159
+ className={cn('relative h-full w-full overflow-hidden bg-neutral-100', className)}
160
+ data-pascal-navigation-sync={synchronizeNavigation ? 'on' : 'off'}
161
+ data-pascal-viewer-stage={activeMode}
162
+ >
163
+ {showCompass && compassHost === undefined ? (
164
+ <div className="pointer-events-none absolute inset-0 z-30" ref={setInternalCompassHost} />
165
+ ) : null}
166
+
167
+ {showSwitcher && enabledModes.length > 1 ? (
168
+ <ViewerStageSwitcher
169
+ className={switcherClassName}
170
+ hideSplitOnMobile={collapseSplitOnMobile}
171
+ mode={activeMode}
172
+ modes={enabledModes}
173
+ onChange={requestMode}
174
+ />
175
+ ) : null}
176
+
177
+ <div
178
+ className={
179
+ activeMode === 'split'
180
+ ? 'absolute inset-0 grid grid-rows-2 md:grid-cols-2 md:grid-rows-1'
181
+ : 'absolute inset-0'
182
+ }
183
+ >
184
+ {threeDEnabled ? (
185
+ <div
186
+ className={cn(
187
+ activeMode === '2d'
188
+ ? 'pointer-events-none invisible absolute inset-0 h-full w-full'
189
+ : 'relative h-full min-h-0 w-full min-w-0',
190
+ threeDClassName,
191
+ )}
192
+ data-pascal-viewer-3d
193
+ >
194
+ {children}
195
+ </div>
196
+ ) : null}
197
+
198
+ {mountFloorplan ? (
199
+ <FloorplanPreview
200
+ className={cn(
201
+ activeMode === '3d'
202
+ ? 'hidden'
203
+ : activeMode === 'split'
204
+ ? 'min-h-0 min-w-0 border-border border-t md:border-t-0 md:border-l'
205
+ : 'h-full w-full',
206
+ floorplanClassName,
207
+ )}
208
+ compassHost={resolvedCompassHost}
209
+ levelId={levelId ?? internalLevelId}
210
+ navigationVisible={activeMode !== '3d'}
211
+ onLevelChange={chooseLevel}
212
+ scene={scene}
213
+ showCompass={showCompass}
214
+ showLevelSelector={showLevelSelector}
215
+ synchronizeNavigation={synchronizeNavigation}
216
+ />
217
+ ) : null}
218
+ </div>
219
+ </div>
220
+ )
221
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { flushSync } from 'react-dom'
4
+ import { requestWalkthroughPointerLock } from '../lib/walkthrough-pointer-lock'
4
5
  import useEditor from '../store/use-editor'
5
6
  import { ViewerControlsBar } from './viewer/viewer-controls-bar'
6
7
  import { ViewerSceneHeader } from './viewer/viewer-scene-header'
@@ -12,33 +13,12 @@ type ProjectOwner = {
12
13
  image: string | null
13
14
  }
14
15
 
15
- function requestWalkthroughPointerLock() {
16
- const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
17
- if (!canvas) return
18
-
19
- if (!canvas.hasAttribute('tabindex')) {
20
- canvas.tabIndex = -1
21
- }
22
- canvas.focus({ preventScroll: true })
23
-
24
- if (document.pointerLockElement === canvas) return
25
-
26
- try {
27
- // The request can also reject ASYNC (browser cooldown after a recent
28
- // unlock) — swallow it like the P-resume path; clicking the canvas
29
- // re-requests once the cooldown passes.
30
- const result = canvas.requestPointerLock?.() as Promise<void> | undefined
31
- if (result && typeof result.catch === 'function') result.catch(() => {})
32
- } catch {
33
- return
34
- }
35
- }
36
-
37
16
  interface ViewerOverlayProps {
38
17
  projectName?: string | null
39
18
  owner?: ProjectOwner | null
40
19
  canShowScans?: boolean
41
20
  canShowGuides?: boolean
21
+ hideBottomBar?: boolean
42
22
  onBack?: () => void
43
23
  }
44
24
 
@@ -47,17 +27,20 @@ export const ViewerOverlay = ({
47
27
  owner,
48
28
  canShowScans = true,
49
29
  canShowGuides = true,
30
+ hideBottomBar = false,
50
31
  onBack,
51
32
  }: ViewerOverlayProps) => (
52
33
  <>
53
34
  <ViewerSceneHeader onBack={onBack} owner={owner} projectName={projectName} />
54
- <ViewerControlsBar
55
- canShowGuides={canShowGuides}
56
- canShowScans={canShowScans}
57
- onWalkthroughToggle={() => {
58
- flushSync(() => useEditor.getState().setFirstPersonMode(true))
59
- requestWalkthroughPointerLock()
60
- }}
61
- />
35
+ {!hideBottomBar ? (
36
+ <ViewerControlsBar
37
+ canShowGuides={canShowGuides}
38
+ canShowScans={canShowScans}
39
+ onWalkthroughToggle={() => {
40
+ flushSync(() => useEditor.getState().setFirstPersonMode(true))
41
+ requestWalkthroughPointerLock()
42
+ }}
43
+ />
44
+ ) : null}
62
45
  </>
63
46
  )