@pascal-app/editor 1.0.0 → 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 (97) hide show
  1. package/package.json +7 -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/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -28,7 +28,15 @@ import {
28
28
  useScene,
29
29
  } from '@pascal-app/core'
30
30
  import { markPerfAction, useViewer } from '@pascal-app/viewer'
31
- import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
31
+ import {
32
+ ClipboardPaste,
33
+ Copy,
34
+ GripVertical,
35
+ MoreVertical,
36
+ Plus,
37
+ Trash2,
38
+ X,
39
+ } from 'lucide-react'
32
40
  import {
33
41
  type ButtonHTMLAttributes,
34
42
  type CSSProperties,
@@ -45,6 +53,7 @@ import {
45
53
  } from '../../lib/level-duplication'
46
54
  import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
47
55
  import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
56
+ import { unitMemberLevels, leaveUnitFocus } from '../../lib/units'
48
57
  import { useLinearDisplay } from '../../lib/use-linear-display'
49
58
  import { cn } from '../../lib/utils'
50
59
  import { ActionButton } from './controls/action-button'
@@ -131,12 +140,15 @@ function LevelRow({
131
140
  onDuplicate,
132
141
  onPaste,
133
142
  onRequestDelete,
143
+ unitDotColor,
134
144
  }: {
135
145
  level: LevelNode
136
146
  isSelected: boolean
137
147
  isDragging?: boolean
138
148
  dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
139
149
  dragHandleRef?: (element: HTMLButtonElement | null) => void
150
+ /** Focused-unit color when the unit has zones on this level. */
151
+ unitDotColor?: string
140
152
  onSelect: () => void
141
153
  onDuplicate: (preset?: LevelDuplicatePreset) => void
142
154
  onPaste?: () => void
@@ -145,11 +157,14 @@ function LevelRow({
145
157
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
146
158
  const [isEditing, setIsEditing] = useState(false)
147
159
  const updateNode = useScene((s) => s.updateNode)
148
- const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2)
160
+ const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2)
149
161
 
150
162
  const storeyHeight = getStoredLevelHeight(level)
151
- // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
152
- const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
163
+ // Decimal units keep the compact readout; integer millimeters must retain trailing zeroes.
164
+ const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision)
165
+ const storeyHeightLabel = `${
166
+ displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight
167
+ } ${displayUnit}`
153
168
  // Same rule as the site panel and command palette: the ordinal-0 ground
154
169
  // floor is the vertical model's zero anchor and must never be deletable.
155
170
  const canDeleteLevel = level.level !== 0
@@ -215,6 +230,13 @@ function LevelRow({
215
230
  type="button"
216
231
  >
217
232
  <span className="truncate">{getLevelDisplayName(level)}</span>
233
+ {unitDotColor && (
234
+ <span
235
+ className="ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full"
236
+ style={{ backgroundColor: unitDotColor }}
237
+ title="Focused unit has zones here"
238
+ />
239
+ )}
218
240
  </button>
219
241
 
220
242
  {/* Storey height badge — opens the height popover */}
@@ -244,7 +266,7 @@ function LevelRow({
244
266
  precision={3}
245
267
  step={0.1}
246
268
  unit="m"
247
- value={Math.round(storeyHeight * 1000) / 1000}
269
+ value={storeyHeight}
248
270
  />
249
271
  <div className="mt-1.5 grid grid-cols-3 gap-1.5">
250
272
  {heightPresets.map((preset) => (
@@ -343,9 +365,11 @@ function SortableLevelRow({
343
365
  onDuplicate,
344
366
  onPaste,
345
367
  onRequestDelete,
368
+ unitDotColor,
346
369
  }: {
347
370
  level: LevelNode
348
371
  isSelected: boolean
372
+ unitDotColor?: string
349
373
  onSelect: () => void
350
374
  onDuplicate: (preset?: LevelDuplicatePreset) => void
351
375
  onPaste?: () => void
@@ -381,7 +405,42 @@ function SortableLevelRow({
381
405
  onPaste={onPaste}
382
406
  onRequestDelete={onRequestDelete}
383
407
  onSelect={onSelect}
408
+ unitDotColor={unitDotColor}
409
+ />
410
+ </div>
411
+ )
412
+ }
413
+
414
+ // ── Unit focus chip: name + × to end focus, only while a unit is focused ────
415
+
416
+ function UnitFocusChip() {
417
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
418
+ const focusedUnit = useScene((s) => {
419
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
420
+ return unit?.type === 'unit' ? unit : null
421
+ })
422
+
423
+ if (!focusedUnit) return null
424
+
425
+ return (
426
+ <div
427
+ className="mt-4 flex h-7 max-w-48 items-center gap-1.5 rounded-full border border-border bg-background/90 pr-1 pl-2.5 font-medium text-xs shadow-2xl backdrop-blur-md"
428
+ data-testid="unit-focus-chip"
429
+ >
430
+ <span
431
+ className="h-2 w-2 shrink-0 rounded-full"
432
+ style={{ backgroundColor: focusedUnit.color }}
384
433
  />
434
+ <span className="truncate">{focusedUnit.name || 'Unit'}</span>
435
+ <button
436
+ aria-label="Exit unit focus"
437
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
438
+ onClick={() => leaveUnitFocus()}
439
+ title="Exit unit focus"
440
+ type="button"
441
+ >
442
+ <X className="h-3 w-3" />
443
+ </button>
385
444
  </div>
386
445
  )
387
446
  }
@@ -398,6 +457,17 @@ export function FloatingLevelSelector() {
398
457
 
399
458
  const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
400
459
  const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
460
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
461
+ const focusedUnitColor = useScene((s) => {
462
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
463
+ return unit?.type === 'unit' ? unit.color : null
464
+ })
465
+ const focusedUnitLevelIds = useScene(
466
+ useShallow((s) => {
467
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
468
+ return unit?.type === 'unit' ? unitMemberLevels(unit, s.nodes).map((level) => level.id) : []
469
+ }),
470
+ )
401
471
  const sensors = useSensors(
402
472
  useSensor(PointerSensor, {
403
473
  activationConstraint: { distance: 4 },
@@ -628,6 +698,11 @@ export function FloatingLevelSelector() {
628
698
  <SortableLevelRow
629
699
  isSelected={isSelected}
630
700
  level={level}
701
+ unitDotColor={
702
+ focusedUnitColor && focusedUnitLevelIds.includes(level.id)
703
+ ? focusedUnitColor
704
+ : undefined
705
+ }
631
706
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
632
707
  onPaste={() => handlePasteToLevel(level)}
633
708
  onRequestDelete={() => setDeletingLevel(level)}
@@ -658,6 +733,7 @@ export function FloatingLevelSelector() {
658
733
  </SortableContext>
659
734
  </DndContext>
660
735
  </div>
736
+ <UnitFocusChip />
661
737
  </div>
662
738
 
663
739
  {/* Delete confirmation dialog */}
@@ -72,8 +72,9 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode
72
72
  )}
73
73
  {parametrics.groups.map((group, gi) => (
74
74
  <MultiGroupFields
75
+ defaultExpanded={group.defaultExpanded}
75
76
  fields={group.fields as ParamField<AnyNode>[]}
76
- key={`group-${gi}`}
77
+ key={`${nodeType}-${group.label}-${gi}`}
77
78
  nodeIds={nodeIds}
78
79
  nodeType={nodeType}
79
80
  parametrics={parametrics}
@@ -88,12 +89,14 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode
88
89
  }
89
90
 
90
91
  function MultiGroupFields({
92
+ defaultExpanded,
91
93
  title,
92
94
  fields,
93
95
  nodeIds,
94
96
  nodeType,
95
97
  parametrics,
96
98
  }: {
99
+ defaultExpanded?: boolean
97
100
  title: string
98
101
  fields: ParamField<AnyNode>[]
99
102
  nodeIds: AnyNodeId[]
@@ -108,7 +111,7 @@ function MultiGroupFields({
108
111
  )
109
112
  if (genericFields.length === 0 || !anyVisible) return null
110
113
  return (
111
- <PanelSection title={title}>
114
+ <PanelSection defaultExpanded={defaultExpanded} title={title}>
112
115
  {genericFields.map((field, fi) => {
113
116
  if (
114
117
  String(field.key) === 'height' &&
@@ -254,7 +257,7 @@ function MultiVec3Field({
254
257
  restoreOnCommit={false}
255
258
  step={0.05}
256
259
  unit="m"
257
- value={Math.round(axisValue * 100) / 100}
260
+ value={axisValue}
258
261
  />
259
262
  )
260
263
  })}
@@ -159,7 +159,7 @@ export function ParametricFieldControl({
159
159
  restoreOnCommit={!onCommit}
160
160
  step={0.05}
161
161
  unit="m"
162
- value={Math.round(axisValue * 100) / 100}
162
+ value={axisValue}
163
163
  />
164
164
  )
165
165
  })}
@@ -157,7 +157,11 @@ export function ParametricInspector({
157
157
  width={320}
158
158
  >
159
159
  {parametrics.groups.map((group, gi) => (
160
- <PanelSection key={`group-${gi}`} title={group.label}>
160
+ <PanelSection
161
+ defaultExpanded={group.defaultExpanded}
162
+ key={`${nodeType}-${group.label}-${gi}`}
163
+ title={group.label}
164
+ >
161
165
  {group.fields.map((field, fi) => (
162
166
  <FieldRenderer
163
167
  key={`field-${gi}-${fi}-${String(field.key)}`}
@@ -175,7 +179,7 @@ export function ParametricInspector({
175
179
  )}
176
180
  {(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
177
181
  <PanelSection title="Actions">
178
- <ActionGroup className={isZone ? 'flex-col' : undefined}>
182
+ <ActionGroup className={isZone ? 'flex-col' : parametrics.actions?.length ? 'grid grid-cols-2 gap-2' : undefined}>
179
183
  {canMove && (
180
184
  <ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
181
185
  )}
@@ -376,7 +376,7 @@ export function ReferencePanel() {
376
376
  precision={2}
377
377
  step={0.1}
378
378
  unit="m"
379
- value={Math.round(node.position[0] * 100) / 100}
379
+ value={node.position[0]}
380
380
  />
381
381
  <SliderControl
382
382
  label={
@@ -394,7 +394,7 @@ export function ReferencePanel() {
394
394
  precision={2}
395
395
  step={0.1}
396
396
  unit="m"
397
- value={Math.round(node.position[1] * 100) / 100}
397
+ value={node.position[1]}
398
398
  />
399
399
  <SliderControl
400
400
  label={
@@ -412,7 +412,7 @@ export function ReferencePanel() {
412
412
  precision={2}
413
413
  step={0.1}
414
414
  unit="m"
415
- value={Math.round(node.position[2] * 100) / 100}
415
+ value={node.position[2]}
416
416
  />
417
417
  </PanelSection>
418
418
 
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ import { Check } from 'lucide-react'
4
+ import type { ButtonHTMLAttributes } from 'react'
5
+ import { cn } from './../../../lib/utils'
6
+
7
+ interface CheckboxProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
8
+ checked: boolean
9
+ onCheckedChange: (checked: boolean) => void
10
+ }
11
+
12
+ export function Checkbox({ checked, onCheckedChange, className, ...props }: CheckboxProps) {
13
+ return (
14
+ <button
15
+ aria-checked={checked}
16
+ className={cn(
17
+ 'flex h-3.5 w-3.5 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring',
18
+ checked
19
+ ? 'border-primary bg-primary text-primary-foreground'
20
+ : 'border-border bg-background/60 text-transparent hover:border-foreground/40',
21
+ className,
22
+ )}
23
+ role="checkbox"
24
+ type="button"
25
+ {...props}
26
+ onClick={(event) => {
27
+ event.stopPropagation()
28
+ onCheckedChange(!checked)
29
+ }}
30
+ >
31
+ <Check className="h-2.5 w-2.5" strokeWidth={3} />
32
+ </button>
33
+ )
34
+ }
@@ -18,6 +18,7 @@ import {
18
18
  Download,
19
19
  Map as MapIcon,
20
20
  Save,
21
+ Send,
21
22
  Trash2,
22
23
  Upload,
23
24
  } from 'lucide-react'
@@ -36,6 +37,12 @@ import {
36
37
  exportFloorplanPdf,
37
38
  type FloorplanExportScope,
38
39
  } from '../../../../../lib/floorplan/floorplan-export'
40
+ import {
41
+ LocalAppError,
42
+ probeLocalApp,
43
+ sendGlbToLocalApp,
44
+ waitForLocalImport,
45
+ } from '../../../../../lib/send-to-app'
39
46
  import { Button } from './../../../../../components/ui/primitives/button'
40
47
  import {
41
48
  Dialog,
@@ -46,6 +53,7 @@ import {
46
53
  import { Input } from './../../../../../components/ui/primitives/input'
47
54
  import { Switch } from './../../../../../components/ui/primitives/switch'
48
55
  import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
56
+ import { type SendToAppStep, useSendToApp } from './../../../../../store/use-send-to-app'
49
57
  import useFloorplanMode from './../../../../../store/use-floorplan-mode'
50
58
  import { AudioSettingsDialog } from './audio-settings-dialog'
51
59
  import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
@@ -84,6 +92,15 @@ const MODEL_EXPORT_FORMATS = [
84
92
 
85
93
  type ModelExportFormat = (typeof MODEL_EXPORT_FORMATS)[number]['format']
86
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
+
87
104
  type ExportableNodeType = {
88
105
  type: string
89
106
  label: string
@@ -208,6 +225,8 @@ export interface ProjectVisibility {
208
225
 
209
226
  export interface SettingsPanelProps {
210
227
  projectId?: string
228
+ /** Shown as the scene name in apps the scene is sent to (Blender collection name). */
229
+ projectName?: string
211
230
  projectVisibility?: ProjectVisibility
212
231
  onVisibilityChange?: (
213
232
  field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
@@ -217,6 +236,7 @@ export interface SettingsPanelProps {
217
236
 
218
237
  export function SettingsPanel({
219
238
  projectId,
239
+ projectName,
220
240
  projectVisibility,
221
241
  onVisibilityChange,
222
242
  }: SettingsPanelProps = {}) {
@@ -226,6 +246,7 @@ export function SettingsPanel({
226
246
  const rootNodeIds = useScene((state) => state.rootNodeIds)
227
247
  const installedPlugins = useScene((state) => state.installedPlugins)
228
248
  const materials = useScene((state) => state.materials)
249
+ const collections = useScene((state) => state.collections)
229
250
  const setScene = useScene((state) => state.setScene)
230
251
  const clearScene = useScene((state) => state.clearScene)
231
252
  const resetSelection = useViewer((state) => state.resetSelection)
@@ -244,6 +265,10 @@ export function SettingsPanel({
244
265
  const [activeModelExport, setActiveModelExport] = useState<ModelExportFormat | null>(null)
245
266
  const [modelExportError, setModelExportError] = useState<string | null>(null)
246
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)
247
272
  const [activeFloorplanExport, setActiveFloorplanExport] = useState<FloorplanExportScope | null>(
248
273
  null,
249
274
  )
@@ -323,7 +348,7 @@ export function SettingsPanel({
323
348
  // Materials ride along: nodes reference them by `scene:<id>` slot
324
349
  // refs, so a save without the table produces a file whose custom
325
350
  // finishes revert to defaults on the very Load Build path below.
326
- const sceneData = { nodes, rootNodeIds, installedPlugins, materials }
351
+ const sceneData = { nodes, rootNodeIds, installedPlugins, materials, collections }
327
352
  const json = JSON.stringify(sceneData, null, 2)
328
353
  const blob = new Blob([json], { type: 'application/json' })
329
354
  const url = URL.createObjectURL(blob)
@@ -389,6 +414,7 @@ export function SettingsPanel({
389
414
  // pointed at a material that no longer existed — custom finishes
390
415
  // silently reverted to defaults on import.
391
416
  materials: parsed.materials,
417
+ collections: parsed.collections,
392
418
  installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
393
419
  hasExplicitPluginInstallState:
394
420
  parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
@@ -463,7 +489,7 @@ export function SettingsPanel({
463
489
  }, [])
464
490
 
465
491
  const handleModelExport = async (format: ModelExportFormat, label: string) => {
466
- if (!modelExport || activeModelExport) return
492
+ if (!modelExport || activeModelExport || useSendToApp.getState().step) return
467
493
 
468
494
  setActiveModelExport(format)
469
495
  setModelExportError(null)
@@ -492,6 +518,62 @@ export function SettingsPanel({
492
518
  }
493
519
  }
494
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
+
495
577
  const handleFloorplanExport = async (scope: FloorplanExportScope) => {
496
578
  if (activeFloorplanExport) return
497
579
 
@@ -710,7 +792,7 @@ export function SettingsPanel({
710
792
  <Button
711
793
  aria-busy={isActive}
712
794
  className="w-full justify-start gap-2"
713
- disabled={activeModelExport !== null || !modelExport}
795
+ disabled={activeModelExport !== null || sendToBlenderStep !== null || !modelExport}
714
796
  key={format}
715
797
  onClick={() => void handleModelExport(format, label)}
716
798
  variant="outline"
@@ -721,6 +803,41 @@ export function SettingsPanel({
721
803
  )
722
804
  })}
723
805
 
806
+ <Button
807
+ aria-busy={sendToBlenderStep !== null}
808
+ className="w-full justify-start gap-2"
809
+ disabled={activeModelExport !== null || sendToBlenderStep !== null || !modelExport}
810
+ onClick={() => void handleSendToBlender()}
811
+ variant="outline"
812
+ >
813
+ <Send aria-hidden="true" className="size-4" />
814
+ {sendToBlenderStep ? SEND_TO_BLENDER_STEP_LABEL[sendToBlenderStep] : 'Send to Blender'}
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
+
724
841
  {activeModelExport ? (
725
842
  <p className="text-muted-foreground text-xs" role="status">
726
843
  Preparing {activeModelExport.toUpperCase()} file…
@@ -40,7 +40,7 @@ describe('simple 3D print export', () => {
40
40
  return artifact
41
41
  }
42
42
 
43
- const prepared = await preparePrintExport(modelExport, true)
43
+ const prepared = await preparePrintExport(modelExport, true, 'print-3mf')
44
44
 
45
45
  expect(calls).toEqual([
46
46
  {
@@ -58,6 +58,33 @@ describe('simple 3D print export', () => {
58
58
  expect(prepared).toEqual({ artifact, report })
59
59
  })
60
60
 
61
+ test('uses the same safe profile for printable STL files', async () => {
62
+ const calls: { format?: string; options?: ModelExportOptions }[] = []
63
+ const stlReport: PrintExportReport = { ...report, format: 'stl' }
64
+ const artifact = { blob: new Blob(['stl']), filename: 'house.zip', metadata: stlReport }
65
+ const modelExport: ModelExport = async (format, options) => {
66
+ calls.push({ format, options })
67
+ return artifact
68
+ }
69
+
70
+ const prepared = await preparePrintExport(modelExport, false, 'print-stl')
71
+
72
+ expect(calls).toEqual([
73
+ {
74
+ format: 'print-stl',
75
+ options: {
76
+ onlyVisible: false,
77
+ download: false,
78
+ printScale: 100,
79
+ printScope: 'levels',
80
+ printContent: 'structure',
81
+ printBase: 'none',
82
+ },
83
+ },
84
+ ])
85
+ expect(prepared).toEqual({ artifact, report: stlReport })
86
+ })
87
+
61
88
  test('blocks the download when preflight finds invalid geometry', async () => {
62
89
  const blockedReport: PrintExportReport = {
63
90
  ...report,
@@ -76,7 +103,7 @@ describe('simple 3D print export', () => {
76
103
  metadata: blockedReport,
77
104
  })
78
105
 
79
- await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
106
+ await expect(preparePrintExport(modelExport, true, 'print-3mf')).rejects.toThrow(
80
107
  'One wall has an open edge.',
81
108
  )
82
109
  })
@@ -122,7 +149,7 @@ describe('simple 3D print export', () => {
122
149
  metadata: blockedBundleReport,
123
150
  })
124
151
 
125
- await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
152
+ await expect(preparePrintExport(modelExport, true, 'print-3mf')).rejects.toThrow(
126
153
  'The upper level has an open edge.',
127
154
  )
128
155
  })
@@ -133,7 +160,7 @@ describe('simple 3D print export', () => {
133
160
  filename: 'house.3mf',
134
161
  })
135
162
 
136
- await expect(preparePrintExport(modelExport, false)).rejects.toThrow(
163
+ await expect(preparePrintExport(modelExport, false, 'print-3mf')).rejects.toThrow(
137
164
  'did not return a valid file',
138
165
  )
139
166
  })
@@ -5,7 +5,11 @@ import {
5
5
  isPrintLevelBundleReport,
6
6
  type PrintLevelBundleReport,
7
7
  } from '../../../../../lib/level-print-export'
8
- import type { ModelExport, ModelExportArtifact } from '../../../../../lib/model-export'
8
+ import type {
9
+ ModelExport,
10
+ ModelExportArtifact,
11
+ ModelExportFormat,
12
+ } from '../../../../../lib/model-export'
9
13
  import {
10
14
  isPrintExportReport,
11
15
  type PrintExportReport,
@@ -17,6 +21,8 @@ type PreparedPrintExport = {
17
21
  report: PrintExportReport | PrintLevelBundleReport
18
22
  }
19
23
 
24
+ type PrintModelExportFormat = Extract<ModelExportFormat, 'print-3mf' | 'print-stl'>
25
+
20
26
  function downloadArtifact(artifact: ModelExportArtifact) {
21
27
  const url = URL.createObjectURL(artifact.blob)
22
28
  const link = document.createElement('a')
@@ -39,8 +45,9 @@ function firstBlockingMessage(report: PrintExportReport | PrintLevelBundleReport
39
45
  export async function preparePrintExport(
40
46
  modelExport: ModelExport,
41
47
  onlyVisible: boolean,
48
+ format: PrintModelExportFormat,
42
49
  ): Promise<PreparedPrintExport> {
43
- const artifact = await modelExport('print-3mf', {
50
+ const artifact = await modelExport(format, {
44
51
  onlyVisible,
45
52
  download: false,
46
53
  printScale: 100,
@@ -68,35 +75,47 @@ export async function preparePrintExport(
68
75
 
69
76
  export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) {
70
77
  const modelExport = useEditor((state) => state.modelExport)
71
- const [isExporting, setIsExporting] = useState(false)
78
+ const [exportingFormat, setExportingFormat] = useState<PrintModelExportFormat | null>(null)
72
79
  const [error, setError] = useState<string | null>(null)
73
80
 
74
- const handleExport = async () => {
81
+ const handleExport = async (format: PrintModelExportFormat) => {
75
82
  if (!modelExport) return
76
83
 
77
- setIsExporting(true)
84
+ setExportingFormat(format)
78
85
  setError(null)
79
86
  try {
80
- const prepared = await preparePrintExport(modelExport, onlyVisible)
87
+ const prepared = await preparePrintExport(modelExport, onlyVisible, format)
81
88
  downloadArtifact(prepared.artifact)
82
89
  } catch (reason) {
83
90
  setError(reason instanceof Error ? reason.message : '3D print export failed.')
84
91
  } finally {
85
- setIsExporting(false)
92
+ setExportingFormat(null)
86
93
  }
87
94
  }
88
95
 
96
+ const isExporting = exportingFormat !== null
97
+
89
98
  return (
90
99
  <>
91
100
  <Button
92
- aria-busy={isExporting}
101
+ aria-busy={exportingFormat === 'print-3mf'}
102
+ className="w-full justify-start gap-2"
103
+ disabled={isExporting || !modelExport}
104
+ onClick={() => void handleExport('print-3mf')}
105
+ variant="outline"
106
+ >
107
+ <Printer className="size-4" />
108
+ Export 3D print 3MF
109
+ </Button>
110
+ <Button
111
+ aria-busy={exportingFormat === 'print-stl'}
93
112
  className="w-full justify-start gap-2"
94
113
  disabled={isExporting || !modelExport}
95
- onClick={handleExport}
114
+ onClick={() => void handleExport('print-stl')}
96
115
  variant="outline"
97
116
  >
98
117
  <Printer className="size-4" />
99
- Export 3D print files
118
+ Export 3D print STL
100
119
  </Button>
101
120
  {error && (
102
121
  <div className="flex gap-2 text-destructive text-xs">