@pascal-app/editor 1.0.4 → 1.0.6

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 +40 -38
  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
@@ -1,4 +1,9 @@
1
- import { type AnyNode, getWallThickness } from '@pascal-app/core'
1
+ import {
2
+ type AnyNode,
3
+ getRoofGlazing,
4
+ getWallThickness,
5
+ isGlazedRoofSegment,
6
+ } from '@pascal-app/core'
2
7
  import {
3
8
  mergePrintExportDiagnostics,
4
9
  type PrintExportDiagnostic,
@@ -30,6 +35,10 @@ function semanticThicknessMeters(node: AnyNode): number | null {
30
35
  case 'slab':
31
36
  return node.thickness
32
37
  case 'roof-segment': {
38
+ if (isGlazedRoofSegment(node)) {
39
+ const glazing = getRoofGlazing(node)
40
+ return Math.min(glazing.frameWidth, glazing.frameDepth, glazing.glassThickness)
41
+ }
33
42
  const structuralThicknesses = [node.wallThickness, node.deckThickness + node.shingleThickness]
34
43
  if (node.roofType === 'dutch') structuralThicknesses.push(node.dutchTopRakeThickness)
35
44
  return Math.min(...structuralThicknesses)
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { DoorNode, RoofSegmentNode, type RoofType } from '@pascal-app/core'
3
+ import { buildPrintableRoofSegmentSolids } from '@pascal-app/editor'
3
4
  import * as THREE from 'three'
4
- import { buildPrintableRoofSegmentSolids } from './print-roof-solids'
5
5
 
6
6
  const ROOF_TYPES: RoofType[] = [
7
7
  'gable',
@@ -68,6 +68,21 @@ describe('buildPrintableRoofSegmentSolids', () => {
68
68
  }
69
69
  })
70
70
 
71
+ test("a gable's print shell reaches its gable overhang at the ends, the eaves along the sides", () => {
72
+ const extent = (node: RoofSegmentNode) => {
73
+ const mesh = onlyMesh(buildPrintableRoofSegmentSolids(node).object!)
74
+ mesh.geometry.computeBoundingBox()
75
+ const box = mesh.geometry.boundingBox!.clone()
76
+ mesh.geometry.dispose()
77
+ return box
78
+ }
79
+ const plain = extent(fixture('gable'))
80
+ const raked = extent(fixture('gable', { gableOverhang: 0.6 }))
81
+ expect(raked.max.x).toBeCloseTo(4 / 2 + 0.15 / 2 + 0.6, 6)
82
+ expect(raked.min.x).toBeCloseTo(-(4 / 2 + 0.15 / 2 + 0.6), 6)
83
+ expect(raked.max.z).toBeCloseTo(plain.max.z, 6)
84
+ })
85
+
71
86
  test('preserves segment provenance and local transform', () => {
72
87
  const node = fixture('gable', { position: [1, 2, 3], rotation: Math.PI / 3 })
73
88
  const result = buildPrintableRoofSegmentSolids(node)
@@ -131,3 +146,26 @@ describe('buildPrintableRoofSegmentSolids', () => {
131
146
  )
132
147
  })
133
148
  })
149
+
150
+ for (const roofType of ['gable', 'shed', 'barrel'] as const)
151
+ test(`glazed ${roofType} prints pane and frame solids without an opaque roof shell`, () => {
152
+ const result = buildPrintableRoofSegmentSolids(
153
+ fixture(roofType, { roofSystem: 'glazed', wallThickness: 0 }),
154
+ )
155
+ expect(result.status).toBe('ready')
156
+ if (result.status !== 'ready') return
157
+ try {
158
+ expect(result.object.children.some((child) => child.name === 'print-glazing-frame')).toBe(
159
+ true,
160
+ )
161
+ expect(result.object.children.some((child) => child.name === 'print-glazing-pane')).toBe(true)
162
+ expect(result.object.children.some((child) => child.name === 'print-roof-shell')).toBe(false)
163
+ for (const child of result.object.children) {
164
+ const geometry = (child as THREE.Mesh).geometry
165
+ expect(geometry.getIndex()!.count).toBeGreaterThan(0)
166
+ expect([...geometry.getAttribute('position').array].every(Number.isFinite)).toBe(true)
167
+ }
168
+ } finally {
169
+ for (const child of result.object.children) (child as THREE.Mesh).geometry.dispose()
170
+ }
171
+ })
@@ -1,10 +1,15 @@
1
1
  import {
2
2
  type AnyNode,
3
+ getBarrelRoofSolidPieces,
3
4
  getConicalRoofCoverage,
5
+ getGlazedRoofPieceFaces,
6
+ getGlazedRoofPieces,
4
7
  getRoofModuleFaces,
8
+ getRoofSegmentDeckReach,
5
9
  getRoofShapeInsets,
6
10
  getRoofShapeRatios,
7
11
  getSegmentSlopeFrame,
12
+ isGlazedRoofSegment,
8
13
  nodeRegistry,
9
14
  normalizeRoofSegmentTrim,
10
15
  type RoofSegmentNode,
@@ -411,11 +416,17 @@ function buildRoofModuleGeometry(
411
416
 
412
417
  function getVolumeFaces(
413
418
  node: RoofSegmentNode,
414
- options: { widthExtension: number; verticalOffset: number; isVoid: boolean },
419
+ options: {
420
+ widthExtension: number
421
+ /** How far it reaches along x, where a gable's rakes are; `widthExtension` when absent. */
422
+ rakeExtension?: number
423
+ verticalOffset: number
424
+ isVoid: boolean
425
+ },
415
426
  ): RoofFace[] {
416
427
  const conicalCoverage = getConicalRoofCoverage(node)
417
428
  const { activeRh, tanTheta } = getSegmentSlopeFrame(node)
418
- const width = Math.max(0.01, node.width + options.widthExtension * 2)
429
+ const width = Math.max(0.01, node.width + (options.rakeExtension ?? options.widthExtension) * 2)
419
430
  const depth = Math.max(0.01, node.depth + options.widthExtension * 2)
420
431
  const autoDrop = options.widthExtension * tanTheta
421
432
  const wallHeight = Math.max(0.01, node.wallHeight - autoDrop + options.verticalOffset)
@@ -448,13 +459,13 @@ function getShingleOuterFaces(node: RoofSegmentNode): RoofFace[] {
448
459
  const conicalCoverage = getConicalRoofCoverage(node)
449
460
  const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node)
450
461
  const shapeRatios = getRoofShapeRatios(node)
451
- const horizontalOverhang = node.overhang * cosTheta
452
- const deckExtension = node.wallThickness / 2 + horizontalOverhang
462
+ const reach = getRoofSegmentDeckReach(node)
463
+ const deckExtension = reach.z
453
464
  const deckVerticalThickness = activeRh > 0 ? node.deckThickness / cosTheta : node.deckThickness
454
465
  const deckDrop = deckExtension * tanTheta
455
466
  const shingleHorizontalThickness = node.shingleThickness * sinTheta
456
467
  const shingleVerticalThickness = node.shingleThickness * cosTheta
457
- const baseWidth = Math.max(0.01, node.width + deckExtension * 2)
468
+ const baseWidth = Math.max(0.01, node.width + reach.x * 2)
458
469
  const baseDepth = Math.max(0.01, node.depth + deckExtension * 2)
459
470
  const baseWallHeight = node.wallHeight - deckDrop + deckVerticalThickness
460
471
  const baseRoofHeight =
@@ -529,8 +540,9 @@ function blockingDiagnostics(
529
540
  dimensions.some((value) => !Number.isFinite(value) || value < 0) ||
530
541
  node.width <= FACE_EPSILON ||
531
542
  node.depth <= FACE_EPSILON ||
532
- node.wallThickness <= FACE_EPSILON ||
533
- node.deckThickness + node.shingleThickness <= FACE_EPSILON
543
+ (!isGlazedRoofSegment(node) &&
544
+ (node.wallThickness <= FACE_EPSILON ||
545
+ node.deckThickness + node.shingleThickness <= FACE_EPSILON))
534
546
  ) {
535
547
  diagnostics.push({
536
548
  severity: 'error',
@@ -581,8 +593,28 @@ export function buildPrintableRoofSegmentSolids(
581
593
  ): PrintRoofSolidResult {
582
594
  const diagnostics = blockingDiagnostics(node, nodes)
583
595
  if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics }
596
+ if (isGlazedRoofSegment(node) || node.roofType === 'barrel') {
597
+ const root = new THREE.Group()
598
+ root.name = isGlazedRoofSegment(node) ? 'print-glazed-roof-solids' : 'print-barrel-roof-solids'
599
+ root.userData = { pascalId: node.id }
600
+ root.position.set(...node.position)
601
+ root.rotation.y = node.rotation
602
+ for (const piece of isGlazedRoofSegment(node)
603
+ ? getGlazedRoofPieces(node)
604
+ : getBarrelRoofSolidPieces(node)) {
605
+ const geometry = geometryFromFaces(getGlazedRoofPieceFaces(piece))
606
+ if (!geometry) continue
607
+ const mesh = new THREE.Mesh(geometry)
608
+ mesh.name = isGlazedRoofSegment(node)
609
+ ? piece.slot === 0
610
+ ? 'print-glazing-frame'
611
+ : 'print-glazing-pane'
612
+ : 'print-roof-shell'
613
+ root.add(mesh)
614
+ }
615
+ return { status: 'ready', object: root, diagnostics: [] }
616
+ }
584
617
 
585
- const { cosTheta } = getSegmentSlopeFrame(node)
586
618
  const wallOuter = getVolumeFaces(node, {
587
619
  widthExtension: node.wallThickness / 2,
588
620
  verticalOffset: 0,
@@ -593,9 +625,10 @@ export function buildPrintableRoofSegmentSolids(
593
625
  verticalOffset: 0,
594
626
  isVoid: false,
595
627
  })
596
- const deckExtension = node.wallThickness / 2 + node.overhang * cosTheta
628
+ const reach = getRoofSegmentDeckReach(node)
597
629
  const roofInner = getVolumeFaces(node, {
598
- widthExtension: deckExtension,
630
+ widthExtension: reach.z,
631
+ rakeExtension: reach.x,
599
632
  verticalOffset: 0,
600
633
  isVoid: true,
601
634
  })
@@ -27,7 +27,7 @@ type ManifoldFactory = (config?: {
27
27
  // emscripten then locates manifold.wasm relative to the glue's own URL. Hosts
28
28
  // that can't reach the CDN (offline, CSP) pass their own URLs through
29
29
  // configureManifoldRuntime.
30
- const MANIFOLD_VERSION = '3.5.1'
30
+ const MANIFOLD_VERSION = '3.5.4'
31
31
  const FALLBACK_MODULE_URL = `https://cdn.jsdelivr.net/npm/manifold-3d@${MANIFOLD_VERSION}/manifold.js`
32
32
 
33
33
  function importUntraced(specifier: string): Promise<{ default: ManifoldFactory }> {
@@ -6,9 +6,8 @@ import {
6
6
  getWallBaseElevationForNodes,
7
7
  getWallCurveFrameAt,
8
8
  getWallFaceOffsets,
9
- getWallPlaneTop,
9
+ getWallTop,
10
10
  resolveCeilingHeight,
11
- resolveWallTop,
12
11
  roomDrawnFloor,
13
12
  type WallNode,
14
13
  } from '@pascal-app/core'
@@ -78,7 +77,7 @@ export function resolveRoomAssemblyHeights(
78
77
  const wall = nodes[id]
79
78
  if (wall?.type !== 'wall') continue
80
79
  const base = getWallBaseElevationForNodes(wall, sceneNodes)
81
- wallTops.set(id, resolveWallTop(wall, getWallPlaneTop(wall, levelId, sceneNodes), base))
80
+ wallTops.set(id, getWallTop(wall, levelId, sceneNodes, base))
82
81
  }
83
82
  const ceilingNode = room.ceilingId ? nodes[room.ceilingId] : undefined
84
83
  const ceiling =
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, jest, spyOn, test } from 'bun:test'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import {
4
4
  type AnyNode,
5
+ type AnyNodeDefinition,
5
6
  BuildingNode,
6
7
  CeilingNode,
7
8
  createRoomTopologyIndex,
@@ -10,7 +11,9 @@ import {
10
11
  ItemNode,
11
12
  LevelNode,
12
13
  type NodeEvent,
14
+ nodeRegistry,
13
15
  reconcileLevelStructure,
16
+ registerNode,
14
17
  SlabNode,
15
18
  SpawnNode,
16
19
  StairNode,
@@ -24,11 +27,12 @@ import { useViewer } from '@pascal-app/viewer'
24
27
  import { _roots, act } from '@react-three/fiber'
25
28
  import { createElement } from 'react'
26
29
  import { Group, type LineSegments } from 'three'
30
+ import { z } from 'zod'
27
31
  import { SelectionManager } from '../components/editor/selection-manager'
28
32
  import { cancelActiveTool, runHistoryShortcut } from '../hooks/use-keyboard'
29
33
  import { resolvePlanRoomHit, roomPickingEnabled, useSelectedRoom } from '../hooks/use-selected-room'
30
34
  import useEditor from '../store/use-editor'
31
- import useInteractionScope from '../store/use-interaction-scope'
35
+ import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
32
36
  import useSessionGroups from '../store/use-session-groups'
33
37
  import { withSelectionHarness } from '../test-utils/selection-harness'
34
38
  import { startRoomDivide } from './room-divide-session'
@@ -646,6 +650,46 @@ describe('room drill-down state through the mounted selection manager', () => {
646
650
  expect(useEditor.getState().room).toBeNull()
647
651
  })
648
652
  })
653
+ test('a second click on the selected crate picks it up, and that click cannot drop it', async () => {
654
+ jest.useFakeTimers()
655
+ if (!nodeRegistry.has('test-crate')) {
656
+ registerNode({
657
+ kind: 'test-crate',
658
+ schemaVersion: 1,
659
+ schema: z.object({ type: z.literal('test-crate') }) as never,
660
+ category: 'furnish',
661
+ defaults: () => ({}),
662
+ capabilities: { selectable: {}, movable: { axes: ['x', 'z'] } },
663
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
664
+ } as AnyNodeDefinition)
665
+ }
666
+ const crate = {
667
+ id: 'test-crate_a',
668
+ type: 'test-crate',
669
+ parentId: levelId,
670
+ position: [2, 0, 2],
671
+ rotation: [0, 0, 0],
672
+ } as unknown as AnyNode
673
+ await withRooms(async ({ click }) => {
674
+ await act(async () => {
675
+ useScene.setState({ nodes: { ...useScene.getState().nodes, [crate.id]: crate } })
676
+ })
677
+ await click(crate.id, [2, 2])
678
+ jest.advanceTimersByTime(50)
679
+ await click(crate.id, [2, 2])
680
+ expect(getMovingNode()?.id).toBe(crate.id)
681
+
682
+ // The browser's click that ends this press would otherwise commit the move in place.
683
+ const endingClick = new Event('click', { cancelable: true })
684
+ window.dispatchEvent(endingClick)
685
+ expect(endingClick.defaultPrevented).toBe(true)
686
+ const dropClick = new Event('click', { cancelable: true })
687
+ window.dispatchEvent(dropClick)
688
+ expect(dropClick.defaultPrevented).toBe(false)
689
+ await act(async () => useEditor.getState().setMovingNode(null))
690
+ })
691
+ })
692
+
649
693
  test('plan and box furniture selections end the selected room', async () => {
650
694
  await withRooms(async ({ click, left }) => {
651
695
  const chair = ItemNode.parse({
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeId,
5
+ BuildingNode,
5
6
  CabinetModuleNode,
6
7
  type CabinetModuleNode as CabinetModuleNodeType,
7
8
  CabinetNode,
@@ -11,14 +12,17 @@ import {
11
12
  configureArtifactStore,
12
13
  getArtifactStore,
13
14
  ItemNode,
14
- type LevelNode,
15
+ LevelNode,
15
16
  MeasurementNode,
17
+ RoofNode,
18
+ RoofSegmentNode,
16
19
  SceneMaterial,
17
20
  type SceneMaterialId,
18
21
  SlabNode,
19
22
  useScene,
20
23
  WallNode,
21
24
  WindowNode,
25
+ ZoneNode,
22
26
  } from '@pascal-app/core'
23
27
  import { useViewer } from '@pascal-app/viewer'
24
28
  import {
@@ -366,6 +370,194 @@ describe('scene clipboard', () => {
366
370
  expect(scriptedY).toBeCloseTo(3 / 2)
367
371
  })
368
372
 
373
+ // A roof lives on the level above the walls that carry it: a paste puts it there, and walls
374
+ // pasted on the roof level's row go to the storey it covers.
375
+ function roofedRoomOn(levelId: LevelNode['id']) {
376
+ const corners: [number, number][] = [
377
+ [0, 0],
378
+ [8, 0],
379
+ [8, 6],
380
+ [0, 6],
381
+ ]
382
+ const walls = corners.map((start, i) =>
383
+ WallNode.parse({ parentId: levelId, start, end: corners[(i + 1) % 4], height: 2.5 }),
384
+ )
385
+ const segment = RoofSegmentNode.parse({ width: 8, depth: 6, wallHeight: 0 })
386
+ const roof = RoofNode.parse({
387
+ parentId: levelId,
388
+ position: [4, 2.5, 3],
389
+ children: [segment.id],
390
+ })
391
+ return { walls, roof, segment: { ...segment, parentId: roof.id } }
392
+ }
393
+
394
+ function seedHouse(...levels: LevelNode[]) {
395
+ const building = BuildingNode.parse({ children: levels.map((level) => level.id) })
396
+ const { walls, roof, segment } = roofedRoomOn(sourceLevelId)
397
+ useScene.setState((state) => ({
398
+ nodes: {
399
+ ...state.nodes,
400
+ [sourceLevelId]: makeLevel(sourceLevelId, [...walls.map((wall) => wall.id), roof.id]),
401
+ ...Object.fromEntries(walls.map((wall) => [wall.id, wall])),
402
+ [roof.id]: roof,
403
+ [segment.id]: segment,
404
+ [building.id]: building,
405
+ ...Object.fromEntries(
406
+ levels.map((level) => [level.id, { ...level, parentId: building.id }]),
407
+ ),
408
+ },
409
+ }))
410
+ return { walls, roof }
411
+ }
412
+
413
+ test('walls pasted with the roof they carry: the roof lands on a new Roof level, in one undo step', () => {
414
+ const storey = LevelNode.parse({ level: 0 })
415
+ const { walls, roof } = seedHouse(storey)
416
+ const before = Object.keys(useScene.getState().nodes).length
417
+ expect(copySelectedNodesToEditorClipboard([...walls.map((wall) => wall.id), roof.id])).toBe(
418
+ true,
419
+ )
420
+ const { pastedIds } = pasteEditorClipboardToLevel(storey.id)!
421
+ const nodes = useScene.getState().nodes
422
+ const pastedRoof = pastedIds.map((id) => nodes[id]!).find((node) => node.type === 'roof')!
423
+ const roofLevel = nodes[pastedRoof.parentId as AnyNodeId] as LevelNode
424
+ expect(roofLevel).toMatchObject({ type: 'level', level: 1, metadata: { role: 'roof' } })
425
+ expect(roofLevel.children).toEqual([pastedRoof.id])
426
+ expect((nodes[storey.id] as LevelNode).children).toHaveLength(4)
427
+ useScene.temporal.getState().undo()
428
+ expect(Object.keys(useScene.getState().nodes)).toHaveLength(before)
429
+ })
430
+
431
+ test('walls pasted on the roof level go to the storey it covers, the selection with them', () => {
432
+ const storey = LevelNode.parse({ level: 0 })
433
+ const roofLevel = LevelNode.parse({ level: 1, metadata: { role: 'roof' } })
434
+ const { walls } = seedHouse(storey, roofLevel)
435
+ expect(copySelectedNodesToEditorClipboard([walls[0]!.id])).toBe(true)
436
+ const [pastedId] = pasteEditorClipboardToLevel(roofLevel.id)!.pastedIds
437
+ expect(useScene.getState().nodes[pastedId!]?.parentId).toBe(storey.id)
438
+ expect(useViewer.getState().selection.levelId).toBe(storey.id)
439
+ })
440
+
441
+ test('a retained roof pasted onto a roof level keeps its roof ownership and can be undone', () => {
442
+ const storey = LevelNode.parse({ level: 0 })
443
+ const roofLevel = LevelNode.parse({ level: 1, metadata: { role: 'roof' } })
444
+ seedHouse(storey, roofLevel)
445
+ const retained = ItemNode.parse({
446
+ parentId: sourceLevelId,
447
+ position: [1, 0.25, 2],
448
+ metadata: { role: 'roof' },
449
+ asset: {
450
+ id: 'retained',
451
+ name: 'Authored roof',
452
+ category: 'custom',
453
+ thumbnail: '',
454
+ src: '/roof.glb',
455
+ },
456
+ provenance: { refs: [{ ns: 'source', id: 'building/roof' }] },
457
+ })
458
+ useScene.getState().createNode(retained, sourceLevelId)
459
+ expect(copySelectedNodesToEditorClipboard([retained.id])).toBe(true)
460
+ const [pastedId] = pasteEditorClipboardToLevel(roofLevel.id)!.pastedIds
461
+ const pasted = useScene.getState().nodes[pastedId!] as typeof retained
462
+ expect(pasted.parentId).toBe(roofLevel.id)
463
+ expect(pasted.metadata.role).toBe('roof')
464
+ expect(pasted.asset).toEqual(retained.asset)
465
+ expect(pasted.provenance).toEqual(retained.provenance)
466
+ expect(useViewer.getState().selection.levelId).toBe(roofLevel.id)
467
+ useScene.temporal.getState().undo()
468
+ expect(useScene.getState().nodes[pastedId!]).toBeUndefined()
469
+ expect(useScene.getState().nodes[retained.id]).toBeDefined()
470
+ })
471
+
472
+ // Measurements, cabinets and the like stayed on the roof level at its floor while the walls
473
+ // copied with them went down: a paste lands on one level.
474
+ test('a paste carrying walls lands whole on the storey a roof level covers', () => {
475
+ const storey = LevelNode.parse({ level: 0 })
476
+ const roofLevel = LevelNode.parse({ level: 1, metadata: { role: 'roof' } })
477
+ const { walls } = seedHouse(storey, roofLevel)
478
+ const measurement = MeasurementNode.parse({
479
+ parentId: sourceLevelId,
480
+ measurement: {
481
+ kind: 'distance',
482
+ points: [
483
+ [0, 0, 0],
484
+ [8, 0, 0],
485
+ ],
486
+ },
487
+ })
488
+ useScene.setState((state) => {
489
+ const source = state.nodes[sourceLevelId] as LevelNode
490
+ return {
491
+ nodes: {
492
+ ...state.nodes,
493
+ [measurement.id]: measurement,
494
+ [sourceLevelId]: { ...source, children: [...source.children, measurement.id] },
495
+ },
496
+ }
497
+ })
498
+ expect(copySelectedNodesToEditorClipboard([walls[0]!.id, measurement.id])).toBe(true)
499
+ const { pastedIds } = pasteEditorClipboardToLevel(roofLevel.id)!
500
+ const nodes = useScene.getState().nodes
501
+ expect(pastedIds.map((id) => nodes[id]?.parentId)).toEqual([storey.id, storey.id])
502
+ expect((nodes[roofLevel.id] as LevelNode).children).toEqual([])
503
+ })
504
+
505
+ // Pasted corner windows kept naming the originals, so the copies drew as plain windows that
506
+ // could not be joined while the originals stood.
507
+ test('a corner window pasted with both its walls joins its own copies; one side alone is plain', () => {
508
+ const south = WallNode.parse({
509
+ id: 'wall_clipboard-south',
510
+ parentId: sourceLevelId,
511
+ start: [0, 0],
512
+ end: [4, 0],
513
+ })
514
+ const east = WallNode.parse({
515
+ id: 'wall_clipboard-east',
516
+ parentId: sourceLevelId,
517
+ start: [4, 0],
518
+ end: [4, 3],
519
+ })
520
+ const onSouth = WindowNode.parse({
521
+ id: 'window_clipboard-south',
522
+ parentId: south.id,
523
+ wallId: south.id,
524
+ position: [3.4, 1.2, 0],
525
+ corner: { end: 'end', partnerId: 'window_clipboard-east', post: 'none' },
526
+ })
527
+ const onEast = WindowNode.parse({
528
+ id: 'window_clipboard-east',
529
+ parentId: east.id,
530
+ wallId: east.id,
531
+ position: [0.6, 1.2, 0],
532
+ corner: { end: 'start', partnerId: onSouth.id, post: 'none' },
533
+ })
534
+ useScene.setState((state) => ({
535
+ nodes: {
536
+ ...state.nodes,
537
+ [sourceLevelId]: makeLevel(sourceLevelId, [south.id, east.id]),
538
+ [south.id]: { ...south, children: [onSouth.id] },
539
+ [east.id]: { ...east, children: [onEast.id] },
540
+ [onSouth.id]: onSouth,
541
+ [onEast.id]: onEast,
542
+ },
543
+ }))
544
+ const windowsOf = (wallIds: readonly AnyNodeId[]) =>
545
+ wallIds.flatMap((id) =>
546
+ (useScene.getState().nodes[id] as WallNode).children.map(
547
+ (child) => useScene.getState().nodes[child as AnyNodeId] as WindowNode,
548
+ ),
549
+ )
550
+
551
+ expect(copySelectedNodesToEditorClipboard([south.id, east.id])).toBe(true)
552
+ const [a, b] = windowsOf(pasteEditorClipboardToLevel(targetLevelId)!.pastedIds)
553
+ expect(a?.corner?.partnerId).toBe(b?.id)
554
+ expect(b?.corner?.partnerId).toBe(a?.id)
555
+
556
+ expect(copySelectedNodesToEditorClipboard([south.id])).toBe(true)
557
+ const [alone] = windowsOf(pasteEditorClipboardToLevel(targetLevelId)!.pastedIds)
558
+ expect(alone?.corner).toBeUndefined()
559
+ })
560
+
369
561
  test('a paste keeps collection membership on both sides in one undo step, or drops collections the scene lacks', () => {
370
562
  const collectionId = 'collection_clipboard-lighting' as CollectionId
371
563
  const lamp = ItemNode.parse({
@@ -552,6 +744,59 @@ describe('scene clipboard', () => {
552
744
  }
553
745
  })
554
746
 
747
+ test("a room copied to another project brings its floor script's artifacts", async () => {
748
+ let systemClipboardText = ''
749
+ Object.defineProperty(navigator, 'clipboard', {
750
+ configurable: true,
751
+ value: {
752
+ readText: async () => systemClipboardText,
753
+ writeText: async (text: string) => {
754
+ systemClipboardText = text
755
+ },
756
+ },
757
+ })
758
+ const room = ZoneNode.parse({
759
+ id: 'zone_clipboard-floor-script',
760
+ parentId: sourceLevelId,
761
+ name: 'Hall',
762
+ polygon: [
763
+ [0, 0],
764
+ [3, 0],
765
+ [3, 3],
766
+ [0, 3],
767
+ ],
768
+ floor: {
769
+ script: {
770
+ kind: 'script',
771
+ script: 'e'.repeat(64),
772
+ artifact: 'f'.repeat(64),
773
+ inputs: 'key',
774
+ manifest: { bounds: { min: [0, 0, 0], max: [3, 0.01, 3] }, triangles: 2 },
775
+ },
776
+ },
777
+ })
778
+ useScene.setState((state) => ({
779
+ nodes: {
780
+ ...state.nodes,
781
+ [sourceLevelId]: makeLevel(sourceLevelId, [room.id]),
782
+ [room.id]: room,
783
+ },
784
+ }))
785
+ useViewer.getState().setProjectId('project_clipboard-source')
786
+ expect(copySelectedNodesToEditorClipboard([room.id])).toBe(true)
787
+ useViewer.getState().setProjectId('project_clipboard-target')
788
+ const copyFrom = mock(async (_projectId: string, _sha256s: string[]) => [] as string[])
789
+ const previousStore = getArtifactStore()
790
+ configureArtifactStore({ ...previousStore, copyFrom })
791
+ try {
792
+ await pasteSystemEditorClipboardToLevel(targetLevelId)
793
+ expect(new Set(copyFrom.mock.calls[0]![1])).toEqual(new Set(['e'.repeat(64), 'f'.repeat(64)]))
794
+ } finally {
795
+ configureArtifactStore(previousStore)
796
+ useViewer.getState().setProjectId(null)
797
+ }
798
+ })
799
+
555
800
  test('navigation during artifact copy does not paste into the next project', async () => {
556
801
  copyScriptedWindow()
557
802
  const previousStore = getArtifactStore()