@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
@@ -14,15 +14,33 @@ import {
14
14
  import {
15
15
  type HoverStyles,
16
16
  InteractiveSystem,
17
+ PERF_OVERLAY_ENABLED,
18
+ recordPerfSample,
17
19
  SceneEnvironment,
18
20
  useViewer,
19
21
  Viewer,
22
+ ViewerPresentations,
20
23
  } from '@pascal-app/viewer'
21
- import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
24
+ import {
25
+ memo,
26
+ Profiler,
27
+ type ProfilerOnRenderCallback,
28
+ type ReactNode,
29
+ useCallback,
30
+ useEffect,
31
+ useLayoutEffect,
32
+ useRef,
33
+ useState,
34
+ } from 'react'
22
35
  import { ViewerOverlay } from '../../components/viewer-overlay'
23
36
  import { ViewerZoneSystem } from '../../components/viewer-zone-system'
24
37
  import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
25
38
  import { useKeyboard } from '../../hooks/use-keyboard'
39
+ import { useSaveShortcut } from '../../hooks/use-save-shortcut'
40
+ import {
41
+ createLocalProjectPresentationPersistence,
42
+ type LocalProjectPresentationPersistence,
43
+ } from '../../lib/local-project-presentation-persistence'
26
44
  import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
27
45
  import {
28
46
  applySceneGraphToEditor,
@@ -31,8 +49,10 @@ import {
31
49
  writePersistedSelection,
32
50
  } from '../../lib/scene'
33
51
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
52
+ import { type CameraHintAction, useCameraHintFocus } from '../../store/use-camera-hint-focus'
34
53
  import useEditor from '../../store/use-editor'
35
54
  import useFloorplanMode from '../../store/use-floorplan-mode'
55
+ import useSessionGroups from '../../store/use-session-groups'
36
56
  import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
37
57
  import { CeilingSystem } from '../systems/ceiling/ceiling-system'
38
58
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
@@ -51,13 +71,16 @@ import { PanelManager } from '../ui/panels/panel-manager'
51
71
  import { ErrorBoundary } from '../ui/primitives/error-boundary'
52
72
  import { useSidebarStore } from '../ui/primitives/sidebar'
53
73
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
54
- import { SceneLoader } from '../ui/scene-loader'
74
+ import { SceneLoader, SceneLoadFailed } from '../ui/scene-loader'
55
75
  import { AppSidebar } from '../ui/sidebar/app-sidebar'
56
76
  import type { ExtraPanel } from '../ui/sidebar/icon-rail'
57
77
  import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
58
78
  import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
59
79
  import type { SidebarTab } from '../ui/sidebar/tab-bar'
60
80
  import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
81
+ import { ViewerStage } from '../viewer/viewer-stage'
82
+ import type { ViewerStageMode } from '../viewer/viewer-stage-modes'
83
+ import { CaptureCameraRig } from './capture-camera-rig'
61
84
  import { CustomCameraControls } from './custom-camera-controls'
62
85
  import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
63
86
  import { EditorLayoutV2 } from './editor-layout-v2'
@@ -85,6 +108,8 @@ import { WallMoveSideHandles } from './wall-move-side-handles'
85
108
  import { WallOpeningHighlights } from './wall-opening-highlights'
86
109
 
87
110
  const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
111
+ const PREVIEW_STAGE_SWITCHER_POSITION =
112
+ 'top-28 right-4 left-auto translate-x-0 md:top-4 md:right-auto md:left-1/2 md:-translate-x-1/2'
88
113
  const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
89
114
  const DELETE_CURSOR_BADGE_OFFSET_X = 14
90
115
  const DELETE_CURSOR_BADGE_OFFSET_Y = 14
@@ -93,7 +118,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
93
118
  const PAINT_CURSOR_BADGE_OFFSET_X = 14
94
119
  const PAINT_CURSOR_BADGE_OFFSET_Y = 14
95
120
  const SCENE_READY_FALLBACK_MS = 8000
121
+ const PRESENTATION_PROJECT_NOT_RESTORED = Symbol('presentation-project-not-restored')
122
+ const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
96
123
  type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
124
+ const recordEditorRender: ProfilerOnRenderCallback = (_id, _phase, actualDuration) => {
125
+ if (PERF_OVERLAY_ENABLED) recordPerfSample('react-render', actualDuration)
126
+ }
97
127
  const EDITOR_HOVER_STYLES: HoverStyles = {
98
128
  default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
99
129
  delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -173,6 +203,12 @@ export interface EditorProps {
173
203
  // Persistence — defaults to localStorage when omitted
174
204
  onLoad?: () => Promise<SceneGraph | null>
175
205
  onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
206
+ /**
207
+ * Cmd/Ctrl+S. Return true when the host handled the save (the community
208
+ * version checkpoint); anything else falls through to flushing the autosave,
209
+ * so the chord still saves when the host's control isn't mounted.
210
+ */
211
+ onSaveShortcut?: () => boolean | undefined
176
212
  onDirty?: () => void
177
213
  onSaveStatusChange?: (status: SaveStatus) => void
178
214
 
@@ -190,6 +226,13 @@ export interface EditorProps {
190
226
  // Thumbnail
191
227
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
192
228
 
229
+ /**
230
+ * When true, skip the viewer post-FX pipeline (same as `?disable=postFx`).
231
+ * Hosts use this for a stable local "light preview" without relying only on
232
+ * module-load URL flags or shading toggles.
233
+ */
234
+ disablePostFx?: boolean
235
+
193
236
  // Version preview overlays (rendered by host app)
194
237
  sidebarOverlay?: ReactNode
195
238
  viewerBanner?: ReactNode
@@ -368,7 +411,7 @@ type ShortcutKey = {
368
411
  }
369
412
 
370
413
  type CameraControlHint = {
371
- action: string
414
+ action: CameraHintAction
372
415
  keys: ShortcutKey[]
373
416
  alternativeKeys?: ShortcutKey[]
374
417
  }
@@ -502,7 +545,15 @@ function ViewerCanvasControlsHint({
502
545
  isPreviewMode: boolean
503
546
  onDismiss: () => void
504
547
  }) {
505
- const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
548
+ const all = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
549
+ // A host teaching one gesture at a time narrows this to the one it is asking
550
+ // for, and to nothing once it is done. Null — the default — is all of them.
551
+ const focus = useCameraHintFocus((state) => state.actions)
552
+ const hints = focus === null ? all : all.filter((hint) => focus.includes(hint.action))
553
+
554
+ if (hints.length === 0) {
555
+ return null
556
+ }
506
557
 
507
558
  return (
508
559
  <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
@@ -510,7 +561,10 @@ function ViewerCanvasControlsHint({
510
561
  aria-label="Camera controls hint"
511
562
  className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
512
563
  >
513
- <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
564
+ <div
565
+ className="grid min-w-0 flex-1 items-start divide-x divide-border/18"
566
+ style={{ gridTemplateColumns: `repeat(${hints.length}, minmax(0, 1fr))` }}
567
+ >
514
568
  {hints.map((hint) => (
515
569
  <CameraControlHintItem hint={hint} key={hint.action} />
516
570
  ))}
@@ -731,6 +785,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
731
785
  isStudioMode,
732
786
  onThumbnailCapture,
733
787
  viewerSceneSlot,
788
+ presentationsReady,
734
789
  }: {
735
790
  isVersionPreviewMode: boolean
736
791
  isLoading: boolean
@@ -738,6 +793,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
738
793
  isStudioMode: boolean
739
794
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
740
795
  viewerSceneSlot?: ReactNode
796
+ presentationsReady: boolean
741
797
  }) {
742
798
  // Studio mode is a clean render/snapshot surface — no selection or editing
743
799
  // affordances. It mirrors version-preview's chrome gating on the canvas.
@@ -772,10 +828,12 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
772
828
  {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
773
829
  {!(isLoading || noEditing) && <ToolManager />}
774
830
  {isFirstPersonMode && <FirstPersonControls />}
831
+ {isCaptureMode && <CaptureCameraRig />}
775
832
  <CustomCameraControls />
776
833
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
777
834
  {!isFirstPersonMode && <SiteEdgeLabels />}
778
835
  <InteractiveSystem />
836
+ {presentationsReady ? <ViewerPresentations /> : null}
779
837
  {!noEditing && viewerSceneSlot}
780
838
  </>
781
839
  )
@@ -959,8 +1017,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
959
1017
  sceneReadyKey,
960
1018
  onSceneReadyChange,
961
1019
  onThumbnailCapture,
1020
+ presentationsReady,
962
1021
  viewerSceneSlot,
963
1022
  floorplanSceneSlot,
1023
+ disablePostFx = false,
964
1024
  }: {
965
1025
  isVersionPreviewMode: boolean
966
1026
  isLoading: boolean
@@ -971,13 +1031,16 @@ const ViewerCanvas = memo(function ViewerCanvas({
971
1031
  sceneReadyKey: number
972
1032
  onSceneReadyChange: (ready: boolean) => void
973
1033
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
1034
+ presentationsReady: boolean
974
1035
  viewerSceneSlot?: ReactNode
975
1036
  floorplanSceneSlot?: ReactNode
1037
+ disablePostFx?: boolean
976
1038
  }) {
977
1039
  const viewMode = useEditor((s) => s.viewMode)
978
1040
  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
979
1041
  const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
980
1042
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
1043
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
981
1044
 
982
1045
  const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
983
1046
  null,
@@ -1086,11 +1149,16 @@ const ViewerCanvas = memo(function ViewerCanvas({
1086
1149
  <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
1087
1150
  <Viewer
1088
1151
  defaultRender={EDITOR_DEFAULT_RENDER}
1152
+ disablePostFx={disablePostFx}
1089
1153
  hoverStyles={EDITOR_HOVER_STYLES}
1090
1154
  onSceneReadyChange={onSceneReadyChange}
1091
1155
  renderContext="editor"
1156
+ renderPaused={!show3d && !showLoader}
1092
1157
  sceneReadyKey={sceneReadyKey}
1093
- selectionManager={isFirstPersonMode ? 'default' : 'custom'}
1158
+ // Walk/drone framing during snapshot capture is camera-only: the
1159
+ // viewer's default selection manager would hover-highlight whatever
1160
+ // the cursor crosses, which orbit capture never does.
1161
+ selectionManager={isFirstPersonMode && !isCaptureMode ? 'default' : 'custom'}
1094
1162
  >
1095
1163
  <ViewerSceneContent
1096
1164
  isFirstPersonMode={isFirstPersonMode}
@@ -1098,6 +1166,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1098
1166
  isStudioMode={isStudioMode}
1099
1167
  isVersionPreviewMode={isVersionPreviewMode}
1100
1168
  onThumbnailCapture={onThumbnailCapture}
1169
+ presentationsReady={presentationsReady}
1101
1170
  viewerSceneSlot={viewerSceneSlot}
1102
1171
  />
1103
1172
  </Viewer>
@@ -1108,7 +1177,61 @@ const ViewerCanvas = memo(function ViewerCanvas({
1108
1177
  )
1109
1178
  })
1110
1179
 
1111
- export default function Editor({
1180
+ function PreviewStage({
1181
+ isFirstPersonMode,
1182
+ mode,
1183
+ onModeChange,
1184
+ showLoader,
1185
+ viewerContent,
1186
+ }: {
1187
+ isFirstPersonMode: boolean
1188
+ mode: ViewerStageMode
1189
+ onModeChange: (mode: ViewerStageMode) => void
1190
+ showLoader: boolean
1191
+ viewerContent: ReactNode
1192
+ }) {
1193
+ const hasFloorplan = useScene((state) =>
1194
+ Object.values(state.nodes).some((node) => node.type === 'level'),
1195
+ )
1196
+
1197
+ const handleModeChange = useCallback(
1198
+ (nextMode: ViewerStageMode) => {
1199
+ if (nextMode !== '3d') useEditor.getState().setFirstPersonMode(false)
1200
+ onModeChange(nextMode)
1201
+ },
1202
+ [onModeChange],
1203
+ )
1204
+
1205
+ const stageMode = isFirstPersonMode || !hasFloorplan ? '3d' : mode
1206
+ const stageModes = hasFloorplan && !isFirstPersonMode ? undefined : (['3d'] as const)
1207
+
1208
+ return (
1209
+ <div className="dark relative h-full w-full overflow-hidden bg-neutral-100 text-foreground">
1210
+ {isFirstPersonMode ? (
1211
+ <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1212
+ ) : (
1213
+ <ViewerOverlay
1214
+ hideBottomBar={stageMode !== '3d'}
1215
+ onBack={() => useEditor.getState().setPreviewMode(false)}
1216
+ />
1217
+ )}
1218
+
1219
+ <ViewerStage
1220
+ className="absolute inset-0"
1221
+ mode={stageMode}
1222
+ modes={stageModes}
1223
+ onModeChange={handleModeChange}
1224
+ showCompass={hasFloorplan && !isFirstPersonMode}
1225
+ showSwitcher={hasFloorplan && !isFirstPersonMode}
1226
+ switcherClassName={`${PREVIEW_STAGE_SWITCHER_POSITION} ${showLoader ? 'z-[70]' : ''}`}
1227
+ >
1228
+ {viewerContent}
1229
+ </ViewerStage>
1230
+ </div>
1231
+ )
1232
+ }
1233
+
1234
+ function EditorContent({
1112
1235
  layoutVersion = 'v1',
1113
1236
  appMenuButton,
1114
1237
  sidebarTop,
@@ -1124,6 +1247,7 @@ export default function Editor({
1124
1247
  projectId,
1125
1248
  onLoad,
1126
1249
  onSave,
1250
+ onSaveShortcut,
1127
1251
  onDirty,
1128
1252
  onSaveStatusChange,
1129
1253
  previewScene,
@@ -1131,6 +1255,7 @@ export default function Editor({
1131
1255
  isLoading = false,
1132
1256
  onLoaderChange,
1133
1257
  onThumbnailCapture,
1258
+ disablePostFx = false,
1134
1259
  sidebarOverlay,
1135
1260
  viewerBanner,
1136
1261
  settingsPanelProps,
@@ -1140,20 +1265,54 @@ export default function Editor({
1140
1265
  }: EditorProps) {
1141
1266
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
1142
1267
  const isStudioMode = useEditor((s) => s.workspaceMode === 'studio')
1268
+ const presentationProjectId = projectId ?? null
1269
+ const presentationPersistenceRef = useRef<LocalProjectPresentationPersistence | null>(null)
1270
+ const [restoredPresentationProjectId, setRestoredPresentationProjectId] = useState<
1271
+ string | null | typeof PRESENTATION_PROJECT_NOT_RESTORED
1272
+ >(PRESENTATION_PROJECT_NOT_RESTORED)
1273
+ const presentationsReady = restoredPresentationProjectId === presentationProjectId
1274
+
1275
+ useClientLayoutEffect(() => {
1276
+ const persistence = createLocalProjectPresentationPersistence()
1277
+ presentationPersistenceRef.current = persistence
1278
+ return () => {
1279
+ presentationPersistenceRef.current = null
1280
+ persistence.dispose()
1281
+ }
1282
+ }, [])
1283
+
1284
+ useClientLayoutEffect(() => {
1285
+ const persistence = presentationPersistenceRef.current
1286
+ if (!persistence) return
1287
+ persistence.switchProject(presentationProjectId)
1288
+ setRestoredPresentationProjectId(presentationProjectId)
1289
+ }, [presentationProjectId])
1143
1290
 
1144
1291
  useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode })
1145
1292
 
1146
- const { isLoadingSceneRef } = useAutoSave({
1293
+ const { isLoadingSceneRef, saveNow } = useAutoSave({
1147
1294
  onSave,
1148
1295
  onDirty,
1149
1296
  onSaveStatusChange,
1150
1297
  isVersionPreviewMode,
1151
1298
  })
1152
1299
 
1300
+ const handleSaveShortcut = useCallback(() => {
1301
+ if (onSaveShortcut?.() === true) return
1302
+ saveNow()
1303
+ }, [onSaveShortcut, saveNow])
1304
+ useSaveShortcut(handleSaveShortcut)
1305
+
1153
1306
  const [isSceneLoading, setIsSceneLoading] = useState(false)
1154
1307
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
1308
+ // A failed `onLoad` is shown as an error with a retry, never as an empty
1309
+ // scene: an editor that renders the default scaffold after a failed load
1310
+ // autosaves that scaffold over the real project.
1311
+ const [sceneLoadError, setSceneLoadError] = useState<unknown>(null)
1312
+ const [sceneLoadAttempt, setSceneLoadAttempt] = useState(0)
1155
1313
  const [sceneReadyKey, setSceneReadyKey] = useState(0)
1156
1314
  const [isViewerSceneReady, setIsViewerSceneReady] = useState(false)
1315
+ const [previewStageMode, setPreviewStageMode] = useState<ViewerStageMode>('3d')
1157
1316
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
1158
1317
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
1159
1318
 
@@ -1186,18 +1345,23 @@ export default function Editor({
1186
1345
  }
1187
1346
  }, [projectId])
1188
1347
 
1189
- // Load scene on mount (or when onLoad identity changes, e.g. project switch)
1348
+ // Load on mount, project switches, and explicit retry attempts.
1190
1349
  useEffect(() => {
1350
+ void sceneLoadAttempt
1191
1351
  let cancelled = false
1192
1352
 
1193
1353
  async function load() {
1194
1354
  isLoadingSceneRef.current = true
1355
+ setSceneLoadError(null)
1195
1356
  setHasLoadedInitialScene(false)
1196
1357
  setIsViewerSceneReady(false)
1197
1358
  setIsSceneLoading(true)
1198
1359
  useScene.getState().unloadScene()
1199
1360
  useViewer.getState().resetSelection()
1361
+ // Session groups are not scene-graph state — clear on every load/switch.
1362
+ useSessionGroups.getState().clearGroups()
1200
1363
 
1364
+ let failed = false
1201
1365
  try {
1202
1366
  const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
1203
1367
  if (!cancelled) {
@@ -1205,19 +1369,23 @@ export default function Editor({
1205
1369
  setIsViewerSceneReady(false)
1206
1370
  setSceneReadyKey((key) => key + 1)
1207
1371
  }
1208
- } catch {
1372
+ } catch (error) {
1373
+ // Leave the store unloaded and the autosave loop in its loading
1374
+ // state: nothing may be written until a load actually succeeds.
1375
+ failed = true
1209
1376
  if (!cancelled) {
1210
- applySceneGraphToEditor(null)
1211
- setIsViewerSceneReady(false)
1212
- setSceneReadyKey((key) => key + 1)
1377
+ console.error('[editor] scene load failed', error)
1378
+ setSceneLoadError(error ?? new Error('Scene load failed'))
1213
1379
  }
1214
1380
  } finally {
1215
1381
  if (!cancelled) {
1216
1382
  setIsSceneLoading(false)
1217
- setHasLoadedInitialScene(true)
1218
- requestAnimationFrame(() => {
1219
- isLoadingSceneRef.current = false
1220
- })
1383
+ if (!failed) {
1384
+ setHasLoadedInitialScene(true)
1385
+ requestAnimationFrame(() => {
1386
+ isLoadingSceneRef.current = false
1387
+ })
1388
+ }
1221
1389
  }
1222
1390
  }
1223
1391
  }
@@ -1227,11 +1395,18 @@ export default function Editor({
1227
1395
  return () => {
1228
1396
  cancelled = true
1229
1397
  }
1230
- }, [onLoad, isLoadingSceneRef])
1398
+ }, [onLoad, isLoadingSceneRef, sceneLoadAttempt])
1399
+
1400
+ const retrySceneLoad = useCallback(() => {
1401
+ setSceneLoadAttempt((attempt) => attempt + 1)
1402
+ }, [])
1231
1403
 
1232
1404
  // Apply preview scene when version preview mode changes
1233
1405
  useEffect(() => {
1234
1406
  if (isVersionPreviewMode && previewScene) {
1407
+ // Drop session groups from the edit session so plain-click expand cannot
1408
+ // pull in members that are not part of the preview graph.
1409
+ useSessionGroups.getState().clearGroups()
1235
1410
  applySceneGraphToEditor(previewScene)
1236
1411
  }
1237
1412
  }, [isVersionPreviewMode, previewScene])
@@ -1244,6 +1419,10 @@ export default function Editor({
1244
1419
  return releaseReadOnly
1245
1420
  }, [isVersionPreviewMode])
1246
1421
 
1422
+ useEffect(() => {
1423
+ if (!isPreviewMode) setPreviewStageMode('3d')
1424
+ }, [isPreviewMode])
1425
+
1247
1426
  useEffect(() => {
1248
1427
  document.body.classList.add('dark')
1249
1428
  return () => {
@@ -1269,10 +1448,19 @@ export default function Editor({
1269
1448
  }, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey])
1270
1449
 
1271
1450
  const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady
1451
+ const visibleLoader =
1452
+ showLoader &&
1453
+ !(
1454
+ isPreviewMode &&
1455
+ previewStageMode === '2d' &&
1456
+ !isLoading &&
1457
+ !isSceneLoading &&
1458
+ hasLoadedInitialScene
1459
+ )
1272
1460
 
1273
1461
  useEffect(() => {
1274
- onLoaderChange?.(showLoader)
1275
- }, [showLoader, onLoaderChange])
1462
+ onLoaderChange?.(visibleLoader)
1463
+ }, [visibleLoader, onLoaderChange])
1276
1464
 
1277
1465
  const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
1278
1466
  const wasFirstPersonModeRef = useRef(isFirstPersonMode)
@@ -1313,6 +1501,7 @@ export default function Editor({
1313
1501
  const previewViewerContent = (
1314
1502
  <Viewer
1315
1503
  defaultRender={EDITOR_DEFAULT_RENDER}
1504
+ disablePostFx={disablePostFx}
1316
1505
  hoverStyles={EDITOR_HOVER_STYLES}
1317
1506
  renderContext="editor"
1318
1507
  selectionManager="default"
@@ -1326,11 +1515,13 @@ export default function Editor({
1326
1515
  <CustomCameraControls />
1327
1516
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
1328
1517
  <InteractiveSystem />
1518
+ {presentationsReady ? <ViewerPresentations /> : null}
1329
1519
  </Viewer>
1330
1520
  )
1331
1521
 
1332
1522
  const viewerCanvas = (
1333
1523
  <ViewerCanvas
1524
+ disablePostFx={disablePostFx}
1334
1525
  hasLoadedInitialScene={hasLoadedInitialScene}
1335
1526
  isFirstPersonMode={isFirstPersonMode}
1336
1527
  isLoading={isLoading}
@@ -1338,6 +1529,7 @@ export default function Editor({
1338
1529
  isVersionPreviewMode={isVersionPreviewMode}
1339
1530
  onSceneReadyChange={handleSceneReadyChange}
1340
1531
  onThumbnailCapture={onThumbnailCapture}
1532
+ presentationsReady={presentationsReady}
1341
1533
  sceneReadyKey={sceneReadyKey}
1342
1534
  showLoader={showLoader}
1343
1535
  viewerSceneSlot={viewerSceneSlot}
@@ -1374,12 +1566,13 @@ export default function Editor({
1374
1566
  }
1375
1567
 
1376
1568
  const tabBarTabs = [
1377
- ...(sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({
1569
+ ...(sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon, noPanel }) => ({
1378
1570
  id,
1379
1571
  label,
1380
1572
  mobileDefaultSnap,
1381
1573
  mobileIcon,
1382
1574
  icon,
1575
+ noPanel,
1383
1576
  })) ?? []),
1384
1577
  // Host panels appear after the explicit tabs in the rail. The icon
1385
1578
  // doubles as the mobile icon; a half-height sheet is a sensible default.
@@ -1395,23 +1588,24 @@ export default function Editor({
1395
1588
  return (
1396
1589
  <>
1397
1590
  <FloorplanModeCoordinator />
1398
- {showLoader && (
1591
+ {visibleLoader && (
1399
1592
  <div className="fixed inset-0 z-60">
1400
- <SceneLoader className="bg-background" />
1593
+ {sceneLoadError ? (
1594
+ <SceneLoadFailed className="bg-background" onRetry={retrySceneLoad} />
1595
+ ) : (
1596
+ <SceneLoader className="bg-background" />
1597
+ )}
1401
1598
  </div>
1402
1599
  )}
1403
1600
 
1404
1601
  {!isLoading && isPreviewMode ? (
1405
- <div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
1406
- {isFirstPersonMode ? (
1407
- <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1408
- ) : (
1409
- <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1410
- )}
1411
- <div className="h-full w-full" data-pascal-viewer-3d>
1412
- {previewViewerContent}
1413
- </div>
1414
- </div>
1602
+ <PreviewStage
1603
+ isFirstPersonMode={isFirstPersonMode}
1604
+ mode={previewStageMode}
1605
+ onModeChange={setPreviewStageMode}
1606
+ showLoader={visibleLoader}
1607
+ viewerContent={previewViewerContent}
1608
+ />
1415
1609
  ) : (
1416
1610
  <>
1417
1611
  <EditorLayoutV2
@@ -1437,7 +1631,10 @@ export default function Editor({
1437
1631
  <HelperManager />
1438
1632
  </div>
1439
1633
  )}
1440
- {isFirstPersonMode && (
1634
+ {/* Capture mode drives walk / drone from its own overlay, which
1635
+ owns the framing chrome — the walkthrough HUD would both
1636
+ clutter the frame and offer a second, conflicting exit. */}
1637
+ {isFirstPersonMode && !isCaptureMode && (
1441
1638
  <FirstPersonOverlay
1442
1639
  onExit={() => useEditor.getState().setFirstPersonMode(false)}
1443
1640
  />
@@ -1471,23 +1668,24 @@ export default function Editor({
1471
1668
  return (
1472
1669
  <div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
1473
1670
  <FloorplanModeCoordinator />
1474
- {showLoader && (
1671
+ {visibleLoader && (
1475
1672
  <div className="fixed inset-0 z-60">
1476
- <SceneLoader className="bg-background" />
1673
+ {sceneLoadError ? (
1674
+ <SceneLoadFailed className="bg-background" onRetry={retrySceneLoad} />
1675
+ ) : (
1676
+ <SceneLoader className="bg-background" />
1677
+ )}
1477
1678
  </div>
1478
1679
  )}
1479
1680
 
1480
1681
  {!isLoading && isPreviewMode ? (
1481
- <>
1482
- {isFirstPersonMode ? (
1483
- <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1484
- ) : (
1485
- <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1486
- )}
1487
- <div className="h-full w-full" data-pascal-viewer-3d>
1488
- {previewViewerContent}
1489
- </div>
1490
- </>
1682
+ <PreviewStage
1683
+ isFirstPersonMode={isFirstPersonMode}
1684
+ mode={previewStageMode}
1685
+ onModeChange={setPreviewStageMode}
1686
+ showLoader={visibleLoader}
1687
+ viewerContent={previewViewerContent}
1688
+ />
1491
1689
  ) : (
1492
1690
  <>
1493
1691
  {/* Sidebar */}
@@ -1526,3 +1724,11 @@ export default function Editor({
1526
1724
  </div>
1527
1725
  )
1528
1726
  }
1727
+
1728
+ export default function Editor(props: EditorProps) {
1729
+ return (
1730
+ <Profiler id="editor" onRender={recordEditorRender}>
1731
+ <EditorContent {...props} />
1732
+ </Profiler>
1733
+ )
1734
+ }
@@ -1,23 +1,17 @@
1
1
  'use client'
2
2
 
3
+ import { useViewer } from '@pascal-app/viewer'
3
4
  import { type ForwardedRef, Fragment, forwardRef } from 'react'
5
+ import { formatLinearMeasurement, type MetricNotation } from '../../lib/measurements'
4
6
 
5
7
  // Canonical in-world dimension formatter — metric metres or imperial
6
8
  // feet/inches. Shared by every measurement readout so they read the same.
7
9
  export function formatMeasurement(
8
10
  value: number,
9
11
  unit: 'metric' | 'imperial',
10
- metricNotation: 'meters' | 'millimeters' = 'meters',
12
+ metricNotation: MetricNotation = 'meters',
11
13
  ): string {
12
- if (unit === 'imperial') {
13
- const feet = value * 3.280_84
14
- const wholeFeet = Math.floor(feet)
15
- const inches = Math.round((feet - wholeFeet) * 12)
16
- if (inches === 12) return `${wholeFeet + 1}'0"`
17
- return `${wholeFeet}'${inches}"`
18
- }
19
- if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm`
20
- return `${Number.parseFloat(value.toFixed(2))}m`
14
+ return formatLinearMeasurement(value, unit, metricNotation)
21
15
  }
22
16
 
23
17
  type MeasurePart = 'height' | 'length' | 'thickness'
@@ -55,12 +49,18 @@ export function DimensionPill({
55
49
  primary?: string
56
50
  primaryRef?: ForwardedRef<HTMLSpanElement>
57
51
  }) {
52
+ const metricNotation = useViewer((state) => state.metricNotation)
53
+
58
54
  return (
59
55
  <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
60
56
  {parts.map((part, index) => {
61
57
  const text = part.signed
62
- ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}`
63
- : formatMeasurement(part.value, unit)
58
+ ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(
59
+ Math.abs(part.value),
60
+ unit,
61
+ metricNotation,
62
+ )}`
63
+ : formatMeasurement(part.value, unit, metricNotation)
64
64
  return (
65
65
  <Fragment key={part.key}>
66
66
  {index > 0 ? (