@pascal-app/editor 1.0.1 → 1.0.4

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