@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,17 +1,30 @@
1
1
  import {
2
2
  type AnyNode,
3
+ collectAlignmentAnchors,
4
+ createSurfaceOpeningPreviewController,
5
+ type EventSuffix,
3
6
  emitter,
4
7
  type GridEvent,
5
8
  type LevelNode,
9
+ movingAlignmentAnchors,
10
+ type NodeEvent,
11
+ resolveAlignment,
6
12
  StairNode,
7
13
  StairSegmentNode,
14
+ syncAutoStairOpenings,
8
15
  useScene,
9
16
  } from '@pascal-app/core'
17
+ import { useAlignmentGuides } from '@pascal-app/editor'
10
18
  import { useViewer } from '@pascal-app/viewer'
11
19
  import { useEffect, useMemo, useRef } from 'react'
12
20
  import * as THREE from 'three'
13
21
  import { sfxEmitter } from '../../../lib/sfx-bus'
22
+ import {
23
+ resolveStairDestinationLevel,
24
+ resolveStairPlacementLevelId,
25
+ } from '../../../lib/stair-levels'
14
26
  import { CursorSphere } from '../shared/cursor-sphere'
27
+ import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
15
28
  import {
16
29
  DEFAULT_CURVED_STAIR_INNER_RADIUS,
17
30
  DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
@@ -32,6 +45,23 @@ import {
32
45
  } from './stair-defaults'
33
46
 
34
47
  const GRID_OFFSET = 0.02
48
+ /** Figma-style alignment-snap threshold (meters), matching the move tools. */
49
+ const ALIGNMENT_THRESHOLD_M = 0.08
50
+ type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
51
+
52
+ const CLICK_TRIGGER_KINDS = [
53
+ 'shelf',
54
+ 'item',
55
+ 'slab',
56
+ 'ceiling',
57
+ 'wall',
58
+ 'fence',
59
+ 'column',
60
+ 'roof',
61
+ 'roof-segment',
62
+ 'stair',
63
+ 'stair-segment',
64
+ ] as const
35
65
 
36
66
  /**
37
67
  * Generates the step-profile geometry for the ghost preview.
@@ -69,19 +99,10 @@ function createStairPreviewGeometry(): THREE.BufferGeometry {
69
99
  }
70
100
 
71
101
  /**
72
- * Creates a stair group with one default stair segment at the given position/rotation.
102
+ * Creates a default straight stair segment.
73
103
  */
74
- function commitStairPlacement(
75
- levelId: LevelNode['id'],
76
- position: [number, number, number],
77
- rotation: number,
78
- ): void {
79
- const { createNodes, nodes } = useScene.getState()
80
-
81
- const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
82
- const name = `Staircase ${stairCount + 1}`
83
-
84
- const segment = StairSegmentNode.parse({
104
+ function createDefaultStairSegment() {
105
+ return StairSegmentNode.parse({
85
106
  segmentType: 'stair',
86
107
  width: DEFAULT_STAIR_WIDTH,
87
108
  length: DEFAULT_STAIR_LENGTH,
@@ -92,14 +113,24 @@ function commitStairPlacement(
92
113
  thickness: DEFAULT_STAIR_THICKNESS,
93
114
  position: [0, 0, 0],
94
115
  })
116
+ }
95
117
 
96
- const sortedLevels = Object.values(nodes)
97
- .filter((node): node is LevelNode => node.type === 'level')
98
- .sort((left, right) => left.level - right.level)
99
- const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
100
- const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
101
-
102
- const stair = StairNode.parse({
118
+ function createDefaultStairNode({
119
+ name,
120
+ levelId,
121
+ nextLevelId,
122
+ position,
123
+ rotation,
124
+ segmentId,
125
+ }: {
126
+ name: string
127
+ levelId: LevelNode['id']
128
+ nextLevelId: LevelNode['id']
129
+ position: [number, number, number]
130
+ rotation: number
131
+ segmentId: StairSegmentNode['id']
132
+ }) {
133
+ return StairNode.parse({
103
134
  name,
104
135
  position,
105
136
  rotation,
@@ -121,11 +152,55 @@ function commitStairPlacement(
121
152
  showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
122
153
  railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
123
154
  railingMode: DEFAULT_STAIR_RAILING_MODE,
124
- children: [segment.id],
155
+ children: [segmentId],
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Creates a stair group with one default stair segment at the given position/rotation.
161
+ */
162
+ function commitStairPlacement(
163
+ levelId: LevelNode['id'],
164
+ position: [number, number, number],
165
+ rotation: number,
166
+ ): void {
167
+ const { createNodes, nodes } = useScene.getState()
168
+ const placementLevelId = resolveStairPlacementLevelId(
169
+ nodes,
170
+ levelId,
171
+ useViewer.getState().selection.buildingId,
172
+ )
173
+ if (!placementLevelId) return
174
+
175
+ const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
176
+ const name = `Staircase ${stairCount + 1}`
177
+ const segment = createDefaultStairSegment()
178
+
179
+ const destinationPlan = resolveStairDestinationLevel({
180
+ createMissing: true,
181
+ fromLevelId: placementLevelId,
182
+ nodes,
183
+ })
184
+ const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
185
+
186
+ const stair = createDefaultStairNode({
187
+ name,
188
+ levelId: placementLevelId,
189
+ nextLevelId,
190
+ position,
191
+ rotation,
192
+ segmentId: segment.id,
125
193
  })
126
194
 
195
+ const createdLevel = destinationPlan?.createdLevel
196
+ const levelCreateOps =
197
+ createdLevel && destinationPlan.buildingId
198
+ ? [{ node: createdLevel, parentId: destinationPlan.buildingId }]
199
+ : []
200
+
127
201
  createNodes([
128
- { node: stair, parentId: levelId },
202
+ ...levelCreateOps,
203
+ { node: stair, parentId: placementLevelId },
129
204
  { node: segment, parentId: stair.id },
130
205
  ])
131
206
 
@@ -137,6 +212,7 @@ export const StairTool: React.FC = () => {
137
212
  const previewRef = useRef<THREE.Group>(null)
138
213
  const rotationRef = useRef(0)
139
214
  const previousGridPosRef = useRef<[number, number] | null>(null)
215
+ const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
140
216
  const currentLevelId = useViewer((state) => state.selection.levelId)
141
217
 
142
218
  const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
@@ -144,22 +220,144 @@ export const StairTool: React.FC = () => {
144
220
  useEffect(() => {
145
221
  if (!currentLevelId) return
146
222
 
223
+ const openingPreview = createSurfaceOpeningPreviewController()
224
+
147
225
  // Reset rotation when tool activates
148
226
  rotationRef.current = 0
149
227
  if (previewRef.current) previewRef.current.rotation.y = 0
228
+ lastCanonicalPositionRef.current = null
229
+
230
+ const buildPreviewScene = (position: [number, number, number], rotation: number) => {
231
+ const nodes = useScene.getState().nodes
232
+ const placementLevelId = resolveStairPlacementLevelId(
233
+ nodes,
234
+ currentLevelId,
235
+ useViewer.getState().selection.buildingId,
236
+ )
237
+ if (!placementLevelId) return null
238
+
239
+ const destinationPlan = resolveStairDestinationLevel({
240
+ createMissing: true,
241
+ fromLevelId: placementLevelId,
242
+ nodes,
243
+ })
244
+ const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
245
+ const segment = createDefaultStairSegment()
246
+ const stair = createDefaultStairNode({
247
+ name: 'Staircase Preview',
248
+ levelId: placementLevelId,
249
+ nextLevelId,
250
+ position,
251
+ rotation,
252
+ segmentId: segment.id,
253
+ })
254
+ const previewNodes = {
255
+ ...nodes,
256
+ ...(destinationPlan?.createdLevel
257
+ ? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel }
258
+ : {}),
259
+ [stair.id]: { ...stair, parentId: placementLevelId },
260
+ [segment.id]: { ...segment, parentId: stair.id },
261
+ } as Record<string, AnyNode>
262
+
263
+ return { placementLevelId, previewNodes, stair }
264
+ }
150
265
 
151
- const onGridMove = (event: GridEvent) => {
152
- const gridX = Math.round(event.localPosition[0] * 2) / 2
153
- const gridZ = Math.round(event.localPosition[2] * 2) / 2
154
- const y = event.localPosition[1]
155
-
266
+ const applyDraftPreview = (position: [number, number, number], rotation: number) => {
267
+ const preview = buildPreviewScene(position, rotation)
268
+ const visualPosition = preview
269
+ ? getFloorStackPreviewPosition({
270
+ node: preview.stair,
271
+ position,
272
+ rotation,
273
+ levelId: preview.placementLevelId,
274
+ nodes: preview.previewNodes,
275
+ })
276
+ : position
156
277
  if (cursorRef.current) {
157
- cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
278
+ cursorRef.current.position.set(
279
+ visualPosition[0],
280
+ visualPosition[1] + GRID_OFFSET,
281
+ visualPosition[2],
282
+ )
158
283
  }
159
284
 
160
285
  if (previewRef.current) {
161
- previewRef.current.position.set(gridX, y, gridZ)
286
+ previewRef.current.position.set(...visualPosition)
287
+ previewRef.current.rotation.y = rotation
288
+ }
289
+
290
+ if (!preview) {
291
+ openingPreview.clear()
292
+ return
293
+ }
294
+
295
+ openingPreview.apply(syncAutoStairOpenings(preview.previewNodes))
296
+ }
297
+
298
+ // Alignment candidates — anchors of every alignable object; refreshed
299
+ // after each placement. The moving stair aligns by its footprint edges so
300
+ // users can snap the run side against walls, slabs, elevators, or another
301
+ // stair instead of only lining up the invisible origin point.
302
+ let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
303
+ const resolveStairFootprintAlignment = (
304
+ x: number,
305
+ z: number,
306
+ rotation: number,
307
+ ): ReturnType<typeof resolveAlignment> | null => {
308
+ const preview = buildPreviewScene([x, 0, z], rotation)
309
+ const moving = preview
310
+ ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation)
311
+ : []
312
+ if (moving.length === 0) return null
313
+ return resolveAlignment({
314
+ moving,
315
+ candidates: alignmentCandidates,
316
+ threshold: ALIGNMENT_THRESHOLD_M,
317
+ })
318
+ }
319
+ // The probe is the RAW cursor, not the grid-snapped point: resolving
320
+ // against the grid point would only catch anchors that happen to sit near
321
+ // a grid line. Matched axes use the raw probe + snap delta; unmatched axes
322
+ // keep the normal grid snap. Alt bypasses.
323
+ const alignPoint = (
324
+ gridX: number,
325
+ gridZ: number,
326
+ rawX: number,
327
+ rawZ: number,
328
+ bypass: boolean,
329
+ ): [number, number] => {
330
+ if (bypass || alignmentCandidates.length === 0) {
331
+ useAlignmentGuides.getState().clear()
332
+ return [gridX, gridZ]
162
333
  }
334
+ const ar = resolveStairFootprintAlignment(rawX, rawZ, rotationRef.current)
335
+ if (!ar || ar.guides.length === 0) {
336
+ useAlignmentGuides.getState().clear()
337
+ return [gridX, gridZ]
338
+ }
339
+ let x = gridX
340
+ let z = gridZ
341
+ if (ar.snap) {
342
+ if (ar.guides.some((guide) => guide.axis === 'x')) x = rawX + ar.snap.dx
343
+ if (ar.guides.some((guide) => guide.axis === 'z')) z = rawZ + ar.snap.dz
344
+ }
345
+ const finalAlignment = resolveStairFootprintAlignment(x, z, rotationRef.current)
346
+ useAlignmentGuides.getState().set(finalAlignment?.guides ?? ar.guides)
347
+ return [x, z]
348
+ }
349
+
350
+ const onGridMove = (event: GridEvent) => {
351
+ const [gridX, gridZ] = alignPoint(
352
+ Math.round(event.localPosition[0] * 2) / 2,
353
+ Math.round(event.localPosition[2] * 2) / 2,
354
+ event.localPosition[0],
355
+ event.localPosition[2],
356
+ event.nativeEvent?.altKey === true,
357
+ )
358
+ const position: [number, number, number] = [gridX, 0, gridZ]
359
+ lastCanonicalPositionRef.current = position
360
+ applyDraftPreview(position, rotationRef.current)
163
361
 
164
362
  if (
165
363
  previousGridPosRef.current &&
@@ -171,12 +369,34 @@ export const StairTool: React.FC = () => {
171
369
  previousGridPosRef.current = [gridX, gridZ]
172
370
  }
173
371
 
174
- const onGridClick = (event: GridEvent) => {
372
+ const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
373
+ const [gridX, gridZ] = alignPoint(
374
+ Math.round(event.localPosition[0] * 2) / 2,
375
+ Math.round(event.localPosition[2] * 2) / 2,
376
+ event.localPosition[0],
377
+ event.localPosition[2],
378
+ event.nativeEvent?.altKey === true,
379
+ )
380
+ return [gridX, 0, gridZ]
381
+ }
382
+
383
+ const commitAtCursor = (event: ClickTriggerEvent) => {
175
384
  if (!currentLevelId) return
385
+ const nodeEvent = 'node' in event ? (event as NodeEvent<AnyNode>) : null
386
+ if (nodeEvent) {
387
+ nodeEvent.stopPropagation()
388
+ nodeEvent.nativeEvent.stopPropagation()
389
+ }
176
390
 
177
- const gridX = Math.round(event.localPosition[0] * 2) / 2
178
- const gridZ = Math.round(event.localPosition[2] * 2) / 2
179
- commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
391
+ const position = nodeEvent
392
+ ? lastCanonicalPositionRef.current
393
+ : getAlignedGridPosition(event as GridEvent)
394
+ if (!position) return
395
+
396
+ commitStairPlacement(currentLevelId, position, rotationRef.current)
397
+ openingPreview.clear()
398
+ alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
399
+ useAlignmentGuides.getState().clear()
180
400
  }
181
401
 
182
402
  const onKeyDown = (event: KeyboardEvent) => {
@@ -193,20 +413,34 @@ export const StairTool: React.FC = () => {
193
413
  event.preventDefault()
194
414
  sfxEmitter.emit('sfx:item-rotate')
195
415
  rotationRef.current += rotationDelta
196
- if (previewRef.current) {
416
+ if (lastCanonicalPositionRef.current) {
417
+ applyDraftPreview(lastCanonicalPositionRef.current, rotationRef.current)
418
+ } else if (previewRef.current) {
197
419
  previewRef.current.rotation.y = rotationRef.current
198
420
  }
199
421
  }
200
422
  }
201
423
 
202
424
  emitter.on('grid:move', onGridMove)
203
- emitter.on('grid:click', onGridClick)
425
+ emitter.on('grid:click', commitAtCursor)
426
+ type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
427
+ type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
428
+ for (const kind of CLICK_TRIGGER_KINDS) {
429
+ const key = `${kind}:click` as ClickKey
430
+ emitter.on(key, commitAtCursor as never)
431
+ }
204
432
  window.addEventListener('keydown', onKeyDown)
205
433
 
206
434
  return () => {
207
435
  emitter.off('grid:move', onGridMove)
208
- emitter.off('grid:click', onGridClick)
436
+ emitter.off('grid:click', commitAtCursor)
437
+ for (const kind of CLICK_TRIGGER_KINDS) {
438
+ const key = `${kind}:click` as ClickKey
439
+ emitter.off(key, commitAtCursor as never)
440
+ }
209
441
  window.removeEventListener('keydown', onKeyDown)
442
+ useAlignmentGuides.getState().clear()
443
+ openingPreview.clear()
210
444
  }
211
445
  }, [currentLevelId])
212
446