@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,9 +1,21 @@
1
1
  'use client'
2
2
 
3
- import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
4
- import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
3
+ import {
4
+ emitter,
5
+ type FenceNode,
6
+ isCurvedWall,
7
+ nodeRegistry,
8
+ type WallNode,
9
+ } from '@pascal-app/core'
10
+ import {
11
+ type MouseEvent as ReactMouseEvent,
12
+ useCallback,
13
+ useEffect,
14
+ useSyncExternalStore,
15
+ } from 'react'
5
16
  import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
6
17
  import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
18
+ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point'
7
19
  import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
8
20
  import useAlignmentGuides from '../../store/use-alignment-guides'
9
21
  import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
@@ -12,6 +24,9 @@ import useSegmentDraftChain from '../../store/use-segment-draft-chain'
12
24
  import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
13
25
  import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
14
26
 
27
+ const NOOP_SUBSCRIBE = () => () => {}
28
+ const DEFAULT_ROOF_FOOTPRINT_CHOICE = () => 'draw'
29
+
15
30
  type UseFloorplanBackgroundPlacementArgs = {
16
31
  activePolygonDraftPoints: WallPlanPoint[]
17
32
  ceilingDraftPoints: WallPlanPoint[]
@@ -55,6 +70,7 @@ type UseFloorplanBackgroundPlacementArgs = {
55
70
  isWallBuildActive: boolean
56
71
  isZoneBuildActive: boolean
57
72
  levelId: string | null
73
+ registryToolOwnsSnapping: boolean
58
74
  roofDraftStart: WallPlanPoint | null
59
75
  setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
60
76
  setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
@@ -113,6 +129,7 @@ export function useFloorplanBackgroundPlacement({
113
129
  isWallBuildActive,
114
130
  isZoneBuildActive,
115
131
  levelId,
132
+ registryToolOwnsSnapping,
116
133
  roofDraftStart,
117
134
  setCursorPoint,
118
135
  setFenceDraftEnd,
@@ -125,6 +142,26 @@ export function useFloorplanBackgroundPlacement({
125
142
  walls,
126
143
  worldGridSnap,
127
144
  }: UseFloorplanBackgroundPlacementArgs) {
145
+ // Read the roof's footprint-source option through the registry, not
146
+ // `@pascal-app/nodes`: this file lands in the nodes package's program via
147
+ // its editor imports, so a direct nodes import would cycle onto nodes' own
148
+ // dist output.
149
+ const roofFootprintOption = nodeRegistry
150
+ .get('roof')
151
+ ?.toolOptions?.find((option) => option.id === 'footprintSource')
152
+ const roofFootprintChoice = useSyncExternalStore(
153
+ roofFootprintOption?.subscribe ?? NOOP_SUBSCRIBE,
154
+ roofFootprintOption?.value ?? DEFAULT_ROOF_FOOTPRINT_CHOICE,
155
+ roofFootprintOption?.value ?? DEFAULT_ROOF_FOOTPRINT_CHOICE,
156
+ )
157
+ // Conical always builds from a curved wall pick, regardless of the choice.
158
+ const roofIsConical = useEditor((state) => state.toolDefaults.roof?.roofType === 'conical')
159
+ const roofFootprintSource = roofIsConical ? 'walls' : roofFootprintChoice
160
+
161
+ useEffect(() => {
162
+ if (isRoofBuildActive && roofFootprintSource !== 'draw') clearRoofPlacementDraft()
163
+ }, [clearRoofPlacementDraft, isRoofBuildActive, roofFootprintSource])
164
+
128
165
  const handleBackgroundPlacementClick = useCallback(
129
166
  (
130
167
  planPoint: WallPlanPoint,
@@ -188,6 +225,11 @@ export function useFloorplanBackgroundPlacement({
188
225
  emitFloorplanGridEvent('click', snappedPoint, event)
189
226
  setCursorPoint(snappedPoint)
190
227
 
228
+ if (roofFootprintSource !== 'draw') {
229
+ clearRoofPlacementDraft()
230
+ return true
231
+ }
232
+
191
233
  if (roofDraftStart) {
192
234
  clearRoofPlacementDraft()
193
235
  } else {
@@ -357,9 +399,13 @@ export function useFloorplanBackgroundPlacement({
357
399
  // local floor-plan draft handler (column / spawn / shelf / etc.).
358
400
  // The tool's `grid:click` subscriber owns the placement.
359
401
  if (isFloorplanGridInteractionActive) {
360
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
361
- emitFloorplanGridEvent('click', snappedPoint, event)
362
- setCursorPoint(snappedPoint)
402
+ const eventPoint = resolveGenericFloorplanGridEventPoint({
403
+ point: planPoint,
404
+ registryToolOwnsSnapping,
405
+ snap: getSnappedFloorplanPoint,
406
+ })
407
+ emitFloorplanGridEvent('click', eventPoint, event)
408
+ setCursorPoint(eventPoint)
363
409
  return true
364
410
  }
365
411
 
@@ -392,6 +438,8 @@ export function useFloorplanBackgroundPlacement({
392
438
  isZoneBuildActive,
393
439
  levelId,
394
440
  roofDraftStart,
441
+ registryToolOwnsSnapping,
442
+ roofFootprintSource,
395
443
  setCursorPoint,
396
444
  setFenceDraftEnd,
397
445
  setFenceDraftStart,
@@ -4,8 +4,11 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type FenceNode,
7
+ getFenceCenterlineFrameAt,
8
+ getFenceCenterlineLength,
7
9
  getWallBaseElevationForNodes,
8
10
  getWallCurveFrameAt,
11
+ getWallCurveLength,
9
12
  getWallEffectiveHeightForNodes,
10
13
  getWallThickness,
11
14
  isCurvedWall,
@@ -16,6 +19,7 @@ import {
16
19
  type WallNode,
17
20
  } from '@pascal-app/core'
18
21
  import { useViewer } from '@pascal-app/viewer'
22
+ import { Html } from '@react-three/drei'
19
23
  import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
20
24
  import { useEffect, useMemo, useRef, useState } from 'react'
21
25
  import {
@@ -29,6 +33,7 @@ import {
29
33
  OrthographicCamera,
30
34
  Plane,
31
35
  Quaternion,
36
+ Ray,
32
37
  Shape,
33
38
  Vector2,
34
39
  Vector3,
@@ -37,6 +42,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
37
42
  import { MeshBasicNodeMaterial } from 'three/webgpu'
38
43
  import {
39
44
  clearStructuralElevationGuide,
45
+ getFenceBaseElevationForNodes,
40
46
  publishStructuralElevationGuide,
41
47
  resolveStructuralElevationSnap,
42
48
  } from '../../lib/elevation-guides'
@@ -52,6 +58,7 @@ import useInteractionScope, {
52
58
  import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
53
59
  import { resolveResizeSnapValue } from './handles/resize-snap'
54
60
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
61
+ import { MeasurementPill } from './measurement-pill'
55
62
  import {
56
63
  createArrowHitAreaGeometry,
57
64
  createEndpointHitAreaGeometry,
@@ -76,6 +83,8 @@ const CORNER_DASH_SIZE = 0.1
76
83
  const CORNER_GAP_SIZE = 0.07
77
84
  const CORNER_DASH_THICKNESS = 0.006
78
85
  const CORNER_FLOOR_OFFSET = 0.01
86
+ const THICKNESS_HANDLE_RADIUS = 0.075
87
+ const MIN_WALL_THICKNESS = 0.05
79
88
 
80
89
  type WallMoveHandle = {
81
90
  key: string
@@ -140,14 +149,11 @@ export function WallMoveSideHandles() {
140
149
  const isCurveReshape = useIsCurveReshape()
141
150
 
142
151
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
143
- // Fence side-move / height / corner-pickers now flow through the
144
- // registry handle path (see packages/nodes/src/fence/definition.ts).
145
- // Only walls still need the legacy renderer here — the registry path
146
- // didn't render correctly for walls specifically and was reverted in
147
- // commit 0e207a7f; revisit once that's diagnosed.
152
+ // Walls still use this legacy handle renderer. Fences retain their registry
153
+ // handles and mount only the matching thickness dots from this component.
148
154
  const selectedNode = useScene((state) => {
149
155
  const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
150
- return node?.type === 'wall' ? node : null
156
+ return node?.type === 'wall' || node?.type === 'fence' ? node : null
151
157
  })
152
158
 
153
159
  const shouldRender =
@@ -160,7 +166,11 @@ export function WallMoveSideHandles() {
160
166
 
161
167
  if (!shouldRender || !selectedNode) return null
162
168
 
163
- return <WallMoveSideHandlesForWall wall={selectedNode} />
169
+ return selectedNode.type === 'wall' ? (
170
+ <WallMoveSideHandlesForWall wall={selectedNode} />
171
+ ) : (
172
+ <FenceThicknessHandles fence={selectedNode} />
173
+ )
164
174
  }
165
175
 
166
176
  function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
@@ -220,6 +230,18 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
220
230
  <WallMoveArrowHandle handle={handle} key={handle.key} wall={effectiveWall} />
221
231
  ))}
222
232
  <WallHeightArrowHandle wall={effectiveWall} />
233
+ <StructureThicknessHandle
234
+ baseElevation={baseElevation}
235
+ levelObject={levelObject}
236
+ node={effectiveWall}
237
+ side={1}
238
+ />
239
+ <StructureThicknessHandle
240
+ baseElevation={baseElevation}
241
+ levelObject={levelObject}
242
+ node={effectiveWall}
243
+ side={-1}
244
+ />
223
245
  <WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
224
246
  <WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
225
247
  </group>
@@ -233,6 +255,276 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
233
255
  )
234
256
  }
235
257
 
258
+ function closestAxisParameterToRay(axisOrigin: Vector3, axisDirection: Vector3, ray: Ray) {
259
+ const originToRay = new Vector3().subVectors(axisOrigin, ray.origin)
260
+ const directionDot = axisDirection.dot(ray.direction)
261
+ const axisDot = axisDirection.dot(originToRay)
262
+ const rayDot = ray.direction.dot(originToRay)
263
+ const denominator = 1 - directionDot * directionDot
264
+ if (Math.abs(denominator) < 1e-6) return -axisDot
265
+
266
+ const axisParameter = (directionDot * rayDot - axisDot) / denominator
267
+ const rayParameter = rayDot + directionDot * axisParameter
268
+ return rayParameter < 0 ? -axisDot : axisParameter
269
+ }
270
+
271
+ function StructureThicknessHandle({
272
+ node,
273
+ side,
274
+ baseElevation,
275
+ levelObject,
276
+ }: {
277
+ node: WallNode | FenceNode
278
+ side: 1 | -1
279
+ baseElevation: number
280
+ levelObject: Object3D
281
+ }) {
282
+ const [isHovered, setIsHovered] = useState(false)
283
+ const [isDragging, setIsDragging] = useState(false)
284
+ const { camera } = useThree()
285
+ const unit = useViewer((state) => state.unit)
286
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
287
+ const frame =
288
+ node.type === 'fence' ? getFenceCenterlineFrameAt(node, 0.5) : getWallCurveFrameAt(node, 0.5)
289
+ const thickness = node.type === 'fence' ? (node.thickness ?? 0.08) : getWallThickness(node)
290
+ const structureHeight =
291
+ node.type === 'fence'
292
+ ? (node.height ?? 1.8)
293
+ : getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
294
+ const outward = new Vector2(frame.normal.x * side, frame.normal.y * side)
295
+ const faceOffset = thickness / 2 + 0.006
296
+ const position: [number, number, number] = [
297
+ frame.point.x + outward.x * faceOffset,
298
+ structureHeight / 2,
299
+ frame.point.y + outward.y * faceOffset,
300
+ ]
301
+ const rotationY = Math.atan2(outward.x, outward.y)
302
+ const isActive = isHovered || isDragging
303
+ const scale = zoom * (isActive ? 1.18 : 1)
304
+ const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(0.13), [])
305
+ const hitMaterial = useInvisibleHitAreaMaterial()
306
+ const dotMaterial = useMemo(
307
+ () =>
308
+ new MeshBasicNodeMaterial({
309
+ color: new Color(ARROW_COLOR),
310
+ side: DoubleSide,
311
+ depthTest: false,
312
+ depthWrite: false,
313
+ }),
314
+ [],
315
+ )
316
+ const ringMaterial = useMemo(
317
+ () =>
318
+ new MeshBasicNodeMaterial({
319
+ color: new Color(ARROW_HOVER_COLOR),
320
+ side: DoubleSide,
321
+ depthTest: false,
322
+ depthWrite: false,
323
+ }),
324
+ [],
325
+ )
326
+ const dragControls = useMemo<HandleDragControls>(
327
+ () => ({
328
+ onStart: () => {},
329
+ onEnd: () => {},
330
+ }),
331
+ [],
332
+ )
333
+
334
+ useEffect(() => {
335
+ dotMaterial.color.set(isActive ? ARROW_HOVER_COLOR : ARROW_COLOR)
336
+ }, [dotMaterial, isActive])
337
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
338
+ useEffect(() => () => dotMaterial.dispose(), [dotMaterial])
339
+ useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
340
+ useEffect(
341
+ () => () => {
342
+ if (document.body.style.cursor === 'ew-resize') document.body.style.cursor = ''
343
+ },
344
+ [],
345
+ )
346
+
347
+ const activateThicknessResize = useHandleDrag({
348
+ kind: 'drag',
349
+ cursor: 'ew-resize',
350
+ dragControls,
351
+ handleIndex: side > 0 ? 0 : 1,
352
+ node,
353
+ rideObject: levelObject,
354
+ setIsDragging,
355
+ onStart: ({ event, getPointerRay, initialNode, nodeId, rideObject }) => {
356
+ if (initialNode.type !== 'wall' && initialNode.type !== 'fence') return null
357
+
358
+ rideObject.updateWorldMatrix(true, false)
359
+ const initialFrame =
360
+ initialNode.type === 'fence'
361
+ ? getFenceCenterlineFrameAt(initialNode, 0.5)
362
+ : getWallCurveFrameAt(initialNode, 0.5)
363
+ const localAxis = new Vector3(initialFrame.normal.x * side, 0, initialFrame.normal.y * side)
364
+ const initialStructureHeight =
365
+ initialNode.type === 'fence'
366
+ ? (initialNode.height ?? 1.8)
367
+ : getWallEffectiveHeightForNodes(initialNode, useScene.getState().nodes)
368
+ const initialStructureThickness =
369
+ initialNode.type === 'fence'
370
+ ? (initialNode.thickness ?? 0.08)
371
+ : getWallThickness(initialNode)
372
+ const minimumThickness = initialNode.type === 'fence' ? 0.03 : MIN_WALL_THICKNESS
373
+ const localOrigin = new Vector3(
374
+ initialFrame.point.x + localAxis.x * (initialStructureThickness / 2 + 0.006),
375
+ baseElevation + initialStructureHeight / 2,
376
+ initialFrame.point.y + localAxis.z * (initialStructureThickness / 2 + 0.006),
377
+ )
378
+ const worldOrigin = localOrigin.clone().applyMatrix4(rideObject.matrixWorld)
379
+ const worldAxisEnd = localOrigin.clone().add(localAxis).applyMatrix4(rideObject.matrixWorld)
380
+ const worldAxis = worldAxisEnd.sub(worldOrigin)
381
+ const axisScale = worldAxis.length()
382
+ if (axisScale < 1e-6) return null
383
+ worldAxis.normalize()
384
+
385
+ const pointerRay = new Ray()
386
+ const initialPointer =
387
+ closestAxisParameterToRay(
388
+ worldOrigin,
389
+ worldAxis,
390
+ getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, pointerRay),
391
+ ) / axisScale
392
+ let lastThickness = initialStructureThickness
393
+
394
+ return {
395
+ onBegin: () => {
396
+ useInteractionScope.getState().begin({
397
+ kind: 'handle-drag',
398
+ nodeId,
399
+ handle: 'thickness',
400
+ })
401
+ },
402
+ onEnd: () => {
403
+ useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag')
404
+ },
405
+ move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
406
+ const currentPointer =
407
+ closestAxisParameterToRay(
408
+ worldOrigin,
409
+ worldAxis,
410
+ getMovePointerRay(moveEvent.clientX, moveEvent.clientY, pointerRay),
411
+ ) / axisScale
412
+ const rawThickness = initialStructureThickness + (currentPointer - initialPointer) * 2
413
+ const nextThickness = Math.max(
414
+ minimumThickness,
415
+ resolveResizeSnapValue({
416
+ rawValue: rawThickness,
417
+ fallbackValue: lastThickness,
418
+ gridSnapEnabled: true,
419
+ gridSnapActive: isGridSnapActive(),
420
+ gridSnapStep: useEditor.getState().gridSnapStep,
421
+ magneticSnapActive: false,
422
+ }),
423
+ )
424
+ if (nextThickness !== lastThickness) sfxEmitter.emit('sfx:resize')
425
+ lastThickness = nextThickness
426
+ return { thickness: nextThickness }
427
+ },
428
+ }
429
+ },
430
+ })
431
+
432
+ return (
433
+ <>
434
+ <group position={position} rotation={[0, rotationY, 0]} scale={scale}>
435
+ <InvisibleHandleHitArea
436
+ geometry={hitGeometry}
437
+ material={hitMaterial}
438
+ onPointerDown={activateThicknessResize}
439
+ onPointerEnter={(event) => {
440
+ event.stopPropagation()
441
+ setIsHovered(true)
442
+ document.body.style.cursor = 'ew-resize'
443
+ }}
444
+ onPointerLeave={(event) => {
445
+ event.stopPropagation()
446
+ setIsHovered(false)
447
+ if (!isDragging && document.body.style.cursor === 'ew-resize') {
448
+ document.body.style.cursor = ''
449
+ }
450
+ }}
451
+ scale={1}
452
+ />
453
+ <mesh material={dotMaterial} raycast={NO_RAYCAST} renderOrder={1003}>
454
+ <circleGeometry args={[THICKNESS_HANDLE_RADIUS, 32]} />
455
+ </mesh>
456
+ <mesh material={ringMaterial} raycast={NO_RAYCAST} renderOrder={1002}>
457
+ <ringGeometry args={[THICKNESS_HANDLE_RADIUS, THICKNESS_HANDLE_RADIUS * 1.18, 32]} />
458
+ </mesh>
459
+ </group>
460
+ {isDragging ? (
461
+ <Html
462
+ center
463
+ position={[position[0], position[1] + 0.22, position[2]]}
464
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
465
+ zIndexRange={[25, 0]}
466
+ >
467
+ <MeasurementPill
468
+ height={structureHeight}
469
+ length={
470
+ node.type === 'fence' ? getFenceCenterlineLength(node) : getWallCurveLength(node)
471
+ }
472
+ primary="thickness"
473
+ thickness={thickness}
474
+ unit={unit}
475
+ />
476
+ </Html>
477
+ ) : null}
478
+ </>
479
+ )
480
+ }
481
+
482
+ function FenceThicknessHandles({ fence }: { fence: FenceNode }) {
483
+ const nodes = useScene((state) => state.nodes)
484
+ const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(fence.id))
485
+ const effectiveFence = useMemo(
486
+ () => (liveOverride ? ({ ...fence, ...liveOverride } as FenceNode) : fence),
487
+ [fence, liveOverride],
488
+ )
489
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
490
+ fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null,
491
+ )
492
+
493
+ useEffect(() => {
494
+ let frameId = 0
495
+ const resolveLevelObject = () => {
496
+ const next = fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null
497
+ setLevelObject(next)
498
+ if (!next) frameId = window.requestAnimationFrame(resolveLevelObject)
499
+ }
500
+ resolveLevelObject()
501
+ return () => {
502
+ if (frameId) window.cancelAnimationFrame(frameId)
503
+ }
504
+ }, [fence.parentId])
505
+
506
+ if (!levelObject) return null
507
+
508
+ const baseElevation = getFenceBaseElevationForNodes(effectiveFence, nodes)
509
+ return createPortal(
510
+ <group position={[0, baseElevation, 0]}>
511
+ <StructureThicknessHandle
512
+ baseElevation={baseElevation}
513
+ levelObject={levelObject}
514
+ node={effectiveFence}
515
+ side={1}
516
+ />
517
+ <StructureThicknessHandle
518
+ baseElevation={baseElevation}
519
+ levelObject={levelObject}
520
+ node={effectiveFence}
521
+ side={-1}
522
+ />
523
+ </group>,
524
+ levelObject,
525
+ )
526
+ }
527
+
236
528
  function buildDashedVerticalGeometry(height: number) {
237
529
  if (!(Number.isFinite(height) && height > 0)) return new BufferGeometry()
238
530
 
@@ -39,10 +39,13 @@ import {
39
39
  collectParticipants,
40
40
  computeGroupBox,
41
41
  expandToComponent,
42
+ type GroupPlanBounds,
43
+ groupPlanBounds,
42
44
  levelFrame,
43
- participantExtents,
45
+ planBoundsCenter,
44
46
  rotateGroupPatches,
45
47
  rotateGroupSnapshots,
48
+ rotatePlanBounds,
46
49
  translateGroupPatches,
47
50
  type Vec2,
48
51
  } from '../editor/group-transform-shared'
@@ -112,6 +115,9 @@ export function startFloorplanGroupMove(
112
115
  affectedIds: AnyNodeId[]
113
116
  candidates: ReturnType<typeof collectAlignmentAnchors>
114
117
  restAnchors: ReturnType<typeof bboxCornerAnchors>
118
+ startBounds: GroupPlanBounds
119
+ restBounds: GroupPlanBounds
120
+ rotation: number
115
121
  restCenter: Vec2
116
122
  lastDelta: Vec2 | null
117
123
  }
@@ -140,20 +146,16 @@ export function startFloorplanGroupMove(
140
146
  // The group aligns as one rigid footprint: its bbox corners + center are
141
147
  // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays
142
148
  // mounted under every view mode); plan coords are level-frame, so convert.
143
- const restBox = computeGroupBox(fullIds)
144
149
  const { inverse: frameInv } = levelFrame(levelId)
145
- const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null
146
- const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null
147
- const restAnchors =
148
- boxMin && boxMax
149
- ? bboxCornerAnchors(
150
- 'group-move',
151
- Math.min(boxMin.x, boxMax.x),
152
- Math.min(boxMin.z, boxMax.z),
153
- Math.max(boxMin.x, boxMax.x),
154
- Math.max(boxMin.z, boxMax.z),
155
- )
156
- : []
150
+ const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv)
151
+ if (!restBounds) return null
152
+ const restAnchors = bboxCornerAnchors(
153
+ 'group-move',
154
+ restBounds.minX,
155
+ restBounds.minZ,
156
+ restBounds.maxX,
157
+ restBounds.maxZ,
158
+ )
157
159
 
158
160
  for (const id of affectedIds) {
159
161
  useLiveTransforms.getState().clear(id)
@@ -169,12 +171,23 @@ export function startFloorplanGroupMove(
169
171
  nodeId,
170
172
  handle: GROUP_MOVE_DRAG_LABEL,
171
173
  })
172
- // Rotation pivot for mid-drag R/T — the participant DATA extents' center
173
- // (stable across the drag; rotations re-seed around the same point).
174
- const ext = participantExtents(starts)
175
- const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
174
+ // Rotation pivot for mid-drag R/T — the START footprint's center, the same
175
+ // point the 3D body drag, the idle keyboard rotate and the rotate gizmos
176
+ // orbit. Stable across the drag; rotations re-seed around the same point.
177
+ const restCenter = planBoundsCenter(restBounds)
176
178
 
177
- return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null }
179
+ return {
180
+ starts,
181
+ links,
182
+ affectedIds,
183
+ candidates,
184
+ restAnchors,
185
+ startBounds: restBounds,
186
+ restBounds,
187
+ rotation: 0,
188
+ restCenter,
189
+ lastDelta: null,
190
+ }
178
191
  }
179
192
 
180
193
  const applyMove = (e: PointerEvent, s: Session) => {
@@ -241,18 +254,20 @@ export function startFloorplanGroupMove(
241
254
  // current delta — the carried group turns exactly like the idle keyboard
242
255
  // rotate, and the commit stays a single updateNodes.
243
256
  const rotateSession = (s: Session, direction: 1 | -1) => {
244
- const rotated = rotateGroupSnapshots(
245
- s.starts,
246
- s.links,
247
- { x: s.restCenter[0], z: s.restCenter[1] },
248
- -direction * (Math.PI / 4),
249
- )
257
+ const pivot = { x: s.restCenter[0], z: s.restCenter[1] }
258
+ const delta = -direction * (Math.PI / 4)
259
+ const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta)
250
260
  s.starts = rotated.starts
251
261
  s.links = rotated.links
252
- const ext = participantExtents(rotated.starts)
253
- if (ext) {
254
- s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
255
- }
262
+ s.rotation += delta
263
+ s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation)
264
+ s.restAnchors = bboxCornerAnchors(
265
+ 'group-move',
266
+ s.restBounds.minX,
267
+ s.restBounds.minZ,
268
+ s.restBounds.maxX,
269
+ s.restBounds.maxZ,
270
+ )
256
271
  sfxEmitter.emit('sfx:item-rotate')
257
272
  applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
258
273
  }
@@ -358,7 +373,22 @@ export function startFloorplanGroupMove(
358
373
  const onKeyDown = (e: KeyboardEvent) => {
359
374
  const key = e.key.toLowerCase()
360
375
  if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
361
- if (!session) return
376
+ // Armed but still under the drag threshold: engage first (exactly what
377
+ // the next pointer-move would do) so the rotation lands inside this
378
+ // session. Falling through to the global idle arm instead would write
379
+ // the scene behind snapshots already captured here, and the first
380
+ // `applyDelta` would republish them — undoing the rotation.
381
+ if (!session) {
382
+ session = engage()
383
+ if (!session) {
384
+ // No plane hit yet: swallow the chord and keep the gesture armed so
385
+ // the next pointer-move can still engage; the idle arm must not run
386
+ // behind the snapshots captured here.
387
+ e.preventDefault()
388
+ e.stopPropagation()
389
+ return
390
+ }
391
+ }
362
392
  e.preventDefault()
363
393
  e.stopPropagation()
364
394
  rotateSession(session, key === 'r' ? 1 : -1)
@@ -415,9 +445,13 @@ export function startFloorplanGroupRotate(event: {
415
445
  const { starts, links } = collectParticipants(fullIds, nodes, levelId)
416
446
  if (starts.length === 0) return false
417
447
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
418
- const ext = participantExtents(starts)
419
- if (!ext) return false
420
- const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 }
448
+ const { inverse: frameInv } = levelFrame(levelId)
449
+ const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv)
450
+ if (!bounds) return false
451
+ // Same pivot as the dashed box the handles hang off (and as the 3D rotate
452
+ // gizmo): its centre, not the anchor points' centre.
453
+ const [pivotX, pivotZ] = planBoundsCenter(bounds)
454
+ const pivot = { x: pivotX, z: pivotZ }
421
455
  const startPlan = clientToPlan(event.clientX, event.clientY)
422
456
  if (!startPlan) return false
423
457
  // Bearing around the pivot in the plan frame — the same atan2 x→z sense
@@ -3,6 +3,7 @@
3
3
  import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
6
+ import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled'
6
7
  import {
7
8
  type FloorplanToolContext,
8
9
  getFloorplanNodeExtension,
@@ -35,6 +36,7 @@ function registeredFloorplanTool(
35
36
  export function FloorplanRegisteredToolLayer() {
36
37
  const mode = useEditor((state) => state.mode)
37
38
  const tool = useEditor((state) => state.tool)
39
+ const registeredToolEnabled = useRegisteredToolEnabled(tool)
38
40
  const floorplanMode = useFloorplanMode((state) => state.mode)
39
41
  const gridSnapStep = useEditor((state) => state.gridSnapStep)
40
42
  const toolDefaults = useEditor((state) =>
@@ -54,7 +56,7 @@ export function FloorplanRegisteredToolLayer() {
54
56
  useEditor.getState().setMode('select')
55
57
  }, [])
56
58
  if (mode !== 'build') return null
57
- const Tool = registeredFloorplanTool(tool, floorplanMode)
59
+ const Tool = registeredToolEnabled ? registeredFloorplanTool(tool, floorplanMode) : null
58
60
  return Tool ? (
59
61
  <Suspense fallback={null}>
60
62
  <Tool