@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -1,15 +0,0 @@
1
- export const DEFAULT_STAIR_TYPE = 'straight' as const
2
- export const DEFAULT_STAIR_WIDTH = 1.0
3
- export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
4
- export const DEFAULT_STAIR_FILL_TO_FLOOR = true
5
- export const DEFAULT_STAIR_THICKNESS = 0.25
6
- export const DEFAULT_STAIR_OPENING_OFFSET = 0
7
- export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
8
- export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
9
- export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
10
- export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const
11
- export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9
12
- export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true
13
- export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true
14
- export const DEFAULT_STAIR_RAILING_MODE = 'both' as const
15
- export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92
@@ -1,682 +0,0 @@
1
- import {
2
- type AnyNode,
3
- collectAlignmentAnchors,
4
- createSizedStairFlight,
5
- createSurfaceOpeningPreviewController,
6
- DEFAULT_LEVEL_HEIGHT,
7
- emitter,
8
- type GridEvent,
9
- getFloorStackedPosition,
10
- getLevelFloorToFloorHeight,
11
- type LevelNode,
12
- movingAlignmentAnchors,
13
- type NodeEvent,
14
- planStairCreation,
15
- planStairSizing,
16
- resolveAlignment,
17
- resolveFrozenFloorPlacementPatch,
18
- resolveSupportSlabPatch,
19
- StairNode,
20
- type StairSegmentNode,
21
- syncAutoStairOpenings,
22
- useScene,
23
- } from '@pascal-app/core'
24
- import { useViewer } from '@pascal-app/viewer'
25
- import { useThree } from '@react-three/fiber'
26
- import { useEffect, useMemo, useRef } from 'react'
27
- import * as THREE from 'three'
28
- import { EDITOR_LAYER } from '../../../lib/constants'
29
- import { sfxEmitter } from '../../../lib/sfx-bus'
30
- import {
31
- resolveStairDestinationLevel,
32
- resolveStairPlacementLevelId,
33
- } from '../../../lib/stair-levels'
34
-
35
- import useAlignmentGuides from '../../../store/use-alignment-guides'
36
- import useEditor, {
37
- isAlignmentGuideActive,
38
- isGridSnapActive,
39
- isMagneticSnapActive,
40
- } from '../../../store/use-editor'
41
-
42
- import useFacingPose from '../../../store/use-facing-pose'
43
- import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
44
- import { CursorSphere } from '../shared/cursor-sphere'
45
- import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
46
- import {
47
- type PointerSupportSurface,
48
- resolvePointerSupportSurface,
49
- } from '../shared/pointer-support-cap'
50
- import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard'
51
- import {
52
- DEFAULT_CURVED_STAIR_INNER_RADIUS,
53
- DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
54
- DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
55
- DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
56
- DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
57
- DEFAULT_SPIRAL_TOP_LANDING_MODE,
58
- DEFAULT_STAIR_ATTACHMENT_SIDE,
59
- DEFAULT_STAIR_FILL_TO_FLOOR,
60
- DEFAULT_STAIR_OPENING_OFFSET,
61
- DEFAULT_STAIR_RAILING_HEIGHT,
62
- DEFAULT_STAIR_RAILING_MODE,
63
- DEFAULT_STAIR_THICKNESS,
64
- DEFAULT_STAIR_TYPE,
65
- DEFAULT_STAIR_WIDTH,
66
- } from './stair-defaults'
67
-
68
- const GRID_OFFSET = 0.02
69
- /** Figma-style alignment-snap threshold (meters), matching the move tools. */
70
- const ALIGNMENT_THRESHOLD_M = 0.08
71
- type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
72
- type MoveTriggerEvent = GridEvent | NodeEvent<AnyNode>
73
-
74
- /**
75
- * Generates the step-profile geometry for the ghost preview.
76
- * Same algorithm as StairSystem's generateStairSegmentGeometry.
77
- */
78
- function createStairPreviewGeometry(rise: number): THREE.BufferGeometry {
79
- const { stepCount, length, riserHeight, going: treadDepth } = planStairSizing(rise)
80
-
81
- const shape = new THREE.Shape()
82
- shape.moveTo(0, 0)
83
-
84
- for (let i = 0; i < stepCount; i++) {
85
- shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
86
- shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
87
- }
88
-
89
- // Fill to floor (absoluteHeight = 0)
90
- shape.lineTo(length, 0)
91
- shape.lineTo(0, 0)
92
-
93
- const geometry = new THREE.ExtrudeGeometry(shape, {
94
- steps: 1,
95
- depth: DEFAULT_STAIR_WIDTH,
96
- bevelEnabled: false,
97
- })
98
-
99
- // Rotate so extrusion is along X (width), shape profile in XZ plane
100
- const matrix = new THREE.Matrix4()
101
- matrix.makeRotationY(-Math.PI / 2)
102
- matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0)
103
- geometry.applyMatrix4(matrix)
104
-
105
- return geometry
106
- }
107
-
108
- /**
109
- * Creates a default straight stair segment climbing `rise` — the storey it is
110
- * dropped on, not a constant: the placed stair has no explicit `totalRise`, so
111
- * this is the height `syncStairRises` immediately converges it to anyway.
112
- */
113
- function resolvePlacedStairBase(
114
- nodes: Record<string, AnyNode>,
115
- levelId: LevelNode['id'],
116
- stair: StairNode,
117
- supportSurface: PointerSupportSurface | null,
118
- seed: StairSegmentNode,
119
- ): number {
120
- // Keep the pointer-selected base when an overlapping deck is above the cursor.
121
- const base = getFloorStackedPosition({
122
- node: stair,
123
- nodes: { ...nodes, [seed.id]: { ...seed, parentId: stair.id } },
124
- position: stair.position,
125
- rotation: stair.rotation,
126
- levelId,
127
- maxElevation: supportSurface?.elevation ?? null,
128
- })[1]
129
- return base
130
- }
131
-
132
- function createSeedStairSegment(rise: number) {
133
- return createSizedStairFlight(rise, {
134
- width: DEFAULT_STAIR_WIDTH,
135
- attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
136
- fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
137
- thickness: DEFAULT_STAIR_THICKNESS,
138
- })
139
- }
140
-
141
- function createDefaultStairNode({
142
- name,
143
- levelId,
144
- nextLevelId,
145
- position,
146
- rotation,
147
- segmentId,
148
- }: {
149
- name: string
150
- levelId: LevelNode['id']
151
- nextLevelId: LevelNode['id']
152
- position: [number, number, number]
153
- rotation: number
154
- segmentId: StairSegmentNode['id']
155
- }) {
156
- return StairNode.parse({
157
- name,
158
- parentId: levelId,
159
- position,
160
- rotation,
161
- stairType: DEFAULT_STAIR_TYPE,
162
- uniformRisers: true,
163
- fromLevelId: levelId,
164
- toLevelId: nextLevelId,
165
- slabOpeningMode: 'destination',
166
- openingOffset: DEFAULT_STAIR_OPENING_OFFSET,
167
- width: DEFAULT_STAIR_WIDTH,
168
- thickness: DEFAULT_STAIR_THICKNESS,
169
- fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
170
- innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS,
171
- sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
172
- topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE,
173
- topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
174
- showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
175
- showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
176
- railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
177
- railingMode: DEFAULT_STAIR_RAILING_MODE,
178
- children: [segmentId],
179
- })
180
- }
181
-
182
- /**
183
- * Creates a stair group with one default stair segment at the given position/rotation.
184
- */
185
- function commitStairPlacement(
186
- levelId: LevelNode['id'],
187
- position: [number, number, number],
188
- rotation: number,
189
- supportSurface: PointerSupportSurface | null,
190
- ): void {
191
- const { createNodes, nodes } = useScene.getState()
192
- const placementLevelId = resolveStairPlacementLevelId(
193
- nodes,
194
- levelId,
195
- useViewer.getState().selection.buildingId,
196
- )
197
- if (!placementLevelId) return
198
-
199
- const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
200
- const name = `Staircase ${stairCount + 1}`
201
- const storeyRise = getLevelFloorToFloorHeight(placementLevelId, nodes)
202
- if (!(storeyRise > 0)) return
203
- const seed = createSeedStairSegment(storeyRise)
204
-
205
- const destinationPlan = resolveStairDestinationLevel({
206
- createMissing: true,
207
- fromLevelId: placementLevelId,
208
- nodes,
209
- })
210
- const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
211
-
212
- const stair = StairNode.parse({
213
- ...createDefaultStairNode({
214
- name,
215
- levelId: placementLevelId,
216
- nextLevelId,
217
- position,
218
- rotation,
219
- segmentId: seed.id,
220
- }),
221
- parentId: placementLevelId,
222
- })
223
- const base = resolvePlacedStairBase(nodes, placementLevelId, stair, supportSurface, seed)
224
- let segment: StairSegmentNode
225
- try {
226
- segment = {
227
- ...planStairCreation(
228
- stair,
229
- nodes,
230
- {
231
- width: seed.width,
232
- attachmentSide: seed.attachmentSide,
233
- fillToFloor: seed.fillToFloor,
234
- thickness: seed.thickness,
235
- },
236
- base,
237
- ).flight,
238
- id: seed.id,
239
- }
240
- } catch (error) {
241
- if (error instanceof RangeError) return
242
- throw error
243
- }
244
- stair.stepCount = segment.stepCount
245
- const prospectiveNodes = {
246
- ...nodes,
247
- [stair.id]: stair,
248
- [segment.id]: { ...segment, parentId: stair.id },
249
- } as Record<string, AnyNode>
250
- const placementPatch = supportSurface?.sourceNodeId
251
- ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, {
252
- position,
253
- rotation,
254
- elevation: supportSurface.elevation,
255
- preferredSlabId: supportSurface.supportSlabId,
256
- })
257
- : {
258
- position,
259
- ...resolveSupportSlabPatch(stair, prospectiveNodes, {
260
- maxElevation: supportSurface?.elevation,
261
- }),
262
- }
263
- const committedStair = StairNode.parse({
264
- ...stair,
265
- ...placementPatch,
266
- })
267
-
268
- const createdLevel = destinationPlan?.createdLevel
269
- const levelCreateOps =
270
- createdLevel && destinationPlan.buildingId
271
- ? [{ node: createdLevel, parentId: destinationPlan.buildingId }]
272
- : []
273
-
274
- createNodes([
275
- ...levelCreateOps,
276
- { node: committedStair, parentId: placementLevelId },
277
- { node: segment, parentId: committedStair.id },
278
- ])
279
-
280
- sfxEmitter.emit('sfx:structure-build')
281
- }
282
-
283
- export const StairTool: React.FC = () => {
284
- const camera = useThree((state) => state.camera)
285
- const cameraRef = useRef(camera)
286
- cameraRef.current = camera
287
- const cursorRef = useRef<THREE.Group>(null)
288
- const previewRef = useRef<THREE.Group>(null)
289
- const previewMeshRef = useRef<THREE.Mesh>(null)
290
- const rotationRef = useRef(0)
291
- const supportSurfaceRef = useRef<PointerSupportSurface | null>(null)
292
- const previousGridPosRef = useRef<[number, number] | null>(null)
293
- const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
294
- const currentLevelId = useViewer((state) => state.selection.levelId)
295
-
296
- const previewRise = useScene((state) =>
297
- currentLevelId ? getLevelFloorToFloorHeight(currentLevelId, state.nodes) : DEFAULT_LEVEL_HEIGHT,
298
- )
299
- const previewRiseRef = useRef(previewRise)
300
- previewRiseRef.current = previewRise
301
- const previewGeometry = useMemo(() => createStairPreviewGeometry(DEFAULT_LEVEL_HEIGHT), [])
302
- const ownedGeometryRef = useRef(previewGeometry)
303
- const geometryRiseRef = useRef(DEFAULT_LEVEL_HEIGHT)
304
- useEffect(() => () => ownedGeometryRef.current.dispose(), [])
305
-
306
- useEffect(() => {
307
- if (!currentLevelId) return
308
-
309
- const openingPreview = createSurfaceOpeningPreviewController()
310
- // Refuses the duplicate commit triggers a single physical click produces
311
- // — see `stair-click-guard.ts`. Fresh per armed session.
312
- const commitGate = createStairCommitGate()
313
-
314
- // Reset rotation when tool activates
315
- rotationRef.current = 0
316
- useStairBuildPreview.getState().reset()
317
- if (previewRef.current) {
318
- previewRef.current.rotation.y = 0
319
- previewRef.current.scale.y = 1
320
- }
321
- lastCanonicalPositionRef.current = null
322
- supportSurfaceRef.current = null
323
-
324
- const buildPreviewScene = (
325
- position: [number, number, number],
326
- rotation: number,
327
- supportSurface: PointerSupportSurface | null,
328
- ) => {
329
- const nodes = useScene.getState().nodes
330
- const placementLevelId = resolveStairPlacementLevelId(
331
- nodes,
332
- currentLevelId,
333
- useViewer.getState().selection.buildingId,
334
- )
335
- if (!placementLevelId) return null
336
-
337
- const destinationPlan = resolveStairDestinationLevel({
338
- createMissing: true,
339
- fromLevelId: placementLevelId,
340
- nodes,
341
- })
342
- const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
343
- const storeyRise = getLevelFloorToFloorHeight(placementLevelId, nodes)
344
- if (!(storeyRise > 0)) return null
345
- const seed = createSeedStairSegment(storeyRise)
346
- const stair = createDefaultStairNode({
347
- name: 'Staircase Preview',
348
- levelId: placementLevelId,
349
- nextLevelId,
350
- position,
351
- rotation,
352
- segmentId: seed.id,
353
- })
354
- const base = resolvePlacedStairBase(nodes, placementLevelId, stair, supportSurface, seed)
355
- let segment: StairSegmentNode
356
- try {
357
- segment = {
358
- ...planStairCreation(
359
- stair,
360
- nodes,
361
- {
362
- width: seed.width,
363
- attachmentSide: seed.attachmentSide,
364
- fillToFloor: seed.fillToFloor,
365
- thickness: seed.thickness,
366
- },
367
- base,
368
- ).flight,
369
- id: seed.id,
370
- }
371
- } catch (error) {
372
- if (error instanceof RangeError) return null
373
- throw error
374
- }
375
- stair.stepCount = segment.stepCount
376
- const previewNodes = {
377
- ...nodes,
378
- ...(destinationPlan?.createdLevel
379
- ? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel }
380
- : {}),
381
- [stair.id]: { ...stair, parentId: placementLevelId },
382
- [segment.id]: { ...segment, parentId: stair.id },
383
- } as Record<string, AnyNode>
384
-
385
- return { placementLevelId, previewNodes, stair, rise: segment.height }
386
- }
387
-
388
- // The preview rebuild (full-scene copy + destination-level resolution +
389
- // auto-opening CSG) is expensive; `grid:move` fires it every pointer event
390
- // but the placed position is grid-snapped, so within a cell every rebuild
391
- // is identical. Dedupe on the snapped position + rotation so we rebuild
392
- // only when the staircase would actually land somewhere new — this is the
393
- // difference between a smooth and a stuttering stair tool (the elevator is
394
- // cheap because it has no opening sync).
395
- let lastPreviewKey: string | null = null
396
-
397
- const applyDraftPreview = (
398
- position: [number, number, number],
399
- rotation: number,
400
- supportSurface: PointerSupportSurface | null,
401
- ) => {
402
- const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'},${previewRiseRef.current}`
403
- if (key === lastPreviewKey) return
404
- lastPreviewKey = key
405
- const preview = buildPreviewScene(position, rotation, supportSurface)
406
- useStairBuildPreview
407
- .getState()
408
- .setPreview(preview ? [position[0], position[2]] : null, rotation, preview?.rise ?? null)
409
- const frozenPatch =
410
- preview && supportSurface?.sourceNodeId
411
- ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, {
412
- position,
413
- rotation,
414
- elevation: supportSurface.elevation,
415
- preferredSlabId: supportSurface.supportSlabId,
416
- })
417
- : null
418
- const previewPosition = frozenPatch?.position ?? position
419
- const previewStair = frozenPatch
420
- ? ({ ...preview?.stair, ...frozenPatch } as AnyNode)
421
- : preview?.stair
422
- const visualPosition =
423
- preview && previewStair
424
- ? getFloorStackPreviewPosition({
425
- node: previewStair,
426
- position: previewPosition,
427
- rotation,
428
- levelId: preview.placementLevelId,
429
- nodes: preview.previewNodes,
430
- maxElevation: supportSurface?.sourceNodeId ? null : supportSurface?.elevation,
431
- })
432
- : previewPosition
433
- if (cursorRef.current) {
434
- cursorRef.current.position.set(
435
- visualPosition[0],
436
- visualPosition[1] + GRID_OFFSET,
437
- visualPosition[2],
438
- )
439
- }
440
-
441
- if (previewRef.current) {
442
- previewRef.current.visible = Boolean(preview)
443
- previewRef.current.position.set(...visualPosition)
444
- previewRef.current.rotation.y = rotation
445
- if (preview && previewMeshRef.current && preview.rise !== geometryRiseRef.current) {
446
- const previous = previewMeshRef.current.geometry
447
- previewMeshRef.current.geometry = createStairPreviewGeometry(preview.rise)
448
- previous.dispose()
449
- ownedGeometryRef.current = previewMeshRef.current.geometry
450
- geometryRiseRef.current = preview.rise
451
- }
452
- previewRef.current.scale.y = 1
453
- }
454
-
455
- // Forward-facing triangle (editor-side overlay). The run ascends along
456
- // local +Z from the entry at z≈0; the stair's front is the -Z entry side,
457
- // so `reversed` points the triangle out of the entry (where you approach
458
- // from), sitting just before it — not inside the footprint or at the
459
- // elevated far end. Centre is the footprint mid-run (origin is the entry).
460
- if (!preview) {
461
- useFacingPose.getState().clear()
462
- openingPreview.clear()
463
- return
464
- }
465
- const sizing = planStairSizing(preview.rise)
466
- useFacingPose.getState().set({
467
- position: visualPosition,
468
- rotationY: rotation,
469
- depth: sizing.length,
470
- center: [0, sizing.length / 2],
471
- reversed: true,
472
- })
473
-
474
- openingPreview.apply(syncAutoStairOpenings(preview.previewNodes))
475
- }
476
-
477
- // Alignment candidates — anchors of every alignable object; refreshed
478
- // after each placement. The moving stair aligns by its footprint edges so
479
- // users can snap the run side against walls, slabs, elevators, or another
480
- // stair instead of only lining up the invisible origin point.
481
- let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
482
- const resolveStairFootprintAlignment = (
483
- x: number,
484
- z: number,
485
- rotation: number,
486
- ): ReturnType<typeof resolveAlignment> | null => {
487
- const preview = buildPreviewScene([x, 0, z], rotation, supportSurfaceRef.current)
488
- const moving = preview
489
- ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation)
490
- : []
491
- if (moving.length === 0) return null
492
- return resolveAlignment({
493
- moving,
494
- candidates: alignmentCandidates,
495
- threshold: ALIGNMENT_THRESHOLD_M,
496
- })
497
- }
498
- // The probe is the RAW cursor, not the grid-snapped point: resolving
499
- // against the grid point would only catch anchors that happen to sit near
500
- // a grid line. Matched axes use the raw probe + snap delta; unmatched axes
501
- // keep the normal grid snap. Guides are published in every snapping mode
502
- // (including Off); the magnetic pull toward them (applySnap) applies only in
503
- // 'lines' mode.
504
- const alignPoint = (
505
- gridX: number,
506
- gridZ: number,
507
- rawX: number,
508
- rawZ: number,
509
- bypass: boolean,
510
- applySnap: boolean,
511
- ): [number, number] => {
512
- if (bypass || alignmentCandidates.length === 0) {
513
- useAlignmentGuides.getState().clear()
514
- return [gridX, gridZ]
515
- }
516
- const ar = resolveStairFootprintAlignment(rawX, rawZ, rotationRef.current)
517
- if (!ar || ar.guides.length === 0) {
518
- useAlignmentGuides.getState().clear()
519
- return [gridX, gridZ]
520
- }
521
- if (!applySnap) {
522
- useAlignmentGuides.getState().set(ar.guides)
523
- return [gridX, gridZ]
524
- }
525
- let x = gridX
526
- let z = gridZ
527
- if (ar.snap) {
528
- if (ar.guides.some((guide) => guide.axis === 'x')) x = rawX + ar.snap.dx
529
- if (ar.guides.some((guide) => guide.axis === 'z')) z = rawZ + ar.snap.dz
530
- }
531
- const finalAlignment = resolveStairFootprintAlignment(x, z, rotationRef.current)
532
- useAlignmentGuides.getState().set(finalAlignment?.guides ?? ar.guides)
533
- return [x, z]
534
- }
535
-
536
- const resolveStairPosition = (event: MoveTriggerEvent): [number, number, number] | null => {
537
- const pointed = resolvePointerSupportSurface(cameraRef.current, event.position, {
538
- includeNodeTopSurfaces: true,
539
- })
540
- supportSurfaceRef.current = pointed
541
- const fallbackPosition =
542
- 'node' in event ? lastCanonicalPositionRef.current : event.localPosition
543
- if (!pointed?.localPoint && !fallbackPosition) return null
544
- const rawX = pointed?.localPoint?.[0] ?? fallbackPosition![0]
545
- const rawZ = pointed?.localPoint?.[2] ?? fallbackPosition![2]
546
- // Grid snap follows the global mode (live step so the HUD chip is
547
- // honest); Off keeps the raw cursor. Shift cycles the mode centrally.
548
- const step = useEditor.getState().gridSnapStep
549
- const [gridX, gridZ] = alignPoint(
550
- isGridSnapActive() ? Math.round(rawX / step) * step : rawX,
551
- isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ,
552
- rawX,
553
- rawZ,
554
- !isAlignmentGuideActive(),
555
- isMagneticSnapActive(),
556
- )
557
- return [gridX, 0, gridZ]
558
- }
559
-
560
- const onPointerMove = (event: MoveTriggerEvent) => {
561
- const position = resolveStairPosition(event)
562
- if (!position) return
563
- const [gridX, , gridZ] = position
564
- lastCanonicalPositionRef.current = position
565
- applyDraftPreview(position, rotationRef.current, supportSurfaceRef.current)
566
-
567
- if (
568
- (isGridSnapActive() || isMagneticSnapActive()) &&
569
- previousGridPosRef.current &&
570
- (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
571
- ) {
572
- sfxEmitter.emit('sfx:grid-snap')
573
- }
574
-
575
- previousGridPosRef.current = [gridX, gridZ]
576
- }
577
-
578
- const commitAtCursor = (event: ClickTriggerEvent) => {
579
- if (!currentLevelId) return
580
- // One physical click can reach here twice (node click synthesized on
581
- // pointerup + the native browser click driving `grid:click`) — see
582
- // `stair-click-guard.ts`. The gate refuses anything after a single-
583
- // continuation commit; the swallow below eats the same gesture's
584
- // follow-up click while the tool stays armed (repeat continuation).
585
- if (!commitGate.shouldCommit()) return
586
- const nodeEvent = 'node' in event ? (event as NodeEvent<AnyNode>) : null
587
- if (nodeEvent) {
588
- nodeEvent.stopPropagation()
589
- nodeEvent.nativeEvent.stopPropagation()
590
- // The canvas-level `grid:click` listener is out of stopPropagation's
591
- // reach — without this, the browser click that follows this
592
- // pointerup-synthesized node click commits a second stair.
593
- swallowFollowUpBrowserClick()
594
- }
595
-
596
- const position = resolveStairPosition(event)
597
- if (!position) return
598
-
599
- commitStairPlacement(currentLevelId, position, rotationRef.current, supportSurfaceRef.current)
600
- openingPreview.clear()
601
- // Commit cleared the opening preview, so force the next hover (even on the
602
- // same cell) to rebuild rather than dedupe against the just-placed key.
603
- lastPreviewKey = null
604
- useAlignmentGuides.getState().clear()
605
-
606
- // Single by default; the C-toggle ('point' context, shared with every
607
- // other placement tool) opts into placing more. On single, drop the tool
608
- // and the facing triangle so we fall back to select after one stair.
609
- if (useEditor.getState().getContinuation('point') === 'repeat') {
610
- alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
611
- } else {
612
- commitGate.markExited()
613
- useFacingPose.getState().clear()
614
- useEditor.getState().setTool(null)
615
- // Return to select mode explicitly (matches the spawn tool's exit).
616
- // The selection managers route node clicks only while
617
- // `mode === 'select'`; exiting with `mode: 'build'` + a null tool
618
- // left every click dead until the user pressed Escape.
619
- useEditor.getState().setMode('select')
620
- }
621
- }
622
-
623
- const onKeyDown = (event: KeyboardEvent) => {
624
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
625
- return
626
- }
627
-
628
- const ROTATION_STEP = Math.PI / 4
629
- let rotationDelta = 0
630
- if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
631
- else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
632
-
633
- if (rotationDelta !== 0) {
634
- event.preventDefault()
635
- sfxEmitter.emit('sfx:item-rotate')
636
- rotationRef.current += rotationDelta
637
- if (lastCanonicalPositionRef.current) {
638
- applyDraftPreview(
639
- lastCanonicalPositionRef.current,
640
- rotationRef.current,
641
- supportSurfaceRef.current,
642
- )
643
- } else if (previewRef.current) {
644
- previewRef.current.rotation.y = rotationRef.current
645
- }
646
- }
647
- }
648
-
649
- emitter.on('grid:move', onPointerMove)
650
- emitter.on('grid:click', commitAtCursor)
651
- emitter.on('node:click', commitAtCursor)
652
- emitter.on('node:move', onPointerMove)
653
- window.addEventListener('keydown', onKeyDown)
654
-
655
- return () => {
656
- emitter.off('grid:move', onPointerMove)
657
- emitter.off('grid:click', commitAtCursor)
658
- emitter.off('node:click', commitAtCursor)
659
- emitter.off('node:move', onPointerMove)
660
- window.removeEventListener('keydown', onKeyDown)
661
- useAlignmentGuides.getState().clear()
662
- openingPreview.clear()
663
- useFacingPose.getState().clear()
664
- useStairBuildPreview.getState().reset()
665
- }
666
- }, [currentLevelId])
667
-
668
- return (
669
- <group>
670
- <CursorSphere ref={cursorRef} />
671
-
672
- {/* 3D ghost preview — position/rotation updated imperatively. The
673
- forward-facing triangle is drawn by the editor-side overlay from the
674
- pose published in `applyDraftPreview`. */}
675
- <group ref={previewRef}>
676
- <mesh dispose={null} geometry={previewGeometry} layers={EDITOR_LAYER} ref={previewMeshRef}>
677
- <meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
678
- </mesh>
679
- </group>
680
- </group>
681
- )
682
- }