@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
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @pascal-app/editor
2
+
3
+ The Pascal building editor as a React component: tools, panels, the floor plan, views and the agent chat surfaces, composed over `@pascal-app/viewer`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes
9
+ ```
10
+
11
+ Load the built-in plugin once before mounting, as for the viewer (see the [viewer quick start](../viewer/README.md#usage)).
12
+
13
+ ## Usage
14
+
15
+ ```tsx
16
+ import { Editor } from '@pascal-app/editor'
17
+
18
+ export function ProjectPage({ projectId }: { projectId: string }) {
19
+ return (
20
+ <div className="dark" style={{ width: '100vw', height: '100vh' }}>
21
+ <Editor
22
+ layoutVersion="v2"
23
+ projectId={projectId}
24
+ onLoad={() => loadScene(projectId)}
25
+ onSave={(scene) => saveScene(projectId, scene)}
26
+ actionMenuPlacement="top"
27
+ loadingSlot={<MyProjectSpinner />}
28
+ onLoaderChange={(visible) => track('editor-loading', visible)}
29
+ />
30
+ </div>
31
+ )
32
+ }
33
+ ```
34
+
35
+ `apps/editor/components/scene-loader.tsx` is a complete host: navbar, sidebar tabs, toolbars and persistence.
36
+
37
+ ## Host props
38
+
39
+ | Prop | What it does |
40
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
41
+ | `loadingSlot` | Your loading surface, shown in the canvas panes of both layouts and in 2D and 3D while the scene loads. The editor still decides when loading is over and fires `onLoaderChange`; a failed load always shows the editor's error and retry screen. Omit it or pass `null` for the default loader. |
42
+ | `actionMenuPlacement` | `'bottom'` (default) or `'top'`. With `'top'`, the v2 layout gives the tool menu its own row under the toolbar, centred on the scene panes and following them in split, so the bottom strip is free; v1 floats it at the top edge. Mobile keeps the bottom rail. |
43
+ | `viewBar` | Each pane's view bar (v2, default `true`). Turn it off for a host that shows one fixed view. |
44
+ | `sceneOverlay` | Chrome over the 3D and 2D panes with the level selector and tool dock, hidden while no scene view is on screen. |
45
+ | `stageOverlay` | A full-bleed surface over the whole stage; the scene stays mounted underneath. To show something beside the scene, register a view instead. |
46
+
47
+ ## Theme
48
+
49
+ The editor sets no theme of its own: its chrome follows the nearest `.dark` ancestor, so leave the class off for a light editor. Menus and tooltips portal to `<body>`, so put `dark` there too (or on `<html>`) for a dark one. Hand the person a theme switch with `InterfaceThemeRow` in the settings panel's `preferences` slot:
50
+
51
+ ```tsx
52
+ import { InterfaceThemeRow, SettingsPanel, type InterfaceTheme } from '@pascal-app/editor'
53
+
54
+ <SettingsPanel
55
+ preferences={<InterfaceThemeRow value={theme} onChange={(next: InterfaceTheme) => setTheme(next)} />}
56
+ />
57
+ ```
58
+
59
+ `theme` is `'system' | 'light' | 'dark'`; applying it (toggling `.dark`) is the host's. A host that passes no row gets none. The Preview stage, the walkthrough HUD and the controls bar stay dark in either theme. The viewer's own "Scene theme" is separate (see [materials and themes](../../wiki/architecture/materials-and-themes.md)).
60
+
61
+ ## Views
62
+
63
+ The stage is one or two panes, each showing a view: 3D, 2D, or one a plugin registers.
64
+
65
+ ```tsx
66
+ import { openEditorView, PLUGINS_VIEW_ID, registerEditorHostView } from '@pascal-app/editor'
67
+
68
+ registerEditorHostView({
69
+ id: 'acme:signage:schedule',
70
+ label: 'Sign schedule',
71
+ icon: { kind: 'url', src: '/icons/signs.webp' },
72
+ component: () => import('./schedule-view'),
73
+ pluginId: 'acme:signage',
74
+ })
75
+
76
+ openEditorView('acme:signage:schedule', { beside: true }) // split, next to the scene
77
+ openEditorView(PLUGINS_VIEW_ID) // the plugin directory
78
+ ```
79
+
80
+ Each workspace keeps its own layout, and swapping panes never remounts the 3D canvas or the plan. `\` toggles split. The full contract is in [plugin authoring](../../wiki/architecture/plugin-authoring.md).
81
+
82
+ ## Selecting and showing a node
83
+
84
+ The helpers the editor's own lists use:
85
+
86
+ ```ts
87
+ import { enterUnitFocus, frameViewerNode, selectZoneOrRoom } from '@pascal-app/editor'
88
+
89
+ selectZoneOrRoom(zoneId) // 'room' for an enclosed room, else 'zone'
90
+ frameViewerNode(nodeId, source) // the node's saved view, else its box; the plan frames it when no 3D view is on screen
91
+ enterUnitFocus(unitId) // focus a unit, as the site panel does
92
+ ```
93
+
94
+ What a host's chat points at (`useViewer.pointedIds`) lights in the plan and as a room outline, as well as in 3D. See [selection managers](../../wiki/architecture/selection-managers.md) and [point and ask](../../wiki/architecture/point-and-ask.md).
95
+
96
+ ## Plugin chat surfaces
97
+
98
+ A plugin's `PluginChatExtension` can give a host's chat a start screen (`start`), shown while the thread is empty and acting through `ChatStartProps` (`send`, `attach`, `requirePro`). `IconRefGlyph` and `IconRefImage` render registry icons the way the editor does:
99
+
100
+ ```tsx
101
+ import { IconRefGlyph } from '@pascal-app/editor'
102
+
103
+ <IconRefGlyph icon={view.icon} />
104
+ ```
105
+
106
+ See [plugin authoring, agent tools and chat extensions](../../wiki/architecture/plugin-authoring.md#agent-tools-and-chat-extensions).
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Pascal building editor component",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.tsx",
9
9
  "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx",
10
- "./procedural-items": "./src/components/procedural-items/inspector.tsx"
10
+ "./procedural-items": "./src/components/procedural-items/inspector.tsx",
11
+ "./distribution": "./src/components/tools/distribution/distribution-public.ts"
11
12
  },
12
13
  "scripts": {
14
+ "bench:room-labels": "bun run src/components/editor/__bench__/room-label-placement.bench.ts",
13
15
  "check-types": "tsgo --noEmit",
14
16
  "test": "bun test src"
15
17
  },
16
18
  "peerDependencies": {
17
- "@pascal-app/core": "^1.0.4",
18
- "@pascal-app/viewer": "^1.0.4",
19
+ "@pascal-app/core": "^1.0.6",
20
+ "@pascal-app/viewer": "^1.0.6",
19
21
  "@react-three/drei": "^10",
20
22
  "@react-three/fiber": "^9",
21
23
  "next": ">=15",
@@ -27,23 +29,23 @@
27
29
  "@dnd-kit/core": "^6.3.1",
28
30
  "@dnd-kit/sortable": "^10.0.0",
29
31
  "@dnd-kit/utilities": "^3.2.2",
30
- "@iconify/react": "^6.0.2",
31
- "@number-flow/react": "^0.6.0",
32
- "@pascal-app/geometry-script": "^1.0.4",
33
- "@pascal-app/ifc-converter": "^1.0.4",
34
- "@pascal-app/lingo": "^0.2.0",
35
- "@radix-ui/react-alert-dialog": "^1.1.15",
36
- "@radix-ui/react-context-menu": "^2.2.16",
37
- "@radix-ui/react-dialog": "^1.1.15",
38
- "@radix-ui/react-dropdown-menu": "^2.1.16",
39
- "@radix-ui/react-popover": "^1.1.15",
40
- "@radix-ui/react-select": "^2.2.6",
41
- "@radix-ui/react-separator": "^1.1.8",
42
- "@radix-ui/react-slider": "^1.3.6",
43
- "@radix-ui/react-slot": "^1.2.4",
44
- "@radix-ui/react-switch": "^1.2.6",
45
- "@radix-ui/react-tabs": "^1.1.13",
46
- "@radix-ui/react-tooltip": "^1.2.8",
32
+ "@iconify/react": "^6.0.3",
33
+ "@number-flow/react": "^0.6.2",
34
+ "@pascal-app/geometry-script": "^1.0.6",
35
+ "@pascal-app/ifc-converter": "^1.0.6",
36
+ "@pascal-app/lingo": "^0.5.0",
37
+ "@radix-ui/react-alert-dialog": "^1.2.1",
38
+ "@radix-ui/react-context-menu": "^2.4.1",
39
+ "@radix-ui/react-dialog": "^1.3.1",
40
+ "@radix-ui/react-dropdown-menu": "^2.2.1",
41
+ "@radix-ui/react-popover": "^1.3.1",
42
+ "@radix-ui/react-select": "^2.4.1",
43
+ "@radix-ui/react-separator": "^1.2.0",
44
+ "@radix-ui/react-slider": "^1.6.1",
45
+ "@radix-ui/react-slot": "^1.5.0",
46
+ "@radix-ui/react-switch": "^1.4.1",
47
+ "@radix-ui/react-tabs": "^1.2.1",
48
+ "@radix-ui/react-tooltip": "^1.4.1",
47
49
  "@visual-json/react": "^0.4.0",
48
50
  "blob-stream": "^0.1.3",
49
51
  "class-variance-authority": "^0.7.1",
@@ -51,32 +53,32 @@
51
53
  "cmdk": "^1.1.1",
52
54
  "fflate": "^0.8.3",
53
55
  "howler": "^2.2.4",
54
- "lucide-react": "^1.7.0",
55
- "manifold-3d": "3.5.1",
56
+ "lucide-react": "^1.61.0",
57
+ "manifold-3d": "3.5.4",
56
58
  "mitt": "^3.0.1",
57
- "motion": "^12.34.3",
58
- "nanoid": "^5.1.6",
59
- "pdfkit": "^0.19.1",
60
- "tailwind-merge": "^3.5.0",
59
+ "motion": "^14.6.0",
60
+ "nanoid": "^6.0.2",
61
+ "pdfkit": "^0.20.2",
62
+ "tailwind-merge": "^3.7.0",
61
63
  "three-bvh-csg": "^0.0.18",
62
- "three-mesh-bvh": "~0.9.8",
63
- "zod": ">=4.5.4 <4.6",
64
- "zustand": "^5.0.11"
64
+ "three-mesh-bvh": "~0.9.16",
65
+ "zod": ">=4.6.7 <4.7",
66
+ "zustand": "^5.0.16"
65
67
  },
66
68
  "devDependencies": {
67
- "@pascal-app/core": "^1.0.4",
68
- "@pascal-app/viewer": "^1.0.4",
69
+ "@pascal-app/core": "^1.0.6",
70
+ "@pascal-app/viewer": "^1.0.6",
69
71
  "@pascal/typescript-config": "*",
70
- "@react-three/test-renderer": "^9.1.0",
72
+ "@react-three/test-renderer": "^9.1.1",
71
73
  "@types/blob-stream": "^0.1.33",
72
- "@types/bun": "^1.4.2",
73
- "@types/howler": "^2.2.12",
74
- "@types/pdfkit": "^0.17.6",
74
+ "@types/bun": "^1.4.3",
75
+ "@types/howler": "^2.2.13",
76
+ "@types/pdfkit": "^0.17.7",
75
77
  "@types/react": "19.3.0",
76
78
  "@types/react-dom": "19.3.0",
77
- "@types/three": "^0.184.0",
79
+ "@types/three": "^0.186.0",
78
80
  "fast-xml-parser": "^5.11.2",
79
- "typescript": "6.0.3"
81
+ "typescript": "7.0.2"
80
82
  },
81
83
  "repository": {
82
84
  "type": "git",
@@ -0,0 +1,108 @@
1
+ import type { Polygon } from '@pascal-app/core'
2
+ import {
3
+ type PillCandidate,
4
+ placePills,
5
+ type RoomHit,
6
+ type RoomLabelEntry,
7
+ roomAt,
8
+ } from '../room-labels'
9
+
10
+ // One room-label placement pass and one pointer pick, for a 900 × 600 px house of 31, 60 and 120
11
+ // rooms (every third an L), printed as JSON medians in ms. Run with `bun run <this file>`.
12
+ let seed = 11
13
+ const random = () => {
14
+ seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31
15
+ return seed / 2 ** 31
16
+ }
17
+
18
+ function house(count: number) {
19
+ const columns = Math.ceil(Math.sqrt(count * 1.5))
20
+ const rows = Math.ceil(count / columns)
21
+ const width = 900 / columns
22
+ const depth = 600 / rows
23
+ return Array.from({ length: count }, (_, i) => {
24
+ const left = 270 + (i % columns) * width
25
+ const top = 150 + Math.floor(i / columns) * depth
26
+ const right = left + width
27
+ const bottom = top + depth
28
+ const outer: [number, number][] =
29
+ i % 3 === 0
30
+ ? [
31
+ [left, top],
32
+ [right, top],
33
+ [right, top + depth / 2],
34
+ [left + width / 2, top + depth / 2],
35
+ [left + width / 2, bottom],
36
+ [left, bottom],
37
+ ]
38
+ : [
39
+ [left, top],
40
+ [right, top],
41
+ [right, bottom],
42
+ [left, bottom],
43
+ ]
44
+ const outline: Polygon = { outer, holes: [] }
45
+ const pill = 60 + random() * 110
46
+ const candidate: PillCandidate = {
47
+ id: `room_${i}`,
48
+ x: left + width / 4,
49
+ y: top + depth / 4,
50
+ size: [pill, 24, pill * 0.6],
51
+ area: width * depth * (i % 3 === 0 ? 0.75 : 1),
52
+ outline,
53
+ }
54
+ const entry: RoomLabelEntry = {
55
+ zoneId: candidate.id,
56
+ polygon: outer,
57
+ holes: [],
58
+ area: candidate.area,
59
+ }
60
+ return { candidate, entry }
61
+ })
62
+ }
63
+
64
+ const median = (samples: number[]) => samples.sort((a, b) => a - b)[samples.length >> 1]!
65
+
66
+ const pane = { width: 1440, height: 900 }
67
+ const results: Record<string, unknown> = {}
68
+ for (const count of [31, 60, 120]) {
69
+ const rooms = house(count)
70
+ const candidates = rooms.map((room) => room.candidate)
71
+ const entries = rooms.map((room) => room.entry)
72
+ for (let i = 0; i < 20; i++) placePills(candidates, pane)
73
+ const cold: number[] = []
74
+ const warm: number[] = []
75
+ let previous = placePills(candidates, pane)
76
+ for (let i = 0; i < 100; i++) {
77
+ let started = performance.now()
78
+ placePills(candidates, pane)
79
+ cold.push(performance.now() - started)
80
+ started = performance.now()
81
+ previous = placePills(candidates, pane, previous)
82
+ warm.push(performance.now() - started)
83
+ }
84
+ const points = Array.from(
85
+ { length: 1000 },
86
+ (): RoomHit => [270 + random() * 900, 150 + random() * 600, 0],
87
+ )
88
+ for (const point of points) roomAt(entries, () => point)
89
+ const picks: number[] = []
90
+ for (let i = 0; i < 20; i++) {
91
+ const started = performance.now()
92
+ for (const point of points) roomAt(entries, () => point)
93
+ picks.push((performance.now() - started) / points.length)
94
+ }
95
+ const tiers = Object.fromEntries(
96
+ ['full', 'name', 'dot', 'hidden'].map((tier) => [
97
+ tier,
98
+ [...previous.values()].filter((placement) => placement.tier === tier).length,
99
+ ]),
100
+ )
101
+ results[count] = {
102
+ placeMs: median(cold),
103
+ placeWithPreviousMs: median(warm),
104
+ pickMs: median(picks),
105
+ tiers,
106
+ }
107
+ }
108
+ console.log(JSON.stringify(results, null, 2))
@@ -12,7 +12,9 @@ import {
12
12
  setKeyLightDirectionOverride,
13
13
  setProceduralEmission,
14
14
  setSlotDefaultOverrides,
15
+ siteSurfaces,
15
16
  temporarilyHideNodeTypes,
17
+ temporarilyWithoutAmbiance,
16
18
  useSceneAtmosphere,
17
19
  useViewer,
18
20
  } from '@pascal-app/viewer'
@@ -92,20 +94,6 @@ async function settledHeroFraming() {
92
94
  return framing
93
95
  }
94
96
 
95
- /** The site's own surfaces (ground fill, horizon disc, property ribbons) —
96
- * not its registered children (buildings, items), which are groups. */
97
- function siteSurfaces(): Object3D[] {
98
- const surfaces: Object3D[] = []
99
- for (const siteId of sceneRegistry.byType.site ?? []) {
100
- for (const child of sceneRegistry.nodes.get(siteId)?.children ?? []) {
101
- if (child.visible && !(child.type === 'Group' && child.children.length > 0)) {
102
- surfaces.push(child)
103
- }
104
- }
105
- }
106
- return surfaces
107
- }
108
-
109
97
  /**
110
98
  * A soft contact shade on the ground along every ground-floor wall: the wall
111
99
  * centrelines stroked onto a canvas in the building's facade axes, then
@@ -221,6 +209,8 @@ export function BakeThumbnail({
221
209
 
222
210
  const run = async () => {
223
211
  const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn'])
212
+ // The hero is the building in the world's own light, wherever the person's eye is.
213
+ const restoreAmbiance = temporarilyWithoutAmbiance()
224
214
  const priorLights = new Map<string, boolean>()
225
215
  const restGroups = new Map<string, [number, number, number]>()
226
216
  for (const node of Object.values(useScene.getState().nodes)) {
@@ -333,7 +323,7 @@ export function BakeThumbnail({
333
323
  }
334
324
 
335
325
  pipeline.applyEnvironment({
336
- theme: useViewer.getState().sceneTheme,
326
+ theme: useViewer.getState().sceneAppearance,
337
327
  transparent: false,
338
328
  grade: true,
339
329
  edges: useViewer.getState().edges,
@@ -399,6 +389,7 @@ export function BakeThumbnail({
399
389
  .getState()
400
390
  .setProceduralLights(id as import('@pascal-app/core').AnyNodeId, on)
401
391
  restoreNodeVisibility()
392
+ restoreAmbiance()
402
393
  }
403
394
  }
404
395
 
@@ -0,0 +1,150 @@
1
+ import { expect, spyOn, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ BaseNode,
6
+ LevelNode,
7
+ nodeRegistry,
8
+ objectId,
9
+ registerNode,
10
+ runAsSceneCommitAuthor,
11
+ sceneRegistry,
12
+ useScene,
13
+ } from '@pascal-app/core'
14
+ import {
15
+ type RevealGroupEvent,
16
+ startConstructionReveal,
17
+ subscribeRevealEvents,
18
+ useViewer,
19
+ } from '@pascal-app/viewer'
20
+ import { act, create } from '@react-three/test-renderer'
21
+ import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three'
22
+ import { sfxEmitter } from '../../lib/sfx-bus'
23
+ import { ConstructionRevealAudio } from './construction-reveal-audio'
24
+
25
+ test('real reveal landings respect audio controls and the shared cue clock', async () => {
26
+ const restore = nodeRegistry._snapshot()
27
+ const savedScene = useScene.getState()
28
+ const savedHistory = useScene.temporal.getState()
29
+ const savedViewer = useViewer.getState()
30
+ const savedObjects = new Map(sceneRegistry.nodes)
31
+ const actDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
32
+ const rafDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestAnimationFrame')
33
+ const cancelDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelAnimationFrame')
34
+ Object.defineProperties(globalThis, {
35
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
36
+ requestAnimationFrame: { configurable: true, value: () => 0 },
37
+ cancelAnimationFrame: { configurable: true, value: () => {} },
38
+ })
39
+ let audioClock = 0
40
+ const clock = spyOn(performance, 'now').mockImplementation(() => audioClock)
41
+ const cues: string[] = []
42
+ const observe = (name: string) => cues.push(name)
43
+ sfxEmitter.on('*', observe)
44
+ const events: RevealGroupEvent[] = []
45
+ const stopObserve = subscribeRevealEvents((event) => {
46
+ if (event.type === 'land') events.push(event)
47
+ })
48
+ let instant = false
49
+ let time = 0
50
+ let index = 0
51
+ const meshes: Mesh[] = []
52
+ const schema = BaseNode.extend({ id: objectId('audio-smoke') })
53
+ for (const [kind, phase] of [
54
+ ['audio:structure', 'structure'],
55
+ ['audio:furnishing', 'furnishing'],
56
+ ] as const) {
57
+ registerNode({
58
+ kind,
59
+ schemaVersion: 1,
60
+ schema,
61
+ category: 'utility',
62
+ defaults: () => ({}),
63
+ capabilities: { reveal: { phase, style: 'scale' } },
64
+ } as unknown as AnyNodeDefinition)
65
+ }
66
+ const level = LevelNode.parse({ id: 'level_audio_smoke' })
67
+ useScene.setState({ nodes: { [level.id]: level }, rootNodeIds: [level.id], readOnly: false })
68
+ useViewer.setState({ renderContext: 'editor', isExporting: false })
69
+ useViewer
70
+ .getState()
71
+ .setSelection({ levelId: level.id, buildingId: null, selectedIds: [], zoneId: null })
72
+ const driver = startConstructionReveal({
73
+ reveals: (commit) => commit.author === 'agent',
74
+ level: () => 'full',
75
+ prefersReducedMotion: () => instant,
76
+ })
77
+ const renderer = await create(<ConstructionRevealAudio enabled={false} level="full" />)
78
+ async function land(kind = 'audio:structure') {
79
+ const id = `audio-smoke_${++index}`
80
+ const node = { ...schema.parse({ id }), type: kind, parentId: level.id } as unknown as AnyNode
81
+ const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
82
+ meshes.push(mesh)
83
+ sceneRegistry.nodes.set(node.id, mesh)
84
+ const before = events.length
85
+ await act(async () =>
86
+ runAsSceneCommitAuthor('agent', () => useScene.getState().createNode(node, level.id)),
87
+ )
88
+ // A real CPU mesh supplies the renderer's ready handshake, not drawn pixels.
89
+ useScene.getState().clearDirty(node.id)
90
+ for (let frame = 0; frame < 150; frame++) {
91
+ time += 20
92
+ await act(async () => driver.tick(time, 20))
93
+ }
94
+ expect(events.length).toBeGreaterThan(before)
95
+ expect(Boolean(events.at(-1)?.instant)).toBe(instant)
96
+ }
97
+ try {
98
+ await land()
99
+ expect(cues).toEqual([])
100
+ await renderer.update(<ConstructionRevealAudio enabled={true} level="simple" />)
101
+ await land()
102
+ expect(cues).toEqual([])
103
+ await renderer.update(<ConstructionRevealAudio enabled={true} level="off" />)
104
+ await land()
105
+ expect(cues).toEqual([])
106
+ await renderer.update(<ConstructionRevealAudio enabled={true} level="full" />)
107
+ await land()
108
+ expect(cues).toEqual(['sfx:structure-build'])
109
+ await renderer.update(<ConstructionRevealAudio enabled={false} level="full" />)
110
+ await renderer.update(<ConstructionRevealAudio enabled={true} level="framing" />)
111
+ audioClock = 249
112
+ await land()
113
+ expect(cues).toEqual(['sfx:structure-build'])
114
+ audioClock = 250
115
+ await land('audio:furnishing')
116
+ expect(cues).toEqual(['sfx:structure-build', 'sfx:item-place'])
117
+ audioClock = 1000
118
+ instant = true
119
+ await land()
120
+ expect(cues).toHaveLength(2)
121
+ await renderer.unmount()
122
+ instant = false
123
+ await land()
124
+ expect(cues).toHaveLength(2)
125
+ } finally {
126
+ driver.stop()
127
+ await renderer.unmount()
128
+ stopObserve()
129
+ sfxEmitter.off('*', observe)
130
+ clock.mockRestore()
131
+ restore()
132
+ useScene.setState(savedScene, true)
133
+ useScene.temporal.setState(savedHistory, true)
134
+ useViewer.setState(savedViewer, true)
135
+ sceneRegistry.nodes.clear()
136
+ for (const [id, mesh] of savedObjects) sceneRegistry.nodes.set(id, mesh)
137
+ for (const mesh of meshes) {
138
+ mesh.geometry.dispose()
139
+ ;(mesh.material as MeshBasicMaterial).dispose()
140
+ }
141
+ for (const [name, descriptor] of [
142
+ ['IS_REACT_ACT_ENVIRONMENT', actDescriptor],
143
+ ['requestAnimationFrame', rafDescriptor],
144
+ ['cancelAnimationFrame', cancelDescriptor],
145
+ ] as const) {
146
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor)
147
+ else Reflect.deleteProperty(globalThis, name)
148
+ }
149
+ }
150
+ })
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import type { RevealPhase } from '@pascal-app/core'
4
+ import { type ConstructionRevealLevel, subscribeRevealEvents } from '@pascal-app/viewer'
5
+ import { useEffect, useRef } from 'react'
6
+ import { triggerSFX } from '../../lib/sfx-bus'
7
+
8
+ const REVEAL_PHASE_CUES = {
9
+ foundation: 'sfx:structure-build',
10
+ structure: 'sfx:structure-build',
11
+ circulation: 'sfx:structure-build',
12
+ openings: 'sfx:structure-build',
13
+ roof: 'sfx:structure-build',
14
+ furnishing: 'sfx:item-place',
15
+ } as const satisfies Record<RevealPhase, 'sfx:structure-build' | 'sfx:item-place'>
16
+
17
+ /** Mount once alongside ConstructionReveal; its driver owns the commit policy. */
18
+ export function ConstructionRevealAudio({
19
+ enabled,
20
+ level,
21
+ }: {
22
+ enabled: boolean
23
+ level: ConstructionRevealLevel
24
+ }) {
25
+ // Prop changes and Strict Effects must not reset the shared phase/level gate.
26
+ const lastPlayedMs = useRef(Number.NEGATIVE_INFINITY)
27
+
28
+ useEffect(() => {
29
+ if (!enabled || (level !== 'full' && level !== 'framing')) return
30
+ return subscribeRevealEvents((event) => {
31
+ if (event.type !== 'land' || event.instant) return
32
+ const nowMs = performance.now()
33
+ if (nowMs - lastPlayedMs.current < 250) return
34
+ lastPlayedMs.current = nowMs
35
+ triggerSFX(REVEAL_PHASE_CUES[event.phase])
36
+ })
37
+ }, [enabled, level])
38
+
39
+ return null
40
+ }