@pascal-app/editor 0.5.1 → 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 (150) hide show
  1. package/package.json +12 -7
  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 +29 -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 +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -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,
@@ -23,7 +24,7 @@ import {
23
24
  X,
24
25
  } from 'lucide-react'
25
26
  import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
26
- import { useEffect, useRef, useState } from 'react'
27
+ import { memo, useEffect, useRef, useState } from 'react'
27
28
  import { useShallow } from 'zustand/react/shallow'
28
29
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
29
30
  import {
@@ -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'
@@ -80,7 +89,7 @@ function useSiteNode(): SiteNode | null {
80
89
  )
81
90
  }
82
91
 
83
- function PropertyLineSection() {
92
+ const PropertyLineSection = memo(function PropertyLineSection() {
84
93
  const siteNode = useSiteNode()
85
94
  const updateNode = useScene((state) => state.updateNode)
86
95
  const mode = useEditor((state) => state.mode)
@@ -218,13 +227,13 @@ function PropertyLineSection() {
218
227
  )}
219
228
  </div>
220
229
  )
221
- }
230
+ })
222
231
 
223
232
  // ============================================================================
224
233
  // SITE PHASE VIEW - Property line + building buttons
225
234
  // ============================================================================
226
235
 
227
- function CameraPopover({
236
+ const CameraPopover = memo(function CameraPopover({
228
237
  nodeId,
229
238
  hasCamera,
230
239
  open,
@@ -303,9 +312,9 @@ function CameraPopover({
303
312
  </PopoverContent>
304
313
  </Popover>
305
314
  )
306
- }
315
+ })
307
316
 
308
- function ReferenceItem({
317
+ const ReferenceItem = memo(function ReferenceItem({
309
318
  refNode,
310
319
  isLastRow,
311
320
  setSelectedReferenceId,
@@ -360,7 +369,7 @@ 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
  />
@@ -375,7 +384,7 @@ function ReferenceItem({
375
384
  </button>
376
385
  </div>
377
386
  )
378
- }
387
+ })
379
388
 
380
389
  const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
381
390
 
@@ -387,14 +396,17 @@ interface LevelReferencesProps {
387
396
  onDeleteAsset?: (projectId: string, url: string) => void
388
397
  }
389
398
 
390
- function LevelReferences({
399
+ const LevelReferences = memo(function LevelReferences({
391
400
  levelId,
392
401
  isLastLevel,
393
402
  projectId,
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 @@ 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 @@ 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
  }
@@ -554,10 +582,11 @@ function LevelReferences({
554
582
  )}
555
583
  </div>
556
584
  )
557
- }
585
+ })
558
586
 
559
- function LevelItem({
587
+ const LevelItem = memo(function LevelItem({
560
588
  level,
589
+ levels,
561
590
  selectedLevelId,
562
591
  setSelection,
563
592
  updateNode,
@@ -567,6 +596,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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,11 +860,17 @@ 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
+ })
788
872
 
789
- function LevelsSection({
873
+ const LevelsSection = memo(function LevelsSection({
790
874
  projectId,
791
875
  onUploadAsset,
792
876
  onDeleteAsset,
@@ -824,7 +908,7 @@ 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 @@ 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}
@@ -870,9 +955,9 @@ function LevelsSection({
870
955
  </div>
871
956
  </div>
872
957
  )
873
- }
958
+ })
874
959
 
875
- function LayerToggle() {
960
+ const LayerToggle = memo(function LayerToggle() {
876
961
  const structureLayer = useEditor((state) => state.structureLayer)
877
962
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
878
963
  const phase = useEditor((state) => state.phase)
@@ -1000,9 +1085,9 @@ function LayerToggle() {
1000
1085
  </button>
1001
1086
  </div>
1002
1087
  )
1003
- }
1088
+ })
1004
1089
 
1005
- function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1090
+ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1006
1091
  const [isEditing, setIsEditing] = useState(false)
1007
1092
  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
1008
1093
  const deleteNode = useScene((state) => state.deleteNode)
@@ -1087,7 +1172,7 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
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
  />
@@ -1163,9 +1248,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1163
1248
  </div>
1164
1249
  </div>
1165
1250
  )
1166
- }
1251
+ })
1167
1252
 
1168
- function MultiSelectionBadge() {
1253
+ const MultiSelectionBadge = memo(function MultiSelectionBadge() {
1169
1254
  const selectedIds = useViewer((state) => state.selection.selectedIds)
1170
1255
  const setSelection = useViewer((state) => state.setSelection)
1171
1256
 
@@ -1185,9 +1270,9 @@ function MultiSelectionBadge() {
1185
1270
  </div>
1186
1271
  </div>
1187
1272
  )
1188
- }
1273
+ })
1189
1274
 
1190
- function ContentSection() {
1275
+ const ContentSection = memo(function ContentSection() {
1191
1276
  const selectedLevelId = useViewer((state) => state.selection.levelId)
1192
1277
  const structureLayer = useEditor((state) => state.structureLayer)
1193
1278
  const phase = useEditor((state) => state.phase)
@@ -1265,9 +1350,9 @@ function ContentSection() {
1265
1350
  </div>
1266
1351
  </TreeNodeDragProvider>
1267
1352
  )
1268
- }
1353
+ })
1269
1354
 
1270
- function BuildingItem({
1355
+ const BuildingItem = memo(function BuildingItem({
1271
1356
  building,
1272
1357
  isBuildingActive,
1273
1358
  buildingCameraOpen,
@@ -1308,19 +1393,16 @@ function BuildingItem({
1308
1393
  }
1309
1394
 
1310
1395
  return (
1311
- <motion.div
1396
+ <div
1312
1397
  className={cn('flex shrink-0 flex-col overflow-hidden', isBuildingActive && 'min-h-0 flex-1')}
1313
- layout
1314
- transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
1315
1398
  >
1316
- <motion.div
1399
+ <div
1317
1400
  className={cn(
1318
1401
  'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',
1319
1402
  isBuildingActive
1320
1403
  ? 'bg-accent/50 text-foreground'
1321
1404
  : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
1322
1405
  )}
1323
- layout="position"
1324
1406
  onClick={handleSelect}
1325
1407
  onDoubleClick={handleDoubleClick}
1326
1408
  ref={itemRef}
@@ -1404,7 +1486,7 @@ function BuildingItem({
1404
1486
  </div>
1405
1487
  </PopoverContent>
1406
1488
  </Popover>
1407
- </motion.div>
1489
+ </div>
1408
1490
 
1409
1491
  {/* Tools and content for the active building */}
1410
1492
  <AnimatePresence initial={false}>
@@ -1433,9 +1515,9 @@ function BuildingItem({
1433
1515
  </motion.div>
1434
1516
  )}
1435
1517
  </AnimatePresence>
1436
- </motion.div>
1518
+ </div>
1437
1519
  )
1438
- }
1520
+ })
1439
1521
 
1440
1522
  export interface SitePanelProps {
1441
1523
  projectId?: string
@@ -1534,7 +1616,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1534
1616
  No buildings yet
1535
1617
  </motion.div>
1536
1618
  ) : (
1537
- <motion.div className="flex min-h-0 flex-1 flex-col" layout>
1619
+ <div className="flex min-h-0 flex-1 flex-col">
1538
1620
  {buildings.map((building) => {
1539
1621
  const isBuildingActive =
1540
1622
  (phase === 'structure' || phase === 'furnish') &&
@@ -1553,7 +1635,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1553
1635
  />
1554
1636
  )
1555
1637
  })}
1556
- </motion.div>
1638
+ </div>
1557
1639
  )}
1558
1640
  </motion.div>
1559
1641
  </div>
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
- import { useCallback, useEffect, useState } from 'react'
4
+ import { memo, useCallback, useEffect, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
6
6
  import useEditor from './../../../../../store/use-editor'
7
7
  import { InlineRenameInput } from './inline-rename-input'
@@ -24,7 +24,11 @@ interface ItemTreeNodeProps {
24
24
  isLast?: boolean
25
25
  }
26
26
 
27
- export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
27
+ export const ItemTreeNode = memo(function ItemTreeNode({
28
+ nodeId,
29
+ depth,
30
+ isLast,
31
+ }: ItemTreeNodeProps) {
28
32
  const [isEditing, setIsEditing] = useState(false)
29
33
  const [expanded, setExpanded] = useState(true)
30
34
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -121,4 +125,4 @@ export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
121
125
  ))}
122
126
  </TreeNodeWrapper>
123
127
  )
124
- }
128
+ })
@@ -1,19 +1,23 @@
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
- import { useCallback, useState } from 'react'
4
+ import { memo, useCallback, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
6
6
  import { InlineRenameInput } from './inline-rename-input'
7
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
  }
15
15
 
16
- export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
16
+ export const LevelTreeNode = memo(function LevelTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: LevelTreeNodeProps) {
17
21
  const [expanded, setExpanded] = useState(true)
18
22
  const [isEditing, setIsEditing] = useState(false)
19
23
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -67,4 +71,4 @@ export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
67
71
  ))}
68
72
  </TreeNodeWrapper>
69
73
  )
70
- }
74
+ })
@@ -2,7 +2,7 @@ import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useScene } from '@
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { AnimatePresence } from 'motion/react'
4
4
  import Image from 'next/image'
5
- import { useCallback, useEffect, useState } from 'react'
5
+ import { memo, useCallback, useEffect, useState } from 'react'
6
6
  import { useShallow } from 'zustand/react/shallow'
7
7
  import useEditor from '../../../../../store/use-editor'
8
8
  import { InlineRenameInput } from './inline-rename-input'
@@ -16,7 +16,11 @@ interface RoofTreeNodeProps {
16
16
  isLast?: boolean
17
17
  }
18
18
 
19
- export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
19
+ export const RoofTreeNode = memo(function RoofTreeNode({
20
+ nodeId,
21
+ depth,
22
+ isLast,
23
+ }: RoofTreeNodeProps) {
20
24
  const [isEditing, setIsEditing] = useState(false)
21
25
  const [expanded, setExpanded] = useState(false)
22
26
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -147,7 +151,7 @@ export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
147
151
  </TreeNodeWrapper>
148
152
  </div>
149
153
  )
150
- }
154
+ })
151
155
 
152
156
  function RoofSegmentTreeNode({
153
157
  node,
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
- import { useCallback, useState } from 'react'
4
+ import { memo, useCallback, useState } from 'react'
5
5
  import useEditor from './../../../../../store/use-editor'
6
6
  import { InlineRenameInput } from './inline-rename-input'
7
7
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -13,7 +13,11 @@ interface SlabTreeNodeProps {
13
13
  isLast?: boolean
14
14
  }
15
15
 
16
- export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
16
+ export const SlabTreeNode = memo(function SlabTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: SlabTreeNodeProps) {
17
21
  const [isEditing, setIsEditing] = useState(false)
18
22
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
19
23
  const polygon = useScene((s) => (s.nodes[nodeId] as SlabNode | undefined)?.polygon ?? [])
@@ -74,7 +78,7 @@ export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
74
78
  onToggle={() => {}}
75
79
  />
76
80
  )
77
- }
81
+ })
78
82
 
79
83
  /**
80
84
  * Calculate the area of a polygon using the shoelace formula
@@ -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
+ })