@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.
Files changed (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. 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 by 90 degrees' },
92
- { keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' },
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 AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
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: AnyNodeId
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
- type GuideNode,
6
+ GuideNode,
7
7
  LevelNode,
8
- type ScanNode,
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
- node={refNode}
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
- if (!projectId) {
426
- useUploadStore.getState().startUpload(levelId, 'scan', file.name)
427
- useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
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, 'scan', file.name)
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
- // Auto-detect type based on file extension/mime type
443
- const isScan =
444
- file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
445
- const isImage = file.type.startsWith('image/')
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 (!(isScan || isImage)) {
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
- setSelection({ levelId: level.id })
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
- setSelection({ levelId: level.id })
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
- node={level}
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-40 p-1" side="right">
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
- node={zone}
1175
+ nodeId={zone.id}
1091
1176
  onStartEditing={() => setIsEditing(true)}
1092
1177
  onStopEditing={() => setIsEditing(false)}
1093
1178
  />
@@ -1,4 +1,4 @@
1
- import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core'
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: AnyNodeId
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 { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
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: AnyNodeId
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
- area += polygon[i]?.[0] * polygon[j]?.[1]
82
- area -= polygon[j]?.[0] * polygon[i]?.[1]
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
@@ -1,10 +1,13 @@
1
1
  'use client'
2
2
 
3
+ import type { ReactNode } from 'react'
3
4
  import { cn } from './../../../lib/utils'
4
5
 
5
6
  export type SidebarTab = {
6
7
  id: string
7
8
  label: string
9
+ mobileDefaultSnap?: number
10
+ mobileIcon?: ReactNode
8
11
  }
9
12
 
10
13
  interface TabBarProps {