| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990 |
- 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();
|