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,166 @@
1
+ import * as THREE from "three";
2
+
3
+ type Occluder = {
4
+ index: number; valid: boolean;
5
+ corners: THREE.Vector3[]; planes: THREE.Plane[];
6
+ minX: number; minY: number; maxX: number; maxY: number;
7
+ };
8
+ const GRID = 16;
9
+
10
+ /** A verified solid rectangle, not a transparent cover or a mesh bounding box. */
11
+ export function substrateRectangle(geometry: THREE.BufferGeometry) {
12
+ const positions = geometry.getAttribute("position"), indices = geometry.index;
13
+ if (!positions || !indices) return undefined;
14
+ let front = -Infinity;
15
+ for (let i = 0; i < positions.count; i++) front = Math.max(front, positions.getZ(i));
16
+ const points = new Map<string, THREE.Vector3>(), triangles: THREE.Vector3[][] = [];
17
+ for (let i = 0; i < indices.count; i += 3) {
18
+ const vertices = [indices.getX(i), indices.getX(i + 1), indices.getX(i + 2)];
19
+ if (!vertices.every(index => Math.abs(positions.getZ(index) - front) < 1e-6)) continue;
20
+ const triangle = vertices.map(index => new THREE.Vector3().fromBufferAttribute(positions, index));
21
+ for (const point of triangle) points.set(`${point.x},${point.y}`, point);
22
+ triangles.push(triangle);
23
+ }
24
+ if (triangles.length !== 2 || points.size !== 4) return undefined;
25
+ const shared = triangles[0].filter(a => triangles[1].some(b => a.equals(b)));
26
+ if (shared.length !== 2 || shared[0].x === shared[1].x || shared[0].y === shared[1].y) return undefined;
27
+ const bounds = new THREE.Box3().setFromPoints([...points.values()]);
28
+ const width = bounds.max.x - bounds.min.x, height = bounds.max.y - bounds.min.y;
29
+ if (width <= 0 || height <= 0) return undefined;
30
+ if ([...points.values()].some(p => ![bounds.min.x, bounds.max.x].includes(p.x)
31
+ || ![bounds.min.y, bounds.max.y].includes(p.y))) return undefined;
32
+ const centers = triangles.map(t => t[0].clone().add(t[1]).add(t[2]).multiplyScalar(1 / 3));
33
+ const area = triangles.reduce((sum, t) => sum + new THREE.Triangle(...t as [THREE.Vector3, THREE.Vector3, THREE.Vector3]).getArea(), 0);
34
+ if (Math.abs(area - width * height) > 1e-5 || centers[0].distanceTo(centers[1]) < 1e-5) return undefined;
35
+ return bounds;
36
+ }
37
+
38
+ /** Conservative camera occlusion volumes behind the array's opaque substrates. */
39
+ export class ArrayOcclusion {
40
+ private readonly occluders: Occluder[];
41
+ private readonly grid: Uint32Array;
42
+ private readonly gridCounts = new Uint32Array(GRID * GRID);
43
+ private readonly capacity: number;
44
+ private readonly eye = new THREE.Vector3();
45
+ private readonly direction = new THREE.Vector3();
46
+ private readonly center = new THREE.Vector3();
47
+ private readonly half = new THREE.Vector3();
48
+ private readonly worldCenter = new THREE.Vector3();
49
+ private readonly point = new THREE.Vector3();
50
+ private readonly extrusion = new THREE.Vector3();
51
+ private readonly projection = new THREE.Matrix4();
52
+ private readonly view = new THREE.Matrix4();
53
+ private perspective = true;
54
+ private pixelScale = 0;
55
+ private near = 0;
56
+ private readonly rectangle: THREE.Box3;
57
+ tested = 0;
58
+ rejected = 0;
59
+
60
+ constructor(rectangle: THREE.Box3, capacity: number) {
61
+ this.rectangle = rectangle.clone();
62
+ this.capacity = capacity;
63
+ this.grid = new Uint32Array(GRID * GRID * capacity);
64
+ this.occluders = Array.from({ length: capacity }, (_, index) => ({
65
+ index, valid: false,
66
+ corners: Array.from({ length: 4 }, () => new THREE.Vector3()),
67
+ planes: Array.from({ length: 5 }, () => new THREE.Plane()),
68
+ minX: 0, minY: 0, maxX: 0, maxY: 0,
69
+ }));
70
+ }
71
+
72
+ update(camera: THREE.Camera, matrices: readonly THREE.Matrix4[], active: Uint32Array, count: number, pixelHeight: number) {
73
+ this.tested = this.rejected = 0;
74
+ this.gridCounts.fill(0);
75
+ this.eye.setFromMatrixPosition(camera.matrixWorld);
76
+ camera.getWorldDirection(this.direction);
77
+ this.perspective = camera instanceof THREE.PerspectiveCamera;
78
+ this.near = (camera as THREE.PerspectiveCamera).near ?? 0.1;
79
+ this.view.copy(camera.matrixWorldInverse);
80
+ this.projection.multiplyMatrices(camera.projectionMatrix, this.view);
81
+ // Three output pixels plus a world-space guard protect antialiasing and
82
+ // the existing thin cover's refracted samples near an occluder's edge.
83
+ this.pixelScale = pixelHeight > 0 ? 6 / (Math.abs(camera.projectionMatrix.elements[5]) * pixelHeight) : Infinity;
84
+ for (let i = 0; i < count; i++) {
85
+ const index = active[i], object = this.occluders[index], matrix = matrices[index];
86
+ const { min, max } = this.rectangle, corners = object.corners;
87
+ corners[0].set(min.x, min.y, max.z).applyMatrix4(matrix);
88
+ corners[1].set(max.x, min.y, max.z).applyMatrix4(matrix);
89
+ corners[2].set(max.x, max.y, max.z).applyMatrix4(matrix);
90
+ corners[3].set(min.x, max.y, max.z).applyMatrix4(matrix);
91
+ object.minX = object.minY = Infinity; object.maxX = object.maxY = -Infinity;
92
+ object.valid = true;
93
+ this.center.set(0, 0, 0);
94
+ for (const corner of corners) {
95
+ this.center.add(corner);
96
+ this.point.copy(corner).applyMatrix4(this.view);
97
+ if (-this.point.z <= this.near + 0.01) object.valid = false;
98
+ this.point.copy(corner).applyMatrix4(this.projection);
99
+ object.minX = Math.min(object.minX, this.point.x); object.maxX = Math.max(object.maxX, this.point.x);
100
+ object.minY = Math.min(object.minY, this.point.y); object.maxY = Math.max(object.maxY, this.point.y);
101
+ }
102
+ if (!object.valid) continue;
103
+ if (object.minX > 1 || object.maxX < -1 || object.minY > 1 || object.maxY < -1) continue;
104
+ this.center.multiplyScalar(0.25);
105
+ const front = object.planes[4].setFromCoplanarPoints(corners[0], corners[1], corners[2]);
106
+ if (Math.abs(front.normal.dot(this.direction)) < 0.01) continue;
107
+ // All five planes point into the hidden volume. Rejects are valid only
108
+ // when the entire candidate box is strictly inside this one volume.
109
+ if (this.perspective ? front.distanceToPoint(this.eye) > 0 : front.normal.dot(this.direction) < 0) front.negate();
110
+ for (let edge = 0; edge < 4; edge++) {
111
+ const a = corners[edge], b = corners[(edge + 1) % 4];
112
+ this.extrusion.copy(a).add(this.direction);
113
+ const plane = object.planes[edge].setFromCoplanarPoints(a, b, this.perspective ? this.eye : this.extrusion);
114
+ if (plane.distanceToPoint(this.center) < 0) plane.negate();
115
+ }
116
+ const left = Math.max(0, Math.floor((object.minX + 1) * GRID / 2));
117
+ const right = Math.min(GRID - 1, Math.floor((object.maxX + 1) * GRID / 2));
118
+ const bottom = Math.max(0, Math.floor((object.minY + 1) * GRID / 2));
119
+ const top = Math.min(GRID - 1, Math.floor((object.maxY + 1) * GRID / 2));
120
+ for (let y = bottom; y <= top; y++) for (let x = left; x <= right; x++) {
121
+ const cell = y * GRID + x;
122
+ this.grid[cell * this.capacity + this.gridCounts[cell]++] = index;
123
+ }
124
+ }
125
+ }
126
+
127
+ hidden(bounds: THREE.Box3, matrix: THREE.Matrix4, index: number) {
128
+ this.tested++;
129
+ if (bounds.isEmpty() || !Number.isFinite(this.pixelScale)) return false;
130
+ bounds.getCenter(this.center); bounds.getSize(this.half).multiplyScalar(0.5);
131
+ this.worldCenter.copy(this.center).applyMatrix4(matrix);
132
+ this.point.copy(this.worldCenter).applyMatrix4(this.view);
133
+ const margin = Math.max(0.08, this.pixelScale * (this.perspective ? Math.max(0, -this.point.z) : 1));
134
+ const m = matrix.elements, h = this.half, v = this.view.elements;
135
+ // OBB support along camera Z gives the nearest corner depth directly.
136
+ // The five occluder planes below already prove screen coverage; projecting
137
+ // all eight corners again adds work without strengthening that proof.
138
+ const depthRadius = Math.abs(v[2] * m[0] + v[6] * m[1] + v[10] * m[2]) * h.x
139
+ + Math.abs(v[2] * m[4] + v[6] * m[5] + v[10] * m[6]) * h.y
140
+ + Math.abs(v[2] * m[8] + v[6] * m[9] + v[10] * m[10]) * h.z;
141
+ if (-this.point.z - depthRadius <= this.near + margin) return false;
142
+ this.point.copy(this.worldCenter).applyMatrix4(this.projection);
143
+ const x = Math.floor((this.point.x + 1) * GRID / 2), y = Math.floor((this.point.y + 1) * GRID / 2);
144
+ // A volume covering the candidate must also cover its projected center.
145
+ // Spatial bins only reduce the candidate list; the exact five-plane proof
146
+ // below still decides visibility, with the same conservative margin.
147
+ if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false;
148
+ const cell = y * GRID + x, candidateCount = this.gridCounts[cell], start = cell * this.capacity;
149
+ if (!candidateCount) return false;
150
+ for (let candidate = 0; candidate < candidateCount; candidate++) {
151
+ const object = this.occluders[this.grid[start + candidate]];
152
+ if (object.index === index) continue;
153
+ let covered = true;
154
+ for (let p = 4; p >= 0; p--) {
155
+ const plane = object.planes[p];
156
+ const n = plane.normal;
157
+ const radius = Math.abs(n.x * m[0] + n.y * m[1] + n.z * m[2]) * h.x
158
+ + Math.abs(n.x * m[4] + n.y * m[5] + n.z * m[6]) * h.y
159
+ + Math.abs(n.x * m[8] + n.y * m[9] + n.z * m[10]) * h.z;
160
+ if (plane.distanceToPoint(this.worldCenter) - radius <= margin) { covered = false; break; }
161
+ }
162
+ if (covered) { this.rejected++; return true; }
163
+ }
164
+ return false;
165
+ }
166
+ }
@@ -0,0 +1,38 @@
1
+ import * as THREE from "three";
2
+
3
+ /** Partition authored faces without cutting triangles or changing their normals.
4
+ * Fine bevels occupy four corners; a small shared group keeps faces spanning
5
+ * quadrant boundaries. Every original triangle occurs in exactly one region.
6
+ */
7
+ export function splitArrayShell(surface: string, source: THREE.BufferGeometry) {
8
+ if (!["Frosted_Polymer", "Ivory_Edges"].includes(surface) || !source.index) return undefined;
9
+ const position = source.getAttribute("position");
10
+ if (!position) return undefined;
11
+ const bounds = new THREE.Box3().setFromBufferAttribute(position as THREE.BufferAttribute);
12
+ const center = bounds.getCenter(new THREE.Vector3());
13
+ const regions = Array.from({ length: 5 }, () => [] as number[]);
14
+ for (let i = 0; i < source.index.count; i += 3) {
15
+ const vertices = [source.index.getX(i), source.index.getX(i + 1), source.index.getX(i + 2)];
16
+ const side = vertices.map(index => (position.getX(index) < center.x ? 0 : 1)
17
+ + (position.getY(index) < center.y ? 0 : 2));
18
+ const region = side.every(value => value === side[0]) ? side[0] : 4;
19
+ regions[region].push(...vertices);
20
+ }
21
+ if (regions.filter(region => region.length).length < 2) return undefined;
22
+ return regions.flatMap((indices, region) => {
23
+ if (!indices.length) return [];
24
+ const geometry = new THREE.BufferGeometry(), vertices: number[] = [], remap = new Map<number, number>();
25
+ const compact = indices.map(index => {
26
+ if (!remap.has(index)) { remap.set(index, vertices.length); vertices.push(index); }
27
+ return remap.get(index)!;
28
+ });
29
+ for (const [name, attribute] of Object.entries(source.attributes)) {
30
+ const values = new Float32Array(vertices.length * attribute.itemSize);
31
+ for (let i = 0; i < vertices.length; i++) for (let c = 0; c < attribute.itemSize; c++)
32
+ values[i * attribute.itemSize + c] = attribute.getComponent(vertices[i], c);
33
+ geometry.setAttribute(name, new THREE.BufferAttribute(values, attribute.itemSize));
34
+ }
35
+ geometry.setIndex(compact); geometry.computeBoundingBox(); geometry.computeBoundingSphere();
36
+ return [{ geometry, suffix: region === 4 ? "_Spanning" : `_Region_${region}` }];
37
+ });
38
+ }
@@ -0,0 +1,309 @@
1
+ import * as THREE from "three";
2
+ import { ArrayOcclusion, substrateRectangle } from "./array-occlusion.ts";
3
+
4
+ const FRUSTUM_PADDING = 0.35;
5
+ // A companion attribute makes WebGLGeometries upload the shadow matrices before
6
+ // onBeforeShadow. It is not a shader input; binding uses mesh.instanceMatrix.
7
+ const SHADOW_MATRIX_ATTRIBUTE = "rhineShadowMatrix";
8
+ type ShadowBuffers = {
9
+ geometry: THREE.BufferGeometry;
10
+ camera: THREE.InstancedBufferAttribute;
11
+ shadow: THREE.InstancedBufferAttribute;
12
+ };
13
+
14
+ type Batch = {
15
+ mesh: THREE.InstancedMesh;
16
+ indices: Uint32Array;
17
+ count: number;
18
+ filtered: Uint32Array;
19
+ occluded: number;
20
+ outside: number;
21
+ shadowCount: number;
22
+ triangles: number;
23
+ shadowBuffers?: ShadowBuffers;
24
+
25
+ };
26
+ type DetailLOD = {
27
+ high: THREE.InstancedMesh; low: THREE.InstancedMesh;
28
+ featureSize: number; enterBelow: number; leaveAbove: number;
29
+ levels: Uint8Array; highIndices: Uint32Array; lowIndices: Uint32Array;
30
+ highCount: number; lowCount: number;
31
+ };
32
+
33
+ /**
34
+ * Keep logical cassette poses separate from compact GPU instance slots.
35
+ * Batch transforms must be identity: authored geometry and slot matrices share
36
+ * world space. Complete-cassette bounds provide the broad phase; flagged parts
37
+ * can be culled individually. Shadow casters use an independent light-frustum list.
38
+ * Pass every shadow light frustum, or omit the list to retain all active casters.
39
+ */
40
+ export class ArrayVisibility {
41
+ private readonly matrices: THREE.Matrix4[];
42
+ private readonly active: Uint8Array;
43
+ private readonly activeIndices: Uint32Array;
44
+ private readonly visibleIndices: Uint32Array;
45
+ private readonly submitted: Uint8Array;
46
+ private readonly shadowHooks = new WeakSet<THREE.InstancedMesh>();
47
+ private readonly shadowBuffers = new WeakMap<THREE.InstancedMesh, ShadowBuffers>();
48
+ private readonly shadowSubmitted: Uint8Array;
49
+ private readonly cardBounds = new THREE.Box3();
50
+ private readonly worldBounds = new THREE.Box3();
51
+ private readonly viewProjection = new THREE.Matrix4();
52
+ private readonly frustum = new THREE.Frustum();
53
+ private batches: Batch[] = [];
54
+ private readonly batchByMesh = new Map<THREE.InstancedMesh, Batch>();
55
+ private activeSlots = 0;
56
+ private visibleSlots = 0;
57
+ private shadowSlots = 0;
58
+ private cameraTriangles = 0;
59
+ private shadowTriangles = 0;
60
+ private shadowOnlyTriangles = 0;
61
+ private shadowCulledTriangles = 0;
62
+ private readonly detailLODs: DetailLOD[] = [];
63
+ private readonly lodPoint = new THREE.Vector3();
64
+ private occlusion?: ArrayOcclusion;
65
+
66
+
67
+ setOpaqueSubstrate(geometry: THREE.BufferGeometry) {
68
+ const rectangle = substrateRectangle(geometry);
69
+ this.occlusion = rectangle ? new ArrayOcclusion(rectangle, this.capacity) : undefined;
70
+ }
71
+
72
+ addDetailLOD(high: THREE.InstancedMesh, low: THREE.InstancedMesh,
73
+ featureSize: number, enterBelow: number, leaveAbove: number) {
74
+ this.detailLODs.push({ high, low, featureSize, enterBelow, leaveAbove,
75
+ levels: new Uint8Array(this.capacity), highIndices: new Uint32Array(this.capacity),
76
+ lowIndices: new Uint32Array(this.capacity), highCount: 0, lowCount: 0 });
77
+ }
78
+
79
+ constructor(readonly capacity: number) {
80
+ if (!Number.isInteger(capacity) || capacity < 0)
81
+ throw new RangeError("Array capacity must be a non-negative integer.");
82
+ this.matrices = Array.from({ length: capacity }, () => new THREE.Matrix4());
83
+ this.active = new Uint8Array(capacity);
84
+ this.activeIndices = new Uint32Array(capacity);
85
+ this.visibleIndices = new Uint32Array(capacity);
86
+ this.submitted = new Uint8Array(capacity);
87
+ this.shadowSubmitted = new Uint8Array(capacity);
88
+ }
89
+
90
+ setSlot(index: number, matrix: THREE.Matrix4, active: boolean) {
91
+ this.matrixAt(index).copy(matrix);
92
+ this.active[index] = active ? 1 : 0;
93
+ }
94
+
95
+ /** Borrow the full authored pose, including hidden and culled slots. */
96
+ matrixAt(index: number): THREE.Matrix4 {
97
+ if (!Number.isInteger(index) || index < 0 || index >= this.capacity)
98
+ throw new RangeError("Array slot is outside the logical pool.");
99
+ return this.matrices[index];
100
+ }
101
+
102
+ /** Call after all geometry/bake batches have been created. */
103
+ prepare(instances: readonly THREE.InstancedMesh[]) {
104
+ this.cardBounds.makeEmpty();
105
+ this.batchByMesh.clear();
106
+ this.batches = instances.map((mesh) => {
107
+ if (mesh.instanceMatrix.count < this.capacity)
108
+ throw new RangeError("Instance buffer is smaller than the logical pool.");
109
+ if (mesh.geometry.boundingBox === null) mesh.geometry.computeBoundingBox();
110
+ if (mesh.geometry.boundingBox) this.cardBounds.union(mesh.geometry.boundingBox);
111
+ let buffers = this.shadowBuffers.get(mesh);
112
+ if (mesh.castShadow && (!buffers || buffers.geometry !== mesh.geometry)) {
113
+ // Keep the upload-only attribute off shared selected/baked geometries.
114
+ const geometry = mesh.geometry.clone();
115
+ const shadow = new THREE.InstancedBufferAttribute(new Float32Array(this.capacity * 16), 16)
116
+ .setUsage(THREE.DynamicDrawUsage);
117
+ geometry.setAttribute(SHADOW_MATRIX_ATTRIBUTE, shadow);
118
+ mesh.geometry = geometry;
119
+ buffers = { geometry, camera: mesh.instanceMatrix, shadow };
120
+ this.shadowBuffers.set(mesh, buffers);
121
+ }
122
+ const batch: Batch = { mesh, indices: this.visibleIndices, count: 0,
123
+ filtered: new Uint32Array(this.capacity), occluded: 0, outside: 0, shadowCount: 0,
124
+ triangles: (mesh.geometry.index?.count ?? mesh.geometry.attributes.position.count) / 3,
125
+ shadowBuffers: buffers };
126
+ if (mesh.castShadow && !this.shadowHooks.has(mesh)) {
127
+ this.shadowHooks.add(mesh);
128
+ const before = mesh.onBeforeShadow, after = mesh.onAfterShadow;
129
+ const restore = () => {
130
+ const current = this.batchByMesh.get(mesh);
131
+ if (current?.shadowBuffers) mesh.instanceMatrix = current.shadowBuffers.camera;
132
+ mesh.count = current?.count ?? 0;
133
+ };
134
+ mesh.onBeforeShadow = (...args) => {
135
+ const current = this.batchByMesh.get(mesh);
136
+ if (current?.shadowBuffers) mesh.instanceMatrix = current.shadowBuffers.shadow;
137
+ mesh.count = current?.shadowCount ?? 0;
138
+ try { before.apply(mesh, args); }
139
+ catch (error) { restore(); throw error; }
140
+ };
141
+ mesh.onAfterShadow = (...args) => {
142
+ try { after.apply(mesh, args); }
143
+ finally { restore(); }
144
+ };
145
+ }
146
+ this.batchByMesh.set(mesh, batch);
147
+ return batch;
148
+ });
149
+ }
150
+
151
+ submit(camera: THREE.Camera, instances: readonly THREE.InstancedMesh[], cull = true, pixelHeight = 0,
152
+ shadowFrusta?: readonly THREE.Frustum[]) {
153
+ if (instances.length !== this.batches.length ||
154
+ instances.some((mesh, index) => mesh !== this.batches[index].mesh))
155
+ this.prepare(instances);
156
+ camera.updateWorldMatrix(true, false);
157
+ this.viewProjection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
158
+ this.frustum.setFromProjectionMatrix(
159
+ this.viewProjection, camera.coordinateSystem, camera.reversedDepth,
160
+ );
161
+ this.activeSlots = 0;
162
+ this.visibleSlots = 0;
163
+ const testBounds = cull && !this.cardBounds.isEmpty();
164
+ for (let index = 0; index < this.capacity; index++) {
165
+ if (!this.active[index]) continue;
166
+ this.activeIndices[this.activeSlots++] = index;
167
+ if (testBounds) {
168
+ this.worldBounds.copy(this.cardBounds)
169
+ .applyMatrix4(this.matrices[index]).expandByScalar(FRUSTUM_PADDING);
170
+ if (!this.frustum.intersectsBox(this.worldBounds)) continue;
171
+ }
172
+ this.visibleIndices[this.visibleSlots++] = index;
173
+ }
174
+ // A substrate covering an on-screen point necessarily intersects this
175
+ // conservative complete-card frustum list. Off-screen casters stay in the
176
+ // separate shadow list and do not need camera-occlusion volumes.
177
+ this.occlusion?.update(camera, this.matrices, this.visibleIndices, this.visibleSlots, pixelHeight);
178
+ for (const lod of this.detailLODs) {
179
+ lod.highCount = lod.lowCount = 0;
180
+ for (let index = 0; index < this.visibleSlots; index++) {
181
+ const logical = this.visibleIndices[index];
182
+ this.lodPoint.set(0, 1.85, 0).applyMatrix4(this.matrices[logical]).applyMatrix4(camera.matrixWorldInverse);
183
+ const depth = camera instanceof THREE.PerspectiveCamera ? Math.max(0.01, -this.lodPoint.z - 3) : 1;
184
+ const pixels = pixelHeight > 0 ? lod.featureSize * Math.abs(camera.projectionMatrix.elements[5]) * pixelHeight / (2 * depth) : Infinity;
185
+ const low = pixels < (lod.levels[logical] ? lod.leaveAbove : lod.enterBelow);
186
+ lod.levels[logical] = low ? 1 : 0;
187
+ if (low) lod.lowIndices[lod.lowCount++] = logical;
188
+ else lod.highIndices[lod.highCount++] = logical;
189
+ }
190
+ }
191
+ this.shadowSlots = 0;
192
+ this.cameraTriangles = this.shadowTriangles = this.shadowOnlyTriangles = this.shadowCulledTriangles = 0;
193
+ this.shadowSubmitted.fill(0);
194
+ for (const batch of this.batches) {
195
+ const { mesh } = batch;
196
+ // A renderer error can interrupt a shadow draw before onAfterShadow.
197
+ // Start every submission from the owned camera buffer in that case too.
198
+ if (batch.shadowBuffers) mesh.instanceMatrix = batch.shadowBuffers.camera;
199
+ batch.indices = this.visibleIndices;
200
+ batch.count = this.visibleSlots;
201
+ batch.shadowCount = 0;
202
+ const lod = this.detailLODs.find(item => item.high === mesh || item.low === mesh);
203
+ if (lod) {
204
+ batch.indices = mesh === lod.high ? lod.highIndices : lod.lowIndices;
205
+ batch.count = mesh === lod.high ? lod.highCount : lod.lowCount;
206
+ }
207
+ batch.occluded = batch.outside = 0;
208
+ if (this.occlusion && mesh.userData.occlusionCull) {
209
+ let kept = 0;
210
+ for (let i = 0; i < batch.count; i++) {
211
+ const logical = batch.indices[i];
212
+ this.worldBounds.copy(mesh.geometry.boundingBox!).applyMatrix4(this.matrices[logical]).expandByScalar(FRUSTUM_PADDING);
213
+ if (cull && !this.frustum.intersectsBox(this.worldBounds)) batch.outside++;
214
+ else if (this.occlusion.hidden(mesh.geometry.boundingBox!, this.matrices[logical], logical)) batch.occluded++;
215
+ else batch.filtered[kept++] = logical;
216
+ }
217
+ batch.indices = batch.filtered; batch.count = kept;
218
+ }
219
+ if (mesh.castShadow) this.submitted.fill(0);
220
+ for (let index = 0; index < batch.count; index++) {
221
+ const logical = batch.indices[index];
222
+ mesh.setMatrixAt(index, this.matrices[logical]);
223
+ if (mesh.castShadow) this.submitted[logical] = 1;
224
+ }
225
+ if (mesh.castShadow && batch.shadowBuffers) {
226
+ const attribute = batch.shadowBuffers.shadow;
227
+ // Use the union when there are multiple shadow lights. The main camera
228
+ // list is independent: a visible object need not enter a light's map.
229
+ for (let index = 0; index < this.activeSlots; index++) {
230
+ const logical = this.activeIndices[index];
231
+ if (cull && shadowFrusta?.length) {
232
+ this.worldBounds.copy(mesh.geometry.boundingBox!).applyMatrix4(this.matrices[logical])
233
+ .expandByScalar(FRUSTUM_PADDING);
234
+ if (!shadowFrusta.some(frustum => frustum.intersectsBox(this.worldBounds))) continue;
235
+ }
236
+ this.matrices[logical].toArray(attribute.array, batch.shadowCount++ * 16);
237
+ if (!this.submitted[logical]) this.shadowOnlyTriangles += batch.triangles;
238
+ if (!this.shadowSubmitted[logical]) { this.shadowSubmitted[logical] = 1; this.shadowSlots++; }
239
+ }
240
+ attribute.clearUpdateRanges();
241
+ if (batch.shadowCount > 0) {
242
+ attribute.addUpdateRange(0, batch.shadowCount * 16);
243
+ attribute.needsUpdate = true;
244
+ }
245
+ mesh.userData.shadowInstanceCount = batch.shadowCount;
246
+ this.shadowTriangles += batch.shadowCount * batch.triangles;
247
+ this.shadowCulledTriangles += (this.activeSlots - batch.shadowCount) * batch.triangles;
248
+ }
249
+ this.cameraTriangles += batch.count * batch.triangles;
250
+ mesh.count = batch.count;
251
+ mesh.instanceMatrix.clearUpdateRanges();
252
+ if (batch.count > 0) {
253
+ mesh.instanceMatrix.addUpdateRange(0, batch.count * 16);
254
+ mesh.instanceMatrix.needsUpdate = true;
255
+ }
256
+ // setMatrixAt does not invalidate these. Raycasting also uses the sphere
257
+ // when frustumCulled is false, so both must follow compaction and motion.
258
+ mesh.boundingBox = null;
259
+ mesh.boundingSphere = null;
260
+ }
261
+ }
262
+
263
+ logicalIndex(mesh: THREE.InstancedMesh, instanceId: number): number | undefined {
264
+ const batch = this.batchByMesh.get(mesh);
265
+ if (!batch || !Number.isInteger(instanceId) || instanceId < 0 || instanceId >= batch.count)
266
+ return undefined;
267
+ return batch.indices[instanceId];
268
+ }
269
+
270
+ /** Geometry inventory per pass; actual frame submissions can draw it again. */
271
+ triangleStats() {
272
+ return { cameraTrianglesPerPass: this.cameraTriangles,
273
+ shadowTrianglesPerPass: this.shadowTriangles,
274
+ shadowOnlyTrianglesPerPass: this.shadowOnlyTriangles,
275
+ shadowCulledTrianglesPerPass: this.shadowCulledTriangles };
276
+ }
277
+
278
+ getStats() {
279
+ return {
280
+ capacity: this.capacity,
281
+ activeSlots: this.activeSlots,
282
+ visibleSlots: this.visibleSlots,
283
+ culledSlots: this.activeSlots - this.visibleSlots,
284
+ shadowSlots: this.shadowSlots,
285
+ ...this.triangleStats(),
286
+ ...(this.detailLODs.length ? { lod: this.detailLODs.map(item => ({
287
+ name: item.high.name, high: this.batchByMesh.get(item.high)?.count ?? 0,
288
+ low: this.batchByMesh.get(item.low)?.count ?? 0,
289
+ enterBelowPixels: item.enterBelow, leaveAbovePixels: item.leaveAbove,
290
+ })) } : {}),
291
+ ...(this.occlusion ? { occlusion: {
292
+ testedParts: this.occlusion.tested, culledParts: this.occlusion.rejected,
293
+ outsideParts: this.batches.reduce((sum, batch) => sum + batch.outside, 0),
294
+ savedTrianglesPerPass: this.batches.reduce((sum, batch) => sum + (batch.occluded + batch.outside)
295
+ * (batch.mesh.geometry.index?.count ?? batch.mesh.geometry.attributes.position.count) / 3, 0),
296
+ parts: this.batches.filter(batch => batch.mesh.userData.occlusionCull).map(batch => ({
297
+ name: batch.mesh.name, visible: batch.count, culled: batch.occluded, outside: batch.outside,
298
+ trianglesPerInstance: batch.triangles, submittedTrianglesPerPass: batch.count * batch.triangles,
299
+ })),
300
+ } } : {}),
301
+ batches: this.batches.map(({ mesh, count, shadowCount }) => ({
302
+ name: mesh.name,
303
+ castShadow: mesh.castShadow,
304
+ count,
305
+ ...(mesh.castShadow ? { shadowCount } : {}),
306
+ })),
307
+ };
308
+ }
309
+ }
@@ -0,0 +1,4 @@
1
+ /** Host integration only: preserve upstream asset paths under the plugin base. */
2
+ let base = '/';
3
+ export const setAssetBase = (value: string) => { base = `${value.replace(/\/$/, '')}/`; };
4
+ export const assetUrl = (path: string) => `${base}${path.replace(/^\//, '')}`;
@@ -0,0 +1,12 @@
1
+ // One shared mark for the interface and the printed archive label.
2
+ const paths = `<path d="M156 75C127 48 103 15 70 15C37 15 15 39 15 70S38 128 70 128C103 128 127 96 176 52M155 75C182 99 208 128 240 128C273 128 295 105 295 73S273 15 240 15C221 15 207 23 192 38" fill="none" stroke="currentColor" stroke-width="26"/><path d="M44 70h50M69 45v50M219 70h44" fill="none" stroke="currentColor" stroke-width="15"/>`;
3
+ export const labelMarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 310 145" color="#171713">${paths}</svg>`;
4
+ export const logo = `<svg viewBox="0 0 310 185" aria-label="Rhine Lab" role="img">${paths}<text x="165" y="174" text-anchor="middle" font-family="MiSans,sans-serif" font-size="16" font-weight="700" letter-spacing="22">RHINE·LAB</text></svg>`;
5
+ // The same Bezier contour, continuous for the opening's moving draw/erase ends.
6
+ // Its small printed gap is animated with stroke dashes, not baked into the path.
7
+ export const bootMarkContour =
8
+ "M295 73C295 41 273 15 240 15C221 15 207 23 192 38C186 43 181 47 176 52C127 96 103 128 70 128C38 128 15 101 15 70C15 39 37 15 70 15C103 15 127 48 156 75C182 99 208 128 240 128C273 128 295 105 295 73Z";
9
+
10
+ // Optical spacing for this fixed wordmark, measured from the reference glyphs.
11
+ const analysisPositions = [2, 28, 55, 81, 103, 129, 154, 166];
12
+ export const brandHeading = `<h1>RHINE LAB</h1><div>SYNTHESIZE INFORMATION</div><p><span class="brand-analysis" role="img" aria-label="ANALYSIS">${[..."ANALYSIS"].map((letter, i) => `<span aria-hidden="true" style="left:${analysisPositions[i]}px">${letter}</span>`).join("")}</span> <b>OS</b></p>`;
@@ -0,0 +1,81 @@
1
+ import * as THREE from "three";
2
+
3
+ /** Independent screws allow the buried lower screw to be omitted entirely. */
4
+ export function splitCassetteFasteners(source: THREE.BufferGeometry) {
5
+ if (!source.index) return undefined;
6
+ const positions = source.getAttribute("position"), top: number[] = [], bottom: number[] = [];
7
+ for (let offset = 0; offset < source.index.count; offset += 3) {
8
+ const indices = [source.index.getX(offset), source.index.getX(offset + 1), source.index.getX(offset + 2)];
9
+ const upper = indices.every(index => positions.getY(index) > 1.85);
10
+ const lower = indices.every(index => positions.getY(index) < 1.85);
11
+ if (!upper && !lower) return undefined;
12
+ (upper ? top : bottom).push(...indices);
13
+ }
14
+ if (!top.length || !bottom.length) return undefined;
15
+ return [top, bottom].map(indices => {
16
+ // Compact attributes as well as indices: BufferGeometry computes bounds
17
+ // from all vertices, including unreferenced ones.
18
+ const geometry = new THREE.BufferGeometry(), remap = new Map<number, number>();
19
+ const vertices: number[] = [], compact = indices.map(index => {
20
+ if (!remap.has(index)) { remap.set(index, vertices.length); vertices.push(index); }
21
+ return remap.get(index)!;
22
+ });
23
+ for (const [name, attribute] of Object.entries(source.attributes)) {
24
+ const values = new Float32Array(vertices.length * attribute.itemSize);
25
+ for (let i = 0; i < vertices.length; i++) for (let c = 0; c < attribute.itemSize; c++)
26
+ values[i * attribute.itemSize + c] = attribute.getComponent(vertices[i], c);
27
+ geometry.setAttribute(name, new THREE.BufferAttribute(values, attribute.itemSize));
28
+ }
29
+ geometry.setIndex(compact); geometry.computeBoundingBox(); geometry.computeBoundingSphere();
30
+ return geometry;
31
+ });
32
+ }
33
+
34
+ /** Reduce outer-ring tessellation while keeping the screw recess and relief exact. */
35
+ export function createCassetteLOD(surface: string, source: THREE.BufferGeometry) {
36
+ if (surface !== "Titanium_Fasteners" || !source.index) return undefined;
37
+ const position = source.getAttribute("position");
38
+ const originalTriangles = source.index.count / 3;
39
+ if (originalTriangles < 500) return undefined;
40
+ const bounds = [new THREE.Box3(), new THREE.Box3()];
41
+ const point = new THREE.Vector3();
42
+ for (let i = 0; i < position.count; i++) {
43
+ point.fromBufferAttribute(position, i);
44
+ bounds[point.x < 0 ? 0 : 1].expandByPoint(point);
45
+ }
46
+ // These are the two authored circular fasteners. A changed asset keeps its
47
+ // original mesh until its replacement can be validated independently.
48
+ if (bounds.some(box => box.isEmpty() || Math.abs(box.max.x - box.min.x - 0.12) > 0.00001
49
+ || Math.abs(box.max.y - box.min.y - 0.12) > 0.00001)) return undefined;
50
+ const centers = bounds.map(box => box.getCenter(new THREE.Vector3()));
51
+ const geometry = source.clone(), output = geometry.getAttribute("position"), normals = geometry.getAttribute("normal");
52
+ const step = Math.PI * 2 / 16;
53
+ for (let i = 0; i < position.count; i++) {
54
+ const center = centers[position.getX(i) < 0 ? 0 : 1];
55
+ const x = position.getX(i) - center.x, y = position.getY(i) - center.y;
56
+ const radius = Math.hypot(x, y);
57
+ if (radius <= 0.05) continue; // Recess, slots and all inner engraving stay exact.
58
+ const angle = Math.atan2(y, x), target = Math.round(angle / step) * step;
59
+ const snappedRadius = Math.round(radius * 100000) / 100000;
60
+ output.setXY(i, center.x + Math.cos(target) * snappedRadius, center.y + Math.sin(target) * snappedRadius);
61
+ if (normals) {
62
+ const nx = normals.getX(i), ny = normals.getY(i), delta = target - angle;
63
+ normals.setXY(i, nx * Math.cos(delta) - ny * Math.sin(delta), nx * Math.sin(delta) + ny * Math.cos(delta));
64
+ }
65
+ }
66
+ const indices: number[] = [], a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
67
+ for (let i = 0; i < source.index.count; i += 3) {
68
+ const ia = source.index.getX(i), ib = source.index.getX(i + 1), ic = source.index.getX(i + 2);
69
+ a.fromBufferAttribute(output, ia); b.fromBufferAttribute(output, ib); c.fromBufferAttribute(output, ic);
70
+ if (b.sub(a).cross(c.sub(a)).lengthSq() > 1e-20) indices.push(ia, ib, ic);
71
+ }
72
+ geometry.setIndex(indices); geometry.computeBoundingBox(); geometry.computeBoundingSphere();
73
+ if (source.boundingBox === null) source.computeBoundingBox();
74
+ const triangles = indices.length / 3;
75
+ if (triangles >= originalTriangles
76
+ || geometry.boundingBox!.min.distanceTo(source.boundingBox!.min) > 0.000001
77
+ || geometry.boundingBox!.max.distanceTo(source.boundingBox!.max) > 0.000001) {
78
+ geometry.dispose(); return undefined;
79
+ }
80
+ return { geometry, featureSize: 0.12, enterBelow: 18, leaveAbove: 22, originalTriangles, triangles };
81
+ }
@@ -0,0 +1,21 @@
1
+ import { ARCHIVE_LANES } from '../catalogue.ts';
2
+
3
+ // Reusable positions in the authored animation, never document records.
4
+ // Source identities, labels and per-category lengths live in the adapter.
5
+ export const archiveColumns = [...ARCHIVE_LANES];
6
+ export const archiveSlots = Array.from({ length: 40 }, (_, index) => ({
7
+ lane: Math.floor(index / 8),
8
+ row: 12 + index % 8,
9
+ }));
10
+
11
+ export function columnFiles(lane: number): number[] {
12
+ return Array.from({ length: 8 }, (_, index) => lane * 8 + index);
13
+ }
14
+ export function fileLocation(index: number) {
15
+ const { lane, row } = archiveSlots[((index % 40) + 40) % 40];
16
+ return { lane, row, slot: lane * 32 + row };
17
+ }
18
+ export function fileAtSlot(slot: number) {
19
+ const lane = Math.max(0, Math.min(4, Math.floor(slot / 32)));
20
+ return lane * 8 + Math.max(0, Math.min(7, slot % 32 - 12));
21
+ }