@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -6,14 +6,18 @@ import {
6
6
  generateSceneMaterialId,
7
7
  getArtifactStore,
8
8
  isDerivedNode,
9
+ isStoreyContent,
9
10
  type LevelNode,
10
11
  nodeRegistry,
11
12
  type ParsedBuildJson,
12
13
  remapMeasurementReferences,
14
+ runAsSingleSceneHistoryStep,
13
15
  SceneMaterial,
14
16
  type SceneMaterialId,
15
17
  type StairNode,
16
18
  scriptedSize,
19
+ seatRoofs,
20
+ storeyOf,
17
21
  useScene,
18
22
  } from '@pascal-app/core'
19
23
  import { clampDoorToWall, clampWindowToWall } from '@pascal-app/core/building'
@@ -258,6 +262,13 @@ function remapNodeReferences(
258
262
  }
259
263
  }
260
264
 
265
+ // A corner window pasted with its partner joins the partner's copy; pasted alone, it is plain.
266
+ if (clone.type === 'window' && clone.corner) {
267
+ const partnerId = idMap.get(clone.corner.partnerId as AnyNodeId)
268
+ if (partnerId) clone.corner = { ...clone.corner, partnerId }
269
+ else delete clone.corner
270
+ }
271
+
261
272
  if (clone.type === 'stair') {
262
273
  const nextLevelId = getNextLevelId(targetLevel, nodes)
263
274
  ;(clone as StairNode).fromLevelId = targetLevel.id
@@ -477,11 +488,17 @@ export async function pasteSystemEditorClipboardToLevel(
477
488
  return applyClipboardPayloadToLevel(payload, targetLevel.id, refused)
478
489
  }
479
490
 
491
+ /** The script and GLB a scripted node, a room's floor script or a wall's face scripts reference. */
480
492
  function artifactHashes(node: AnyNode): string[] {
481
- const source = 'source' in node ? node.source : undefined
482
- return typeof source === 'object' && source.kind === 'script'
483
- ? [source.script, source.artifact]
484
- : []
493
+ const sources =
494
+ node.type === 'zone'
495
+ ? [node.floor?.script]
496
+ : node.type === 'wall'
497
+ ? [node.finishScripts?.a, node.finishScripts?.b]
498
+ : ['source' in node ? node.source : undefined]
499
+ return sources.flatMap((source) =>
500
+ typeof source === 'object' && source?.kind === 'script' ? [source.script, source.artifact] : [],
501
+ )
485
502
  }
486
503
 
487
504
  /**
@@ -582,11 +599,21 @@ function applyClipboardPayloadToLevel(
582
599
  targetLevelId?: AnyNodeId,
583
600
  { ids: refused, reason: refusal }: Refused = NONE_REFUSED,
584
601
  ): PasteResult | null {
585
- const targetLevel = getPasteTargetLevel(targetLevelId)
586
- if (!targetLevel) return null
602
+ const aimedLevel = getPasteTargetLevel(targetLevelId)
603
+ if (!aimedLevel) return null
587
604
  const payload = refused.size > 0 ? withoutRefusedSubtrees(copied, refused) : copied
588
605
 
589
606
  const scene = useScene.getState()
607
+ // A paste lands on one level. On a roof level, one carrying anything a storey holds (walls,
608
+ // rooms, items, cabinets, …) goes to the storey it covers, whole: the row in view was the roof's,
609
+ // the plan is the storey's, and a roof in it that bears there is seated back up.
610
+ const rootIdSet = new Set(payload.rootIds)
611
+ const targetLevel = payload.nodes.some(
612
+ (node) => rootIdSet.has(node.id as AnyNodeId) && isStoreyContent(node.type, node.metadata),
613
+ )
614
+ ? ((scene.nodes[storeyOf(scene.nodes, aimedLevel.id) as AnyNodeId] as LevelNode | undefined) ??
615
+ aimedLevel)
616
+ : aimedLevel
590
617
  const idMap = new Map<AnyNodeId, AnyNodeId>()
591
618
  const materialIdMap = new Map<SceneMaterialId, SceneMaterialId>()
592
619
  const materialsToCreate: SceneMaterial[] = []
@@ -607,7 +634,6 @@ function applyClipboardPayloadToLevel(
607
634
  materialsToCreate.push({ ...material, id: nextId })
608
635
  }
609
636
 
610
- const rootIdSet = new Set(payload.rootIds)
611
637
  const pastedNodes: AnyNode[] = []
612
638
  const skippedIds: AnyNodeId[] = []
613
639
 
@@ -646,12 +672,10 @@ function applyClipboardPayloadToLevel(
646
672
  }
647
673
  }
648
674
 
649
- for (const material of materialsToCreate) {
650
- scene.addSceneMaterial(material)
651
- }
652
675
  const sourceIds = new Map([...idMap].map(([source, copy]) => [copy, source]))
653
- scene.createNodes(
654
- pastedNodes.map((node) => ({
676
+ // A roof pasted onto the walls that carry it goes to the level above them, in the paste itself.
677
+ const { changes } = seatRoofs(scene.nodes, {
678
+ create: pastedNodes.map((node) => ({
655
679
  node,
656
680
  parentId: (node.parentId as AnyNodeId | null) ?? undefined,
657
681
  // A copy is in its source's collections; an item brings them in its own `collectionIds`.
@@ -659,15 +683,21 @@ function applyClipboardPayloadToLevel(
659
683
  collectionIds: collectionIdsOf(scene.collections, sourceIds.get(node.id as AnyNodeId)!),
660
684
  }),
661
685
  })),
662
- )
686
+ })
687
+ runAsSingleSceneHistoryStep(useScene, () => {
688
+ for (const material of materialsToCreate) scene.addSceneMaterial(material)
689
+ scene.applyNodeChanges(changes)
690
+ })
663
691
 
692
+ const nodes = useScene.getState().nodes
664
693
  const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
665
694
  const pastedRootIds = payload.rootIds
666
695
  .map((rootId) => idMap.get(rootId))
667
696
  .filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
697
+ const rootLevel = nodes[(nodes[pastedRootIds[0]!]?.parentId ?? '') as AnyNodeId]
668
698
 
669
699
  useViewer.getState().setSelection({
670
- levelId: targetLevel.id,
700
+ levelId: rootLevel?.type === 'level' ? rootLevel.id : targetLevel.id,
671
701
  selectedIds: pastedRootIds,
672
702
  })
673
703
 
@@ -98,7 +98,7 @@ export function resolveCanvasSelectionNode({
98
98
  const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node
99
99
  let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget
100
100
  const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
101
- if (parentFrame) {
101
+ if (parentFrame && !parentFrame.independent) {
102
102
  const parent = parentFrame.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
103
103
  if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) {
104
104
  target = parent
@@ -28,6 +28,9 @@ type SFXEvents = {
28
28
  'sfx:menu-click': undefined
29
29
  'sfx:paint-apply': undefined
30
30
  'sfx:success': undefined
31
+ 'sfx:turn-done': undefined
32
+ 'sfx:turn-failed': undefined
33
+ 'sfx:turn-needs-input': undefined
31
34
  'sfx:terrain-sculpt-start': TerrainVerb
32
35
  'sfx:terrain-sculpt-stop': undefined
33
36
  }
@@ -58,6 +61,9 @@ const handleMenuHover = () => playSFX('menuHover')
58
61
  const handleMenuClick = () => playSFX('menuClick')
59
62
  const handlePaintApply = () => playSFX('paintApply')
60
63
  const handleSuccess = () => playSFX('success')
64
+ const handleTurnDone = () => playSFX('turnDone')
65
+ const handleTurnFailed = () => playSFX('turnFailed')
66
+ const handleTurnNeedsInput = () => playSFX('turnNeedsInput')
61
67
  const TERRAIN_LOOP_BY_VERB = {
62
68
  raise: 'terrainRaise',
63
69
  lower: 'terrainLower',
@@ -89,9 +95,13 @@ export function initSFXBus() {
89
95
  sfxEmitter.on('sfx:menu-click', handleMenuClick)
90
96
  sfxEmitter.on('sfx:paint-apply', handlePaintApply)
91
97
  sfxEmitter.on('sfx:success', handleSuccess)
98
+ sfxEmitter.on('sfx:turn-done', handleTurnDone)
99
+ sfxEmitter.on('sfx:turn-failed', handleTurnFailed)
100
+ sfxEmitter.on('sfx:turn-needs-input', handleTurnNeedsInput)
92
101
  sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
93
102
  sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
94
103
  unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
104
+ updateSFXVolumes()
95
105
  }
96
106
 
97
107
  export function disposeSFXBus() {
@@ -110,6 +120,9 @@ export function disposeSFXBus() {
110
120
  sfxEmitter.off('sfx:menu-click', handleMenuClick)
111
121
  sfxEmitter.off('sfx:paint-apply', handlePaintApply)
112
122
  sfxEmitter.off('sfx:success', handleSuccess)
123
+ sfxEmitter.off('sfx:turn-done', handleTurnDone)
124
+ sfxEmitter.off('sfx:turn-failed', handleTurnFailed)
125
+ sfxEmitter.off('sfx:turn-needs-input', handleTurnNeedsInput)
113
126
  sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
114
127
  sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
115
128
  unsubscribeAudio?.()
@@ -1,10 +1,10 @@
1
1
  import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+ import useAudio from '../store/use-audio'
2
3
 
3
4
  type FakeContext = { id: string }
4
5
 
5
6
  let activeContext: FakeContext = { id: 'first' }
6
7
  let initialState: 'loaded' | 'loading' = 'loaded'
7
- let muted = false
8
8
  let throwOnPlay = false
9
9
  const instances: FakeHowl[] = []
10
10
 
@@ -92,12 +92,6 @@ const fakeHowler = {
92
92
  }
93
93
 
94
94
  mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
95
- mock.module('../store/use-audio', () => ({
96
- default: {
97
- getState: () => ({ masterVolume: 100, muted, sfxVolume: 100 }),
98
- subscribe: () => () => {},
99
- },
100
- }))
101
95
 
102
96
  const { disposeSFX, playSFX, preloadSFX, startLoopSFX, stopLoopSFX, updateSFXVolumes } =
103
97
  await import('./sfx-player')
@@ -107,7 +101,7 @@ beforeEach(() => {
107
101
  disposeSFXBus()
108
102
  activeContext = { id: 'first' }
109
103
  initialState = 'loaded'
110
- muted = false
104
+ useAudio.setState({ masterVolume: 100, muted: false, sfxVolume: 100 })
111
105
  throwOnPlay = false
112
106
  instances.length = 0
113
107
  })
@@ -118,6 +112,51 @@ afterAll(() => {
118
112
  })
119
113
 
120
114
  describe('SFX audio context lifecycle', () => {
115
+ test('editor startup warms assets without playing and repeated init does not duplicate delivery', () => {
116
+ initSFXBus()
117
+ const warmed = [...instances]
118
+ expect(warmed.length).toBeGreaterThan(0)
119
+ expect(warmed.every((sound) => sound.playCount === 0)).toBe(true)
120
+ initSFXBus()
121
+ expect(instances).toHaveLength(warmed.length)
122
+ triggerSFX('sfx:item-place')
123
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
124
+ })
125
+
126
+ test('a one-shot requested before loading is dropped, not replayed on load', () => {
127
+ initialState = 'loading'
128
+ initSFXBus()
129
+ triggerSFX('sfx:item-place')
130
+ for (const sound of instances) sound.finishLoading()
131
+ expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
132
+ // A different fresh cue avoids relying on the per-name repeat clock.
133
+ triggerSFX('sfx:structure-build')
134
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
135
+ })
136
+
137
+ test('muted startup neither starts audio nor consumes a later fresh cue', () => {
138
+ useAudio.setState({ muted: true })
139
+ initSFXBus()
140
+ triggerSFX('sfx:item-place')
141
+ expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
142
+ useAudio.setState({ muted: false })
143
+ updateSFXVolumes()
144
+ triggerSFX('sfx:item-place')
145
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
146
+ })
147
+
148
+ test('cleanup detaches delivery and unloads resources once, then remount plays once', () => {
149
+ initSFXBus()
150
+ const warmed = [...instances]
151
+ disposeSFXBus()
152
+ disposeSFXBus()
153
+ triggerSFX('sfx:item-place')
154
+ expect(warmed.every((sound) => sound.playCount === 0 && sound.unloadCount === 1)).toBe(true)
155
+ initSFXBus()
156
+ triggerSFX('sfx:item-place')
157
+ expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
158
+ })
159
+
121
160
  test('reuses the preloaded cache while the Howler context is unchanged', () => {
122
161
  preloadSFX()
123
162
  const initialCount = instances.length
@@ -234,7 +273,7 @@ describe('SFX audio context lifecycle', () => {
234
273
  startLoopSFX('terrainLower')
235
274
  const lower = instances.find((sound) => sound.loop && sound.playCount === 1)
236
275
 
237
- muted = true
276
+ useAudio.setState({ muted: true })
238
277
  updateSFXVolumes()
239
278
 
240
279
  expect(lower?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1])
@@ -263,4 +302,26 @@ describe('SFX audio context lifecycle', () => {
263
302
 
264
303
  expect(lower?.playCount).toBe(0)
265
304
  })
305
+
306
+ test('turn cues play at the master and effects volume, and not in a muted tab', () => {
307
+ const cues = ['sfx:turn-done', 'sfx:turn-failed', 'sfx:turn-needs-input'] as const
308
+ useAudio.setState({ masterVolume: 50, muted: true, sfxVolume: 40 })
309
+ initSFXBus()
310
+
311
+ for (const cue of cues) triggerSFX(cue)
312
+ expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
313
+
314
+ useAudio.setState({ muted: false })
315
+ for (const cue of cues) triggerSFX(cue)
316
+
317
+ const played = instances.filter((sound) => sound.playCount === 1)
318
+ expect(played.map((sound) => sound.src)).toEqual([
319
+ '/audios/sfx/turn_done.mp3',
320
+ '/audios/sfx/turn_failed.mp3',
321
+ '/audios/sfx/turn_needs_input.mp3',
322
+ ])
323
+ for (const sound of played) {
324
+ expect(sound.volumeCalls.find(([, id]) => id === 1)?.[0]).toBeCloseTo(0.2)
325
+ }
326
+ })
266
327
  })
@@ -120,6 +120,12 @@ export const SFX: Record<string, SFXConfig> = {
120
120
  src: '/audios/sfx/success.mp3',
121
121
  minIntervalMs: 3_000,
122
122
  },
123
+ // An agent's turn, heard from another tab or another app: it ended with its work done, it failed,
124
+ // or it waits on the person. Two-note chimes (`scripts/synth-turn-cues.ts`), so no jitter, as for
125
+ // `success`. The host plays each once per turn; no gap here could tell two turns from one.
126
+ turnDone: { src: '/audios/sfx/turn_done.mp3' },
127
+ turnFailed: { src: '/audios/sfx/turn_failed.mp3' },
128
+ turnNeedsInput: { src: '/audios/sfx/turn_needs_input.mp3' },
123
129
  } as const
124
130
 
125
131
  export type SFXName = keyof typeof SFX
@@ -37,6 +37,31 @@ describe('stair level helpers', () => {
37
37
  expect(plan?.createdLevel?.parentId).toBe(building.id)
38
38
  })
39
39
 
40
+ // Every generated one-storey house has a Roof level: the stair tool sent its flight up into it.
41
+ test('climbs to a new storey under the roof level, which moves up over it', () => {
42
+ const ground = LevelNode.parse({ level: 0, children: [] })
43
+ const roofLevel = LevelNode.parse({ level: 1, children: [], metadata: { role: 'roof' } })
44
+ const building = BuildingNode.parse({ children: [ground.id, roofLevel.id] })
45
+ const nodes = {
46
+ [building.id]: building,
47
+ [ground.id]: { ...ground, parentId: building.id },
48
+ [roofLevel.id]: { ...roofLevel, parentId: building.id },
49
+ } as Record<AnyNodeId, AnyNode>
50
+
51
+ const plan = resolveStairDestinationLevel({
52
+ createMissing: true,
53
+ fromLevelId: ground.id,
54
+ nodes,
55
+ })
56
+
57
+ expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
58
+ expect(plan?.createdLevel?.level).toBe(1)
59
+ expect(plan?.liftedLevels).toEqual([{ id: roofLevel.id, data: { level: 2 } }])
60
+ expect(resolveStairDestinationLevel({ fromLevelId: ground.id, nodes })?.toLevel.id).toBe(
61
+ ground.id,
62
+ )
63
+ })
64
+
40
65
  test('uses the nearest higher sibling level instead of creating one', () => {
41
66
  const building = BuildingNode.parse({})
42
67
  const ground = LevelNode.parse({ level: 0, parentId: building.id })
@@ -6,6 +6,8 @@ import {
6
6
  type LevelNode as LevelNodeType,
7
7
  resolveBuildingForLevel,
8
8
  type StairNode,
9
+ servesAsRoofLevel,
10
+ storeyInsertIndex,
9
11
  } from '@pascal-app/core'
10
12
 
11
13
  function sortLevelsByHeight(levels: LevelNodeType[]) {
@@ -141,30 +143,46 @@ export function resolveStairDestinationLevel({
141
143
  nodes as Record<AnyNodeId, AnyNode>,
142
144
  )
143
145
  const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
144
- const nextExistingLevel = levels.find((level) => level.level > fromLevel.level) ?? null
146
+ // A stair rises to a storey: the roof level over the top one is not a floor to climb to.
147
+ const nextExistingLevel =
148
+ levels.find((level) => level.level > fromLevel.level && !servesAsRoofLevel(nodes, level)) ??
149
+ null
145
150
  if (nextExistingLevel) {
146
151
  return {
147
152
  buildingId,
148
153
  createdLevel: null,
149
154
  fromLevel,
150
155
  levels,
156
+ liftedLevels: [],
151
157
  toLevel: nextExistingLevel,
152
158
  }
153
159
  }
154
160
 
155
161
  if (createMissing && buildingId) {
162
+ // The new storey goes under the roof level, which moves up over it.
163
+ const { floorIndex, shift } = storeyInsertIndex(nodes, buildingId)
156
164
  const createdLevel = LevelNode.parse({
157
165
  children: [],
158
166
  height: DEFAULT_LEVEL_HEIGHT,
159
- level: fromLevel.level + 1,
167
+ level: floorIndex,
160
168
  parentId: buildingId,
161
169
  })
170
+ const lifted = new Map(shift.map(({ id, data }) => [id, data.level]))
162
171
 
163
172
  return {
164
173
  buildingId,
165
174
  createdLevel,
166
175
  fromLevel,
167
- levels: sortLevelsByHeight([...levels, createdLevel]),
176
+ levels: sortLevelsByHeight([
177
+ ...levels.map((level) =>
178
+ lifted.has(level.id) ? { ...level, level: lifted.get(level.id)! } : level,
179
+ ),
180
+ createdLevel,
181
+ ]),
182
+ liftedLevels: shift.map(({ id, data }) => ({
183
+ id: id as AnyNodeId,
184
+ data: data as Partial<AnyNode>,
185
+ })),
168
186
  toLevel: createdLevel,
169
187
  }
170
188
  }
@@ -174,6 +192,7 @@ export function resolveStairDestinationLevel({
174
192
  createdLevel: null,
175
193
  fromLevel,
176
194
  levels,
195
+ liftedLevels: [],
177
196
  toLevel: fromLevel,
178
197
  }
179
198
  }
@@ -53,7 +53,8 @@ const OPEN_GROUND = 500
53
53
  * Why a terrace outline cannot be built, or its plan: an outdoor room of the
54
54
  * outline, closed with separators where no wall runs, without a ceiling.
55
55
  * Core refuses an outline over a room (the same rule MCP's outdoor
56
- * `create_room` follows) and names the room.
56
+ * `create_room` follows) and names the room, except the numbered room its
57
+ * walls enclose exactly: a walled courtyard, which becomes the terrace.
57
58
  */
58
59
  export function planTerrace(
59
60
  nodes: Record<string, AnyNode>,
@@ -27,6 +27,7 @@ import {
27
27
  Sphere,
28
28
  Vector3,
29
29
  } from 'three'
30
+ import { useBelowGrade } from './below-grade'
30
31
  import { nodeDisplayLabel } from './node-display-label'
31
32
  import { roomFloorElevation } from './room-handle-drag'
32
33
  import { RoomSelectionIndex, type RoomSelectionRecord, resolveRoomHit } from './room-selection'
@@ -222,13 +223,15 @@ export function resolveViewerHover(
222
223
 
223
224
  /**
224
225
  * The current floor reads as the editor shows it: the levels above it step
225
- * aside (in the stacked display) so its rooms are in view and in reach. Mount
226
- * it in the surface's DOM tree, beside the editor's own level display, so one
227
- * hands the flag to the other in order.
226
+ * aside (in the stacked display) so its rooms are in view and in reach, and the
227
+ * ground is veiled over it when it lies below grade. Mount it in the surface's DOM tree,
228
+ * beside the editor's own level display, so one hands the flag to the other in
229
+ * order.
228
230
  */
229
231
  export function useViewerFloorDisplay() {
230
232
  // A walkthrough tours the whole building.
231
233
  const walking = useViewer((s) => s.walkthroughMode)
234
+ useBelowGrade(!walking)
232
235
  useEffect(() => {
233
236
  if (walking) return
234
237
  useViewer.setState({ hideLevelsAboveSelection: true })
@@ -344,14 +347,13 @@ function expandByZone(target: Box3, zone: ZoneNode, source: ViewerSource) {
344
347
  }
345
348
  }
346
349
 
347
- /** The frame request for a node: its saved view, else the box it spans. */
350
+ /** The frame request for a node: the box it spans, and its saved view when it has one. */
348
351
  function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameEvent | null {
349
352
  const node = source.nodes[id]
350
353
  if (!node) return null
351
354
  const saved = node.camera
352
- if (saved?.position && saved.target) {
353
- return { bounds: null, pose: { position: saved.position, target: saved.target } }
354
- }
355
+ const pose =
356
+ saved?.position && saved.target ? { position: saved.position, target: saved.target } : null
355
357
  box.makeEmpty()
356
358
  if (node.type === 'zone') expandByZone(box, node, source)
357
359
  else if (node.type === 'unit') {
@@ -363,8 +365,16 @@ function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameE
363
365
  const object = source.objectFor(id)
364
366
  if (object) box.setFromObject(object)
365
367
  }
366
- if (box.isEmpty()) return null
367
- return { bounds: { min: box.min.toArray(), max: box.max.toArray() } }
368
+ const bounds = box.isEmpty() ? null : { min: box.min.toArray(), max: box.max.toArray() }
369
+ if (pose) return { bounds, pose }
370
+ return bounds ? { bounds } : null
371
+ }
372
+
373
+ /** Framing glides to its pose, or jumps there when the person asks for reduced motion. */
374
+ export function framingAnimates(): boolean {
375
+ return !(
376
+ typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
377
+ )
368
378
  }
369
379
 
370
380
  export function frameViewerNode(id: string, source: ViewerSource) {
@@ -426,13 +436,14 @@ export function frameViewerCamera(
426
436
  : undefined,
427
437
  )
428
438
  if (!pose) return
429
- control.setLookAt(...pose.position, ...pose.target, true)
439
+ const animate = framingAnimates()
440
+ control.setLookAt(...pose.position, ...pose.target, animate)
430
441
  if ((camera as OrthographicCamera).isOrthographicCamera && event.bounds && !event.pose) {
431
442
  box.min.set(...event.bounds.min)
432
443
  box.max.set(...event.bounds.max)
433
444
  box.getBoundingSphere(frameSphere)
434
445
  frameSphere.radius = Math.max(frameSphere.radius * 1.3, 1)
435
- control.fitToSphere(frameSphere, true)
446
+ control.fitToSphere(frameSphere, animate)
436
447
  }
437
448
  }
438
449
 
@@ -0,0 +1,42 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { Group, Vector3 } from 'three'
3
+ import {
4
+ createWallPointerTracker,
5
+ wallPointerInParent,
6
+ wallSideAtPointer,
7
+ } from './wall-placement-pointer'
8
+
9
+ test('keeps the grab offset across a pointer gap on the original face', () => {
10
+ const pointer = createWallPointerTracker({ wallId: 'wall_a', side: 'front', position: [4, 2] })
11
+ expect(pointer.resolve('wall_a', 'front', [3, 1])).toEqual([4, 2])
12
+ pointer.leave()
13
+ expect(pointer.resolve('wall_a', 'front', [5, 3])).toEqual([6, 4])
14
+ })
15
+ test('a different wall or face permanently releases the old grab offset', () => {
16
+ for (const [wall, side] of [
17
+ ['wall_b', 'front'],
18
+ ['wall_a', 'back'],
19
+ ] as const) {
20
+ const pointer = createWallPointerTracker({ wallId: 'wall_a', side: 'front', position: [4, 2] })
21
+ pointer.resolve('wall_a', 'front', [3, 1])
22
+ expect(pointer.resolve(wall, side, [5, 3])).toEqual([5, 3])
23
+ expect(pointer.resolve('wall_a', 'front', [6, 4])).toEqual([6, 4])
24
+ }
25
+ })
26
+ test('a committed floor placement releases the original wall grab', () => {
27
+ const pointer = createWallPointerTracker({ wallId: 'wall_a', position: [4, 2] })
28
+ pointer.resolve('wall_a', 'front', [3, 1])
29
+ pointer.forget()
30
+ expect(pointer.resolve('wall_a', 'front', [5, 3])).toEqual([5, 3])
31
+ })
32
+ test('reads the pointer in the rotated wall parent and identifies its face', () => {
33
+ const parent = new Group()
34
+ parent.position.set(3, 1, 5)
35
+ parent.rotation.y = Math.PI / 2
36
+ parent.updateMatrixWorld(true)
37
+ const world = parent.localToWorld(new Vector3(2, 3, -0.1))
38
+ const local = wallPointerInParent(world.toArray(), parent)
39
+ expect(local[0]).toBeCloseTo(2)
40
+ expect(local[1]).toBeCloseTo(3)
41
+ expect(wallSideAtPointer(local)).toBe('back')
42
+ })
@@ -0,0 +1,57 @@
1
+ import { type Object3D, Vector3 } from 'three'
2
+
3
+ type WallSide = 'front' | 'back'
4
+ type Point = readonly [number, number]
5
+
6
+ /** Keep the grabbed point only on its original face; new hosts follow the cursor. */
7
+ export function createWallPointerTracker(source?: {
8
+ wallId: string | null
9
+ side?: WallSide
10
+ position: readonly number[]
11
+ }) {
12
+ let forgotten = !source
13
+ let grabOffset: { x: number; y: number } | null = null
14
+ let anchor: { wallId: string; side: WallSide; x: number; y: number } | null = null
15
+ return {
16
+ resolve(wallId: string, side: WallSide, point: Point): [number, number] {
17
+ if (!anchor || anchor.wallId !== wallId || anchor.side !== side) {
18
+ const preserve =
19
+ !forgotten && source?.wallId === wallId && (!source.side || source.side === side)
20
+ if (!preserve) forgotten = true
21
+ if (preserve && !grabOffset) {
22
+ grabOffset = { x: source!.position[0]! - point[0], y: source!.position[1]! - point[1] }
23
+ }
24
+ anchor = {
25
+ wallId,
26
+ side,
27
+ x: preserve ? grabOffset!.x : 0,
28
+ y: preserve ? grabOffset!.y : 0,
29
+ }
30
+ }
31
+ return [point[0] + anchor.x, point[1] + anchor.y]
32
+ },
33
+ leave() {
34
+ anchor = null
35
+ },
36
+ forget() {
37
+ anchor = null
38
+ grabOffset = null
39
+ forgotten = true
40
+ },
41
+ }
42
+ }
43
+
44
+ /** Measure the hit in the same parent frame that renders the placed child. */
45
+ export function wallPointerInParent(
46
+ worldPoint: readonly number[],
47
+ parent: Object3D,
48
+ ): [number, number, number] {
49
+ parent.updateWorldMatrix(true, false)
50
+ const point = parent.worldToLocal(new Vector3(worldPoint[0], worldPoint[1], worldPoint[2]))
51
+ return [point.x, point.y, point.z]
52
+ }
53
+
54
+ /** Collision mesh winding can differ from the wall frame; the hit identifies the touched side. */
55
+ export function wallSideAtPointer(point: readonly number[]): WallSide {
56
+ return point[2]! >= 0 ? 'front' : 'back'
57
+ }
@@ -0,0 +1,35 @@
1
+ import { type AnyNode, type AnyNodeId, useScene, type WindowNode } from '@pascal-app/core'
2
+
3
+ /**
4
+ * Write what Wrap the corner, Merge windows or a placement at a corner planned: one batch, so one
5
+ * undo step, with the windows and the walls that carry them marked for rebuild.
6
+ */
7
+ export function writeWindowChanges(changes: {
8
+ create: WindowNode | null
9
+ updates: Record<string, Partial<WindowNode>>
10
+ remove: readonly string[]
11
+ }) {
12
+ const scene = useScene.getState()
13
+ const parents = new Set<string>()
14
+ for (const id of [...Object.keys(changes.updates), ...changes.remove]) {
15
+ const parentId = scene.nodes[id as AnyNodeId]?.parentId
16
+ if (parentId) parents.add(parentId)
17
+ }
18
+ scene.applyNodeChanges({
19
+ create: changes.create
20
+ ? [{ node: changes.create, parentId: changes.create.parentId as AnyNodeId }]
21
+ : [],
22
+ update: Object.entries(changes.updates).map(([id, data]) => ({
23
+ id: id as AnyNodeId,
24
+ data: data as Partial<AnyNode>,
25
+ })),
26
+ delete: changes.remove as AnyNodeId[],
27
+ })
28
+ const after = useScene.getState()
29
+ for (const id of Object.keys(changes.updates)) after.dirtyNodes.add(id as AnyNodeId)
30
+ if (changes.create) {
31
+ after.dirtyNodes.add(changes.create.id as AnyNodeId)
32
+ if (changes.create.parentId) parents.add(changes.create.parentId)
33
+ }
34
+ for (const id of parents) after.dirtyNodes.add(id as AnyNodeId)
35
+ }