@pascal-app/editor 0.8.0 → 0.9.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
3
3
  import { Layers } from 'lucide-react'
4
4
  import { memo, useCallback, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
6
+ import { getDefaultLevelName } from '../../../../../lib/level-name'
6
7
  import { InlineRenameInput } from './inline-rename-input'
7
8
  import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
8
9
  import { TreeNodeActions } from './tree-node-actions'
@@ -35,7 +36,7 @@ export const LevelTreeNode = memo(function LevelTreeNode({
35
36
  const handleStartEditing = useCallback(() => setIsEditing(true), [])
36
37
  const handleStopEditing = useCallback(() => setIsEditing(false), [])
37
38
 
38
- const defaultName = `Level ${level}`
39
+ const defaultName = getDefaultLevelName(level)
39
40
 
40
41
  return (
41
42
  <TreeNodeWrapper
@@ -0,0 +1,93 @@
1
+ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { memo, useCallback, 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 RegistryTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ /**
17
+ * Generic, leaf tree-node row driven entirely by the kind's
18
+ * `def.presentation` (icon + label). Replaces the per-kind boilerplate
19
+ * components that differed only in their default name and icon — today the
20
+ * roof vents (box / ridge / turbine / cupola / eyebrow). Register a kind in
21
+ * `treeNodeByType` against this component instead of authoring another copy.
22
+ */
23
+ export const RegistryTreeNode = memo(function RegistryTreeNode({
24
+ nodeId,
25
+ depth,
26
+ isLast,
27
+ }: RegistryTreeNodeProps) {
28
+ const [isEditing, setIsEditing] = useState(false)
29
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
30
+ const node = useScene((s) => s.nodes[nodeId])
31
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
32
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
33
+ const setSelection = useViewer((state) => state.setSelection)
34
+ const setHoveredId = useViewer((state) => state.setHoveredId)
35
+
36
+ const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
37
+ const icon = presentation?.icon
38
+ const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
39
+ const defaultName = node?.name || presentation?.label || 'Node'
40
+
41
+ const handleClick = useCallback(
42
+ (e: React.MouseEvent) => {
43
+ e.stopPropagation()
44
+ const handled = handleTreeSelection(
45
+ e,
46
+ nodeId,
47
+ useViewer.getState().selection.selectedIds,
48
+ setSelection,
49
+ )
50
+ if (!handled && useEditor.getState().phase === 'furnish') {
51
+ useEditor.getState().setPhase('structure')
52
+ }
53
+ },
54
+ [nodeId, setSelection],
55
+ )
56
+
57
+ return (
58
+ <TreeNodeWrapper
59
+ actions={<TreeNodeActions nodeId={nodeId} />}
60
+ depth={depth}
61
+ expanded={false}
62
+ hasChildren={false}
63
+ icon={
64
+ <Image
65
+ alt=""
66
+ className="object-contain opacity-60"
67
+ height={14}
68
+ src={iconSrc}
69
+ width={14}
70
+ />
71
+ }
72
+ isHovered={isHovered}
73
+ isLast={isLast}
74
+ isSelected={isSelected}
75
+ isVisible={isVisible}
76
+ label={
77
+ <InlineRenameInput
78
+ defaultName={defaultName}
79
+ isEditing={isEditing}
80
+ nodeId={nodeId}
81
+ onStartEditing={() => setIsEditing(true)}
82
+ onStopEditing={() => setIsEditing(false)}
83
+ />
84
+ }
85
+ nodeId={nodeId}
86
+ onClick={handleClick}
87
+ onDoubleClick={() => focusTreeNode(nodeId)}
88
+ onMouseEnter={() => setHoveredId(nodeId)}
89
+ onMouseLeave={() => setHoveredId(null)}
90
+ onToggle={() => {}}
91
+ />
92
+ )
93
+ })
@@ -6,7 +6,7 @@ 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'
9
- import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
9
+ import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
10
10
  import { TreeNodeActions } from './tree-node-actions'
11
11
  import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
12
12
 
@@ -163,12 +163,36 @@ function RoofSegmentTreeNode({
163
163
  isLast?: boolean
164
164
  }) {
165
165
  const [isEditing, setIsEditing] = useState(false)
166
+ const [expanded, setExpanded] = useState(true)
167
+ const accessoryIds = useScene(
168
+ useShallow(
169
+ (s) => (s.nodes[node.id] as RoofSegmentNode | undefined)?.children ?? ([] as string[]),
170
+ ),
171
+ )
166
172
  const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
167
173
  const isHovered = useViewer((state) => state.hoveredId === node.id)
168
174
  const setSelection = useViewer((state) => state.setSelection)
169
175
  const setHoveredId = useViewer((state) => state.setHoveredId)
170
176
  const { startDrag, isDragging } = useTreeNodeDrag()
171
177
 
178
+ useEffect(() => {
179
+ return useViewer.subscribe((state) => {
180
+ const { selectedIds } = state.selection
181
+ if (selectedIds.length === 0) return
182
+ const nodes = useScene.getState().nodes
183
+ for (const id of selectedIds) {
184
+ let current = nodes[id as AnyNodeId]
185
+ while (current?.parentId) {
186
+ if (current.parentId === node.id) {
187
+ setExpanded(true)
188
+ return
189
+ }
190
+ current = nodes[current.parentId as AnyNodeId]
191
+ }
192
+ }
193
+ })
194
+ }, [node.id])
195
+
172
196
  const handleClick = useCallback(
173
197
  (e: React.MouseEvent) => {
174
198
  if (isDragging) return
@@ -192,13 +216,15 @@ function RoofSegmentTreeNode({
192
216
 
193
217
  const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`
194
218
 
219
+ const hasAccessories = accessoryIds.length > 0
220
+
195
221
  return (
196
222
  <div data-drop-child={node.id}>
197
223
  <TreeNodeWrapper
198
224
  actions={<TreeNodeActions nodeId={node.id} />}
199
225
  depth={depth}
200
- expanded={false}
201
- hasChildren={false}
226
+ expanded={expanded}
227
+ hasChildren={hasAccessories}
202
228
  icon={
203
229
  <Image
204
230
  alt=""
@@ -228,8 +254,18 @@ function RoofSegmentTreeNode({
228
254
  onMouseEnter={() => setHoveredId(node.id)}
229
255
  onMouseLeave={() => setHoveredId(null)}
230
256
  onPointerDown={handlePointerDown}
231
- onToggle={() => {}}
232
- />
257
+ onToggle={() => setExpanded((prev) => !prev)}
258
+ >
259
+ {hasAccessories &&
260
+ accessoryIds.map((childId, index) => (
261
+ <TreeNode
262
+ depth={depth + 1}
263
+ isLast={index === accessoryIds.length - 1}
264
+ key={childId}
265
+ nodeId={childId as AnyNodeId}
266
+ />
267
+ ))}
268
+ </TreeNodeWrapper>
233
269
  </div>
234
270
  )
235
271
  }
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, type ShelfNode, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import Image from 'next/image'
6
+ import { memo, useCallback, useEffect, useState } from 'react'
7
+ import { useShallow } from 'zustand/react/shallow'
8
+ import useEditor from './../../../../../store/use-editor'
9
+ import { InlineRenameInput } from './inline-rename-input'
10
+ import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
11
+ import { TreeNodeActions } from './tree-node-actions'
12
+
13
+ interface ShelfTreeNodeProps {
14
+ nodeId: ShelfNode['id']
15
+ depth: number
16
+ isLast?: boolean
17
+ }
18
+
19
+ /**
20
+ * Sidebar tree entry for shelf. Mirrors `item-tree-node`'s shape so the
21
+ * shelf's hosted items list as collapsible children — same pattern items
22
+ * use for their nested items. The shelf has its own `children: ItemNode[`id`]`
23
+ * field on the schema; items reparent into it via `def.surfaces` + the
24
+ * placement coordinator's shelf strategy.
25
+ */
26
+ export const ShelfTreeNode = memo(function ShelfTreeNode({
27
+ nodeId,
28
+ depth,
29
+ isLast,
30
+ }: ShelfTreeNodeProps) {
31
+ const [isEditing, setIsEditing] = useState(false)
32
+ const [expanded, setExpanded] = useState(true)
33
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
34
+ const children = useScene(
35
+ useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []),
36
+ )
37
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
38
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
39
+ const setSelection = useViewer((state) => state.setSelection)
40
+ const setHoveredId = useViewer((state) => state.setHoveredId)
41
+
42
+ // Expand when a descendant is selected — same imperative subscription
43
+ // the item tree-node uses, so we don't re-render when unrelated
44
+ // selection-state ticks.
45
+ useEffect(() => {
46
+ return useViewer.subscribe((state) => {
47
+ const { selectedIds } = state.selection
48
+ if (selectedIds.length === 0) return
49
+ const nodes = useScene.getState().nodes
50
+ for (const id of selectedIds) {
51
+ let current = nodes[id as AnyNodeId]
52
+ while (current?.parentId) {
53
+ if (current.parentId === nodeId) {
54
+ setExpanded(true)
55
+ return
56
+ }
57
+ current = nodes[current.parentId as AnyNodeId]
58
+ }
59
+ }
60
+ })
61
+ }, [nodeId])
62
+
63
+ const handleClick = useCallback(
64
+ (e: React.MouseEvent) => {
65
+ e.stopPropagation()
66
+ const handled = handleTreeSelection(
67
+ e,
68
+ nodeId,
69
+ useViewer.getState().selection.selectedIds,
70
+ setSelection,
71
+ )
72
+ if (!handled && useEditor.getState().phase === 'furnish') {
73
+ useEditor.getState().setPhase('structure')
74
+ }
75
+ },
76
+ [nodeId, setSelection],
77
+ )
78
+
79
+ const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
80
+ const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId])
81
+ const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId])
82
+ const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
83
+ const handleStartEditing = useCallback(() => setIsEditing(true), [])
84
+ const handleStopEditing = useCallback(() => setIsEditing(false), [])
85
+
86
+ const hasChildren = children.length > 0
87
+
88
+ return (
89
+ <TreeNodeWrapper
90
+ actions={<TreeNodeActions nodeId={nodeId} />}
91
+ depth={depth}
92
+ expanded={expanded}
93
+ hasChildren={hasChildren}
94
+ icon={
95
+ <Image alt="" className="object-contain" height={14} src="/icons/shelf.png" width={14} />
96
+ }
97
+ isHovered={isHovered}
98
+ isLast={isLast}
99
+ isSelected={isSelected}
100
+ isVisible={isVisible}
101
+ label={
102
+ <InlineRenameInput
103
+ defaultName="Shelf"
104
+ isEditing={isEditing}
105
+ nodeId={nodeId}
106
+ onStartEditing={handleStartEditing}
107
+ onStopEditing={handleStopEditing}
108
+ />
109
+ }
110
+ nodeId={nodeId}
111
+ onClick={handleClick}
112
+ onDoubleClick={handleDoubleClick}
113
+ onMouseEnter={handleMouseEnter}
114
+ onMouseLeave={handleMouseLeave}
115
+ onToggle={handleToggle}
116
+ >
117
+ {hasChildren &&
118
+ children.map((childId, index) => (
119
+ <TreeNode
120
+ depth={depth + 1}
121
+ isLast={index === children.length - 1}
122
+ key={childId}
123
+ nodeId={childId}
124
+ />
125
+ ))}
126
+ </TreeNodeWrapper>
127
+ )
128
+ })
@@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
91
91
 
92
92
  for (let i = 0; i < n; i++) {
93
93
  const j = (i + 1) % n
94
- area += polygon[i]?.[0] * polygon[j]?.[1]
95
- area -= polygon[j]?.[0] * polygon[i]?.[1]
94
+ const pi = polygon[i]
95
+ const pj = polygon[j]
96
+ if (!(pi && pj)) continue
97
+ area += pi[0] * pj[1]
98
+ area -= pj[0] * pi[1]
96
99
  }
97
100
 
98
101
  return Math.abs(area) / 2
@@ -0,0 +1,83 @@
1
+ import { type AnyNodeId, type SolarPanelNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { memo, useCallback, 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 SolarPanelTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: SolarPanelTreeNodeProps) {
21
+ const [isEditing, setIsEditing] = useState(false)
22
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
23
+ const node = useScene((s) => s.nodes[nodeId] as SolarPanelNode | undefined)
24
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
25
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
26
+ const setSelection = useViewer((state) => state.setSelection)
27
+ const setHoveredId = useViewer((state) => state.setHoveredId)
28
+
29
+ const handleClick = useCallback(
30
+ (e: React.MouseEvent) => {
31
+ e.stopPropagation()
32
+ const handled = handleTreeSelection(
33
+ e,
34
+ nodeId,
35
+ useViewer.getState().selection.selectedIds,
36
+ setSelection,
37
+ )
38
+ if (!handled && useEditor.getState().phase === 'furnish') {
39
+ useEditor.getState().setPhase('structure')
40
+ }
41
+ },
42
+ [nodeId, setSelection],
43
+ )
44
+
45
+ const defaultName = node?.name || 'Solar Panel'
46
+
47
+ return (
48
+ <TreeNodeWrapper
49
+ actions={<TreeNodeActions nodeId={nodeId} />}
50
+ depth={depth}
51
+ expanded={false}
52
+ hasChildren={false}
53
+ icon={
54
+ <Image
55
+ alt=""
56
+ className="object-contain opacity-60"
57
+ height={14}
58
+ src="/icons/roof.png"
59
+ width={14}
60
+ />
61
+ }
62
+ isHovered={isHovered}
63
+ isLast={isLast}
64
+ isSelected={isSelected}
65
+ isVisible={isVisible}
66
+ label={
67
+ <InlineRenameInput
68
+ defaultName={defaultName}
69
+ isEditing={isEditing}
70
+ nodeId={nodeId}
71
+ onStartEditing={() => setIsEditing(true)}
72
+ onStopEditing={() => setIsEditing(false)}
73
+ />
74
+ }
75
+ nodeId={nodeId}
76
+ onClick={handleClick}
77
+ onDoubleClick={() => focusTreeNode(nodeId)}
78
+ onMouseEnter={() => setHoveredId(nodeId)}
79
+ onMouseLeave={() => setHoveredId(null)}
80
+ onToggle={() => {}}
81
+ />
82
+ )
83
+ })
@@ -50,7 +50,13 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
50
50
  expanded={false}
51
51
  hasChildren={false}
52
52
  icon={
53
- <Image alt="" className="object-contain" height={14} src="/icons/site.png" width={14} />
53
+ <Image
54
+ alt=""
55
+ className="object-contain"
56
+ height={14}
57
+ src="/icons/spawn-point.png"
58
+ width={14}
59
+ />
54
60
  }
55
61
  isHovered={isHovered}
56
62
  isLast={isLast}
@@ -56,13 +56,20 @@ export function focusTreeNode(nodeId: AnyNodeId) {
56
56
  import { cn } from '../../../../../lib/utils'
57
57
  import { BuildingTreeNode } from './building-tree-node'
58
58
  import { CeilingTreeNode } from './ceiling-tree-node'
59
+ import { ChimneyTreeNode } from './chimney-tree-node'
59
60
  import { ColumnTreeNode } from './column-tree-node'
60
61
  import { DoorTreeNode } from './door-tree-node'
62
+ import { DormerTreeNode } from './dormer-tree-node'
63
+ import { ElevatorTreeNode } from './elevator-tree-node'
61
64
  import { FenceTreeNode } from './fence-tree-node'
65
+ import { GutterTreeNode } from './gutter-tree-node'
62
66
  import { ItemTreeNode } from './item-tree-node'
63
67
  import { LevelTreeNode } from './level-tree-node'
68
+ import { RegistryTreeNode } from './registry-tree-node'
64
69
  import { RoofTreeNode } from './roof-tree-node'
70
+ import { ShelfTreeNode } from './shelf-tree-node'
65
71
  import { SlabTreeNode } from './slab-tree-node'
72
+ import { SolarPanelTreeNode } from './solar-panel-tree-node'
66
73
  import { SpawnTreeNode } from './spawn-tree-node'
67
74
  import { StairTreeNode } from './stair-tree-node'
68
75
  import { WallTreeNode } from './wall-tree-node'
@@ -75,45 +82,70 @@ interface TreeNodeProps {
75
82
  isLast?: boolean
76
83
  }
77
84
 
85
+ // Per-kind tree-node components keyed by `node.type`. Lookup replaces
86
+ // the legacy switch — adding a kind to this map is now the only edit
87
+ // needed in this file (the switch's `case '<kind>':` clauses were
88
+ // flagged by the Phase 6 grep gate as the last per-kind dispatch
89
+ // outside the registry; future work moves these to a
90
+ // `def.presentation`-driven generic tree-node and removes this map
91
+ // entirely).
92
+ const treeNodeByType: Record<
93
+ string,
94
+ React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
95
+ > = {
96
+ building: BuildingTreeNode as React.ComponentType<{
97
+ depth: number
98
+ isLast?: boolean
99
+ nodeId: AnyNodeId
100
+ }>,
101
+ 'box-vent': RegistryTreeNode,
102
+ ceiling: CeilingTreeNode,
103
+ chimney: ChimneyTreeNode,
104
+ dormer: DormerTreeNode,
105
+ 'solar-panel': SolarPanelTreeNode,
106
+ column: ColumnTreeNode,
107
+ elevator: ElevatorTreeNode,
108
+ level: LevelTreeNode as React.ComponentType<{
109
+ depth: number
110
+ isLast?: boolean
111
+ nodeId: AnyNodeId
112
+ }>,
113
+ shelf: ShelfTreeNode as React.ComponentType<{
114
+ depth: number
115
+ isLast?: boolean
116
+ nodeId: AnyNodeId
117
+ }>,
118
+ slab: SlabTreeNode,
119
+ spawn: SpawnTreeNode as React.ComponentType<{
120
+ depth: number
121
+ isLast?: boolean
122
+ nodeId: AnyNodeId
123
+ }>,
124
+ wall: WallTreeNode,
125
+ fence: FenceTreeNode,
126
+ gutter: GutterTreeNode,
127
+ 'ridge-vent': RegistryTreeNode,
128
+ 'turbine-vent': RegistryTreeNode,
129
+ cupola: RegistryTreeNode,
130
+ 'eyebrow-vent': RegistryTreeNode,
131
+ roof: RoofTreeNode,
132
+ stair: StairTreeNode,
133
+ door: DoorTreeNode,
134
+ window: WindowTreeNode,
135
+ zone: ZoneTreeNode as React.ComponentType<{
136
+ depth: number
137
+ isLast?: boolean
138
+ nodeId: AnyNodeId
139
+ }>,
140
+ item: ItemTreeNode,
141
+ }
142
+
78
143
  export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
79
144
  const nodeType = useScene((state) => state.nodes[nodeId]?.type)
80
-
81
145
  if (!nodeType) return null
82
-
83
- switch (nodeType) {
84
- case 'building':
85
- return (
86
- <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
87
- )
88
- case 'ceiling':
89
- return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
90
- case 'column':
91
- return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
92
- case 'level':
93
- return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
94
- case 'slab':
95
- return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
96
- case 'spawn':
97
- return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
98
- case 'wall':
99
- return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
100
- case 'fence':
101
- return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
102
- case 'roof':
103
- return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
104
- case 'stair':
105
- return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
106
- case 'item':
107
- return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
108
- case 'door':
109
- return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
110
- case 'window':
111
- return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
112
- case 'zone':
113
- return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
114
- default:
115
- return null
116
- }
146
+ const Component = treeNodeByType[nodeType]
147
+ if (!Component) return null
148
+ return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
117
149
  })
118
150
 
119
151
  interface TreeNodeWrapperProps {
@@ -1,7 +1,14 @@
1
- import { emitter, useScene, type ZoneNode } from '@pascal-app/core'
1
+ import {
2
+ type AnyNodeId,
3
+ emitter,
4
+ useScene,
5
+ type ZoneNode,
6
+ } from '@pascal-app/core'
2
7
  import { useViewer } from '@pascal-app/viewer'
3
- import { Camera, Hexagon, Trash2 } from 'lucide-react'
8
+ import { Camera, Hexagon, Save, Trash2 } from 'lucide-react'
4
9
  import { useState } from 'react'
10
+ import { sfxEmitter } from './../../../../../lib/sfx-bus'
11
+ import { collectZoneContentIds } from './../../../../../lib/zone-content'
5
12
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
6
13
  import {
7
14
  Popover,
@@ -10,6 +17,8 @@ import {
10
17
  } from './../../../../../components/ui/primitives/popover'
11
18
  import { cn } from './../../../../../lib/utils'
12
19
  import useEditor from './../../../../../store/use-editor'
20
+ import { ActionButton } from '../../../controls/action-button'
21
+ import { PanelSection } from '../../../controls/panel-section'
13
22
 
14
23
  function ZoneItem({ zone }: { zone: ZoneNode }) {
15
24
  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
@@ -26,6 +35,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
26
35
 
27
36
  const handleDelete = (e: React.MouseEvent) => {
28
37
  e.stopPropagation()
38
+ sfxEmitter.emit('sfx:structure-delete')
29
39
  deleteNode(zone.id)
30
40
  if (isSelected) {
31
41
  setSelection({ zoneId: null })
@@ -125,6 +135,8 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
125
135
  export function ZonePanel() {
126
136
  const nodes = useScene((state) => state.nodes)
127
137
  const currentLevelId = useViewer((state) => state.selection.levelId)
138
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
139
+ const setSelection = useViewer((state) => state.setSelection)
128
140
  const setPhase = useEditor((state) => state.setPhase)
129
141
  const setMode = useEditor((state) => state.setMode)
130
142
  const setTool = useEditor((state) => state.setTool)
@@ -133,6 +145,7 @@ export function ZonePanel() {
133
145
  const levelZones = Object.values(nodes).filter(
134
146
  (node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
135
147
  )
148
+ const selectedZone = levelZones.find((zone) => zone.id === selectedZoneId)
136
149
 
137
150
  const handleAddZone = () => {
138
151
  if (currentLevelId) {
@@ -142,6 +155,17 @@ export function ZonePanel() {
142
155
  }
143
156
  }
144
157
 
158
+ const deleteSelectedZone = (withContent: boolean) => {
159
+ if (!selectedZone) return
160
+ const scene = useScene.getState()
161
+ const ids = withContent
162
+ ? [selectedZone.id as AnyNodeId, ...collectZoneContentIds(scene.nodes, selectedZone)]
163
+ : [selectedZone.id as AnyNodeId]
164
+ sfxEmitter.emit('sfx:structure-delete')
165
+ scene.deleteNodes(Array.from(new Set(ids)))
166
+ setSelection({ selectedIds: [], zoneId: null })
167
+ }
168
+
145
169
  if (!currentLevelId) {
146
170
  return (
147
171
  <div className="px-3 py-4 text-muted-foreground text-sm">
@@ -162,6 +186,31 @@ export function ZonePanel() {
162
186
  ) : (
163
187
  levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
164
188
  )}
189
+ {selectedZone ? (
190
+ <PanelSection className="mt-2 border-t" title="Actions">
191
+ <ActionButton
192
+ className="w-full flex-none"
193
+ icon={<Save className="h-4 w-4" />}
194
+ label="Save to catalog"
195
+ onClick={() => emitter.emit('room-preset:create', { zoneId: selectedZone.id })}
196
+ type="button"
197
+ />
198
+ <ActionButton
199
+ className="w-full flex-none"
200
+ icon={<Trash2 className="h-4 w-4 text-red-400" />}
201
+ label="Delete"
202
+ onClick={() => deleteSelectedZone(false)}
203
+ type="button"
204
+ />
205
+ <ActionButton
206
+ className="w-full flex-none"
207
+ icon={<Trash2 className="h-4 w-4 text-red-400" />}
208
+ label="Delete with contents"
209
+ onClick={() => deleteSelectedZone(true)}
210
+ type="button"
211
+ />
212
+ </PanelSection>
213
+ ) : null}
165
214
  </div>
166
215
  )
167
216
  }