@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -1,28 +1,39 @@
1
1
  import {
2
2
  clearSceneHistory,
3
+ DEFAULT_LEVEL_HEIGHT,
3
4
  emitter,
5
+ getLevelDisplayName,
6
+ LevelNode,
7
+ type AnyNodeId,
8
+ type BuildingNode,
4
9
  isNodeKindEnabled,
5
10
  nodeRegistry,
11
+ type ParsedBuildJson,
6
12
  useRegistryVersion,
7
13
  useScene,
8
- type ParsedBuildJson,
9
14
  validateBuildJson,
10
15
  } from '@pascal-app/core'
11
16
  import { useViewer, viewerPresentationRegistry } from '@pascal-app/viewer'
12
17
  import { TreeView, VisualJson } from '@visual-json/react'
13
18
  import {
19
+ ArrowDown,
20
+ ArrowUp,
14
21
  Camera,
15
22
  Check,
16
- ChevronDown,
17
23
  Copy,
18
24
  Download,
25
+ Layers,
26
+ ListTree,
19
27
  Map as MapIcon,
20
28
  Save,
29
+ ChevronDown,
30
+ Send,
21
31
  Trash2,
22
32
  Upload,
23
33
  } from 'lucide-react'
24
34
  import {
25
35
  type KeyboardEvent,
36
+ type ReactNode,
26
37
  type SyntheticEvent,
27
38
  useCallback,
28
39
  useEffect,
@@ -32,10 +43,17 @@ import {
32
43
  useState,
33
44
  useSyncExternalStore,
34
45
  } from 'react'
46
+ import { useShallow } from 'zustand/react/shallow'
35
47
  import {
36
48
  exportFloorplanPdf,
37
49
  type FloorplanExportScope,
38
50
  } from '../../../../../lib/floorplan/floorplan-export'
51
+ import {
52
+ LocalAppError,
53
+ probeLocalApp,
54
+ sendGlbToLocalApp,
55
+ waitForLocalImport,
56
+ } from '../../../../../lib/send-to-app'
39
57
  import { Button } from './../../../../../components/ui/primitives/button'
40
58
  import {
41
59
  Dialog,
@@ -45,8 +63,11 @@ import {
45
63
  } from './../../../../../components/ui/primitives/dialog'
46
64
  import { Input } from './../../../../../components/ui/primitives/input'
47
65
  import { Switch } from './../../../../../components/ui/primitives/switch'
66
+ import { cn } from './../../../../../lib/utils'
67
+ import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
48
68
  import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
49
69
  import useFloorplanMode from './../../../../../store/use-floorplan-mode'
70
+ import { type SendToAppStep, useSendToApp } from './../../../../../store/use-send-to-app'
50
71
  import { AudioSettingsDialog } from './audio-settings-dialog'
51
72
  import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
52
73
  import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
@@ -84,6 +105,15 @@ const MODEL_EXPORT_FORMATS = [
84
105
 
85
106
  type ModelExportFormat = (typeof MODEL_EXPORT_FORMATS)[number]['format']
86
107
 
108
+ const SEND_TO_BLENDER_STEP_LABEL: Record<SendToAppStep, string> = {
109
+ probing: 'Looking for Blender…',
110
+ exporting: 'Preparing the scene…',
111
+ sending: 'Sending to Blender…',
112
+ importing: 'Blender is importing…',
113
+ }
114
+
115
+ const BLENDER_ADDON_URL = 'https://github.com/pascalorg/blender-addon#install'
116
+
87
117
  type ExportableNodeType = {
88
118
  type: string
89
119
  label: string
@@ -206,8 +236,72 @@ export interface ProjectVisibility {
206
236
  showGuidesPublic: boolean
207
237
  }
208
238
 
239
+ function SettingsSection({
240
+ children,
241
+ description,
242
+ title,
243
+ tone = 'default',
244
+ }: {
245
+ children: ReactNode
246
+ description?: string
247
+ title: string
248
+ tone?: 'default' | 'destructive'
249
+ }) {
250
+ const headingId = useId()
251
+ return (
252
+ <section aria-labelledby={headingId} className="space-y-3 py-5 first:pt-0 last:pb-0">
253
+ <div className="space-y-0.5">
254
+ <h3
255
+ className={cn(
256
+ 'font-semibold text-sm',
257
+ tone === 'destructive' ? 'text-destructive' : 'text-foreground',
258
+ )}
259
+ id={headingId}
260
+ >
261
+ {title}
262
+ </h3>
263
+ {description ? <p className="text-muted-foreground text-xs">{description}</p> : null}
264
+ </div>
265
+ {children}
266
+ </section>
267
+ )
268
+ }
269
+
270
+ function SettingsSubheading({ children }: { children: ReactNode }) {
271
+ return <h4 className="font-medium text-muted-foreground text-xs">{children}</h4>
272
+ }
273
+
274
+ function SettingsSwitchRow({
275
+ checked,
276
+ description,
277
+ label,
278
+ onCheckedChange,
279
+ }: {
280
+ checked: boolean
281
+ description: string
282
+ label: string
283
+ onCheckedChange: (checked: boolean) => void
284
+ }) {
285
+ const id = useId()
286
+ return (
287
+ <div className="flex items-center justify-between gap-4 py-1">
288
+ <div className="min-w-0 space-y-0.5">
289
+ <label className="block font-medium text-sm" htmlFor={id}>
290
+ {label}
291
+ </label>
292
+ <p className="text-muted-foreground text-xs">{description}</p>
293
+ </div>
294
+ <Switch checked={checked} id={id} onCheckedChange={onCheckedChange} />
295
+ </div>
296
+ )
297
+ }
298
+
209
299
  export interface SettingsPanelProps {
300
+ /** Merged onto the scrolling root so hosts can restyle padding (e.g. inside a dialog). */
301
+ className?: string
210
302
  projectId?: string
303
+ /** Shown as the scene name in apps the scene is sent to (Blender collection name). */
304
+ projectName?: string
211
305
  projectVisibility?: ProjectVisibility
212
306
  onVisibilityChange?: (
213
307
  field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
@@ -215,8 +309,126 @@ export interface SettingsPanelProps {
215
309
  ) => Promise<void>
216
310
  }
217
311
 
312
+ function FloorSettingsSection() {
313
+ const selectedBuildingId = useViewer((state) => state.selection.buildingId)
314
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
315
+ const setSelection = useViewer((state) => state.setSelection)
316
+ const createNode = useScene((state) => state.createNode)
317
+ const building = useScene((state) => {
318
+ const selected = selectedBuildingId ? state.nodes[selectedBuildingId] : undefined
319
+ if (selected?.type === 'building') return selected as BuildingNode
320
+
321
+ const site = state.rootNodeIds
322
+ .map((nodeId) => state.nodes[nodeId])
323
+ .find((node) => node?.type === 'site')
324
+ if (site?.type !== 'site') return null
325
+
326
+ return (
327
+ site.children
328
+ .map((childId) => state.nodes[childId as AnyNodeId])
329
+ .find((node): node is BuildingNode => node?.type === 'building') ?? null
330
+ )
331
+ })
332
+ const levels = useScene(
333
+ useShallow((state) => {
334
+ if (!building) return []
335
+
336
+ return building.children
337
+ .map((childId) => state.nodes[childId as AnyNodeId])
338
+ .filter((node): node is LevelNode => node?.type === 'level')
339
+ .sort((a, b) => b.level - a.level)
340
+ }),
341
+ )
342
+
343
+ if (!building) return null
344
+
345
+ const addLevel = (level: number) => {
346
+ const newLevel = LevelNode.parse({
347
+ level,
348
+ height: DEFAULT_LEVEL_HEIGHT,
349
+ children: [],
350
+ parentId: building.id,
351
+ })
352
+ createNode(newLevel, building.id)
353
+ setSelection({ buildingId: building.id, levelId: newLevel.id })
354
+ }
355
+
356
+ const addFloor = () =>
357
+ addLevel(levels.length === 0 ? 0 : Math.max(0, ...levels.map((level) => level.level)) + 1)
358
+ const addBasement = () => addLevel(Math.min(0, ...levels.map((level) => level.level)) - 1)
359
+
360
+ return (
361
+ <div className="space-y-3">
362
+ <label className="font-medium text-muted-foreground text-xs uppercase">Floors</label>
363
+ <div className="grid grid-cols-2 gap-2">
364
+ <Button className="justify-start" onClick={addFloor} size="sm" type="button" variant="outline">
365
+ <ArrowUp className="size-3.5" />
366
+ Add floor
367
+ </Button>
368
+ <Button
369
+ className="justify-start"
370
+ onClick={addBasement}
371
+ size="sm"
372
+ type="button"
373
+ variant="outline"
374
+ >
375
+ <ArrowDown className="size-3.5" />
376
+ Add basement
377
+ </Button>
378
+ </div>
379
+
380
+ <div className="space-y-1 rounded-md border p-1">
381
+ {levels.length === 0 ? (
382
+ <div className="px-2 py-2 text-muted-foreground text-xs">No floors yet</div>
383
+ ) : (
384
+ levels.map((level) => {
385
+ const isSelected = level.id === selectedLevelId
386
+ const canDelete = level.level !== 0
387
+
388
+ return (
389
+ <div className="flex items-center gap-1" key={level.id}>
390
+ <button
391
+ className={cn(
392
+ 'flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors',
393
+ isSelected
394
+ ? 'bg-accent text-foreground'
395
+ : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
396
+ )}
397
+ onClick={() => setSelection({ buildingId: building.id, levelId: level.id })}
398
+ type="button"
399
+ >
400
+ <Layers className="size-3.5 shrink-0" />
401
+ <span className="truncate">{getLevelDisplayName(level)}</span>
402
+ <span className="ml-auto shrink-0 text-muted-foreground text-xs">
403
+ {level.level === 0 ? '0' : level.level > 0 ? `+${level.level}` : level.level}
404
+ </span>
405
+ </button>
406
+ <button
407
+ aria-label={`Remove ${getLevelDisplayName(level)}`}
408
+ className="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-30"
409
+ disabled={!canDelete}
410
+ onClick={() => deleteLevelWithFallbackSelection(level.id)}
411
+ title={canDelete ? 'Remove floor' : 'The ground floor cannot be removed'}
412
+ type="button"
413
+ >
414
+ <Trash2 className="size-3.5" />
415
+ </button>
416
+ </div>
417
+ )
418
+ })
419
+ )}
420
+ </div>
421
+ <p className="text-muted-foreground text-xs">
422
+ Select a floor to make it active. The ground floor is always kept as the building datum.
423
+ </p>
424
+ </div>
425
+ )
426
+ }
427
+
218
428
  export function SettingsPanel({
429
+ className,
219
430
  projectId,
431
+ projectName,
220
432
  projectVisibility,
221
433
  onVisibilityChange,
222
434
  }: SettingsPanelProps = {}) {
@@ -226,6 +438,7 @@ export function SettingsPanel({
226
438
  const rootNodeIds = useScene((state) => state.rootNodeIds)
227
439
  const installedPlugins = useScene((state) => state.installedPlugins)
228
440
  const materials = useScene((state) => state.materials)
441
+ const collections = useScene((state) => state.collections)
229
442
  const setScene = useScene((state) => state.setScene)
230
443
  const clearScene = useScene((state) => state.clearScene)
231
444
  const resetSelection = useViewer((state) => state.resetSelection)
@@ -244,6 +457,10 @@ export function SettingsPanel({
244
457
  const [activeModelExport, setActiveModelExport] = useState<ModelExportFormat | null>(null)
245
458
  const [modelExportError, setModelExportError] = useState<string | null>(null)
246
459
  const [modelExportWarning, setModelExportWarning] = useState<string | null>(null)
460
+ const sendToBlenderStep = useSendToApp((state) => state.step)
461
+ const sendToBlenderMessage = useSendToApp((state) => state.message)
462
+ const setSendToBlenderStep = useSendToApp((state) => state.setStep)
463
+ const setSendToBlenderMessage = useSendToApp((state) => state.setMessage)
247
464
  const [activeFloorplanExport, setActiveFloorplanExport] = useState<FloorplanExportScope | null>(
248
465
  null,
249
466
  )
@@ -323,7 +540,7 @@ export function SettingsPanel({
323
540
  // Materials ride along: nodes reference them by `scene:<id>` slot
324
541
  // refs, so a save without the table produces a file whose custom
325
542
  // finishes revert to defaults on the very Load Build path below.
326
- const sceneData = { nodes, rootNodeIds, installedPlugins, materials }
543
+ const sceneData = { nodes, rootNodeIds, installedPlugins, materials, collections }
327
544
  const json = JSON.stringify(sceneData, null, 2)
328
545
  const blob = new Blob([json], { type: 'application/json' })
329
546
  const url = URL.createObjectURL(blob)
@@ -389,6 +606,7 @@ export function SettingsPanel({
389
606
  // pointed at a material that no longer existed — custom finishes
390
607
  // silently reverted to defaults on import.
391
608
  materials: parsed.materials,
609
+ collections: parsed.collections,
392
610
  installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
393
611
  hasExplicitPluginInstallState:
394
612
  parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
@@ -463,7 +681,7 @@ export function SettingsPanel({
463
681
  }, [])
464
682
 
465
683
  const handleModelExport = async (format: ModelExportFormat, label: string) => {
466
- if (!modelExport || activeModelExport) return
684
+ if (!modelExport || activeModelExport || useSendToApp.getState().step) return
467
685
 
468
686
  setActiveModelExport(format)
469
687
  setModelExportError(null)
@@ -492,6 +710,62 @@ export function SettingsPanel({
492
710
  }
493
711
  }
494
712
 
713
+ const handleSendToBlender = async () => {
714
+ if (!modelExport || activeModelExport || useSendToApp.getState().step) return
715
+
716
+ setSendToBlenderMessage(null)
717
+ setSendToBlenderStep('probing')
718
+ try {
719
+ const probe = await probeLocalApp()
720
+ if (probe.status === 'unreachable') {
721
+ setSendToBlenderMessage({
722
+ tone: 'error',
723
+ text: 'Blender isn’t listening. Open Blender with the Pascal add-on installed and enabled, then try again.',
724
+ })
725
+ return
726
+ }
727
+ if (probe.status === 'refused') {
728
+ setSendToBlenderMessage({
729
+ tone: 'error',
730
+ 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.`,
731
+ })
732
+ return
733
+ }
734
+
735
+ setSendToBlenderStep('exporting')
736
+ const artifact = await modelExport('glb', {
737
+ onlyVisible: exportOnlyVisible,
738
+ excludedNodeTypes,
739
+ includedPresentationIds: includedPresentationIds.filter((id) =>
740
+ exportablePresentations.some((contribution) => contribution.id === id),
741
+ ),
742
+ download: false,
743
+ })
744
+ if (!artifact) throw new Error('Model export did not produce a file')
745
+
746
+ setSendToBlenderStep('sending')
747
+ const queued = await sendGlbToLocalApp(probe.base, artifact.blob, {
748
+ name: projectName,
749
+ projectId,
750
+ })
751
+
752
+ setSendToBlenderStep('importing')
753
+ const done = await waitForLocalImport(probe.base, queued.id)
754
+ setSendToBlenderMessage({
755
+ tone: 'info',
756
+ text: done.summary ? `Sent to Blender. ${done.summary}.` : 'Sent to Blender.',
757
+ })
758
+ } catch (error) {
759
+ const text =
760
+ error instanceof LocalAppError || error instanceof Error
761
+ ? error.message
762
+ : 'Couldn’t send the scene to Blender. Try again.'
763
+ setSendToBlenderMessage({ tone: 'error', text })
764
+ } finally {
765
+ setSendToBlenderStep(null)
766
+ }
767
+ }
768
+
495
769
  const handleFloorplanExport = async (scope: FloorplanExportScope) => {
496
770
  if (activeFloorplanExport) return
497
771
 
@@ -509,374 +783,433 @@ export function SettingsPanel({
509
783
  }
510
784
 
511
785
  return (
512
- <div className="subtle-scrollbar min-h-0 flex-1 space-y-6 overflow-x-hidden overflow-y-auto overscroll-contain p-3">
513
- {projectId && (
514
- <div className="space-y-2">
515
- <label className="font-medium text-muted-foreground text-xs uppercase">Project</label>
516
- <div className="font-medium text-sm">Project ID</div>
517
- <div className="flex items-center gap-2">
518
- <Input
519
- aria-label="Project ID"
520
- className="font-mono text-xs"
521
- readOnly
522
- value={projectId}
523
- />
524
- <Button
525
- aria-label={projectIdCopyState === 'copied' ? 'Project ID copied' : 'Copy project ID'}
526
- className="rounded-full"
527
- onClick={() => void handleCopyProjectId()}
528
- size="sm"
529
- type="button"
530
- variant="outline"
531
- >
532
- {projectIdCopyState === 'copied' ? (
533
- <Check className="size-3.5" />
534
- ) : (
535
- <Copy className="size-3.5" />
536
- )}
537
- {projectIdCopyState === 'copied'
538
- ? 'Copied'
539
- : projectIdCopyState === 'error'
540
- ? 'Try again'
541
- : 'Copy'}
542
- </Button>
543
- </div>
544
- </div>
786
+ <div
787
+ className={cn(
788
+ 'subtle-scrollbar @container min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3',
789
+ className,
545
790
  )}
546
-
547
- {/* Visibility Section (only for cloud projects) */}
548
- {projectId && !isLocalProject && (
549
- <div className="space-y-3">
550
- <label className="font-medium text-muted-foreground text-xs uppercase">Visibility</label>
551
- <div className="flex items-center justify-between">
552
- <div>
553
- <div className="font-medium text-sm">Public</div>
554
- <div className="text-muted-foreground text-xs">
555
- {projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view
556
- </div>
791
+ >
792
+ <div className="divide-y divide-border/60">
793
+ <section className="space-y-3 py-5 first:pt-0 last:pb-0">
794
+ <FloorSettingsSection />
795
+ </section>
796
+ {projectId && (
797
+ <SettingsSection
798
+ description="Reference this project from the API, MCP tools, or support."
799
+ title="Project"
800
+ >
801
+ <div className="flex items-center gap-2">
802
+ <Input
803
+ aria-label="Project ID"
804
+ className="font-mono text-xs"
805
+ readOnly
806
+ value={projectId}
807
+ />
808
+ <Button
809
+ aria-label={
810
+ projectIdCopyState === 'copied' ? 'Project ID copied' : 'Copy project ID'
811
+ }
812
+ className="rounded-full"
813
+ onClick={() => void handleCopyProjectId()}
814
+ size="sm"
815
+ type="button"
816
+ variant="outline"
817
+ >
818
+ {projectIdCopyState === 'copied' ? (
819
+ <Check className="size-3.5" />
820
+ ) : (
821
+ <Copy className="size-3.5" />
822
+ )}
823
+ {projectIdCopyState === 'copied'
824
+ ? 'Copied'
825
+ : projectIdCopyState === 'error'
826
+ ? 'Try again'
827
+ : 'Copy'}
828
+ </Button>
557
829
  </div>
558
- <Switch
559
- aria-label="Make project public"
830
+ </SettingsSection>
831
+ )}
832
+
833
+ {/* Sharing (only for cloud projects) */}
834
+ {projectId && !isLocalProject && (
835
+ <SettingsSection description="Control who can open this project." title="Sharing">
836
+ <SettingsSwitchRow
560
837
  checked={!(projectVisibility?.isPrivate ?? false)}
838
+ description={
839
+ projectVisibility?.isPrivate
840
+ ? 'Only you can view it.'
841
+ : 'Anyone with the link can view it.'
842
+ }
843
+ label="Public project"
561
844
  onCheckedChange={(checked) => handleVisibilityChange('isPrivate', !checked)}
562
845
  />
563
- </div>
564
- <div className="flex items-center justify-between">
565
- <div>
566
- <div className="font-medium text-sm">Show 3D Scans</div>
567
- <div className="text-muted-foreground text-xs">Visible to public viewers</div>
568
- </div>
569
- <Switch
570
- aria-label="Show 3D scans to public viewers"
846
+ <SettingsSwitchRow
571
847
  checked={projectVisibility?.showScansPublic ?? true}
848
+ description="Visible in the public viewer."
849
+ label="Show 3D scans"
572
850
  onCheckedChange={(checked) => handleVisibilityChange('showScansPublic', checked)}
573
851
  />
574
- </div>
575
- <div className="flex items-center justify-between">
576
- <div>
577
- <div className="font-medium text-sm">Show Floorplans</div>
578
- <div className="text-muted-foreground text-xs">Visible to public viewers</div>
579
- </div>
580
- <Switch
581
- aria-label="Show floorplans to public viewers"
852
+ <SettingsSwitchRow
582
853
  checked={projectVisibility?.showGuidesPublic ?? true}
854
+ description="Visible in the public viewer."
855
+ label="Show floor plans"
583
856
  onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
584
857
  />
585
- </div>
586
- <div className="flex items-center justify-between">
587
- <div>
588
- <div className="font-medium text-sm">Shadows</div>
589
- <div className="text-muted-foreground text-xs">Cast shadows from lights</div>
590
- </div>
591
- <Switch
592
- aria-label="Enable shadows"
593
- checked={shadows}
594
- onCheckedChange={(checked) => useViewer.getState().setShadows(checked)}
595
- />
596
- </div>
597
- </div>
598
- )}
599
-
600
- {/* Export Section */}
601
- <div className="space-y-4">
602
- <label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
603
-
604
- <div className="space-y-2">
605
- <div className="font-medium text-muted-foreground text-xs">3D model</div>
606
- <details
607
- className="group"
608
- onKeyDownCapture={(event) => {
609
- // Keep Space available to the disclosure and switches, not canvas panning.
610
- if (event.code === 'Space') event.stopPropagation()
611
- }}
612
- >
613
- <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">
614
- Export options
615
- <ChevronDown aria-hidden="true" className="size-4 group-open:rotate-180" />
616
- </summary>
617
- <div className="space-y-3 pt-3">
618
- <div className="flex items-center justify-between gap-4 rounded-md border p-3">
619
- <div className="min-w-0">
620
- <label className="font-medium text-sm" htmlFor={visibleOnlySwitchId}>
621
- Visible nodes only
622
- </label>
623
- <div className="text-muted-foreground text-xs">
624
- Exclude hidden furniture and other hidden scene nodes
858
+ </SettingsSection>
859
+ )}
860
+
861
+ <SettingsSection description="Rendering options for this editor session." title="Viewer">
862
+ <SettingsSwitchRow
863
+ checked={shadows}
864
+ description="Cast shadows from lights."
865
+ label="Shadows"
866
+ onCheckedChange={(checked) => useViewer.getState().setShadows(checked)}
867
+ />
868
+ </SettingsSection>
869
+
870
+ <SettingsSection description="Download the scene or hand it to another app." title="Export">
871
+ <div className="space-y-3">
872
+ <SettingsSubheading>3D model</SettingsSubheading>
873
+ <details
874
+ className="group"
875
+ onKeyDownCapture={(event) => {
876
+ // Keep Space available to the disclosure and switches, not canvas panning.
877
+ if (event.code === 'Space') event.stopPropagation()
878
+ }}
879
+ >
880
+ <summary className="flex min-h-10 cursor-pointer list-none items-center justify-between rounded-md border px-3 font-medium text-sm focus-visible:outline-2 focus-visible:outline-ring">
881
+ Export options
882
+ <ChevronDown aria-hidden="true" className="size-4 group-open:rotate-180" />
883
+ </summary>
884
+ <div className="space-y-3 pt-3">
885
+ <div className="flex items-center justify-between gap-4 rounded-md border p-3">
886
+ <div className="min-w-0">
887
+ <label className="font-medium text-sm" htmlFor={visibleOnlySwitchId}>
888
+ Visible nodes only
889
+ </label>
890
+ <div className="text-muted-foreground text-xs">
891
+ Exclude hidden furniture and other hidden scene nodes
892
+ </div>
625
893
  </div>
894
+ <Switch
895
+ aria-label="Export visible nodes only"
896
+ checked={exportOnlyVisible}
897
+ id={visibleOnlySwitchId}
898
+ onCheckedChange={setExportOnlyVisible}
899
+ />
626
900
  </div>
627
- <Switch
628
- aria-label="Export visible nodes only"
629
- checked={exportOnlyVisible}
630
- id={visibleOnlySwitchId}
631
- onCheckedChange={setExportOnlyVisible}
632
- />
633
- </div>
634
901
 
635
- <fieldset className="space-y-2 rounded-md border p-3">
636
- <legend className="px-1 font-medium text-sm">Include in file</legend>
637
- <p className="text-muted-foreground text-xs">
638
- Choose which procedural content is baked into model files. GLB and USDZ use the
639
- textured portable path; STL and OBJ remain geometry-only.
640
- </p>
641
- {exportableNodeTypes.length > 0 ? (
642
- <div className="space-y-2 pt-1">
643
- {exportableNodeTypes.map(({ type, label, supportsGeometryOnly }, index) => {
644
- const switchId = `${includeNodeTypeIdPrefix}-${index}`
645
- return (
646
- <div className="flex items-center justify-between gap-4" key={type}>
647
- <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
648
- {label}
649
- {!supportsGeometryOnly && (
650
- <span className="text-muted-foreground text-xs"> (GLB/USDZ only)</span>
651
- )}
652
- </label>
653
- <Switch
654
- aria-label={`Include ${label} in model files`}
655
- checked={!excludedNodeTypes.includes(type)}
656
- id={switchId}
657
- onCheckedChange={(included) => handleNodeTypeInclusion(type, included)}
658
- />
659
- </div>
660
- )
661
- })}
662
- </div>
663
- ) : (
902
+ <fieldset className="space-y-2 rounded-md border p-3">
903
+ <legend className="px-1 font-medium text-sm">Include in file</legend>
664
904
  <p className="text-muted-foreground text-xs">
665
- No optional procedural content is present.
905
+ Choose which procedural content is baked into model files. GLB and USDZ use the
906
+ textured portable path; STL and OBJ remain geometry-only.
666
907
  </p>
667
- )}
668
- <p className="text-muted-foreground text-xs">
669
- Viewer surroundings are excluded unless selected separately below.
670
- </p>
671
- </fieldset>
672
- {exportablePresentations.length > 0 ? (
673
- <fieldset className="space-y-2 rounded-md border p-3">
674
- <legend className="px-1 font-medium text-sm">Viewer surroundings</legend>
908
+ {exportableNodeTypes.length > 0 ? (
909
+ <div className="space-y-2 pt-1">
910
+ {exportableNodeTypes.map(({ type, label, supportsGeometryOnly }, index) => {
911
+ const switchId = `${includeNodeTypeIdPrefix}-${index}`
912
+ return (
913
+ <div className="flex items-center justify-between gap-4" key={type}>
914
+ <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
915
+ {label}
916
+ {!supportsGeometryOnly && (
917
+ <span className="text-muted-foreground text-xs">
918
+ {' '}
919
+ (GLB/USDZ only)
920
+ </span>
921
+ )}
922
+ </label>
923
+ <Switch
924
+ aria-label={`Include ${label} in model files`}
925
+ checked={!excludedNodeTypes.includes(type)}
926
+ id={switchId}
927
+ onCheckedChange={(included) =>
928
+ handleNodeTypeInclusion(type, included)
929
+ }
930
+ />
931
+ </div>
932
+ )
933
+ })}
934
+ </div>
935
+ ) : (
936
+ <p className="text-muted-foreground text-xs">
937
+ No optional procedural content is present.
938
+ </p>
939
+ )}
675
940
  <p className="text-muted-foreground text-xs">
676
- Optional static surroundings are included only in GLB and USDZ.
941
+ Viewer surroundings are excluded unless selected separately below.
677
942
  </p>
678
- <div className="space-y-2 pt-1">
679
- {exportablePresentations.map((contribution, index) => {
680
- const switchId = `${includePresentationIdPrefix}-${index}`
681
- const label = contribution.staticExport!.label
682
- return (
683
- <div
684
- className="flex items-center justify-between gap-4"
685
- key={contribution.id}
686
- >
687
- <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
688
- {label}
689
- </label>
690
- <Switch
691
- aria-label={`Include ${label} in GLB and USDZ`}
692
- checked={includedPresentationIds.includes(contribution.id)}
693
- id={switchId}
694
- onCheckedChange={(included) =>
695
- handlePresentationInclusion(contribution.id, included)
696
- }
697
- />
698
- </div>
699
- )
700
- })}
701
- </div>
702
943
  </fieldset>
703
- ) : null}
944
+ {exportablePresentations.length > 0 ? (
945
+ <fieldset className="space-y-2 rounded-md border p-3">
946
+ <legend className="px-1 font-medium text-sm">Viewer surroundings</legend>
947
+ <p className="text-muted-foreground text-xs">
948
+ Optional static surroundings are included only in GLB and USDZ.
949
+ </p>
950
+ <div className="space-y-2 pt-1">
951
+ {exportablePresentations.map((contribution, index) => {
952
+ const switchId = `${includePresentationIdPrefix}-${index}`
953
+ const label = contribution.staticExport!.label
954
+ return (
955
+ <div
956
+ className="flex items-center justify-between gap-4"
957
+ key={contribution.id}
958
+ >
959
+ <label className="min-w-0 font-medium text-sm" htmlFor={switchId}>
960
+ {label}
961
+ </label>
962
+ <Switch
963
+ aria-label={`Include ${label} in GLB and USDZ`}
964
+ checked={includedPresentationIds.includes(contribution.id)}
965
+ id={switchId}
966
+ onCheckedChange={(included) =>
967
+ handlePresentationInclusion(contribution.id, included)
968
+ }
969
+ />
970
+ </div>
971
+ )
972
+ })}
973
+ </div>
974
+ </fieldset>
975
+ ) : null}
976
+ </div>
977
+ </details>
978
+
979
+ <div className="grid gap-2 @sm:grid-cols-2">
980
+ {MODEL_EXPORT_FORMATS.map(({ format, label }) => {
981
+ const isActive = activeModelExport === format
982
+ return (
983
+ <Button
984
+ aria-busy={isActive}
985
+ className="w-full justify-start gap-2"
986
+ disabled={
987
+ activeModelExport !== null || sendToBlenderStep !== null || !modelExport
988
+ }
989
+ key={format}
990
+ onClick={() => void handleModelExport(format, label)}
991
+ variant="outline"
992
+ >
993
+ <Download aria-hidden="true" className="size-4" />
994
+ {isActive ? `Exporting ${label}…` : `Export ${label}`}
995
+ </Button>
996
+ )
997
+ })}
704
998
  </div>
705
- </details>
706
999
 
707
- {MODEL_EXPORT_FORMATS.map(({ format, label }) => {
708
- const isActive = activeModelExport === format
709
- return (
1000
+ <div className="space-y-2">
710
1001
  <Button
711
- aria-busy={isActive}
1002
+ aria-busy={sendToBlenderStep !== null}
712
1003
  className="w-full justify-start gap-2"
713
- disabled={activeModelExport !== null || !modelExport}
714
- key={format}
715
- onClick={() => void handleModelExport(format, label)}
1004
+ disabled={activeModelExport !== null || sendToBlenderStep !== null || !modelExport}
1005
+ onClick={() => void handleSendToBlender()}
716
1006
  variant="outline"
717
1007
  >
718
- <Download aria-hidden="true" className="size-4" />
719
- {isActive ? `Exporting ${label}…` : `Export ${label}`}
1008
+ <Send aria-hidden="true" className="size-4" />
1009
+ {sendToBlenderStep
1010
+ ? SEND_TO_BLENDER_STEP_LABEL[sendToBlenderStep]
1011
+ : 'Send to Blender'}
720
1012
  </Button>
721
- )
722
- })}
723
-
724
- {activeModelExport ? (
725
- <p className="text-muted-foreground text-xs" role="status">
726
- Preparing {activeModelExport.toUpperCase()} file…
727
- </p>
728
- ) : null}
729
- {modelExportError ? (
730
- <p className="text-destructive text-xs" role="alert">
731
- {modelExportError}
732
- </p>
733
- ) : null}
734
- {modelExportWarning ? (
735
- <p className="text-foreground text-xs" role="status">
736
- Warning: {modelExportWarning}
737
- </p>
738
- ) : null}
739
-
740
- <PrintExportButton onlyVisible={exportOnlyVisible} />
741
- </div>
742
-
743
- <div className="space-y-2">
744
- <div className="flex items-center justify-between font-medium text-muted-foreground text-xs">
745
- <span>Floor plan</span>
746
- <span>{floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}</span>
1013
+ <p className="text-muted-foreground text-xs">
1014
+ Needs the{' '}
1015
+ <a
1016
+ className="underline underline-offset-2"
1017
+ href={BLENDER_ADDON_URL}
1018
+ rel="noreferrer"
1019
+ target="_blank"
1020
+ >
1021
+ Pascal add-on for Blender
1022
+ </a>{' '}
1023
+ running in an open Blender.
1024
+ </p>
1025
+ </div>
1026
+ {sendToBlenderMessage ? (
1027
+ <p
1028
+ className={
1029
+ sendToBlenderMessage.tone === 'error'
1030
+ ? 'text-destructive text-xs'
1031
+ : 'text-foreground text-xs'
1032
+ }
1033
+ role={sendToBlenderMessage.tone === 'error' ? 'alert' : 'status'}
1034
+ >
1035
+ {sendToBlenderMessage.text}
1036
+ </p>
1037
+ ) : null}
1038
+
1039
+ {activeModelExport ? (
1040
+ <p className="text-muted-foreground text-xs" role="status">
1041
+ Preparing {activeModelExport.toUpperCase()} file…
1042
+ </p>
1043
+ ) : null}
1044
+ {modelExportError ? (
1045
+ <p className="text-destructive text-xs" role="alert">
1046
+ {modelExportError}
1047
+ </p>
1048
+ ) : null}
1049
+ {modelExportWarning ? (
1050
+ <p className="text-foreground text-xs" role="status">
1051
+ Warning: {modelExportWarning}
1052
+ </p>
1053
+ ) : null}
1054
+
1055
+ <div className="grid gap-2 @sm:grid-cols-2">
1056
+ <PrintExportButton onlyVisible={exportOnlyVisible} />
1057
+ </div>
747
1058
  </div>
748
- <Button
749
- aria-busy={activeFloorplanExport === 'full'}
750
- className="w-full justify-start gap-2"
751
- disabled={activeFloorplanExport !== null}
752
- onClick={() => void handleFloorplanExport('full')}
753
- variant="outline"
754
- >
755
- <MapIcon className="size-4" />
756
- Full floor plan
757
- </Button>
758
- <Button
759
- aria-busy={activeFloorplanExport === 'structure'}
760
- className="w-full justify-start gap-2"
761
- disabled={activeFloorplanExport !== null}
762
- onClick={() => void handleFloorplanExport('structure')}
763
- variant="outline"
764
- >
765
- <MapIcon className="size-4" />
766
- Structure only
767
- </Button>
768
- {activeFloorplanExport ? (
769
- <p className="text-muted-foreground text-xs" role="status">
770
- Preparing floor-plan PDF…
771
- </p>
772
- ) : null}
773
- {floorplanExportError ? (
774
- <p className="break-words text-destructive text-xs" role="alert">
775
- {floorplanExportError}
776
- </p>
777
- ) : null}
778
- </div>
779
- </div>
780
-
781
- {/* Thumbnail Section (only for cloud projects) */}
782
- {projectId && !isLocalProject && (
783
- <div className="space-y-2">
784
- <label className="font-medium text-muted-foreground text-xs uppercase">Thumbnail</label>
785
- <Button
786
- className="w-full justify-start gap-2"
787
- disabled={isGeneratingThumbnail}
788
- onClick={handleGenerateThumbnail}
789
- variant="outline"
790
- >
791
- <Camera className="size-4" />
792
- {isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}
793
- </Button>
794
- </div>
795
- )}
796
1059
 
797
- {/* Save/Load Section */}
798
- <div className="space-y-2">
799
- <label className="font-medium text-muted-foreground text-xs uppercase">Save & Load</label>
1060
+ <div className="space-y-3 pt-2">
1061
+ <div className="flex items-center justify-between">
1062
+ <SettingsSubheading>Floor plan</SettingsSubheading>
1063
+ <span className="text-muted-foreground text-xs">
1064
+ {floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}
1065
+ </span>
1066
+ </div>
1067
+ <div className="grid gap-2 @sm:grid-cols-2">
1068
+ <Button
1069
+ aria-busy={activeFloorplanExport === 'full'}
1070
+ className="w-full justify-start gap-2"
1071
+ disabled={activeFloorplanExport !== null}
1072
+ onClick={() => void handleFloorplanExport('full')}
1073
+ variant="outline"
1074
+ >
1075
+ <MapIcon className="size-4" />
1076
+ Full floor plan
1077
+ </Button>
1078
+ <Button
1079
+ aria-busy={activeFloorplanExport === 'structure'}
1080
+ className="w-full justify-start gap-2"
1081
+ disabled={activeFloorplanExport !== null}
1082
+ onClick={() => void handleFloorplanExport('structure')}
1083
+ variant="outline"
1084
+ >
1085
+ <MapIcon className="size-4" />
1086
+ Structure only
1087
+ </Button>
1088
+ </div>
1089
+ {activeFloorplanExport ? (
1090
+ <p className="text-muted-foreground text-xs" role="status">
1091
+ Preparing floor-plan PDF…
1092
+ </p>
1093
+ ) : null}
1094
+ {floorplanExportError ? (
1095
+ <p className="break-words text-destructive text-xs" role="alert">
1096
+ {floorplanExportError}
1097
+ </p>
1098
+ ) : null}
1099
+ </div>
1100
+ </SettingsSection>
800
1101
 
801
- <Button className="w-full justify-start gap-2" onClick={handleSaveBuild} variant="outline">
802
- <Save className="size-4" />
803
- Save Build
804
- </Button>
1102
+ {/* Thumbnail (only for cloud projects) */}
1103
+ {projectId && !isLocalProject && (
1104
+ <SettingsSection
1105
+ description="Refresh the preview shown in your project list."
1106
+ title="Thumbnail"
1107
+ >
1108
+ <div className="grid gap-2 @sm:grid-cols-2">
1109
+ <Button
1110
+ className="w-full justify-start gap-2"
1111
+ disabled={isGeneratingThumbnail}
1112
+ onClick={handleGenerateThumbnail}
1113
+ variant="outline"
1114
+ >
1115
+ <Camera className="size-4" />
1116
+ {isGeneratingThumbnail ? 'Generating…' : 'Generate thumbnail'}
1117
+ </Button>
1118
+ </div>
1119
+ </SettingsSection>
1120
+ )}
805
1121
 
806
- <Button
807
- className="w-full justify-start gap-2"
808
- onClick={() => fileInputRef.current?.click()}
809
- variant="outline"
1122
+ <SettingsSection
1123
+ description="Keep a JSON copy of the scene, or restore one."
1124
+ title="Save and load"
810
1125
  >
811
- <Upload className="size-4" />
812
- Load Build
813
- </Button>
814
-
815
- <input
816
- accept="application/json"
817
- className="hidden"
818
- onChange={handleFileLoad}
819
- ref={fileInputRef}
820
- type="file"
821
- />
822
-
823
- <LoadBuildDialog
824
- onCancel={() => setPendingImport(null)}
825
- onConfirm={handleConfirmImport}
826
- pending={pendingImport}
827
- />
828
- </div>
829
-
830
- {/* Audio Section */}
831
- <div className="space-y-2">
832
- <label className="font-medium text-muted-foreground text-xs uppercase">Audio</label>
833
- <AudioSettingsDialog />
834
- </div>
835
-
836
- {/* Keyboard Section */}
837
- <div className="space-y-2">
838
- <label className="font-medium text-muted-foreground text-xs uppercase">Keyboard</label>
839
- <KeyboardShortcutsDialog />
840
- </div>
841
-
842
- {/* Scene Graph */}
843
- <div className="space-y-1">
844
- <label className="font-medium text-muted-foreground text-xs uppercase">Scene Graph</label>
845
- <Dialog>
846
- <DialogTrigger asChild>
847
- <Button className="h-auto justify-start p-0 text-sm" variant="link">
848
- Explore scene graph
1126
+ <div className="grid gap-2 @sm:grid-cols-2">
1127
+ <Button
1128
+ className="w-full justify-start gap-2"
1129
+ onClick={handleSaveBuild}
1130
+ variant="outline"
1131
+ >
1132
+ <Save className="size-4" />
1133
+ Save build
849
1134
  </Button>
850
- </DialogTrigger>
851
- <DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
852
- <DialogTitle className="sr-only">Scene Graph</DialogTitle>
853
- <div
854
- className="flex h-full min-h-0 w-full min-w-0 *:h-full *:w-full *:overflow-y-auto"
855
- onContextMenuCapture={blockSceneGraphMutations}
856
- onDragStartCapture={blockSceneGraphMutations}
857
- onDropCapture={blockSceneGraphMutations}
858
- onKeyDownCapture={blockSceneGraphDeletion}
1135
+ <Button
1136
+ className="w-full justify-start gap-2"
1137
+ onClick={() => fileInputRef.current?.click()}
1138
+ variant="outline"
859
1139
  >
860
- <VisualJson value={sceneGraphValue}>
861
- <TreeView showCounts />
862
- </VisualJson>
863
- </div>
864
- </DialogContent>
865
- </Dialog>
866
- </div>
1140
+ <Upload className="size-4" />
1141
+ Load build
1142
+ </Button>
1143
+ </div>
867
1144
 
868
- {/* Danger Zone */}
869
- <div className="space-y-2">
870
- <label className="font-medium text-destructive text-xs uppercase">Danger Zone</label>
1145
+ <input
1146
+ accept="application/json"
1147
+ className="hidden"
1148
+ onChange={handleFileLoad}
1149
+ ref={fileInputRef}
1150
+ type="file"
1151
+ />
1152
+
1153
+ <LoadBuildDialog
1154
+ onCancel={() => setPendingImport(null)}
1155
+ onConfirm={handleConfirmImport}
1156
+ pending={pendingImport}
1157
+ />
1158
+ </SettingsSection>
1159
+
1160
+ <SettingsSection description="Sound levels and keyboard shortcuts." title="Preferences">
1161
+ <div className="grid gap-2 @sm:grid-cols-2">
1162
+ <AudioSettingsDialog />
1163
+ <KeyboardShortcutsDialog />
1164
+ </div>
1165
+ </SettingsSection>
871
1166
 
872
- <Button
873
- className="w-full justify-start gap-2"
874
- onClick={handleResetToDefault}
875
- variant="destructive"
1167
+ <SettingsSection
1168
+ description="Inspect how nodes are nested in this scene."
1169
+ title="Scene graph"
876
1170
  >
877
- <Trash2 className="size-4" />
878
- Clear & Start New
879
- </Button>
1171
+ <Dialog>
1172
+ <DialogTrigger asChild>
1173
+ <div className="grid gap-2 @sm:grid-cols-2">
1174
+ <Button className="w-full justify-start gap-2" variant="outline">
1175
+ <ListTree className="size-4" />
1176
+ Explore scene graph
1177
+ </Button>
1178
+ </div>
1179
+ </DialogTrigger>
1180
+ <DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
1181
+ <DialogTitle className="sr-only">Scene Graph</DialogTitle>
1182
+ <div
1183
+ className="flex h-full min-h-0 w-full min-w-0 *:h-full *:w-full *:overflow-y-auto"
1184
+ onContextMenuCapture={blockSceneGraphMutations}
1185
+ onDragStartCapture={blockSceneGraphMutations}
1186
+ onDropCapture={blockSceneGraphMutations}
1187
+ onKeyDownCapture={blockSceneGraphDeletion}
1188
+ >
1189
+ <VisualJson value={sceneGraphValue}>
1190
+ <TreeView showCounts />
1191
+ </VisualJson>
1192
+ </div>
1193
+ </DialogContent>
1194
+ </Dialog>
1195
+ </SettingsSection>
1196
+
1197
+ <SettingsSection
1198
+ description="Removes every node from this scene. This cannot be undone."
1199
+ title="Danger zone"
1200
+ tone="destructive"
1201
+ >
1202
+ <div className="grid gap-2 @sm:grid-cols-2">
1203
+ <Button
1204
+ className="w-full justify-start gap-2"
1205
+ onClick={handleResetToDefault}
1206
+ variant="destructive"
1207
+ >
1208
+ <Trash2 className="size-4" />
1209
+ Clear and start new
1210
+ </Button>
1211
+ </div>
1212
+ </SettingsSection>
880
1213
  </div>
881
1214
  </div>
882
1215
  )