@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
@@ -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,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
- import { useState } from 'react'
4
+ import { memo, useState } from 'react'
5
+ import { useShallow } from 'zustand/react/shallow'
5
6
  import {
6
7
  Tooltip,
7
8
  TooltipContent,
@@ -11,37 +12,46 @@ 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 const BuildingTreeNode = memo(function BuildingTreeNode({
21
+ nodeId,
22
+ depth,
23
+ isLast,
24
+ }: BuildingTreeNodeProps) {
20
25
  const [expanded, setExpanded] = useState(true)
21
26
  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)
27
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
28
+ const name = useScene((s) => s.nodes[nodeId]?.name)
29
+ const children = useScene(
30
+ useShallow((s) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []),
31
+ )
32
+ const isSelected = useViewer((state) => state.selection.buildingId === nodeId)
33
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
24
34
  const setSelection = useViewer((state) => state.setSelection)
25
35
 
26
36
  const handleClick = () => {
27
- setSelection({ buildingId: node.id })
37
+ setSelection({ buildingId: nodeId })
28
38
  }
29
39
 
30
40
  const handleAddLevel = (e: React.MouseEvent) => {
31
41
  e.stopPropagation()
32
42
  const newLevel = LevelNode.parse({
33
- level: node.children.length,
43
+ level: children.length,
34
44
  children: [],
35
- parentId: node.id,
45
+ parentId: nodeId,
36
46
  })
37
- createNode(newLevel, node.id)
47
+ createNode(newLevel, nodeId)
38
48
  }
39
49
 
40
50
  return (
41
51
  <TreeNodeWrapper
42
52
  actions={
43
53
  <div className="flex items-center gap-0.5">
44
- <TreeNodeActions node={node} />
54
+ <TreeNodeActions nodeId={nodeId} />
45
55
  <Tooltip>
46
56
  <TooltipTrigger asChild>
47
57
  <button
@@ -57,24 +67,25 @@ export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps)
57
67
  }
58
68
  depth={depth}
59
69
  expanded={expanded}
60
- hasChildren={node.children.length > 0}
70
+ hasChildren={children.length > 0}
61
71
  icon={<Building2 className="h-3.5 w-3.5" />}
62
72
  isHovered={isHovered}
63
73
  isLast={isLast}
64
74
  isSelected={isSelected}
65
- label={node.name || 'Building'}
75
+ isVisible={isVisible}
76
+ label={name || 'Building'}
66
77
  onClick={handleClick}
67
- onDoubleClick={() => focusTreeNode(node.id)}
78
+ onDoubleClick={() => focusTreeNode(nodeId)}
68
79
  onToggle={() => setExpanded(!expanded)}
69
80
  >
70
- {node.children.map((childId, index) => (
81
+ {children.map((childId, index) => (
71
82
  <TreeNode
72
83
  depth={depth + 1}
73
- isLast={index === node.children.length - 1}
84
+ isLast={index === children.length - 1}
74
85
  key={childId}
75
86
  nodeId={childId}
76
87
  />
77
88
  ))}
78
89
  </TreeNodeWrapper>
79
90
  )
80
- }
91
+ })
@@ -1,111 +1,123 @@
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 { memo, 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 const CeilingTreeNode = memo(function CeilingTreeNode({
18
+ nodeId,
19
+ depth,
20
+ isLast,
21
+ }: CeilingTreeNodeProps) {
17
22
  const [expanded, setExpanded] = useState(false)
18
23
  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)
24
+ const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
25
+ const children = useScene(
26
+ useShallow((s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.children ?? []),
27
+ )
28
+ const polygon = useScene(
29
+ (s) => (s.nodes[nodeId as AnyNodeId] as CeilingNode | undefined)?.polygon ?? [],
30
+ )
31
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
32
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
22
33
  const setSelection = useViewer((state) => state.setSelection)
23
34
  const setHoveredId = useViewer((state) => state.setHoveredId)
24
35
 
36
+ // Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
25
37
  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
38
+ return useViewer.subscribe((state) => {
39
+ const { selectedIds } = state.selection
40
+ if (selectedIds.length === 0) return
41
+ const nodes = useScene.getState().nodes
42
+ for (const id of selectedIds) {
43
+ let current = nodes[id as AnyNodeId]
44
+ while (current?.parentId) {
45
+ if (current.parentId === nodeId) {
46
+ setExpanded(true)
47
+ return
48
+ }
49
+ current = nodes[current.parentId as AnyNodeId]
35
50
  }
36
- current = nodes[current.parentId as AnyNodeId]
37
51
  }
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
- }
52
+ })
53
+ }, [nodeId])
56
54
 
57
- const handleMouseEnter = () => {
58
- setHoveredId(node.id)
59
- }
55
+ const handleClick = useCallback(
56
+ (e: React.MouseEvent) => {
57
+ e.stopPropagation()
58
+ const handled = handleTreeSelection(
59
+ e,
60
+ nodeId,
61
+ useViewer.getState().selection.selectedIds,
62
+ setSelection,
63
+ )
64
+ if (!handled && useEditor.getState().phase === 'furnish') {
65
+ useEditor.getState().setPhase('structure')
66
+ }
67
+ },
68
+ [nodeId, setSelection],
69
+ )
60
70
 
61
- const handleMouseLeave = () => {
62
- setHoveredId(null)
63
- }
71
+ const handleDoubleClick = useCallback(() => focusTreeNode(nodeId as AnyNodeId), [nodeId])
72
+ const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
73
+ const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
74
+ const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
75
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
76
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
64
77
 
65
- // Calculate approximate area from polygon
66
- const area = calculatePolygonArea(node.polygon).toFixed(1)
78
+ const area = calculatePolygonArea(polygon).toFixed(1)
67
79
  const defaultName = `Ceiling (${area}m²)`
68
80
 
69
81
  return (
70
82
  <TreeNodeWrapper
71
- actions={<TreeNodeActions node={node} />}
83
+ actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
72
84
  depth={depth}
73
85
  expanded={expanded}
74
- hasChildren={node.children.length > 0}
86
+ hasChildren={children.length > 0}
75
87
  icon={
76
88
  <Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
77
89
  }
78
90
  isHovered={isHovered}
79
91
  isLast={isLast}
80
92
  isSelected={isSelected}
81
- isVisible={node.visible !== false}
93
+ isVisible={isVisible}
82
94
  label={
83
95
  <InlineRenameInput
84
96
  defaultName={defaultName}
85
97
  isEditing={isEditing}
86
- node={node}
87
- onStartEditing={() => setIsEditing(true)}
88
- onStopEditing={() => setIsEditing(false)}
98
+ nodeId={nodeId as AnyNodeId}
99
+ onStartEditing={handleStartEditing}
100
+ onStopEditing={handleStopEditing}
89
101
  />
90
102
  }
91
- nodeId={node.id}
103
+ nodeId={nodeId}
92
104
  onClick={handleClick}
93
105
  onDoubleClick={handleDoubleClick}
94
106
  onMouseEnter={handleMouseEnter}
95
107
  onMouseLeave={handleMouseLeave}
96
- onToggle={() => setExpanded(!expanded)}
108
+ onToggle={handleToggle}
97
109
  >
98
- {node.children.map((childId, index) => (
110
+ {children.map((childId, index) => (
99
111
  <TreeNode
100
112
  depth={depth + 1}
101
- isLast={index === node.children.length - 1}
113
+ isLast={index === children.length - 1}
102
114
  key={childId}
103
115
  nodeId={childId}
104
116
  />
105
117
  ))}
106
118
  </TreeNodeWrapper>
107
119
  )
108
- }
120
+ })
109
121
 
110
122
  /**
111
123
  * Calculate the area of a polygon using the shoelace formula
@@ -1,33 +1,54 @@
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 { 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'
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 const DoorTreeNode = memo(function DoorTreeNode({
19
+ nodeId,
20
+ depth,
21
+ isLast,
22
+ }: DoorTreeNodeProps) {
19
23
  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)
24
+ const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
25
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
26
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
23
27
  const setSelection = useViewer((state) => state.setSelection)
24
28
  const setHoveredId = useViewer((state) => state.setHoveredId)
25
29
 
26
- const defaultName = 'Door'
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
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
47
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
27
48
 
28
49
  return (
29
50
  <TreeNodeWrapper
30
- actions={<TreeNodeActions node={node} />}
51
+ actions={<TreeNodeActions nodeId={nodeId as AnyNodeId} />}
31
52
  depth={depth}
32
53
  expanded={false}
33
54
  hasChildren={false}
@@ -37,28 +58,22 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
37
58
  isHovered={isHovered}
38
59
  isLast={isLast}
39
60
  isSelected={isSelected}
40
- isVisible={node.visible !== false}
61
+ isVisible={isVisible}
41
62
  label={
42
63
  <InlineRenameInput
43
- defaultName={defaultName}
64
+ defaultName="Door"
44
65
  isEditing={isEditing}
45
- node={node}
46
- onStartEditing={() => setIsEditing(true)}
47
- onStopEditing={() => setIsEditing(false)}
66
+ nodeId={nodeId as AnyNodeId}
67
+ onStartEditing={handleStartEditing}
68
+ onStopEditing={handleStopEditing}
48
69
  />
49
70
  }
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)}
71
+ nodeId={nodeId}
72
+ onClick={handleClick}
73
+ onDoubleClick={() => focusTreeNode(nodeId as AnyNodeId)}
74
+ onMouseEnter={() => setHoveredId(nodeId)}
60
75
  onMouseLeave={() => setHoveredId(null)}
61
76
  onToggle={() => {}}
62
77
  />
63
78
  )
64
- }
79
+ })