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 = `${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();