@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.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- 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 +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- 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 +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- 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 +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- 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/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- 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 +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- 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 +30 -11
- 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/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- 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 +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- 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/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -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/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- 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/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -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/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- 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
|
|
513
|
-
{
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
const isActive = activeModelExport === format
|
|
709
|
-
return (
|
|
1000
|
+
<div className="space-y-2">
|
|
710
1001
|
<Button
|
|
711
|
-
aria-busy={
|
|
1002
|
+
aria-busy={sendToBlenderStep !== null}
|
|
712
1003
|
className="w-full justify-start gap-2"
|
|
713
|
-
disabled={activeModelExport !== null || !modelExport}
|
|
714
|
-
|
|
715
|
-
onClick={() => void handleModelExport(format, label)}
|
|
1004
|
+
disabled={activeModelExport !== null || sendToBlenderStep !== null || !modelExport}
|
|
1005
|
+
onClick={() => void handleSendToBlender()}
|
|
716
1006
|
variant="outline"
|
|
717
1007
|
>
|
|
718
|
-
<
|
|
719
|
-
{
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
<
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
<
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
<
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
</DialogContent>
|
|
865
|
-
</Dialog>
|
|
866
|
-
</div>
|
|
1140
|
+
<Upload className="size-4" />
|
|
1141
|
+
Load build
|
|
1142
|
+
</Button>
|
|
1143
|
+
</div>
|
|
867
1144
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
<
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
variant="destructive"
|
|
1167
|
+
<SettingsSection
|
|
1168
|
+
description="Inspect how nodes are nested in this scene."
|
|
1169
|
+
title="Scene graph"
|
|
876
1170
|
>
|
|
877
|
-
<
|
|
878
|
-
|
|
879
|
-
|
|
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
|
)
|