@pascal-app/editor 0.6.0 → 0.8.0
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 +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +267 -36
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/tool-manager.tsx +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/level-duplication.test.ts +70 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -395
|
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
14
14
|
import {
|
|
15
15
|
Camera,
|
|
16
16
|
ChevronDown,
|
|
17
|
+
Copy,
|
|
17
18
|
Loader2,
|
|
18
19
|
MoreHorizontal,
|
|
19
20
|
Pencil,
|
|
@@ -31,10 +32,16 @@ import {
|
|
|
31
32
|
PopoverContent,
|
|
32
33
|
PopoverTrigger,
|
|
33
34
|
} from './../../../../../components/ui/primitives/popover'
|
|
35
|
+
import {
|
|
36
|
+
buildLevelDuplicateCreateOps,
|
|
37
|
+
type LevelDuplicatePreset,
|
|
38
|
+
} from './../../../../../lib/level-duplication'
|
|
34
39
|
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
|
40
|
+
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
|
35
41
|
import { cn } from './../../../../../lib/utils'
|
|
36
42
|
import useEditor from './../../../../../store/use-editor'
|
|
37
43
|
import { useUploadStore } from '../../../../../store/use-upload'
|
|
44
|
+
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
|
|
38
45
|
import { InlineRenameInput } from './inline-rename-input'
|
|
39
46
|
import { focusTreeNode, TreeNode } from './tree-node'
|
|
40
47
|
import { TreeNodeDragProvider } from './tree-node-drag'
|
|
@@ -360,7 +367,7 @@ const ReferenceItem = memo(function ReferenceItem({
|
|
|
360
367
|
<InlineRenameInput
|
|
361
368
|
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
|
|
362
369
|
isEditing={isEditing}
|
|
363
|
-
|
|
370
|
+
nodeId={refNode.id}
|
|
364
371
|
onStartEditing={() => setIsEditing(true)}
|
|
365
372
|
onStopEditing={() => setIsEditing(false)}
|
|
366
373
|
/>
|
|
@@ -394,7 +401,10 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
394
401
|
onUploadAsset,
|
|
395
402
|
onDeleteAsset,
|
|
396
403
|
}: LevelReferencesProps) {
|
|
404
|
+
const createNode = useScene((s) => s.createNode)
|
|
397
405
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
406
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
407
|
+
const setShowGuides = useViewer((s) => s.setShowGuides)
|
|
398
408
|
const references = useScene(
|
|
399
409
|
useShallow((s) =>
|
|
400
410
|
Object.values(s.nodes).filter(
|
|
@@ -417,19 +427,27 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
417
427
|
|
|
418
428
|
const scanInputRef = useRef<HTMLInputElement>(null)
|
|
419
429
|
|
|
420
|
-
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
430
|
+
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
421
431
|
const file = e.target.files?.[0]
|
|
422
432
|
if (!file) return
|
|
423
433
|
e.target.value = ''
|
|
424
434
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
435
|
+
// Auto-detect type based on file extension/mime type
|
|
436
|
+
const isScan =
|
|
437
|
+
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
|
|
438
|
+
const isImage = file.type.startsWith('image/')
|
|
439
|
+
const type = isScan ? 'scan' : 'guide'
|
|
440
|
+
|
|
441
|
+
if (!(isScan || isImage)) {
|
|
442
|
+
useUploadStore.getState().startUpload(levelId, type, file.name)
|
|
443
|
+
useUploadStore
|
|
444
|
+
.getState()
|
|
445
|
+
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')
|
|
428
446
|
return
|
|
429
447
|
}
|
|
430
448
|
|
|
431
449
|
if (file.size > MAX_FILE_SIZE) {
|
|
432
|
-
useUploadStore.getState().startUpload(levelId,
|
|
450
|
+
useUploadStore.getState().startUpload(levelId, type, file.name)
|
|
433
451
|
useUploadStore
|
|
434
452
|
.getState()
|
|
435
453
|
.setError(
|
|
@@ -439,21 +457,29 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
439
457
|
return
|
|
440
458
|
}
|
|
441
459
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
460
|
+
if (isImage) {
|
|
461
|
+
useUploadStore.getState().startUpload(levelId, 'guide', file.name)
|
|
462
|
+
useUploadStore.getState().setStatus(levelId, 'uploading')
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
const guide = await createLocalGuideImage({ createNode, file, levelId })
|
|
466
|
+
setShowGuides(true)
|
|
467
|
+
setSelectedReferenceId(guide.id)
|
|
468
|
+
setSelection({ selectedIds: [], zoneId: null })
|
|
469
|
+
useUploadStore.getState().setResult(levelId, guide.url)
|
|
470
|
+
window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600)
|
|
471
|
+
} catch {
|
|
472
|
+
useUploadStore.getState().setError(levelId, 'Could not add that guide image.')
|
|
473
|
+
}
|
|
474
|
+
return
|
|
475
|
+
}
|
|
446
476
|
|
|
447
|
-
if (!
|
|
477
|
+
if (!projectId) {
|
|
448
478
|
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
|
|
449
|
-
useUploadStore
|
|
450
|
-
.getState()
|
|
451
|
-
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')
|
|
479
|
+
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
|
|
452
480
|
return
|
|
453
481
|
}
|
|
454
482
|
|
|
455
|
-
const type = isScan ? 'scan' : 'guide'
|
|
456
|
-
|
|
457
483
|
clearUpload(levelId)
|
|
458
484
|
onUploadAsset?.(projectId, levelId, file, type)
|
|
459
485
|
}
|
|
@@ -558,6 +584,7 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
558
584
|
|
|
559
585
|
const LevelItem = memo(function LevelItem({
|
|
560
586
|
level,
|
|
587
|
+
levels,
|
|
561
588
|
selectedLevelId,
|
|
562
589
|
setSelection,
|
|
563
590
|
updateNode,
|
|
@@ -567,6 +594,7 @@ const LevelItem = memo(function LevelItem({
|
|
|
567
594
|
onDeleteAsset,
|
|
568
595
|
}: {
|
|
569
596
|
level: LevelNode
|
|
597
|
+
levels: LevelNode[]
|
|
570
598
|
selectedLevelId: string | null
|
|
571
599
|
setSelection: (selection: any) => void
|
|
572
600
|
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void
|
|
@@ -576,11 +604,22 @@ const LevelItem = memo(function LevelItem({
|
|
|
576
604
|
onDeleteAsset?: (projectId: string, url: string) => void
|
|
577
605
|
}) {
|
|
578
606
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
|
607
|
+
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
|
579
608
|
const [isEditing, setIsEditing] = useState(false)
|
|
609
|
+
const createNodes = useScene((s) => s.createNodes)
|
|
610
|
+
const updateNodes = useScene((s) => s.updateNodes)
|
|
580
611
|
const itemRef = useRef<HTMLDivElement>(null)
|
|
581
612
|
const isSelected = selectedLevelId === level.id
|
|
582
613
|
const canDeleteLevel = level.level !== 0
|
|
583
614
|
const [isExpanded, setIsExpanded] = useState(isSelected)
|
|
615
|
+
const buildingId =
|
|
616
|
+
typeof level.parentId === 'string' && level.parentId.startsWith('building_')
|
|
617
|
+
? (level.parentId as BuildingNode['id'])
|
|
618
|
+
: undefined
|
|
619
|
+
|
|
620
|
+
const selectLevel = (levelId: LevelNode['id']) => {
|
|
621
|
+
setSelection(buildingId ? { buildingId, levelId } : { levelId })
|
|
622
|
+
}
|
|
584
623
|
|
|
585
624
|
useEffect(() => {
|
|
586
625
|
setIsExpanded(isSelected)
|
|
@@ -593,13 +632,34 @@ const LevelItem = memo(function LevelItem({
|
|
|
593
632
|
}, [isSelected])
|
|
594
633
|
|
|
595
634
|
const handleSelect = () => {
|
|
596
|
-
|
|
635
|
+
selectLevel(level.id)
|
|
597
636
|
}
|
|
598
637
|
|
|
599
638
|
const handleDoubleClick = () => {
|
|
600
639
|
focusTreeNode(level.id)
|
|
601
640
|
}
|
|
602
641
|
|
|
642
|
+
const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => {
|
|
643
|
+
const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
|
|
644
|
+
nodes: useScene.getState().nodes,
|
|
645
|
+
level,
|
|
646
|
+
levels,
|
|
647
|
+
preset,
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
if (shiftedLevels.length > 0) {
|
|
651
|
+
updateNodes(
|
|
652
|
+
shiftedLevels.map((shiftedLevel) => ({
|
|
653
|
+
id: shiftedLevel.id as AnyNodeId,
|
|
654
|
+
data: { level: shiftedLevel.level } as Partial<AnyNode>,
|
|
655
|
+
})),
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
createNodes(createOps)
|
|
659
|
+
selectLevel(newLevelId as LevelNode['id'])
|
|
660
|
+
setDuplicateDialogOpen(false)
|
|
661
|
+
}
|
|
662
|
+
|
|
603
663
|
return (
|
|
604
664
|
<div className="relative flex flex-col">
|
|
605
665
|
<div
|
|
@@ -641,7 +701,7 @@ const LevelItem = memo(function LevelItem({
|
|
|
641
701
|
if (isSelected) {
|
|
642
702
|
setIsExpanded(!isExpanded)
|
|
643
703
|
} else {
|
|
644
|
-
|
|
704
|
+
selectLevel(level.id)
|
|
645
705
|
}
|
|
646
706
|
}}
|
|
647
707
|
>
|
|
@@ -665,7 +725,7 @@ const LevelItem = memo(function LevelItem({
|
|
|
665
725
|
<InlineRenameInput
|
|
666
726
|
defaultName={`Level ${level.level}`}
|
|
667
727
|
isEditing={isEditing}
|
|
668
|
-
|
|
728
|
+
nodeId={level.id}
|
|
669
729
|
onStartEditing={() => setIsEditing(true)}
|
|
670
730
|
onStopEditing={() => setIsEditing(false)}
|
|
671
731
|
/>
|
|
@@ -750,7 +810,23 @@ const LevelItem = memo(function LevelItem({
|
|
|
750
810
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
751
811
|
</button>
|
|
752
812
|
</PopoverTrigger>
|
|
753
|
-
<PopoverContent align="start" className="w-
|
|
813
|
+
<PopoverContent align="start" className="w-48 p-1" side="right">
|
|
814
|
+
<button
|
|
815
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
|
816
|
+
onClick={() => handleDuplicateLevel()}
|
|
817
|
+
title="Duplicate level"
|
|
818
|
+
>
|
|
819
|
+
<Copy className="h-3.5 w-3.5" />
|
|
820
|
+
Duplicate
|
|
821
|
+
</button>
|
|
822
|
+
<button
|
|
823
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
|
824
|
+
onClick={() => setDuplicateDialogOpen(true)}
|
|
825
|
+
title="Duplicate level with options"
|
|
826
|
+
>
|
|
827
|
+
<Copy className="h-3.5 w-3.5" />
|
|
828
|
+
Duplicate with options...
|
|
829
|
+
</button>
|
|
754
830
|
<button
|
|
755
831
|
className="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
756
832
|
disabled={!canDeleteLevel}
|
|
@@ -782,6 +858,12 @@ const LevelItem = memo(function LevelItem({
|
|
|
782
858
|
</motion.div>
|
|
783
859
|
)}
|
|
784
860
|
</AnimatePresence>
|
|
861
|
+
<LevelDuplicateDialog
|
|
862
|
+
level={level}
|
|
863
|
+
onConfirm={handleDuplicateLevel}
|
|
864
|
+
onOpenChange={setDuplicateDialogOpen}
|
|
865
|
+
open={duplicateDialogOpen}
|
|
866
|
+
/>
|
|
785
867
|
</div>
|
|
786
868
|
)
|
|
787
869
|
})
|
|
@@ -824,7 +906,7 @@ const LevelsSection = memo(function LevelsSection({
|
|
|
824
906
|
parentId: building.id,
|
|
825
907
|
})
|
|
826
908
|
createNode(newLevel, building.id)
|
|
827
|
-
setSelection({ levelId: newLevel.id })
|
|
909
|
+
setSelection({ buildingId: building.id, levelId: newLevel.id })
|
|
828
910
|
}
|
|
829
911
|
|
|
830
912
|
return (
|
|
@@ -859,6 +941,7 @@ const LevelsSection = memo(function LevelsSection({
|
|
|
859
941
|
isLast={index === levels.length - 1}
|
|
860
942
|
key={level.id}
|
|
861
943
|
level={level}
|
|
944
|
+
levels={levels}
|
|
862
945
|
onDeleteAsset={onDeleteAsset}
|
|
863
946
|
onUploadAsset={onUploadAsset}
|
|
864
947
|
projectId={projectId}
|
|
@@ -1087,7 +1170,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
|
|
1087
1170
|
<InlineRenameInput
|
|
1088
1171
|
defaultName={defaultName}
|
|
1089
1172
|
isEditing={isEditing}
|
|
1090
|
-
|
|
1173
|
+
nodeId={zone.id}
|
|
1091
1174
|
onStartEditing={() => setIsEditing(true)}
|
|
1092
1175
|
onStopEditing={() => setIsEditing(false)}
|
|
1093
1176
|
/>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type LevelNode, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { Layers } from 'lucide-react'
|
|
4
4
|
import { memo, useCallback, useState } from 'react'
|
|
@@ -8,7 +8,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
|
8
8
|
import { TreeNodeActions } from './tree-node-actions'
|
|
9
9
|
|
|
10
10
|
interface LevelTreeNodeProps {
|
|
11
|
-
nodeId:
|
|
11
|
+
nodeId: LevelNode['id']
|
|
12
12
|
depth: number
|
|
13
13
|
isLast?: boolean
|
|
14
14
|
}
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type SpawnNode, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import Image from 'next/image'
|
|
6
|
+
import { memo, useCallback, useState } from 'react'
|
|
7
|
+
import useEditor from './../../../../../store/use-editor'
|
|
8
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
9
|
+
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
10
|
+
import { TreeNodeActions } from './tree-node-actions'
|
|
11
|
+
|
|
12
|
+
interface SpawnTreeNodeProps {
|
|
13
|
+
nodeId: SpawnNode['id']
|
|
14
|
+
depth: number
|
|
15
|
+
isLast?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SpawnTreeNode = memo(function SpawnTreeNode({
|
|
19
|
+
nodeId,
|
|
20
|
+
depth,
|
|
21
|
+
isLast,
|
|
22
|
+
}: SpawnTreeNodeProps) {
|
|
23
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
24
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
25
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
26
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
27
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
28
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
29
|
+
|
|
30
|
+
const handleClick = useCallback(
|
|
31
|
+
(e: React.MouseEvent) => {
|
|
32
|
+
e.stopPropagation()
|
|
33
|
+
const handled = handleTreeSelection(
|
|
34
|
+
e,
|
|
35
|
+
nodeId,
|
|
36
|
+
useViewer.getState().selection.selectedIds,
|
|
37
|
+
setSelection,
|
|
38
|
+
)
|
|
39
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
40
|
+
useEditor.getState().setPhase('structure')
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
[nodeId, setSelection],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<TreeNodeWrapper
|
|
48
|
+
actions={<TreeNodeActions nodeId={nodeId} />}
|
|
49
|
+
depth={depth}
|
|
50
|
+
expanded={false}
|
|
51
|
+
hasChildren={false}
|
|
52
|
+
icon={
|
|
53
|
+
<Image alt="" className="object-contain" height={14} src="/icons/site.png" width={14} />
|
|
54
|
+
}
|
|
55
|
+
isHovered={isHovered}
|
|
56
|
+
isLast={isLast}
|
|
57
|
+
isSelected={isSelected}
|
|
58
|
+
isVisible={isVisible}
|
|
59
|
+
label={
|
|
60
|
+
<InlineRenameInput
|
|
61
|
+
defaultName="Spawn Point"
|
|
62
|
+
isEditing={isEditing}
|
|
63
|
+
nodeId={nodeId}
|
|
64
|
+
onStartEditing={() => setIsEditing(true)}
|
|
65
|
+
onStopEditing={() => setIsEditing(false)}
|
|
66
|
+
/>
|
|
67
|
+
}
|
|
68
|
+
nodeId={nodeId}
|
|
69
|
+
onClick={handleClick}
|
|
70
|
+
onDoubleClick={() => focusTreeNode(nodeId)}
|
|
71
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
72
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
73
|
+
onToggle={() => {}}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
})
|
|
@@ -56,12 +56,14 @@ export function focusTreeNode(nodeId: AnyNodeId) {
|
|
|
56
56
|
import { cn } from '../../../../../lib/utils'
|
|
57
57
|
import { BuildingTreeNode } from './building-tree-node'
|
|
58
58
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
|
59
|
+
import { ColumnTreeNode } from './column-tree-node'
|
|
59
60
|
import { DoorTreeNode } from './door-tree-node'
|
|
60
61
|
import { FenceTreeNode } from './fence-tree-node'
|
|
61
62
|
import { ItemTreeNode } from './item-tree-node'
|
|
62
63
|
import { LevelTreeNode } from './level-tree-node'
|
|
63
64
|
import { RoofTreeNode } from './roof-tree-node'
|
|
64
65
|
import { SlabTreeNode } from './slab-tree-node'
|
|
66
|
+
import { SpawnTreeNode } from './spawn-tree-node'
|
|
65
67
|
import { StairTreeNode } from './stair-tree-node'
|
|
66
68
|
import { WallTreeNode } from './wall-tree-node'
|
|
67
69
|
import { WindowTreeNode } from './window-tree-node'
|
|
@@ -80,13 +82,19 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|
|
80
82
|
|
|
81
83
|
switch (nodeType) {
|
|
82
84
|
case 'building':
|
|
83
|
-
return
|
|
85
|
+
return (
|
|
86
|
+
<BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
|
87
|
+
)
|
|
84
88
|
case 'ceiling':
|
|
85
89
|
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
90
|
+
case 'column':
|
|
91
|
+
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
86
92
|
case 'level':
|
|
87
|
-
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
93
|
+
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
|
88
94
|
case 'slab':
|
|
89
95
|
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
96
|
+
case 'spawn':
|
|
97
|
+
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
|
|
90
98
|
case 'wall':
|
|
91
99
|
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
92
100
|
case 'fence':
|
|
@@ -102,7 +110,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|
|
102
110
|
case 'window':
|
|
103
111
|
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
104
112
|
case 'zone':
|
|
105
|
-
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
113
|
+
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
|
|
106
114
|
default:
|
|
107
115
|
return null
|
|
108
116
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useScene, type ZoneNode } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { memo, useCallback, useState } from 'react'
|
|
4
4
|
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
|
@@ -7,7 +7,7 @@ import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
|
|
7
7
|
import { TreeNodeActions } from './tree-node-actions'
|
|
8
8
|
|
|
9
9
|
interface ZoneTreeNodeProps {
|
|
10
|
-
nodeId:
|
|
10
|
+
nodeId: ZoneNode['id']
|
|
11
11
|
depth: number
|
|
12
12
|
isLast?: boolean
|
|
13
13
|
}
|
|
@@ -44,7 +44,9 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|
|
44
44
|
depth={depth}
|
|
45
45
|
expanded={false}
|
|
46
46
|
hasChildren={false}
|
|
47
|
-
icon={
|
|
47
|
+
icon={
|
|
48
|
+
<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />
|
|
49
|
+
}
|
|
48
50
|
isHovered={isHovered}
|
|
49
51
|
isLast={isLast}
|
|
50
52
|
isSelected={isSelected}
|
|
@@ -78,8 +80,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|
|
78
80
|
|
|
79
81
|
for (let i = 0; i < n; i++) {
|
|
80
82
|
const j = (i + 1) % n
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
const current = polygon[i]
|
|
84
|
+
const next = polygon[j]
|
|
85
|
+
if (!(current && next)) continue
|
|
86
|
+
area += current[0] * next[1]
|
|
87
|
+
area -= next[0] * current[1]
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
return Math.abs(area) / 2
|
|
@@ -41,7 +41,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|
|
41
41
|
className={cn(
|
|
42
42
|
'group/row mx-1 mb-0.5 flex h-8 cursor-pointer select-none items-center rounded-lg border px-2 text-sm transition-all duration-200',
|
|
43
43
|
isSelected
|
|
44
|
-
? 'border-neutral-200/60 bg-white text-foreground shadow-
|
|
44
|
+
? 'border-neutral-200/60 bg-white text-foreground shadow-elevation-0 ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
|
|
45
45
|
: 'border-transparent text-muted-foreground hover:border-neutral-200/50 hover:bg-white/40 hover:text-foreground dark:hover:border-border/40 dark:hover:bg-accent/30',
|
|
46
46
|
)}
|
|
47
47
|
onClick={handleClick}
|
|
@@ -17,7 +17,7 @@ const sliderVariants = cva(
|
|
|
17
17
|
[&_[data-slot=slider-track]]:border
|
|
18
18
|
[&_[data-slot=slider-track]]:border-neutral-300
|
|
19
19
|
[&_[data-slot=slider-track]]:bg-white/50
|
|
20
|
-
[&_[data-slot=slider-track]]:shadow-
|
|
20
|
+
[&_[data-slot=slider-track]]:shadow-elevation-0
|
|
21
21
|
[&_[data-slot=slider-track]]:ring-1
|
|
22
22
|
[&_[data-slot=slider-track]]:ring-white
|
|
23
23
|
[&_[data-slot=slider-track]]:ring-inset
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { emitter, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useEffect, useRef } from 'react'
|
|
5
|
+
import { computeSceneBoundsXZ } from '../lib/scene-bounds'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auto-frame the camera onto a freshly loaded scene.
|
|
9
|
+
*
|
|
10
|
+
* Motivation: when the MCP `setScene` tool (or any other entry point) swaps
|
|
11
|
+
* the scene graph while the default camera is pointing at empty space, the
|
|
12
|
+
* user sees a black viewport. This hook subscribes to the core scene store
|
|
13
|
+
* and, whenever `nodes` transitions from empty → non-empty, computes the
|
|
14
|
+
* XZ bounds of the new scene and emits `camera-controls:fit-scene`. The
|
|
15
|
+
* `<CustomCameraControls />` component picks up that event and frames the
|
|
16
|
+
* camera onto the bounds.
|
|
17
|
+
*
|
|
18
|
+
* Mount in exactly ONE component (the Editor). It holds no state of its own;
|
|
19
|
+
* the subscription is torn down on unmount.
|
|
20
|
+
*/
|
|
21
|
+
export function useAutoFrame(): void {
|
|
22
|
+
// Track the previous node count so we can detect the empty → non-empty edge.
|
|
23
|
+
const wasEmptyRef = useRef(true)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
// Initialise from current store state so a remount after a setScene
|
|
27
|
+
// doesn't re-frame an already-populated scene.
|
|
28
|
+
wasEmptyRef.current = Object.keys(useScene.getState().nodes).length === 0
|
|
29
|
+
|
|
30
|
+
const unsubscribe = useScene.subscribe((state) => {
|
|
31
|
+
const isEmpty = Object.keys(state.nodes).length === 0
|
|
32
|
+
const wasEmpty = wasEmptyRef.current
|
|
33
|
+
wasEmptyRef.current = isEmpty
|
|
34
|
+
|
|
35
|
+
// Only react to empty → non-empty transitions. Normal edits keep both
|
|
36
|
+
// flags false; a `clearScene()` goes non-empty → empty and is ignored.
|
|
37
|
+
if (!wasEmpty || isEmpty) return
|
|
38
|
+
|
|
39
|
+
const bounds = computeSceneBoundsXZ(state.nodes)
|
|
40
|
+
emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return unsubscribe
|
|
44
|
+
}, [])
|
|
45
|
+
}
|
|
@@ -60,6 +60,7 @@ export function useAutoSave({
|
|
|
60
60
|
// Stable subscription to scene changes
|
|
61
61
|
useEffect(() => {
|
|
62
62
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
63
|
+
let lastNodeCount = Object.keys(useScene.getState().nodes).length
|
|
63
64
|
|
|
64
65
|
async function executeSave() {
|
|
65
66
|
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
|
|
@@ -71,6 +72,19 @@ export function useAutoSave({
|
|
|
71
72
|
const { nodes, rootNodeIds } = useScene.getState()
|
|
72
73
|
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
|
|
73
74
|
|
|
75
|
+
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
|
76
|
+
// This catches accidental full deletions before they're persisted.
|
|
77
|
+
const currentNodeCount = Object.keys(nodes).length
|
|
78
|
+
const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
|
|
79
|
+
if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
82
|
+
)
|
|
83
|
+
setSaveStatus('error')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
lastNodeCount = currentNodeCount
|
|
87
|
+
|
|
74
88
|
isSavingRef.current = true
|
|
75
89
|
pendingSaveRef.current = false
|
|
76
90
|
setSaveStatus('saving')
|