@pascal-app/editor 1.0.0-beta.5 → 1.0.0

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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,17 +1,21 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  collectAlignmentAnchors,
4
+ createDefaultStairSegment,
4
5
  createSurfaceOpeningPreviewController,
5
- type EventSuffix,
6
+ DEFAULT_LEVEL_HEIGHT,
6
7
  emitter,
7
8
  type GridEvent,
9
+ getFloorStackedPosition,
10
+ getLevelFloorToFloorHeight,
8
11
  type LevelNode,
9
12
  movingAlignmentAnchors,
10
13
  type NodeEvent,
11
14
  resolveAlignment,
15
+ resolveFrozenFloorPlacementPatch,
12
16
  resolveSupportSlabPatch,
13
17
  StairNode,
14
- StairSegmentNode,
18
+ type StairSegmentNode,
15
19
  syncAutoStairOpenings,
16
20
  useScene,
17
21
  } from '@pascal-app/core'
@@ -36,7 +40,10 @@ import useFacingPose from '../../../store/use-facing-pose'
36
40
  import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
37
41
  import { CursorSphere } from '../shared/cursor-sphere'
38
42
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
39
- import { resolvePointerSupportSurface } from '../shared/pointer-support-cap'
43
+ import {
44
+ type PointerSupportSurface,
45
+ resolvePointerSupportSurface,
46
+ } from '../shared/pointer-support-cap'
40
47
  import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard'
41
48
  import {
42
49
  DEFAULT_CURVED_STAIR_INNER_RADIUS,
@@ -47,7 +54,6 @@ import {
47
54
  DEFAULT_SPIRAL_TOP_LANDING_MODE,
48
55
  DEFAULT_STAIR_ATTACHMENT_SIDE,
49
56
  DEFAULT_STAIR_FILL_TO_FLOOR,
50
- DEFAULT_STAIR_HEIGHT,
51
57
  DEFAULT_STAIR_LENGTH,
52
58
  DEFAULT_STAIR_OPENING_OFFSET,
53
59
  DEFAULT_STAIR_RAILING_HEIGHT,
@@ -64,26 +70,12 @@ const ALIGNMENT_THRESHOLD_M = 0.08
64
70
  type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
65
71
  type MoveTriggerEvent = GridEvent | NodeEvent<AnyNode>
66
72
 
67
- const CLICK_TRIGGER_KINDS = [
68
- 'shelf',
69
- 'item',
70
- 'slab',
71
- 'ceiling',
72
- 'wall',
73
- 'fence',
74
- 'column',
75
- 'roof',
76
- 'roof-segment',
77
- 'stair',
78
- 'stair-segment',
79
- ] as const
80
-
81
73
  /**
82
74
  * Generates the step-profile geometry for the ghost preview.
83
75
  * Same algorithm as StairSystem's generateStairSegmentGeometry.
84
76
  */
85
- function createStairPreviewGeometry(): THREE.BufferGeometry {
86
- const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT
77
+ function createStairPreviewGeometry(rise: number): THREE.BufferGeometry {
78
+ const riserHeight = rise / DEFAULT_STAIR_STEP_COUNT
87
79
  const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT
88
80
 
89
81
  const shape = new THREE.Shape()
@@ -114,19 +106,40 @@ function createStairPreviewGeometry(): THREE.BufferGeometry {
114
106
  }
115
107
 
116
108
  /**
117
- * Creates a default straight stair segment.
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.
118
112
  */
119
- function createDefaultStairSegment() {
120
- return StairSegmentNode.parse({
121
- segmentType: 'stair',
113
+ function resolvePlacedStairRise(
114
+ nodes: Record<string, AnyNode>,
115
+ levelId: LevelNode['id'],
116
+ stair: StairNode,
117
+ supportSurface: PointerSupportSurface | null,
118
+ ): number {
119
+ // Same contract as `resolveStairTotalRise` for a stair that is not in the
120
+ // scene yet: the storey height minus whatever slab lifts the drop point,
121
+ // capped by the surface the pointer actually aims at (a floor under an
122
+ // overlapping deck must not elect the deck).
123
+ const base = getFloorStackedPosition({
124
+ node: stair,
125
+ nodes,
126
+ position: stair.position,
127
+ rotation: stair.rotation,
128
+ levelId,
129
+ maxElevation: supportSurface?.elevation ?? null,
130
+ })[1]
131
+ return getLevelFloorToFloorHeight(levelId, nodes) - base
132
+ }
133
+
134
+ function createSeedStairSegment(rise: number) {
135
+ return createDefaultStairSegment({
122
136
  width: DEFAULT_STAIR_WIDTH,
123
137
  length: DEFAULT_STAIR_LENGTH,
124
- height: DEFAULT_STAIR_HEIGHT,
138
+ height: rise,
125
139
  stepCount: DEFAULT_STAIR_STEP_COUNT,
126
140
  attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
127
141
  fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
128
142
  thickness: DEFAULT_STAIR_THICKNESS,
129
- position: [0, 0, 0],
130
143
  })
131
144
  }
132
145
 
@@ -177,7 +190,7 @@ function commitStairPlacement(
177
190
  levelId: LevelNode['id'],
178
191
  position: [number, number, number],
179
192
  rotation: number,
180
- supportElevationCap: number | null,
193
+ supportSurface: PointerSupportSurface | null,
181
194
  ): void {
182
195
  const { createNodes, nodes } = useScene.getState()
183
196
  const placementLevelId = resolveStairPlacementLevelId(
@@ -189,7 +202,7 @@ function commitStairPlacement(
189
202
 
190
203
  const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
191
204
  const name = `Staircase ${stairCount + 1}`
192
- const segment = createDefaultStairSegment()
205
+ const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes))
193
206
 
194
207
  const destinationPlan = resolveStairDestinationLevel({
195
208
  createMissing: true,
@@ -205,20 +218,35 @@ function commitStairPlacement(
205
218
  nextLevelId,
206
219
  position,
207
220
  rotation,
208
- segmentId: segment.id,
221
+ segmentId: seed.id,
209
222
  }),
210
223
  parentId: placementLevelId,
211
224
  })
225
+ const segment = {
226
+ ...seed,
227
+ height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface),
228
+ }
212
229
  const prospectiveNodes = {
213
230
  ...nodes,
214
231
  [stair.id]: stair,
215
232
  [segment.id]: { ...segment, parentId: stair.id },
216
233
  } as Record<string, AnyNode>
234
+ const placementPatch = supportSurface?.sourceNodeId
235
+ ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, {
236
+ position,
237
+ rotation,
238
+ elevation: supportSurface.elevation,
239
+ preferredSlabId: supportSurface.supportSlabId,
240
+ })
241
+ : {
242
+ position,
243
+ ...resolveSupportSlabPatch(stair, prospectiveNodes, {
244
+ maxElevation: supportSurface?.elevation,
245
+ }),
246
+ }
217
247
  const committedStair = StairNode.parse({
218
248
  ...stair,
219
- ...resolveSupportSlabPatch(stair, prospectiveNodes, {
220
- maxElevation: supportElevationCap,
221
- }),
249
+ ...placementPatch,
222
250
  })
223
251
 
224
252
  const createdLevel = destinationPlan?.createdLevel
@@ -243,12 +271,18 @@ export const StairTool: React.FC = () => {
243
271
  const cursorRef = useRef<THREE.Group>(null)
244
272
  const previewRef = useRef<THREE.Group>(null)
245
273
  const rotationRef = useRef(0)
246
- const supportCapRef = useRef<number | null>(null)
274
+ const supportSurfaceRef = useRef<PointerSupportSurface | null>(null)
247
275
  const previousGridPosRef = useRef<[number, number] | null>(null)
248
276
  const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
249
277
  const currentLevelId = useViewer((state) => state.selection.levelId)
250
278
 
251
- const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
279
+ const previewRise = useScene((state) =>
280
+ currentLevelId ? getLevelFloorToFloorHeight(currentLevelId, state.nodes) : DEFAULT_LEVEL_HEIGHT,
281
+ )
282
+ const previewRiseRef = useRef(previewRise)
283
+ previewRiseRef.current = previewRise
284
+ const previewGeometry = useMemo(() => createStairPreviewGeometry(previewRise), [previewRise])
285
+ useEffect(() => () => previewGeometry.dispose(), [previewGeometry])
252
286
 
253
287
  useEffect(() => {
254
288
  if (!currentLevelId) return
@@ -261,11 +295,18 @@ export const StairTool: React.FC = () => {
261
295
  // Reset rotation when tool activates
262
296
  rotationRef.current = 0
263
297
  useStairBuildPreview.getState().reset()
264
- if (previewRef.current) previewRef.current.rotation.y = 0
298
+ if (previewRef.current) {
299
+ previewRef.current.rotation.y = 0
300
+ previewRef.current.scale.y = 1
301
+ }
265
302
  lastCanonicalPositionRef.current = null
266
- supportCapRef.current = null
303
+ supportSurfaceRef.current = null
267
304
 
268
- const buildPreviewScene = (position: [number, number, number], rotation: number) => {
305
+ const buildPreviewScene = (
306
+ position: [number, number, number],
307
+ rotation: number,
308
+ supportSurface: PointerSupportSurface | null,
309
+ ) => {
269
310
  const nodes = useScene.getState().nodes
270
311
  const placementLevelId = resolveStairPlacementLevelId(
271
312
  nodes,
@@ -280,15 +321,19 @@ export const StairTool: React.FC = () => {
280
321
  nodes,
281
322
  })
282
323
  const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
283
- const segment = createDefaultStairSegment()
324
+ const seed = createSeedStairSegment(getLevelFloorToFloorHeight(placementLevelId, nodes))
284
325
  const stair = createDefaultStairNode({
285
326
  name: 'Staircase Preview',
286
327
  levelId: placementLevelId,
287
328
  nextLevelId,
288
329
  position,
289
330
  rotation,
290
- segmentId: segment.id,
331
+ segmentId: seed.id,
291
332
  })
333
+ const segment = {
334
+ ...seed,
335
+ height: resolvePlacedStairRise(nodes, placementLevelId, stair, supportSurface),
336
+ }
292
337
  const previewNodes = {
293
338
  ...nodes,
294
339
  ...(destinationPlan?.createdLevel
@@ -298,7 +343,7 @@ export const StairTool: React.FC = () => {
298
343
  [segment.id]: { ...segment, parentId: stair.id },
299
344
  } as Record<string, AnyNode>
300
345
 
301
- return { placementLevelId, previewNodes, stair }
346
+ return { placementLevelId, previewNodes, stair, rise: segment.height }
302
347
  }
303
348
 
304
349
  // The preview rebuild (full-scene copy + destination-level resolution +
@@ -313,23 +358,37 @@ export const StairTool: React.FC = () => {
313
358
  const applyDraftPreview = (
314
359
  position: [number, number, number],
315
360
  rotation: number,
316
- supportElevationCap: number | null,
361
+ supportSurface: PointerSupportSurface | null,
317
362
  ) => {
318
- const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportElevationCap?.toFixed(3) ?? 'none'}`
363
+ const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'}`
319
364
  if (key === lastPreviewKey) return
320
365
  lastPreviewKey = key
321
366
  useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation)
322
- const preview = buildPreviewScene(position, rotation)
323
- const visualPosition = preview
324
- ? getFloorStackPreviewPosition({
325
- node: preview.stair,
326
- position,
327
- rotation,
328
- levelId: preview.placementLevelId,
329
- nodes: preview.previewNodes,
330
- maxElevation: supportElevationCap,
331
- })
332
- : position
367
+ const preview = buildPreviewScene(position, rotation, supportSurface)
368
+ const frozenPatch =
369
+ preview && supportSurface?.sourceNodeId
370
+ ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, {
371
+ position,
372
+ rotation,
373
+ elevation: supportSurface.elevation,
374
+ preferredSlabId: supportSurface.supportSlabId,
375
+ })
376
+ : null
377
+ const previewPosition = frozenPatch?.position ?? position
378
+ const previewStair = frozenPatch
379
+ ? ({ ...preview?.stair, ...frozenPatch } as AnyNode)
380
+ : preview?.stair
381
+ const visualPosition =
382
+ preview && previewStair
383
+ ? getFloorStackPreviewPosition({
384
+ node: previewStair,
385
+ position: previewPosition,
386
+ rotation,
387
+ levelId: preview.placementLevelId,
388
+ nodes: preview.previewNodes,
389
+ maxElevation: supportSurface?.sourceNodeId ? null : supportSurface?.elevation,
390
+ })
391
+ : previewPosition
333
392
  if (cursorRef.current) {
334
393
  cursorRef.current.position.set(
335
394
  visualPosition[0],
@@ -341,6 +400,9 @@ export const StairTool: React.FC = () => {
341
400
  if (previewRef.current) {
342
401
  previewRef.current.position.set(...visualPosition)
343
402
  previewRef.current.rotation.y = rotation
403
+ // The ghost geometry is built for the storey height; squash it to the
404
+ // rise the placed flight will get on this surface.
405
+ previewRef.current.scale.y = preview ? preview.rise / previewRiseRef.current : 1
344
406
  }
345
407
 
346
408
  // Forward-facing triangle (editor-side overlay). The run ascends along
@@ -374,7 +436,7 @@ export const StairTool: React.FC = () => {
374
436
  z: number,
375
437
  rotation: number,
376
438
  ): ReturnType<typeof resolveAlignment> | null => {
377
- const preview = buildPreviewScene([x, 0, z], rotation)
439
+ const preview = buildPreviewScene([x, 0, z], rotation, supportSurfaceRef.current)
378
440
  const moving = preview
379
441
  ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation)
380
442
  : []
@@ -424,8 +486,10 @@ export const StairTool: React.FC = () => {
424
486
  }
425
487
 
426
488
  const resolveStairPosition = (event: MoveTriggerEvent): [number, number, number] | null => {
427
- const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
428
- supportCapRef.current = pointed?.elevation ?? null
489
+ const pointed = resolvePointerSupportSurface(cameraRef.current, event.position, {
490
+ includeNodeTopSurfaces: true,
491
+ })
492
+ supportSurfaceRef.current = pointed
429
493
  const fallbackPosition =
430
494
  'node' in event ? lastCanonicalPositionRef.current : event.localPosition
431
495
  if (!pointed?.localPoint && !fallbackPosition) return null
@@ -450,7 +514,7 @@ export const StairTool: React.FC = () => {
450
514
  if (!position) return
451
515
  const [gridX, , gridZ] = position
452
516
  lastCanonicalPositionRef.current = position
453
- applyDraftPreview(position, rotationRef.current, supportCapRef.current)
517
+ applyDraftPreview(position, rotationRef.current, supportSurfaceRef.current)
454
518
 
455
519
  if (
456
520
  (isGridSnapActive() || isMagneticSnapActive()) &&
@@ -484,7 +548,7 @@ export const StairTool: React.FC = () => {
484
548
  const position = resolveStairPosition(event)
485
549
  if (!position) return
486
550
 
487
- commitStairPlacement(currentLevelId, position, rotationRef.current, supportCapRef.current)
551
+ commitStairPlacement(currentLevelId, position, rotationRef.current, supportSurfaceRef.current)
488
552
  openingPreview.clear()
489
553
  // Commit cleared the opening preview, so force the next hover (even on the
490
554
  // same cell) to rebuild rather than dedupe against the just-placed key.
@@ -526,7 +590,7 @@ export const StairTool: React.FC = () => {
526
590
  applyDraftPreview(
527
591
  lastCanonicalPositionRef.current,
528
592
  rotationRef.current,
529
- supportCapRef.current,
593
+ supportSurfaceRef.current,
530
594
  )
531
595
  } else if (previewRef.current) {
532
596
  previewRef.current.rotation.y = rotationRef.current
@@ -536,26 +600,15 @@ export const StairTool: React.FC = () => {
536
600
 
537
601
  emitter.on('grid:move', onPointerMove)
538
602
  emitter.on('grid:click', commitAtCursor)
539
- type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
540
- type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
541
- type MoveKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
542
- for (const kind of CLICK_TRIGGER_KINDS) {
543
- const key = `${kind}:click` as ClickKey
544
- emitter.on(key, commitAtCursor as never)
545
- const moveKey = `${kind}:move` as MoveKey
546
- emitter.on(moveKey, onPointerMove as never)
547
- }
603
+ emitter.on('node:click', commitAtCursor)
604
+ emitter.on('node:move', onPointerMove)
548
605
  window.addEventListener('keydown', onKeyDown)
549
606
 
550
607
  return () => {
551
608
  emitter.off('grid:move', onPointerMove)
552
609
  emitter.off('grid:click', commitAtCursor)
553
- for (const kind of CLICK_TRIGGER_KINDS) {
554
- const key = `${kind}:click` as ClickKey
555
- emitter.off(key, commitAtCursor as never)
556
- const moveKey = `${kind}:move` as MoveKey
557
- emitter.off(moveKey, onPointerMove as never)
558
- }
610
+ emitter.off('node:click', commitAtCursor)
611
+ emitter.off('node:move', onPointerMove)
559
612
  window.removeEventListener('keydown', onKeyDown)
560
613
  useAlignmentGuides.getState().clear()
561
614
  openingPreview.clear()
@@ -3,6 +3,7 @@ import {
3
3
  type AnyNodeId,
4
4
  type BuildingNode,
5
5
  type CeilingNode,
6
+ createSceneApi,
6
7
  type FenceNode,
7
8
  nodeRegistry,
8
9
  type SlabNode,
@@ -11,9 +12,10 @@ import {
11
12
  } from '@pascal-app/core'
12
13
  import { useViewer } from '@pascal-app/viewer'
13
14
  import { type ComponentType, lazy, Suspense, useMemo } from 'react'
15
+ import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled'
14
16
  import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
15
17
  import useEditor, { type Phase, type Tool } from '../../store/use-editor'
16
- import {
18
+ import useInteractionScope, {
17
19
  useControlPointReshape,
18
20
  useEditingHole,
19
21
  useEndpointReshape,
@@ -30,7 +32,7 @@ import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
30
32
  import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
31
33
  import { ElevatorTool } from './elevator/elevator-tool'
32
34
  import { MoveTool } from './item/move-tool'
33
- import { RoofTool } from './roof/roof-tool'
35
+ import { RegistryToolProvider } from './registry-tool-context'
34
36
  import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
35
37
  import { FacingPoseIndicator } from './shared/facing-pose-indicator'
36
38
  import { SiteBoundaryEditor } from './site/site-boundary-editor'
@@ -91,7 +93,6 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
91
93
  'property-line': SiteBoundaryEditor,
92
94
  },
93
95
  structure: {
94
- roof: RoofTool,
95
96
  stair: StairTool,
96
97
  zone: ZoneTool,
97
98
  },
@@ -102,7 +103,11 @@ export const ToolManager: React.FC = () => {
102
103
  const phase = useEditor((state) => state.phase)
103
104
  const mode = useEditor((state) => state.mode)
104
105
  const tool = useEditor((state) => state.tool)
106
+ const registeredToolEnabled = useRegisteredToolEnabled(tool)
105
107
  const movingNode = useMovingNode()
108
+ const registryToolOwnsPlacement = useInteractionScope(
109
+ (state) => state.scope.kind === 'placing' && state.scope.driver === 'registry-tool',
110
+ )
106
111
  const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
107
112
  const endpointReshape = useEndpointReshape()
108
113
  const controlPointReshape = useControlPointReshape()
@@ -139,8 +144,20 @@ export const ToolManager: React.FC = () => {
139
144
  const selectedIds = useViewer((state) => state.selection.selectedIds)
140
145
  const buildingId = useViewer((state) => state.selection.buildingId)
141
146
  const activeLevelId = useViewer((state) => state.selection.levelId)
147
+ const unit = useViewer((state) => state.unit)
142
148
  const setSelection = useViewer((state) => state.setSelection)
143
149
  const nodes = useScene((state) => state.nodes)
150
+ const registrySceneApi = useMemo(() => createSceneApi(useScene), [])
151
+ const registryToolContext = useMemo(
152
+ () => ({
153
+ activeLevelId: activeLevelId ?? null,
154
+ isCameraDragging: () => useViewer.getState().cameraDragging,
155
+ sceneApi: registrySceneApi,
156
+ selectNode: (nodeId: AnyNodeId) => setSelection({ selectedIds: [nodeId] }),
157
+ unit,
158
+ }),
159
+ [activeLevelId, registrySceneApi, setSelection, unit],
160
+ )
144
161
 
145
162
  // Building transform for the local group — all building-relative tools live inside this group
146
163
  // so their cursor positions and committed data are naturally in building-local space.
@@ -223,7 +240,7 @@ export const ToolManager: React.FC = () => {
223
240
  !showCeilingBoundaryEditor
224
241
 
225
242
  // Show build tools when in build mode
226
- const showBuildTool = mode === 'build' && tool !== null
243
+ const showBuildTool = mode === 'build' && tool !== null && registeredToolEnabled
227
244
 
228
245
  // A move initiated from the 2D floor-plan (orange move-dot) is owned end-to-
229
246
  // end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at
@@ -233,7 +250,7 @@ export const ToolManager: React.FC = () => {
233
250
  // (the scene writes the overlay makes still mirror into the 3D view). A
234
251
  // 3D-initiated move leaves the origin null until its own commit, so this only
235
252
  // suppresses the 3D tool for genuinely 2D-owned moves.
236
- const showMover = movingNode != null && movingNodeOrigin !== '2d'
253
+ const showMover = movingNode != null && movingNodeOrigin !== '2d' && !registryToolOwnsPlacement
237
254
 
238
255
  // Registry-first: if the active tool's kind has a NodeDefinition with a
239
256
  // tool contribution, the registry-driven tool takes over.
@@ -261,7 +278,7 @@ export const ToolManager: React.FC = () => {
261
278
  }
262
279
 
263
280
  return (
264
- <>
281
+ <RegistryToolProvider value={registryToolContext}>
265
282
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
266
283
  {showSiteBoundaryEditor && <SiteBoundaryEditor />}
267
284
  {/* Terrain sculpting is a mode rather than a `tools[phase][tool]` entry —
@@ -375,7 +392,7 @@ export const ToolManager: React.FC = () => {
375
392
  )}
376
393
  {/* Registry-first: when the active tool's kind has a registered
377
394
  NodeDefinition with a tool contribution, mount it here. */}
378
- {!movingNode && useRegistryTool && RegistryToolComponent && (
395
+ {(!movingNode || registryToolOwnsPlacement) && useRegistryTool && RegistryToolComponent && (
379
396
  <Suspense fallback={null}>
380
397
  <RegistryToolComponent />
381
398
  </Suspense>
@@ -405,6 +422,6 @@ export const ToolManager: React.FC = () => {
405
422
  {/* "Magnetic" beacon at the active wall-draft snap point. */}
406
423
  <WallSnapBeaconLayer />
407
424
  </group>
408
- </>
425
+ </RegistryToolProvider>
409
426
  )
410
427
  }