@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
@@ -0,0 +1,126 @@
1
+ 'use client'
2
+
3
+ import { nodeRegistry, type ToolOption } from '@pascal-app/core'
4
+ import Image from 'next/image'
5
+ import { useSyncExternalStore } from 'react'
6
+ import { cn } from '../../../lib/utils'
7
+ import { triggerSFX } from '../../../lib/sfx-bus'
8
+
9
+ const ALWAYS_VISIBLE = {
10
+ subscribe: () => () => {},
11
+ value: () => true,
12
+ }
13
+
14
+ function ToolOptionRow({
15
+ option,
16
+ onSelect,
17
+ getChoiceThumbnail,
18
+ active = true,
19
+ }: {
20
+ option: ToolOption
21
+ onSelect?: (option: ToolOption, value: string) => void
22
+ getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
23
+ active?: boolean
24
+ }) {
25
+ const visibility = option.visible ?? ALWAYS_VISIBLE
26
+ const visible = useSyncExternalStore(visibility.subscribe, visibility.value, visibility.value)
27
+ const value = useSyncExternalStore(option.subscribe, option.value, option.value)
28
+ if (
29
+ !visible ||
30
+ (!active && !option.choices.some((choice) => getChoiceThumbnail?.(option, choice.value)))
31
+ ) return null
32
+
33
+ const activeChoice = active && option.choices.find((choice) => choice.value === value)
34
+ return (
35
+ <div className="flex flex-col gap-2">
36
+ <div className="px-0.5 font-medium text-muted-foreground text-xs">{option.label}</div>
37
+ <div
38
+ className="grid gap-1.5"
39
+ style={{
40
+ gridTemplateColumns: `repeat(${Math.min(option.choices.length, 3)}, minmax(0, 1fr))`,
41
+ }}
42
+ >
43
+ {option.choices.map((choice) => {
44
+ const selected = active && choice.value === value
45
+ const thumbnail = getChoiceThumbnail?.(option, choice.value)
46
+ return (
47
+ <button
48
+ aria-pressed={selected}
49
+ className={cn(
50
+ 'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
51
+ selected
52
+ ? 'bg-primary/10 text-primary ring-1 ring-primary/50'
53
+ : 'bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground',
54
+ )}
55
+ key={choice.value}
56
+ onClick={() => {
57
+ triggerSFX('sfx:menu-click')
58
+ option.set(choice.value)
59
+ onSelect?.(option, choice.value)
60
+ }}
61
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
62
+ type="button"
63
+ >
64
+ {thumbnail && (
65
+ <Image
66
+ alt=""
67
+ className={cn(
68
+ 'mx-auto mb-1 size-14 object-contain',
69
+ !selected && 'opacity-70 grayscale',
70
+ )}
71
+ height={56}
72
+ src={thumbnail}
73
+ width={56}
74
+ />
75
+ )}
76
+ {choice.label}
77
+ </button>
78
+ )
79
+ })}
80
+ </div>
81
+ {activeChoice && activeChoice.description ? (
82
+ <p className="px-0.5 text-[11px] text-muted-foreground leading-relaxed">
83
+ {activeChoice.description}
84
+ </p>
85
+ ) : null}
86
+ </div>
87
+ )
88
+ }
89
+
90
+ /**
91
+ * The pick-one option rows a kind declares via `def.toolOptions` (e.g. the
92
+ * roof's 'Create from: Draw / Room'), for whichever sidebar the host mounts
93
+ * it in — the standalone Build tab and the community Build sidebar both get
94
+ * every kind's options with no per-kind wiring. Renders nothing for kinds
95
+ * without options. Selecting a choice only writes the kind's own state;
96
+ * hosts that want selection to also arm the tool pass `onSelect`.
97
+ */
98
+ export function ToolOptionsPanel({
99
+ kind,
100
+ className,
101
+ onSelect,
102
+ getChoiceThumbnail,
103
+ active = true,
104
+ }: {
105
+ kind: string | null | undefined
106
+ className?: string
107
+ onSelect?: (option: ToolOption, value: string) => void
108
+ getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
109
+ active?: boolean
110
+ }) {
111
+ const options = (kind ? nodeRegistry.get(kind)?.toolOptions : undefined) ?? []
112
+ if (options.length === 0) return null
113
+ return (
114
+ <div className={cn('flex flex-col gap-3', className)}>
115
+ {options.map((option) => (
116
+ <ToolOptionRow
117
+ active={active}
118
+ getChoiceThumbnail={getChoiceThumbnail}
119
+ key={option.id}
120
+ onSelect={onSelect}
121
+ option={option}
122
+ />
123
+ ))}
124
+ </div>
125
+ )
126
+ }
@@ -27,8 +27,16 @@ import {
27
27
  LevelNode,
28
28
  useScene,
29
29
  } from '@pascal-app/core'
30
- import { useViewer } from '@pascal-app/viewer'
31
- import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
30
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
31
+ import {
32
+ ClipboardPaste,
33
+ Copy,
34
+ GripVertical,
35
+ MoreVertical,
36
+ Plus,
37
+ Trash2,
38
+ X,
39
+ } from 'lucide-react'
32
40
  import {
33
41
  type ButtonHTMLAttributes,
34
42
  type CSSProperties,
@@ -45,6 +53,7 @@ import {
45
53
  } from '../../lib/level-duplication'
46
54
  import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
47
55
  import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
56
+ import { unitMemberLevels, leaveUnitFocus } from '../../lib/units'
48
57
  import { useLinearDisplay } from '../../lib/use-linear-display'
49
58
  import { cn } from '../../lib/utils'
50
59
  import { ActionButton } from './controls/action-button'
@@ -131,12 +140,15 @@ function LevelRow({
131
140
  onDuplicate,
132
141
  onPaste,
133
142
  onRequestDelete,
143
+ unitDotColor,
134
144
  }: {
135
145
  level: LevelNode
136
146
  isSelected: boolean
137
147
  isDragging?: boolean
138
148
  dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
139
149
  dragHandleRef?: (element: HTMLButtonElement | null) => void
150
+ /** Focused-unit color when the unit has zones on this level. */
151
+ unitDotColor?: string
140
152
  onSelect: () => void
141
153
  onDuplicate: (preset?: LevelDuplicatePreset) => void
142
154
  onPaste?: () => void
@@ -145,11 +157,17 @@ function LevelRow({
145
157
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
146
158
  const [isEditing, setIsEditing] = useState(false)
147
159
  const updateNode = useScene((s) => s.updateNode)
148
- const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2)
160
+ const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2)
149
161
 
150
162
  const storeyHeight = getStoredLevelHeight(level)
151
- // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
152
- const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
163
+ // Decimal units keep the compact readout; integer millimeters must retain trailing zeroes.
164
+ const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision)
165
+ const storeyHeightLabel = `${
166
+ displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight
167
+ } ${displayUnit}`
168
+ // Same rule as the site panel and command palette: the ordinal-0 ground
169
+ // floor is the vertical model's zero anchor and must never be deletable.
170
+ const canDeleteLevel = level.level !== 0
153
171
 
154
172
  // Clean preset values per display system; imperial stores exact meters
155
173
  // for whole-foot storey heights.
@@ -212,6 +230,13 @@ function LevelRow({
212
230
  type="button"
213
231
  >
214
232
  <span className="truncate">{getLevelDisplayName(level)}</span>
233
+ {unitDotColor && (
234
+ <span
235
+ className="ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full"
236
+ style={{ backgroundColor: unitDotColor }}
237
+ title="Focused unit has zones here"
238
+ />
239
+ )}
215
240
  </button>
216
241
 
217
242
  {/* Storey height badge — opens the height popover */}
@@ -241,7 +266,7 @@ function LevelRow({
241
266
  precision={3}
242
267
  step={0.1}
243
268
  unit="m"
244
- value={Math.round(storeyHeight * 1000) / 1000}
269
+ value={storeyHeight}
245
270
  />
246
271
  <div className="mt-1.5 grid grid-cols-3 gap-1.5">
247
272
  {heightPresets.map((preset) => (
@@ -304,11 +329,13 @@ function LevelRow({
304
329
  </button>
305
330
  )}
306
331
  <button
307
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
332
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors enabled:hover:bg-white/10 enabled:hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
333
+ disabled={!canDeleteLevel}
308
334
  onClick={(e) => {
309
335
  e.stopPropagation()
310
336
  onRequestDelete()
311
337
  }}
338
+ title={canDeleteLevel ? 'Delete level' : 'The ground level cannot be deleted'}
312
339
  type="button"
313
340
  >
314
341
  <Trash2 className="h-3 w-3" />
@@ -338,9 +365,11 @@ function SortableLevelRow({
338
365
  onDuplicate,
339
366
  onPaste,
340
367
  onRequestDelete,
368
+ unitDotColor,
341
369
  }: {
342
370
  level: LevelNode
343
371
  isSelected: boolean
372
+ unitDotColor?: string
344
373
  onSelect: () => void
345
374
  onDuplicate: (preset?: LevelDuplicatePreset) => void
346
375
  onPaste?: () => void
@@ -376,7 +405,42 @@ function SortableLevelRow({
376
405
  onPaste={onPaste}
377
406
  onRequestDelete={onRequestDelete}
378
407
  onSelect={onSelect}
408
+ unitDotColor={unitDotColor}
409
+ />
410
+ </div>
411
+ )
412
+ }
413
+
414
+ // ── Unit focus chip: name + × to end focus, only while a unit is focused ────
415
+
416
+ function UnitFocusChip() {
417
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
418
+ const focusedUnit = useScene((s) => {
419
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
420
+ return unit?.type === 'unit' ? unit : null
421
+ })
422
+
423
+ if (!focusedUnit) return null
424
+
425
+ return (
426
+ <div
427
+ className="mt-4 flex h-7 max-w-48 items-center gap-1.5 rounded-full border border-border bg-background/90 pr-1 pl-2.5 font-medium text-xs shadow-2xl backdrop-blur-md"
428
+ data-testid="unit-focus-chip"
429
+ >
430
+ <span
431
+ className="h-2 w-2 shrink-0 rounded-full"
432
+ style={{ backgroundColor: focusedUnit.color }}
379
433
  />
434
+ <span className="truncate">{focusedUnit.name || 'Unit'}</span>
435
+ <button
436
+ aria-label="Exit unit focus"
437
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
438
+ onClick={() => leaveUnitFocus()}
439
+ title="Exit unit focus"
440
+ type="button"
441
+ >
442
+ <X className="h-3 w-3" />
443
+ </button>
380
444
  </div>
381
445
  )
382
446
  }
@@ -393,6 +457,17 @@ export function FloatingLevelSelector() {
393
457
 
394
458
  const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
395
459
  const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
460
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
461
+ const focusedUnitColor = useScene((s) => {
462
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
463
+ return unit?.type === 'unit' ? unit.color : null
464
+ })
465
+ const focusedUnitLevelIds = useScene(
466
+ useShallow((s) => {
467
+ const unit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
468
+ return unit?.type === 'unit' ? unitMemberLevels(unit, s.nodes).map((level) => level.id) : []
469
+ }),
470
+ )
396
471
  const sensors = useSensors(
397
472
  useSensor(PointerSensor, {
398
473
  activationConstraint: { distance: 4 },
@@ -572,6 +647,9 @@ export function FloatingLevelSelector() {
572
647
  {!draggingLevelId && (
573
648
  <button
574
649
  className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
650
+ // A stable hook for host-app onboarding to point at. Static, and
651
+ // read only from outside: nothing here depends on it.
652
+ data-guide-target="level-add"
575
653
  onClick={handleAddAbove}
576
654
  title="Add level above"
577
655
  type="button"
@@ -608,20 +686,34 @@ export function FloatingLevelSelector() {
608
686
  const showGapBelow = i < reversedLevels.length - 1
609
687
 
610
688
  return (
611
- <div className="relative" key={level.id}>
689
+ <div
690
+ className="relative"
691
+ // A stable hook for host-app onboarding to point at, on
692
+ // the ground floor only — the one level a guide can name
693
+ // without knowing the building. Static, and read only
694
+ // from outside: nothing here depends on it.
695
+ data-guide-target={level.level === 0 ? 'level-ground' : undefined}
696
+ key={level.id}
697
+ >
612
698
  <SortableLevelRow
613
699
  isSelected={isSelected}
614
700
  level={level}
701
+ unitDotColor={
702
+ focusedUnitColor && focusedUnitLevelIds.includes(level.id)
703
+ ? focusedUnitColor
704
+ : undefined
705
+ }
615
706
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
616
707
  onPaste={() => handlePasteToLevel(level)}
617
708
  onRequestDelete={() => setDeletingLevel(level)}
618
- onSelect={() =>
709
+ onSelect={() => {
710
+ if (!isSelected) markPerfAction('level-switch', level.id)
619
711
  setSelection(
620
712
  resolvedBuildingId
621
713
  ? { buildingId: resolvedBuildingId, levelId: level.id }
622
714
  : { levelId: level.id },
623
715
  )
624
- }
716
+ }}
625
717
  />
626
718
 
627
719
  {showGapBelow && !draggingLevelId && (
@@ -641,6 +733,7 @@ export function FloatingLevelSelector() {
641
733
  </SortableContext>
642
734
  </DndContext>
643
735
  </div>
736
+ <UnitFocusChip />
644
737
  </div>
645
738
 
646
739
  {/* Delete confirmation dialog */}
@@ -90,6 +90,7 @@ function ShortcutSequence({
90
90
  function ChipRow({
91
91
  ariaLabel,
92
92
  disabled = false,
93
+ guideTarget,
93
94
  icon,
94
95
  label,
95
96
  onClick,
@@ -98,6 +99,11 @@ function ChipRow({
98
99
  }: {
99
100
  ariaLabel?: string
100
101
  disabled?: boolean
102
+ /**
103
+ * A static hook for a host app's first-run tour to point at, written to
104
+ * `data-guide-target`. Nothing here reads it.
105
+ */
106
+ guideTarget?: string
101
107
  icon?: string
102
108
  label: string
103
109
  onClick?: () => void
@@ -130,6 +136,7 @@ function ChipRow({
130
136
  'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
131
137
  disabled && 'opacity-45 saturate-0',
132
138
  )}
139
+ data-guide-target={guideTarget}
133
140
  onClick={onClick}
134
141
  type="button"
135
142
  >
@@ -181,6 +188,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
181
188
  <>
182
189
  <ChipRow
183
190
  ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
191
+ guideTarget="snap-mode"
184
192
  icon={SNAPPING_MODE_ICONS[snappingMode]}
185
193
  label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
186
194
  onClick={() => {
@@ -193,6 +201,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
193
201
  {gridActive ? (
194
202
  <ChipRow
195
203
  ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
204
+ guideTarget="snap-grid-step"
196
205
  label={`Grid: ${gridSnapStep.toFixed(2)} m`}
197
206
  onClick={() => {
198
207
  setGridSnapStep(nextGridSnapStep(gridSnapStep))
@@ -8,7 +8,7 @@ import {
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
10
  import { useViewer } from '@pascal-app/viewer'
11
- import { useEffect, useMemo, useState } from 'react'
11
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
12
12
  import { useShallow } from 'zustand/react/shallow'
13
13
  import { useIsMobile } from '../../../hooks/use-mobile'
14
14
  import {
@@ -19,6 +19,7 @@ import {
19
19
  resolveRotateHandleHelpHints,
20
20
  resolveSelectModeHelpHints,
21
21
  } from '../../../lib/contextual-help'
22
+ import { getContextualHelpNodeExtension } from '../../../lib/contextual-help-extension'
22
23
  import { continuationContextOf } from '../../../lib/continuation'
23
24
  import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
24
25
  import type { ReshapeKind } from '../../../lib/interaction/scope'
@@ -33,7 +34,6 @@ import { BuildingHelper } from './building-helper'
33
34
  import { ContextualHelperPanel } from './contextual-helper-panel'
34
35
  import { ItemHelper } from './item-helper'
35
36
  import { RegisteredToolHelper } from './registered-tool-helper'
36
- import { RoofHelper } from './roof-helper'
37
37
 
38
38
  // Reshaping a selected node's geometry (endpoint / curve / polygon corner). The
39
39
  // snapping chip is the main control; these just name the gesture + Esc.
@@ -88,6 +88,9 @@ type ActiveModifierKeys = {
88
88
  shift: boolean
89
89
  }
90
90
 
91
+ const EMPTY_CONTEXTUAL_HINTS: ContextualShortcutHint[] = []
92
+ const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
93
+
91
94
  function useActiveModifierKeys(): ActiveModifierKeys {
92
95
  const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
93
96
  command: false,
@@ -143,6 +146,21 @@ export function HelperManager() {
143
146
  .filter((node): node is AnyNode => node !== undefined),
144
147
  ),
145
148
  )
149
+ const contextualHelpNode =
150
+ scope.kind === 'mesh-editing'
151
+ ? selectedNodes.find((node) => node.id === scope.nodeId) ?? null
152
+ : null
153
+ const contextualHelpExtension = contextualHelpNode
154
+ ? getContextualHelpNodeExtension(nodeRegistry.get(contextualHelpNode.type))
155
+ : undefined
156
+ const contextualEditHints = useSyncExternalStore(
157
+ contextualHelpExtension?.subscribe ?? NO_CONTEXTUAL_HELP_SUBSCRIPTION,
158
+ () =>
159
+ contextualHelpNode
160
+ ? (contextualHelpExtension?.getHints(contextualHelpNode.id) ?? EMPTY_CONTEXTUAL_HINTS)
161
+ : EMPTY_CONTEXTUAL_HINTS,
162
+ () => EMPTY_CONTEXTUAL_HINTS,
163
+ )
146
164
  // The snapping context for whatever's active (wall / item / polygon) — drives
147
165
  // which snapping chips the HUD shows, derived once and shared by every branch.
148
166
  const snapContext = useMemo(
@@ -152,6 +170,10 @@ export function HelperManager() {
152
170
  mode,
153
171
  tool,
154
172
  profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
173
+ profileOfNode: (nodeId) => {
174
+ const node = useScene.getState().nodes[nodeId as AnyNodeId]
175
+ return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
176
+ },
155
177
  draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
156
178
  }),
157
179
  [scope, mode, tool],
@@ -212,6 +234,22 @@ export function HelperManager() {
212
234
  )
213
235
  }
214
236
 
237
+ // A single-node resize arrow is still an active snapping interaction. Roof
238
+ // width/depth handles opt into grid snapping, so keep the mode and grid-step
239
+ // controls visible for the whole drag instead of falling through to idle
240
+ // selection hints.
241
+ if (activeHandleDrag) {
242
+ return (
243
+ <ContextualHelperPanel
244
+ hints={[
245
+ { keys: ['Drag'], label: 'Resize' },
246
+ { keys: ['Esc'], label: 'Cancel' },
247
+ ]}
248
+ snapContext={snapContext}
249
+ />
250
+ )
251
+ }
252
+
215
253
  // Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
216
254
  // before the select branch so the idle "drag selected / add objects" hints
217
255
  // never leak over an in-progress reshape — and it gets its own snapping chip.
@@ -227,13 +265,13 @@ export function HelperManager() {
227
265
  const movingContinuationContext = isFreshPlacementMetadata(movingNode.metadata)
228
266
  ? continuationContextOf(movingNode.type)
229
267
  : null
230
- // Force-place only makes sense for kinds that collision-validate their drop;
231
- // structural kinds (wall/slab/…) never reject, so don't advertise Alt.
268
+ const collisionValidatesDrop =
269
+ nodeRegistry.get(movingNode.type)?.capabilities.floorPlaced?.collides === true
232
270
  return (
233
271
  <ItemHelper
234
272
  continuationContext={movingContinuationContext}
235
273
  showEsc
236
- showForce={nodeRegistry.get(movingNode.type)?.snapProfile !== 'structural'}
274
+ showForce={collisionValidatesDrop}
237
275
  snapContext={snapContext}
238
276
  />
239
277
  )
@@ -255,6 +293,10 @@ export function HelperManager() {
255
293
  return <ContextualHelperPanel hints={terrainSculptHints(terrainVerb, terrainSampling)} />
256
294
  }
257
295
 
296
+ if (scope.kind === 'mesh-editing') {
297
+ return <ContextualHelperPanel hints={contextualEditHints} snapContext={snapContext} />
298
+ }
299
+
258
300
  // Idle select only — an active scope (handle-drag, box-select, …) must not show
259
301
  // the idle selection hints.
260
302
  if (mode === 'select' && scope.kind === 'idle') {
@@ -273,12 +315,6 @@ export function HelperManager() {
273
315
  )
274
316
  }
275
317
 
276
- // Legacy fallback — only `roof` remains because it hasn't migrated to
277
- // `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
278
- // generic tool branch so the snap-context fallback below doesn't capture it
279
- // and drop its bespoke `RoofHelper` hints. When roof migrates, this deletes.
280
- if (tool === 'roof') return <RoofHelper snapContext={snapContext} />
281
-
282
318
  // Registry-first: a kind renders the generic `RegisteredToolHelper` when it
283
319
  // declares `def.toolHints`, OR whenever its draft resolves to a snap /
284
320
  // continuation context — so a snappable tool with NO hand-written hints (e.g.
@@ -6,7 +6,7 @@ interface ItemHelperProps {
6
6
  showEsc?: boolean
7
7
  snapContext?: SnapContext | null
8
8
  // Whether to advertise Alt = force-place. Only meaningful for kinds that
9
- // collision-validate their drop (structural kinds never reject, so it's hidden).
9
+ // collision-validate their drop.
10
10
  showForce?: boolean
11
11
  // Set for a fresh point-kind placement (e.g. a positioned preset) so the
12
12
  // once/repeat continuation chip shows; null for an existing-node move.
@@ -1,4 +1,5 @@
1
1
  import type { ToolHint } from '@pascal-app/core'
2
+ import { useMemo, useSyncExternalStore } from 'react'
2
3
  import type { ContinuationContext } from '../../../lib/continuation'
3
4
  import type { SnapContext } from '../../../lib/snapping-mode'
4
5
  import useEditor from '../../../store/use-editor'
@@ -27,11 +28,31 @@ export function RegisteredToolHelper({
27
28
  // Live vertex count of an in-progress polygon draft, so hints gated on a
28
29
  // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
29
30
  const draftVertexCount = useEditor((s) => s.draftVertexCount)
31
+ const visibilityStore = useMemo(
32
+ () => ({
33
+ subscribe: (onChange: () => void) => {
34
+ const unsubscribers = hints.flatMap((hint) =>
35
+ hint.visible ? [hint.visible.subscribe(onChange)] : [],
36
+ )
37
+ return () => {
38
+ for (const unsubscribe of unsubscribers) unsubscribe()
39
+ }
40
+ },
41
+ getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
42
+ }),
43
+ [hints],
44
+ )
45
+ useSyncExternalStore(
46
+ visibilityStore.subscribe,
47
+ visibilityStore.getSnapshot,
48
+ visibilityStore.getSnapshot,
49
+ )
30
50
  // Some hints are replaced by live contextual chips, so keep the generic
31
51
  // registry renderer from duplicating stale/static versions.
32
52
  const visible = hints.filter(
33
53
  (hint) =>
34
54
  !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
55
+ hint.visible?.value() !== false &&
35
56
  (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
36
57
  )
37
58
  if (visible.length === 0 && !snapContext && !continuationContext) return null
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import {
4
+ resolveHomogeneousSelection,
5
+ resolveUniqueSelectionIds,
6
+ } from './homogeneous-selection'
7
+
8
+ function node(
9
+ id: string,
10
+ type: string,
11
+ metadata?: Record<string, unknown>,
12
+ ): AnyNode {
13
+ return {
14
+ object: 'node',
15
+ id: id as AnyNodeId,
16
+ type,
17
+ parentId: null,
18
+ visible: true,
19
+ metadata: metadata ?? {},
20
+ children: [],
21
+ } as unknown as AnyNode
22
+ }
23
+
24
+ describe('resolveHomogeneousSelection', () => {
25
+ test('mixed selection is null', () => {
26
+ const nodes = {
27
+ wall_a: node('wall_a', 'wall'),
28
+ slab_a: node('slab_a', 'slab'),
29
+ }
30
+ expect(resolveHomogeneousSelection(['wall_a', 'slab_a'], nodes)).toBeNull()
31
+ })
32
+
33
+ test('three walls share the wall type', () => {
34
+ const nodes = {
35
+ wall_a: node('wall_a', 'wall'),
36
+ wall_b: node('wall_b', 'wall'),
37
+ wall_c: node('wall_c', 'wall'),
38
+ }
39
+ expect(resolveHomogeneousSelection(['wall_a', 'wall_b', 'wall_c'], nodes)).toBe('wall')
40
+ })
41
+
42
+ test('proxy-promoted children resolve to the parent type', () => {
43
+ const nodes = {
44
+ cabinet_run: node('cabinet_run', 'cabinet'),
45
+ 'cabinet-module_a': node('cabinet-module_a', 'cabinet-module', {
46
+ nodeSelectionProxyId: 'cabinet_run',
47
+ }),
48
+ 'cabinet-module_b': node('cabinet-module_b', 'cabinet-module', {
49
+ nodeSelectionProxyId: 'cabinet_run',
50
+ }),
51
+ 'cabinet-module_c': node('cabinet-module_c', 'cabinet-module', {
52
+ nodeSelectionProxyId: 'cabinet_run',
53
+ }),
54
+ cabinet_run_b: node('cabinet_run_b', 'cabinet'),
55
+ 'cabinet-module_d': node('cabinet-module_d', 'cabinet-module', {
56
+ nodeSelectionProxyId: 'cabinet_run_b',
57
+ }),
58
+ }
59
+ expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_d'], nodes)).toBe(
60
+ 'cabinet',
61
+ )
62
+ expect(resolveUniqueSelectionIds(['cabinet-module_a', 'cabinet-module_b', 'cabinet-module_c'], nodes)).toEqual(
63
+ ['cabinet_run'],
64
+ )
65
+ expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_b'], nodes)).toBeNull()
66
+ })
67
+
68
+ test('stale ids are skipped without breaking a homogeneous remainder', () => {
69
+ const nodes = {
70
+ wall_a: node('wall_a', 'wall'),
71
+ wall_b: node('wall_b', 'wall'),
72
+ }
73
+ expect(resolveHomogeneousSelection(['wall_a', 'gone', 'wall_b'], nodes)).toBe('wall')
74
+ })
75
+
76
+ test('a single live node after skips is not homogeneous', () => {
77
+ const nodes = { wall_a: node('wall_a', 'wall') }
78
+ expect(resolveHomogeneousSelection(['wall_a', 'gone'], nodes)).toBeNull()
79
+ })
80
+ })