@ifc-lite/renderer 3.1.0 → 4.2.0

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 (429) hide show
  1. package/README.md +22 -0
  2. package/dist/aabb-edges.d.ts +10 -0
  3. package/dist/aabb-edges.d.ts.map +1 -1
  4. package/dist/aabb-edges.js +13 -0
  5. package/dist/aabb-edges.js.map +1 -1
  6. package/dist/ao-params.d.ts +85 -0
  7. package/dist/ao-params.d.ts.map +1 -0
  8. package/dist/ao-params.js +109 -0
  9. package/dist/ao-params.js.map +1 -0
  10. package/dist/ao-pass.d.ts +47 -0
  11. package/dist/ao-pass.d.ts.map +1 -0
  12. package/dist/ao-pass.js +157 -0
  13. package/dist/ao-pass.js.map +1 -0
  14. package/dist/appearance-preview.d.ts +2 -0
  15. package/dist/appearance-preview.d.ts.map +1 -1
  16. package/dist/appearance-preview.js +4 -0
  17. package/dist/appearance-preview.js.map +1 -1
  18. package/dist/camera-animation-immediate.d.ts +5 -0
  19. package/dist/camera-animation-immediate.d.ts.map +1 -0
  20. package/dist/camera-animation-immediate.js +16 -0
  21. package/dist/camera-animation-immediate.js.map +1 -0
  22. package/dist/camera-animation.d.ts +3 -9
  23. package/dist/camera-animation.d.ts.map +1 -1
  24. package/dist/camera-animation.js +14 -9
  25. package/dist/camera-animation.js.map +1 -1
  26. package/dist/camera-matrices.d.ts +3 -0
  27. package/dist/camera-matrices.d.ts.map +1 -1
  28. package/dist/camera-matrices.js +12 -1
  29. package/dist/camera-matrices.js.map +1 -1
  30. package/dist/camera-orientation.d.ts +62 -0
  31. package/dist/camera-orientation.d.ts.map +1 -0
  32. package/dist/camera-orientation.js +155 -0
  33. package/dist/camera-orientation.js.map +1 -0
  34. package/dist/camera-projection.d.ts +3 -1
  35. package/dist/camera-projection.d.ts.map +1 -1
  36. package/dist/camera-projection.js +26 -20
  37. package/dist/camera-projection.js.map +1 -1
  38. package/dist/camera-surface-zoom.d.ts +32 -0
  39. package/dist/camera-surface-zoom.d.ts.map +1 -0
  40. package/dist/camera-surface-zoom.js +37 -0
  41. package/dist/camera-surface-zoom.js.map +1 -0
  42. package/dist/camera.d.ts +32 -61
  43. package/dist/camera.d.ts.map +1 -1
  44. package/dist/camera.js +71 -167
  45. package/dist/camera.js.map +1 -1
  46. package/dist/clash-solid-pipeline.d.ts +9 -3
  47. package/dist/clash-solid-pipeline.d.ts.map +1 -1
  48. package/dist/clash-solid-pipeline.js +31 -8
  49. package/dist/clash-solid-pipeline.js.map +1 -1
  50. package/dist/constants.d.ts +1 -1
  51. package/dist/constants.d.ts.map +1 -1
  52. package/dist/constants.js +4 -3
  53. package/dist/constants.js.map +1 -1
  54. package/dist/depth-reconstruct.d.ts +43 -0
  55. package/dist/depth-reconstruct.d.ts.map +1 -0
  56. package/dist/depth-reconstruct.js +35 -0
  57. package/dist/depth-reconstruct.js.map +1 -0
  58. package/dist/device-recovery.d.ts +60 -0
  59. package/dist/device-recovery.d.ts.map +1 -0
  60. package/dist/device-recovery.js +153 -0
  61. package/dist/device-recovery.js.map +1 -0
  62. package/dist/device.d.ts +2 -0
  63. package/dist/device.d.ts.map +1 -1
  64. package/dist/device.js +13 -1
  65. package/dist/device.js.map +1 -1
  66. package/dist/environment.d.ts +26 -15
  67. package/dist/environment.d.ts.map +1 -1
  68. package/dist/environment.js +24 -13
  69. package/dist/environment.js.map +1 -1
  70. package/dist/federation-registry.d.ts +16 -0
  71. package/dist/federation-registry.d.ts.map +1 -1
  72. package/dist/federation-registry.js +80 -9
  73. package/dist/federation-registry.js.map +1 -1
  74. package/dist/gpu-static-upload.d.ts +39 -0
  75. package/dist/gpu-static-upload.d.ts.map +1 -0
  76. package/dist/gpu-static-upload.js +70 -0
  77. package/dist/gpu-static-upload.js.map +1 -0
  78. package/dist/gpu-upload-guard.d.ts +95 -0
  79. package/dist/gpu-upload-guard.d.ts.map +1 -0
  80. package/dist/gpu-upload-guard.js +88 -0
  81. package/dist/gpu-upload-guard.js.map +1 -0
  82. package/dist/index.d.ts +71 -41
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +340 -375
  85. package/dist/index.js.map +1 -1
  86. package/dist/instanced-override-color.d.ts +8 -0
  87. package/dist/instanced-override-color.d.ts.map +1 -1
  88. package/dist/instanced-override-color.js +10 -0
  89. package/dist/instanced-override-color.js.map +1 -1
  90. package/dist/instanced-render.d.ts +22 -2
  91. package/dist/instanced-render.d.ts.map +1 -1
  92. package/dist/instanced-render.js +47 -10
  93. package/dist/instanced-render.js.map +1 -1
  94. package/dist/instanced-rte.d.ts +17 -0
  95. package/dist/instanced-rte.d.ts.map +1 -0
  96. package/dist/instanced-rte.js +32 -0
  97. package/dist/instanced-rte.js.map +1 -0
  98. package/dist/merged-mesh-extract.d.ts.map +1 -1
  99. package/dist/merged-mesh-extract.js +5 -0
  100. package/dist/merged-mesh-extract.js.map +1 -1
  101. package/dist/mesh-rte-uniforms.d.ts +46 -0
  102. package/dist/mesh-rte-uniforms.d.ts.map +1 -0
  103. package/dist/mesh-rte-uniforms.js +59 -0
  104. package/dist/mesh-rte-uniforms.js.map +1 -0
  105. package/dist/model-bounds-tracker.d.ts +7 -0
  106. package/dist/model-bounds-tracker.d.ts.map +1 -1
  107. package/dist/model-bounds-tracker.js +16 -5
  108. package/dist/model-bounds-tracker.js.map +1 -1
  109. package/dist/model-translation.d.ts +40 -2
  110. package/dist/model-translation.d.ts.map +1 -1
  111. package/dist/model-translation.js +152 -17
  112. package/dist/model-translation.js.map +1 -1
  113. package/dist/pick-rte-snapshot.d.ts +7 -0
  114. package/dist/pick-rte-snapshot.d.ts.map +1 -0
  115. package/dist/pick-rte-snapshot.js +15 -0
  116. package/dist/pick-rte-snapshot.js.map +1 -0
  117. package/dist/pick-types.d.ts +17 -0
  118. package/dist/pick-types.d.ts.map +1 -0
  119. package/dist/pick-types.js +5 -0
  120. package/dist/pick-types.js.map +1 -0
  121. package/dist/pick-uniforms.d.ts +6 -0
  122. package/dist/pick-uniforms.d.ts.map +1 -1
  123. package/dist/pick-uniforms.js +22 -18
  124. package/dist/pick-uniforms.js.map +1 -1
  125. package/dist/pick-world-position.d.ts +18 -0
  126. package/dist/pick-world-position.d.ts.map +1 -0
  127. package/dist/pick-world-position.js +21 -0
  128. package/dist/pick-world-position.js.map +1 -0
  129. package/dist/picker-readbacks.d.ts +6 -0
  130. package/dist/picker-readbacks.d.ts.map +1 -0
  131. package/dist/picker-readbacks.js +20 -0
  132. package/dist/picker-readbacks.js.map +1 -0
  133. package/dist/picker-rte-uniforms.d.ts +6 -0
  134. package/dist/picker-rte-uniforms.d.ts.map +1 -0
  135. package/dist/picker-rte-uniforms.js +41 -0
  136. package/dist/picker-rte-uniforms.js.map +1 -0
  137. package/dist/picker.d.ts +6 -7
  138. package/dist/picker.d.ts.map +1 -1
  139. package/dist/picker.js +88 -115
  140. package/dist/picker.js.map +1 -1
  141. package/dist/picking-manager.d.ts +10 -1
  142. package/dist/picking-manager.d.ts.map +1 -1
  143. package/dist/picking-manager.js +57 -21
  144. package/dist/picking-manager.js.map +1 -1
  145. package/dist/pipeline.d.ts +1 -7
  146. package/dist/pipeline.d.ts.map +1 -1
  147. package/dist/pipeline.js +17 -20
  148. package/dist/pipeline.js.map +1 -1
  149. package/dist/point-picker.d.ts +5 -1
  150. package/dist/point-picker.d.ts.map +1 -1
  151. package/dist/point-picker.js +49 -9
  152. package/dist/point-picker.js.map +1 -1
  153. package/dist/pointcloud/point-cloud-handle-ids.d.ts +22 -0
  154. package/dist/pointcloud/point-cloud-handle-ids.d.ts.map +1 -0
  155. package/dist/pointcloud/point-cloud-handle-ids.js +31 -0
  156. package/dist/pointcloud/point-cloud-handle-ids.js.map +1 -0
  157. package/dist/pointcloud/point-cloud-node.d.ts +14 -6
  158. package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
  159. package/dist/pointcloud/point-cloud-node.js.map +1 -1
  160. package/dist/pointcloud/point-cloud-pick-sources.d.ts +47 -0
  161. package/dist/pointcloud/point-cloud-pick-sources.d.ts.map +1 -0
  162. package/dist/pointcloud/point-cloud-pick-sources.js +32 -0
  163. package/dist/pointcloud/point-cloud-pick-sources.js.map +1 -0
  164. package/dist/pointcloud/point-cloud-placement.d.ts.map +1 -1
  165. package/dist/pointcloud/point-cloud-placement.js +20 -5
  166. package/dist/pointcloud/point-cloud-placement.js.map +1 -1
  167. package/dist/pointcloud/point-cloud-ray-transform.d.ts +78 -4
  168. package/dist/pointcloud/point-cloud-ray-transform.d.ts.map +1 -1
  169. package/dist/pointcloud/point-cloud-ray-transform.js +31 -84
  170. package/dist/pointcloud/point-cloud-ray-transform.js.map +1 -1
  171. package/dist/pointcloud/point-cloud-renderer.d.ts +21 -19
  172. package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
  173. package/dist/pointcloud/point-cloud-renderer.js +46 -52
  174. package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
  175. package/dist/pointcloud/point-cloud-stream-lifecycle.d.ts +44 -0
  176. package/dist/pointcloud/point-cloud-stream-lifecycle.d.ts.map +1 -0
  177. package/dist/pointcloud/point-cloud-stream-lifecycle.js +44 -0
  178. package/dist/pointcloud/point-cloud-stream-lifecycle.js.map +1 -0
  179. package/dist/pointcloud/point-cloud-uniforms.d.ts +5 -0
  180. package/dist/pointcloud/point-cloud-uniforms.d.ts.map +1 -1
  181. package/dist/pointcloud/point-cloud-uniforms.js +55 -44
  182. package/dist/pointcloud/point-cloud-uniforms.js.map +1 -1
  183. package/dist/pointcloud/point-cloud-visibility.d.ts +10 -0
  184. package/dist/pointcloud/point-cloud-visibility.d.ts.map +1 -0
  185. package/dist/pointcloud/point-cloud-visibility.js +18 -0
  186. package/dist/pointcloud/point-cloud-visibility.js.map +1 -0
  187. package/dist/pointcloud/point-pipeline.d.ts +4 -9
  188. package/dist/pointcloud/point-pipeline.d.ts.map +1 -1
  189. package/dist/pointcloud/point-pipeline.js +4 -9
  190. package/dist/pointcloud/point-pipeline.js.map +1 -1
  191. package/dist/pointcloud/point-shader.wgsl.d.ts +1 -21
  192. package/dist/pointcloud/point-shader.wgsl.d.ts.map +1 -1
  193. package/dist/pointcloud/point-shader.wgsl.js +25 -5
  194. package/dist/pointcloud/point-shader.wgsl.js.map +1 -1
  195. package/dist/post-pass-chain.d.ts +38 -0
  196. package/dist/post-pass-chain.d.ts.map +1 -0
  197. package/dist/post-pass-chain.js +99 -0
  198. package/dist/post-pass-chain.js.map +1 -0
  199. package/dist/post-processor.d.ts +12 -26
  200. package/dist/post-processor.d.ts.map +1 -1
  201. package/dist/post-processor.js +14 -72
  202. package/dist/post-processor.js.map +1 -1
  203. package/dist/raycast-engine.d.ts +7 -5
  204. package/dist/raycast-engine.d.ts.map +1 -1
  205. package/dist/raycast-engine.js +17 -20
  206. package/dist/raycast-engine.js.map +1 -1
  207. package/dist/raycast-point-cloud-query.d.ts +1 -1
  208. package/dist/raycast-point-cloud-query.d.ts.map +1 -1
  209. package/dist/reference-image-pipeline.d.ts +1 -1
  210. package/dist/reference-image-pipeline.d.ts.map +1 -1
  211. package/dist/reference-image-pipeline.js +25 -7
  212. package/dist/reference-image-pipeline.js.map +1 -1
  213. package/dist/reference-images.d.ts +3 -1
  214. package/dist/reference-images.d.ts.map +1 -1
  215. package/dist/reference-images.js +15 -5
  216. package/dist/reference-images.js.map +1 -1
  217. package/dist/relative-to-eye.d.ts +146 -0
  218. package/dist/relative-to-eye.d.ts.map +1 -0
  219. package/dist/relative-to-eye.js +326 -0
  220. package/dist/relative-to-eye.js.map +1 -0
  221. package/dist/render-section-draw.d.ts +11 -0
  222. package/dist/render-section-draw.d.ts.map +1 -1
  223. package/dist/render-section-draw.js +6 -3
  224. package/dist/render-section-draw.js.map +1 -1
  225. package/dist/renderer-color-readback.d.ts +50 -0
  226. package/dist/renderer-color-readback.d.ts.map +1 -0
  227. package/dist/renderer-color-readback.js +145 -0
  228. package/dist/renderer-color-readback.js.map +1 -0
  229. package/dist/renderer-overlays.d.ts +15 -3
  230. package/dist/renderer-overlays.d.ts.map +1 -1
  231. package/dist/renderer-overlays.js +46 -20
  232. package/dist/renderer-overlays.js.map +1 -1
  233. package/dist/renderer-screenshot.d.ts +4 -0
  234. package/dist/renderer-screenshot.d.ts.map +1 -0
  235. package/dist/renderer-screenshot.js +17 -0
  236. package/dist/renderer-screenshot.js.map +1 -0
  237. package/dist/renderer-symbolic-overlays.d.ts +5 -2
  238. package/dist/renderer-symbolic-overlays.d.ts.map +1 -1
  239. package/dist/renderer-symbolic-overlays.js +36 -13
  240. package/dist/renderer-symbolic-overlays.js.map +1 -1
  241. package/dist/renderer-viewport.d.ts +45 -0
  242. package/dist/renderer-viewport.d.ts.map +1 -1
  243. package/dist/renderer-viewport.js +44 -0
  244. package/dist/renderer-viewport.js.map +1 -1
  245. package/dist/rte-clip-space.d.ts +12 -0
  246. package/dist/rte-clip-space.d.ts.map +1 -0
  247. package/dist/rte-clip-space.js +22 -0
  248. package/dist/rte-clip-space.js.map +1 -0
  249. package/dist/rte-frustum.d.ts +24 -0
  250. package/dist/rte-frustum.d.ts.map +1 -0
  251. package/dist/rte-frustum.js +45 -0
  252. package/dist/rte-frustum.js.map +1 -0
  253. package/dist/scene-appearance-preview.d.ts +2 -0
  254. package/dist/scene-appearance-preview.d.ts.map +1 -1
  255. package/dist/scene-appearance-preview.js +9 -0
  256. package/dist/scene-appearance-preview.js.map +1 -1
  257. package/dist/scene-authored-owner.d.ts +10 -0
  258. package/dist/scene-authored-owner.d.ts.map +1 -1
  259. package/dist/scene-authored-owner.js +44 -5
  260. package/dist/scene-authored-owner.js.map +1 -1
  261. package/dist/scene-batch-rebuild.d.ts +18 -0
  262. package/dist/scene-batch-rebuild.d.ts.map +1 -0
  263. package/dist/scene-batch-rebuild.js +49 -0
  264. package/dist/scene-batch-rebuild.js.map +1 -0
  265. package/dist/scene-batch-upload.d.ts +4 -1
  266. package/dist/scene-batch-upload.d.ts.map +1 -1
  267. package/dist/scene-batch-upload.js +31 -38
  268. package/dist/scene-batch-upload.js.map +1 -1
  269. package/dist/scene-bucket-routing.d.ts +21 -0
  270. package/dist/scene-bucket-routing.d.ts.map +1 -0
  271. package/dist/scene-bucket-routing.js +44 -0
  272. package/dist/scene-bucket-routing.js.map +1 -0
  273. package/dist/scene-contents.d.ts +8 -0
  274. package/dist/scene-contents.d.ts.map +1 -1
  275. package/dist/scene-derived-mesh-provenance.d.ts +13 -0
  276. package/dist/scene-derived-mesh-provenance.d.ts.map +1 -0
  277. package/dist/scene-derived-mesh-provenance.js +30 -0
  278. package/dist/scene-derived-mesh-provenance.js.map +1 -0
  279. package/dist/scene-device-recovery.d.ts +103 -0
  280. package/dist/scene-device-recovery.d.ts.map +1 -0
  281. package/dist/scene-device-recovery.js +295 -0
  282. package/dist/scene-device-recovery.js.map +1 -0
  283. package/dist/scene-finalize-regroup.d.ts +54 -0
  284. package/dist/scene-finalize-regroup.d.ts.map +1 -0
  285. package/dist/scene-finalize-regroup.js +136 -0
  286. package/dist/scene-finalize-regroup.js.map +1 -0
  287. package/dist/scene-geometry.d.ts +1 -0
  288. package/dist/scene-geometry.d.ts.map +1 -1
  289. package/dist/scene-geometry.js +3 -23
  290. package/dist/scene-geometry.js.map +1 -1
  291. package/dist/scene-instance-bounds.d.ts +12 -0
  292. package/dist/scene-instance-bounds.d.ts.map +1 -0
  293. package/dist/scene-instance-bounds.js +48 -0
  294. package/dist/scene-instance-bounds.js.map +1 -0
  295. package/dist/scene-instance-materialization.d.ts +4 -1
  296. package/dist/scene-instance-materialization.d.ts.map +1 -1
  297. package/dist/scene-instance-materialization.js +21 -6
  298. package/dist/scene-instance-materialization.js.map +1 -1
  299. package/dist/scene-instance-translation.d.ts +17 -0
  300. package/dist/scene-instance-translation.d.ts.map +1 -0
  301. package/dist/scene-instance-translation.js +50 -0
  302. package/dist/scene-instance-translation.js.map +1 -0
  303. package/dist/scene-instance-types.d.ts +6 -0
  304. package/dist/scene-instance-types.d.ts.map +1 -1
  305. package/dist/scene-model-translation.d.ts +13 -1
  306. package/dist/scene-model-translation.d.ts.map +1 -1
  307. package/dist/scene-model-translation.js +23 -1
  308. package/dist/scene-model-translation.js.map +1 -1
  309. package/dist/scene-precision.d.ts +16 -0
  310. package/dist/scene-precision.d.ts.map +1 -0
  311. package/dist/scene-precision.js +73 -0
  312. package/dist/scene-precision.js.map +1 -0
  313. package/dist/scene-rect-select.d.ts +11 -1
  314. package/dist/scene-rect-select.d.ts.map +1 -1
  315. package/dist/scene-rect-select.js +7 -4
  316. package/dist/scene-rect-select.js.map +1 -1
  317. package/dist/scene.d.ts +42 -39
  318. package/dist/scene.d.ts.map +1 -1
  319. package/dist/scene.js +258 -375
  320. package/dist/scene.js.map +1 -1
  321. package/dist/section-2d-lift.d.ts +10 -2
  322. package/dist/section-2d-lift.d.ts.map +1 -1
  323. package/dist/section-2d-lift.js +14 -6
  324. package/dist/section-2d-lift.js.map +1 -1
  325. package/dist/section-2d-line-buffer.d.ts +12 -4
  326. package/dist/section-2d-line-buffer.d.ts.map +1 -1
  327. package/dist/section-2d-line-buffer.js +59 -33
  328. package/dist/section-2d-line-buffer.js.map +1 -1
  329. package/dist/section-2d-overlay.d.ts +18 -72
  330. package/dist/section-2d-overlay.d.ts.map +1 -1
  331. package/dist/section-2d-overlay.js +72 -103
  332. package/dist/section-2d-overlay.js.map +1 -1
  333. package/dist/section-2d-types.d.ts +38 -0
  334. package/dist/section-2d-types.d.ts.map +1 -0
  335. package/dist/section-2d-types.js +6 -0
  336. package/dist/section-2d-types.js.map +1 -0
  337. package/dist/section-cap-pipelines.d.ts +6 -0
  338. package/dist/section-cap-pipelines.d.ts.map +1 -0
  339. package/dist/section-cap-pipelines.js +87 -0
  340. package/dist/section-cap-pipelines.js.map +1 -0
  341. package/dist/section-plane-geometry.d.ts +24 -0
  342. package/dist/section-plane-geometry.d.ts.map +1 -0
  343. package/dist/section-plane-geometry.js +93 -0
  344. package/dist/section-plane-geometry.js.map +1 -0
  345. package/dist/section-plane.d.ts +23 -24
  346. package/dist/section-plane.d.ts.map +1 -1
  347. package/dist/section-plane.js +58 -174
  348. package/dist/section-plane.js.map +1 -1
  349. package/dist/shaders/ao.wgsl.d.ts +2 -0
  350. package/dist/shaders/ao.wgsl.d.ts.map +1 -0
  351. package/dist/shaders/ao.wgsl.js +234 -0
  352. package/dist/shaders/ao.wgsl.js.map +1 -0
  353. package/dist/shaders/color-transfer.wgsl.d.ts +26 -0
  354. package/dist/shaders/color-transfer.wgsl.d.ts.map +1 -0
  355. package/dist/shaders/color-transfer.wgsl.js +53 -0
  356. package/dist/shaders/color-transfer.wgsl.js.map +1 -0
  357. package/dist/shaders/depth-reconstruct.wgsl.d.ts +16 -0
  358. package/dist/shaders/depth-reconstruct.wgsl.d.ts.map +1 -0
  359. package/dist/shaders/depth-reconstruct.wgsl.js +69 -0
  360. package/dist/shaders/depth-reconstruct.wgsl.js.map +1 -0
  361. package/dist/shaders/main-rte-shadow.wgsl.d.ts +4 -0
  362. package/dist/shaders/main-rte-shadow.wgsl.d.ts.map +1 -0
  363. package/dist/shaders/main-rte-shadow.wgsl.js +54 -0
  364. package/dist/shaders/main-rte-shadow.wgsl.js.map +1 -0
  365. package/dist/shaders/main.wgsl.d.ts +1 -6
  366. package/dist/shaders/main.wgsl.d.ts.map +1 -1
  367. package/dist/shaders/main.wgsl.js +110 -159
  368. package/dist/shaders/main.wgsl.js.map +1 -1
  369. package/dist/shaders/relative-to-eye.wgsl.d.ts +12 -0
  370. package/dist/shaders/relative-to-eye.wgsl.d.ts.map +1 -0
  371. package/dist/shaders/relative-to-eye.wgsl.js +40 -0
  372. package/dist/shaders/relative-to-eye.wgsl.js.map +1 -0
  373. package/dist/shaders/section-2d-overlay.wgsl.d.ts +28 -12
  374. package/dist/shaders/section-2d-overlay.wgsl.d.ts.map +1 -1
  375. package/dist/shaders/section-2d-overlay.wgsl.js +40 -13
  376. package/dist/shaders/section-2d-overlay.wgsl.js.map +1 -1
  377. package/dist/shaders/shadow.wgsl.d.ts +1 -1
  378. package/dist/shaders/shadow.wgsl.d.ts.map +1 -1
  379. package/dist/shaders/shadow.wgsl.js +25 -7
  380. package/dist/shaders/shadow.wgsl.js.map +1 -1
  381. package/dist/shaders/sky.wgsl.d.ts +1 -11
  382. package/dist/shaders/sky.wgsl.d.ts.map +1 -1
  383. package/dist/shaders/sky.wgsl.js +17 -19
  384. package/dist/shaders/sky.wgsl.js.map +1 -1
  385. package/dist/shaders/symbolic-overlay.wgsl.d.ts +2 -9
  386. package/dist/shaders/symbolic-overlay.wgsl.d.ts.map +1 -1
  387. package/dist/shaders/symbolic-overlay.wgsl.js +88 -17
  388. package/dist/shaders/symbolic-overlay.wgsl.js.map +1 -1
  389. package/dist/shaders/textured.wgsl.d.ts.map +1 -1
  390. package/dist/shaders/textured.wgsl.js +11 -9
  391. package/dist/shaders/textured.wgsl.js.map +1 -1
  392. package/dist/shadow-light-matrix.d.ts +7 -0
  393. package/dist/shadow-light-matrix.d.ts.map +1 -1
  394. package/dist/shadow-light-matrix.js +11 -0
  395. package/dist/shadow-light-matrix.js.map +1 -1
  396. package/dist/shadow-occluder-batches.d.ts +10 -0
  397. package/dist/shadow-occluder-batches.d.ts.map +1 -0
  398. package/dist/shadow-occluder-batches.js +39 -0
  399. package/dist/shadow-occluder-batches.js.map +1 -0
  400. package/dist/shadow-occluders.d.ts.map +1 -1
  401. package/dist/shadow-occluders.js +4 -0
  402. package/dist/shadow-occluders.js.map +1 -1
  403. package/dist/shadow-pass.d.ts +5 -51
  404. package/dist/shadow-pass.d.ts.map +1 -1
  405. package/dist/shadow-pass.js +49 -45
  406. package/dist/shadow-pass.js.map +1 -1
  407. package/dist/shadow-types.d.ts +39 -0
  408. package/dist/shadow-types.d.ts.map +1 -0
  409. package/dist/shadow-types.js +15 -0
  410. package/dist/shadow-types.js.map +1 -0
  411. package/dist/symbolic-overlay-geometry.d.ts +7 -0
  412. package/dist/symbolic-overlay-geometry.d.ts.map +1 -0
  413. package/dist/symbolic-overlay-geometry.js +43 -0
  414. package/dist/symbolic-overlay-geometry.js.map +1 -0
  415. package/dist/symbolic-overlay-pipelines.d.ts +24 -36
  416. package/dist/symbolic-overlay-pipelines.d.ts.map +1 -1
  417. package/dist/symbolic-overlay-pipelines.js +175 -182
  418. package/dist/symbolic-overlay-pipelines.js.map +1 -1
  419. package/dist/symbolic-text-atlas.d.ts +21 -0
  420. package/dist/symbolic-text-atlas.d.ts.map +1 -1
  421. package/dist/symbolic-text-atlas.js +29 -1
  422. package/dist/symbolic-text-atlas.js.map +1 -1
  423. package/dist/types.d.ts +20 -26
  424. package/dist/types.d.ts.map +1 -1
  425. package/dist/visual-enhancement.d.ts +23 -1
  426. package/dist/visual-enhancement.d.ts.map +1 -1
  427. package/dist/visual-enhancement.js +38 -12
  428. package/dist/visual-enhancement.js.map +1 -1
  429. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ export { appearanceSourceTriangle, expandAppearanceCorners, equivalentAppearance
11
11
  export { sameCompanionParts } from './appearance-companions.js';
12
12
  export { invertAppearancePartition, validateAppearancePartition } from './appearance-preview.js';
13
13
  import { createReferenceImageManager } from './reference-image-host.js';
14
- import { resizeRendererViewport } from './renderer-viewport.js';
14
+ import { measureDrawingBuffer, resizeRendererViewport } from './renderer-viewport.js';
15
15
  export { pickFitPolicy } from './camera-fit-policy.js';
16
16
  // `Scene` is NOT exported: an exported class republishes every method `SceneContents` above just froze.
17
17
  export { Picker } from './picker.js';
@@ -79,71 +79,42 @@ import { Scene } from './scene.js';
79
79
  import { Picker } from './picker.js';
80
80
  import { reportableItemId } from './pick-resolve.js';
81
81
  import { MathUtils, viewBasis } from './math.js';
82
- import { FrustumUtils } from '@ifc-lite/spatial';
83
- import { VisualEnhancementResolver } from './visual-enhancement.js';
82
+ import { isRteAabbVisible, rteFrustum, sourceFrustumFromRte } from './rte-frustum.js';
83
+ import { VisualEnhancementResolver, livePostEffects } from './visual-enhancement.js';
84
84
  import { packClipBox } from './clip-box.js';
85
+ import { MESH_FLAGS_BYTE_OFFSET, MESH_FLAG_RTE_DRAWABLE, MESH_UNIFORM_FLOATS, MESH_UNIFORM_OFFSET, packRteCameraOrigin, packRteFragmentSpace, } from './mesh-rte-uniforms.js';
85
86
  import { RendererOverlays } from './renderer-overlays.js';
86
87
  import { resolveSectionPlaneFrame } from './render-section-plane.js';
87
88
  import { PickingManager } from './picking-manager.js';
88
89
  import { RaycastEngine } from './raycast-engine.js';
89
90
  import { RenderDegradationMonitor } from './render-degradation.js';
90
- import { PostProcessor } from './post-processor.js';
91
+ import { PostPassChain } from './post-pass-chain.js';
91
92
  import { InteractionEffectsGovernor } from './interaction-effects-governor.js';
92
93
  import { VisibilityEpochTracker } from './visibility-epoch.js';
93
94
  import { isEntityVisible } from './entity-visibility.js';
94
95
  import { ModelBoundsTracker } from './model-bounds-tracker.js';
95
96
  import { resolveContributionThresholdPx, projectedAabbRadiusPx, projectedInstancedRadiusPx } from './contribution-cull.js';
96
- import { EdlPass } from './edl-pass.js';
97
97
  import { SkyPass } from './sky-pass.js';
98
98
  import { skyShaderSource } from './shaders/sky.wgsl.js';
99
99
  import { resolveEnvironment } from './environment.js';
100
100
  import { ShadowPass, resolveShadowMapResolution } from './shadow-pass.js';
101
- import { fitSunLightMatrix, cameraFrustumFocusCorners } from './shadow-light-matrix.js';
102
- import { collectShadowOccluders, classifyBatchVisibility, DEFAULT_MIN_CAST_ALPHA } from './shadow-occluders.js';
101
+ import { fitSunLightMatrix, cameraFrustumFocusCorners, resolveShadowNormalBiasMetres } from './shadow-light-matrix.js';
102
+ import { collectShadowOccluders } from './shadow-occluders.js';
103
+ import { uploadInstancedRteDeltas } from './instanced-rte.js';
104
+ import { shadowOccluderBatches } from './shadow-occluder-batches.js';
105
+ import { captureRendererScreenshot } from './renderer-screenshot.js';
106
+ import { beginRendererColorFrameCapture, cancelRendererColorFrame, discardRendererColorFrameReadback, encodeRendererColorFrameCapture, requestRendererColorFrame, retryRendererColorFrame, settleRendererColorFrameCapture } from './renderer-color-readback.js';
103
107
  import { shouldRouteMeshTransparent, shouldRouteBatchTransparent, splitVisibleIdsByPromotion, DEFAULT_GHOST_ALPHA } from './overlay-routing.js';
104
108
  import { XRayAlpha, XRayEpochTracker } from './xray-alpha.js';
105
109
  import { PartialBatchRequests } from './partial-batch-requests.js';
106
110
  import { colorSaltByte, packEntityLane } from './scene-geometry.js';
107
111
  import { PointCloudRenderer } from './pointcloud/point-cloud-renderer.js';
112
+ import { appendPointCloudChunk as appendPointCloudChunkImpl, beginPointCloudStream as beginPointCloudStreamImpl, endPointCloudStream as endPointCloudStreamImpl, removePointCloudAsset as removePointCloudAssetImpl, } from './pointcloud/point-cloud-stream-lifecycle.js';
108
113
  import { DeviationComputer } from './deviation/deviation-computer.js';
114
+ import { runGuardedGpuUpload, isDeviceLossThrow } from './gpu-upload-guard.js';
115
+ import { recoverRendererDevice, rendererDeviceLostError } from './device-recovery.js';
109
116
  const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
110
117
  let warnedEntityIdRange = false;
111
- /**
112
- * Is this throw the GPU device telling us it is gone?
113
- *
114
- * The discriminator is the exception TYPE, not its message, because WebGPU
115
- * draws exactly that line:
116
- * - a call on a dead / invalid-state device throws a `DOMException`
117
- * (`InvalidStateError` in Safari 26.5 — the whole of issue #2229);
118
- * - a buffer allocation the host cannot back throws a plain `RangeError`
119
- * ("createBuffer failed, size (…) is too large … when mappedAtCreation ==
120
- * true"), which `gpu-upload-guard` documents happening on a HEALTHY device
121
- * under memory pressure.
122
- *
123
- * Treating the second as a device loss is a false positive that costs the whole
124
- * session, so only the first latches; everything else degrades one frame.
125
- *
126
- * There is deliberately NO consecutive-failure threshold as a middle ground.
127
- * Not because failures necessarily arrive back-to-back — between two BCF / IDS
128
- * capture frames the awaited `camera.frameBounds` normally does let an ordinary
129
- * rAF frame through, which would reset a counter — but because those ordinary
130
- * frames are not guaranteed to SUCCEED: they allocate too (`ensureMeshResources`
131
- * creates a buffer per unresourced mesh, and the queued-mesh flush allocates),
132
- * so under sustained host memory pressure any finite budget is still reachable.
133
- * A latch whose safety depends on incidental animation timing is the wrong
134
- * shape of guarantee for "never kill the viewport by mistake".
135
- *
136
- * Real losses on browsers that do not throw are still caught by the async
137
- * `device.lost` promise — which the WebGPU spec makes the sole loss channel
138
- * anyway (on a conformant engine, calls against a lost device are no-ops, not
139
- * throws; Safari 26.5's synchronous throw is the deviation being handled here).
140
- *
141
- * `typeof` guarded because non-DOM hosts (Node before 17, some workers) have no
142
- * `DOMException` global; there, no throw can be a WebGPU device signal anyway.
143
- */
144
- function isDeviceLossThrow(error) {
145
- return typeof DOMException !== 'undefined' && error instanceof DOMException;
146
- }
147
118
  /**
148
119
  * The reason `whenReady()` rejects when the renderer is destroyed.
149
120
  *
@@ -157,20 +128,6 @@ function rendererDestroyedError() {
157
128
  error.name = 'RendererDestroyedError';
158
129
  return error;
159
130
  }
160
- /**
161
- * The reason `whenReady()` rejects once the GPU device has been lost.
162
- *
163
- * Deliberately NOT `RendererDestroyedError`: a destroyed renderer is finished,
164
- * while a lost one is dead only until the host re-initialises it (`init()`
165
- * clears the latch and readiness is published again). A caller that wants to
166
- * retry needs to tell those apart, so the loss carries its own `name` — same
167
- * plain-`Error` shape, for the same reasons.
168
- */
169
- function rendererDeviceLostError() {
170
- const error = new Error('GPU device was lost before the renderer became ready');
171
- error.name = 'RendererDeviceLostError';
172
- return error;
173
- }
174
131
  /**
175
132
  * Main renderer class
176
133
  */
@@ -190,6 +147,7 @@ export class Renderer {
190
147
  overlays = new RendererOverlays({
191
148
  getModelBounds: () => this.getModelBounds(),
192
149
  expandModelBoundsWithFlatVertices: (positions, stride) => this.modelBoundsTracker.expandWithFlatVertices(positions, stride),
150
+ expandModelBoundsWithAnchoredLineVertices: (positions, origin, stride) => this.modelBoundsTracker.expandWithAnchoredVertices(positions, origin, stride),
193
151
  syncCameraSceneBounds: () => {
194
152
  if (this.modelBounds)
195
153
  this.camera.setSceneBounds(this.modelBounds);
@@ -198,9 +156,12 @@ export class Renderer {
198
156
  });
199
157
  referenceImages = createReferenceImageManager(this);
200
158
  getReferenceImages() { return this.referenceImages; }
201
- postProcessor = null;
159
+ // Ambient occlusion, separation lines and eye-dome lighting (post-pass-chain.ts),
160
+ // created on the first rendered frame.
161
+ postPasses = null;
162
+ /** Device px per CSS px in the drawing buffer; CSS-px sizes scale by it (#5383). */
163
+ pixelRatio = 1;
202
164
  interactionEffects = new InteractionEffectsGovernor();
203
- edlPass = null;
204
165
  // Procedural sky background — created lazily on the first frame that
205
166
  // enables it (most sessions never do).
206
167
  skyPass = null;
@@ -219,8 +180,10 @@ export class Renderer {
219
180
  highQuality: true,
220
181
  };
221
182
  pointCloudRenderer = null;
222
- /**
223
- * Set true at the end of the LATEST `init()`; gates `whenReady()` and
183
+ pointCloudStreamEpoch = 0;
184
+ pointCloudStreamEpochs = new Map(); // handle id → epoch; cleanup rebuilds `{ id }`, cleared per epoch
185
+ pointCloudNextHandleId = 1; // watermark carried across teardown() so a replacement never reissues an id
186
+ /** Set true at the end of the LATEST `init()`; gates `whenReady()` and
224
187
  * `isReady()`. Revoked synchronously by `init()` and by `destroy()`, and
225
188
  * overridden (not cleared) by a device loss — see `deviceLost`, which the
226
189
  * two readiness methods consult alongside this flag because the loss can
@@ -242,19 +205,18 @@ export class Renderer {
242
205
  */
243
206
  destroyed = false;
244
207
  /**
245
- * The tail of the `init()` queue. `init()` chains onto this rather than
246
- * running immediately, so two overlapping calls cannot both walk past the
247
- * "a previous init completed" guard while the first is still awaiting its
248
- * device and both allocate a full set of GPU objects (#2448). Always
249
- * settled fulfilled — a rejected init is swallowed HERE (never for the
250
- * caller) so one failure does not deadlock every later call.
208
+ * The tail of the GPU lifecycle queue. `init()` and `recoverDevice()` chain
209
+ * onto this rather than running immediately, so overlapping calls cannot
210
+ * both mutate the shared device/pipeline fields while one is awaiting its
211
+ * adapter. Always settled fulfilled — a rejected operation is swallowed
212
+ * HERE (never for its caller) so one failure cannot deadlock later work.
251
213
  */
252
214
  initChain = Promise.resolve();
253
215
  /**
254
- * Incremented synchronously by every `init()` call AND by every public
255
- * `destroy()`. It stamps "the lifecycle event an in-flight init belongs to":
256
- * an init that no longer carries the current stamp has been superseded and
257
- * must neither allocate nor publish readiness.
216
+ * Incremented synchronously by every `init()` / `recoverDevice()` call and
217
+ * by every public `destroy()`. It stamps the lifecycle event an in-flight
218
+ * operation belongs to: stale work must neither allocate nor publish
219
+ * readiness.
258
220
  *
259
221
  * Both bumps are load-bearing, for the same reason. Because the queue above
260
222
  * defers the body, an init can finish while a later one is still waiting its
@@ -312,6 +274,10 @@ export class Renderer {
312
274
  /** Retained so a listener registered AFTER the loss still learns of it. */
313
275
  deviceLostInfo = null;
314
276
  deviceLostListeners = new Set();
277
+ deviceLossSequence = 0;
278
+ recovery = {
279
+ inFlight: null, lostReferenceImages: false, quantizedBatchesRequested: false, omissions: new Set(), pointCloudOptions: null,
280
+ };
315
281
  /** BIM ↔ scan deviation: owns the compute pipeline + its BVH cache. */
316
282
  deviationComputer = new DeviationComputer();
317
283
  visualEnhancementResolver = new VisualEnhancementResolver();
@@ -425,13 +391,9 @@ export class Renderer {
425
391
  // One-shot log guard — prints Y-up clip bounds on first section-enable so
426
392
  // users can confirm the slider is operating on the intended range.
427
393
  _loggedSectionBounds = false;
428
- // Pooled per-frame buffers to avoid GC pressure from per-batch Float32Array allocations
429
- // A single 224-byte uniform buffer (56 floats) is reused for all batches/meshes within a frame
430
- // (48 floats viewProj…flags + 8 floats clipBoxMin/clipBoxMax)
431
- // 60 floats = the WGSL Uniforms struct incl. quantParams (see
432
- // pipeline.getUniformBufferSize).
433
- uniformScratch = new Float32Array(60);
434
- uniformScratchU32 = new Uint32Array(this.uniformScratch.buffer, 176, 4);
394
+ // One 336-byte buffer serves all frame batches/meshes (84 floats including RTE lanes).
395
+ uniformScratch = new Float32Array(MESH_UNIFORM_FLOATS);
396
+ uniformScratchU32 = new Uint32Array(this.uniformScratch.buffer, MESH_FLAGS_BYTE_OFFSET, 4);
435
397
  // What the last render() actually clipped, so the GPU picker can mirror it and
436
398
  // section/crop-clipped geometry stays unpickable, not just invisible. Updated
437
399
  // every render; read by pick()/pickRect(). null = nothing clipped that frame.
@@ -453,8 +415,8 @@ export class Renderer {
453
415
  * are released first. The comment below advertises a `destroy()` + `init()`
454
416
  * re-init flow, and the obvious device-loss auto-recovery is to call
455
417
  * `init()` on the live instance — which, without this, silently orphaned
456
- * two render pipelines, the picker, the post-processor, the point-cloud and
457
- * deviation pipelines, the EDL pass and the overlay layer's glyph atlas, per
418
+ * two render pipelines, the picker, the post passes, the point-cloud and
419
+ * deviation pipelines and the overlay layer's glyph atlas, per
458
420
  * recovery (#2448). Making the method self-safe is cheaper than trusting
459
421
  * every future caller to remember.
460
422
  *
@@ -486,7 +448,7 @@ export class Renderer {
486
448
  this.initChain = run.then(() => undefined, () => undefined);
487
449
  return run;
488
450
  }
489
- async initOnce(generation) {
451
+ async initOnce(generation, options = {}) {
490
452
  // `pipeline` is the marker for "a previous init() completed": it is
491
453
  // assigned unconditionally there and nulled by destroy().
492
454
  if (this.pipeline !== null) {
@@ -504,16 +466,21 @@ export class Renderer {
504
466
  // the generation and this body running is stamped with the generation
505
467
  // that is clearing it. `deviceLostGeneration` deliberately keeps its
506
468
  // stale value — it is only ever read alongside this flag.
507
- this.deviceLost = false;
508
- // Subscribe before the device exists so a loss during the first frames
509
- // is never missed — the handler is only invoked when `device.lost`
510
- // actually resolves (a real fault), long after init in practice.
511
- this.device.onDeviceLost((info) => this.handleDeviceLost(info));
512
- await this.device.init(this.canvas);
469
+ if (options.clearDeviceLost !== false)
470
+ this.deviceLost = false;
471
+ // Subscribe before init so a loss during the first frames is not missed.
472
+ const initializingDevice = this.device;
473
+ initializingDevice.onDeviceLost((info) => {
474
+ // Ignore a delayed loss from a wrapper recovery already replaced.
475
+ if (this.device !== initializingDevice)
476
+ return;
477
+ this.handleDeviceLost(info, { fromDevicePromise: true });
478
+ });
479
+ await initializingDevice.init(this.canvas);
513
480
  // A `destroy()` (or a newer `init()`) landed while we were parked on the
514
481
  // device. Everything below allocates a full GPU stack — two pipelines,
515
- // the picker, the post-processor, the point-cloud and deviation
516
- // pipelines, the EDL pass, the overlay glyph atlas — and this aborted
482
+ // the picker, the point-cloud and deviation pipelines, the overlay
483
+ // glyph atlas — and this aborted
517
484
  // path runs no second teardown, so all of it would be orphaned outright
518
485
  // (#2465). `markReady()`'s generation check is not enough on its own: it
519
486
  // withholds the readiness PUBLICATION, not the allocation. Release the
@@ -523,15 +490,15 @@ export class Renderer {
523
490
  this.device.destroy();
524
491
  return;
525
492
  }
526
- // Get canvas dimensions (use pixel dimensions if set, otherwise use CSS dimensions)
527
- // and clamp to the GPU's max 2D texture dimension so the initial pipeline allocations
528
- // can't overflow on tall/wide layouts (see render() for the per-frame clamp).
529
- const rect = this.canvas.getBoundingClientRect();
493
+ // Size the buffer to the element's device pixels, the same sizing every
494
+ // frame applies (#5383); an unlaid-out canvas keeps its attributes. Both
495
+ // are clamped to the GPU's max 2D texture dimension so the initial
496
+ // pipeline allocations can't overflow on tall/wide layouts.
530
497
  const maxDim = this.device.getMaxTextureDimension();
531
- const rawWidth = this.canvas.width || Math.max(1, Math.floor(rect.width));
532
- const rawHeight = this.canvas.height || Math.max(1, Math.floor(rect.height));
533
- const width = Math.min(rawWidth, maxDim);
534
- const height = Math.min(rawHeight, maxDim);
498
+ const measured = measureDrawingBuffer(this.canvas, maxDim);
499
+ this.pixelRatio = measured?.pixelRatio ?? 1;
500
+ const width = Math.max(1, Math.min(measured?.width ?? this.canvas.width, maxDim));
501
+ const height = Math.max(1, Math.min(measured?.height ?? this.canvas.height, maxDim));
535
502
  // Set pixel dimensions if not already set, or if we clamped them down
536
503
  if (!this.canvas.width || !this.canvas.height || this.canvas.width !== width || this.canvas.height !== height) {
537
504
  this.canvas.width = width;
@@ -541,25 +508,11 @@ export class Renderer {
541
508
  this.picker = new Picker(this.device, width, height);
542
509
  this.overlays.init(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
543
510
  this.referenceImages.init(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
544
- // PostProcessor is optional — if it fails (e.g. mobile GPU lacking
545
- // depth TEXTURE_BINDING), rendering still works without post-processing.
546
- try {
547
- this.postProcessor = new PostProcessor(this.device, {
548
- enableContactShading: true,
549
- contactRadius: 1.0,
550
- contactIntensity: 0.3,
551
- }, this.pipeline.getSampleCount());
552
- }
553
- catch (e) {
554
- console.warn('[Renderer] PostProcessor init failed (post-processing disabled):', e);
555
- this.postProcessor = null;
556
- }
557
- this.pointCloudRenderer = new PointCloudRenderer(this.device.getDevice(), this.device.getFormat(), 'depth24plus-stencil8', this.pipeline.getSampleCount());
511
+ this.pointCloudRenderer = new PointCloudRenderer(this.device.getDevice(), this.device.getFormat(), 'depth24plus-stencil8', this.pipeline.getSampleCount(), this.pointCloudNextHandleId);
558
512
  // Compute pipeline for the BIM↔scan deviation heatmap. Lazily
559
513
  // owns the per-triangle BVH GPU buffers; idle until the first
560
514
  // `computeDeviations` call.
561
515
  this.deviationComputer.init(this.device.getDevice());
562
- this.edlPass = new EdlPass(this.device, this.pipeline.getSampleCount());
563
516
  this.camera.setAspect(width / height);
564
517
  // Update picking manager with initialized picker
565
518
  this.pickingManager.setPicker(this.picker);
@@ -587,7 +540,8 @@ export class Renderer {
587
540
  // from the GPU click-pick provider above, since raycastScene* is
588
541
  // synchronous CPU code while pick() is an async GPU readback.
589
542
  this.raycastEngine.setPointCloudProvider(() => this.pointCloudRenderer?.getRayQuerySources() ?? []);
590
- this.markReady(generation);
543
+ if (options.publishReady !== false)
544
+ this.markReady(generation);
591
545
  }
592
546
  /**
593
547
  * Resolves once `init()` has finished and the GPU device + point-cloud
@@ -719,10 +673,34 @@ export class Renderer {
719
673
  isDeviceLost() {
720
674
  return this.deviceLost;
721
675
  }
722
- handleDeviceLost(info) {
723
- if (this.deviceLost)
676
+ recoverDevice() {
677
+ return recoverRendererDevice(this);
678
+ }
679
+ /**
680
+ * `onLossDetected` for every `runGuardedGpuUpload` call below (#4885):
681
+ * a synchronous Safari-style `DOMException` (issue #2229) caught OUTSIDE
682
+ * `render()` never otherwise reaches `handleDeviceLost` — only the async
683
+ * `device.lost` promise and `render()`'s own catch do — so without this,
684
+ * `isDeviceLost()` would keep answering false and every later upload on
685
+ * this path would keep failing against the same dead device.
686
+ */
687
+ reportUploadLoss(error) {
688
+ this.handleDeviceLost({
689
+ message: error instanceof Error ? error.message : String(error),
690
+ reason: 'gpu-upload-exception',
691
+ });
692
+ }
693
+ handleDeviceLost(info, options = {}) {
694
+ // Latched: only the current device's `device.lost` signal is a replacement loss; anything else is a duplicate report.
695
+ if (this.deviceLost) {
696
+ if (options.fromDevicePromise)
697
+ this.deviceLossSequence++;
724
698
  return;
699
+ }
700
+ this.deviceLossSequence++;
725
701
  this.deviceLost = true;
702
+ cancelRendererColorFrame(this);
703
+ this.recovery.lostReferenceImages = this.referenceImages.hasImages();
726
704
  this.referenceImages.destroy();
727
705
  this.deviceLostGeneration = this.initGeneration;
728
706
  this.deviceLostInfo = info;
@@ -781,11 +759,13 @@ export class Renderer {
781
759
  // regions really do have such a source on a HEALTHY device: the outer
782
760
  // one runs `scene.restoreAllEvicted()` for capture frames, the encode
783
761
  // one builds visibility sub-batches through
784
- // `scene.getOrCreatePartialBatch()`, and both allocate via
785
- // `createBuffer({ mappedAtCreation: true })`, which throws a plain
786
- // `RangeError` under host memory pressure — the failure
787
- // `gpu-upload-guard` documents verbatim. Latching there would kill the
788
- // viewport for a failure whose blast radius should be one frame, and
762
+ // `scene.getOrCreatePartialBatch()`, and both merge geometry into
763
+ // fresh typed arrays, whose allocation throws a plain `RangeError`
764
+ // ("Array buffer allocation failed") under host memory pressure.
765
+ // (Before #5429 the GPU upload itself added a second source, the
766
+ // mapped-at-creation `createBuffer` RangeError; uploads now go through
767
+ // `createStaticGpuBuffer`, which cannot raise it.) Latching there
768
+ // would kill the viewport for a failure whose blast radius should be one frame, and
789
769
  // would raise a false "graphics device was lost" toast plus false
790
770
  // `device_lost` telemetry on top.
791
771
  //
@@ -894,28 +874,16 @@ export class Renderer {
894
874
  * chunks will arrive (currently a no-op but kept for symmetry).
895
875
  */
896
876
  beginPointCloudStream(meta) {
897
- if (!this.pointCloudRenderer) {
898
- throw new Error('Renderer not initialized. Call init() first.');
899
- }
900
- return this.pointCloudRenderer.beginAsset(meta);
877
+ return beginPointCloudStreamImpl(this, meta);
901
878
  }
902
879
  appendPointCloudChunk(handle, chunk) {
903
- if (!this.pointCloudRenderer)
904
- return;
905
- this.pointCloudRenderer.appendChunk(handle, chunk);
906
- this.modelBoundsTracker.expandForPointClouds();
907
- this.camera.setSceneBounds(this.modelBounds);
908
- this.requestRender();
880
+ appendPointCloudChunkImpl(this, handle, chunk);
909
881
  }
910
882
  endPointCloudStream(handle) {
911
- this.pointCloudRenderer?.endAsset(handle);
912
- this.requestRender();
883
+ endPointCloudStreamImpl(this, handle);
913
884
  }
914
885
  removePointCloudAsset(handle) {
915
- this.pointCloudRenderer?.removeAsset(handle);
916
- // Bounds may have shrunk — recompute from scratch so fit-to-view
917
- // and section-plane sliders see fresh extents.
918
- this.refreshPlacementBounds();
886
+ removePointCloudAssetImpl(this, handle);
919
887
  }
920
888
  /**
921
889
  * Reassign a streamed point-cloud's expressId after upload. Use
@@ -948,12 +916,28 @@ export class Renderer {
948
916
  this.clearCaches();
949
917
  this.refreshPlacementBounds();
950
918
  }
919
+ /** Absolute workspace yaw about the render-frame pivot; GPU-instanced only (#4890). Skips invalidation on a no-op (#4890 review). */
920
+ setModelRotation(modelIndex, angle, pivot) {
921
+ const changed = this.scene.setModelRotation(modelIndex, angle, pivot);
922
+ if (!changed)
923
+ return false;
924
+ this.clearCaches();
925
+ this.refreshPlacementBounds();
926
+ return true;
927
+ }
951
928
  /** Streamed clouds are addressed by durable asset handle. */
952
929
  setPointCloudTranslation(handle, translation) {
953
930
  this.pointCloudRenderer?.setAssetTranslation(handle, translation);
954
931
  this.clearCaches();
955
932
  this.refreshPlacementBounds();
956
933
  }
934
+ /** Toggle one resident streamed scan without releasing its GPU buffers. */
935
+ setPointCloudVisibility(handle, visible) {
936
+ if (!this.pointCloudRenderer?.setAssetVisible(handle, visible))
937
+ return;
938
+ this.clearCaches();
939
+ this.refreshPlacementBounds();
940
+ }
957
941
  refreshPlacementBounds() {
958
942
  this.modelBoundsTracker.recompute();
959
943
  this.camera.setSceneBounds(this.modelBounds);
@@ -1012,22 +996,26 @@ export class Renderer {
1012
996
  * @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
1013
997
  */
1014
998
  loadGeometry(geometry) {
1015
- if (!this.device.isInitialized() || !this.pipeline) {
999
+ if (this.isDeviceLost())
1000
+ return { ok: false, reason: 'device-lost' }; // #4885: zombie device stays "initialized"
1001
+ if (!this.device.isInitialized() || !this.pipeline)
1016
1002
  throw new Error('Renderer not initialized. Call init() first.');
1017
- }
1018
1003
  const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
1019
1004
  if (meshes.length === 0) {
1020
1005
  console.warn('[Renderer] loadGeometry called with empty mesh array');
1021
- return;
1006
+ return { ok: true, value: undefined };
1022
1007
  }
1023
- // Use batched rendering for optimal performance
1024
- const device = this.device.getDevice();
1025
- this.scene.appendToBatches(meshes, device, this.pipeline, false);
1026
- // Calculate and store model bounds for fitToView
1027
- this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1028
- console.log(`[Renderer] Loaded ${meshes.length} meshes`);
1029
- // Update camera scene bounds for tight orthographic near/far planes
1030
- this.camera.setSceneBounds(this.modelBounds);
1008
+ const pipeline = this.pipeline;
1009
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1010
+ // Use batched rendering for optimal performance
1011
+ const device = this.device.getDevice();
1012
+ this.scene.appendToBatches(meshes, device, pipeline, false);
1013
+ // Calculate and store model bounds for fitToView
1014
+ this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1015
+ console.log(`[Renderer] Loaded ${meshes.length} meshes`);
1016
+ // Update camera scene bounds for tight orthographic near/far planes
1017
+ this.camera.setSceneBounds(this.modelBounds);
1018
+ }, (error) => this.reportUploadLoss(error));
1031
1019
  }
1032
1020
  /**
1033
1021
  * Add multiple meshes to the scene (convenience method for streaming)
@@ -1036,17 +1024,20 @@ export class Renderer {
1036
1024
  * @param isStreaming - If true, throttles batch rebuilding for better streaming performance
1037
1025
  */
1038
1026
  addMeshes(meshes, isStreaming = false) {
1027
+ if (this.isDeviceLost())
1028
+ return { ok: false, reason: 'device-lost' };
1039
1029
  if (!this.device.isInitialized() || !this.pipeline) {
1040
1030
  throw new Error('Renderer not initialized. Call init() first.');
1041
1031
  }
1042
1032
  if (meshes.length === 0)
1043
- return;
1044
- const device = this.device.getDevice();
1045
- this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
1046
- // Update model bounds incrementally
1047
- this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1048
- // Update camera scene bounds for tight orthographic near/far planes
1049
- this.camera.setSceneBounds(this.modelBounds);
1033
+ return { ok: true, value: undefined };
1034
+ const pipeline = this.pipeline;
1035
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1036
+ const device = this.device.getDevice();
1037
+ this.scene.appendToBatches(meshes, device, pipeline, isStreaming);
1038
+ this.modelBoundsTracker.updateFromMeshes(meshes, (index) => this.scene.getModelTranslation(index));
1039
+ this.camera.setSceneBounds(this.modelBounds);
1040
+ }, (error) => this.reportUploadLoss(error));
1050
1041
  }
1051
1042
  /**
1052
1043
  * Fit camera to view all loaded geometry
@@ -1074,50 +1065,46 @@ export class Renderer {
1074
1065
  */
1075
1066
  addMesh(mesh) {
1076
1067
  if (!this.pipeline)
1077
- return;
1078
- // Create per-mesh uniform buffer and bind group if not already created
1079
- if (!mesh.uniformBuffer && this.device.isInitialized()) {
1080
- const device = this.device.getDevice();
1081
- // Create uniform buffer for this mesh
1082
- mesh.uniformBuffer = device.createBuffer({
1083
- size: this.pipeline.getUniformBufferSize(),
1084
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1085
- });
1086
- // Create bind group for this mesh
1087
- mesh.bindGroup = device.createBindGroup({
1088
- layout: this.pipeline.getBindGroupLayout(),
1089
- entries: [
1090
- {
1091
- binding: 0,
1092
- resource: { buffer: mesh.uniformBuffer },
1093
- },
1094
- ],
1095
- });
1096
- }
1068
+ return { ok: true, value: undefined };
1069
+ const pipeline = this.pipeline;
1070
+ const outcome = (!mesh.uniformBuffer && !this.isDeviceLost() && this.device.isInitialized())
1071
+ ? runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1072
+ const device = this.device.getDevice();
1073
+ mesh.uniformBuffer = device.createBuffer({
1074
+ size: pipeline.getUniformBufferSize(),
1075
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1076
+ });
1077
+ mesh.bindGroup = device.createBindGroup({
1078
+ layout: pipeline.getBindGroupLayout(),
1079
+ entries: [{ binding: 0, resource: { buffer: mesh.uniformBuffer } }],
1080
+ });
1081
+ }, (error) => this.reportUploadLoss(error))
1082
+ : { ok: true, value: undefined };
1097
1083
  this.scene.addMesh(mesh);
1084
+ return outcome;
1098
1085
  }
1099
1086
  /**
1100
1087
  * Ensure all meshes have GPU resources (call after adding meshes if pipeline wasn't ready)
1101
1088
  */
1102
1089
  ensureMeshResources() {
1103
1090
  if (!this.pipeline || !this.device.isInitialized())
1104
- return;
1105
- const device = this.device.getDevice();
1106
- for (const mesh of this.scene.getMeshes()) {
1107
- if (!mesh.uniformBuffer) {
1108
- mesh.uniformBuffer = device.createBuffer({
1109
- size: this.pipeline.getUniformBufferSize(),
1110
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1111
- });
1112
- mesh.bindGroup = device.createBindGroup({
1113
- layout: this.pipeline.getBindGroupLayout(),
1114
- entries: [{
1115
- binding: 0,
1116
- resource: { buffer: mesh.uniformBuffer },
1117
- }],
1118
- });
1091
+ return { ok: true, value: undefined };
1092
+ const pipeline = this.pipeline;
1093
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => {
1094
+ const device = this.device.getDevice();
1095
+ for (const mesh of this.scene.getMeshes()) {
1096
+ if (!mesh.uniformBuffer) {
1097
+ mesh.uniformBuffer = device.createBuffer({
1098
+ size: pipeline.getUniformBufferSize(),
1099
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1100
+ });
1101
+ mesh.bindGroup = device.createBindGroup({
1102
+ layout: pipeline.getBindGroupLayout(),
1103
+ entries: [{ binding: 0, resource: { buffer: mesh.uniformBuffer } }],
1104
+ });
1105
+ }
1119
1106
  }
1120
- }
1107
+ }, (error) => this.reportUploadLoss(error));
1121
1108
  }
1122
1109
  /**
1123
1110
  * Get model bounds (used for section planes, fitToView, etc.)
@@ -1150,71 +1137,17 @@ export class Renderer {
1150
1137
  * collector drops them.
1151
1138
  */
1152
1139
  shadowOccluderBatches(options, device, hasVisibilityFiltering) {
1153
- const all = this.scene.getBatchedMeshes();
1154
- if (!hasVisibilityFiltering) {
1155
- for (const batch of all)
1156
- this.noteShadowOccluderResidency(batch);
1157
- return all;
1158
- }
1159
- const pipeline = this.pipeline;
1160
- const out = [];
1161
- for (const batch of all) {
1162
- const vis = classifyBatchVisibility(batch.expressIds, options.hiddenIds, options.isolatedIds);
1163
- if (vis.kind === 'none')
1164
- continue; // fully hidden → does not cast
1165
- if (vis.kind === 'all') {
1166
- this.noteShadowOccluderResidency(batch);
1167
- out.push(batch); // fully visible → its own buffers
1168
- continue;
1169
- }
1170
- // Partially hidden. Transparent parents don't cast (collector's alpha
1171
- // filter) — skip rather than build a wasted, divergent-key clone.
1172
- if (batch.color[3] < DEFAULT_MIN_CAST_ALPHA)
1173
- continue;
1174
- // The partial sub-batch is built from the PARENT's CPU meshData, so a
1175
- // cold parent yields nothing until it is restored. Queue that restore
1176
- // here too: the colour pass only queues it for parents inside its own
1177
- // frustum, but an up-sun occluder behind the camera still has to cast.
1178
- this.noteShadowOccluderResidency(batch);
1179
- // Opaque partial: reuse the colour pass's cached sub-batch. The key is
1180
- // visibility-content-independent; `_partialBatchEpoch` invalidates it on
1181
- // any hide/isolate or override change, so the clone is always current.
1182
- // Without a pipeline the renderer isn't drawing, so skip (the shadow
1183
- // pass won't run either); casting the whole parent would be wrong.
1184
- if (!pipeline)
1185
- continue;
1186
- const sub = this.scene.getOrCreatePartialBatch(`${batch.colorKey}:${batch.id}`, batch.colorKey, vis.visibleIds, device, pipeline, this._partialBatchEpoch);
1187
- // A cold parent yields an empty partial (its residency restore is queued
1188
- // above); skip this frame — the collector drops zero-index draws anyway,
1189
- // and the subset casts once resident.
1190
- if (sub && sub.indexCount > 0)
1191
- out.push(sub);
1192
- }
1193
- return out;
1140
+ return shadowOccluderBatches(this.scene, options, device, hasVisibilityFiltering, this.pipeline, this._partialBatchEpoch);
1194
1141
  }
1195
- /**
1196
- * Keep a shadow occluder batch resident so it does not thin out silently on
1197
- * large models under the GPU residency budget (#2670 review). The depth pass
1198
- * reads these batches' buffers, but that read did not count as usage, so an
1199
- * up-sun occluder outside the colour frustum aged into an eviction candidate.
1200
- * Transparent batches never cast (the collector's alpha filter), so their
1201
- * residency is irrelevant here.
1202
- */
1203
- noteShadowOccluderResidency(batch) {
1204
- if (batch.color[3] < DEFAULT_MIN_CAST_ALPHA)
1205
- return;
1206
- if (batch.gpuResident === false) {
1207
- this.scene.requestBatchResidency(batch);
1208
- }
1209
- else {
1210
- this.scene.recordBatchDrawn(batch);
1211
- }
1142
+ /** Guarded entry point (#4885) for the unguarded body below. */
1143
+ createMeshFromData(meshData) {
1144
+ return runGuardedGpuUpload(() => this.isDeviceLost(), () => this.createMeshFromDataUnguarded(meshData), (error) => this.reportUploadLoss(error));
1212
1145
  }
1213
1146
  /**
1214
1147
  * Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
1215
1148
  * This is called on-demand when a mesh is selected, avoiding 2x buffer creation during streaming
1216
1149
  */
1217
- createMeshFromData(meshData) {
1150
+ createMeshFromDataUnguarded(meshData) {
1218
1151
  if (!this.device.isInitialized())
1219
1152
  return;
1220
1153
  const device = this.device.getDevice();
@@ -1223,18 +1156,17 @@ export class Renderer {
1223
1156
  const interleaved = new Float32Array(interleavedRaw);
1224
1157
  const interleavedU32 = new Uint32Array(interleavedRaw);
1225
1158
  // Build this individual mesh (selection highlight + GPU object-id picker)
1226
- // in ABSOLUTE world space so it renders with an identity model matrix.
1159
+ // in the same small local frame as its source batch.
1227
1160
  // CRITICAL: replicate the BATCH's exact two-step f32 path so the highlight
1228
1161
  // is bit-coincident with its source surface (no z-fight, no depth bias):
1229
1162
  // batch stores s = f32(local + (origin - sharedOrigin)) [merge]
1230
- // batch shader world = f32(f32(sharedOrigin) + s) [draw]
1231
- // We compute the same `world` here. When there's no shared origin yet
1232
- // (legacy / pre-batch), fall back to a plain f64 fold (local + origin).
1163
+ // batch shader RTE = sharedOrigin - eye + s [draw]
1164
+ // We retain `s` and the canonical origin separately. When there is no
1165
+ // shared origin, retain the piece origin instead of folding it in.
1233
1166
  const o = meshData.origin;
1234
- const so = this.scene.getSharedFrameOrigin(meshData.modelIndex);
1167
+ const so = this.scene.getSharedFrameOrigin(meshData.modelIndex, meshData);
1235
1168
  const ox = o ? o[0] : 0, oy = o ? o[1] : 0, oz = o ? o[2] : 0;
1236
1169
  const fr = Math.fround;
1237
- const sox = so ? fr(so[0]) : null, soy = so ? fr(so[1]) : 0, soz = so ? fr(so[2]) : 0;
1238
1170
  const dx = so ? (ox - so[0]) : ox, dy = so ? (oy - so[1]) : oy, dz = so ? (oz - so[2]) : oz;
1239
1171
  // Quantized batches (issue #1682 phase 6) render lattice-snapped
1240
1172
  // positions: the shader's quantMin + q*step is exactly the lattice
@@ -1251,9 +1183,9 @@ export class Renderer {
1251
1183
  for (let i = 0; i < vertexCount; i++) {
1252
1184
  const base = i * 7;
1253
1185
  const posBase = i * 3;
1254
- interleaved[base] = so ? fr(sox + snap(fr(p[posBase] + dx))) : snap(p[posBase] + dx);
1255
- interleaved[base + 1] = so ? fr(soy + snap(fr(p[posBase + 1] + dy))) : snap(p[posBase + 1] + dy);
1256
- interleaved[base + 2] = so ? fr(soz + snap(fr(p[posBase + 2] + dz))) : snap(p[posBase + 2] + dz);
1186
+ interleaved[base] = so ? snap(fr(p[posBase] + dx)) : snap(p[posBase]);
1187
+ interleaved[base + 1] = so ? snap(fr(p[posBase + 1] + dy)) : snap(p[posBase + 1]);
1188
+ interleaved[base + 2] = so ? snap(fr(p[posBase + 2] + dz)) : snap(p[posBase + 2]);
1257
1189
  const hasNormals = meshData.normals.length > 0;
1258
1190
  interleaved[base + 3] = hasNormals ? meshData.normals[posBase] : 0;
1259
1191
  interleaved[base + 4] = hasNormals ? meshData.normals[posBase + 1] : 0;
@@ -1283,7 +1215,9 @@ export class Renderer {
1283
1215
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
1284
1216
  });
1285
1217
  device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
1286
- // Add to scene with identity transform (positions already in world space).
1218
+ // Keep the hydrated mesh in its decoded local frame. Folding this
1219
+ // origin back into f32 vertices used to make selection/highlight the
1220
+ // only mesh path that lost centimetres at national-grid coordinates.
1287
1221
  // Flagged `hydrated` so it can be freed when its entity leaves the
1288
1222
  // selection — these duplicate geometry already drawn by a batch and would
1289
1223
  // otherwise accumulate + double-draw (see Scene.disposeHydratedMeshesExcept).
@@ -1298,7 +1232,14 @@ export class Renderer {
1298
1232
  vertexBuffer,
1299
1233
  indexBuffer,
1300
1234
  indexCount: meshData.indices.length,
1301
- transform: MathUtils.identity(),
1235
+ transform: (() => {
1236
+ const transform = MathUtils.identity();
1237
+ transform.m[12] = so ? so[0] : ox;
1238
+ transform.m[13] = so ? so[1] : oy;
1239
+ transform.m[14] = so ? so[2] : oz;
1240
+ return transform;
1241
+ })(),
1242
+ rteOrigin: so ? [so[0], so[1], so[2]] : [ox, oy, oz],
1302
1243
  color: meshData.color,
1303
1244
  hydrated: true,
1304
1245
  });
@@ -1416,6 +1357,7 @@ export class Renderer {
1416
1357
  * rejecting pipeline creation) batches stay on the f32 path.
1417
1358
  */
1418
1359
  async enableQuantizedBatches() {
1360
+ this.recovery.quantizedBatchesRequested = true;
1419
1361
  if (!this.pipeline)
1420
1362
  return false;
1421
1363
  const ok = await this.pipeline.ensureQuantizedPipelines();
@@ -1457,6 +1399,7 @@ export class Renderer {
1457
1399
  // emit a stream of validation errors. Stay quiet until re-init.
1458
1400
  if (this.deviceLost) {
1459
1401
  this._renderSkipCount++;
1402
+ cancelRendererColorFrame(this);
1460
1403
  return;
1461
1404
  }
1462
1405
  try {
@@ -1468,6 +1411,7 @@ export class Renderer {
1468
1411
  // as "did not fail" — see `frameContainedThrow`.
1469
1412
  if (!this.frameContainedThrow)
1470
1413
  this.consecutiveDegradedFrames = 0;
1414
+ retryRendererColorFrame(this, () => this.requestRender());
1471
1415
  }
1472
1416
  catch (error) {
1473
1417
  // Safari (26.5) reports device loss SYNCHRONOUSLY: a call against a
@@ -1483,6 +1427,7 @@ export class Renderer {
1483
1427
  // host down with us".
1484
1428
  this._renderSkipCount++;
1485
1429
  this.containFrameThrow(error, 'frame');
1430
+ cancelRendererColorFrame(this);
1486
1431
  }
1487
1432
  }
1488
1433
  /**
@@ -1494,23 +1439,16 @@ export class Renderer {
1494
1439
  this._renderSkipCount++;
1495
1440
  return;
1496
1441
  }
1497
- // Validate canvas dimensions
1498
- // Align width to 64 pixels for WebGPU texture row alignment (256 bytes / 4 bytes per pixel)
1499
- // and clamp both axes to the GPU's max 2D texture dimension. Some hosts (e.g. tall iframes
1500
- // on high-DPR displays) can produce canvas dimensions that exceed 8192 and would otherwise
1501
- // make every depth/colour texture allocation a validation error.
1502
- const rect = this.canvas.getBoundingClientRect();
1503
- const maxDim = this.device.getMaxTextureDimension();
1504
- const rawWidth = Math.max(1, Math.floor(rect.width));
1505
- const widthAligned = Math.max(64, Math.floor(rawWidth / 64) * 64);
1506
- const width = Math.min(widthAligned, Math.floor(maxDim / 64) * 64);
1507
- const rawHeight = Math.max(1, Math.floor(rect.height));
1508
- const height = Math.min(rawHeight, maxDim);
1509
- // Skip rendering if canvas is too small
1510
- if (width < 64 || height < 10) {
1442
+ // Drawing buffer = the element's device-pixel size (capped ratio, clamped
1443
+ // to the GPU's max texture dimension); see computeDrawingBufferSize (#5383).
1444
+ const measured = measureDrawingBuffer(this.canvas, this.device.getMaxTextureDimension());
1445
+ // Skip rendering while the canvas is collapsed or too small.
1446
+ if (!measured || measured.height < 10) {
1511
1447
  this._renderSkipCount++;
1512
1448
  return;
1513
1449
  }
1450
+ const { width, height } = measured;
1451
+ this.pixelRatio = measured.pixelRatio;
1514
1452
  // Update canvas pixel dimensions if needed
1515
1453
  const dimensionsChanged = this.canvas.width !== width || this.canvas.height !== height;
1516
1454
  if (dimensionsChanged) {
@@ -1534,6 +1472,13 @@ export class Renderer {
1534
1472
  }
1535
1473
  const device = this.device.getDevice();
1536
1474
  const viewProj = this.camera.getViewProjMatrix().m;
1475
+ const relativeToEyeFrame = this.camera.getRelativeToEyeFrame();
1476
+ // Culling must share the translation-free RTE frame used by every GPU
1477
+ // pass. `sourceFrustum` is the f64 plane equivalent for the immutable
1478
+ // source-space BVH; render-time boxes stay eye-relative below.
1479
+ const rteCullFrustum = rteFrustum(relativeToEyeFrame);
1480
+ const rteCullCamera = relativeToEyeFrame.getCameraWorld();
1481
+ const sourceCullFrustum = sourceFrustumFromRte(rteCullFrustum, rteCullCamera);
1537
1482
  // Frame stats (issue #1682): geometry draw calls + per-frame cull
1538
1483
  // outcomes, snapshotted into _lastFrameStats before queue.submit.
1539
1484
  let frameDrawCalls = 0;
@@ -1573,11 +1518,8 @@ export class Renderer {
1573
1518
  const edgeIntensity = Math.min(3.0, Math.max(0.0, visualEnhancement.edgeContrast.intensity));
1574
1519
  const edgeEnabledU32 = edgeEnabled ? 1 : 0;
1575
1520
  const edgeIntensityMilliU32 = Math.round(edgeIntensity * 1000);
1576
- const contactEnabled = effectsLive && visualEnhancement.enabled && visualEnhancement.contactShading.quality !== 'off';
1577
- const separationEnabled = effectsLive && visualEnhancement.enabled
1578
- && visualEnhancement.separationLines.enabled
1579
- && visualEnhancement.separationLines.quality !== 'off';
1580
- const needsObjectIdPass = contactEnabled || separationEnabled;
1521
+ // Only the separation lines read the object-id attachment after the pass.
1522
+ const needsObjectIdPass = livePostEffects(visualEnhancement, effectsLive).separationLines;
1581
1523
  // Check if visibility filtering is active
1582
1524
  const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
1583
1525
  const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
@@ -1685,8 +1627,7 @@ export class Renderer {
1685
1627
  // Frustum culling (if enabled and spatial index available)
1686
1628
  if (options.enableFrustumCulling && options.spatialIndex) {
1687
1629
  try {
1688
- const frustum = FrustumUtils.fromViewProjMatrix(viewProj);
1689
- const visibleIds = new Set(options.spatialIndex.queryFrustum(frustum));
1630
+ const visibleIds = new Set(options.spatialIndex.queryFrustum(sourceCullFrustum));
1690
1631
  meshes = meshes.filter(mesh => visibleIds.has(mesh.expressId));
1691
1632
  }
1692
1633
  catch (error) {
@@ -1727,12 +1668,15 @@ export class Renderer {
1727
1668
  }
1728
1669
  return; // Skip this frame, context will be reconfigured next frame
1729
1670
  }
1671
+ let colorCapture = null;
1672
+ let colorReadback = null;
1730
1673
  try {
1731
1674
  const clearColor = options.clearColor
1732
1675
  ? (Array.isArray(options.clearColor)
1733
1676
  ? { r: options.clearColor[0], g: options.clearColor[1], b: options.clearColor[2], a: options.clearColor[3] }
1734
1677
  : options.clearColor)
1735
1678
  : { r: 0.1, g: 0.1, b: 0.1, a: 1 };
1679
+ colorCapture = beginRendererColorFrameCapture(this, currentTexture, this.canvas.width, this.canvas.height, this.device.getFormat());
1736
1680
  const textureView = currentTexture.createView();
1737
1681
  const objectIdView = this.pipeline.getObjectIdTextureView();
1738
1682
  // Separate meshes into opaque and transparent
@@ -1806,6 +1750,8 @@ export class Renderer {
1806
1750
  for (const mesh of allMeshes) {
1807
1751
  if (mesh.uniformBuffer) {
1808
1752
  meshBuf.set(viewProj, 0);
1753
+ relativeToEyeFrame.packUniforms(meshBuf, MESH_UNIFORM_OFFSET.rteViewProj);
1754
+ packRteCameraOrigin(relativeToEyeFrame, meshBuf);
1809
1755
  meshBuf.set(mesh.transform.m, 16);
1810
1756
  // Check if mesh is selected (single or multi-selection)
1811
1757
  // For multi-model support: also check modelIndex if provided
@@ -1846,6 +1792,13 @@ export class Renderer {
1846
1792
  clipBit;
1847
1793
  meshFlags[2] = edgeEnabledU32;
1848
1794
  meshFlags[3] = edgeIntensityMilliU32;
1795
+ // Individual meshes retain their origin and share the RTE
1796
+ // fragment contract with colour batches.
1797
+ packRteFragmentSpace(relativeToEyeFrame, sectionPlaneData, options.clipBox, meshBuf);
1798
+ const meshOrigin = mesh.rteOrigin
1799
+ ?? [mesh.transform.m[12], mesh.transform.m[13], mesh.transform.m[14]];
1800
+ relativeToEyeFrame.packDrawableOrigin(meshOrigin, meshBuf, MESH_UNIFORM_OFFSET.drawableDelta);
1801
+ meshFlags[0] |= MESH_FLAG_RTE_DRAWABLE;
1849
1802
  device.queue.writeBuffer(mesh.uniformBuffer, 0, meshBuf);
1850
1803
  }
1851
1804
  }
@@ -1875,8 +1828,16 @@ export class Renderer {
1875
1828
  else {
1876
1829
  this.shadowPass.setResolution(resolution);
1877
1830
  }
1878
- const boundsMin = [bounds.min.x, bounds.min.y, bounds.min.z];
1879
- const boundsMax = [bounds.max.x, bounds.max.y, bounds.max.z];
1831
+ // Fit shadows in the same eye-relative frame as vertices.
1832
+ const shadowEye = relativeToEyeFrame.getCameraWorld();
1833
+ const sourceBoundsMin = [bounds.min.x, bounds.min.y, bounds.min.z];
1834
+ const sourceBoundsMax = [bounds.max.x, bounds.max.y, bounds.max.z];
1835
+ const boundsMin = [
1836
+ bounds.min.x - shadowEye[0], bounds.min.y - shadowEye[1], bounds.min.z - shadowEye[2],
1837
+ ];
1838
+ const boundsMax = [
1839
+ bounds.max.x - shadowEye[0], bounds.max.y - shadowEye[1], bounds.max.z - shadowEye[2],
1840
+ ];
1880
1841
  // Lateral shadow fit. AT REST: fit to the camera frustum
1881
1842
  // clipped to the model (maintainer #1) so a small building on
1882
1843
  // a large site keeps sharp shadows instead of spending the
@@ -1899,9 +1860,13 @@ export class Renderer {
1899
1860
  aspect: this.canvas.height > 0 ? this.canvas.width / this.canvas.height : 1,
1900
1861
  ortho: this.camera.getProjectionMode() === 'orthographic',
1901
1862
  orthoHalfHeight: this.camera.getOrthoSize(),
1902
- boundsMin,
1903
- boundsMax,
1904
- }) ?? undefined;
1863
+ boundsMin: sourceBoundsMin,
1864
+ boundsMax: sourceBoundsMax,
1865
+ })?.map((corner) => ({
1866
+ x: corner.x - shadowEye[0],
1867
+ y: corner.y - shadowEye[1],
1868
+ z: corner.z - shadowEye[2],
1869
+ }));
1905
1870
  }
1906
1871
  const sun = resolveEnvironment(options.environment).sunDirection;
1907
1872
  const fit = fitSunLightMatrix({ sunDirection: sun, boundsMin, boundsMax, focusCorners });
@@ -1936,7 +1901,7 @@ export class Renderer {
1936
1901
  box: options.clipBox?.enabled
1937
1902
  ? { min: options.clipBox.min, max: options.clipBox.max }
1938
1903
  : null,
1939
- });
1904
+ }, { cameraWorld: shadowEye });
1940
1905
  // Shadow uniform: light matrix + sampling params. The kernel
1941
1906
  // width follows the sun's angular size (physical, ~0.53°
1942
1907
  // like Blender's Sun lamp). Bias scales with the kernel: a
@@ -1946,7 +1911,7 @@ export class Renderer {
1946
1911
  const texelWorld = (2 * fit.orthoHalfWidth) / resolution;
1947
1912
  const sunAngleDeg = shadowOpts.sunAngleDeg ?? 0.53;
1948
1913
  const pcfRadius = Math.min(Math.max(sunAngleDeg * 3.0, 0.75), 8.0);
1949
- const normalBias = texelWorld * (2.0 + pcfRadius);
1914
+ const normalBias = resolveShadowNormalBiasMetres(texelWorld, pcfRadius);
1950
1915
  const s = this.shadowScratch;
1951
1916
  s.set(fit.lightViewProj.m, 0);
1952
1917
  s[16] = 1 / resolution; // texelSize
@@ -2082,7 +2047,7 @@ export class Renderer {
2082
2047
  if (allBatchedMeshes.length > 0 || this.scene.getTexturedMeshes().length > 0 || this.scene.getInstancedTemplates().length > 0) {
2083
2048
  // Frustum culling for batched meshes - skip entire batches outside the camera view
2084
2049
  // This is the primary performance optimization for large models (200K+ meshes)
2085
- const frustum = FrustumUtils.fromViewProjMatrix(viewProj);
2050
+ const frustum = rteCullFrustum;
2086
2051
  // Contribution culling (issue #1682): skip batches whose world
2087
2052
  // AABB projects below a pixel threshold. Disabled unless the
2088
2053
  // caller opts in via options.contributionCull; the threshold is
@@ -2109,7 +2074,7 @@ export class Renderer {
2109
2074
  mode: this.camera.getProjectionMode(),
2110
2075
  fovYRadians: this.camera.getFOV(),
2111
2076
  orthoHalfHeight: this.camera.getOrthoSize(),
2112
- viewportHeightPx: this.canvas.height,
2077
+ viewportHeightPx: this.canvas.height / this.pixelRatio, // CSS px, like the thresholds
2113
2078
  };
2114
2079
  }
2115
2080
  // Per-batch visibility (only meaningful while filtering is active).
@@ -2164,7 +2129,7 @@ export class Renderer {
2164
2129
  // Frustum culling: skip batches entirely outside the camera view
2165
2130
  if (batch.bounds) {
2166
2131
  const batchAABB = { min: batch.bounds.min, max: batch.bounds.max };
2167
- if (!FrustumUtils.isAABBVisible(frustum, batchAABB)) {
2132
+ if (!isRteAabbVisible(frustum, batchAABB, rteCullCamera)) {
2168
2133
  frameBatchesFrustumCulled++;
2169
2134
  continue; // Entire batch is off-screen
2170
2135
  }
@@ -2260,6 +2225,8 @@ export class Renderer {
2260
2225
  const tpl = this.uniformScratch;
2261
2226
  const tplFlags = this.uniformScratchU32;
2262
2227
  tpl.set(viewProj, 0);
2228
+ relativeToEyeFrame.packUniforms(tpl, MESH_UNIFORM_OFFSET.rteViewProj);
2229
+ packRteCameraOrigin(relativeToEyeFrame, tpl);
2263
2230
  // Identity model matrix (positions already in world space)
2264
2231
  tpl[16] = 1;
2265
2232
  tpl[17] = 0;
@@ -2310,6 +2277,11 @@ export class Renderer {
2310
2277
  tplClipBit;
2311
2278
  tplFlags[2] = edgeEnabledU32;
2312
2279
  tplFlags[3] = edgeIntensityMilliU32;
2280
+ // Flat/quantized/textured batches enter WGSL in the single
2281
+ // camera-relative frame. Their fragment clip inputs must use
2282
+ // that same frame; mixing a local vertex with a 5,000 km f32
2283
+ // world plane loses centimetre cuts before the comparison.
2284
+ packRteFragmentSpace(relativeToEyeFrame, sectionPlaneData, options.clipBox, tpl);
2313
2285
  // Helper function to render a batch — patches color into the shared template
2314
2286
  const renderBatch = (batch) => {
2315
2287
  if (!batch.bindGroup || !batch.uniformBuffer)
@@ -2329,6 +2301,11 @@ export class Renderer {
2329
2301
  tpl[28] = o ? o[0] : 0;
2330
2302
  tpl[29] = o ? o[1] : 0;
2331
2303
  tpl[30] = o ? o[2] : 0;
2304
+ // The regular model matrix remains for absolute-space
2305
+ // fragment work (section/shadow); vertex projection uses
2306
+ // this f64-subtracted high/low origin instead.
2307
+ relativeToEyeFrame.packDrawableOrigin(o ?? [0, 0, 0], tpl, MESH_UNIFORM_OFFSET.drawableDelta);
2308
+ tplFlags[0] |= MESH_FLAG_RTE_DRAWABLE;
2332
2309
  // Quantized dequantization params (issue #1682 phase 6);
2333
2310
  // zeroed for f32 batches (their pipelines ignore them).
2334
2311
  const qz = batch.quantized;
@@ -2408,7 +2385,7 @@ export class Renderer {
2408
2385
  const kept = [];
2409
2386
  for (const it of instancedTemplates) {
2410
2387
  if (it.bounds) {
2411
- if (!FrustumUtils.isAABBVisible(frustum, it.bounds)) {
2388
+ if (!isRteAabbVisible(frustum, it.bounds, rteCullCamera)) {
2412
2389
  frameInstancedFrustumCulled++;
2413
2390
  continue;
2414
2391
  }
@@ -2426,11 +2403,12 @@ export class Renderer {
2426
2403
  visibleInstanced = kept;
2427
2404
  }
2428
2405
  if (visibleInstanced.length > 0) {
2406
+ uploadInstancedRteDeltas(device, visibleInstanced, relativeToEyeFrame.getCameraWorld());
2429
2407
  // Opaque instanced pass. flags.x bit 2 marks "instanced pass" so the
2430
2408
  // shader routes per-instance opacity: opaque (or selected) occurrences
2431
2409
  // draw here; translucent ones (lens/x-ray/compare overrides) are
2432
2410
  // discarded and drawn in the transparent sub-pass below.
2433
- this.pipeline.writeRawUniforms(tpl, 0x4);
2411
+ this.pipeline.writeRawUniforms(tpl, MESH_FLAG_RTE_DRAWABLE | 0x4);
2434
2412
  pass.setPipeline(this.pipeline.getInstancedPipeline());
2435
2413
  pass.setBindGroup(0, this.pipeline.getBindGroup());
2436
2414
  pass.setBindGroup(1, this.pipeline.getEnvironmentBindGroup());
@@ -2490,6 +2468,8 @@ export class Renderer {
2490
2468
  tpl[28] = tm.origin[0];
2491
2469
  tpl[29] = tm.origin[1];
2492
2470
  tpl[30] = tm.origin[2];
2471
+ relativeToEyeFrame.packDrawableOrigin(tm.origin, tpl, MESH_UNIFORM_OFFSET.drawableDelta);
2472
+ tplFlags[0] |= MESH_FLAG_RTE_DRAWABLE;
2493
2473
  tpl[32] = txOverride ? txOverride[0] : tm.color[0];
2494
2474
  tpl[33] = txOverride ? txOverride[1] : tm.color[1];
2495
2475
  tpl[34] = txOverride ? txOverride[2] : tm.color[2];
@@ -2636,7 +2616,7 @@ export class Renderer {
2636
2616
  if (visibleInstanced.length > 0 &&
2637
2617
  this.scene.hasTransparentInstances() &&
2638
2618
  instancedTransparentPipeline !== null) {
2639
- this.pipeline.writeRawUniforms(tpl, 0x4 | 0x8);
2619
+ this.pipeline.writeRawUniforms(tpl, MESH_FLAG_RTE_DRAWABLE | 0x4 | 0x8);
2640
2620
  pass.setPipeline(instancedTransparentPipeline);
2641
2621
  pass.setBindGroup(0, this.pipeline.getBindGroup());
2642
2622
  pass.setBindGroup(1, this.pipeline.getEnvironmentBindGroup());
@@ -2693,6 +2673,10 @@ export class Renderer {
2693
2673
  tplClipBit;
2694
2674
  tplFlags[2] = edgeEnabledU32;
2695
2675
  tplFlags[3] = edgeIntensityMilliU32;
2676
+ packRteFragmentSpace(relativeToEyeFrame, sectionPlaneData, options.clipBox, tpl);
2677
+ const transparentOrigin = mesh.rteOrigin ?? [mesh.transform.m[12], mesh.transform.m[13], mesh.transform.m[14]];
2678
+ relativeToEyeFrame.packDrawableOrigin(transparentOrigin, tpl, MESH_UNIFORM_OFFSET.drawableDelta);
2679
+ tplFlags[0] |= MESH_FLAG_RTE_DRAWABLE;
2696
2680
  device.queue.writeBuffer(mesh.uniformBuffer, 0, tpl);
2697
2681
  pass.setBindGroup(0, mesh.bindGroup);
2698
2682
  pass.setVertexBuffer(0, mesh.vertexBuffer);
@@ -2753,6 +2737,10 @@ export class Renderer {
2753
2737
  tplClipBit;
2754
2738
  tplFlags[2] = edgeEnabledU32;
2755
2739
  tplFlags[3] = edgeIntensityMilliU32;
2740
+ packRteFragmentSpace(relativeToEyeFrame, sectionPlaneData, options.clipBox, tpl);
2741
+ const selectedOrigin = mesh.rteOrigin ?? [mesh.transform.m[12], mesh.transform.m[13], mesh.transform.m[14]];
2742
+ relativeToEyeFrame.packDrawableOrigin(selectedOrigin, tpl, MESH_UNIFORM_OFFSET.drawableDelta);
2743
+ tplFlags[0] |= MESH_FLAG_RTE_DRAWABLE;
2756
2744
  device.queue.writeBuffer(mesh.uniformBuffer, 0, tpl);
2757
2745
  pass.setPipeline(this.pipeline.getSelectionPipeline());
2758
2746
  pass.setBindGroup(0, mesh.bindGroup);
@@ -2800,65 +2788,55 @@ export class Renderer {
2800
2788
  // viewport size to convert pixel sizes into clip-space offsets.
2801
2789
  if (this.pointCloudRenderer && this.pointCloudRenderer.hasAssets()) {
2802
2790
  this.pointCloudRenderer.draw(pass, {
2803
- viewProj,
2791
+ viewProj: relativeToEyeFrame.getViewProjection().m,
2792
+ relativeToEyeFrame,
2804
2793
  sectionPlane: sectionPlaneData
2805
2794
  ? { ...sectionPlaneData, flipped: options.sectionPlane?.flipped === true }
2806
2795
  : null,
2807
- viewport: { width: this.canvas.width, height: this.canvas.height },
2796
+ clipBox: options.clipBox,
2797
+ viewport: { width: this.canvas.width / this.pixelRatio, height: this.canvas.height / this.pixelRatio },
2808
2798
  });
2809
2799
  }
2810
2800
  // Section-plane gizmo, 2D section cap and every standalone 3D
2811
2801
  // overlay (annotation / alignment / grid / DXF / clash / symbolic
2812
2802
  // text). One draw call into the pass — see RendererOverlays.draw().
2813
- this.referenceImages.draw(pass, viewProj);
2803
+ this.referenceImages.draw(pass, viewProj, relativeToEyeFrame.getViewProjection().m, relativeToEyeFrame.getCameraWorld());
2814
2804
  this.overlays.draw(pass, {
2815
2805
  options,
2816
2806
  viewProj,
2817
2807
  modelBounds: this.getModelBounds(),
2818
2808
  camera: this.camera,
2819
- canvasWidth: this.canvas.width,
2820
- canvasHeight: this.canvas.height,
2809
+ canvasWidth: this.canvas.width / this.pixelRatio, // CSS px: glyph sizes are CSS px
2810
+ canvasHeight: this.canvas.height / this.pixelRatio, pixelRatio: this.pixelRatio,
2811
+ relativeToEyeFrame,
2812
+ rteViewProj: relativeToEyeFrame.getViewProjection().m,
2813
+ rteCamera: relativeToEyeFrame.getCameraWorld(),
2821
2814
  });
2822
2815
  pass.end();
2823
- const canRunPostPass = (contactEnabled || separationEnabled)
2824
- && this.postProcessor !== null;
2825
- if (canRunPostPass && this.postProcessor) {
2826
- this.postProcessor.updateOptions({
2827
- enableContactShading: contactEnabled,
2828
- contactRadius: visualEnhancement.contactShading.radius,
2829
- contactIntensity: visualEnhancement.contactShading.intensity,
2830
- });
2831
- this.postProcessor.apply(encoder, {
2832
- targetView: textureView,
2833
- // Depth-only view required because depth24plus-stencil8
2834
- // cannot be sampled as texture_depth_* with aspect 'all'.
2835
- depthView: this.pipeline.getDepthOnlyTextureView(),
2836
- objectIdView: this.pipeline.getObjectIdTextureView(),
2837
- contactQuality: contactEnabled && visualEnhancement.contactShading.quality === 'high' ? 'high' : 'low',
2838
- radius: Math.min(3.0, Math.max(1.0, visualEnhancement.contactShading.radius)),
2839
- intensity: contactEnabled ? Math.min(1.0, Math.max(0.0, visualEnhancement.contactShading.intensity)) : 0.0,
2840
- separationQuality: visualEnhancement.separationLines.quality === 'high' ? 'high' : 'low',
2841
- separationRadius: Math.min(2.0, Math.max(1.0, visualEnhancement.separationLines.radius)),
2842
- separationIntensity: separationEnabled ? Math.min(1.0, Math.max(0.0, visualEnhancement.separationLines.intensity)) : 0.0,
2843
- enableSeparationLines: separationEnabled,
2844
- });
2845
- }
2846
- // Eye-Dome Lighting — runs AFTER contact/separation so it darkens
2847
- // every layer uniformly. Cheap (~9 depth taps), only active when
2848
- // there are point clouds in the scene and the user has enabled it.
2849
- if (this.edlPass
2850
- && this.edlOptions.enabled
2851
- && this.pointCloudRenderer?.hasAssets()) {
2852
- this.edlPass.apply(encoder, {
2853
- targetView: textureView,
2854
- depthView: this.pipeline.getDepthOnlyTextureView(),
2855
- }, {
2856
- strength: this.edlOptions.strength,
2857
- radiusPx: this.edlOptions.radiusPx,
2858
- highQuality: this.edlOptions.highQuality,
2859
- });
2860
- }
2816
+ // Created lazily like the sky/shadow passes; each pass inside is too.
2817
+ this.postPasses ??= new PostPassChain(this.device, this.pipeline.getSampleCount());
2818
+ this.postPasses.encode({
2819
+ encoder,
2820
+ targetView: textureView,
2821
+ width: this.canvas.width,
2822
+ height: this.canvas.height,
2823
+ // Depth-only: depth24plus-stencil8 cannot be sampled as texture_depth_* with aspect 'all'.
2824
+ depthView: this.pipeline.getDepthOnlyTextureView(),
2825
+ objectIdView,
2826
+ projection: this.camera.getProjMatrix(),
2827
+ enhancement: visualEnhancement,
2828
+ effectsLive,
2829
+ pixelRatio: this.pixelRatio,
2830
+ // EDL only when point clouds are loaded and the user enabled it.
2831
+ edl: this.edlOptions.enabled && this.pointCloudRenderer?.hasAssets() ? this.edlOptions : null,
2832
+ });
2833
+ colorReadback = colorCapture && encodeRendererColorFrameCapture(device, encoder, colorCapture);
2861
2834
  device.queue.submit([encoder.finish()]);
2835
+ if (colorReadback && colorCapture) {
2836
+ settleRendererColorFrameCapture(this, colorCapture, colorReadback);
2837
+ colorCapture = null;
2838
+ colorReadback = null;
2839
+ }
2862
2840
  this._lastFrameStats = {
2863
2841
  drawCalls: frameDrawCalls,
2864
2842
  batchesDrawn: frameBatchesDrawn,
@@ -2882,6 +2860,7 @@ export class Renderer {
2882
2860
  }
2883
2861
  }
2884
2862
  catch (error) {
2863
+ discardRendererColorFrameReadback(colorReadback);
2885
2864
  // Balance the validation scope if we threw before popping it above —
2886
2865
  // an unpopped scope would capture every later frame's errors silently.
2887
2866
  // drainErrorScope logs a pop rejection (device loss) rather than
@@ -2901,7 +2880,9 @@ export class Renderer {
2901
2880
  // typed-array views — plus one 5-argument call in
2902
2881
  // `point-cloud-uniforms.ts` whose offset and size are compile-time
2903
2882
  // constants matching its scratch array — so the spec's
2904
- // `OperationError` preconditions are unreachable; the one
2883
+ // `OperationError` preconditions are unreachable (the partial-batch
2884
+ // upload's `createStaticGpuBuffer` also pads its whole-view payload
2885
+ // to the 4-byte multiple the spec demands, #5429); the one
2905
2886
  // `copyExternalImageToTexture` copies the glyph atlas's own
2906
2887
  // never-externally-drawn canvas at its full fixed size, so neither
2907
2888
  // `SecurityError` nor a zero-size `OperationError` can arise; and
@@ -2910,10 +2891,11 @@ export class Renderer {
2910
2891
  // draws, `finish`, `submit`, `createBindGroup`) reports failure as
2911
2892
  // an asynchronous `GPUValidationError` through the error scope, not
2912
2893
  // as a throw. The region's real healthy-device failure is
2913
- // `getOrCreatePartialBatch`'s `createBuffer({ mappedAtCreation:
2914
- // true })`, and that throws a `RangeError` — which is exactly why
2894
+ // `getOrCreatePartialBatch`'s CPU-side geometry merge running out of
2895
+ // host memory, and that throws a `RangeError` — which is exactly why
2915
2896
  // the discriminator keys on the TYPE and not on "a frame threw".
2916
2897
  this.containFrameThrow(error, 'encode');
2898
+ cancelRendererColorFrame(this);
2917
2899
  }
2918
2900
  }
2919
2901
  /**
@@ -3044,8 +3026,8 @@ export class Renderer {
3044
3026
  }
3045
3027
  /** Stage one new owner; borrowed mesh buffers must remain immutable until disposal. */
3046
3028
  prepareAuthoredOwner(parts) {
3047
- if (!this.device.isInitialized() || !this.pipeline)
3048
- throw new Error('Renderer is not initialized.');
3029
+ if (this.deviceLost || !this.device.isInitialized() || !this.pipeline)
3030
+ throw this.deviceLost ? rendererDeviceLostError() : new Error('Renderer is not initialized.');
3049
3031
  const prepared = this.scene.prepareAuthoredOwner(parts, this.device.getDevice(), this.pipeline);
3050
3032
  return { commit: () => { prepared.commit(); this.refreshPlacementBounds(); this.invalidateBVHCache(); this.requestRender(); }, dispose: prepared.dispose };
3051
3033
  }
@@ -3240,30 +3222,9 @@ export class Renderer {
3240
3222
  }
3241
3223
  return this.device.getDevice();
3242
3224
  }
3243
- /**
3244
- * Capture a screenshot of the current view
3245
- * Waits for GPU work to complete and captures exactly what's displayed
3246
- * @returns PNG data URL or null if capture failed
3247
- */
3248
- async captureScreenshot() {
3249
- if (!this.device.isInitialized()) {
3250
- console.warn('[Renderer] Cannot capture screenshot: not initialized');
3251
- return null;
3252
- }
3253
- try {
3254
- // Wait for any pending GPU work to complete before capturing
3255
- // This ensures we capture the fully rendered frame
3256
- const device = this.device.getDevice();
3257
- await device.queue.onSubmittedWorkDone();
3258
- // Capture exactly what's displayed on the canvas
3259
- const dataUrl = this.canvas.toDataURL('image/png');
3260
- return dataUrl;
3261
- }
3262
- catch (error) {
3263
- console.error('[Renderer] Screenshot capture failed:', error);
3264
- return null;
3265
- }
3266
- }
3225
+ captureScreenshot() { return captureRendererScreenshot(this.device, this.canvas); }
3226
+ /** Capture the next submitted frame's bounded color pixels, not compositor state. */
3227
+ captureColorFrame() { return requestRendererColorFrame(this, !this.destroyed && !this.deviceLost && this.device.isInitialized(), () => this.requestRender()); }
3267
3228
  /**
3268
3229
  * Destroy the renderer and release all GPU resources.
3269
3230
  *
@@ -3298,6 +3259,7 @@ export class Renderer {
3298
3259
  destroy() {
3299
3260
  this.initGeneration++;
3300
3261
  this.destroyed = true;
3262
+ cancelRendererColorFrame(this);
3301
3263
  this.teardown();
3302
3264
  this.rejectReadyWaiters(rendererDestroyedError());
3303
3265
  }
@@ -3306,15 +3268,18 @@ export class Renderer {
3306
3268
  * in-flight init. Callers: the public `destroy()` (which invalidates first)
3307
3269
  * and `initOnce()`, tearing down the previous init before building its own.
3308
3270
  */
3309
- teardown() {
3271
+ teardown(clearScene = true) {
3310
3272
  // Nothing below survives this call, so `whenReady()` / `isReady()` must
3311
3273
  // go back to waiting. Set first: every release below is synchronous, but
3312
3274
  // the flag is what a caller holding a live reference actually reads.
3313
3275
  this.ready = false;
3276
+ cancelRendererColorFrame(this);
3314
3277
  // Scene mesh GPU buffers
3315
- this.scene.clear();
3316
- // Re-arm the section-bounds diagnostic log for the next model.
3317
- this._loggedSectionBounds = false;
3278
+ if (clearScene) {
3279
+ this.scene.clear();
3280
+ // Re-arm the section-bounds diagnostic log for the next model.
3281
+ this._loggedSectionBounds = false;
3282
+ }
3318
3283
  // Render pipelines (textures + uniform buffers)
3319
3284
  this.pipeline?.destroy();
3320
3285
  this.pipeline = null;
@@ -3325,11 +3290,8 @@ export class Renderer {
3325
3290
  this.picker?.destroy();
3326
3291
  this.picker = null;
3327
3292
  this.pickingManager.setPicker(null);
3328
- // Post-processor uniform buffer
3329
- this.postProcessor?.destroy();
3330
- this.postProcessor = null;
3331
- this.edlPass?.destroy();
3332
- this.edlPass = null;
3293
+ this.postPasses?.destroy();
3294
+ this.postPasses = null;
3333
3295
  this.skyPass?.destroy();
3334
3296
  this.skyPass = null;
3335
3297
  this.shadowPass?.destroy();
@@ -3339,6 +3301,9 @@ export class Renderer {
3339
3301
  this.overlays.destroy();
3340
3302
  this.referenceImages.destroy();
3341
3303
  // Point cloud GPU resources
3304
+ this.pointCloudNextHandleId = this.pointCloudRenderer?.handleIds?.current() ?? this.pointCloudNextHandleId;
3305
+ this.pointCloudStreamEpoch++;
3306
+ this.pointCloudStreamEpochs.clear();
3342
3307
  this.pointCloudRenderer?.clear();
3343
3308
  this.pointCloudRenderer = null;
3344
3309
  // BIM ↔ scan deviation pipeline + cached BVH GPU buffers.