@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -19,3 +19,12 @@ export function runRedo() {
19
19
  useScene.temporal.getState().redo()
20
20
  refreshSceneAfterHistoryJump()
21
21
  }
22
+
23
+ /**
24
+ * ⌘Z / ⌘⇧Z (undo/redo). Pointer-drag sessions intercept these in the capture
25
+ * phase and cancel the gesture instead — mid-drag, "undo" means "abort what my
26
+ * mouse is doing", never a history jump under a live pointer.
27
+ */
28
+ export function isHistoryShortcut(e: KeyboardEvent) {
29
+ return (e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')
30
+ }
@@ -0,0 +1,133 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode } from '@pascal-app/core'
3
+ import {
4
+ type AttachClass,
5
+ attachClassOf,
6
+ type HotSetCandidate,
7
+ isCandidateInHotSet,
8
+ isPickableForAttach,
9
+ } from './hot-set'
10
+
11
+ const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
12
+
13
+ const floor: HotSetCandidate = {
14
+ type: 'level',
15
+ isFloorLike: true,
16
+ exposesTop: false,
17
+ attachClass: 'surface',
18
+ }
19
+ const wall: HotSetCandidate = {
20
+ type: 'wall',
21
+ isFloorLike: false,
22
+ exposesTop: false,
23
+ attachClass: 'surface',
24
+ }
25
+ const ceiling: HotSetCandidate = {
26
+ type: 'ceiling',
27
+ isFloorLike: false,
28
+ exposesTop: false,
29
+ attachClass: 'surface',
30
+ }
31
+ const table: HotSetCandidate = {
32
+ type: 'item',
33
+ isFloorLike: false,
34
+ exposesTop: true,
35
+ attachClass: 'surface',
36
+ }
37
+ const wallShelf: HotSetCandidate = {
38
+ type: 'shelf',
39
+ isFloorLike: false,
40
+ exposesTop: true,
41
+ attachClass: 'wall',
42
+ }
43
+ const ceilingFan: HotSetCandidate = {
44
+ type: 'item',
45
+ isFloorLike: false,
46
+ exposesTop: true,
47
+ attachClass: 'ceiling',
48
+ }
49
+
50
+ describe('attachClassOf', () => {
51
+ test('wall and wall-side collapse to wall', () => {
52
+ expect(attachClassOf('wall')).toBe('wall')
53
+ expect(attachClassOf('wall-side')).toBe('wall')
54
+ })
55
+ test('ceiling maps to ceiling', () => {
56
+ expect(attachClassOf('ceiling')).toBe('ceiling')
57
+ })
58
+ test('undefined/null/unknown is surface-resting', () => {
59
+ expect(attachClassOf(undefined)).toBe('surface')
60
+ expect(attachClassOf(null)).toBe('surface')
61
+ expect(attachClassOf('')).toBe('surface')
62
+ })
63
+ })
64
+
65
+ describe('isPickableForAttach — wall-mounted (window)', () => {
66
+ test('only walls are eligible; floor/ceiling/tops are not', () => {
67
+ expect(isPickableForAttach('wall', wall)).toBe(true)
68
+ expect(isPickableForAttach('wall', floor)).toBe(false)
69
+ expect(isPickableForAttach('wall', ceiling)).toBe(false)
70
+ expect(isPickableForAttach('wall', table)).toBe(false)
71
+ expect(isPickableForAttach('wall', wallShelf)).toBe(false)
72
+ })
73
+ })
74
+
75
+ describe('isPickableForAttach — ceiling-mounted', () => {
76
+ test('only ceilings are eligible', () => {
77
+ expect(isPickableForAttach('ceiling', ceiling)).toBe(true)
78
+ expect(isPickableForAttach('ceiling', wall)).toBe(false)
79
+ expect(isPickableForAttach('ceiling', floor)).toBe(false)
80
+ })
81
+ })
82
+
83
+ describe('isPickableForAttach — surface-resting (sofa / cactus)', () => {
84
+ test('floor is always eligible', () => {
85
+ expect(isPickableForAttach('surface', floor)).toBe(true)
86
+ })
87
+ test('host tops (table, wall-shelf top) are eligible', () => {
88
+ expect(isPickableForAttach('surface', table)).toBe(true)
89
+ expect(isPickableForAttach('surface', wallShelf)).toBe(true)
90
+ })
91
+ test('a wall (no top surface) is not eligible', () => {
92
+ expect(isPickableForAttach('surface', wall)).toBe(false)
93
+ })
94
+ test('a ceiling-mounted host (ceiling fan) is never eligible — Track E', () => {
95
+ expect(isPickableForAttach('surface', ceilingFan)).toBe(false)
96
+ })
97
+ })
98
+
99
+ describe('isCandidateInHotSet — by scope', () => {
100
+ const surfaceClass: AttachClass = 'surface'
101
+ test('idle: everything is in the hot-set (selection filtering lives elsewhere)', () => {
102
+ expect(isCandidateInHotSet({ kind: 'idle' }, null, ceilingFan)).toBe(true)
103
+ })
104
+ test('placing a surface item: derives from attach class', () => {
105
+ const scope = {
106
+ kind: 'placing' as const,
107
+ node: mockNode('i1', 'item'),
108
+ nodeId: 'i1',
109
+ nodeType: 'item',
110
+ view: '3d' as const,
111
+ pressDrag: false,
112
+ }
113
+ expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true)
114
+ expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false)
115
+ })
116
+ test('moving a wall-mounted item: only walls', () => {
117
+ const scope = {
118
+ kind: 'moving' as const,
119
+ node: mockNode('w1', 'window'),
120
+ nodeId: 'w1',
121
+ nodeType: 'window',
122
+ view: '2d' as const,
123
+ }
124
+ expect(isCandidateInHotSet(scope, 'wall', wall)).toBe(true)
125
+ expect(isCandidateInHotSet(scope, 'wall', table)).toBe(false)
126
+ })
127
+ test('non-placement active scopes target nothing in the scene', () => {
128
+ expect(isCandidateInHotSet({ kind: 'box-select' }, null, floor)).toBe(false)
129
+ expect(
130
+ isCandidateInHotSet({ kind: 'handle-drag', nodeId: 'x', handle: 'h' }, null, floor),
131
+ ).toBe(false)
132
+ })
133
+ })
@@ -0,0 +1,67 @@
1
+ // The hot-set: which scene objects are raycast-eligible during an interaction.
2
+ //
3
+ // It is never hand-authored per interaction. It falls out of the node's
4
+ // `asset.attachTo` plus whether a candidate exposes a top surface. "Floor item"
5
+ // really means surface-resting: it rests on the floor *or* any host's top
6
+ // surface. Walls and ceilings are the special attach modes. Adding a node kind
7
+ // = set `attachTo` (or leave blank); the hot-set follows with zero per-kind
8
+ // wiring.
9
+
10
+ import type { InteractionScope } from './scope'
11
+
12
+ // What a node attaches to, collapsed to the three classes the hot-set cares
13
+ // about. `wall-side` is a wall attachment; everything without an explicit
14
+ // `attachTo` is surface-resting.
15
+ export type AttachClass = 'wall' | 'ceiling' | 'surface'
16
+
17
+ export function attachClassOf(attachTo: string | undefined | null): AttachClass {
18
+ if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
19
+ if (attachTo === 'ceiling') return 'ceiling'
20
+ return 'surface'
21
+ }
22
+
23
+ // The metadata the hot-set needs about a candidate host/surface. Derived from
24
+ // the candidate node + its registry definition by the caller, so this module
25
+ // stays pure and unit-testable without the scene or registry.
26
+ export type HotSetCandidate = {
27
+ type: string
28
+ // The level floor plane / ground a surface-resting node can always rest on.
29
+ isFloorLike: boolean
30
+ // The candidate exposes a usable top surface (registry
31
+ // `capabilities.surfaces.top`) — a table, a shelf, a slab.
32
+ exposesTop: boolean
33
+ // The candidate's own attach class. A ceiling fan is `ceiling`: it hangs from
34
+ // the ceiling and must never act as a host top (Track E).
35
+ attachClass: AttachClass
36
+ }
37
+
38
+ // For a node whose attach class is `placed`, is `candidate` a valid
39
+ // host/surface to pick during placement or move?
40
+ export function isPickableForAttach(placed: AttachClass, candidate: HotSetCandidate): boolean {
41
+ if (placed === 'wall') return candidate.type === 'wall'
42
+ if (placed === 'ceiling') return candidate.type === 'ceiling'
43
+ // Surface-resting: the floor, or any host that exposes a top surface — but
44
+ // never a ceiling-mounted host (a floor lamp must not land on a ceiling fan).
45
+ if (candidate.isFloorLike) return true
46
+ if (!candidate.exposesTop) return false
47
+ if (candidate.attachClass === 'ceiling') return false
48
+ return true
49
+ }
50
+
51
+ // The hot-set predicate for a whole scope. For placing/moving it derives from
52
+ // the moving node's attach class; for every other active scope nothing in the
53
+ // scene is a placement target, so the body's own raycast owns the pointer.
54
+ // `idle` returns true here — selection/phase filtering stays in the selection
55
+ // manager; this only narrows what an *active* interaction can target.
56
+ export function isCandidateInHotSet(
57
+ scope: InteractionScope,
58
+ placedAttachClass: AttachClass | null,
59
+ candidate: HotSetCandidate,
60
+ ): boolean {
61
+ if (scope.kind === 'idle') return true
62
+ if (scope.kind === 'placing' || scope.kind === 'moving') {
63
+ if (placedAttachClass === null) return true
64
+ return isPickableForAttach(placedAttachClass, candidate)
65
+ }
66
+ return false
67
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode } from '@pascal-app/core'
3
+ import { resolveOverlayPolicy } from './overlay-policy'
4
+ import type { ActiveInteractionScope } from './scope'
5
+
6
+ const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
7
+
8
+ const ACTIVE_SCOPES: ActiveInteractionScope[] = [
9
+ {
10
+ kind: 'placing',
11
+ node: mockNode('i1', 'item'),
12
+ nodeId: 'i1',
13
+ nodeType: 'item',
14
+ view: '3d',
15
+ pressDrag: false,
16
+ },
17
+ { kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
18
+ { kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
19
+ { kind: 'drafting', tool: 'wall' },
20
+ { kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
21
+ { kind: 'box-select' },
22
+ { kind: 'painting' },
23
+ ]
24
+
25
+ describe('resolveOverlayPolicy', () => {
26
+ test('idle keeps everything shown and pickable', () => {
27
+ const p = resolveOverlayPolicy({ kind: 'idle' })
28
+ expect(p.zoneLabels).toBe('shown')
29
+ expect(p.contextBadges).toBe('shown')
30
+ expect(p.conflictingControls).toBe('shown')
31
+ expect(p.sceneObjectsPickable).toBe(true)
32
+ })
33
+
34
+ test('every active scope hides zone labels, fades badges, hides conflicting controls', () => {
35
+ for (const scope of ACTIVE_SCOPES) {
36
+ const p = resolveOverlayPolicy(scope)
37
+ expect(p.zoneLabels).toBe('hidden')
38
+ expect(p.contextBadges).toBe('faded')
39
+ expect(p.conflictingControls).toBe('hidden')
40
+ expect(p.sceneObjectsPickable).toBe(false)
41
+ }
42
+ })
43
+
44
+ test('active affordances and the contextual HUD always stay interactive', () => {
45
+ for (const scope of [{ kind: 'idle' } as const, ...ACTIVE_SCOPES]) {
46
+ const p = resolveOverlayPolicy(scope)
47
+ expect(p.activeAffordances).toBe('shown')
48
+ expect(p.contextualHudInteractive).toBe(true)
49
+ }
50
+ })
51
+ })
@@ -0,0 +1,59 @@
1
+ // The overlay scope matrix — the "Sims-light" feel. During any non-idle
2
+ // interaction, two layers behave differently:
3
+ //
4
+ // - 3D scene objects stay VISIBLE but become NON-pickable (the hot-set owns
5
+ // what the active interaction can target). Context is preserved; you just
6
+ // can't grab the wrong thing.
7
+ // - DOM/HUD overlays step back, differentiated by how distracting they are:
8
+ // zone labels -> hidden (not a primary editing concern)
9
+ // context badges -> faded + pointer-events:none (hover name pills)
10
+ // other controls -> hard-hidden (other objects' handles, the floating
11
+ // action menu, conflicting controls)
12
+ //
13
+ // The active interaction's own affordances (ghost, snap guides, dimension
14
+ // labels, the active handle) always stay — "default-off, opt-in for the active
15
+ // action". The contextual control HUD is exempt from the pointer-events
16
+ // step-back because it *is* the active interaction's own controls.
17
+
18
+ import { type InteractionScope, isActive } from './scope'
19
+
20
+ export type OverlayVisibility = 'shown' | 'faded' | 'hidden'
21
+
22
+ export type OverlayPolicy = {
23
+ zoneLabels: OverlayVisibility
24
+ // Hover name pills / context badges.
25
+ contextBadges: OverlayVisibility
26
+ // Other objects' handles + the floating action menu — anything whose action
27
+ // would conflict with the active interaction.
28
+ conflictingControls: OverlayVisibility
29
+ // Non-active scene objects: visible always, pickable only when idle.
30
+ sceneObjectsPickable: boolean
31
+ // The active interaction's own ghost/guides/dimension labels/handle. Always
32
+ // shown; this field exists so consumers can assert the contract.
33
+ activeAffordances: 'shown'
34
+ // The contextual control HUD keeps pointer events even while everything else
35
+ // steps back, because it is the active interaction's own controls.
36
+ contextualHudInteractive: boolean
37
+ }
38
+
39
+ const IDLE_POLICY: OverlayPolicy = {
40
+ zoneLabels: 'shown',
41
+ contextBadges: 'shown',
42
+ conflictingControls: 'shown',
43
+ sceneObjectsPickable: true,
44
+ activeAffordances: 'shown',
45
+ contextualHudInteractive: true,
46
+ }
47
+
48
+ const ACTIVE_POLICY: OverlayPolicy = {
49
+ zoneLabels: 'hidden',
50
+ contextBadges: 'faded',
51
+ conflictingControls: 'hidden',
52
+ sceneObjectsPickable: false,
53
+ activeAffordances: 'shown',
54
+ contextualHudInteractive: true,
55
+ }
56
+
57
+ export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy {
58
+ return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY
59
+ }
@@ -0,0 +1,211 @@
1
+ // The authoritative description of "what the user is currently doing".
2
+ //
3
+ // Before this, that question was answered by re-deriving from 7+ independent
4
+ // `useEditor` flags (`mode`, `tool`, `movingNode`, `placementDragMode`,
5
+ // `activeHandleDrag`, `curvingWall`, `curvingFence`, `editingHole`,
6
+ // `movingWallEndpoint`, `movingFenceEndpoint`, …). Every overlay and pick site
7
+ // re-derived its behaviour from a different subset, so the flags could drift
8
+ // into illegal combinations (moving + curving at once; a stale `movingNode`
9
+ // after a drag ended). Collapsing them into one discriminated union makes those
10
+ // combinations unrepresentable: a scope is exactly one interaction at a time,
11
+ // and `idle` carries no interaction payload at all.
12
+
13
+ import type { AnyNode } from '@pascal-app/core'
14
+
15
+ export type InteractionView = '2d' | '3d'
16
+
17
+ // Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
18
+ // node, one in-flight reshape. Grouping them as sub-states of `reshaping`
19
+ // (rather than four sibling scopes) keeps the union small while still making
20
+ // "curving and hole-editing at once" unrepresentable.
21
+ export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
22
+
23
+ export type InteractionScope =
24
+ | { kind: 'idle' }
25
+ // Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
26
+ // gizmo press-drag flavour (commit on release) vs click-to-place.
27
+ | {
28
+ kind: 'placing'
29
+ // The node being placed, carried inline: a fresh-placement / duplicate
30
+ // draft is not in the scene yet, so it cannot be recovered by id. Set once
31
+ // at `begin` and never mutated, so it is a stable reference for the gesture.
32
+ node: AnyNode
33
+ nodeId: string
34
+ nodeType: string
35
+ view: InteractionView
36
+ pressDrag: boolean
37
+ }
38
+ // Moving an existing node.
39
+ | { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView }
40
+ // Dragging a resize/translate/rotate handle of a selected node.
41
+ | { kind: 'handle-drag'; nodeId: string; handle: string }
42
+ // Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
43
+ | { kind: 'drafting'; tool: string }
44
+ // Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set
45
+ // only for `reshape: 'hole'`; `endpoint` only for `reshape: 'endpoint'`.
46
+ | {
47
+ kind: 'reshaping'
48
+ nodeId: string
49
+ reshape: ReshapeKind
50
+ holeIndex?: number
51
+ endpoint?: 'start' | 'end'
52
+ index?: number
53
+ side?: 'in' | 'out'
54
+ }
55
+ // Marquee selection drag.
56
+ | { kind: 'box-select' }
57
+ // Material paint application.
58
+ | { kind: 'painting' }
59
+
60
+ export type InteractionKind = InteractionScope['kind']
61
+
62
+ export type ActiveInteractionScope = Exclude<InteractionScope, { kind: 'idle' }>
63
+
64
+ export const IDLE_SCOPE: InteractionScope = { kind: 'idle' }
65
+
66
+ export function isIdle(scope: InteractionScope): scope is { kind: 'idle' } {
67
+ return scope.kind === 'idle'
68
+ }
69
+
70
+ export function isActive(scope: InteractionScope): scope is ActiveInteractionScope {
71
+ return scope.kind !== 'idle'
72
+ }
73
+
74
+ // The node a scope is acting on, if any. Drafting/box-select/painting/idle
75
+ // target no single existing node.
76
+ export function scopeNodeId(scope: InteractionScope): string | null {
77
+ switch (scope.kind) {
78
+ case 'placing':
79
+ case 'moving':
80
+ case 'handle-drag':
81
+ case 'reshaping':
82
+ return scope.nodeId
83
+ default:
84
+ return null
85
+ }
86
+ }
87
+
88
+ // The node a placing/moving scope is acting on, carried inline (see the
89
+ // `placing` variant comment). Null for every other scope. Replaces the legacy
90
+ // `useEditor.movingNode` flag: the node lives inside the discriminated union, so
91
+ // it cannot survive past the interaction's `end()`.
92
+ export function movingNodeOf(scope: InteractionScope): AnyNode | null {
93
+ return scope.kind === 'placing' || scope.kind === 'moving' ? scope.node : null
94
+ }
95
+
96
+ // Selection/hover picking is only meaningful while idle. During any active
97
+ // interaction the pointer belongs to that interaction's body, not to selecting
98
+ // a different object — the picking choke point should not route a hover/click
99
+ // to selection while this is false.
100
+ export function selectionEnabled(scope: InteractionScope): boolean {
101
+ return scope.kind === 'idle'
102
+ }
103
+
104
+ // Derived views of the scope that mirror the legacy `useEditor` flags they
105
+ // replaced. Each returns null unless that exact interaction is active, so a
106
+ // stale payload is unrepresentable: the value is a pure function of the single
107
+ // authoritative scope, not an independent flag that can drift out of sync.
108
+
109
+ // The legacy `activeHandleDrag` flag. `label` keeps the legacy field name so
110
+ // downstream `=== ROTATE_HANDLE_DRAG_LABEL` / `=== 'height'` checks are unchanged.
111
+ export function handleDragInfo(scope: InteractionScope): { nodeId: string; label: string } | null {
112
+ return scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
113
+ }
114
+
115
+ // The legacy `editingHole` flag (`SurfaceHoleTarget`).
116
+ export function editingHoleInfo(
117
+ scope: InteractionScope,
118
+ ): { nodeId: string; holeIndex: number } | null {
119
+ return scope.kind === 'reshaping' && scope.reshape === 'hole' && scope.holeIndex !== undefined
120
+ ? { nodeId: scope.nodeId, holeIndex: scope.holeIndex }
121
+ : null
122
+ }
123
+
124
+ // Build the scope payload for a hole reshape, so producers don't re-spell the
125
+ // discriminator at every call site.
126
+ export function holeEditScope(target: {
127
+ nodeId: string
128
+ holeIndex: number
129
+ }): ActiveInteractionScope {
130
+ return {
131
+ kind: 'reshaping',
132
+ nodeId: target.nodeId,
133
+ reshape: 'hole',
134
+ holeIndex: target.holeIndex,
135
+ }
136
+ }
137
+
138
+ // True while the selected node's geometry is being curved (legacy
139
+ // `curvingWall` / `curvingFence` — now one scope; the wall-vs-fence kind is
140
+ // recovered from the reshaped node's type, looked up from the scene by nodeId).
141
+ export function isCurveReshape(scope: InteractionScope): boolean {
142
+ return scope.kind === 'reshaping' && scope.reshape === 'curve'
143
+ }
144
+
145
+ // The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
146
+ // itself (consumers fetch the node from the scene by `nodeId`; it is stable for
147
+ // the duration of the drag).
148
+ export function endpointReshapeInfo(
149
+ scope: InteractionScope,
150
+ ): { nodeId: string; endpoint: 'start' | 'end' } | null {
151
+ return scope.kind === 'reshaping' && scope.reshape === 'endpoint' && scope.endpoint !== undefined
152
+ ? { nodeId: scope.nodeId, endpoint: scope.endpoint }
153
+ : null
154
+ }
155
+
156
+ export function controlPointReshapeInfo(
157
+ scope: InteractionScope,
158
+ ): { nodeId: string; index: number } | null {
159
+ return scope.kind === 'reshaping' &&
160
+ scope.reshape === 'control-point' &&
161
+ scope.index !== undefined
162
+ ? { nodeId: scope.nodeId, index: scope.index }
163
+ : null
164
+ }
165
+
166
+ export function tangentReshapeInfo(
167
+ scope: InteractionScope,
168
+ ): { nodeId: string; index: number; side: 'in' | 'out' } | null {
169
+ return scope.kind === 'reshaping' &&
170
+ scope.reshape === 'tangent' &&
171
+ scope.index !== undefined &&
172
+ scope.side !== undefined
173
+ ? { nodeId: scope.nodeId, index: scope.index, side: scope.side }
174
+ : null
175
+ }
176
+
177
+ // The id of the node being reshaped (any reshape kind), for the scene lookup
178
+ // that recovers the full node payload a few consumers still need.
179
+ export function reshapingNodeId(scope: InteractionScope): string | null {
180
+ return scope.kind === 'reshaping' ? scope.nodeId : null
181
+ }
182
+
183
+ // Builders so producers don't re-spell the discriminator at every call site.
184
+ export function curveReshapeScope(nodeId: string): ActiveInteractionScope {
185
+ return { kind: 'reshaping', nodeId, reshape: 'curve' }
186
+ }
187
+
188
+ export function endpointReshapeScope(
189
+ nodeId: string,
190
+ endpoint: 'start' | 'end',
191
+ ): ActiveInteractionScope {
192
+ return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
193
+ }
194
+
195
+ export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope {
196
+ return { kind: 'reshaping', nodeId, reshape: 'control-point', index }
197
+ }
198
+
199
+ export function tangentReshapeScope(
200
+ nodeId: string,
201
+ index: number,
202
+ side: 'in' | 'out',
203
+ ): ActiveInteractionScope {
204
+ return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side }
205
+ }
206
+
207
+ // Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
208
+ // HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
209
+ export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
210
+ return { kind: 'reshaping', nodeId, reshape: 'boundary' }
211
+ }
@@ -5,10 +5,10 @@ import { useViewer } from '@pascal-app/viewer'
5
5
  function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {
6
6
  const { nodes } = useScene.getState()
7
7
  const level = nodes[levelId]
8
- if (!level || level.type !== 'level' || !level.parentId) return null
8
+ if (level?.type !== 'level' || !level.parentId) return null
9
9
 
10
10
  const building = nodes[level.parentId as AnyNodeId]
11
- if (!building || building.type !== 'building') return null
11
+ if (building?.type !== 'building') return null
12
12
 
13
13
  const siblingLevelIds = (building as BuildingNode).children.filter(
14
14
  (childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',