@pascal-app/editor 1.0.0-beta.5 → 1.0.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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -11,11 +11,15 @@ import {
11
11
  useScene,
12
12
  type ZoneNode,
13
13
  } from '@pascal-app/core'
14
- import { useViewer } from '@pascal-app/viewer'
14
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
15
15
  import {
16
16
  Camera,
17
17
  ChevronDown,
18
+ ChevronRight,
18
19
  Copy,
20
+ Eye,
21
+ EyeOff,
22
+ Group,
19
23
  Loader2,
20
24
  MoreHorizontal,
21
25
  Pencil,
@@ -25,7 +29,7 @@ import {
25
29
  X,
26
30
  } from 'lucide-react'
27
31
  import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
28
- import { memo, useEffect, useRef, useState } from 'react'
32
+ import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react'
29
33
  import { useShallow } from 'zustand/react/shallow'
30
34
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
31
35
  import {
@@ -47,14 +51,17 @@ import {
47
51
  metersToLinearUnit,
48
52
  squareMetersToAreaUnit,
49
53
  } from './../../../../../lib/measurements'
50
- import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
54
+ import { createLocalGuideImage, createLocalScan } from './../../../../../lib/local-guide-image'
55
+ import { editorHostTreeChildrenRegistry } from './../../../../../lib/host-tree-children'
56
+ import { createUnitInBuilding, toggleZoneMembership } from './../../../../../lib/units'
51
57
  import { cn } from './../../../../../lib/utils'
52
58
  import useEditor from './../../../../../store/use-editor'
53
59
  import { useUploadStore } from '../../../../../store/use-upload'
54
60
  import { MetricControl } from '../../../controls/metric-control'
55
61
  import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
56
62
  import { InlineRenameInput } from './inline-rename-input'
57
- import { focusTreeNode, TreeNode } from './tree-node'
63
+ import { ZoneMembershipCheckbox } from './zone-membership-checkbox'
64
+ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
58
65
  import { TreeNodeDragProvider } from './tree-node-drag'
59
66
 
60
67
  // ============================================================================
@@ -350,8 +357,25 @@ const ReferenceItem = memo(function ReferenceItem({
350
357
  handleDelete: (id: string, e: React.MouseEvent) => void
351
358
  }) {
352
359
  const [isEditing, setIsEditing] = useState(false)
360
+ const [isExpanded, setIsExpanded] = useState(true)
361
+ const updateNode = useScene((state) => state.updateNode)
362
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
363
+ const isCapture = refNode.type === 'scan'
364
+ const isVisible = refNode.visible !== false
365
+ useSyncExternalStore(
366
+ editorHostTreeChildrenRegistry.subscribe,
367
+ editorHostTreeChildrenRegistry.getSnapshot,
368
+ editorHostTreeChildrenRegistry.getSnapshot,
369
+ )
370
+ const hostChildren = isCapture
371
+ ? editorHostTreeChildrenRegistry.childrenForKind(refNode.type)
372
+ : undefined
373
+ const hasHostChildren = Boolean(hostChildren?.hasChildren(refNode))
374
+ const HostChildren = hasHostChildren ? hostChildren?.component : undefined
375
+
353
376
  const handleSelect = () => {
354
377
  setSelectedReferenceId(refNode.id)
378
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
355
379
  }
356
380
 
357
381
  const handleDoubleClick = () => {
@@ -359,53 +383,98 @@ const ReferenceItem = memo(function ReferenceItem({
359
383
  }
360
384
 
361
385
  return (
362
- <div
363
- className="group/ref relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30"
364
- onClick={handleSelect}
365
- onDoubleClick={handleDoubleClick}
366
- >
386
+ <div className="relative flex flex-col">
367
387
  <div
368
388
  className={cn(
369
- 'pointer-events-none absolute z-10 w-px bg-border/50',
370
- isLastRow ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
389
+ 'group/ref relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30',
390
+ selectedReferenceId === refNode.id && 'bg-accent/50 text-foreground',
391
+ !isVisible && 'opacity-50',
371
392
  )}
372
- style={{ left: 45 }}
373
- />
374
- <div
375
- className="pointer-events-none absolute top-1/2 z-10 h-px bg-border/50"
376
- style={{ left: 45, width: 8 }}
377
- />
393
+ onClick={handleSelect}
394
+ onDoubleClick={handleDoubleClick}
395
+ >
396
+ <div
397
+ className={cn(
398
+ 'pointer-events-none absolute z-10 w-px bg-border/50',
399
+ isLastRow && !(hasHostChildren && isExpanded) ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
400
+ )}
401
+ style={{ left: 45 }}
402
+ />
403
+ <div
404
+ className="pointer-events-none absolute top-1/2 z-10 h-px bg-border/50"
405
+ style={{ left: 45, width: 8 }}
406
+ />
378
407
 
379
- <div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-[60px] text-muted-foreground group-hover/ref:text-foreground">
380
- {refNode.type === 'scan' ? (
381
- <img
382
- alt="Scan"
383
- className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
384
- src="/icons/mesh.webp"
385
- />
386
- ) : (
387
- <img
388
- alt="Guide"
389
- className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
390
- src="/icons/floorplan.webp"
408
+ {isCapture ? (
409
+ <button
410
+ className="z-20 ml-[52px] flex h-4 w-4 shrink-0 items-center justify-center"
411
+ onClick={(event) => {
412
+ event.stopPropagation()
413
+ if (hasHostChildren) setIsExpanded((expanded) => !expanded)
414
+ }}
415
+ type="button"
416
+ >
417
+ {hasHostChildren ? (
418
+ <ChevronRight
419
+ className={cn('h-3 w-3 transition-transform', isExpanded && 'rotate-90')}
420
+ />
421
+ ) : null}
422
+ </button>
423
+ ) : null}
424
+
425
+ <div
426
+ className={cn(
427
+ 'flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 text-muted-foreground group-hover/ref:text-foreground',
428
+ !isCapture && 'pl-[60px]',
429
+ )}
430
+ >
431
+ {isCapture ? (
432
+ <img
433
+ alt="Capture"
434
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
435
+ src="/icons/mesh.webp"
436
+ />
437
+ ) : (
438
+ <img
439
+ alt="Guide"
440
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
441
+ src="/icons/floorplan.webp"
442
+ />
443
+ )}
444
+ <InlineRenameInput
445
+ defaultName={isCapture ? 'Capture' : 'Guide Image'}
446
+ isEditing={isEditing}
447
+ nodeId={refNode.id}
448
+ onStartEditing={() => setIsEditing(true)}
449
+ onStopEditing={() => setIsEditing(false)}
391
450
  />
392
- )}
393
- <InlineRenameInput
394
- defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
395
- isEditing={isEditing}
396
- nodeId={refNode.id}
397
- onStartEditing={() => setIsEditing(true)}
398
- onStopEditing={() => setIsEditing(false)}
399
- />
400
- </div>
451
+ </div>
401
452
 
402
- <button
403
- className="z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10"
404
- onClick={(e) => handleDelete(refNode.id, e)}
405
- title="Delete"
406
- >
407
- <Trash2 className="h-3 w-3" />
408
- </button>
453
+ {isCapture ? (
454
+ <button
455
+ className="z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10"
456
+ onClick={(event) => {
457
+ event.stopPropagation()
458
+ updateNode(refNode.id, { visible: !isVisible })
459
+ }}
460
+ title={isVisible ? 'Hide' : 'Show'}
461
+ type="button"
462
+ >
463
+ {isVisible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
464
+ </button>
465
+ ) : null}
466
+ <button
467
+ className="z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10"
468
+ onClick={(e) => handleDelete(refNode.id, e)}
469
+ title="Delete"
470
+ type="button"
471
+ >
472
+ <Trash2 className="h-3 w-3" />
473
+ </button>
474
+ </div>
475
+ {isExpanded && HostChildren ? (
476
+ <HostChildren depth={3} nodeId={refNode.id} parentVisible={isVisible} />
477
+ ) : null}
409
478
  </div>
410
479
  )
411
480
  })
@@ -431,6 +500,7 @@ const LevelReferences = memo(function LevelReferences({
431
500
  const deleteNode = useScene((s) => s.deleteNode)
432
501
  const setSelection = useViewer((s) => s.setSelection)
433
502
  const setShowGuides = useViewer((s) => s.setShowGuides)
503
+ const setShowScans = useViewer((s) => s.setShowScans)
434
504
  const references = useScene(
435
505
  useShallow((s) =>
436
506
  Object.values(s.nodes).filter(
@@ -500,6 +570,23 @@ const LevelReferences = memo(function LevelReferences({
500
570
  return
501
571
  }
502
572
 
573
+ if (!onUploadAsset) {
574
+ useUploadStore.getState().startUpload(levelId, 'scan', file.name)
575
+ useUploadStore.getState().setStatus(levelId, 'uploading')
576
+
577
+ try {
578
+ const { scan, url } = await createLocalScan({ createNode, file, levelId })
579
+ setShowScans(true)
580
+ setSelectedReferenceId(scan.id)
581
+ setSelection({ selectedIds: [], zoneId: null })
582
+ useUploadStore.getState().setResult(levelId, url)
583
+ window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600)
584
+ } catch {
585
+ useUploadStore.getState().setError(levelId, 'Could not add that scan.')
586
+ }
587
+ return
588
+ }
589
+
503
590
  if (!projectId) {
504
591
  useUploadStore.getState().startUpload(levelId, 'scan', file.name)
505
592
  useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
@@ -507,7 +594,7 @@ const LevelReferences = memo(function LevelReferences({
507
594
  }
508
595
 
509
596
  clearUpload(levelId)
510
- onUploadAsset?.(projectId, levelId, file, type)
597
+ onUploadAsset(projectId, levelId, file, type)
511
598
  }
512
599
 
513
600
  const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
@@ -643,7 +730,8 @@ const LevelItem = memo(function LevelItem({
643
730
  ? (level.parentId as BuildingNode['id'])
644
731
  : undefined
645
732
 
646
- const selectLevel = (levelId: LevelNode['id']) => {
733
+ const selectLevel = (levelId: LevelNode['id'], measure = true) => {
734
+ if (measure && selectedLevelId !== levelId) markPerfAction('level-switch', levelId)
647
735
  setSelection(buildingId ? { buildingId, levelId } : { levelId })
648
736
  }
649
737
 
@@ -682,7 +770,7 @@ const LevelItem = memo(function LevelItem({
682
770
  )
683
771
  }
684
772
  createNodes(createOps)
685
- selectLevel(newLevelId as LevelNode['id'])
773
+ selectLevel(newLevelId as LevelNode['id'], false)
686
774
  setDuplicateDialogOpen(false)
687
775
  }
688
776
 
@@ -882,7 +970,7 @@ const LevelItem = memo(function LevelItem({
882
970
  precision={2}
883
971
  step={0.05}
884
972
  unit="m"
885
- value={Math.round((level.baseElevation ?? 0) * 100) / 100}
973
+ value={(level.baseElevation ?? 0)}
886
974
  />
887
975
  </div>
888
976
  <LevelReferences
@@ -993,6 +1081,74 @@ const LevelsSection = memo(function LevelsSection({
993
1081
  )
994
1082
  })
995
1083
 
1084
+ const UnitsSection = memo(function UnitsSection({
1085
+ buildingId,
1086
+ }: {
1087
+ buildingId: BuildingNode['id']
1088
+ }) {
1089
+ const unitIds = useScene(
1090
+ useShallow((s) => {
1091
+ const building = s.nodes[buildingId] as BuildingNode | undefined
1092
+ return (building?.children ?? []).filter((id) => s.nodes[id]?.type === 'unit')
1093
+ }),
1094
+ )
1095
+ const hasUnits = unitIds.length > 0
1096
+ const [expanded, setExpanded] = useState(hasUnits)
1097
+
1098
+ useEffect(() => {
1099
+ if (hasUnits) setExpanded(true)
1100
+ }, [hasUnits])
1101
+
1102
+ const handleNewUnit = (event: React.MouseEvent) => {
1103
+ event.stopPropagation()
1104
+ createUnitInBuilding(buildingId)
1105
+ setExpanded(true)
1106
+ }
1107
+
1108
+ return (
1109
+ <div className="subtle-scrollbar max-h-72 shrink-0 overflow-y-auto overflow-x-hidden">
1110
+ <TreeNodeWrapper
1111
+ actions={
1112
+ <button
1113
+ className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
1114
+ onClick={handleNewUnit}
1115
+ title="New unit"
1116
+ type="button"
1117
+ >
1118
+ <Plus className="h-3 w-3" />
1119
+ </button>
1120
+ }
1121
+ depth={1}
1122
+ expanded={expanded}
1123
+ hasChildren
1124
+ icon={<Group className="h-3.5 w-3.5" />}
1125
+ label={
1126
+ <span className="flex items-center gap-1.5">
1127
+ Units
1128
+ {hasUnits && <span className="text-muted-foreground text-xs">{unitIds.length}</span>}
1129
+ </span>
1130
+ }
1131
+ onClick={() => setExpanded((value) => !value)}
1132
+ onToggle={() => setExpanded((value) => !value)}
1133
+ >
1134
+ {unitIds.map((unitId) => (
1135
+ <TreeNode depth={2} key={unitId} nodeId={unitId} />
1136
+ ))}
1137
+ <TreeNodeWrapper
1138
+ depth={2}
1139
+ expanded={false}
1140
+ hasChildren={false}
1141
+ icon={<Plus className="h-3.5 w-3.5" />}
1142
+ isLast
1143
+ label="New unit"
1144
+ onClick={handleNewUnit}
1145
+ onToggle={() => {}}
1146
+ />
1147
+ </TreeNodeWrapper>
1148
+ </div>
1149
+ )
1150
+ })
1151
+
996
1152
  const LayerToggle = memo(function LayerToggle() {
997
1153
  const structureLayer = useEditor((state) => state.structureLayer)
998
1154
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
@@ -1128,6 +1284,11 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
1128
1284
  const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
1129
1285
  const deleteNode = useScene((state) => state.deleteNode)
1130
1286
  const updateNode = useScene((state) => state.updateNode)
1287
+ const focusedUnitId = useViewer((state) => state.focusedUnitId)
1288
+ const focusedUnit = useScene((s) => {
1289
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
1290
+ return unit?.type === 'unit' ? unit : null
1291
+ })
1131
1292
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
1132
1293
  const hoveredId = useViewer((state) => state.hoveredId)
1133
1294
  const setSelection = useViewer((state) => state.setSelection)
@@ -1201,6 +1362,13 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa
1201
1362
  style={{ left: 8, width: 4 }}
1202
1363
  />
1203
1364
 
1365
+ {focusedUnit && (
1366
+ <ZoneMembershipCheckbox
1367
+ checked={focusedUnit.members.includes(zone.id)}
1368
+ onToggle={() => toggleZoneMembership(focusedUnit.id, zone.id)}
1369
+ unitName={focusedUnit.name || 'Unit'}
1370
+ />
1371
+ )}
1204
1372
  <span className={cn('mr-2', !isSelected && 'opacity-40')}>
1205
1373
  <ColorDot color={zone.color} onChange={handleColorChange} />
1206
1374
  </span>
@@ -1332,7 +1500,10 @@ const ContentSection = memo(function ContentSection() {
1332
1500
  if (!selectedLevelId) return []
1333
1501
  const lvl = s.nodes[selectedLevelId] as LevelNode | undefined
1334
1502
  if (!lvl) return []
1335
- return lvl.children.filter((childId) => s.nodes[childId]?.type !== 'zone')
1503
+ return lvl.children.filter((childId) => {
1504
+ const type = s.nodes[childId]?.type
1505
+ return type !== 'zone' && type !== 'scan'
1506
+ })
1336
1507
  }),
1337
1508
  )
1338
1509
 
@@ -1541,6 +1712,7 @@ const BuildingItem = memo(function BuildingItem({
1541
1712
  onUploadAsset={onUploadAsset}
1542
1713
  projectId={projectId}
1543
1714
  />
1715
+ <UnitsSection buildingId={building.id} />
1544
1716
  <LayerToggle />
1545
1717
  </div>
1546
1718
  <div className="subtle-scrollbar relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
@@ -70,12 +70,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
70
70
  const handleClick = useCallback(
71
71
  (e: React.MouseEvent) => {
72
72
  e.stopPropagation()
73
- handleTreeSelection(
74
- e,
75
- nodeId,
76
- useViewer.getState().selection.selectedIds,
77
- setSelection,
78
- )
73
+ handleTreeSelection(e, nodeId, useViewer.getState().selection.selectedIds, setSelection)
79
74
  routeTreeSelectionToNode(node)
80
75
  },
81
76
  [node, nodeId, setSelection],
@@ -134,7 +129,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
134
129
  depth={depth + 1}
135
130
  isLast={index === children.length - 1}
136
131
  key={childId}
137
- nodeId={childId}
132
+ nodeId={childId as AnyNodeId}
138
133
  />
139
134
  ))}
140
135
  </TreeNodeWrapper>
@@ -1,5 +1,5 @@
1
1
  import { type LevelNode, useScene } from '@pascal-app/core'
2
- import { useViewer } from '@pascal-app/viewer'
2
+ import { markPerfAction, 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'
@@ -30,7 +30,10 @@ export const LevelTreeNode = memo(function LevelTreeNode({
30
30
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
31
31
  const setSelection = useViewer((state) => state.setSelection)
32
32
 
33
- const handleClick = useCallback(() => setSelection({ levelId: nodeId }), [nodeId, setSelection])
33
+ const handleClick = useCallback(() => {
34
+ if (!isSelected) markPerfAction('level-switch', nodeId)
35
+ setSelection({ levelId: nodeId })
36
+ }, [isSelected, nodeId, setSelection])
34
37
  const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
35
38
  const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
36
39
  const handleStartEditing = useCallback(() => setIsEditing(true), [])
@@ -2,8 +2,9 @@ import { Icon as IconifyIcon } from '@iconify/react'
2
2
  import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
3
3
  import { useViewer } from '@pascal-app/viewer'
4
4
  import Image from 'next/image'
5
- import { memo, useCallback, useEffect, useState } from 'react'
5
+ import { memo, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
6
6
  import { useShallow } from 'zustand/react/shallow'
7
+ import { editorHostTreeChildrenRegistry } from '../../../../../lib/host-tree-children'
7
8
  import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
8
9
  import { InlineRenameInput } from './inline-rename-input'
9
10
  import {
@@ -43,9 +44,19 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
43
44
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
44
45
  const setSelection = useViewer((state) => state.setSelection)
45
46
  const setHoveredId = useViewer((state) => state.setHoveredId)
47
+ useSyncExternalStore(
48
+ editorHostTreeChildrenRegistry.subscribe,
49
+ editorHostTreeChildrenRegistry.getSnapshot,
50
+ editorHostTreeChildrenRegistry.getSnapshot,
51
+ )
46
52
 
47
53
  const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
48
54
  const tree = node ? nodeRegistry.get(node.type)?.tree : undefined
55
+ const hostChildren = node
56
+ ? editorHostTreeChildrenRegistry.childrenForKind(node.type)
57
+ : undefined
58
+ const hasHostChildren = Boolean(node && hostChildren?.hasChildren(node))
59
+ const HostChildren = hasHostChildren ? hostChildren?.component : undefined
49
60
  const icon = presentation?.icon
50
61
  const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp'
51
62
  const iconElement =
@@ -63,7 +74,7 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
63
74
  const snapTarget = resolveNodeSnapTarget(node)
64
75
  const defaultName =
65
76
  node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node'
66
- const hasChildren = children.length > 0
77
+ const hasChildren = children.length > 0 || hasHostChildren
67
78
 
68
79
  useEffect(() => {
69
80
  return useViewer.subscribe((state) => {
@@ -126,15 +137,21 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
126
137
  onMouseLeave={() => setHoveredId(null)}
127
138
  onToggle={() => setExpanded((prev) => !prev)}
128
139
  >
129
- {hasChildren &&
130
- children.map((childId, index) => (
131
- <TreeNode
132
- depth={depth + 1}
133
- isLast={index === children.length - 1}
134
- key={childId}
135
- nodeId={childId as AnyNodeId}
136
- />
137
- ))}
140
+ {hasChildren && (
141
+ <>
142
+ {children.map((childId, index) => (
143
+ <TreeNode
144
+ depth={depth + 1}
145
+ isLast={!hasHostChildren && index === children.length - 1}
146
+ key={childId}
147
+ nodeId={childId as AnyNodeId}
148
+ />
149
+ ))}
150
+ {HostChildren ? (
151
+ <HostChildren depth={depth + 1} nodeId={nodeId} parentVisible={isVisible} />
152
+ ) : null}
153
+ </>
154
+ )}
138
155
  </TreeNodeWrapper>
139
156
  )
140
157
  })
@@ -24,7 +24,7 @@ interface ShelfTreeNodeProps {
24
24
  /**
25
25
  * Sidebar tree entry for shelf. Mirrors `item-tree-node`'s shape so the
26
26
  * shelf's hosted items list as collapsible children — same pattern items
27
- * use for their nested items. The shelf has its own `children: ItemNode[`id`]`
27
+ * use for their nested items. The shelf has its own `children: string[]`
28
28
  * field on the schema; items reparent into it via `def.surfaces` + the
29
29
  * placement coordinator's shelf strategy.
30
30
  */
@@ -69,12 +69,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
69
69
  const handleClick = useCallback(
70
70
  (e: React.MouseEvent) => {
71
71
  e.stopPropagation()
72
- handleTreeSelection(
73
- e,
74
- nodeId,
75
- useViewer.getState().selection.selectedIds,
76
- setSelection,
77
- )
72
+ handleTreeSelection(e, nodeId, useViewer.getState().selection.selectedIds, setSelection)
78
73
  routeTreeSelectionToNode(node)
79
74
  },
80
75
  [node, nodeId, setSelection],
@@ -124,7 +119,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
124
119
  depth={depth + 1}
125
120
  isLast={index === children.length - 1}
126
121
  key={childId}
127
- nodeId={childId}
122
+ nodeId={childId as AnyNodeId}
128
123
  />
129
124
  ))}
130
125
  </TreeNodeWrapper>
@@ -0,0 +1,8 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { getTreeNodeComponent } from './tree-node'
3
+
4
+ describe('site tree node routing', () => {
5
+ test('renders plugin node kinds through the generic tree row', () => {
6
+ expect(getTreeNodeComponent('lean-to-extension')).toBe(getTreeNodeComponent('plugin-kind'))
7
+ })
8
+ })
@@ -107,6 +107,7 @@ import { SlabTreeNode } from './slab-tree-node'
107
107
  import { SolarPanelTreeNode } from './solar-panel-tree-node'
108
108
  import { SpawnTreeNode } from './spawn-tree-node'
109
109
  import { StairTreeNode } from './stair-tree-node'
110
+ import { UnitTreeNode } from './unit-tree-node'
110
111
  import { WallTreeNode } from './wall-tree-node'
111
112
  import { WindowTreeNode } from './window-tree-node'
112
113
  import { ZoneTreeNode } from './zone-tree-node'
@@ -117,6 +118,12 @@ interface TreeNodeProps {
117
118
  isLast?: boolean
118
119
  }
119
120
 
121
+ type TreeNodeComponent = React.ComponentType<{
122
+ depth: number
123
+ isLast?: boolean
124
+ nodeId: AnyNodeId
125
+ }>
126
+
120
127
  // Per-kind tree-node components keyed by `node.type`. Lookup replaces
121
128
  // the legacy switch — adding a kind to this map is now the only edit
122
129
  // needed in this file (the switch's `case '<kind>':` clauses were
@@ -124,10 +131,7 @@ interface TreeNodeProps {
124
131
  // outside the registry; future work moves these to a
125
132
  // `def.presentation`-driven generic tree-node and removes this map
126
133
  // entirely).
127
- const treeNodeByType: Record<
128
- string,
129
- React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
130
- > = {
134
+ const treeNodeByType: Record<string, TreeNodeComponent> = {
131
135
  building: BuildingTreeNode as React.ComponentType<{
132
136
  depth: number
133
137
  isLast?: boolean
@@ -136,6 +140,7 @@ const treeNodeByType: Record<
136
140
  cabinet: RegistryTreeNode,
137
141
  'cabinet-module': RegistryTreeNode,
138
142
  'box-vent': RegistryTreeNode,
143
+ 'block': RegistryTreeNode,
139
144
  ceiling: CeilingTreeNode,
140
145
  chimney: ChimneyTreeNode,
141
146
  dormer: DormerTreeNode,
@@ -169,7 +174,9 @@ const treeNodeByType: Record<
169
174
  'eyebrow-vent': RegistryTreeNode,
170
175
  skylight: RegistryTreeNode,
171
176
  roof: RoofTreeNode,
177
+ scan: RegistryTreeNode,
172
178
  stair: StairTreeNode,
179
+ unit: UnitTreeNode,
173
180
  door: DoorTreeNode,
174
181
  window: WindowTreeNode,
175
182
  zone: ZoneTreeNode as React.ComponentType<{
@@ -180,6 +187,10 @@ const treeNodeByType: Record<
180
187
  item: ItemTreeNode,
181
188
  }
182
189
 
190
+ export function getTreeNodeComponent(nodeType: string): TreeNodeComponent {
191
+ return treeNodeByType[nodeType] ?? RegistryTreeNode
192
+ }
193
+
183
194
  export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
184
195
  // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean
185
196
  // selector so unrelated scene updates don't re-render every row.
@@ -191,14 +202,15 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
191
202
  const nodeType = useScene((state) => state.nodes[nodeId]?.type)
192
203
  if (shouldHide) return null
193
204
  if (!nodeType) return null
194
- const Component = treeNodeByType[nodeType]
195
- if (!Component) return null
205
+ const Component = getTreeNodeComponent(nodeType)
196
206
  return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
197
207
  })
198
208
 
199
209
  interface TreeNodeWrapperProps {
200
210
  nodeId?: string
201
211
  icon: React.ReactNode
212
+ /** Keep the icon's own color when unselected (color dots are the identity). */
213
+ keepIconColor?: boolean
202
214
  label: React.ReactNode
203
215
  depth: number
204
216
  hasChildren: boolean
@@ -224,6 +236,7 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
224
236
  {
225
237
  nodeId,
226
238
  icon,
239
+ keepIconColor,
227
240
  label,
228
241
  depth,
229
242
  hasChildren,
@@ -318,7 +331,7 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
318
331
  <span
319
332
  className={cn(
320
333
  'flex h-5 w-5 shrink-0 items-center justify-center transition-all duration-200',
321
- !isSelected && 'opacity-60 grayscale',
334
+ !isSelected && !keepIconColor && 'opacity-60 grayscale',
322
335
  )}
323
336
  >
324
337
  {icon}