@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.
- package/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- 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 {
|
|
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
|
-
//
|
|
152
|
-
const
|
|
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={
|
|
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={
|
|
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={
|
|
260
|
+
value={axisValue}
|
|
258
261
|
/>
|
|
259
262
|
)
|
|
260
263
|
})}
|
|
@@ -157,7 +157,11 @@ export function ParametricInspector({
|
|
|
157
157
|
width={320}
|
|
158
158
|
>
|
|
159
159
|
{parametrics.groups.map((group, gi) => (
|
|
160
|
-
<PanelSection
|
|
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={
|
|
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={
|
|
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={
|
|
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 {
|
|
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(
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
|
118
|
+
Export 3D print STL
|
|
100
119
|
</Button>
|
|
101
120
|
{error && (
|
|
102
121
|
<div className="flex gap-2 text-destructive text-xs">
|