@pascal-app/editor 0.6.0 → 0.8.0

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