@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
@@ -0,0 +1,284 @@
1
+ import { type CameraPose, criticallyDamped } from '@pascal-app/core'
2
+
3
+ /**
4
+ * Follow Pascal: the camera follows what the agent is building (the owner, 2026-10-08). It eases to
5
+ * what the current step works on, at the distance its scale needs; stays about 1.5 s on each target at
6
+ * least; frames what lands together as one; keeps the horizon level (it moves as an orbit about the
7
+ * thing it looks at, never as a free flight); and moves like a critically damped spring.
8
+ *
9
+ * Everything here is arithmetic on boxes and angles, so it is tested without a canvas; the component
10
+ * that feeds it the reveal's events and writes its poses to the camera is `FollowPascal`.
11
+ */
12
+
13
+ export type Vec3 = [number, number, number]
14
+ export type Box = { min: Vec3; max: Vec3 }
15
+
16
+ /** What kind of thing a step works on decides how far and from how high it is looked at. */
17
+ export type FollowKind = 'floor' | 'detail' | 'roof' | 'furnish' | 'group'
18
+
19
+ export type FollowWant = {
20
+ box: Box
21
+ kind: FollowKind
22
+ levelId?: string
23
+ nodeIds?: readonly string[]
24
+ /** The outward direction of the wall it is on, in the plan (x, z), for what is looked at square on. */
25
+ normal?: [number, number]
26
+ /** The eye must stand above this height (metres): a roof lifted out of the way is looked down through, not from within. */
27
+ eyeAbove?: number
28
+ /**
29
+ * What it works on is seen from outside (an exterior wall bent, a window moved on one, a roof, the
30
+ * ground): the view is from outdoors with no cutaway, whatever hides the target.
31
+ */
32
+ exterior?: boolean
33
+ /** When it was asked for, in seconds on the follower's clock. */
34
+ at: number
35
+ }
36
+
37
+ /** The camera stays on a target at least this long. */
38
+ export const FOLLOW_DWELL_S = 1.5
39
+ /** What lands within this of the first is framed with it. */
40
+ export const FOLLOW_GATHER_S = 0.35
41
+ /** How long a flight takes: a spring's response, not a duration. */
42
+ export const FOLLOW_RESPONSE_S = 1.1
43
+
44
+ /** The eye's elevation above the horizon for each kind, in radians. */
45
+ export const FOLLOW_ELEVATION: Record<FollowKind, number> = {
46
+ floor: 0.5,
47
+ detail: 0.27,
48
+ roof: 0.61,
49
+ furnish: 0.92,
50
+ group: 0.45,
51
+ }
52
+
53
+ const centreOf = ({ min, max }: Box): Vec3 => [
54
+ (min[0] + max[0]) / 2,
55
+ (min[1] + max[1]) / 2,
56
+ (min[2] + max[2]) / 2,
57
+ ]
58
+ const sizeOf = ({ min, max }: Box): Vec3 => [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
59
+
60
+ /** What lands together, as one target: the union of their boxes, a common direction if they share one. */
61
+ export function mergeWants(wants: readonly FollowWant[]): FollowWant {
62
+ const first = wants[0]!
63
+ const min: Vec3 = [...first.box.min]
64
+ const max: Vec3 = [...first.box.max]
65
+ for (const { box } of wants) {
66
+ for (let axis = 0; axis < 3; axis += 1) {
67
+ min[axis] = Math.min(min[axis]!, box.min[axis]!)
68
+ max[axis] = Math.max(max[axis]!, box.max[axis]!)
69
+ }
70
+ }
71
+ const kind = wants.every((want) => want.kind === first.kind) ? first.kind : 'group'
72
+ let normal: [number, number] | undefined
73
+ if (wants.every((want) => want.normal)) {
74
+ const sum: [number, number] = [0, 0]
75
+ for (const want of wants) {
76
+ sum[0] += want.normal![0]
77
+ sum[1] += want.normal![1]
78
+ }
79
+ const length = Math.hypot(sum[0], sum[1])
80
+ // They share a direction only when they mostly agree: the length of the sum over their count.
81
+ if (length / wants.length > 0.6) normal = [sum[0] / length, sum[1] / length]
82
+ }
83
+ const eyes = wants.flatMap((want) => (want.eyeAbove === undefined ? [] : [want.eyeAbove]))
84
+ const nodeIds = [...new Set(wants.flatMap((want) => want.nodeIds ?? []))]
85
+ return {
86
+ box: { min, max },
87
+ kind,
88
+ ...(first.levelId ? { levelId: first.levelId } : {}),
89
+ ...(nodeIds.length ? { nodeIds } : {}),
90
+ ...(normal ? { normal } : {}),
91
+ ...(eyes.length ? { eyeAbove: Math.max(...eyes) } : {}),
92
+ // Seen from outside when any of what lands together is: a mixed step takes the outside view.
93
+ ...(wants.some((want) => want.exterior) ? { exterior: true } : {}),
94
+ at: Math.max(...wants.map((want) => want.at)),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Decides when the camera changes target. Wants are gathered for a moment so things landing together
100
+ * are framed as one, and the camera never leaves a target before its dwell is over.
101
+ */
102
+ export class Follower {
103
+ private pending: FollowWant[] = []
104
+ private last = Number.NEGATIVE_INFINITY
105
+
106
+ want(want: FollowWant): void {
107
+ this.pending.push(want)
108
+ }
109
+
110
+ /** The target to fly to now, if one is due. `blocked` holds it (the person is pointing at something). */
111
+ due(now: number, blocked = false): FollowWant | null {
112
+ const oldest = this.pending[0]
113
+ if (!oldest || blocked) return null
114
+ if (now - this.last < FOLLOW_DWELL_S || now - oldest.at < FOLLOW_GATHER_S) return null
115
+ const nextFloor = this.pending.findIndex((want) => want.levelId !== oldest.levelId)
116
+ const count = nextFloor < 0 ? this.pending.length : nextFloor
117
+ const merged = mergeWants(this.pending.splice(0, count))
118
+ this.last = now
119
+ return merged
120
+ }
121
+
122
+ /** Forgets what waits and starts its dwell over (a new build; the person took the camera back). */
123
+ reset(): void {
124
+ this.pending = []
125
+ this.last = Number.NEGATIVE_INFINITY
126
+ }
127
+ }
128
+
129
+ export type OrbitValue = { x: number; y: number; z: number; r: number; az: number; el: number }
130
+
131
+ export type FollowView = {
132
+ /** The camera's vertical field of view, in degrees. */
133
+ fov: number
134
+ /** The canvas' width over its height. */
135
+ aspect: number
136
+ /** Where the camera looks from now, so a flight takes the nearest way round. */
137
+ azimuth: number
138
+ }
139
+
140
+ const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
141
+ const TAU = Math.PI * 2
142
+
143
+ /** The angle `to` written as the nearest equivalent of `from`, so it never goes the long way round. */
144
+ export function nearestTurn(to: number, from: number): number {
145
+ let angle = to
146
+ while (angle - from > Math.PI) angle -= TAU
147
+ while (angle - from < -Math.PI) angle += TAU
148
+ return angle
149
+ }
150
+
151
+ /**
152
+ * Where the camera stands to see `want`: the box's centre, its scale decides how far (a window up
153
+ * close, a floor whole, a group between) at the elevation of its kind, from the side nearest the way
154
+ * it already looks, or square to the wall it is on.
155
+ */
156
+ export function frameOf(want: FollowWant, view: FollowView): OrbitValue {
157
+ const [cx, cy, cz] = centreOf(want.box)
158
+ const [sx, sy, sz] = sizeOf(want.box)
159
+ const detail = want.kind === 'detail'
160
+ const extent = Math.max(sx, sz, sy * 1.4, detail ? 1.2 : 3)
161
+ const halfVertical = (view.fov * Math.PI) / 360
162
+ const halfHorizontal = Math.atan(Math.tan(halfVertical) * Math.max(0.5, view.aspect))
163
+ const fits = (extent * 0.5) / Math.tan(Math.min(halfVertical, halfHorizontal))
164
+ let r = clamp(fits * 1.25 + sy * 0.3, detail ? 2.8 : 6, 90)
165
+ const el = FOLLOW_ELEVATION[want.kind]
166
+ const eyeY = Math.max(cy, 0.9) + r * Math.sin(el)
167
+ if (want.eyeAbove !== undefined && eyeY < want.eyeAbove) {
168
+ // Further back along the same line of sight, until the eye is above what it must clear.
169
+ r = (want.eyeAbove - Math.max(cy, 0.9)) / Math.sin(el)
170
+ }
171
+ let az = view.azimuth
172
+ if (want.normal) {
173
+ const facing = Math.atan2(want.normal[0], want.normal[1])
174
+ // A little off square, to the side it is already seen from.
175
+ const [left, right] = [facing + 0.5, facing - 0.5]
176
+ az =
177
+ Math.abs(nearestTurn(left, view.azimuth) - view.azimuth) <=
178
+ Math.abs(nearestTurn(right, view.azimuth) - view.azimuth)
179
+ ? left
180
+ : right
181
+ }
182
+ return {
183
+ x: cx,
184
+ y: want.kind === 'roof' ? cy - 0.6 : Math.max(cy, 0.9),
185
+ z: cz,
186
+ r,
187
+ az: nearestTurn(az, view.azimuth),
188
+ el,
189
+ }
190
+ }
191
+
192
+ const KEYS = ['x', 'y', 'z', 'r', 'az', 'el'] as const
193
+
194
+ function orbitPose({ x, y, z, r, az, el }: OrbitValue): CameraPose {
195
+ const flat = Math.cos(el)
196
+ return {
197
+ position: [x + r * flat * Math.sin(az), y + r * Math.sin(el), z + r * flat * Math.cos(az)],
198
+ target: [x, y, z],
199
+ projection: 'perspective',
200
+ }
201
+ }
202
+
203
+ /** Try a higher eye before turning away; never reorient an already clear view. */
204
+ export function clearFrame(
205
+ goal: OrbitValue,
206
+ sees: (pose: CameraPose) => boolean,
207
+ ): OrbitValue | null {
208
+ for (const offset of [
209
+ 0,
210
+ Math.PI / 6,
211
+ -Math.PI / 6,
212
+ Math.PI / 3,
213
+ -Math.PI / 3,
214
+ Math.PI / 2,
215
+ -Math.PI / 2,
216
+ ]) {
217
+ for (const elevation of [goal.el, 0.9, 1.2, (75 * Math.PI) / 180]) {
218
+ const candidate = { ...goal, az: goal.az + offset, el: Math.max(goal.el, elevation) }
219
+ if (sees(orbitPose(candidate))) return candidate
220
+ }
221
+ }
222
+ return null
223
+ }
224
+
225
+ /** The camera as six springs on an orbit: the point it looks at, how far, and which way round. */
226
+ export class OrbitSpring {
227
+ private states = Object.fromEntries(KEYS.map((key) => [key, { x: 0, v: 0 }])) as Record<
228
+ (typeof KEYS)[number],
229
+ { x: number; v: number }
230
+ >
231
+ private goal: OrbitValue
232
+
233
+ constructor(
234
+ start: OrbitValue,
235
+ private response = FOLLOW_RESPONSE_S,
236
+ ) {
237
+ this.goal = { ...start }
238
+ this.snap(start)
239
+ }
240
+
241
+ snap(value: OrbitValue): void {
242
+ this.goal = { ...value }
243
+ for (const key of KEYS) this.states[key] = { x: value[key], v: 0 }
244
+ }
245
+
246
+ /** Flies to `value`, from where it is, carrying its speed. */
247
+ to(value: OrbitValue): void {
248
+ this.goal = { ...value, az: nearestTurn(value.az, this.states.az.x) }
249
+ }
250
+
251
+ step(dt: number): void {
252
+ for (const key of KEYS) criticallyDamped(this.states[key], this.goal[key], this.response, dt)
253
+ }
254
+
255
+ value(): OrbitValue {
256
+ return Object.fromEntries(KEYS.map((key) => [key, this.states[key].x])) as OrbitValue
257
+ }
258
+
259
+ /** The orbit read off a camera that is already somewhere. */
260
+ fromPose(pose: CameraPose): void {
261
+ const dx = pose.position[0] - pose.target[0]
262
+ const dy = pose.position[1] - pose.target[1]
263
+ const dz = pose.position[2] - pose.target[2]
264
+ const r = Math.hypot(dx, dy, dz) || 1
265
+ this.snap({
266
+ x: pose.target[0],
267
+ y: pose.target[1],
268
+ z: pose.target[2],
269
+ r,
270
+ az: Math.atan2(dx, dz),
271
+ el: Math.asin(clamp(dy / r, -1, 1)),
272
+ })
273
+ }
274
+
275
+ /** Where the camera is now: at the radius and angles about its centre, always looking at it, level. */
276
+ pose(): CameraPose {
277
+ return orbitPose(this.value())
278
+ }
279
+
280
+ /** The goal it is flying to. */
281
+ destination(): OrbitValue {
282
+ return { ...this.goal }
283
+ }
284
+ }
@@ -3,18 +3,24 @@ import {
3
3
  type AnyNode,
4
4
  type AnyNodeDefinition,
5
5
  type AnyNodeId,
6
+ BuildingNode,
6
7
  beginSceneHistoryPauseSession,
7
8
  CabinetModuleNode,
8
9
  CabinetNode,
9
10
  findLevelAncestorId,
10
11
  getSceneHistoryPauseDepth,
12
+ type LevelNode,
11
13
  nodeRegistry,
12
14
  pauseSceneHistory,
15
+ RoofNode,
16
+ RoofSegmentNode,
13
17
  registerNode,
14
18
  resumeSceneHistory,
15
19
  type SceneCommit,
16
20
  subscribeSceneCommits,
17
21
  useScene,
22
+ WallNode,
23
+ WindowNode,
18
24
  } from '@pascal-app/core'
19
25
  import { z } from 'zod'
20
26
  import {
@@ -223,6 +229,102 @@ describe('commitFreshPlacementSubtree', () => {
223
229
  expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
224
230
  })
225
231
 
232
+ // A roof lives on the level above the walls that carry it: one dropped onto them (a duplicate
233
+ // in hand) goes there in the placement's one step.
234
+ test('a roof dropped onto the walls that carry it lands on a new Roof level, in the one step', () => {
235
+ const corners: [number, number][] = [
236
+ [0, 0],
237
+ [8, 0],
238
+ [8, 6],
239
+ [0, 6],
240
+ ]
241
+ const walls = corners.map((start, i) =>
242
+ WallNode.parse({ parentId: LEVEL_ID, start, end: corners[(i + 1) % 4], height: 2.5 }),
243
+ )
244
+ const segment = RoofSegmentNode.parse({ width: 8, depth: 6, wallHeight: 0 })
245
+ const draft = RoofNode.parse({
246
+ parentId: LEVEL_ID,
247
+ visible: false,
248
+ metadata: { isNew: true },
249
+ children: [segment.id],
250
+ })
251
+ const building = BuildingNode.parse({ children: [LEVEL_ID] })
252
+ useScene.setState({
253
+ nodes: {
254
+ [building.id]: building,
255
+ [LEVEL_ID]: {
256
+ ...level([...walls.map((wall) => wall.id), draft.id]),
257
+ parentId: building.id,
258
+ height: 2.5,
259
+ },
260
+ ...Object.fromEntries(walls.map((wall) => [wall.id, wall])),
261
+ [draft.id]: draft,
262
+ [segment.id]: { ...segment, parentId: draft.id },
263
+ },
264
+ rootNodeIds: [building.id],
265
+ collections: {},
266
+ dirtyNodes: new Set(),
267
+ } as never)
268
+ useScene.temporal.getState().clear()
269
+
270
+ const committedId = commitFreshPlacementSubtree(draft.id, {
271
+ position: [4, 2.5, 3],
272
+ visible: true,
273
+ } as Partial<AnyNode>)!
274
+ const nodes = useScene.getState().nodes
275
+ const roofLevel = nodes[nodes[committedId]!.parentId as AnyNodeId] as LevelNode
276
+ expect(roofLevel).toMatchObject({ type: 'level', metadata: { role: 'roof' } })
277
+ expect(roofLevel.children as string[]).toEqual([committedId])
278
+ expect((nodes[LEVEL_ID] as LevelNode).children as string[]).not.toContain(committedId)
279
+ expect(useScene.getState().rootNodeIds).toEqual([building.id])
280
+
281
+ useScene.temporal.getState().undo()
282
+ expect(useScene.getState().nodes[committedId]).toBeUndefined()
283
+ expect(useScene.getState().nodes[roofLevel.id]).toBeUndefined()
284
+ })
285
+
286
+ test('a window joined at a corner while in hand stays joined: its partner names the committed window', () => {
287
+ const wall = WallNode.parse({ id: 'wall_a', parentId: LEVEL_ID, start: [4, 0], end: [0, 0] })
288
+ const other = WallNode.parse({ id: 'wall_b', parentId: LEVEL_ID, start: [0, 0], end: [0, 3] })
289
+ const draft = WindowNode.parse({
290
+ id: 'window_draft',
291
+ parentId: wall.id,
292
+ wallId: wall.id,
293
+ position: [3.5, 1.5, 0],
294
+ width: 1,
295
+ visible: false,
296
+ metadata: { isNew: true },
297
+ corner: { end: 'end', partnerId: 'window_partner', post: 'none' },
298
+ })
299
+ const partner = WindowNode.parse({
300
+ id: 'window_partner',
301
+ parentId: other.id,
302
+ wallId: other.id,
303
+ position: [0.5, 1.5, 0],
304
+ width: 1,
305
+ corner: { end: 'start', partnerId: draft.id, post: 'none' },
306
+ })
307
+ useScene.setState({
308
+ nodes: {
309
+ [LEVEL_ID]: level([wall.id as AnyNodeId, other.id as AnyNodeId]),
310
+ [wall.id]: { ...wall, children: [draft.id] } as AnyNode,
311
+ [other.id]: { ...other, children: [partner.id] } as AnyNode,
312
+ [draft.id]: draft as AnyNode,
313
+ [partner.id]: partner as AnyNode,
314
+ },
315
+ rootNodeIds: [LEVEL_ID],
316
+ collections: {},
317
+ dirtyNodes: new Set(),
318
+ } as never)
319
+
320
+ const committedId = commitFreshPlacementSubtree(draft.id as AnyNodeId, { visible: true })
321
+
322
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
323
+ expect(committedId).toBeTruthy()
324
+ expect((nodes[committedId!] as WindowNode).corner?.partnerId).toBe(partner.id)
325
+ expect((nodes[partner.id] as WindowNode).corner?.partnerId).toBe(committedId!)
326
+ })
327
+
226
328
  test("never records inside another owner's pause", () => {
227
329
  pauseSceneHistory(useScene)
228
330
  try {
@@ -280,6 +382,16 @@ describe('commitFreshPlacementSubtree', () => {
280
382
  expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
281
383
  })
282
384
 
385
+ // A copy of one side of a corner window kept naming the original's partner, which never named it
386
+ // back: a link that made the copy refuse to join another window.
387
+ test('a copy of one side of a corner window is a plain window', () => {
388
+ const side = WindowNode.parse({
389
+ corner: { end: 'end', partnerId: 'window_partner', post: 'none' },
390
+ })
391
+ expect((prepareFreshPlacementRootDuplicate(side) as WindowNode).corner).toBeUndefined()
392
+ expect(side.corner?.partnerId).toBe('window_partner')
393
+ })
394
+
283
395
  test('commits a duplicated cabinet draft without deleting the original modules', () => {
284
396
  seedCabinetRun()
285
397
  useScene.temporal.getState().clear()
@@ -8,14 +8,17 @@ import {
8
8
  collectSubtree,
9
9
  createSceneApi,
10
10
  type DuplicableConfig,
11
+ getEffectiveNode,
11
12
  getSceneHistoryPauseDepth,
12
13
  getSurfaceProvider,
13
14
  nodeRegistry,
14
15
  resolveSurfacePlacement,
15
16
  runSceneHistoryDraftWrite,
16
17
  type SurfaceRejectReason,
18
+ seatRoofs,
17
19
  useScene,
18
20
  } from '@pascal-app/core'
21
+ import { remapNodeReferences } from '@pascal-app/core/clone-scene-graph'
19
22
  import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
20
23
  import useInteractionScope from '../store/use-interaction-scope'
21
24
  import usePlacementPreview from '../store/use-placement-preview'
@@ -39,6 +42,47 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined {
39
42
  return parentId ?? undefined
40
43
  }
41
44
 
45
+ /**
46
+ * The nodes the draft root names, outside its subtree and other than its parent, that name it
47
+ * back (a corner window's partner, joined while the draft was in hand). The commit gives the
48
+ * root a new id, so they are rewritten to name the committed node, never the deleted draft.
49
+ */
50
+ function peerReferenceUpdates(
51
+ nodes: Readonly<Record<string, AnyNode>>,
52
+ root: AnyNode,
53
+ subtreeIds: ReadonlySet<string>,
54
+ committedId: AnyNodeId,
55
+ ): { id: AnyNodeId; data: Partial<AnyNode> }[] {
56
+ const named = new Set<string>()
57
+ const visit = (value: unknown) => {
58
+ if (typeof value === 'string') {
59
+ if (nodes[value] && !subtreeIds.has(value) && value !== root.parentId) named.add(value)
60
+ } else if (Array.isArray(value)) value.forEach(visit)
61
+ else if (value && typeof value === 'object') Object.values(value).forEach(visit)
62
+ }
63
+ const {
64
+ id: _id,
65
+ parentId: _parentId,
66
+ children: _children,
67
+ ...fields
68
+ } = root as AnyNode & {
69
+ children?: unknown
70
+ }
71
+ visit(fields)
72
+ const ids = new Map([[root.id, committedId as string]])
73
+ return [...named].flatMap((id) => {
74
+ const peer = nodes[id]!
75
+ const remapped = remapNodeReferences(peer, ids) as Record<string, unknown>
76
+ const data = Object.fromEntries(
77
+ Object.entries(remapped).filter(
78
+ ([key, value]) =>
79
+ JSON.stringify(value) !== JSON.stringify((peer as Record<string, unknown>)[key]),
80
+ ),
81
+ )
82
+ return Object.keys(data).length ? [{ id: id as AnyNodeId, data: data as Partial<AnyNode> }] : []
83
+ })
84
+ }
85
+
42
86
  /**
43
87
  * Create ops for a clone of scene nodes: each copy joins the collections its
44
88
  * source is in, whatever its kind.
@@ -80,6 +124,8 @@ export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
80
124
  metadata?: unknown
81
125
  }
82
126
  delete duplicate.id
127
+ // A copy of one side of a corner window is a plain window: its partner stays with the original.
128
+ delete duplicate.corner
83
129
  if (Array.isArray(duplicate.children)) duplicate.children = []
84
130
  duplicate.metadata = {
85
131
  ...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
@@ -255,7 +301,9 @@ export function commitFreshPlacementSubtree(
255
301
  onReject?.(rejection)
256
302
  return null
257
303
  }
258
- const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
304
+ const descendants = subtree.descendants.map((node) =>
305
+ cleanPlacementMetadata(getEffectiveNode(node)),
306
+ )
259
307
  const parentId = parentIdOf(root)
260
308
  const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId })
261
309
  // The drafts' memberships, read before they are deleted.
@@ -269,6 +317,12 @@ export function commitFreshPlacementSubtree(
269
317
  if (previous === -1) updates.push(merged)
270
318
  else updates[previous] = merged
271
319
  }
320
+ const subtreeIds = new Set([rootId, ...descendants.map((node) => node.id)])
321
+ for (const update of peerReferenceUpdates(scene.nodes, root, subtreeIds, cloned.rootId))
322
+ if (!updates.some((entry) => entry.id === update.id)) updates.push(update)
323
+
324
+ // A roof dropped onto the walls that carry it goes to the level above them, in the same step.
325
+ const seated = seatRoofs(scene.nodes, { create, update: updates, delete: [rootId] }).changes
272
326
 
273
327
  const scope = useInteractionScope.getState()
274
328
  const factoryCreated =
@@ -282,13 +336,13 @@ export function commitFreshPlacementSubtree(
282
336
  useScene.getState().deleteNode(rootId)
283
337
  })
284
338
  recordPlacementStep(rootId, wasTracking, () =>
285
- useScene.getState().applyNodeChanges({ create, update: updates }),
339
+ useScene.getState().applyNodeChanges({ create: seated.create, update: seated.update }),
286
340
  )
287
341
  } else {
288
342
  recordPlacementStep(rootId, wasTracking, () => {
289
343
  try {
290
344
  // applyNodeChanges validates the complete proposed graph before publishing any part of it.
291
- scene.applyNodeChanges({ delete: [rootId], create, update: updates })
345
+ scene.applyNodeChanges(seated)
292
346
  for (const node of [subtree.root, ...subtree.descendants]) scene.clearDirty(node.id)
293
347
  } catch (error) {
294
348
  // Zustand publishes before notifying subscribers. A subscriber error must restore
@@ -54,6 +54,7 @@ export async function compileAndStoreGeometryScript(input: {
54
54
  nodeId?: string
55
55
  params?: Record<string, GeometryScriptParamValue>
56
56
  kind?: 'item' | 'door' | 'window' | 'column'
57
+ inputs?: Record<string, unknown>
57
58
  name?: string
58
59
  description?: string
59
60
  category?: string
@@ -80,7 +81,11 @@ export async function compileAndStoreGeometryScript(input: {
80
81
  if (!blenderBuilder) throw new Error('This editor cannot run Blender (bpy) scripts')
81
82
  return { ...(await blenderBuilder({ code, params, nodeId, metadata })), nodeId }
82
83
  }
83
- const { glb, ...compiled } = await compileGeometryScriptInWorker({ code, params })
84
+ const { glb, ...compiled } = await compileGeometryScriptInWorker({
85
+ code,
86
+ params,
87
+ inputs: input.inputs,
88
+ })
84
89
  const context = { nodeId, metadata: { ...metadata, mount: compiled.mount } }
85
90
  const store = getArtifactStore()
86
91
  const [sha256] = await Promise.all([
@@ -138,10 +138,17 @@ function startSandbox(): Promise<Window> {
138
138
  export async function compileGeometryScriptInWorker(input: {
139
139
  code: string
140
140
  params?: Record<string, GeometryScriptParamValue>
141
+ /** What the host builds it for (a room's floor), in the host's frame. */
142
+ inputs?: Record<string, unknown>
141
143
  }): Promise<GeometryScriptCompileOutput> {
142
144
  const target = await startSandbox()
143
145
  const id = nextId++
144
- const request: GeometryScriptWorkerRequest = { id, code: input.code, params: input.params }
146
+ const request: GeometryScriptWorkerRequest = {
147
+ id,
148
+ code: input.code,
149
+ params: input.params,
150
+ inputs: input.inputs,
151
+ }
145
152
  return new Promise((resolve, reject) => {
146
153
  const timeout = setTimeout(() => {
147
154
  reset(new Error(`The script ran longer than ${COMPILE_TIMEOUT_MS / 1000} s and was stopped`))