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,658 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
|
3
|
+
import { createArchiveLighting } from "./archive-lighting";
|
|
4
|
+
import { damp } from "./motion";
|
|
5
|
+
import { ViewerCameraMotion } from "./viewer-camera";
|
|
6
|
+
// TEMPORARY RHINE PROFILER
|
|
7
|
+
import type { RenderPerformanceCapture } from "../temporary-performance";
|
|
8
|
+
import { normalizeQuality, type RenderQuality } from "./render-quality";
|
|
9
|
+
import {
|
|
10
|
+
applyTextureQuality,
|
|
11
|
+
createViewerPipeline,
|
|
12
|
+
resizeQuality,
|
|
13
|
+
} from "./quality-renderer";
|
|
14
|
+
|
|
15
|
+
const PARTS = [
|
|
16
|
+
{ id: "fasteners", label: "紧固件", en: "FASTENERS", depth: 2.75 },
|
|
17
|
+
{ id: "cover", label: "透明盖板", en: "OPTICAL COVER", depth: 1.85 },
|
|
18
|
+
{
|
|
19
|
+
id: "optical-lenses",
|
|
20
|
+
label: "折射环组",
|
|
21
|
+
en: "REFRACTIVE RINGS",
|
|
22
|
+
depth: 0.75,
|
|
23
|
+
},
|
|
24
|
+
{ id: "optical-core", label: "光学核心", en: "OPTICAL CORE", depth: -0.15 },
|
|
25
|
+
{ id: "substrate", label: "信息基板", en: "SUBSTRATE", depth: -1.1 },
|
|
26
|
+
{ id: "carrier", label: "背板与框架", en: "CARRIER", depth: -2.05 },
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
type ModelSource = { model: THREE.Group; dispose: () => void; setClarity?: (value: number) => void };
|
|
30
|
+
export class ModelViewer {
|
|
31
|
+
readonly root: HTMLElement;
|
|
32
|
+
private canvasHost: HTMLElement;
|
|
33
|
+
private renderer: THREE.WebGLRenderer;
|
|
34
|
+
private unregisterPerformance?: () => void;
|
|
35
|
+
private pipeline: ReturnType<typeof createViewerPipeline>;
|
|
36
|
+
private quality = normalizeQuality(undefined);
|
|
37
|
+
private appliedQuality = "";
|
|
38
|
+
private scene = new THREE.Scene();
|
|
39
|
+
private camera = new THREE.PerspectiveCamera(34, 16 / 9, 0.3, 120);
|
|
40
|
+
private controlCamera = this.camera.clone();
|
|
41
|
+
private cameraMotion = new ViewerCameraMotion(this.camera);
|
|
42
|
+
private controls: OrbitControls;
|
|
43
|
+
private source?: ModelSource;
|
|
44
|
+
private groups = new Map<string, THREE.Group>();
|
|
45
|
+
private spread = { value: 0, velocity: 0 };
|
|
46
|
+
private targetSpread = 0;
|
|
47
|
+
private clarity = { value: 1, velocity: 0 };
|
|
48
|
+
private targetClarity = 1;
|
|
49
|
+
private lastTime = 0;
|
|
50
|
+
private request = 0;
|
|
51
|
+
private reduced = false;
|
|
52
|
+
private loading = false;
|
|
53
|
+
private closing = false;
|
|
54
|
+
private transitions: Animation[] = [];
|
|
55
|
+
private transitionId = 0;
|
|
56
|
+
private status = "";
|
|
57
|
+
private opener: HTMLElement | null = null;
|
|
58
|
+
private siblings: { node: HTMLElement; inert: boolean }[] = [];
|
|
59
|
+
private initialCamera = new THREE.Vector3(7.2, 3.8, 12);
|
|
60
|
+
private onClose: () => void;
|
|
61
|
+
private provider?: () => Promise<ModelSource>;
|
|
62
|
+
isOpen = false;
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
parent: HTMLElement,
|
|
66
|
+
onClose: () => void,
|
|
67
|
+
private onSound: (
|
|
68
|
+
sound: "explode" | "assemble" | "tick",
|
|
69
|
+
) => void = () => {},
|
|
70
|
+
private performanceProbe?: RenderPerformanceCapture,
|
|
71
|
+
) {
|
|
72
|
+
this.onClose = onClose;
|
|
73
|
+
this.root = document.createElement("section");
|
|
74
|
+
this.root.className = "model-viewer";
|
|
75
|
+
this.root.hidden = true;
|
|
76
|
+
this.root.setAttribute("role", "dialog");
|
|
77
|
+
this.root.setAttribute("aria-modal", "true");
|
|
78
|
+
this.root.setAttribute("aria-labelledby", "viewer-title");
|
|
79
|
+
this.root.innerHTML = `
|
|
80
|
+
<div class="viewer-canvas"></div>
|
|
81
|
+
<div class="scene-atmosphere viewer-atmosphere" aria-hidden="true"></div>
|
|
82
|
+
<header class="viewer-header">
|
|
83
|
+
<button class="viewer-back" data-viewer="close">← <span>返回档案</span><kbd>ESC</kbd></button>
|
|
84
|
+
<div class="viewer-heading"><span>RHINE LAB / OBJECT STUDY</span><h2 id="viewer-title">档案模型</h2><p id="viewer-file"></p></div>
|
|
85
|
+
<span class="viewer-index">360<span>°</span></span>
|
|
86
|
+
</header>
|
|
87
|
+
<div class="viewer-surface" role="group" aria-label="玻璃模式"><button data-viewer="clear" aria-pressed="true">清晰</button><button data-viewer="frosted" aria-pressed="false">磨砂</button></div>
|
|
88
|
+
<aside class="viewer-parts" aria-label="模型装配结构"><div>ASSEMBLY / 装配结构</div>${PARTS.map((p, i) => `<p><span>${String(i + 1).padStart(2, "0")}</span><strong>${p.label}</strong><small>${p.en}</small></p>`).join("")}</aside>
|
|
89
|
+
<div class="viewer-loading" role="status"><span>正在载入模型…</span><button data-viewer="retry" hidden>重新载入 ↗</button></div>
|
|
90
|
+
<footer class="viewer-footer">
|
|
91
|
+
<div class="viewer-help"><span>拖动旋转</span><span>↑ ↓ ← → 平移</span><span>滚轮缩放</span></div>
|
|
92
|
+
<div class="viewer-actions"><button data-viewer="explode" aria-pressed="false"><span>+</span> 拆解档案</button><button data-viewer="assemble" aria-pressed="true"><span>−</span> 一键重组</button></div>
|
|
93
|
+
<button class="viewer-reset" data-viewer="reset">复位视角 <span>↗</span></button>
|
|
94
|
+
</footer>
|
|
95
|
+
<div class="viewer-state" aria-live="polite">已组装</div>`;
|
|
96
|
+
parent.appendChild(this.root);
|
|
97
|
+
this.canvasHost = this.root.querySelector(".viewer-canvas")!;
|
|
98
|
+
this.renderer = new THREE.WebGLRenderer({
|
|
99
|
+
antialias: true,
|
|
100
|
+
powerPreference: "high-performance",
|
|
101
|
+
});
|
|
102
|
+
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
103
|
+
this.renderer.toneMappingExposure = 1.05;
|
|
104
|
+
this.renderer.domElement.tabIndex = 0;
|
|
105
|
+
this.renderer.domElement.setAttribute(
|
|
106
|
+
"aria-label",
|
|
107
|
+
"档案三维模型:拖动旋转,方向键平移,滚轮或加减键缩放,Home 复位",
|
|
108
|
+
);
|
|
109
|
+
this.canvasHost.appendChild(this.renderer.domElement);
|
|
110
|
+
this.scene.background = new THREE.Color("#eae5e1");
|
|
111
|
+
this.scene.fog = new THREE.Fog("#eae5e1", 13.5, 26.5);
|
|
112
|
+
// Render-target textures belong to their WebGL context. Recreate the main
|
|
113
|
+
// scene's light room here so this renderer receives its actual illumination.
|
|
114
|
+
createArchiveLighting(this.renderer, this.scene);
|
|
115
|
+
this.camera.position.copy(this.initialCamera);
|
|
116
|
+
this.controlCamera.copy(this.camera);
|
|
117
|
+
this.controls = new OrbitControls(
|
|
118
|
+
this.controlCamera,
|
|
119
|
+
this.renderer.domElement,
|
|
120
|
+
);
|
|
121
|
+
this.controls.enableDamping = false;
|
|
122
|
+
this.pipeline = createViewerPipeline(
|
|
123
|
+
this.renderer,
|
|
124
|
+
this.scene,
|
|
125
|
+
this.camera,
|
|
126
|
+
);
|
|
127
|
+
this.pipeline.smaa.enabled = false;
|
|
128
|
+
this.unregisterPerformance = performanceProbe?.register('viewer', this.renderer, () => ({
|
|
129
|
+
open: this.isOpen, loading: this.loading, quality: { ...this.quality },
|
|
130
|
+
buffers: { output: { width: this.renderer.domElement.width, height: this.renderer.domElement.height },
|
|
131
|
+
composer: { width: this.pipeline.composer.readBuffer.width, height: this.pipeline.composer.readBuffer.height },
|
|
132
|
+
transmissionScale: this.renderer.transmissionResolutionScale, transmissionTarget: null },
|
|
133
|
+
parts: this.groups.size, ready: Boolean(this.source), spread: this.spread.value, clarity: this.clarity.value,
|
|
134
|
+
content: [...this.groups].map(([part, group]) => ({ part, children: group.children.length, visible: group.visible })),
|
|
135
|
+
passes: { scene: true, smaa: this.quality.antialias === 'smaa' },
|
|
136
|
+
}), () => this.pipeline.composer.passes.map((pass, index) => ({
|
|
137
|
+
name: index === 0 ? 'viewer-scene' : pass === this.pipeline.smaa ? 'viewer-smaa' : 'viewer-output', pass,
|
|
138
|
+
})));
|
|
139
|
+
|
|
140
|
+
this.controls.rotateSpeed = 0.65;
|
|
141
|
+
this.controls.zoomSpeed = 0.7;
|
|
142
|
+
this.controls.panSpeed = 0.7;
|
|
143
|
+
this.controls.minDistance = 5;
|
|
144
|
+
this.controls.maxDistance = 28;
|
|
145
|
+
this.controls.maxTargetRadius = 5;
|
|
146
|
+
this.controls.screenSpacePanning = true;
|
|
147
|
+
this.controls.touches.ONE = THREE.TOUCH.ROTATE;
|
|
148
|
+
this.controls.touches.TWO = THREE.TOUCH.DOLLY_PAN;
|
|
149
|
+
this.controls.enabled = false;
|
|
150
|
+
this.controls.update();
|
|
151
|
+
this.cameraMotion.snap(this.controlCamera, this.controls.target);
|
|
152
|
+
this.controls.addEventListener("start", () => this.interruptReset());
|
|
153
|
+
this.root.addEventListener("click", (event) => {
|
|
154
|
+
if (this.closing) return;
|
|
155
|
+
const action = (event.target as HTMLElement).closest<HTMLElement>(
|
|
156
|
+
"[data-viewer]",
|
|
157
|
+
)?.dataset.viewer;
|
|
158
|
+
if (action === "close") this.close();
|
|
159
|
+
if (action === "retry") void this.load();
|
|
160
|
+
if (this.loading || !this.source) return;
|
|
161
|
+
if (action === "clear" || action === "frosted") {
|
|
162
|
+
this.setSurface(action === "clear");
|
|
163
|
+
this.onSound("tick");
|
|
164
|
+
}
|
|
165
|
+
if (action === "explode" && this.targetSpread !== 1) {
|
|
166
|
+
this.setExploded(true);
|
|
167
|
+
this.onSound("explode");
|
|
168
|
+
}
|
|
169
|
+
if (action === "assemble" && this.targetSpread !== 0) {
|
|
170
|
+
this.setExploded(false);
|
|
171
|
+
this.onSound("assemble");
|
|
172
|
+
}
|
|
173
|
+
if (action === "reset") {
|
|
174
|
+
this.resetView();
|
|
175
|
+
this.onSound("tick");
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
this.root.addEventListener("keydown", (event) => this.keydown(event));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
open(
|
|
182
|
+
id: string,
|
|
183
|
+
title: string,
|
|
184
|
+
provider: () => Promise<ModelSource>,
|
|
185
|
+
reduced: boolean,
|
|
186
|
+
) {
|
|
187
|
+
if (this.isOpen) return;
|
|
188
|
+
this.isOpen = true;
|
|
189
|
+
this.closing = false;
|
|
190
|
+
this.reduced = reduced;
|
|
191
|
+
this.provider = provider;
|
|
192
|
+
this.opener = document.activeElement as HTMLElement | null;
|
|
193
|
+
this.siblings = [...this.root.parentElement!.children]
|
|
194
|
+
.filter(
|
|
195
|
+
(node): node is HTMLElement =>
|
|
196
|
+
node instanceof HTMLElement && node !== this.root,
|
|
197
|
+
)
|
|
198
|
+
.map((node) => ({ node, inert: node.inert }));
|
|
199
|
+
this.siblings.forEach(({ node }) => (node.inert = true));
|
|
200
|
+
this.root.hidden = false;
|
|
201
|
+
this.root.dataset.transition = "opening";
|
|
202
|
+
this.root.querySelector("#viewer-title")!.textContent = title;
|
|
203
|
+
this.root.querySelector("#viewer-file")!.textContent =
|
|
204
|
+
"FILE " + id + " / INTERNAL DATABASE";
|
|
205
|
+
this.spread = { value: 0, velocity: 0 };
|
|
206
|
+
this.targetSpread = 0;
|
|
207
|
+
this.clarity = { value: 1, velocity: 0 };
|
|
208
|
+
this.setSurface(true);
|
|
209
|
+
this.lastTime = 0;
|
|
210
|
+
this.root.dataset.exploded = "false";
|
|
211
|
+
this.resetView(false);
|
|
212
|
+
this.resize();
|
|
213
|
+
this.renderer.domElement.focus({ preventScroll: true });
|
|
214
|
+
this.enter();
|
|
215
|
+
void this.load();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async load() {
|
|
219
|
+
if (!this.provider || this.loading) return;
|
|
220
|
+
const ticket = ++this.request;
|
|
221
|
+
const finishLoad = this.performanceProbe?.beginLoad('viewer-setup');
|
|
222
|
+
let loadStatus: 'complete' | 'error' | 'aborted' = 'error';
|
|
223
|
+
this.loading = true;
|
|
224
|
+
this.controls.enabled = false;
|
|
225
|
+
const loading = this.root.querySelector<HTMLElement>(".viewer-loading")!;
|
|
226
|
+
loading.hidden = false;
|
|
227
|
+
loading.querySelector("span")!.textContent = "正在载入模型…";
|
|
228
|
+
loading.querySelector<HTMLElement>("button")!.hidden = true;
|
|
229
|
+
this.setButtonsDisabled(true);
|
|
230
|
+
try {
|
|
231
|
+
const source = await this.provider();
|
|
232
|
+
if (!this.isOpen || this.closing || ticket !== this.request) {
|
|
233
|
+
source.dispose(); loadStatus = 'aborted';
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.source = source;
|
|
237
|
+
for (const part of PARTS) {
|
|
238
|
+
const group = new THREE.Group();
|
|
239
|
+
group.name = part.id;
|
|
240
|
+
this.groups.set(part.id, group);
|
|
241
|
+
}
|
|
242
|
+
for (const child of [...source.model.children]) {
|
|
243
|
+
const group = this.groups.get(child.userData.assemblyPart ?? "cover");
|
|
244
|
+
group?.add(child);
|
|
245
|
+
}
|
|
246
|
+
for (const group of this.groups.values()) source.model.add(group);
|
|
247
|
+
source.model.position.set(0, -1.85, 0);
|
|
248
|
+
this.scene.add(source.model);
|
|
249
|
+
applyTextureQuality(source.model, this.renderer, this.quality);
|
|
250
|
+
this.loading = false;
|
|
251
|
+
loading.hidden = true;
|
|
252
|
+
this.controls.enabled = true;
|
|
253
|
+
this.setButtonsDisabled(false);
|
|
254
|
+
this.setExploded(false);
|
|
255
|
+
this.setStatus("已组装");
|
|
256
|
+
// Render before revealing the canvas so a new model never flashes in.
|
|
257
|
+
this.update(this.lastTime);
|
|
258
|
+
if (!this.reduced)
|
|
259
|
+
this.transitions.push(
|
|
260
|
+
this.canvasHost.animate(
|
|
261
|
+
[
|
|
262
|
+
{ opacity: 0, transform: "scale(0.97)" },
|
|
263
|
+
{ opacity: 1, transform: "scale(1)" },
|
|
264
|
+
],
|
|
265
|
+
{ duration: 380, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
loadStatus = 'complete';
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (!this.isOpen || this.closing || ticket !== this.request) return;
|
|
271
|
+
this.loading = false;
|
|
272
|
+
loading.querySelector("span")!.textContent = "模型载入失败,请重试";
|
|
273
|
+
loading.querySelector<HTMLElement>("button")!.hidden = false;
|
|
274
|
+
console.error("Model viewer failed to load", error);
|
|
275
|
+
} finally { finishLoad?.(loadStatus); }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private enter() {
|
|
279
|
+
const ticket = ++this.transitionId;
|
|
280
|
+
this.transitions.forEach((animation) => animation.cancel());
|
|
281
|
+
this.transitions = [];
|
|
282
|
+
if (this.reduced) {
|
|
283
|
+
this.root.dataset.transition = "open";
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const fade = this.root.animate([{ opacity: 0 }, { opacity: 1 }], {
|
|
287
|
+
duration: 320,
|
|
288
|
+
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
|
289
|
+
});
|
|
290
|
+
this.transitions.push(fade);
|
|
291
|
+
for (const selector of [
|
|
292
|
+
".viewer-header",
|
|
293
|
+
".viewer-footer",
|
|
294
|
+
".viewer-state",
|
|
295
|
+
]) {
|
|
296
|
+
const element = this.root.querySelector<HTMLElement>(selector)!;
|
|
297
|
+
this.transitions.push(
|
|
298
|
+
element.animate(
|
|
299
|
+
[
|
|
300
|
+
{ opacity: 0, translate: "0 10px" },
|
|
301
|
+
{ opacity: 1, translate: "0 0" },
|
|
302
|
+
],
|
|
303
|
+
{
|
|
304
|
+
duration: 300,
|
|
305
|
+
delay: 60,
|
|
306
|
+
fill: "backwards",
|
|
307
|
+
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
|
308
|
+
},
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
void fade.finished
|
|
313
|
+
.then(() => {
|
|
314
|
+
if (ticket === this.transitionId) this.root.dataset.transition = "open";
|
|
315
|
+
})
|
|
316
|
+
.catch(() => {});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
close() {
|
|
320
|
+
if (!this.isOpen || this.closing) return;
|
|
321
|
+
this.closing = true;
|
|
322
|
+
this.request++;
|
|
323
|
+
this.loading = false;
|
|
324
|
+
this.controls.enabled = false;
|
|
325
|
+
this.setButtonsDisabled(true);
|
|
326
|
+
const ticket = ++this.transitionId;
|
|
327
|
+
// Capture the current fade when Escape interrupts opening.
|
|
328
|
+
const opacity = getComputedStyle(this.root).opacity;
|
|
329
|
+
const canvasStyle = getComputedStyle(this.canvasHost);
|
|
330
|
+
const canvasOpacity = canvasStyle.opacity;
|
|
331
|
+
const transform = canvasStyle.transform;
|
|
332
|
+
this.transitions.forEach((animation) => animation.cancel());
|
|
333
|
+
this.transitions = [];
|
|
334
|
+
this.root.dataset.transition = "closing";
|
|
335
|
+
if (this.reduced) {
|
|
336
|
+
this.finishClose();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const fade = this.root.animate([{ opacity }, { opacity: 0 }], {
|
|
340
|
+
duration: 220,
|
|
341
|
+
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
|
342
|
+
fill: "forwards",
|
|
343
|
+
});
|
|
344
|
+
this.transitions.push(
|
|
345
|
+
fade,
|
|
346
|
+
this.canvasHost.animate(
|
|
347
|
+
[
|
|
348
|
+
{ transform, opacity: canvasOpacity },
|
|
349
|
+
{ transform: "scale(0.97)", opacity: 0 },
|
|
350
|
+
],
|
|
351
|
+
{
|
|
352
|
+
duration: 220,
|
|
353
|
+
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
|
354
|
+
fill: "forwards",
|
|
355
|
+
},
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
void fade.finished
|
|
359
|
+
.then(() => {
|
|
360
|
+
if (ticket === this.transitionId) this.finishClose();
|
|
361
|
+
})
|
|
362
|
+
.catch(() => {});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private finishClose() {
|
|
366
|
+
// Keep rendering and retain modal focus until the visible exit completes.
|
|
367
|
+
this.isOpen = false;
|
|
368
|
+
this.closing = false;
|
|
369
|
+
this.root.hidden = true;
|
|
370
|
+
this.transitions.forEach((animation) => animation.cancel());
|
|
371
|
+
this.transitions = [];
|
|
372
|
+
if (this.source) {
|
|
373
|
+
this.scene.remove(this.source.model);
|
|
374
|
+
this.source.dispose();
|
|
375
|
+
this.source = undefined;
|
|
376
|
+
}
|
|
377
|
+
this.groups.clear();
|
|
378
|
+
this.siblings.forEach(({ node, inert }) => (node.inert = inert));
|
|
379
|
+
this.siblings = [];
|
|
380
|
+
this.opener?.focus({ preventScroll: true });
|
|
381
|
+
this.onClose();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private setButtonsDisabled(disabled: boolean) {
|
|
385
|
+
for (const action of ["explode", "assemble", "reset", "clear", "frosted"]) {
|
|
386
|
+
this.root.querySelector<HTMLButtonElement>(
|
|
387
|
+
`[data-viewer="${action}"]`,
|
|
388
|
+
)!.disabled = disabled;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
private setSurface(clear: boolean) {
|
|
392
|
+
this.targetClarity = clear ? 1 : 0;
|
|
393
|
+
this.root.dataset.surface = clear ? "clear" : "frosted";
|
|
394
|
+
this.root.querySelector('[data-viewer="clear"]')!.setAttribute("aria-pressed", String(clear));
|
|
395
|
+
this.root.querySelector('[data-viewer="frosted"]')!.setAttribute("aria-pressed", String(!clear));
|
|
396
|
+
if (this.reduced) this.clarity = { value: this.targetClarity, velocity: 0 };
|
|
397
|
+
}
|
|
398
|
+
private setExploded(value: boolean) {
|
|
399
|
+
this.targetSpread = value ? 1 : 0;
|
|
400
|
+
this.root.dataset.exploded = String(value);
|
|
401
|
+
this.root
|
|
402
|
+
.querySelector('[data-viewer="explode"]')!
|
|
403
|
+
.setAttribute("aria-pressed", String(value));
|
|
404
|
+
this.root
|
|
405
|
+
.querySelector('[data-viewer="assemble"]')!
|
|
406
|
+
.setAttribute("aria-pressed", String(!value));
|
|
407
|
+
this.setStatus(
|
|
408
|
+
value ? "正在拆解" : this.spread.value > 0.001 ? "正在重组" : "已组装",
|
|
409
|
+
);
|
|
410
|
+
if (this.reduced) this.spread = { value: this.targetSpread, velocity: 0 };
|
|
411
|
+
}
|
|
412
|
+
private setStatus(value: string) {
|
|
413
|
+
if (value !== this.status) {
|
|
414
|
+
this.status = value;
|
|
415
|
+
this.root.querySelector(".viewer-state")!.textContent = value;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
private resetView(animated = true) {
|
|
419
|
+
this.controls.enabled = false;
|
|
420
|
+
this.controls.enableDamping = false;
|
|
421
|
+
this.controls.update();
|
|
422
|
+
this.controls.target.set(0, 0, 0);
|
|
423
|
+
this.controlCamera.position.copy(this.initialCamera);
|
|
424
|
+
this.controls.enableDamping = false;
|
|
425
|
+
this.controls.update();
|
|
426
|
+
if (animated && !this.reduced) this.cameraMotion.reset();
|
|
427
|
+
else this.cameraMotion.snap(this.controlCamera, this.controls.target);
|
|
428
|
+
this.controls.enabled =
|
|
429
|
+
this.isOpen && !this.loading && Boolean(this.source);
|
|
430
|
+
}
|
|
431
|
+
private interruptReset() {
|
|
432
|
+
if (!this.cameraMotion.resetting) return;
|
|
433
|
+
this.cameraMotion.interruptReset(this.controlCamera, this.controls.target);
|
|
434
|
+
this.controls.update();
|
|
435
|
+
}
|
|
436
|
+
private keydown(event: KeyboardEvent) {
|
|
437
|
+
event.stopPropagation();
|
|
438
|
+
if (event.key === "Escape") {
|
|
439
|
+
event.preventDefault();
|
|
440
|
+
this.close();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (this.closing) {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (event.key === "Tab") {
|
|
448
|
+
const elements = [
|
|
449
|
+
...this.root.querySelectorAll<HTMLElement>(
|
|
450
|
+
'button:not([disabled]):not([hidden]),canvas[tabindex="0"]',
|
|
451
|
+
),
|
|
452
|
+
];
|
|
453
|
+
const first = elements[0],
|
|
454
|
+
last = elements.at(-1);
|
|
455
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
456
|
+
event.preventDefault();
|
|
457
|
+
last?.focus();
|
|
458
|
+
}
|
|
459
|
+
if (!event.shiftKey && document.activeElement === last) {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
first?.focus();
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (!this.source || this.loading) return;
|
|
466
|
+
if (event.key === "Home") {
|
|
467
|
+
event.preventDefault();
|
|
468
|
+
this.resetView();
|
|
469
|
+
this.onSound("tick");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (["+", "=", "-"].includes(event.key)) {
|
|
473
|
+
event.preventDefault();
|
|
474
|
+
this.interruptReset();
|
|
475
|
+
const distance = this.controlCamera.position.distanceTo(
|
|
476
|
+
this.controls.target,
|
|
477
|
+
);
|
|
478
|
+
const next = THREE.MathUtils.clamp(
|
|
479
|
+
distance * (event.key === "-" ? 1.12 : 1 / 1.12),
|
|
480
|
+
5,
|
|
481
|
+
28,
|
|
482
|
+
);
|
|
483
|
+
this.controlCamera.position
|
|
484
|
+
.sub(this.controls.target)
|
|
485
|
+
.multiplyScalar(next / distance)
|
|
486
|
+
.add(this.controls.target);
|
|
487
|
+
this.controls.update();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (
|
|
491
|
+
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
|
|
492
|
+
) {
|
|
493
|
+
event.preventDefault();
|
|
494
|
+
this.interruptReset();
|
|
495
|
+
const right = new THREE.Vector3().setFromMatrixColumn(
|
|
496
|
+
this.camera.matrix,
|
|
497
|
+
0,
|
|
498
|
+
);
|
|
499
|
+
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrix, 1);
|
|
500
|
+
const delta = new THREE.Vector3();
|
|
501
|
+
const step =
|
|
502
|
+
this.controlCamera.position.distanceTo(this.controls.target) * 0.025;
|
|
503
|
+
if (event.key === "ArrowLeft") delta.addScaledVector(right, -step);
|
|
504
|
+
if (event.key === "ArrowRight") delta.addScaledVector(right, step);
|
|
505
|
+
if (event.key === "ArrowUp") delta.addScaledVector(up, step);
|
|
506
|
+
if (event.key === "ArrowDown") delta.addScaledVector(up, -step);
|
|
507
|
+
// Clamp the requested focus before moving the camera by the same amount.
|
|
508
|
+
// This preserves orbit radius at the panning limit.
|
|
509
|
+
const previous = this.controls.target.clone();
|
|
510
|
+
this.controls.target.add(delta);
|
|
511
|
+
this.controls.target.clampLength(0, this.controls.maxTargetRadius);
|
|
512
|
+
this.controlCamera.position.add(
|
|
513
|
+
this.controls.target.clone().sub(previous),
|
|
514
|
+
);
|
|
515
|
+
this.controls.update();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
setQuality(quality: RenderQuality) {
|
|
520
|
+
const key = JSON.stringify(quality);
|
|
521
|
+
if (this.appliedQuality === key) return;
|
|
522
|
+
this.appliedQuality = key;
|
|
523
|
+
this.quality = normalizeQuality(quality);
|
|
524
|
+
this.pipeline.smaa.enabled = this.quality.antialias === "smaa";
|
|
525
|
+
applyTextureQuality(this.scene, this.renderer, this.quality);
|
|
526
|
+
this.resize();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
resize() {
|
|
530
|
+
if (!this.isOpen) return;
|
|
531
|
+
const width = this.canvasHost.clientWidth,
|
|
532
|
+
height = this.canvasHost.clientHeight;
|
|
533
|
+
resizeQuality(
|
|
534
|
+
this.renderer,
|
|
535
|
+
this.pipeline.composer,
|
|
536
|
+
this.canvasHost,
|
|
537
|
+
this.quality,
|
|
538
|
+
);
|
|
539
|
+
this.camera.aspect = width / height;
|
|
540
|
+
this.camera.updateProjectionMatrix();
|
|
541
|
+
this.controlCamera.aspect = this.camera.aspect;
|
|
542
|
+
this.controlCamera.updateProjectionMatrix();
|
|
543
|
+
const touch = matchMedia("(pointer: coarse)").matches;
|
|
544
|
+
const help = this.root.querySelector(".viewer-help")!;
|
|
545
|
+
help.innerHTML = touch
|
|
546
|
+
? "<span>单指旋转</span><span>双指缩放 / 平移</span>"
|
|
547
|
+
: "<span>拖动旋转</span><span>↑ ↓ ← → 平移</span><span>滚轮缩放</span>";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
update(time: number, rafTime: number | null = null) {
|
|
551
|
+
if (!this.isOpen) return;
|
|
552
|
+
// TEMPORARY RHINE PROFILER: one-off renders are timed without counting as RAF ticks.
|
|
553
|
+
const measured = this.performanceProbe?.running ? this.performanceProbe.begin(rafTime, this.renderer, 'viewer') : undefined;
|
|
554
|
+
let failed = true;
|
|
555
|
+
try { this.updateFrame(time, measured ? stage => this.performanceProbe!.checkpoint(measured, stage) : undefined); failed = false; }
|
|
556
|
+
finally {
|
|
557
|
+
if (measured) measured.sample.counters = { modelReady: Boolean(this.source), loading: this.loading,
|
|
558
|
+
parts: this.groups.size, exploded: this.targetSpread === 1, clearSurface: this.targetClarity === 1,
|
|
559
|
+
smaaEnabled: this.quality.antialias === 'smaa' };
|
|
560
|
+
this.performanceProbe?.end(measured, failed);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private updateFrame(time: number, checkpoint?: (stage: string) => void) {
|
|
565
|
+
const dt = Math.min(this.lastTime ? time - this.lastTime : 1 / 60, 0.05);
|
|
566
|
+
this.lastTime = time;
|
|
567
|
+
if (this.source) {
|
|
568
|
+
damp(this.clarity, this.targetClarity, 8, dt);
|
|
569
|
+
if (Math.abs(this.clarity.value - this.targetClarity) < .0001 && Math.abs(this.clarity.velocity) < .001)
|
|
570
|
+
this.clarity = { value: this.targetClarity, velocity: 0 };
|
|
571
|
+
this.source.setClarity?.(this.clarity.value);
|
|
572
|
+
damp(this.spread, this.targetSpread, this.reduced ? 45 : 5.5, dt);
|
|
573
|
+
if (
|
|
574
|
+
Math.abs(this.spread.value - this.targetSpread) < 0.0001 &&
|
|
575
|
+
Math.abs(this.spread.velocity) < 0.001
|
|
576
|
+
) {
|
|
577
|
+
this.spread = { value: this.targetSpread, velocity: 0 };
|
|
578
|
+
this.setStatus(this.targetSpread ? "已拆解" : "已组装");
|
|
579
|
+
}
|
|
580
|
+
for (const part of PARTS) {
|
|
581
|
+
this.groups.get(part.id)!.position.z = part.depth * this.spread.value;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
this.controls.update();
|
|
585
|
+
this.cameraMotion.update(
|
|
586
|
+
this.controlCamera,
|
|
587
|
+
this.controls.target,
|
|
588
|
+
dt,
|
|
589
|
+
this.reduced,
|
|
590
|
+
);
|
|
591
|
+
const portrait = this.root.closest<HTMLElement>("[data-layout]")?.dataset.layout === "portrait";
|
|
592
|
+
const zoom = portrait ? Math.min(1.15, this.camera.aspect / 0.85) / (1 + 0.08 * this.spread.value) : 1;
|
|
593
|
+
if (this.camera.zoom !== zoom) {
|
|
594
|
+
this.camera.zoom = zoom;
|
|
595
|
+
this.controlCamera.zoom = zoom;
|
|
596
|
+
this.camera.updateProjectionMatrix();
|
|
597
|
+
this.controlCamera.updateProjectionMatrix();
|
|
598
|
+
}
|
|
599
|
+
// Match the detail scene's gentle haze without washing out the object as
|
|
600
|
+
// the user zooms. The assembled model is centered on the world origin.
|
|
601
|
+
const fog = this.scene.fog as THREE.Fog;
|
|
602
|
+
const objectDistance = this.camera.position.length();
|
|
603
|
+
fog.near = Math.max(0, objectDistance - 1);
|
|
604
|
+
fog.far = objectDistance + 12;
|
|
605
|
+
checkpoint?.("viewerUpdate");
|
|
606
|
+
if (this.quality.antialias === "smaa") this.pipeline.composer.render();
|
|
607
|
+
else this.renderer.render(this.scene, this.camera);
|
|
608
|
+
checkpoint?.('viewerRender');
|
|
609
|
+
this.root.dataset.stats = JSON.stringify({
|
|
610
|
+
ready: Boolean(this.source),
|
|
611
|
+
clarity: this.clarity.value,
|
|
612
|
+
targetClarity: this.targetClarity,
|
|
613
|
+
spread: this.spread.value,
|
|
614
|
+
target: this.targetSpread,
|
|
615
|
+
distance: this.camera.position.distanceTo(this.cameraMotion.focus),
|
|
616
|
+
targetPosition: this.cameraMotion.focus.toArray(),
|
|
617
|
+
requestedTarget: this.controls.target.toArray(),
|
|
618
|
+
requestedDistance: this.controlCamera.position.distanceTo(
|
|
619
|
+
this.controls.target,
|
|
620
|
+
),
|
|
621
|
+
cameraPosition: this.camera.position.toArray(),
|
|
622
|
+
resetting: this.cameraMotion.resetting,
|
|
623
|
+
azimuth: this.controls.getAzimuthalAngle(),
|
|
624
|
+
polar: this.controls.getPolarAngle(),
|
|
625
|
+
parts: [...this.groups].map(([id, group]) => ({
|
|
626
|
+
id,
|
|
627
|
+
z: group.position.z,
|
|
628
|
+
meshes: group.children.length,
|
|
629
|
+
})),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Host plugin teardown: retire pending loads and this independent WebGL context. */
|
|
634
|
+
dispose() {
|
|
635
|
+
this.request++;
|
|
636
|
+
this.transitionId++;
|
|
637
|
+
this.isOpen = false;
|
|
638
|
+
this.closing = true;
|
|
639
|
+
this.transitions.forEach(animation => animation.cancel());
|
|
640
|
+
this.transitions = [];
|
|
641
|
+
if (this.source) {
|
|
642
|
+
this.scene.remove(this.source.model);
|
|
643
|
+
this.source.dispose();
|
|
644
|
+
this.source = undefined;
|
|
645
|
+
}
|
|
646
|
+
this.groups.clear();
|
|
647
|
+
this.siblings.forEach(({ node, inert }) => { node.inert = inert; });
|
|
648
|
+
this.siblings = [];
|
|
649
|
+
this.controls.dispose();
|
|
650
|
+
for (const pass of this.pipeline.composer.passes) pass.dispose?.();
|
|
651
|
+
this.pipeline.composer.dispose();
|
|
652
|
+
this.scene.environment?.dispose();
|
|
653
|
+
this.unregisterPerformance?.();
|
|
654
|
+
this.renderer.dispose();
|
|
655
|
+
this.renderer.forceContextLoss();
|
|
656
|
+
this.root.remove();
|
|
657
|
+
}
|
|
658
|
+
}
|