@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,78 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ cascadeDirty,
5
+ type HandleDragModifiers,
6
+ type LinearResizeHandle,
7
+ type RadialResizeHandle,
8
+ type SceneApi,
9
+ useLiveNodeOverrides,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { replacePreviewOverrideIds } from './preview-overrides'
13
+
14
+ export function linearResizeFactor<N>(
15
+ descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
16
+ ): number {
17
+ if (descriptor.kind === 'radial-resize') return 1
18
+ return descriptor.anchor === 'center' ? 2 : descriptor.anchor === 'min' ? 1 : -1
19
+ }
20
+
21
+ export function createLinearResizeDragBinding({
22
+ descriptor,
23
+ initialNode,
24
+ nodeId,
25
+ sceneApi,
26
+ initialModifiers,
27
+ }: {
28
+ descriptor: LinearResizeHandle<AnyNode>
29
+ initialNode: AnyNode
30
+ nodeId: AnyNodeId
31
+ sceneApi: SceneApi
32
+ initialModifiers: HandleDragModifiers
33
+ }) {
34
+ const overrideId = descriptor.overrideTarget?.(initialNode, sceneApi) ?? nodeId
35
+ let lastModifiers = initialModifiers
36
+ let previewOverrideIds = new Set<AnyNodeId>()
37
+
38
+ return {
39
+ overrideId,
40
+ commit: descriptor.commit
41
+ ? (patch: Partial<AnyNode>) =>
42
+ descriptor.commit?.(initialNode, patch, sceneApi, lastModifiers)
43
+ : undefined,
44
+ apply(next: number, modifiers: HandleDragModifiers): Partial<AnyNode> {
45
+ lastModifiers = modifiers
46
+ const patch = descriptor.apply(initialNode, next, sceneApi, modifiers) as Partial<AnyNode>
47
+ const previewEntries = descriptor.previewOverrides?.(initialNode, next, sceneApi, modifiers)
48
+ if (!previewEntries) return patch
49
+
50
+ previewOverrideIds = replacePreviewOverrideIds(
51
+ previewOverrideIds,
52
+ previewEntries,
53
+ (previewId) => {
54
+ useLiveNodeOverrides.getState().clear(previewId)
55
+ useScene.getState().markDirty(previewId)
56
+ },
57
+ )
58
+ useLiveNodeOverrides
59
+ .getState()
60
+ .setMany(
61
+ previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record<string, unknown>]),
62
+ )
63
+ for (const [previewId] of previewEntries) {
64
+ for (const id of cascadeDirty(previewId, { scene: sceneApi }))
65
+ useScene.getState().markDirty(id)
66
+ }
67
+ return patch
68
+ },
69
+ clearPreview(): void {
70
+ for (const previewId of previewOverrideIds) {
71
+ useLiveNodeOverrides.getState().clear(previewId)
72
+ for (const id of cascadeDirty(previewId, { scene: sceneApi }))
73
+ useScene.getState().markDirty(id)
74
+ }
75
+ previewOverrideIds = new Set()
76
+ },
77
+ }
78
+ }
@@ -49,4 +49,50 @@ describe('resolveResizeSnapValue', () => {
49
49
  ).toBe(0.56)
50
50
  expect(magneticSnap).not.toHaveBeenCalled()
51
51
  })
52
+
53
+ it('applies a structural connection snap independently of the active mode', () => {
54
+ const connectionSnap = mock(() => 0.6)
55
+
56
+ expect(
57
+ resolveResizeSnapValue({
58
+ rawValue: 0.59,
59
+ gridSnapEnabled: false,
60
+ gridSnapActive: false,
61
+ gridSnapStep: 0.1,
62
+ magneticSnapActive: false,
63
+ connectionSnap,
64
+ }),
65
+ ).toBe(0.6)
66
+ expect(connectionSnap).toHaveBeenCalledWith(0.59)
67
+ })
68
+
69
+ it('bypasses a structural connection snap while force-moving', () => {
70
+ const connectionSnap = mock(() => 0.6)
71
+
72
+ expect(
73
+ resolveResizeSnapValue({
74
+ rawValue: 0.59,
75
+ gridSnapEnabled: false,
76
+ gridSnapActive: false,
77
+ gridSnapStep: 0.1,
78
+ magneticSnapActive: false,
79
+ connectionSnapActive: false,
80
+ connectionSnap,
81
+ }),
82
+ ).toBe(0.59)
83
+ expect(connectionSnap).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('keeps the last valid value when pointer projection is non-finite', () => {
87
+ expect(
88
+ resolveResizeSnapValue({
89
+ rawValue: Number.NaN,
90
+ fallbackValue: 12.3,
91
+ gridSnapEnabled: false,
92
+ gridSnapActive: false,
93
+ gridSnapStep: 0.5,
94
+ magneticSnapActive: false,
95
+ }),
96
+ ).toBe(12.3)
97
+ })
52
98
  })
@@ -2,22 +2,31 @@ import { snapScalar } from '@pascal-app/core'
2
2
 
3
3
  export function resolveResizeSnapValue({
4
4
  rawValue,
5
+ fallbackValue = rawValue,
5
6
  gridSnapEnabled,
6
7
  gridSnapActive,
7
8
  gridSnapStep,
8
9
  magneticSnapActive,
9
10
  magneticSnap,
11
+ connectionSnapActive = true,
12
+ connectionSnap,
10
13
  }: {
11
14
  rawValue: number
15
+ fallbackValue?: number
12
16
  gridSnapEnabled: boolean
13
17
  gridSnapActive: boolean
14
18
  gridSnapStep: number
15
19
  magneticSnapActive: boolean
16
20
  magneticSnap?: (value: number) => number
21
+ connectionSnapActive?: boolean
22
+ connectionSnap?: (value: number) => number
17
23
  }): number {
24
+ if (!Number.isFinite(rawValue)) return fallbackValue
18
25
  const gridValue =
19
26
  gridSnapEnabled && gridSnapActive && gridSnapStep > 0
20
27
  ? snapScalar(rawValue, gridSnapStep)
21
28
  : rawValue
22
- return magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
29
+ const modeValue = magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
30
+ const resolved = connectionSnapActive && connectionSnap ? connectionSnap(modeValue) : modeValue
31
+ return Number.isFinite(resolved) ? resolved : fallbackValue
23
32
  }
@@ -4,7 +4,9 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type Cursor,
7
+ cascadeDirty,
7
8
  createSceneApi,
9
+ type HandleDragModifiers,
8
10
  runAsSingleSceneHistoryStep,
9
11
  useLiveNodeOverrides,
10
12
  useScene,
@@ -46,6 +48,7 @@ export type HandleDragStartContext = {
46
48
 
47
49
  export type HandleDragMoveContext = {
48
50
  event: PointerEvent
51
+ modifiers: HandleDragModifiers
49
52
  getPointerRay: GetPointerRay
50
53
  intersectPlane: IntersectPlane
51
54
  }
@@ -177,6 +180,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
177
180
 
178
181
  let lastPatch: Partial<AnyNode> | null = null
179
182
  let historyPaused = true
183
+ let altKey = event.nativeEvent.altKey
180
184
 
181
185
  const resumeHistory = () => {
182
186
  if (!historyPaused) return
@@ -185,12 +189,18 @@ export function useHandleDrag(args: UseHandleDragArgs) {
185
189
  }
186
190
 
187
191
  const onMove = (moveEvent: PointerEvent) => {
188
- const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
192
+ const patch = session.move({
193
+ event: moveEvent,
194
+ modifiers: { altKey },
195
+ getPointerRay,
196
+ intersectPlane,
197
+ })
189
198
  if (!patch) return
190
199
  lastPatch = patch
191
200
  useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
192
201
  if (markDirty) {
193
- useScene.getState().markDirty(overrideId)
202
+ for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
203
+ useScene.getState().markDirty(id)
194
204
  }
195
205
  }
196
206
 
@@ -199,6 +209,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
199
209
  window.removeEventListener('pointerup', onUp)
200
210
  window.removeEventListener('pointercancel', onCancel)
201
211
  window.removeEventListener('keydown', onKeyDown, true)
212
+ window.removeEventListener('keyup', onKeyUp, true)
202
213
  if (document.body.style.cursor === cursor) {
203
214
  document.body.style.cursor = ''
204
215
  }
@@ -213,7 +224,8 @@ export function useHandleDrag(args: UseHandleDragArgs) {
213
224
  const clearOverride = () => {
214
225
  useLiveNodeOverrides.getState().clear(overrideId)
215
226
  if (markDirty) {
216
- useScene.getState().markDirty(overrideId)
227
+ for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
228
+ useScene.getState().markDirty(id)
217
229
  }
218
230
  }
219
231
 
@@ -240,17 +252,25 @@ export function useHandleDrag(args: UseHandleDragArgs) {
240
252
  // Escape / ⌘Z abort the drag — capture phase so they win over the global
241
253
  // use-keyboard arms (⌘Z must never history-jump under a live pointer).
242
254
  const onKeyDown = (e: KeyboardEvent) => {
255
+ if (e.key === 'Alt') {
256
+ altKey = true
257
+ return
258
+ }
243
259
  if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
244
260
  e.preventDefault()
245
261
  e.stopPropagation()
246
262
  swallowNextClick()
247
263
  onCancel()
248
264
  }
265
+ const onKeyUp = (e: KeyboardEvent) => {
266
+ if (e.key === 'Alt') altKey = false
267
+ }
249
268
 
250
269
  dragCleanupRef.current = onCancel
251
270
  window.addEventListener('pointermove', onMove)
252
271
  window.addEventListener('pointerup', onUp)
253
272
  window.addEventListener('pointercancel', onCancel)
254
273
  window.addEventListener('keydown', onKeyDown, true)
274
+ window.addEventListener('keyup', onKeyUp, true)
255
275
  }
256
276
  }
@@ -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,6 +49,8 @@ import {
31
49
  writePersistedSelection,
32
50
  } from '../../lib/scene'
33
51
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
52
+ import { useUnitFocusRules } from '../../lib/units'
53
+ import { type CameraHintAction, useCameraHintFocus } from '../../store/use-camera-hint-focus'
34
54
  import useEditor from '../../store/use-editor'
35
55
  import useFloorplanMode from '../../store/use-floorplan-mode'
36
56
  import useSessionGroups from '../../store/use-session-groups'
@@ -52,7 +72,7 @@ import { PanelManager } from '../ui/panels/panel-manager'
52
72
  import { ErrorBoundary } from '../ui/primitives/error-boundary'
53
73
  import { useSidebarStore } from '../ui/primitives/sidebar'
54
74
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
55
- import { SceneLoader } from '../ui/scene-loader'
75
+ import { SceneLoader, SceneLoadFailed } from '../ui/scene-loader'
56
76
  import { AppSidebar } from '../ui/sidebar/app-sidebar'
57
77
  import type { ExtraPanel } from '../ui/sidebar/icon-rail'
58
78
  import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
@@ -61,6 +81,7 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
61
81
  import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
62
82
  import { ViewerStage } from '../viewer/viewer-stage'
63
83
  import type { ViewerStageMode } from '../viewer/viewer-stage-modes'
84
+ import { CaptureCameraRig } from './capture-camera-rig'
64
85
  import { CustomCameraControls } from './custom-camera-controls'
65
86
  import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
66
87
  import { EditorLayoutV2 } from './editor-layout-v2'
@@ -98,7 +119,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
98
119
  const PAINT_CURSOR_BADGE_OFFSET_X = 14
99
120
  const PAINT_CURSOR_BADGE_OFFSET_Y = 14
100
121
  const SCENE_READY_FALLBACK_MS = 8000
122
+ const PRESENTATION_PROJECT_NOT_RESTORED = Symbol('presentation-project-not-restored')
123
+ const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
101
124
  type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
125
+ const recordEditorRender: ProfilerOnRenderCallback = (_id, _phase, actualDuration) => {
126
+ if (PERF_OVERLAY_ENABLED) recordPerfSample('react-render', actualDuration)
127
+ }
102
128
  const EDITOR_HOVER_STYLES: HoverStyles = {
103
129
  default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
104
130
  delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -178,6 +204,12 @@ export interface EditorProps {
178
204
  // Persistence — defaults to localStorage when omitted
179
205
  onLoad?: () => Promise<SceneGraph | null>
180
206
  onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
207
+ /**
208
+ * Cmd/Ctrl+S. Return true when the host handled the save (the community
209
+ * version checkpoint); anything else falls through to flushing the autosave,
210
+ * so the chord still saves when the host's control isn't mounted.
211
+ */
212
+ onSaveShortcut?: () => boolean | undefined
181
213
  onDirty?: () => void
182
214
  onSaveStatusChange?: (status: SaveStatus) => void
183
215
 
@@ -380,7 +412,7 @@ type ShortcutKey = {
380
412
  }
381
413
 
382
414
  type CameraControlHint = {
383
- action: string
415
+ action: CameraHintAction
384
416
  keys: ShortcutKey[]
385
417
  alternativeKeys?: ShortcutKey[]
386
418
  }
@@ -514,7 +546,15 @@ function ViewerCanvasControlsHint({
514
546
  isPreviewMode: boolean
515
547
  onDismiss: () => void
516
548
  }) {
517
- const hints = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
549
+ const all = isPreviewMode ? PREVIEW_CAMERA_CONTROL_HINTS : EDITOR_CAMERA_CONTROL_HINTS
550
+ // A host teaching one gesture at a time narrows this to the one it is asking
551
+ // for, and to nothing once it is done. Null — the default — is all of them.
552
+ const focus = useCameraHintFocus((state) => state.actions)
553
+ const hints = focus === null ? all : all.filter((hint) => focus.includes(hint.action))
554
+
555
+ if (hints.length === 0) {
556
+ return null
557
+ }
518
558
 
519
559
  return (
520
560
  <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
@@ -522,7 +562,10 @@ function ViewerCanvasControlsHint({
522
562
  aria-label="Camera controls hint"
523
563
  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"
524
564
  >
525
- <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
565
+ <div
566
+ className="grid min-w-0 flex-1 items-start divide-x divide-border/18"
567
+ style={{ gridTemplateColumns: `repeat(${hints.length}, minmax(0, 1fr))` }}
568
+ >
526
569
  {hints.map((hint) => (
527
570
  <CameraControlHintItem hint={hint} key={hint.action} />
528
571
  ))}
@@ -743,6 +786,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
743
786
  isStudioMode,
744
787
  onThumbnailCapture,
745
788
  viewerSceneSlot,
789
+ presentationsReady,
746
790
  }: {
747
791
  isVersionPreviewMode: boolean
748
792
  isLoading: boolean
@@ -750,6 +794,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
750
794
  isStudioMode: boolean
751
795
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
752
796
  viewerSceneSlot?: ReactNode
797
+ presentationsReady: boolean
753
798
  }) {
754
799
  // Studio mode is a clean render/snapshot surface — no selection or editing
755
800
  // affordances. It mirrors version-preview's chrome gating on the canvas.
@@ -784,10 +829,12 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
784
829
  {!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
785
830
  {!(isLoading || noEditing) && <ToolManager />}
786
831
  {isFirstPersonMode && <FirstPersonControls />}
832
+ {isCaptureMode && <CaptureCameraRig />}
787
833
  <CustomCameraControls />
788
834
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
789
835
  {!isFirstPersonMode && <SiteEdgeLabels />}
790
836
  <InteractiveSystem />
837
+ {presentationsReady ? <ViewerPresentations /> : null}
791
838
  {!noEditing && viewerSceneSlot}
792
839
  </>
793
840
  )
@@ -971,6 +1018,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
971
1018
  sceneReadyKey,
972
1019
  onSceneReadyChange,
973
1020
  onThumbnailCapture,
1021
+ presentationsReady,
974
1022
  viewerSceneSlot,
975
1023
  floorplanSceneSlot,
976
1024
  disablePostFx = false,
@@ -984,6 +1032,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
984
1032
  sceneReadyKey: number
985
1033
  onSceneReadyChange: (ready: boolean) => void
986
1034
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
1035
+ presentationsReady: boolean
987
1036
  viewerSceneSlot?: ReactNode
988
1037
  floorplanSceneSlot?: ReactNode
989
1038
  disablePostFx?: boolean
@@ -992,6 +1041,11 @@ const ViewerCanvas = memo(function ViewerCanvas({
992
1041
  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
993
1042
  const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
994
1043
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
1044
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
1045
+ useUnitFocusRules()
1046
+ const presetIsolation = useEditor((s) =>
1047
+ s.captureMode.mode === 'preset' ? s.captureMode.isolated : null,
1048
+ )
995
1049
 
996
1050
  const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
997
1051
  null,
@@ -1102,11 +1156,18 @@ const ViewerCanvas = memo(function ViewerCanvas({
1102
1156
  defaultRender={EDITOR_DEFAULT_RENDER}
1103
1157
  disablePostFx={disablePostFx}
1104
1158
  hoverStyles={EDITOR_HOVER_STYLES}
1159
+ isolate={presetIsolation}
1160
+ // Preset captures isolate one subtree and keep the exterior transparent.
1161
+ // Other modes retain the viewer's configured background policy.
1162
+ transparent={presetIsolation === null ? undefined : true}
1105
1163
  onSceneReadyChange={onSceneReadyChange}
1106
1164
  renderContext="editor"
1107
1165
  renderPaused={!show3d && !showLoader}
1108
1166
  sceneReadyKey={sceneReadyKey}
1109
- selectionManager={isFirstPersonMode ? 'default' : 'custom'}
1167
+ // Walk/drone framing during snapshot capture is camera-only: the
1168
+ // viewer's default selection manager would hover-highlight whatever
1169
+ // the cursor crosses, which orbit capture never does.
1170
+ selectionManager={isFirstPersonMode && !isCaptureMode ? 'default' : 'custom'}
1110
1171
  >
1111
1172
  <ViewerSceneContent
1112
1173
  isFirstPersonMode={isFirstPersonMode}
@@ -1114,6 +1175,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
1114
1175
  isStudioMode={isStudioMode}
1115
1176
  isVersionPreviewMode={isVersionPreviewMode}
1116
1177
  onThumbnailCapture={onThumbnailCapture}
1178
+ presentationsReady={presentationsReady}
1117
1179
  viewerSceneSlot={viewerSceneSlot}
1118
1180
  />
1119
1181
  </Viewer>
@@ -1178,7 +1240,7 @@ function PreviewStage({
1178
1240
  )
1179
1241
  }
1180
1242
 
1181
- export default function Editor({
1243
+ function EditorContent({
1182
1244
  layoutVersion = 'v1',
1183
1245
  appMenuButton,
1184
1246
  sidebarTop,
@@ -1194,6 +1256,7 @@ export default function Editor({
1194
1256
  projectId,
1195
1257
  onLoad,
1196
1258
  onSave,
1259
+ onSaveShortcut,
1197
1260
  onDirty,
1198
1261
  onSaveStatusChange,
1199
1262
  previewScene,
@@ -1211,18 +1274,51 @@ export default function Editor({
1211
1274
  }: EditorProps) {
1212
1275
  const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
1213
1276
  const isStudioMode = useEditor((s) => s.workspaceMode === 'studio')
1277
+ const presentationProjectId = projectId ?? null
1278
+ const presentationPersistenceRef = useRef<LocalProjectPresentationPersistence | null>(null)
1279
+ const [restoredPresentationProjectId, setRestoredPresentationProjectId] = useState<
1280
+ string | null | typeof PRESENTATION_PROJECT_NOT_RESTORED
1281
+ >(PRESENTATION_PROJECT_NOT_RESTORED)
1282
+ const presentationsReady = restoredPresentationProjectId === presentationProjectId
1283
+
1284
+ useClientLayoutEffect(() => {
1285
+ const persistence = createLocalProjectPresentationPersistence()
1286
+ presentationPersistenceRef.current = persistence
1287
+ return () => {
1288
+ presentationPersistenceRef.current = null
1289
+ persistence.dispose()
1290
+ }
1291
+ }, [])
1292
+
1293
+ useClientLayoutEffect(() => {
1294
+ const persistence = presentationPersistenceRef.current
1295
+ if (!persistence) return
1296
+ persistence.switchProject(presentationProjectId)
1297
+ setRestoredPresentationProjectId(presentationProjectId)
1298
+ }, [presentationProjectId])
1214
1299
 
1215
1300
  useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode })
1216
1301
 
1217
- const { isLoadingSceneRef } = useAutoSave({
1302
+ const { isLoadingSceneRef, saveNow } = useAutoSave({
1218
1303
  onSave,
1219
1304
  onDirty,
1220
1305
  onSaveStatusChange,
1221
1306
  isVersionPreviewMode,
1222
1307
  })
1223
1308
 
1309
+ const handleSaveShortcut = useCallback(() => {
1310
+ if (onSaveShortcut?.() === true) return
1311
+ saveNow()
1312
+ }, [onSaveShortcut, saveNow])
1313
+ useSaveShortcut(handleSaveShortcut)
1314
+
1224
1315
  const [isSceneLoading, setIsSceneLoading] = useState(false)
1225
1316
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
1317
+ // A failed `onLoad` is shown as an error with a retry, never as an empty
1318
+ // scene: an editor that renders the default scaffold after a failed load
1319
+ // autosaves that scaffold over the real project.
1320
+ const [sceneLoadError, setSceneLoadError] = useState<unknown>(null)
1321
+ const [sceneLoadAttempt, setSceneLoadAttempt] = useState(0)
1226
1322
  const [sceneReadyKey, setSceneReadyKey] = useState(0)
1227
1323
  const [isViewerSceneReady, setIsViewerSceneReady] = useState(false)
1228
1324
  const [previewStageMode, setPreviewStageMode] = useState<ViewerStageMode>('3d')
@@ -1258,12 +1354,13 @@ export default function Editor({
1258
1354
  }
1259
1355
  }, [projectId])
1260
1356
 
1261
- // Load scene on mount (or when onLoad identity changes, e.g. project switch)
1357
+ // Load on mount, project switches, and explicit retry attempts.
1262
1358
  useEffect(() => {
1263
1359
  let cancelled = false
1264
1360
 
1265
- async function load() {
1361
+ async function load(attempt: number) {
1266
1362
  isLoadingSceneRef.current = true
1363
+ setSceneLoadError(null)
1267
1364
  setHasLoadedInitialScene(false)
1268
1365
  setIsViewerSceneReady(false)
1269
1366
  setIsSceneLoading(true)
@@ -1272,36 +1369,45 @@ export default function Editor({
1272
1369
  // Session groups are not scene-graph state — clear on every load/switch.
1273
1370
  useSessionGroups.getState().clearGroups()
1274
1371
 
1372
+ let failed = false
1275
1373
  try {
1276
1374
  const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
1277
- if (!cancelled) {
1375
+ if (!cancelled && attempt === sceneLoadAttempt) {
1278
1376
  applySceneGraphToEditor(sceneGraph)
1279
1377
  setIsViewerSceneReady(false)
1280
1378
  setSceneReadyKey((key) => key + 1)
1281
1379
  }
1282
- } catch {
1380
+ } catch (error) {
1381
+ // Leave the store unloaded and the autosave loop in its loading
1382
+ // state: nothing may be written until a load actually succeeds.
1383
+ failed = true
1283
1384
  if (!cancelled) {
1284
- applySceneGraphToEditor(null)
1285
- setIsViewerSceneReady(false)
1286
- setSceneReadyKey((key) => key + 1)
1385
+ console.error('[editor] scene load failed', error)
1386
+ setSceneLoadError(error ?? new Error('Scene load failed'))
1287
1387
  }
1288
1388
  } finally {
1289
1389
  if (!cancelled) {
1290
1390
  setIsSceneLoading(false)
1291
- setHasLoadedInitialScene(true)
1292
- requestAnimationFrame(() => {
1293
- isLoadingSceneRef.current = false
1294
- })
1391
+ if (!failed) {
1392
+ setHasLoadedInitialScene(true)
1393
+ requestAnimationFrame(() => {
1394
+ isLoadingSceneRef.current = false
1395
+ })
1396
+ }
1295
1397
  }
1296
1398
  }
1297
1399
  }
1298
1400
 
1299
- load()
1401
+ load(sceneLoadAttempt)
1300
1402
 
1301
1403
  return () => {
1302
1404
  cancelled = true
1303
1405
  }
1304
- }, [onLoad, isLoadingSceneRef])
1406
+ }, [onLoad, isLoadingSceneRef, sceneLoadAttempt])
1407
+
1408
+ const retrySceneLoad = useCallback(() => {
1409
+ setSceneLoadAttempt((attempt) => attempt + 1)
1410
+ }, [])
1305
1411
 
1306
1412
  // Apply preview scene when version preview mode changes
1307
1413
  useEffect(() => {
@@ -1417,6 +1523,7 @@ export default function Editor({
1417
1523
  <CustomCameraControls />
1418
1524
  <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
1419
1525
  <InteractiveSystem />
1526
+ {presentationsReady ? <ViewerPresentations /> : null}
1420
1527
  </Viewer>
1421
1528
  )
1422
1529
 
@@ -1430,6 +1537,7 @@ export default function Editor({
1430
1537
  isVersionPreviewMode={isVersionPreviewMode}
1431
1538
  onSceneReadyChange={handleSceneReadyChange}
1432
1539
  onThumbnailCapture={onThumbnailCapture}
1540
+ presentationsReady={presentationsReady}
1433
1541
  sceneReadyKey={sceneReadyKey}
1434
1542
  showLoader={showLoader}
1435
1543
  viewerSceneSlot={viewerSceneSlot}
@@ -1490,7 +1598,11 @@ export default function Editor({
1490
1598
  <FloorplanModeCoordinator />
1491
1599
  {visibleLoader && (
1492
1600
  <div className="fixed inset-0 z-60">
1493
- <SceneLoader className="bg-background" />
1601
+ {sceneLoadError ? (
1602
+ <SceneLoadFailed className="bg-background" onRetry={retrySceneLoad} />
1603
+ ) : (
1604
+ <SceneLoader className="bg-background" />
1605
+ )}
1494
1606
  </div>
1495
1607
  )}
1496
1608
 
@@ -1527,7 +1639,10 @@ export default function Editor({
1527
1639
  <HelperManager />
1528
1640
  </div>
1529
1641
  )}
1530
- {isFirstPersonMode && (
1642
+ {/* Capture mode drives walk / drone from its own overlay, which
1643
+ owns the framing chrome — the walkthrough HUD would both
1644
+ clutter the frame and offer a second, conflicting exit. */}
1645
+ {isFirstPersonMode && !isCaptureMode && (
1531
1646
  <FirstPersonOverlay
1532
1647
  onExit={() => useEditor.getState().setFirstPersonMode(false)}
1533
1648
  />
@@ -1563,7 +1678,11 @@ export default function Editor({
1563
1678
  <FloorplanModeCoordinator />
1564
1679
  {visibleLoader && (
1565
1680
  <div className="fixed inset-0 z-60">
1566
- <SceneLoader className="bg-background" />
1681
+ {sceneLoadError ? (
1682
+ <SceneLoadFailed className="bg-background" onRetry={retrySceneLoad} />
1683
+ ) : (
1684
+ <SceneLoader className="bg-background" />
1685
+ )}
1567
1686
  </div>
1568
1687
  )}
1569
1688
 
@@ -1613,3 +1732,11 @@ export default function Editor({
1613
1732
  </div>
1614
1733
  )
1615
1734
  }
1735
+
1736
+ export default function Editor(props: EditorProps) {
1737
+ return (
1738
+ <Profiler id="editor" onRender={recordEditorRender}>
1739
+ <EditorContent {...props} />
1740
+ </Profiler>
1741
+ )
1742
+ }