@pascal-app/editor 0.4.0 → 0.5.1

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 (61) hide show
  1. package/package.json +5 -5
  2. package/src/components/editor/floating-action-menu.tsx +101 -29
  3. package/src/components/editor/floating-building-action-menu.tsx +69 -0
  4. package/src/components/editor/floorplan-panel.tsx +31 -13
  5. package/src/components/editor/index.tsx +219 -167
  6. package/src/components/editor/node-action-menu.tsx +26 -10
  7. package/src/components/editor/selection-manager.tsx +38 -2
  8. package/src/components/editor/thumbnail-generator.tsx +245 -64
  9. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  10. package/src/components/tools/building/move-building-tool.tsx +157 -0
  11. package/src/components/tools/door/door-math.ts +1 -1
  12. package/src/components/tools/door/door-tool.tsx +19 -7
  13. package/src/components/tools/door/move-door-tool.tsx +17 -8
  14. package/src/components/tools/fence/fence-drafting.ts +125 -0
  15. package/src/components/tools/fence/fence-tool.tsx +190 -0
  16. package/src/components/tools/fence/move-fence-tool.tsx +223 -0
  17. package/src/components/tools/item/item-tool.tsx +3 -3
  18. package/src/components/tools/item/move-tool.tsx +7 -0
  19. package/src/components/tools/item/placement-strategies.ts +15 -7
  20. package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
  21. package/src/components/tools/roof/move-roof-tool.tsx +5 -2
  22. package/src/components/tools/roof/roof-tool.tsx +6 -6
  23. package/src/components/tools/select/box-select-tool.tsx +2 -2
  24. package/src/components/tools/shared/polygon-editor.tsx +2 -2
  25. package/src/components/tools/slab/slab-tool.tsx +4 -4
  26. package/src/components/tools/stair/stair-defaults.ts +10 -0
  27. package/src/components/tools/stair/stair-tool.tsx +29 -6
  28. package/src/components/tools/tool-manager.tsx +42 -14
  29. package/src/components/tools/wall/wall-tool.tsx +19 -29
  30. package/src/components/tools/window/move-window-tool.tsx +17 -8
  31. package/src/components/tools/window/window-math.ts +1 -1
  32. package/src/components/tools/window/window-tool.tsx +19 -7
  33. package/src/components/tools/zone/zone-tool.tsx +7 -7
  34. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  35. package/src/components/ui/helpers/building-helper.tsx +32 -0
  36. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  37. package/src/components/ui/panels/fence-panel.tsx +184 -0
  38. package/src/components/ui/panels/panel-manager.tsx +3 -0
  39. package/src/components/ui/panels/stair-panel.tsx +206 -33
  40. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
  41. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
  42. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
  43. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
  44. package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
  45. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  46. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
  47. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
  48. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
  49. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
  50. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
  51. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
  52. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
  53. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
  54. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
  55. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
  56. package/src/components/viewer-overlay.tsx +1 -0
  57. package/src/hooks/use-auto-save.ts +3 -6
  58. package/src/hooks/use-contextual-tools.ts +10 -2
  59. package/src/hooks/use-grid-events.ts +13 -1
  60. package/src/hooks/use-keyboard.ts +4 -0
  61. package/src/store/use-editor.tsx +7 -0
@@ -1,7 +1,8 @@
1
- import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
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
4
  import { useState } from 'react'
5
+ import { useShallow } from 'zustand/react/shallow'
5
6
  import {
6
7
  Tooltip,
7
8
  TooltipContent,
@@ -11,37 +12,42 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
11
12
  import { TreeNodeActions } from './tree-node-actions'
12
13
 
13
14
  interface BuildingTreeNodeProps {
14
- node: BuildingNode
15
+ nodeId: AnyNodeId
15
16
  depth: number
16
17
  isLast?: boolean
17
18
  }
18
19
 
19
- export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) {
20
+ export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) {
20
21
  const [expanded, setExpanded] = useState(true)
21
22
  const createNode = useScene((state) => state.createNode)
22
- const isSelected = useViewer((state) => state.selection.buildingId === node.id)
23
- const isHovered = useViewer((state) => state.hoveredId === node.id)
23
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
24
+ const name = useScene((s) => s.nodes[nodeId]?.name)
25
+ const children = useScene(
26
+ useShallow((s) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []),
27
+ )
28
+ const isSelected = useViewer((state) => state.selection.buildingId === nodeId)
29
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
24
30
  const setSelection = useViewer((state) => state.setSelection)
25
31
 
26
32
  const handleClick = () => {
27
- setSelection({ buildingId: node.id })
33
+ setSelection({ buildingId: nodeId })
28
34
  }
29
35
 
30
36
  const handleAddLevel = (e: React.MouseEvent) => {
31
37
  e.stopPropagation()
32
38
  const newLevel = LevelNode.parse({
33
- level: node.children.length,
39
+ level: children.length,
34
40
  children: [],
35
- parentId: node.id,
41
+ parentId: nodeId,
36
42
  })
37
- createNode(newLevel, node.id)
43
+ createNode(newLevel, nodeId)
38
44
  }
39
45
 
40
46
  return (
41
47
  <TreeNodeWrapper
42
48
  actions={
43
49
  <div className="flex items-center gap-0.5">
44
- <TreeNodeActions node={node} />
50
+ <TreeNodeActions nodeId={nodeId} />
45
51
  <Tooltip>
46
52
  <TooltipTrigger asChild>
47
53
  <button
@@ -57,20 +63,21 @@ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps)
57
63
  }
58
64
  depth={depth}
59
65
  expanded={expanded}
60
- hasChildren={node.children.length > 0}
66
+ hasChildren={children.length > 0}
61
67
  icon={<Building2 className="h-3.5 w-3.5" />}
62
68
  isHovered={isHovered}
63
69
  isLast={isLast}
64
70
  isSelected={isSelected}
65
- label={node.name || 'Building'}
71
+ isVisible={isVisible}
72
+ label={name || 'Building'}
66
73
  onClick={handleClick}
67
- onDoubleClick={() => focusTreeNode(node.id)}
74
+ onDoubleClick={() => focusTreeNode(nodeId)}
68
75
  onToggle={() => setExpanded(!expanded)}
69
76
  >
70
- {node.children.map((childId, index) => (
77
+ {children.map((childId, index) => (
71
78
  <TreeNode
72
79
  depth={depth + 1}
73
- isLast={index === node.children.length - 1}
80
+ isLast={index === children.length - 1}
74
81
  key={childId}
75
82
  nodeId={childId}
76
83
  />
@@ -1,104 +1,112 @@
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 { useEffect, useState } from 'react'
4
+ import { useCallback, useEffect, useState } from 'react'
5
+ import { useShallow } from 'zustand/react/shallow'
5
6
  import useEditor from './../../../../../store/use-editor'
6
7
  import { InlineRenameInput } from './inline-rename-input'
7
8
  import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
8
9
  import { TreeNodeActions } from './tree-node-actions'
9
10
 
10
11
  interface CeilingTreeNodeProps {
11
- node: CeilingNode
12
+ nodeId: AnyNodeId
12
13
  depth: number
13
14
  isLast?: boolean
14
15
  }
15
16
 
16
- export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
17
+ export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps) {
17
18
  const [expanded, setExpanded] = useState(false)
18
19
  const [isEditing, setIsEditing] = useState(false)
19
- const selectedIds = useViewer((state) => state.selection.selectedIds)
20
- const isSelected = selectedIds.includes(node.id)
21
- const isHovered = useViewer((state) => state.hoveredId === node.id)
20
+ const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
21
+ const children = useScene(
22
+ useShallow((s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.children ?? []),
23
+ )
24
+ const polygon = useScene(
25
+ (s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.polygon ?? [],
26
+ )
27
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
28
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
22
29
  const setSelection = useViewer((state) => state.setSelection)
23
30
  const setHoveredId = useViewer((state) => state.setHoveredId)
24
31
 
32
+ // Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
25
33
  useEffect(() => {
26
- if (selectedIds.length === 0) return
27
- const nodes = useScene.getState().nodes
28
- let isDescendant = false
29
- for (const id of selectedIds) {
30
- let current = nodes[id as AnyNodeId]
31
- while (current?.parentId) {
32
- if (current.parentId === node.id) {
33
- isDescendant = true
34
- break
34
+ return useViewer.subscribe((state) => {
35
+ const { selectedIds } = state.selection
36
+ if (selectedIds.length === 0) return
37
+ const nodes = useScene.getState().nodes
38
+ for (const id of selectedIds) {
39
+ let current = nodes[id as AnyNodeId]
40
+ while (current?.parentId) {
41
+ if (current.parentId === nodeId) {
42
+ setExpanded(true)
43
+ return
44
+ }
45
+ current = nodes[current.parentId as AnyNodeId]
35
46
  }
36
- current = nodes[current.parentId as AnyNodeId]
37
47
  }
38
- if (isDescendant) break
39
- }
40
- if (isDescendant) {
41
- setExpanded(true)
42
- }
43
- }, [selectedIds, node.id])
44
-
45
- const handleClick = (e: React.MouseEvent) => {
46
- e.stopPropagation()
47
- const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
48
- if (!handled && useEditor.getState().phase === 'furnish') {
49
- useEditor.getState().setPhase('structure')
50
- }
51
- }
52
-
53
- const handleDoubleClick = () => {
54
- focusTreeNode(node.id)
55
- }
48
+ })
49
+ }, [nodeId])
56
50
 
57
- const handleMouseEnter = () => {
58
- setHoveredId(node.id)
59
- }
51
+ const handleClick = useCallback(
52
+ (e: React.MouseEvent) => {
53
+ e.stopPropagation()
54
+ const handled = handleTreeSelection(
55
+ e,
56
+ nodeId,
57
+ useViewer.getState().selection.selectedIds,
58
+ setSelection,
59
+ )
60
+ if (!handled && useEditor.getState().phase === 'furnish') {
61
+ useEditor.getState().setPhase('structure')
62
+ }
63
+ },
64
+ [nodeId, setSelection],
65
+ )
60
66
 
61
- const handleMouseLeave = () => {
62
- setHoveredId(null)
63
- }
67
+ const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
68
+ const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
69
+ const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
70
+ const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
71
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
72
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
64
73
 
65
- // Calculate approximate area from polygon
66
- const area = calculatePolygonArea(node.polygon).toFixed(1)
74
+ const area = calculatePolygonArea(polygon).toFixed(1)
67
75
  const defaultName = `Ceiling (${area}m²)`
68
76
 
69
77
  return (
70
78
  <TreeNodeWrapper
71
- actions={<TreeNodeActions node={node} />}
79
+ actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
72
80
  depth={depth}
73
81
  expanded={expanded}
74
- hasChildren={node.children.length > 0}
82
+ hasChildren={children.length > 0}
75
83
  icon={
76
84
  <Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
77
85
  }
78
86
  isHovered={isHovered}
79
87
  isLast={isLast}
80
88
  isSelected={isSelected}
81
- isVisible={node.visible !== false}
89
+ isVisible={isVisible}
82
90
  label={
83
91
  <InlineRenameInput
84
92
  defaultName={defaultName}
85
93
  isEditing={isEditing}
86
- node={node}
87
- onStartEditing={() => setIsEditing(true)}
88
- onStopEditing={() => setIsEditing(false)}
94
+ nodeId={nodeId as AnyNodeId}
95
+ onStartEditing={handleStartEditing}
96
+ onStopEditing={handleStopEditing}
89
97
  />
90
98
  }
91
- nodeId={node.id}
99
+ nodeId={nodeId}
92
100
  onClick={handleClick}
93
101
  onDoubleClick={handleDoubleClick}
94
102
  onMouseEnter={handleMouseEnter}
95
103
  onMouseLeave={handleMouseLeave}
96
- onToggle={() => setExpanded(!expanded)}
104
+ onToggle={handleToggle}
97
105
  >
98
- {node.children.map((childId, index) => (
106
+ {children.map((childId, index) => (
99
107
  <TreeNode
100
108
  depth={depth + 1}
101
- isLast={index === node.children.length - 1}
109
+ isLast={index === children.length - 1}
102
110
  key={childId}
103
111
  nodeId={childId}
104
112
  />
@@ -1,33 +1,50 @@
1
1
  'use client'
2
2
 
3
- import type { DoorNode } from '@pascal-app/core'
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 { useState } from 'react'
6
+ import { 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'
10
10
  import { TreeNodeActions } from './tree-node-actions'
11
11
 
12
12
  interface DoorTreeNodeProps {
13
- node: DoorNode
13
+ nodeId: AnyNodeId
14
14
  depth: number
15
15
  isLast?: boolean
16
16
  }
17
17
 
18
- export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
18
+ export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
19
19
  const [isEditing, setIsEditing] = useState(false)
20
- const selectedIds = useViewer((state) => state.selection.selectedIds)
21
- const isSelected = selectedIds.includes(node.id)
22
- const isHovered = useViewer((state) => state.hoveredId === node.id)
20
+ const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
21
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
22
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
23
23
  const setSelection = useViewer((state) => state.setSelection)
24
24
  const setHoveredId = useViewer((state) => state.setHoveredId)
25
25
 
26
- const defaultName = 'Door'
26
+ const handleClick = useCallback(
27
+ (e: React.MouseEvent) => {
28
+ e.stopPropagation()
29
+ const handled = handleTreeSelection(
30
+ e,
31
+ nodeId,
32
+ useViewer.getState().selection.selectedIds,
33
+ setSelection,
34
+ )
35
+ if (!handled && useEditor.getState().phase === 'furnish') {
36
+ useEditor.getState().setPhase('structure')
37
+ }
38
+ },
39
+ [nodeId, setSelection],
40
+ )
41
+
42
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
43
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
27
44
 
28
45
  return (
29
46
  <TreeNodeWrapper
30
- actions={<TreeNodeActions node={node} />}
47
+ actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
31
48
  depth={depth}
32
49
  expanded={false}
33
50
  hasChildren={false}
@@ -37,26 +54,20 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
37
54
  isHovered={isHovered}
38
55
  isLast={isLast}
39
56
  isSelected={isSelected}
40
- isVisible={node.visible !== false}
57
+ isVisible={isVisible}
41
58
  label={
42
59
  <InlineRenameInput
43
- defaultName={defaultName}
60
+ defaultName="Door"
44
61
  isEditing={isEditing}
45
- node={node}
46
- onStartEditing={() => setIsEditing(true)}
47
- onStopEditing={() => setIsEditing(false)}
62
+ nodeId={nodeId as AnyNodeId}
63
+ onStartEditing={handleStartEditing}
64
+ onStopEditing={handleStopEditing}
48
65
  />
49
66
  }
50
- nodeId={node.id}
51
- onClick={(e: React.MouseEvent) => {
52
- e.stopPropagation()
53
- const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
54
- if (!handled && useEditor.getState().phase === 'furnish') {
55
- useEditor.getState().setPhase('structure')
56
- }
57
- }}
58
- onDoubleClick={() => focusTreeNode(node.id)}
59
- onMouseEnter={() => setHoveredId(node.id)}
67
+ nodeId={nodeId}
68
+ onClick={handleClick}
69
+ onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
70
+ onMouseEnter={() => setHoveredId(nodeId)}
60
71
  onMouseLeave={() => setHoveredId(null)}
61
72
  onToggle={() => {}}
62
73
  />
@@ -0,0 +1,65 @@
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 { 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 function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
17
+ const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined
18
+ const [isEditing, setIsEditing] = useState(false)
19
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
20
+ const isSelected = selectedIds.includes(nodeId)
21
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
22
+ const setSelection = useViewer((state) => state.setSelection)
23
+ const setHoveredId = useViewer((state) => state.setHoveredId)
24
+
25
+ if (!node) return null
26
+
27
+ const handleClick = (e: React.MouseEvent) => {
28
+ e.stopPropagation()
29
+ const handled = handleTreeSelection(e, nodeId, selectedIds, setSelection)
30
+ if (!handled && useEditor.getState().phase === 'furnish') {
31
+ useEditor.getState().setPhase('structure')
32
+ }
33
+ }
34
+
35
+ return (
36
+ <TreeNodeWrapper
37
+ actions={<TreeNodeActions node={node} />}
38
+ depth={depth}
39
+ expanded={false}
40
+ hasChildren={false}
41
+ icon={
42
+ <Image alt="" className="object-contain" height={14} src="/icons/fence.png" width={14} />
43
+ }
44
+ isHovered={isHovered}
45
+ isLast={isLast}
46
+ isSelected={isSelected}
47
+ isVisible={node.visible !== false}
48
+ label={
49
+ <InlineRenameInput
50
+ defaultName="Fence"
51
+ isEditing={isEditing}
52
+ node={node}
53
+ onStartEditing={() => setIsEditing(true)}
54
+ onStopEditing={() => setIsEditing(false)}
55
+ />
56
+ }
57
+ nodeId={nodeId}
58
+ onClick={handleClick}
59
+ onDoubleClick={() => focusTreeNode(nodeId)}
60
+ onMouseEnter={() => setHoveredId(nodeId)}
61
+ onMouseLeave={() => setHoveredId(null)}
62
+ onToggle={() => {}}
63
+ />
64
+ )
65
+ }
@@ -24,6 +24,7 @@ import {
24
24
  } from 'lucide-react'
25
25
  import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
26
26
  import { 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,
@@ -393,8 +394,15 @@ function LevelReferences({
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 &&
@@ -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,
@@ -1175,7 +1188,6 @@ function MultiSelectionBadge() {
1175
1188
  }
1176
1189
 
1177
1190
  function ContentSection() {
1178
- const nodes = useScene((state) => state.nodes)
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">
@@ -1431,7 +1444,6 @@ export interface SitePanelProps {
1431
1444
  }
1432
1445
 
1433
1446
  export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) {
1434
- const nodes = useScene((state) => state.nodes)
1435
1447
  const rootNodeIds = useScene((state) => state.rootNodeIds)
1436
1448
  const updateNode = useScene((state) => state.updateNode)
1437
1449
  const selectedBuildingId = useViewer((state) => state.selection.buildingId)
@@ -1442,13 +1454,20 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
1442
1454
  const [siteCameraOpen, setSiteCameraOpen] = useState(false)
1443
1455
  const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null)
1444
1456
 
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')
1457
+ const siteNode = useScene((s) =>
1458
+ rootNodeIds[0] ? ((s.nodes[rootNodeIds[0]] as SiteNode | undefined) ?? null) : null,
1459
+ )
1460
+ const buildings = useScene(
1461
+ useShallow((s) => {
1462
+ if (!siteNode) return []
1463
+ return siteNode.children
1464
+ .map((child) => {
1465
+ const id = typeof child === 'string' ? child : child.id
1466
+ return s.nodes[id] as BuildingNode | undefined
1467
+ })
1468
+ .filter((node): node is BuildingNode => node?.type === 'building')
1469
+ }),
1470
+ )
1452
1471
 
1453
1472
  return (
1454
1473
  <LayoutGroup>
@@ -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
+ })