@pascal-app/editor 0.4.0 → 0.6.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 (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -0,0 +1,69 @@
1
+ import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { memo, 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 FenceTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export const FenceTreeNode = memo(function FenceTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: FenceTreeNodeProps) {
21
+ const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined
22
+ const [isEditing, setIsEditing] = useState(false)
23
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
24
+ const isSelected = 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
+ if (!node) return null
30
+
31
+ const handleClick = (e: React.MouseEvent) => {
32
+ e.stopPropagation()
33
+ const handled = handleTreeSelection(e, nodeId, selectedIds, setSelection)
34
+ if (!handled && useEditor.getState().phase === 'furnish') {
35
+ useEditor.getState().setPhase('structure')
36
+ }
37
+ }
38
+
39
+ return (
40
+ <TreeNodeWrapper
41
+ actions={<TreeNodeActions node={node} />}
42
+ depth={depth}
43
+ expanded={false}
44
+ hasChildren={false}
45
+ icon={
46
+ <Image alt="" className="object-contain" height={14} src="/icons/fence.png" width={14} />
47
+ }
48
+ isHovered={isHovered}
49
+ isLast={isLast}
50
+ isSelected={isSelected}
51
+ isVisible={node.visible !== false}
52
+ label={
53
+ <InlineRenameInput
54
+ defaultName="Fence"
55
+ isEditing={isEditing}
56
+ node={node}
57
+ onStartEditing={() => setIsEditing(true)}
58
+ onStopEditing={() => setIsEditing(false)}
59
+ />
60
+ }
61
+ nodeId={nodeId}
62
+ onClick={handleClick}
63
+ onDoubleClick={() => focusTreeNode(nodeId)}
64
+ onMouseEnter={() => setHoveredId(nodeId)}
65
+ onMouseLeave={() => setHoveredId(null)}
66
+ onToggle={() => {}}
67
+ />
68
+ )
69
+ })
@@ -23,7 +23,8 @@ import {
23
23
  X,
24
24
  } from 'lucide-react'
25
25
  import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
26
- import { useEffect, useRef, useState } from 'react'
26
+ import { memo, useEffect, useRef, useState } from 'react'
27
+ import { useShallow } from 'zustand/react/shallow'
27
28
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
28
29
  import {
29
30
  Popover,
@@ -79,7 +80,7 @@ function useSiteNode(): SiteNode | null {
79
80
  )
80
81
  }
81
82
 
82
- function PropertyLineSection() {
83
+ const PropertyLineSection = memo(function PropertyLineSection() {
83
84
  const siteNode = useSiteNode()
84
85
  const updateNode = useScene((state) => state.updateNode)
85
86
  const mode = useEditor((state) => state.mode)
@@ -217,13 +218,13 @@ function PropertyLineSection() {
217
218
  )}
218
219
  </div>
219
220
  )
220
- }
221
+ })
221
222
 
222
223
  // ============================================================================
223
224
  // SITE PHASE VIEW - Property line + building buttons
224
225
  // ============================================================================
225
226
 
226
- function CameraPopover({
227
+ const CameraPopover = memo(function CameraPopover({
227
228
  nodeId,
228
229
  hasCamera,
229
230
  open,
@@ -302,9 +303,9 @@ function CameraPopover({
302
303
  </PopoverContent>
303
304
  </Popover>
304
305
  )
305
- }
306
+ })
306
307
 
307
- function ReferenceItem({
308
+ const ReferenceItem = memo(function ReferenceItem({
308
309
  refNode,
309
310
  isLastRow,
310
311
  setSelectedReferenceId,
@@ -374,7 +375,7 @@ function ReferenceItem({
374
375
  </button>
375
376
  </div>
376
377
  )
377
- }
378
+ })
378
379
 
379
380
  const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
380
381
 
@@ -386,15 +387,22 @@ interface LevelReferencesProps {
386
387
  onDeleteAsset?: (projectId: string, url: string) => void
387
388
  }
388
389
 
389
- function LevelReferences({
390
+ const LevelReferences = memo(function LevelReferences({
390
391
  levelId,
391
392
  isLastLevel,
392
393
  projectId,
393
394
  onUploadAsset,
394
395
  onDeleteAsset,
395
396
  }: LevelReferencesProps) {
396
- const nodes = useScene((s) => s.nodes)
397
397
  const deleteNode = useScene((s) => s.deleteNode)
398
+ const references = useScene(
399
+ useShallow((s) =>
400
+ Object.values(s.nodes).filter(
401
+ (node): node is ScanNode | GuideNode =>
402
+ (node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
403
+ ),
404
+ ),
405
+ )
398
406
  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
399
407
  const uploadState = useUploadStore((s) => s.uploads[levelId])
400
408
  const clearUpload = useUploadStore((s) => s.clearUpload)
@@ -409,11 +417,6 @@ function LevelReferences({
409
417
 
410
418
  const scanInputRef = useRef<HTMLInputElement>(null)
411
419
 
412
- const references = Object.values(nodes).filter(
413
- (node): node is ScanNode | GuideNode =>
414
- (node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
415
- )
416
-
417
420
  const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
418
421
  const file = e.target.files?.[0]
419
422
  if (!file) return
@@ -457,7 +460,10 @@ function LevelReferences({
457
460
 
458
461
  const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
459
462
  e.stopPropagation()
460
- const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined
463
+ const refNode = useScene.getState().nodes[nodeId as AnyNodeId] as
464
+ | ScanNode
465
+ | GuideNode
466
+ | undefined
461
467
 
462
468
  if (
463
469
  projectId &&
@@ -548,9 +554,9 @@ function LevelReferences({
548
554
  )}
549
555
  </div>
550
556
  )
551
- }
557
+ })
552
558
 
553
- function LevelItem({
559
+ const LevelItem = memo(function LevelItem({
554
560
  level,
555
561
  selectedLevelId,
556
562
  setSelection,
@@ -778,9 +784,9 @@ function LevelItem({
778
784
  </AnimatePresence>
779
785
  </div>
780
786
  )
781
- }
787
+ })
782
788
 
783
- function LevelsSection({
789
+ const LevelsSection = memo(function LevelsSection({
784
790
  projectId,
785
791
  onUploadAsset,
786
792
  onDeleteAsset,
@@ -789,21 +795,28 @@ function LevelsSection({
789
795
  onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
790
796
  onDeleteAsset?: (projectId: string, url: string) => void
791
797
  } = {}) {
792
- const nodes = useScene((state) => state.nodes)
793
798
  const createNode = useScene((state) => state.createNode)
794
799
  const updateNode = useScene((state) => state.updateNode)
795
800
  const selectedBuildingId = useViewer((state) => state.selection.buildingId)
796
801
  const selectedLevelId = useViewer((state) => state.selection.levelId)
797
802
  const setSelection = useViewer((state) => state.setSelection)
798
803
 
799
- const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) : null
804
+ const building = useScene((s) =>
805
+ selectedBuildingId ? ((s.nodes[selectedBuildingId] as BuildingNode | undefined) ?? null) : null,
806
+ )
807
+ const levels = useScene(
808
+ useShallow((s) => {
809
+ if (!selectedBuildingId) return []
810
+ const bldg = s.nodes[selectedBuildingId] as BuildingNode | undefined
811
+ if (!bldg) return []
812
+ return bldg.children
813
+ .map((id) => s.nodes[id])
814
+ .filter((node): node is LevelNode => node?.type === 'level')
815
+ }),
816
+ )
800
817
 
801
818
  if (!building) return null
802
819
 
803
- const levels = building.children
804
- .map((id) => nodes[id])
805
- .filter((node): node is LevelNode => node?.type === 'level')
806
-
807
820
  const handleAddLevel = () => {
808
821
  const newLevel = LevelNode.parse({
809
822
  level: levels.length,
@@ -857,9 +870,9 @@ function LevelsSection({
857
870
  </div>
858
871
  </div>
859
872
  )
860
- }
873
+ })
861
874
 
862
- function LayerToggle() {
875
+ const LayerToggle = memo(function LayerToggle() {
863
876
  const structureLayer = useEditor((state) => state.structureLayer)
864
877
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
865
878
  const phase = useEditor((state) => state.phase)
@@ -987,9 +1000,9 @@ function LayerToggle() {
987
1000
  </button>
988
1001
  </div>
989
1002
  )
990
- }
1003
+ })
991
1004
 
992
- function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1005
+ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
993
1006
  const [isEditing, setIsEditing] = useState(false)
994
1007
  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
995
1008
  const deleteNode = useScene((state) => state.deleteNode)
@@ -1150,9 +1163,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1150
1163
  </div>
1151
1164
  </div>
1152
1165
  )
1153
- }
1166
+ })
1154
1167
 
1155
- function MultiSelectionBadge() {
1168
+ const MultiSelectionBadge = memo(function MultiSelectionBadge() {
1156
1169
  const selectedIds = useViewer((state) => state.selection.selectedIds)
1157
1170
  const setSelection = useViewer((state) => state.setSelection)
1158
1171
 
@@ -1172,10 +1185,9 @@ function MultiSelectionBadge() {
1172
1185
  </div>
1173
1186
  </div>
1174
1187
  )
1175
- }
1188
+ })
1176
1189
 
1177
- function ContentSection() {
1178
- const nodes = useScene((state) => state.nodes)
1190
+ const ContentSection = memo(function ContentSection() {
1179
1191
  const selectedLevelId = useViewer((state) => state.selection.levelId)
1180
1192
  const structureLayer = useEditor((state) => state.structureLayer)
1181
1193
  const phase = useEditor((state) => state.phase)
@@ -1183,7 +1195,25 @@ function ContentSection() {
1183
1195
  const setMode = useEditor((state) => state.setMode)
1184
1196
  const setTool = useEditor((state) => state.setTool)
1185
1197
 
1186
- const level = selectedLevelId ? (nodes[selectedLevelId] as LevelNode) : null
1198
+ const level = useScene((s) =>
1199
+ selectedLevelId ? ((s.nodes[selectedLevelId] as LevelNode | undefined) ?? null) : null,
1200
+ )
1201
+ const levelZones = useScene(
1202
+ useShallow((s) => {
1203
+ if (!selectedLevelId) return []
1204
+ return Object.values(s.nodes).filter(
1205
+ (node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,
1206
+ )
1207
+ }),
1208
+ )
1209
+ const elementChildren = useScene(
1210
+ useShallow((s) => {
1211
+ if (!selectedLevelId) return []
1212
+ const lvl = s.nodes[selectedLevelId] as LevelNode | undefined
1213
+ if (!lvl) return []
1214
+ return lvl.children.filter((childId) => s.nodes[childId]?.type !== 'zone')
1215
+ }),
1216
+ )
1187
1217
 
1188
1218
  if (!level) {
1189
1219
  return (
@@ -1192,11 +1222,6 @@ function ContentSection() {
1192
1222
  }
1193
1223
 
1194
1224
  if (structureLayer === 'zones') {
1195
- // Show zones for this level
1196
- const levelZones = Object.values(nodes).filter(
1197
- (node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,
1198
- )
1199
-
1200
1225
  const handleAddZone = () => {
1201
1226
  setPhase('structure')
1202
1227
  setMode('build')
@@ -1223,21 +1248,9 @@ function ContentSection() {
1223
1248
  )
1224
1249
  }
1225
1250
 
1226
- // Filter elements based on phase
1227
- const elementChildren = level.children.filter((childId) => {
1228
- const childNode = nodes[childId]
1229
- if (!childNode || childNode.type === 'zone') return false
1230
-
1231
- // We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode
1232
- // This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible
1233
- // and selectable in both modes, ensuring seamless transition in the tree view.
1234
- return true
1235
- })
1236
-
1237
1251
  if (elementChildren.length === 0) {
1238
1252
  return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div>
1239
1253
  }
1240
-
1241
1254
  return (
1242
1255
  <TreeNodeDragProvider>
1243
1256
  <div className="flex flex-col">
@@ -1252,9 +1265,9 @@ function ContentSection() {
1252
1265
  </div>
1253
1266
  </TreeNodeDragProvider>
1254
1267
  )
1255
- }
1268
+ })
1256
1269
 
1257
- function BuildingItem({
1270
+ const BuildingItem = memo(function BuildingItem({
1258
1271
  building,
1259
1272
  isBuildingActive,
1260
1273
  buildingCameraOpen,
@@ -1295,19 +1308,16 @@ function BuildingItem({
1295
1308
  }
1296
1309
 
1297
1310
  return (
1298
- <motion.div
1311
+ <div
1299
1312
  className={cn('flex shrink-0 flex-col overflow-hidden', isBuildingActive && 'min-h-0 flex-1')}
1300
- layout
1301
- transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
1302
1313
  >
1303
- <motion.div
1314
+ <div
1304
1315
  className={cn(
1305
1316
  'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',
1306
1317
  isBuildingActive
1307
1318
  ? 'bg-accent/50 text-foreground'
1308
1319
  : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
1309
1320
  )}
1310
- layout="position"
1311
1321
  onClick={handleSelect}
1312
1322
  onDoubleClick={handleDoubleClick}
1313
1323
  ref={itemRef}
@@ -1391,7 +1401,7 @@ function BuildingItem({
1391
1401
  </div>
1392
1402
  </PopoverContent>
1393
1403
  </Popover>
1394
- </motion.div>
1404
+ </div>
1395
1405
 
1396
1406
  {/* Tools and content for the active building */}
1397
1407
  <AnimatePresence initial={false}>
@@ -1420,9 +1430,9 @@ function BuildingItem({
1420
1430
  </motion.div>
1421
1431
  )}
1422
1432
  </AnimatePresence>
1423
- </motion.div>
1433
+ </div>
1424
1434
  )
1425
- }
1435
+ })
1426
1436
 
1427
1437
  export interface SitePanelProps {
1428
1438
  projectId?: string
@@ -1431,7 +1441,6 @@ export interface SitePanelProps {
1431
1441
  }
1432
1442
 
1433
1443
  export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) {
1434
- const nodes = useScene((state) => state.nodes)
1435
1444
  const rootNodeIds = useScene((state) => state.rootNodeIds)
1436
1445
  const updateNode = useScene((state) => state.updateNode)
1437
1446
  const selectedBuildingId = useViewer((state) => state.selection.buildingId)
@@ -1442,13 +1451,20 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1442
1451
  const [siteCameraOpen, setSiteCameraOpen] = useState(false)
1443
1452
  const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null)
1444
1453
 
1445
- const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
1446
- const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
1447
- .map((child) => {
1448
- const id = typeof child === 'string' ? child : child.id
1449
- return nodes[id] as BuildingNode | undefined
1450
- })
1451
- .filter((node): node is BuildingNode => node?.type === 'building')
1454
+ const siteNode = useScene((s) =>
1455
+ rootNodeIds[0] ? ((s.nodes[rootNodeIds[0]] as SiteNode | undefined) ?? null) : null,
1456
+ )
1457
+ const buildings = useScene(
1458
+ useShallow((s) => {
1459
+ if (!siteNode) return []
1460
+ return siteNode.children
1461
+ .map((child) => {
1462
+ const id = typeof child === 'string' ? child : child.id
1463
+ return s.nodes[id] as BuildingNode | undefined
1464
+ })
1465
+ .filter((node): node is BuildingNode => node?.type === 'building')
1466
+ }),
1467
+ )
1452
1468
 
1453
1469
  return (
1454
1470
  <LayoutGroup>
@@ -1515,7 +1531,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1515
1531
  No buildings yet
1516
1532
  </motion.div>
1517
1533
  ) : (
1518
- <motion.div className="flex min-h-0 flex-1 flex-col" layout>
1534
+ <div className="flex min-h-0 flex-1 flex-col">
1519
1535
  {buildings.map((building) => {
1520
1536
  const isBuildingActive =
1521
1537
  (phase === 'structure' || phase === 'furnish') &&
@@ -1534,7 +1550,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1534
1550
  />
1535
1551
  )
1536
1552
  })}
1537
- </motion.div>
1553
+ </div>
1538
1554
  )}
1539
1555
  </motion.div>
1540
1556
  </div>
@@ -1,10 +1,10 @@
1
- import { type AnyNode, useScene } from '@pascal-app/core'
1
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
2
2
  import { Pencil } from 'lucide-react'
3
- import { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
4
4
  import { cn } from './../../../../../lib/utils'
5
5
 
6
6
  interface InlineRenameInputProps {
7
- node: AnyNode
7
+ nodeId: AnyNodeId
8
8
  isEditing: boolean
9
9
  onStopEditing: () => void
10
10
  defaultName: string
@@ -12,8 +12,8 @@ interface InlineRenameInputProps {
12
12
  onStartEditing?: () => void
13
13
  }
14
14
 
15
- export function InlineRenameInput({
16
- node,
15
+ export const InlineRenameInput = memo(function InlineRenameInput({
16
+ nodeId,
17
17
  isEditing,
18
18
  onStopEditing,
19
19
  defaultName,
@@ -21,13 +21,14 @@ export function InlineRenameInput({
21
21
  onStartEditing,
22
22
  }: InlineRenameInputProps) {
23
23
  const updateNode = useScene((s) => s.updateNode)
24
- const [value, setValue] = useState(node.name || '')
24
+ const name = useScene((s) => s.nodes[nodeId]?.name)
25
+ const [value, setValue] = useState(name || '')
25
26
  const inputRef = useRef<HTMLInputElement>(null)
26
27
  const inputSize = Math.max((value || defaultName).length, 1)
27
28
 
28
29
  useEffect(() => {
29
30
  if (isEditing) {
30
- setValue(node.name || '')
31
+ setValue(name || '')
31
32
  // Focus and select all text after a short delay
32
33
  setTimeout(() => {
33
34
  if (inputRef.current) {
@@ -36,15 +37,15 @@ export function InlineRenameInput({
36
37
  }
37
38
  }, 0)
38
39
  }
39
- }, [isEditing, node.name])
40
+ }, [isEditing, name])
40
41
 
41
42
  const handleSave = useCallback(() => {
42
43
  const trimmed = value.trim()
43
- if (trimmed !== node.name) {
44
- updateNode(node.id, { name: trimmed || undefined })
44
+ if (trimmed !== name) {
45
+ updateNode(nodeId, { name: trimmed || undefined })
45
46
  }
46
47
  onStopEditing()
47
- }, [value, node.id, node.name, updateNode, onStopEditing])
48
+ }, [value, nodeId, name, updateNode, onStopEditing])
48
49
 
49
50
  const handleKeyDown = (e: React.KeyboardEvent) => {
50
51
  if (e.key === 'Enter') {
@@ -60,7 +61,7 @@ export function InlineRenameInput({
60
61
  return (
61
62
  <div className="group/rename flex h-5 min-w-0 items-center gap-1">
62
63
  <span className={cn('truncate border-transparent border-b', className)}>
63
- {node.name || defaultName}
64
+ {name || defaultName}
64
65
  </span>
65
66
  {onStartEditing && (
66
67
  <button
@@ -95,4 +96,4 @@ export function InlineRenameInput({
95
96
  value={value}
96
97
  />
97
98
  )
98
- }
99
+ })