@pascal-app/editor 0.5.1 → 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 (79) 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 +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  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/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -10
@@ -3,25 +3,81 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ getEffectiveWallSurfaceMaterial,
7
+ getClampedWallCurveOffset,
8
+ getMaxWallCurveOffset,
9
+ getWallCurveLength,
10
+ getWallSurfaceMaterialSignature,
11
+ normalizeWallCurveOffset,
6
12
  type MaterialSchema,
7
13
  useScene,
14
+ type WallSurfaceSide,
8
15
  type WallNode,
9
16
  } from '@pascal-app/core'
10
17
  import { useViewer } from '@pascal-app/viewer'
11
- import { useCallback } from 'react'
18
+ import { Move, Spline } from 'lucide-react'
19
+ import { useCallback, useMemo } from 'react'
20
+ import { sfxEmitter } from '../../../lib/sfx-bus'
21
+ import useEditor from '../../../store/use-editor'
22
+ import { ActionButton, ActionGroup } from '../controls/action-button'
12
23
  import { MaterialPicker } from '../controls/material-picker'
13
24
  import { PanelSection } from '../controls/panel-section'
14
25
  import { SliderControl } from '../controls/slider-control'
15
26
  import { PanelWrapper } from './panel-wrapper'
16
27
 
28
+ function buildWallSurfaceMaterialPatch(
29
+ node: WallNode,
30
+ targetSide: WallSurfaceSide | null,
31
+ material: MaterialSchema | undefined,
32
+ materialPreset: string | undefined,
33
+ ): Partial<WallNode> {
34
+ const nextSurfaceMaterial = { material, materialPreset }
35
+ const nextInterior =
36
+ targetSide === null || targetSide === 'interior'
37
+ ? nextSurfaceMaterial
38
+ : getEffectiveWallSurfaceMaterial(node, 'interior')
39
+ const nextExterior =
40
+ targetSide === null || targetSide === 'exterior'
41
+ ? nextSurfaceMaterial
42
+ : getEffectiveWallSurfaceMaterial(node, 'exterior')
43
+
44
+ return {
45
+ interiorMaterial: nextInterior.material,
46
+ interiorMaterialPreset: nextInterior.materialPreset,
47
+ exteriorMaterial: nextExterior.material,
48
+ exteriorMaterialPreset: nextExterior.materialPreset,
49
+ material: undefined,
50
+ materialPreset: undefined,
51
+ }
52
+ }
53
+
17
54
  export function WallPanel() {
18
- const selectedIds = useViewer((s) => s.selection.selectedIds)
55
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
19
56
  const setSelection = useViewer((s) => s.setSelection)
20
- const nodes = useScene((s) => s.nodes)
21
57
  const updateNode = useScene((s) => s.updateNode)
58
+ const setMovingNode = useEditor((s) => s.setMovingNode)
59
+ const setCurvingWall = useEditor((s) => s.setCurvingWall)
60
+ const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
61
+
62
+ const node = useScene((s) =>
63
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
64
+ )
22
65
 
23
- const selectedId = selectedIds[0]
24
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
66
+ // Boolean selector — re-renders only when this specific wall's child
67
+ // composition crosses the "has a door/window/wall-item" threshold.
68
+ const hasWallChildrenBlockingCurve = useScene((s) => {
69
+ if (!node) return false
70
+ return (node.children ?? []).some((childId) => {
71
+ const child = s.nodes[childId as AnyNodeId]
72
+ if (!child) return false
73
+ if (child.type === 'door' || child.type === 'window') return true
74
+ if (child.type === 'item') {
75
+ const attachTo = child.asset?.attachTo
76
+ return attachTo === 'wall' || attachTo === 'wall-side'
77
+ }
78
+ return false
79
+ })
80
+ })
25
81
 
26
82
  const handleUpdate = useCallback(
27
83
  (updates: Partial<WallNode>) => {
@@ -32,6 +88,35 @@ export function WallPanel() {
32
88
  [selectedId, updateNode],
33
89
  )
34
90
 
91
+ const effectiveInteriorMaterial = useMemo(
92
+ () => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
93
+ [node],
94
+ )
95
+ const effectiveExteriorMaterial = useMemo(
96
+ () => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
97
+ [node],
98
+ )
99
+ const surfaceMaterialsMatch = useMemo(
100
+ () =>
101
+ getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
102
+ getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
103
+ [effectiveExteriorMaterial, effectiveInteriorMaterial],
104
+ )
105
+ const materialTargetSide =
106
+ selectedMaterialTarget &&
107
+ selectedMaterialTarget.nodeId === node?.id &&
108
+ (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
109
+ ? selectedMaterialTarget.role
110
+ : null
111
+ const materialPickerValue =
112
+ materialTargetSide === 'interior'
113
+ ? effectiveInteriorMaterial
114
+ : materialTargetSide === 'exterior'
115
+ ? effectiveExteriorMaterial
116
+ : surfaceMaterialsMatch
117
+ ? effectiveInteriorMaterial
118
+ : {}
119
+
35
120
  const handleUpdateLength = useCallback(
36
121
  (newLength: number) => {
37
122
  if (!node || newLength <= 0) return
@@ -55,25 +140,50 @@ export function WallPanel() {
55
140
  [node, handleUpdate],
56
141
  )
57
142
 
58
- const handleMaterialChange = useCallback(
143
+ const handleMaterialPresetChange = useCallback(
144
+ (materialPreset: string) => {
145
+ if (!node || !materialTargetSide) return
146
+ handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
147
+ },
148
+ [handleUpdate, materialTargetSide, node],
149
+ )
150
+
151
+ const handleCustomMaterialChange = useCallback(
59
152
  (material: MaterialSchema) => {
60
- handleUpdate({ material })
153
+ if (!node || !materialTargetSide) return
154
+ handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
61
155
  },
62
- [handleUpdate],
156
+ [handleUpdate, materialTargetSide, node],
63
157
  )
64
158
 
65
159
  const handleClose = useCallback(() => {
66
160
  setSelection({ selectedIds: [] })
67
161
  }, [setSelection])
68
162
 
69
- if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
163
+ const handleMove = useCallback(() => {
164
+ if (!node) return
165
+ sfxEmitter.emit('sfx:item-pick')
166
+ setMovingNode(node)
167
+ setSelection({ selectedIds: [] })
168
+ }, [node, setMovingNode, setSelection])
169
+
170
+ const handleCurve = useCallback(() => {
171
+ if (!node) return
172
+ sfxEmitter.emit('sfx:item-pick')
173
+ setCurvingWall(node)
174
+ setSelection({ selectedIds: [] })
175
+ }, [node, setCurvingWall, setSelection])
176
+
177
+ if (!(node && node.type === 'wall' && selectedId)) return null
70
178
 
71
179
  const dx = node.end[0] - node.start[0]
72
180
  const dz = node.end[1] - node.start[1]
73
- const length = Math.sqrt(dx * dx + dz * dz)
181
+ const length = getWallCurveLength(node)
74
182
 
75
183
  const height = node.height ?? 2.5
76
184
  const thickness = node.thickness ?? 0.1
185
+ const curveOffset = getClampedWallCurveOffset(node)
186
+ const maxCurveOffset = getMaxWallCurveOffset(node)
77
187
 
78
188
  return (
79
189
  <PanelWrapper
@@ -113,10 +223,48 @@ export function WallPanel() {
113
223
  unit="m"
114
224
  value={Math.round(thickness * 1000) / 1000}
115
225
  />
226
+ {!hasWallChildrenBlockingCurve && (
227
+ <SliderControl
228
+ label="Curve"
229
+ max={Math.max(0.01, maxCurveOffset)}
230
+ min={-Math.max(0.01, maxCurveOffset)}
231
+ onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
232
+ precision={2}
233
+ step={0.1}
234
+ unit="m"
235
+ value={Math.round(curveOffset * 100) / 100}
236
+ />
237
+ )}
116
238
  </PanelSection>
117
239
 
118
240
  <PanelSection title="Material">
119
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
241
+ {!materialTargetSide ? (
242
+ <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
243
+ Click the wall face you want to edit. Materials now apply to one side at a time.
244
+ </div>
245
+ ) : null}
246
+ <MaterialPicker
247
+ disabled={!materialTargetSide}
248
+ hideSideControl
249
+ nodeType="wall"
250
+ onChange={handleCustomMaterialChange}
251
+ onSelectMaterialPreset={handleMaterialPresetChange}
252
+ selectedMaterialPreset={materialPickerValue.materialPreset}
253
+ value={materialPickerValue.material}
254
+ />
255
+ </PanelSection>
256
+
257
+ <PanelSection title="Actions">
258
+ <ActionGroup>
259
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
260
+ {!hasWallChildrenBlockingCurve && (
261
+ <ActionButton
262
+ icon={<Spline className="h-3.5 w-3.5" />}
263
+ label="Curve"
264
+ onClick={handleCurve}
265
+ />
266
+ )}
267
+ </ActionGroup>
120
268
  </PanelSection>
121
269
  </PanelWrapper>
122
270
  )
@@ -24,19 +24,17 @@ import { PanelWrapper } from './panel-wrapper'
24
24
  import { PresetsPopover } from './presets/presets-popover'
25
25
 
26
26
  export function WindowPanel() {
27
- const selectedIds = useViewer((s) => s.selection.selectedIds)
27
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
28
28
  const setSelection = useViewer((s) => s.setSelection)
29
- const nodes = useScene((s) => s.nodes)
30
29
  const updateNode = useScene((s) => s.updateNode)
31
30
  const deleteNode = useScene((s) => s.deleteNode)
32
31
  const setMovingNode = useEditor((s) => s.setMovingNode)
33
32
 
34
33
  const adapter = usePresetsAdapter()
35
34
 
36
- const selectedId = selectedIds[0]
37
- const node = selectedId
38
- ? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
39
- : undefined
35
+ const node = useScene((s) =>
36
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
37
+ )
40
38
 
41
39
  const handleUpdate = useCallback(
42
40
  (updates: Partial<WindowNode>) => {
@@ -153,7 +151,7 @@ export function WindowPanel() {
153
151
  [handleUpdate],
154
152
  )
155
153
 
156
- if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
154
+ if (!(node && node.type === 'window' && selectedId)) return null
157
155
 
158
156
  const numCols = node.columnRatios.length
159
157
  const numRows = node.rowRatios.length
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { Building2, Plus } from 'lucide-react'
4
- import { useState } from 'react'
4
+ import { memo, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
6
6
  import {
7
7
  Tooltip,
@@ -17,7 +17,11 @@ interface BuildingTreeNodeProps {
17
17
  isLast?: boolean
18
18
  }
19
19
 
20
- export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) {
20
+ export const BuildingTreeNode = memo(function BuildingTreeNode({
21
+ nodeId,
22
+ depth,
23
+ isLast,
24
+ }: BuildingTreeNodeProps) {
21
25
  const [expanded, setExpanded] = useState(true)
22
26
  const createNode = useScene((state) => state.createNode)
23
27
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -84,4 +88,4 @@ export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProp
84
88
  ))}
85
89
  </TreeNodeWrapper>
86
90
  )
87
- }
91
+ })
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, type CeilingNode, 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'
@@ -14,7 +14,11 @@ interface CeilingTreeNodeProps {
14
14
  isLast?: boolean
15
15
  }
16
16
 
17
- export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps) {
17
+ export const CeilingTreeNode = memo(function CeilingTreeNode({
18
+ nodeId,
19
+ depth,
20
+ isLast,
21
+ }: CeilingTreeNodeProps) {
18
22
  const [expanded, setExpanded] = useState(false)
19
23
  const [isEditing, setIsEditing] = useState(false)
20
24
  const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
@@ -113,7 +117,7 @@ export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps)
113
117
  ))}
114
118
  </TreeNodeWrapper>
115
119
  )
116
- }
120
+ })
117
121
 
118
122
  /**
119
123
  * Calculate the area of a polygon using the shoelace formula
@@ -3,7 +3,7 @@
3
3
  import { type AnyNodeId, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import Image from 'next/image'
6
- import { useCallback, useState } from 'react'
6
+ import { memo, useCallback, useState } from 'react'
7
7
  import useEditor from './../../../../../store/use-editor'
8
8
  import { InlineRenameInput } from './inline-rename-input'
9
9
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -15,7 +15,11 @@ interface DoorTreeNodeProps {
15
15
  isLast?: boolean
16
16
  }
17
17
 
18
- export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
18
+ export const DoorTreeNode = memo(function DoorTreeNode({
19
+ nodeId,
20
+ depth,
21
+ isLast,
22
+ }: DoorTreeNodeProps) {
19
23
  const [isEditing, setIsEditing] = useState(false)
20
24
  const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
21
25
  const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
@@ -72,4 +76,4 @@ export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
72
76
  onToggle={() => {}}
73
77
  />
74
78
  )
75
- }
79
+ })
@@ -1,7 +1,7 @@
1
1
  import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
- import { useState } from 'react'
4
+ import { memo, 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 FenceTreeNodeProps {
13
13
  isLast?: boolean
14
14
  }
15
15
 
16
- export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
16
+ export const FenceTreeNode = memo(function FenceTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: FenceTreeNodeProps) {
17
21
  const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined
18
22
  const [isEditing, setIsEditing] = useState(false)
19
23
  const selectedIds = useViewer((state) => state.selection.selectedIds)
@@ -62,4 +66,4 @@ export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
62
66
  onToggle={() => {}}
63
67
  />
64
68
  )
65
- }
69
+ })
@@ -23,7 +23,7 @@ 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
27
  import { useShallow } from 'zustand/react/shallow'
28
28
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
29
29
  import {
@@ -80,7 +80,7 @@ function useSiteNode(): SiteNode | null {
80
80
  )
81
81
  }
82
82
 
83
- function PropertyLineSection() {
83
+ const PropertyLineSection = memo(function PropertyLineSection() {
84
84
  const siteNode = useSiteNode()
85
85
  const updateNode = useScene((state) => state.updateNode)
86
86
  const mode = useEditor((state) => state.mode)
@@ -218,13 +218,13 @@ function PropertyLineSection() {
218
218
  )}
219
219
  </div>
220
220
  )
221
- }
221
+ })
222
222
 
223
223
  // ============================================================================
224
224
  // SITE PHASE VIEW - Property line + building buttons
225
225
  // ============================================================================
226
226
 
227
- function CameraPopover({
227
+ const CameraPopover = memo(function CameraPopover({
228
228
  nodeId,
229
229
  hasCamera,
230
230
  open,
@@ -303,9 +303,9 @@ function CameraPopover({
303
303
  </PopoverContent>
304
304
  </Popover>
305
305
  )
306
- }
306
+ })
307
307
 
308
- function ReferenceItem({
308
+ const ReferenceItem = memo(function ReferenceItem({
309
309
  refNode,
310
310
  isLastRow,
311
311
  setSelectedReferenceId,
@@ -375,7 +375,7 @@ function ReferenceItem({
375
375
  </button>
376
376
  </div>
377
377
  )
378
- }
378
+ })
379
379
 
380
380
  const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
381
381
 
@@ -387,7 +387,7 @@ interface LevelReferencesProps {
387
387
  onDeleteAsset?: (projectId: string, url: string) => void
388
388
  }
389
389
 
390
- function LevelReferences({
390
+ const LevelReferences = memo(function LevelReferences({
391
391
  levelId,
392
392
  isLastLevel,
393
393
  projectId,
@@ -554,9 +554,9 @@ function LevelReferences({
554
554
  )}
555
555
  </div>
556
556
  )
557
- }
557
+ })
558
558
 
559
- function LevelItem({
559
+ const LevelItem = memo(function LevelItem({
560
560
  level,
561
561
  selectedLevelId,
562
562
  setSelection,
@@ -784,9 +784,9 @@ function LevelItem({
784
784
  </AnimatePresence>
785
785
  </div>
786
786
  )
787
- }
787
+ })
788
788
 
789
- function LevelsSection({
789
+ const LevelsSection = memo(function LevelsSection({
790
790
  projectId,
791
791
  onUploadAsset,
792
792
  onDeleteAsset,
@@ -870,9 +870,9 @@ function LevelsSection({
870
870
  </div>
871
871
  </div>
872
872
  )
873
- }
873
+ })
874
874
 
875
- function LayerToggle() {
875
+ const LayerToggle = memo(function LayerToggle() {
876
876
  const structureLayer = useEditor((state) => state.structureLayer)
877
877
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
878
878
  const phase = useEditor((state) => state.phase)
@@ -1000,9 +1000,9 @@ function LayerToggle() {
1000
1000
  </button>
1001
1001
  </div>
1002
1002
  )
1003
- }
1003
+ })
1004
1004
 
1005
- function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1005
+ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1006
1006
  const [isEditing, setIsEditing] = useState(false)
1007
1007
  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
1008
1008
  const deleteNode = useScene((state) => state.deleteNode)
@@ -1163,9 +1163,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
1163
1163
  </div>
1164
1164
  </div>
1165
1165
  )
1166
- }
1166
+ })
1167
1167
 
1168
- function MultiSelectionBadge() {
1168
+ const MultiSelectionBadge = memo(function MultiSelectionBadge() {
1169
1169
  const selectedIds = useViewer((state) => state.selection.selectedIds)
1170
1170
  const setSelection = useViewer((state) => state.setSelection)
1171
1171
 
@@ -1185,9 +1185,9 @@ function MultiSelectionBadge() {
1185
1185
  </div>
1186
1186
  </div>
1187
1187
  )
1188
- }
1188
+ })
1189
1189
 
1190
- function ContentSection() {
1190
+ const ContentSection = memo(function ContentSection() {
1191
1191
  const selectedLevelId = useViewer((state) => state.selection.levelId)
1192
1192
  const structureLayer = useEditor((state) => state.structureLayer)
1193
1193
  const phase = useEditor((state) => state.phase)
@@ -1265,9 +1265,9 @@ function ContentSection() {
1265
1265
  </div>
1266
1266
  </TreeNodeDragProvider>
1267
1267
  )
1268
- }
1268
+ })
1269
1269
 
1270
- function BuildingItem({
1270
+ const BuildingItem = memo(function BuildingItem({
1271
1271
  building,
1272
1272
  isBuildingActive,
1273
1273
  buildingCameraOpen,
@@ -1308,19 +1308,16 @@ function BuildingItem({
1308
1308
  }
1309
1309
 
1310
1310
  return (
1311
- <motion.div
1311
+ <div
1312
1312
  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
1313
  >
1316
- <motion.div
1314
+ <div
1317
1315
  className={cn(
1318
1316
  'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',
1319
1317
  isBuildingActive
1320
1318
  ? 'bg-accent/50 text-foreground'
1321
1319
  : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
1322
1320
  )}
1323
- layout="position"
1324
1321
  onClick={handleSelect}
1325
1322
  onDoubleClick={handleDoubleClick}
1326
1323
  ref={itemRef}
@@ -1404,7 +1401,7 @@ function BuildingItem({
1404
1401
  </div>
1405
1402
  </PopoverContent>
1406
1403
  </Popover>
1407
- </motion.div>
1404
+ </div>
1408
1405
 
1409
1406
  {/* Tools and content for the active building */}
1410
1407
  <AnimatePresence initial={false}>
@@ -1433,9 +1430,9 @@ function BuildingItem({
1433
1430
  </motion.div>
1434
1431
  )}
1435
1432
  </AnimatePresence>
1436
- </motion.div>
1433
+ </div>
1437
1434
  )
1438
- }
1435
+ })
1439
1436
 
1440
1437
  export interface SitePanelProps {
1441
1438
  projectId?: string
@@ -1534,7 +1531,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1534
1531
  No buildings yet
1535
1532
  </motion.div>
1536
1533
  ) : (
1537
- <motion.div className="flex min-h-0 flex-1 flex-col" layout>
1534
+ <div className="flex min-h-0 flex-1 flex-col">
1538
1535
  {buildings.map((building) => {
1539
1536
  const isBuildingActive =
1540
1537
  (phase === 'structure' || phase === 'furnish') &&
@@ -1553,7 +1550,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1553
1550
  />
1554
1551
  )
1555
1552
  })}
1556
- </motion.div>
1553
+ </div>
1557
1554
  )}
1558
1555
  </motion.div>
1559
1556
  </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,7 +1,7 @@
1
1
  import { type AnyNodeId, 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'
@@ -13,7 +13,11 @@ interface LevelTreeNodeProps {
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,