@pascal-app/editor 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -1,18 +1,23 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
- type AnyNodeId,
5
4
  emitter,
5
+ isCaptureSessionOn,
6
+ levelGradeStanding,
6
7
  sceneRegistry,
8
+ subscribeCaptureSessions,
7
9
  type ThumbnailGenerateEvent,
8
10
  useScene,
9
11
  } from '@pascal-app/core'
12
+ import { sceneViewInterior } from '@pascal-app/core/agent-operations'
10
13
  import {
11
14
  computeHeroFraming,
12
15
  createSnapshotPipeline,
13
16
  GRID_LAYER,
14
- getVisibleWallMaterials,
15
17
  heroCameraPose,
18
+ holdLiveFrame,
19
+ pendingSceneBuildCount,
20
+ presentLevelsForCapture,
16
21
  refreshIsolation,
17
22
  SNAPSHOT_MAX_EDGE,
18
23
  SNAPSHOT_MIME,
@@ -21,8 +26,12 @@ import {
21
26
  snapLevelsToTruePositions,
22
27
  THUMBNAIL_HEIGHT,
23
28
  THUMBNAIL_WIDTH,
29
+ temporarilyGroundWindowForCapture,
24
30
  temporarilyHideNodeTypes,
31
+ temporarilyShowPresentation,
25
32
  temporarilyShowShadowOnly,
33
+ temporarilyVeilGround,
34
+ temporarilyWithoutAmbiance,
26
35
  useSceneAtmosphere,
27
36
  useViewer,
28
37
  } from '@pascal-app/viewer'
@@ -30,12 +39,13 @@ import type { CameraControls } from '@react-three/drei'
30
39
  import { useThree } from '@react-three/fiber'
31
40
  import { useCallback, useEffect, useRef } from 'react'
32
41
  import * as THREE from 'three'
33
- import type { WebGPURenderer } from 'three/webgpu'
42
+ import { CanvasTarget, ClippingGroup, type WebGPURenderer } from 'three/webgpu'
34
43
  import { EDITOR_LAYER } from '../../lib/constants'
35
44
  import {
36
45
  applySnapshotCapturePose,
37
46
  captureSnapshotScene,
38
47
  createSnapshotQueue,
48
+ deliverSnapshot,
39
49
  enqueueSnapshotCapture,
40
50
  runSnapshotCapture,
41
51
  } from './snapshot-capture'
@@ -50,6 +60,7 @@ export interface SnapshotCameraData {
50
60
  zoom?: number
51
61
  captureMode?: 'standard' | 'viewport' | 'area'
52
62
  resolution?: { w: number; h: number }
63
+ presentation?: ThumbnailGenerateEvent['presentation']
53
64
  }
54
65
 
55
66
  interface ThumbnailGeneratorProps {
@@ -59,6 +70,150 @@ interface ThumbnailGeneratorProps {
59
70
  /** Metres ahead of a controls-less camera to place the stored snapshot target. */
60
71
  const FIRST_PERSON_TARGET_DISTANCE = 8
61
72
 
73
+ /** The long edge, in pixels, of a supersampled capture's picture at most (it may render in tiles). */
74
+ const MAX_CAPTURE_EDGE = 8192
75
+ /** The long edge of one render pass at most, whatever the GPU allows: the post-processing targets scale with it. */
76
+ const MAX_PASS_EDGE = 4096
77
+ /** A supersampled picture's pixels at most on a GPU, and on a software renderer (SwiftShader, llvmpipe) — its time budget. */
78
+ const MAX_CAPTURE_PIXELS = 24_000_000
79
+ const MAX_SOFTWARE_CAPTURE_PIXELS = 16_000_000
80
+ /** Pixels a tile renders past its edge on each inner side, cropped off: the AO and the ink sample across it. */
81
+ const TILE_OVERLAP = 96
82
+
83
+ type CaptureLimits = { passEdge: number; maxPixels: number; software: boolean }
84
+
85
+ export function captureDimensions(
86
+ canvasSize: readonly [number, number],
87
+ event: Pick<ThumbnailGenerateEvent, 'ortho' | 'perspective' | 'standardSize' | 'supersample'>,
88
+ maxPixels: number,
89
+ ): [number, number] {
90
+ const supersample = event.supersample ?? 1
91
+ // A requested camera must not inherit the editor pane's aspect or be upscaled from its crop.
92
+ const size =
93
+ (event.ortho || event.perspective) && event.standardSize && supersample === 1
94
+ ? [event.standardSize.w, event.standardSize.h]
95
+ : canvasSize
96
+ const scale = Math.min(
97
+ Math.max(1, supersample),
98
+ MAX_CAPTURE_EDGE / Math.max(size[0], size[1]),
99
+ Math.sqrt(maxPixels / (size[0] * size[1])),
100
+ )
101
+ return [Math.round(size[0] * scale), Math.round(size[1] * scale)]
102
+ }
103
+
104
+ /**
105
+ * What one capture may ask of this GPU: a pass no larger than its biggest
106
+ * texture (and MAX_PASS_EDGE), and — on a software renderer, where a big
107
+ * frame takes minutes — fewer pixels.
108
+ */
109
+ function captureLimits(renderer: unknown): CaptureLimits {
110
+ const backend = (
111
+ renderer as {
112
+ backend?: {
113
+ device?: {
114
+ limits?: { maxTextureDimension2D?: number }
115
+ adapterInfo?: { architecture?: string; description?: string; vendor?: string }
116
+ }
117
+ gl?: WebGL2RenderingContext
118
+ }
119
+ }
120
+ ).backend
121
+ let gpuEdge = backend?.device?.limits?.maxTextureDimension2D ?? 0
122
+ let rendererName = [
123
+ backend?.device?.adapterInfo?.vendor,
124
+ backend?.device?.adapterInfo?.architecture,
125
+ backend?.device?.adapterInfo?.description,
126
+ ].join(' ')
127
+ const gl = backend?.gl
128
+ if (gl) {
129
+ gpuEdge = Math.min(
130
+ gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
131
+ gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) as number,
132
+ )
133
+ const info = gl.getExtension('WEBGL_debug_renderer_info')
134
+ rendererName = String(gl.getParameter(info ? info.UNMASKED_RENDERER_WEBGL : gl.RENDERER))
135
+ }
136
+ const software = /swiftshader|llvmpipe|software|softpipe/i.test(rendererName)
137
+ const limits = {
138
+ passEdge: Math.min(MAX_PASS_EDGE, gpuEdge || MAX_PASS_EDGE),
139
+ maxPixels: software ? MAX_SOFTWARE_CAPTURE_PIXELS : MAX_CAPTURE_PIXELS,
140
+ software,
141
+ }
142
+ // dev: `window.__pascalCaptureLimits = { passEdge: 2048 }` forces tiling (or a budget) for a probe
143
+ if (process.env.NODE_ENV === 'production') return limits
144
+ return {
145
+ ...limits,
146
+ ...(window as unknown as { __pascalCaptureLimits?: Partial<CaptureLimits> })
147
+ .__pascalCaptureLimits,
148
+ }
149
+ }
150
+
151
+ type Tile = {
152
+ /** The pixels the tile renders, overlap included. */
153
+ x: number
154
+ y: number
155
+ w: number
156
+ h: number
157
+ /** The pixels it contributes to the picture. */
158
+ inner: { x: number; y: number; w: number; h: number }
159
+ }
160
+
161
+ /**
162
+ * A `width × height` picture split into equal tiles no larger than
163
+ * `passEdge`, each rendered TILE_OVERLAP past its inner edges so the
164
+ * screen-space passes (AO, ink, FXAA) have their neighbourhood at a seam.
165
+ * One tile when the picture fits a pass.
166
+ */
167
+ export function tileGrid(width: number, height: number, passEdge: number): Tile[] {
168
+ const room = passEdge - 2 * TILE_OVERLAP
169
+ const cols = width <= passEdge ? 1 : Math.ceil(width / room)
170
+ const rows = height <= passEdge ? 1 : Math.ceil(height / room)
171
+ const tiles: Tile[] = []
172
+ for (let row = 0; row < rows; row++) {
173
+ for (let col = 0; col < cols; col++) {
174
+ const ix = Math.round((col * width) / cols)
175
+ const iy = Math.round((row * height) / rows)
176
+ const iw = Math.round(((col + 1) * width) / cols) - ix
177
+ const ih = Math.round(((row + 1) * height) / rows) - iy
178
+ const x = Math.max(0, ix - TILE_OVERLAP)
179
+ const y = Math.max(0, iy - TILE_OVERLAP)
180
+ const w = Math.min(width, ix + iw + TILE_OVERLAP) - x
181
+ const h = Math.min(height, iy + ih + TILE_OVERLAP) - y
182
+ tiles.push({ x, y, w, h, inner: { x: ix, y: iy, w: iw, h: ih } })
183
+ }
184
+ }
185
+ return tiles
186
+ }
187
+
188
+ /**
189
+ * `render` with the renderer's canvas target swapped for a detached one of
190
+ * `width × height`. Every pass of a capture pipeline sizes itself from the
191
+ * renderer's drawing buffer, so a supersampled capture renders at its own
192
+ * size into its own targets while the live canvas — and the live pipeline's
193
+ * targets — keep theirs. Only for renders into a render target: nothing may
194
+ * draw to the screen while the stand-in is current.
195
+ */
196
+ export function atCaptureSize<T>(
197
+ renderer: WebGPURenderer,
198
+ width: number,
199
+ height: number,
200
+ render: () => T,
201
+ ): T {
202
+ const live = renderer.getCanvasTarget()
203
+ if (live.domElement.width === width && live.domElement.height === height) return render()
204
+ const stand = document.createElement('canvas')
205
+ stand.width = width
206
+ stand.height = height
207
+ const target = new CanvasTarget(stand)
208
+ renderer.setCanvasTarget(target)
209
+ try {
210
+ return render()
211
+ } finally {
212
+ renderer.setCanvasTarget(live)
213
+ target.dispose()
214
+ }
215
+ }
216
+
62
217
  function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
63
218
  const maxEdge = Math.max(width, height)
64
219
  if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }
@@ -67,11 +222,402 @@ function clampSnapshotSize(width: number, height: number): { w: number; h: numbe
67
222
  return { w: Math.round(width * scale), h: Math.round(height * scale) }
68
223
  }
69
224
 
225
+ /**
226
+ * Every directional light re-aimed at the face a capture looks at — from the
227
+ * camera's side of it, 35° above the horizon, a quarter to the camera's
228
+ * left so returns still read — and brightened a little; the returned
229
+ * function puts them back. The lights' own drift toward their theme config
230
+ * (lights.tsx lerps per frame) resumes afterwards.
231
+ */
232
+ function aimLightsAtFace(
233
+ scene: THREE.Scene,
234
+ position: readonly [number, number, number],
235
+ target: readonly [number, number, number],
236
+ ): () => void {
237
+ const aim = new THREE.Vector3(target[0], target[1], target[2])
238
+ const toCamera = new THREE.Vector3(position[0], position[1], position[2]).sub(aim)
239
+ toCamera.y = 0
240
+ if (toCamera.lengthSq() < 1e-9) return () => {}
241
+ toCamera.normalize()
242
+ const left = new THREE.Vector3(0, 1, 0).cross(toCamera).normalize()
243
+ const up = Math.sin((35 * Math.PI) / 180)
244
+ const along = Math.cos((35 * Math.PI) / 180)
245
+ const direction = toCamera
246
+ .clone()
247
+ .multiplyScalar(along)
248
+ .add(new THREE.Vector3(0, up, 0))
249
+ .add(left.multiplyScalar(0.25))
250
+ .normalize()
251
+ const restores: (() => void)[] = []
252
+ scene.traverse((object) => {
253
+ const light = object as THREE.DirectionalLight
254
+ if (!light.isDirectionalLight) return
255
+ const savedPosition = light.position.clone()
256
+ const savedTarget = light.target.position.clone()
257
+ const savedIntensity = light.intensity
258
+ light.position.copy(aim.clone().add(direction.clone().multiplyScalar(150)))
259
+ light.target.position.copy(aim)
260
+ light.target.updateMatrixWorld()
261
+ light.updateMatrixWorld()
262
+ light.intensity = savedIntensity * 1.15
263
+ restores.push(() => {
264
+ light.position.copy(savedPosition)
265
+ light.target.position.copy(savedTarget)
266
+ light.target.updateMatrixWorld()
267
+ light.updateMatrixWorld()
268
+ light.intensity = savedIntensity
269
+ })
270
+ })
271
+ return () => {
272
+ for (const restore of restores) restore()
273
+ }
274
+ }
275
+
276
+ /** The step, in seconds of R3F's clock, a frame run by hand takes (FrameLimiter's own `kick`). */
277
+ const PUMPED_FRAME_SECONDS = 1 / 1000
278
+
279
+ /**
280
+ * Run the frame loop `n` times by hand — the useFrame subscribers and the
281
+ * render — a macrotask apart so React's commits land between the frames.
282
+ *
283
+ * With `frameloop="never"` R3F takes each frame's delta as `timestamp −
284
+ * clock.elapsedTime`, and the viewer's FrameLimiter keeps that clock in
285
+ * SECONDS since it mounted. A pumped frame steps it by a millisecond and puts
286
+ * it back, so the next real frame gets exactly its own delta. (Pumping it with
287
+ * `performance.now()` — milliseconds — handed the next real frame a delta of
288
+ * minus hundreds of thousands of seconds: every delta-scaled lerp blew up, the
289
+ * lights and the backdrop to ±1e6, and the 3D view went black, then colours,
290
+ * while the next capture came back black or blown out white — 2026-09-22.)
291
+ */
292
+ export async function pumpFrames(
293
+ advance: (timestamp: number, runGlobalEffects?: boolean) => void,
294
+ clock: { elapsedTime: number; oldTime: number },
295
+ n: number,
296
+ ): Promise<void> {
297
+ for (let i = 0; i < n; i++) {
298
+ const { elapsedTime, oldTime } = clock
299
+ try {
300
+ advance(elapsedTime + PUMPED_FRAME_SECONDS, true)
301
+ } finally {
302
+ clock.elapsedTime = elapsedTime
303
+ clock.oldTime = oldTime
304
+ }
305
+ await macrotask()
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Objects a renderer tagged `userData.excludeFromCapture` (a plugin's site
311
+ * context, e.g. a utility pole and its drop) hidden for a sheet's capture; the
312
+ * returned function shows them again.
313
+ */
314
+ export function hideCaptureExcluded(scene: THREE.Scene): () => void {
315
+ const hidden: THREE.Object3D[] = []
316
+ scene.traverse((object) => {
317
+ if (
318
+ (object.userData as { excludeFromCapture?: unknown }).excludeFromCapture !== true ||
319
+ !object.visible
320
+ )
321
+ return
322
+ object.visible = false
323
+ hidden.push(object)
324
+ })
325
+ return () => {
326
+ for (const object of hidden) object.visible = true
327
+ }
328
+ }
329
+
330
+ /**
331
+ * A section looks INTO the house, and what it sees there — the attic under
332
+ * the roof, a porch roof's underside — faces away from the sun re-aimed at
333
+ * the cut: it read as a solid dark mass. For the frame the unshadowed fill
334
+ * lights shine from the camera and the hemisphere's ground takes the sky's
335
+ * colour, so the attic reads as a light void with its roof and ceiling
336
+ * lines; the returned function puts the lights back.
337
+ */
338
+ function lightInterior(scene: THREE.Scene, camera: THREE.Camera): () => void {
339
+ const look = camera.getWorldDirection(new THREE.Vector3())
340
+ const eye = camera.getWorldPosition(new THREE.Vector3())
341
+ const restores: (() => void)[] = []
342
+ scene.traverse((object) => {
343
+ const directional = object as THREE.DirectionalLight
344
+ if (directional.isDirectionalLight && !directional.castShadow) {
345
+ const savedPosition = directional.position.clone()
346
+ const savedTarget = directional.target.position.clone()
347
+ const savedIntensity = directional.intensity
348
+ directional.position.copy(eye)
349
+ directional.target.position.copy(eye.clone().add(look))
350
+ directional.target.updateMatrixWorld()
351
+ directional.updateMatrixWorld()
352
+ directional.intensity = Math.max(savedIntensity, INTERIOR_FILL_INTENSITY)
353
+ restores.push(() => {
354
+ directional.position.copy(savedPosition)
355
+ directional.target.position.copy(savedTarget)
356
+ directional.target.updateMatrixWorld()
357
+ directional.updateMatrixWorld()
358
+ directional.intensity = savedIntensity
359
+ })
360
+ return
361
+ }
362
+ const hemisphere = object as THREE.HemisphereLight
363
+ if (hemisphere.isHemisphereLight) {
364
+ const savedGround = hemisphere.groundColor.clone()
365
+ hemisphere.groundColor.copy(hemisphere.color)
366
+ restores.push(() => hemisphere.groundColor.copy(savedGround))
367
+ }
368
+ })
369
+ return () => {
370
+ for (const restore of restores) restore()
371
+ }
372
+ }
373
+
374
+ /** The fill a section's interior gets from the camera's side (the key light is the re-aimed sun). */
375
+ const INTERIOR_FILL_INTENSITY = 2
376
+
377
+ /** Why a sheet's capture refuses to run in a background tab — the words reach the Architect. */
378
+ const HIDDEN_TAB =
379
+ 'The Pascal tab is in the background, where the 3D viewer draws no frames — bring it to the front.'
380
+
381
+ function isTabHidden(): boolean {
382
+ return typeof document !== 'undefined' && document.visibilityState === 'hidden'
383
+ }
384
+
385
+ /** A scene that stops settling for this long holds a node no system will ever build. */
386
+ const SCENE_BUILD_STALL_MS = 15_000
387
+ /** However busy the scene, a capture waits no longer than this for it. */
388
+ const SCENE_BUILD_MAX_MS = 120_000
389
+
390
+ /**
391
+ * Until the scene has built what the picture shows — walls, roofs, openings,
392
+ * every item's model (a loading placeholder draws through the roof) — the
393
+ * viewer's own scene-ready test. Progress-based: a slow GPU loading a big
394
+ * house keeps it waiting while the pending count moves; a count that stops
395
+ * moving (a node no system settles) lets the capture go. Returns the count
396
+ * left.
397
+ */
398
+ async function untilSceneBuilt(): Promise<number> {
399
+ const started = performance.now()
400
+ let pending = pendingSceneBuildCount()
401
+ let changedAt = started
402
+ while (pending > 0) {
403
+ if (isTabHidden()) throw new Error(HIDDEN_TAB)
404
+ const now = performance.now()
405
+ if (now - changedAt > SCENE_BUILD_STALL_MS || now - started > SCENE_BUILD_MAX_MS) break
406
+ await new Promise((resolve) => setTimeout(resolve, 100))
407
+ const next = pendingSceneBuildCount()
408
+ if (next !== pending) {
409
+ pending = next
410
+ changedAt = performance.now()
411
+ }
412
+ }
413
+ return pending
414
+ }
415
+
416
+ /** `promise`, or a rejection with `message` after `ms` — a wait that must not hold the capture queue for good. */
417
+ function within<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
418
+ let timer: ReturnType<typeof setTimeout> | undefined
419
+ return Promise.race([
420
+ promise,
421
+ new Promise<never>((_, reject) => {
422
+ timer = setTimeout(() => reject(new Error(message)), ms)
423
+ }),
424
+ ]).finally(() => clearTimeout(timer))
425
+ }
426
+
427
+ /** A readback or a host callback that takes longer than this is abandoned so the next capture runs. */
428
+ const CAPTURE_SETTLE_MS = 120_000
429
+
430
+ /** After a capture session puts the user's view back, the viewport resumes once the restored materials are in. */
431
+ const SESSION_RESUME_MS = 250
432
+
433
+ /** The longest a capture keeps the viewport on its last frame, should its release never come. */
434
+ const HOLD_MAX_MS = 300_000
435
+
436
+ /**
437
+ * What the sheets' pictures are drawn in, whatever the user's view: full-
438
+ * height walls, the storeys stacked at their true heights, the materials'
439
+ * own textures and colours (a white or blueprint palette is a view, not the
440
+ * house). The shading stays the user's: the capture brings its own AO.
441
+ */
442
+ const FINISHED_PRESENTATION = {
443
+ wallMode: 'up',
444
+ levelMode: 'stacked',
445
+ textures: true,
446
+ colorPreset: 'clay',
447
+ } as const
448
+
449
+ /** The viewer switched to FINISHED_PRESENTATION; the returned function puts the user's view back. */
450
+ export function presentFinished(): () => void {
451
+ const viewer = useViewer.getState()
452
+ const restores: (() => void)[] = []
453
+ if (viewer.wallMode !== FINISHED_PRESENTATION.wallMode) {
454
+ const wallMode = viewer.wallMode
455
+ viewer.setWallMode(FINISHED_PRESENTATION.wallMode)
456
+ restores.push(() => useViewer.getState().setWallMode(wallMode))
457
+ }
458
+ if (viewer.levelMode !== FINISHED_PRESENTATION.levelMode) {
459
+ const levelMode = viewer.levelMode
460
+ viewer.setLevelMode(FINISHED_PRESENTATION.levelMode)
461
+ restores.push(() => useViewer.getState().setLevelMode(levelMode))
462
+ }
463
+ if (viewer.textures !== FINISHED_PRESENTATION.textures) {
464
+ const textures = viewer.textures
465
+ viewer.setTextures(FINISHED_PRESENTATION.textures)
466
+ restores.push(() => useViewer.getState().setTextures(textures))
467
+ }
468
+ if (viewer.colorPreset !== FINISHED_PRESENTATION.colorPreset) {
469
+ const colorPreset = viewer.colorPreset
470
+ viewer.setColorPreset(FINISHED_PRESENTATION.colorPreset)
471
+ restores.push(() => useViewer.getState().setColorPreset(colorPreset))
472
+ }
473
+ return () => {
474
+ for (const restore of restores.reverse()) restore()
475
+ }
476
+ }
477
+
478
+ /**
479
+ * The pictures' scene while a capture session is on (core's `isCaptureSessionOn`, runs that overlap
480
+ * counted once): `present` when the first starts, and what it returns, which puts the user's view
481
+ * back, when the last ends. Returns the stop, which puts the view back if a session is still on.
482
+ */
483
+ export function presentWhileCapturing(present: () => () => void): () => void {
484
+ let restore: (() => void) | null = null
485
+ const follow = () => {
486
+ if (isCaptureSessionOn() && !restore) restore = present()
487
+ else if (!isCaptureSessionOn() && restore) {
488
+ restore()
489
+ restore = null
490
+ }
491
+ }
492
+ follow()
493
+ const unsubscribe = subscribeCaptureSessions(follow)
494
+ return () => {
495
+ unsubscribe()
496
+ restore?.()
497
+ restore = null
498
+ }
499
+ }
500
+
501
+ /** A yield to the next macrotask through a MessageChannel — unlike a timer, never throttled in a hidden tab. */
502
+ function macrotask(): Promise<void> {
503
+ return new Promise((resolve) => {
504
+ const channel = new MessageChannel()
505
+ channel.port1.onmessage = () => {
506
+ channel.port1.close()
507
+ resolve()
508
+ }
509
+ channel.port2.postMessage(null)
510
+ })
511
+ }
512
+
513
+ /**
514
+ * The children of `root` (the scene, or one floor) gathered under one
515
+ * ClippingGroup carrying `planes` (world space) for the length of a render;
516
+ * the returned function puts them back. The WebGPU renderer clips only
517
+ * through such groups (there is no renderer-level plane list), and a group
518
+ * added and removed inside one synchronous render never reaches the React
519
+ * tree. `clipShadows` cuts the shadows too, so light reaches past the cut.
520
+ */
521
+ function clipSceneFor(
522
+ root: THREE.Object3D,
523
+ planes: readonly { normal: [number, number, number]; constant: number }[],
524
+ clipShadows = false,
525
+ ): () => void {
526
+ const group = new ClippingGroup()
527
+ group.clippingPlanes = planes.map(
528
+ (p) =>
529
+ new THREE.Plane(
530
+ new THREE.Vector3(p.normal[0], p.normal[1], p.normal[2]).normalize(),
531
+ p.constant,
532
+ ),
533
+ )
534
+ group.enabled = true
535
+ group.clipShadows = clipShadows
536
+ const children = [...root.children]
537
+ for (const child of children) group.add(child)
538
+ root.add(group)
539
+ return () => {
540
+ for (const child of children) root.add(child)
541
+ root.remove(group)
542
+ }
543
+ }
544
+
545
+ /**
546
+ * An interior view of one floor, for one render (view_scene): the other
547
+ * floors and this floor's roofs and ceilings out of the picture, everything
548
+ * on it cut where core says (sceneViewInterior, from the scene as this editor
549
+ * has it) and its shadows with it, and whatever the user's solo or isolation
550
+ * holds back on it shown. The site's ground follows this floor, not the
551
+ * user's: veiled wholesale over a floor below grade; over one only partly
552
+ * below grade, or at grade behind a hillside, veiled only where it stands
553
+ * between this frame's camera and the floor (the ground window).
554
+ */
555
+ export function interiorCutaway(
556
+ levelId: string,
557
+ restore: (callback: () => void) => void,
558
+ camera?: THREE.Camera,
559
+ ) {
560
+ const { nodes } = useScene.getState()
561
+ const { cutHeight, hiddenIds } = sceneViewInterior(nodes, levelId)
562
+ const floor = sceneRegistry.nodes.get(levelId)
563
+ if (!floor) throw new Error('The floor to look inside is not built yet. Try again.')
564
+ for (const id of hiddenIds) {
565
+ const object = sceneRegistry.nodes.get(id)
566
+ if (!object?.visible) continue
567
+ object.visible = false
568
+ restore(() => {
569
+ object.visible = true
570
+ })
571
+ }
572
+ restore(temporarilyShowPresentation(floor))
573
+ restore(clipSceneFor(floor, [{ normal: [0, -1, 0], constant: cutHeight }], true))
574
+ const standing = levelGradeStanding(nodes, levelId)
575
+ if (standing === 'below-grade' || !camera) {
576
+ restore(temporarilyVeilGround(standing !== 'at-grade'))
577
+ } else {
578
+ restore(temporarilyVeilGround(false))
579
+ restore(temporarilyGroundWindowForCapture(camera, nodes, levelId))
580
+ }
581
+ }
582
+
583
+ /**
584
+ * The ground of an exterior picture, for one render: solid, whatever level the person is
585
+ * on — but a posed frame (an authored snapshot pose, or the caller's own perspective or
586
+ * orthographic view, which is how both agent lanes ask) decides its own ground window from
587
+ * its own camera, never inheriting the person's. An unposed frame (the person's viewport,
588
+ * the hero shot) makes no such decision: the window is the person's to hold.
589
+ */
590
+ export function exteriorGroundForCapture(
591
+ restore: (callback: () => void) => void,
592
+ posed: boolean,
593
+ camera: THREE.Camera,
594
+ ) {
595
+ restore(temporarilyVeilGround(false))
596
+ if (posed) restore(temporarilyGroundWindowForCapture(camera, useScene.getState().nodes))
597
+ }
598
+
599
+ /**
600
+ * A caller's own orthographic view, or with no view of its own an
601
+ * orthographic main camera, captures orthographically; a caller's perspective
602
+ * view (view_scene's), the auto-save hero shot and an authored snapshot pose
603
+ * stay perspective.
604
+ */
605
+ export function capturesOrthographically(
606
+ event: Pick<ThumbnailGenerateEvent, 'snapLevels' | 'cameraPose' | 'ortho' | 'perspective'>,
607
+ mainCamera: THREE.Camera,
608
+ ): boolean {
609
+ if (event.snapLevels || event.cameraPose) return false
610
+ if (event.ortho) return true
611
+ return !event.perspective && mainCamera instanceof THREE.OrthographicCamera
612
+ }
613
+
70
614
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
71
615
  const gl = useThree((state) => state.gl)
72
616
  const scene = useThree((state) => state.scene)
73
617
  const atmosphere = useSceneAtmosphere()
74
618
  const getThree = useThree((state) => state.get)
619
+ const advance = useThree((state) => state.advance)
620
+ const clock = useThree((state) => state.clock)
75
621
  const controls = useThree((state) => state.controls) as CameraControls | null
76
622
  const isGenerating = useRef(false)
77
623
  const captureQueue = useRef(createSnapshotQueue())
@@ -80,6 +626,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
80
626
  const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
81
627
  const pipelineRef = useRef<SnapshotPipeline | null>(null)
82
628
  const captureVersion = useRef(0)
629
+ // An ORTHOGRAPHIC capture (a sheet's elevation, 2026-09-10) needs its own
630
+ // camera and its own pass — the pipeline binds the camera it was built
631
+ // with — built on first use, kept until the atmosphere changes.
632
+ const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null)
633
+ const orthoPipelineRef = useRef<SnapshotPipeline | null>(null)
634
+ const orthoPipelineBuild = useRef<Promise<SnapshotPipeline | null> | null>(null)
83
635
 
84
636
  useEffect(() => {
85
637
  onThumbnailCaptureRef.current = onThumbnailCapture
@@ -117,16 +669,71 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
117
669
  thumbnailCameraRef.current = null
118
670
  pipelineRef.current?.dispose()
119
671
  pipelineRef.current = null
672
+ orthoPipelineRef.current?.dispose()
673
+ orthoPipelineRef.current = null
674
+ orthoPipelineBuild.current = null
675
+ }
676
+ }, [gl, scene, atmosphere])
677
+
678
+ /** The orthographic capture camera and its pipeline, built once per atmosphere. */
679
+ const orthoPipeline = useCallback(async (): Promise<{
680
+ camera: THREE.OrthographicCamera
681
+ pipeline: SnapshotPipeline | null
682
+ }> => {
683
+ if (!orthoCameraRef.current) {
684
+ const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000)
685
+ cam.layers.disable(EDITOR_LAYER)
686
+ cam.layers.disable(GRID_LAYER)
687
+ orthoCameraRef.current = cam
688
+ }
689
+ const camera = orthoCameraRef.current
690
+ if (!orthoPipelineRef.current) {
691
+ if (!orthoPipelineBuild.current) {
692
+ // the post-processed stack (AO + ink edges) for the orthographic camera
693
+ orthoPipelineBuild.current = createSnapshotPipeline({
694
+ renderer: gl as unknown as WebGPURenderer,
695
+ scene,
696
+ camera,
697
+ atmosphere,
698
+ })
699
+ }
700
+ orthoPipelineRef.current = await orthoPipelineBuild.current
120
701
  }
702
+ return { camera, pipeline: orthoPipelineRef.current }
121
703
  }, [gl, scene, atmosphere])
122
704
 
123
705
  const generate = useCallback(
124
706
  async (event: ThumbnailGenerateEvent) => {
125
- const { captureMode, cropRegion, standardSize, cameraPose, requestId } = event
707
+ const { captureMode, cropRegion, standardSize, cameraPose, requestId, interior } = event
126
708
  const snapLevels = event.snapLevels === true
127
709
  const transparent = event.transparent === true
710
+ // A SHEET's capture asks for more than a
711
+ // thumbnail: its own ink edges, an orthographic or perspective view of
712
+ // its own (the user's camera never moves), node types hidden, the sun
713
+ // re-aimed at the face, the scene clipped at a section's cut, and the
714
+ // frame rendered at print scale.
715
+ const edgesOverride = event.edges
716
+ const ortho = event.ortho
717
+ const hideTypes = event.hideTypes ?? []
718
+ const perspective = event.perspective
719
+ const lightFace = event.lightFace === true
720
+ const clip = event.clip ?? []
721
+ const supersample = event.supersample ?? 1
128
722
  const standardW = standardSize?.w ?? THUMBNAIL_WIDTH
129
723
  const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT
724
+ // dev: the capture handshake on `window.__pascalCaptureTrace`, for a probe
725
+ const trace = (step: string, data?: unknown) => {
726
+ if (process.env.NODE_ENV === 'production') return
727
+ const w = window as unknown as { __pascalCaptureTrace?: unknown[] }
728
+ w.__pascalCaptureTrace ??= []
729
+ w.__pascalCaptureTrace.push({ t: Math.round(performance.now()), step, data })
730
+ }
731
+ trace('generate', {
732
+ captureMode,
733
+ ortho: !!ortho,
734
+ busy: isGenerating.current,
735
+ callback: !!onThumbnailCaptureRef.current,
736
+ })
130
737
  await runSnapshotCapture(
131
738
  requestId,
132
739
  isGenerating,
@@ -136,8 +743,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
136
743
  if (!onCapture) throw new Error('Snapshot storage is unavailable')
137
744
  if (cameraPose && event.projectId !== useViewer.getState().projectId)
138
745
  throw new Error('The active project changed before capture')
139
- const thumbnailCamera = thumbnailCameraRef.current
140
- if (!thumbnailCamera) throw new Error('Snapshot camera is not ready')
746
+ const perspectiveCamera = thumbnailCameraRef.current
747
+ if (!perspectiveCamera) throw new Error('Snapshot camera is not ready')
141
748
  const { camera: mainCamera, controls } = getThree()
142
749
  if (cameraPose && (snapLevels || (captureMode && captureMode !== 'standard'))) {
143
750
  throw new Error('An explicit snapshot camera requires standard capture mode')
@@ -146,244 +753,517 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
146
753
  throw new Error('Snapshot renderer is not ready. Try again.')
147
754
  }
148
755
 
149
- // Copy the main camera's transform and projection so the thumbnail
150
- // matches exactly what the user sees in the viewport.
151
- thumbnailCamera.position.copy(mainCamera.position)
152
- thumbnailCamera.quaternion.copy(mainCamera.quaternion)
153
- if (mainCamera instanceof THREE.PerspectiveCamera) {
154
- thumbnailCamera.fov = mainCamera.fov
155
- thumbnailCamera.near = mainCamera.near
156
- thumbnailCamera.far = mainCamera.far
157
- }
158
- const { width, height } = gl.domElement
159
- thumbnailCamera.aspect = width / height
160
- if (cameraPose) {
161
- applySnapshotCapturePose(
162
- thumbnailCamera,
163
- cameraPose,
164
- { width, height },
165
- {
166
- w: standardW,
167
- h: standardH,
168
- },
169
- )
170
- }
171
- thumbnailCamera.updateProjectionMatrix()
172
- // The capture camera never joins the scene graph, so its matrixWorld
173
- // is only refreshed by the render itself — too late for the backdrop
174
- // uniforms below.
175
- thumbnailCamera.updateMatrixWorld()
176
-
177
- const pipeline = pipelineRef.current
178
- pipeline?.applyEnvironment({
179
- theme: useViewer.getState().sceneTheme,
180
- transparent,
181
- grade: useViewer.getState().shading === 'rendered',
182
- // Preset/item captures stay clean; scene captures mirror the canvas.
183
- edges: transparent ? 'off' : useViewer.getState().edges,
184
- camera: thumbnailCamera,
185
- })
186
-
187
- // Capture camera data for snapshot storage
188
- const pos = cameraPose ? thumbnailCamera.position : mainCamera.position
189
- let tgt: [number, number, number] | null = null
190
- if (!cameraPose && controls && 'getTarget' in controls) {
191
- const v = new THREE.Vector3()
192
- ;(controls as any).getTarget(v)
193
- tgt = [v.x, v.y, v.z]
194
- } else {
195
- // Walk / drone captures run without orbit controls, so there is no orbit
196
- // target to read. Synthesize one down the view axis — otherwise the
197
- // saved snapshot carries no framing to return to.
198
- const look = new THREE.Vector3(0, 0, -1)
199
- .applyQuaternion(cameraPose ? thumbnailCamera.quaternion : mainCamera.quaternion)
200
- .multiplyScalar(FIRST_PERSON_TARGET_DISTANCE)
201
- .add(pos)
202
- tgt = [look.x, look.y, look.z]
203
- }
204
- const isOrtho = !cameraPose && mainCamera instanceof THREE.OrthographicCamera
205
- const cameraData: SnapshotCameraData = {
206
- ...(requestId && { requestId }),
207
- position: [pos.x, pos.y, pos.z],
208
- ...(cameraPose && {
209
- quaternion: [...cameraPose.quaternion] as [number, number, number, number],
210
- fov: cameraPose.fov,
211
- }),
212
- target: tgt,
213
- type: isOrtho ? 'orthographic' : 'perspective',
214
- ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
215
- }
756
+ // a supersampled frame renders at a multiple of the canvas' size into
757
+ // the capture's own targets; the live canvas is never resized
758
+ const canvasSize: [number, number] = [gl.domElement.width, gl.domElement.height]
759
+ const pose = ortho ?? perspective
760
+ const limits = captureLimits(gl)
761
+ const [width, height] = captureDimensions(canvasSize, event, limits.maxPixels)
762
+ // a supersampled picture is encoded at the size it could render (the
763
+ // caller asked for canvas × supersample, maybe past this GPU's limits)
764
+ const encodeSize = supersample > 1 ? { w: width, h: height } : standardSize
765
+ // a background tab draws no frames and (on the WebGL backend) never
766
+ // finishes a readback: a sheet's capture says so at once
767
+ if (pose && isTabHidden()) throw new Error(HIDDEN_TAB)
768
+ // a sheet's capture runs frames by hand in a scene set up for the
769
+ // picture: the viewport keeps its last frame meanwhile
770
+ const releaseLiveFrame = pose ? holdLiveFrame(HOLD_MAX_MS) : null
216
771
 
217
- const capturePromise = captureSnapshotScene((restore) => {
218
- if (snapLevels) {
219
- const prevMode = useViewer.getState().levelMode
220
- if (prevMode !== 'stacked') {
221
- restore(() => useViewer.getState().setLevelMode(prevMode))
222
- useViewer.getState().setLevelMode('stacked')
772
+ try {
773
+ const edges = edgesOverride ?? (transparent ? 'off' : useViewer.getState().edges)
774
+
775
+ const wantOrtho = capturesOrthographically(event, mainCamera)
776
+ let thumbnailCamera: THREE.PerspectiveCamera | THREE.OrthographicCamera =
777
+ perspectiveCamera
778
+ let pipeline: SnapshotPipeline | null = pipelineRef.current
779
+ if (wantOrtho) {
780
+ // the orthographic capture goes through its own post-processed
781
+ // pipeline (AO, ink edges) and target, on either backend — never
782
+ // through the live canvas
783
+ const built = await orthoPipeline()
784
+ const cam = built.camera
785
+ if (ortho) {
786
+ const halfW = ortho.viewWidth / 2
787
+ const halfH = halfW / (width / height)
788
+ cam.position.set(ortho.position[0], ortho.position[1], ortho.position[2])
789
+ cam.up.set(0, 1, 0)
790
+ cam.lookAt(ortho.target[0], ortho.target[1], ortho.target[2])
791
+ cam.left = -halfW
792
+ cam.right = halfW
793
+ cam.top = halfH
794
+ cam.bottom = -halfH
795
+ cam.zoom = 1
796
+ cam.near = 0.1
797
+ cam.far = 2000
798
+ } else {
799
+ const main = mainCamera as THREE.OrthographicCamera
800
+ cam.position.copy(main.position)
801
+ cam.quaternion.copy(main.quaternion)
802
+ cam.left = main.left
803
+ cam.right = main.right
804
+ cam.top = main.top
805
+ cam.bottom = main.bottom
806
+ cam.zoom = main.zoom
807
+ cam.near = main.near
808
+ cam.far = main.far
809
+ }
810
+ cam.updateProjectionMatrix()
811
+ cam.updateMatrixWorld()
812
+ thumbnailCamera = cam
813
+ pipeline = built.pipeline
814
+ } else {
815
+ if (perspective && !snapLevels && !cameraPose) {
816
+ // a sheet's cover view: the capture camera stands where the
817
+ // sheet asks, the user's camera never moves
818
+ perspectiveCamera.position.set(
819
+ perspective.position[0],
820
+ perspective.position[1],
821
+ perspective.position[2],
822
+ )
823
+ perspectiveCamera.up.set(0, 1, 0)
824
+ perspectiveCamera.lookAt(
825
+ perspective.target[0],
826
+ perspective.target[1],
827
+ perspective.target[2],
828
+ )
829
+ perspectiveCamera.fov = perspective.fov ?? 60
830
+ perspectiveCamera.near = 0.1
831
+ perspectiveCamera.far = 2000
832
+ } else {
833
+ // Copy the main camera's transform and projection so the thumbnail
834
+ // matches exactly what the user sees in the viewport.
835
+ perspectiveCamera.position.copy(mainCamera.position)
836
+ perspectiveCamera.quaternion.copy(mainCamera.quaternion)
837
+ if (mainCamera instanceof THREE.PerspectiveCamera) {
838
+ perspectiveCamera.fov = mainCamera.fov
839
+ perspectiveCamera.near = mainCamera.near
840
+ perspectiveCamera.far = mainCamera.far
841
+ }
842
+ }
843
+ perspectiveCamera.aspect = width / height
844
+ if (cameraPose) {
845
+ applySnapshotCapturePose(
846
+ perspectiveCamera,
847
+ cameraPose,
848
+ { width, height },
849
+ {
850
+ w: standardW,
851
+ h: standardH,
852
+ },
853
+ )
223
854
  }
224
- restore(snapLevelsToTruePositions())
855
+ perspectiveCamera.updateProjectionMatrix()
856
+ // The capture camera never joins the scene graph, so its matrixWorld
857
+ // is only refreshed by the render itself — too late for the backdrop
858
+ // uniforms below.
859
+ perspectiveCamera.updateMatrixWorld()
225
860
  }
226
- restore(temporarilyHideNodeTypes(['scan', 'guide', 'spawn']))
227
-
228
- // Auto-save uses the published hero framing. An authored shot keeps
229
- // its own camera while sharing the same true level positions.
230
- if (snapLevels) {
231
- const framing = computeHeroFraming()
232
- if (framing) {
233
- const pose = heroCameraPose({
234
- boxes: framing.boxes,
235
- aim: framing.aim,
236
- azimuthRad: framing.azimuthRad,
237
- aspect: width / height,
238
- })
239
- thumbnailCamera.position.set(pose.position[0], pose.position[1], pose.position[2])
240
- thumbnailCamera.lookAt(pose.target[0], pose.target[1], pose.target[2])
241
- thumbnailCamera.updateMatrixWorld()
242
- pipeline?.applyEnvironment({
243
- theme: useViewer.getState().sceneTheme,
244
- transparent,
245
- grade: useViewer.getState().shading === 'rendered',
246
- edges: transparent ? 'off' : useViewer.getState().edges,
247
- camera: thumbnailCamera,
248
- })
249
- cameraData.position = pose.position
250
- cameraData.target = pose.target
861
+
862
+ pipeline?.applyEnvironment({
863
+ theme: useViewer.getState().sceneAppearance,
864
+ transparent,
865
+ grade: useViewer.getState().shading === 'rendered',
866
+ edges,
867
+ camera: thumbnailCamera,
868
+ })
869
+
870
+ // Capture camera data for snapshot storage
871
+ const pos = cameraPose ? thumbnailCamera.position : mainCamera.position
872
+ let tgt: [number, number, number] | null = null
873
+ if (!cameraPose && controls && 'getTarget' in controls) {
874
+ const v = new THREE.Vector3()
875
+ ;(controls as any).getTarget(v)
876
+ tgt = [v.x, v.y, v.z]
877
+ } else {
878
+ // Walk / drone captures run without orbit controls, so there is no orbit
879
+ // target to read. Synthesize one down the view axis — otherwise the
880
+ // saved snapshot carries no framing to return to.
881
+ const look = new THREE.Vector3(0, 0, -1)
882
+ .applyQuaternion(cameraPose ? thumbnailCamera.quaternion : mainCamera.quaternion)
883
+ .multiplyScalar(FIRST_PERSON_TARGET_DISTANCE)
884
+ .add(pos)
885
+ tgt = [look.x, look.y, look.z]
886
+ }
887
+ const isOrtho = !cameraPose && mainCamera instanceof THREE.OrthographicCamera
888
+ const cameraData: SnapshotCameraData = {
889
+ ...(requestId && { requestId }),
890
+ position: [pos.x, pos.y, pos.z],
891
+ ...(cameraPose && {
892
+ quaternion: [...cameraPose.quaternion] as [number, number, number, number],
893
+ fov: cameraPose.fov,
894
+ }),
895
+ target: tgt,
896
+ type: isOrtho ? 'orthographic' : 'perspective',
897
+ ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
898
+ }
899
+
900
+ if (pose) {
901
+ // a sheet's capture: the house as built — never a half-built scene
902
+ // or an item's loading placeholder (the cover once showed striped
903
+ // boxes through a white roof, 2026-09-23) — then the viewer's own
904
+ // systems (the wall materials, a plugin's batches) take two real
905
+ // frames — run by hand, never awaited from the animation loop: a
906
+ // hidden tab gets no animation frames at all, and a capture that
907
+ // waited on one hung the sheet's whole capture chain (2026-09-10)
908
+ const pending = await untilSceneBuilt()
909
+ trace('scene-built', { pending })
910
+ await pumpFrames(advance, clock, 2)
911
+ }
912
+
913
+ // the scene as the picture wants it, for one render; `restore` puts
914
+ // every change back right after it
915
+ const setUp = (restore: (callback: () => void) => void) => {
916
+ // A picture is read for its rooms, taken in the world's own light: the earth the
917
+ // person's view turns to under the ground (sky, fog, vignette, the sky's lights)
918
+ // is not in it.
919
+ restore(temporarilyWithoutAmbiance())
920
+ if (snapLevels) {
921
+ const prevMode = useViewer.getState().levelMode
922
+ if (prevMode !== 'stacked') {
923
+ restore(() => useViewer.getState().setLevelMode(prevMode))
924
+ useViewer.getState().setLevelMode('stacked')
925
+ }
926
+ restore(snapLevelsToTruePositions())
927
+ }
928
+ // Hide scan, guide, and spawn nodes (and what the caller asks) so
929
+ // they are excluded from the thumbnail regardless of whether the
930
+ // system listeners are registered.
931
+ restore(temporarilyHideNodeTypes(['scan', 'guide', 'spawn', ...hideTypes]))
932
+ // a sheet's picture is of the house: what a renderer excluded
933
+ // from captures stays out of it, and every storey stands at its
934
+ // true height
935
+ if (pose) {
936
+ restore(hideCaptureExcluded(scene))
937
+ restore(snapLevelsToTruePositions())
938
+ }
939
+ if (lightFace && pose) restore(aimLightsAtFace(scene, pose.position, pose.target))
940
+ if (clip.length > 0) {
941
+ restore(clipSceneFor(scene, clip))
942
+ restore(lightInterior(scene, thumbnailCamera))
943
+ }
944
+
945
+ // Auto-save uses the published hero framing. An authored shot keeps
946
+ // its own camera while sharing the same true level positions.
947
+ if (snapLevels) {
948
+ const framing = computeHeroFraming()
949
+ if (framing) {
950
+ const hero = heroCameraPose({
951
+ boxes: framing.boxes,
952
+ aim: framing.aim,
953
+ azimuthRad: framing.azimuthRad,
954
+ aspect: width / height,
955
+ })
956
+ thumbnailCamera.position.set(hero.position[0], hero.position[1], hero.position[2])
957
+ thumbnailCamera.lookAt(hero.target[0], hero.target[1], hero.target[2])
958
+ thumbnailCamera.updateMatrixWorld()
959
+ pipeline?.applyEnvironment({
960
+ theme: useViewer.getState().sceneAppearance,
961
+ transparent,
962
+ grade: useViewer.getState().shading === 'rendered',
963
+ edges,
964
+ camera: thumbnailCamera,
965
+ })
966
+ cameraData.position = hero.position
967
+ cameraData.target = hero.target
968
+ }
969
+ }
970
+
971
+ restore(() => emitter.emit('thumbnail:after-capture', undefined))
972
+ // Walls whole, as built, whatever the user's wall mode: an
973
+ // authored pose is not the user's view, an interior is cut below.
974
+ emitter.emit(
975
+ 'thumbnail:before-capture',
976
+ cameraPose || interior ? { wholeWalls: true } : undefined,
977
+ )
978
+ if (cameraPose) {
979
+ restore(snapLevelsToTruePositions())
980
+ restore(temporarilyShowShadowOnly(scene))
981
+ }
982
+
983
+ // Geometry and presentation systems may have mounted new meshes
984
+ // since the viewport's last frame. Apply the same isolation to
985
+ // those meshes before either snapshot render path submits them,
986
+ // and restore fog afterwards so the atmosphere never leaks in.
987
+ restore(refreshIsolation(scene))
988
+ if (event.presentation) {
989
+ restore(presentLevelsForCapture(event.presentation))
990
+ cameraData.presentation = event.presentation
991
+ }
992
+ if (interior) {
993
+ interiorCutaway(interior.levelId, restore, thumbnailCamera)
994
+ restore(lightInterior(scene, thumbnailCamera))
995
+ } else if (snapLevels || pose || cameraPose) {
996
+ exteriorGroundForCapture(restore, Boolean(cameraPose || pose), thumbnailCamera)
251
997
  }
252
998
  }
999
+ // The scene pass is a FRAME-updated node: it renders once per node
1000
+ // frame, and only the animation loop advances that frame. With the
1001
+ // viewer idle behind the Sheets overlay two captures share one
1002
+ // frame and the second returns the first's picture (2026-09-10:
1003
+ // the north elevation came back as the east). Advance it by hand.
1004
+ const advanceNodeFrame = () =>
1005
+ (
1006
+ gl as unknown as { _nodes?: { nodeFrame?: { update?: () => void } } }
1007
+ )._nodes?.nodeFrame?.update?.()
1008
+ // a sheet's picture goes to the sheets lossless: they pick its print encoding
1009
+ const mime = pose ? 'image/png' : undefined
1010
+ const tiles = pose && pipeline ? tileGrid(width, height, limits.passEdge) : []
253
1011
 
254
- restore(() => emitter.emit('thumbnail:after-capture', undefined))
255
- emitter.emit('thumbnail:before-capture', undefined)
256
- if (cameraPose) {
257
- restore(snapLevelsToTruePositions())
258
- restore(temporarilyShowShadowOnly(scene))
259
- const wallMaterials = new Map<THREE.Mesh, THREE.Material | THREE.Material[]>()
260
- restore(() => {
261
- for (const [mesh, material] of wallMaterials) mesh.material = material
1012
+ if (tiles.length > 1 && pipeline) {
1013
+ // Past one pass (the GPU's biggest texture, or MAX_PASS_EDGE) the
1014
+ // picture renders tile by tile — each a view offset of the same
1015
+ // camera, the scene set up afresh for it (the frame loop runs
1016
+ // between the readbacks) — and the tiles land on one canvas.
1017
+ trace('capture:tiles', {
1018
+ width,
1019
+ height,
1020
+ tiles: tiles.length,
1021
+ software: limits.software,
262
1022
  })
263
- const state = useScene.getState()
264
- const viewer = useViewer.getState()
265
- for (const id of sceneRegistry.byType.wall ?? []) {
266
- const node = state.nodes[id as AnyNodeId]
267
- const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh | undefined
268
- if (node?.type !== 'wall' || !mesh?.isMesh) continue
269
- wallMaterials.set(mesh, mesh.material)
270
- mesh.material = getVisibleWallMaterials(
271
- node,
272
- viewer.shading,
273
- viewer.textures,
274
- viewer.colorPreset,
275
- viewer.sceneTheme,
276
- state.materials,
1023
+ const picture = new OffscreenCanvas(width, height)
1024
+ const context = picture.getContext('2d')
1025
+ if (!context) throw new Error('The picture canvas could not be created')
1026
+ for (const tile of tiles) {
1027
+ const shot = captureSnapshotScene((restore) => {
1028
+ setUp(restore)
1029
+ thumbnailCamera.setViewOffset(width, height, tile.x, tile.y, tile.w, tile.h)
1030
+ restore(() => thumbnailCamera.clearViewOffset())
1031
+ thumbnailCamera.updateMatrixWorld()
1032
+ pipeline.applyEnvironment({
1033
+ theme: useViewer.getState().sceneAppearance,
1034
+ transparent,
1035
+ grade: useViewer.getState().shading === 'rendered',
1036
+ edges,
1037
+ camera: thumbnailCamera,
1038
+ })
1039
+ advanceNodeFrame()
1040
+ return atCaptureSize(gl as unknown as WebGPURenderer, tile.w, tile.h, () =>
1041
+ pipeline.capture({
1042
+ captureMode: 'standard',
1043
+ standardSize: { w: tile.w, h: tile.h },
1044
+ mime,
1045
+ }),
1046
+ )
1047
+ })
1048
+ const result = await within(
1049
+ Promise.resolve(shot),
1050
+ CAPTURE_SETTLE_MS,
1051
+ 'The GPU readback of the captured frame never came back.',
1052
+ )
1053
+ const bitmap = await createImageBitmap(result.blob)
1054
+ const { inner } = tile
1055
+ context.drawImage(
1056
+ bitmap,
1057
+ inner.x - tile.x,
1058
+ inner.y - tile.y,
1059
+ inner.w,
1060
+ inner.h,
1061
+ inner.x,
1062
+ inner.y,
1063
+ inner.w,
1064
+ inner.h,
277
1065
  )
1066
+ bitmap.close()
278
1067
  }
1068
+ const blob = await picture.convertToBlob({ type: 'image/png' })
1069
+ trace('capture:done', { w: width, h: height, bytes: blob.size, tiles: tiles.length })
1070
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
1071
+ cameraData.resolution = { w: width, h: height }
1072
+ if (
1073
+ version !== captureVersion.current ||
1074
+ perspectiveCamera !== thumbnailCameraRef.current
1075
+ ) {
1076
+ throw new Error('The scene changed during capture. Try again.')
1077
+ }
1078
+ trace('callback', { bytes: blob.size })
1079
+ await within(
1080
+ Promise.resolve(deliverSnapshot(event, blob, cameraData, onCapture)),
1081
+ CAPTURE_SETTLE_MS,
1082
+ 'The snapshot host never took the frame.',
1083
+ )
1084
+ return
279
1085
  }
280
1086
 
281
- // Geometry and presentation systems may have mounted new meshes
282
- // since the viewport's last frame. Apply the same isolation to
283
- // those meshes before either snapshot render path submits them,
284
- // and restore fog afterwards so the atmosphere never leaks in.
285
- restore(refreshIsolation(scene))
286
- if (pipeline) return pipeline.capture({ captureMode, cropRegion, standardSize })
287
- gl.render(scene, thumbnailCamera)
288
- return undefined
289
- })
290
-
291
- let blob: Blob
292
- if (pipeline) {
293
- const result = await capturePromise
294
- if (!result) throw new Error('Snapshot capture produced no image')
295
- blob = result.blob
296
- if (captureMode !== undefined) cameraData.captureMode = captureMode
297
- cameraData.resolution = { w: result.outW, h: result.outH }
298
- } else {
299
- await capturePromise
300
- let outW: number
301
- let outH: number
302
-
303
- if (captureMode === 'viewport') {
304
- ;({ w: outW, h: outH } = clampSnapshotSize(width, height))
305
- const offscreen = document.createElement('canvas')
306
- offscreen.width = outW
307
- offscreen.height = outH
308
- const ctx = offscreen.getContext('2d')!
309
- if (outW !== width || outH !== height) ctx.imageSmoothingQuality = 'high'
310
- ctx.drawImage(gl.domElement, 0, 0, width, height, 0, 0, outW, outH)
311
- blob = await new Promise<Blob>((resolve, reject) =>
312
- offscreen.toBlob(
313
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
314
- SNAPSHOT_MIME,
315
- SNAPSHOT_QUALITY,
316
- ),
317
- )
318
- } else if (captureMode === 'area' && cropRegion) {
319
- const sx = Math.round(cropRegion.x * width)
320
- const sy = Math.round(cropRegion.y * height)
321
- const sourceW = Math.round(cropRegion.width * width)
322
- const sourceH = Math.round(cropRegion.height * height)
323
- ;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
324
- const offscreen = document.createElement('canvas')
325
- offscreen.width = outW
326
- offscreen.height = outH
327
- const ctx = offscreen.getContext('2d')!
328
- if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
329
- ctx.drawImage(gl.domElement, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
330
- blob = await new Promise<Blob>((resolve, reject) =>
331
- offscreen.toBlob(
332
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
333
- SNAPSHOT_MIME,
334
- SNAPSHOT_QUALITY,
335
- ),
1087
+ const capturePromise = captureSnapshotScene((restore) => {
1088
+ setUp(restore)
1089
+ if (pipeline) {
1090
+ trace('capture:start', { ortho: wantOrtho, pipeline: true, width, height })
1091
+ advanceNodeFrame()
1092
+ return atCaptureSize(gl as unknown as WebGPURenderer, width, height, () =>
1093
+ pipeline.capture({ captureMode, cropRegion, standardSize: encodeSize, mime }),
1094
+ )
1095
+ }
1096
+ // a sheet's picture (or a supersampled one) is never drawn into
1097
+ // the live canvas the viewport shows
1098
+ if (pose || width !== canvasSize[0] || height !== canvasSize[1])
1099
+ throw new Error('Snapshot renderer is not ready. Try again.')
1100
+ // Fallback: plain render directly to the canvas
1101
+ if (transparent) {
1102
+ const clearColor = (
1103
+ gl as unknown as { getClearColor: (t: THREE.Color) => THREE.Color }
1104
+ ).getClearColor(new THREE.Color())
1105
+ const clearAlpha = gl.getClearAlpha()
1106
+ const sceneBackground = scene.background
1107
+ restore(() => {
1108
+ scene.background = sceneBackground
1109
+ gl.setClearColor(clearColor, clearAlpha)
1110
+ })
1111
+ scene.background = null
1112
+ gl.setClearColor(new THREE.Color('#ffffff'), 0)
1113
+ }
1114
+ trace('canvas:render', { ortho: wantOrtho })
1115
+ gl.render(scene, thumbnailCamera)
1116
+ return undefined
1117
+ })
1118
+
1119
+ let blob: Blob
1120
+ if (pipeline) {
1121
+ // the frame is rendered and the scene restored: a readback that
1122
+ // never returns must not hold every later capture in the queue
1123
+ const result = await within(
1124
+ Promise.resolve(capturePromise),
1125
+ CAPTURE_SETTLE_MS,
1126
+ 'The GPU readback of the captured frame never came back.',
336
1127
  )
1128
+ if (!result) throw new Error('Snapshot capture produced no image')
1129
+ trace('capture:done', { w: result.outW, h: result.outH, bytes: result.blob.size })
1130
+ blob = result.blob
1131
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
1132
+ cameraData.resolution = { w: result.outW, h: result.outH }
337
1133
  } else {
338
- const srcAspect = width / height
339
- const dstAspect = standardW / standardH
340
- let sx = 0,
341
- sy = 0,
342
- sWidth = width,
343
- sHeight = height
344
- if (srcAspect > dstAspect) {
345
- sWidth = Math.round(height * dstAspect)
346
- sx = Math.round((width - sWidth) / 2)
347
- } else if (srcAspect < dstAspect) {
348
- sHeight = Math.round(width / dstAspect)
349
- sy = Math.round((height - sHeight) / 2)
1134
+ await capturePromise
1135
+ let outW: number
1136
+ let outH: number
1137
+
1138
+ if (captureMode === 'viewport') {
1139
+ ;({ w: outW, h: outH } = clampSnapshotSize(width, height))
1140
+ const offscreen = document.createElement('canvas')
1141
+ offscreen.width = outW
1142
+ offscreen.height = outH
1143
+ const ctx = offscreen.getContext('2d')!
1144
+ if (outW !== width || outH !== height) ctx.imageSmoothingQuality = 'high'
1145
+ ctx.drawImage(gl.domElement, 0, 0, width, height, 0, 0, outW, outH)
1146
+ blob = await new Promise<Blob>((resolve, reject) =>
1147
+ offscreen.toBlob(
1148
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
1149
+ SNAPSHOT_MIME,
1150
+ SNAPSHOT_QUALITY,
1151
+ ),
1152
+ )
1153
+ } else if (captureMode === 'area' && cropRegion) {
1154
+ const sx = Math.round(cropRegion.x * width)
1155
+ const sy = Math.round(cropRegion.y * height)
1156
+ const sourceW = Math.round(cropRegion.width * width)
1157
+ const sourceH = Math.round(cropRegion.height * height)
1158
+ ;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
1159
+ const offscreen = document.createElement('canvas')
1160
+ offscreen.width = outW
1161
+ offscreen.height = outH
1162
+ const ctx = offscreen.getContext('2d')!
1163
+ if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
1164
+ ctx.drawImage(gl.domElement, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
1165
+ blob = await new Promise<Blob>((resolve, reject) =>
1166
+ offscreen.toBlob(
1167
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
1168
+ SNAPSHOT_MIME,
1169
+ SNAPSHOT_QUALITY,
1170
+ ),
1171
+ )
1172
+ } else {
1173
+ const srcAspect = width / height
1174
+ const dstAspect = standardW / standardH
1175
+ let sx = 0,
1176
+ sy = 0,
1177
+ sWidth = width,
1178
+ sHeight = height
1179
+ if (srcAspect > dstAspect) {
1180
+ sWidth = Math.round(height * dstAspect)
1181
+ sx = Math.round((width - sWidth) / 2)
1182
+ } else if (srcAspect < dstAspect) {
1183
+ sHeight = Math.round(width / dstAspect)
1184
+ sy = Math.round((height - sHeight) / 2)
1185
+ }
1186
+ outW = standardW
1187
+ outH = standardH
1188
+ const offscreen = document.createElement('canvas')
1189
+ offscreen.width = outW
1190
+ offscreen.height = outH
1191
+ offscreen
1192
+ .getContext('2d')!
1193
+ .drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
1194
+ blob = await new Promise<Blob>((resolve, reject) =>
1195
+ offscreen.toBlob(
1196
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
1197
+ SNAPSHOT_MIME,
1198
+ SNAPSHOT_QUALITY,
1199
+ ),
1200
+ )
350
1201
  }
351
- outW = standardW
352
- outH = standardH
353
- const offscreen = document.createElement('canvas')
354
- offscreen.width = outW
355
- offscreen.height = outH
356
- offscreen
357
- .getContext('2d')!
358
- .drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
359
- blob = await new Promise<Blob>((resolve, reject) =>
360
- offscreen.toBlob(
361
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
362
- SNAPSHOT_MIME,
363
- SNAPSHOT_QUALITY,
364
- ),
365
- )
366
- }
367
1202
 
368
- if (captureMode !== undefined) cameraData.captureMode = captureMode
369
- cameraData.resolution = { w: outW, h: outH }
370
- }
1203
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
1204
+ cameraData.resolution = { w: outW, h: outH }
1205
+ }
371
1206
 
372
- if (
373
- version !== captureVersion.current ||
374
- thumbnailCamera !== thumbnailCameraRef.current
375
- ) {
376
- throw new Error('The scene changed during capture. Try again.')
377
- }
378
- if (cameraPose && event.projectId !== useViewer.getState().projectId) {
379
- throw new Error('The active project changed during capture')
1207
+ if (
1208
+ version !== captureVersion.current ||
1209
+ perspectiveCamera !== thumbnailCameraRef.current
1210
+ ) {
1211
+ throw new Error('The scene changed during capture. Try again.')
1212
+ }
1213
+ if (cameraPose && event.projectId !== useViewer.getState().projectId) {
1214
+ throw new Error('The active project changed during capture')
1215
+ }
1216
+ trace('callback', { bytes: blob.size })
1217
+ await within(
1218
+ Promise.resolve(deliverSnapshot(event, blob, cameraData, onCapture)),
1219
+ CAPTURE_SETTLE_MS,
1220
+ 'The snapshot host never took the frame.',
1221
+ )
1222
+ } finally {
1223
+ releaseLiveFrame?.()
1224
+ // a sheet capture (transparent ground, ink edges, an orthographic
1225
+ // camera) sets the environment the live viewer shares; put the
1226
+ // viewer's own look back whatever happened above, or the live 3D
1227
+ // view keeps the capture's tinted (sepia-looking) environment
1228
+ if (transparent || edgesOverride !== undefined || ortho !== undefined) {
1229
+ try {
1230
+ pipelineRef.current?.applyEnvironment({
1231
+ theme: useViewer.getState().sceneAppearance,
1232
+ transparent: false,
1233
+ grade: useViewer.getState().shading === 'rendered',
1234
+ edges: useViewer.getState().edges,
1235
+ camera: mainCamera,
1236
+ })
1237
+ } catch (error) {
1238
+ trace('restore-environment', String(error))
1239
+ }
1240
+ }
380
1241
  }
381
- await onCapture(blob, cameraData)
382
1242
  },
383
1243
  (failure) => emitter.emit('snapshot:capture-failed', failure),
384
1244
  )
385
1245
  },
386
- [gl, scene, getThree],
1246
+ [gl, scene, getThree, orthoPipeline, advance, clock],
1247
+ )
1248
+
1249
+ // A run of captures (a sheet's pictures, an agent's view) sets the shared
1250
+ // scene up for its pictures — the finished presentation, the ground hidden —
1251
+ // so while any run is on the viewport keeps its last frame and the viewer
1252
+ // shows the finished house whatever view the user was in; the last run's
1253
+ // end puts the user's view back, then (a few commits later) the viewport
1254
+ // resumes. Runs overlap (a sheet and an agent's view): the user's view is
1255
+ // the one before the first, never a run's finished presentation.
1256
+ useEffect(
1257
+ () =>
1258
+ presentWhileCapturing(() => {
1259
+ const releaseLiveFrame = holdLiveFrame(HOLD_MAX_MS)
1260
+ const restorePresentation = presentFinished()
1261
+ return () => {
1262
+ restorePresentation()
1263
+ setTimeout(releaseLiveFrame, SESSION_RESUME_MS)
1264
+ }
1265
+ }),
1266
+ [],
387
1267
  )
388
1268
 
389
1269
  // Thumbnail request via emitter. Two call shapes: