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,29 @@
1
+ type Clue = { id: string; kind: string };
2
+ // A report-sized clearing, three side sheets per side, and smaller upper/lower groups.
3
+ // Slots are allocated in immutable clue-ID order, including withdrawn clues, so new arrivals do not reshuffle a board.
4
+ export const INVESTIGATION_SLOTS = [
5
+ [-5.65, 2.35], [-5.65, .10], [-5.55, -2.20],
6
+ [5.55, 2.30], [5.65, .05], [5.60, -2.20],
7
+ [-2.78, 2.65], [0, 2.65], [2.78, 2.65],
8
+ [-2.78, -2.65], [0, -2.65], [2.78, -2.65],
9
+ ] as const;
10
+ const preferences: Record<string, number[]> = {
11
+ excerpt: [0,1,2,6,9,7,10,8,11,3,4,5],
12
+ finding: [3,4,5,8,11,7,10,6,9,0,1,2],
13
+ relation: [7,10,6,8,9,11,4,1,3,0,5,2],
14
+ time: [10,9,11,2,5,1,4,6,8,0,3,7],
15
+ question: [9,11,10,2,5,6,8,1,4,0,3,7],
16
+ contrast: [4,8,11,3,5,6,9,7,10,1,0,2],
17
+ };
18
+ export const investigationClueIndex = (clue: Clue) => Math.max(0, Number(clue.id.replace(/^C/, '')) - 1 || 0);
19
+ export function investigationPositions(clues: readonly Clue[]) {
20
+ const occupied = new Map<number, Set<number>>(), result = new Map<string, {x:number;y:number}>();
21
+ for (const clue of [...clues].sort((a,b) => investigationClueIndex(a) - investigationClueIndex(b))) {
22
+ const page = Math.floor(investigationClueIndex(clue) / 12), used = occupied.get(page) || new Set<number>();
23
+ const slot = (preferences[clue.kind] || preferences.finding).find(value => !used.has(value));
24
+ if (slot === undefined) continue;
25
+ used.add(slot); occupied.set(page,used);
26
+ const [x,y] = INVESTIGATION_SLOTS[slot]; result.set(clue.id,{x,y});
27
+ }
28
+ return result;
29
+ }
@@ -0,0 +1,34 @@
1
+ export type LabelCandidate<T> = { item: T; lane: number; row: number; axis: 'row' | 'lane' | 'pointer' | 'activity'; offset: number };
2
+
3
+ /** Shared by prediction and selection, including unbounded loop cells. */
4
+ export function archivePointerTarget(counts: readonly number[], rows: readonly number[], slots: readonly number[],
5
+ current: number, selected: { lane: number; row: number }, index: number, cell?: { lane: number; row: number }) {
6
+ if (cell && cell.row === selected.row && cell.lane === selected.lane) return null;
7
+ const lane = Math.floor(index / 8), count = counts[lane] || 0;
8
+ const delta = cell && lane === current ? cell.row - selected.row : index % 8 - slots[lane];
9
+ const row = count ? ((rows[lane] + delta) % count + count) % count : 0;
10
+ return { lane, row, delta };
11
+ }
12
+
13
+ /** Explicit intent shares the same bounded working set with ordinary neighbours. */
14
+ export function archiveLabelCandidates<T>(lanes: readonly (readonly T[])[], rows: readonly number[], current: number, limit = 6,
15
+ preferred: readonly LabelCandidate<T>[] = []) {
16
+ const wrap = (n: number, count: number) => (n % count + count) % count;
17
+ const result: LabelCandidate<T>[] = [];
18
+ if (!lanes.length) return result;
19
+ const seen = new Set<T>();
20
+ for (const candidate of preferred) {
21
+ if (seen.has(candidate.item) || result.length >= limit) continue;
22
+ seen.add(candidate.item); result.push(candidate);
23
+ }
24
+ const add = (lane: number, offset: number, axis: 'row' | 'lane') => {
25
+ lane = wrap(lane, lanes.length); const items = lanes[lane]; if (!items.length) return;
26
+ const row = wrap((rows[lane] || 0) + (axis === 'row' ? offset : 0), items.length), item = items[row];
27
+ if (lane === current && row === wrap(rows[current] || 0, items.length) || seen.has(item) || result.length >= limit) return;
28
+ seen.add(item); result.push({ item, lane, row, axis, offset });
29
+ };
30
+ add(current, -1, 'row'); add(current, 1, 'row');
31
+ add(current - 1, -1, 'lane'); add(current + 1, 1, 'lane');
32
+ add(current, -2, 'row'); add(current, 2, 'row');
33
+ return result;
34
+ }
@@ -0,0 +1,40 @@
1
+ /** A continuous title loop with one accessible label and two visual copies. */
2
+ export function createLoopingTitle(element: HTMLElement, initialText: string) {
3
+ let currentText: string | undefined;
4
+ let currentScrolling = true;
5
+ let track: HTMLSpanElement;
6
+ const measure = new ResizeObserver(entries => {
7
+ const width = entries[0]?.borderBoxSize[0]?.inlineSize;
8
+ if (width) track.style.setProperty('--rhine-title-duration', `${Math.max(10, width / 16)}s`);
9
+ });
10
+
11
+ function update(text: string, scrolling = true) {
12
+ // Status snapshots can repeat while an answer streams; keep the loop moving.
13
+ if (text === currentText && scrolling === currentScrolling) return;
14
+ currentText = text;
15
+ currentScrolling = scrolling;
16
+ measure.disconnect();
17
+ element.classList.add('rhine-loop-title');
18
+ element.classList.toggle('is-scrolling', scrolling);
19
+ element.title = text;
20
+
21
+ // This label sizes the viewport and is the only text read by assistive tech.
22
+ const label = document.createElement('span');
23
+ label.className = 'rhine-loop-title-label';
24
+ label.textContent = text;
25
+ track = document.createElement('span');
26
+ track.className = 'rhine-loop-title-track';
27
+ track.setAttribute('aria-hidden', 'true');
28
+ for (let index = 0; index < 2; index++) {
29
+ const copy = document.createElement('span');
30
+ copy.className = 'rhine-loop-title-copy';
31
+ copy.textContent = text;
32
+ track.append(copy);
33
+ }
34
+ element.replaceChildren(label, track);
35
+ if (scrolling) measure.observe(track.firstElementChild!, { box: 'border-box' });
36
+ }
37
+
38
+ update(initialText);
39
+ return { update, destroy: () => measure.disconnect() };
40
+ }
@@ -0,0 +1,41 @@
1
+ /* Manual retrieval shares the transparent archive HUD. */
2
+ .rhine-workbench .rhine-query-form{display:flex;flex-direction:column}
3
+ .rhine-workbench .rhine-search-controls{order:0;display:flex;align-items:center;justify-content:space-between;gap:20px;margin:0 0 12px}
4
+ .rhine-workbench .rhine-query-form>.search-field{order:1}
5
+ .rhine-workbench .rhine-query-actions{order:2}
6
+ .rhine-workbench .rhine-search-modes{display:flex;gap:25px;align-items:center}
7
+ .rhine-workbench .rhine-search-modes button{font-size:14px;padding:8px 0;color:#888773;border-bottom:1px solid transparent}
8
+ .rhine-workbench .rhine-search-modes button[aria-pressed=true]{color:#3d4833;border-bottom-color:#8c9273}
9
+ .rhine-workbench .rhine-search-game{display:flex;align-items:center;gap:12px;font-size:11px;color:#858570}
10
+ .rhine-workbench .rhine-search-controls select,.rhine-workbench .rhine-search-fields :is(input,select){background:transparent;color:#414b36;border:0;border-bottom:1px solid #aca995;border-radius:0;font:inherit;min-width:0;max-width:100%;padding:7px 3px;outline-offset:3px}
11
+ .rhine-workbench .rhine-search-controls option,.rhine-workbench .rhine-search-fields option{background:#eeece2;color:#414b36}
12
+ .rhine-workbench .rhine-search-advanced{order:3;margin-top:13px;border-block:1px solid #b5b09b70;font-size:11px;color:#858570}
13
+ .rhine-workbench .rhine-search-advanced summary{cursor:pointer;list-style:disclosure-closed;padding:9px 0;width:fit-content;color:#576049}
14
+ .rhine-workbench .rhine-search-advanced[open] summary{list-style:disclosure-open}
15
+ .rhine-workbench .rhine-search-advanced summary span{margin-left:14px;font-size:10px;color:#96917c}
16
+ .rhine-workbench .rhine-search-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px 22px;padding:7px 0 13px;max-height:140px;overflow:auto;scrollbar-width:thin}
17
+ .rhine-workbench .rhine-search-fields label{display:flex;flex-direction:column;gap:3px;font-size:11px;line-height:1.6}
18
+ .rhine-workbench .rhine-search-fields :is(input,select){font-size:12px;width:100%;box-sizing:border-box}
19
+ .rhine-workbench .rhine-search-wide{grid-column:span 2}
20
+ .rhine-workbench .rhine-search-settings-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:0 0 10px;font-size:10px}
21
+ .rhine-workbench .rhine-search-settings-footer button{font-size:11px;white-space:nowrap;color:#616c4f}
22
+ .rhine-workbench .rhine-search-cancel{font-size:11px;color:#9c785f;margin-right:15px}
23
+ .rhine-workbench .rhine-index-modal .rhine-result-summary{line-height:1.7;max-height:60px;overflow:auto;scrollbar-width:thin}
24
+ .rhine-workbench :is([data-layout=compact],[data-layout=portrait]) .rhine-search-fields{grid-template-columns:repeat(2,minmax(0,1fr));max-height:125px;gap:10px 14px}
25
+ .rhine-workbench [data-layout=portrait] .rhine-search-controls{gap:10px;align-items:baseline;flex-wrap:wrap;margin-bottom:7px}
26
+ .rhine-workbench [data-layout=portrait] .rhine-search-modes{gap:22px}
27
+ .rhine-workbench [data-layout=portrait] .rhine-search-game{gap:7px;font-size:10px}
28
+ .rhine-workbench [data-layout=portrait] .rhine-search-advanced{margin-top:9px}
29
+ .rhine-workbench [data-layout=portrait] .rhine-search-settings-footer{align-items:flex-start;gap:12px;font-size:9px;line-height:1.6}
30
+
31
+ .rhine-workbench [data-layout] .rhine-index-modal .rhine-query-actions{margin-top:0;padding-top:8px}
32
+ .rhine-workbench [data-layout] .rhine-index-modal .modal-top{min-height:32px;padding-bottom:6px}
33
+ .rhine-workbench [data-layout] .rhine-index-modal h2{margin:16px 0 12px}
34
+ .rhine-workbench [data-layout] .rhine-index-modal .category-filters{padding:8px 0 6px}
35
+ .rhine-workbench [data-layout] .rhine-index-modal .modal-bottom{margin-top:8px;padding-top:8px}
36
+ .rhine-workbench :is([data-layout=compact],[data-layout=portrait]) .rhine-index-modal h2{font-size:24px;margin:10px 0}
37
+ .rhine-workbench :is([data-layout=compact],[data-layout=portrait]) .rhine-index-modal .rhine-query-actions{font-size:10px;line-height:1.6}
38
+ .rhine-workbench :is([data-layout=compact],[data-layout=portrait]) .rhine-index-modal .result-header{padding:6px 0}
39
+ .rhine-workbench [data-layout=portrait] .rhine-search-advanced summary span{display:none}
40
+ .rhine-workbench :is([data-layout=compact],[data-layout=portrait]) .rhine-index-modal{height:calc(100% - 24px);padding:10px 18px}
41
+ .rhine-workbench [data-layout=compact] .rhine-search-fields{grid-template-columns:repeat(4,minmax(0,1fr))}
@@ -0,0 +1,89 @@
1
+ import type { RhineOptions } from './types';
2
+
3
+ export const manualSearchControls = `
4
+ <div class="rhine-search-controls">
5
+ <div class="rhine-search-modes" role="group" aria-label="检索来源"><button type="button" data-search-mode="local" aria-pressed="true">本地检索</button><button type="button" data-search-mode="cloud" aria-pressed="false">云端检索</button></div>
6
+ <label class="rhine-search-game">资料范围 <select name="games" aria-label="检索游戏范围"><option value="">已启用的资料库</option><option value="arknights">明日方舟</option><option value="endfield">终末地</option></select></label>
7
+ </div>
8
+ <details class="rhine-search-advanced"><summary>高级设置 <span>按需限定检索范围</span></summary>
9
+ <div class="rhine-search-fields">
10
+ <label data-search-for="local">资料类型<select name="resource_types"><option value="">全部类型</option><option value="original_story">剧情原文</option><option value="character_bundle">角色档案与模组</option><option value="reviewed_wiki">审校 Wiki</option><option value="entity_profile">实体资料</option><option value="timeline">时间线</option></select></label>
11
+ <label data-search-for="local">匹配方式<select name="match_mode"><option value="literal">原文关键词</option><option value="regex">正则表达式(线性子集)</option></select></label>
12
+ <label data-search-for="cloud" hidden>检索深度<select name="depth"><option value="">服务默认</option><option value="fast">快速</option><option value="standard">标准</option><option value="deep">深入</option></select></label>
13
+ <label data-search-for="cloud" hidden>来源范围<select name="evidence_policy"><option value="">服务默认</option><option value="mixed">综合资料</option><option value="original_only">仅原文</option></select></label>
14
+ <label data-search-for="cloud" hidden>检索方式<select name="search_intent"><option value="">自动判断</option><option value="quote_search">台词与引文</option><option value="scene_search">事件与场景</option><option value="single_sentence_search">模糊寻找一句原文</option></select></label>
15
+ <label data-search-for="cloud" hidden>候选审核<select name="validation"><option value="">服务默认</option><option value="none">跳过审核</option><option value="record_only">记录审核结果</option><option value="llm">启用模型审核</option></select></label>
16
+ <label>人物/实体<input name="entity_names" placeholder="如:塔露拉、科西切" maxlength="2000"/></label>
17
+ <label>说话人<input name="speakers" placeholder="填写原文说话人名称" maxlength="2000"/></label>
18
+ <label>活动/篇章<input name="activities" placeholder="如:怒号光明" maxlength="2000"/></label>
19
+ <label data-search-for="cloud" hidden>最多返回来源<input name="final_limit" type="number" min="1" max="100" step="1" placeholder="服务默认(可设 1–100)"/></label>
20
+ <label data-search-for="cloud" hidden class="rhine-search-wide">补充检索词<input name="query_variants" placeholder="等义表述,用分号分隔;最多 8 项" maxlength="4000"/></label>
21
+ </div><div class="rhine-search-settings-footer"><span>多项用逗号分隔;留空沿用默认。设置只用于本次手动检索。</span><button type="button" class="rhine-search-reset">恢复默认</button></div>
22
+ </details>`;
23
+
24
+ export function mountManualSearch(form: HTMLFormElement, api: RhineOptions['api'], changed: () => void) {
25
+ let mode: 'local' | 'cloud' = 'local', enabled: boolean | undefined;
26
+ let controller: AbortController | undefined;
27
+ const field = (name: string) => form.elements.namedItem(name) as HTMLInputElement | HTMLSelectElement;
28
+ const value = (name: string) => field(name).value.trim();
29
+ const list = (name: string) => [...new Set(value(name).split(/[,,、;;\n]+/u).map(v => v.trim()).filter(Boolean))];
30
+ const render = () => {
31
+ form.querySelectorAll<HTMLButtonElement>('[data-search-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.searchMode === mode)));
32
+ form.querySelectorAll<HTMLElement>('[data-search-for]').forEach(element => element.hidden = element.dataset.searchFor !== mode);
33
+ const input = form.querySelector<HTMLInputElement>('#rhine-query')!;
34
+ input.maxLength = mode === 'cloud' ? 20000 : 512;
35
+ input.placeholder = mode === 'cloud' ? '描述你想寻找的资料、事件,或一句记不清的原文' : '搜索人物、剧情、机构或一句原文';
36
+ form.querySelector('.rhine-query-hint')!.textContent = mode === 'local' ? '检索已安装的本地资料;留空可浏览目录。'
37
+ : enabled === false ? '云端服务尚未启用,请在 PRTS 资料设置中开启。' : '向云端检索资料;返回后可阅读原文、收入档案架或证据盒。';
38
+ };
39
+ form.querySelectorAll<HTMLButtonElement>('[data-search-mode]').forEach(button => button.addEventListener('click', () => {
40
+ const next = button.dataset.searchMode as typeof mode;
41
+ if (next === mode) return;
42
+ mode = next; render(); changed();
43
+ }));
44
+ form.querySelector('.rhine-search-reset')!.addEventListener('click', () => {
45
+ form.querySelectorAll<HTMLInputElement | HTMLSelectElement>('.rhine-search-fields input,.rhine-search-fields select').forEach(element => element.value = element.name === 'match_mode' ? 'literal' : '');
46
+ field('games').value = ''; render();
47
+ });
48
+ render();
49
+ return {
50
+ get mode() { return mode; },
51
+ resetMode() { mode = 'local'; render(); },
52
+ async refresh() {
53
+ controller?.abort(); const current = new AbortController(); controller = current;
54
+ try {
55
+ const response = await api('archive.options', {}, current.signal);
56
+ if (current.signal.aborted || response?.error || response?.ok === false) return;
57
+ const data = response?.games ? response : response?.data;
58
+ if (!data?.games) return;
59
+ enabled = data.cloud_enabled;
60
+ for (const option of (field('games') as HTMLSelectElement).options) option.disabled = !!option.value && !data.games.includes(option.value);
61
+ if (value('games') && !data.games.includes(value('games'))) field('games').value = '';
62
+ render();
63
+ } catch { /* Search itself supplies actionable errors; an options refresh is optional. */ }
64
+ },
65
+ request(query: string): Record<string, unknown> {
66
+ const request: Record<string, unknown> = { query };
67
+ if (value('games')) request.games = [value('games')];
68
+ if (mode === 'local') {
69
+ if (value('resource_types')) request.resource_types = [value('resource_types')];
70
+ request.match_mode = value('match_mode');
71
+ for (const [input, key] of [['entity_names','entity_names'],['speakers','speakers'],['activities','activity_names']]) if (list(input).length) request[key] = list(input);
72
+ } else {
73
+ if (!query) throw new Error('请先输入想查找的问题或资料。');
74
+ if (enabled === false) throw new Error('云端检索尚未启用,请在 PRTS 资料设置中开启。');
75
+ for (const key of ['depth','evidence_policy']) if (value(key)) request[key] = value(key);
76
+ const options: Record<string, unknown> = {}, filters: Record<string, unknown> = {};
77
+ for (const key of ['search_intent','validation']) if (value(key)) options[key] = value(key);
78
+ for (const key of ['entity_names','speakers','activities']) if (list(key).length) filters[key] = list(key);
79
+ if (Object.keys(filters).length) options.filters = filters;
80
+ if (value('final_limit')) options.limits = { final_limit: Number(value('final_limit')) };
81
+ const variants = value('query_variants').split(/[;;\n]+/u).map(v => v.trim()).filter(Boolean);
82
+ if (variants.length) options.query_variants = [...new Set(variants)];
83
+ if (Object.keys(options).length) request.options = options;
84
+ }
85
+ return request;
86
+ },
87
+ dispose() { controller?.abort(); },
88
+ };
89
+ }
@@ -0,0 +1,180 @@
1
+ import * as THREE from "three";
2
+ import { glassRevealGLSL, frostedTransmissionGLSL, FROSTED_ROUGHNESS } from "./glass-reveal.ts";
3
+ import { internalOpticsFragment } from "./internal-optics.ts";
4
+
5
+ type Surface = THREE.MeshPhysicalMaterial;
6
+ type Palette = { high: Surface; low?: Surface };
7
+
8
+ // The array and selected file share geometry. Morph their surface properties
9
+ // on one mesh so transparent shells never overlap during a quality change.
10
+ export class CardAppearance {
11
+ private palettes = new Map<string, Palette>();
12
+
13
+ register(name: string, high: Surface, low?: Surface) {
14
+ this.palettes.set(name, { high, low });
15
+ }
16
+
17
+ prepare(group: THREE.Group) {
18
+ for (const child of group.children) {
19
+ const mesh = child as THREE.Mesh;
20
+ const name = mesh.userData.surface as string;
21
+ const palette = this.palettes.get(name);
22
+ if (!palette) continue;
23
+ // Shelf borders use the array palette at both appearance endpoints.
24
+ const mat = (mesh.userData.useArrayMaterial && palette.low ? palette.low : palette.high).clone();
25
+ const amount = { value: 0 };
26
+ const clarity = { value: 0 };
27
+ mesh.material = mat;
28
+ if (mat.userData.opticalOrder)
29
+ mesh.renderOrder = mat.userData.opticalOrder;
30
+ mesh.userData.appearance = amount;
31
+ mesh.userData.glassClarity = clarity;
32
+ mat.onBeforeCompile = (shader) => {
33
+ if (mat.userData.opticalOrder)
34
+ shader.fragmentShader = internalOpticsFragment(shader.fragmentShader);
35
+ shader.uniforms.archiveQuality = amount;
36
+ shader.uniforms.archiveClarity = clarity;
37
+ shader.fragmentShader =
38
+ "uniform float archiveQuality;\nuniform float archiveClarity;\n" +
39
+ shader.fragmentShader;
40
+ if (name === "Frosted_Polymer") {
41
+ shader.vertexShader =
42
+ "varying float vArchiveHeight;\nvarying vec2 vArchiveProjectedAxis;\n" + shader.vertexShader;
43
+ shader.vertexShader = shader.vertexShader.replace(
44
+ "#include <begin_vertex>",
45
+ "#include <begin_vertex>\nvArchiveHeight = position.y / 3.7;",
46
+ );
47
+ shader.fragmentShader =
48
+ "varying float vArchiveHeight;\nvarying vec2 vArchiveProjectedAxis;\n" +
49
+ glassRevealGLSL +
50
+ shader.fragmentShader;
51
+ shader.vertexShader = shader.vertexShader.replace(
52
+ "#include <project_vertex>",
53
+ "#include <project_vertex>\nvArchiveProjectedAxis = 1.85 * vec2(projectionMatrix[0][0] * modelViewMatrix[1][0], projectionMatrix[1][1] * modelViewMatrix[1][1]) / max(0.0001, abs(mvPosition.z));",
54
+ );
55
+ shader.fragmentShader = shader.fragmentShader.replace(
56
+ "#include <transmission_pars_fragment>",
57
+ frostedTransmissionGLSL + "\n" + THREE.ShaderChunk.transmission_pars_fragment.replace(
58
+ "float lod = log2( transmissionSamplerSize.x ) * applyIorToRoughness( roughness, ior );",
59
+ "float lod = archiveTransmissionLod(roughness, ior, transmissionSamplerSize);",
60
+ ),
61
+ );
62
+ shader.fragmentShader = shader.fragmentShader.replace(
63
+ "#include <color_fragment>",
64
+ "#include <color_fragment>\ndiffuseColor.rgb *= mix(mix(vec3(0.40, 0.30, 0.20), vec3(1.0, 0.98, 0.94), smoothstep(0.1, 1.0, vArchiveHeight)), vec3(1.0), archiveQuality);",
65
+ );
66
+ shader.fragmentShader = shader.fragmentShader.replace(
67
+ "#include <roughnessmap_fragment>",
68
+ `#include <roughnessmap_fragment>\nroughnessFactor = mix(mix(0.28, ${FROSTED_ROUGHNESS}, archiveQuality), 0.025, glassRevealAtHeight(archiveClarity, vArchiveHeight));`,
69
+ );
70
+ } else if (!palette.low) {
71
+ // Stable screen-space coverage adds internal geometry without an
72
+ // abrupt visibility toggle or a second transparent body.
73
+ shader.fragmentShader = shader.fragmentShader.replace(
74
+ "#include <color_fragment>",
75
+ "#include <color_fragment>\nfloat coverage = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\nif (archiveQuality <= coverage) discard;",
76
+ );
77
+ }
78
+ };
79
+ mat.customProgramCacheKey = () =>
80
+ `archive-surface-clarity-${name}-${Boolean(palette.low)}`;
81
+ }
82
+ }
83
+
84
+ setClarity(group: THREE.Group, value: number) {
85
+ const clarity = THREE.MathUtils.clamp(value, 0, 1);
86
+ // Traversal still works after the viewer reparents meshes into part groups.
87
+ group.traverse((child) => {
88
+ if (!(child instanceof THREE.Mesh) || !child.userData.glassClarity)
89
+ return;
90
+ child.userData.glassClarity.value = clarity;
91
+ if (child.userData.surface !== "Frosted_Polymer") return;
92
+ const mat = child.material as Surface;
93
+ const palette = this.palettes.get("Frosted_Polymer")!;
94
+ const quality = child.userData.appearance.value as number;
95
+ const baseline = (
96
+ key: "thickness" | "transmission" | "attenuationDistance",
97
+ ) =>
98
+ THREE.MathUtils.lerp(
99
+ palette.low?.[key] ?? palette.high[key],
100
+ palette.high[key],
101
+ quality,
102
+ );
103
+ mat.thickness = THREE.MathUtils.lerp(
104
+ baseline("thickness"),
105
+ 0.018,
106
+ clarity,
107
+ );
108
+ mat.transmission = THREE.MathUtils.lerp(
109
+ baseline("transmission"),
110
+ 0.985,
111
+ clarity,
112
+ );
113
+ mat.attenuationDistance = THREE.MathUtils.lerp(
114
+ baseline("attenuationDistance"),
115
+ 8,
116
+ clarity,
117
+ );
118
+ });
119
+ }
120
+
121
+ apply(group: THREE.Group, value: number) {
122
+ for (const child of group.children) {
123
+ const mesh = child as THREE.Mesh;
124
+ if (mesh.userData.sharedShelfProxy) continue;
125
+ const palette = this.palettes.get(mesh.userData.surface);
126
+ if (!palette) {
127
+ // The printed canvas belongs to this file, including returning copies.
128
+ (mesh.material as THREE.MeshBasicMaterial).opacity = value;
129
+ continue;
130
+ }
131
+ mesh.userData.appearance.value = value;
132
+ const low = palette.low;
133
+ const high = mesh.userData.useArrayMaterial && low ? low : palette.high;
134
+ if (!low) continue;
135
+ const mat = mesh.material as Surface;
136
+ mat.color.copy(low.color).lerp(high.color, value);
137
+ if (
138
+ mat.attenuationColor &&
139
+ low.attenuationColor &&
140
+ high.attenuationColor
141
+ ) {
142
+ mat.attenuationColor
143
+ .copy(low.attenuationColor)
144
+ .lerp(high.attenuationColor, value);
145
+ mat.attenuationDistance =
146
+ Number.isFinite(low.attenuationDistance) &&
147
+ Number.isFinite(high.attenuationDistance)
148
+ ? THREE.MathUtils.lerp(
149
+ low.attenuationDistance,
150
+ high.attenuationDistance,
151
+ value,
152
+ )
153
+ : high.attenuationDistance;
154
+ }
155
+ for (const key of [
156
+ "roughness",
157
+ "metalness",
158
+ "transmission",
159
+ "thickness",
160
+ "clearcoat",
161
+ "clearcoatRoughness",
162
+ ] as const) {
163
+ mat[key] = THREE.MathUtils.lerp(low[key] ?? 0, high[key] ?? 0, value);
164
+ }
165
+ // Keep the same transmission shader/pass throughout the transition.
166
+ if (high.transmission > 0)
167
+ mat.transmission = Math.max(0.000001, mat.transmission);
168
+ }
169
+ }
170
+
171
+ dispose(group: THREE.Group) {
172
+ for (const child of group.children) {
173
+ const mesh = child as THREE.Mesh;
174
+ if (mesh.userData.sharedShelfProxy) continue;
175
+ const mat = mesh.material as THREE.MeshBasicMaterial;
176
+ if (!mesh.userData.surface && !mat.map?.userData.sharedArchiveLabel) mat.map?.dispose();
177
+ mat.dispose();
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,84 @@
1
+ import type * as THREE from 'three';
2
+
3
+ export interface ArchiveLabel {
4
+ code: string;
5
+ title?: string;
6
+ sourceId?: string;
7
+ }
8
+
9
+ export const ARCHIVE_LABEL_NAME = 'Rhine_Archive_Label';
10
+ export const ARCHIVE_LABEL_WIDTH = 1536;
11
+ export const ARCHIVE_LABEL_HEIGHT = 714;
12
+ export const ARCHIVE_LABEL_SIZE = { width: 0.99, height: 0.46 };
13
+ const FONT = 'MiSans, "PingFang SC", "Microsoft YaHei", sans-serif';
14
+
15
+ export function archiveLabelKey(label: ArchiveLabel) {
16
+ return JSON.stringify([label.code, label.title || '', label.sourceId || '']);
17
+ }
18
+
19
+ /** Match the authored cover plate in every view; only the cassette itself moves. */
20
+ export function positionArchiveLabel(label: THREE.Object3D) {
21
+ label.rotation.set(0, 0, 0);
22
+ label.position.set(-1.36, 3.04, 0.255);
23
+ }
24
+
25
+ /** The array exposes one strip of the plate. Keep text there at a fixed size. */
26
+ export function archiveTitleLine(title: string, measure: (text: string) => number, width: number): string {
27
+ const chars = Array.from(title.replace(/\s+/gu, ' ').trim());
28
+ const bounded = chars.slice(0, 1024).join('');
29
+ if (chars.length <= 1024 && measure(bounded) <= width) return bounded;
30
+ if (measure('…') > width) return '';
31
+ let low = 0, high = Math.min(chars.length, 1024);
32
+ while (low < high) {
33
+ const mid = Math.ceil((low + high) / 2);
34
+ if (measure(chars.slice(0, mid).join('') + '…') <= width) low = mid;
35
+ else high = mid - 1;
36
+ }
37
+ return chars.slice(0, low).join('').trimEnd() + '…';
38
+ }
39
+
40
+ /** Repaint only when content changes; no HTML, glyph meshes or per-frame uploads. */
41
+ export function paintArchiveLabel(canvas: HTMLCanvasElement, mark: CanvasImageSource, label: ArchiveLabel) {
42
+ const c = canvas.getContext('2d')!;
43
+ c.setTransform(1, 0, 0, 1, 0, 0);
44
+ c.clearRect(0, 0, canvas.width, canvas.height);
45
+ c.setTransform(canvas.width / 1536, 0, 0, canvas.height / 714, 0, 0);
46
+ c.fillStyle = '#eeeae2';
47
+ c.fillRect(0, 0, 1536, 714);
48
+ c.fillStyle = '#9b9b8c';
49
+ c.fillRect(62, 36, 1412, 4);
50
+ const title = label.title?.trim() || (label.code === 'READING' ? '正在读取资料' : '莱茵生命 · 资料档案');
51
+ const separator = title.indexOf(' / ');
52
+ const name = separator > 0 ? title.slice(0, separator) : title;
53
+ const type = separator > 0 ? title.slice(separator + 3).replace(/\s+/gu, ' ').trim() : '';
54
+ c.font = `400 112px ${FONT}`;
55
+ const slashWidth = c.measureText('/').width, gap = 40, width = 1384, x = 76, baseline = 216;
56
+ c.font = `600 144px ${FONT}`;
57
+ c.fillStyle = '#353931';
58
+ const nameWidth = width - c.measureText(type).width - slashWidth - gap * 2;
59
+ if (type && nameWidth >= c.measureText('…').width) {
60
+ // Retain the document type even when a long entity name needs shortening.
61
+ const line = archiveTitleLine(name, value => c.measureText(value).width, nameWidth);
62
+ c.fillText(line, x, baseline);
63
+ const slashX = x + c.measureText(line).width + gap;
64
+ c.font = `400 112px ${FONT}`;
65
+ c.fillStyle = '#8a8c7e';
66
+ c.fillText('/', slashX, baseline);
67
+ c.font = `600 144px ${FONT}`;
68
+ c.fillStyle = '#353931';
69
+ c.fillText(type, slashX + slashWidth + gap, baseline);
70
+ } else {
71
+ c.fillText(archiveTitleLine(title, value => c.measureText(value).width, width), x, baseline);
72
+ }
73
+ c.fillStyle = '#b7b5a8';
74
+ c.fillRect(76, 404, 1384, 3);
75
+ c.fillStyle = '#353931';
76
+ c.font = `400 64px ${FONT}`;
77
+ c.fillText(label.code, 76, 617);
78
+ c.font = `400 51px ${FONT}`;
79
+ c.fillText('RHINE LAB, LLC.', 414, 574);
80
+ c.fillStyle = '#727568';
81
+ c.font = `34px ${FONT}`;
82
+ c.fillText('INTERNAL DATABASE / INFO', 414, 638);
83
+ c.drawImage(mark, 1320, 570, 140, 65);
84
+ }
@@ -0,0 +1,39 @@
1
+ import * as THREE from "three";
2
+ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
3
+
4
+ export type LightingLook = "baseline" | "refined";
5
+
6
+ // The archive and its independent viewer use the same studio illumination.
7
+ // Each renderer needs its own PMREM render target / WebGL texture.
8
+ export function createArchiveLighting(
9
+ renderer: THREE.WebGLRenderer,
10
+ scene: THREE.Scene,
11
+ look: LightingLook = "baseline",
12
+ ) {
13
+ const refined = look === "refined";
14
+ renderer.toneMappingExposure = refined ? 1.0 : 1.05;
15
+ const pmrem = new THREE.PMREMGenerator(renderer);
16
+ const room = new RoomEnvironment();
17
+ scene.environment = pmrem.fromScene(room, 0.04).texture;
18
+ room.dispose();
19
+ pmrem.dispose();
20
+ scene.environmentIntensity = refined ? 0.52 : 0.48;
21
+ scene.add(
22
+ new THREE.HemisphereLight(
23
+ "#fffaf5",
24
+ refined ? "#b49b80" : "#b4a18c",
25
+ refined ? 0.5 : 0.65,
26
+ ),
27
+ );
28
+ const key = new THREE.DirectionalLight(
29
+ refined ? "#fff4e5" : "#fff7ed",
30
+ refined ? 1.7 : 1.4,
31
+ );
32
+ key.position.set(
33
+ ...((refined ? [-8, 14, 4] : [-6, 14, -5]) as [number, number, number]),
34
+ );
35
+ const fill = new THREE.DirectionalLight("#ffffff", refined ? 0.3 : 0.6);
36
+ fill.position.set(7, 8, -10);
37
+ scene.add(key, fill);
38
+ return key;
39
+ }
@@ -0,0 +1,82 @@
1
+ import { archiveColumns, columnFiles, fileLocation } from "./data.ts";
2
+
3
+ export type ArchiveCell = { lane: number; row: number };
4
+ export type ArchiveNavigation =
5
+ { axis: "row" | "lane"; direction: number } | { cell: ArchiveCell };
6
+
7
+ export const LOOP_COLUMNS = 9;
8
+ export const LOOP_ROWS = 32;
9
+ export const COLUMN_SPACING = 5.2;
10
+ export const ROW_SPACING = 0.62;
11
+ const POOL_LANES = [0, 1, 2, 3, 4, -2, -1, 5, 6];
12
+
13
+ export function wrap(value: number, count: number) {
14
+ return ((value % count) + count) % count;
15
+ }
16
+
17
+ // Choose an occurrence of an item in an unbounded sequence. Directional moves
18
+ // use adjacent cells instead, so the last-to-first transition never reverses.
19
+ export function nearestOccurrence(
20
+ value: number,
21
+ center: number,
22
+ period: number,
23
+ ) {
24
+ return value + Math.floor((center - value + period / 2) / period) * period;
25
+ }
26
+
27
+ export function fileAtCell({ lane, row }: ArchiveCell) {
28
+ const files = columnFiles(wrap(lane, archiveColumns.length));
29
+ return files[wrap(row - 12, files.length)];
30
+ }
31
+
32
+ export function selectionCell(
33
+ index: number,
34
+ current: ArchiveCell,
35
+ navigation?: ArchiveNavigation,
36
+ ): ArchiveCell {
37
+ if (navigation && "cell" in navigation) return { ...navigation.cell };
38
+ const next = fileLocation(index);
39
+ const row = nearestOccurrence(
40
+ next.row,
41
+ current.row,
42
+ columnFiles(next.lane).length,
43
+ );
44
+ if (navigation?.axis === "row") {
45
+ return { lane: current.lane, row: current.row + navigation.direction };
46
+ }
47
+ return {
48
+ lane:
49
+ navigation?.axis === "lane"
50
+ ? current.lane + navigation.direction
51
+ : nearestOccurrence(next.lane, current.lane, archiveColumns.length),
52
+ row,
53
+ };
54
+ }
55
+
56
+ // Preserve the reference animation's original first 160 instances. The four
57
+ // extra columns form a hidden margin on either side during interactive use.
58
+ export function poolCell(index: number): ArchiveCell {
59
+ return {
60
+ lane: POOL_LANES[Math.floor(index / LOOP_ROWS)],
61
+ row: index % LOOP_ROWS,
62
+ };
63
+ }
64
+
65
+ export function visibleCell(index: number, center: ArchiveCell): ArchiveCell {
66
+ return {
67
+ lane: nearestOccurrence(
68
+ POOL_LANES[Math.floor(index / LOOP_ROWS)],
69
+ center.lane,
70
+ LOOP_COLUMNS,
71
+ ),
72
+ row: nearestOccurrence(index % LOOP_ROWS, center.row, LOOP_ROWS),
73
+ };
74
+ }
75
+
76
+ export function cellKey(cell: ArchiveCell) {
77
+ return `${cell.lane}:${cell.row}`;
78
+ }
79
+
80
+ export function sameCell(a: ArchiveCell, b: ArchiveCell) {
81
+ return a.lane === b.lane && a.row === b.row;
82
+ }