prts-terrarchive 0.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 (287) hide show
  1. package/GAME_ASSETS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +274 -0
  4. package/README.md +423 -0
  5. package/THIRD_PARTY_NOTICES.md +61 -0
  6. package/bin/bake-rhine-details.mjs +34 -0
  7. package/bin/install.js +273 -0
  8. package/bin/pack-map-assets.mjs +110 -0
  9. package/bin/preview-rhine.mjs +71 -0
  10. package/bin/rhine-detail-baker.ts +84 -0
  11. package/contracts/cloud_tools_v1.schema.json +243 -0
  12. package/contracts/corpus_i18n_v1.schema.json +179 -0
  13. package/contracts/corpus_tools_v1.schema.json +1801 -0
  14. package/cordis.patch.yml +32 -0
  15. package/docs/endfield-official-i18n.md +103 -0
  16. package/lib/client.js +3912 -0
  17. package/lib/endfield-map/map.js.br +0 -0
  18. package/lib/endfield-map/map.js.gz +0 -0
  19. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
  20. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
  21. package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
  22. package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
  23. package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
  24. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
  25. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
  26. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
  27. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
  28. package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
  29. package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
  30. package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
  31. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
  32. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
  33. package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
  34. package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
  35. package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
  36. package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
  37. package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
  38. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
  39. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
  40. package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
  41. package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
  42. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
  43. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
  44. package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
  45. package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
  46. package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
  47. package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
  48. package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
  49. package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
  50. package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
  51. package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
  52. package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
  53. package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
  54. package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
  55. package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
  56. package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
  57. package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
  58. package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
  59. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
  60. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
  61. package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
  62. package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
  63. package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
  64. package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
  65. package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
  66. package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
  67. package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
  68. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
  69. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
  70. package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
  71. package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
  72. package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
  73. package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
  74. package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
  75. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
  76. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
  77. package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
  78. package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
  79. package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
  80. package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
  81. package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
  82. package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
  83. package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
  84. package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
  85. package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
  86. package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
  87. package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
  88. package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
  89. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
  90. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
  91. package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
  92. package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
  93. package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
  94. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
  95. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
  96. package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
  97. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
  98. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
  99. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
  100. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
  101. package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
  102. package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
  103. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
  104. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
  105. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
  106. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
  107. package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
  108. package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
  109. package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
  110. package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
  111. package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
  112. package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
  113. package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
  114. package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
  115. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
  116. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
  117. package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
  118. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
  119. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
  120. package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
  121. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
  122. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
  123. package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
  124. package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
  125. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
  126. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
  127. package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
  128. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
  129. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
  130. package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
  131. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
  132. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
  133. package/lib/rhine/assets/archive-assembly.glb +0 -0
  134. package/lib/rhine/assets/archive-cassette.glb +0 -0
  135. package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
  136. package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
  137. package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
  138. package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
  139. package/lib/rhine/fonts/MiSans-license.pdf +0 -0
  140. package/lib/rhine/fonts/NOTICE.txt +6 -0
  141. package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
  142. package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
  143. package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
  144. package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
  145. package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
  146. package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
  147. package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
  148. package/lib/rhine/licenses/three-MIT.txt +21 -0
  149. package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
  150. package/lib/rhine/rhine.css +1 -0
  151. package/lib/rhine/rhine.js +5106 -0
  152. package/lib/skins/common.css +31 -0
  153. package/lib/skins/endfield-aic.css +141 -0
  154. package/lib/skins/prts-agent.css +136 -0
  155. package/lib/skins/rhine-lab.css +70 -0
  156. package/package.json +112 -0
  157. package/presets/definition.js +26 -0
  158. package/presets/prts/agent.cordis.yml +25 -0
  159. package/presets/prts/preset.yml +3 -0
  160. package/presets/register.js +86 -0
  161. package/resources/entity-routing.json +21 -0
  162. package/skills/prts-investigation/SKILL.md +121 -0
  163. package/skills/prts-retrieval/SKILL.md +35 -0
  164. package/skills/prts-retrieval/references/cloud-tools.md +13 -0
  165. package/skills/prts-retrieval/references/diagnostics.md +15 -0
  166. package/skills/prts-retrieval/references/local-tools.md +24 -0
  167. package/skills/prts-retrieval/references/module-arknights.md +23 -0
  168. package/skills/prts-retrieval/references/module-dual.md +15 -0
  169. package/skills/prts-retrieval/references/module-endfield.md +21 -0
  170. package/skills/prts-retrieval/references/retrieval-process.md +44 -0
  171. package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
  172. package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
  173. package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
  174. package/skills/prts-retrieval/references/tools-runtime.md +48 -0
  175. package/skills/prts-retrieval/references/wiki-schema.md +38 -0
  176. package/src/archive-presentation.js +287 -0
  177. package/src/cloud-projection.js +124 -0
  178. package/src/cloud.js +498 -0
  179. package/src/entity-recognizer.js +311 -0
  180. package/src/entity-routing.js +82 -0
  181. package/src/evidence-state.js +332 -0
  182. package/src/i18n.js +365 -0
  183. package/src/index.js +1261 -0
  184. package/src/installer.js +1362 -0
  185. package/src/investigation-review.js +87 -0
  186. package/src/investigation-store.js +579 -0
  187. package/src/investigation-tools.js +113 -0
  188. package/src/localization-format.js +23 -0
  189. package/src/manual-search.js +103 -0
  190. package/src/read.js +1097 -0
  191. package/src/release-compatibility.js +73 -0
  192. package/src/release-watch.js +40 -0
  193. package/src/search-projection.js +139 -0
  194. package/src/search.js +1522 -0
  195. package/src/skill.js +98 -0
  196. package/src/source-map.js +348 -0
  197. package/src/state.js +314 -0
  198. package/src/store.js +1569 -0
  199. package/src/timeline.js +386 -0
  200. package/src/ui.js +1353 -0
  201. package/src/wiki.js +111 -0
  202. package/tsconfig.rhine.json +15 -0
  203. package/ui/rhine/archive-navigation-limit.ts +31 -0
  204. package/ui/rhine/archive-rack.ts +83 -0
  205. package/ui/rhine/assets/evidence-observatory.webp +0 -0
  206. package/ui/rhine/board-plane-transform.ts +24 -0
  207. package/ui/rhine/catalogue.ts +72 -0
  208. package/ui/rhine/deferred-preparation.ts +146 -0
  209. package/ui/rhine/evidence-board-controls.css +22 -0
  210. package/ui/rhine/evidence-board-history.ts +73 -0
  211. package/ui/rhine/evidence-board-model.ts +189 -0
  212. package/ui/rhine/evidence-board-panel.ts +762 -0
  213. package/ui/rhine/evidence-board-scene.ts +548 -0
  214. package/ui/rhine/evidence-board.css +191 -0
  215. package/ui/rhine/evidence-card-art.ts +470 -0
  216. package/ui/rhine/evidence-card-style.ts +17 -0
  217. package/ui/rhine/evidence-inbox-scene.ts +43 -0
  218. package/ui/rhine/evidence-inbox.css +29 -0
  219. package/ui/rhine/evidence-inbox.ts +104 -0
  220. package/ui/rhine/index.ts +12 -0
  221. package/ui/rhine/interaction.css +20 -0
  222. package/ui/rhine/investigation-board-plane.ts +5 -0
  223. package/ui/rhine/investigation-board.css +34 -0
  224. package/ui/rhine/investigation-board.ts +348 -0
  225. package/ui/rhine/investigation-edits.ts +46 -0
  226. package/ui/rhine/investigation-layout.ts +29 -0
  227. package/ui/rhine/label-prefetch.ts +34 -0
  228. package/ui/rhine/looping-title.ts +40 -0
  229. package/ui/rhine/manual-search.css +41 -0
  230. package/ui/rhine/manual-search.ts +89 -0
  231. package/ui/rhine/original/appearance.ts +180 -0
  232. package/ui/rhine/original/archive-label.ts +84 -0
  233. package/ui/rhine/original/archive-lighting.ts +39 -0
  234. package/ui/rhine/original/archive-loop.ts +82 -0
  235. package/ui/rhine/original/archive-refill.ts +64 -0
  236. package/ui/rhine/original/array-detail-data.ts +2 -0
  237. package/ui/rhine/original/array-detail.ts +41 -0
  238. package/ui/rhine/original/array-interior.ts +411 -0
  239. package/ui/rhine/original/array-occlusion.ts +166 -0
  240. package/ui/rhine/original/array-shell.ts +38 -0
  241. package/ui/rhine/original/array-visibility.ts +309 -0
  242. package/ui/rhine/original/asset-url.ts +4 -0
  243. package/ui/rhine/original/brand.ts +12 -0
  244. package/ui/rhine/original/cassette-lod.ts +81 -0
  245. package/ui/rhine/original/data.ts +21 -0
  246. package/ui/rhine/original/decryption.ts +185 -0
  247. package/ui/rhine/original/detail-signature.ts +18 -0
  248. package/ui/rhine/original/glass-reveal.ts +45 -0
  249. package/ui/rhine/original/internal-optics.ts +70 -0
  250. package/ui/rhine/original/label-renderer.ts +119 -0
  251. package/ui/rhine/original/label-textures.ts +117 -0
  252. package/ui/rhine/original/model-viewer.ts +658 -0
  253. package/ui/rhine/original/motion.ts +116 -0
  254. package/ui/rhine/original/opaque-backfaces.ts +64 -0
  255. package/ui/rhine/original/program-preparation.ts +111 -0
  256. package/ui/rhine/original/quality-renderer.ts +97 -0
  257. package/ui/rhine/original/quality-settings.ts +111 -0
  258. package/ui/rhine/original/render-quality.ts +163 -0
  259. package/ui/rhine/original/scene.ts +2051 -0
  260. package/ui/rhine/original/shelf-interior.ts +142 -0
  261. package/ui/rhine/original/ui-transitions.ts +111 -0
  262. package/ui/rhine/original/viewer-camera.ts +92 -0
  263. package/ui/rhine/original/viewport-layout.ts +41 -0
  264. package/ui/rhine/original-ui.css +2334 -0
  265. package/ui/rhine/readability.css +46 -0
  266. package/ui/rhine/reading-object.ts +96 -0
  267. package/ui/rhine/reading-origin.ts +28 -0
  268. package/ui/rhine/reading-room.css +55 -0
  269. package/ui/rhine/report-markdown.ts +98 -0
  270. package/ui/rhine/scene-edge-navigation.ts +84 -0
  271. package/ui/rhine/scene.ts +1726 -0
  272. package/ui/rhine/shelf-layout.ts +38 -0
  273. package/ui/rhine/snapshot-update-gate.ts +22 -0
  274. package/ui/rhine/source-reading.ts +15 -0
  275. package/ui/rhine/source-selection.css +164 -0
  276. package/ui/rhine/temporary-performance-observers.ts +163 -0
  277. package/ui/rhine/temporary-performance-panel.ts +124 -0
  278. package/ui/rhine/temporary-performance.css +19 -0
  279. package/ui/rhine/temporary-performance.ts +552 -0
  280. package/ui/rhine/temporary-render-diagnostics.ts +105 -0
  281. package/ui/rhine/tool-activity.ts +69 -0
  282. package/ui/rhine/types.ts +219 -0
  283. package/ui/rhine/visible-scene-hit.ts +13 -0
  284. package/ui/rhine/workbench.css +818 -0
  285. package/ui/rhine/workbench.ts +2245 -0
  286. package/ui/rhine/workspace-travel.ts +32 -0
  287. package/vite.rhine.config.ts +19 -0
@@ -0,0 +1,2051 @@
1
+ import { visibleSceneHit } from '../visible-scene-hit';
2
+ import * as THREE from "three";
3
+ import type { RenderPerformanceCapture } from "../temporary-performance";
4
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
5
+ import { createArchiveLighting, type LightingLook } from "./archive-lighting";
6
+ import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
7
+ import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
8
+ import { SSAOPass } from "three/addons/postprocessing/SSAOPass.js";
9
+ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
10
+ import { BokehPass } from "three/addons/postprocessing/BokehPass.js";
11
+ import { SMAAPass } from "three/addons/postprocessing/SMAAPass.js";
12
+ import { normalizeQuality, type RenderQuality } from "./render-quality";
13
+ import { applyTextureQuality, resizeQuality } from "./quality-renderer";
14
+ import { CardAppearance } from "./appearance";
15
+ import { configureInternalOptics } from "./internal-optics";
16
+ import { ARRAY_INTERIOR_SURFACES, bakeArrayInterior, copyInteriorLights } from "./array-interior";
17
+ import { ArrayVisibility } from "./array-visibility";
18
+ import { enableOpaqueBackfaces } from "./opaque-backfaces";
19
+ import { ArchiveRefill } from "./archive-refill";
20
+ import { ShelfInterior, SHELF_INTERIOR_SURFACES } from "./shelf-interior";
21
+ import { ArchiveLabelTextures } from "./label-textures";
22
+ import { ProgramPreparation } from "./program-preparation";
23
+ import { SHELF_PAGE_SIZE } from "../shelf-layout";
24
+ import { createCassetteLOD, splitCassetteFasteners } from "./cassette-lod";
25
+ import { splitArrayShell } from "./array-shell";
26
+ import { createArrayDetail } from "./array-detail";
27
+ import { DecryptionController } from "./decryption";
28
+ import { CLEAR_ROUGHNESS } from "./glass-reveal";
29
+ import { fileAtSlot, fileLocation } from "./data";
30
+ import {
31
+ cellKey,
32
+ sameCell,
33
+ selectionCell,
34
+ fileAtCell,
35
+ poolCell,
36
+ visibleCell,
37
+ wrap,
38
+ LOOP_COLUMNS,
39
+ LOOP_ROWS,
40
+ COLUMN_SPACING,
41
+ ROW_SPACING,
42
+ type ArchiveCell,
43
+ type ArchiveNavigation,
44
+ } from "./archive-loop";
45
+ import { ArchiveLabelRenderer, ARCHIVE_LABEL_LAYER } from "./label-renderer";
46
+ import { labelMarkSvg } from "./brand";
47
+ import { ARCHIVE_LABEL_NAME, ARCHIVE_LABEL_WIDTH, ARCHIVE_LABEL_HEIGHT, archiveLabelKey, paintArchiveLabel, positionArchiveLabel, ARCHIVE_LABEL_SIZE, type ArchiveLabel } from "./archive-label";
48
+ import { archiveFraming, swipeDirection } from "./viewport-layout";
49
+ import { assetUrl as publicAsset } from "./asset-url";
50
+ import {
51
+ archiveWave,
52
+ extraction,
53
+ baselineSelectionWave,
54
+ rippleEnvelope,
55
+ settlingWave,
56
+ damp,
57
+ columnStrength,
58
+ idleWave,
59
+ cinematicField,
60
+ INSPECTION_LIFT,
61
+ returnStep,
62
+ } from "./motion";
63
+
64
+ const ease = (t: number) => {
65
+ t = THREE.MathUtils.clamp(t, 0, 1);
66
+ return t * t * t * (t * (t * 6 - 15) + 10);
67
+ };
68
+ export class ArchiveScene {
69
+ performanceProbe?: RenderPerformanceCapture;
70
+ private loadModel(assetUrl: string, asset: string) {
71
+ const task = () => new GLTFLoader().loadAsync(assetUrl);
72
+ return this.performanceProbe ? this.performanceProbe.trackLoad("model-fetch-decode", task, { asset }) : task();
73
+ }
74
+ readonly renderer: THREE.WebGLRenderer;
75
+ readonly scene = new THREE.Scene();
76
+ // The reference uses a long lens 72–140 units from the cassette. A 0.1 near
77
+ // plane quantizes adjacent optical layers to the same depth (visible shimmer).
78
+ // All visible foreground geometry is beyond 5; retain the framing and lens.
79
+ readonly camera = new THREE.PerspectiveCamera(34, 16 / 9, 5, 300);
80
+ private composer: EffectComposer;
81
+ private readonly labelRenderer = new ArchiveLabelRenderer();
82
+ private scenePixelHeight = 1;
83
+ private ao: SSAOPass;
84
+ private bokeh: BokehPass;
85
+ private instances: THREE.InstancedMesh[] = [];
86
+ private readonly backfaceSurfaces = { full: [] as string[], array: [] as string[] };
87
+ private arrayPickMeshes: THREE.InstancedMesh[] = [];
88
+ private arrayStationPickMeshes: THREE.InstancedMesh[] = [];
89
+ private readonly arrayVisibility = new ArrayVisibility(LOOP_COLUMNS * LOOP_ROWS);
90
+ private arrayInteriorBake?: ReturnType<typeof bakeArrayInterior>;
91
+ private arrayInteriorInstances: THREE.InstancedMesh[] = [];
92
+ private arrayInteriorSources: THREE.Mesh[] = [];
93
+ private arrayInteriorError?: string;
94
+ private shelfInterior?: ShelfInterior;
95
+ private readonly programPreparation = new ProgramPreparation();
96
+ private shelfInteriorAttempted = false;
97
+ private shelfInteriorError?: string;
98
+ private readonly collectionPools = { moving: [] as THREE.Group[], shelf: [] as THREE.Group[] };
99
+ private readonly collectionKinds = new WeakMap<THREE.Group, "moving" | "shelf">();
100
+ private readonly pooledCollections = new Set<THREE.Group>();
101
+ private collectionCreated = 0;
102
+ private collectionReused = 0;
103
+ private model = new THREE.Group();
104
+ private appearance = new CardAppearance();
105
+ private readonly decryption = new DecryptionController();
106
+ private cursor = new THREE.Vector2();
107
+ private raycaster = new THREE.Raycaster();
108
+ private dummy = new THREE.Object3D();
109
+ private positions: THREE.Vector3[] = [];
110
+ private cells: ArchiveCell[] = [];
111
+ private selectedCell: ArchiveCell = { lane: 2, row: 12 };
112
+ private departedSelection = false;
113
+ private readonly arrayRefill = new ArchiveRefill();
114
+ private looping = false;
115
+ private coordinateOrigin: ArchiveCell = { lane: 0, row: 0 };
116
+ private lift = { value: 0, velocity: 0 };
117
+ private rail = { value: 0, velocity: 0 };
118
+ private shoulder = { value: 12, velocity: 0 };
119
+ private laneFocus = { value: 2, velocity: 0 };
120
+ private columnCamera = { value: 0, velocity: 0 };
121
+ private returnY: number | null = null;
122
+ private canInspect = false;
123
+ private clearance = 0;
124
+ private pulseGain = 1;
125
+ private idleGain = 0;
126
+ private lastInteraction = 0;
127
+ private scanTime = 29.1;
128
+ private scanBlend = 0;
129
+ private investigationScanning = false;
130
+ private investigationSlots = new Map<number, ArchiveCell>();
131
+ private investigationPoses = new Map<number, { position: THREE.Vector3; quaternion: THREE.Quaternion }>();
132
+ private cameraAim = new THREE.Vector3();
133
+ private outgoing: {
134
+ group: THREE.Group;
135
+ slot: number;
136
+ cell: ArchiveCell;
137
+ lift: { value: number; velocity: number };
138
+ returnY: number | null;
139
+ clarity: number;
140
+ }[] = [];
141
+ private pulses: { row: number; lane: number; time: number }[] = [];
142
+ private pendingPulse: ArchiveCell | null = null;
143
+ private selectedSlot = 76;
144
+ private detail = 0;
145
+ private targetDetail = 0;
146
+ private reveal = 0;
147
+ private targetReveal = 0;
148
+ private last = 0;
149
+ private pointer = new THREE.Vector2();
150
+ private dragging = false;
151
+ private rotation = 0;
152
+ private targetRotation = 0;
153
+ private light: THREE.DirectionalLight;
154
+ private clock = 0;
155
+ private loaded = false;
156
+ private labelCanvas = document.createElement("canvas");
157
+ private labelTexture?: THREE.CanvasTexture;
158
+ private labelMark = new Image();
159
+ private archiveLabel: ArchiveLabel | null = null;
160
+ private labelTextureCache?: ArchiveLabelTextures;
161
+ private labels() {
162
+ return this.labelTextureCache ??= new ArchiveLabelTextures(this.labelMark, Math.min(16, this.renderer.capabilities.getMaxAnisotropy()), () => this.performanceProbe);
163
+ }
164
+ setArchiveLabelCandidates(contents: readonly ArchiveLabel[]) { this.labels().setPrefetchCandidates(contents); }
165
+ archiveLabelPrepared(content: ArchiveLabel) { return this.labels().isPrepared(content); }
166
+ prepareArchiveLabel(content: ArchiveLabel, context?: { plan: number; slot: number; intent?: 'row' | 'lane' | 'pointer' | 'activity' }) {
167
+ if (this.disposed) return;
168
+ const finish = this.performanceProbe?.beginWork('label-prefetch');
169
+ try { this.labels().prepare(content, this.renderer, context); finish?.(true); }
170
+ catch (error) { finish?.(false); throw error; }
171
+ }
172
+ private reduced = false;
173
+ private quality = normalizeQuality(undefined);
174
+ private appliedQuality = "";
175
+ private smaa = new SMAAPass();
176
+ private aoKernelSize = 32;
177
+ private displayHeight = 0;
178
+ private layoutKind = "";
179
+ // Plugin integration hooks. Zero offset and no hook preserve the upstream
180
+ // archive renderer, camera, animation, quality and pointer behavior exactly.
181
+ readonly collectionOffset = new THREE.Vector3();
182
+ collectionOverviewScale = 1;
183
+ collectionFraming?: { center: THREE.Vector3; span: number; x: number; y: number; amount: number; distance?: number;
184
+ direction?: THREE.Vector3; parallax?: boolean };
185
+ collectionDepthOfFieldScale = 1;
186
+ collectionArrayVisible = true;
187
+ collectionFocus?: THREE.Vector3;
188
+ /** World-space reading station for a cassette extracted from the rack. */
189
+ collectionDetailPosition?: THREE.Vector3;
190
+ private investigationClearances = new Map<number, number>();
191
+ beforeRender?: () => void;
192
+ private appliedCollectionOffset = new THREE.Vector3();
193
+ private appliedLightingOffset = new THREE.Vector3();
194
+ private events = new AbortController();
195
+ private disposed = false;
196
+ onSelect?: (index: number, cell?: ArchiveCell) => void;
197
+ onHover?: (index: number | null, cell?: ArchiveCell) => void;
198
+ onLabelOpen?: (sourceId: string) => void;
199
+ onNavigate?: (axis: "row" | "lane", direction: number) => void;
200
+ constructor(
201
+ private container: HTMLElement,
202
+ private readonly selectionPulse = baselineSelectionWave,
203
+ private readonly deferSelectionPulse = false,
204
+ private readonly lightingLook: LightingLook = "baseline",
205
+ private readonly arrayInteriorMode: "baked" | "geometry" = "baked",
206
+ private readonly arrayCulling = true,
207
+ private readonly arrayLOD = true,
208
+ private readonly arrayOcclusion = true,
209
+ private readonly arrayShell = true,
210
+ private readonly arrayDetails = true,
211
+ ) {
212
+ // The archive always displays clear covers; no timed decryption or refrost.
213
+ // Keep the completed frame for existing inspection/status consumers.
214
+ this.decryption.finish();
215
+ this.renderer = new THREE.WebGLRenderer({
216
+ antialias: true,
217
+ alpha: false,
218
+ powerPreference: "high-performance",
219
+ });
220
+ this.renderer.setPixelRatio(
221
+ Math.min(devicePixelRatio, 1.5) *
222
+ Math.min(innerWidth / 1920, innerHeight / 1080),
223
+ );
224
+ this.renderer.setSize(container.clientWidth, container.clientHeight);
225
+ this.renderer.info.autoReset = false;
226
+ this.renderer.shadowMap.enabled = true;
227
+ // RenderPass, SSAO and Bokeh render this scene separately. Refresh dynamic
228
+ // shadows once after all motion updates, then reuse them for the other passes.
229
+ this.renderer.shadowMap.autoUpdate = false;
230
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
231
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
232
+ this.renderer.toneMappingExposure = 1.05;
233
+ this.renderer.domElement.setAttribute(
234
+ "aria-label",
235
+ "三维研究档案阵列,可点击选择档案",
236
+ );
237
+ container.appendChild(this.renderer.domElement);
238
+ this.scene.background = new THREE.Color("#eae5e1");
239
+ this.scene.fog = new THREE.Fog("#eae5e1", 22, 47);
240
+ this.light = createArchiveLighting(this.renderer, this.scene, lightingLook);
241
+ this.light.castShadow = true;
242
+ Object.assign(this.light.shadow.camera, {
243
+ left: -16,
244
+ right: 16,
245
+ top: 15,
246
+ bottom: -15,
247
+ near: 0.1,
248
+ far: 45,
249
+ });
250
+ this.light.shadow.mapSize.set(2048, 2048);
251
+ this.light.shadow.normalBias = lightingLook === "refined" ? 0.018 : 0.035;
252
+ this.light.shadow.bias = lightingLook === "refined" ? -0.00012 : -0.0003;
253
+ this.light.shadow.radius = 4;
254
+ const floor = new THREE.Mesh(
255
+ new THREE.PlaneGeometry(200, 200),
256
+ new THREE.MeshStandardMaterial({ color: "#d8c9b9", roughness: 0.95 }),
257
+ );
258
+ floor.rotation.x = -Math.PI / 2;
259
+ floor.position.y = -4.63;
260
+ floor.receiveShadow = true;
261
+ this.scene.add(floor);
262
+ this.camera.position.set(-62.26, 35.98, 43.28);
263
+ this.cameraAim.set(-0.5, 1.1, 0.4);
264
+ this.camera.fov = 6.15;
265
+ this.camera.lookAt(this.cameraAim);
266
+ this.composer = new EffectComposer(this.renderer);
267
+ this.composer.addPass(new RenderPass(this.scene, this.camera));
268
+ this.ao = new SSAOPass(
269
+ this.scene,
270
+ this.camera,
271
+ container.clientWidth,
272
+ container.clientHeight,
273
+ );
274
+ this.ao.kernelRadius = lightingLook === "refined" ? 0.44 : 0.38;
275
+ this.ao.minDistance = 0.001;
276
+ this.ao.maxDistance = 0.09;
277
+ this.composer.addPass(this.ao);
278
+ this.bokeh = new BokehPass(this.scene, this.camera, {
279
+ focus: 25,
280
+ aperture: 0.0018,
281
+ maxblur: 0.011,
282
+ });
283
+ this.composer.addPass(this.bokeh);
284
+ this.smaa.enabled = false;
285
+ this.composer.addPass(this.smaa);
286
+ this.composer.addPass(new OutputPass());
287
+ this.labelRenderer.attach(this.composer);
288
+ this.raycaster.layers.enable(ARCHIVE_LABEL_LAYER);
289
+ this.bindPointer();
290
+ }
291
+ async load(assetUrl = publicAsset("assets/archive-cassette.glb")) {
292
+ this.labelMark.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(labelMarkSvg)}`;
293
+ const [gltf] = await Promise.all([
294
+ this.loadModel(assetUrl, "archive-cassette.glb"),
295
+ this.labelMark.decode(),
296
+ // Canvas text is rasterized once. Load both used weights before any bake
297
+ // so fallback glyphs cannot remain frozen in an otherwise ready texture.
298
+ document.fonts.load('400 112px MiSans').catch(() => []),
299
+ document.fonts.load('600 144px MiSans').catch(() => []),
300
+ ]);
301
+ gltf.scene.updateMatrixWorld(true);
302
+ this.model.userData.performanceFamily = "hero";
303
+ const meshes: THREE.Mesh[] = [];
304
+ gltf.scene.traverse((o) => {
305
+ if (o instanceof THREE.Mesh) meshes.push(o);
306
+ });
307
+ const count = LOOP_COLUMNS * LOOP_ROWS;
308
+ const addArrayInstance = (mesh: THREE.Mesh, castShadow = false) => {
309
+ const name = mesh.name || mesh.userData.surface || "Archive_Array";
310
+ const batch = (geometry: THREE.BufferGeometry, suffix = "", material = mesh.material) => {
311
+ const inst = new THREE.InstancedMesh(geometry, material, count);
312
+ inst.name = name + suffix;
313
+ inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
314
+ inst.castShadow = castShadow; inst.receiveShadow = true; inst.frustumCulled = false;
315
+ // Test every part, including upper fasteners/inlays that can leave
316
+ // the screen while the lower half of their cassette is still visible.
317
+ // Opaque substrates keep a separate light-frustum instance buffer.
318
+ inst.userData.occlusionCull = this.arrayOcclusion;
319
+ if (mesh.userData.surface === "Frosted_Polymer") this.arrayPickMeshes.push(inst);
320
+ if (["Frosted_Polymer", "Ivory_Edges", "Optical_Diffuser"].includes(mesh.userData.surface)) {
321
+ this.arrayStationPickMeshes.push(inst);
322
+ }
323
+ this.instances.push(inst); this.scene.add(inst);
324
+ return inst;
325
+ };
326
+ const shell = this.arrayShell && this.arrayOcclusion && splitArrayShell(mesh.userData.surface, mesh.geometry);
327
+ if (shell) return shell.map(part => batch(part.geometry, part.suffix))[0];
328
+ const lettering = this.arrayDetails && this.arrayLOD && name === "Array_Interior_Lettering"
329
+ ? createArrayDetail(name, mesh.geometry, mesh.material as THREE.Material) : undefined;
330
+ if (lettering) {
331
+ const high = batch(mesh.geometry), low = batch(lettering.geometry, "_LOD1", lettering.material);
332
+ high.userData.lodLevel = 0; low.userData.lodLevel = 1;
333
+ this.arrayVisibility.addDetailLOD(high, low, 0.43, 64, 72);
334
+ return high;
335
+ }
336
+ const lod = this.arrayLOD && createCassetteLOD(mesh.userData.surface, mesh.geometry);
337
+ const parts = this.arrayOcclusion && mesh.userData.surface === "Titanium_Fasteners"
338
+ ? splitCassetteFasteners(mesh.geometry) : undefined;
339
+ const lowParts = parts && lod ? splitCassetteFasteners(lod.geometry) : undefined;
340
+ const high = (parts ?? [mesh.geometry]).map((geometry, index) => {
341
+ const suffix = parts ? (index === 0 ? "_Top" : "_Bottom") : "";
342
+ const inst = batch(geometry, suffix);
343
+ const lowGeometry = parts ? lowParts?.[index] : lod ? lod.geometry : undefined;
344
+ if (lod && lowGeometry) {
345
+ const low = batch(lowGeometry, suffix + "_LOD1");
346
+ inst.userData.lodLevel = 0; low.userData.lodLevel = 1;
347
+ this.arrayVisibility.addDetailLOD(inst, low, lod.featureSize, lod.enterBelow, lod.leaveAbove);
348
+ }
349
+ return inst;
350
+ });
351
+ if (parts && lod) lod.geometry.dispose();
352
+ return high[0];
353
+ };
354
+ for (let index = 0; index < count; index++) {
355
+ const cell = poolCell(index);
356
+ this.cells.push(cell);
357
+ this.positions.push(this.cellPosition(cell));
358
+ }
359
+ for (const mesh of meshes) {
360
+ const geom = mesh.geometry
361
+ .clone()
362
+ .applyMatrix4(mesh.matrixWorld)
363
+ .scale(1, 1, 1);
364
+ const source = mesh.material as THREE.MeshStandardMaterial;
365
+ const name = source.name.replace(/\.\d+$/, "");
366
+ const mat = source.clone() as THREE.MeshPhysicalMaterial;
367
+ mat.envMapIntensity = 0.6;
368
+ if (name === "Frosted_Polymer") {
369
+ mat.color.set("#fffdfa");
370
+ mat.transmission = 0.9;
371
+ mat.thickness = 0.12;
372
+ mat.roughness = CLEAR_ROUGHNESS;
373
+ mat.ior = 1.46;
374
+ mat.attenuationColor = new THREE.Color("#eee6df");
375
+ mat.attenuationDistance = 2;
376
+ }
377
+ if (name === "Internal_Ceramic") {
378
+ mat.color.set(this.lightingLook === "refined" ? "#c4baae" : "#c7beb6");
379
+ mat.roughness = 0.6;
380
+ }
381
+ if (name === "Printed_Label") mat.color.set("#eae5dc");
382
+ if (name === "Ivory_Edges") {
383
+ mat.color.set("#f0e7df");
384
+ mat.roughness = 0.31;
385
+ mat.transmission = 0.65;
386
+ mat.thickness = 0.04;
387
+ }
388
+ if (name === "Optical_Diffuser") {
389
+ mat.color.set("#e2dad4");
390
+ mat.transmission = 0;
391
+ mat.roughness = 0.7;
392
+ }
393
+ if (name === "Subsurface_Optics") {
394
+ mat.color.set(this.lightingLook === "refined" ? "#b9a796" : "#b9aba1");
395
+ mat.roughness = 0.48;
396
+ mat.metalness = 0.05;
397
+ }
398
+ if (name === "Optical_Edges") {
399
+ // Internal refractive shoulders must be in the opaque capture: WebGL's
400
+ // screen-space transmission cannot recursively sample another glass mesh.
401
+ mat.transmission = 0;
402
+ mat.color.set(this.lightingLook === "refined" ? "#d8c7b5" : "#d4c7be");
403
+ mat.roughness = 0.26;
404
+ mat.metalness = 0.08;
405
+ }
406
+ configureInternalOptics(name, mat);
407
+ if (enableOpaqueBackfaces(name, geom, mat, "full")) this.backfaceSurfaces.full.push(name);
408
+ if (name === "Carbon_Ink") continue;
409
+ const selectedMesh = new THREE.Mesh(geom, mat);
410
+ selectedMesh.userData.surface = name;
411
+ selectedMesh.castShadow = name === "Optical_Diffuser";
412
+ selectedMesh.receiveShadow = true;
413
+ this.model.add(selectedMesh);
414
+ // Keep the authored shell and the complete selected model. Packed cards
415
+ // share a baked interior; extraction/return ownership below swaps the
416
+ // entire array instance for the original model before it rises.
417
+ const shellSurfaces = [
418
+ "Frosted_Polymer",
419
+ "Ivory_Edges",
420
+ "Titanium_Fasteners",
421
+ "Index_Inlay",
422
+ "Optical_Diffuser",
423
+ ];
424
+ const isInterior = ARRAY_INTERIOR_SURFACES.has(name);
425
+ if (
426
+ !shellSurfaces.includes(name) &&
427
+ !isInterior
428
+ ) {
429
+ this.appearance.register(name, mat);
430
+ continue;
431
+ }
432
+ const arrayMat = mat.clone();
433
+ // The array screw LOD has open edges; it retains the original sidedness.
434
+ if (name === "Titanium_Fasteners") arrayMat.side = source.side;
435
+ if (name === "Frosted_Polymer") {
436
+ // A packed row keeps the authored clear cover, so every cassette reads
437
+ // with the same body and interior as an extracted file.
438
+ arrayMat.transmission = 0.985;
439
+ arrayMat.thickness = 0.018;
440
+ arrayMat.roughness = CLEAR_ROUGHNESS;
441
+ arrayMat.attenuationDistance = 8;
442
+ if (this.lightingLook === "refined") {
443
+ // Longer oblique paths pick up the warm body tint, while the thin
444
+ // edges and the extracted clear cover retain a brighter response.
445
+ arrayMat.thickness = 0.28;
446
+ arrayMat.attenuationColor.set("#d4c7b4");
447
+ arrayMat.attenuationDistance = 1.2;
448
+ }
449
+ arrayMat.transparent = false;
450
+ arrayMat.color.set("#fffdfa");
451
+ arrayMat.onBeforeCompile = (shader) => {
452
+ shader.vertexShader =
453
+ "varying float vPanelHeight;\n" + shader.vertexShader;
454
+ shader.vertexShader = shader.vertexShader.replace(
455
+ "#include <begin_vertex>",
456
+ "#include <begin_vertex>\nvPanelHeight = position.y / 3.7;",
457
+ );
458
+ shader.fragmentShader =
459
+ "varying float vPanelHeight;\n" + shader.fragmentShader;
460
+ shader.fragmentShader = shader.fragmentShader.replace(
461
+ "#include <color_fragment>",
462
+ "#include <color_fragment>\ndiffuseColor.rgb *= mix(vec3(0.40, 0.30, 0.20), vec3(1.0, 0.98, 0.94), smoothstep(0.1, 1.0, vPanelHeight));",
463
+ );
464
+ };
465
+ }
466
+ if (name === "Ivory_Edges") {
467
+ // Opaque ribs keep a packed row readable; the clear cover above is what
468
+ // carries the authored body and interior.
469
+ arrayMat.transmission = 0;
470
+ arrayMat.color.set(
471
+ this.lightingLook === "refined" ? "#dcc9b0" : "#fff5e9",
472
+ );
473
+ arrayMat.roughness = 0.38;
474
+ }
475
+ if (name === "Index_Inlay") {
476
+ arrayMat.color.set("#e4d6c5");
477
+ arrayMat.metalness = 0.05;
478
+ }
479
+ if (enableOpaqueBackfaces(name, geom, arrayMat, "array")) this.backfaceSurfaces.array.push(name);
480
+ this.appearance.register(name, mat, arrayMat);
481
+ const arraySource = new THREE.Mesh(geom, arrayMat);
482
+ arraySource.userData.surface = name;
483
+ if (isInterior || name === "Optical_Diffuser") this.arrayInteriorSources.push(arraySource);
484
+ if (this.arrayOcclusion && name === "Optical_Diffuser") this.arrayVisibility.setOpaqueSubstrate(geom);
485
+ // Register both palettes even when the body is baked. The complete model
486
+ // must retain the existing low-lift appearance rather than acquire the
487
+ // detail-only dither used for surfaces without a low palette.
488
+ if (!isInterior || this.arrayInteriorMode === "geometry")
489
+ addArrayInstance(arraySource, name === "Optical_Diffuser");
490
+ }
491
+ if (this.arrayInteriorMode === "baked") {
492
+ try {
493
+ this.arrayInteriorBake = bakeArrayInterior(this.renderer, this.scene, this.arrayInteriorSources);
494
+ for (const mesh of this.arrayInteriorBake.meshes)
495
+ this.arrayInteriorInstances.push(addArrayInstance(mesh));
496
+ } catch (error) {
497
+ const failedInstances = new Set(this.arrayInteriorInstances);
498
+ this.instances = this.instances.filter(inst => !failedInstances.has(inst));
499
+ for (const inst of failedInstances) {
500
+ this.scene.remove(inst);
501
+ inst.dispose();
502
+ }
503
+ this.arrayInteriorInstances = [];
504
+ this.arrayInteriorBake?.dispose();
505
+ this.arrayInteriorBake = undefined;
506
+ this.arrayInteriorError = error instanceof Error ? error.message : String(error);
507
+ console.warn("Archive interior bake unavailable; retaining full geometry.", error);
508
+ for (const mesh of this.arrayInteriorSources)
509
+ if (ARRAY_INTERIOR_SURFACES.has(mesh.userData.surface)) addArrayInstance(mesh);
510
+ }
511
+ }
512
+ this.arrayVisibility.prepare(this.instances);
513
+ this.labelCanvas.width = ARCHIVE_LABEL_WIDTH;
514
+ this.labelCanvas.height = ARCHIVE_LABEL_HEIGHT;
515
+ this.labelTexture = new THREE.CanvasTexture(this.labelCanvas);
516
+ this.labelTexture.colorSpace = THREE.SRGBColorSpace;
517
+ this.labelTexture.userData.archivePrint = true;
518
+ this.labelTexture.anisotropy =
519
+ this.renderer.capabilities.getMaxAnisotropy();
520
+ const label = new THREE.Mesh(
521
+ new THREE.PlaneGeometry(ARCHIVE_LABEL_SIZE.width, ARCHIVE_LABEL_SIZE.height),
522
+ this.labelRenderer.createMaterial(this.labelTexture),
523
+ );
524
+ label.name = ARCHIVE_LABEL_NAME;
525
+ label.layers.set(ARCHIVE_LABEL_LAYER);
526
+ positionArchiveLabel(label);
527
+ this.model.add(label);
528
+ this.appearance.prepare(this.model);
529
+ this.appearance.apply(this.model, 0);
530
+ this.appearance.setClarity(this.model, 1);
531
+ const placeholder = this.labelTexture;
532
+ this.drawLabel(0);
533
+ placeholder.dispose();
534
+ this.scene.add(this.model);
535
+ this.model.position.copy(this.positions[this.selectedSlot]);
536
+ this.loaded = true;
537
+ }
538
+
539
+ private assemblyTemplate?: Promise<THREE.Group>;
540
+ async createAssemblyModel(assetUrl = publicAsset("assets/archive-assembly.glb")) {
541
+ const archiveLabel = { ...(this.archiveLabel || { code: "ARCHIVE" }) };
542
+ this.assemblyTemplate ??= this.loadModel(assetUrl, "archive-assembly.glb")
543
+ .then((gltf) => {
544
+ gltf.scene.updateMatrixWorld(true);
545
+ return gltf.scene;
546
+ })
547
+ .catch((error) => {
548
+ this.assemblyTemplate = undefined;
549
+ throw error;
550
+ });
551
+ const template = await this.assemblyTemplate;
552
+ const model = new THREE.Group();
553
+ const meshes: THREE.Mesh[] = [];
554
+ template.traverse((object) => {
555
+ if (!(object instanceof THREE.Mesh)) return;
556
+ const name = (object.material as THREE.Material).name.replace(
557
+ /\.\d+$/,
558
+ "",
559
+ );
560
+ const mesh = new THREE.Mesh(
561
+ object.geometry.clone().applyMatrix4(object.matrixWorld),
562
+ object.material,
563
+ );
564
+ mesh.userData.surface = name;
565
+ mesh.userData.assemblyPart = object.userData.assemblyPart;
566
+ model.add(mesh);
567
+ meshes.push(mesh);
568
+ });
569
+ this.appearance.prepare(model);
570
+ this.appearance.apply(model, 1);
571
+ this.appearance.setClarity(model, this.decryption.clarity);
572
+ const canvas = document.createElement("canvas");
573
+ canvas.width = this.labelCanvas.width;
574
+ canvas.height = this.labelCanvas.height;
575
+ paintArchiveLabel(canvas, this.labelMark, archiveLabel);
576
+ const texture = new THREE.CanvasTexture(canvas);
577
+ texture.colorSpace = THREE.SRGBColorSpace;
578
+ texture.userData.archivePrint = true;
579
+ texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
580
+ const label = new THREE.Mesh(
581
+ new THREE.PlaneGeometry(ARCHIVE_LABEL_SIZE.width, ARCHIVE_LABEL_SIZE.height),
582
+ new THREE.MeshBasicMaterial({
583
+ map: texture,
584
+ toneMapped: false,
585
+ transparent: true,
586
+ depthWrite: false,
587
+ }),
588
+ );
589
+ positionArchiveLabel(label);
590
+ label.name = ARCHIVE_LABEL_NAME;
591
+ label.userData.archiveLabel = { ...archiveLabel };
592
+ label.userData.assemblyPart = "cover";
593
+ model.add(label);
594
+ meshes.push(label);
595
+ return {
596
+ model,
597
+ setClarity: (value: number) => this.appearance.setClarity(model, value),
598
+ dispose: () => {
599
+ for (const mesh of meshes) {
600
+ mesh.geometry.dispose();
601
+ (mesh.material as THREE.Material).dispose();
602
+ }
603
+ texture.dispose();
604
+ },
605
+ };
606
+ }
607
+ setMode(mode: "hidden" | "archive" | "detail") {
608
+ if (mode === "detail") this.departedSelection = false;
609
+ if (mode !== "archive") this.pendingPulse = null;
610
+ this.looping = mode !== "hidden";
611
+ if (!this.looping) {
612
+ this.arrayRefill.clear();
613
+ const canonical = fileLocation(fileAtSlot(this.selectedSlot));
614
+ this.selectedCell = { lane: canonical.lane, row: canonical.row };
615
+ this.coordinateOrigin = { lane: 0, row: 0 };
616
+ for (const old of this.outgoing) {
617
+ this.disposeCollectionFile(old.group);
618
+ }
619
+ this.outgoing = [];
620
+ }
621
+ this.lastInteraction = this.clock;
622
+ this.targetReveal = mode === "hidden" ? 0 : 1;
623
+ this.targetDetail = mode === "detail" ? 1 : 0;
624
+ this.dragging = false;
625
+ if (mode !== "detail") {
626
+ this.targetRotation = 0;
627
+ if (this.rotation !== 0) this.returnY = this.model.position.y;
628
+ } else this.returnY = null;
629
+ }
630
+ setReduced(value: boolean) {
631
+ this.reduced = value;
632
+ }
633
+ /** Workspace paper uses the same final-resolution, depth-aware print pass as archive labels. */
634
+ createPrintMaterial(map: THREE.Texture) {
635
+ map.userData.archivePrint = true;
636
+ map.anisotropy = Math.min(16, this.renderer.capabilities.getMaxAnisotropy());
637
+ map.needsUpdate = true;
638
+ return this.labelRenderer.createMaterial(map, { alignDepthSamples: true });
639
+ }
640
+ setQuality(value: RenderQuality | boolean) {
641
+ const quality =
642
+ typeof value === "boolean"
643
+ ? normalizeQuality(undefined, value)
644
+ : normalizeQuality(value);
645
+ const key = JSON.stringify(quality);
646
+ if (this.appliedQuality === key) return;
647
+ this.appliedQuality = key;
648
+ this.quality = quality;
649
+ if (quality.aoSamples && quality.aoSamples !== this.aoKernelSize) {
650
+ const old = this.ao;
651
+ this.ao = new SSAOPass(this.scene, this.camera, 1, 1, quality.aoSamples);
652
+ this.ao.kernelRadius = old.kernelRadius;
653
+ this.ao.minDistance = old.minDistance;
654
+ this.ao.maxDistance = old.maxDistance;
655
+ const index = this.composer.passes.indexOf(old);
656
+ this.composer.removePass(old);
657
+ this.composer.insertPass(this.ao, index);
658
+ old.dispose();
659
+ this.aoKernelSize = quality.aoSamples;
660
+ }
661
+ this.ao.enabled = quality.aoSamples > 0;
662
+ this.bokeh.enabled = quality.depthOfField > 0;
663
+ this.smaa.enabled = quality.antialias === "smaa";
664
+ this.renderer.shadowMap.enabled = quality.shadows > 0;
665
+ const size = Math.min(
666
+ quality.shadows || 1024,
667
+ this.renderer.capabilities.maxTextureSize,
668
+ );
669
+ if (this.light.shadow.mapSize.x !== size) {
670
+ this.light.shadow.map?.dispose();
671
+ this.light.shadow.map = null;
672
+ this.light.shadow.mapSize.set(size, size);
673
+ }
674
+ this.light.shadow.needsUpdate = true;
675
+ applyTextureQuality(this.scene, this.renderer, quality);
676
+ if (this.arrayInteriorBake) {
677
+ const texture = this.arrayInteriorBake.texture;
678
+ const anisotropy = Math.min(quality.anisotropy, this.renderer.capabilities.getMaxAnisotropy());
679
+ if (texture.anisotropy !== anisotropy) {
680
+ texture.anisotropy = anisotropy;
681
+ texture.needsUpdate = true;
682
+ }
683
+ }
684
+ this.resize();
685
+ }
686
+ private cellPosition(cell: ArchiveCell) {
687
+ return new THREE.Vector3(
688
+ (cell.lane - 2) * COLUMN_SPACING,
689
+ -4.6,
690
+ (cell.row - 15.5) * ROW_SPACING,
691
+ );
692
+ }
693
+ private rebaseCoordinates() {
694
+ // Periodically reduce the logical coordinates while preserving every
695
+ // relative position, spring velocity, ripple and idle phase.
696
+ const shift = {
697
+ lane:
698
+ Math.abs(this.selectedCell.lane) > 2048
699
+ ? Math.round((this.selectedCell.lane - 2) / 5) * 5
700
+ : 0,
701
+ row:
702
+ Math.abs(this.selectedCell.row) > 2048
703
+ ? Math.floor((this.selectedCell.row - 12) / 8) * 8
704
+ : 0,
705
+ };
706
+ if (!shift.lane && !shift.row) return;
707
+ this.arrayRefill.rebase(shift);
708
+ this.selectedCell.lane -= shift.lane;
709
+ this.selectedCell.row -= shift.row;
710
+ this.coordinateOrigin.lane += shift.lane;
711
+ this.coordinateOrigin.row += shift.row;
712
+ this.laneFocus.value -= shift.lane;
713
+ this.shoulder.value -= shift.row;
714
+ this.columnCamera.value -= shift.lane * COLUMN_SPACING;
715
+ this.rail.value += shift.row * ROW_SPACING;
716
+ for (const old of this.outgoing) {
717
+ old.cell.lane -= shift.lane;
718
+ old.cell.row -= shift.row;
719
+ }
720
+ for (const pulse of this.pulses) {
721
+ pulse.lane -= shift.lane;
722
+ pulse.row -= shift.row;
723
+ }
724
+ for (const cell of this.investigationSlots.values()) {
725
+ cell.lane -= shift.lane;
726
+ cell.row -= shift.row;
727
+ }
728
+ if (this.pendingPulse) {
729
+ this.pendingPulse.lane -= shift.lane;
730
+ this.pendingPulse.row -= shift.row;
731
+ }
732
+ }
733
+ get returningFileCount() { return this.outgoing.length; }
734
+ get selectedArchiveCell(): Readonly<ArchiveCell> { return this.selectedCell; }
735
+
736
+ select(index: number, navigation?: ArchiveNavigation) {
737
+ this.lastInteraction = this.clock;
738
+ const next = fileLocation(index).slot;
739
+ const canonical = fileLocation(index);
740
+ const cell = this.looping
741
+ ? selectionCell(index, this.selectedCell, navigation)
742
+ : { lane: canonical.lane, row: canonical.row };
743
+ const changed = !sameCell(cell, this.selectedCell);
744
+ if (this.looping && changed && this.loaded && !this.departedSelection && this.lift.value > 0.0001) {
745
+ const group = this.createCollectionFile(null, { returning: true });
746
+ group.position.copy(this.model.position);
747
+ group.quaternion.copy(this.model.quaternion);
748
+ group.scale.copy(this.model.scale);
749
+ this.appearance.apply(group, ease(this.lift.value / 0.4));
750
+ this.appearance.setClarity(group, this.decryption.clarity);
751
+ this.scene.add(group);
752
+ this.outgoing.push({
753
+ group,
754
+ slot: this.selectedSlot,
755
+ cell: { ...this.selectedCell },
756
+ lift: { ...this.lift },
757
+ returnY: group.rotation.y !== 0 ? group.position.y : null,
758
+ clarity: this.decryption.clarity,
759
+ });
760
+ this.lift.value = 0;
761
+ this.lift.velocity = 0;
762
+ }
763
+ this.selectedSlot = next;
764
+ this.selectedCell = cell;
765
+ if (changed) {
766
+ this.departedSelection = false;
767
+ this.rotation = 0;
768
+ this.returnY = null;
769
+ }
770
+ const returning = this.outgoing.findIndex((o) => sameCell(o.cell, cell));
771
+ if (returning >= 0) {
772
+ const o = this.outgoing[returning];
773
+ this.lift = { ...o.lift };
774
+ this.rotation = o.group.rotation.y;
775
+ this.returnY = o.returnY;
776
+ this.disposeCollectionFile(o.group);
777
+ this.outgoing.splice(returning, 1);
778
+ }
779
+ if (this.deferSelectionPulse) {
780
+ this.pendingPulse = this.looping ? { ...cell } : null;
781
+ } else this.emitPulse(cell);
782
+ this.targetRotation = 0;
783
+ this.drawLabel(index);
784
+ }
785
+ private emitPulse(cell: ArchiveCell) {
786
+ this.pulses.push({ ...cell, time: this.clock });
787
+ this.pulses = this.pulses.slice(-6);
788
+ }
789
+ setArchiveLabel(label: ArchiveLabel | string | null) {
790
+ const content = typeof label === "string" ? { code: label } : label;
791
+ if (archiveLabelKey(this.archiveLabel || { code: "ARCHIVE" })
792
+ === archiveLabelKey(content || { code: "ARCHIVE" })) return;
793
+ this.archiveLabel = content ? { ...content } : null;
794
+ this.drawLabel();
795
+ }
796
+ /** Activity metadata only; select() owns the original rail and wave motion. */
797
+ setInvestigationScanning(active: boolean) {
798
+ this.investigationScanning = active;
799
+ }
800
+ /** Pin the physical occurrence until the actor releases it, even across wraps. */
801
+ setInvestigationSlots(indices: number[]) {
802
+ const next = new Set(indices.filter(Number.isFinite).map(index => wrap(Math.trunc(index), 40)));
803
+ for (const index of this.investigationSlots.keys()) if (!next.has(index)) {
804
+ this.investigationSlots.delete(index);
805
+ this.investigationPoses.delete(index);
806
+ this.investigationClearances.delete(index);
807
+ }
808
+ for (const index of next) if (!this.investigationSlots.has(index)) {
809
+ this.investigationPoses.set(index, this.archiveSourcePose(index));
810
+ const cell = selectionCell(index, this.selectedCell);
811
+ this.investigationSlots.set(index, cell);
812
+ if (sameCell(cell, this.selectedCell)) this.departedSelection = false;
813
+ }
814
+ }
815
+ get isArchiveRefilling() { return this.arrayRefill.active; }
816
+ canExtractArchive(index: number) {
817
+ return !this.arrayRefill.blocks(selectionCell(index, this.selectedCell).lane);
818
+ }
819
+ /** Release the actor's vacancy, then slide the surviving rear row forward. */
820
+ departInvestigationSlot(index: number) {
821
+ const cell = this.investigationSlots.get(wrap(Math.trunc(index), 40));
822
+ if (!cell) return;
823
+ this.arrayRefill.remove(cell);
824
+ if (sameCell(cell, this.selectedCell)) {
825
+ this.departedSelection = true;
826
+ this.pendingPulse = null;
827
+ this.lift.value = 0; this.lift.velocity = 0;
828
+ this.returnY = null;
829
+ this.model.visible = false;
830
+ }
831
+ this.outgoing = this.outgoing.filter(item => {
832
+ if (!sameCell(item.cell, cell)) return true;
833
+ this.disposeCollectionFile(item.group);
834
+ return false;
835
+ });
836
+ }
837
+ setInvestigationReading(index: number | null) {
838
+ this.setInvestigationSlots(index === null ? [] : [index]);
839
+ }
840
+ private drawLabel(_index?: number) {
841
+ const label = this.model.getObjectByName(ARCHIVE_LABEL_NAME) as THREE.Mesh | undefined;
842
+ if (!label) return;
843
+ const content = this.archiveLabel || { code: "ARCHIVE" };
844
+ this.labelTexture = this.labels().bind(label.material as THREE.MeshBasicMaterial, content, 'hero-label');
845
+ this.labelCanvas = this.labelTexture.image as HTMLCanvasElement;
846
+ label.userData.archiveLabel = { ...content };
847
+ }
848
+ resize() {
849
+ const w = this.container.clientWidth,
850
+ h = this.container.clientHeight;
851
+ const kind = this.container.closest<HTMLElement>("[data-layout]")?.dataset.layout ?? "";
852
+ const displayHeight = this.container.getBoundingClientRect().height;
853
+ if (this.layoutKind === "cinematic" && kind !== "cinematic" && this.displayHeight > 0) {
854
+ // Removing letterboxing starts from the same apparent model size. The
855
+ // existing camera interpolation then carries it to the responsive anchor.
856
+ this.camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan(
857
+ Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * displayHeight / this.displayHeight,
858
+ ));
859
+ }
860
+ this.displayHeight = displayHeight;
861
+ this.layoutKind = kind;
862
+ const dimensions = resizeQuality(
863
+ this.renderer,
864
+ this.composer,
865
+ this.container,
866
+ this.quality,
867
+ { sharpLabels: true },
868
+ );
869
+ this.scenePixelHeight = dimensions.height;
870
+ this.ao.setSize(
871
+ Math.max(1, Math.floor(dimensions.width * this.quality.aoResolution)),
872
+ Math.max(1, Math.floor(dimensions.height * this.quality.aoResolution)),
873
+ );
874
+ this.container.dataset.renderQuality = JSON.stringify({
875
+ ...JSON.parse(this.container.dataset.renderQuality!),
876
+ aoSamples: this.ao.enabled ? this.aoKernelSize : 0,
877
+ aoWidth: this.ao.width,
878
+ aoHeight: this.ao.height,
879
+ shadows: this.renderer.shadowMap.enabled
880
+ ? this.light.shadow.mapSize.x
881
+ : 0,
882
+ depthOfField: this.bokeh.enabled ? this.quality.depthOfField : 0,
883
+ });
884
+ this.camera.aspect = w / h;
885
+ this.camera.updateProjectionMatrix();
886
+ }
887
+ /** Reuse the visible array instances for navigation from adjacent stations. */
888
+ pickArchiveSurface(raycaster: THREE.Raycaster) {
889
+ if (!this.loaded || !this.collectionArrayVisible || this.reveal < 0.8) return undefined;
890
+ return visibleSceneHit(raycaster.intersectObjects([...this.arrayStationPickMeshes, this.model], true));
891
+ }
892
+ private cellForHit(hit: THREE.Intersection | undefined) {
893
+ if (!hit) return null;
894
+ if (hit.instanceId === undefined) return this.selectedCell;
895
+ const index = this.arrayVisibility.logicalIndex(hit.object as THREE.InstancedMesh, hit.instanceId);
896
+ return index === undefined ? null : this.cells[index];
897
+ }
898
+ private bindPointer() {
899
+ const canvas = this.renderer.domElement;
900
+ let startX = 0,
901
+ startY = 0;
902
+ let activePointer: number | null = null, previousX = 0, started = 0, cancelled = false;
903
+ const pointers = new Set<number>();
904
+ canvas.addEventListener("pointerdown", (e) => {
905
+ if (e.pointerType === "mouse" && e.button !== 0) return;
906
+ pointers.add(e.pointerId);
907
+ if (pointers.size > 1) { cancelled = true; this.dragging = false; return; }
908
+ activePointer = e.pointerId;
909
+ cancelled = false;
910
+ previousX = e.clientX;
911
+ started = performance.now();
912
+ startX = e.clientX;
913
+ startY = e.clientY;
914
+ canvas.setPointerCapture(e.pointerId);
915
+ if (this.canInspect) {
916
+ this.dragging = true;
917
+ canvas.setPointerCapture(e.pointerId);
918
+ } else if (this.reveal >= 0.8 && this.detail <= 0.2 && this.loaded) {
919
+ // Camera easing can change the cell beneath a stationary pointer.
920
+ // Press also supplies advance intent for touch, which has no hover.
921
+ const r = canvas.getBoundingClientRect();
922
+ this.cursor.set((e.clientX - r.left) / r.width * 2 - 1, -(e.clientY - r.top) / r.height * 2 + 1);
923
+ this.raycaster.setFromCamera(this.cursor, this.camera);
924
+ const pick = () => this.raycaster.intersectObjects([...this.arrayPickMeshes, this.model], true)[0];
925
+ const hit = this.performanceProbe ? this.performanceProbe.measureWork('archive-pointer-press', pick) : pick();
926
+ const cell = this.cellForHit(hit);
927
+ this.onHover?.(cell ? fileAtCell(cell) : null, cell ? { ...cell } : undefined);
928
+ }
929
+ }, { signal: this.events.signal });
930
+ canvas.addEventListener("pointermove", (e) => {
931
+ if (activePointer !== null && e.pointerId !== activePointer) return;
932
+ if (cancelled) return;
933
+ const r = canvas.getBoundingClientRect();
934
+ if (e.pointerType === "mouse") this.pointer.set(
935
+ (e.clientX - r.left) / r.width - 0.5,
936
+ (e.clientY - r.top) / r.height - 0.5,
937
+ );
938
+ if (this.dragging) {
939
+ if (!this.canInspect) {
940
+ this.dragging = false;
941
+ return;
942
+ }
943
+ this.targetRotation = THREE.MathUtils.clamp(
944
+ this.targetRotation + (e.clientX - previousX) * 0.004,
945
+ -0.8,
946
+ 0.8,
947
+ );
948
+ previousX = e.clientX;
949
+ return;
950
+ }
951
+ if (e.pointerType !== "mouse") return;
952
+ if (this.reveal < 0.8 || this.detail > 0.2 || !this.loaded) return;
953
+ this.cursor.set(
954
+ ((e.clientX - r.left) / r.width) * 2 - 1,
955
+ (-(e.clientY - r.top) / r.height) * 2 + 1,
956
+ );
957
+ this.raycaster.setFromCamera(this.cursor, this.camera);
958
+ const pick = () => this.raycaster.intersectObjects([...this.arrayPickMeshes, this.model], true)[0];
959
+ const hit = this.performanceProbe ? this.performanceProbe.measureWork('archive-pointer-pick', pick) : pick();
960
+ const cell = this.cellForHit(hit);
961
+ canvas.style.cursor = cell ? "pointer" : "default";
962
+ this.onHover?.(cell ? fileAtCell(cell) : null, cell ? { ...cell } : undefined);
963
+ }, { signal: this.events.signal });
964
+ canvas.addEventListener("pointerup", (e) => {
965
+ pointers.delete(e.pointerId);
966
+ if (e.pointerId !== activePointer) return;
967
+ activePointer = null;
968
+ this.dragging = false;
969
+ if (cancelled) return;
970
+ if (e.pointerType !== "mouse" && this.detail < 0.2 && this.reveal >= 0.8 && this.loaded) {
971
+ const swipe = swipeDirection(e.clientX - startX, e.clientY - startY, performance.now() - started);
972
+ if (swipe) { this.onNavigate?.(swipe.axis, swipe.direction); return; }
973
+ }
974
+ if (
975
+ Math.hypot(e.clientX - startX, e.clientY - startY) > 6 ||
976
+ this.detail > 0.2 ||
977
+ this.reveal < 0.8 ||
978
+ !this.loaded
979
+ )
980
+ return;
981
+ const r = canvas.getBoundingClientRect();
982
+ this.cursor.set(
983
+ ((e.clientX - r.left) / r.width) * 2 - 1,
984
+ (-(e.clientY - r.top) / r.height) * 2 + 1,
985
+ );
986
+ this.raycaster.setFromCamera(this.cursor, this.camera);
987
+ const pick = () => this.raycaster.intersectObjects([...this.arrayPickMeshes, this.model], true)[0];
988
+ const hit = this.performanceProbe ? this.performanceProbe.measureWork('archive-pointer-pick', pick) : pick();
989
+ if (hit?.object.name === ARCHIVE_LABEL_NAME && hit.object.userData.archiveLabel?.sourceId) {
990
+ this.onLabelOpen?.(hit.object.userData.archiveLabel.sourceId);
991
+ return;
992
+ }
993
+ const cell = this.cellForHit(hit);
994
+ if (cell) this.onSelect?.(fileAtCell(cell), { ...cell });
995
+ }, { signal: this.events.signal });
996
+ canvas.addEventListener("pointercancel", (e) => {
997
+ pointers.delete(e.pointerId);
998
+ if (e.pointerId === activePointer) { activePointer = null; cancelled = true; this.dragging = false; this.onHover?.(null); }
999
+ }, { signal: this.events.signal });
1000
+ canvas.addEventListener("lostpointercapture", (e) => {
1001
+ pointers.delete(e.pointerId);
1002
+ if (e.pointerId === activePointer) { activePointer = null; this.dragging = false; }
1003
+ }, { signal: this.events.signal });
1004
+ canvas.addEventListener("pointerleave", () => {
1005
+ this.pointer.set(0, 0);
1006
+ this.onHover?.(null);
1007
+ }, { signal: this.events.signal });
1008
+ }
1009
+ update(
1010
+ time: number,
1011
+ cinematic?: { reveal: number; lift: number; zoom: number; time: number },
1012
+ checkpoint?: (stage: string) => void,
1013
+ ) {
1014
+ if (this.disposed) return;
1015
+ // Move the existing studio shadow coverage with a visit to the adjoining
1016
+ // shelf. Its direction, intensity, bounds and original endpoint stay exact.
1017
+ if (this.appliedLightingOffset.lengthSq() > 0) {
1018
+ this.light.position.sub(this.appliedLightingOffset);
1019
+ this.light.target.position.sub(this.appliedLightingOffset);
1020
+ }
1021
+ this.appliedLightingOffset.copy(this.collectionOffset);
1022
+ this.light.position.add(this.appliedLightingOffset);
1023
+ this.light.target.position.add(this.appliedLightingOffset);
1024
+ this.light.target.updateMatrixWorld();
1025
+ if (this.appliedCollectionOffset.lengthSq() > 0) {
1026
+ this.camera.position.sub(this.appliedCollectionOffset);
1027
+ this.cameraAim.sub(this.appliedCollectionOffset);
1028
+ this.appliedCollectionOffset.set(0, 0, 0);
1029
+ }
1030
+ const dt = Math.min(time - this.last || 0.016, 0.05);
1031
+ this.last = time;
1032
+ this.clock = time;
1033
+ if (!this.loaded) return;
1034
+ const blend = 1 - Math.exp(-dt * (this.reduced ? 35 : 2.8));
1035
+ this.reveal = cinematic
1036
+ ? cinematic.reveal
1037
+ : THREE.MathUtils.lerp(this.reveal, this.targetReveal, blend);
1038
+ this.rotation = this.targetDetail
1039
+ ? THREE.MathUtils.lerp(this.rotation, this.targetRotation, blend)
1040
+ : returnStep(this.rotation, dt, this.reduced);
1041
+ const shot = cinematic?.time ?? 29.1;
1042
+ if (cinematic) {
1043
+ this.scanTime = shot;
1044
+ this.scanBlend = 1;
1045
+ } else {
1046
+ this.scanTime += dt;
1047
+ this.scanBlend *= Math.exp(-dt * 3);
1048
+ }
1049
+ if (this.looping && !cinematic) this.rebaseCoordinates();
1050
+ this.arrayRefill.update(dt, [
1051
+ ...this.investigationSlots.values(), ...this.outgoing.map(item => item.cell),
1052
+ ], this.reduced || !!cinematic);
1053
+ const chosen = this.cellPosition(this.selectedCell);
1054
+ const selectedVisualRow = this.arrayRefill.row(this.selectedCell);
1055
+ const selectedRow = this.selectedCell.row;
1056
+ const selectedLane = this.selectedCell.lane;
1057
+ damp(this.shoulder, selectedRow, this.reduced ? 35 : 5, dt);
1058
+ damp(this.laneFocus, selectedLane, this.reduced ? 35 : 4, dt);
1059
+ damp(this.columnCamera, chosen.x, this.reduced ? 35 : 3.7, dt);
1060
+ damp(
1061
+ this.rail,
1062
+ cinematic ? 0 : -2.17 - chosen.z,
1063
+ this.reduced ? 35 : 3.7,
1064
+ dt,
1065
+ );
1066
+ if (cinematic) {
1067
+ this.rail.value = 0;
1068
+ this.rail.velocity = 0;
1069
+ this.lift.value = extraction(shot);
1070
+ this.lift.velocity = 0;
1071
+ this.shoulder.value = selectedRow;
1072
+ this.laneFocus.value = selectedLane;
1073
+ this.laneFocus.velocity = 0;
1074
+ this.columnCamera.value = chosen.x;
1075
+ this.columnCamera.velocity = 0;
1076
+ }
1077
+ // Keep the illuminated set near the origin. Lateral navigation is a track
1078
+ // movement of the whole array, just like the existing front/back rail.
1079
+ const trackX = cinematic ? 0 : this.columnCamera.value;
1080
+ const center = {
1081
+ lane: this.columnCamera.value / COLUMN_SPACING + 2,
1082
+ row: (-this.rail.value - 2.17) / ROW_SPACING + 15.5,
1083
+ };
1084
+ for (let i = 0; i < this.positions.length; i++) {
1085
+ this.cells[i] =
1086
+ cinematic || !this.looping ? poolCell(i) : visibleCell(i, center);
1087
+ this.positions[i].set(
1088
+ (this.cells[i].lane - 2) * COLUMN_SPACING,
1089
+ -4.6,
1090
+ (this.cells[i].row - 15.5) * ROW_SPACING,
1091
+ );
1092
+ }
1093
+ this.pulses = this.pulses.filter((p) => time - p.time < 3.2);
1094
+ const aligningCopy = this.outgoing.some((o) => o.returnY !== null);
1095
+ const idle =
1096
+ !cinematic &&
1097
+ !this.reduced &&
1098
+ this.targetReveal > 0 &&
1099
+ !this.targetDetail &&
1100
+ this.detail < 0.01 &&
1101
+ this.returnY === null &&
1102
+ !aligningCopy &&
1103
+ time - this.lastInteraction > 2.5;
1104
+ this.idleGain = cinematic
1105
+ ? 0
1106
+ : THREE.MathUtils.lerp(
1107
+ this.idleGain,
1108
+ idle ? 1 : 0,
1109
+ 1 - Math.exp(-dt * (idle ? 0.8 : 4)),
1110
+ );
1111
+ this.pulseGain = THREE.MathUtils.lerp(
1112
+ this.pulseGain,
1113
+ this.targetDetail || this.returnY !== null || aligningCopy ? 0 : 1,
1114
+ 1 - Math.exp(-dt * 8),
1115
+ );
1116
+ const field = (row: number, lane: number) => {
1117
+ if (cinematic)
1118
+ return cinematicField(
1119
+ row,
1120
+ lane,
1121
+ shot,
1122
+ this.shoulder.value,
1123
+ this.laneFocus.value,
1124
+ );
1125
+ let height =
1126
+ archiveWave(
1127
+ row + this.coordinateOrigin.row,
1128
+ lane + this.coordinateOrigin.lane,
1129
+ this.scanTime,
1130
+ ) *
1131
+ this.scanBlend +
1132
+ idleWave(
1133
+ row + this.coordinateOrigin.row,
1134
+ lane + this.coordinateOrigin.lane,
1135
+ time,
1136
+ ) *
1137
+ this.idleGain;
1138
+ if (!cinematic && !this.reduced) {
1139
+ let ripple = 0;
1140
+ for (const p of this.pulses) {
1141
+ const distance = Math.hypot(row - p.row, (lane - p.lane) * 2.2);
1142
+ const age = time - p.time;
1143
+ ripple +=
1144
+ this.selectionPulse(distance, age) *
1145
+ (this.deferSelectionPulse ? rippleEnvelope(distance, age) : 1);
1146
+ }
1147
+ height += THREE.MathUtils.clamp(ripple, -0.6, 0.6) * this.pulseGain;
1148
+ }
1149
+ const distance = row - this.shoulder.value;
1150
+ return (
1151
+ height +
1152
+ settlingWave(distance, 26.56) *
1153
+ columnStrength(lane, this.laneFocus.value)
1154
+ );
1155
+ };
1156
+ const selectedBase = chosen.y + field(selectedVisualRow, selectedLane);
1157
+ if (!cinematic) {
1158
+ if (this.returnY !== null && this.rotation !== 0) {
1159
+ this.lift.value = this.returnY - selectedBase;
1160
+ this.lift.velocity = 0;
1161
+ } else {
1162
+ this.returnY = null;
1163
+ damp(
1164
+ this.lift,
1165
+ this.targetDetail
1166
+ ? INSPECTION_LIFT
1167
+ : this.departedSelection ? 0 : this.outgoing.some(
1168
+ (o) =>
1169
+ o.returnY !== null &&
1170
+ o.cell.lane === selectedLane &&
1171
+ Math.abs(o.cell.row - selectedRow) < 5,
1172
+ )
1173
+ ? 0
1174
+ : 0.4 * this.targetReveal,
1175
+ this.reduced
1176
+ ? 35
1177
+ : this.deferSelectionPulse &&
1178
+ !this.targetDetail &&
1179
+ this.lift.value < 0.4
1180
+ ? 7.6
1181
+ : 4.2,
1182
+ dt,
1183
+ );
1184
+ }
1185
+ }
1186
+ const cameraTarget = this.targetDetail
1187
+ ? ease((this.lift.value - 0.8) / 2.4)
1188
+ : this.returnY !== null
1189
+ ? this.detail
1190
+ : ease((this.lift.value - 0.4) / (INSPECTION_LIFT - 0.4));
1191
+ this.detail = cinematic
1192
+ ? cinematic.zoom
1193
+ : THREE.MathUtils.lerp(this.detail, cameraTarget, blend);
1194
+ const detail = this.detail;
1195
+ this.appearance.apply(this.model, ease(this.lift.value / 0.4));
1196
+ this.appearance.setClarity(this.model, this.decryption.clarity);
1197
+ // Reference 26.92–27.76: the array travels horizontally into a white field.
1198
+ const entry = cinematic ? ease((shot - 21.9) / 0.86) : this.reveal;
1199
+ const entranceTime = THREE.MathUtils.clamp((shot - 21.92) / 0.75, 0, 1);
1200
+ const entryZ = cinematic
1201
+ ? -23 * (1 - entranceTime) ** 2
1202
+ : -28 * (1 - entry);
1203
+ for (let i = this.outgoing.length - 1; i >= 0; i--) {
1204
+ const o = this.outgoing[i];
1205
+ const p = this.cellPosition(o.cell);
1206
+ const visualRow = this.arrayRefill.row(o.cell);
1207
+ p.z += (visualRow - o.cell.row) * ROW_SPACING;
1208
+ const baseY = p.y + field(visualRow, o.cell.lane);
1209
+ o.group.rotation.y = returnStep(o.group.rotation.y, dt, this.reduced);
1210
+ if (o.returnY !== null) {
1211
+ o.lift.value = o.returnY - baseY;
1212
+ o.lift.velocity = 0;
1213
+ if (o.group.rotation.y === 0) o.returnY = null;
1214
+ } else damp(o.lift, 0, this.reduced ? 35 : 4.5, dt);
1215
+ o.group.position.set(
1216
+ p.x - trackX,
1217
+ baseY + o.lift.value,
1218
+ p.z + entryZ + this.rail.value,
1219
+ );
1220
+ const quality = ease(o.lift.value / 0.4);
1221
+ this.appearance.apply(o.group, quality);
1222
+ this.appearance.setClarity(o.group, o.clarity);
1223
+ const { lane } = o.cell;
1224
+ const row = visualRow;
1225
+ o.group.rotation.x =
1226
+ (field(row + 0.5, lane) - field(row - 0.5, lane)) *
1227
+ 0.024 *
1228
+ (1 - detail) *
1229
+ (1 - quality);
1230
+ if (o.lift.value < 0.0001 && Math.abs(o.group.rotation.y) < 0.0001) {
1231
+ this.disposeCollectionFile(o.group);
1232
+ this.outgoing.splice(i, 1);
1233
+ }
1234
+ }
1235
+ if (
1236
+ this.pendingPulse &&
1237
+ !cinematic &&
1238
+ !this.targetDetail &&
1239
+ this.targetReveal
1240
+ ) {
1241
+ const selectedY = selectedBase + this.lift.value;
1242
+ const oldCardsLower = this.outgoing.every(
1243
+ (old) =>
1244
+ old.cell.lane !== selectedLane ||
1245
+ Math.abs(old.cell.row - selectedRow) > 4 ||
1246
+ old.group.position.y + 0.015 < selectedY,
1247
+ );
1248
+ // The new file causes the wave: finish most of its rise and let nearby
1249
+ // outgoing files get below it before starting the outward pulse.
1250
+ if (this.lift.value >= 0.35 && this.returnY === null && oldCardsLower) {
1251
+ if (!this.reduced) this.emitPulse(this.pendingPulse);
1252
+ this.pendingPulse = null;
1253
+ }
1254
+ }
1255
+ // Resolve returning copies before restoring their array instances, avoiding
1256
+ // a missing file for one frame at the ownership handoff.
1257
+ const hidden = new Set(this.outgoing.map((o) => cellKey(o.cell)));
1258
+ if (!this.departedSelection) hidden.add(cellKey(this.selectedCell));
1259
+ const occupied = new Set([...this.investigationSlots.values()].map(cellKey));
1260
+ for (const key of occupied) hidden.add(key);
1261
+ this.model.visible = this.collectionArrayVisible && !this.departedSelection
1262
+ && !this.arrayRefill.vacant(this.selectedCell) && !occupied.has(cellKey(this.selectedCell));
1263
+ // A manual selection can create a returning copy while an actor owns that
1264
+ // same physical cassette. Keep the return state but render a single owner.
1265
+ for (const outgoing of this.outgoing) outgoing.group.visible = this.collectionArrayVisible && !occupied.has(cellKey(outgoing.cell));
1266
+ for (let i = 0; i < this.positions.length; i++) {
1267
+ const p = this.positions[i];
1268
+ const { lane } = this.cells[i];
1269
+ const row = this.arrayRefill.row(this.cells[i]);
1270
+ const slope = field(row + 0.5, lane) - field(row - 0.5, lane);
1271
+ this.dummy.position.set(
1272
+ p.x - trackX,
1273
+ p.y + field(row, lane),
1274
+ (row - 15.5) * ROW_SPACING + entryZ + this.rail.value,
1275
+ );
1276
+ this.dummy.rotation.set(slope * 0.024 * (1 - detail), 0, 0);
1277
+ // Store the real pose even for a hidden or off-camera cell. Actors can
1278
+ // acquire it later, independently of the packed GPU instance index.
1279
+ this.dummy.scale.setScalar(1);
1280
+ this.dummy.updateMatrix();
1281
+ this.arrayVisibility.setSlot(i, this.dummy.matrix,
1282
+ !this.arrayRefill.vacant(this.cells[i]) && !hidden.has(cellKey(this.cells[i])) && !((cinematic || !this.looping) && i >= 160));
1283
+ }
1284
+ // Actor-owned physical cells still follow the authored field and rail even
1285
+ // when navigation moves them outside the reusable logical pool.
1286
+ for (const [index, cell] of this.investigationSlots) {
1287
+ const pose = this.investigationPoses.get(index)!;
1288
+ const p = this.cellPosition(cell);
1289
+ pose.position.set(p.x - trackX, p.y + field(cell.row, cell.lane), p.z + entryZ + this.rail.value);
1290
+ const slope = field(cell.row + 0.5, cell.lane) - field(cell.row - 0.5, cell.lane);
1291
+ this.dummy.rotation.set(slope * 0.024 * (1 - detail), 0, 0);
1292
+ pose.quaternion.copy(this.dummy.quaternion);
1293
+ let top = pose.position.y + 3.76;
1294
+ for (let row = cell.row - 5; row <= cell.row + 5; row++) {
1295
+ if (row !== cell.row) top = Math.max(top, -4.6 + field(row, cell.lane) + 3.76);
1296
+ }
1297
+ for (const outgoing of this.outgoing) if (outgoing.cell.lane === cell.lane && !sameCell(outgoing.cell, cell))
1298
+ top = Math.max(top, outgoing.group.position.y + 3.76);
1299
+ this.investigationClearances.set(index, Math.max(INSPECTION_LIFT, top + 0.35 - pose.position.y));
1300
+ }
1301
+ this.model.position.set(
1302
+ chosen.x - trackX,
1303
+ selectedBase + this.lift.value,
1304
+ (selectedVisualRow - 15.5) * ROW_SPACING + entryZ + this.rail.value,
1305
+ );
1306
+ // Extraction only changes elevation. Reframing belongs to the camera.
1307
+ this.model.rotation.set(
1308
+ (field(selectedVisualRow + 0.5, selectedLane) -
1309
+ field(selectedVisualRow - 0.5, selectedLane)) *
1310
+ 0.024 *
1311
+ (1 - detail) *
1312
+ (1 - ease(this.lift.value / 0.4)),
1313
+ cinematic ? 0 : this.rotation,
1314
+ 0,
1315
+ );
1316
+ // Measured from frame 787: X edge (382,-204), adjacent row (78,38).
1317
+ // The label vertical edge constrains height; the file base is occluded.
1318
+ // Do not calibrate field of view from the visible fragment of a file.
1319
+ const orbit = ease((shot - 22.6) / 1.6);
1320
+ const settle = ease((shot - 24.25) / 2.25);
1321
+ const yaw = THREE.MathUtils.degToRad(89 - 22 * orbit - 8 * settle);
1322
+ const elevation = THREE.MathUtils.degToRad(
1323
+ 3 + 40 * ease((shot - 21.96) / 0.22) - 8 * orbit - 16 * settle,
1324
+ );
1325
+ const span = THREE.MathUtils.lerp(
1326
+ THREE.MathUtils.lerp(10.8, 10.3, orbit),
1327
+ 7.33,
1328
+ settle,
1329
+ );
1330
+ let distance = THREE.MathUtils.lerp(
1331
+ THREE.MathUtils.lerp(28 + 7 * orbit, 140, settle),
1332
+ 72,
1333
+ detail,
1334
+ );
1335
+ const arrayAim = new THREE.Vector3(
1336
+ -1.091,
1337
+ THREE.MathUtils.lerp(-2.55 + 0.4 * orbit, -0.045, settle),
1338
+ THREE.MathUtils.lerp(2.48, 0.481, settle),
1339
+ );
1340
+ const cameraAim = arrayAim.clone();
1341
+ const viewDirection = new THREE.Vector3(
1342
+ -Math.sin(yaw) * Math.cos(elevation),
1343
+ Math.sin(elevation),
1344
+ Math.cos(yaw) * Math.cos(elevation),
1345
+ );
1346
+ if (cinematic) {
1347
+ const earlyTurn = ease((shot - 27.3) / 1.3);
1348
+ const finalTurn = ease((shot - 28.6) / 5.4);
1349
+ const shotYaw =
1350
+ yaw - THREE.MathUtils.degToRad(9 * earlyTurn + 32 * finalTurn);
1351
+ const shotElevation =
1352
+ elevation - THREE.MathUtils.degToRad(1.5 * earlyTurn + 3.7 * finalTurn);
1353
+ viewDirection.set(
1354
+ -Math.sin(shotYaw) * Math.cos(shotElevation),
1355
+ Math.sin(shotElevation),
1356
+ Math.cos(shotYaw) * Math.cos(shotElevation),
1357
+ );
1358
+ } else {
1359
+ viewDirection
1360
+ .lerp(new THREE.Vector3(-0.277, 0.238, 0.931), detail)
1361
+ .normalize();
1362
+ }
1363
+ if (cinematic) {
1364
+ const pan = ease((shot - 25.4) / 0.95);
1365
+ const right = new THREE.Vector3()
1366
+ .crossVectors(new THREE.Vector3(0, 1, 0), viewDirection)
1367
+ .normalize();
1368
+ cameraAim.addScaledVector(
1369
+ right,
1370
+ -2.05 * (1 - pan) * ease((shot - 24.2) / 0.8),
1371
+ );
1372
+ }
1373
+ if (cinematic && shot >= 25.05 && shot <= 27.3) {
1374
+ // Frames 760–785: the camera carries the same physical column from the
1375
+ // right into the selected position while the neighboring crests subside.
1376
+ const pan = ease((shot - 25.4) / 1.05);
1377
+ const right = new THREE.Vector3()
1378
+ .crossVectors(new THREE.Vector3(0, 1, 0), viewDirection)
1379
+ .normalize();
1380
+ const up = new THREE.Vector3()
1381
+ .crossVectors(viewDirection, right)
1382
+ .normalize();
1383
+ const pixelScale = 1080 / span;
1384
+ const anchorAim = this.model.position
1385
+ .clone()
1386
+ .add(new THREE.Vector3(-2.5, 3.7, 0));
1387
+ anchorAim.addScaledVector(
1388
+ right,
1389
+ -(THREE.MathUtils.lerp(840, 518, pan) - 960) / pixelScale,
1390
+ );
1391
+ anchorAim.addScaledVector(
1392
+ up,
1393
+ -(540 - THREE.MathUtils.lerp(340, 288, pan)) / pixelScale,
1394
+ );
1395
+ cameraAim.lerp(anchorAim, ease((shot - 25.05) / 0.35));
1396
+ }
1397
+ if (cinematic && shot > 27.3) {
1398
+ const close = ease((shot - 27.3) / 6.7);
1399
+ const extractionCamera = ease((shot - 27.3) / 1.25);
1400
+ const screenX = THREE.MathUtils.lerp(
1401
+ 518 - 98 * extractionCamera,
1402
+ 618,
1403
+ close,
1404
+ );
1405
+ const screenY = THREE.MathUtils.lerp(
1406
+ 296 + 34 * extractionCamera,
1407
+ 287,
1408
+ close,
1409
+ );
1410
+ const pixelScale = 1080 / THREE.MathUtils.lerp(span, 5.9, detail);
1411
+ const right = new THREE.Vector3()
1412
+ .crossVectors(new THREE.Vector3(0, 1, 0), viewDirection)
1413
+ .normalize();
1414
+ const up = new THREE.Vector3()
1415
+ .crossVectors(viewDirection, right)
1416
+ .normalize();
1417
+ const anchorAim = this.model.position
1418
+ .clone()
1419
+ .add(new THREE.Vector3(-2.5, 3.7, 0));
1420
+ anchorAim.addScaledVector(right, -(screenX - 960) / pixelScale);
1421
+ anchorAim.addScaledVector(up, -(540 - screenY) / pixelScale);
1422
+ cameraAim.lerp(anchorAim, ease((shot - 27.3) / 0.5));
1423
+ }
1424
+ const framing = archiveFraming(this.container.clientWidth, this.container.clientHeight, span, detail,
1425
+ this.container.closest<HTMLElement>("[data-layout]")?.dataset.layout === "compact");
1426
+ if (!cinematic) {
1427
+ const right = new THREE.Vector3()
1428
+ .crossVectors(new THREE.Vector3(0, 1, 0), viewDirection)
1429
+ .normalize();
1430
+ const up = new THREE.Vector3()
1431
+ .crossVectors(viewDirection, right)
1432
+ .normalize();
1433
+ const width = this.container.clientWidth, height = this.container.clientHeight;
1434
+ const pixelScale = height / framing.span;
1435
+ if (framing.portrait) {
1436
+ // Keep the preview camera independent of the live lift, wave and rail.
1437
+ // Following model.position here would visually cancel those motions.
1438
+ const previewAim = new THREE.Vector3(0, -4.6 + settlingWave(0, 26.56) + 0.4 + 1.85, -2.17);
1439
+ previewAim.addScaledVector(up, (framing.previewY - 0.5) * height / pixelScale);
1440
+ cameraAim.copy(previewAim);
1441
+ }
1442
+ const detailAim = (this.collectionDetailPosition
1443
+ ? this.collectionDetailPosition.clone().sub(this.collectionOffset)
1444
+ : this.model.position.clone()).add(new THREE.Vector3(0, 1.85, 0));
1445
+ detailAim.addScaledVector(right, (0.5 - framing.detailX) * width / pixelScale);
1446
+ detailAim.addScaledVector(up, (framing.detailY - 0.5) * height / pixelScale);
1447
+ cameraAim.lerp(detailAim, detail);
1448
+ }
1449
+ let overviewSpan = framing.span * (1 + (this.collectionOverviewScale - 1) * (1 - detail));
1450
+ if (!cinematic && this.collectionFraming && this.collectionFraming.amount > 0) {
1451
+ const frame = this.collectionFraming;
1452
+ const amount = frame.amount * (1 - detail);
1453
+ if (frame.direction) viewDirection.lerp(frame.direction, amount).normalize();
1454
+ const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), viewDirection).normalize();
1455
+ const up = new THREE.Vector3().crossVectors(viewDirection, right).normalize();
1456
+ // Offset is already interpolated by the adapter. Remove its complete
1457
+ // destination here so framing and physical travel share one easing.
1458
+ const aim = frame.center.clone().addScaledVector(this.collectionOffset, -1 / frame.amount)
1459
+ .addScaledVector(right, (0.5 - frame.x) * frame.span * this.camera.aspect)
1460
+ .addScaledVector(up, (frame.y - 0.5) * frame.span);
1461
+ cameraAim.lerp(aim, amount);
1462
+ overviewSpan = THREE.MathUtils.lerp(overviewSpan, frame.span, amount);
1463
+ if (frame.distance !== undefined) distance = THREE.MathUtils.lerp(distance, frame.distance, amount);
1464
+ }
1465
+ const cameraPosition = cameraAim
1466
+ .clone()
1467
+ .addScaledVector(viewDirection, distance);
1468
+ if (!cinematic && !this.reduced && this.collectionFraming?.parallax !== false) {
1469
+ cameraPosition.x += this.pointer.x * 0.12;
1470
+ cameraPosition.y -= this.pointer.y * 0.12;
1471
+ }
1472
+ const cameraBlend = cinematic ? 1 : 1 - Math.exp(-dt * 5);
1473
+ this.camera.position.lerp(cameraPosition, cameraBlend);
1474
+ this.cameraAim.lerp(cameraAim, cameraBlend);
1475
+ this.camera.lookAt(this.cameraAim);
1476
+ this.camera.fov = THREE.MathUtils.lerp(
1477
+ this.camera.fov,
1478
+ THREE.MathUtils.radToDeg(
1479
+ 2 * Math.atan((cinematic ? THREE.MathUtils.lerp(span, 5.9, detail)
1480
+ : overviewSpan) / (2 * distance)),
1481
+ ),
1482
+ cameraBlend,
1483
+ );
1484
+ const fog = this.scene.fog as THREE.Fog;
1485
+ // The camera position is damped after its target distance changes. Anchor
1486
+ // fog to the rendered camera, or entry puts the array behind the far plane
1487
+ // until the camera catches up (a brief white wash that exit never showed).
1488
+ const renderedDistance = this.camera.position.distanceTo(this.cameraAim);
1489
+ fog.near = renderedDistance + THREE.MathUtils.lerp(5, -1, detail);
1490
+ fog.far = renderedDistance + THREE.MathUtils.lerp(25, 12, detail);
1491
+
1492
+ if (this.collectionOffset.lengthSq() > 0) {
1493
+ this.camera.position.add(this.collectionOffset);
1494
+ this.cameraAim.add(this.collectionOffset);
1495
+ this.appliedCollectionOffset.copy(this.collectionOffset);
1496
+ this.camera.lookAt(this.cameraAim);
1497
+ }
1498
+ this.camera.updateProjectionMatrix();
1499
+ this.camera.updateMatrixWorld();
1500
+ let neighborTop = -Infinity;
1501
+ const lane = selectedLane,
1502
+ row = selectedRow;
1503
+ for (let r = row - 5; r <= row + 5; r++) {
1504
+ if (r !== row)
1505
+ neighborTop = Math.max(neighborTop, -4.6 + field(r, lane) + 3.76);
1506
+ }
1507
+ for (const o of this.outgoing) {
1508
+ if (o.cell.lane === lane && Math.abs(o.cell.row - row) <= 5) {
1509
+ neighborTop = Math.max(neighborTop, o.group.position.y + 3.76);
1510
+ }
1511
+ }
1512
+ this.clearance = this.model.position.y - neighborTop;
1513
+ this.canInspect =
1514
+ !cinematic &&
1515
+ Boolean(this.targetDetail) &&
1516
+ detail > 0.9 &&
1517
+ this.pulseGain < 0.01 &&
1518
+ this.clearance > 0.3;
1519
+ this.container.dataset.inspection =
1520
+ this.returnY !== null
1521
+ ? "aligning"
1522
+ : this.canInspect
1523
+ ? "ready"
1524
+ : this.targetDetail
1525
+ ? "lifting"
1526
+ : "preview";
1527
+ this.beforeRender?.();
1528
+ this.sampleCameraMotion();
1529
+ // Use this frame's final camera, including the translation to the rack.
1530
+ // The light follows collectionOffset, independently of the main camera.
1531
+ // Prepare its final projection before filtering instances, including frame 1.
1532
+ this.light.updateWorldMatrix(true, false);
1533
+ this.light.target.updateWorldMatrix(true, false);
1534
+ this.light.shadow.camera.updateProjectionMatrix();
1535
+ this.light.shadow.updateMatrices(this.light);
1536
+ this.arrayVisibility.submit(this.camera, this.instances, this.arrayCulling, this.scenePixelHeight,
1537
+ [this.light.shadow.getFrustum()]);
1538
+ for (const mesh of this.instances) mesh.visible = this.collectionArrayVisible;
1539
+ const focalPoint = (this.collectionFocus?.clone() ?? this.model.position
1540
+ .clone()
1541
+ .add(new THREE.Vector3(0, 2, 0)))
1542
+ .applyMatrix4(this.camera.matrixWorldInverse);
1543
+ const bokehUniforms = this.bokeh.uniforms as Record<
1544
+ string,
1545
+ { value: number }
1546
+ >;
1547
+ bokehUniforms.focus.value = -focalPoint.z;
1548
+ this.bokeh.enabled = this.quality.depthOfField > 0 && this.collectionDepthOfFieldScale > 0.001;
1549
+ bokehUniforms.aperture.value =
1550
+ (THREE.MathUtils.lerp(0.0003, 0.0008, detail) *
1551
+ this.quality.depthOfField * this.collectionDepthOfFieldScale) /
1552
+ 100;
1553
+ checkpoint?.("sceneUpdate");
1554
+ this.renderer.info.reset();
1555
+ this.renderer.shadowMap.needsUpdate = this.renderer.shadowMap.enabled;
1556
+ this.labelRenderer.prepare(this.scene);
1557
+ checkpoint?.("labelPrepare");
1558
+ this.composer.render();
1559
+ checkpoint?.("composer");
1560
+ this.performanceProbe?.gpuStage(this.renderer, "label-overlay");
1561
+ this.labelRenderer.render(this.renderer, this.scene, this.camera);
1562
+ checkpoint?.("labelOverlay");
1563
+ }
1564
+ /** Clone the actual authored cassette, including its original optics. */
1565
+ createCollectionFile(index: number | null, options: { shelf?: boolean; returning?: boolean; label?: ArchiveLabel } = {}, fresh = false) {
1566
+ const kind = options.shelf ? "shelf" : "moving";
1567
+ let group = fresh ? undefined : this.collectionPools[kind].pop();
1568
+ if (group) {
1569
+ this.pooledCollections.delete(group);
1570
+ this.collectionReused++;
1571
+ } else {
1572
+ group = this.model.clone(true);
1573
+ if (options.shelf) {
1574
+ for (const mesh of group.children)
1575
+ if (mesh.userData.surface === "Ivory_Edges") mesh.userData.useArrayMaterial = true;
1576
+ }
1577
+ this.appearance.prepare(group);
1578
+ const label = group.getObjectByName(ARCHIVE_LABEL_NAME) as THREE.Mesh;
1579
+ label.material = this.labelRenderer.createMaterial(this.labelTexture!);
1580
+ label.layers.set(ARCHIVE_LABEL_LAYER);
1581
+ delete label.userData.archiveLabelKey;
1582
+ this.collectionKinds.set(group, kind);
1583
+ this.collectionCreated++;
1584
+ }
1585
+ const label = group.getObjectByName(ARCHIVE_LABEL_NAME) as THREE.Mesh;
1586
+ const content = options.label || (options.returning ? this.archiveLabel || { code: "ARCHIVE" }
1587
+ : { code: index === null ? "READING" : "NO." + String(index + 1).padStart(3, "0") });
1588
+ const visibleLabel = this.model.getObjectByName(ARCHIVE_LABEL_NAME) as THREE.Mesh;
1589
+ const retained = options.returning && !options.label
1590
+ && this.labels().share(visibleLabel.material as THREE.MeshBasicMaterial, label.material as THREE.MeshBasicMaterial);
1591
+ if (!retained) this.labels().bind(label.material as THREE.MeshBasicMaterial, content, options.shelf ? 'shelf-label' : 'moving-label');
1592
+ label.userData.archiveLabelKey = archiveLabelKey(content); label.userData.archiveLabel = { ...content };
1593
+ positionArchiveLabel(label);
1594
+ if (options.returning) {
1595
+ const previous = this.model.getObjectByName(ARCHIVE_LABEL_NAME)!;
1596
+ label.position.copy(previous.position); label.quaternion.copy(previous.quaternion);
1597
+ }
1598
+ group.userData.performanceFamily = options.shelf ? "shelf" : "moving";
1599
+ delete group.userData.sourceId;
1600
+ group.visible = true;
1601
+ group.position.set(0, 0, 0);
1602
+ group.scale.setScalar(1);
1603
+ this.appearance.apply(group, 1);
1604
+ this.appearance.setClarity(group, 1);
1605
+ group.rotation.set(0, 0, 0);
1606
+ if (options.shelf) this.shelfInterior?.attach(group);
1607
+ return group;
1608
+ }
1609
+ /** Prepared between animation frames, before the shelf needs its first image. */
1610
+ async prepareShelfInterior() {
1611
+ if (this.disposed || this.shelfInteriorAttempted) return;
1612
+ this.shelfInteriorAttempted = true;
1613
+ const template = this.createCollectionFile(null, { shelf: true });
1614
+ try {
1615
+ // The offline-style capture has different fog/shadow/tone-mapping defines.
1616
+ // Compile those before its synchronous render/readback, and retain them
1617
+ // while the extra views are prepared in separate background tasks.
1618
+ await this.prepareInteriorBakePrograms(template);
1619
+ if (this.disposed) return;
1620
+ if (this.renderer.getContext().isContextLost()) { this.shelfInteriorAttempted = false; return; }
1621
+ try {
1622
+ this.shelfInterior = new ShelfInterior(this.renderer, this.scene, template);
1623
+ this.shelfInterior.attach(template);
1624
+ } catch (error) {
1625
+ if (this.renderer.getContext().isContextLost()) this.shelfInteriorAttempted = false;
1626
+ this.shelfInteriorError = error instanceof Error ? error.message : String(error);
1627
+ console.warn("Shelf interior bake unavailable; retaining complete geometry.", error);
1628
+ }
1629
+ await this.preparePrograms("shelf", [template]);
1630
+ } finally { this.disposeCollectionFile(template); }
1631
+ }
1632
+ private async prepareInteriorBakePrograms(template: THREE.Group) {
1633
+ const scene = new THREE.Scene();
1634
+ scene.environment = this.scene.environment;
1635
+ scene.environmentIntensity = this.scene.environmentIntensity;
1636
+ scene.environmentRotation.copy(this.scene.environmentRotation);
1637
+ copyInteriorLights(this.scene, scene);
1638
+ const root = new THREE.Group(), materials: THREE.Material[] = [];
1639
+ for (const child of template.children) {
1640
+ if (!(child instanceof THREE.Mesh) || !(SHELF_INTERIOR_SURFACES.has(child.userData.surface) || child.userData.surface === "Optical_Diffuser")) continue;
1641
+ const material = (child.material as THREE.MeshPhysicalMaterial).clone();
1642
+ material.fog = false; material.toneMapped = false;
1643
+ if (material.isMeshPhysicalMaterial) material.transmission = 0;
1644
+ materials.push(material); root.add(new THREE.Mesh(child.geometry, material));
1645
+ }
1646
+ const target = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, colorSpace: THREE.LinearSRGBColorSpace });
1647
+ const shadows = this.renderer.shadowMap.enabled;
1648
+ let pending: Promise<void>;
1649
+ try {
1650
+ this.renderer.shadowMap.enabled = false;
1651
+ const task = () => this.programPreparation.prepare("shelf-bake", [root], this.renderer, this.camera, scene, target);
1652
+ pending = this.performanceProbe ? this.performanceProbe.trackLoad("shader-preparation", task, { family: "shelf-bake" }) : task();
1653
+ } finally { this.renderer.shadowMap.enabled = shadows; }
1654
+ try { await pending; }
1655
+ finally { target.dispose(); for (const material of materials) material.dispose(); }
1656
+ }
1657
+ async prepareShelfView(index: number) {
1658
+ if (this.disposed || !this.shelfInterior || this.shelfInterior.hasView(index + 1)) return;
1659
+ const directions = [[-2.15, 0.87, 1], [-2.5, 1, 1], [-2.85, 1.14, 1], [-0.30, 0.23, 1]];
1660
+ const direction = directions[index];
1661
+ if (!direction) return;
1662
+ const template = this.createCollectionFile(null, { shelf: true });
1663
+ const task = async () => {
1664
+ this.shelfInterior!.addView(this.renderer, this.scene, template, new THREE.Vector3(...direction));
1665
+ };
1666
+ try {
1667
+ if (this.performanceProbe) await this.performanceProbe.trackLoad("shelf-view-bake", task, { view: index + 1 });
1668
+ else await task();
1669
+ } finally { this.disposeCollectionFile(template); }
1670
+ }
1671
+ preparePrograms(kind: "shelf" | "workspace", sources: readonly THREE.Object3D[]) {
1672
+ const task = () => this.programPreparation.prepare(kind, sources, this.renderer, this.camera, this.scene, this.composer.readBuffer);
1673
+ return this.performanceProbe ? this.performanceProbe.trackLoad("shader-preparation", task, { family: kind }) : task();
1674
+ }
1675
+ invalidatePrograms() { this.programPreparation.invalidate(); this.labelTextureCache?.invalidateResidency(); }
1676
+ setShelfDetail(group: THREE.Group, moving: boolean) {
1677
+ this.shelfInterior?.update(group, moving, this.camera, this.scenePixelHeight);
1678
+ }
1679
+ /** Run outside the animation callback; keep a small ready-to-use reserve. */
1680
+ prepareMovingFile() {
1681
+ if (this.disposed || this.collectionPools.moving.length >= 3) return;
1682
+ const group = this.createCollectionFile(null, {}, true);
1683
+ try {
1684
+ this.prepareCollectionTexture(group);
1685
+ } finally {
1686
+ this.disposeCollectionFile(group);
1687
+ }
1688
+ }
1689
+ prepareCollectionTexture(group: THREE.Group) {
1690
+ if (this.disposed) return;
1691
+ const label = group.getObjectByName(ARCHIVE_LABEL_NAME) as THREE.Mesh;
1692
+ const map = (label.material as THREE.MeshBasicMaterial).map;
1693
+ if (map) this.renderer.initTexture(map);
1694
+ }
1695
+ /** Change the immutable print reference; other files keep their current text. */
1696
+ setCollectionLabel(group: THREE.Group, index: number | null, metadata?: Pick<ArchiveLabel, "title" | "sourceId">) {
1697
+ const content = { code: index === null ? "READING" : "NO." + String(index + 1).padStart(3, "0"), ...metadata };
1698
+ const label = group.getObjectByName(ARCHIVE_LABEL_NAME) as THREE.Mesh | undefined;
1699
+ if (!label) return;
1700
+ this.labels().bind(label.material as THREE.MeshBasicMaterial, content, this.collectionKinds.get(group) === 'shelf' ? 'shelf-label' : 'moving-label');
1701
+ label.userData.archiveLabelKey = archiveLabelKey(content); label.userData.archiveLabel = { ...content };
1702
+ }
1703
+ private releaseCollectionLabel(group: THREE.Group) {
1704
+ const label = group.getObjectByName(ARCHIVE_LABEL_NAME) as THREE.Mesh | undefined;
1705
+ if (label) this.labelTextureCache?.release(label.material as THREE.MeshBasicMaterial);
1706
+ }
1707
+ /** Latest authored pose, including wave slope and any in-progress return. */
1708
+ archiveSourcePose(index: number) {
1709
+ index = wrap(Math.trunc(index), 40);
1710
+ const cell = this.investigationSlots.get(index) || selectionCell(index, this.selectedCell);
1711
+ if (sameCell(cell, this.selectedCell)) return {
1712
+ position: this.model.position.clone(), quaternion: this.model.quaternion.clone(),
1713
+ };
1714
+ const returning = this.outgoing.find(item => sameCell(item.cell, cell));
1715
+ if (returning) return {
1716
+ position: returning.group.position.clone(), quaternion: returning.group.quaternion.clone(),
1717
+ };
1718
+ const retained = this.investigationPoses.get(index);
1719
+ if (retained) return { position: retained.position.clone(), quaternion: retained.quaternion.clone() };
1720
+ const instance = this.cells.findIndex(item => sameCell(item, cell));
1721
+ if (instance >= 0) {
1722
+ const matrix = this.arrayVisibility.matrixAt(instance);
1723
+ const position = new THREE.Vector3(), quaternion = new THREE.Quaternion(), scale = new THREE.Vector3();
1724
+ matrix.decompose(position, quaternion, scale);
1725
+ return { position, quaternion };
1726
+ }
1727
+ const position = this.cellPosition(cell);
1728
+ position.x -= this.columnCamera.value;
1729
+ position.z += this.rail.value;
1730
+ return { position, quaternion: new THREE.Quaternion() };
1731
+ }
1732
+ /** Position a real-source cassette without moving the user's camera. */
1733
+ archiveSourcePosition(index: number) {
1734
+ return this.archiveSourcePose(index).position;
1735
+ }
1736
+ /** True while the archive itself owns this cell's extraction and optics. */
1737
+ archiveSourceOwned(index: number) {
1738
+ const cell = this.investigationSlots.get(wrap(Math.trunc(index), 40))
1739
+ || selectionCell(index, this.selectedCell);
1740
+ return sameCell(cell, this.selectedCell);
1741
+ }
1742
+ /** Authored extraction height for a cell the archive itself has lifted. */
1743
+ archiveSourceLift(index: number) {
1744
+ const cell = this.investigationSlots.get(wrap(Math.trunc(index), 40)) || selectionCell(index, this.selectedCell);
1745
+ if (sameCell(cell, this.selectedCell)) return this.lift.value;
1746
+ return this.outgoing.find(item => sameCell(item.cell, cell))?.lift.value ?? 0;
1747
+ }
1748
+ /** Pinned rest pose: preview lifts and navigation never own an Agent's lift. */
1749
+ archiveReadingPose(index: number) {
1750
+ const rest = this.investigationPoses.get(wrap(Math.trunc(index), 40));
1751
+ if (rest) return { position: rest.position.clone(), quaternion: rest.quaternion.clone() };
1752
+ const pose = this.archiveSourcePose(index);
1753
+ pose.position.y -= this.archiveSourceLift(index);
1754
+ return pose;
1755
+ }
1756
+ archiveReadingLift(index: number) {
1757
+ return this.investigationClearances.get(wrap(Math.trunc(index), 40)) ?? INSPECTION_LIFT;
1758
+ }
1759
+ /** Carry a plugin-owned cassette through the authored surface transition. */
1760
+ setCollectionAppearance(group: THREE.Group, lift: number) {
1761
+ this.appearance.apply(group, ease(THREE.MathUtils.clamp(lift / 0.4, 0, 1)));
1762
+ this.appearance.setClarity(group, this.decryption.clarity);
1763
+ }
1764
+ pulseInvestigation(index: number) {
1765
+ this.emitPulse(selectionCell(index, this.selectedCell));
1766
+ }
1767
+ disposeCollectionFile(group: THREE.Group) {
1768
+ if (this.pooledCollections.has(group)) return;
1769
+ this.scene.remove(group);
1770
+ // Shelf status markers belong to its current source; only the authored
1771
+ // cassette and its independent label/materials are kept for reuse.
1772
+ for (const child of [...group.children]) {
1773
+ if (!(child instanceof THREE.Mesh) || child.userData.surface || child.userData.sharedShelfProxy || child.name === ARCHIVE_LABEL_NAME) continue;
1774
+ group.remove(child);
1775
+ for (const material of Array.isArray(child.material) ? child.material : [child.material]) material.dispose();
1776
+ }
1777
+ delete group.userData.sourceId;
1778
+ group.visible = false;
1779
+ const kind = this.collectionKinds.get(group);
1780
+ if (!this.disposed && kind && this.collectionPools[kind].length < (kind === "shelf" ? SHELF_PAGE_SIZE : 4)) {
1781
+ this.collectionPools[kind].push(group);
1782
+ this.pooledCollections.add(group);
1783
+ } else {
1784
+ this.shelfInterior?.detach(group);
1785
+ this.releaseCollectionLabel(group);
1786
+ this.appearance.dispose(group);
1787
+ this.collectionKinds.delete(group);
1788
+ }
1789
+ }
1790
+ get collectionDeparture() { return this.model.position.clone(); }
1791
+ get collectionOrientation() { return this.model.quaternion.clone(); }
1792
+ dispose() {
1793
+ if (this.disposed) return;
1794
+ this.disposed = true;
1795
+ this.programPreparation?.dispose();
1796
+ this.shelfInterior?.dispose();
1797
+ this.shelfInterior = undefined;
1798
+ for (const group of this.pooledCollections) { this.releaseCollectionLabel(group); this.appearance.dispose(group); }
1799
+ this.pooledCollections.clear();
1800
+ this.collectionPools.moving.length = this.collectionPools.shelf.length = 0;
1801
+ this.events.abort();
1802
+ this.investigationSlots.clear(); this.investigationPoses.clear();
1803
+ this.arrayRefill.clear();
1804
+ this.beforeRender = undefined;
1805
+ const geometries = new Set<THREE.BufferGeometry>();
1806
+ const materials = new Set<THREE.Material>();
1807
+ const textures = new Set<THREE.Texture>();
1808
+ // The bake owns its targets and derived meshes; the original palette and
1809
+ // geometry remain shared by selected/returning cards and are disposed below.
1810
+ for (const inst of this.arrayInteriorInstances) {
1811
+ this.scene.remove(inst);
1812
+ inst.dispose();
1813
+ }
1814
+ this.arrayInteriorInstances = [];
1815
+ this.arrayInteriorBake?.dispose();
1816
+ this.arrayInteriorBake = undefined;
1817
+ for (const mesh of this.arrayInteriorSources) {
1818
+ geometries.add(mesh.geometry);
1819
+ for (const mat of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) materials.add(mat);
1820
+ }
1821
+ this.arrayInteriorSources = [];
1822
+ this.scene.traverse(object => {
1823
+ if (!(object instanceof THREE.Mesh)) return;
1824
+ geometries.add(object.geometry);
1825
+ for (const mat of Array.isArray(object.material) ? object.material : [object.material]) {
1826
+ materials.add(mat);
1827
+ for (const value of Object.values(mat)) if (value instanceof THREE.Texture && !value.userData.sharedArchiveLabel) textures.add(value);
1828
+ }
1829
+ if (object instanceof THREE.InstancedMesh) object.dispose();
1830
+ });
1831
+ geometries.forEach(value => value.dispose());
1832
+ materials.forEach(value => value.dispose());
1833
+ textures.forEach(value => value.dispose());
1834
+ this.scene.environment?.dispose();
1835
+ this.labelTextureCache?.dispose();
1836
+ this.light.shadow.map?.dispose();
1837
+ for (const pass of this.composer.passes) pass.dispose?.();
1838
+ this.composer.dispose();
1839
+ this.labelRenderer?.dispose();
1840
+ this.scene.clear();
1841
+ this.renderer.dispose();
1842
+ this.renderer.forceContextLoss();
1843
+ this.renderer.domElement.remove();
1844
+ }
1845
+ projectCard(x: number, y: number) {
1846
+ this.model.updateMatrixWorld(true);
1847
+ const p = this.model
1848
+ .localToWorld(new THREE.Vector3(x, y, 0.255))
1849
+ .project(this.camera);
1850
+ return [(p.x + 1) * this.container.clientWidth / 2, (1 - p.y) * this.container.clientHeight / 2];
1851
+ }
1852
+ get decryptionFrame() { return this.decryption.frame; }
1853
+ finishDecryption() { this.decryption.finish(); }
1854
+ get detailVisibility() {
1855
+ return ease((this.detail - 0.25) / 0.55);
1856
+ }
1857
+ private projectLabelPoint(x: number, y: number) {
1858
+ const label = this.model.getObjectByName(ARCHIVE_LABEL_NAME);
1859
+ if (!label) return [0, 0];
1860
+ label.updateWorldMatrix(true, false);
1861
+ const p = label.localToWorld(new THREE.Vector3(x * ARCHIVE_LABEL_SIZE.width, y * ARCHIVE_LABEL_SIZE.height, 0)).project(this.camera);
1862
+ return [Math.round((p.x * 0.5 + 0.5) * this.container.clientWidth),
1863
+ Math.round((-p.y * 0.5 + 0.5) * this.container.clientHeight)];
1864
+ }
1865
+ // TEMPORARY RHINE PROFILER: cheap, content-free snapshot; no matrix updates/traversals.
1866
+ performanceCounters() {
1867
+ let arrayPartInstances = 0, arrayBatches = 0;
1868
+ for (const mesh of this.instances) if (mesh.visible && mesh.count) { arrayPartInstances += mesh.count; arrayBatches++; }
1869
+ const triangles = this.arrayVisibility.triangleStats();
1870
+ const shelf = this.shelfInterior?.getStats();
1871
+ return { arrayPartInstances, arrayBatches,
1872
+ shelfGeometryFiles: shelf?.detailed ?? 0, shelfTextureFiles: shelf?.textured ?? 0,
1873
+ shelfMotionFiles: shelf?.reasons.motion ?? 0, shelfAngleFiles: shelf?.reasons.angle ?? 0,
1874
+ shelfPreparedViews: shelf?.views ?? 0, shelfAngleErrorMaxPixels: shelf?.angleErrorMaxPixels ?? 0, shelfBackFacing: shelf?.backFacing ?? 0,
1875
+ arrayCameraTrianglesPerPass: this.collectionArrayVisible ? triangles.cameraTrianglesPerPass : 0,
1876
+ arrayShadowTrianglesPerPass: this.collectionArrayVisible ? triangles.shadowTrianglesPerPass : 0,
1877
+ arrayShadowOnlyTrianglesPerPass: this.collectionArrayVisible ? triangles.shadowOnlyTrianglesPerPass : 0,
1878
+ arrayShadowCulledTrianglesPerPass: this.collectionArrayVisible ? triangles.shadowCulledTrianglesPerPass : 0,
1879
+ heroVisible: this.model.visible, arrayVisible: this.collectionArrayVisible,
1880
+ cameraDetailBlend: this.detail, cameraDetailTarget: this.targetDetail,
1881
+ cameraTranslation: this.cameraMotion?.translation || 0, cameraRotationRadians: this.cameraMotion?.rotationRadians || 0,
1882
+ cameraFovDelta: this.cameraMotion?.fovDegrees || 0, cameraActuallyMoving: this.cameraMotion?.actualMoving || false,
1883
+ returningFiles: this.outgoing.length, shadowEnabled: this.renderer.shadowMap.enabled,
1884
+ labelDepthEnabled: this.labelRenderer.capture.enabled, smaaEnabled: this.smaa.enabled,
1885
+ aoEnabled: this.ao.enabled, dofEnabled: this.bokeh.enabled };
1886
+ }
1887
+ performancePasses() {
1888
+ return this.composer.passes.map(pass => ({ name: pass instanceof RenderPass ? 'scene-color'
1889
+ : pass === this.labelRenderer.capture ? 'label-depth-copy' : pass === this.ao ? 'ssao'
1890
+ : pass === this.bokeh ? 'depth-of-field' : pass === this.smaa ? 'smaa' : pass instanceof OutputPass ? 'output' : 'other', pass }));
1891
+ }
1892
+ private readonly diagnosticCameraPosition = new THREE.Vector3();
1893
+ private readonly diagnosticCameraRotation = new THREE.Quaternion();
1894
+ private diagnosticCameraFov = 0;
1895
+ private diagnosticCameraInitialized = false;
1896
+ private cameraMotion = { translation: 0, rotationRadians: 0, fovDegrees: 0, actualMoving: false };
1897
+ private sampleCameraMotion() {
1898
+ if (!this.performanceProbe?.running) { this.diagnosticCameraInitialized = false; return; }
1899
+ const translation = this.diagnosticCameraInitialized ? this.camera.position.distanceTo(this.diagnosticCameraPosition) : 0;
1900
+ const rotationRadians = this.diagnosticCameraInitialized ? this.camera.quaternion.angleTo(this.diagnosticCameraRotation) : 0;
1901
+ const fovDegrees = this.diagnosticCameraInitialized ? Math.abs(this.camera.fov - this.diagnosticCameraFov) : 0;
1902
+ this.cameraMotion = { translation, rotationRadians, fovDegrees, actualMoving: translation > 0.00001 || rotationRadians > 0.000001 || fovDegrees > 0.00001 };
1903
+ this.diagnosticCameraPosition.copy(this.camera.position); this.diagnosticCameraRotation.copy(this.camera.quaternion);
1904
+ this.diagnosticCameraFov = this.camera.fov; this.diagnosticCameraInitialized = true;
1905
+ }
1906
+ private cameraDiagnostics() {
1907
+ return { position: this.camera.position.toArray(), quaternion: this.camera.quaternion.toArray(), fov: this.camera.fov,
1908
+ detailBlend: this.detail, detailTarget: this.targetDetail, lift: this.lift.value, ...this.cameraMotion };
1909
+ }
1910
+ performanceState() {
1911
+ const target = (value: { width: number; height: number } | null) => value ? { width: value.width, height: value.height } : null;
1912
+ return { loaded: this.loaded, returningFiles: this.outgoing.length, quality: { ...this.quality },
1913
+ buffers: { output: target(this.renderer.domElement), composer: target(this.composer.readBuffer),
1914
+ labelDepth: target(this.labelRenderer.capture.target), labelDepthEnabled: this.labelRenderer.capture.enabled,
1915
+ shadow: target(this.light.shadow.map), shadowEnabled: this.renderer.shadowMap.enabled,
1916
+ transmissionScale: this.renderer.transmissionResolutionScale, transmissionTarget: null },
1917
+ passes: this.composer.passes.map(pass => ({ name: pass instanceof RenderPass ? 'scene-color'
1918
+ : pass === this.labelRenderer.capture ? 'label-depth-copy' : pass === this.ao ? 'ssao'
1919
+ : pass === this.bokeh ? 'depth-of-field' : pass === this.smaa ? 'smaa' : pass instanceof OutputPass ? 'output' : 'other',
1920
+ enabled: pass.enabled })),
1921
+ content: { ...this.performanceCounters(), array: this.arrayVisibility.getStats(), backfaceCulling: this.backfaceSurfaces,
1922
+ interiorMode: this.arrayInteriorBake ? 'baked' : 'geometry' },
1923
+ camera: this.cameraDiagnostics(),
1924
+ programPreparation: this.programPreparation?.stats(),
1925
+ shelfInterior: this.shelfInterior?.getStats(true),
1926
+ labelCache: this.labelTextureCache?.stats(),
1927
+ collectionResources: { created: this.collectionCreated, reused: this.collectionReused,
1928
+ movingReserve: this.collectionPools.moving.length, shelfReserve: this.collectionPools.shelf.length },
1929
+ };
1930
+ }
1931
+ getStats() {
1932
+ this.model.updateMatrixWorld(true);
1933
+ const project = (x: number, y: number, z: number) => {
1934
+ const p = this.model
1935
+ .localToWorld(new THREE.Vector3(x, y, z))
1936
+ .project(this.camera);
1937
+ return [Math.round((p.x + 1) * this.container.clientWidth / 2), Math.round((1 - p.y) * this.container.clientHeight / 2)];
1938
+ };
1939
+ return {
1940
+ decryption: { ...this.decryption.frame, clarity: this.decryption.clarity },
1941
+ topLeft: project(-2.5, 3.7, 0),
1942
+ topRight: project(2.5, 3.7, 0),
1943
+ labelTopLeft: this.projectLabelPoint(-0.5, 0.5),
1944
+ labelBottomLeft: this.projectLabelPoint(-0.5, -0.5),
1945
+ archiveLabel: { ...this.archiveLabel },
1946
+ archiveLabelSize: [ARCHIVE_LABEL_WIDTH, ARCHIVE_LABEL_HEIGHT],
1947
+ labelCorners: [[-0.5, 0.5], [0.5, 0.5], [0.5, -0.5], [-0.5, -0.5]]
1948
+ .map(([x, y]) => this.projectLabelPoint(x, y)),
1949
+ modelPosition: this.model.position
1950
+ .toArray()
1951
+ .map((v) => Math.round(v * 10000) / 10000),
1952
+ cameraPosition: this.camera.position
1953
+ .toArray()
1954
+ .map((v) => Math.round(v * 10000) / 10000),
1955
+ fieldOfView: this.camera.fov,
1956
+ loaded: this.loaded,
1957
+ drawCalls: this.renderer.info.render.calls,
1958
+ triangles: this.renderer.info.render.triangles,
1959
+ archiveCount: this.positions.length,
1960
+ arrayVisibility: this.arrayVisibility.getStats(), backfaceCulling: this.backfaceSurfaces,
1961
+ arrayDetails: this.instances.flatMap(inst => {
1962
+ const material = inst.material as THREE.MeshStandardMaterial;
1963
+ return material.userData.arrayDetail ? [{ ...material.userData.arrayDetail,
1964
+ name: inst.name, instances: inst.count,
1965
+ baseTextureBytes: (material.normalMap as THREE.DataTexture | null)?.image.data?.byteLength ?? 0,
1966
+ }] : [];
1967
+ }),
1968
+ camera: this.cameraDiagnostics(),
1969
+ programPreparation: this.programPreparation?.stats(),
1970
+ shelfInterior: {
1971
+ mode: this.shelfInterior ? "baked" : "geometry",
1972
+ ...this.shelfInterior?.getStats(true),
1973
+ ...(this.shelfInteriorError ? { error: this.shelfInteriorError } : {}),
1974
+ },
1975
+ labelCache: this.labelTextureCache?.stats(),
1976
+ collectionResources: {
1977
+ created: this.collectionCreated, reused: this.collectionReused,
1978
+ movingReserve: this.collectionPools.moving.length, shelfReserve: this.collectionPools.shelf.length,
1979
+ },
1980
+ arrayInterior: {
1981
+ mode: this.arrayInteriorBake ? "baked" : "geometry",
1982
+ requested: this.arrayInteriorMode,
1983
+ arrayGroups: this.instances.length,
1984
+ arrayTrianglesPerCassette: this.instances.filter(inst => inst.userData.lodLevel !== 1).reduce((sum, inst) =>
1985
+ sum + (inst.geometry.index?.count ?? inst.geometry.getAttribute("position").count) / 3, 0),
1986
+ arrayLowTrianglesPerCassette: this.instances.filter(inst => inst.userData.lodLevel !== 0).reduce((sum, inst) =>
1987
+ sum + (inst.geometry.index?.count ?? inst.geometry.getAttribute("position").count) / 3, 0),
1988
+ ...(this.arrayInteriorBake?.stats ?? {}),
1989
+ ...(this.arrayInteriorError ? { error: this.arrayInteriorError } : {}),
1990
+ },
1991
+ returningFiles: this.outgoing.length,
1992
+ selectionPhase: this.pendingPulse
1993
+ ? "lifting"
1994
+ : this.pulses.length
1995
+ ? "wave"
1996
+ : "settled",
1997
+ pendingPulse: this.pendingPulse ? { ...this.pendingPulse } : null,
1998
+ pulses: this.pulses.map((pulse) => ({ ...pulse })),
1999
+ referenceTime: Math.round((this.scanTime + 5) * 100) / 100,
2000
+ selectedSlot: this.selectedSlot,
2001
+ selectedLane: Math.floor(this.selectedSlot / 32),
2002
+ selectedCell: { ...this.selectedCell },
2003
+ departedSelection: this.departedSelection,
2004
+ arrayRefills: this.arrayRefill.stats().map(motion => ({ ...motion,
2005
+ cells: [Math.max(...motion.holes) + 1, ...motion.holes, Math.min(...motion.holes) - 1].map(row => {
2006
+ const index = this.cells.findIndex(cell => cell.lane === motion.lane && cell.row === row);
2007
+ const matrix = index >= 0 ? this.arrayVisibility.matrixAt(index) : null;
2008
+ return { row, visualRow: this.arrayRefill.row({ lane: motion.lane, row }),
2009
+ position: matrix ? [matrix.elements[12], matrix.elements[13], matrix.elements[14]] : null };
2010
+ }),
2011
+ })),
2012
+ selectedModelVisible: this.model.visible,
2013
+ coordinateOrigin: { ...this.coordinateOrigin },
2014
+ poolBounds: {
2015
+ minLane: Math.min(...this.cells.map((c) => c.lane)),
2016
+ maxLane: Math.max(...this.cells.map((c) => c.lane)),
2017
+ minRow: Math.min(...this.cells.map((c) => c.row)),
2018
+ maxRow: Math.max(...this.cells.map((c) => c.row)),
2019
+ },
2020
+ laneFocus: this.laneFocus.value,
2021
+ columnCamera: this.columnCamera.value,
2022
+ rotation: this.rotation,
2023
+ clearance: this.clearance,
2024
+ canInspect: this.canInspect,
2025
+ returnPhase: this.returnY !== null ? "aligning" : "lowering",
2026
+ extraction: Math.round(this.lift.value * 1000) / 1000,
2027
+ appearance: Math.round(ease(this.lift.value / 0.4) * 1000) / 1000,
2028
+ cameraDetail: Math.round(this.detail * 1000) / 1000,
2029
+ idleGain: this.idleGain,
2030
+ investigationWave: {
2031
+ active: this.investigationScanning, driver: "original-select",
2032
+ },
2033
+ investigationSlots: [...this.investigationSlots].map(([index, cell]) => ({ index, ...cell })),
2034
+ cameraDistance: this.camera.position.distanceTo(this.cameraAim),
2035
+ cameraNear: this.camera.near,
2036
+ cameraFar: this.camera.far,
2037
+ fogNear: (this.scene.fog as THREE.Fog).near,
2038
+ fogFar: (this.scene.fog as THREE.Fog).far,
2039
+ returningAppearance: this.outgoing.map((o) => ({
2040
+ slot: o.slot,
2041
+ cell: { ...o.cell },
2042
+ lift: o.lift.value,
2043
+ quality: ease(o.lift.value / 0.4),
2044
+ rotation: o.group.rotation.y,
2045
+ worldY: o.group.position.y,
2046
+ phase: o.returnY !== null ? "aligning" : "lowering",
2047
+ })),
2048
+ rail: Math.round(this.rail.value * 1000) / 1000,
2049
+ };
2050
+ }
2051
+ }