@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -49,6 +49,8 @@ import {
49
49
  import { isHistoryShortcut } from '../../lib/history'
50
50
  import { endpointReshapeScope } from '../../lib/interaction/scope'
51
51
  import { sfxEmitter } from '../../lib/sfx-bus'
52
+ import { intersectSpatialDragPlane, spatialDragLocalY } from '../../lib/spatial-drag-plane'
53
+ import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input'
52
54
  import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
53
55
  import useInteractionScope, {
54
56
  useEndpointReshape,
@@ -769,7 +771,9 @@ function WallBaseElevationHandle({
769
771
  const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4(
770
772
  levelObject.matrixWorld,
771
773
  )
772
- const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
774
+ const planeNormal = new Vector3()
775
+ .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld)
776
+ .setY(0)
773
777
  if (planeNormal.lengthSq() === 0) return null
774
778
  planeNormal.normalize()
775
779
  const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
@@ -908,16 +912,12 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
908
912
  const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
909
913
  if (!levelObject) return
910
914
 
911
- // Vertical plane through the wall midpoint whose normal points toward
912
- // the camera (projected to horizontal). Raycasting against it converts
913
- // pointer movement into a world-space Y value.
915
+ levelObject.updateWorldMatrix(true, false)
916
+ const worldToLocal = levelObject.matrixWorld.clone().invert()
914
917
  const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld)
915
- const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
916
- if (planeNormal.lengthSq() === 0) return
917
- planeNormal.normalize()
918
- const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
919
-
920
918
  const ndc = new Vector2()
919
+ const spatialPointerId = getSpatialPointerId(event.nativeEvent)
920
+ const spatialRay = spatialPointerId ? event.ray.clone() : null
921
921
  const setNDC = (clientX: number, clientY: number) => {
922
922
  const rect = gl.domElement.getBoundingClientRect()
923
923
  ndc.set(
@@ -926,17 +926,39 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
926
926
  )
927
927
  }
928
928
 
929
- setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
930
- raycaster.setFromCamera(ndc, camera)
931
- const hit = new Vector3()
932
- if (!raycaster.ray.intersectPlane(plane, hit)) return
929
+ let plane: Plane | null = null
930
+ let initialY: number
931
+ if (spatialRay) {
932
+ const worldUp = new Vector3(0, 1, 0).transformDirection(levelObject.matrixWorld)
933
+ const axisPoint = new Vector3()
934
+ const spatialRayLocalY = (ray: Ray) => {
935
+ const axisParameter = closestAxisParameterToRay(midpointWorld, worldUp, ray)
936
+ axisPoint.copy(midpointWorld).addScaledVector(worldUp, axisParameter)
937
+ return spatialDragLocalY(axisPoint, worldToLocal)
938
+ }
939
+ initialY = spatialRayLocalY(spatialRay)
940
+ } else {
941
+ // A camera-facing vertical plane gives desktop pointer movement a stable
942
+ // world-space height. Spatial pointers use the closest point on the
943
+ // wall's vertical axis instead, which remains defined for vertical rays.
944
+ const planeNormal = new Vector3()
945
+ .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld)
946
+ .setY(0)
947
+ if (planeNormal.lengthSq() === 0) return
948
+ plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal.normalize(), midpointWorld)
949
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
950
+ raycaster.setFromCamera(ndc, camera)
951
+ const hit = new Vector3()
952
+ if (!intersectSpatialDragPlane(raycaster.ray, plane, hit)) return
953
+ initialY = spatialDragLocalY(hit, worldToLocal)
954
+ }
933
955
 
934
956
  // Dragging the top makes the wall custom-height; seed from the resolved
935
957
  // effective height so a plane-bound wall's drag starts at its real top.
936
958
  const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
937
- const initialY = hit.y
938
959
  const wallId = wall.id as AnyNodeId
939
960
  let pendingHeight = initialHeight
961
+ let releaseSpatialCapture: (() => void) | null = null
940
962
 
941
963
  document.body.style.cursor = 'ns-resize'
942
964
  sfxEmitter.emit('sfx:item-pick')
@@ -954,9 +976,23 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
954
976
  const onMove = (e: PointerEvent) => {
955
977
  setNDC(e.clientX, e.clientY)
956
978
  raycaster.setFromCamera(ndc, camera)
957
- const intersection = new Vector3()
958
- if (!raycaster.ray.intersectPlane(plane, intersection)) return
959
- const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY))
979
+ applyRay(raycaster.ray)
980
+ }
981
+ const applyRay = (ray: Ray) => {
982
+ let currentY: number
983
+ if (spatialRay) {
984
+ const worldUp = new Vector3(0, 1, 0).transformDirection(levelObject.matrixWorld)
985
+ const axisParameter = closestAxisParameterToRay(midpointWorld, worldUp, ray)
986
+ const axisPoint = midpointWorld.clone().addScaledVector(worldUp, axisParameter)
987
+ currentY = spatialDragLocalY(axisPoint, worldToLocal)
988
+ } else {
989
+ if (!plane) return
990
+ const intersection = new Vector3()
991
+ if (!intersectSpatialDragPlane(ray, plane, intersection)) return
992
+ currentY = spatialDragLocalY(intersection, worldToLocal)
993
+ }
994
+ const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (currentY - initialY))
995
+ if (Math.abs(newHeight - pendingHeight) < 1e-6) return
960
996
  pendingHeight = newHeight
961
997
  useLiveNodeOverrides.getState().set(wallId, { height: newHeight })
962
998
  useScene.getState().markDirty(wallId)
@@ -967,6 +1003,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
967
1003
  window.removeEventListener('pointerup', onUp)
968
1004
  window.removeEventListener('pointercancel', onCancel)
969
1005
  window.removeEventListener('keydown', onKeyDown, true)
1006
+ releaseSpatialCapture?.()
1007
+ releaseSpatialCapture = null
970
1008
  if (document.body.style.cursor === 'ns-resize') {
971
1009
  document.body.style.cursor = ''
972
1010
  }
@@ -981,6 +1019,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
981
1019
  // Commit: write the final override-merged value to zustand once
982
1020
  // (tracked, undoable), then drop the override so the renderer
983
1021
  // falls back to the scene store.
1022
+ useScene.temporal.getState().resume()
984
1023
  if (pendingHeight !== initialHeight) {
985
1024
  useScene.getState().updateNode(wallId, { height: pendingHeight })
986
1025
  }
@@ -1006,10 +1045,21 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
1006
1045
  onCancel()
1007
1046
  }
1008
1047
 
1009
- dragCleanupRef.current = cleanup
1010
- window.addEventListener('pointermove', onMove)
1011
- window.addEventListener('pointerup', onUp)
1012
- window.addEventListener('pointercancel', onCancel)
1048
+ dragCleanupRef.current = onCancel
1049
+ if (spatialPointerId && spatialRay) {
1050
+ releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
1051
+ onMove: (ray) => {
1052
+ spatialRay.copy(ray)
1053
+ applyRay(spatialRay)
1054
+ },
1055
+ onRelease: onUp,
1056
+ onCancel,
1057
+ })
1058
+ } else {
1059
+ window.addEventListener('pointermove', onMove)
1060
+ window.addEventListener('pointerup', onUp)
1061
+ window.addEventListener('pointercancel', onCancel)
1062
+ }
1013
1063
  window.addEventListener('keydown', onKeyDown, true)
1014
1064
  }
1015
1065
 
@@ -11,10 +11,10 @@ import {
11
11
  type Group,
12
12
  type Mesh,
13
13
  MeshBasicMaterial,
14
- Vector3,
15
14
  } from 'three'
16
15
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
17
16
  import { EDITOR_LAYER } from '../../lib/constants'
17
+ import { copyOverlayWorldTransform } from '../../lib/overlay-transform'
18
18
 
19
19
  // How far the outline sits outside the opening's own extents, so it reads as
20
20
  // a frame *around* the opening rather than coinciding with its edges.
@@ -23,7 +23,6 @@ const PAD = 0.05
23
23
  const ACCENT = 0x83_81_ed
24
24
 
25
25
  const NO_RAYCAST = () => null
26
- const scratchScale = new Vector3()
27
26
 
28
27
  // Indigo accent matching the resize-arrow handles — deliberately distinct
29
28
  // from the white selection outline so the highlight reads as "editable child
@@ -182,7 +181,7 @@ function OpeningHighlight({ openingId, depth }: { openingId: string; depth: numb
182
181
  return
183
182
  }
184
183
  group.visible = true
185
- obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale)
184
+ copyOverlayWorldTransform(obj, group)
186
185
  })
187
186
 
188
187
  if (!isOpening || !outlineGeometry || !fillGeometry) return null
@@ -37,8 +37,7 @@ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-
37
37
  import {
38
38
  classifyParticipant,
39
39
  collectParticipants,
40
- computeGroupBox,
41
- expandToComponent,
40
+ computeGroupPlanBox,
42
41
  type GroupPlanBounds,
43
42
  groupPlanBounds,
44
43
  levelFrame,
@@ -128,9 +127,8 @@ export function startFloorplanGroupMove(
128
127
  const participantIds = selectedIds.filter(
129
128
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
130
129
  )
131
- // Move the full connected wall/fence component, mirroring the 3D gizmo.
132
- const fullIds = expandToComponent(participantIds, nodes, levelId)
133
- const { starts, links } = collectParticipants(fullIds, nodes, levelId)
130
+ // Only the selection moves; connected walls stretch through `links`, as in 3D.
131
+ const { starts, links } = collectParticipants(participantIds, nodes, levelId)
134
132
  if (starts.length === 0) return null
135
133
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
136
134
 
@@ -144,10 +142,9 @@ export function startFloorplanGroupMove(
144
142
  const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
145
143
 
146
144
  // The group aligns as one rigid footprint: its bbox corners + center are
147
- // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays
148
- // mounted under every view mode); plan coords are level-frame, so convert.
145
+ // the moving anchors, measured in the level frame like the plan itself.
149
146
  const { inverse: frameInv } = levelFrame(levelId)
150
- const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv)
147
+ const restBounds = groupPlanBounds(starts, frameInv)
151
148
  if (!restBounds) return null
152
149
  const restAnchors = bboxCornerAnchors(
153
150
  'group-move',
@@ -441,12 +438,11 @@ export function startFloorplanGroupRotate(event: {
441
438
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
442
439
  )
443
440
  if (participantIds.length === 0) return false
444
- const fullIds = expandToComponent(participantIds, nodes, levelId)
445
- const { starts, links } = collectParticipants(fullIds, nodes, levelId)
441
+ const { starts, links } = collectParticipants(participantIds, nodes, levelId)
446
442
  if (starts.length === 0) return false
447
443
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
448
444
  const { inverse: frameInv } = levelFrame(levelId)
449
- const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv)
445
+ const bounds = groupPlanBounds(starts, frameInv)
450
446
  if (!bounds) return false
451
447
  // Same pivot as the dashed box the handles hang off (and as the 3D rotate
452
448
  // gizmo): its centre, not the anchor points' centre.
@@ -605,8 +601,9 @@ const GROUP_BOX_ROTATE_CURSOR_STYLE = {
605
601
 
606
602
  /**
607
603
  * Dashed bounding box around the current multi-selection's transformable
608
- * participants (expanded to the welded wall/fence component) — shows what a
609
- * group drag will carry along, and IS the group's drag handle: press anywhere
604
+ * participants — what a group drag carries (connected walls outside the
605
+ * selection stretch at their shared ends to stay joined) — and IS the group's
606
+ * drag handle: press anywhere
610
607
  * inside it to slide the group, click to pick it up. Holding a selection
611
608
  * modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under
612
609
  * the box can still be toggled in and out. Rides the live drag delta so it
@@ -653,7 +650,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
653
650
  }, [])
654
651
 
655
652
  // `meshEpoch` re-runs the measurement once the meshes settle after a scene
656
- // change (undo/redo included) — `computeGroupBox` reads mesh world bounds,
653
+ // change (undo/redo included) — `computeGroupPlanBox` reads mesh bounds,
657
654
  // which lag the `nodes` commit by a frame or two.
658
655
  const meshEpoch = useMeshSettleEpoch(nodes)
659
656
  const box = useMemo(() => {
@@ -663,17 +660,13 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
663
660
  (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
664
661
  )
665
662
  if (participantIds.length === 0) return null
666
- const fullIds = expandToComponent(participantIds, nodes, levelId)
667
- const world = computeGroupBox(fullIds)
668
- if (!world) return null
669
- const { inverse } = levelFrame(levelId)
670
- const min = world.min.clone().applyMatrix4(inverse)
671
- const max = world.max.clone().applyMatrix4(inverse)
663
+ const plan = computeGroupPlanBox(participantIds, levelId)
664
+ if (!plan) return null
672
665
  return {
673
- x: Math.min(min.x, max.x),
674
- z: Math.min(min.z, max.z),
675
- width: Math.abs(max.x - min.x),
676
- depth: Math.abs(max.z - min.z),
666
+ x: plan.minX,
667
+ z: plan.minZ,
668
+ width: plan.maxX - plan.minX,
669
+ depth: plan.maxZ - plan.minZ,
677
670
  }
678
671
  }, [selectedIds, levelId, nodes, meshEpoch])
679
672
 
@@ -14,8 +14,19 @@ import {
14
14
  } from '../../lib/floorplan/floorplan-mode'
15
15
  import useEditor from '../../store/use-editor'
16
16
  import useFloorplanMode from '../../store/use-floorplan-mode'
17
+ import useInteractionScope, { useReshapingNode } from '../../store/use-interaction-scope'
17
18
 
18
- const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
19
+ type Loader = () => Promise<{ default: ComponentType<FloorplanToolContext> }>
20
+ const lazyToolCache = new WeakMap<Loader, ComponentType<FloorplanToolContext>>()
21
+
22
+ function lazyTool(loader: Loader | undefined): ComponentType<FloorplanToolContext> | null {
23
+ if (!loader) return null
24
+ const cached = lazyToolCache.get(loader)
25
+ if (cached) return cached
26
+ const component = lazy(loader)
27
+ lazyToolCache.set(loader, component)
28
+ return component
29
+ }
19
30
 
20
31
  function registeredFloorplanTool(
21
32
  tool: string | null,
@@ -24,13 +35,16 @@ function registeredFloorplanTool(
24
35
  if (!tool) return null
25
36
  const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
26
37
  if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null
27
- const loader = extension?.tool
28
- if (!loader) return null
29
- const cached = lazyToolCache.get(loader)
30
- if (cached) return cached
31
- const component = lazy(loader)
32
- lazyToolCache.set(loader, component)
33
- return component
38
+ return lazyTool(extension?.tool)
39
+ }
40
+
41
+ /** The plan sibling of `def.affordanceTools[reshape]`: a kind's layer for its own reshape. */
42
+ function registeredReshapeLayer(
43
+ kind: string | null,
44
+ reshape: string | null,
45
+ ): ComponentType<FloorplanToolContext> | null {
46
+ if (!(kind && reshape)) return null
47
+ return lazyTool(getFloorplanNodeExtension(nodeRegistry.get(kind))?.reshapeLayers?.[reshape])
34
48
  }
35
49
 
36
50
  export function FloorplanRegisteredToolLayer() {
@@ -42,6 +56,10 @@ export function FloorplanRegisteredToolLayer() {
42
56
  const toolDefaults = useEditor((state) =>
43
57
  state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
44
58
  )
59
+ const reshape = useInteractionScope((state) =>
60
+ state.scope.kind === 'reshaping' ? state.scope.reshape : null,
61
+ )
62
+ const reshapingNode = useReshapingNode()
45
63
  const activeLevelId = useViewer((state) => state.selection.levelId)
46
64
  const unit = useViewer((state) => state.unit)
47
65
  const metricNotation = useViewer((state) => state.metricNotation)
@@ -55,11 +73,14 @@ export function FloorplanRegisteredToolLayer() {
55
73
  useEditor.getState().setTool(null)
56
74
  useEditor.getState().setMode('select')
57
75
  }, [])
58
- if (mode !== 'build') return null
59
- const Tool = registeredToolEnabled ? registeredFloorplanTool(tool, floorplanMode) : null
60
- return Tool ? (
76
+ const Active =
77
+ registeredReshapeLayer(reshapingNode?.type ?? null, reshape) ??
78
+ (mode === 'build' && registeredToolEnabled
79
+ ? registeredFloorplanTool(tool, floorplanMode)
80
+ : null)
81
+ return Active ? (
61
82
  <Suspense fallback={null}>
62
- <Tool
83
+ <Active
63
84
  activeLevelId={activeLevelId}
64
85
  finishTool={finishTool}
65
86
  gridSnapStep={gridSnapStep}
@@ -27,6 +27,7 @@ import {
27
27
  prepareFreshPlacementRootDuplicate,
28
28
  } from '../../lib/fresh-planar-placement'
29
29
  import { curveReshapeScope } from '../../lib/interaction/scope'
30
+ import { duplicateWithoutMove, registryMoveDisabled } from '../../lib/node-action-movement'
30
31
  import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
31
32
  import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
32
33
  import { sfxEmitter } from '../../lib/sfx-bus'
@@ -250,7 +251,8 @@ export function FloorplanRegistryActionMenu() {
250
251
  // walls land on their bespoke `MoveWallTool` (perpendicular slide
251
252
  // with linked-wall cascade) via `affordanceTools.move`.
252
253
  const canMove =
253
- !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
254
+ !registryMoveDisabled(node) &&
255
+ (!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move)
254
256
  const canDuplicate = def.capabilities.duplicable !== false
255
257
  const canDelete = def.capabilities.deletable !== false
256
258
  const canAddHole = node.type === 'slab' || node.type === 'ceiling'
@@ -320,6 +322,15 @@ export function FloorplanRegistryActionMenu() {
320
322
  const handleDuplicate = () => {
321
323
  if (!node.parentId) return
322
324
  sfxEmitter.emit('sfx:item-pick')
325
+ if (registryMoveDisabled(node)) {
326
+ try {
327
+ const id = duplicateWithoutMove(node)
328
+ if (id) useViewer.getState().setSelection({ selectedIds: [id] })
329
+ } catch (error) {
330
+ console.error('Failed to duplicate node', error)
331
+ }
332
+ return
333
+ }
323
334
  useScene.temporal.getState().pause()
324
335
  let draftId: AnyNodeId | null = null
325
336
  try {