@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,15 +1,20 @@
1
- import { beforeEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeId,
5
5
  applyHeightPatch,
6
+ BlockNode,
6
7
  CeilingNode,
7
8
  createTerrainField,
8
9
  DoorNode as DoorSchema,
9
10
  encodeTerrainField,
10
11
  flattenPatch,
11
12
  GROUND_SUPPORT_ID,
13
+ getFloorPlacedElevation,
12
14
  initSpaceDetectionSync,
15
+ nodeRegistry,
16
+ registerNode,
17
+ resolveTerrainWallConstructionOptions,
13
18
  runAsSingleSceneHistoryStep,
14
19
  SlabNode,
15
20
  spatialGridManager,
@@ -23,7 +28,6 @@ import useInteractionScope from '../../../store/use-interaction-scope'
23
28
  import {
24
29
  createWallOnCurrentLevel,
25
30
  resolveEndpointWallSplit,
26
- resolveTerrainWallConstructionOptions,
27
31
  snapWallDraftPointDetailed,
28
32
  } from './wall-drafting'
29
33
  import type { WallPlanPoint } from './wall-snap-geometry'
@@ -139,6 +143,15 @@ function levelWalls(): WallNode[] {
139
143
  }
140
144
 
141
145
  describe('createWallOnCurrentLevel', () => {
146
+ // Set by tests that mutate the process-wide node registry; restored here
147
+ // so the mutation can't leak into later test files (order-dependent flakes).
148
+ let restoreRegistry: (() => void) | undefined
149
+
150
+ afterEach(() => {
151
+ restoreRegistry?.()
152
+ restoreRegistry = undefined
153
+ })
154
+
142
155
  beforeEach(() => {
143
156
  useViewer.setState({
144
157
  selection: {
@@ -198,6 +211,181 @@ describe('createWallOnCurrentLevel', () => {
198
211
  expect(support.elevation).toBe(1.75)
199
212
  })
200
213
 
214
+ test('never exposes a raised wall at floor elevation during commit', () => {
215
+ const observed: WallNode[] = []
216
+ const unsubscribe = useScene.subscribe((state) => {
217
+ const wall = Object.values(state.nodes).find(
218
+ (node): node is WallNode => node?.type === 'wall' && node.id !== 'wall_a',
219
+ )
220
+ if (wall) observed.push(wall)
221
+ })
222
+
223
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
224
+ supportCap: 1.75,
225
+ preferredSupportSlabId: GROUND_SUPPORT_ID,
226
+ constructionElevation: 1.75,
227
+ constructionHeight: 2.5,
228
+ flatConstructionBase: true,
229
+ })
230
+ unsubscribe()
231
+
232
+ expect(created).not.toBeNull()
233
+ expect(observed.length).toBeGreaterThan(0)
234
+ expect(observed.every((wall) => wall.supportSlabId === GROUND_SUPPORT_ID)).toBe(true)
235
+ expect(observed.every((wall) => wall.supportOffset === 1.75)).toBe(true)
236
+ })
237
+
238
+ test('keeps a node-top room plane flat across changing terrain', () => {
239
+ const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [0, 0] })
240
+ const patch = flattenPatch(field, { minX: 3.5, minZ: 0, maxX: 4, maxZ: 4 }, 1)
241
+ if (!patch) throw new Error('Expected terrain patch')
242
+ const terrain = applyHeightPatch(field, patch)
243
+ const site = {
244
+ id: 'site_test',
245
+ type: 'site',
246
+ object: 'node',
247
+ parentId: null,
248
+ visible: true,
249
+ metadata: {},
250
+ children: ['building_test'],
251
+ terrain: encodeTerrainField(terrain),
252
+ } as unknown as AnyNode
253
+ const building = {
254
+ id: 'building_test',
255
+ type: 'building',
256
+ object: 'node',
257
+ parentId: site.id,
258
+ visible: true,
259
+ metadata: {},
260
+ children: [LEVEL_ID],
261
+ position: [0, 0, 0],
262
+ rotation: [0, 0, 0],
263
+ } as AnyNode
264
+ const level = {
265
+ id: LEVEL_ID,
266
+ type: 'level',
267
+ object: 'node',
268
+ parentId: building.id,
269
+ visible: true,
270
+ metadata: {},
271
+ children: [],
272
+ level: 0,
273
+ baseElevation: 0,
274
+ height: 3,
275
+ } as AnyNode
276
+ useScene.setState({
277
+ nodes: Object.fromEntries([site, building, level].map((node) => [node.id, node])),
278
+ rootNodeIds: [site.id],
279
+ dirtyNodes: new Set(),
280
+ } as never)
281
+
282
+ const lowTerrainWall = createWallOnCurrentLevel([0, 0], [1, 0], {
283
+ constructionElevation: 3,
284
+ constructionHeight: 2.5,
285
+ flatConstructionBase: true,
286
+ supportCap: 3,
287
+ })
288
+ const highTerrainWall = createWallOnCurrentLevel([4, 0], [4, 1], {
289
+ constructionElevation: 3,
290
+ constructionHeight: 2.5,
291
+ flatConstructionBase: true,
292
+ supportCap: 3,
293
+ })
294
+
295
+ expect(lowTerrainWall?.supportSlabId).toBe(GROUND_SUPPORT_ID)
296
+ expect(highTerrainWall?.supportSlabId).toBe(GROUND_SUPPORT_ID)
297
+ expect(lowTerrainWall?.supportOffset).toBeCloseTo(3)
298
+ expect(highTerrainWall?.supportOffset).toBeCloseTo(2)
299
+ expect(
300
+ spatialGridManager.getSlabSupportForWall(
301
+ LEVEL_ID,
302
+ lowTerrainWall!.start,
303
+ lowTerrainWall!.end,
304
+ lowTerrainWall!.curveOffset,
305
+ lowTerrainWall!.thickness,
306
+ lowTerrainWall!.supportSlabId,
307
+ undefined,
308
+ lowTerrainWall!.supportOffset,
309
+ ).elevation,
310
+ ).toBeCloseTo(3)
311
+ expect(
312
+ spatialGridManager.getSlabSupportForWall(
313
+ LEVEL_ID,
314
+ highTerrainWall!.start,
315
+ highTerrainWall!.end,
316
+ highTerrainWall!.curveOffset,
317
+ highTerrainWall!.thickness,
318
+ highTerrainWall!.supportSlabId,
319
+ undefined,
320
+ highTerrainWall!.supportOffset,
321
+ ).elevation,
322
+ ).toBeCloseTo(3)
323
+ })
324
+
325
+ test('pins an existing construction source before a generated room slab can lift it', () => {
326
+ // The reset + throwaway `block` registration is scoped to this test —
327
+ // the registry is a process-wide singleton, so leaking it would leave
328
+ // later test FILES with a stripped registry (order-dependent flakes).
329
+ restoreRegistry = nodeRegistry._snapshot()
330
+ nodeRegistry._reset()
331
+ spatialGridManager.clear()
332
+ registerNode({
333
+ kind: 'block',
334
+ schemaVersion: 2,
335
+ schema: BlockNode,
336
+ category: 'structure',
337
+ defaults: () => BlockNode.parse({ name: 'Block' }),
338
+ capabilities: {
339
+ floorPlaced: {
340
+ footprint: () => ({ dimensions: [4, 2.4, 4], rotation: [0, 0, 0] }),
341
+ },
342
+ },
343
+ } as never)
344
+
345
+ const slope = BlockNode.parse({
346
+ name: 'Existing slope',
347
+ parentId: LEVEL_ID,
348
+ position: [0, 0, 0],
349
+ })
350
+ seedLevel([makeWall([0, 0], [4, 0], 'wall_a')], [slope as AnyNode])
351
+
352
+ createWallOnCurrentLevel([0, 1], [1, 1], {
353
+ constructionElevation: 2.4,
354
+ constructionHeight: 2.5,
355
+ constructionSourceNodeId: slope.id,
356
+ flatConstructionBase: true,
357
+ supportCap: 2.4,
358
+ })
359
+
360
+ const pinnedSlope = useScene.getState().nodes[slope.id] as BlockNode
361
+ expect(pinnedSlope.supportSlabId).toBe(GROUND_SUPPORT_ID)
362
+
363
+ const generatedSlab = SlabNode.parse({
364
+ polygon: [
365
+ [-2, -2],
366
+ [2, -2],
367
+ [2, 2],
368
+ [-2, 2],
369
+ ],
370
+ elevation: 2.45,
371
+ autoFromWalls: true,
372
+ parentId: LEVEL_ID,
373
+ })
374
+ spatialGridManager.handleNodeCreated(generatedSlab as AnyNode, LEVEL_ID)
375
+
376
+ expect(
377
+ getFloorPlacedElevation({
378
+ node: pinnedSlope,
379
+ nodes: {
380
+ ...useScene.getState().nodes,
381
+ [generatedSlab.id]: generatedSlab as AnyNode,
382
+ },
383
+ position: pinnedSlope.position,
384
+ rotation: [0, pinnedSlope.rotation, 0],
385
+ }),
386
+ ).toBe(0)
387
+ })
388
+
201
389
  test('a flat-ground draft (no sculpted terrain) commits plane-bound', () => {
202
390
  // Pointing at bare ground freezes a GROUND construction plane at 0. With
203
391
  // no terrain field in the scene that plane is just the backdrop — none of
@@ -248,6 +436,136 @@ describe('createWallOnCurrentLevel', () => {
248
436
  expect(created?.supportSlabId).not.toBe(GROUND_SUPPORT_ID)
249
437
  })
250
438
 
439
+ test('a fresh-scene wall started on a slab node-top elects that slab plane-bound', () => {
440
+ const slab = SlabNode.parse({
441
+ id: 'slab_fresh_floor',
442
+ parentId: LEVEL_ID,
443
+ polygon: [
444
+ [-1, -1],
445
+ [5, -1],
446
+ [5, 5],
447
+ [-1, 5],
448
+ ],
449
+ elevation: 0.05,
450
+ thickness: 0.05,
451
+ })
452
+ seedLevel([], [slab])
453
+ spatialGridManager.clear()
454
+ spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
455
+
456
+ const created = createWallOnCurrentLevel([2, 2], [3, 2], {
457
+ supportCap: 0.05,
458
+ preferredSupportSlabId: null,
459
+ constructionElevation: 0.05,
460
+ constructionHeight: 2.5,
461
+ constructionSourceNodeId: slab.id,
462
+ flatConstructionBase: true,
463
+ })
464
+
465
+ expect(created).not.toBeNull()
466
+ expect(created?.supportSlabId).toBeUndefined()
467
+ expect(created?.height).toBeUndefined()
468
+ expect(created?.supportOffset).toBeUndefined()
469
+ const support = spatialGridManager.getSlabSupportForWall(
470
+ LEVEL_ID,
471
+ created!.start,
472
+ created!.end,
473
+ created!.curveOffset,
474
+ created!.thickness,
475
+ created!.supportSlabId,
476
+ )
477
+ expect(support.electedSlabId).toBe(slab.id)
478
+ expect(support.elevation).toBeCloseTo(0.05)
479
+ })
480
+
481
+ test('a direct slab source does not pin a cross-slab wall away from the higher majority support', () => {
482
+ const sourceSlab = SlabNode.parse({
483
+ id: 'slab_source_low',
484
+ parentId: LEVEL_ID,
485
+ polygon: [
486
+ [-0.1, -1],
487
+ [0.1, -1],
488
+ [0.1, 1],
489
+ [-0.1, 1],
490
+ ],
491
+ elevation: 0.1,
492
+ thickness: 0.05,
493
+ })
494
+ const majoritySlab = SlabNode.parse({
495
+ id: 'slab_majority_high',
496
+ parentId: LEVEL_ID,
497
+ polygon: [
498
+ [0.1, -1],
499
+ [4.1, -1],
500
+ [4.1, 1],
501
+ [0.1, 1],
502
+ ],
503
+ elevation: 0.6,
504
+ thickness: 0.1,
505
+ })
506
+ seedLevel([], [sourceSlab, majoritySlab])
507
+ spatialGridManager.clear()
508
+ spatialGridManager.handleNodeCreated(sourceSlab as AnyNode, LEVEL_ID)
509
+ spatialGridManager.handleNodeCreated(majoritySlab as AnyNode, LEVEL_ID)
510
+
511
+ const created = createWallOnCurrentLevel([0, 0], [4, 0], {
512
+ supportCap: 0.6,
513
+ preferredSupportSlabId: null,
514
+ constructionElevation: 0.1,
515
+ constructionHeight: 2.5,
516
+ constructionSourceNodeId: sourceSlab.id,
517
+ flatConstructionBase: true,
518
+ })
519
+
520
+ expect(created?.supportSlabId).toBe(majoritySlab.id)
521
+ expect(created?.height).toBeUndefined()
522
+ expect(created?.supportOffset).toBeUndefined()
523
+ })
524
+
525
+ test('a grazing direct slab source does not override the commit elevation cap', () => {
526
+ const sourceSlab = SlabNode.parse({
527
+ id: 'slab_source_high',
528
+ parentId: LEVEL_ID,
529
+ polygon: [
530
+ [-0.1, -1],
531
+ [0.1, -1],
532
+ [0.1, 1],
533
+ [-0.1, 1],
534
+ ],
535
+ elevation: 0.6,
536
+ thickness: 0.1,
537
+ })
538
+ const cappedSlab = SlabNode.parse({
539
+ id: 'slab_capped_low',
540
+ parentId: LEVEL_ID,
541
+ polygon: [
542
+ [-0.1, -1],
543
+ [4.1, -1],
544
+ [4.1, 1],
545
+ [-0.1, 1],
546
+ ],
547
+ elevation: 0.1,
548
+ thickness: 0.05,
549
+ })
550
+ seedLevel([], [sourceSlab, cappedSlab])
551
+ spatialGridManager.clear()
552
+ spatialGridManager.handleNodeCreated(sourceSlab as AnyNode, LEVEL_ID)
553
+ spatialGridManager.handleNodeCreated(cappedSlab as AnyNode, LEVEL_ID)
554
+
555
+ const created = createWallOnCurrentLevel([0, 0], [4, 0], {
556
+ supportCap: 0.1,
557
+ preferredSupportSlabId: null,
558
+ constructionElevation: 0.6,
559
+ constructionHeight: 2.5,
560
+ constructionSourceNodeId: sourceSlab.id,
561
+ flatConstructionBase: true,
562
+ })
563
+
564
+ expect(created?.supportSlabId).toBe(cappedSlab.id)
565
+ expect(created?.height).toBeUndefined()
566
+ expect(created?.supportOffset).toBeUndefined()
567
+ })
568
+
251
569
  test('a non-ground draft never freezes the ghost height, even at a raised plane', () => {
252
570
  const created = createWallOnCurrentLevel([2, 2], [3, 2], {
253
571
  supportCap: 1.2,
@@ -1,17 +1,13 @@
1
1
  import {
2
- type AnyNode,
3
2
  type AnyNodeId,
4
3
  DEFAULT_ANGLE_STEP,
5
- DEFAULT_LEVEL_HEIGHT,
6
- GROUND_SUPPORT_ID,
7
4
  planWallInsertion,
8
5
  planWallSplitAtPoint,
9
- resolveWallSupportSlabPatch,
6
+ resolveWallConstruction,
10
7
  runAsSingleSceneHistoryStep,
11
8
  snapPointAlongAngleRay,
12
- spatialGridManager,
13
- terrainSupportLift,
14
9
  useScene,
10
+ type WallConstructionOptions,
15
11
  type WallNode,
16
12
  } from '@pascal-app/core'
17
13
  import { useViewer } from '@pascal-app/viewer'
@@ -209,41 +205,6 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b
209
205
  return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
210
206
  }
211
207
 
212
- export type WallConstructionOptions = {
213
- /** Pointer-decided maximum support elevation in level-local metres. */
214
- supportCap?: number | null
215
- /** Support source selected by the first click or inherited from a snapped wall. */
216
- preferredSupportSlabId?: string | null
217
- /** Frozen level-local Y shown by the draft ghost. */
218
- constructionElevation?: number | null
219
- /** Height shown by the draft ghost. */
220
- constructionHeight?: number | null
221
- }
222
-
223
- export function resolveTerrainWallConstructionOptions(
224
- nodes: Record<string, AnyNode>,
225
- levelId: string,
226
- point: WallPlanPoint,
227
- defaults?: Record<string, unknown>,
228
- ): WallConstructionOptions | undefined {
229
- const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1])
230
- if (constructionElevation == null) return undefined
231
-
232
- const level = nodes[levelId]
233
- const constructionHeight =
234
- typeof defaults?.height === 'number'
235
- ? defaults.height
236
- : level?.type === 'level'
237
- ? (level.height ?? DEFAULT_LEVEL_HEIGHT)
238
- : DEFAULT_LEVEL_HEIGHT
239
-
240
- return {
241
- constructionElevation,
242
- constructionHeight,
243
- supportCap: constructionElevation,
244
- }
245
- }
246
-
247
208
  export function createWallOnCurrentLevel(
248
209
  start: WallPlanPoint,
249
210
  end: WallPlanPoint,
@@ -269,82 +230,30 @@ export function createWallOnCurrentLevel(
269
230
  if (!result.ok) return null
270
231
  const { plan } = result
271
232
 
272
- applyNodeChanges(plan.changes)
273
- const committedNodes = useScene.getState().nodes
274
- const supportUpdates = plan.insertedWalls.flatMap((wall) => {
275
- const createdWall = committedNodes[wall.id]
276
- if (createdWall?.type !== 'wall') return []
277
- const terrainBase = terrainSupportLift(
278
- committedNodes,
279
- currentLevelId,
280
- createdWall.start[0],
281
- createdWall.start[1],
282
- )
283
- // A ground-preferred draft is the terrain exception only while sculpted
284
- // terrain actually supports this storey. On flat ground the ground plane
285
- // is just the backdrop the chain started from: drop the draft options so
286
- // the wall commits plane-bound — no stamped height, no persisted ground
287
- // host, no election cap at the draft plane.
288
- const wallOptions =
289
- options?.preferredSupportSlabId === GROUND_SUPPORT_ID && terrainBase == null
290
- ? undefined
291
- : options
292
- const preferredSupportSlabId =
293
- wallOptions?.preferredSupportSlabId ??
294
- (wallOptions?.constructionElevation != null && terrainBase != null
295
- ? GROUND_SUPPORT_ID
296
- : null)
297
- const supportPatch = resolveWallSupportSlabPatch(createdWall, committedNodes, {
298
- maxElevation: wallOptions?.supportCap ?? null,
299
- preferredSlabId: preferredSupportSlabId,
300
- })
301
- const supportSlabId = supportPatch.supportSlabId
302
- const sourceSupport = spatialGridManager.getSlabSupportForWall(
303
- currentLevelId,
304
- createdWall.start,
305
- createdWall.end,
306
- createdWall.curveOffset,
307
- createdWall.thickness,
308
- supportSlabId,
309
- wallOptions?.supportCap ?? null,
310
- )
311
- // Freezing the draft plane into the node (explicit height + offset from
312
- // the elected support) is the terrain exception only: a ground-drafted
313
- // chain keeps one construction plane and a fixed body height while
314
- // sculpting moves the ground. Every other wall stays plane-bound —
315
- // height absent, top at the storey plane, base re-elected from slab
316
- // support — so a wall merely started on a slab or deck must never have
317
- // the ghost height stamped onto it.
318
- const groundDraft = preferredSupportSlabId === GROUND_SUPPORT_ID
319
- const supportOffset =
320
- groundDraft && wallOptions?.constructionElevation != null
321
- ? wallOptions.constructionElevation - sourceSupport.elevation
322
- : undefined
323
- const preserveDraftHeight =
324
- groundDraft &&
325
- createdWall.height == null &&
326
- wallOptions?.constructionHeight != null &&
327
- wallOptions.constructionElevation != null
328
- return [
329
- {
330
- id: createdWall.id,
331
- data: {
332
- ...supportPatch,
333
- height: preserveDraftHeight
334
- ? (wallOptions?.constructionHeight ?? createdWall.height)
335
- : createdWall.height,
336
- supportOffset:
337
- supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
338
- },
339
- },
340
- ]
233
+ const construction = resolveWallConstruction(nodes, currentLevelId, plan.insertedWalls, options)
234
+ const finalizedWalls = construction.walls
235
+ const finalizedWallsById = new Map(finalizedWalls.map((wall) => [wall.id, wall]))
236
+ const sourceUpdate = construction.sourceSupportUpdate
237
+ const sourceAlreadyUpdated = sourceUpdate
238
+ ? plan.changes.update.some((operation) => operation.id === sourceUpdate.id)
239
+ : false
240
+ applyNodeChanges({
241
+ ...plan.changes,
242
+ update: plan.changes.update
243
+ .map((operation) =>
244
+ sourceUpdate?.id === operation.id
245
+ ? { ...operation, data: { ...operation.data, ...sourceUpdate.data } }
246
+ : operation,
247
+ )
248
+ .concat(sourceUpdate && !sourceAlreadyUpdated ? [sourceUpdate] : []),
249
+ create: plan.changes.create.map((operation) => ({
250
+ ...operation,
251
+ node: finalizedWallsById.get(operation.node.id as WallNode['id']) ?? operation.node,
252
+ })),
341
253
  })
342
- if (supportUpdates.length > 0) {
343
- useScene.getState().updateNodes(supportUpdates)
344
- }
345
254
  sfxEmitter.emit('sfx:structure-build')
346
255
 
347
- const terminalWall = plan.insertedWalls.at(-1)!
256
+ const terminalWall = finalizedWalls.at(-1)!
348
257
  const committedWall = useScene.getState().nodes[plan.terminalWallId]
349
258
  return committedWall?.type === 'wall' ? committedWall : terminalWall
350
259
  })
@@ -54,7 +54,7 @@ export function ControlModes() {
54
54
  const mode = useEditor((state) => state.mode)
55
55
  const phase = useEditor((state) => state.phase)
56
56
  const selectionTool = useEditor((state) => state.floorplanSelectionTool)
57
- const setMode = useEditor((state) => state.setMode)
57
+ const armToolMode = useEditor((state) => state.armToolMode)
58
58
  const setPhase = useEditor((state) => state.setPhase)
59
59
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
60
60
  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
@@ -82,21 +82,21 @@ export function ControlModes() {
82
82
  }
83
83
 
84
84
  if (id === 'select') {
85
- setMode('select')
85
+ armToolMode({ mode: 'select' })
86
86
  setSelectionTool('click')
87
87
  } else if (id === 'box-select') {
88
- setMode('select')
88
+ armToolMode({ mode: 'select' })
89
89
  setSelectionTool('marquee')
90
90
  } else if (id === 'zone') {
91
91
  if (getIsActive('zone')) {
92
- setMode('select')
92
+ armToolMode({ mode: 'select' })
93
93
  } else {
94
94
  setPhase('structure')
95
95
  setStructureLayer('zones')
96
- setMode('build')
96
+ armToolMode({ mode: 'build', tool: 'zone' })
97
97
  }
98
98
  } else {
99
- setMode(id)
99
+ armToolMode({ mode: id })
100
100
  }
101
101
  }
102
102
 
@@ -118,6 +118,9 @@ export function ControlModes() {
118
118
  isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
119
119
  isImageMode && !isActive && 'hover:bg-white/5',
120
120
  )}
121
+ // A static hook for a host app that wants to point a first-run
122
+ // tour at this button. Nothing here reads it.
123
+ data-guide-target={c.id === 'select' ? 'mode-select' : undefined}
121
124
  label={c.label}
122
125
  onClick={() => handleClick(c.id)}
123
126
  shortcut={c.shortcut}
@@ -1,10 +1,12 @@
1
1
  'use client'
2
2
 
3
+ import { useScene } from '@pascal-app/core'
3
4
  import { useViewer } from '@pascal-app/viewer'
4
5
  import { motion } from 'motion/react'
5
6
  import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
6
7
  import { useIsMobile } from './../../../hooks/use-mobile'
7
8
  import { useReducedMotion } from './../../../hooks/use-reduced-motion'
9
+ import { shouldShowEditingControls } from './../../../lib/interaction/overlay-policy'
8
10
  import { cn } from './../../../lib/utils'
9
11
  import useEditor from './../../../store/use-editor'
10
12
  import { CameraActions } from './camera-actions'
@@ -18,6 +20,7 @@ const MOBILE_BOTTOM_OFFSET = 24
18
20
 
19
21
  export function ActionMenu({ className }: { className?: string }) {
20
22
  const isMobile = useIsMobile()
23
+ const readOnly = useScene((s) => s.readOnly)
21
24
  const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
22
25
  const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
23
26
  const CONTEXTUAL_TABS = new Set(['ai', 'items', 'studio'])
@@ -31,7 +34,14 @@ export function ActionMenu({ className }: { className?: string }) {
31
34
  // Also hide on Chat / Items / Studio tabs; those are contextual workflows
32
35
  // (composing / picking furniture / generating renders) where the build
33
36
  // menu is irrelevant.
34
- if (hasSelectionOnMobile || hasReferenceOnMobile || isContextualPanelOnMobile) return null
37
+ if (
38
+ !shouldShowEditingControls(readOnly) ||
39
+ hasSelectionOnMobile ||
40
+ hasReferenceOnMobile ||
41
+ isContextualPanelOnMobile
42
+ ) {
43
+ return null
44
+ }
35
45
 
36
46
  const transition = reducedMotion
37
47
  ? { duration: 0 }
@@ -49,10 +49,9 @@ export function EditorCommands() {
49
49
  const { navigateTo, setInputValue, setOpen } = useCommandPalette()
50
50
 
51
51
  const setPhase = useEditor((s) => s.setPhase)
52
- const setMode = useEditor((s) => s.setMode)
53
- const setTool = useEditor((s) => s.setTool)
52
+ const armToolMode = useEditor((s) => s.armToolMode)
53
+ const armMaterialPaint = useEditor((s) => s.armMaterialPaint)
54
54
  const setStructureLayer = useEditor((s) => s.setStructureLayer)
55
- const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
56
55
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
57
56
  const setPreviewMode = useEditor((s) => s.setPreviewMode)
58
57
 
@@ -68,9 +67,9 @@ export function EditorCommands() {
68
67
  const activateTool = (tool: StructureTool) => {
69
68
  run(() => {
70
69
  setPhase('structure')
71
- setMode('build')
72
70
  if (tool === 'zone') setStructureLayer('zones')
73
- setTool(tool)
71
+ else setStructureLayer('elements')
72
+ armToolMode({ mode: 'build', tool })
74
73
  })
75
74
  }
76
75
 
@@ -163,10 +162,9 @@ export function EditorCommands() {
163
162
  shortcut: ['P'],
164
163
  execute: () =>
165
164
  run(() => {
166
- primeMaterialPaintFromSelection()
167
165
  setPhase('structure')
168
166
  setStructureLayer('elements')
169
- setMode('material-paint')
167
+ armMaterialPaint()
170
168
  }),
171
169
  },
172
170
  {
@@ -176,9 +174,8 @@ export function EditorCommands() {
176
174
  icon: <Mountain className="h-4 w-4" />,
177
175
  keywords: ['terrain', 'ground', 'elevation', 'sculpt', 'hill', 'slope', 'grade', 'dig'],
178
176
  shortcut: ['G'],
179
- // No `setPhase`: `setMode` moves to the site phase itself, and doing it
180
- // here would set the phase twice with a mode reset in between.
181
- execute: () => run(() => setMode('terrain-sculpt')),
177
+ // The ToolMode transition moves to the site phase itself.
178
+ execute: () => run(() => armToolMode({ mode: 'terrain-sculpt' })),
182
179
  },
183
180
 
184
181
  // ── Levels ───────────────────────────────────────────────────────────
@@ -428,8 +425,8 @@ export function EditorCommands() {
428
425
  setInputValue,
429
426
  setOpen,
430
427
  setPhase,
431
- setMode,
432
- setTool,
428
+ armToolMode,
429
+ armMaterialPaint,
433
430
  setStructureLayer,
434
431
  isPreviewMode,
435
432
  setPreviewMode,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { AnyNodeId, LevelNode } from '@pascal-app/core'
4
4
  import { useScene } from '@pascal-app/core'
5
- import { useViewer } from '@pascal-app/viewer'
5
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
6
6
  import { Command, useCommandState } from 'cmdk'
7
7
  import { ChevronRight, Search } from 'lucide-react'
8
8
  import type { ReactNode } from 'react'
@@ -408,7 +408,12 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
408
408
  key={level.id}
409
409
  label={getLevelDisplayName(level)}
410
410
  onSelect={() =>
411
- run(() => useViewer.getState().setSelection({ levelId: level.id }))
411
+ run(() => {
412
+ if (level.id !== useViewer.getState().selection.levelId) {
413
+ markPerfAction('level-switch', level.id)
414
+ }
415
+ useViewer.getState().setSelection({ levelId: level.id })
416
+ })
412
417
  }
413
418
  />
414
419
  ))}