@pascal-app/editor 0.6.0 → 0.7.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 +9 -5
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +20 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -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 +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- 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 +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -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/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/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/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- 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 +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- 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 +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- 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 +138 -0
- package/src/components/ui/panels/panel-manager.tsx +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- 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 +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -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/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- 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 +72 -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/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from './../../../lib/utils'
|
|
4
|
+
import type { SidebarTab } from './tab-bar'
|
|
5
|
+
|
|
6
|
+
interface MobileTabBarProps {
|
|
7
|
+
tabs: SidebarTab[]
|
|
8
|
+
activeTab: string
|
|
9
|
+
onTabPress: (id: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function MobileTabBar({ tabs, activeTab, onTabPress }: MobileTabBarProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className="z-50 flex h-14 shrink-0 border-border/50 border-t bg-sidebar text-sidebar-foreground"
|
|
16
|
+
style={{
|
|
17
|
+
// Cap the safe-area inset — iOS Chrome can report its bottom UI bar
|
|
18
|
+
// (50–100px) as part of the safe area which would balloon the tab bar.
|
|
19
|
+
// 34px matches the iPhone home-indicator height (the typical max).
|
|
20
|
+
paddingBottom: 'min(env(safe-area-inset-bottom, 0px), 34px)',
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{tabs.map((tab) => {
|
|
24
|
+
const isActive = activeTab === tab.id
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
className={cn(
|
|
28
|
+
'flex flex-1 flex-col items-center justify-center gap-0.5 text-xs transition-colors',
|
|
29
|
+
isActive ? 'text-foreground' : 'text-muted-foreground',
|
|
30
|
+
)}
|
|
31
|
+
key={tab.id}
|
|
32
|
+
onClick={() => onTabPress(tab.id)}
|
|
33
|
+
type="button"
|
|
34
|
+
>
|
|
35
|
+
{tab.mobileIcon ? (
|
|
36
|
+
<span className={cn('flex h-5 w-5 items-center justify-center')}>
|
|
37
|
+
{tab.mobileIcon}
|
|
38
|
+
</span>
|
|
39
|
+
) : null}
|
|
40
|
+
<span className="font-medium">{tab.label}</span>
|
|
41
|
+
</button>
|
|
42
|
+
)
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -88,8 +88,8 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
|
|
88
88
|
{
|
|
89
89
|
title: 'Item Placement',
|
|
90
90
|
shortcuts: [
|
|
91
|
-
{ keys: ['R'], action: 'Rotate item clockwise
|
|
92
|
-
{ keys: ['T'], action: 'Rotate item counter-clockwise
|
|
91
|
+
{ keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
|
|
92
|
+
{ keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
|
|
93
93
|
{
|
|
94
94
|
keys: ['Shift'],
|
|
95
95
|
action: 'Temporarily bypass placement validation constraints',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { Building2, Plus } from 'lucide-react'
|
|
4
4
|
import { memo, useState } from 'react'
|
|
@@ -12,7 +12,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
|
|
12
12
|
import { TreeNodeActions } from './tree-node-actions'
|
|
13
13
|
|
|
14
14
|
interface BuildingTreeNodeProps {
|
|
15
|
-
nodeId:
|
|
15
|
+
nodeId: BuildingNode['id']
|
|
16
16
|
depth: number
|
|
17
17
|
isLast?: boolean
|
|
18
18
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import Image from 'next/image'
|
|
4
|
+
import { memo, useCallback, useState } from 'react'
|
|
5
|
+
import useEditor from './../../../../../store/use-editor'
|
|
6
|
+
import { InlineRenameInput } from './inline-rename-input'
|
|
7
|
+
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
|
8
|
+
import { TreeNodeActions } from './tree-node-actions'
|
|
9
|
+
|
|
10
|
+
interface ColumnTreeNodeProps {
|
|
11
|
+
nodeId: AnyNodeId
|
|
12
|
+
depth: number
|
|
13
|
+
isLast?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ColumnTreeNode = memo(function ColumnTreeNode({
|
|
17
|
+
nodeId,
|
|
18
|
+
depth,
|
|
19
|
+
isLast,
|
|
20
|
+
}: ColumnTreeNodeProps) {
|
|
21
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
22
|
+
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
|
23
|
+
const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined)
|
|
24
|
+
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
|
25
|
+
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
|
26
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
27
|
+
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
28
|
+
|
|
29
|
+
const handleClick = useCallback(
|
|
30
|
+
(e: React.MouseEvent) => {
|
|
31
|
+
e.stopPropagation()
|
|
32
|
+
const handled = handleTreeSelection(
|
|
33
|
+
e,
|
|
34
|
+
nodeId,
|
|
35
|
+
useViewer.getState().selection.selectedIds,
|
|
36
|
+
setSelection,
|
|
37
|
+
)
|
|
38
|
+
if (!handled && useEditor.getState().phase === 'furnish') {
|
|
39
|
+
useEditor.getState().setPhase('structure')
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
[nodeId, setSelection],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const defaultName = node?.name || 'Column'
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<TreeNodeWrapper
|
|
49
|
+
actions={<TreeNodeActions nodeId={nodeId} />}
|
|
50
|
+
depth={depth}
|
|
51
|
+
expanded={false}
|
|
52
|
+
hasChildren={false}
|
|
53
|
+
icon={
|
|
54
|
+
<Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
|
|
55
|
+
}
|
|
56
|
+
isHovered={isHovered}
|
|
57
|
+
isLast={isLast}
|
|
58
|
+
isSelected={isSelected}
|
|
59
|
+
isVisible={isVisible}
|
|
60
|
+
label={
|
|
61
|
+
<InlineRenameInput
|
|
62
|
+
defaultName={defaultName}
|
|
63
|
+
isEditing={isEditing}
|
|
64
|
+
nodeId={nodeId}
|
|
65
|
+
onStartEditing={() => setIsEditing(true)}
|
|
66
|
+
onStopEditing={() => setIsEditing(false)}
|
|
67
|
+
/>
|
|
68
|
+
}
|
|
69
|
+
nodeId={nodeId}
|
|
70
|
+
onClick={handleClick}
|
|
71
|
+
onDoubleClick={() => focusTreeNode(nodeId)}
|
|
72
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
73
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
74
|
+
onToggle={() => {}}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
})
|
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
type BuildingNode,
|
|
5
5
|
emitter,
|
|
6
|
-
|
|
6
|
+
GuideNode,
|
|
7
7
|
LevelNode,
|
|
8
|
-
|
|
8
|
+
ScanNode,
|
|
9
9
|
type SiteNode,
|
|
10
10
|
useScene,
|
|
11
11
|
type ZoneNode,
|
|
@@ -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,
|
|
@@ -32,9 +33,17 @@ import {
|
|
|
32
33
|
PopoverTrigger,
|
|
33
34
|
} from './../../../../../components/ui/primitives/popover'
|
|
34
35
|
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
|
36
|
+
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
buildLevelDuplicateCreateOps,
|
|
40
|
+
type LevelDuplicatePreset,
|
|
41
|
+
} from './../../../../../lib/level-duplication'
|
|
42
|
+
|
|
35
43
|
import { cn } from './../../../../../lib/utils'
|
|
36
44
|
import useEditor from './../../../../../store/use-editor'
|
|
37
45
|
import { useUploadStore } from '../../../../../store/use-upload'
|
|
46
|
+
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
|
|
38
47
|
import { InlineRenameInput } from './inline-rename-input'
|
|
39
48
|
import { focusTreeNode, TreeNode } from './tree-node'
|
|
40
49
|
import { TreeNodeDragProvider } from './tree-node-drag'
|
|
@@ -360,7 +369,7 @@ const ReferenceItem = memo(function ReferenceItem({
|
|
|
360
369
|
<InlineRenameInput
|
|
361
370
|
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
|
|
362
371
|
isEditing={isEditing}
|
|
363
|
-
|
|
372
|
+
nodeId={refNode.id}
|
|
364
373
|
onStartEditing={() => setIsEditing(true)}
|
|
365
374
|
onStopEditing={() => setIsEditing(false)}
|
|
366
375
|
/>
|
|
@@ -394,7 +403,10 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
394
403
|
onUploadAsset,
|
|
395
404
|
onDeleteAsset,
|
|
396
405
|
}: LevelReferencesProps) {
|
|
406
|
+
const createNode = useScene((s) => s.createNode)
|
|
397
407
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
408
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
409
|
+
const setShowGuides = useViewer((s) => s.setShowGuides)
|
|
398
410
|
const references = useScene(
|
|
399
411
|
useShallow((s) =>
|
|
400
412
|
Object.values(s.nodes).filter(
|
|
@@ -417,19 +429,27 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
417
429
|
|
|
418
430
|
const scanInputRef = useRef<HTMLInputElement>(null)
|
|
419
431
|
|
|
420
|
-
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
432
|
+
const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
421
433
|
const file = e.target.files?.[0]
|
|
422
434
|
if (!file) return
|
|
423
435
|
e.target.value = ''
|
|
424
436
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
437
|
+
// Auto-detect type based on file extension/mime type
|
|
438
|
+
const isScan =
|
|
439
|
+
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
|
|
440
|
+
const isImage = file.type.startsWith('image/')
|
|
441
|
+
const type = isScan ? 'scan' : 'guide'
|
|
442
|
+
|
|
443
|
+
if (!(isScan || isImage)) {
|
|
444
|
+
useUploadStore.getState().startUpload(levelId, type, file.name)
|
|
445
|
+
useUploadStore
|
|
446
|
+
.getState()
|
|
447
|
+
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')
|
|
428
448
|
return
|
|
429
449
|
}
|
|
430
450
|
|
|
431
451
|
if (file.size > MAX_FILE_SIZE) {
|
|
432
|
-
useUploadStore.getState().startUpload(levelId,
|
|
452
|
+
useUploadStore.getState().startUpload(levelId, type, file.name)
|
|
433
453
|
useUploadStore
|
|
434
454
|
.getState()
|
|
435
455
|
.setError(
|
|
@@ -439,21 +459,29 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
439
459
|
return
|
|
440
460
|
}
|
|
441
461
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
462
|
+
if (isImage) {
|
|
463
|
+
useUploadStore.getState().startUpload(levelId, 'guide', file.name)
|
|
464
|
+
useUploadStore.getState().setStatus(levelId, 'uploading')
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const guide = await createLocalGuideImage({ createNode, file, levelId })
|
|
468
|
+
setShowGuides(true)
|
|
469
|
+
setSelectedReferenceId(guide.id)
|
|
470
|
+
setSelection({ selectedIds: [], zoneId: null })
|
|
471
|
+
useUploadStore.getState().setResult(levelId, guide.url)
|
|
472
|
+
window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600)
|
|
473
|
+
} catch {
|
|
474
|
+
useUploadStore.getState().setError(levelId, 'Could not add that guide image.')
|
|
475
|
+
}
|
|
476
|
+
return
|
|
477
|
+
}
|
|
446
478
|
|
|
447
|
-
if (!
|
|
479
|
+
if (!projectId) {
|
|
448
480
|
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.')
|
|
481
|
+
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
|
|
452
482
|
return
|
|
453
483
|
}
|
|
454
484
|
|
|
455
|
-
const type = isScan ? 'scan' : 'guide'
|
|
456
|
-
|
|
457
485
|
clearUpload(levelId)
|
|
458
486
|
onUploadAsset?.(projectId, levelId, file, type)
|
|
459
487
|
}
|
|
@@ -558,6 +586,7 @@ const LevelReferences = memo(function LevelReferences({
|
|
|
558
586
|
|
|
559
587
|
const LevelItem = memo(function LevelItem({
|
|
560
588
|
level,
|
|
589
|
+
levels,
|
|
561
590
|
selectedLevelId,
|
|
562
591
|
setSelection,
|
|
563
592
|
updateNode,
|
|
@@ -567,6 +596,7 @@ const LevelItem = memo(function LevelItem({
|
|
|
567
596
|
onDeleteAsset,
|
|
568
597
|
}: {
|
|
569
598
|
level: LevelNode
|
|
599
|
+
levels: LevelNode[]
|
|
570
600
|
selectedLevelId: string | null
|
|
571
601
|
setSelection: (selection: any) => void
|
|
572
602
|
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void
|
|
@@ -576,11 +606,22 @@ const LevelItem = memo(function LevelItem({
|
|
|
576
606
|
onDeleteAsset?: (projectId: string, url: string) => void
|
|
577
607
|
}) {
|
|
578
608
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
|
609
|
+
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
|
579
610
|
const [isEditing, setIsEditing] = useState(false)
|
|
611
|
+
const createNodes = useScene((s) => s.createNodes)
|
|
612
|
+
const updateNodes = useScene((s) => s.updateNodes)
|
|
580
613
|
const itemRef = useRef<HTMLDivElement>(null)
|
|
581
614
|
const isSelected = selectedLevelId === level.id
|
|
582
615
|
const canDeleteLevel = level.level !== 0
|
|
583
616
|
const [isExpanded, setIsExpanded] = useState(isSelected)
|
|
617
|
+
const buildingId =
|
|
618
|
+
typeof level.parentId === 'string' && level.parentId.startsWith('building_')
|
|
619
|
+
? (level.parentId as BuildingNode['id'])
|
|
620
|
+
: undefined
|
|
621
|
+
|
|
622
|
+
const selectLevel = (levelId: LevelNode['id']) => {
|
|
623
|
+
setSelection(buildingId ? { buildingId, levelId } : { levelId })
|
|
624
|
+
}
|
|
584
625
|
|
|
585
626
|
useEffect(() => {
|
|
586
627
|
setIsExpanded(isSelected)
|
|
@@ -593,13 +634,34 @@ const LevelItem = memo(function LevelItem({
|
|
|
593
634
|
}, [isSelected])
|
|
594
635
|
|
|
595
636
|
const handleSelect = () => {
|
|
596
|
-
|
|
637
|
+
selectLevel(level.id)
|
|
597
638
|
}
|
|
598
639
|
|
|
599
640
|
const handleDoubleClick = () => {
|
|
600
641
|
focusTreeNode(level.id)
|
|
601
642
|
}
|
|
602
643
|
|
|
644
|
+
const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => {
|
|
645
|
+
const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
|
|
646
|
+
nodes: useScene.getState().nodes,
|
|
647
|
+
level,
|
|
648
|
+
levels,
|
|
649
|
+
preset,
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
if (shiftedLevels.length > 0) {
|
|
653
|
+
updateNodes(
|
|
654
|
+
shiftedLevels.map((shiftedLevel) => ({
|
|
655
|
+
id: shiftedLevel.id as AnyNodeId,
|
|
656
|
+
data: { level: shiftedLevel.level } as Partial<AnyNode>,
|
|
657
|
+
})),
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
createNodes(createOps)
|
|
661
|
+
selectLevel(newLevelId as LevelNode['id'])
|
|
662
|
+
setDuplicateDialogOpen(false)
|
|
663
|
+
}
|
|
664
|
+
|
|
603
665
|
return (
|
|
604
666
|
<div className="relative flex flex-col">
|
|
605
667
|
<div
|
|
@@ -641,7 +703,7 @@ const LevelItem = memo(function LevelItem({
|
|
|
641
703
|
if (isSelected) {
|
|
642
704
|
setIsExpanded(!isExpanded)
|
|
643
705
|
} else {
|
|
644
|
-
|
|
706
|
+
selectLevel(level.id)
|
|
645
707
|
}
|
|
646
708
|
}}
|
|
647
709
|
>
|
|
@@ -665,7 +727,7 @@ const LevelItem = memo(function LevelItem({
|
|
|
665
727
|
<InlineRenameInput
|
|
666
728
|
defaultName={`Level ${level.level}`}
|
|
667
729
|
isEditing={isEditing}
|
|
668
|
-
|
|
730
|
+
nodeId={level.id}
|
|
669
731
|
onStartEditing={() => setIsEditing(true)}
|
|
670
732
|
onStopEditing={() => setIsEditing(false)}
|
|
671
733
|
/>
|
|
@@ -750,7 +812,23 @@ const LevelItem = memo(function LevelItem({
|
|
|
750
812
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
751
813
|
</button>
|
|
752
814
|
</PopoverTrigger>
|
|
753
|
-
<PopoverContent align="start" className="w-
|
|
815
|
+
<PopoverContent align="start" className="w-48 p-1" side="right">
|
|
816
|
+
<button
|
|
817
|
+
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"
|
|
818
|
+
onClick={() => handleDuplicateLevel()}
|
|
819
|
+
title="Duplicate level"
|
|
820
|
+
>
|
|
821
|
+
<Copy className="h-3.5 w-3.5" />
|
|
822
|
+
Duplicate
|
|
823
|
+
</button>
|
|
824
|
+
<button
|
|
825
|
+
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"
|
|
826
|
+
onClick={() => setDuplicateDialogOpen(true)}
|
|
827
|
+
title="Duplicate level with options"
|
|
828
|
+
>
|
|
829
|
+
<Copy className="h-3.5 w-3.5" />
|
|
830
|
+
Duplicate with options...
|
|
831
|
+
</button>
|
|
754
832
|
<button
|
|
755
833
|
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
834
|
disabled={!canDeleteLevel}
|
|
@@ -782,6 +860,12 @@ const LevelItem = memo(function LevelItem({
|
|
|
782
860
|
</motion.div>
|
|
783
861
|
)}
|
|
784
862
|
</AnimatePresence>
|
|
863
|
+
<LevelDuplicateDialog
|
|
864
|
+
level={level}
|
|
865
|
+
onConfirm={handleDuplicateLevel}
|
|
866
|
+
onOpenChange={setDuplicateDialogOpen}
|
|
867
|
+
open={duplicateDialogOpen}
|
|
868
|
+
/>
|
|
785
869
|
</div>
|
|
786
870
|
)
|
|
787
871
|
})
|
|
@@ -824,7 +908,7 @@ const LevelsSection = memo(function LevelsSection({
|
|
|
824
908
|
parentId: building.id,
|
|
825
909
|
})
|
|
826
910
|
createNode(newLevel, building.id)
|
|
827
|
-
setSelection({ levelId: newLevel.id })
|
|
911
|
+
setSelection({ buildingId: building.id, levelId: newLevel.id })
|
|
828
912
|
}
|
|
829
913
|
|
|
830
914
|
return (
|
|
@@ -859,6 +943,7 @@ const LevelsSection = memo(function LevelsSection({
|
|
|
859
943
|
isLast={index === levels.length - 1}
|
|
860
944
|
key={level.id}
|
|
861
945
|
level={level}
|
|
946
|
+
levels={levels}
|
|
862
947
|
onDeleteAsset={onDeleteAsset}
|
|
863
948
|
onUploadAsset={onUploadAsset}
|
|
864
949
|
projectId={projectId}
|
|
@@ -1087,7 +1172,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
|
|
|
1087
1172
|
<InlineRenameInput
|
|
1088
1173
|
defaultName={defaultName}
|
|
1089
1174
|
isEditing={isEditing}
|
|
1090
|
-
|
|
1175
|
+
nodeId={zone.id}
|
|
1091
1176
|
onStartEditing={() => setIsEditing(true)}
|
|
1092
1177
|
onStopEditing={() => setIsEditing(false)}
|
|
1093
1178
|
/>
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
|
54
|
+
alt=""
|
|
55
|
+
className="object-contain"
|
|
56
|
+
height={14}
|
|
57
|
+
src="/icons/site.png"
|
|
58
|
+
width={14}
|
|
59
|
+
/>
|
|
60
|
+
}
|
|
61
|
+
isHovered={isHovered}
|
|
62
|
+
isLast={isLast}
|
|
63
|
+
isSelected={isSelected}
|
|
64
|
+
isVisible={isVisible}
|
|
65
|
+
label={
|
|
66
|
+
<InlineRenameInput
|
|
67
|
+
defaultName="Spawn Point"
|
|
68
|
+
isEditing={isEditing}
|
|
69
|
+
nodeId={nodeId}
|
|
70
|
+
onStartEditing={() => setIsEditing(true)}
|
|
71
|
+
onStopEditing={() => setIsEditing(false)}
|
|
72
|
+
/>
|
|
73
|
+
}
|
|
74
|
+
nodeId={nodeId}
|
|
75
|
+
onClick={handleClick}
|
|
76
|
+
onDoubleClick={() => focusTreeNode(nodeId)}
|
|
77
|
+
onMouseEnter={() => setHoveredId(nodeId)}
|
|
78
|
+
onMouseLeave={() => setHoveredId(null)}
|
|
79
|
+
onToggle={() => {}}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
})
|
|
@@ -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,17 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|
|
80
82
|
|
|
81
83
|
switch (nodeType) {
|
|
82
84
|
case 'building':
|
|
83
|
-
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
85
|
+
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
|
84
86
|
case 'ceiling':
|
|
85
87
|
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
88
|
+
case 'column':
|
|
89
|
+
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
86
90
|
case 'level':
|
|
87
|
-
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
91
|
+
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
|
88
92
|
case 'slab':
|
|
89
93
|
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
94
|
+
case 'spawn':
|
|
95
|
+
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
|
|
90
96
|
case 'wall':
|
|
91
97
|
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
92
98
|
case 'fence':
|
|
@@ -102,7 +108,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|
|
102
108
|
case 'window':
|
|
103
109
|
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
104
110
|
case 'zone':
|
|
105
|
-
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
111
|
+
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
|
|
106
112
|
default:
|
|
107
113
|
return null
|
|
108
114
|
}
|
|
@@ -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,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
|
|
|
44
44
|
depth={depth}
|
|
45
45
|
expanded={false}
|
|
46
46
|
hasChildren={false}
|
|
47
|
-
icon={<ColorDot color={color} onChange={(c) => updateNode(nodeId, { color: c })} />}
|
|
47
|
+
icon={<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />}
|
|
48
48
|
isHovered={isHovered}
|
|
49
49
|
isLast={isLast}
|
|
50
50
|
isSelected={isSelected}
|
|
@@ -78,8 +78,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|
|
78
78
|
|
|
79
79
|
for (let i = 0; i < n; i++) {
|
|
80
80
|
const j = (i + 1) % n
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const current = polygon[i]
|
|
82
|
+
const next = polygon[j]
|
|
83
|
+
if (!(current && next)) continue
|
|
84
|
+
area += current[0] * next[1]
|
|
85
|
+
area -= next[0] * current[1]
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
return Math.abs(area) / 2
|