@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,6 +1,7 @@
1
- import type { AnyNode, EditorApi } from '@pascal-app/core'
1
+ import { type AnyNode, type EditorApi, useScene } from '@pascal-app/core'
2
2
  import useEditor from '../store/use-editor'
3
3
  import useInteractionScope from '../store/use-interaction-scope'
4
+ import { resolveDirectManipulationNode, resolveMoveActionNode } from './direct-manipulation'
4
5
  import {
5
6
  controlPointReshapeScope,
6
7
  endpointReshapeScope,
@@ -25,14 +26,16 @@ export function createEditorApi(): EditorApi {
25
26
  // (every concrete kind enumerated). Descriptors pass any node; the
26
27
  // cast lets registry-driven move kinds through without forcing a
27
28
  // schema-level type widening.
28
- editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
29
+ const target = resolveMoveActionNode(node, useScene.getState().nodes)
30
+ editor.setMovingNode(target as Parameters<typeof editor.setMovingNode>[0])
29
31
  },
30
32
  engageMoveDrag(node: AnyNode) {
31
33
  const editor = useEditor.getState()
32
34
  // Flag drag mode BEFORE mounting the move tool so the coordinator reads
33
35
  // it at setup and wires its commit-on-release listener.
34
36
  editor.setPlacementDragMode(true)
35
- editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
37
+ const target = resolveDirectManipulationNode(node, useScene.getState().nodes)
38
+ editor.setMovingNode(target as Parameters<typeof editor.setMovingNode>[0])
36
39
  },
37
40
  engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
38
41
  // Endpoint reshape is kind-agnostic: the scope carries the node id + which
@@ -1,9 +1,10 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core'
3
3
  import useElevationGuides from '../store/use-elevation-guides'
4
4
  import {
5
5
  clearStructuralElevationGuide,
6
6
  collectElevationSnapTargets,
7
+ publishResolvedElevationGuide,
7
8
  publishStructuralElevationGuide,
8
9
  resolveElevationSnapMatch,
9
10
  resolveStructuralElevationSnap,
@@ -42,6 +43,10 @@ function structuralScene() {
42
43
  }
43
44
 
44
45
  describe('elevation guides', () => {
46
+ afterEach(() => {
47
+ useElevationGuides.setState({ guide: null })
48
+ })
49
+
45
50
  test('collects the level, slab faces, and ceiling plane on the source level', () => {
46
51
  const { level, nodes, slab } = structuralScene()
47
52
  const targets = collectElevationSnapTargets(
@@ -104,4 +109,26 @@ describe('elevation guides', () => {
104
109
  publishStructuralElevationGuide(source, 0.7, nodes)
105
110
  expect(useElevationGuides.getState().guide).toBeNull()
106
111
  })
112
+
113
+ test('publishes an explicitly resolved neighboring datum', () => {
114
+ const { level } = structuralScene()
115
+ useElevationGuides.setState({ guide: null })
116
+
117
+ publishResolvedElevationGuide(
118
+ { nodeId: 'leanto_moving', levelId: level.id, anchor: [2, 1] },
119
+ {
120
+ id: 'leanto_neighbor:high-edge',
121
+ elevation: 3.4,
122
+ anchor: [5, 1],
123
+ label: 'Neighbor shed edge',
124
+ },
125
+ )
126
+
127
+ expect(useElevationGuides.getState().guide).toMatchObject({
128
+ ownerId: 'leanto_moving',
129
+ elevation: 3.4,
130
+ direction: [1, 0],
131
+ label: 'Neighbor shed edge',
132
+ })
133
+ })
107
134
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ type FenceNode,
4
5
  findLevelAncestorId,
5
6
  getWallBaseElevationForNodes,
6
7
  getWallEffectiveHeightForNodes,
@@ -53,8 +54,10 @@ function segmentCenter(
53
54
  // A stale host resolves to the level base — the sculpted ground under the
54
55
  // fence's start point, sampled where the builder samples it, so the guide line
55
56
  // lands on the rail it claims to describe.
56
- function fenceBaseElevation(node: AnyNode, nodes: Record<string, AnyNode>): number {
57
- if (node.type !== 'fence') return 0
57
+ export function getFenceBaseElevationForNodes(
58
+ node: FenceNode,
59
+ nodes: Record<string, AnyNode>,
60
+ ): number {
58
61
  const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined
59
62
  const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null)
60
63
  const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes)
@@ -161,7 +164,7 @@ export function collectElevationSnapTargets(
161
164
  }
162
165
 
163
166
  if (node.type === 'fence') {
164
- const base = fenceBaseElevation(node, nodes)
167
+ const base = getFenceBaseElevationForNodes(node, nodes)
165
168
  const center = segmentCenter(node.start, node.end)
166
169
  targets.push({
167
170
  id: `${node.id}:base`,
@@ -245,8 +248,20 @@ export function publishStructuralElevationGuide(
245
248
  return
246
249
  }
247
250
 
248
- const dx = match.target.anchor[0] - source.anchor[0]
249
- const dz = match.target.anchor[1] - source.anchor[1]
251
+ publishResolvedElevationGuide(source, match.target)
252
+ }
253
+
254
+ export function publishResolvedElevationGuide(
255
+ source: ElevationGuideSource,
256
+ target: ElevationSnapTarget,
257
+ ): void {
258
+ if (!source.levelId) {
259
+ clearStructuralElevationGuide(source.nodeId)
260
+ return
261
+ }
262
+
263
+ const dx = target.anchor[0] - source.anchor[0]
264
+ const dz = target.anchor[1] - source.anchor[1]
250
265
  const length = Math.hypot(dx, dz)
251
266
  const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0]
252
267
 
@@ -255,8 +270,8 @@ export function publishStructuralElevationGuide(
255
270
  levelId: source.levelId,
256
271
  center: source.anchor,
257
272
  direction,
258
- elevation: match.elevation,
259
- label: match.target.label,
273
+ elevation: target.elevation,
274
+ label: target.label,
260
275
  })
261
276
  }
262
277
 
@@ -0,0 +1,167 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import * as THREE from 'three'
3
+ import type { WebGPURenderer } from 'three/webgpu'
4
+ import { createExportTextureUtils } from './export-texture-utils'
5
+
6
+ const globals = globalThis as unknown as { document?: unknown }
7
+ const previousDocument = globals.document
8
+
9
+ type FakeRenderer = {
10
+ renderer: WebGPURenderer
11
+ calls: { render: number; dispose: number; destroy: number; sizes: [number, number][] }
12
+ domElement: object
13
+ }
14
+
15
+ function fakeRenderer(): FakeRenderer {
16
+ const calls = { render: 0, dispose: 0, destroy: 0, sizes: [] as [number, number][] }
17
+ const domElement = { kind: 'renderer-canvas' }
18
+ const renderer = {
19
+ domElement,
20
+ outputColorSpace: THREE.SRGBColorSpace,
21
+ backend: {
22
+ device: {
23
+ destroy: () => {
24
+ calls.destroy += 1
25
+ },
26
+ },
27
+ },
28
+ setSize: (width: number, height: number) => {
29
+ calls.sizes.push([width, height])
30
+ },
31
+ render: () => {
32
+ calls.render += 1
33
+ },
34
+ dispose: async () => {
35
+ calls.dispose += 1
36
+ },
37
+ }
38
+ return { renderer: renderer as unknown as WebGPURenderer, calls, domElement }
39
+ }
40
+
41
+ function installCanvasDocument(drawn: object[]) {
42
+ globals.document = {
43
+ createElement: (tagName: string) => {
44
+ if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
45
+ return {
46
+ width: 0,
47
+ height: 0,
48
+ getContext: () => ({
49
+ drawImage: (source: object) => {
50
+ drawn.push(source)
51
+ },
52
+ }),
53
+ }
54
+ },
55
+ }
56
+ }
57
+
58
+ function compressedTexture(name: string, width = 8, height = 4): THREE.CompressedTexture {
59
+ const texture = new THREE.CompressedTexture([], width, height)
60
+ texture.name = name
61
+ texture.colorSpace = THREE.NoColorSpace
62
+ texture.wrapS = THREE.RepeatWrapping
63
+ texture.wrapT = THREE.MirroredRepeatWrapping
64
+ texture.minFilter = THREE.NearestFilter
65
+ texture.magFilter = THREE.NearestFilter
66
+ return texture
67
+ }
68
+
69
+ afterEach(() => {
70
+ globals.document = previousDocument
71
+ })
72
+
73
+ describe('createExportTextureUtils', () => {
74
+ test('starts one renderer for every compressed texture in the export and releases it once', async () => {
75
+ const drawn: object[] = []
76
+ installCanvasDocument(drawn)
77
+ const fake = fakeRenderer()
78
+ let starts = 0
79
+ const utils = createExportTextureUtils(async () => {
80
+ starts += 1
81
+ return fake.renderer
82
+ })
83
+
84
+ const readable = await Promise.all([
85
+ utils.decompress(compressedTexture('NormalGL_a')),
86
+ utils.decompress(compressedTexture('Color_b', 16, 16)),
87
+ utils.decompress(compressedTexture('Rough_c', 2, 8)),
88
+ ])
89
+
90
+ expect(starts).toBe(1)
91
+ expect(fake.calls.render).toBe(3)
92
+ expect(fake.calls.sizes).toEqual([
93
+ [8, 4],
94
+ [16, 16],
95
+ [2, 8],
96
+ ])
97
+ expect(drawn).toEqual([fake.domElement, fake.domElement, fake.domElement])
98
+ expect(fake.renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
99
+
100
+ const first = readable[0] as THREE.CanvasTexture
101
+ expect(first.isCanvasTexture).toBe(true)
102
+ expect(first.name).toBe('NormalGL_a')
103
+ expect(first.colorSpace).toBe(THREE.NoColorSpace)
104
+ expect(first.wrapS).toBe(THREE.RepeatWrapping)
105
+ expect(first.wrapT).toBe(THREE.MirroredRepeatWrapping)
106
+ expect(first.minFilter).toBe(THREE.NearestFilter)
107
+ expect(first.magFilter).toBe(THREE.NearestFilter)
108
+
109
+ await utils.dispose()
110
+ await utils.dispose()
111
+ expect(fake.calls.dispose).toBe(1)
112
+ expect(fake.calls.destroy).toBe(1)
113
+ })
114
+
115
+ test('clamps the readback to maxTextureSize', async () => {
116
+ installCanvasDocument([])
117
+ const fake = fakeRenderer()
118
+ const utils = createExportTextureUtils(async () => fake.renderer)
119
+
120
+ const readable = await utils.decompress(compressedTexture('Color', 4096, 1024), 2048)
121
+
122
+ expect(fake.calls.sizes).toEqual([[2048, 1024]])
123
+ expect((readable.image as { width: number; height: number }).width).toBe(2048)
124
+ await utils.dispose()
125
+ })
126
+
127
+ test('rejects when no GPU renderer can start instead of leaving the export pending', async () => {
128
+ installCanvasDocument([])
129
+ const utils = createExportTextureUtils(async () => {
130
+ throw new Error('no adapter')
131
+ })
132
+
133
+ await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('no adapter')
134
+ await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('no adapter')
135
+ await expect(utils.dispose()).resolves.toBeUndefined()
136
+ })
137
+
138
+ test('disposing without decompressing never starts a renderer', async () => {
139
+ let starts = 0
140
+ const utils = createExportTextureUtils(async () => {
141
+ starts += 1
142
+ return fakeRenderer().renderer
143
+ })
144
+
145
+ await utils.dispose()
146
+ expect(starts).toBe(0)
147
+ })
148
+
149
+ test('a decompress after dispose rejects instead of starting an unowned renderer', async () => {
150
+ installCanvasDocument([])
151
+ const fake = fakeRenderer()
152
+ let starts = 0
153
+ const utils = createExportTextureUtils(async () => {
154
+ starts += 1
155
+ return fake.renderer
156
+ })
157
+
158
+ await utils.decompress(compressedTexture('Color'))
159
+ await utils.dispose()
160
+ await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('abandoned')
161
+ await utils.dispose()
162
+
163
+ expect(starts).toBe(1)
164
+ expect(fake.calls.dispose).toBe(1)
165
+ expect(fake.calls.destroy).toBe(1)
166
+ })
167
+ })
@@ -0,0 +1,121 @@
1
+ import { initializeGpuRenderer } from '@pascal-app/viewer'
2
+ import type * as THREE from 'three'
3
+ import { texture as textureNode, uv } from 'three/tsl'
4
+ import { CanvasTexture, NodeMaterial, QuadMesh, WebGPURenderer } from 'three/webgpu'
5
+
6
+ /**
7
+ * GPU decompressor handed to three's GLTF/USDZ exporters for compressed (KTX2)
8
+ * textures. One instance lives for the duration of an export and is disposed
9
+ * with it.
10
+ */
11
+ export type ExportTextureUtils = {
12
+ decompress(texture: THREE.Texture, maxTextureSize?: number): Promise<THREE.Texture>
13
+ dispose(): Promise<void>
14
+ }
15
+
16
+ // The WebGL probe context initializeGpuRenderer opens on this canvas is never
17
+ // released, so one canvas serves every export instead of one per export.
18
+ let probeCanvas: HTMLCanvasElement | null | undefined
19
+
20
+ async function initializeDecompressRenderer(): Promise<WebGPURenderer> {
21
+ probeCanvas ??= typeof document === 'undefined' ? null : document.createElement('canvas')
22
+ const result = await initializeGpuRenderer({
23
+ probeCanvas,
24
+ createRenderer: (parameters) =>
25
+ new WebGPURenderer(parameters as ConstructorParameters<typeof WebGPURenderer>[0]),
26
+ })
27
+ if (result.status !== 'ready') {
28
+ throw new Error('Could not start a GPU renderer to decompress textures for export.', {
29
+ cause: result.error,
30
+ })
31
+ }
32
+ return result.renderer
33
+ }
34
+
35
+ function releaseDevice(renderer: WebGPURenderer) {
36
+ // initializeGpuRenderer hands three a caller-owned device, which
37
+ // WebGPUBackend.dispose() deliberately leaves alive.
38
+ const backend = renderer.backend as { device?: { destroy?: () => void } | null }
39
+ try {
40
+ backend.device?.destroy?.()
41
+ } catch {}
42
+ }
43
+
44
+ /**
45
+ * Mirrors three's `WebGPUTextureUtils.decompress` but keeps a single renderer
46
+ * for the whole export. The stock helper creates, initialises and disposes a
47
+ * WebGPURenderer — adapter and device request included, with no timeout — for
48
+ * every compressed texture it is handed, so a furnished scene paid that cost
49
+ * dozens of times and hung outright when one device request stalled. Handing
50
+ * it a renderer is not an option: it resizes whatever renderer it receives
51
+ * (the live viewer's canvas) and, in r186, throws on the first call that
52
+ * supplies one.
53
+ */
54
+ export function createExportTextureUtils(
55
+ initialize: () => Promise<WebGPURenderer> = initializeDecompressRenderer,
56
+ ): ExportTextureUtils {
57
+ let pending: Promise<WebGPURenderer> | null = null
58
+ let disposed = false
59
+ const quad = new QuadMesh()
60
+
61
+ return {
62
+ async decompress(texture, maxTextureSize = Number.POSITIVE_INFINITY) {
63
+ if (disposed) {
64
+ throw new Error('The export was abandoned before this texture was decompressed.')
65
+ }
66
+ pending ??= initialize()
67
+ const renderer = await pending
68
+
69
+ // Everything below the await is synchronous, which is what lets
70
+ // overlapping calls share `quad` and the renderer's colour space.
71
+ const image = texture.image as { width: number; height: number }
72
+ const width = Math.min(image.width, maxTextureSize)
73
+ const height = Math.min(image.height, maxTextureSize)
74
+
75
+ const material = new NodeMaterial()
76
+ material.fragmentNode = textureNode(texture, uv().flipY())
77
+ const previousColorSpace = renderer.outputColorSpace
78
+ renderer.setSize(width, height)
79
+ renderer.outputColorSpace = texture.colorSpace
80
+ quad.material = material
81
+ try {
82
+ quad.render(renderer)
83
+ } finally {
84
+ renderer.outputColorSpace = previousColorSpace
85
+ material.dispose()
86
+ }
87
+
88
+ const canvas = document.createElement('canvas')
89
+ canvas.width = width
90
+ canvas.height = height
91
+ const context = canvas.getContext('2d')
92
+ if (!context) {
93
+ throw new Error('Could not create a 2D canvas to read back a decompressed texture.')
94
+ }
95
+ context.drawImage(renderer.domElement, 0, 0, width, height)
96
+
97
+ const readable = new CanvasTexture(canvas)
98
+ readable.minFilter = texture.minFilter
99
+ readable.magFilter = texture.magFilter
100
+ readable.wrapS = texture.wrapS
101
+ readable.wrapT = texture.wrapT
102
+ readable.colorSpace = texture.colorSpace
103
+ readable.name = texture.name
104
+ return readable
105
+ },
106
+
107
+ async dispose() {
108
+ disposed = true
109
+ const started = pending
110
+ pending = null
111
+ if (!started) return
112
+ const renderer = await started.catch(() => null)
113
+ if (!renderer) return
114
+ try {
115
+ await renderer.dispose()
116
+ } finally {
117
+ releaseDevice(renderer)
118
+ }
119
+ },
120
+ }
121
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import * as THREE from 'three'
3
+ import { uniform } from 'three/tsl'
4
+ import { cloneExportUserData } from './export-user-data'
5
+
6
+ describe('cloneExportUserData', () => {
7
+ test('keeps plain data and deep-copies it', () => {
8
+ const source = {
9
+ pascalId: 'wall-1',
10
+ pascalSwingLeaf: { axis: 'y', openRotationY: 1.2 },
11
+ tags: ['a', 'b'],
12
+ offsets: new Float32Array([1, 2, 3]),
13
+ }
14
+ const copy = cloneExportUserData(source)
15
+ expect(copy).toEqual(source)
16
+ expect(copy.pascalSwingLeaf).not.toBe(source.pascalSwingLeaf)
17
+ expect(copy.tags).not.toBe(source.tags)
18
+ expect(copy.offsets).not.toBe(source.offsets)
19
+ })
20
+
21
+ test('drops functions and live three resources instead of throwing', () => {
22
+ const material = new THREE.MeshStandardMaterial()
23
+ material.addEventListener('dispose', () => {})
24
+ const source = {
25
+ pascalId: 'pool-1',
26
+ waterEffect: { material, uniforms: { time: uniform(0) }, strength: 0.5 },
27
+ mesh: new THREE.Mesh(new THREE.BoxGeometry(), material),
28
+ geometry: new THREE.BufferGeometry(),
29
+ texture: new THREE.Texture(),
30
+ onDispose: () => {},
31
+ list: [material, 'kept'],
32
+ }
33
+ expect(() => structuredClone(source)).toThrow()
34
+ expect(cloneExportUserData(source)).toEqual({
35
+ pascalId: 'pool-1',
36
+ waterEffect: { uniforms: {}, strength: 0.5 },
37
+ list: [undefined, 'kept'],
38
+ })
39
+ })
40
+
41
+ test('tolerates cycles', () => {
42
+ const source: Record<string, unknown> = { pascalId: 'x' }
43
+ source.self = source
44
+ const copy = cloneExportUserData(source)
45
+ expect(copy.pascalId).toBe('x')
46
+ expect(copy.self).toBe(copy)
47
+ })
48
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Copy `userData` for an export clone. `structuredClone` throws the moment it
3
+ * meets a function, and live scenes keep runtime resources in userData (a
4
+ * plugin's effect material, TSL uniforms, a mesh reference) whose dispose
5
+ * listeners are functions — so a plain `structuredClone(userData)` aborts the
6
+ * whole export. Exports only ever read plain data back out of userData, so
7
+ * runtime resources and functions are dropped instead of cloned.
8
+ */
9
+
10
+ type RuntimeResource = {
11
+ isObject3D?: boolean
12
+ isMaterial?: boolean
13
+ isTexture?: boolean
14
+ isBufferGeometry?: boolean
15
+ isNode?: boolean
16
+ }
17
+
18
+ function isRuntimeResource(value: object): boolean {
19
+ const flags = value as RuntimeResource
20
+ return Boolean(
21
+ flags.isObject3D ||
22
+ flags.isMaterial ||
23
+ flags.isTexture ||
24
+ flags.isBufferGeometry ||
25
+ flags.isNode,
26
+ )
27
+ }
28
+
29
+ function cloneValue(value: unknown, seen: Map<object, unknown>): unknown {
30
+ if (typeof value === 'function') return undefined
31
+ if (value === null || typeof value !== 'object') return value
32
+ if (seen.has(value)) return seen.get(value)
33
+ if (isRuntimeResource(value)) return undefined
34
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
35
+ const copy = structuredClone(value)
36
+ seen.set(value, copy)
37
+ return copy
38
+ }
39
+ if (value instanceof Date || value instanceof RegExp) return structuredClone(value)
40
+ if (Array.isArray(value)) {
41
+ const copy: unknown[] = []
42
+ seen.set(value, copy)
43
+ for (const entry of value) copy.push(cloneValue(entry, seen))
44
+ return copy
45
+ }
46
+ if (value instanceof Map) {
47
+ const copy = new Map<unknown, unknown>()
48
+ seen.set(value, copy)
49
+ for (const [key, entry] of value) copy.set(cloneValue(key, seen), cloneValue(entry, seen))
50
+ return copy
51
+ }
52
+ if (value instanceof Set) {
53
+ const copy = new Set<unknown>()
54
+ seen.set(value, copy)
55
+ for (const entry of value) copy.add(cloneValue(entry, seen))
56
+ return copy
57
+ }
58
+ const copy: Record<string, unknown> = {}
59
+ seen.set(value, copy)
60
+ for (const [key, entry] of Object.entries(value)) {
61
+ const cloned = cloneValue(entry, seen)
62
+ if (cloned !== undefined) copy[key] = cloned
63
+ }
64
+ return copy
65
+ }
66
+
67
+ export function cloneExportUserData(userData: Record<string, unknown>): Record<string, unknown> {
68
+ return cloneValue(userData, new Map()) as Record<string, unknown>
69
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { OrthographicCamera, PerspectiveCamera, Vector3 } from 'three'
3
+ import { getFloatingMenuScale } from './floating-menu-scale'
4
+
5
+ describe('getFloatingMenuScale', () => {
6
+ test('uses the standard orthographic zoom scale and clamps its range', () => {
7
+ const camera = new OrthographicCamera()
8
+ const anchor = new Vector3()
9
+
10
+ camera.zoom = 15
11
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
12
+ camera.zoom = 2
13
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
14
+ camera.zoom = 40
15
+ expect(getFloatingMenuScale(camera, anchor)).toBe(1)
16
+ })
17
+
18
+ test('uses inverse perspective-camera distance and clamps its range', () => {
19
+ const camera = new PerspectiveCamera()
20
+ const anchor = new Vector3()
21
+
22
+ camera.position.set(0, 0, 16)
23
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
24
+ camera.position.set(0, 0, 48)
25
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
26
+ camera.position.set(0, 0, 6)
27
+ expect(getFloatingMenuScale(camera, anchor)).toBe(1)
28
+ })
29
+ })
@@ -0,0 +1,14 @@
1
+ import { type Camera, OrthographicCamera, type Vector3 } from 'three'
2
+
3
+ const MIN_MENU_SCALE = 0.5
4
+ const MAX_MENU_SCALE = 1
5
+ const REF_ORTHO_ZOOM = 20
6
+ const REF_CAMERA_DISTANCE = 12
7
+
8
+ export function getFloatingMenuScale(camera: Camera, anchor: Vector3): number {
9
+ const raw =
10
+ camera instanceof OrthographicCamera
11
+ ? camera.zoom / REF_ORTHO_ZOOM
12
+ : REF_CAMERA_DISTANCE / Math.max(camera.position.distanceTo(anchor), 0.001)
13
+ return Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
14
+ }
@@ -1,8 +1,23 @@
1
- import { afterEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { LevelNode, useScene, WallNode } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
2
4
  import useAlignmentGuides from '../../store/use-alignment-guides'
3
- import { applyFloorplanAlignment } from './apply-alignment'
5
+ import { alignFloorplanDraftPoint, applyFloorplanAlignment } from './apply-alignment'
4
6
 
5
7
  describe('applyFloorplanAlignment', () => {
8
+ beforeEach(() => {
9
+ // These tests assume no active building (alignment runs on world axes).
10
+ // The scene/viewer stores are process-wide singletons, so an earlier test
11
+ // FILE can leak a selected building fixture into them — under bun's
12
+ // platform-dependent file order that turned into an order-dependent
13
+ // failure (getActiveBuildingPose reading a fixture building without a
14
+ // rotation array). Pin the empty-scene context explicitly.
15
+ useScene.setState({ nodes: {} } as never)
16
+ useViewer.setState({
17
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
18
+ } as never)
19
+ })
20
+
6
21
  afterEach(() => {
7
22
  useAlignmentGuides.getState().clear()
8
23
  })
@@ -22,4 +37,34 @@ describe('applyFloorplanAlignment', () => {
22
37
  expect(result.guides).toHaveLength(1)
23
38
  expect(useAlignmentGuides.getState().guides).toHaveLength(1)
24
39
  })
40
+
41
+ test('can restrict candidates to the active level', () => {
42
+ const lowerLevel = LevelNode.parse({ id: 'level_lower', level: 0 })
43
+ const upperLevel = LevelNode.parse({ id: 'level_upper', level: 1 })
44
+ const lowerWall = WallNode.parse({
45
+ id: 'wall_lower',
46
+ parentId: lowerLevel.id,
47
+ start: [10, 10],
48
+ end: [14, 10],
49
+ })
50
+ const upperWall = WallNode.parse({
51
+ id: 'wall_upper',
52
+ parentId: upperLevel.id,
53
+ start: [0, 0],
54
+ end: [4, 0],
55
+ })
56
+ useScene.setState({
57
+ nodes: Object.fromEntries(
58
+ [lowerLevel, upperLevel, lowerWall, upperWall].map((node) => [node.id, node]),
59
+ ),
60
+ } as never)
61
+
62
+ expect(
63
+ alignFloorplanDraftPoint([0.04, 0], {
64
+ applySnap: true,
65
+ levelId: lowerLevel.id,
66
+ }),
67
+ ).toEqual([0.04, 0])
68
+ expect(useAlignmentGuides.getState().guides).toHaveLength(0)
69
+ })
25
70
  })
@@ -2,6 +2,7 @@ import {
2
2
  type AlignmentAnchor,
3
3
  type AlignmentGuide,
4
4
  collectAlignmentAnchors,
5
+ resolveLevelId,
5
6
  useScene,
6
7
  } from '@pascal-app/core'
7
8
  import useAlignmentGuides from '../../store/use-alignment-guides'
@@ -96,13 +97,21 @@ export function alignFloorplanDraftPoint(
96
97
  bypass?: boolean
97
98
  threshold?: number
98
99
  excludeIds?: readonly string[]
100
+ levelId?: string | null
99
101
  },
100
102
  ): [number, number] {
101
103
  if (opts?.bypass) {
102
104
  useAlignmentGuides.getState().clear()
103
105
  return [point[0], point[1]]
104
106
  }
105
- let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID)
107
+ const nodes = useScene.getState().nodes
108
+ let candidates = collectAlignmentAnchors(nodes, FLOORPLAN_DRAFT_ALIGN_ID)
109
+ if (opts && 'levelId' in opts) {
110
+ candidates = candidates.filter((anchor) => {
111
+ const candidate = nodes[anchor.nodeId as keyof typeof nodes]
112
+ return candidate ? resolveLevelId(candidate, nodes) === opts.levelId : false
113
+ })
114
+ }
106
115
  if (opts?.excludeIds?.length) {
107
116
  const excluded = new Set(opts.excludeIds)
108
117
  candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))