@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
@@ -33,6 +33,7 @@ import {
33
33
  type RoofNode,
34
34
  type RoofSegmentNode,
35
35
  resolveSlabPlacementElevation,
36
+ resolveTerrainWallConstructionOptions,
36
37
  type SiteNode,
37
38
  type SlabNode,
38
39
  SlabNode as SlabNodeSchema,
@@ -88,6 +89,8 @@ import {
88
89
  type FloorplanNodeTransform as SharedFloorplanNodeTransform,
89
90
  worldToFloorplanLocalPoint,
90
91
  } from '../../lib/floorplan'
92
+ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point'
93
+ import type { EditorGridEvent } from '../../lib/grid-event-presentation'
91
94
  import { groundHeightAt } from '../../lib/ground-surface'
92
95
  import { guideEmitter } from '../../lib/guide-events'
93
96
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
@@ -148,6 +151,7 @@ import {
148
151
  isBoxSelectPointerSuppressed,
149
152
  markBoxSelectHandled,
150
153
  } from '../tools/select/box-select-state'
154
+ import { marqueePolygon } from '../tools/select/marquee-footprint'
151
155
  import {
152
156
  type Point2 as MarqueePoint2,
153
157
  polygonsIntersect as marqueePolygonsIntersect,
@@ -184,7 +188,6 @@ import {
184
188
  chainEndJoinsExistingWall,
185
189
  createWallOnCurrentLevel,
186
190
  isSegmentLongEnough,
187
- resolveTerrainWallConstructionOptions,
188
191
  snapWallDraftPoint,
189
192
  snapWallDraftPointDetailed,
190
193
  snapPointToGrid as snapWallPointToGrid,
@@ -864,7 +867,8 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
864
867
  | { start?: unknown; end?: unknown; polygon?: unknown }
865
868
  | undefined
866
869
  if (!node) continue
867
- const { start, end, polygon } = node
870
+ const { start, end } = node
871
+ const polygon = marqueePolygon(node)
868
872
  if (isMarqueeVec2(start) && isMarqueeVec2(end)) {
869
873
  dataTested.add(id)
870
874
  if (marqueeSegmentIntersectsPolygon(start, end, planQuad)) hitIdsFromData.add(id)
@@ -4673,6 +4677,7 @@ function FloorplanLinearDraftLayer({
4673
4677
  const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
4674
4678
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
4675
4679
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
4680
+ const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn)
4676
4681
 
4677
4682
  const draftPolygon = useMemo(() => {
4678
4683
  if (
@@ -4709,6 +4714,21 @@ function FloorplanLinearDraftLayer({
4709
4714
  return draftPolygon ? formatPolygonPoints(draftPolygon) : null
4710
4715
  }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
4711
4716
 
4717
+ const roofDraftDirectionLine = useMemo(() => {
4718
+ if (!(isRoofBuildActive && roofDraftStart && roofDraftEnd)) return null
4719
+ const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
4720
+ const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
4721
+ const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
4722
+ const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
4723
+ if (maxX - minX < 1e-6 || maxY - minY < 1e-6) return null
4724
+
4725
+ const centerX = (minX + maxX) / 2
4726
+ const centerY = (minY + maxY) / 2
4727
+ return roofDraftQuarterTurn
4728
+ ? { x1: centerX, y1: minY, x2: centerX, y2: maxY }
4729
+ : { x1: minX, y1: centerY, x2: maxX, y2: centerY }
4730
+ }, [isRoofBuildActive, roofDraftEnd, roofDraftQuarterTurn, roofDraftStart])
4731
+
4712
4732
  const fenceDraftSegment = useMemo(() => {
4713
4733
  if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
4714
4734
  return null
@@ -4887,6 +4907,19 @@ function FloorplanLinearDraftLayer({
4887
4907
  unitsPerPixel={unitsPerPixel}
4888
4908
  />
4889
4909
 
4910
+ {roofDraftDirectionLine && (
4911
+ <line
4912
+ pointerEvents="none"
4913
+ stroke={draftStroke}
4914
+ strokeLinecap="round"
4915
+ strokeWidth={unitsPerPixel * 1.5}
4916
+ x1={toSvgX(roofDraftDirectionLine.x1)}
4917
+ x2={toSvgX(roofDraftDirectionLine.x2)}
4918
+ y1={toSvgY(roofDraftDirectionLine.y1)}
4919
+ y2={toSvgY(roofDraftDirectionLine.y2)}
4920
+ />
4921
+ )}
4922
+
4890
4923
  {draftWallMeasurement && (
4891
4924
  <FloorplanDraftWallMeasurement
4892
4925
  labelBackground={isDark ? '#0f172a' : '#ffffff'}
@@ -5063,6 +5096,9 @@ export function FloorplanPanel({
5063
5096
  if (building?.type !== 'building') return false
5064
5097
  return building.children.some((cid) => state.nodes[cid]?.type === 'level')
5065
5098
  })
5099
+ // The studio workspace (renders / materials / item builder) is a clean
5100
+ // stage — editor viewport chrome, compass included, stays out of it.
5101
+ const isStudioWorkspace = useEditor((s) => s.workspaceMode === 'studio')
5066
5102
  const elevators = useScene(
5067
5103
  useShallow((state) => {
5068
5104
  const building = currentBuildingId ? state.nodes[currentBuildingId] : null
@@ -8931,12 +8967,29 @@ export function FloorplanPanel({
8931
8967
  const groundY = groundHeightAt(worldX, worldZ, floorplanGridWorldY)
8932
8968
  const worldY = groundY ?? floorplanGridWorldY
8933
8969
  const localY = groundY === null ? floorplanGridLocalY : groundY - buildingPosition[1]
8970
+ const planScene =
8971
+ nativeEvent.currentTarget.querySelector<SVGGraphicsElement>('[data-floorplan-scene]')
8972
+ const screenMatrix = planScene?.getScreenCTM()
8934
8973
 
8935
- emitter.emit(`grid:${eventType}` as any, {
8974
+ const gridEvent: EditorGridEvent = {
8936
8975
  nativeEvent: nativeEvent.nativeEvent as any,
8937
8976
  position: [worldX, worldY, worldZ],
8938
8977
  localPosition: [planPoint[0], localY, planPoint[1]],
8939
- })
8978
+ screenProjection: screenMatrix
8979
+ ? {
8980
+ pointer: [nativeEvent.clientX, nativeEvent.clientY],
8981
+ localToScreen: [
8982
+ screenMatrix.a,
8983
+ screenMatrix.b,
8984
+ screenMatrix.c,
8985
+ screenMatrix.d,
8986
+ screenMatrix.e,
8987
+ screenMatrix.f,
8988
+ ],
8989
+ }
8990
+ : undefined,
8991
+ }
8992
+ emitter.emit(`grid:${eventType}` as any, gridEvent)
8940
8993
  },
8941
8994
  [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
8942
8995
  )
@@ -9400,10 +9453,14 @@ export function FloorplanPanel({
9400
9453
  // this exclusion the catch-all would emit `grid:move` and re-drive the
9401
9454
  // 3D MoveDoorTool's free-follow, fighting the overlay again.
9402
9455
  if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
9403
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
9404
- emitFloorplanGridEvent('move', snappedPoint, event)
9456
+ const eventPoint = resolveGenericFloorplanGridEventPoint({
9457
+ point: planPoint,
9458
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
9459
+ snap: getSnappedFloorplanPoint,
9460
+ })
9461
+ emitFloorplanGridEvent('move', eventPoint, event)
9405
9462
  setCursorPoint((previousPoint) =>
9406
- previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
9463
+ previousPoint && pointsEqual(previousPoint, eventPoint) ? previousPoint : eventPoint,
9407
9464
  )
9408
9465
  return
9409
9466
  }
@@ -9503,6 +9560,7 @@ export function FloorplanPanel({
9503
9560
  // stale closure and float a door symbol while the window tool is armed.
9504
9561
  showOpeningGhost,
9505
9562
  isPolygonBuildActive,
9563
+ isRegistryToolBuildActive,
9506
9564
  isRoofBuildActive,
9507
9565
  isWallBuildActive,
9508
9566
  levelId,
@@ -9849,6 +9907,7 @@ export function FloorplanPanel({
9849
9907
  isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
9850
9908
  isPolygonBuildActive,
9851
9909
  isRoofBuildActive,
9910
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
9852
9911
  isWallBuildActive,
9853
9912
  isZoneBuildActive,
9854
9913
  levelId,
@@ -10512,9 +10571,11 @@ export function FloorplanPanel({
10512
10571
  }
10513
10572
 
10514
10573
  if (useEditor.getState().phase !== 'site') {
10515
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
10574
+ useEditor.getState().setPhase('site')
10575
+ useEditor.getState().armToolMode({ mode: 'select' })
10576
+ } else {
10577
+ selectSiteFloorplanContext()
10516
10578
  }
10517
- selectSiteFloorplanContext()
10518
10579
 
10519
10580
  const nextDraft = {
10520
10581
  siteId,
@@ -10596,9 +10657,11 @@ export function FloorplanPanel({
10596
10657
  ]
10597
10658
 
10598
10659
  if (useEditor.getState().phase !== 'site') {
10599
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
10660
+ useEditor.getState().setPhase('site')
10661
+ useEditor.getState().armToolMode({ mode: 'select' })
10662
+ } else {
10663
+ selectSiteFloorplanContext()
10600
10664
  }
10601
- selectSiteFloorplanContext()
10602
10665
 
10603
10666
  const nextDraft = {
10604
10667
  siteId,
@@ -11107,7 +11170,8 @@ export function FloorplanPanel({
11107
11170
  <FloorplanRegistryActionMenu />
11108
11171
  <FloorplanGroupActionMenu />
11109
11172
 
11110
- {(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11173
+ {!isStudioWorkspace &&
11174
+ (levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11111
11175
  (compassHost ? (
11112
11176
  createPortal(
11113
11177
  <FloorplanCompassButton
@@ -9,7 +9,7 @@ import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/
9
9
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
10
  import { useCeilingEvents } from '../../hooks/use-ceiling-events'
11
11
  import { useGridEvents } from '../../hooks/use-grid-events'
12
- import { getPlacementSurface } from '../../lib/active-placement-surface'
12
+ import { getPlacementSurface, usesOrientedPlacementPlane } from '../../lib/active-placement-surface'
13
13
  import useEditor, { isGridSnapActive } from '../../store/use-editor'
14
14
  import { getMovingNode } from '../../store/use-interaction-scope'
15
15
 
@@ -229,16 +229,21 @@ export const Grid = ({
229
229
  }
230
230
 
231
231
  const gridMesh = gridRef.current
232
- const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
233
- if (onWall && surfacePoint) {
234
- // Wall-anchored lattice: orient the plane into the wall and pin the mesh to
232
+ const onOrientedPlane = surfacePoint != null && usesOrientedPlacementPlane(surfaceNormal)
233
+ if (onOrientedPlane && surfacePoint) {
234
+ // Surface-anchored lattice: orient the plane into the host and pin the mesh to
235
235
  // the plane's FOOT (the point on the wall plane closest to the world origin)
236
236
  // — never the moving ghost. Sliding the opening along the wall then only
237
237
  // moves the reveal patch (the cursor uniform); the snap lattice stays put.
238
238
  // (Copying `surfacePoint` here made the grid follow the item — useless.)
239
239
  gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
240
240
  const planeOffset = surfacePoint.dot(surfaceNormal)
241
- gridMesh.position.copy(surfaceNormal).multiplyScalar(planeOffset)
241
+ const latticeAnchor = published?.anchor ?? surfacePoint
242
+ gridMesh.position.copy(latticeAnchor)
243
+ gridMesh.position.addScaledVector(
244
+ surfaceNormal,
245
+ -latticeAnchor.dot(surfaceNormal) + planeOffset,
246
+ )
242
247
  // Cursor → plane-local XY: rotate (ghost − anchor) by the inverse plane
243
248
  // orientation. Both lie in the plane, so the resulting local Z is ~0.
244
249
  invQuatRef.current.copy(gridMesh.quaternion).invert()
@@ -275,8 +280,8 @@ export const Grid = ({
275
280
  // Floor grid depth-tests against the scene (ground occludes a sub-floor
276
281
  // lattice); the wall grid ignores depth so it stays visible through the wall
277
282
  // when the opening is being handled from the opposite side.
278
- if (material.depthTest === onWall) {
279
- material.depthTest = !onWall
283
+ if (material.depthTest === onOrientedPlane) {
284
+ material.depthTest = !onOrientedPlane
280
285
  material.needsUpdate = true
281
286
  }
282
287
 
@@ -14,7 +14,7 @@ import {
14
14
  useLiveTransforms,
15
15
  useScene,
16
16
  } from '@pascal-app/core'
17
- import { useViewer } from '@pascal-app/viewer'
17
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
18
18
  import { Plane, Vector2, Vector3 } from 'three'
19
19
  import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
20
20
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
@@ -40,9 +40,11 @@ import {
40
40
  collectParticipants,
41
41
  computeGroupBox,
42
42
  expandToComponent,
43
+ groupPlanBounds,
43
44
  levelFrame,
44
- participantExtents,
45
+ planBoundsCenter,
45
46
  rotateGroupSnapshots,
47
+ rotatePlanBounds,
46
48
  translateGroupPatches,
47
49
  type Vec2,
48
50
  } from './group-transform-shared'
@@ -104,46 +106,15 @@ export function startGroupPickUp(
104
106
  if (starts.length === 0) return false
105
107
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
106
108
 
107
- // Rest bounds in the level frame. Prefer the mounted meshes' world box
108
- // (footprint-accurate), but fall back to the participant DATA when the
109
- // meshes aren't up yet — Duplicate starts the pick-up synchronously after
110
- // `createNodes`, one frame before the clones' renderers mount.
111
109
  const { inverse: frameInv } = levelFrame(levelId)
112
110
  const restBox = computeGroupBox(fullIds)
113
- let minX = Number.POSITIVE_INFINITY
114
- let minZ = Number.POSITIVE_INFINITY
115
- let maxX = Number.NEGATIVE_INFINITY
116
- let maxZ = Number.NEGATIVE_INFINITY
117
- if (restBox) {
118
- const boxMin = restBox.min.clone().applyMatrix4(frameInv)
119
- const boxMax = restBox.max.clone().applyMatrix4(frameInv)
120
- minX = Math.min(boxMin.x, boxMax.x)
121
- minZ = Math.min(boxMin.z, boxMax.z)
122
- maxX = Math.max(boxMin.x, boxMax.x)
123
- maxZ = Math.max(boxMin.z, boxMax.z)
124
- } else {
125
- const reach = (x: number, z: number) => {
126
- minX = Math.min(minX, x)
127
- minZ = Math.min(minZ, z)
128
- maxX = Math.max(maxX, x)
129
- maxZ = Math.max(maxZ, z)
130
- }
131
- for (const s of starts) {
132
- if (s.kind === 'endpoint') {
133
- reach(s.start[0], s.start[1])
134
- reach(s.end[0], s.end[1])
135
- } else if (s.kind === 'polygon') {
136
- for (const [x, z] of s.polygon) {
137
- reach(x, z)
138
- }
139
- } else {
140
- reach(s.position[0], s.position[2])
141
- }
142
- }
143
- }
144
- if (!Number.isFinite(minX)) return false
111
+ const startBounds = groupPlanBounds(restBox, starts, frameInv)
112
+ if (!startBounds) return false
113
+ // Mutable: mid-carry R/T re-seeds the footprint around the same pivot.
114
+ let restBounds = startBounds
115
+ let carriedRotation = 0
145
116
  // Rotation pivot for mid-carry R/T; stable across the whole pick-up.
146
- const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2]
117
+ const restCenter = planBoundsCenter(restBounds)
147
118
  // Ground plane for the 3D surface: the meshes' base when available, floor
148
119
  // level otherwise. Placements live in the level frame, so both surfaces
149
120
  // resolve into it before measuring.
@@ -157,7 +128,13 @@ export function startGroupPickUp(
157
128
  if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
158
129
  }
159
130
  const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
160
- let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ)
131
+ let restAnchors = bboxCornerAnchors(
132
+ 'group-move',
133
+ restBounds.minX,
134
+ restBounds.minZ,
135
+ restBounds.maxX,
136
+ restBounds.maxZ,
137
+ )
161
138
 
162
139
  // Cursor → level-frame plan point, whichever surface the pointer is over.
163
140
  const ndc = new Vector2()
@@ -274,18 +251,20 @@ export function startGroupPickUp(
274
251
  // the current delta — the carried group turns exactly like the idle
275
252
  // keyboard rotate, and the placement stays a single updateNodes.
276
253
  const rotateCarried = (direction: 1 | -1) => {
277
- const rotated = rotateGroupSnapshots(
278
- starts,
279
- links,
280
- { x: restCenter[0], z: restCenter[1] },
281
- -direction * (Math.PI / 4),
282
- )
254
+ const pivot = { x: restCenter[0], z: restCenter[1] }
255
+ const delta = -direction * (Math.PI / 4)
256
+ const rotated = rotateGroupSnapshots(starts, links, pivot, delta)
283
257
  starts = rotated.starts
284
258
  links = rotated.links
285
- const ext = participantExtents(rotated.starts)
286
- if (ext) {
287
- restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
288
- }
259
+ carriedRotation += delta
260
+ restBounds = rotatePlanBounds(startBounds, pivot, carriedRotation)
261
+ restAnchors = bboxCornerAnchors(
262
+ 'group-move',
263
+ restBounds.minX,
264
+ restBounds.minZ,
265
+ restBounds.maxX,
266
+ restBounds.maxZ,
267
+ )
289
268
  sfxEmitter.emit('sfx:item-rotate')
290
269
  applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0)
291
270
  }
@@ -575,6 +554,11 @@ export function deleteSelection(): boolean {
575
554
  if (selectedIds.length === 0) return false
576
555
 
577
556
  const commitDelete = () => {
557
+ const detail =
558
+ selectedIds.length === 1
559
+ ? (useScene.getState().nodes[selectedIds[0]!]?.type ?? selectedIds[0]!)
560
+ : String(selectedIds.length)
561
+ markPerfAction('delete', detail)
578
562
  if (selectedIds.length === 1) {
579
563
  emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
580
564
  } else {
@@ -32,9 +32,12 @@ import {
32
32
  collectParticipants,
33
33
  computeGroupBox,
34
34
  expandToComponent,
35
+ type GroupPlanBounds,
36
+ groupPlanBounds,
35
37
  levelFrame,
36
- participantExtents,
38
+ planBoundsCenter,
37
39
  rotateGroupSnapshots,
40
+ rotatePlanBounds,
38
41
  translateGroupPatches,
39
42
  type Vec2,
40
43
  } from './group-transform-shared'
@@ -88,6 +91,9 @@ export function armGroupMove3d(args: {
88
91
  affectedIds: AnyNodeId[]
89
92
  candidates: ReturnType<typeof collectAlignmentAnchors>
90
93
  restAnchors: ReturnType<typeof bboxCornerAnchors>
94
+ startBounds: GroupPlanBounds
95
+ restBounds: GroupPlanBounds
96
+ rotation: number
91
97
  restCenter: Vec2
92
98
  plane: Plane
93
99
  startLocal: Vector3
@@ -130,14 +136,14 @@ export function armGroupMove3d(args: {
130
136
  if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
131
137
  }
132
138
  const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
133
- const boxMin = restBox.min.clone().applyMatrix4(frameInv)
134
- const boxMax = restBox.max.clone().applyMatrix4(frameInv)
139
+ const restBounds = groupPlanBounds(restBox, starts, frameInv)
140
+ if (!restBounds) return null
135
141
  const restAnchors = bboxCornerAnchors(
136
142
  'group-move',
137
- Math.min(boxMin.x, boxMax.x),
138
- Math.min(boxMin.z, boxMax.z),
139
- Math.max(boxMin.x, boxMax.x),
140
- Math.max(boxMin.z, boxMax.z),
143
+ restBounds.minX,
144
+ restBounds.minZ,
145
+ restBounds.maxX,
146
+ restBounds.maxZ,
141
147
  )
142
148
 
143
149
  for (const id of affectedIds) {
@@ -154,9 +160,11 @@ export function armGroupMove3d(args: {
154
160
  nodeId,
155
161
  handle: GROUP_MOVE_DRAG_LABEL,
156
162
  })
157
- // Rotation pivot for mid-drag R/T — the participant DATA extents' center.
158
- const ext = participantExtents(starts)
159
- const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
163
+ // Rotation pivot for mid-drag R/T — the START footprint's center, the same
164
+ // point the idle keyboard rotate and the rotate gizmos orbit. Fixed for the
165
+ // whole session: the snapshots are start placements, and `applyDelta` adds
166
+ // the live drag delta on top of them.
167
+ const restCenter = planBoundsCenter(restBounds)
160
168
 
161
169
  return {
162
170
  starts,
@@ -164,6 +172,9 @@ export function armGroupMove3d(args: {
164
172
  affectedIds,
165
173
  candidates,
166
174
  restAnchors,
175
+ startBounds: restBounds,
176
+ restBounds,
177
+ rotation: 0,
167
178
  restCenter,
168
179
  plane,
169
180
  startLocal,
@@ -245,18 +256,22 @@ export function armGroupMove3d(args: {
245
256
  // current delta — the carried group turns exactly like the idle keyboard
246
257
  // rotate, and the commit stays a single updateNodes.
247
258
  const rotateSession = (s: Session, direction: 1 | -1) => {
248
- const rotated = rotateGroupSnapshots(
249
- s.starts,
250
- s.links,
251
- { x: s.restCenter[0], z: s.restCenter[1] },
252
- -direction * (Math.PI / 4),
253
- )
259
+ const pivot = { x: s.restCenter[0], z: s.restCenter[1] }
260
+ const delta = -direction * (Math.PI / 4)
261
+ const rotated = rotateGroupSnapshots(s.starts, s.links, pivot, delta)
254
262
  s.starts = rotated.starts
255
263
  s.links = rotated.links
256
- const ext = participantExtents(rotated.starts)
257
- if (ext) {
258
- s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
259
- }
264
+ // Re-fit from the start footprint at the accumulated angle: rotating the
265
+ // previous axis-aligned fit would inflate the box every step.
266
+ s.rotation += delta
267
+ s.restBounds = rotatePlanBounds(s.startBounds, pivot, s.rotation)
268
+ s.restAnchors = bboxCornerAnchors(
269
+ 'group-move',
270
+ s.restBounds.minX,
271
+ s.restBounds.minZ,
272
+ s.restBounds.maxX,
273
+ s.restBounds.maxZ,
274
+ )
260
275
  sfxEmitter.emit('sfx:item-rotate')
261
276
  applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
262
277
  }
@@ -369,7 +384,22 @@ export function armGroupMove3d(args: {
369
384
  const onKeyDown = (e: KeyboardEvent) => {
370
385
  const key = e.key.toLowerCase()
371
386
  if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
372
- if (!session) return
387
+ // Armed but still under the drag threshold: engage first (exactly what
388
+ // the next pointer-move would do) so the rotation lands inside this
389
+ // session. Falling through to the global idle arm instead would write
390
+ // the scene behind snapshots already captured here, and the first
391
+ // `applyDelta` would republish them — undoing the rotation.
392
+ if (!session) {
393
+ session = engage()
394
+ if (!session) {
395
+ // No plane hit yet: swallow the chord and keep the gesture armed so
396
+ // the next pointer-move can still engage; the idle arm must not run
397
+ // behind the snapshots captured here.
398
+ e.preventDefault()
399
+ e.stopPropagation()
400
+ return
401
+ }
402
+ }
373
403
  e.preventDefault()
374
404
  e.stopPropagation()
375
405
  rotateSession(session, key === 'r' ? 1 : -1)
@@ -4,7 +4,10 @@ import { z } from 'zod'
4
4
  import {
5
5
  classifyParticipant,
6
6
  collectParticipants,
7
+ planBoundsCenter,
7
8
  rotateGroupPatches,
9
+ rotateGroupSnapshots,
10
+ rotatePlanBounds,
8
11
  translateGroupPatches,
9
12
  } from './group-transform-shared'
10
13
 
@@ -40,6 +43,34 @@ function registerElevatorTestKind() {
40
43
  } as AnyNodeDefinition)
41
44
  }
42
45
 
46
+ // A level holding one of each rigid placement shape: an item ([x,y,z] rotation)
47
+ // and a column (numeric rotation).
48
+ function placedNodes() {
49
+ return {
50
+ building_test: { id: 'building_test', type: 'building', children: ['level_test'] },
51
+ level_test: {
52
+ id: 'level_test',
53
+ type: 'level',
54
+ parentId: 'building_test',
55
+ children: ['item_chair', 'column_post'],
56
+ },
57
+ item_chair: {
58
+ id: 'item_chair',
59
+ type: 'item',
60
+ parentId: 'level_test',
61
+ position: [1, 0, 3],
62
+ rotation: [0, 0.5, 0],
63
+ },
64
+ column_post: {
65
+ id: 'column_post',
66
+ type: 'column',
67
+ parentId: 'level_test',
68
+ position: [4, 0, -1],
69
+ rotation: 1.25,
70
+ },
71
+ } as unknown as Record<string, AnyNode>
72
+ }
73
+
43
74
  describe('group transform participants', () => {
44
75
  beforeAll(() => {
45
76
  registerBuildingScopedTestKind()
@@ -362,6 +393,53 @@ describe('group transform participants', () => {
362
393
  expect(lampPatch.position).toEqual([3, 2.4, 2])
363
394
  })
364
395
 
396
+ test('translate patches carry the snapshot rotation for vec3 and scalar kinds', () => {
397
+ const { starts } = collectParticipants(
398
+ ['item_chair', 'column_post'],
399
+ placedNodes(),
400
+ 'level_test',
401
+ )
402
+ const patches = Object.fromEntries(translateGroupPatches(starts, [], 1, 2))
403
+
404
+ expect(patches.item_chair).toEqual({ position: [2, 0, 5], rotation: [0, 0.5, 0] })
405
+ expect(patches.column_post).toEqual({ position: [5, 0, 1], rotation: 1.25 })
406
+ })
407
+
408
+ test('a mid-drag rotation survives the next translate re-publish', () => {
409
+ const { starts } = collectParticipants(
410
+ ['item_chair', 'column_post'],
411
+ placedNodes(),
412
+ 'level_test',
413
+ )
414
+ // What a mid-drag R does: turn the snapshots, then re-apply the live delta.
415
+ const rotated = rotateGroupSnapshots(starts, [], { x: 0, z: 0 }, Math.PI / 2)
416
+ const patches = Object.fromEntries(
417
+ translateGroupPatches(rotated.starts, rotated.links, 1, 2),
418
+ ) as Record<string, { position: number[]; rotation: number[] | number }>
419
+
420
+ // Orbited 90° in the atan2 x→z sense ((x, z) → (-z, x)), then slid.
421
+ expect(patches.item_chair!.position[0]).toBeCloseTo(-2)
422
+ expect(patches.item_chair!.position[2]).toBeCloseTo(3)
423
+ expect(patches.column_post!.position[0]).toBeCloseTo(2)
424
+ expect(patches.column_post!.position[2]).toBeCloseTo(6)
425
+ // …and the facings turned with it instead of reverting to the pre-drag yaw.
426
+ expect((patches.item_chair!.rotation as number[])[1]).toBeCloseTo(0.5 - Math.PI / 2)
427
+ expect(patches.column_post!.rotation as number).toBeCloseTo(1.25 - Math.PI / 2)
428
+ })
429
+
430
+ test('rotating the plan bounds keeps the footprint centred on the pivot', () => {
431
+ const bounds = { minX: 0, minZ: 0, maxX: 4, maxZ: 2 }
432
+ const [pivotX, pivotZ] = planBoundsCenter(bounds)
433
+ const rotated = rotatePlanBounds(bounds, { x: pivotX, z: pivotZ }, Math.PI / 2)
434
+
435
+ expect(rotated.minX).toBeCloseTo(1)
436
+ expect(rotated.maxX).toBeCloseTo(3)
437
+ expect(rotated.minZ).toBeCloseTo(-1)
438
+ expect(rotated.maxZ).toBeCloseTo(3)
439
+ expect(planBoundsCenter(rotated)[0]).toBeCloseTo(pivotX)
440
+ expect(planBoundsCenter(rotated)[1]).toBeCloseTo(pivotZ)
441
+ })
442
+
365
443
  test('supports legacy level-parented elevators already loaded in the editor', () => {
366
444
  const nodes = {
367
445
  building_test: {