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.
- package/GAME_ASSETS.md +26 -0
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +423 -0
- package/THIRD_PARTY_NOTICES.md +61 -0
- package/bin/bake-rhine-details.mjs +34 -0
- package/bin/install.js +273 -0
- package/bin/pack-map-assets.mjs +110 -0
- package/bin/preview-rhine.mjs +71 -0
- package/bin/rhine-detail-baker.ts +84 -0
- package/contracts/cloud_tools_v1.schema.json +243 -0
- package/contracts/corpus_i18n_v1.schema.json +179 -0
- package/contracts/corpus_tools_v1.schema.json +1801 -0
- package/cordis.patch.yml +32 -0
- package/docs/endfield-official-i18n.md +103 -0
- package/lib/client.js +3912 -0
- package/lib/endfield-map/map.js.br +0 -0
- package/lib/endfield-map/map.js.gz +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
- package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
- package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
- package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
- package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
- package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
- package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
- package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
- package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
- package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
- package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
- package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
- package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
- package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
- package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
- package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
- package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
- package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
- package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
- package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
- package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
- package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
- package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
- package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
- package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
- package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
- package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
- package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
- package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
- package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
- package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
- package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
- package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
- package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
- package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
- package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
- package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
- package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
- package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
- package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
- package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
- package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
- package/lib/rhine/assets/archive-assembly.glb +0 -0
- package/lib/rhine/assets/archive-cassette.glb +0 -0
- package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-license.pdf +0 -0
- package/lib/rhine/fonts/NOTICE.txt +6 -0
- package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
- package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
- package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
- package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
- package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
- package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
- package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
- package/lib/rhine/licenses/three-MIT.txt +21 -0
- package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
- package/lib/rhine/rhine.css +1 -0
- package/lib/rhine/rhine.js +5106 -0
- package/lib/skins/common.css +31 -0
- package/lib/skins/endfield-aic.css +141 -0
- package/lib/skins/prts-agent.css +136 -0
- package/lib/skins/rhine-lab.css +70 -0
- package/package.json +112 -0
- package/presets/definition.js +26 -0
- package/presets/prts/agent.cordis.yml +25 -0
- package/presets/prts/preset.yml +3 -0
- package/presets/register.js +86 -0
- package/resources/entity-routing.json +21 -0
- package/skills/prts-investigation/SKILL.md +121 -0
- package/skills/prts-retrieval/SKILL.md +35 -0
- package/skills/prts-retrieval/references/cloud-tools.md +13 -0
- package/skills/prts-retrieval/references/diagnostics.md +15 -0
- package/skills/prts-retrieval/references/local-tools.md +24 -0
- package/skills/prts-retrieval/references/module-arknights.md +23 -0
- package/skills/prts-retrieval/references/module-dual.md +15 -0
- package/skills/prts-retrieval/references/module-endfield.md +21 -0
- package/skills/prts-retrieval/references/retrieval-process.md +44 -0
- package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
- package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
- package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
- package/skills/prts-retrieval/references/tools-runtime.md +48 -0
- package/skills/prts-retrieval/references/wiki-schema.md +38 -0
- package/src/archive-presentation.js +287 -0
- package/src/cloud-projection.js +124 -0
- package/src/cloud.js +498 -0
- package/src/entity-recognizer.js +311 -0
- package/src/entity-routing.js +82 -0
- package/src/evidence-state.js +332 -0
- package/src/i18n.js +365 -0
- package/src/index.js +1261 -0
- package/src/installer.js +1362 -0
- package/src/investigation-review.js +87 -0
- package/src/investigation-store.js +579 -0
- package/src/investigation-tools.js +113 -0
- package/src/localization-format.js +23 -0
- package/src/manual-search.js +103 -0
- package/src/read.js +1097 -0
- package/src/release-compatibility.js +73 -0
- package/src/release-watch.js +40 -0
- package/src/search-projection.js +139 -0
- package/src/search.js +1522 -0
- package/src/skill.js +98 -0
- package/src/source-map.js +348 -0
- package/src/state.js +314 -0
- package/src/store.js +1569 -0
- package/src/timeline.js +386 -0
- package/src/ui.js +1353 -0
- package/src/wiki.js +111 -0
- package/tsconfig.rhine.json +15 -0
- package/ui/rhine/archive-navigation-limit.ts +31 -0
- package/ui/rhine/archive-rack.ts +83 -0
- package/ui/rhine/assets/evidence-observatory.webp +0 -0
- package/ui/rhine/board-plane-transform.ts +24 -0
- package/ui/rhine/catalogue.ts +72 -0
- package/ui/rhine/deferred-preparation.ts +146 -0
- package/ui/rhine/evidence-board-controls.css +22 -0
- package/ui/rhine/evidence-board-history.ts +73 -0
- package/ui/rhine/evidence-board-model.ts +189 -0
- package/ui/rhine/evidence-board-panel.ts +762 -0
- package/ui/rhine/evidence-board-scene.ts +548 -0
- package/ui/rhine/evidence-board.css +191 -0
- package/ui/rhine/evidence-card-art.ts +470 -0
- package/ui/rhine/evidence-card-style.ts +17 -0
- package/ui/rhine/evidence-inbox-scene.ts +43 -0
- package/ui/rhine/evidence-inbox.css +29 -0
- package/ui/rhine/evidence-inbox.ts +104 -0
- package/ui/rhine/index.ts +12 -0
- package/ui/rhine/interaction.css +20 -0
- package/ui/rhine/investigation-board-plane.ts +5 -0
- package/ui/rhine/investigation-board.css +34 -0
- package/ui/rhine/investigation-board.ts +348 -0
- package/ui/rhine/investigation-edits.ts +46 -0
- package/ui/rhine/investigation-layout.ts +29 -0
- package/ui/rhine/label-prefetch.ts +34 -0
- package/ui/rhine/looping-title.ts +40 -0
- package/ui/rhine/manual-search.css +41 -0
- package/ui/rhine/manual-search.ts +89 -0
- package/ui/rhine/original/appearance.ts +180 -0
- package/ui/rhine/original/archive-label.ts +84 -0
- package/ui/rhine/original/archive-lighting.ts +39 -0
- package/ui/rhine/original/archive-loop.ts +82 -0
- package/ui/rhine/original/archive-refill.ts +64 -0
- package/ui/rhine/original/array-detail-data.ts +2 -0
- package/ui/rhine/original/array-detail.ts +41 -0
- package/ui/rhine/original/array-interior.ts +411 -0
- package/ui/rhine/original/array-occlusion.ts +166 -0
- package/ui/rhine/original/array-shell.ts +38 -0
- package/ui/rhine/original/array-visibility.ts +309 -0
- package/ui/rhine/original/asset-url.ts +4 -0
- package/ui/rhine/original/brand.ts +12 -0
- package/ui/rhine/original/cassette-lod.ts +81 -0
- package/ui/rhine/original/data.ts +21 -0
- package/ui/rhine/original/decryption.ts +185 -0
- package/ui/rhine/original/detail-signature.ts +18 -0
- package/ui/rhine/original/glass-reveal.ts +45 -0
- package/ui/rhine/original/internal-optics.ts +70 -0
- package/ui/rhine/original/label-renderer.ts +119 -0
- package/ui/rhine/original/label-textures.ts +117 -0
- package/ui/rhine/original/model-viewer.ts +658 -0
- package/ui/rhine/original/motion.ts +116 -0
- package/ui/rhine/original/opaque-backfaces.ts +64 -0
- package/ui/rhine/original/program-preparation.ts +111 -0
- package/ui/rhine/original/quality-renderer.ts +97 -0
- package/ui/rhine/original/quality-settings.ts +111 -0
- package/ui/rhine/original/render-quality.ts +163 -0
- package/ui/rhine/original/scene.ts +2051 -0
- package/ui/rhine/original/shelf-interior.ts +142 -0
- package/ui/rhine/original/ui-transitions.ts +111 -0
- package/ui/rhine/original/viewer-camera.ts +92 -0
- package/ui/rhine/original/viewport-layout.ts +41 -0
- package/ui/rhine/original-ui.css +2334 -0
- package/ui/rhine/readability.css +46 -0
- package/ui/rhine/reading-object.ts +96 -0
- package/ui/rhine/reading-origin.ts +28 -0
- package/ui/rhine/reading-room.css +55 -0
- package/ui/rhine/report-markdown.ts +98 -0
- package/ui/rhine/scene-edge-navigation.ts +84 -0
- package/ui/rhine/scene.ts +1726 -0
- package/ui/rhine/shelf-layout.ts +38 -0
- package/ui/rhine/snapshot-update-gate.ts +22 -0
- package/ui/rhine/source-reading.ts +15 -0
- package/ui/rhine/source-selection.css +164 -0
- package/ui/rhine/temporary-performance-observers.ts +163 -0
- package/ui/rhine/temporary-performance-panel.ts +124 -0
- package/ui/rhine/temporary-performance.css +19 -0
- package/ui/rhine/temporary-performance.ts +552 -0
- package/ui/rhine/temporary-render-diagnostics.ts +105 -0
- package/ui/rhine/tool-activity.ts +69 -0
- package/ui/rhine/types.ts +219 -0
- package/ui/rhine/visible-scene-hit.ts +13 -0
- package/ui/rhine/workbench.css +818 -0
- package/ui/rhine/workbench.ts +2245 -0
- package/ui/rhine/workspace-travel.ts +32 -0
- package/vite.rhine.config.ts +19 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { clampEvidencePosition, evidenceCardLayout, EVIDENCE_CARD_CAPACITY, EVIDENCE_WRITING_BOUNDS,
|
|
3
|
+
isPreviewCard, type EvidenceCard, type EvidenceTemplate } from './evidence-board-model';
|
|
4
|
+
import { drawEvidenceCard } from './evidence-card-art';
|
|
5
|
+
import { ARCHIVE_LABEL_LAYER } from './original/label-renderer';
|
|
6
|
+
import observatoryUrl from './assets/evidence-observatory.webp';
|
|
7
|
+
|
|
8
|
+
/** Local XY whiteboard, front +Z. Raised panel; feet meet the shared floor. */
|
|
9
|
+
const WIDTH = 16.2, HEIGHT = 9.4, SURFACE_Z = 0.163, PAPER_Z = 0.235, PRINT_Z = 0.042;
|
|
10
|
+
const INK = '#252724';
|
|
11
|
+
const PAPER_COLORS = { note: '#eee8dd', source: '#fffdfa', question: '#e4d5c1' } as const;
|
|
12
|
+
type CardVisual = {
|
|
13
|
+
card: EvidenceCard; index: number; layout: ReturnType<typeof evidenceCardLayout>;
|
|
14
|
+
group: THREE.Group; paper: THREE.Mesh;
|
|
15
|
+
print: THREE.Mesh<THREE.ShapeGeometry, THREE.MeshBasicMaterial>;
|
|
16
|
+
printBacking: THREE.Mesh<THREE.ShapeGeometry, THREE.MeshBasicMaterial>;
|
|
17
|
+
highlight: THREE.LineSegments; resizeHandle: THREE.Group; shadow: THREE.Mesh; backing: THREE.Mesh; tape: THREE.Mesh;
|
|
18
|
+
pin: THREE.Group; texture: THREE.CanvasTexture;
|
|
19
|
+
canvas: HTMLCanvasElement; signature: string; arrival?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Pinned papers and explicit strings in the existing scene, without a render loop. */
|
|
23
|
+
export function createEvidenceBoard(createPrintMaterial: (texture: THREE.Texture) => THREE.MeshBasicMaterial, options: { interactiveHeading?: boolean } = {}) {
|
|
24
|
+
const group = new THREE.Group(); group.name = 'Rhine_Evidence_Board';
|
|
25
|
+
const geometries = new Set<THREE.BufferGeometry>(), materials = new Set<THREE.Material>(), textures = new Set<THREE.Texture>();
|
|
26
|
+
const cards = new Map<string, CardVisual>();
|
|
27
|
+
const strings = new THREE.Group(); strings.name = 'Evidence_Red_Strings';
|
|
28
|
+
const toolMeshes: THREE.Object3D[] = [], toolHits: THREE.Intersection[] = [];
|
|
29
|
+
const toolAnchors = { corner: new THREE.Vector3(7.15, 4.055, 0.22), chalk: new THREE.Vector3(-3.8, -4.20, 0.48) };
|
|
30
|
+
let selectedId: string | null = null;
|
|
31
|
+
let resizeEnabled = true;
|
|
32
|
+
let disposed = false, receivedCards = 0, cardTextureUpdates = 0, linkRebuilds = 0, linkSignature = '';
|
|
33
|
+
const ownGeometry = <T extends THREE.BufferGeometry>(value: T): T => { geometries.add(value); return value; };
|
|
34
|
+
const ownMaterial = <T extends THREE.Material>(value: T): T => { materials.add(value); return value; };
|
|
35
|
+
const standard = (color: string, roughness = 0.76, metalness = 0) => ownMaterial(
|
|
36
|
+
new THREE.MeshStandardMaterial({ color, roughness, metalness, fog: false }),
|
|
37
|
+
);
|
|
38
|
+
const metal = standard('#c4baae', 0.36, 0.45), white = standard('#eae5dc', 0.86);
|
|
39
|
+
const shell = standard('#f0e7df', 0.43, 0.07);
|
|
40
|
+
const brass = standard('#b9a796', 0.43, 0.45), charcoal = standard('#8d8880', 0.76);
|
|
41
|
+
const redPin = standard('#a55e51', 0.38, 0.12), redString = standard('#97594f', 0.92);
|
|
42
|
+
const paperMaterials = { note: standard(PAPER_COLORS.note, 0.97),
|
|
43
|
+
source: standard(PAPER_COLORS.source, 0.97), question: standard(PAPER_COLORS.question, 0.97) };
|
|
44
|
+
const cube = ownGeometry(new THREE.BoxGeometry(1, 1, 1));
|
|
45
|
+
const sheetPlane = ownGeometry(new THREE.PlaneGeometry(1, 1));
|
|
46
|
+
const pinHead = ownGeometry(new THREE.SphereGeometry(0.077, 16, 10));
|
|
47
|
+
const pinStem = ownGeometry(new THREE.CylinderGeometry(0.014, 0.014, 0.11, 8));
|
|
48
|
+
const pinWasher = ownGeometry(new THREE.CylinderGeometry(0.047, 0.047, 0.022, 12));
|
|
49
|
+
const box = (parent: THREE.Group, size: [number, number, number],
|
|
50
|
+
position: [number, number, number], material: THREE.Material, shadow = false) => {
|
|
51
|
+
const mesh = new THREE.Mesh(cube, material); mesh.scale.set(...size); mesh.position.set(...position);
|
|
52
|
+
mesh.castShadow = shadow; mesh.receiveShadow = true; parent.add(mesh); return mesh;
|
|
53
|
+
};
|
|
54
|
+
const canvas = (width: number, height: number) => {
|
|
55
|
+
const value = document.createElement('canvas'); value.width = width; value.height = height;
|
|
56
|
+
const context = value.getContext('2d');
|
|
57
|
+
if (!context) throw new Error('证据板文字画布不可用。');
|
|
58
|
+
context.textBaseline = 'top'; return { canvas: value, context };
|
|
59
|
+
};
|
|
60
|
+
const textureFor = (sheet: HTMLCanvasElement) => {
|
|
61
|
+
const texture = new THREE.CanvasTexture(sheet); texture.colorSpace = THREE.SRGBColorSpace;
|
|
62
|
+
texture.anisotropy = 4; textures.add(texture); return texture;
|
|
63
|
+
};
|
|
64
|
+
// Reuse the existing sharp label pass. Paper solids still supply scene depth,
|
|
65
|
+
// so pins, strings and overlapping sheets correctly occlude the print.
|
|
66
|
+
const printed = (texture: THREE.CanvasTexture) => ownMaterial(createPrintMaterial(texture));
|
|
67
|
+
const font = (context: CanvasRenderingContext2D, size: number, weight = 400) => {
|
|
68
|
+
context.font = `${weight} ${size}px MiSans, sans-serif`;
|
|
69
|
+
};
|
|
70
|
+
// A purpose-built research fixture: enamel, recessed trim and machined corner blocks.
|
|
71
|
+
const frameShape = new THREE.Shape();
|
|
72
|
+
frameShape.moveTo(-8.1, -4.45); frameShape.lineTo(-7.86, -4.7);
|
|
73
|
+
frameShape.lineTo(7.86, -4.7); frameShape.lineTo(8.1, -4.45);
|
|
74
|
+
frameShape.lineTo(8.1, 4.45); frameShape.lineTo(7.86, 4.7);
|
|
75
|
+
frameShape.lineTo(-7.86, 4.7); frameShape.lineTo(-8.1, 4.45); frameShape.closePath();
|
|
76
|
+
const chassis = new THREE.Mesh(ownGeometry(new THREE.ExtrudeGeometry(frameShape,
|
|
77
|
+
{ depth: 0.18, bevelEnabled: true, bevelSegments: 1, bevelSize: 0.035, bevelThickness: 0.035 })), shell);
|
|
78
|
+
chassis.position.z = -0.23; chassis.castShadow = true; group.add(chassis);
|
|
79
|
+
box(group, [15.99, 9.13, 0.10], [0, 0, 0.077], metal);
|
|
80
|
+
box(group, [15.76, 8.87, 0.053], [0, -0.03, 0.137], white);
|
|
81
|
+
const corner = standard('#e2dad4', 0.39, 0.45);
|
|
82
|
+
for (const x of [-7.87, 7.87]) for (const y of [-4.38, 4.38]) {
|
|
83
|
+
box(group, [0.31, 0.41, 0.1], [x, y, 0.145], corner);
|
|
84
|
+
const screw = new THREE.Mesh(pinWasher, charcoal); screw.rotation.x = Math.PI / 2;
|
|
85
|
+
screw.position.set(x, y, 0.204); group.add(screw);
|
|
86
|
+
}
|
|
87
|
+
// The same warm printed label and champagne hardware as the original cassettes.
|
|
88
|
+
const plate = canvas(1536, 108), context = plate.context;
|
|
89
|
+
context.fillStyle = '#e4ddd3'; context.fillRect(0, 0, 1536, 108);
|
|
90
|
+
if (!options.interactiveHeading) {
|
|
91
|
+
context.fillStyle = '#35312c'; font(context, 37, 600); context.fillText('RHINE LAB', 35, 20);
|
|
92
|
+
font(context, 20); context.fillStyle = '#817567'; context.fillText('RESEARCH / 调查与证据', 37, 70);
|
|
93
|
+
context.textAlign = 'right'; font(context, 20, 600); context.fillText('EVIDENCE / LIVE', 1500, 28);
|
|
94
|
+
font(context, 15); context.fillText('RESEARCH SYSTEMS · PRTS', 1500, 72);
|
|
95
|
+
}
|
|
96
|
+
// The interactive heading is projected onto this physical nameplate by the workbench.
|
|
97
|
+
const plateMesh = new THREE.Mesh(ownGeometry(new THREE.PlaneGeometry(options.interactiveHeading ? 10.8 : 7.9, 0.556)), printed(textureFor(plate.canvas)));
|
|
98
|
+
plateMesh.layers.set(ARCHIVE_LABEL_LAYER);
|
|
99
|
+
plateMesh.position.set(options.interactiveHeading ? -2.05 : -3.5, 4.055, 0.18); group.add(plateMesh);
|
|
100
|
+
// Fine measurement ticks occupy the rim, never turn the writing surface into a table.
|
|
101
|
+
const ticks: number[] = [];
|
|
102
|
+
for (let i = 0; i < 76; i++) {
|
|
103
|
+
const x = -7.5 + i * 0.2; ticks.push(x, -4.15, 0.175, x, -4.15 + (i % 5 === 0 ? 0.10 : 0.04), 0.175);
|
|
104
|
+
}
|
|
105
|
+
const tickGeometry = ownGeometry(new THREE.BufferGeometry());
|
|
106
|
+
tickGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ticks, 3));
|
|
107
|
+
group.add(new THREE.LineSegments(tickGeometry, ownMaterial(new THREE.LineBasicMaterial({ color: '#9d9185', transparent: true, opacity: 0.42, fog: false }))));
|
|
108
|
+
for (const x of [-5.7, 5.7]) {
|
|
109
|
+
box(group, [0.42, 4.735, 0.46], [x, -6.8675, -0.26], shell, true);
|
|
110
|
+
box(group, [0.20, 4.46, 0.06], [x, -6.88, 0.015], metal);
|
|
111
|
+
box(group, [1.75, 0.15, 1.6], [x, -9.31, -0.22], metal, true);
|
|
112
|
+
box(group, [1.55, 0.05, 1.42], [x, -9.405, -0.22], charcoal);
|
|
113
|
+
}
|
|
114
|
+
box(group, [15.34, 0.10, 0.62], [0, -4.39, 0.43], shell, true);
|
|
115
|
+
box(group, [15.34, 0.10, 0.055], [0, -4.37, 0.75], metal);
|
|
116
|
+
box(group, [1.0, 0.12, 0.36], [5.7, -4.28, 0.46], charcoal, true);
|
|
117
|
+
box(group, [0.89, 0.10, 0.34], [5.7, -4.17, 0.46], corner);
|
|
118
|
+
// A small paper carton rests on the pen ledge. Short chalk sticks lie across its
|
|
119
|
+
// depth, rather than reading as full-width marker pens. Every solid is pickable.
|
|
120
|
+
const chalkBox = new THREE.Group(); chalkBox.name = 'Evidence_Chalk_Box';
|
|
121
|
+
chalkBox.position.copy(toolAnchors.chalk); group.add(chalkBox);
|
|
122
|
+
const carton = standard('#e5dccd', 0.98), cartonInside = standard('#d0c3ae', 1);
|
|
123
|
+
const chalkWhite = standard('#f5f0e8', 1), chalkRed = standard('#b27969', 1);
|
|
124
|
+
toolMeshes.push(box(chalkBox, [1.02, 0.022, 0.48], [0, -0.127, 0], cartonInside, true));
|
|
125
|
+
for (const x of [-0.497, 0.497]) toolMeshes.push(box(chalkBox, [0.026, 0.11, 0.48], [x, -0.072, 0], carton, true));
|
|
126
|
+
toolMeshes.push(box(chalkBox, [0.97, 0.15, 0.026], [0, -0.05, -0.227], carton, true));
|
|
127
|
+
toolMeshes.push(box(chalkBox, [0.97, 0.075, 0.026], [0, -0.087, 0.227], carton, true));
|
|
128
|
+
const chalkGeometry = ownGeometry(new THREE.CylinderGeometry(0.047, 0.049, 1, 10));
|
|
129
|
+
const chalkPieces = [
|
|
130
|
+
{ x: -0.285, z: 0.012, length: 0.42, angle: -0.025, red: false },
|
|
131
|
+
{ x: -0.142, z: 0.006, length: 0.45, angle: 0.025, red: false },
|
|
132
|
+
{ x: 0, z: 0.024, length: 0.40, angle: -0.012, red: true },
|
|
133
|
+
{ x: 0.144, z: -0.003, length: 0.43, angle: 0.035, red: false },
|
|
134
|
+
{ x: 0.286, z: 0.015, length: 0.41, angle: -0.02, red: false },
|
|
135
|
+
];
|
|
136
|
+
for (const piece of chalkPieces) {
|
|
137
|
+
const chalk = new THREE.Mesh(chalkGeometry, piece.red ? chalkRed : chalkWhite);
|
|
138
|
+
chalk.rotation.set(Math.PI / 2, 0, piece.angle); chalk.scale.y = piece.length;
|
|
139
|
+
chalk.position.set(piece.x, -0.06, piece.z);
|
|
140
|
+
chalk.castShadow = true; chalkBox.add(chalk); toolMeshes.push(chalk);
|
|
141
|
+
}
|
|
142
|
+
const chalkLid = new THREE.Group(); chalkLid.name = 'Evidence_Chalk_Lid';
|
|
143
|
+
chalkLid.position.set(0, -0.05, -0.231); chalkLid.rotation.x = 0.4; chalkBox.add(chalkLid);
|
|
144
|
+
toolMeshes.push(box(chalkLid, [1.02, 0.42, 0.018], [0, 0.21, 0], carton, true));
|
|
145
|
+
const toolsLabel = canvas(512, 210);
|
|
146
|
+
toolsLabel.context.fillStyle = '#e5dccd'; toolsLabel.context.fillRect(0, 0, 512, 210);
|
|
147
|
+
toolsLabel.context.fillStyle = '#706657'; font(toolsLabel.context, 58, 600);
|
|
148
|
+
toolsLabel.context.textAlign = 'center'; toolsLabel.context.fillText('RHINE', 256, 40);
|
|
149
|
+
font(toolsLabel.context, 27, 500); toolsLabel.context.fillText('CHALK / 05', 256, 119);
|
|
150
|
+
toolsLabel.context.fillStyle = '#aa7869'; toolsLabel.context.fillRect(211, 169, 90, 3);
|
|
151
|
+
const toolsPrint = new THREE.Mesh(ownGeometry(new THREE.PlaneGeometry(0.96, 0.394)), printed(textureFor(toolsLabel.canvas)));
|
|
152
|
+
toolsPrint.name = 'Evidence_Chalk_Label'; toolsPrint.layers.set(ARCHIVE_LABEL_LAYER);
|
|
153
|
+
toolsPrint.position.set(0, 0.21, 0.010); chalkLid.add(toolsPrint);
|
|
154
|
+
const shadowCanvas = canvas(128, 128), shadowContext = shadowCanvas.context;
|
|
155
|
+
for (let inset = 0; inset < 12; inset++) {
|
|
156
|
+
shadowContext.fillStyle = `rgba(47,41,34,${0.013 + inset * 0.001})`;
|
|
157
|
+
shadowContext.fillRect(inset + 1, inset + 1, 126 - inset * 2, 126 - inset * 2);
|
|
158
|
+
}
|
|
159
|
+
const shadowMaterial = ownMaterial(new THREE.MeshBasicMaterial({ map: textureFor(shadowCanvas.canvas),
|
|
160
|
+
transparent: true, depthWrite: false, toneMapped: false, fog: false }));
|
|
161
|
+
const tapeCanvas = canvas(256, 64), tapeContext = tapeCanvas.context;
|
|
162
|
+
tapeContext.fillStyle = '#dfd4bf'; tapeContext.fillRect(0, 0, 256, 64);
|
|
163
|
+
tapeContext.strokeStyle = '#c7bba8'; tapeContext.lineWidth = 1;
|
|
164
|
+
for (let i = 5; i < 256; i += 7) { tapeContext.beginPath(); tapeContext.moveTo(i, 0); tapeContext.lineTo(i + 4, 64); tapeContext.stroke(); }
|
|
165
|
+
const tapeMaterial = printed(textureFor(tapeCanvas.canvas)); tapeMaterial.opacity = 0.74;
|
|
166
|
+
const cornerVertices: number[] = [];
|
|
167
|
+
for (const x of [-0.515, 0.515]) for (const y of [-0.515, 0.515]) {
|
|
168
|
+
cornerVertices.push(x, y - Math.sign(y) * 0.06, 0, x, y, 0, x, y, 0, x - Math.sign(x) * 0.055, y, 0);
|
|
169
|
+
}
|
|
170
|
+
const selectionGeometry = ownGeometry(new THREE.BufferGeometry());
|
|
171
|
+
selectionGeometry.setAttribute('position', new THREE.Float32BufferAttribute(cornerVertices, 3));
|
|
172
|
+
const selectionMaterial = ownMaterial(new THREE.LineBasicMaterial({ color: '#967b59', fog: false }));
|
|
173
|
+
const resizeGeometry = ownGeometry(new THREE.BufferGeometry());
|
|
174
|
+
resizeGeometry.setAttribute('position', new THREE.Float32BufferAttribute([
|
|
175
|
+
-0.085, 0.085, 0, 0.085, 0.085, 0, 0.085, 0.085, 0, 0.085, -0.085, 0,
|
|
176
|
+
0.085, -0.085, 0, -0.085, -0.085, 0, -0.085, -0.085, 0, -0.085, 0.085, 0,
|
|
177
|
+
-0.045, -0.04, 0, 0.04, 0.045, 0,
|
|
178
|
+
], 3));
|
|
179
|
+
const resizeFill = ownMaterial(new THREE.MeshBasicMaterial({ color: '#f0e8db', fog: false, toneMapped: false }));
|
|
180
|
+
const photo = new Image(); let photoReady = false;
|
|
181
|
+
photo.onload = () => {
|
|
182
|
+
if (disposed) return;
|
|
183
|
+
photoReady = true;
|
|
184
|
+
for (const visual of cards.values()) if (visual.card.visual === 'observatory') redraw(visual);
|
|
185
|
+
};
|
|
186
|
+
photo.src = observatoryUrl;
|
|
187
|
+
|
|
188
|
+
const shapes = (['note', 'source', 'question', 'plain', 'tag'] as const).map(kind => {
|
|
189
|
+
const shape = new THREE.Shape();
|
|
190
|
+
if(kind==='tag'){
|
|
191
|
+
shape.moveTo(-.44,-.5);shape.lineTo(.44,-.5);shape.lineTo(.5,-.33);shape.lineTo(.5,.33);
|
|
192
|
+
shape.lineTo(.44,.5);shape.lineTo(-.44,.5);shape.lineTo(-.5,.33);shape.lineTo(-.5,-.33);shape.closePath();
|
|
193
|
+
}else{
|
|
194
|
+
shape.moveTo(-0.5, -0.5);
|
|
195
|
+
if (kind === 'note') { shape.lineTo(0.43, -0.5); shape.lineTo(0.5, -0.43); }
|
|
196
|
+
else shape.lineTo(0.5, -0.5);
|
|
197
|
+
if (kind === 'source') { shape.lineTo(0.5, 0.415); shape.lineTo(0.42, 0.5); }
|
|
198
|
+
else shape.lineTo(0.5, 0.5);
|
|
199
|
+
shape.lineTo(-0.5, 0.5); shape.closePath();
|
|
200
|
+
}
|
|
201
|
+
const print = ownGeometry(new THREE.ShapeGeometry(shape));
|
|
202
|
+
const uv = print.getAttribute('uv');
|
|
203
|
+
for (let index = 0; index < uv.count; index++) uv.setXY(index, uv.getX(index) + 0.5, uv.getY(index) + 0.5);
|
|
204
|
+
const paper = ownGeometry(new THREE.ExtrudeGeometry(shape, { depth: 0.04, bevelEnabled: false, steps: 1, curveSegments: 1 }));
|
|
205
|
+
return [kind, { print, paper }] as const;
|
|
206
|
+
});
|
|
207
|
+
const paperShapes = Object.fromEntries(shapes) as Record<EvidenceCard['kind'] | 'plain' | 'tag', typeof shapes[number][1]>;
|
|
208
|
+
const shapeFor=(card:EvidenceCard)=>paperShapes[card.clueKind==='report'||card.clueKind==='excerpt'||card.clueKind==='contrast'?'plain'
|
|
209
|
+
:card.clueKind==='time'?'tag':card.clueKind==='question'||card.clueKind==='relation'?'source':card.kind];
|
|
210
|
+
group.add(strings);
|
|
211
|
+
|
|
212
|
+
// Both previews are created once. Pointer motion only changes transforms/buffer values.
|
|
213
|
+
let placementTemplate: EvidenceTemplate | null = null, connectionFrom: string | null = null;
|
|
214
|
+
let placementUpdates = 0, connectionUpdates = 0;
|
|
215
|
+
let placementWidth = 0, placementHeight = 0;
|
|
216
|
+
const placementMaterial = ownMaterial(new THREE.MeshBasicMaterial({ color: PAPER_COLORS.note,
|
|
217
|
+
transparent: true, opacity: 0.58, depthWrite: false, toneMapped: false, fog: false }));
|
|
218
|
+
const placement = new THREE.Mesh(paperShapes.note.print, placementMaterial);
|
|
219
|
+
placement.name = 'Evidence_Placement_Preview'; placement.visible = false; placement.position.z = 0.74;
|
|
220
|
+
const placementOutline = new THREE.LineSegments(selectionGeometry, selectionMaterial);
|
|
221
|
+
placementOutline.position.z = 0.003; placement.add(placementOutline); group.add(placement);
|
|
222
|
+
const previewTemplates: Record<EvidenceTemplate, EvidenceCard> = {
|
|
223
|
+
note: { id: 'tool:note', title: '', body: '', stage: 0, kind: 'note', presentation: 'compact', rotation: 0 },
|
|
224
|
+
question: { id: 'tool:question', title: '', body: '', stage: 0, kind: 'question', presentation: 'compact', rotation: 0 },
|
|
225
|
+
source: { id: 'tool:source', title: '', body: '', stage: 0, kind: 'source', rotation: 0 },
|
|
226
|
+
tag: { id: 'tool:tag', title: '', body: '', stage: 0, kind: 'note', presentation: 'tag', rotation: 0 },
|
|
227
|
+
};
|
|
228
|
+
const connectionPositions = new THREE.Float32BufferAttribute(new Float32Array(6), 3).setUsage(THREE.DynamicDrawUsage);
|
|
229
|
+
const connectionDistances = new THREE.Float32BufferAttribute(new Float32Array(2), 1).setUsage(THREE.DynamicDrawUsage);
|
|
230
|
+
const connectionGeometry = ownGeometry(new THREE.BufferGeometry());
|
|
231
|
+
connectionGeometry.setAttribute('position', connectionPositions);
|
|
232
|
+
connectionGeometry.setAttribute('lineDistance', connectionDistances);
|
|
233
|
+
const connection = new THREE.Line(connectionGeometry, ownMaterial(new THREE.LineDashedMaterial({
|
|
234
|
+
color: '#97594f', dashSize: 0.13, gapSize: 0.08, transparent: true, opacity: 0.9, depthWrite: true, fog: false,
|
|
235
|
+
})));
|
|
236
|
+
connection.name = 'Evidence_Connection_Preview'; connection.visible = false; connection.frustumCulled = false;
|
|
237
|
+
group.add(connection);
|
|
238
|
+
const connectionStart = new THREE.Vector3(), connectionEnd = new THREE.Vector3();
|
|
239
|
+
const insideWritingSurface = (value?: { x: number; y: number }) => value !== undefined
|
|
240
|
+
&& Number.isFinite(value.x) && Number.isFinite(value.y)
|
|
241
|
+
&& value.x >= EVIDENCE_WRITING_BOUNDS.left && value.x <= EVIDENCE_WRITING_BOUNDS.right
|
|
242
|
+
&& value.y >= EVIDENCE_WRITING_BOUNDS.bottom && value.y <= EVIDENCE_WRITING_BOUNDS.top;
|
|
243
|
+
|
|
244
|
+
function setPlacementPreview(template: EvidenceTemplate | null, at?: { x: number; y: number }) {
|
|
245
|
+
if (disposed) return;
|
|
246
|
+
if (template && template !== placementTemplate) {
|
|
247
|
+
const card = previewTemplates[template], layout = evidenceCardLayout(card, 0);
|
|
248
|
+
placementWidth = layout.width; placementHeight = layout.height;
|
|
249
|
+
placement.geometry = shapeFor(card).print;
|
|
250
|
+
placementMaterial.color.set(PAPER_COLORS[card.kind]);
|
|
251
|
+
placement.scale.set(placementWidth, placementHeight, 1);
|
|
252
|
+
}
|
|
253
|
+
placementTemplate = template;
|
|
254
|
+
placement.visible = Boolean(template && insideWritingSurface(at));
|
|
255
|
+
if (!placement.visible || !at) return;
|
|
256
|
+
const x = Math.max(EVIDENCE_WRITING_BOUNDS.left + placementWidth / 2,
|
|
257
|
+
Math.min(EVIDENCE_WRITING_BOUNDS.right - placementWidth / 2, at.x));
|
|
258
|
+
const y = Math.max(EVIDENCE_WRITING_BOUNDS.bottom + placementHeight / 2,
|
|
259
|
+
Math.min(EVIDENCE_WRITING_BOUNDS.top - placementHeight / 2, at.y));
|
|
260
|
+
if (placement.position.x !== x || placement.position.y !== y) {
|
|
261
|
+
placement.position.x = x; placement.position.y = y; placementUpdates++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function setConnectionPreview(fromId: string | null, at?: { x: number; y: number }) {
|
|
265
|
+
if (disposed) return;
|
|
266
|
+
const from = fromId ? cards.get(fromId) : undefined, next = from?.card.id ?? null;
|
|
267
|
+
if (connectionFrom !== next) {
|
|
268
|
+
const previous = connectionFrom ? cards.get(connectionFrom) : undefined;
|
|
269
|
+
connectionFrom = next;
|
|
270
|
+
if (previous) positionVisual(previous);
|
|
271
|
+
if (from) positionVisual(from);
|
|
272
|
+
}
|
|
273
|
+
connection.visible = Boolean(from && insideWritingSurface(at));
|
|
274
|
+
if (!connection.visible || !from || !at) return;
|
|
275
|
+
writePinPosition(from, connectionStart);
|
|
276
|
+
connectionEnd.set(at.x, at.y, 0.74);
|
|
277
|
+
if (connectionPositions.getX(0) === Math.fround(connectionStart.x)
|
|
278
|
+
&& connectionPositions.getY(0) === Math.fround(connectionStart.y)
|
|
279
|
+
&& connectionPositions.getZ(0) === Math.fround(connectionStart.z)
|
|
280
|
+
&& connectionPositions.getX(1) === Math.fround(connectionEnd.x)
|
|
281
|
+
&& connectionPositions.getY(1) === Math.fround(connectionEnd.y)) return;
|
|
282
|
+
connectionPositions.setXYZ(0, connectionStart.x, connectionStart.y, connectionStart.z);
|
|
283
|
+
connectionPositions.setXYZ(1, connectionEnd.x, connectionEnd.y, connectionEnd.z);
|
|
284
|
+
connectionDistances.setX(1, connectionStart.distanceTo(connectionEnd));
|
|
285
|
+
connectionPositions.needsUpdate = true; connectionDistances.needsUpdate = true; connectionUpdates++;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function redraw(visual: CardVisual) {
|
|
289
|
+
drawEvidenceCard(visual.canvas, visual.card, visual.index, photoReady ? photo : undefined);
|
|
290
|
+
visual.texture.needsUpdate = true; cardTextureUpdates++;
|
|
291
|
+
}
|
|
292
|
+
const revealMaterials = new Map<THREE.Material, { opacity: number; transparent: boolean; depthWrite: boolean }>();
|
|
293
|
+
let reveal = 1;
|
|
294
|
+
function setReveal(value:number) {
|
|
295
|
+
const next=THREE.MathUtils.clamp(value,0,1);group.visible=next>0;
|
|
296
|
+
if(next===reveal&&(next===0||next===1))return;reveal=next;
|
|
297
|
+
if(next===1){
|
|
298
|
+
for(const [material,state] of revealMaterials){material.opacity=state.opacity;material.depthWrite=state.depthWrite;if(material.transparent!==state.transparent){material.transparent=state.transparent;material.needsUpdate=true;}}
|
|
299
|
+
revealMaterials.clear();return;
|
|
300
|
+
}
|
|
301
|
+
if(next===0)return;
|
|
302
|
+
for(const material of materials){
|
|
303
|
+
if(!revealMaterials.has(material))revealMaterials.set(material,{opacity:material.opacity,transparent:material.transparent,depthWrite:material.depthWrite});
|
|
304
|
+
material.opacity=revealMaterials.get(material)!.opacity*next;material.depthWrite=false;if(!material.transparent){material.transparent=true;material.needsUpdate=true;}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function clearStrings() {
|
|
308
|
+
for (const child of [...strings.children]) {
|
|
309
|
+
if (child instanceof THREE.Mesh) child.geometry.dispose(); strings.remove(child);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function writePinPosition(visual: CardVisual, target: THREE.Vector3) {
|
|
313
|
+
const { x, y } = visual.pin.position, angle = visual.group.rotation.z;
|
|
314
|
+
return target.set(x * Math.cos(angle) - y * Math.sin(angle), x * Math.sin(angle) + y * Math.cos(angle), 0.102)
|
|
315
|
+
.add(visual.group.position);
|
|
316
|
+
}
|
|
317
|
+
function refreshStrings() {
|
|
318
|
+
const signature = JSON.stringify([...cards.values()].map(value => [
|
|
319
|
+
value.card.id, value.group.position.toArray(), value.group.rotation.z,
|
|
320
|
+
value.pin.position.toArray(), value.card.links]));
|
|
321
|
+
if (signature === linkSignature) return;
|
|
322
|
+
linkSignature = signature; clearStrings(); linkRebuilds++;
|
|
323
|
+
const seen = new Set<string>();
|
|
324
|
+
const front = Math.max(PAPER_Z, ...[...cards.values()].map(value => value.group.position.z + PRINT_Z)) + .044;
|
|
325
|
+
for (const a of cards.values()) for (const id of a.card.links || []) {
|
|
326
|
+
const b = cards.get(id); if (!b || b === a) continue;
|
|
327
|
+
const key = JSON.stringify([a.card.id, b.card.id].sort());
|
|
328
|
+
if (seen.has(key)) continue; seen.add(key);
|
|
329
|
+
const start = writePinPosition(a, new THREE.Vector3()), end = writePinPosition(b, new THREE.Vector3());
|
|
330
|
+
// A lightly tensioned red thread runs directly from pin to pin above the papers.
|
|
331
|
+
const middle = new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2 - .055, front);
|
|
332
|
+
const geometry = new THREE.TubeGeometry(new THREE.QuadraticBezierCurve3(start, middle, end), 20, .022, 5, false);
|
|
333
|
+
const string = new THREE.Mesh(geometry, redString); string.name = 'Explicit_Evidence_Link';
|
|
334
|
+
string.userData.from = a.card.id; string.userData.to = b.card.id;
|
|
335
|
+
string.castShadow = true; strings.add(string);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function positionVisual(visual: CardVisual) {
|
|
339
|
+
const { card, index } = visual, layout = evidenceCardLayout(card, index);
|
|
340
|
+
const position = clampEvidencePosition(card, index, layout.x, layout.y);
|
|
341
|
+
visual.layout = { ...layout, ...position };
|
|
342
|
+
const remaining = 1 - Math.min(1, visual.arrival ?? 1);
|
|
343
|
+
visual.group.position.set(position.x, position.y + remaining * remaining * .28, PAPER_Z + index * 0.016 + remaining * remaining * .5 + (selectedId === card.id ? 0.055 : 0));
|
|
344
|
+
visual.group.rotation.z = THREE.MathUtils.degToRad(layout.rotation);
|
|
345
|
+
visual.paper.scale.set(layout.width, layout.height, 1); visual.print.scale.set(layout.width, layout.height, 1);
|
|
346
|
+
visual.printBacking.scale.set(layout.width, layout.height, 1);
|
|
347
|
+
visual.highlight.scale.set(layout.width, layout.height, 1);
|
|
348
|
+
visual.highlight.visible = selectedId === card.id || connectionFrom === card.id;
|
|
349
|
+
visual.resizeHandle.visible = card.clueKind !== 'report' && resizeEnabled && selectedId === card.id;
|
|
350
|
+
visual.resizeHandle.position.set(layout.width / 2, -layout.height / 2, 0.12);
|
|
351
|
+
visual.shadow.scale.set(layout.width + 0.22 * layout.scale, layout.height + 0.24 * layout.scale, 1);
|
|
352
|
+
visual.shadow.position.set(0.045 * layout.scale, -0.067 * layout.scale, -0.051);
|
|
353
|
+
visual.backing.scale.set(layout.width, layout.height, 1); visual.backing.visible = card.clueKind ? ['report','excerpt','contrast'].includes(card.clueKind) : card.kind === 'source';
|
|
354
|
+
visual.backing.position.set(0.043 * layout.scale, -0.038 * layout.scale, -0.01);
|
|
355
|
+
visual.tape.visible = card.clueKind === 'question' || !card.presentation && (card.visual === 'observatory' || card.visual === 'schematic');
|
|
356
|
+
visual.tape.scale.set(0.78 * layout.scale, 0.23 * layout.scale, 1);
|
|
357
|
+
visual.tape.position.set(card.clueKind ? -.08 : -layout.width * 0.20, layout.height / 2 - 0.008 * layout.scale, 0.095);
|
|
358
|
+
visual.pin.visible = card.clueKind !== 'report';
|
|
359
|
+
visual.pin.position.set(card.clueKind ? -layout.width * .48 : card.presentation === 'tag' ? -layout.width * 0.44
|
|
360
|
+
: card.visual === 'observatory' || card.visual === 'signal' ? layout.width * 0.37
|
|
361
|
+
: card.visual === 'schematic' ? layout.width * 0.35 : -layout.width * 0.37,
|
|
362
|
+
card.presentation === 'tag' ? 0 : layout.height / 2 - 0.11 * layout.scale, 0);
|
|
363
|
+
const head = visual.pin.children[2] as THREE.Mesh;
|
|
364
|
+
const pinScale = connectionFrom === card.id ? 1.3 : card.presentation === 'tag' ? 0.8 : 1;
|
|
365
|
+
head.scale.set(pinScale, pinScale, pinScale * 0.72);
|
|
366
|
+
}
|
|
367
|
+
function remove(visual: CardVisual) {
|
|
368
|
+
group.remove(visual.group); visual.texture.dispose(); textures.delete(visual.texture);
|
|
369
|
+
visual.print.material.dispose(); materials.delete(visual.print.material);
|
|
370
|
+
visual.printBacking.material.dispose(); materials.delete(visual.printBacking.material);
|
|
371
|
+
}
|
|
372
|
+
function setCards(next: EvidenceCard[]) {
|
|
373
|
+
if (disposed) return;
|
|
374
|
+
receivedCards = next.length;
|
|
375
|
+
const unique = [...new Map(next.map(card => [card.id, card])).values()].slice(0, next.some(card => card.clueKind === 'report') ? EVIDENCE_CARD_CAPACITY + 1 : EVIDENCE_CARD_CAPACITY);
|
|
376
|
+
const ids = new Set(unique.map(card => card.id));
|
|
377
|
+
for (const [id, visual] of cards) if (!ids.has(id)) { remove(visual); cards.delete(id); }
|
|
378
|
+
if (selectedId && !ids.has(selectedId)) selectedId = null;
|
|
379
|
+
if (connectionFrom && !ids.has(connectionFrom)) setConnectionPreview(null);
|
|
380
|
+
unique.forEach((card, index) => {
|
|
381
|
+
// Scale changes only the physical sheet. Derive the canvas from its original
|
|
382
|
+
// aspect ratio so floating-point rounding never reallocates or redraws ink.
|
|
383
|
+
const unscaled = evidenceCardLayout({ ...card, scale: 1 }, index);
|
|
384
|
+
const expectedWidth = card.presentation ? 512 : 768;
|
|
385
|
+
const expectedHeight = Math.round(expectedWidth * unscaled.height / unscaled.width);
|
|
386
|
+
let visual = cards.get(card.id);
|
|
387
|
+
if (!visual) {
|
|
388
|
+
const layout = evidenceCardLayout(card, index);
|
|
389
|
+
const sheet = canvas(expectedWidth, expectedHeight).canvas, texture = textureFor(sheet);
|
|
390
|
+
const material = printed(texture);
|
|
391
|
+
const cardGroup = new THREE.Group(); cardGroup.name = 'Pinned_Evidence_Card';
|
|
392
|
+
const shadow = new THREE.Mesh(sheetPlane, shadowMaterial); shadow.position.set(0.045, -0.067, -0.051); cardGroup.add(shadow);
|
|
393
|
+
const backing = new THREE.Mesh(sheetPlane, paperMaterials.source); backing.position.set(0.043, -0.038, -0.01);
|
|
394
|
+
backing.rotation.z = 0.018; cardGroup.add(backing);
|
|
395
|
+
const highlight = new THREE.LineSegments(selectionGeometry, selectionMaterial); highlight.position.z = 0.10; cardGroup.add(highlight);
|
|
396
|
+
const resizeHandle = new THREE.Group(); resizeHandle.name = 'Evidence_Paper_Resize_Handle';
|
|
397
|
+
resizeHandle.userData.evidenceResizeCardId = card.id;
|
|
398
|
+
const resizeFace = new THREE.Mesh(sheetPlane, resizeFill); resizeFace.scale.set(0.17, 0.17, 1);
|
|
399
|
+
const resizeInk = new THREE.LineSegments(resizeGeometry, selectionMaterial); resizeInk.position.z = 0.003;
|
|
400
|
+
resizeHandle.add(resizeFace, resizeInk); cardGroup.add(resizeHandle);
|
|
401
|
+
const paper = new THREE.Mesh(shapeFor(card).paper, paperMaterials[card.kind]);
|
|
402
|
+
paper.castShadow = true; paper.receiveShadow = true; cardGroup.add(paper);
|
|
403
|
+
// Coarse scene depth can cover the edge of a pin or string. The sharp
|
|
404
|
+
// layer then reveals printed pixels rather than a blank white fringe.
|
|
405
|
+
// This backing shares its canvas texture and geometry with the sharp layer.
|
|
406
|
+
const printBacking = new THREE.Mesh(shapeFor(card).print,
|
|
407
|
+
ownMaterial(new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, fog: false })));
|
|
408
|
+
printBacking.position.z = PRINT_Z; cardGroup.add(printBacking);
|
|
409
|
+
const print = new THREE.Mesh(shapeFor(card).print, material);
|
|
410
|
+
print.layers.set(ARCHIVE_LABEL_LAYER);
|
|
411
|
+
print.position.z = PRINT_Z; print.userData.evidenceCardId = card.id; cardGroup.add(print);
|
|
412
|
+
const tape = new THREE.Mesh(sheetPlane, tapeMaterial); tape.scale.set(0.78, 0.23, 1);
|
|
413
|
+
tape.layers.set(ARCHIVE_LABEL_LAYER); tape.renderOrder = 1;
|
|
414
|
+
tape.rotation.z = -0.07; cardGroup.add(tape);
|
|
415
|
+
const pin = new THREE.Group(); pin.name = 'Evidence_Pushpin';
|
|
416
|
+
const stem = new THREE.Mesh(pinStem, brass); stem.rotation.x = Math.PI / 2; stem.position.z = 0.089; pin.add(stem);
|
|
417
|
+
const washer = new THREE.Mesh(pinWasher, brass); washer.rotation.x = Math.PI / 2; washer.position.z = 0.074; pin.add(washer);
|
|
418
|
+
const head = new THREE.Mesh(pinHead, card.kind === 'note' ? brass : redPin);
|
|
419
|
+
head.scale.set(1, 1, 0.72); head.position.z = 0.155; head.castShadow = true; pin.add(head);
|
|
420
|
+
cardGroup.add(pin); group.add(cardGroup);
|
|
421
|
+
visual = { card, index, layout, group: cardGroup, paper, print, printBacking, highlight, resizeHandle, shadow, backing, tape, pin, texture, canvas: sheet, signature: '', arrival: card.clueKind && !matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 1 };
|
|
422
|
+
cards.set(card.id, visual);
|
|
423
|
+
}
|
|
424
|
+
visual.card = { ...card, ...(card.position ? { position: { ...card.position } } : {}) }; visual.index = index;
|
|
425
|
+
visual.paper.geometry = shapeFor(card).paper; visual.paper.material = paperMaterials[card.kind];
|
|
426
|
+
visual.print.geometry = shapeFor(card).print;
|
|
427
|
+
visual.printBacking.geometry = visual.print.geometry;
|
|
428
|
+
const head = visual.pin.children[2] as THREE.Mesh; head.material = card.kind === 'note' ? brass : redPin;
|
|
429
|
+
positionVisual(visual);
|
|
430
|
+
if (visual.canvas.width !== expectedWidth || visual.canvas.height !== expectedHeight) {
|
|
431
|
+
// WebGL texture storage has immutable dimensions after its first upload.
|
|
432
|
+
visual.texture.dispose(); textures.delete(visual.texture);
|
|
433
|
+
visual.canvas.width = expectedWidth; visual.canvas.height = expectedHeight;
|
|
434
|
+
visual.texture = textureFor(visual.canvas);
|
|
435
|
+
visual.texture.userData.archivePrint = true;
|
|
436
|
+
visual.texture.anisotropy = visual.print.material.map!.anisotropy;
|
|
437
|
+
visual.print.material.map = visual.texture;
|
|
438
|
+
visual.print.material.needsUpdate = true;
|
|
439
|
+
visual.printBacking.material.map = visual.texture;
|
|
440
|
+
}
|
|
441
|
+
const signature = JSON.stringify([card.title, card.body, card.summary, card.clueKind, card.variant, card.evidenceLabel, card.kind, card.visual, card.presentation,
|
|
442
|
+
card.sourceTitle, card.sourceLabel, isPreviewCard(card), index, expectedWidth, expectedHeight]);
|
|
443
|
+
if (signature !== visual.signature) { visual.signature = signature; redraw(visual); }
|
|
444
|
+
});
|
|
445
|
+
refreshStrings();
|
|
446
|
+
}
|
|
447
|
+
function select(id: string | null) {
|
|
448
|
+
if (disposed) return;
|
|
449
|
+
const next = id && cards.has(id) ? id : null; if (selectedId === next) return;
|
|
450
|
+
selectedId = next; for (const visual of cards.values()) positionVisual(visual); refreshStrings();
|
|
451
|
+
}
|
|
452
|
+
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -SURFACE_Z);
|
|
453
|
+
const inverse = new THREE.Matrix4(), localRay = new THREE.Ray(), point = new THREE.Vector3();
|
|
454
|
+
const handlePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), -0.12);
|
|
455
|
+
const resizePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
|
456
|
+
return {
|
|
457
|
+
group, setCards, select, setReveal, setPlacementPreview, setConnectionPreview,
|
|
458
|
+
update(delta: number) { let arriving=false;for (const visual of cards.values()) if ((visual.arrival ?? 1) < 1) { visual.arrival = Math.min(1, (visual.arrival || 0) + delta / .38); positionVisual(visual); arriving=true; }if(arriving)refreshStrings(); },
|
|
459
|
+
setResizeEnabled(value: boolean) {
|
|
460
|
+
if (resizeEnabled === value) return;
|
|
461
|
+
resizeEnabled = value;
|
|
462
|
+
for (const visual of cards.values()) visual.resizeHandle.visible = visual.card.clueKind !== 'report' && value && selectedId === visual.card.id;
|
|
463
|
+
},
|
|
464
|
+
pickResizeHandle(raycaster: THREE.Raycaster, tolerance = 0.22): string | null {
|
|
465
|
+
const visual = selectedId ? cards.get(selectedId) : undefined;
|
|
466
|
+
if (disposed || !group.visible || !visual?.resizeHandle.visible) return null;
|
|
467
|
+
visual.group.updateWorldMatrix(true, false); inverse.copy(visual.group.matrixWorld).invert();
|
|
468
|
+
localRay.copy(raycaster.ray).applyMatrix4(inverse);
|
|
469
|
+
if (!localRay.intersectPlane(handlePlane, point)) return null;
|
|
470
|
+
return Math.hypot(point.x - visual.resizeHandle.position.x, point.y - visual.resizeHandle.position.y)
|
|
471
|
+
<= tolerance ? visual.card.id : null;
|
|
472
|
+
},
|
|
473
|
+
/** Stable board-local anchors; callers must copy before world/screen projection. */
|
|
474
|
+
getToolAnchors() { return toolAnchors; },
|
|
475
|
+
pickTool(raycaster: THREE.Raycaster): boolean {
|
|
476
|
+
if (disposed || !group.visible) return false;
|
|
477
|
+
group.updateWorldMatrix(true, true); toolHits.length = 0;
|
|
478
|
+
raycaster.intersectObjects(toolMeshes, false, toolHits);
|
|
479
|
+
return toolHits.length > 0;
|
|
480
|
+
},
|
|
481
|
+
boardPoint(raycaster: THREE.Raycaster): { x: number; y: number } | null {
|
|
482
|
+
if (disposed || !group.visible) return null;
|
|
483
|
+
group.updateWorldMatrix(true, false); inverse.copy(group.matrixWorld).invert();
|
|
484
|
+
localRay.copy(raycaster.ray).applyMatrix4(inverse);
|
|
485
|
+
if (!localRay.intersectPlane(plane, point)) return null;
|
|
486
|
+
// Beyond-edge coordinates let moveCard clamp a dragged paper to the surface.
|
|
487
|
+
return { x: point.x, y: point.y };
|
|
488
|
+
},
|
|
489
|
+
resizePoint(raycaster: THREE.Raycaster, id: string): { x: number; y: number } | null {
|
|
490
|
+
const visual = cards.get(id);
|
|
491
|
+
if (disposed || !group.visible || !visual) return null;
|
|
492
|
+
group.updateWorldMatrix(true, false); inverse.copy(group.matrixWorld).invert();
|
|
493
|
+
localRay.copy(raycaster.ray).applyMatrix4(inverse);
|
|
494
|
+
// Intersect the selected sheet's grip plane, not the recessed whiteboard:
|
|
495
|
+
// otherwise oblique views introduce a small perspective scaling error.
|
|
496
|
+
resizePlane.constant = -(visual.group.position.z + visual.resizeHandle.position.z);
|
|
497
|
+
if (!localRay.intersectPlane(resizePlane, point)) return null;
|
|
498
|
+
return { x: point.x, y: point.y };
|
|
499
|
+
},
|
|
500
|
+
moveCard(id: string, x: number, y: number) {
|
|
501
|
+
if (disposed || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
502
|
+
const visual = cards.get(id); if (!visual) return;
|
|
503
|
+
const position = clampEvidencePosition(visual.card, visual.index, x, y);
|
|
504
|
+
if (position.x === visual.group.position.x && position.y === visual.group.position.y) return;
|
|
505
|
+
visual.card = { ...visual.card, position }; positionVisual(visual); refreshStrings();
|
|
506
|
+
},
|
|
507
|
+
resizeCard(id: string, scale: number, x: number, y: number) {
|
|
508
|
+
if (disposed || ![scale, x, y].every(Number.isFinite)) return;
|
|
509
|
+
const visual = cards.get(id); if (!visual) return;
|
|
510
|
+
const next = { ...visual.card, scale }, position = clampEvidencePosition(next, visual.index, x, y);
|
|
511
|
+
if (scale === visual.card.scale && position.x === visual.layout.x && position.y === visual.layout.y) return;
|
|
512
|
+
// Resizing reuses the sheet, print texture and furniture geometry.
|
|
513
|
+
visual.card = { ...next, position }; positionVisual(visual); refreshStrings();
|
|
514
|
+
},
|
|
515
|
+
pick(raycaster: THREE.Raycaster): string | null {
|
|
516
|
+
if (disposed || !group.visible) return null;
|
|
517
|
+
group.updateWorldMatrix(true, true);
|
|
518
|
+
const hit = raycaster.intersectObjects([...cards.values()].map(visual => visual.print), false)[0];
|
|
519
|
+
return hit ? String(hit.object.userData.evidenceCardId) : null;
|
|
520
|
+
},
|
|
521
|
+
stats(): Record<string, unknown> {
|
|
522
|
+
return { cards: cards.size, receivedCards, reveal, capacity: EVIDENCE_CARD_CAPACITY, selectedId,
|
|
523
|
+
cardTextureUpdates, textureCount: textures.size, geometryCount: geometries.size,
|
|
524
|
+
linkRoutes: strings.children.map(line => {
|
|
525
|
+
const mesh = line as THREE.Mesh<THREE.TubeGeometry, THREE.MeshStandardMaterial>;
|
|
526
|
+
return { ...line.userData, color: `#${mesh.material.color.getHexString()}`, opacity: mesh.material.opacity,
|
|
527
|
+
radius: mesh.geometry.parameters.radius, visible: mesh.visible };
|
|
528
|
+
}),
|
|
529
|
+
links: strings.children.length, connections: strings.children.length, linkRebuilds, width: WIDTH, height: HEIGHT,
|
|
530
|
+
placementPreview: placement.visible, placementTemplate, placementUpdates,
|
|
531
|
+
connectionPreview: connection.visible, connectionFrom, connectionUpdates,
|
|
532
|
+
chalkMeshes: toolMeshes.length,
|
|
533
|
+
resizeHandle: selectedId && resizeEnabled ? selectedId : null,
|
|
534
|
+
cardTexturePixels: [...cards.values()].reduce((total, value) => total + value.canvas.width * value.canvas.height, 0),
|
|
535
|
+
illustrationReady: photoReady,
|
|
536
|
+
layout: [...cards.values()].map(value => ({ id: value.card.id, ...value.layout,
|
|
537
|
+
presentation: value.card.presentation, textureWidth: value.canvas.width, textureHeight: value.canvas.height })) };
|
|
538
|
+
},
|
|
539
|
+
dispose() {
|
|
540
|
+
if (disposed) return;
|
|
541
|
+
disposed = true; photo.onload = null; clearStrings(); group.removeFromParent(); group.clear(); cards.clear();
|
|
542
|
+
toolMeshes.length = 0; toolHits.length = 0;
|
|
543
|
+
textures.forEach(texture => texture.dispose()); textures.clear();
|
|
544
|
+
materials.forEach(material => material.dispose()); materials.clear();
|
|
545
|
+
geometries.forEach(geometry => geometry.dispose()); geometries.clear();
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|