@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
@@ -1,21 +1,48 @@
1
1
  import {
2
2
  clearSceneHistory,
3
3
  emitter,
4
+ isNodeKindEnabled,
5
+ nodeRegistry,
6
+ useRegistryVersion,
4
7
  useScene,
8
+ type ParsedBuildJson,
5
9
  validateBuildJson,
6
10
  } from '@pascal-app/core'
7
- import { useViewer } from '@pascal-app/viewer'
11
+ import { useViewer, viewerPresentationRegistry } from '@pascal-app/viewer'
8
12
  import { TreeView, VisualJson } from '@visual-json/react'
9
- import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
13
+ import {
14
+ Camera,
15
+ Check,
16
+ ChevronDown,
17
+ Copy,
18
+ Download,
19
+ Map as MapIcon,
20
+ Save,
21
+ Send,
22
+ Trash2,
23
+ Upload,
24
+ } from 'lucide-react'
10
25
  import {
11
26
  type KeyboardEvent,
12
27
  type SyntheticEvent,
13
28
  useCallback,
29
+ useEffect,
30
+ useId,
14
31
  useMemo,
15
32
  useRef,
16
33
  useState,
34
+ useSyncExternalStore,
17
35
  } from 'react'
18
- import { exportFloorplanPdf } from '../../../../../lib/floorplan/floorplan-export'
36
+ import {
37
+ exportFloorplanPdf,
38
+ type FloorplanExportScope,
39
+ } from '../../../../../lib/floorplan/floorplan-export'
40
+ import {
41
+ LocalAppError,
42
+ probeLocalApp,
43
+ sendGlbToLocalApp,
44
+ waitForLocalImport,
45
+ } from '../../../../../lib/send-to-app'
19
46
  import { Button } from './../../../../../components/ui/primitives/button'
20
47
  import {
21
48
  Dialog,
@@ -23,12 +50,15 @@ import {
23
50
  DialogTitle,
24
51
  DialogTrigger,
25
52
  } from './../../../../../components/ui/primitives/dialog'
53
+ import { Input } from './../../../../../components/ui/primitives/input'
26
54
  import { Switch } from './../../../../../components/ui/primitives/switch'
27
55
  import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
56
+ import { type SendToAppStep, useSendToApp } from './../../../../../store/use-send-to-app'
28
57
  import useFloorplanMode from './../../../../../store/use-floorplan-mode'
29
58
  import { AudioSettingsDialog } from './audio-settings-dialog'
30
59
  import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
31
60
  import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
61
+ import { PrintExportButton } from './print-export-button'
32
62
 
33
63
  type SceneNode = Record<string, unknown> & {
34
64
  id?: unknown
@@ -53,6 +83,30 @@ type SceneGraphValue = {
53
83
  detachedNodes?: SceneGraphNode[]
54
84
  }
55
85
 
86
+ const MODEL_EXPORT_FORMATS = [
87
+ { format: 'glb', label: 'GLB' },
88
+ { format: 'usdz', label: 'USDZ' },
89
+ { format: 'stl', label: 'STL' },
90
+ { format: 'obj', label: 'OBJ' },
91
+ ] as const
92
+
93
+ type ModelExportFormat = (typeof MODEL_EXPORT_FORMATS)[number]['format']
94
+
95
+ const SEND_TO_BLENDER_STEP_LABEL: Record<SendToAppStep, string> = {
96
+ probing: 'Looking for Blender…',
97
+ exporting: 'Preparing the scene…',
98
+ sending: 'Sending to Blender…',
99
+ importing: 'Blender is importing…',
100
+ }
101
+
102
+ const BLENDER_ADDON_URL = 'https://github.com/pascalorg/blender-addon#install'
103
+
104
+ type ExportableNodeType = {
105
+ type: string
106
+ label: string
107
+ supportsGeometryOnly: boolean
108
+ }
109
+
56
110
  const isSceneNode = (value: unknown): value is SceneNode => {
57
111
  return (
58
112
  typeof value === 'object' &&
@@ -171,6 +225,8 @@ export interface ProjectVisibility {
171
225
 
172
226
  export interface SettingsPanelProps {
173
227
  projectId?: string
228
+ /** Shown as the scene name in apps the scene is sent to (Blender collection name). */
229
+ projectName?: string
174
230
  projectVisibility?: ProjectVisibility
175
231
  onVisibilityChange?: (
176
232
  field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
@@ -180,22 +236,88 @@ export interface SettingsPanelProps {
180
236
 
181
237
  export function SettingsPanel({
182
238
  projectId,
239
+ projectName,
183
240
  projectVisibility,
184
241
  onVisibilityChange,
185
242
  }: SettingsPanelProps = {}) {
186
243
  const fileInputRef = useRef<HTMLInputElement>(null)
244
+ const copyResetTimeoutRef = useRef<number | null>(null)
187
245
  const nodes = useScene((state) => state.nodes)
188
246
  const rootNodeIds = useScene((state) => state.rootNodeIds)
189
247
  const installedPlugins = useScene((state) => state.installedPlugins)
248
+ const materials = useScene((state) => state.materials)
249
+ const collections = useScene((state) => state.collections)
190
250
  const setScene = useScene((state) => state.setScene)
191
251
  const clearScene = useScene((state) => state.clearScene)
192
252
  const resetSelection = useViewer((state) => state.resetSelection)
193
- const exportScene = useViewer((state) => state.exportScene)
253
+ const modelExport = useEditor((state) => state.modelExport)
194
254
  const shadows = useViewer((state) => state.shadows)
195
255
  const setPhase = useEditor((state) => state.setPhase)
196
256
  const floorplanMode = useFloorplanMode((state) => state.mode)
257
+ const registryVersion = useRegistryVersion()
258
+ const visibleOnlySwitchId = useId()
259
+ const includeNodeTypeIdPrefix = useId()
260
+ const includePresentationIdPrefix = useId()
197
261
  const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
262
+ const [exportOnlyVisible, setExportOnlyVisible] = useState(true)
263
+ const [excludedNodeTypes, setExcludedNodeTypes] = useState<string[]>([])
264
+ const [includedPresentationIds, setIncludedPresentationIds] = useState<string[]>([])
265
+ const [activeModelExport, setActiveModelExport] = useState<ModelExportFormat | null>(null)
266
+ const [modelExportError, setModelExportError] = useState<string | null>(null)
267
+ const [modelExportWarning, setModelExportWarning] = useState<string | null>(null)
268
+ const sendToBlenderStep = useSendToApp((state) => state.step)
269
+ const sendToBlenderMessage = useSendToApp((state) => state.message)
270
+ const setSendToBlenderStep = useSendToApp((state) => state.setStep)
271
+ const setSendToBlenderMessage = useSendToApp((state) => state.setMessage)
272
+ const [activeFloorplanExport, setActiveFloorplanExport] = useState<FloorplanExportScope | null>(
273
+ null,
274
+ )
275
+ const [floorplanExportError, setFloorplanExportError] = useState<string | null>(null)
198
276
  const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
277
+ const [projectIdCopyState, setProjectIdCopyState] = useState<'idle' | 'copied' | 'error'>('idle')
278
+ const exportableNodeTypes = useMemo(() => {
279
+ void registryVersion
280
+ const uniqueTypes = new Set(Object.values(nodes).map((node) => node.type))
281
+ const options: ExportableNodeType[] = []
282
+
283
+ for (const type of uniqueTypes) {
284
+ const definition = nodeRegistry.get(type)
285
+ if (
286
+ !(
287
+ definition?.bakeGeometry ||
288
+ definition?.bakeGeometryAsync ||
289
+ definition?.bake === 'replace'
290
+ ) ||
291
+ !isNodeKindEnabled(type, installedPlugins)
292
+ ) {
293
+ continue
294
+ }
295
+ options.push({
296
+ type,
297
+ label: definition.presentation?.label ?? type,
298
+ supportsGeometryOnly: !definition.bakeGeometryAsync || Boolean(definition.bakeGeometry),
299
+ })
300
+ }
301
+
302
+ return options.sort((a, b) => {
303
+ const labelOrder = a.label.localeCompare(b.label)
304
+ return labelOrder === 0 ? a.type.localeCompare(b.type) : labelOrder
305
+ })
306
+ }, [installedPlugins, nodes, registryVersion])
307
+ const registeredPresentations = useSyncExternalStore(
308
+ viewerPresentationRegistry.subscribe,
309
+ viewerPresentationRegistry.getSnapshot,
310
+ viewerPresentationRegistry.getSnapshot,
311
+ )
312
+ const exportablePresentations = useMemo(
313
+ () =>
314
+ registeredPresentations.filter(
315
+ (contribution) =>
316
+ contribution.staticExport &&
317
+ (!contribution.pluginId || installedPlugins.includes(contribution.pluginId)),
318
+ ),
319
+ [installedPlugins, registeredPresentations],
320
+ )
199
321
  const sceneGraphValue = useMemo(
200
322
  () => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
201
323
  [nodes, rootNodeIds],
@@ -211,10 +333,22 @@ export function SettingsPanel({
211
333
  }
212
334
  }, [])
213
335
 
336
+ useEffect(
337
+ () => () => {
338
+ if (copyResetTimeoutRef.current !== null) {
339
+ window.clearTimeout(copyResetTimeoutRef.current)
340
+ }
341
+ },
342
+ [],
343
+ )
344
+
214
345
  const isLocalProject = false // Props-based; only show cloud sections when projectId provided
215
346
 
216
347
  const handleSaveBuild = () => {
217
- const sceneData = { nodes, rootNodeIds, installedPlugins }
348
+ // Materials ride along: nodes reference them by `scene:<id>` slot
349
+ // refs, so a save without the table produces a file whose custom
350
+ // finishes revert to defaults on the very Load Build path below.
351
+ const sceneData = { nodes, rootNodeIds, installedPlugins, materials, collections }
218
352
  const json = JSON.stringify(sceneData, null, 2)
219
353
  const blob = new Blob([json], { type: 'application/json' })
220
354
  const url = URL.createObjectURL(blob)
@@ -270,16 +404,17 @@ export function SettingsPanel({
270
404
  e.target.value = ''
271
405
  }
272
406
 
273
- const handleConfirmImport = (parsed: {
274
- nodes: Record<string, unknown>
275
- rootNodeIds: string[]
276
- installedPlugins?: string[]
277
- }) => {
407
+ const handleConfirmImport = (parsed: ParsedBuildJson) => {
278
408
  const currentScene = useScene.getState()
279
409
  setScene(
280
410
  parsed.nodes as Parameters<typeof setScene>[0],
281
411
  parsed.rootNodeIds as Parameters<typeof setScene>[1],
282
412
  {
413
+ // Without this, every `scene:<id>` slot ref in the imported file
414
+ // pointed at a material that no longer existed — custom finishes
415
+ // silently reverted to defaults on import.
416
+ materials: parsed.materials,
417
+ collections: parsed.collections,
283
418
  installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
284
419
  hasExplicitPluginInstallState:
285
420
  parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
@@ -310,6 +445,25 @@ export function SettingsPanel({
310
445
  setTimeout(() => setIsGeneratingThumbnail(false), 3000)
311
446
  }
312
447
 
448
+ const handleCopyProjectId = async () => {
449
+ if (!projectId) return
450
+ if (copyResetTimeoutRef.current !== null) {
451
+ window.clearTimeout(copyResetTimeoutRef.current)
452
+ }
453
+
454
+ try {
455
+ await navigator.clipboard.writeText(projectId)
456
+ setProjectIdCopyState('copied')
457
+ } catch {
458
+ setProjectIdCopyState('error')
459
+ }
460
+
461
+ copyResetTimeoutRef.current = window.setTimeout(() => {
462
+ setProjectIdCopyState('idle')
463
+ copyResetTimeoutRef.current = null
464
+ }, 2000)
465
+ }
466
+
313
467
  const handleVisibilityChange = async (
314
468
  field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
315
469
  value: boolean,
@@ -317,8 +471,161 @@ export function SettingsPanel({
317
471
  await onVisibilityChange?.(field, value)
318
472
  }
319
473
 
474
+ const handleNodeTypeInclusion = useCallback((type: string, included: boolean) => {
475
+ setExcludedNodeTypes((current) => {
476
+ const isExcluded = current.includes(type)
477
+ if (included) {
478
+ return isExcluded ? current.filter((excludedType) => excludedType !== type) : current
479
+ }
480
+ return isExcluded ? current : [...current, type]
481
+ })
482
+ }, [])
483
+ const handlePresentationInclusion = useCallback((id: string, included: boolean) => {
484
+ setIncludedPresentationIds((current) => {
485
+ const isIncluded = current.includes(id)
486
+ if (included) return isIncluded ? current : [...current, id]
487
+ return isIncluded ? current.filter((includedId) => includedId !== id) : current
488
+ })
489
+ }, [])
490
+
491
+ const handleModelExport = async (format: ModelExportFormat, label: string) => {
492
+ if (!modelExport || activeModelExport || useSendToApp.getState().step) return
493
+
494
+ setActiveModelExport(format)
495
+ setModelExportError(null)
496
+ setModelExportWarning(null)
497
+ try {
498
+ const artifact = await modelExport(format, {
499
+ onlyVisible: exportOnlyVisible,
500
+ excludedNodeTypes,
501
+ includedPresentationIds:
502
+ format === 'glb' || format === 'usdz'
503
+ ? includedPresentationIds.filter((id) =>
504
+ exportablePresentations.some((contribution) => contribution.id === id),
505
+ )
506
+ : [],
507
+ })
508
+ if (!artifact) {
509
+ throw new Error('Model export did not produce a file')
510
+ }
511
+ if (artifact.warnings?.length) setModelExportWarning(artifact.warnings.join(' '))
512
+ } catch (error) {
513
+ setModelExportError(
514
+ error instanceof Error ? error.message : `Couldn’t export ${label}. Try again.`,
515
+ )
516
+ } finally {
517
+ setActiveModelExport(null)
518
+ }
519
+ }
520
+
521
+ const handleSendToBlender = async () => {
522
+ if (!modelExport || activeModelExport || useSendToApp.getState().step) return
523
+
524
+ setSendToBlenderMessage(null)
525
+ setSendToBlenderStep('probing')
526
+ try {
527
+ const probe = await probeLocalApp()
528
+ if (probe.status === 'unreachable') {
529
+ setSendToBlenderMessage({
530
+ tone: 'error',
531
+ text: 'Blender isn’t listening. Open Blender with the Pascal add-on installed and enabled, then try again.',
532
+ })
533
+ return
534
+ }
535
+ if (probe.status === 'refused') {
536
+ setSendToBlenderMessage({
537
+ tone: 'error',
538
+ text: `Blender is open but hasn’t allowed ${window.location.origin} yet. In Blender, open the Pascal tab in the 3D viewport sidebar (N) and click Allow.`,
539
+ })
540
+ return
541
+ }
542
+
543
+ setSendToBlenderStep('exporting')
544
+ const artifact = await modelExport('glb', {
545
+ onlyVisible: exportOnlyVisible,
546
+ excludedNodeTypes,
547
+ includedPresentationIds: includedPresentationIds.filter((id) =>
548
+ exportablePresentations.some((contribution) => contribution.id === id),
549
+ ),
550
+ download: false,
551
+ })
552
+ if (!artifact) throw new Error('Model export did not produce a file')
553
+
554
+ setSendToBlenderStep('sending')
555
+ const queued = await sendGlbToLocalApp(probe.base, artifact.blob, {
556
+ name: projectName,
557
+ projectId,
558
+ })
559
+
560
+ setSendToBlenderStep('importing')
561
+ const done = await waitForLocalImport(probe.base, queued.id)
562
+ setSendToBlenderMessage({
563
+ tone: 'info',
564
+ text: done.summary ? `Sent to Blender. ${done.summary}.` : 'Sent to Blender.',
565
+ })
566
+ } catch (error) {
567
+ const text =
568
+ error instanceof LocalAppError || error instanceof Error
569
+ ? error.message
570
+ : 'Couldn’t send the scene to Blender. Try again.'
571
+ setSendToBlenderMessage({ tone: 'error', text })
572
+ } finally {
573
+ setSendToBlenderStep(null)
574
+ }
575
+ }
576
+
577
+ const handleFloorplanExport = async (scope: FloorplanExportScope) => {
578
+ if (activeFloorplanExport) return
579
+
580
+ setActiveFloorplanExport(scope)
581
+ setFloorplanExportError(null)
582
+ try {
583
+ await exportFloorplanPdf(scope)
584
+ } catch (error) {
585
+ setFloorplanExportError(
586
+ `Couldn’t export the floor plan. ${error instanceof Error ? error.message : 'Try again.'}`,
587
+ )
588
+ } finally {
589
+ setActiveFloorplanExport(null)
590
+ }
591
+ }
592
+
320
593
  return (
321
- <div className="flex flex-col gap-6 p-3">
594
+ <div className="subtle-scrollbar min-h-0 flex-1 space-y-6 overflow-x-hidden overflow-y-auto overscroll-contain p-3">
595
+ {projectId && (
596
+ <div className="space-y-2">
597
+ <label className="font-medium text-muted-foreground text-xs uppercase">Project</label>
598
+ <div className="font-medium text-sm">Project ID</div>
599
+ <div className="flex items-center gap-2">
600
+ <Input
601
+ aria-label="Project ID"
602
+ className="font-mono text-xs"
603
+ readOnly
604
+ value={projectId}
605
+ />
606
+ <Button
607
+ aria-label={projectIdCopyState === 'copied' ? 'Project ID copied' : 'Copy project ID'}
608
+ className="rounded-full"
609
+ onClick={() => void handleCopyProjectId()}
610
+ size="sm"
611
+ type="button"
612
+ variant="outline"
613
+ >
614
+ {projectIdCopyState === 'copied' ? (
615
+ <Check className="size-3.5" />
616
+ ) : (
617
+ <Copy className="size-3.5" />
618
+ )}
619
+ {projectIdCopyState === 'copied'
620
+ ? 'Copied'
621
+ : projectIdCopyState === 'error'
622
+ ? 'Try again'
623
+ : 'Copy'}
624
+ </Button>
625
+ </div>
626
+ </div>
627
+ )}
628
+
322
629
  {/* Visibility Section (only for cloud projects) */}
323
630
  {projectId && !isLocalProject && (
324
631
  <div className="space-y-3">
@@ -331,6 +638,7 @@ export function SettingsPanel({
331
638
  </div>
332
639
  </div>
333
640
  <Switch
641
+ aria-label="Make project public"
334
642
  checked={!(projectVisibility?.isPrivate ?? false)}
335
643
  onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)}
336
644
  />
@@ -341,6 +649,7 @@ export function SettingsPanel({
341
649
  <div className="text-muted-foreground text-xs">Visible to public viewers</div>
342
650
  </div>
343
651
  <Switch
652
+ aria-label="Show 3D scans to public viewers"
344
653
  checked={projectVisibility?.showScansPublic ?? true}
345
654
  onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)}
346
655
  />
@@ -351,6 +660,7 @@ export function SettingsPanel({
351
660
  <div className="text-muted-foreground text-xs">Visible to public viewers</div>
352
661
  </div>
353
662
  <Switch
663
+ aria-label="Show floorplans to public viewers"
354
664
  checked={projectVisibility?.showGuidesPublic ?? true}
355
665
  onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
356
666
  />
@@ -361,6 +671,7 @@ export function SettingsPanel({
361
671
  <div className="text-muted-foreground text-xs">Cast shadows from lights</div>
362
672
  </div>
363
673
  <Switch
674
+ aria-label="Enable shadows"
364
675
  checked={shadows}
365
676
  onCheckedChange={(checked) => useViewer.getState().setShadows(checked)}
366
677
  />
@@ -374,30 +685,176 @@ export function SettingsPanel({
374
685
 
375
686
  <div className="space-y-2">
376
687
  <div className="font-medium text-muted-foreground text-xs">3D model</div>
377
- <Button
378
- className="w-full justify-start gap-2"
379
- onClick={() => exportScene?.('glb')}
380
- variant="outline"
688
+ <details
689
+ className="group"
690
+ onKeyDownCapture={(event) => {
691
+ // Keep Space available to the disclosure and switches, not canvas panning.
692
+ if (event.code === 'Space') event.stopPropagation()
693
+ }}
381
694
  >
382
- <Download className="size-4" />
383
- Export GLB
384
- </Button>
385
- <Button
386
- className="w-full justify-start gap-2"
387
- onClick={() => exportScene?.('stl')}
388
- variant="outline"
389
- >
390
- <Download className="size-4" />
391
- Export STL
392
- </Button>
695
+ <summary className="flex min-h-10 cursor-pointer list-none items-center justify-between rounded-md border px-3 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring">
696
+ Export options
697
+ <ChevronDown aria-hidden="true" className="size-4 group-open:rotate-180" />
698
+ </summary>
699
+ <div className="space-y-3 pt-3">
700
+ <div className="flex items-center justify-between gap-4 rounded-md border p-3">
701
+ <div className="min-w-0">
702
+ <label className="font-medium text-sm" htmlFor={visibleOnlySwitchId}>
703
+ Visible nodes only
704
+ </label>
705
+ <div className="text-muted-foreground text-xs">
706
+ Exclude hidden furniture and other hidden scene nodes
707
+ </div>
708
+ </div>
709
+ <Switch
710
+ aria-label="Export visible nodes only"
711
+ checked={exportOnlyVisible}
712
+ id={visibleOnlySwitchId}
713
+ onCheckedChange={setExportOnlyVisible}
714
+ />
715
+ </div>
716
+
717
+ <fieldset className="space-y-2 rounded-md border p-3">
718
+ <legend className="px-1 font-medium text-sm">Include in file</legend>
719
+ <p className="text-muted-foreground text-xs">
720
+ Choose which procedural content is baked into model files. GLB and USDZ use the
721
+ textured portable path; STL and OBJ remain geometry-only.
722
+ </p>
723
+ {exportableNodeTypes.length > 0 ? (
724
+ <div className="space-y-2 pt-1">
725
+ {exportableNodeTypes.map(({ type, label, supportsGeometryOnly }, index) => {
726
+ const switchId = `${includeNodeTypeIdPrefix}-${index}`
727
+ return (
728
+ <div className="flex items-center justify-between gap-4" key={type}>
729
+ <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
730
+ {label}
731
+ {!supportsGeometryOnly && (
732
+ <span className="text-muted-foreground text-xs"> (GLB/USDZ only)</span>
733
+ )}
734
+ </label>
735
+ <Switch
736
+ aria-label={`Include ${label} in model files`}
737
+ checked={!excludedNodeTypes.includes(type)}
738
+ id={switchId}
739
+ onCheckedChange={(included) => handleNodeTypeInclusion(type, included)}
740
+ />
741
+ </div>
742
+ )
743
+ })}
744
+ </div>
745
+ ) : (
746
+ <p className="text-muted-foreground text-xs">
747
+ No optional procedural content is present.
748
+ </p>
749
+ )}
750
+ <p className="text-muted-foreground text-xs">
751
+ Viewer surroundings are excluded unless selected separately below.
752
+ </p>
753
+ </fieldset>
754
+ {exportablePresentations.length > 0 ? (
755
+ <fieldset className="space-y-2 rounded-md border p-3">
756
+ <legend className="px-1 font-medium text-sm">Viewer surroundings</legend>
757
+ <p className="text-muted-foreground text-xs">
758
+ Optional static surroundings are included only in GLB and USDZ.
759
+ </p>
760
+ <div className="space-y-2 pt-1">
761
+ {exportablePresentations.map((contribution, index) => {
762
+ const switchId = `${includePresentationIdPrefix}-${index}`
763
+ const label = contribution.staticExport!.label
764
+ return (
765
+ <div
766
+ className="flex items-center justify-between gap-4"
767
+ key={contribution.id}
768
+ >
769
+ <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
770
+ {label}
771
+ </label>
772
+ <Switch
773
+ aria-label={`Include ${label} in GLB and USDZ`}
774
+ checked={includedPresentationIds.includes(contribution.id)}
775
+ id={switchId}
776
+ onCheckedChange={(included) =>
777
+ handlePresentationInclusion(contribution.id, included)
778
+ }
779
+ />
780
+ </div>
781
+ )
782
+ })}
783
+ </div>
784
+ </fieldset>
785
+ ) : null}
786
+ </div>
787
+ </details>
788
+
789
+ {MODEL_EXPORT_FORMATS.map(({ format, label }) => {
790
+ const isActive = activeModelExport === format
791
+ return (
792
+ <Button
793
+ aria-busy={isActive}
794
+ className="w-full justify-start gap-2"
795
+ disabled={activeModelExport !== null || sendToBlenderStep !== null || !modelExport}
796
+ key={format}
797
+ onClick={() => void handleModelExport(format, label)}
798
+ variant="outline"
799
+ >
800
+ <Download aria-hidden="true" className="size-4" />
801
+ {isActive ? `Exporting ${label}…` : `Export ${label}`}
802
+ </Button>
803
+ )
804
+ })}
805
+
393
806
  <Button
807
+ aria-busy={sendToBlenderStep !== null}
394
808
  className="w-full justify-start gap-2"
395
- onClick={() => exportScene?.('obj')}
809
+ disabled={activeModelExport !== null || sendToBlenderStep !== null || !modelExport}
810
+ onClick={() => void handleSendToBlender()}
396
811
  variant="outline"
397
812
  >
398
- <Download className="size-4" />
399
- Export OBJ
813
+ <Send aria-hidden="true" className="size-4" />
814
+ {sendToBlenderStep ? SEND_TO_BLENDER_STEP_LABEL[sendToBlenderStep] : 'Send to Blender'}
400
815
  </Button>
816
+ <p className="text-muted-foreground text-xs">
817
+ Needs the{' '}
818
+ <a
819
+ className="underline underline-offset-2"
820
+ href={BLENDER_ADDON_URL}
821
+ rel="noreferrer"
822
+ target="_blank"
823
+ >
824
+ Pascal add-on for Blender
825
+ </a>{' '}
826
+ running in an open Blender.
827
+ </p>
828
+ {sendToBlenderMessage ? (
829
+ <p
830
+ className={
831
+ sendToBlenderMessage.tone === 'error'
832
+ ? 'text-destructive text-xs'
833
+ : 'text-foreground text-xs'
834
+ }
835
+ role={sendToBlenderMessage.tone === 'error' ? 'alert' : 'status'}
836
+ >
837
+ {sendToBlenderMessage.text}
838
+ </p>
839
+ ) : null}
840
+
841
+ {activeModelExport ? (
842
+ <p className="text-muted-foreground text-xs" role="status">
843
+ Preparing {activeModelExport.toUpperCase()} file…
844
+ </p>
845
+ ) : null}
846
+ {modelExportError ? (
847
+ <p className="text-destructive text-xs" role="alert">
848
+ {modelExportError}
849
+ </p>
850
+ ) : null}
851
+ {modelExportWarning ? (
852
+ <p className="text-foreground text-xs" role="status">
853
+ Warning: {modelExportWarning}
854
+ </p>
855
+ ) : null}
856
+
857
+ <PrintExportButton onlyVisible={exportOnlyVisible} />
401
858
  </div>
402
859
 
403
860
  <div className="space-y-2">
@@ -406,21 +863,35 @@ export function SettingsPanel({
406
863
  <span>{floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}</span>
407
864
  </div>
408
865
  <Button
866
+ aria-busy={activeFloorplanExport === 'full'}
409
867
  className="w-full justify-start gap-2"
410
- onClick={() => exportFloorplanPdf('full')}
868
+ disabled={activeFloorplanExport !== null}
869
+ onClick={() => void handleFloorplanExport('full')}
411
870
  variant="outline"
412
871
  >
413
872
  <MapIcon className="size-4" />
414
873
  Full floor plan
415
874
  </Button>
416
875
  <Button
876
+ aria-busy={activeFloorplanExport === 'structure'}
417
877
  className="w-full justify-start gap-2"
418
- onClick={() => exportFloorplanPdf('structure')}
878
+ disabled={activeFloorplanExport !== null}
879
+ onClick={() => void handleFloorplanExport('structure')}
419
880
  variant="outline"
420
881
  >
421
882
  <MapIcon className="size-4" />
422
883
  Structure only
423
884
  </Button>
885
+ {activeFloorplanExport ? (
886
+ <p className="text-muted-foreground text-xs" role="status">
887
+ Preparing floor-plan PDF…
888
+ </p>
889
+ ) : null}
890
+ {floorplanExportError ? (
891
+ <p className="break-words text-destructive text-xs" role="alert">
892
+ {floorplanExportError}
893
+ </p>
894
+ ) : null}
424
895
  </div>
425
896
  </div>
426
897