@pascal-app/editor 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +40 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -1,14 +1,56 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
- import { type AnyNode, sceneRegistry, useScene } from '@pascal-app/core'
3
- import { BuildingNode, CeilingNode, LevelNode, WallNode } from '@pascal-app/core/schema'
4
- import { hideFromScene, SCENE_LAYER, useViewer } from '@pascal-app/viewer'
5
- import { Group, Layers, Mesh, OrthographicCamera, PerspectiveCamera, Vector2 } from 'three'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ createTerrainField,
6
+ emitter,
7
+ encodeTerrainField,
8
+ getLevelElevations,
9
+ sceneRegistry,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { type SceneViewPose, sceneViewPlan } from '@pascal-app/core/agent-operations'
13
+ import {
14
+ BuildingNode,
15
+ CeilingNode,
16
+ LevelNode,
17
+ SiteNode,
18
+ SlabNode,
19
+ WallNode,
20
+ } from '@pascal-app/core/schema'
21
+ import {
22
+ groundWindowBoxNow,
23
+ hideFromScene,
24
+ SCENE_LAYER,
25
+ SITE_GROUND,
26
+ setGroundWindow,
27
+ useViewer,
28
+ } from '@pascal-app/viewer'
29
+ import {
30
+ Group,
31
+ Layers,
32
+ Mesh,
33
+ type Object3D,
34
+ OrthographicCamera,
35
+ PerspectiveCamera,
36
+ PlaneGeometry,
37
+ Raycaster,
38
+ Scene,
39
+ Vector2,
40
+ Vector3,
41
+ } from 'three'
6
42
  import { CanvasTarget, ClippingGroup, type WebGPURenderer } from 'three/webgpu'
43
+ import hillside from '../../../../core/src/lib/__fixtures__/owner-hillside-grade.json'
44
+ import victorSeattle from '../../lib/__fixtures__/owner-victor-seattle-parking.json'
45
+ import villaAurelia from '../../lib/__fixtures__/owner-villa-aurelia-basement.json'
7
46
  import {
8
47
  atCaptureSize,
48
+ captureDimensions,
9
49
  capturesOrthographically,
50
+ exteriorGroundForCapture,
10
51
  interiorCutaway,
11
52
  presentFinished,
53
+ presentWhileCapturing,
12
54
  pumpFrames,
13
55
  tileGrid,
14
56
  } from './thumbnail-generator'
@@ -47,6 +89,34 @@ function fakeRenderer(canvas: FakeCanvas, pixelRatio: number) {
47
89
  }
48
90
 
49
91
  describe('supersampled capture', () => {
92
+ test('an explicit camera renders at the requested resolution and aspect without resizing the pane', () => {
93
+ const live: FakeCanvas = { width: 800, height: 1000, style: {} }
94
+ const renderer = fakeRenderer(live, 1)
95
+ const position: [number, number, number] = [2, 1, 3]
96
+ const target: [number, number, number] = [0, 0, 0]
97
+ for (const pose of [
98
+ { perspective: { position, target, fov: 35 } },
99
+ { ortho: { position, target, viewWidth: 4 } },
100
+ ]) {
101
+ const size = captureDimensions(
102
+ [800, 1000],
103
+ { ...pose, standardSize: { w: 1920, h: 1280 } },
104
+ 24_000_000,
105
+ )
106
+ expect(size).toEqual([1920, 1280])
107
+ const buffer = atCaptureSize(renderer, ...size, () =>
108
+ renderer.getDrawingBufferSize(new Vector2()).toArray(),
109
+ )
110
+ expect(buffer).toEqual([1920, 1280])
111
+ expect([live.width, live.height]).toEqual([800, 1000])
112
+ }
113
+ expect(
114
+ captureDimensions([800, 1000], { standardSize: { w: 1920, h: 1280 } }, 24_000_000),
115
+ ).toEqual([800, 1000])
116
+ expect(captureDimensions([800, 1000], { supersample: 2 }, 24_000_000)).toEqual([1600, 2000])
117
+ expect(captureDimensions([6000, 4000], { supersample: 2 }, 6_000_000)).toEqual([3000, 2000])
118
+ })
119
+
50
120
  test('renders at its own size while the live canvas keeps its size', () => {
51
121
  // a 1.5 dpr screen: the old capture resized the live canvas with drawing
52
122
  // buffer pixels where CSS pixels were due — 3× during the capture, 1.5×
@@ -177,6 +247,46 @@ describe('a sheet capture session', () => {
177
247
  unsubscribe()
178
248
  expect(writes).toBe(0)
179
249
  })
250
+
251
+ // A sheet's pictures and an agent's view can overlap: the user's view is the one before the
252
+ // first, never a run's finished presentation.
253
+ test('runs that overlap present once and put the view back after the last', () => {
254
+ const session = (owner: string, active: boolean) =>
255
+ emitter.emit('camera-controls:capture-session', { owner, active })
256
+ const steps: string[] = []
257
+ const stop = presentWhileCapturing(() => {
258
+ steps.push('present')
259
+ return () => steps.push('restore')
260
+ })
261
+ session('sheets-a', true)
262
+ session('view-b', true)
263
+ session('sheets-a', false)
264
+ expect(steps).toEqual(['present'])
265
+ session('nobody', false)
266
+ session('view-b', false)
267
+ expect(steps).toEqual(['present', 'restore'])
268
+ session('view-c', true)
269
+ stop()
270
+ expect(steps).toEqual(['present', 'restore', 'present', 'restore'])
271
+ session('view-c', false)
272
+ expect(steps).toHaveLength(4)
273
+ })
274
+
275
+ // The generator mounted while a sheet captures (a remount, the editor opened mid-run): the run's
276
+ // finished presentation from the first frame, the user's view back at its end.
277
+ test('a run already on when the generator mounts is presented at once', () => {
278
+ emitter.emit('camera-controls:capture-session', { owner: 'sheets-early', active: true })
279
+ const steps: string[] = []
280
+ const stop = presentWhileCapturing(() => {
281
+ steps.push('present')
282
+ return () => steps.push('restore')
283
+ })
284
+ expect(steps).toEqual(['present'])
285
+ emitter.emit('camera-controls:capture-session', { owner: 'sheets-early', active: false })
286
+ expect(steps).toEqual(['present', 'restore'])
287
+ stop()
288
+ expect(steps).toEqual(['present', 'restore'])
289
+ })
180
290
  })
181
291
 
182
292
  describe('a print-size picture past one render pass', () => {
@@ -289,6 +399,242 @@ describe('an interior view of a floor', () => {
289
399
  })
290
400
  })
291
401
 
402
+ // What a capture's own camera sees of the ground, on the owner's scenes (13 and 14 Oct, trimmed to
403
+ // their levels, slabs and walls) and a review's plate: the basement's floor and not the lawn, the
404
+ // window opened for the frame (and the person's own given back), and nothing opened from the
405
+ // garden side. Each test runs the cutaway as the capture does, looks, and restores.
406
+ describe('what a capture sees of the ground', () => {
407
+ let previousScene: ReturnType<typeof useScene.getState>
408
+ beforeEach(() => {
409
+ previousScene = useScene.getState()
410
+ })
411
+ afterEach(() => {
412
+ sceneRegistry.clear()
413
+ useScene.setState(previousScene, true)
414
+ })
415
+
416
+ /**
417
+ * The scene as the editor holds it: every level's object at its height, a floor in `basement`, and
418
+ * the site's lawn (wider than any frame) with its veil, hidden. The veil is never picked, as
419
+ * GroundVeil mounts it: the ray below sees through it.
420
+ */
421
+ function editorScene(
422
+ nodes: Record<AnyNodeId, AnyNode>,
423
+ basement?: string,
424
+ siteId = 'site_owner',
425
+ ) {
426
+ useScene.setState({ nodes } as never)
427
+ const scene = new Scene()
428
+ for (const [levelId, { baseY }] of getLevelElevations(nodes)) {
429
+ const level = new Group()
430
+ level.position.y = baseY
431
+ scene.add(level)
432
+ sceneRegistry.nodes.set(levelId, level)
433
+ }
434
+ const floor = new Mesh(new PlaneGeometry(400, 400).rotateX(-Math.PI / 2))
435
+ if (basement) sceneRegistry.nodes.get(basement)?.add(floor)
436
+ const ground = new Mesh(new PlaneGeometry(400, 400).rotateX(-Math.PI / 2))
437
+ ground.userData[SITE_GROUND] = 'solid'
438
+ const veil = new Mesh(ground.geometry)
439
+ veil.userData[SITE_GROUND] = 'veil'
440
+ veil.visible = false
441
+ veil.raycast = () => {}
442
+ const lotLine = new Mesh()
443
+ const site = new Group().add(ground, veil, lotLine)
444
+ scene.add(site)
445
+ sceneRegistry.nodes.set(siteId, site)
446
+ sceneRegistry.byType.site?.add(siteId)
447
+ return { scene, floor, ground, veil, lotLine }
448
+ }
449
+
450
+ /** Opens the capture's ground cutaway, runs `look`, restores in reverse; returns what `look` returns. */
451
+ function whileCut<T>(open: (add: (restore: () => void) => void) => void, look: () => T): T {
452
+ const restorers: (() => void)[] = []
453
+ open((restore) => restorers.push(restore))
454
+ try {
455
+ return look()
456
+ } finally {
457
+ for (const restore of restorers.reverse()) restore()
458
+ }
459
+ }
460
+
461
+ /** The first thing the capture camera draws on the line from its eye to its aim. */
462
+ function firstSeen(scene: Scene, pose: SceneViewPose): Object3D | undefined {
463
+ const eye = new Vector3(...pose.position)
464
+ const ray = new Raycaster(eye, new Vector3(...pose.target).sub(eye).normalize())
465
+ ray.layers.set(SCENE_LAYER)
466
+ scene.updateMatrixWorld(true)
467
+ const drawn = (object: Object3D | null): boolean =>
468
+ !object || (object.visible && drawn(object.parent))
469
+ return ray.intersectObject(scene, true).find((hit) => drawn(hit.object))?.object
470
+ }
471
+
472
+ const cameraAt = (position: [number, number, number]) => {
473
+ const camera = new PerspectiveCamera()
474
+ camera.position.set(...position)
475
+ return camera
476
+ }
477
+ const persons = {
478
+ min: [-6, 0, -7] as [number, number, number],
479
+ max: [6, 3, 7] as [number, number, number],
480
+ }
481
+
482
+ // The villa's Lower Ground 3.35 m under the lawn, the Victor's parking 2.61 m under it and past
483
+ // its west lot line.
484
+ test.each([
485
+ ['the villa Aurelia', villaAurelia, 'level_68jf2zqaukfjof3m', 'level_ztimtvrgdzzd6c9f'],
486
+ ['the Victor Seattle', victorSeattle, 'level_q209tmgwuzcahwzj', 'level_wj5tz664u03eolux'],
487
+ ] as const)(
488
+ "the agent's picture of %s's basement sees its floor, not the lawn; a floor at grade is pictured on solid ground, even veiled",
489
+ (_, owner, basement, atGrade) => {
490
+ const nodes = owner.nodes as unknown as Record<AnyNodeId, AnyNode>
491
+ const plan = sceneViewPlan(nodes, { target: basement })
492
+ const { scene, floor, ground, veil, lotLine } = editorScene(nodes, basement)
493
+ expect(firstSeen(scene, plan.pose)).toBe(ground)
494
+ whileCut(
495
+ (add) => interiorCutaway(basement, add),
496
+ () => {
497
+ expect(firstSeen(scene, plan.pose)).toBe(floor)
498
+ expect(veil.visible).toBe(true)
499
+ expect(lotLine.layers.isEnabled(SCENE_LAYER)).toBe(true)
500
+ },
501
+ )
502
+ expect(firstSeen(scene, plan.pose)).toBe(ground)
503
+ expect(veil.visible).toBe(false)
504
+
505
+ // A floor at grade while the person has the ground veiled and aside.
506
+ hideFromScene(ground, 'ground-aside')
507
+ veil.visible = true
508
+ whileCut(
509
+ (add) => interiorCutaway(atGrade, add),
510
+ () => {
511
+ expect(ground.layers.isEnabled(SCENE_LAYER)).toBe(true)
512
+ expect(veil.visible).toBe(false)
513
+ },
514
+ )
515
+ expect(ground.layers.isEnabled(SCENE_LAYER)).toBe(false)
516
+ expect(veil.visible).toBe(true)
517
+ },
518
+ )
519
+
520
+ // The owner's hillside house (14 Oct): no floor buried, a 6 m plateau on the street side. A
521
+ // capture from the street sees the house through the ground window, from its own camera,
522
+ // whatever the person's view holds; from the garden side the ground stands solid.
523
+ describe('on the hillside house', () => {
524
+ const nodes = hillside.nodes as unknown as Record<AnyNodeId, AnyNode>
525
+ const garden = Object.values(nodes).find(
526
+ (node) => node.type === 'level' && node.name === 'Garden level · walk-out',
527
+ )
528
+ if (!garden) throw new Error('the hillside fixture is missing its garden level')
529
+
530
+ test('from the street side the window opens for the frame and closes after it, the solid ground staying drawn; from the garden side none opens', () => {
531
+ const { ground, veil } = editorScene(nodes, undefined, 'site_hillside')
532
+ whileCut(
533
+ (add) => interiorCutaway(garden.id, add, cameraAt([0, 7, -20])),
534
+ () => {
535
+ expect(groundWindowBoxNow()).not.toBeNull()
536
+ expect(veil.visible).toBe(true)
537
+ // Its window is cut per fragment, not by hiding it.
538
+ expect(ground.layers.isEnabled(SCENE_LAYER)).toBe(true)
539
+ },
540
+ )
541
+ expect(groundWindowBoxNow()).toBeNull()
542
+ expect(veil.visible).toBe(false)
543
+ whileCut(
544
+ (add) => interiorCutaway(garden.id, add, cameraAt([0, 3, 20])),
545
+ () => {
546
+ expect(groundWindowBoxNow()).toBeNull()
547
+ expect(veil.visible).toBe(false)
548
+ },
549
+ )
550
+ })
551
+
552
+ // Both agent lanes ask with a perspective/orthographic pose of their own, not a cameraPose:
553
+ // the posed exterior frame decides its window from its own eye, not the person's closed one.
554
+ test('a posed exterior capture decides its own window and gives the person’s back; an unposed one decides none', () => {
555
+ const { veil } = editorScene(nodes, undefined, 'site_hillside')
556
+ whileCut(
557
+ (add) => exteriorGroundForCapture(add, true, cameraAt([0, 7, -20])),
558
+ () => {
559
+ expect(groundWindowBoxNow()).not.toBeNull()
560
+ expect(veil.visible).toBe(true)
561
+ },
562
+ )
563
+ expect(groundWindowBoxNow()).toBeNull()
564
+ expect(veil.visible).toBe(false)
565
+ // The person's window, open for their eye, closes for a garden-side frame and returns; an
566
+ // unposed capture leaves it alone.
567
+ setGroundWindow(persons)
568
+ whileCut(
569
+ (add) => exteriorGroundForCapture(add, true, cameraAt([0, 3, 20])),
570
+ () => expect(groundWindowBoxNow()).toBeNull(),
571
+ )
572
+ expect(groundWindowBoxNow()).toBe(persons)
573
+ whileCut(
574
+ (add) => exteriorGroundForCapture(add, false, cameraAt([0, 3, 20])),
575
+ () => expect(groundWindowBoxNow()).toBe(persons),
576
+ )
577
+ expect(groundWindowBoxNow()).toBe(persons)
578
+ setGroundWindow(null)
579
+ })
580
+ })
581
+
582
+ // The review's plate (14 Oct): [-5,5] × [-4,4] at Y 0.2, terrain 2 m only inside x 0…4, z -3…3.
583
+ // An interior capture from overhead opens the window over the buried eastern rooms: partly below
584
+ // grade has no wholesale veil to fall back on.
585
+ test('from overhead the window opens over the buried rooms of a floor partly below grade, and closes after', () => {
586
+ const field = createTerrainField({ cols: 25, rows: 25, spacing: 1, origin: [-12, -12] })
587
+ const heights = new Int16Array(field.heights)
588
+ for (let row = 0; row < field.rows; row += 1)
589
+ for (let col = 0; col < field.cols; col += 1) {
590
+ const x = field.origin[0] + col * field.spacing
591
+ const z = field.origin[1] + row * field.spacing
592
+ if (x >= 0 && x <= 4 && z >= -3 && z <= 3) heights[row * field.cols + col] = 200
593
+ }
594
+ const list: AnyNode[] = [
595
+ SiteNode.parse({
596
+ id: 'site_a',
597
+ children: ['building_a'],
598
+ terrain: encodeTerrainField({ ...field, heights }),
599
+ }),
600
+ BuildingNode.parse({ id: 'building_a', parentId: 'site_a', children: ['level_0'] }),
601
+ LevelNode.parse({
602
+ id: 'level_0',
603
+ level: 0,
604
+ height: 3,
605
+ parentId: 'building_a',
606
+ children: ['slab_0'],
607
+ }),
608
+ SlabNode.parse({
609
+ id: 'slab_0',
610
+ parentId: 'level_0',
611
+ elevation: 0.2,
612
+ polygon: [
613
+ [-5, -4],
614
+ [5, -4],
615
+ [5, 4],
616
+ [-5, 4],
617
+ ],
618
+ }),
619
+ ]
620
+ const { ground, veil } = editorScene(
621
+ Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>,
622
+ undefined,
623
+ 'site_a',
624
+ )
625
+ whileCut(
626
+ (add) => interiorCutaway('level_0', add, cameraAt([0, 15, 0])),
627
+ () => {
628
+ expect(groundWindowBoxNow()).not.toBeNull()
629
+ expect(veil.visible).toBe(true)
630
+ expect(ground.layers.isEnabled(SCENE_LAYER)).toBe(true)
631
+ },
632
+ )
633
+ expect(groundWindowBoxNow()).toBeNull()
634
+ expect(veil.visible).toBe(false)
635
+ })
636
+ })
637
+
292
638
  describe("a capture's projection", () => {
293
639
  const user = { ortho: new OrthographicCamera(), perspective: new PerspectiveCamera() }
294
640
  const view = { position: [8, 6, 8], target: [0, 1, 0] } as never
@@ -1,6 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { emitter, sceneRegistry, type ThumbnailGenerateEvent, useScene } from '@pascal-app/core'
3
+ import {
4
+ emitter,
5
+ isCaptureSessionOn,
6
+ levelGradeStanding,
7
+ sceneRegistry,
8
+ subscribeCaptureSessions,
9
+ type ThumbnailGenerateEvent,
10
+ useScene,
11
+ } from '@pascal-app/core'
4
12
  import { sceneViewInterior } from '@pascal-app/core/agent-operations'
5
13
  import {
6
14
  computeHeroFraming,
@@ -9,6 +17,7 @@ import {
9
17
  heroCameraPose,
10
18
  holdLiveFrame,
11
19
  pendingSceneBuildCount,
20
+ presentLevelsForCapture,
12
21
  refreshIsolation,
13
22
  SNAPSHOT_MAX_EDGE,
14
23
  SNAPSHOT_MIME,
@@ -17,9 +26,12 @@ import {
17
26
  snapLevelsToTruePositions,
18
27
  THUMBNAIL_HEIGHT,
19
28
  THUMBNAIL_WIDTH,
29
+ temporarilyGroundWindowForCapture,
20
30
  temporarilyHideNodeTypes,
21
31
  temporarilyShowPresentation,
22
32
  temporarilyShowShadowOnly,
33
+ temporarilyVeilGround,
34
+ temporarilyWithoutAmbiance,
23
35
  useSceneAtmosphere,
24
36
  useViewer,
25
37
  } from '@pascal-app/viewer'
@@ -48,6 +60,7 @@ export interface SnapshotCameraData {
48
60
  zoom?: number
49
61
  captureMode?: 'standard' | 'viewport' | 'area'
50
62
  resolution?: { w: number; h: number }
63
+ presentation?: ThumbnailGenerateEvent['presentation']
51
64
  }
52
65
 
53
66
  interface ThumbnailGeneratorProps {
@@ -69,6 +82,25 @@ const TILE_OVERLAP = 96
69
82
 
70
83
  type CaptureLimits = { passEdge: number; maxPixels: number; software: boolean }
71
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
+
72
104
  /**
73
105
  * What one capture may ask of this GPU: a pass no larger than its biggest
74
106
  * texture (and MAX_PASS_EDGE), and — on a software renderer, where a big
@@ -443,6 +475,29 @@ export function presentFinished(): () => void {
443
475
  }
444
476
  }
445
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
+
446
501
  /** A yield to the next macrotask through a MessageChannel — unlike a timer, never throttled in a hidden tab. */
447
502
  function macrotask(): Promise<void> {
448
503
  return new Promise((resolve) => {
@@ -492,10 +547,18 @@ function clipSceneFor(
492
547
  * floors and this floor's roofs and ceilings out of the picture, everything
493
548
  * on it cut where core says (sceneViewInterior, from the scene as this editor
494
549
  * has it) and its shadows with it, and whatever the user's solo or isolation
495
- * holds back on it shown.
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).
496
554
  */
497
- export function interiorCutaway(levelId: string, restore: (callback: () => void) => void) {
498
- const { cutHeight, hiddenIds } = sceneViewInterior(useScene.getState().nodes, levelId)
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)
499
562
  const floor = sceneRegistry.nodes.get(levelId)
500
563
  if (!floor) throw new Error('The floor to look inside is not built yet. Try again.')
501
564
  for (const id of hiddenIds) {
@@ -508,6 +571,29 @@ export function interiorCutaway(levelId: string, restore: (callback: () => void)
508
571
  }
509
572
  restore(temporarilyShowPresentation(floor))
510
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))
511
597
  }
512
598
 
513
599
  /**
@@ -670,21 +756,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
670
756
  // a supersampled frame renders at a multiple of the canvas' size into
671
757
  // the capture's own targets; the live canvas is never resized
672
758
  const canvasSize: [number, number] = [gl.domElement.width, gl.domElement.height]
759
+ const pose = ortho ?? perspective
673
760
  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)
761
+ const [width, height] = captureDimensions(canvasSize, event, limits.maxPixels)
684
762
  // a supersampled picture is encoded at the size it could render (the
685
763
  // caller asked for canvas × supersample, maybe past this GPU's limits)
686
764
  const encodeSize = supersample > 1 ? { w: width, h: height } : standardSize
687
- const pose = ortho ?? perspective
688
765
  // a background tab draws no frames and (on the WebGL backend) never
689
766
  // finishes a readback: a sheet's capture says so at once
690
767
  if (pose && isTabHidden()) throw new Error(HIDDEN_TAB)
@@ -783,7 +860,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
783
860
  }
784
861
 
785
862
  pipeline?.applyEnvironment({
786
- theme: useViewer.getState().sceneTheme,
863
+ theme: useViewer.getState().sceneAppearance,
787
864
  transparent,
788
865
  grade: useViewer.getState().shading === 'rendered',
789
866
  edges,
@@ -836,6 +913,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
836
913
  // the scene as the picture wants it, for one render; `restore` puts
837
914
  // every change back right after it
838
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())
839
920
  if (snapLevels) {
840
921
  const prevMode = useViewer.getState().levelMode
841
922
  if (prevMode !== 'stacked') {
@@ -876,7 +957,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
876
957
  thumbnailCamera.lookAt(hero.target[0], hero.target[1], hero.target[2])
877
958
  thumbnailCamera.updateMatrixWorld()
878
959
  pipeline?.applyEnvironment({
879
- theme: useViewer.getState().sceneTheme,
960
+ theme: useViewer.getState().sceneAppearance,
880
961
  transparent,
881
962
  grade: useViewer.getState().shading === 'rendered',
882
963
  edges,
@@ -904,9 +985,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
904
985
  // those meshes before either snapshot render path submits them,
905
986
  // and restore fog afterwards so the atmosphere never leaks in.
906
987
  restore(refreshIsolation(scene))
988
+ if (event.presentation) {
989
+ restore(presentLevelsForCapture(event.presentation))
990
+ cameraData.presentation = event.presentation
991
+ }
907
992
  if (interior) {
908
- interiorCutaway(interior.levelId, restore)
993
+ interiorCutaway(interior.levelId, restore, thumbnailCamera)
909
994
  restore(lightInterior(scene, thumbnailCamera))
995
+ } else if (snapLevels || pose || cameraPose) {
996
+ exteriorGroundForCapture(restore, Boolean(cameraPose || pose), thumbnailCamera)
910
997
  }
911
998
  }
912
999
  // The scene pass is a FRAME-updated node: it renders once per node
@@ -943,7 +1030,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
943
1030
  restore(() => thumbnailCamera.clearViewOffset())
944
1031
  thumbnailCamera.updateMatrixWorld()
945
1032
  pipeline.applyEnvironment({
946
- theme: useViewer.getState().sceneTheme,
1033
+ theme: useViewer.getState().sceneAppearance,
947
1034
  transparent,
948
1035
  grade: useViewer.getState().shading === 'rendered',
949
1036
  edges,
@@ -1008,7 +1095,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
1008
1095
  }
1009
1096
  // a sheet's picture (or a supersampled one) is never drawn into
1010
1097
  // the live canvas the viewport shows
1011
- if (pose || scale > 1) throw new Error('Snapshot renderer is not ready. Try again.')
1098
+ if (pose || width !== canvasSize[0] || height !== canvasSize[1])
1099
+ throw new Error('Snapshot renderer is not ready. Try again.')
1012
1100
  // Fallback: plain render directly to the canvas
1013
1101
  if (transparent) {
1014
1102
  const clearColor = (
@@ -1140,7 +1228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
1140
1228
  if (transparent || edgesOverride !== undefined || ortho !== undefined) {
1141
1229
  try {
1142
1230
  pipelineRef.current?.applyEnvironment({
1143
- theme: useViewer.getState().sceneTheme,
1231
+ theme: useViewer.getState().sceneAppearance,
1144
1232
  transparent: false,
1145
1233
  grade: useViewer.getState().shading === 'rendered',
1146
1234
  edges: useViewer.getState().edges,
@@ -1158,39 +1246,25 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
1158
1246
  [gl, scene, getThree, orthoPipeline, advance, clock],
1159
1247
  )
1160
1248
 
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
- }, [])
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
+ [],
1267
+ )
1194
1268
 
1195
1269
  // Thumbnail request via emitter. Two call shapes:
1196
1270
  // - user-driven capture: `{ projectId, captureMode, cropRegion }` — captures