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,1726 @@
|
|
|
1
|
+
import { visibleSceneHit } from './visible-scene-hit';
|
|
2
|
+
import { createEvidenceInboxScene } from './evidence-inbox-scene';
|
|
3
|
+
import { createReadingObject } from './reading-object';
|
|
4
|
+
import { INVESTIGATION_BOARD_PLANE } from './investigation-board-plane';
|
|
5
|
+
import { archiveLabelCandidates, archivePointerTarget, type LabelCandidate } from './label-prefetch';
|
|
6
|
+
import * as THREE from 'three';
|
|
7
|
+
import { ArchiveScene } from './original/scene';
|
|
8
|
+
import { archiveColumns, fileLocation } from './original/data';
|
|
9
|
+
import { wrap, type ArchiveNavigation, type ArchiveCell } from './original/archive-loop';
|
|
10
|
+
import { qualityPresets, type RenderQuality } from './original/render-quality';
|
|
11
|
+
import { setAssetBase } from './original/asset-url';
|
|
12
|
+
import { archiveLane, SHELF_PAGE_SIZE, sourceIdentity, sourceIndex } from './catalogue';
|
|
13
|
+
import { SHELF_LEVELS, SHELF_SLOTS_PER_LEVEL, SHELF_LEVEL_SPACING, SHELF_WIDTH as RACK_WIDTH,
|
|
14
|
+
SHELF_DEPTH as RACK_DEPTH, SHELF_HEIGHT, SHELF_EXIT_DISTANCE, SHELF_READING_POSITION, shelfReadingPath, shelfSlot } from './shelf-layout';
|
|
15
|
+
import { DeferredPreparation } from './deferred-preparation';
|
|
16
|
+
import { operationSource } from './tool-activity';
|
|
17
|
+
import { beginWorkspaceTravel, advanceWorkspaceTravel, type WorkspaceTravel } from './workspace-travel';
|
|
18
|
+
import { ArchiveNavigationLimiter } from './archive-navigation-limit';
|
|
19
|
+
import { createEvidenceBoard } from './evidence-board-scene';
|
|
20
|
+
import { clampEvidencePosition, evidenceCardLayout, evidenceCardScale, EVIDENCE_SCALE_MIN, EVIDENCE_SCALE_MAX,
|
|
21
|
+
EVIDENCE_WRITING_BOUNDS, type EvidenceCard, type EvidenceBoardTool } from './evidence-board-model';
|
|
22
|
+
import type { ArchiveOperation, ArchiveSource, InvestigationSnapshot, RhineLocation, RhineScene, RhineSceneOptions, BoardAnchorFrame } from './types';
|
|
23
|
+
|
|
24
|
+
// The archive is the authored renderer: original camera, optics, lighting,
|
|
25
|
+
// continuous 288-file loop. Evidence occupies an adjoining
|
|
26
|
+
// part of this same world; camera travel never swaps or rebuilds the archive.
|
|
27
|
+
const SHELF_X = 35;
|
|
28
|
+
const BOARD_CENTER = new THREE.Vector3(29.7, 4.8, -8);
|
|
29
|
+
const BOARD_FOCUS = BOARD_CENTER.clone();
|
|
30
|
+
const BOARD_ZOOM_MIN = 1, BOARD_ZOOM_MAX = 3;
|
|
31
|
+
const SHELF_YAW = 0;
|
|
32
|
+
// One profile for the workbench and viewer. Keep native CSS resolution and
|
|
33
|
+
// SMAA for the cassette edges; the rack's full-resolution transmission remains
|
|
34
|
+
// a view-specific clarity adjustment below.
|
|
35
|
+
export const WORKBENCH_QUALITY: RenderQuality = { ...qualityPresets.performance };
|
|
36
|
+
// The original archive camera looks along a 59-degree horizontal bearing.
|
|
37
|
+
// Locate the small organizer in the right two thirds of its translated view.
|
|
38
|
+
const SHELF_RIGHT = new THREE.Vector3(Math.cos(THREE.MathUtils.degToRad(59)), 0, Math.sin(THREE.MathUtils.degToRad(59)));
|
|
39
|
+
const SHELF_CENTER = new THREE.Vector3(SHELF_X, -4.6, -2.17).addScaledVector(SHELF_RIGHT, 4.2);
|
|
40
|
+
const smooth = (value: number) => {
|
|
41
|
+
const t = THREE.MathUtils.clamp(value, 0, 1);
|
|
42
|
+
return t * t * t * (t * (t * 6 - 15) + 10);
|
|
43
|
+
};
|
|
44
|
+
interface CollectionFile { id: string; group: THREE.Group; marker: THREE.Mesh; saved: THREE.Mesh; progress: number; focus: number; arrival: number }
|
|
45
|
+
interface ActivityRequest { key: string; operationId?: string; source?: ArchiveSource; kind: 'arrival' | 'read'; age: number; labelWaitAt?: number }
|
|
46
|
+
interface ActivityFile extends ActivityRequest {
|
|
47
|
+
group: THREE.Group; index: number; elapsed: number; lift: number;
|
|
48
|
+
stage: 'lifting' | 'holding' | 'travelling' | 'returning';
|
|
49
|
+
departure: THREE.Vector3; orientation: THREE.Quaternion; startLift: number; targetLift: number; released?: boolean;
|
|
50
|
+
}
|
|
51
|
+
const MAX_ACTIVITY_FILES = 2;
|
|
52
|
+
const MAX_ACTIVITY_QUEUE = 12;
|
|
53
|
+
const ARCHIVE_STEP_SECONDS = 2.8;
|
|
54
|
+
const ACTIVITY_LIFT_SECONDS = 1.35;
|
|
55
|
+
|
|
56
|
+
export async function createRhineScene(host: HTMLElement, options: RhineSceneOptions): Promise<RhineScene> {
|
|
57
|
+
let activitySince = options.activitySince ?? Date.now();
|
|
58
|
+
let quality: RenderQuality = { ...WORKBENCH_QUALITY };
|
|
59
|
+
setAssetBase(options.assetBase);
|
|
60
|
+
// A URL-only comparison switch keeps the previous geometry path available
|
|
61
|
+
// for visual/performance review without changing the user's quality settings.
|
|
62
|
+
const renderOptions = new URLSearchParams(globalThis.location.search);
|
|
63
|
+
const arrayInterior = renderOptions.get('rhineArray') === 'geometry'
|
|
64
|
+
? 'geometry' : 'baked';
|
|
65
|
+
const original = new ArchiveScene(host, undefined, undefined, undefined, arrayInterior,
|
|
66
|
+
renderOptions.get('rhineCull') !== 'off', renderOptions.get('rhineLod') !== 'off',
|
|
67
|
+
renderOptions.get('rhineOcclusion') !== 'off', renderOptions.get('rhineShell') !== 'off',
|
|
68
|
+
renderOptions.get('rhineDetails') !== 'off');
|
|
69
|
+
original.performanceProbe = options.performanceProbe;
|
|
70
|
+
const load = <T>(kind: string, task: () => Promise<T>, detail: Record<string, unknown> = {}) =>
|
|
71
|
+
options.performanceProbe ? options.performanceProbe.trackLoad(kind, task, detail) : task();
|
|
72
|
+
try {
|
|
73
|
+
await Promise.all([
|
|
74
|
+
load('scene-setup', () => original.load(`${options.assetBase.replace(/\/$/, '')}/assets/archive-cassette.glb`)),
|
|
75
|
+
load('font', () => document.fonts.load('400 20px MiSans'), { family: 'MiSans', weight: 400 }),
|
|
76
|
+
load('font', () => document.fonts.load('600 20px MiSans'), { family: 'MiSans', weight: 600 }),
|
|
77
|
+
load('font', () => document.fonts.load('700 20px MiSans'), { family: 'MiSans', weight: 700 }),
|
|
78
|
+
]);
|
|
79
|
+
original.setQuality(quality);
|
|
80
|
+
} catch (error) { original.dispose(); throw error; }
|
|
81
|
+
original.setReduced(!!options.reducedMotion);
|
|
82
|
+
original.setMode('archive');
|
|
83
|
+
original.select(16);
|
|
84
|
+
// TEMPORARY RHINE PROFILER
|
|
85
|
+
const unregisterPerformance = options.performanceProbe?.register('scene', original.renderer, () => ({
|
|
86
|
+
...original.performanceState(), active, reducedMotion: reduced, location, detail, movingCamera: Boolean(cameraTravel),
|
|
87
|
+
preparation: { ...preparation.stats(), pendingShelf: pendingShelf.size }, navigation: navigationLimiter.stats(),
|
|
88
|
+
sourceCount: sourceList.length, archiveSourceCount: archiveSources.length, physicalFiles: files.size,
|
|
89
|
+
activityFiles: activityFiles.length, activityQueue: activityQueue.length, pendingArrivals: waitingArrivals.size,
|
|
90
|
+
activityStages: activityFiles.reduce<Record<string, number>>((counts, item) => { counts[item.stage] = (counts[item.stage] || 0) + 1; return counts; }, {}),
|
|
91
|
+
boardCards: boardCards.length, scanSteps, renderedFrames, searching: scanningActive(),
|
|
92
|
+
}), () => original.performancePasses());
|
|
93
|
+
const navigationLimiter = options.navigationLimiter ?? new ArchiveNavigationLimiter();
|
|
94
|
+
|
|
95
|
+
let disposed = false;
|
|
96
|
+
let active = true;
|
|
97
|
+
let reduced = !!options.reducedMotion;
|
|
98
|
+
let contextLost = false;
|
|
99
|
+
let raf = 0;
|
|
100
|
+
let renderedFrames = 0;
|
|
101
|
+
const readyWaiters = new Set<{ resolve: () => void; reject: (error: Error) => void }>();
|
|
102
|
+
const rejectReady = (message: string) => {
|
|
103
|
+
for (const waiter of readyWaiters) waiter.reject(new Error(message));
|
|
104
|
+
readyWaiters.clear();
|
|
105
|
+
};
|
|
106
|
+
let lastFrame = 0;
|
|
107
|
+
let archiveIndex = 16;
|
|
108
|
+
let archiveLaneIndex = 2;
|
|
109
|
+
let archiveSources: ArchiveSource[] = [];
|
|
110
|
+
let lanes: ArchiveSource[][] = archiveColumns.map(() => []);
|
|
111
|
+
const laneRows = archiveColumns.map(() => 0);
|
|
112
|
+
const slotRows = archiveColumns.map(() => 0);
|
|
113
|
+
let detail = false;
|
|
114
|
+
let location: RhineLocation = 'archive';
|
|
115
|
+
// Close inspection of clear covers needs a full-resolution refraction
|
|
116
|
+
// capture and a sharp rack throughout its depth. Apply once per view/quality
|
|
117
|
+
// change; interpolating these buffer sizes during camera travel reallocates
|
|
118
|
+
// GPU targets every frame. Keep the user's archive settings separately.
|
|
119
|
+
const effectiveQuality = (): RenderQuality => location === 'desk'
|
|
120
|
+
? { ...quality, transmission: 1, depthOfField: 0 }
|
|
121
|
+
: { ...quality, ...(location === 'board' ? { depthOfField: 0 } : {}) };
|
|
122
|
+
let searching = false;
|
|
123
|
+
let userPriorityUntil = 0;
|
|
124
|
+
let followAgent = true;
|
|
125
|
+
const userOwnsView = () => !followAgent || performance.now() < userPriorityUntil;
|
|
126
|
+
const prioritizeUser = () => { userPriorityUntil = performance.now() + 4000; scanElapsed = 0; };
|
|
127
|
+
let scanElapsed = ARCHIVE_STEP_SECONDS - 0.35;
|
|
128
|
+
let scanSteps = 0;
|
|
129
|
+
let readingScanDirection = 1;
|
|
130
|
+
let arrivalScanRemaining = 0;
|
|
131
|
+
let deskAmount = 0;
|
|
132
|
+
let boardAmount = 0;
|
|
133
|
+
let boardZoom = BOARD_ZOOM_MIN;
|
|
134
|
+
const boardZoomCenter = new THREE.Vector2();
|
|
135
|
+
const boardFocus = BOARD_FOCUS.clone();
|
|
136
|
+
let boardFullscreen = false;
|
|
137
|
+
let boardEditorInset = 0;
|
|
138
|
+
let appliedBoardEditorInset = 0;
|
|
139
|
+
let boardOverview: { zoom: number; center: THREE.Vector2 } | null = null;
|
|
140
|
+
const boardNormal = new THREE.Vector3();
|
|
141
|
+
let cameraTravel: WorkspaceTravel | null = null;
|
|
142
|
+
const workspaceFrameCenter = new THREE.Vector3();
|
|
143
|
+
let sourceList: ArchiveSource[] = [];
|
|
144
|
+
let sourceIds = new Set<string>();
|
|
145
|
+
let selectedId: string | null = null;
|
|
146
|
+
let focusedId: string | null = null;
|
|
147
|
+
let shelfPage = 0;
|
|
148
|
+
let visibleIds: string[] = [];
|
|
149
|
+
let sessionId: string | null = null;
|
|
150
|
+
let investigation: InvestigationSnapshot | null = null;
|
|
151
|
+
const transferred = new Set<string>();
|
|
152
|
+
const waitingArrivals = new Set<string>();
|
|
153
|
+
const files = new Map<string, CollectionFile>();
|
|
154
|
+
const preparation = new DeferredPreparation({ monitorTask: () => {
|
|
155
|
+
const probe = options.performanceProbe;
|
|
156
|
+
if (!probe?.running) return;
|
|
157
|
+
const sync = probe.beginWork('archive-preparation-sync'), lifetime = probe.beginWork('archive-preparation-lifetime');
|
|
158
|
+
return (stage, succeeded) => stage === 'sync' ? sync?.(succeeded) : lifetime?.(succeeded, true);
|
|
159
|
+
} });
|
|
160
|
+
if (renderOptions.get('rhineShelf') !== 'geometry') {
|
|
161
|
+
preparation.enqueue('shelf-interior', () => original.prepareShelfInterior(), 20);
|
|
162
|
+
for (let view = 0; view < 4; view++) preparation.enqueue(`shelf-view:${view}`, () => original.prepareShelfView(view), -1);
|
|
163
|
+
}
|
|
164
|
+
const pendingShelf = new Map<string, { settleWaiting: boolean; run?: () => void }>();
|
|
165
|
+
// Build the small moving reserve between frames before a result needs it.
|
|
166
|
+
for (let index = 0; index < 3; index++)
|
|
167
|
+
preparation.enqueue(`moving:${index}`, () => original.prepareMovingFile(), 10);
|
|
168
|
+
const activityFiles: ActivityFile[] = [];
|
|
169
|
+
const activityQueue: ActivityRequest[] = [];
|
|
170
|
+
const observedOperations = new Map<string, ArchiveOperation['state']>();
|
|
171
|
+
const historicalOperations = new Set<string>();
|
|
172
|
+
const scheduledReads = new Set<string>();
|
|
173
|
+
let operations = new Map<string, ArchiveOperation>();
|
|
174
|
+
let operationsInitialized = false;
|
|
175
|
+
let activitySpacing = 0;
|
|
176
|
+
const events = new AbortController();
|
|
177
|
+
// The board shares the existing world's renderer, lighting and camera.
|
|
178
|
+
const evidenceBoard = createEvidenceBoard(texture => original.createPrintMaterial(texture), { interactiveHeading: true });
|
|
179
|
+
evidenceBoard.group.userData.performanceFamily = "board";
|
|
180
|
+
const readingObject = createReadingObject(texture => original.createPrintMaterial(texture));
|
|
181
|
+
original.scene.add(readingObject.group);
|
|
182
|
+
let boardCards: EvidenceCard[] = [];
|
|
183
|
+
let boardTool: EvidenceBoardTool = { mode: 'select' };
|
|
184
|
+
const boardAnchors = evidenceBoard.getToolAnchors();
|
|
185
|
+
// Same strip as the RHINE LAB nameplate, recessed inside the board rim.
|
|
186
|
+
const controlCorners = [new THREE.Vector3(3.67, 4.32, 0.181), new THREE.Vector3(7.37, 4.32, 0.181),
|
|
187
|
+
new THREE.Vector3(7.37, 3.80, 0.181), new THREE.Vector3(3.67, 3.80, 0.181)];
|
|
188
|
+
const surface = INVESTIGATION_BOARD_PLANE;
|
|
189
|
+
const surfaceCorners = [new THREE.Vector3(surface.left, surface.top, surface.z),
|
|
190
|
+
new THREE.Vector3(surface.left + surface.width, surface.top, surface.z),
|
|
191
|
+
new THREE.Vector3(surface.left + surface.width, surface.top - surface.height, surface.z),
|
|
192
|
+
new THREE.Vector3(surface.left, surface.top - surface.height, surface.z)];
|
|
193
|
+
const projectedAnchor = new THREE.Vector3();
|
|
194
|
+
const boardAnchorFrame: BoardAnchorFrame = { corner: { x: 0, y: 0, visible: false },
|
|
195
|
+
chalk: { x: 0, y: 0, visible: false },
|
|
196
|
+
controls: [{ x: 0, y: 0, visible: false }, { x: 0, y: 0, visible: false },
|
|
197
|
+
{ x: 0, y: 0, visible: false }, { x: 0, y: 0, visible: false }],
|
|
198
|
+
surface: [{ x: 0, y: 0, visible: false }, { x: 0, y: 0, visible: false },
|
|
199
|
+
{ x: 0, y: 0, visible: false }, { x: 0, y: 0, visible: false }],
|
|
200
|
+
width: 0, height: 0, zoom: 1, visible: false };
|
|
201
|
+
let anchorWidth = host.clientWidth, anchorHeight = host.clientHeight, anchorsDirty = true;
|
|
202
|
+
function syncBoardAnchors() {
|
|
203
|
+
if (!options.onBoardAnchors) return;
|
|
204
|
+
const visible = location === 'board' && !detail && !cameraTravel && active && !contextLost;
|
|
205
|
+
let dirty = anchorsDirty || boardAnchorFrame.visible !== visible || boardAnchorFrame.zoom !== boardZoom;
|
|
206
|
+
boardAnchorFrame.visible = visible; boardAnchorFrame.zoom = boardZoom;
|
|
207
|
+
boardAnchorFrame.width = anchorWidth; boardAnchorFrame.height = anchorHeight;
|
|
208
|
+
if (visible) for (const name of ['corner', 'chalk'] as const) {
|
|
209
|
+
evidenceBoard.group.localToWorld(projectedAnchor.copy(boardAnchors[name])).project(original.camera);
|
|
210
|
+
const x = Math.round((projectedAnchor.x + 1) * anchorWidth) / 2;
|
|
211
|
+
const y = Math.round((1 - projectedAnchor.y) * anchorHeight) / 2;
|
|
212
|
+
const inView = projectedAnchor.z >= -1 && projectedAnchor.z <= 1
|
|
213
|
+
&& x >= 0 && x <= anchorWidth && y >= 0 && y <= anchorHeight;
|
|
214
|
+
const previous = boardAnchorFrame[name];
|
|
215
|
+
if (previous.x !== x || previous.y !== y || previous.visible !== inView) dirty = true;
|
|
216
|
+
previous.x = x; previous.y = y; previous.visible = inView;
|
|
217
|
+
}
|
|
218
|
+
if (visible) for (let index = 0; index < 4; index++) {
|
|
219
|
+
evidenceBoard.group.localToWorld(projectedAnchor.copy(controlCorners[index])).project(original.camera);
|
|
220
|
+
// Subpixel precision keeps the narrow label's perspective stable during camera travel.
|
|
221
|
+
const x = Math.round((projectedAnchor.x + 1) * anchorWidth * 50) / 100;
|
|
222
|
+
const y = Math.round((1 - projectedAnchor.y) * anchorHeight * 50) / 100;
|
|
223
|
+
const inView = projectedAnchor.z >= -1 && projectedAnchor.z <= 1
|
|
224
|
+
&& x >= 0 && x <= anchorWidth && y >= 0 && y <= anchorHeight;
|
|
225
|
+
const previous = boardAnchorFrame.controls[index];
|
|
226
|
+
if (previous.x !== x || previous.y !== y || previous.visible !== inView) dirty = true;
|
|
227
|
+
previous.x = x; previous.y = y; previous.visible = inView;
|
|
228
|
+
}
|
|
229
|
+
if (visible) for (let index = 0; index < 4; index++) {
|
|
230
|
+
evidenceBoard.group.localToWorld(projectedAnchor.copy(surfaceCorners[index])).project(original.camera);
|
|
231
|
+
const x = Math.round((projectedAnchor.x + 1) * anchorWidth * 50) / 100;
|
|
232
|
+
const y = Math.round((1 - projectedAnchor.y) * anchorHeight * 50) / 100;
|
|
233
|
+
// Zoom and pan may put the rim outside the viewport; that must not hide visible board content.
|
|
234
|
+
const inFront = projectedAnchor.z >= -1 && projectedAnchor.z <= 1;
|
|
235
|
+
const previous = boardAnchorFrame.surface[index];
|
|
236
|
+
if (previous.x !== x || previous.y !== y || previous.visible !== inFront) dirty = true;
|
|
237
|
+
previous.x = x; previous.y = y; previous.visible = inFront;
|
|
238
|
+
}
|
|
239
|
+
if (dirty) { anchorsDirty = false; options.onBoardAnchors(boardAnchorFrame); }
|
|
240
|
+
}
|
|
241
|
+
evidenceBoard.group.position.copy(BOARD_CENTER);
|
|
242
|
+
evidenceBoard.group.rotation.set(0, THREE.MathUtils.degToRad(-34), 0);
|
|
243
|
+
original.scene.add(evidenceBoard.group);
|
|
244
|
+
const evidenceInbox=createEvidenceInboxScene(texture=>original.createPrintMaterial(texture));
|
|
245
|
+
// The board's matrices are not yet rendered at construction time.
|
|
246
|
+
evidenceBoard.group.updateWorldMatrix(true,false);
|
|
247
|
+
evidenceInbox.group.position.set(-10.7,-9.35,1.7).applyMatrix4(evidenceBoard.group.matrixWorld);
|
|
248
|
+
evidenceInbox.group.quaternion.copy(evidenceBoard.group.quaternion);original.scene.add(evidenceInbox.group);
|
|
249
|
+
let inboxOpen=false,inboxAmount=0;
|
|
250
|
+
const inboxFocus=new THREE.Vector3();
|
|
251
|
+
|
|
252
|
+
const currentArchiveSource = () => lanes[archiveLaneIndex][laneRows[archiveLaneIndex]] || null;
|
|
253
|
+
const sourceNumber = (source: ArchiveSource) => {
|
|
254
|
+
const archive = sourceIndex(archiveSources, source);
|
|
255
|
+
if (archive >= 0) return archive;
|
|
256
|
+
const shelfIndex = sourceIndex(sourceList, source);
|
|
257
|
+
return shelfIndex >= 0 ? shelfIndex : sourceIndex(investigation?.sources || [], source);
|
|
258
|
+
};
|
|
259
|
+
const sourceSlot = (source: ArchiveSource) => {
|
|
260
|
+
const lane = archiveLane(source);
|
|
261
|
+
const position = Math.max(0, lanes[lane].findIndex(item => sourceIdentity(item) === sourceIdentity(source)));
|
|
262
|
+
return lane * 8 + wrap(slotRows[lane] + position - laneRows[lane], 8);
|
|
263
|
+
};
|
|
264
|
+
const readingActive = () => [...operations.values()].some(item => item.kind === 'read' && item.state === 'active')
|
|
265
|
+
|| (!!investigation?.running && investigation.phase === 'reading');
|
|
266
|
+
const scanningActive = () => searching || [...operations.values()].some(item => item.kind === 'search' && item.state === 'active')
|
|
267
|
+
|| (!!investigation?.searching && investigation.phase !== 'reading') || arrivalScanRemaining > 0;
|
|
268
|
+
const archiveBusy = () => readingActive() || scanningActive();
|
|
269
|
+
|
|
270
|
+
// Preserve the authored shell; resting files can share a captured interior.
|
|
271
|
+
const rack = new THREE.Group();
|
|
272
|
+
rack.userData.performanceFamily = "rack";
|
|
273
|
+
rack.position.copy(SHELF_CENTER);
|
|
274
|
+
const box = new THREE.BoxGeometry(1, 1, 1);
|
|
275
|
+
const rackMaterials = [
|
|
276
|
+
new THREE.MeshStandardMaterial({ color: '#e4ddd2', roughness: 0.72 }),
|
|
277
|
+
new THREE.MeshStandardMaterial({ color: '#eee8dd', roughness: 0.46 }),
|
|
278
|
+
new THREE.MeshStandardMaterial({ color: '#cbbb8d', roughness: 0.55, metalness: 0.1 }),
|
|
279
|
+
];
|
|
280
|
+
// Shared unit boxes reduce the complete two-tier frame to three draw batches.
|
|
281
|
+
const rackMatrices: THREE.Matrix4[][] = rackMaterials.map(() => []);
|
|
282
|
+
const partPose = new THREE.Object3D();
|
|
283
|
+
const rackPart = (position: [number, number, number], size: [number, number, number], materialIndex = 0) => {
|
|
284
|
+
partPose.position.set(...position); partPose.scale.set(...size); partPose.updateMatrix();
|
|
285
|
+
rackMatrices[materialIndex].push(partPose.matrix.clone());
|
|
286
|
+
};
|
|
287
|
+
for (let level = 0; level < SHELF_LEVELS; level++) {
|
|
288
|
+
const y = level * SHELF_LEVEL_SPACING;
|
|
289
|
+
rackPart([0, y + 0.12, 0], [RACK_WIDTH, 0.24, RACK_DEPTH]);
|
|
290
|
+
for (const z of [-RACK_DEPTH / 2, RACK_DEPTH / 2]) {
|
|
291
|
+
rackPart([0, y + 0.4, z], [RACK_WIDTH, 0.55, 0.14], 1);
|
|
292
|
+
rackPart([0, y + 0.69, z], [RACK_WIDTH, 0.025, 0.035], 2);
|
|
293
|
+
}
|
|
294
|
+
// Open front: the cassette slides out horizontally below the next tray.
|
|
295
|
+
rackPart([-RACK_WIDTH / 2, y + 0.20, 0], [0.14, 0.12, RACK_DEPTH], 1);
|
|
296
|
+
rackPart([RACK_WIDTH / 2, y + 0.7, 0], [0.14, 1.1, RACK_DEPTH], 1);
|
|
297
|
+
for (let slot = 0; slot < SHELF_SLOTS_PER_LEVEL; slot++) {
|
|
298
|
+
const { z } = shelfSlot(slot);
|
|
299
|
+
rackPart([0, y + 0.25, z], [RACK_WIDTH - 0.3, 0.035, 0.026], 2);
|
|
300
|
+
rackPart([-RACK_WIDTH / 2 - 0.075, y + 0.19, z], [0.018, 0.035, 0.14], 2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const x of [-RACK_WIDTH / 2 - 0.12, RACK_WIDTH / 2 + 0.12]) {
|
|
304
|
+
for (const z of [-RACK_DEPTH / 2, RACK_DEPTH / 2])
|
|
305
|
+
rackPart([x, SHELF_HEIGHT / 2, z], [0.16, SHELF_HEIGHT, 0.16], 1);
|
|
306
|
+
}
|
|
307
|
+
rackMatrices.forEach((matrices, materialIndex) => {
|
|
308
|
+
const mesh = new THREE.InstancedMesh(box, rackMaterials[materialIndex], matrices.length);
|
|
309
|
+
matrices.forEach((matrix, index) => mesh.setMatrixAt(index, matrix));
|
|
310
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
311
|
+
mesh.castShadow = true; mesh.receiveShadow = true; rack.add(mesh);
|
|
312
|
+
});
|
|
313
|
+
rack.name = 'Rhine_Archive_Rack';
|
|
314
|
+
rack.userData.workspaceLocation = 'desk';
|
|
315
|
+
evidenceBoard.group.userData.workspaceLocation = 'board';
|
|
316
|
+
original.scene.add(rack);
|
|
317
|
+
preparation.enqueue("workspace-programs", () => original.preparePrograms("workspace", [rack, evidenceBoard.group, evidenceInbox.group]), 15);
|
|
318
|
+
const collectionFocus = SHELF_CENTER.clone().add(new THREE.Vector3(0, SHELF_HEIGHT / 2, 0));
|
|
319
|
+
const collectionCenter = collectionFocus.clone();
|
|
320
|
+
const activityFrameCenter = new THREE.Vector3();
|
|
321
|
+
let activityFrameAmount = 0;
|
|
322
|
+
const readingPosition = SHELF_CENTER.clone().add(new THREE.Vector3(
|
|
323
|
+
SHELF_READING_POSITION.x, SHELF_READING_POSITION.y, SHELF_READING_POSITION.z));
|
|
324
|
+
const rackOrientation = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, SHELF_YAW, 0));
|
|
325
|
+
const deskPose = (id: string) => {
|
|
326
|
+
const { x, y, z } = shelfSlot(Math.max(0, visibleIds.indexOf(id)));
|
|
327
|
+
return SHELF_CENTER.clone().add(new THREE.Vector3(x, y, z));
|
|
328
|
+
};
|
|
329
|
+
const notifyShelf = () => options.onShelfSelect?.(focusedId, shelfPage);
|
|
330
|
+
const updateShelfMetadata = (file: CollectionFile, source: ArchiveSource) => {
|
|
331
|
+
original.setCollectionLabel(file.group, sourceNumber(source), { title: source.title, sourceId: source.id });
|
|
332
|
+
(file.marker.material as THREE.MeshStandardMaterial).color.set(
|
|
333
|
+
source.state === 'cited' ? '#b59958' : source.state === 'read' ? '#748465' : '#aaa89a',
|
|
334
|
+
);
|
|
335
|
+
file.saved.visible = !!source.saved;
|
|
336
|
+
};
|
|
337
|
+
const prepareShelfFile = (id: string) => {
|
|
338
|
+
const existing = pendingShelf.get(id);
|
|
339
|
+
if (existing) {
|
|
340
|
+
if (id === selectedId && existing.run) preparation.enqueue(`shelf:${id}`, existing.run, 5);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const source = sourceList.find(item => item.id === id)!;
|
|
344
|
+
// Capture the page-arrival decision before setSources queues this update's
|
|
345
|
+
// new transfers. Deferred construction must not mark those as delivered.
|
|
346
|
+
const pending: { settleWaiting: boolean; run?: () => void } = {
|
|
347
|
+
settleWaiting: waitingArrivals.has(sourceIdentity(source)),
|
|
348
|
+
};
|
|
349
|
+
pendingShelf.set(id, pending);
|
|
350
|
+
pending.run = () => {
|
|
351
|
+
pendingShelf.delete(id);
|
|
352
|
+
if (disposed || files.has(id) || !visibleIds.includes(id)) return;
|
|
353
|
+
const source = sourceList.find(item => item.id === id);
|
|
354
|
+
if (!source) return;
|
|
355
|
+
const group = original.createCollectionFile(sourceList.indexOf(source), { shelf: true, label: { code: `NO.${String(sourceNumber(source) + 1).padStart(3, "0")}`, title: source.title, sourceId: source.id } });
|
|
356
|
+
group.userData.sourceId = id; group.userData.workspaceLocation = 'desk';
|
|
357
|
+
group.position.copy(deskPose(id)); group.quaternion.copy(rackOrientation);
|
|
358
|
+
group.visible = !boardFullscreen;
|
|
359
|
+
const marker = new THREE.Mesh(box, new THREE.MeshStandardMaterial({ roughness: 0.52 }));
|
|
360
|
+
marker.position.set(-1.96, 3.45, 0.28); marker.scale.set(0.13, 0.33, 0.065);
|
|
361
|
+
const saved = new THREE.Mesh(box, new THREE.MeshStandardMaterial({ color: '#25291f', roughness: 0.58 }));
|
|
362
|
+
saved.position.set(-1.7, 3.61, 0.22); saved.scale.set(0.17, 0.10, 0.11);
|
|
363
|
+
group.add(marker, saved); original.scene.add(group);
|
|
364
|
+
const waiting = pending.settleWaiting && waitingArrivals.delete(sourceIdentity(source));
|
|
365
|
+
if (waiting) transferred.add(sourceIdentity(source));
|
|
366
|
+
const file = { id, group, marker, saved, progress: 0, focus: 0, arrival: waiting && !reduced ? 0 : 1 };
|
|
367
|
+
files.set(id, file);
|
|
368
|
+
updateShelfMetadata(file, source);
|
|
369
|
+
// Allocate/upload the owned label here as well, not when the rack first
|
|
370
|
+
// enters the camera and the page's textures would otherwise upload at once.
|
|
371
|
+
original.prepareCollectionTexture(group);
|
|
372
|
+
wake();
|
|
373
|
+
};
|
|
374
|
+
preparation.enqueue(`shelf:${id}`, pending.run, id === selectedId ? 5 : 0);
|
|
375
|
+
};
|
|
376
|
+
const syncCollection = () => {
|
|
377
|
+
shelfPage = Math.max(0, Math.min(shelfPage, Math.ceil(sourceList.length / SHELF_PAGE_SIZE) - 1));
|
|
378
|
+
visibleIds = sourceList.slice(shelfPage * SHELF_PAGE_SIZE, (shelfPage + 1) * SHELF_PAGE_SIZE).map(source => source.id);
|
|
379
|
+
for (const id of pendingShelf.keys()) if (!visibleIds.includes(id)) {
|
|
380
|
+
preparation.cancel(`shelf:${id}`); pendingShelf.delete(id);
|
|
381
|
+
}
|
|
382
|
+
for (const [id, file] of files) {
|
|
383
|
+
if (!visibleIds.includes(id)) {
|
|
384
|
+
original.disposeCollectionFile(file.group); files.delete(id);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!focusedId || !visibleIds.includes(focusedId)) focusedId = visibleIds[0] || null;
|
|
388
|
+
for (const id of visibleIds) {
|
|
389
|
+
const source = sourceList.find(item => item.id === id)!;
|
|
390
|
+
const file = files.get(id);
|
|
391
|
+
if (!file) {
|
|
392
|
+
prepareShelfFile(id);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
updateShelfMetadata(file, source);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const showShelfPage = (page: number) => {
|
|
399
|
+
if (disposed || (detail && selectedId)) return;
|
|
400
|
+
const next = Math.max(0, Math.min(Math.floor(page), Math.ceil(sourceList.length / SHELF_PAGE_SIZE) - 1));
|
|
401
|
+
if (next === shelfPage) return;
|
|
402
|
+
prioritizeUser(); shelfPage = next; syncCollection(); notifyShelf(); wake();
|
|
403
|
+
};
|
|
404
|
+
const browseShelf = (direction: number) => {
|
|
405
|
+
if (disposed || !sourceList.length || (detail && selectedId)) return;
|
|
406
|
+
const delta = Math.trunc(direction);
|
|
407
|
+
if (!Number.isFinite(delta) || delta === 0) return;
|
|
408
|
+
const current = sourceList.findIndex(source => source.id === focusedId);
|
|
409
|
+
const next = wrap(Math.max(0, current) + delta, sourceList.length);
|
|
410
|
+
shelfPage = Math.floor(next / SHELF_PAGE_SIZE);
|
|
411
|
+
focusedId = sourceList[next].id;
|
|
412
|
+
syncCollection(); notifyShelf(); wake();
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
let labelPrefetchKey = '', labelPrefetchPlan = 0;
|
|
416
|
+
let hoveredArchive: { index: number; cell?: ArchiveCell } | null = null;
|
|
417
|
+
let activityLabelSource: ArchiveSource | null = null;
|
|
418
|
+
const pendingLabelPrefetch = new Map<number, { plan: number; slot: number; intent: LabelCandidate<ArchiveSource>['axis'] }>();
|
|
419
|
+
const pointerTarget = (index: number, cell?: ArchiveCell) => archivePointerTarget(
|
|
420
|
+
lanes.map(items => items.length), laneRows, slotRows, archiveLaneIndex, original.selectedArchiveCell, index, cell);
|
|
421
|
+
const archivePrint = (source: ArchiveSource, activity = false) => {
|
|
422
|
+
const number = sourceNumber(source);
|
|
423
|
+
return { code: activity && number < 0 ? 'READING' : `NO.${String(number + 1).padStart(3, '0')}`,
|
|
424
|
+
title: source.title, sourceId: source.id };
|
|
425
|
+
};
|
|
426
|
+
const syncLabelPrefetch = () => {
|
|
427
|
+
if (disposed) return;
|
|
428
|
+
const preferred: LabelCandidate<ArchiveSource>[] = [];
|
|
429
|
+
if (hoveredArchive && location === 'archive' && !detail && !cameraTravel && active && !contextLost) {
|
|
430
|
+
const target = pointerTarget(hoveredArchive.index, hoveredArchive.cell);
|
|
431
|
+
const item = target && lanes[target.lane]?.[target.row];
|
|
432
|
+
if (target && item) preferred.push({ item, lane: target.lane, row: target.row, axis: 'pointer', offset: target.delta });
|
|
433
|
+
}
|
|
434
|
+
if (!reduced) {
|
|
435
|
+
const upcoming = [activityLabelSource, ...activityQueue.filter(item => item.kind === 'read').map(item => item.source),
|
|
436
|
+
...activityQueue.filter(item => item.kind !== 'read').map(item => item.source)];
|
|
437
|
+
let activityCount = 0;
|
|
438
|
+
for (const source of upcoming) {
|
|
439
|
+
if (!source) continue;
|
|
440
|
+
const item = archiveSources.find(item => sourceIdentity(item) === sourceIdentity(source)) || source;
|
|
441
|
+
if (preferred.some(candidate => candidate.item === item)) continue;
|
|
442
|
+
const lane = archiveLane(item), row = lanes[lane].findIndex(source => source.id === item.id);
|
|
443
|
+
preferred.push({ item, lane, row, axis: 'activity', offset: 0 });
|
|
444
|
+
if (++activityCount === MAX_ACTIVITY_FILES) break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const targets = location === 'archive' ? archiveLabelCandidates(lanes, laneRows, archiveLaneIndex, 6, preferred) : preferred;
|
|
448
|
+
const candidates = targets.map(target => archivePrint(target.item, target.axis === 'activity'));
|
|
449
|
+
const prefetchKey = JSON.stringify([document.fonts?.status || 'loaded', targets.map(t => t.axis), candidates]);
|
|
450
|
+
if (prefetchKey === labelPrefetchKey) return;
|
|
451
|
+
labelPrefetchKey = prefetchKey;
|
|
452
|
+
const plan = ++labelPrefetchPlan;
|
|
453
|
+
original.setArchiveLabelCandidates(candidates);
|
|
454
|
+
for (let n = 0; n < 6; n++) {
|
|
455
|
+
const key = `label-prefetch:${n}`, old = pendingLabelPrefetch.get(n);
|
|
456
|
+
preparation.cancel(key); pendingLabelPrefetch.delete(n);
|
|
457
|
+
if (old) options.performanceProbe?.diagnostic('resourceUpdates', { kind: 'label-prefetch-state', state: 'cancelled', ...old });
|
|
458
|
+
if (!candidates[n]) continue;
|
|
459
|
+
const target = targets[n], context = { plan, slot: n, intent: target.axis }; pendingLabelPrefetch.set(n, context);
|
|
460
|
+
options.performanceProbe?.diagnostic('resourceUpdates', { kind: 'label-prefetch-state', state: 'queued', ...context,
|
|
461
|
+
lane: target.lane, row: target.row, axis: target.axis, offset: target.offset });
|
|
462
|
+
preparation.enqueue(key, () => {
|
|
463
|
+
pendingLabelPrefetch.delete(n);
|
|
464
|
+
options.performanceProbe?.diagnostic('resourceUpdates', { kind: 'label-prefetch-state', state: 'started', ...context });
|
|
465
|
+
try { original.prepareArchiveLabel(candidates[n], context); }
|
|
466
|
+
catch (error) { options.performanceProbe?.diagnostic('resourceUpdates', { kind: 'label-prefetch-state', state: 'failed', ...context }); throw error; }
|
|
467
|
+
}, target.axis === 'pointer' ? 5 : target.axis === 'activity' ? 4 : -2);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
const notifyArchive = () => {
|
|
471
|
+
const source = currentArchiveSource();
|
|
472
|
+
const inspected = location === 'desk' && selectedId ? sourceList.find(item => item.id === selectedId) : undefined;
|
|
473
|
+
const labelSource = inspected || source;
|
|
474
|
+
original.setArchiveLabel(labelSource ? archivePrint(labelSource) : null);
|
|
475
|
+
syncLabelPrefetch();
|
|
476
|
+
options.onArchiveSelect?.(archiveIndex);
|
|
477
|
+
// Empty lanes still participate in the original mechanical loop. Keep
|
|
478
|
+
// the last real UI selection, otherwise source synchronization repeatedly
|
|
479
|
+
// falls back to the first result and pulls the moving track backwards.
|
|
480
|
+
if (source || !archiveBusy()) options.onArchiveSourceSelect?.(source?.id || null, archiveLaneIndex, userOwnsView());
|
|
481
|
+
};
|
|
482
|
+
const chooseArchive = (index: number, navigation?: ArchiveNavigation) => {
|
|
483
|
+
archiveIndex = wrap(index, 40);
|
|
484
|
+
archiveLaneIndex = fileLocation(archiveIndex).lane;
|
|
485
|
+
slotRows[archiveLaneIndex] = archiveIndex % 8;
|
|
486
|
+
if (detail) { detail = false; original.setMode('archive'); }
|
|
487
|
+
original.select(archiveIndex, navigation);
|
|
488
|
+
notifyArchive(); wake();
|
|
489
|
+
};
|
|
490
|
+
const stepArchive = (axis: 'row' | 'lane', direction: number) => {
|
|
491
|
+
if (disposed || location !== 'archive' || detail) return;
|
|
492
|
+
const step = direction < 0 ? -1 : 1;
|
|
493
|
+
if (axis === 'row') {
|
|
494
|
+
const count = lanes[archiveLaneIndex].length;
|
|
495
|
+
if (count) laneRows[archiveLaneIndex] = wrap(laneRows[archiveLaneIndex] + step, count);
|
|
496
|
+
chooseArchive(archiveLaneIndex * 8 + wrap(slotRows[archiveLaneIndex] + step, 8), { axis: 'row', direction: step });
|
|
497
|
+
} else {
|
|
498
|
+
const next = wrap(archiveLaneIndex + step, archiveColumns.length);
|
|
499
|
+
chooseArchive(next * 8 + slotRows[next], { axis: 'lane', direction: step });
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
const acceptArchiveNavigation = (intent: Record<string, string | number> = {}) => {
|
|
503
|
+
if (disposed || !active || contextLost || document.hidden || location !== 'archive' || detail) return false;
|
|
504
|
+
prioritizeUser();
|
|
505
|
+
if (!navigationLimiter.tryAccept(performance.now(), original.returningFileCount)) return false;
|
|
506
|
+
hoveredArchive = null;
|
|
507
|
+
options.performanceProbe?.mark('archive-navigation', { returningFiles: original.returningFileCount, fromLane: archiveLaneIndex, fromRow: laneRows[archiveLaneIndex], ...intent });
|
|
508
|
+
return true;
|
|
509
|
+
};
|
|
510
|
+
const navigate = (axis: 'row' | 'lane', direction: number) => {
|
|
511
|
+
const step = direction < 0 ? -1 : 1, targetLane = axis === 'lane' ? wrap(archiveLaneIndex + step, lanes.length) : archiveLaneIndex;
|
|
512
|
+
const count = lanes[targetLane].length, targetRow = count ? wrap(laneRows[targetLane] + (axis === 'row' ? step : 0), count) : 0;
|
|
513
|
+
if (!direction || !acceptArchiveNavigation({ method: 'step', axis, direction: step, targetLane, targetRow })) return false;
|
|
514
|
+
stepArchive(axis, direction);
|
|
515
|
+
return true;
|
|
516
|
+
};
|
|
517
|
+
const focusArchiveSource = (id: string) => {
|
|
518
|
+
const source = archiveSources.find(item => item.id === id);
|
|
519
|
+
if (!source || disposed) return;
|
|
520
|
+
const lane = archiveLane(source);
|
|
521
|
+
const index = sourceSlot(source);
|
|
522
|
+
laneRows[lane] = lanes[lane].indexOf(source);
|
|
523
|
+
// Preserve the current physical occurrence in the infinite track when
|
|
524
|
+
// incoming source metadata reselects a document already under the cursor.
|
|
525
|
+
chooseArchive(index);
|
|
526
|
+
};
|
|
527
|
+
const selectArchiveSource = (id: string, userInitiated = true) => {
|
|
528
|
+
if (userInitiated) prioritizeUser();
|
|
529
|
+
focusArchiveSource(id);
|
|
530
|
+
};
|
|
531
|
+
// Browsing ticks are rate limited; explicit Open and data synchronization
|
|
532
|
+
// continue to select their requested source immediately and accurately.
|
|
533
|
+
const browseArchiveSource = (id: string) => {
|
|
534
|
+
const source = archiveSources.find(item => item.id === id);
|
|
535
|
+
if (!source || id === currentArchiveSource()?.id) return false;
|
|
536
|
+
const lane = archiveLane(source);
|
|
537
|
+
if (!acceptArchiveNavigation({ method: 'source', targetLane: lane, targetRow: lanes[lane].indexOf(source) })) return false;
|
|
538
|
+
focusArchiveSource(id); return true;
|
|
539
|
+
};
|
|
540
|
+
const setArchiveSources = (next: ArchiveSource[]) => {
|
|
541
|
+
if (disposed) return;
|
|
542
|
+
const previous = currentArchiveSource();
|
|
543
|
+
const previousLaneIds = lanes.map((sources, lane) => sources[laneRows[lane]]?.id);
|
|
544
|
+
archiveSources = [...new Map(next.map(source => [sourceIdentity(source), source])).values()];
|
|
545
|
+
lanes = archiveColumns.map((_, lane) => archiveSources.filter(source => archiveLane(source) === lane));
|
|
546
|
+
lanes.forEach((sources, lane) => {
|
|
547
|
+
const old = sources.findIndex(source => source.id === previousLaneIds[lane]);
|
|
548
|
+
laneRows[lane] = old >= 0 ? old : Math.min(laneRows[lane], Math.max(0, sources.length - 1));
|
|
549
|
+
});
|
|
550
|
+
if (!previous && archiveSources.length && !currentArchiveSource() && location === 'archive' && !detail && !archiveBusy()) {
|
|
551
|
+
const first = archiveSources[0];
|
|
552
|
+
archiveLaneIndex = archiveLane(first);
|
|
553
|
+
laneRows[archiveLaneIndex] = lanes[archiveLaneIndex].indexOf(first);
|
|
554
|
+
chooseArchive(archiveLaneIndex * 8 + slotRows[archiveLaneIndex]);
|
|
555
|
+
} else notifyArchive();
|
|
556
|
+
for (const [id, file] of files) {
|
|
557
|
+
const source = sourceList.find(item => item.id === id);
|
|
558
|
+
if (source) updateShelfMetadata(file, source);
|
|
559
|
+
}
|
|
560
|
+
wake();
|
|
561
|
+
};
|
|
562
|
+
original.onLabelOpen = id => {
|
|
563
|
+
if (!disposed && active && location === 'archive' && !detail && archiveSources.some(source => source.id === id)) {
|
|
564
|
+
prioritizeUser(); options.onArchiveSourceOpen?.(id);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
let arrayPointerAccepted = true;
|
|
568
|
+
original.onHover = (index, cell) => {
|
|
569
|
+
const next = index !== null && active && !contextLost && location === 'archive' && !detail && !cameraTravel
|
|
570
|
+
? { index, cell } : null;
|
|
571
|
+
if (next?.index === hoveredArchive?.index && next?.cell?.lane === hoveredArchive?.cell?.lane
|
|
572
|
+
&& next?.cell?.row === hoveredArchive?.cell?.row) return;
|
|
573
|
+
hoveredArchive = next; syncLabelPrefetch();
|
|
574
|
+
};
|
|
575
|
+
original.onSelect = (index, cell) => {
|
|
576
|
+
if (location !== 'archive' || detail) return;
|
|
577
|
+
const target = pointerTarget(index, cell);
|
|
578
|
+
// The selected cell opens the reader; it does not step again.
|
|
579
|
+
if (!target) { arrayPointerAccepted = true; return; }
|
|
580
|
+
const { lane, row, delta } = target;
|
|
581
|
+
arrayPointerAccepted = acceptArchiveNavigation({ method: 'scene-pick', targetLane: lane, targetRow: row, rowDelta: delta });
|
|
582
|
+
if (!arrayPointerAccepted) return;
|
|
583
|
+
if (lanes[lane].length) laneRows[lane] = row;
|
|
584
|
+
chooseArchive(index, cell ? { cell } : undefined);
|
|
585
|
+
};
|
|
586
|
+
original.onNavigate = (axis, direction) => {
|
|
587
|
+
if (location === 'desk') browseShelf(direction);
|
|
588
|
+
else navigate(axis, direction);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const setSources = (next: ArchiveSource[], animate = true) => {
|
|
592
|
+
options.performanceProbe?.mark('sources-update', { count: next.length, animate });
|
|
593
|
+
if (disposed) return;
|
|
594
|
+
const unique = [...new Map(next.map(source => [sourceIdentity(source), source])).values()];
|
|
595
|
+
const priorIdentities = new Set(sourceList.map(sourceIdentity));
|
|
596
|
+
const byIdentity = new Map(unique.map(source => [sourceIdentity(source), source]));
|
|
597
|
+
// Incoming results may be re-sorted by relevance. Shelf placement remains
|
|
598
|
+
// the user's arrival order, including unchanged identities with new labels.
|
|
599
|
+
sourceList = [
|
|
600
|
+
...sourceList.flatMap(old => {
|
|
601
|
+
const source = byIdentity.get(sourceIdentity(old));
|
|
602
|
+
if (!source) return [];
|
|
603
|
+
byIdentity.delete(sourceIdentity(old));
|
|
604
|
+
return [{ ...source, id: old.id }];
|
|
605
|
+
}),
|
|
606
|
+
...byIdentity.values(),
|
|
607
|
+
];
|
|
608
|
+
sourceIds = new Set(sourceList.map(source => source.id));
|
|
609
|
+
if (selectedId && !sourceIds.has(selectedId)) selectedId = null;
|
|
610
|
+
const priorFocus = focusedId;
|
|
611
|
+
const priorPage = shelfPage;
|
|
612
|
+
syncCollection();
|
|
613
|
+
for (const item of [...activityQueue, ...activityFiles]) {
|
|
614
|
+
const source = item.source && sourceList.find(source => sourceIdentity(source) === sourceIdentity(item.source!));
|
|
615
|
+
if (!source) continue;
|
|
616
|
+
item.source = source;
|
|
617
|
+
if ('group' in item) original.setCollectionLabel((item as ActivityFile).group, sourceNumber(source), { title: source.title, sourceId: source.id });
|
|
618
|
+
}
|
|
619
|
+
const arrivals = sourceList.filter(source => !priorIdentities.has(sourceIdentity(source))
|
|
620
|
+
&& (!source.callId || !historicalOperations.has(source.callId)
|
|
621
|
+
&& (investigation?.operations === undefined || operations.has(source.callId))));
|
|
622
|
+
if (animate && arrivals.length) arrivalScanRemaining = Math.max(arrivalScanRemaining, 2.8);
|
|
623
|
+
// Every result is available immediately. A large batch uses up to three
|
|
624
|
+
// actual documents to show its arrival, without minutes of obsolete motion.
|
|
625
|
+
for (const source of arrivals) {
|
|
626
|
+
if (!animate || reduced) transferred.add(sourceIdentity(source));
|
|
627
|
+
else waitingArrivals.add(sourceIdentity(source));
|
|
628
|
+
}
|
|
629
|
+
if (animate && !reduced && active && !document.hidden) {
|
|
630
|
+
const candidates = arrivals.filter(source => !hasReadActivity(source));
|
|
631
|
+
for (const source of candidates.slice(0, 3)) enqueueActivity({
|
|
632
|
+
key: `arrival:${sourceIdentity(source)}`, source, kind: 'arrival', age: 0,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (priorFocus !== focusedId || priorPage !== shelfPage) notifyShelf();
|
|
636
|
+
wake();
|
|
637
|
+
};
|
|
638
|
+
const hasReadActivity = (source: ArchiveSource) => [...activityQueue, ...activityFiles].some(item =>
|
|
639
|
+
item.kind === 'read' && item.source && sourceIdentity(item.source) === sourceIdentity(source));
|
|
640
|
+
const enqueueActivity = (request: ActivityRequest) => {
|
|
641
|
+
if (activityQueue.some(item => item.key === request.key) || activityFiles.some(item => item.key === request.key)) return;
|
|
642
|
+
if (request.kind === 'read' && request.source) {
|
|
643
|
+
// A returned document and its read receipt can arrive in the same frame.
|
|
644
|
+
// Give that single document a reading sequence instead of a second copy.
|
|
645
|
+
const arrival = activityQueue.findIndex(item => item.kind === 'arrival' && item.source
|
|
646
|
+
&& sourceIdentity(item.source) === sourceIdentity(request.source!));
|
|
647
|
+
if (arrival >= 0) activityQueue.splice(arrival, 1);
|
|
648
|
+
const extracting = activityFiles.find(item => item.kind === 'arrival' && item.stage !== 'travelling'
|
|
649
|
+
&& item.source && sourceIdentity(item.source) === sourceIdentity(request.source!));
|
|
650
|
+
if (extracting) {
|
|
651
|
+
Object.assign(extracting, request);
|
|
652
|
+
original.setCollectionLabel(extracting.group, sourceNumber(request.source), { title: request.source.title, sourceId: request.source.id });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
activityQueue.push(request);
|
|
657
|
+
while (activityQueue.length > MAX_ACTIVITY_QUEUE) {
|
|
658
|
+
const disposable = activityQueue.findIndex(item => item.kind === 'arrival');
|
|
659
|
+
activityQueue.splice(disposable >= 0 ? disposable : 0, 1);
|
|
660
|
+
}
|
|
661
|
+
syncLabelPrefetch();
|
|
662
|
+
};
|
|
663
|
+
const clearActivities = () => {
|
|
664
|
+
for (const item of activityFiles) original.disposeCollectionFile(item.group);
|
|
665
|
+
activityFiles.length = 0; activityQueue.length = 0; activityLabelSource = null;
|
|
666
|
+
syncLabelPrefetch();
|
|
667
|
+
original.setInvestigationSlots([]);
|
|
668
|
+
};
|
|
669
|
+
const operationSources = (operation: ArchiveOperation): ArchiveSource[] => {
|
|
670
|
+
const available = [...new Map([...(investigation?.sources || []), ...archiveSources, ...sourceList]
|
|
671
|
+
.map(source => [sourceIdentity(source), source])).values()];
|
|
672
|
+
const returned = operation.sourceIds.flatMap(id => {
|
|
673
|
+
const receipt = investigation?.sources.find(source => source.id === id);
|
|
674
|
+
const source = available.find(source => source.id === id || receipt && sourceIdentity(source) === sourceIdentity(receipt));
|
|
675
|
+
return source ? [source] : [];
|
|
676
|
+
});
|
|
677
|
+
if (returned.length) return returned;
|
|
678
|
+
if (operation.state !== 'active') return [];
|
|
679
|
+
if (operation.tool === 'web_fetch') {
|
|
680
|
+
const source = operationSource(operation, available);
|
|
681
|
+
return source ? [source] : [];
|
|
682
|
+
}
|
|
683
|
+
const matches = available.filter(source => (!operation.dataVersion || source.dataVersion === operation.dataVersion)
|
|
684
|
+
&& (operation.documentId ? source.documentId === operation.documentId
|
|
685
|
+
: operation.documentUid ? source.documentUid === operation.documentUid
|
|
686
|
+
: operation.sourceRef ? source.sourceRef === operation.sourceRef : false));
|
|
687
|
+
return matches.length === 1 ? matches : [];
|
|
688
|
+
};
|
|
689
|
+
const observeOperations = () => {
|
|
690
|
+
const snapshot = investigation;
|
|
691
|
+
if (!snapshot) return;
|
|
692
|
+
// Legacy hosts still get an honest unnumbered mechanism for an active read.
|
|
693
|
+
const current: ArchiveOperation[] = snapshot.operations || (snapshot.running && snapshot.phase === 'reading' ? [{
|
|
694
|
+
id: `legacy:${snapshot.investigationId || ''}:${snapshot.tool}:${snapshot.activeDocumentId || snapshot.activeSourceRef || ''}`,
|
|
695
|
+
tool: snapshot.tool, kind: 'read', state: 'active', sourceIds: [],
|
|
696
|
+
documentId: snapshot.activeDocumentId, sourceRef: snapshot.activeSourceRef, dataVersion: snapshot.activeDataVersion,
|
|
697
|
+
}] : []);
|
|
698
|
+
const priorOperations = operations;
|
|
699
|
+
operations = new Map(current.map(operation => [operation.id, operation]));
|
|
700
|
+
for (const activity of [...activityQueue, ...activityFiles]) {
|
|
701
|
+
const prior = activity.operationId && priorOperations.get(activity.operationId);
|
|
702
|
+
// Sending the next question must not cancel an already confirmed receipt
|
|
703
|
+
// while its visual lift is still playing. Missing active calls do retire.
|
|
704
|
+
if (prior && prior.state === 'complete' && !operations.has(prior.id)) operations.set(prior.id, prior);
|
|
705
|
+
}
|
|
706
|
+
for (const operation of current) {
|
|
707
|
+
const previous = observedOperations.get(operation.id);
|
|
708
|
+
observedOperations.set(operation.id, operation.state);
|
|
709
|
+
// Opening an old conversation establishes a baseline, never a replay.
|
|
710
|
+
const historical = operation.state !== 'active' && (!operationsInitialized
|
|
711
|
+
|| previous === undefined && (Number.isFinite(operation.completedAt)
|
|
712
|
+
? operation.completedAt! < activitySince : snapshot.loadingHistory));
|
|
713
|
+
if (historical) historicalOperations.add(operation.id);
|
|
714
|
+
if (operation.kind !== 'read' || operation.state === 'error' || historical
|
|
715
|
+
|| previous === 'complete' && !scheduledReads.has(`${operation.id}:0`)) continue;
|
|
716
|
+
const returned = operationSources(operation);
|
|
717
|
+
const targets: (ArchiveSource | undefined)[] = returned.length ? returned : [undefined];
|
|
718
|
+
targets.forEach((source, index) => {
|
|
719
|
+
const key = `${operation.id}:${index}`;
|
|
720
|
+
const existing = [...activityQueue, ...activityFiles].find(item => item.key === key);
|
|
721
|
+
if (existing && source) {
|
|
722
|
+
existing.source = source;
|
|
723
|
+
if ('group' in existing) {
|
|
724
|
+
const actor = existing as ActivityFile;
|
|
725
|
+
const number = sourceNumber(source);
|
|
726
|
+
original.setCollectionLabel(actor.group, number >= 0 ? number : null, { title: source.title, sourceId: source.id });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (scheduledReads.has(key) || !active || document.hidden || reduced) return;
|
|
730
|
+
scheduledReads.add(key);
|
|
731
|
+
enqueueActivity({ key, operationId: operation.id, source, kind: 'read', age: 0 });
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
operationsInitialized = true;
|
|
735
|
+
// The snapshot only contains this turn. Bound retained IDs across long chats.
|
|
736
|
+
if (observedOperations.size > 2048) {
|
|
737
|
+
for (const id of observedOperations.keys()) if (!operations.has(id)) observedOperations.delete(id);
|
|
738
|
+
for (const id of historicalOperations) if (!operations.has(id)) historicalOperations.delete(id);
|
|
739
|
+
for (const key of scheduledReads) if (!current.some(item => key.startsWith(`${item.id}:`))) scheduledReads.delete(key);
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
const setInvestigation = (snapshot: InvestigationSnapshot) => {
|
|
743
|
+
if (disposed) return;
|
|
744
|
+
investigation = snapshot;
|
|
745
|
+
if (sessionId !== null && sessionId !== snapshot.sessionId) {
|
|
746
|
+
setBoardTool({ mode: 'select' });
|
|
747
|
+
clearActivities(); transferred.clear(); waitingArrivals.clear();
|
|
748
|
+
observedOperations.clear(); historicalOperations.clear(); scheduledReads.clear(); operations.clear(); operationsInitialized = false;
|
|
749
|
+
activitySince = Date.now();
|
|
750
|
+
userPriorityUntil = 0; searching = false; arrivalScanRemaining = 0;
|
|
751
|
+
scanElapsed = ARCHIVE_STEP_SECONDS - 0.35; scanSteps = 0;
|
|
752
|
+
selectedId = null; focusedId = null; shelfPage = 0;
|
|
753
|
+
setSources([], false); setArchiveSources([]);
|
|
754
|
+
}
|
|
755
|
+
sessionId = snapshot.sessionId;
|
|
756
|
+
// Local directory loading and host investigation state have independent
|
|
757
|
+
// lifetimes. Streaming snapshots must not reset the mechanical scan timer.
|
|
758
|
+
observeOperations(); wake();
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const occupyingArray = (actor: ActivityFile) => actor.stage !== 'travelling' || actor.elapsed < 2.4 * 0.28;
|
|
762
|
+
const releaseActivitySlot = (actor: ActivityFile) => {
|
|
763
|
+
if (actor.released) return;
|
|
764
|
+
original.departInvestigationSlot(actor.index);
|
|
765
|
+
actor.released = true;
|
|
766
|
+
};
|
|
767
|
+
const syncActivitySlots = () => {
|
|
768
|
+
for (const actor of activityFiles) if (!occupyingArray(actor)) releaseActivitySlot(actor);
|
|
769
|
+
original.setInvestigationSlots(activityFiles.filter(occupyingArray).map(item => item.index));
|
|
770
|
+
};
|
|
771
|
+
const finishActivity = (actor: ActivityFile) => {
|
|
772
|
+
if (actor.stage === 'travelling') releaseActivitySlot(actor);
|
|
773
|
+
if (actor.source && actor.stage === 'travelling') {
|
|
774
|
+
const identity = sourceIdentity(actor.source);
|
|
775
|
+
transferred.add(identity);
|
|
776
|
+
if (!visibleIds.some(id => sourceList.find(source => source.id === id && sourceIdentity(source) === identity))) waitingArrivals.add(identity);
|
|
777
|
+
else waitingArrivals.delete(identity);
|
|
778
|
+
const file = [...files.values()].find(file => {
|
|
779
|
+
const source = sourceList.find(source => source.id === file.id);
|
|
780
|
+
return source && sourceIdentity(source) === identity;
|
|
781
|
+
});
|
|
782
|
+
if (file) file.arrival = 0;
|
|
783
|
+
}
|
|
784
|
+
original.disposeCollectionFile(actor.group);
|
|
785
|
+
activityFiles.splice(activityFiles.indexOf(actor), 1);
|
|
786
|
+
};
|
|
787
|
+
const activityDestination = (actor: ActivityFile) => {
|
|
788
|
+
const source = actor.source;
|
|
789
|
+
const canonical = source && sourceList.find(item => sourceIdentity(item) === sourceIdentity(source));
|
|
790
|
+
if (canonical && visibleIds.includes(canonical.id)) return deskPose(canonical.id);
|
|
791
|
+
// Other pages continue beyond the view's right edge. Never land a second
|
|
792
|
+
// document on an occupied visible slot or switch the user's current page.
|
|
793
|
+
return SHELF_CENTER.clone().addScaledVector(SHELF_RIGHT, 24).add(new THREE.Vector3(0, 0.28, 0));
|
|
794
|
+
};
|
|
795
|
+
const beginReturn = (actor: ActivityFile) => {
|
|
796
|
+
if (actor.stage === 'returning') return;
|
|
797
|
+
actor.departure.copy(actor.group.position); actor.orientation.copy(actor.group.quaternion);
|
|
798
|
+
actor.startLift = actor.lift; actor.elapsed = 0; actor.stage = 'returning';
|
|
799
|
+
};
|
|
800
|
+
const updateActivities = (dt: number) => {
|
|
801
|
+
activitySpacing = Math.max(0, activitySpacing - dt);
|
|
802
|
+
for (const request of activityQueue) request.age += dt;
|
|
803
|
+
const queuedBefore = activityQueue.length;
|
|
804
|
+
for (let index = activityQueue.length - 1; index >= 0; index--) {
|
|
805
|
+
const request = activityQueue[index];
|
|
806
|
+
const operation = request.operationId ? operations.get(request.operationId) : undefined;
|
|
807
|
+
if (operation?.state === 'error' || request.age > 9 && operation?.state !== 'active') activityQueue.splice(index, 1);
|
|
808
|
+
}
|
|
809
|
+
if (activityQueue.length !== queuedBefore) { activityLabelSource = null; syncLabelPrefetch(); }
|
|
810
|
+
// Explicit browsing owns the view. Pointer hover does not cancel a running
|
|
811
|
+
// lift, and a finished fast tool still receives its full minimum sequence.
|
|
812
|
+
if (!reduced && !detail && !cameraTravel && !userOwnsView()
|
|
813
|
+
&& activityFiles.length < MAX_ACTIVITY_FILES && activityQueue.length && activitySpacing === 0) {
|
|
814
|
+
const requestSlot = (request: ActivityRequest) => request.source ? sourceSlot(request.source)
|
|
815
|
+
: Array.from({ length: MAX_ACTIVITY_FILES + 1 }, (_, offset) => archiveLaneIndex * 8 + wrap(slotRows[archiveLaneIndex] + offset, 8))
|
|
816
|
+
.find(index => !activityFiles.some(actor => occupyingArray(actor) && actor.index === index)) ?? archiveIndex;
|
|
817
|
+
const available = (request: ActivityRequest) => {
|
|
818
|
+
const index = requestSlot(request);
|
|
819
|
+
return original.canExtractArchive(index)
|
|
820
|
+
&& !activityFiles.some(actor => occupyingArray(actor) && actor.index === index);
|
|
821
|
+
};
|
|
822
|
+
let next = activityQueue.findIndex(request => request.kind === 'read' && available(request));
|
|
823
|
+
if (next < 0) next = activityQueue.findIndex(available);
|
|
824
|
+
if (next >= 0 && activityQueue[next].source) {
|
|
825
|
+
const request = activityQueue[next], source = request.source!;
|
|
826
|
+
if (!original.archiveLabelPrepared(archivePrint(source, true))) {
|
|
827
|
+
if (activityLabelSource !== source) { activityLabelSource = source; syncLabelPrefetch(); }
|
|
828
|
+
request.labelWaitAt ??= performance.now();
|
|
829
|
+
// Do not put synchronous upload work back in the animation callback.
|
|
830
|
+
// A failed preparation cannot indefinitely prevent the visible activity.
|
|
831
|
+
if (performance.now() - request.labelWaitAt < 500) next = -1;
|
|
832
|
+
else options.performanceProbe?.diagnostic('resourceUpdates', { kind: 'label-prefetch-fallback', intent: 'activity', reason: 'deadline' });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (next >= 0) {
|
|
836
|
+
const request = activityQueue.splice(next, 1)[0];
|
|
837
|
+
const focusSource = location === 'archive' && !activityFiles.some(occupyingArray) && request.source;
|
|
838
|
+
if (focusSource) focusArchiveSource(focusSource.id);
|
|
839
|
+
const index = requestSlot(request);
|
|
840
|
+
const number = request.source ? sourceNumber(request.source) : -1;
|
|
841
|
+
const group = original.createCollectionFile(number >= 0 ? number : null, request.source ? {
|
|
842
|
+
label: { code: number >= 0 ? `NO.${String(number + 1).padStart(3, '0')}` : 'READING', title: request.source.title, sourceId: request.source.id },
|
|
843
|
+
} : {});
|
|
844
|
+
group.visible = true;
|
|
845
|
+
const pose = original.archiveSourcePose(index);
|
|
846
|
+
group.position.copy(pose.position); group.quaternion.copy(pose.quaternion);
|
|
847
|
+
original.scene.add(group);
|
|
848
|
+
activityFiles.push({ ...request, group, index, elapsed: 0, lift: 0, stage: 'lifting',
|
|
849
|
+
departure: pose.position.clone(), orientation: pose.quaternion.clone(),
|
|
850
|
+
startLift: original.archiveSourceLift(index), targetLift: original.archiveReadingLift(index) });
|
|
851
|
+
activitySpacing = 0.6;
|
|
852
|
+
scanElapsed = 0;
|
|
853
|
+
// Authored signed pulse, fired once at extraction, not every snapshot.
|
|
854
|
+
if (!focusSource) original.pulseInvestigation(index);
|
|
855
|
+
syncActivitySlots();
|
|
856
|
+
activityLabelSource = null; syncLabelPrefetch();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
for (const actor of [...activityFiles]) {
|
|
860
|
+
actor.elapsed += dt;
|
|
861
|
+
const operation = actor.operationId ? operations.get(actor.operationId) : undefined;
|
|
862
|
+
const reading = actor.kind === 'read' && operation?.state === 'active' && investigation?.running;
|
|
863
|
+
const failed = operation?.state === 'error' || actor.operationId && !operation;
|
|
864
|
+
if (reduced) { finishActivity(actor); continue; }
|
|
865
|
+
if (actor.stage !== 'travelling' && (detail || failed)) beginReturn(actor);
|
|
866
|
+
if (actor.stage === 'lifting') {
|
|
867
|
+
if (actor.elapsed >= ACTIVITY_LIFT_SECONDS) { actor.stage = 'holding'; actor.elapsed = 0; }
|
|
868
|
+
} else if (actor.stage === 'holding') {
|
|
869
|
+
if (!reading && actor.elapsed >= (actor.kind === 'read' ? 0.75 : 0.25)) {
|
|
870
|
+
const delivered = actor.kind === 'arrival' || Boolean(operation?.sourceIds.length);
|
|
871
|
+
if (actor.source && delivered && !failed && !transferred.has(sourceIdentity(actor.source))) {
|
|
872
|
+
actor.departure.copy(actor.group.position); actor.orientation.copy(actor.group.quaternion);
|
|
873
|
+
actor.stage = 'travelling'; actor.elapsed = 0;
|
|
874
|
+
} else beginReturn(actor);
|
|
875
|
+
}
|
|
876
|
+
} else if (actor.stage === 'returning') {
|
|
877
|
+
actor.lift = THREE.MathUtils.lerp(actor.startLift, original.archiveSourceLift(actor.index), smooth(actor.elapsed / 0.65));
|
|
878
|
+
if (actor.elapsed >= 0.65) { finishActivity(actor); continue; }
|
|
879
|
+
} else {
|
|
880
|
+
const t = Math.min(1, actor.elapsed / 2.4);
|
|
881
|
+
const destination = activityDestination(actor);
|
|
882
|
+
const entrance = destination.clone(); entrance.x -= SHELF_EXIT_DISTANCE;
|
|
883
|
+
const clearance = Math.max(2.15, actor.departure.y, SHELF_CENTER.y + SHELF_HEIGHT + 1.2);
|
|
884
|
+
// Approach outside the open front, descend to the correct tier, then
|
|
885
|
+
// insert horizontally. Lower-tier arrivals never cross the upper tray.
|
|
886
|
+
if (t < 0.28) {
|
|
887
|
+
actor.group.position.copy(actor.departure);
|
|
888
|
+
actor.group.position.y = THREE.MathUtils.lerp(actor.departure.y, clearance, smooth(t / 0.28));
|
|
889
|
+
} else if (t < 0.60) {
|
|
890
|
+
actor.group.position.lerpVectors(actor.departure, entrance, smooth((t - 0.28) / 0.32));
|
|
891
|
+
actor.group.position.y = clearance;
|
|
892
|
+
} else if (t < 0.80) {
|
|
893
|
+
actor.group.position.copy(entrance);
|
|
894
|
+
actor.group.position.y = THREE.MathUtils.lerp(clearance, destination.y, smooth((t - 0.60) / 0.20));
|
|
895
|
+
} else actor.group.position.lerpVectors(entrance, destination, smooth((t - 0.80) / 0.20));
|
|
896
|
+
actor.group.quaternion.copy(actor.orientation).slerp(rackOrientation, smooth(t / 0.60));
|
|
897
|
+
if (t >= 1) { finishActivity(actor); continue; }
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
syncActivitySlots();
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const updateCollection = (dt: number) => {
|
|
904
|
+
const inboxTarget=inboxOpen&&location==='board'&&!detail?1:0;inboxAmount=reduced?inboxTarget:THREE.MathUtils.lerp(inboxAmount,inboxTarget,1-Math.exp(-dt*7));if(Math.abs(inboxAmount-inboxTarget)<.001)inboxAmount=inboxTarget;evidenceInbox.update(inboxAmount);
|
|
905
|
+
if (cameraTravel) {
|
|
906
|
+
const pose = advanceWorkspaceTravel(cameraTravel, dt, reduced);
|
|
907
|
+
boardAmount = pose.board; deskAmount = pose.desk;
|
|
908
|
+
if (pose.complete) cameraTravel = null;
|
|
909
|
+
}
|
|
910
|
+
// Each navigation request blends its endpoints once. Rack ↔ array never
|
|
911
|
+
// borrows the board's raised position, zoom or mid-route easing stop.
|
|
912
|
+
const aspect = host.clientWidth / Math.max(1, host.clientHeight);
|
|
913
|
+
const portrait = aspect < 1.05;
|
|
914
|
+
original.collectionOffset.copy(BOARD_CENTER).multiplyScalar(boardAmount);
|
|
915
|
+
original.collectionOffset.x += (SHELF_X - (portrait ? 2.5 : 0)) * deskAmount;
|
|
916
|
+
original.collectionOffset.y -= 1.3 * deskAmount;
|
|
917
|
+
// Fit both tiers, including the frame, into the right-hand
|
|
918
|
+
// browsing area. Narrow screens use the upper half above the archive list.
|
|
919
|
+
const yaw = THREE.MathUtils.degToRad(59), elevation = THREE.MathUtils.degToRad(19);
|
|
920
|
+
const projectedWidth = RACK_WIDTH * Math.cos(yaw) + RACK_DEPTH * Math.sin(yaw);
|
|
921
|
+
const projectedHeight = (SHELF_HEIGHT + 0.3) * Math.cos(elevation)
|
|
922
|
+
+ (RACK_WIDTH * Math.sin(yaw) + RACK_DEPTH * Math.cos(yaw)) * Math.sin(elevation);
|
|
923
|
+
// Selecting a paper can open the mobile drawer during pointerdown. Keep the
|
|
924
|
+
// camera's current drawing area until that gesture ends, then reframe it.
|
|
925
|
+
if (!down) appliedBoardEditorInset = boardEditorInset;
|
|
926
|
+
const boardVisibleFraction = portrait ? 1 - appliedBoardEditorInset : 1;
|
|
927
|
+
const boardSpan = boardFullscreen
|
|
928
|
+
? Math.max(9.4 / (0.86 * boardVisibleFraction), 16.2 / (aspect * 0.90))
|
|
929
|
+
: Math.max(11.2 / (portrait ? 0.47 : 0.72), 18.2 / (aspect * (portrait ? 0.91 : 0.80)));
|
|
930
|
+
const rackSpan = Math.max(projectedHeight / (portrait ? 0.39 : 0.64),
|
|
931
|
+
projectedWidth / (aspect * (portrait ? 0.9 : 0.61)));
|
|
932
|
+
const frameAmount = boardAmount + deskAmount;
|
|
933
|
+
if (frameAmount > 0) {
|
|
934
|
+
const blend = deskAmount / frameAmount;
|
|
935
|
+
evidenceBoard.group.localToWorld(boardFocus.set(boardZoomCenter.x, boardZoomCenter.y, 0));
|
|
936
|
+
workspaceFrameCenter.lerpVectors(boardFocus, collectionCenter, blend);
|
|
937
|
+
original.collectionFraming = {
|
|
938
|
+
center: workspaceFrameCenter,
|
|
939
|
+
span: THREE.MathUtils.lerp(boardSpan / boardZoom, rackSpan, blend),
|
|
940
|
+
x: boardFullscreen ? 0.5 : portrait ? 0.5 : THREE.MathUtils.lerp(0.53, 0.67, blend),
|
|
941
|
+
y: boardFullscreen ? boardVisibleFraction / 2 : portrait ? THREE.MathUtils.lerp(0.35, 0.30, blend) : THREE.MathUtils.lerp(0.48, 0.49, blend),
|
|
942
|
+
distance: THREE.MathUtils.lerp(80, 140, blend), amount: frameAmount,
|
|
943
|
+
direction: boardFullscreen ? boardNormal.set(0, 0, 1).applyQuaternion(evidenceBoard.group.quaternion) : undefined,
|
|
944
|
+
parallax: location === 'board' ? false : undefined,
|
|
945
|
+
};
|
|
946
|
+
if(inboxAmount>0){
|
|
947
|
+
evidenceInbox.group.localToWorld(inboxFocus.set(0,4.0,0));
|
|
948
|
+
original.collectionFraming.center.lerp(inboxFocus,inboxAmount);
|
|
949
|
+
original.collectionFraming.span=THREE.MathUtils.lerp(original.collectionFraming.span,portrait?18:12,inboxAmount);
|
|
950
|
+
original.collectionFraming.x=THREE.MathUtils.lerp(original.collectionFraming.x,portrait?.5:.26,inboxAmount);
|
|
951
|
+
original.collectionFraming.y=THREE.MathUtils.lerp(original.collectionFraming.y,portrait?.25:.60,inboxAmount);
|
|
952
|
+
}
|
|
953
|
+
} else original.collectionFraming = undefined;
|
|
954
|
+
// Retain the authored focus anchor; the rack view itself uses a sharp image
|
|
955
|
+
// across both tiers through effectiveQuality().
|
|
956
|
+
original.collectionFocus = deskAmount > 0.7 ? collectionFocus : undefined;
|
|
957
|
+
original.collectionDetailPosition = deskAmount > 0 ? readingPosition : undefined;
|
|
958
|
+
// Keep neighbouring stations in the shared scene; the focused board view
|
|
959
|
+
// temporarily hides them so a shelf cannot occlude the board when panning.
|
|
960
|
+
original.collectionArrayVisible = (!boardFullscreen || detail) && inboxAmount < .05;
|
|
961
|
+
const readingReveal=readingObject.group.visible?readingObject.boardReveal(reduced):1;
|
|
962
|
+
evidenceBoard.setReveal(readingReveal*(1-inboxAmount*.94));
|
|
963
|
+
evidenceInbox.setReveal(boardFullscreen&&!inboxOpen?0:readingReveal);
|
|
964
|
+
rack.visible = !boardFullscreen && inboxAmount < .05;
|
|
965
|
+
for (const file of files.values()) {
|
|
966
|
+
// Extract the selected cassette from the same rack. Its neighbours stay
|
|
967
|
+
// in place throughout the outward and return paths.
|
|
968
|
+
file.group.visible = !boardFullscreen && inboxAmount < .05;
|
|
969
|
+
const target = file.id === selectedId ? 1 : 0;
|
|
970
|
+
file.progress = reduced ? target : THREE.MathUtils.lerp(file.progress, target, 1 - Math.exp(-dt * 7));
|
|
971
|
+
const focusTarget = file.id === focusedId ? 1 : 0;
|
|
972
|
+
file.focus = reduced ? focusTarget : THREE.MathUtils.lerp(file.focus, focusTarget, 1 - Math.exp(-dt * 10));
|
|
973
|
+
if (Math.abs(file.progress - target) < 0.001) file.progress = target;
|
|
974
|
+
file.group.position.copy(deskPose(file.id));
|
|
975
|
+
file.arrival = reduced ? 1 : Math.min(1, file.arrival + dt / 0.5);
|
|
976
|
+
file.group.position.x -= file.focus * 0.50 + (1 - smooth(file.arrival)) * 0.36;
|
|
977
|
+
file.group.rotation.set(0, SHELF_YAW, 0);
|
|
978
|
+
}
|
|
979
|
+
updateActivities(dt);
|
|
980
|
+
const readers = activityFiles.filter(actor => occupyingArray(actor) && actor.kind === 'read');
|
|
981
|
+
const frameRead = location === 'archive' && !detail && !userOwnsView() && readers.length > 0;
|
|
982
|
+
activityFrameAmount = THREE.MathUtils.lerp(activityFrameAmount, frameRead ? 1 : 0, 1 - Math.exp(-dt * 3.5));
|
|
983
|
+
if (activityFrameAmount < 0.001) activityFrameAmount = 0;
|
|
984
|
+
if (frameRead) {
|
|
985
|
+
activityFrameCenter.set(0, 0, 0);
|
|
986
|
+
for (const actor of readers) activityFrameCenter.add(actor.group.position);
|
|
987
|
+
activityFrameCenter.multiplyScalar(1 / readers.length).y += 1.85;
|
|
988
|
+
}
|
|
989
|
+
if (frameAmount === 0 && activityFrameAmount > 0) original.collectionFraming = {
|
|
990
|
+
center: activityFrameCenter, span: Math.max(10.5, 7.5 / aspect),
|
|
991
|
+
x: portrait ? 0.5 : 0.32, y: portrait ? 0.28 : 0.42,
|
|
992
|
+
amount: activityFrameAmount, distance: 140,
|
|
993
|
+
};
|
|
994
|
+
for (const actor of activityFiles) actor.group.visible = !boardFullscreen && inboxAmount < .05;
|
|
995
|
+
};
|
|
996
|
+
original.beforeRender = () => {
|
|
997
|
+
readingObject.update(original.camera, host.clientWidth, host.clientHeight, reduced);
|
|
998
|
+
const inboxAnchor=evidenceInbox.anchor(original.camera,host.clientWidth,host.clientHeight);
|
|
999
|
+
options.onInboxAnchor?.({...inboxAnchor,visible:inboxAnchor.visible&&location==='board'&&!detail&&!cameraTravel&&!inboxOpen&&!readingObject.group.visible});
|
|
1000
|
+
for (const actor of activityFiles) {
|
|
1001
|
+
if (actor.stage === 'travelling') {
|
|
1002
|
+
if (occupyingArray(actor)) {
|
|
1003
|
+
const pose = original.archiveSourcePose(actor.index);
|
|
1004
|
+
actor.group.position.x = actor.departure.x = pose.position.x;
|
|
1005
|
+
actor.group.position.z = actor.departure.z = pose.position.z;
|
|
1006
|
+
}
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const pose = original.archiveReadingPose(actor.index);
|
|
1010
|
+
// Lift the entire body clear of neighbouring crests, independently of
|
|
1011
|
+
// the selected slot's 0.4 preview lift. A long tool call holds it here.
|
|
1012
|
+
actor.targetLift = Math.max(actor.targetLift, original.archiveReadingLift(actor.index));
|
|
1013
|
+
if (actor.stage !== 'returning') actor.lift = THREE.MathUtils.lerp(actor.startLift, actor.targetLift,
|
|
1014
|
+
actor.stage === 'holding' ? 1 : smooth(actor.elapsed / ACTIVITY_LIFT_SECONDS));
|
|
1015
|
+
actor.group.position.copy(pose.position);
|
|
1016
|
+
actor.group.position.y += actor.lift;
|
|
1017
|
+
actor.group.quaternion.copy(pose.quaternion);
|
|
1018
|
+
original.setCollectionAppearance(actor.group, actor.lift);
|
|
1019
|
+
}
|
|
1020
|
+
if (deskAmount <= 0) {
|
|
1021
|
+
// The rack is also visible beside the board; keep its angle-dependent
|
|
1022
|
+
// interior representation current while it is a neighbouring station.
|
|
1023
|
+
for (const file of files.values()) original.setShelfDetail(file.group,
|
|
1024
|
+
file.progress > 0 || file.focus > 0.001 || file.arrival < 1);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
const targetOrientation = original.collectionOrientation;
|
|
1028
|
+
for (const file of files.values()) {
|
|
1029
|
+
const progress = file.progress;
|
|
1030
|
+
if (progress <= 0) continue;
|
|
1031
|
+
const pose = shelfReadingPath(visibleIds.indexOf(file.id), progress, file.focus);
|
|
1032
|
+
file.group.position.copy(SHELF_CENTER).add(new THREE.Vector3(pose.x, pose.y, pose.z));
|
|
1033
|
+
file.group.quaternion.copy(rackOrientation).slerp(targetOrientation, pose.turn);
|
|
1034
|
+
}
|
|
1035
|
+
const inspected = selectedId ? files.get(selectedId) : undefined;
|
|
1036
|
+
if (inspected && deskAmount > 0.7) original.collectionFocus = inspected.group.position.clone().add(new THREE.Vector3(0, 2, 0));
|
|
1037
|
+
for (const file of files.values()) original.setShelfDetail(file.group,
|
|
1038
|
+
file.id === selectedId || file.progress > 0 || file.focus > 0.001 || file.arrival < 1);
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
function wake() {
|
|
1042
|
+
if (disposed || !active || contextLost || document.hidden || raf) return;
|
|
1043
|
+
raf = requestAnimationFrame(frame);
|
|
1044
|
+
}
|
|
1045
|
+
function frame(ms: number) {
|
|
1046
|
+
raf = 0;
|
|
1047
|
+
if (disposed || !active || contextLost || document.hidden) { lastFrame = 0; return; }
|
|
1048
|
+
// TEMPORARY RHINE PROFILER: includes collection updates and every composer pass.
|
|
1049
|
+
const measured = options.performanceProbe?.running ? options.performanceProbe.begin(ms, original.renderer,
|
|
1050
|
+
`${detail ? 'detail' : cameraTravel ? 'travel' : location === 'desk' ? 'rack' : location === 'board' ? 'board' : 'array'}${scanningActive() ? ':search' : ''}${activityFiles.length ? ':activity' : ''}`) : undefined;
|
|
1051
|
+
let failed = true;
|
|
1052
|
+
try {
|
|
1053
|
+
const dt = lastFrame ? Math.min(0.05, (ms - lastFrame) / 1000) : 0.016;
|
|
1054
|
+
lastFrame = ms;
|
|
1055
|
+
const scanning = scanningActive() && location === 'archive' && !detail && !reduced && !userOwnsView();
|
|
1056
|
+
original.setInvestigationScanning(scanning);
|
|
1057
|
+
// Use precisely the original select path: track, shoulder, signed pulse and
|
|
1058
|
+
// preview lift. Allow its spring to settle before advancing to the next cell.
|
|
1059
|
+
if (scanning && !original.isArchiveRefilling && !activityFiles.some(item => item.stage === 'lifting' || item.stage === 'returning'
|
|
1060
|
+
|| item.stage === 'travelling' && occupyingArray(item))) {
|
|
1061
|
+
scanElapsed += dt;
|
|
1062
|
+
if (scanElapsed >= ARCHIVE_STEP_SECONDS) {
|
|
1063
|
+
scanElapsed = 0;
|
|
1064
|
+
options.performanceProbe?.mark('automatic-scan-step', { activityFiles: activityFiles.length });
|
|
1065
|
+
if (activityFiles.some(item => item.stage === 'holding')) {
|
|
1066
|
+
// A parallel search can keep moving beside a long read without
|
|
1067
|
+
// carrying its pinned cassette all the way out of the composition.
|
|
1068
|
+
stepArchive('row', readingScanDirection);
|
|
1069
|
+
readingScanDirection *= -1;
|
|
1070
|
+
} else {
|
|
1071
|
+
readingScanDirection = 1;
|
|
1072
|
+
stepArchive(++scanSteps % 3 === 0 ? 'lane' : 'row', 1);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
arrivalScanRemaining = Math.max(0, arrivalScanRemaining - dt);
|
|
1077
|
+
updateCollection(dt);
|
|
1078
|
+
evidenceBoard.update(dt);
|
|
1079
|
+
options.performanceProbe?.checkpoint(measured, 'collection');
|
|
1080
|
+
original.update(ms / 1000, undefined, measured ? stage => options.performanceProbe!.checkpoint(measured, stage) : undefined);
|
|
1081
|
+
syncBoardAnchors();
|
|
1082
|
+
options.onDetailFrame?.(original.detailVisibility);
|
|
1083
|
+
renderedFrames++;
|
|
1084
|
+
// A loaded GLB is not a ready frame: shelf geometry, label textures and
|
|
1085
|
+
// programs are prepared between frames. Reveal only after they settle.
|
|
1086
|
+
if (readyWaiters.size) {
|
|
1087
|
+
const pending = preparation.stats();
|
|
1088
|
+
if (!pending.pending && !pending.running && !pendingShelf.size && !original.isArchiveRefilling) {
|
|
1089
|
+
for (const waiter of readyWaiters) waiter.resolve();
|
|
1090
|
+
readyWaiters.clear();
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
options.performanceProbe?.checkpoint(measured, 'uiSync');
|
|
1094
|
+
if (measured) measured.sample.counters = { ...measured.sample.counters, ...original.performanceCounters(),
|
|
1095
|
+
rackVisible: rack.visible, boardVisible: evidenceBoard.group.visible, boardCards: boardCards.length,
|
|
1096
|
+
shelfVisibleFiles: [...files.values()].reduce((n, file) => n + Number(file.group.visible), 0),
|
|
1097
|
+
activityLifting: activityFiles.filter(file => file.stage === 'lifting').length,
|
|
1098
|
+
activityTravelling: activityFiles.filter(file => file.stage === 'travelling').length,
|
|
1099
|
+
movingCamera: Boolean(cameraTravel), detailOpen: detail,
|
|
1100
|
+
activityFiles: activityFiles.length, queuedActivities: activityQueue.length, physicalFiles: files.size,
|
|
1101
|
+
preparationPending: preparation.stats().pending, sourceCount: sourceList.length, scanSteps };
|
|
1102
|
+
if (!raf) raf = requestAnimationFrame(frame);
|
|
1103
|
+
failed = false;
|
|
1104
|
+
} finally { options.performanceProbe?.end(measured, failed); }
|
|
1105
|
+
}
|
|
1106
|
+
const resize = () => { if (!disposed) { setBoardTool({ mode: 'select' }); original.resize();
|
|
1107
|
+
anchorWidth = host.clientWidth; anchorHeight = host.clientHeight; anchorsDirty = true; wake(); } };
|
|
1108
|
+
const resizeObserver = new ResizeObserver(resize);
|
|
1109
|
+
resizeObserver.observe(host);
|
|
1110
|
+
document.addEventListener('visibilitychange', () => {
|
|
1111
|
+
preparation.setActive(active && !contextLost && !document.hidden);
|
|
1112
|
+
if (document.hidden) { setBoardTool({ mode: 'select' }); boardPanKey = false; cancelAnimationFrame(raf); raf = 0; lastFrame = 0; }
|
|
1113
|
+
else wake();
|
|
1114
|
+
}, { signal: events.signal });
|
|
1115
|
+
|
|
1116
|
+
let lastWheel = 0;
|
|
1117
|
+
let boardWheelAnchor: { x: number; y: number; screenX: number; screenY: number; time: number; direction: number } | null = null;
|
|
1118
|
+
original.renderer.domElement.addEventListener('wheel', event => {
|
|
1119
|
+
if (detail) return;
|
|
1120
|
+
if (location === 'board') {
|
|
1121
|
+
// Keep browser zoom/pinch available; overlay scroll never targets this canvas.
|
|
1122
|
+
if (event.ctrlKey || event.metaKey) return;
|
|
1123
|
+
event.preventDefault();
|
|
1124
|
+
if (cameraTravel || down || event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
|
|
1125
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? host.clientHeight : 1;
|
|
1126
|
+
const delta = THREE.MathUtils.clamp(event.deltaY * unit, -240, 240);
|
|
1127
|
+
if (!Number.isFinite(delta) || delta === 0) return;
|
|
1128
|
+
const nextZoom = THREE.MathUtils.clamp(boardZoom * Math.exp(-delta * 0.0016), BOARD_ZOOM_MIN, BOARD_ZOOM_MAX);
|
|
1129
|
+
if (nextZoom === boardZoom) return;
|
|
1130
|
+
// A modest shift toward the pointer keeps outer papers accessible when
|
|
1131
|
+
// enlarged. Empty surrounding space zooms around the current board focus.
|
|
1132
|
+
// Hold the anchor through a wheel burst while the camera catches up.
|
|
1133
|
+
const now = performance.now(), direction = Math.sign(delta);
|
|
1134
|
+
if (!boardWheelAnchor || now - boardWheelAnchor.time > 200 || boardWheelAnchor.direction !== direction
|
|
1135
|
+
|| Math.hypot(event.clientX - boardWheelAnchor.screenX, event.clientY - boardWheelAnchor.screenY) > 8) {
|
|
1136
|
+
pointRay(event);
|
|
1137
|
+
const point = evidenceBoard.boardPoint(raycaster);
|
|
1138
|
+
const anchor = point && Math.abs(point.x) <= 8.1 && Math.abs(point.y) <= 4.7 ? point : boardZoomCenter;
|
|
1139
|
+
boardWheelAnchor = { x: anchor.x, y: anchor.y, screenX: event.clientX, screenY: event.clientY, time: now, direction };
|
|
1140
|
+
}
|
|
1141
|
+
boardWheelAnchor.time = now;
|
|
1142
|
+
const ratio = boardZoom / nextZoom;
|
|
1143
|
+
boardZoomCenter.set(boardWheelAnchor.x + (boardZoomCenter.x - boardWheelAnchor.x) * ratio,
|
|
1144
|
+
boardWheelAnchor.y + (boardZoomCenter.y - boardWheelAnchor.y) * ratio);
|
|
1145
|
+
clampBoardCenter();
|
|
1146
|
+
boardZoom = nextZoom;
|
|
1147
|
+
evidenceBoard.setPlacementPreview(null); evidenceBoard.setConnectionPreview(boardTool.mode === 'connect' ? boardTool.fromId || null : null);
|
|
1148
|
+
prioritizeUser(); wake(); return;
|
|
1149
|
+
}
|
|
1150
|
+
event.preventDefault();
|
|
1151
|
+
if (Math.max(Math.abs(event.deltaX), Math.abs(event.deltaY)) < 3) return;
|
|
1152
|
+
const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
|
1153
|
+
const direction = (horizontal ? event.deltaX : event.deltaY) > 0 ? 1 : -1;
|
|
1154
|
+
if (location === 'desk') {
|
|
1155
|
+
const now = performance.now();
|
|
1156
|
+
if (now - lastWheel < 180) return;
|
|
1157
|
+
lastWheel = now;
|
|
1158
|
+
if (horizontal || event.shiftKey) showShelfPage(shelfPage + direction);
|
|
1159
|
+
else browseShelf(direction);
|
|
1160
|
+
} else navigate(horizontal || event.shiftKey ? 'lane' : 'row', direction);
|
|
1161
|
+
}, { passive: false, signal: events.signal });
|
|
1162
|
+
original.renderer.domElement.addEventListener('dblclick', event => {
|
|
1163
|
+
if (location === 'board' && !detail && !cameraTravel && !boardDrag && !boardResize && !boardPan && !boardPress && boardTool.mode === 'select') {
|
|
1164
|
+
pointRay(event);
|
|
1165
|
+
if (!evidenceBoard.pick(raycaster)) {
|
|
1166
|
+
resetBoardView();
|
|
1167
|
+
}
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (location === 'archive' && arrayPointerAccepted) {
|
|
1171
|
+
const source = currentArchiveSource();
|
|
1172
|
+
if (source) options.onArchiveSourceOpen?.(source.id);
|
|
1173
|
+
options.onArchiveOpen?.(archiveIndex);
|
|
1174
|
+
}
|
|
1175
|
+
}, { signal: events.signal });
|
|
1176
|
+
const pointer = new THREE.Vector2();
|
|
1177
|
+
const raycaster = new THREE.Raycaster();
|
|
1178
|
+
raycaster.layers.enable(1); // Printed archive labels use the final-resolution layer.
|
|
1179
|
+
let down: { x: number; y: number } | null = null;
|
|
1180
|
+
let boardDrag: { id: string; pointerId: number; offsetX: number; offsetY: number; x: number; y: number;
|
|
1181
|
+
rect: DOMRect; camera: THREE.PerspectiveCamera; moved: boolean } | null = null;
|
|
1182
|
+
let boardResize: { id: string; pointerId: number; start: { x: number; y: number }; initialScale: number;
|
|
1183
|
+
anchorX: number; anchorY: number; diagonalX: number; diagonalY: number; maximum: number;
|
|
1184
|
+
scale: number; x: number; y: number; rect: DOMRect; camera: THREE.PerspectiveCamera; moved: boolean } | null = null;
|
|
1185
|
+
let boardPan: { pointerId: number; startX: number; startY: number; center: THREE.Vector2;
|
|
1186
|
+
rect: DOMRect; camera: THREE.PerspectiveCamera; moved: boolean; clearSelection: boolean } | null = null;
|
|
1187
|
+
let boardPanKey = false;
|
|
1188
|
+
let boardPress: { pointerId: number; kind: 'chalk' | 'tool'; cardId: string | null; moved: boolean } | null = null;
|
|
1189
|
+
const insideBoard = (point: { x: number; y: number } | null) => point
|
|
1190
|
+
&& point.x >= EVIDENCE_WRITING_BOUNDS.left && point.x <= EVIDENCE_WRITING_BOUNDS.right
|
|
1191
|
+
&& point.y >= EVIDENCE_WRITING_BOUNDS.bottom && point.y <= EVIDENCE_WRITING_BOUNDS.top;
|
|
1192
|
+
function updateToolPreview(event: { clientX: number; clientY: number }) {
|
|
1193
|
+
if (boardTool.mode === 'select') return;
|
|
1194
|
+
pointRay(event);
|
|
1195
|
+
const point = evidenceBoard.boardPoint(raycaster);
|
|
1196
|
+
const usable = insideBoard(point) ? point! : undefined;
|
|
1197
|
+
if (boardTool.mode === 'place') evidenceBoard.setPlacementPreview(boardTool.template,
|
|
1198
|
+
usable && !evidenceBoard.pick(raycaster) ? usable : undefined);
|
|
1199
|
+
else evidenceBoard.setConnectionPreview(boardTool.fromId || null, usable);
|
|
1200
|
+
}
|
|
1201
|
+
function setBoardTool(tool: EvidenceBoardTool) {
|
|
1202
|
+
if (disposed) return;
|
|
1203
|
+
cancelBoardDrag();
|
|
1204
|
+
evidenceBoard.setPlacementPreview(null); evidenceBoard.setConnectionPreview(null);
|
|
1205
|
+
const next: EvidenceBoardTool = tool.mode === 'select' || location !== 'board' || detail || !active || contextLost
|
|
1206
|
+
? { mode: 'select' } : { ...tool };
|
|
1207
|
+
boardTool = next;
|
|
1208
|
+
evidenceBoard.setResizeEnabled(next.mode === 'select');
|
|
1209
|
+
if (next.mode === 'connect') evidenceBoard.setConnectionPreview(next.fromId || null);
|
|
1210
|
+
options.onBoardToolChange?.(next);
|
|
1211
|
+
original.renderer.domElement.style.cursor = location === 'board' ? next.mode === 'select' ? 'grab' : 'crosshair' : '';
|
|
1212
|
+
wake();
|
|
1213
|
+
}
|
|
1214
|
+
function zoomBoard(direction: -1 | 1) {
|
|
1215
|
+
if (disposed || location !== 'board' || detail || cameraTravel || down) return;
|
|
1216
|
+
boardWheelAnchor = null;
|
|
1217
|
+
boardZoom = THREE.MathUtils.clamp(boardZoom * (direction > 0 ? 1.2 : 1 / 1.2), BOARD_ZOOM_MIN, BOARD_ZOOM_MAX);
|
|
1218
|
+
evidenceBoard.setPlacementPreview(null); evidenceBoard.setConnectionPreview(boardTool.mode === 'connect' ? boardTool.fromId || null : null);
|
|
1219
|
+
prioritizeUser(); wake();
|
|
1220
|
+
}
|
|
1221
|
+
const clampBoardCenter = () => {
|
|
1222
|
+
boardZoomCenter.x = THREE.MathUtils.clamp(boardZoomCenter.x, -7.5, 7.5);
|
|
1223
|
+
boardZoomCenter.y = THREE.MathUtils.clamp(boardZoomCenter.y, -4.3, 4.3);
|
|
1224
|
+
};
|
|
1225
|
+
const pointRay = (event: { clientX: number; clientY: number }) => {
|
|
1226
|
+
const rect = original.renderer.domElement.getBoundingClientRect();
|
|
1227
|
+
pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1);
|
|
1228
|
+
raycaster.setFromCamera(pointer, original.camera);
|
|
1229
|
+
};
|
|
1230
|
+
const pickResizeHandle = (event: { clientX: number; clientY: number; pointerType?: string }) => {
|
|
1231
|
+
// Use a screen-sized hit margin around the small physical corner mark.
|
|
1232
|
+
// Convert it through the board plane so both slanted and enlarged views work.
|
|
1233
|
+
const center = evidenceBoard.boardPoint(raycaster);
|
|
1234
|
+
if (!center) return null;
|
|
1235
|
+
const pixels = event.pointerType === 'touch' ? 18 : 11;
|
|
1236
|
+
pointRay({ clientX: event.clientX + pixels, clientY: event.clientY + pixels });
|
|
1237
|
+
const edge = evidenceBoard.boardPoint(raycaster);
|
|
1238
|
+
pointRay(event);
|
|
1239
|
+
const tolerance = edge ? Math.max(0.11, Math.hypot(edge.x - center.x, edge.y - center.y) / Math.SQRT2) : 0.22;
|
|
1240
|
+
return evidenceBoard.pickResizeHandle(raycaster, tolerance);
|
|
1241
|
+
};
|
|
1242
|
+
const cancelBoardDrag = () => {
|
|
1243
|
+
const drag = boardDrag, sizing = boardResize, pan = boardPan, press = boardPress;
|
|
1244
|
+
boardDrag = null; boardResize = null; boardPan = null; boardPress = null; down = null;
|
|
1245
|
+
if (drag || sizing) {
|
|
1246
|
+
evidenceBoard.setCards(boardCards);
|
|
1247
|
+
const pointerId = (drag || sizing)!.pointerId;
|
|
1248
|
+
if (original.renderer.domElement.hasPointerCapture(pointerId)) original.renderer.domElement.releasePointerCapture(pointerId);
|
|
1249
|
+
}
|
|
1250
|
+
if (press && original.renderer.domElement.hasPointerCapture(press.pointerId)) original.renderer.domElement.releasePointerCapture(press.pointerId);
|
|
1251
|
+
if (pan && original.renderer.domElement.hasPointerCapture(pan.pointerId)) original.renderer.domElement.releasePointerCapture(pan.pointerId);
|
|
1252
|
+
original.renderer.domElement.style.cursor = location === 'board' ? 'grab' : '';
|
|
1253
|
+
};
|
|
1254
|
+
function cancelBoardGesture() {
|
|
1255
|
+
if (!boardDrag && !boardResize && !boardPan && !boardPress) return false;
|
|
1256
|
+
setBoardTool({ mode: 'select' }); return true;
|
|
1257
|
+
}
|
|
1258
|
+
function resetBoardView() {
|
|
1259
|
+
if (disposed) return;
|
|
1260
|
+
setBoardTool({ mode: 'select' }); boardWheelAnchor = null;
|
|
1261
|
+
boardZoom = BOARD_ZOOM_MIN; boardZoomCenter.set(0, 0);
|
|
1262
|
+
prioritizeUser(); wake();
|
|
1263
|
+
}
|
|
1264
|
+
function setBoardFullscreen(value: boolean) {
|
|
1265
|
+
if (disposed || boardFullscreen === value || value && location !== 'board') return;
|
|
1266
|
+
setBoardTool({ mode: 'select' }); boardWheelAnchor = null; boardPanKey = false;
|
|
1267
|
+
if (value) {
|
|
1268
|
+
boardOverview = { zoom: boardZoom, center: boardZoomCenter.clone() };
|
|
1269
|
+
boardZoom = BOARD_ZOOM_MIN; boardZoomCenter.set(0, 0);
|
|
1270
|
+
} else if (boardOverview) {
|
|
1271
|
+
boardZoom = boardOverview.zoom; boardZoomCenter.copy(boardOverview.center); boardOverview = null;
|
|
1272
|
+
}
|
|
1273
|
+
boardFullscreen = value; options.performanceProbe?.mark('board-fullscreen', { open: value }); prioritizeUser(); wake();
|
|
1274
|
+
}
|
|
1275
|
+
function setBoardEditorInset(value: number) {
|
|
1276
|
+
const next = Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 0.75) : 0;
|
|
1277
|
+
if (disposed || Math.abs(boardEditorInset - next) < 0.0001) return;
|
|
1278
|
+
boardEditorInset = next;
|
|
1279
|
+
if (boardFullscreen && host.clientWidth / Math.max(1, host.clientHeight) < 1.05) {
|
|
1280
|
+
cancelBoardGesture(); prioritizeUser(); wake();
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
const editingTarget = (target: EventTarget | null) => target instanceof HTMLElement
|
|
1284
|
+
&& (target.isContentEditable || Boolean(target.closest('input,textarea,select,[role="textbox"]')));
|
|
1285
|
+
document.addEventListener('keydown', event => {
|
|
1286
|
+
if (event.code !== 'Space' || event.ctrlKey || event.metaKey || event.altKey || event.defaultPrevented
|
|
1287
|
+
|| !active || disposed || location !== 'board' || detail || editingTarget(event.target)) return;
|
|
1288
|
+
// Let Space activate a focused toolbar button instead of taking its keyboard input.
|
|
1289
|
+
if (event.target instanceof HTMLElement && event.target.closest('button,a,summary')) return;
|
|
1290
|
+
event.preventDefault(); boardPanKey = true;
|
|
1291
|
+
if (!boardDrag && !boardResize && !boardPan) original.renderer.domElement.style.cursor = 'grab';
|
|
1292
|
+
}, { signal: events.signal });
|
|
1293
|
+
document.addEventListener('keyup', event => { if (event.code === 'Space') boardPanKey = false; }, { signal: events.signal });
|
|
1294
|
+
window.addEventListener('blur', () => { boardPanKey = false; setBoardTool({ mode: 'select' }); }, { signal: events.signal });
|
|
1295
|
+
// A neighbouring physical station is a navigation target. Capture only its clicks,
|
|
1296
|
+
// so a rack/array press cannot also start a board pan or open an archive document.
|
|
1297
|
+
const navigationCanvas=original.renderer.domElement;
|
|
1298
|
+
let stationPress: { pointerId:number; target:RhineLocation; x:number; y:number; moved:boolean } | null=null;
|
|
1299
|
+
let stationHover: RhineLocation | null=null, stationHoverAt=0;
|
|
1300
|
+
const stationNavigationAvailable=()=>active&&!disposed&&!detail&&!cameraTravel&&!boardFullscreen
|
|
1301
|
+
&&!inboxOpen&&!readingObject.group.visible&&!boardPanKey&&boardTool.mode==='select'
|
|
1302
|
+
&&!boardDrag&&!boardResize&&!boardPan&&!boardPress;
|
|
1303
|
+
function pickStation(event:{clientX:number;clientY:number}): RhineLocation | null {
|
|
1304
|
+
if(!stationNavigationAvailable())return null;
|
|
1305
|
+
pointRay(event);
|
|
1306
|
+
const objects=[rack,evidenceBoard.group,evidenceInbox.group,...[...files.values()].map(file=>file.group)];
|
|
1307
|
+
const station=visibleSceneHit(raycaster.intersectObjects(objects,true));
|
|
1308
|
+
const archive=original.pickArchiveSurface(raycaster);
|
|
1309
|
+
let target: RhineLocation | null=null;
|
|
1310
|
+
if(archive&&(!station||archive.distance<station.distance))target='archive';
|
|
1311
|
+
else for(let object:THREE.Object3D|null=station?.object||null;object;object=object.parent){
|
|
1312
|
+
if(object.userData.workspaceLocation){target=object.userData.workspaceLocation;break;}
|
|
1313
|
+
}
|
|
1314
|
+
return target===location?null:target;
|
|
1315
|
+
}
|
|
1316
|
+
function clearStationHover(){
|
|
1317
|
+
if(stationHover)navigationCanvas.style.cursor=location==='board'?'grab':'default';
|
|
1318
|
+
stationHover=null;delete navigationCanvas.dataset.workspaceTarget;navigationCanvas.removeAttribute('title');
|
|
1319
|
+
}
|
|
1320
|
+
navigationCanvas.addEventListener('pointerdown',event=>{
|
|
1321
|
+
if(stationPress){stationPress.moved=true;event.preventDefault();event.stopImmediatePropagation();return;}
|
|
1322
|
+
if(!event.isPrimary||event.button!==0)return;
|
|
1323
|
+
const target=pickStation(event);if(!target)return;
|
|
1324
|
+
stationPress={pointerId:event.pointerId,target,x:event.clientX,y:event.clientY,moved:false};
|
|
1325
|
+
down=null;event.preventDefault();event.stopImmediatePropagation();navigationCanvas.setPointerCapture(event.pointerId);
|
|
1326
|
+
},{capture:true,signal:events.signal});
|
|
1327
|
+
navigationCanvas.addEventListener('pointermove',event=>{
|
|
1328
|
+
if(!stationPress||stationPress.pointerId!==event.pointerId)return;
|
|
1329
|
+
if(Math.hypot(event.clientX-stationPress.x,event.clientY-stationPress.y)>8)stationPress.moved=true;
|
|
1330
|
+
event.preventDefault();event.stopImmediatePropagation();
|
|
1331
|
+
},{capture:true,signal:events.signal});
|
|
1332
|
+
navigationCanvas.addEventListener('pointerup',event=>{
|
|
1333
|
+
if(!stationPress||stationPress.pointerId!==event.pointerId)return;
|
|
1334
|
+
const press=stationPress;stationPress=null;
|
|
1335
|
+
event.preventDefault();event.stopImmediatePropagation();
|
|
1336
|
+
if(navigationCanvas.hasPointerCapture(event.pointerId))navigationCanvas.releasePointerCapture(event.pointerId);
|
|
1337
|
+
clearStationHover();
|
|
1338
|
+
if(press.moved||Math.hypot(event.clientX-press.x,event.clientY-press.y)>8||pickStation(event)!==press.target)return;
|
|
1339
|
+
prioritizeUser();options.onLocationRequest?.(press.target);
|
|
1340
|
+
},{capture:true,signal:events.signal});
|
|
1341
|
+
navigationCanvas.addEventListener('pointercancel',()=>{stationPress=null;clearStationHover();},{capture:true,signal:events.signal});
|
|
1342
|
+
navigationCanvas.addEventListener('lostpointercapture',()=>{stationPress=null;},{signal:events.signal});
|
|
1343
|
+
original.renderer.domElement.addEventListener('pointerdown', event => {
|
|
1344
|
+
const middlePan = event.button === 1 && location === 'board' && !detail;
|
|
1345
|
+
if (!event.isPrimary || event.button !== 0 && !middlePan) return;
|
|
1346
|
+
if (boardDrag || boardResize || boardPan || boardPress) return;
|
|
1347
|
+
boardWheelAnchor = null;
|
|
1348
|
+
prioritizeUser(); down = { x: event.clientX, y: event.clientY };
|
|
1349
|
+
if (location !== 'board' || detail || cameraTravel) return;
|
|
1350
|
+
pointRay(event);
|
|
1351
|
+
if(!boardPanKey&&!middlePan&&boardTool.mode==='select'&&evidenceInbox.pick(raycaster)){down=null;event.preventDefault();options.onInboxOpen?.();return;}
|
|
1352
|
+
let id = evidenceBoard.pick(raycaster);
|
|
1353
|
+
if (!boardPanKey && !middlePan && (evidenceBoard.pickTool(raycaster) || boardTool.mode !== 'select')) {
|
|
1354
|
+
boardPress = { pointerId: event.pointerId, kind: evidenceBoard.pickTool(raycaster) ? 'chalk' : 'tool', cardId: id, moved: false };
|
|
1355
|
+
event.preventDefault(); original.renderer.domElement.setPointerCapture(event.pointerId);
|
|
1356
|
+
updateToolPreview(event); return;
|
|
1357
|
+
}
|
|
1358
|
+
if (!boardPanKey && !middlePan && boardTool.mode === 'select') {
|
|
1359
|
+
let resizeId = pickResizeHandle(event);
|
|
1360
|
+
if (resizeId) {
|
|
1361
|
+
// Flush/promote an edited sample before freezing the resize reference.
|
|
1362
|
+
options.onBoardSelect?.(resizeId, false);
|
|
1363
|
+
resizeId = pickResizeHandle(event);
|
|
1364
|
+
const index = boardCards.findIndex(card => card.id === resizeId);
|
|
1365
|
+
const point = resizeId ? evidenceBoard.resizePoint(raycaster, resizeId) : null;
|
|
1366
|
+
if (!resizeId || index < 0 || !point) { down = null; return; }
|
|
1367
|
+
const card = boardCards[index], layout = evidenceCardLayout(card, index);
|
|
1368
|
+
const angle = layout.rotation * Math.PI / 180, cosine = Math.cos(angle), sine = Math.sin(angle);
|
|
1369
|
+
const diagonalX = (layout.width * cosine + layout.height * sine) / layout.scale;
|
|
1370
|
+
const diagonalY = (layout.width * sine - layout.height * cosine) / layout.scale;
|
|
1371
|
+
boardResize = { id: resizeId, pointerId: event.pointerId, start: point, initialScale: layout.scale,
|
|
1372
|
+
anchorX: layout.x - diagonalX * layout.scale / 2, anchorY: layout.y - diagonalY * layout.scale / 2,
|
|
1373
|
+
diagonalX, diagonalY, maximum: evidenceCardScale({ ...card, scale: EVIDENCE_SCALE_MAX }, index),
|
|
1374
|
+
scale: layout.scale, x: layout.x, y: layout.y,
|
|
1375
|
+
rect: original.renderer.domElement.getBoundingClientRect(), camera: original.camera.clone(), moved: false };
|
|
1376
|
+
event.preventDefault(); original.renderer.domElement.setPointerCapture(event.pointerId);
|
|
1377
|
+
original.renderer.domElement.style.cursor = 'nwse-resize'; wake(); return;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (!id || boardPanKey || middlePan) {
|
|
1381
|
+
evidenceBoard.setPlacementPreview(null); evidenceBoard.setConnectionPreview(boardTool.mode === 'connect' ? boardTool.fromId || null : null);
|
|
1382
|
+
const point = evidenceBoard.boardPoint(raycaster);
|
|
1383
|
+
if (!point) { down = null; return; }
|
|
1384
|
+
event.preventDefault();
|
|
1385
|
+
// Freeze the projection for this gesture: using the damped live camera
|
|
1386
|
+
// would feed its own movement back into the next pointer delta.
|
|
1387
|
+
boardPan = { pointerId: event.pointerId, startX: point.x, startY: point.y, center: boardZoomCenter.clone(),
|
|
1388
|
+
rect: original.renderer.domElement.getBoundingClientRect(), camera: original.camera.clone(), moved: false,
|
|
1389
|
+
clearSelection: !id && !boardPanKey && !middlePan };
|
|
1390
|
+
original.renderer.domElement.setPointerCapture(event.pointerId);
|
|
1391
|
+
original.renderer.domElement.style.cursor = 'grabbing'; return;
|
|
1392
|
+
}
|
|
1393
|
+
if (id && boardCards.find(card => card.id === id)?.clueKind === 'report') { options.onBoardSelect?.(id, true); down = null; return; }
|
|
1394
|
+
// Flush the editor before a drag begins: saving an example can change its ID.
|
|
1395
|
+
options.onBoardSelect?.(id, false);
|
|
1396
|
+
id = evidenceBoard.pick(raycaster);
|
|
1397
|
+
const point = evidenceBoard.boardPoint(raycaster);
|
|
1398
|
+
const index = boardCards.findIndex(card => card.id === id);
|
|
1399
|
+
if (!id || !point || index < 0) return;
|
|
1400
|
+
const layout = evidenceCardLayout(boardCards[index], index);
|
|
1401
|
+
boardDrag = { id, pointerId: event.pointerId, offsetX: layout.x - point.x, offsetY: layout.y - point.y,
|
|
1402
|
+
x: layout.x, y: layout.y, rect: original.renderer.domElement.getBoundingClientRect(),
|
|
1403
|
+
camera: original.camera.clone(), moved: false };
|
|
1404
|
+
original.renderer.domElement.setPointerCapture(event.pointerId);
|
|
1405
|
+
}, { signal: events.signal });
|
|
1406
|
+
original.renderer.domElement.addEventListener('pointerup', event => {
|
|
1407
|
+
if (boardResize) {
|
|
1408
|
+
if (boardResize.pointerId !== event.pointerId) return;
|
|
1409
|
+
const sizing = boardResize; boardResize = null; down = null;
|
|
1410
|
+
if (original.renderer.domElement.hasPointerCapture(event.pointerId)) original.renderer.domElement.releasePointerCapture(event.pointerId);
|
|
1411
|
+
if (sizing.moved) options.onBoardResize?.(sizing.id, sizing.scale, sizing.x, sizing.y);
|
|
1412
|
+
else options.onBoardSelect?.(sizing.id);
|
|
1413
|
+
original.renderer.domElement.style.cursor = 'grab'; wake(); return;
|
|
1414
|
+
}
|
|
1415
|
+
if (boardPress) {
|
|
1416
|
+
if (boardPress.pointerId !== event.pointerId) return;
|
|
1417
|
+
const press = boardPress, start = down; boardPress = null; down = null;
|
|
1418
|
+
if (original.renderer.domElement.hasPointerCapture(event.pointerId)) original.renderer.domElement.releasePointerCapture(event.pointerId);
|
|
1419
|
+
if (!start || press.moved || Math.hypot(event.clientX - start.x, event.clientY - start.y) >= 5) return;
|
|
1420
|
+
pointRay(event);
|
|
1421
|
+
if (press.kind === 'chalk') {
|
|
1422
|
+
if (evidenceBoard.pickTool(raycaster)) { setBoardTool({ mode: 'select' }); options.onBoardToolRequest?.(); }
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const id = evidenceBoard.pick(raycaster), point = evidenceBoard.boardPoint(raycaster);
|
|
1426
|
+
if (boardTool.mode === 'place' && !id && insideBoard(point)) {
|
|
1427
|
+
const template = boardTool.template, position = { x: point!.x, y: point!.y };
|
|
1428
|
+
setBoardTool({ mode: 'select' }); options.onBoardPlace?.(template, position);
|
|
1429
|
+
} else if (boardTool.mode === 'connect' && id && id === press.cardId) {
|
|
1430
|
+
if (!boardTool.fromId) setBoardTool({ mode: 'connect', fromId: id });
|
|
1431
|
+
else if (boardTool.fromId !== id) {
|
|
1432
|
+
const fromId = boardTool.fromId; setBoardTool({ mode: 'select' }); options.onBoardConnect?.(fromId, id);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
wake(); return;
|
|
1436
|
+
}
|
|
1437
|
+
if (boardPan) {
|
|
1438
|
+
if (boardPan.pointerId !== event.pointerId) return;
|
|
1439
|
+
const pan = boardPan; boardPan = null; down = null;
|
|
1440
|
+
if (original.renderer.domElement.hasPointerCapture(pan.pointerId)) original.renderer.domElement.releasePointerCapture(pan.pointerId);
|
|
1441
|
+
if (!pan.moved && pan.clearSelection) { evidenceBoard.select(null); options.onBoardSelect?.(null); }
|
|
1442
|
+
original.renderer.domElement.style.cursor = 'grab'; wake(); return;
|
|
1443
|
+
}
|
|
1444
|
+
if (boardDrag && boardDrag.pointerId !== event.pointerId) return;
|
|
1445
|
+
if (boardDrag && boardDrag.pointerId === event.pointerId) {
|
|
1446
|
+
const drag = boardDrag; boardDrag = null; down = null;
|
|
1447
|
+
if (drag.moved) options.onBoardMove?.(drag.id, drag.x, drag.y);
|
|
1448
|
+
else { evidenceBoard.select(drag.id); options.onBoardSelect?.(drag.id); }
|
|
1449
|
+
original.renderer.domElement.style.cursor = 'grab'; wake(); return;
|
|
1450
|
+
}
|
|
1451
|
+
if (location === 'archive' || detail || !down || Math.hypot(event.clientX - down.x, event.clientY - down.y) > 8) { down = null; return; }
|
|
1452
|
+
down = null;
|
|
1453
|
+
const rect = original.renderer.domElement.getBoundingClientRect();
|
|
1454
|
+
pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1);
|
|
1455
|
+
raycaster.setFromCamera(pointer, original.camera);
|
|
1456
|
+
if (location === 'board') {
|
|
1457
|
+
if (cameraTravel) return;
|
|
1458
|
+
const id = evidenceBoard.pick(raycaster);
|
|
1459
|
+
evidenceBoard.select(id); options.onBoardSelect?.(id); wake(); return;
|
|
1460
|
+
}
|
|
1461
|
+
const hit = raycaster.intersectObjects([...files.values()].map(file => file.group), true)[0];
|
|
1462
|
+
let object: THREE.Object3D | null = hit?.object || null;
|
|
1463
|
+
while (object && !object.userData.sourceId) object = object.parent;
|
|
1464
|
+
const id = object?.userData.sourceId as string | undefined;
|
|
1465
|
+
if (!id) return;
|
|
1466
|
+
if (id === focusedId) options.onSelect?.(id);
|
|
1467
|
+
else { focusedId = id; notifyShelf(); wake(); }
|
|
1468
|
+
}, { signal: events.signal });
|
|
1469
|
+
original.renderer.domElement.addEventListener('pointermove', event => {
|
|
1470
|
+
if (location !== 'board' || detail || cameraTravel) return;
|
|
1471
|
+
if (boardResize && boardResize.pointerId === event.pointerId && down) {
|
|
1472
|
+
const sizing = boardResize;
|
|
1473
|
+
if (!sizing.moved && Math.hypot(event.clientX - down.x, event.clientY - down.y) < 4) return;
|
|
1474
|
+
pointer.set((event.clientX - sizing.rect.left) / sizing.rect.width * 2 - 1,
|
|
1475
|
+
-(event.clientY - sizing.rect.top) / sizing.rect.height * 2 + 1);
|
|
1476
|
+
raycaster.setFromCamera(pointer, sizing.camera);
|
|
1477
|
+
const point = evidenceBoard.resizePoint(raycaster, sizing.id), index = boardCards.findIndex(card => card.id === sizing.id);
|
|
1478
|
+
if (!point || index < 0) return;
|
|
1479
|
+
const delta = ((point.x - sizing.start.x) * sizing.diagonalX + (point.y - sizing.start.y) * sizing.diagonalY)
|
|
1480
|
+
/ (sizing.diagonalX ** 2 + sizing.diagonalY ** 2);
|
|
1481
|
+
const scale = THREE.MathUtils.clamp(sizing.initialScale + delta, EVIDENCE_SCALE_MIN, sizing.maximum);
|
|
1482
|
+
const position = clampEvidencePosition({ ...boardCards[index], scale }, index,
|
|
1483
|
+
sizing.anchorX + sizing.diagonalX * scale / 2, sizing.anchorY + sizing.diagonalY * scale / 2);
|
|
1484
|
+
sizing.scale = scale; sizing.x = position.x; sizing.y = position.y; sizing.moved = true;
|
|
1485
|
+
evidenceBoard.resizeCard(sizing.id, scale, position.x, position.y);
|
|
1486
|
+
original.renderer.domElement.style.cursor = 'nwse-resize'; wake(); return;
|
|
1487
|
+
}
|
|
1488
|
+
if (boardPress?.pointerId === event.pointerId && down && Math.hypot(event.clientX - down.x, event.clientY - down.y) >= 5) boardPress.moved = true;
|
|
1489
|
+
if (boardPan && boardPan.pointerId === event.pointerId && down) {
|
|
1490
|
+
if (!boardPan.moved && Math.hypot(event.clientX - down.x, event.clientY - down.y) < 5) return;
|
|
1491
|
+
const rect = boardPan.rect;
|
|
1492
|
+
pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1);
|
|
1493
|
+
raycaster.setFromCamera(pointer, boardPan.camera);
|
|
1494
|
+
const point = evidenceBoard.boardPoint(raycaster);
|
|
1495
|
+
if (!point) return;
|
|
1496
|
+
boardZoomCenter.set(boardPan.center.x + boardPan.startX - point.x, boardPan.center.y + boardPan.startY - point.y);
|
|
1497
|
+
clampBoardCenter(); boardPan.moved = true;
|
|
1498
|
+
original.renderer.domElement.style.cursor = 'grabbing'; wake(); return;
|
|
1499
|
+
}
|
|
1500
|
+
pointRay(event);
|
|
1501
|
+
if (boardDrag && boardDrag.pointerId === event.pointerId && down) {
|
|
1502
|
+
if (!boardDrag.moved && Math.hypot(event.clientX - down.x, event.clientY - down.y) < 5) return;
|
|
1503
|
+
const rect = boardDrag.rect;
|
|
1504
|
+
pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1);
|
|
1505
|
+
raycaster.setFromCamera(pointer, boardDrag.camera);
|
|
1506
|
+
const point = evidenceBoard.boardPoint(raycaster), index = boardCards.findIndex(card => card.id === boardDrag!.id);
|
|
1507
|
+
if (!point || index < 0) return;
|
|
1508
|
+
const position = clampEvidencePosition(boardCards[index], index, point.x + boardDrag.offsetX, point.y + boardDrag.offsetY);
|
|
1509
|
+
boardDrag.x = position.x; boardDrag.y = position.y; boardDrag.moved = true;
|
|
1510
|
+
evidenceBoard.moveCard(boardDrag.id, position.x, position.y);
|
|
1511
|
+
original.renderer.domElement.style.cursor = 'grabbing'; wake(); return;
|
|
1512
|
+
}
|
|
1513
|
+
updateToolPreview(event);
|
|
1514
|
+
original.renderer.domElement.style.cursor = boardPanKey ? 'grab' : evidenceBoard.pickTool(raycaster) ? 'pointer'
|
|
1515
|
+
: boardTool.mode === 'select' ? pickResizeHandle(event) ? 'nwse-resize' : 'grab' : 'crosshair';
|
|
1516
|
+
}, { signal: events.signal });
|
|
1517
|
+
navigationCanvas.addEventListener('pointermove',event=>{
|
|
1518
|
+
if(stationPress||event.pointerType!=='mouse')return;
|
|
1519
|
+
if(!stationNavigationAvailable()){clearStationHover();return;}
|
|
1520
|
+
const now=performance.now();
|
|
1521
|
+
if(now-stationHoverAt<45){if(stationHover)navigationCanvas.style.cursor='pointer';return;}
|
|
1522
|
+
stationHoverAt=now;const target=pickStation(event);
|
|
1523
|
+
if(!target){clearStationHover();return;}
|
|
1524
|
+
stationHover=target;navigationCanvas.style.cursor='pointer';navigationCanvas.dataset.workspaceTarget=target;
|
|
1525
|
+
navigationCanvas.title=`点击进入${{archive:'检索阵列',board:'调查板',desk:'档案架'}[target]}`;
|
|
1526
|
+
},{signal:events.signal});
|
|
1527
|
+
original.renderer.domElement.addEventListener('pointerleave', () => {
|
|
1528
|
+
clearStationHover();
|
|
1529
|
+
if (!boardPan && !boardDrag && !boardResize && !boardPress) {
|
|
1530
|
+
evidenceBoard.setPlacementPreview(null); evidenceBoard.setConnectionPreview(boardTool.mode === 'connect' ? boardTool.fromId || null : null);
|
|
1531
|
+
}
|
|
1532
|
+
}, { signal: events.signal });
|
|
1533
|
+
original.renderer.domElement.addEventListener('pointercancel', event => {
|
|
1534
|
+
if (boardPan?.pointerId === event.pointerId || boardDrag?.pointerId === event.pointerId || boardResize?.pointerId === event.pointerId
|
|
1535
|
+
|| boardPress?.pointerId === event.pointerId || !boardPan && !boardDrag && !boardResize && !boardPress) setBoardTool({ mode: 'select' });
|
|
1536
|
+
}, { signal: events.signal });
|
|
1537
|
+
original.renderer.domElement.addEventListener('lostpointercapture', event => {
|
|
1538
|
+
if (boardDrag?.pointerId === event.pointerId || boardResize?.pointerId === event.pointerId
|
|
1539
|
+
|| boardPan?.pointerId === event.pointerId || boardPress?.pointerId === event.pointerId) setBoardTool({ mode: 'select' });
|
|
1540
|
+
}, { signal: events.signal });
|
|
1541
|
+
original.renderer.domElement.addEventListener('auxclick', event => {
|
|
1542
|
+
if (location === 'board' && event.button === 1) event.preventDefault();
|
|
1543
|
+
}, { signal: events.signal });
|
|
1544
|
+
original.renderer.domElement.addEventListener('webglcontextlost', event => {
|
|
1545
|
+
event.preventDefault(); hoveredArchive = null; contextLost = true; setBoardTool({ mode: 'select' }); boardPanKey = false; cancelAnimationFrame(raf); raf = 0;
|
|
1546
|
+
preparation.setActive(false);
|
|
1547
|
+
rejectReady('三维画面暂时不可用');
|
|
1548
|
+
options.onError?.('三维画面暂时不可用,仍可通过资料目录阅读。');
|
|
1549
|
+
}, { signal: events.signal });
|
|
1550
|
+
original.renderer.domElement.addEventListener('webglcontextrestored', () => {
|
|
1551
|
+
contextLost = false; original.invalidatePrograms();
|
|
1552
|
+
labelPrefetchKey = ''; notifyArchive();
|
|
1553
|
+
if (renderOptions.get("rhineShelf") !== "geometry") {
|
|
1554
|
+
preparation.enqueue("shelf-interior", () => original.prepareShelfInterior(), 20);
|
|
1555
|
+
for (let view = 0; view < 4; view++) preparation.enqueue(`shelf-view:${view}`, () => original.prepareShelfView(view), -1);
|
|
1556
|
+
}
|
|
1557
|
+
preparation.enqueue("workspace-programs", () => original.preparePrograms("workspace", [rack, evidenceBoard.group, evidenceInbox.group, ...[...files.values()].map(file => file.group)]), 15);
|
|
1558
|
+
preparation.setActive(active && !document.hidden); wake();
|
|
1559
|
+
}, { signal: events.signal });
|
|
1560
|
+
preparation.setActive(active && !document.hidden);
|
|
1561
|
+
resize();
|
|
1562
|
+
|
|
1563
|
+
return {
|
|
1564
|
+
whenReady() {
|
|
1565
|
+
if (disposed || contextLost) return Promise.reject(new Error('三维场景已关闭或不可用'));
|
|
1566
|
+
return new Promise<void>((resolve, reject) => { readyWaiters.add({ resolve, reject }); wake(); });
|
|
1567
|
+
},
|
|
1568
|
+
createAssemblyModel: () => load('assembly-clone-setup', () => original.createAssemblyModel(`${options.assetBase.replace(/\/$/, '')}/assets/archive-assembly.glb`)),
|
|
1569
|
+
finishDecryption: () => original.finishDecryption(),
|
|
1570
|
+
setFollowAgent(value: boolean) { followAgent = value; if(value) userPriorityUntil = 0; wake(); },
|
|
1571
|
+
navigate, selectArchiveSource, browseArchiveSource, setArchiveSources, setInvestigation, setSources,
|
|
1572
|
+
setShelfPage: showShelfPage, browseShelf, resize,
|
|
1573
|
+
setBoardFullscreen, setBoardEditorInset, resetBoardView, zoomBoard, setBoardTool, cancelBoardGesture,
|
|
1574
|
+
getBoardCardBounds(id) {
|
|
1575
|
+
const index = boardCards.findIndex(card => card.id === id);
|
|
1576
|
+
if (index < 0 || !active || location !== 'board' || detail || !evidenceBoard.group.visible) return null;
|
|
1577
|
+
const layout = evidenceCardLayout(boardCards[index], index);
|
|
1578
|
+
const angle = layout.rotation * Math.PI / 180, cosine = Math.cos(angle), sine = Math.sin(angle);
|
|
1579
|
+
evidenceBoard.group.updateWorldMatrix(true, false);
|
|
1580
|
+
const points = [[-1, 1], [1, 1], [1, -1], [-1, -1]].map(([dx, dy]) => {
|
|
1581
|
+
const x = dx * layout.width / 2, y = dy * layout.height / 2;
|
|
1582
|
+
const point = evidenceBoard.group.localToWorld(new THREE.Vector3(layout.x + x * cosine - y * sine, layout.y + x * sine + y * cosine, .2)).project(original.camera);
|
|
1583
|
+
return { x: (point.x + 1) * host.clientWidth / 2, y: (1 - point.y) * host.clientHeight / 2 };
|
|
1584
|
+
});
|
|
1585
|
+
return { left: Math.min(...points.map(p => p.x)), right: Math.max(...points.map(p => p.x)), top: Math.min(...points.map(p => p.y)), bottom: Math.max(...points.map(p => p.y)) };
|
|
1586
|
+
},
|
|
1587
|
+
setBoardCards(cards) {
|
|
1588
|
+
if (disposed) return;
|
|
1589
|
+
boardCards = cards;
|
|
1590
|
+
// An authoritative update (undo, delete, session or a text save) owns the
|
|
1591
|
+
// model again. A resize preview must never overwrite that newer snapshot.
|
|
1592
|
+
if (boardResize) cancelBoardDrag();
|
|
1593
|
+
if (boardTool.mode === 'connect' && boardTool.fromId && !cards.some(card => card.id === (boardTool as { fromId?: string }).fromId)) setBoardTool({ mode: 'select' });
|
|
1594
|
+
if (boardDrag && !cards.some(card => card.id === boardDrag!.id)) cancelBoardDrag();
|
|
1595
|
+
evidenceBoard.setCards(cards);
|
|
1596
|
+
if (boardDrag?.moved) evidenceBoard.moveCard(boardDrag.id, boardDrag.x, boardDrag.y);
|
|
1597
|
+
wake();
|
|
1598
|
+
},
|
|
1599
|
+
selectBoardCard(id) {
|
|
1600
|
+
if (disposed) return;
|
|
1601
|
+
if (boardResize && boardResize.id !== id) cancelBoardDrag();
|
|
1602
|
+
evidenceBoard.select(id); wake();
|
|
1603
|
+
},
|
|
1604
|
+
selectArchive(index) {
|
|
1605
|
+
const lane = fileLocation(wrap(index, 40)).lane;
|
|
1606
|
+
if (!acceptArchiveNavigation({ method: 'slot', targetLane: lane, targetRow: lanes[lane].length ? wrap(index % 8, lanes[lane].length) : 0 })) return;
|
|
1607
|
+
if (lanes[lane].length) laneRows[lane] = wrap(index % 8, lanes[lane].length);
|
|
1608
|
+
chooseArchive(index);
|
|
1609
|
+
},
|
|
1610
|
+
setEvidenceInbox(value,open){inboxOpen=open;evidenceInbox.set(value,open);if(open)cancelBoardGesture();wake();},
|
|
1611
|
+
setReadingObject(value) { readingObject.set(value, reduced); if(value) cancelBoardGesture(); wake(); },
|
|
1612
|
+
setDetail(open) {
|
|
1613
|
+
if (disposed) return;
|
|
1614
|
+
if (detail !== open) { setBoardTool({ mode: 'select' }); boardPanKey = false; boardWheelAnchor = null; }
|
|
1615
|
+
options.performanceProbe?.mark('detail', { open });
|
|
1616
|
+
hoveredArchive = null;
|
|
1617
|
+
detail = open; original.setMode(open ? 'detail' : 'archive'); syncLabelPrefetch(); wake();
|
|
1618
|
+
},
|
|
1619
|
+
setLocation(next) {
|
|
1620
|
+
if (disposed || location === next) return;
|
|
1621
|
+
stationPress=null;clearStationHover();
|
|
1622
|
+
if (boardFullscreen && next !== 'board') setBoardFullscreen(false);
|
|
1623
|
+
setBoardTool({ mode: 'select' });
|
|
1624
|
+
boardPanKey = false;
|
|
1625
|
+
boardWheelAnchor = null;
|
|
1626
|
+
options.performanceProbe?.mark('location', { from: location, to: next });
|
|
1627
|
+
hoveredArchive = null;
|
|
1628
|
+
location = next;
|
|
1629
|
+
original.setQuality(effectiveQuality());
|
|
1630
|
+
if (detail) { detail = false; original.setMode('archive'); }
|
|
1631
|
+
cameraTravel = beginWorkspaceTravel({ board: boardAmount, desk: deskAmount }, next);
|
|
1632
|
+
notifyArchive(); wake();
|
|
1633
|
+
},
|
|
1634
|
+
setSearching(value) { if (searching === value) return; searching = value; wake(); },
|
|
1635
|
+
select(id) {
|
|
1636
|
+
if (disposed) return;
|
|
1637
|
+
selectedId = id && sourceIds.has(id) ? id : null;
|
|
1638
|
+
if (selectedId) {
|
|
1639
|
+
shelfPage = Math.floor(sourceList.findIndex(source => source.id === selectedId) / SHELF_PAGE_SIZE);
|
|
1640
|
+
focusedId = selectedId;
|
|
1641
|
+
}
|
|
1642
|
+
syncCollection(); notifyShelf(); notifyArchive(); wake();
|
|
1643
|
+
},
|
|
1644
|
+
setActive(value) {
|
|
1645
|
+
if (active !== value) options.performanceProbe?.breakTimeline(value ? 'scene-active' : 'scene-inactive');
|
|
1646
|
+
if (!value) { boardPanKey = false; setBoardTool({ mode: 'select' }); }
|
|
1647
|
+
active = value; hoveredArchive = null; syncLabelPrefetch();
|
|
1648
|
+
preparation.setActive(active && !contextLost && !document.hidden);
|
|
1649
|
+
if (!active) { cancelAnimationFrame(raf); raf = 0; lastFrame = 0; }
|
|
1650
|
+
else { observeOperations(); wake(); }
|
|
1651
|
+
},
|
|
1652
|
+
setReducedMotion(value) { reduced = value; original.setReduced(value); wake(); },
|
|
1653
|
+
setQuality(value) {
|
|
1654
|
+
if (disposed) return;
|
|
1655
|
+
quality = { ...value };
|
|
1656
|
+
original.setQuality(effectiveQuality());
|
|
1657
|
+
wake();
|
|
1658
|
+
},
|
|
1659
|
+
stats() {
|
|
1660
|
+
const native = original.getStats();
|
|
1661
|
+
const inspected = selectedId ? files.get(selectedId) : undefined;
|
|
1662
|
+
return {
|
|
1663
|
+
...native, heroLoaded: native.loaded, archiveInstances: native.archiveCount,
|
|
1664
|
+
renderedTriangles: native.triangles, engine: 'three/upstream-ArchiveScene', originalQuality: { ...qualityPresets.original },
|
|
1665
|
+
renderQuality: effectiveQuality(), requestedQuality: { ...quality },
|
|
1666
|
+
renderSurface: { width: original.renderer.domElement.width, height: original.renderer.domElement.height },
|
|
1667
|
+
archiveIndex, archiveColumn: archiveLaneIndex, archiveRow: laneRows[archiveLaneIndex],
|
|
1668
|
+
archiveSourceId: currentArchiveSource()?.id || null, archiveSourceCount: archiveSources.length,
|
|
1669
|
+
archiveLaneCounts: lanes.map(sources => sources.length), columnMemory: [...laneRows], physicalSlotRows: [...slotRows],
|
|
1670
|
+
evidenceInbox: evidenceInbox.stats(), readingObject: readingObject.stats(original.camera),
|
|
1671
|
+
cameraLocation: location, cameraX: original.collectionOffset.x, movingCamera: !!cameraTravel,
|
|
1672
|
+
cameraRoute: { board: boardAmount, desk: deskAmount, offset: original.collectionOffset.toArray(),
|
|
1673
|
+
position: original.camera.position.toArray(), quaternion: original.camera.quaternion.toArray(), fov: original.camera.fov },
|
|
1674
|
+
evidenceBoard: { ...evidenceBoard.stats(), visible: evidenceBoard.group.visible, sameScene: evidenceBoard.group.parent === original.scene,
|
|
1675
|
+
zoom: boardZoom, zoomCenter: { x: boardZoomCenter.x, y: boardZoomCenter.y }, zoomRange: [BOARD_ZOOM_MIN, BOARD_ZOOM_MAX],
|
|
1676
|
+
fullscreen: boardFullscreen, editorInset: boardEditorInset, appliedEditorInset: appliedBoardEditorInset,
|
|
1677
|
+
tool: { ...boardTool }, anchors: boardAnchorFrame, panning: Boolean(boardPan), draggingCard: Boolean(boardDrag),
|
|
1678
|
+
resize: boardResize ? { id: boardResize.id, scale: boardResize.scale, x: boardResize.x, y: boardResize.y, moved: boardResize.moved } : null },
|
|
1679
|
+
sourceCount: sourceList.length, selectedId, focusedId, shelfPage, shelfPageCount: Math.ceil(sourceList.length / SHELF_PAGE_SIZE),
|
|
1680
|
+
visibleIds: [...visibleIds], physicalFiles: files.size, physicalCapacity: SHELF_PAGE_SIZE,
|
|
1681
|
+
shelfLayout: { visible: rack.visible, levels: SHELF_LEVELS, slotsPerLevel: SHELF_SLOTS_PER_LEVEL, frameBatches: rack.children.length,
|
|
1682
|
+
files: [...files.values()].map(file => ({ id: file.id, level: shelfSlot(visibleIds.indexOf(file.id)).level,
|
|
1683
|
+
visible: file.group.visible, progress: file.progress, position: file.group.position.toArray(), quaternion: file.group.quaternion.toArray() })) },
|
|
1684
|
+
inspectedFile: inspected ? { id: inspected.id, visible: inspected.group.visible,
|
|
1685
|
+
progress: inspected.progress, representation: inspected.group.userData.shelfRepresentation,
|
|
1686
|
+
position: inspected.group.position.toArray() } : null,
|
|
1687
|
+
preparation: { ...preparation.stats(), pendingShelf: pendingShelf.size },
|
|
1688
|
+
navigation: navigationLimiter.stats(),
|
|
1689
|
+
transfers: activityFiles.filter(item => item.stage === 'travelling').length,
|
|
1690
|
+
transferQueue: activityQueue.length, transferredSourceCount: transferred.size,
|
|
1691
|
+
pendingShelfArrivals: waitingArrivals.size,
|
|
1692
|
+
activeReadingSourceId: activityFiles.find(item => item.kind === 'read')?.source?.id || null,
|
|
1693
|
+
readingMechanism: activityFiles.some(item => item.kind === 'read'),
|
|
1694
|
+
readingLift: Math.max(0, ...activityFiles.filter(item => item.kind === 'read').map(item => item.lift)),
|
|
1695
|
+
archiveActivities: activityFiles.map(item => ({ key: item.key, kind: item.kind, stage: item.stage, sourceId: item.source?.id || null, lift: item.lift,
|
|
1696
|
+
requiredLift: original.archiveReadingLift(item.index), position: item.group.position.toArray(),
|
|
1697
|
+
rest: original.archiveReadingPose(item.index).position.toArray(),
|
|
1698
|
+
screenCorners: [[-2.5,0,-0.13],[2.52,0,0.32],[-2.5,3.76,-0.13],[2.52,3.76,0.32]].map(point => {
|
|
1699
|
+
const p = new THREE.Vector3(...point).applyQuaternion(item.group.quaternion).add(item.group.position).project(original.camera);
|
|
1700
|
+
return [(p.x + 1) / 2, (1 - p.y) / 2];
|
|
1701
|
+
}) })),
|
|
1702
|
+
observedOperationCount: observedOperations.size, scanSteps,
|
|
1703
|
+
userOwnsView: userOwnsView(), renderedFrames, pendingRAF: !!raf, active,
|
|
1704
|
+
searching: scanningActive(),
|
|
1705
|
+
reducedMotion: reduced, hidden: document.hidden, disposed, originalAppearance: true,
|
|
1706
|
+
};
|
|
1707
|
+
},
|
|
1708
|
+
dispose() {
|
|
1709
|
+
if (disposed) return;
|
|
1710
|
+
disposed = true;
|
|
1711
|
+
rejectReady('三维场景已关闭');
|
|
1712
|
+
preparation.dispose(); pendingShelf.clear();
|
|
1713
|
+
cancelAnimationFrame(raf); raf = 0;
|
|
1714
|
+
resizeObserver.disconnect(); events.abort(); clearActivities();
|
|
1715
|
+
for (const file of files.values()) original.disposeCollectionFile(file.group);
|
|
1716
|
+
files.clear();
|
|
1717
|
+
original.scene.remove(rack);
|
|
1718
|
+
rack.children.forEach(mesh => { if (mesh instanceof THREE.InstancedMesh) mesh.dispose(); });
|
|
1719
|
+
box.dispose(); rackMaterials.forEach(value => value.dispose());
|
|
1720
|
+
readingObject.dispose();evidenceInbox.dispose();
|
|
1721
|
+
evidenceBoard.dispose();
|
|
1722
|
+
unregisterPerformance?.();
|
|
1723
|
+
original.dispose();
|
|
1724
|
+
},
|
|
1725
|
+
};
|
|
1726
|
+
}
|