|
|
@@ -1,990 +0,0 @@
|
|
|
-import * as THREE from 'three';
|
|
|
-import { OrbitControls } from '/js/jsm/controls/OrbitControls.js';
|
|
|
-import { TrackballControls } from '/js/jsm/controls/TrackballControls.js';
|
|
|
-import { GLTFLoader } from '/js/jsm/loaders/GLTFLoader.js';
|
|
|
-import { RoomEnvironment } from '/js/jsm/environments/RoomEnvironment.js';
|
|
|
-import { EffectComposer } from '/js/jsm/postprocessing/EffectComposer.js';
|
|
|
-import { RenderPass } from '/js/jsm/postprocessing/RenderPass.js';
|
|
|
-import { OutlinePass } from '/js/jsm/postprocessing/OutlinePass.js';
|
|
|
-import { OutputPass } from '/js/jsm/postprocessing/OutputPass.js';
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 状态预设:名称 / 颜色 / 是否恢复默认色 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-const PRESETS = [
|
|
|
- { key: 'ok', label: '正常', color: '#43A047' },
|
|
|
- { key: 'warn', label: '预警', color: '#F9A825' },
|
|
|
- { key: 'fault', label: '故障', color: '#E53935' },
|
|
|
-];
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* DOM */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-const $ = (id) => document.getElementById(id);
|
|
|
-const viewport = $('viewport');
|
|
|
-const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
|
|
-renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
|
-renderer.setSize(viewport.clientWidth, viewport.clientHeight);
|
|
|
-renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
|
-renderer.toneMappingExposure = 1.05;
|
|
|
-viewport.insertBefore(renderer.domElement, viewport.firstChild);
|
|
|
-
|
|
|
-const scene = new THREE.Scene();
|
|
|
-scene.background = new THREE.Color(0x0f1115);
|
|
|
-
|
|
|
-const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 2000);
|
|
|
-const orbitControls = new OrbitControls(camera, renderer.domElement);
|
|
|
-orbitControls.enableDamping = true;
|
|
|
-orbitControls.dampingFactor = 0.08;
|
|
|
-let controls = orbitControls; // 当前生效的控制器(轨道 / 自由)
|
|
|
-let trackball = null;
|
|
|
-let freeMode = false;
|
|
|
-
|
|
|
-/* 环境反射(让金属/粗糙PBR材质可见),房间环境由 PMREM 生成 */
|
|
|
-const pmrem = new THREE.PMREMGenerator(renderer);
|
|
|
-scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
|
-scene.add(new THREE.HemisphereLight(0xffffff, 0x20242c, 0.9));
|
|
|
-{
|
|
|
- const dl = new THREE.DirectionalLight(0xffffff, 1.6);
|
|
|
- dl.position.set(6, 12, 8);
|
|
|
- scene.add(dl);
|
|
|
- const dl2 = new THREE.DirectionalLight(0xffffff, 0.5);
|
|
|
- dl2.position.set(-8, -4, -6);
|
|
|
- scene.add(dl2);
|
|
|
-}
|
|
|
-
|
|
|
-/* 后处理:描边高亮选中部件 */
|
|
|
-const composer = new EffectComposer(renderer);
|
|
|
-composer.addPass(new RenderPass(scene, camera));
|
|
|
-const outline = new OutlinePass(
|
|
|
- new THREE.Vector2(viewport.clientWidth, viewport.clientHeight), scene, camera
|
|
|
-);
|
|
|
-outline.edgeStrength = 4;
|
|
|
-outline.edgeGlow = 0.2;
|
|
|
-outline.edgeThickness = 1.2;
|
|
|
-outline.visibleEdgeColor = new THREE.Color(0x4da3ff);
|
|
|
-outline.hiddenEdgeColor = new THREE.Color(0x000000);
|
|
|
-composer.addPass(outline);
|
|
|
-composer.addPass(new OutputPass());
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 状态变量 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-const parts = []; // { id, label, obj|inst+instanceId, status, colorHex, defaults:[hex,...], name }
|
|
|
-const selected = new Set(); // 存放 part 引用(面板与场景共用)
|
|
|
-let modelName = null;
|
|
|
-let modelGroup = null;
|
|
|
-let grid = null;
|
|
|
-let hoveredPart = null;
|
|
|
-let saveTimer = null;
|
|
|
-const modelCenter = new THREE.Vector3(); // 当前模型包围盒中心
|
|
|
-const modelSize = new THREE.Vector3(); // 当前模型包围盒尺寸
|
|
|
-let modelDist = 0; // 当前取景距离(含 120% 缩放)
|
|
|
-
|
|
|
-/* 大模型模式:部件数超过阈值时,用 InstancedMesh 批量渲染 */
|
|
|
-const LARGE_THRESHOLD = 1500;
|
|
|
-let largeMode = false;
|
|
|
-let instancedMeshes = []; // 场景中的 InstancedMesh 集合
|
|
|
-let prevSelected = new Set(); // 上一次选中集合(用于增量更新高亮)
|
|
|
-const HIGHLIGHT = new THREE.Color(0x4da3ff);
|
|
|
-const instMaterial = new THREE.MeshStandardMaterial({
|
|
|
- color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
|
|
|
-});
|
|
|
-
|
|
|
-const loader = new GLTFLoader();
|
|
|
-const raycaster = new THREE.Raycaster();
|
|
|
-const pointer = new THREE.Vector2();
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 工具函数 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function eachMaterial(obj, fn) {
|
|
|
- if (!obj) return;
|
|
|
- if (Array.isArray(obj.material)) obj.material.forEach(fn);
|
|
|
- else fn(obj.material);
|
|
|
-}
|
|
|
-function toHex(c) {
|
|
|
- return '#' + new THREE.Color(c).getHexString();
|
|
|
-}
|
|
|
-function isValidModelFile(name) {
|
|
|
- return /\.(glb|gltf)$/i.test(name);
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 部件管理:为每个 Mesh 建立独立材质 + 稳定的部件ID */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function ensureOwnMaterial(obj) {
|
|
|
- if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m.clone());
|
|
|
- else if (obj.material) obj.material = obj.material.clone();
|
|
|
- eachMaterial(obj, (m) => { m.needsUpdate = true; });
|
|
|
-}
|
|
|
-function collectParts(group) {
|
|
|
- parts.length = 0;
|
|
|
- const seenCount = new Map();
|
|
|
- group.traverse((obj) => {
|
|
|
- if (!obj.isMesh || !obj.geometry) return;
|
|
|
- ensureOwnMaterial(obj);
|
|
|
- const base = (obj.name && obj.name.trim()) ? obj.name.trim() : '部件';
|
|
|
- const n = (seenCount.get(base) || 0) + 1;
|
|
|
- seenCount.set(base, n);
|
|
|
- const label = n > 1 ? `${base} [${n}]` : base;
|
|
|
- const id = `${base}#${n}`;
|
|
|
- const defaults = [];
|
|
|
- eachMaterial(obj, (m) => {
|
|
|
- const hex = toHex(m.color || 0xcccccc);
|
|
|
- defaults.push(hex);
|
|
|
- m.color.set(hex);
|
|
|
- });
|
|
|
- const part = { id, label, obj, name: base, status: null, colorHex: null, defaults };
|
|
|
- obj.userData.part = part;
|
|
|
- parts.push(part);
|
|
|
- });
|
|
|
- parts.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
|
|
|
- return parts;
|
|
|
-}
|
|
|
-
|
|
|
-/* 大模型模式:按"唯一几何"归并,用 InstancedMesh 渲染(实例复用几何,
|
|
|
- 绘制次数从 N 降到唯一几何数;每个实例可独立着色与拾取) */
|
|
|
-function countMeshNodes(group) {
|
|
|
- let n = 0;
|
|
|
- group.traverse((o) => { if (o.isMesh && o.geometry) n++; });
|
|
|
- return n;
|
|
|
-}
|
|
|
-function collectPartsInstanced(group) {
|
|
|
- parts.length = 0;
|
|
|
- instancedMeshes = [];
|
|
|
- group.updateMatrixWorld(true);
|
|
|
- const byGeom = new Map(); // geometry.uuid -> { geometry, entries:[{matrix, part}] }
|
|
|
- const seenCount = new Map();
|
|
|
- group.traverse((obj) => {
|
|
|
- if (!obj.isMesh || !obj.geometry) return;
|
|
|
- const g = obj.geometry;
|
|
|
- if (!g.attributes.normal) g.computeVertexNormals();
|
|
|
- if (!g.boundingSphere) g.computeBoundingSphere();
|
|
|
- let rec = byGeom.get(g.uuid);
|
|
|
- if (!rec) { rec = { geometry: g, entries: [] }; byGeom.set(g.uuid, rec); }
|
|
|
-
|
|
|
- const base = (obj.name && obj.name.trim()) ? obj.name.trim() : '部件';
|
|
|
- const n = (seenCount.get(base) || 0) + 1;
|
|
|
- seenCount.set(base, n);
|
|
|
- const label = n > 1 ? `${base} [${n}]` : base;
|
|
|
- const id = `${base}#${n}`;
|
|
|
- const defaultHex = toHex((obj.material && obj.material.color) ? obj.material.color : 0xcccccc);
|
|
|
- const part = {
|
|
|
- id, label, name: base, status: null, colorHex: null,
|
|
|
- defaults: [defaultHex], obj: null, inst: null, instanceId: 0,
|
|
|
- };
|
|
|
- rec.entries.push({ matrix: obj.matrixWorld.clone(), part });
|
|
|
- parts.push(part);
|
|
|
- });
|
|
|
-
|
|
|
- byGeom.forEach((rec) => {
|
|
|
- const inst = new THREE.InstancedMesh(rec.geometry, instMaterial, rec.entries.length);
|
|
|
- inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
|
- const map = new Array(rec.entries.length);
|
|
|
- rec.entries.forEach((e, i) => {
|
|
|
- inst.setMatrixAt(i, e.matrix);
|
|
|
- inst.setColorAt(i, new THREE.Color(e.part.defaults[0]));
|
|
|
- e.part.inst = inst;
|
|
|
- e.part.instanceId = i;
|
|
|
- map[i] = e.part;
|
|
|
- });
|
|
|
- inst.instanceMatrix.needsUpdate = true;
|
|
|
- if (inst.instanceColor) inst.instanceColor.needsUpdate = true;
|
|
|
- inst.userData.instPartMap = map;
|
|
|
- scene.add(inst);
|
|
|
- instancedMeshes.push(inst);
|
|
|
- });
|
|
|
-
|
|
|
- parts.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
|
|
|
- return parts;
|
|
|
-}
|
|
|
-function clearRendered() {
|
|
|
- instancedMeshes.forEach((m) => {
|
|
|
- scene.remove(m);
|
|
|
- m.dispose();
|
|
|
- });
|
|
|
- instancedMeshes = [];
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 部件属性:顶点数 / 三角面 / 世界尺寸(懒计算并缓存) */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function partGeometry(part) {
|
|
|
- if (part.inst) return part.inst.geometry;
|
|
|
- return part.obj ? part.obj.geometry : null;
|
|
|
-}
|
|
|
-function partMatrix(part) {
|
|
|
- if (part.inst) {
|
|
|
- const m = new THREE.Matrix4();
|
|
|
- part.inst.getMatrixAt(part.instanceId, m);
|
|
|
- return m;
|
|
|
- }
|
|
|
- return part.obj ? part.obj.matrixWorld : null;
|
|
|
-}
|
|
|
-function partStats(part) {
|
|
|
- if (part._stats) return part._stats;
|
|
|
- let vtx = 0, tri = 0, size = '—';
|
|
|
- const g = partGeometry(part);
|
|
|
- if (g) {
|
|
|
- if (g.attributes && g.attributes.position) vtx = g.attributes.position.count;
|
|
|
- tri = g.index ? Math.floor(g.index.count / 3) : Math.floor(vtx / 3);
|
|
|
- if (!g.boundingBox) g.computeBoundingBox();
|
|
|
- const m = partMatrix(part);
|
|
|
- if (g.boundingBox && m) {
|
|
|
- const box = g.boundingBox.clone().applyMatrix4(m);
|
|
|
- const d = box.getSize(new THREE.Vector3());
|
|
|
- size = `${d.x.toFixed(1)}×${d.y.toFixed(1)}×${d.z.toFixed(1)}`;
|
|
|
- }
|
|
|
- }
|
|
|
- part._stats = { vtx, tri, size };
|
|
|
- return part._stats;
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 上色(兼容普通模式与大模型模式) */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function applyPartDisplay(part) {
|
|
|
- const base = part.colorHex || part.defaults[0];
|
|
|
- if (part.inst) {
|
|
|
- const c = new THREE.Color(base);
|
|
|
- if (selected.has(part)) c.lerp(HIGHLIGHT, 0.45); // 大模型模式用颜色提亮表示选中
|
|
|
- part.inst.setColorAt(part.instanceId, c);
|
|
|
- if (part.inst.instanceColor) part.inst.instanceColor.needsUpdate = true;
|
|
|
- } else if (part.obj) {
|
|
|
- eachMaterial(part.obj, (m) => { if (m.color) m.color.set(base); });
|
|
|
- }
|
|
|
-}
|
|
|
-function setPartColor(part, hex, status) {
|
|
|
- part.colorHex = toHex(new THREE.Color(hex));
|
|
|
- part.status = status || null;
|
|
|
- applyPartDisplay(part);
|
|
|
-}
|
|
|
-function resetPartColor(part) {
|
|
|
- part.colorHex = null;
|
|
|
- part.status = null;
|
|
|
- applyPartDisplay(part);
|
|
|
-}
|
|
|
-function refreshSelectionColors() {
|
|
|
- if (!largeMode) return;
|
|
|
- const changed = new Set();
|
|
|
- selected.forEach((p) => { if (!prevSelected.has(p)) changed.add(p); });
|
|
|
- prevSelected.forEach((p) => { if (!selected.has(p)) changed.add(p); });
|
|
|
- changed.forEach(applyPartDisplay);
|
|
|
- prevSelected = new Set(selected);
|
|
|
-}
|
|
|
-function resetAllParts() {
|
|
|
- parts.forEach((p) => resetPartColor(p));
|
|
|
- refreshRows();
|
|
|
- renderPartDetail();
|
|
|
- scheduleSave();
|
|
|
-}
|
|
|
-function applyToSelection(hex, status) {
|
|
|
- if (!selected.size) { toast('请先选择一个部件'); return; }
|
|
|
- selected.forEach((p) => setPartColor(p, hex, status));
|
|
|
- refreshRows();
|
|
|
- renderPartDetail();
|
|
|
- scheduleSave();
|
|
|
-}
|
|
|
-function resetSelected() {
|
|
|
- if (!selected.size) { toast('请先选择一个部件'); return; }
|
|
|
- selected.forEach((p) => resetPartColor(p));
|
|
|
- refreshRows();
|
|
|
- renderPartDetail();
|
|
|
- scheduleSave();
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 时序着色演示:本色 → 预警 → 故障,每 1 秒切换 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-const CYCLE_STATES = [
|
|
|
- { label: '本色', apply: (p) => resetPartColor(p) },
|
|
|
- { label: '预警', apply: (p) => setPartColor(p, '#F9A825', 'warn') },
|
|
|
- { label: '故障', apply: (p) => setPartColor(p, '#E53935', 'fault') },
|
|
|
-];
|
|
|
-let cycleTimer = null;
|
|
|
-let cyclePart = null;
|
|
|
-let cycleStep = 0;
|
|
|
-
|
|
|
-/* 只更新该部件对应的一行,避免每秒重建整个列表 */
|
|
|
-function updatePartRow(part) {
|
|
|
- const list = $('partList');
|
|
|
- for (const row of list.children) {
|
|
|
- if (!row.dataset || row.dataset.id !== part.id) continue;
|
|
|
- const sw = row.querySelector('.swatch');
|
|
|
- if (sw) sw.style.background = part.colorHex || part.defaults[0];
|
|
|
- let tag = row.querySelector('.tag');
|
|
|
- const preset = part.status ? PRESETS.find((s) => s.key === part.status) : null;
|
|
|
- if (preset) {
|
|
|
- if (!tag) { tag = document.createElement('span'); tag.className = 'tag'; row.appendChild(tag); }
|
|
|
- tag.textContent = preset.label;
|
|
|
- tag.style.background = preset.color;
|
|
|
- } else if (tag) {
|
|
|
- tag.remove();
|
|
|
- }
|
|
|
- break;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function stopCycle() {
|
|
|
- if (cycleTimer) { clearInterval(cycleTimer); cycleTimer = null; }
|
|
|
- if (cyclePart) { resetPartColor(cyclePart); updatePartRow(cyclePart); }
|
|
|
- cyclePart = null;
|
|
|
- cycleStep = 0;
|
|
|
- const el = $('cycleProgress');
|
|
|
- if (el) { el.style.display = 'none'; el.textContent = ''; }
|
|
|
-}
|
|
|
-
|
|
|
-function startCycle(part) {
|
|
|
- stopCycle();
|
|
|
- if (!part) return;
|
|
|
- cyclePart = part;
|
|
|
- cycleStep = 0;
|
|
|
- const el = $('cycleProgress');
|
|
|
- if (el) el.style.display = 'inline-block';
|
|
|
- const tick = () => {
|
|
|
- const s = CYCLE_STATES[cycleStep];
|
|
|
- s.apply(cyclePart);
|
|
|
- updatePartRow(cyclePart);
|
|
|
- if (el) el.textContent = `${cycleStep + 1}/3 ${s.label}`;
|
|
|
- log(`时序切换 ${cycleStep + 1}/3 ${s.label}`);
|
|
|
- cycleStep = (cycleStep + 1) % CYCLE_STATES.length;
|
|
|
- };
|
|
|
- tick(); // 立即进入 1/3
|
|
|
- cycleTimer = setInterval(tick, 1000); // 每 1 秒切换
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 选择管理 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function clearSelection() {
|
|
|
- selected.clear();
|
|
|
- updateSelectionUI();
|
|
|
-}
|
|
|
-function selectOnly(part) {
|
|
|
- selected.clear();
|
|
|
- if (part) selected.add(part);
|
|
|
- updateSelectionUI();
|
|
|
-}
|
|
|
-function toggleSelect(part, additive) {
|
|
|
- if (!additive) { selectOnly(part); return; }
|
|
|
- if (selected.has(part)) selected.delete(part); else selected.add(part);
|
|
|
- updateSelectionUI();
|
|
|
-}
|
|
|
-function updateSelectionUI() {
|
|
|
- refreshRows();
|
|
|
- renderPartDetail();
|
|
|
- if (largeMode) {
|
|
|
- outline.selectedObjects = [];
|
|
|
- refreshSelectionColors();
|
|
|
- } else {
|
|
|
- outline.selectedObjects = [...selected].map((p) => p.obj);
|
|
|
- }
|
|
|
-}
|
|
|
-function renderPartDetail() {
|
|
|
- const el = $('partDetail');
|
|
|
- if (!el) return;
|
|
|
- el.innerHTML = '';
|
|
|
- const add = (text, cls) => {
|
|
|
- const d = document.createElement('div');
|
|
|
- d.className = 'dline' + (cls ? ' ' + cls : '');
|
|
|
- d.textContent = text;
|
|
|
- el.appendChild(d);
|
|
|
- };
|
|
|
- const n = selected.size;
|
|
|
- if (n === 0) {
|
|
|
- add('未选中部件。点击模型中的部件,或点下方「打开查询部件…」选择。', 'dim');
|
|
|
- return;
|
|
|
- }
|
|
|
- if (n > 1) {
|
|
|
- const names = [...selected].slice(0, 6).map((p) => p.label).join('、');
|
|
|
- add(`已选中 ${n} 个部件:${names}${n > 6 ? '…' : ''}`);
|
|
|
- return;
|
|
|
- }
|
|
|
- const p = selected.values().next().value;
|
|
|
- const st = partStats(p);
|
|
|
- const color = p.colorHex || p.defaults[0];
|
|
|
- const preset = PRESETS.find((s) => s.key === p.status);
|
|
|
- add(`名称:${p.label}`);
|
|
|
- add(`顶点数:${st.vtx.toLocaleString()} 三角面:${st.tri.toLocaleString()}`);
|
|
|
- add(`尺寸:${st.size} 颜色:${color} 状态:${preset ? preset.label : '未设置'}`);
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 部件列表面板 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-const MAX_LIST_ROWS = 800;
|
|
|
-function refreshRows() {
|
|
|
- const list = $('partList');
|
|
|
- if (!list) return;
|
|
|
- const q = ($('search').value || '').trim().toLowerCase();
|
|
|
- const frag = document.createDocumentFragment();
|
|
|
- const matched = q
|
|
|
- ? parts.filter((p) => p.label.toLowerCase().includes(q) || p.name.toLowerCase().includes(q))
|
|
|
- : parts;
|
|
|
- const shown = matched.slice(0, MAX_LIST_ROWS);
|
|
|
- shown.forEach((p) => {
|
|
|
- const st = partStats(p);
|
|
|
- const row = document.createElement('div');
|
|
|
- row.className = 'part-row' + (selected.has(p) ? ' selected' : '');
|
|
|
- row.dataset.id = p.id;
|
|
|
-
|
|
|
- const name = document.createElement('span');
|
|
|
- name.className = 'name';
|
|
|
- name.textContent = p.label;
|
|
|
- name.title = p.label;
|
|
|
-
|
|
|
- const vtx = document.createElement('span');
|
|
|
- vtx.className = 'vtx';
|
|
|
- vtx.textContent = st.vtx.toLocaleString();
|
|
|
-
|
|
|
- const tri = document.createElement('span');
|
|
|
- tri.className = 'tri';
|
|
|
- tri.textContent = st.tri.toLocaleString();
|
|
|
-
|
|
|
- const size = document.createElement('span');
|
|
|
- size.className = 'size';
|
|
|
- size.textContent = st.size;
|
|
|
-
|
|
|
- const color = document.createElement('span');
|
|
|
- color.className = 'color';
|
|
|
- const swatch = document.createElement('i');
|
|
|
- swatch.className = 'swatch';
|
|
|
- swatch.style.background = p.colorHex || p.defaults[0];
|
|
|
- const hex = document.createElement('span');
|
|
|
- hex.textContent = p.colorHex || p.defaults[0];
|
|
|
- color.appendChild(swatch);
|
|
|
- color.appendChild(hex);
|
|
|
-
|
|
|
- const status = document.createElement('span');
|
|
|
- status.className = 'status';
|
|
|
- const preset = PRESETS.find((s) => s.key === p.status);
|
|
|
- if (preset) {
|
|
|
- const tag = document.createElement('span');
|
|
|
- tag.className = 'tag';
|
|
|
- tag.textContent = preset.label;
|
|
|
- tag.style.background = preset.color;
|
|
|
- status.appendChild(tag);
|
|
|
- } else {
|
|
|
- status.textContent = '—';
|
|
|
- }
|
|
|
-
|
|
|
- row.appendChild(name);
|
|
|
- row.appendChild(vtx);
|
|
|
- row.appendChild(tri);
|
|
|
- row.appendChild(size);
|
|
|
- row.appendChild(color);
|
|
|
- row.appendChild(status);
|
|
|
-
|
|
|
- row.addEventListener('click', () => selectOnly(p));
|
|
|
- row.addEventListener('mouseenter', () => setHover(p, true));
|
|
|
- row.addEventListener('mouseleave', () => setHover(p, false));
|
|
|
- frag.appendChild(row);
|
|
|
- });
|
|
|
- list.innerHTML = '';
|
|
|
- list.appendChild(frag);
|
|
|
- $('partCount').textContent = `共 ${parts.length} 个部件${largeMode ? ' · 大模型模式' : ''}`;
|
|
|
- const note = $('partListNote');
|
|
|
- if (note) {
|
|
|
- if (matched.length > MAX_LIST_ROWS) {
|
|
|
- note.style.display = 'block';
|
|
|
- note.textContent = `匹配 ${matched.length} 个,仅显示前 ${MAX_LIST_ROWS} 个,请用搜索缩小范围`;
|
|
|
- } else if (!matched.length) {
|
|
|
- note.style.display = 'block';
|
|
|
- note.textContent = '没有匹配的部件';
|
|
|
- } else {
|
|
|
- note.style.display = 'none';
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 悬停提示 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function setHover(part, on) {
|
|
|
- if (on && part !== hoveredPart) {
|
|
|
- hoveredPart = part;
|
|
|
- renderer.domElement.style.cursor = 'pointer';
|
|
|
- const tip = $('tooltip');
|
|
|
- tip.textContent = part.label;
|
|
|
- tip.style.display = 'block';
|
|
|
- } else if (!on && part === hoveredPart) {
|
|
|
- hoveredPart = null;
|
|
|
- renderer.domElement.style.cursor = 'default';
|
|
|
- $('tooltip').style.display = 'none';
|
|
|
- }
|
|
|
-}
|
|
|
-viewport.addEventListener('mousemove', (e) => {
|
|
|
- const r = renderer.domElement.getBoundingClientRect();
|
|
|
- const tip = $('tooltip');
|
|
|
- if (hoveredPart) {
|
|
|
- tip.style.left = (e.clientX - r.left + 14) + 'px';
|
|
|
- tip.style.top = (e.clientY - r.top + 14) + 'px';
|
|
|
- }
|
|
|
-});
|
|
|
-
|
|
|
-/* 场景拾取(悬停 + 点选) */
|
|
|
-function pickPart(clientX, clientY) {
|
|
|
- const rect = renderer.domElement.getBoundingClientRect();
|
|
|
- pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
|
- pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
- raycaster.setFromCamera(pointer, camera);
|
|
|
- if (largeMode) {
|
|
|
- if (!instancedMeshes.length) return null;
|
|
|
- const hits = raycaster.intersectObjects(instancedMeshes, false);
|
|
|
- if (!hits.length) return null;
|
|
|
- const h = hits[0];
|
|
|
- return h.object.userData.instPartMap[h.instanceId] || null;
|
|
|
- }
|
|
|
- const meshes = parts.map((p) => p.obj);
|
|
|
- const hits = raycaster.intersectObjects(meshes, false);
|
|
|
- return hits.length ? hits[0].object.userData.part : null;
|
|
|
-}
|
|
|
-
|
|
|
-let lastPick = 0;
|
|
|
-renderer.domElement.addEventListener('pointermove', (e) => {
|
|
|
- const now = performance.now();
|
|
|
- if (now - lastPick < 60) return; // 大模型下限制拾取频率,避免卡顿
|
|
|
- lastPick = now;
|
|
|
- const p = pickPart(e.clientX, e.clientY);
|
|
|
- setHover(p, true);
|
|
|
- setHover(hoveredPart && hoveredPart !== p ? hoveredPart : null, false);
|
|
|
-});
|
|
|
-
|
|
|
-/* 区分“点击”与“拖动旋转”:位移小于阈值才算点击 */
|
|
|
-let downXY = null;
|
|
|
-renderer.domElement.addEventListener('pointerdown', (e) => {
|
|
|
- downXY = [e.clientX, e.clientY];
|
|
|
-});
|
|
|
-renderer.domElement.addEventListener('pointerup', (e) => {
|
|
|
- if (!downXY) return;
|
|
|
- const dist = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]);
|
|
|
- downXY = null;
|
|
|
- if (dist > 5) return;
|
|
|
- const p = pickPart(e.clientX, e.clientY);
|
|
|
- toggleSelect(p, (e.ctrlKey || e.metaKey) && p !== null);
|
|
|
-});
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 相机取景 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function fitCamera(group, zoom = 1.2) {
|
|
|
- const box = new THREE.Box3().setFromObject(group);
|
|
|
- if (box.isEmpty()) return;
|
|
|
- const center = box.getCenter(new THREE.Vector3());
|
|
|
- const size = box.getSize(new THREE.Vector3());
|
|
|
- const maxDim = Math.max(size.x, size.y, size.z);
|
|
|
- const fovRad = THREE.MathUtils.degToRad(camera.fov);
|
|
|
- const fitDist = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.35;
|
|
|
- modelCenter.copy(center);
|
|
|
- modelSize.copy(size);
|
|
|
- modelDist = fitDist / zoom; // zoom=1.2 -> 比标准取景放大到 120%
|
|
|
- camera.near = maxDim / 100;
|
|
|
- camera.far = maxDim * 50;
|
|
|
- const dir = new THREE.Vector3(0.7, 0.55, 1).normalize();
|
|
|
- camera.position.copy(center).add(dir.multiplyScalar(modelDist));
|
|
|
- camera.lookAt(center);
|
|
|
- controls.target.copy(center);
|
|
|
- controls.update();
|
|
|
- updateCameraAspect();
|
|
|
- if (grid) scene.remove(grid);
|
|
|
- const g = new THREE.GridHelper(maxDim * 2.2, 20, 0x3a424f, 0x232831);
|
|
|
- g.position.set(center.x, box.min.y, center.z);
|
|
|
- g.material.transparent = true;
|
|
|
- g.material.opacity = 0.5;
|
|
|
- scene.add(g);
|
|
|
- grid = g;
|
|
|
- updateCamInfo();
|
|
|
-}
|
|
|
-
|
|
|
-/* 应用一个视角:
|
|
|
- - 固定坐标视角:X/Y 固定,Z 按"零件中位位置"随模型平移(模块沿 Z 排列);
|
|
|
- - 轴向视角(axis):相机放在模型中心沿该轴方向、按横截面大小取距离,正对该轴端面 */
|
|
|
-function applyView(v) {
|
|
|
- if (!modelDist) return;
|
|
|
- let target, cam;
|
|
|
- if (v.axis) {
|
|
|
- const dir = new THREE.Vector3(...v.axis).normalize();
|
|
|
- const ai = v.axis.findIndex((x) => x !== 0);
|
|
|
- const cross = Math.max(...[0, 1, 2].filter((i) => i !== ai).map((i) => modelSize.getComponent(i)));
|
|
|
- const fovRad = THREE.MathUtils.degToRad(camera.fov);
|
|
|
- const crossFit = (cross / (2 * Math.tan(fovRad / 2))) * 1.2;
|
|
|
- const halfAxis = modelSize.getComponent(ai) / 2;
|
|
|
- const dist = halfAxis + crossFit; // 保证相机在该轴端面之外
|
|
|
- target = modelCenter.clone();
|
|
|
- cam = modelCenter.clone().addScaledVector(dir, dist);
|
|
|
- } else {
|
|
|
- const dz = modelMedianZ - REF_MEDIAN_Z;
|
|
|
- target = new THREE.Vector3(v.target[0], v.target[1], v.target[2] + dz);
|
|
|
- cam = new THREE.Vector3(v.camera[0], v.camera[1], v.camera[2] + dz);
|
|
|
- }
|
|
|
- camera.position.copy(cam);
|
|
|
- camera.lookAt(target);
|
|
|
- controls.target.copy(target);
|
|
|
- controls.update();
|
|
|
- updateCamInfo();
|
|
|
-}
|
|
|
-
|
|
|
-/* 相机沿视线方向前后移动(保持朝向),用于"走近/走远"查看 */
|
|
|
-function moveForward(sign) {
|
|
|
- const dir = new THREE.Vector3().subVectors(controls.target, camera.position).normalize();
|
|
|
- const step = (modelDist || 10) * 0.08;
|
|
|
- camera.position.addScaledVector(dir, sign * step);
|
|
|
- controls.target.addScaledVector(dir, sign * step);
|
|
|
- controls.update();
|
|
|
-}
|
|
|
-window.addEventListener('keydown', (e) => {
|
|
|
- const t = e.target;
|
|
|
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
|
|
- if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') { moveForward(1); e.preventDefault(); }
|
|
|
- else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') { moveForward(-1); e.preventDefault(); }
|
|
|
-});
|
|
|
-
|
|
|
-let viewButtonEls = [];
|
|
|
-function activateView(i) {
|
|
|
- applyView(VIEWS[i]);
|
|
|
- viewButtonEls.forEach((b, j) => b.classList.toggle('active', j === i));
|
|
|
-}
|
|
|
-
|
|
|
-/* 实时显示当前相机角度参数,便于调好角度后固化到按钮 */
|
|
|
-function updateCamInfo() {
|
|
|
- const el = $('camInfo');
|
|
|
- if (!el) return;
|
|
|
- const off = camera.position.clone().sub(controls.target);
|
|
|
- if (off.lengthSq() === 0) { el.textContent = ''; return; }
|
|
|
- const sph = new THREE.Spherical().setFromVector3(off);
|
|
|
- const az = ((THREE.MathUtils.radToDeg(sph.theta) % 360) + 360) % 360;
|
|
|
- const elev = 90 - THREE.MathUtils.radToDeg(sph.phi);
|
|
|
- const p = camera.position, t = controls.target;
|
|
|
- el.textContent =
|
|
|
- `方位角 Az ${az.toFixed(1)}° 俯仰角 El ${elev.toFixed(1)}° 距离 ${sph.radius.toFixed(1)}\n` +
|
|
|
- `相机 (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\n` +
|
|
|
- `目标 (${t.x.toFixed(1)}, ${t.y.toFixed(1)}, ${t.z.toFixed(1)})`;
|
|
|
-}
|
|
|
-controls.addEventListener('change', updateCamInfo);
|
|
|
-
|
|
|
-/* 旋转模式:轨道(OrbitControls,绕上轴) / 自由(TrackballControls,可任意翻滚) */
|
|
|
-function ensureTrackball() {
|
|
|
- if (trackball) return trackball;
|
|
|
- trackball = new TrackballControls(camera, renderer.domElement);
|
|
|
- trackball.rotateSpeed = 3.0;
|
|
|
- trackball.zoomSpeed = 1.2;
|
|
|
- trackball.panSpeed = 0.8;
|
|
|
- trackball.staticMoving = false;
|
|
|
- trackball.dynamicDampingFactor = 0.15;
|
|
|
- trackball.enabled = false;
|
|
|
- trackball.addEventListener('change', updateCamInfo);
|
|
|
- return trackball;
|
|
|
-}
|
|
|
-function setFreeMode(on) {
|
|
|
- freeMode = on;
|
|
|
- if (on) {
|
|
|
- ensureTrackball();
|
|
|
- trackball.target.copy(controls.target);
|
|
|
- trackball.enabled = true;
|
|
|
- orbitControls.enabled = false;
|
|
|
- controls = trackball;
|
|
|
- } else {
|
|
|
- if (trackball) trackball.enabled = false;
|
|
|
- camera.up.set(0, 1, 0); // 回到轨道模式时恢复默认上轴
|
|
|
- orbitControls.target.copy(controls.target);
|
|
|
- orbitControls.enabled = true;
|
|
|
- controls = orbitControls;
|
|
|
- }
|
|
|
- controls.update();
|
|
|
- updateCamInfo();
|
|
|
-}
|
|
|
-
|
|
|
-/* 视角预设:模型已把 CAD 的 Z 朝上转成 Y 朝上(rotation.x = -90°),
|
|
|
- 坐标均为新坐标系 (x, z, -y)_旧。加载时按当前模型的零件中位位置只平移 Z
|
|
|
- (三个模块沿 Z 排列,X/Y 不变),从而三个模块都能套用同一视角。
|
|
|
- 俯视: 相机 (2.0, 22.0, -3.2) -> 目标 (2.0, 0, -22.0)
|
|
|
- 角度A: 相机 (5.4, 16.7, -6.3) -> 目标 (9.4, 0, -22.5)
|
|
|
- 短边: 沿长轴(X)方向正对短边,相机/距离自动按模型横截面计算 */
|
|
|
-const REF_MEDIAN_Z = -22.46; // 参考模型零件 Z 中位数(新坐标系)
|
|
|
-let modelMedianZ = REF_MEDIAN_Z; // 当前模型零件 Z 中位数(加载后更新)
|
|
|
-const VIEWS = [
|
|
|
- { label: '俯视', camera: [2.0, 22.0, -3.2], target: [2.0, 0.0, -22.0] },
|
|
|
- { label: '角度A', camera: [5.4, 16.7, -6.3], target: [9.4, 0.0, -22.5] },
|
|
|
- { label: '短边', axis: [1, 0, 0] },
|
|
|
-];
|
|
|
-
|
|
|
-/* 零件包围盒中心在指定轴上的中位数:对地板等少数大件不敏感,能稳定代表压缩机位置 */
|
|
|
-function computeMedian(group, axis = 2) {
|
|
|
- const vals = [];
|
|
|
- group.traverse((o) => {
|
|
|
- if (!o.isMesh || !o.geometry) return;
|
|
|
- if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
|
|
|
- const c = new THREE.Vector3();
|
|
|
- o.geometry.boundingBox.getCenter(c).applyMatrix4(o.matrixWorld);
|
|
|
- vals.push(c.getComponent(axis));
|
|
|
- });
|
|
|
- if (!vals.length) return 0;
|
|
|
- vals.sort((a, b) => a - b);
|
|
|
- return vals[Math.floor(vals.length / 2)];
|
|
|
-}
|
|
|
-
|
|
|
-function updateCameraAspect() {
|
|
|
- const w = viewport.clientWidth, h = viewport.clientHeight;
|
|
|
- if (!w || !h) return;
|
|
|
- camera.aspect = w / h;
|
|
|
- camera.updateProjectionMatrix();
|
|
|
- renderer.setSize(w, h);
|
|
|
- composer.setSize(w, h);
|
|
|
- outline.setSize(w, h);
|
|
|
- if (trackball) trackball.handleResize();
|
|
|
-}
|
|
|
-window.addEventListener('resize', updateCameraAspect);
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 模型加载 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-async function loadModel(name) {
|
|
|
- if (!isValidModelFile(name)) return;
|
|
|
- stopCycle();
|
|
|
- clearSelection();
|
|
|
- clearRendered();
|
|
|
- parts.length = 0;
|
|
|
- prevSelected = new Set();
|
|
|
- if (modelGroup) scene.remove(modelGroup);
|
|
|
- $('loading').style.display = 'flex';
|
|
|
- $('loading').textContent = '模型加载中…';
|
|
|
- $('partList').innerHTML = '';
|
|
|
- modelName = name;
|
|
|
- try {
|
|
|
- const gltf = await new Promise((resolve, reject) => {
|
|
|
- loader.load(
|
|
|
- '/models/' + encodeURIComponent(name),
|
|
|
- resolve,
|
|
|
- (ev) => {
|
|
|
- if (ev.lengthComputable) {
|
|
|
- const mb = (n) => (n / 1048576).toFixed(1);
|
|
|
- $('loading').textContent =
|
|
|
- `模型加载中… ${mb(ev.loaded)} / ${mb(ev.total)} MB (${Math.round(ev.loaded / ev.total * 100)}%)`;
|
|
|
- } else {
|
|
|
- $('loading').textContent = `模型加载中… ${(ev.loaded / 1048576).toFixed(1)} MB`;
|
|
|
- }
|
|
|
- },
|
|
|
- reject
|
|
|
- );
|
|
|
- });
|
|
|
- $('loading').textContent = '正在构建部件…';
|
|
|
- modelGroup = gltf.scene;
|
|
|
- /* 压缩机模型是 CAD 的 Z 轴朝上,转到 three.js 的 Y 轴朝上,让底板水平 */
|
|
|
- const usePreset = /^压缩机2326/.test(name);
|
|
|
- if (usePreset) modelGroup.rotation.x = -Math.PI / 2;
|
|
|
- log('模型解析完成');
|
|
|
- let t0 = performance.now();
|
|
|
- const meshCount = countMeshNodes(modelGroup);
|
|
|
- largeMode = meshCount > LARGE_THRESHOLD;
|
|
|
- log('网格节点数', meshCount, `(${(performance.now() - t0).toFixed(0)}ms)`);
|
|
|
- if (largeMode) {
|
|
|
- $('loading').textContent = `大模型模式:正在合并 ${meshCount} 个部件…`;
|
|
|
- scene.add(modelGroup);
|
|
|
- modelGroup.visible = false; // 原始网格不渲染,改用实例化批量渲染
|
|
|
- t0 = performance.now();
|
|
|
- collectPartsInstanced(modelGroup);
|
|
|
- log('实例化构建完成', parts.length, '部件', instancedMeshes.length, '个批次',
|
|
|
- `(${(performance.now() - t0).toFixed(0)}ms)`);
|
|
|
- } else {
|
|
|
- scene.add(modelGroup);
|
|
|
- collectParts(modelGroup);
|
|
|
- }
|
|
|
- await applySavedState();
|
|
|
- t0 = performance.now();
|
|
|
- fitCamera(modelGroup, 1.2);
|
|
|
- log('取景完成', `(${(performance.now() - t0).toFixed(0)}ms)`);
|
|
|
- /* 视角预设只对"压缩机2326"开头的模型生效 */
|
|
|
- $('viewBtns').style.display = usePreset ? 'flex' : 'none';
|
|
|
- if (usePreset) {
|
|
|
- modelGroup.updateMatrixWorld(true);
|
|
|
- modelMedianZ = computeMedian(modelGroup, 2);
|
|
|
- log('零件Z中位数', modelMedianZ.toFixed(2));
|
|
|
- activateView(0); // 加载/切换模型后默认回到"俯视"
|
|
|
- } else {
|
|
|
- viewButtonEls.forEach((b) => b.classList.remove('active'));
|
|
|
- }
|
|
|
- refreshRows();
|
|
|
- log('全部就绪');
|
|
|
- /* 压缩机…模块2:对指定部件做时序着色演示 */
|
|
|
- if (/^压缩机2326/.test(name) && /模块2/.test(name)) {
|
|
|
- const target = parts.find((p) => p.label === '三维立体_1687' || p.name === '三维立体_1687');
|
|
|
- if (target) startCycle(target);
|
|
|
- else toast('未找到部件 三维立体_1687');
|
|
|
- }
|
|
|
- if (DEBUG) {
|
|
|
- setTimeout(() => {
|
|
|
- const r = renderer.domElement.getBoundingClientRect();
|
|
|
- const p = pickPart(r.left + r.width / 2, r.top + r.height / 2);
|
|
|
- log('中心拾取测试:', p ? p.label : '未命中');
|
|
|
- log(`模型中心 (${modelCenter.x.toFixed(2)}, ${modelCenter.y.toFixed(2)}, ${modelCenter.z.toFixed(2)})`);
|
|
|
- if (usePreset) {
|
|
|
- VIEWS.forEach((v, i) => {
|
|
|
- activateView(i);
|
|
|
- const p2 = camera.position, t2 = controls.target;
|
|
|
- log(`视角「${v.label}」相机 (${p2.x.toFixed(1)}, ${p2.y.toFixed(1)}, ${p2.z.toFixed(1)}) ` +
|
|
|
- `目标 (${t2.x.toFixed(1)}, ${t2.y.toFixed(1)}, ${t2.z.toFixed(1)})`);
|
|
|
- });
|
|
|
- activateView(0);
|
|
|
- }
|
|
|
- }, 800);
|
|
|
- }
|
|
|
- $('loading').style.display = 'none';
|
|
|
- } catch (err) {
|
|
|
- console.error(err);
|
|
|
- $('loading').innerHTML = '模型加载失败:' + err.message;
|
|
|
- setTimeout(() => { $('loading').style.display = 'none'; }, 4000);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 配色状态持久化:GET/POST /api/state?model=xx */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-async function applySavedState() {
|
|
|
- try {
|
|
|
- const res = await fetch('/api/state?model=' + encodeURIComponent(modelName));
|
|
|
- const state = await res.json();
|
|
|
- parts.forEach((p) => {
|
|
|
- const s = state[p.id];
|
|
|
- if (s && s.color) setPartColor(p, s.color, s.status || null);
|
|
|
- });
|
|
|
- } catch (e) { console.warn('读取配色失败', e); }
|
|
|
-}
|
|
|
-function scheduleSave() {
|
|
|
- clearTimeout(saveTimer);
|
|
|
- $('saveStatus').textContent = '保存中…';
|
|
|
- saveTimer = setTimeout(saveState, 400);
|
|
|
-}
|
|
|
-async function saveState() {
|
|
|
- if (!modelName) return;
|
|
|
- const state = {};
|
|
|
- parts.forEach((p) => {
|
|
|
- if (p.colorHex) state[p.id] = { color: p.colorHex, status: p.status };
|
|
|
- });
|
|
|
- try {
|
|
|
- const res = await fetch('/api/state?model=' + encodeURIComponent(modelName), {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- body: JSON.stringify(state),
|
|
|
- });
|
|
|
- if (res.ok) $('saveStatus').textContent = '✓ 已保存 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
|
|
- else $('saveStatus').textContent = '保存失败';
|
|
|
- } catch (e) {
|
|
|
- $('saveStatus').textContent = '保存失败';
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 简易提示 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-let toastTimer = null;
|
|
|
-function toast(msg) {
|
|
|
- const t = $('saveStatus');
|
|
|
- t.textContent = msg;
|
|
|
- clearTimeout(toastTimer);
|
|
|
- toastTimer = setTimeout(() => { t.textContent = ''; }, 2500);
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 模型下拉 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-async function loadModelList() {
|
|
|
- try {
|
|
|
- const res = await fetch('/api/models');
|
|
|
- const names = await res.json();
|
|
|
- const sel = $('modelSelect');
|
|
|
- sel.innerHTML = '';
|
|
|
- (names.filter(isValidModelFile)).forEach((n) => {
|
|
|
- const o = document.createElement('option');
|
|
|
- o.value = n; o.textContent = n;
|
|
|
- sel.appendChild(o);
|
|
|
- });
|
|
|
- sel.addEventListener('change', () => loadModel(sel.value));
|
|
|
- const wanted = new URLSearchParams(location.search).get('model');
|
|
|
- if (wanted && names.includes(wanted)) sel.value = wanted;
|
|
|
- if (sel.options.length) await loadModel(sel.value);
|
|
|
- else $('loading').innerHTML = 'models 目录中没有可用的 .glb 模型';
|
|
|
- } catch (e) { console.error(e); }
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* UI 事件绑定 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-function initUI() {
|
|
|
- /* 部件状态控制:正常 / 预警 / 故障 / 恢复 */
|
|
|
- const presetRow = $('presetRow');
|
|
|
- PRESETS.forEach((s) => {
|
|
|
- const btn = document.createElement('button');
|
|
|
- btn.className = 'preset-btn';
|
|
|
- btn.innerHTML = `<span class="dot" style="background:${s.color}"></span>${s.label}`;
|
|
|
- btn.title = `将选中部件设为「${s.label}」`;
|
|
|
- btn.addEventListener('click', () => applyToSelection(s.color, s.key));
|
|
|
- presetRow.appendChild(btn);
|
|
|
- });
|
|
|
- const resetBtn = document.createElement('button');
|
|
|
- resetBtn.className = 'preset-btn';
|
|
|
- resetBtn.textContent = '恢复';
|
|
|
- resetBtn.title = '恢复选中部件的默认颜色';
|
|
|
- resetBtn.addEventListener('click', resetSelected);
|
|
|
- presetRow.appendChild(resetBtn);
|
|
|
-
|
|
|
- $('resetAll').addEventListener('click', resetAllParts);
|
|
|
-
|
|
|
- /* 部件查询弹窗 */
|
|
|
- const modal = $('partModal');
|
|
|
- const openModal = () => {
|
|
|
- modal.classList.add('show');
|
|
|
- refreshRows();
|
|
|
- const s = $('search');
|
|
|
- if (s) { s.value = ''; s.focus(); }
|
|
|
- };
|
|
|
- const closeModal = () => modal.classList.remove('show');
|
|
|
- $('openParts').addEventListener('click', openModal);
|
|
|
- $('closeParts').addEventListener('click', closeModal);
|
|
|
- modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
|
|
- window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
|
|
- $('search').addEventListener('input', refreshRows);
|
|
|
-
|
|
|
- /* 视角按钮 */
|
|
|
- const viewBtns = $('viewBtns');
|
|
|
- viewButtonEls = [];
|
|
|
- VIEWS.forEach((v, i) => {
|
|
|
- const b = document.createElement('button');
|
|
|
- b.className = 'view-btn';
|
|
|
- b.textContent = v.label;
|
|
|
- b.title = v.axis
|
|
|
- ? `正对 ${v.axis.join('')} 轴端面`
|
|
|
- : `相机 (${v.camera.join(', ')}) → 目标 (${v.target.join(', ')})`;
|
|
|
- b.addEventListener('click', () => activateView(i));
|
|
|
- viewBtns.appendChild(b);
|
|
|
- viewButtonEls.push(b);
|
|
|
- });
|
|
|
-
|
|
|
- /* 轨道 / 自由 旋转切换 */
|
|
|
- const rotBtn = $('rotModeBtn');
|
|
|
- rotBtn.addEventListener('click', () => {
|
|
|
- setFreeMode(!freeMode);
|
|
|
- rotBtn.textContent = freeMode ? '轨道旋转' : '自由旋转';
|
|
|
- rotBtn.classList.toggle('active', freeMode);
|
|
|
- });
|
|
|
-}
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-/* 动画循环 */
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-const QS = new URLSearchParams(location.search);
|
|
|
-const DEBUG = QS.has('debug');
|
|
|
-const NO_RENDER = QS.has('norender');
|
|
|
-function log(...a) { if (DEBUG) console.log('[3d]', ...a); }
|
|
|
-
|
|
|
-function animate() {
|
|
|
- requestAnimationFrame(animate);
|
|
|
- controls.update();
|
|
|
- composer.render();
|
|
|
-}
|
|
|
-if (!NO_RENDER) animate();
|
|
|
-
|
|
|
-/* ------------------------------------------------------------------ */
|
|
|
-initUI();
|
|
|
-loadModelList();
|
|
|
-updateCameraAspect();
|