@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -3,12 +3,21 @@ import { SCENE_LAYER } from '@pascal-app/viewer'
3
3
  import { useGLTF } from '@react-three/drei/core/Gltf'
4
4
  import { useFrame } from '@react-three/fiber'
5
5
  import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
- import { Color, CylinderGeometry, DoubleSide, type Mesh, type Object3D, RingGeometry } from 'three'
6
+ import {
7
+ Color,
8
+ CylinderGeometry,
9
+ DoubleSide,
10
+ type Group,
11
+ type Mesh,
12
+ type Object3D,
13
+ RingGeometry,
14
+ } from 'three'
7
15
  import { MeshBasicNodeMaterial } from 'three/webgpu'
8
16
  import { EDITOR_LAYER } from '../../../lib/constants'
9
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
10
18
  import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
11
19
  import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
20
+ import useInteractionScope from '../../../store/use-interaction-scope'
12
21
  import {
13
22
  ARROW_COLOR,
14
23
  ARROW_HOVER_COLOR,
@@ -38,6 +47,7 @@ const SITE_FLAG_HALO_COLOR = '#6366f1'
38
47
 
39
48
  type TintableMaterial = {
40
49
  color?: Color
50
+ depthTest: boolean
41
51
  depthWrite: boolean
42
52
  opacity: number
43
53
  needsUpdate: boolean
@@ -69,10 +79,9 @@ function SiteFlagModel({
69
79
  mesh.frustumCulled = false
70
80
  mesh.raycast = NO_RAYCAST
71
81
  mesh.receiveShadow = false
72
- mesh.renderOrder = 1010
73
82
  mesh.material = new MeshBasicNodeMaterial({
74
83
  color: new Color(ARROW_COLOR),
75
- depthTest: false,
84
+ depthTest: true,
76
85
  depthWrite: opacity >= 0.999,
77
86
  opacity,
78
87
  transparent: opacity < 0.999,
@@ -93,6 +102,7 @@ function SiteFlagModel({
93
102
 
94
103
  for (const material of materials as Array<MeshBasicNodeMaterial & TintableMaterial>) {
95
104
  material.color?.copy(color)
105
+ material.depthTest = true
96
106
  material.opacity = opacity
97
107
  material.transparent = opacity < 0.999
98
108
  material.depthWrite = opacity >= 0.999
@@ -217,11 +227,23 @@ function SiteFlagFallback({
217
227
  <group position={[0, SITE_FLAG_BASE_Y + (active ? SITE_FLAG_ACTIVE_LIFT : 0), 0]}>
218
228
  <mesh layers={SCENE_LAYER} position={[0, 0.16, 0]} raycast={NO_RAYCAST}>
219
229
  <cylinderGeometry args={[0.11, 0.16, 0.32, 24]} />
220
- <meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
230
+ <meshBasicMaterial
231
+ color={color}
232
+ depthTest
233
+ depthWrite={opacity >= 0.999}
234
+ opacity={opacity}
235
+ transparent={opacity < 0.999}
236
+ />
221
237
  </mesh>
222
238
  <mesh layers={SCENE_LAYER} position={[0, 0.34, 0]} raycast={NO_RAYCAST}>
223
239
  <cylinderGeometry args={[0.04, 0.11, 0.14, 24]} />
224
- <meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
240
+ <meshBasicMaterial
241
+ color={color}
242
+ depthTest
243
+ depthWrite={opacity >= 0.999}
244
+ opacity={opacity}
245
+ transparent={opacity < 0.999}
246
+ />
225
247
  </mesh>
226
248
  </group>
227
249
  )
@@ -355,14 +377,20 @@ export const SiteBoundaryEditor: React.FC = () => {
355
377
 
356
378
  if (!siteId) return
357
379
 
358
- const editor = useEditor.getState()
359
380
  if (isDragging) {
360
- editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
361
- } else if (
362
- editor.activeHandleDrag?.nodeId === siteId &&
363
- editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
364
- ) {
365
- editor.setActiveHandleDrag(null)
381
+ useInteractionScope
382
+ .getState()
383
+ .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
384
+ } else {
385
+ const scope = useInteractionScope.getState().scope
386
+ const activeHandleDrag =
387
+ scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
388
+ if (
389
+ activeHandleDrag?.nodeId === siteId &&
390
+ activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
391
+ ) {
392
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
393
+ }
366
394
  }
367
395
 
368
396
  if (!isDragging) {
@@ -375,12 +403,14 @@ export const SiteBoundaryEditor: React.FC = () => {
375
403
  useEffect(
376
404
  () => () => {
377
405
  if (!siteId) return
378
- const editor = useEditor.getState()
406
+ const scope = useInteractionScope.getState().scope
407
+ const activeHandleDrag =
408
+ scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
379
409
  if (
380
- editor.activeHandleDrag?.nodeId === siteId &&
381
- editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
410
+ activeHandleDrag?.nodeId === siteId &&
411
+ activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
382
412
  ) {
383
- editor.setActiveHandleDrag(null)
413
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
384
414
  }
385
415
  useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
386
416
  isDraggingSiteBoundaryRef.current = false
@@ -394,6 +424,26 @@ export const SiteBoundaryEditor: React.FC = () => {
394
424
  }
395
425
  }, [isSiteEditing, siteId])
396
426
 
427
+ // The flag models render on SCENE_LAYER (scene-depth occlusion), so unlike
428
+ // EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
429
+ // them around captures (preset/snapshot/auto-save thumbnails), same as
430
+ // `handle-arrow.tsx`.
431
+ const handlesRootRef = useRef<Group>(null)
432
+ useEffect(() => {
433
+ const hideForCapture = () => {
434
+ if (handlesRootRef.current) handlesRootRef.current.visible = false
435
+ }
436
+ const restoreAfterCapture = () => {
437
+ if (handlesRootRef.current) handlesRootRef.current.visible = true
438
+ }
439
+ emitter.on('thumbnail:before-capture', hideForCapture)
440
+ emitter.on('thumbnail:after-capture', restoreAfterCapture)
441
+ return () => {
442
+ emitter.off('thumbnail:before-capture', hideForCapture)
443
+ emitter.off('thumbnail:after-capture', restoreAfterCapture)
444
+ }
445
+ }, [])
446
+
397
447
  const activateSiteEditing = useCallback(() => {
398
448
  isDraggingSiteBoundaryRef.current = true
399
449
  setIsDraggingSiteBoundary(true)
@@ -470,25 +520,27 @@ export const SiteBoundaryEditor: React.FC = () => {
470
520
  if (!showSiteHandles) return null
471
521
 
472
522
  return (
473
- <PolygonEditor
474
- color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
475
- minVertices={3}
476
- onBeforeVertexDrag={activateSiteEditing}
477
- onDragCommit={() => {
478
- sfxEmitter.emit('sfx:item-place')
479
- exitSiteEditing()
480
- }}
481
- onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
482
- onDragStateChange={handleSiteBoundaryDragChange}
483
- onMidpointHoverChange={setHoveredMidpoint}
484
- onPolygonChange={handlePolygonChange}
485
- onPolygonPreview={handlePolygonPreview}
486
- onVertexHoverChange={setHoveredVertex}
487
- polygon={displayPolygon}
488
- renderMidpointHandle={renderSiteFlagMidpoint}
489
- renderVertexHandle={renderSiteFlagVertex}
490
- showBorderLine={isSiteBoundaryHighlighted}
491
- showMidpointHandles={showSiteHandles}
492
- />
523
+ <group ref={handlesRootRef}>
524
+ <PolygonEditor
525
+ color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
526
+ minVertices={3}
527
+ onBeforeVertexDrag={activateSiteEditing}
528
+ onDragCommit={() => {
529
+ sfxEmitter.emit('sfx:item-place')
530
+ exitSiteEditing()
531
+ }}
532
+ onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
533
+ onDragStateChange={handleSiteBoundaryDragChange}
534
+ onMidpointHoverChange={setHoveredMidpoint}
535
+ onPolygonChange={handlePolygonChange}
536
+ onPolygonPreview={handlePolygonPreview}
537
+ onVertexHoverChange={setHoveredVertex}
538
+ polygon={displayPolygon}
539
+ renderMidpointHandle={renderSiteFlagMidpoint}
540
+ renderVertexHandle={renderSiteFlagVertex}
541
+ showBorderLine={isSiteBoundaryHighlighted}
542
+ showMidpointHandles={showSiteHandles}
543
+ />
544
+ </group>
493
545
  )
494
546
  }
@@ -6,6 +6,7 @@ export const DEFAULT_STAIR_STEP_COUNT = 10
6
6
  export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
7
7
  export const DEFAULT_STAIR_FILL_TO_FLOOR = true
8
8
  export const DEFAULT_STAIR_THICKNESS = 0.25
9
+ export const DEFAULT_STAIR_OPENING_OFFSET = 0
9
10
  export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
10
11
  export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
11
12
  export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
@@ -14,7 +14,6 @@ import {
14
14
  syncAutoStairOpenings,
15
15
  useScene,
16
16
  } from '@pascal-app/core'
17
- import { useAlignmentGuides } from '@pascal-app/editor'
18
17
  import { useViewer } from '@pascal-app/viewer'
19
18
  import { useEffect, useMemo, useRef } from 'react'
20
19
  import * as THREE from 'three'
@@ -23,6 +22,15 @@ import {
23
22
  resolveStairDestinationLevel,
24
23
  resolveStairPlacementLevelId,
25
24
  } from '../../../lib/stair-levels'
25
+
26
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
27
+ import useEditor, {
28
+ isAlignmentGuideActive,
29
+ isGridSnapActive,
30
+ isMagneticSnapActive,
31
+ } from '../../../store/use-editor'
32
+
33
+ import useFacingPose from '../../../store/use-facing-pose'
26
34
  import { CursorSphere } from '../shared/cursor-sphere'
27
35
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
28
36
  import {
@@ -36,6 +44,7 @@ import {
36
44
  DEFAULT_STAIR_FILL_TO_FLOOR,
37
45
  DEFAULT_STAIR_HEIGHT,
38
46
  DEFAULT_STAIR_LENGTH,
47
+ DEFAULT_STAIR_OPENING_OFFSET,
39
48
  DEFAULT_STAIR_RAILING_HEIGHT,
40
49
  DEFAULT_STAIR_RAILING_MODE,
41
50
  DEFAULT_STAIR_STEP_COUNT,
@@ -138,7 +147,7 @@ function createDefaultStairNode({
138
147
  fromLevelId: levelId,
139
148
  toLevelId: nextLevelId,
140
149
  slabOpeningMode: 'destination',
141
- openingOffset: 0.08,
150
+ openingOffset: DEFAULT_STAIR_OPENING_OFFSET,
142
151
  width: DEFAULT_STAIR_WIDTH,
143
152
  totalRise: DEFAULT_STAIR_HEIGHT,
144
153
  stepCount: DEFAULT_STAIR_STEP_COUNT,
@@ -263,7 +272,19 @@ export const StairTool: React.FC = () => {
263
272
  return { placementLevelId, previewNodes, stair }
264
273
  }
265
274
 
275
+ // The preview rebuild (full-scene copy + destination-level resolution +
276
+ // auto-opening CSG) is expensive; `grid:move` fires it every pointer event
277
+ // but the placed position is grid-snapped, so within a cell every rebuild
278
+ // is identical. Dedupe on the snapped position + rotation so we rebuild
279
+ // only when the staircase would actually land somewhere new — this is the
280
+ // difference between a smooth and a stuttering stair tool (the elevator is
281
+ // cheap because it has no opening sync).
282
+ let lastPreviewKey: string | null = null
283
+
266
284
  const applyDraftPreview = (position: [number, number, number], rotation: number) => {
285
+ const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}`
286
+ if (key === lastPreviewKey) return
287
+ lastPreviewKey = key
267
288
  const preview = buildPreviewScene(position, rotation)
268
289
  const visualPosition = preview
269
290
  ? getFloorStackPreviewPosition({
@@ -287,6 +308,19 @@ export const StairTool: React.FC = () => {
287
308
  previewRef.current.rotation.y = rotation
288
309
  }
289
310
 
311
+ // Forward-facing triangle (editor-side overlay). The run ascends along
312
+ // local +Z from the entry at z≈0; the stair's front is the -Z entry side,
313
+ // so `reversed` points the triangle out of the entry (where you approach
314
+ // from), sitting just before it — not inside the footprint or at the
315
+ // elevated far end. Centre is the footprint mid-run (origin is the entry).
316
+ useFacingPose.getState().set({
317
+ position: visualPosition,
318
+ rotationY: rotation,
319
+ depth: DEFAULT_STAIR_LENGTH,
320
+ center: [0, DEFAULT_STAIR_LENGTH / 2],
321
+ reversed: true,
322
+ })
323
+
290
324
  if (!preview) {
291
325
  openingPreview.clear()
292
326
  return
@@ -319,13 +353,16 @@ export const StairTool: React.FC = () => {
319
353
  // The probe is the RAW cursor, not the grid-snapped point: resolving
320
354
  // against the grid point would only catch anchors that happen to sit near
321
355
  // a grid line. Matched axes use the raw probe + snap delta; unmatched axes
322
- // keep the normal grid snap. Alt bypasses.
356
+ // keep the normal grid snap. Guides are published in every snapping mode
357
+ // (including Off); the magnetic pull toward them (applySnap) applies only in
358
+ // 'lines' mode.
323
359
  const alignPoint = (
324
360
  gridX: number,
325
361
  gridZ: number,
326
362
  rawX: number,
327
363
  rawZ: number,
328
364
  bypass: boolean,
365
+ applySnap: boolean,
329
366
  ): [number, number] => {
330
367
  if (bypass || alignmentCandidates.length === 0) {
331
368
  useAlignmentGuides.getState().clear()
@@ -336,6 +373,10 @@ export const StairTool: React.FC = () => {
336
373
  useAlignmentGuides.getState().clear()
337
374
  return [gridX, gridZ]
338
375
  }
376
+ if (!applySnap) {
377
+ useAlignmentGuides.getState().set(ar.guides)
378
+ return [gridX, gridZ]
379
+ }
339
380
  let x = gridX
340
381
  let z = gridZ
341
382
  if (ar.snap) {
@@ -348,18 +389,27 @@ export const StairTool: React.FC = () => {
348
389
  }
349
390
 
350
391
  const onGridMove = (event: GridEvent) => {
392
+ // Grid snap follows the global mode (live step so the HUD chip is
393
+ // honest); Off keeps the raw cursor. Shift cycles the mode centrally.
394
+ const step = useEditor.getState().gridSnapStep
351
395
  const [gridX, gridZ] = alignPoint(
352
- Math.round(event.localPosition[0] * 2) / 2,
353
- Math.round(event.localPosition[2] * 2) / 2,
396
+ isGridSnapActive()
397
+ ? Math.round(event.localPosition[0] / step) * step
398
+ : event.localPosition[0],
399
+ isGridSnapActive()
400
+ ? Math.round(event.localPosition[2] / step) * step
401
+ : event.localPosition[2],
354
402
  event.localPosition[0],
355
403
  event.localPosition[2],
356
- event.nativeEvent?.altKey === true,
404
+ !isAlignmentGuideActive(),
405
+ isMagneticSnapActive(),
357
406
  )
358
407
  const position: [number, number, number] = [gridX, 0, gridZ]
359
408
  lastCanonicalPositionRef.current = position
360
409
  applyDraftPreview(position, rotationRef.current)
361
410
 
362
411
  if (
412
+ (isGridSnapActive() || isMagneticSnapActive()) &&
363
413
  previousGridPosRef.current &&
364
414
  (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
365
415
  ) {
@@ -370,12 +420,18 @@ export const StairTool: React.FC = () => {
370
420
  }
371
421
 
372
422
  const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
423
+ const step = useEditor.getState().gridSnapStep
373
424
  const [gridX, gridZ] = alignPoint(
374
- Math.round(event.localPosition[0] * 2) / 2,
375
- Math.round(event.localPosition[2] * 2) / 2,
425
+ isGridSnapActive()
426
+ ? Math.round(event.localPosition[0] / step) * step
427
+ : event.localPosition[0],
428
+ isGridSnapActive()
429
+ ? Math.round(event.localPosition[2] / step) * step
430
+ : event.localPosition[2],
376
431
  event.localPosition[0],
377
432
  event.localPosition[2],
378
- event.nativeEvent?.altKey === true,
433
+ !isAlignmentGuideActive(),
434
+ isMagneticSnapActive(),
379
435
  )
380
436
  return [gridX, 0, gridZ]
381
437
  }
@@ -395,8 +451,20 @@ export const StairTool: React.FC = () => {
395
451
 
396
452
  commitStairPlacement(currentLevelId, position, rotationRef.current)
397
453
  openingPreview.clear()
398
- alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
454
+ // Commit cleared the opening preview, so force the next hover (even on the
455
+ // same cell) to rebuild rather than dedupe against the just-placed key.
456
+ lastPreviewKey = null
399
457
  useAlignmentGuides.getState().clear()
458
+
459
+ // Single by default; the C-toggle ('point' context, shared with every
460
+ // other placement tool) opts into placing more. On single, drop the tool
461
+ // and the facing triangle so we fall back to select after one stair.
462
+ if (useEditor.getState().getContinuation('point') === 'repeat') {
463
+ alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
464
+ } else {
465
+ useFacingPose.getState().clear()
466
+ useEditor.getState().setTool(null)
467
+ }
400
468
  }
401
469
 
402
470
  const onKeyDown = (event: KeyboardEvent) => {
@@ -441,6 +509,7 @@ export const StairTool: React.FC = () => {
441
509
  window.removeEventListener('keydown', onKeyDown)
442
510
  useAlignmentGuides.getState().clear()
443
511
  openingPreview.clear()
512
+ useFacingPose.getState().clear()
444
513
  }
445
514
  }, [currentLevelId])
446
515
 
@@ -448,7 +517,9 @@ export const StairTool: React.FC = () => {
448
517
  <group>
449
518
  <CursorSphere ref={cursorRef} />
450
519
 
451
- {/* 3D ghost preview — position/rotation updated imperatively */}
520
+ {/* 3D ghost preview — position/rotation updated imperatively. The
521
+ forward-facing triangle is drawn by the editor-side overlay from the
522
+ pose published in `applyDraftPreview`. */}
452
523
  <group ref={previewRef}>
453
524
  <mesh castShadow geometry={previewGeometry}>
454
525
  <meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
@@ -2,19 +2,32 @@ import {
2
2
  type AnyNodeId,
3
3
  type BuildingNode,
4
4
  type CeilingNode,
5
+ type FenceNode,
5
6
  nodeRegistry,
6
7
  type SlabNode,
7
8
  useScene,
9
+ type WallNode,
8
10
  } from '@pascal-app/core'
9
11
  import { useViewer } from '@pascal-app/viewer'
10
- import { type ComponentType, lazy, Suspense } from 'react'
12
+ import { type ComponentType, lazy, Suspense, useMemo } from 'react'
11
13
  import useEditor, { type Phase, type Tool } from '../../store/use-editor'
14
+ import {
15
+ useControlPointReshape,
16
+ useEditingHole,
17
+ useEndpointReshape,
18
+ useIsCurveReshape,
19
+ useMovingNode,
20
+ useReshapingNode,
21
+ useTangentReshape,
22
+ } from '../../store/use-interaction-scope'
12
23
  import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
24
+ import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
13
25
  import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
14
26
  import { ElevatorTool } from './elevator/elevator-tool'
15
27
  import { MoveTool } from './item/move-tool'
16
28
  import { RoofTool } from './roof/roof-tool'
17
29
  import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
30
+ import { FacingPoseIndicator } from './shared/facing-pose-indicator'
18
31
  import { SiteBoundaryEditor } from './site/site-boundary-editor'
19
32
  import { StairTool } from './stair/stair-tool'
20
33
  import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -54,12 +67,37 @@ export const ToolManager: React.FC = () => {
54
67
  const phase = useEditor((state) => state.phase)
55
68
  const mode = useEditor((state) => state.mode)
56
69
  const tool = useEditor((state) => state.tool)
57
- const movingNode = useEditor((state) => state.movingNode)
58
- const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
59
- const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
60
- const curvingWall = useEditor((state) => state.curvingWall)
61
- const curvingFence = useEditor((state) => state.curvingFence)
62
- const editingHole = useEditor((state) => state.editingHole)
70
+ const movingNode = useMovingNode()
71
+ const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
72
+ const endpointReshape = useEndpointReshape()
73
+ const controlPointReshape = useControlPointReshape()
74
+ const tangentReshape = useTangentReshape()
75
+ const isCurveReshape = useIsCurveReshape()
76
+ const reshapingNode = useReshapingNode()
77
+ // The endpoint affordance tool's `target` is kind-specific
78
+ // (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
79
+ // the scope's endpoint. Memoised so it stays referentially stable across the
80
+ // scene-write re-renders during the drag — otherwise a fresh object each frame
81
+ // re-fires the tool's setup effect (endpoint drag would loop / freeze).
82
+ const endpointTarget = useMemo(() => {
83
+ if (!(endpointReshape && reshapingNode)) return null
84
+ return reshapingNode.type === 'fence'
85
+ ? { fence: reshapingNode as FenceNode, endpoint: endpointReshape.endpoint }
86
+ : { wall: reshapingNode as WallNode, endpoint: endpointReshape.endpoint }
87
+ }, [endpointReshape, reshapingNode])
88
+ const controlPointTarget = useMemo(() => {
89
+ if (!(controlPointReshape && reshapingNode?.type === 'fence')) return null
90
+ return { fence: reshapingNode as FenceNode, index: controlPointReshape.index }
91
+ }, [controlPointReshape, reshapingNode])
92
+ const tangentTarget = useMemo(() => {
93
+ if (!(tangentReshape && reshapingNode?.type === 'fence')) return null
94
+ return {
95
+ fence: reshapingNode as FenceNode,
96
+ index: tangentReshape.index,
97
+ side: tangentReshape.side,
98
+ }
99
+ }, [reshapingNode, tangentReshape])
100
+ const editingHole = useEditingHole()
63
101
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
64
102
  const selectedIds = useViewer((state) => state.selection.selectedIds)
65
103
  const buildingId = useViewer((state) => state.selection.buildingId)
@@ -94,10 +132,16 @@ export const ToolManager: React.FC = () => {
94
132
  // switches to full polygon editing only after a flag activates site mode.
95
133
  const showSiteBoundaryEditor = phase === 'site' || mode === 'select'
96
134
 
135
+ // A multi-selection is manipulated as one rigid group (drag / R / T), so
136
+ // per-node reshape chrome — the slab / ceiling boundary editors' vertex and
137
+ // edge handles — mounts only for a sole selection.
138
+ const isSoleSelection = selectedIds.length === 1
139
+
97
140
  // Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
98
141
  const showSlabBoundaryEditor =
99
142
  phase === 'structure' &&
100
143
  mode === 'select' &&
144
+ isSoleSelection &&
101
145
  selectedSlabId !== undefined &&
102
146
  !editingSlabHoleIsManual
103
147
 
@@ -112,6 +156,7 @@ export const ToolManager: React.FC = () => {
112
156
  const showCeilingBoundaryEditor =
113
157
  phase === 'structure' &&
114
158
  mode === 'select' &&
159
+ isSoleSelection &&
115
160
  selectedCeilingId !== undefined &&
116
161
  (!editingHole || editingHole.nodeId !== selectedCeilingId)
117
162
 
@@ -133,6 +178,16 @@ export const ToolManager: React.FC = () => {
133
178
  // Show build tools when in build mode
134
179
  const showBuildTool = mode === 'build' && tool !== null
135
180
 
181
+ // A move initiated from the 2D floor-plan (orange move-dot) is owned end-to-
182
+ // end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at
183
+ // dot-down. Mounting the 3D affordance mover alongside it would adopt the
184
+ // same node and, on its unmount, restore the adopt-time position — snapping
185
+ // the committed 2D move back to its start. Gate the 3D mover off for 2D moves
186
+ // (the scene writes the overlay makes still mirror into the 3D view). A
187
+ // 3D-initiated move leaves the origin null until its own commit, so this only
188
+ // suppresses the 3D tool for genuinely 2D-owned moves.
189
+ const showMover = movingNode != null && movingNodeOrigin !== '2d'
190
+
136
191
  // Registry-first: if the active tool's kind has a NodeDefinition with a
137
192
  // tool contribution, the registry-driven tool takes over.
138
193
  const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
@@ -162,7 +217,7 @@ export const ToolManager: React.FC = () => {
162
217
  <>
163
218
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
164
219
  {showSiteBoundaryEditor && <SiteBoundaryEditor />}
165
- {movingNode?.type === 'building' && (
220
+ {showMover && movingNode?.type === 'building' && (
166
221
  <MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
167
222
  )}
168
223
 
@@ -216,49 +271,48 @@ export const ToolManager: React.FC = () => {
216
271
  </Suspense>
217
272
  ) : null
218
273
  })()}
219
- {movingWallEndpoint &&
274
+ {endpointTarget &&
275
+ reshapingNode &&
220
276
  (() => {
221
277
  const RegistryAffordance = getRegistryAffordanceTool(
222
- movingWallEndpoint.wall.type,
278
+ reshapingNode.type,
223
279
  'move-endpoint',
224
280
  )
225
281
  return RegistryAffordance ? (
226
282
  <Suspense fallback={null}>
227
- <RegistryAffordance target={movingWallEndpoint} />
283
+ <RegistryAffordance target={endpointTarget} />
228
284
  </Suspense>
229
285
  ) : null
230
286
  })()}
231
- {movingFenceEndpoint &&
287
+ {isCurveReshape &&
288
+ reshapingNode &&
232
289
  (() => {
233
- const RegistryAffordance = getRegistryAffordanceTool(
234
- movingFenceEndpoint.fence.type,
235
- 'move-endpoint',
236
- )
290
+ const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
237
291
  return RegistryAffordance ? (
238
292
  <Suspense fallback={null}>
239
- <RegistryAffordance target={movingFenceEndpoint} />
293
+ <RegistryAffordance node={reshapingNode} />
240
294
  </Suspense>
241
295
  ) : null
242
296
  })()}
243
- {curvingWall &&
297
+ {controlPointTarget &&
244
298
  (() => {
245
- const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
246
- return Registry ? (
299
+ const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
300
+ return RegistryAffordance ? (
247
301
  <Suspense fallback={null}>
248
- <Registry node={curvingWall} />
302
+ <RegistryAffordance target={controlPointTarget} />
249
303
  </Suspense>
250
304
  ) : null
251
305
  })()}
252
- {curvingFence &&
306
+ {tangentTarget &&
253
307
  (() => {
254
- const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
308
+ const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
255
309
  return RegistryAffordance ? (
256
310
  <Suspense fallback={null}>
257
- <RegistryAffordance node={curvingFence} />
311
+ <RegistryAffordance target={tangentTarget} />
258
312
  </Suspense>
259
313
  ) : null
260
314
  })()}
261
- {movingNode && movingNode.type !== 'building' && (
315
+ {showMover && movingNode.type !== 'building' && (
262
316
  <MoveTool
263
317
  onNodeMoved={handlePlacedNodeSelected}
264
318
  onSpawnMoved={handlePlacedNodeSelected}
@@ -283,6 +337,13 @@ export const ToolManager: React.FC = () => {
283
337
  tools above. Lives inside the building-local group so the
284
338
  building-local guide coords render at the right world position. */}
285
339
  <Alignment3DGuideLayer />
340
+ {/* The one forward-facing triangle renderer. Placement/move tools
341
+ publish their ghost pose to `useFacingPose`; this draws it. Mounted
342
+ here so it shares the building-local frame the tools publish in. */}
343
+ <FacingPoseIndicator />
344
+ {/* Wall-plane proximity / sill / equal-spacing guides for openings,
345
+ published by the door/window move tools in the same world frame. */}
346
+ <OpeningGuides3DLayer />
286
347
  {/* "Magnetic" beacon at the active wall-draft snap point. */}
287
348
  <WallSnapBeaconLayer />
288
349
  </group>