main.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990
  1. import * as THREE from 'three';
  2. import { OrbitControls } from '/js/jsm/controls/OrbitControls.js';
  3. import { TrackballControls } from '/js/jsm/controls/TrackballControls.js';
  4. import { GLTFLoader } from '/js/jsm/loaders/GLTFLoader.js';
  5. import { RoomEnvironment } from '/js/jsm/environments/RoomEnvironment.js';
  6. import { EffectComposer } from '/js/jsm/postprocessing/EffectComposer.js';
  7. import { RenderPass } from '/js/jsm/postprocessing/RenderPass.js';
  8. import { OutlinePass } from '/js/jsm/postprocessing/OutlinePass.js';
  9. import { OutputPass } from '/js/jsm/postprocessing/OutputPass.js';
  10. /* ------------------------------------------------------------------ */
  11. /* 状态预设:名称 / 颜色 / 是否恢复默认色 */
  12. /* ------------------------------------------------------------------ */
  13. const PRESETS = [
  14. { key: 'ok', label: '正常', color: '#43A047' },
  15. { key: 'warn', label: '预警', color: '#F9A825' },
  16. { key: 'fault', label: '故障', color: '#E53935' },
  17. ];
  18. /* ------------------------------------------------------------------ */
  19. /* DOM */
  20. /* ------------------------------------------------------------------ */
  21. const $ = (id) => document.getElementById(id);
  22. const viewport = $('viewport');
  23. const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
  24. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  25. renderer.setSize(viewport.clientWidth, viewport.clientHeight);
  26. renderer.toneMapping = THREE.ACESFilmicToneMapping;
  27. renderer.toneMappingExposure = 1.05;
  28. viewport.insertBefore(renderer.domElement, viewport.firstChild);
  29. const scene = new THREE.Scene();
  30. scene.background = new THREE.Color(0x0f1115);
  31. const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 2000);
  32. const orbitControls = new OrbitControls(camera, renderer.domElement);
  33. orbitControls.enableDamping = true;
  34. orbitControls.dampingFactor = 0.08;
  35. let controls = orbitControls; // 当前生效的控制器(轨道 / 自由)
  36. let trackball = null;
  37. let freeMode = false;
  38. /* 环境反射(让金属/粗糙PBR材质可见),房间环境由 PMREM 生成 */
  39. const pmrem = new THREE.PMREMGenerator(renderer);
  40. scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
  41. scene.add(new THREE.HemisphereLight(0xffffff, 0x20242c, 0.9));
  42. {
  43. const dl = new THREE.DirectionalLight(0xffffff, 1.6);
  44. dl.position.set(6, 12, 8);
  45. scene.add(dl);
  46. const dl2 = new THREE.DirectionalLight(0xffffff, 0.5);
  47. dl2.position.set(-8, -4, -6);
  48. scene.add(dl2);
  49. }
  50. /* 后处理:描边高亮选中部件 */
  51. const composer = new EffectComposer(renderer);
  52. composer.addPass(new RenderPass(scene, camera));
  53. const outline = new OutlinePass(
  54. new THREE.Vector2(viewport.clientWidth, viewport.clientHeight), scene, camera
  55. );
  56. outline.edgeStrength = 4;
  57. outline.edgeGlow = 0.2;
  58. outline.edgeThickness = 1.2;
  59. outline.visibleEdgeColor = new THREE.Color(0x4da3ff);
  60. outline.hiddenEdgeColor = new THREE.Color(0x000000);
  61. composer.addPass(outline);
  62. composer.addPass(new OutputPass());
  63. /* ------------------------------------------------------------------ */
  64. /* 状态变量 */
  65. /* ------------------------------------------------------------------ */
  66. const parts = []; // { id, label, obj|inst+instanceId, status, colorHex, defaults:[hex,...], name }
  67. const selected = new Set(); // 存放 part 引用(面板与场景共用)
  68. let modelName = null;
  69. let modelGroup = null;
  70. let grid = null;
  71. let hoveredPart = null;
  72. let saveTimer = null;
  73. const modelCenter = new THREE.Vector3(); // 当前模型包围盒中心
  74. const modelSize = new THREE.Vector3(); // 当前模型包围盒尺寸
  75. let modelDist = 0; // 当前取景距离(含 120% 缩放)
  76. /* 大模型模式:部件数超过阈值时,用 InstancedMesh 批量渲染 */
  77. const LARGE_THRESHOLD = 1500;
  78. let largeMode = false;
  79. let instancedMeshes = []; // 场景中的 InstancedMesh 集合
  80. let prevSelected = new Set(); // 上一次选中集合(用于增量更新高亮)
  81. const HIGHLIGHT = new THREE.Color(0x4da3ff);
  82. const instMaterial = new THREE.MeshStandardMaterial({
  83. color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
  84. });
  85. const loader = new GLTFLoader();
  86. const raycaster = new THREE.Raycaster();
  87. const pointer = new THREE.Vector2();
  88. /* ------------------------------------------------------------------ */
  89. /* 工具函数 */
  90. /* ------------------------------------------------------------------ */
  91. function eachMaterial(obj, fn) {
  92. if (!obj) return;
  93. if (Array.isArray(obj.material)) obj.material.forEach(fn);
  94. else fn(obj.material);
  95. }
  96. function toHex(c) {
  97. return '#' + new THREE.Color(c).getHexString();
  98. }
  99. function isValidModelFile(name) {
  100. return /\.(glb|gltf)$/i.test(name);
  101. }
  102. /* ------------------------------------------------------------------ */
  103. /* 部件管理:为每个 Mesh 建立独立材质 + 稳定的部件ID */
  104. /* ------------------------------------------------------------------ */
  105. function ensureOwnMaterial(obj) {
  106. if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m.clone());
  107. else if (obj.material) obj.material = obj.material.clone();
  108. eachMaterial(obj, (m) => { m.needsUpdate = true; });
  109. }
  110. function collectParts(group) {
  111. parts.length = 0;
  112. const seenCount = new Map();
  113. group.traverse((obj) => {
  114. if (!obj.isMesh || !obj.geometry) return;
  115. ensureOwnMaterial(obj);
  116. const base = (obj.name && obj.name.trim()) ? obj.name.trim() : '部件';
  117. const n = (seenCount.get(base) || 0) + 1;
  118. seenCount.set(base, n);
  119. const label = n > 1 ? `${base} [${n}]` : base;
  120. const id = `${base}#${n}`;
  121. const defaults = [];
  122. eachMaterial(obj, (m) => {
  123. const hex = toHex(m.color || 0xcccccc);
  124. defaults.push(hex);
  125. m.color.set(hex);
  126. });
  127. const part = { id, label, obj, name: base, status: null, colorHex: null, defaults };
  128. obj.userData.part = part;
  129. parts.push(part);
  130. });
  131. parts.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
  132. return parts;
  133. }
  134. /* 大模型模式:按"唯一几何"归并,用 InstancedMesh 渲染(实例复用几何,
  135. 绘制次数从 N 降到唯一几何数;每个实例可独立着色与拾取) */
  136. function countMeshNodes(group) {
  137. let n = 0;
  138. group.traverse((o) => { if (o.isMesh && o.geometry) n++; });
  139. return n;
  140. }
  141. function collectPartsInstanced(group) {
  142. parts.length = 0;
  143. instancedMeshes = [];
  144. group.updateMatrixWorld(true);
  145. const byGeom = new Map(); // geometry.uuid -> { geometry, entries:[{matrix, part}] }
  146. const seenCount = new Map();
  147. group.traverse((obj) => {
  148. if (!obj.isMesh || !obj.geometry) return;
  149. const g = obj.geometry;
  150. if (!g.attributes.normal) g.computeVertexNormals();
  151. if (!g.boundingSphere) g.computeBoundingSphere();
  152. let rec = byGeom.get(g.uuid);
  153. if (!rec) { rec = { geometry: g, entries: [] }; byGeom.set(g.uuid, rec); }
  154. const base = (obj.name && obj.name.trim()) ? obj.name.trim() : '部件';
  155. const n = (seenCount.get(base) || 0) + 1;
  156. seenCount.set(base, n);
  157. const label = n > 1 ? `${base} [${n}]` : base;
  158. const id = `${base}#${n}`;
  159. const defaultHex = toHex((obj.material && obj.material.color) ? obj.material.color : 0xcccccc);
  160. const part = {
  161. id, label, name: base, status: null, colorHex: null,
  162. defaults: [defaultHex], obj: null, inst: null, instanceId: 0,
  163. };
  164. rec.entries.push({ matrix: obj.matrixWorld.clone(), part });
  165. parts.push(part);
  166. });
  167. byGeom.forEach((rec) => {
  168. const inst = new THREE.InstancedMesh(rec.geometry, instMaterial, rec.entries.length);
  169. inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  170. const map = new Array(rec.entries.length);
  171. rec.entries.forEach((e, i) => {
  172. inst.setMatrixAt(i, e.matrix);
  173. inst.setColorAt(i, new THREE.Color(e.part.defaults[0]));
  174. e.part.inst = inst;
  175. e.part.instanceId = i;
  176. map[i] = e.part;
  177. });
  178. inst.instanceMatrix.needsUpdate = true;
  179. if (inst.instanceColor) inst.instanceColor.needsUpdate = true;
  180. inst.userData.instPartMap = map;
  181. scene.add(inst);
  182. instancedMeshes.push(inst);
  183. });
  184. parts.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
  185. return parts;
  186. }
  187. function clearRendered() {
  188. instancedMeshes.forEach((m) => {
  189. scene.remove(m);
  190. m.dispose();
  191. });
  192. instancedMeshes = [];
  193. }
  194. /* ------------------------------------------------------------------ */
  195. /* 部件属性:顶点数 / 三角面 / 世界尺寸(懒计算并缓存) */
  196. /* ------------------------------------------------------------------ */
  197. function partGeometry(part) {
  198. if (part.inst) return part.inst.geometry;
  199. return part.obj ? part.obj.geometry : null;
  200. }
  201. function partMatrix(part) {
  202. if (part.inst) {
  203. const m = new THREE.Matrix4();
  204. part.inst.getMatrixAt(part.instanceId, m);
  205. return m;
  206. }
  207. return part.obj ? part.obj.matrixWorld : null;
  208. }
  209. function partStats(part) {
  210. if (part._stats) return part._stats;
  211. let vtx = 0, tri = 0, size = '—';
  212. const g = partGeometry(part);
  213. if (g) {
  214. if (g.attributes && g.attributes.position) vtx = g.attributes.position.count;
  215. tri = g.index ? Math.floor(g.index.count / 3) : Math.floor(vtx / 3);
  216. if (!g.boundingBox) g.computeBoundingBox();
  217. const m = partMatrix(part);
  218. if (g.boundingBox && m) {
  219. const box = g.boundingBox.clone().applyMatrix4(m);
  220. const d = box.getSize(new THREE.Vector3());
  221. size = `${d.x.toFixed(1)}×${d.y.toFixed(1)}×${d.z.toFixed(1)}`;
  222. }
  223. }
  224. part._stats = { vtx, tri, size };
  225. return part._stats;
  226. }
  227. /* ------------------------------------------------------------------ */
  228. /* 上色(兼容普通模式与大模型模式) */
  229. /* ------------------------------------------------------------------ */
  230. function applyPartDisplay(part) {
  231. const base = part.colorHex || part.defaults[0];
  232. if (part.inst) {
  233. const c = new THREE.Color(base);
  234. if (selected.has(part)) c.lerp(HIGHLIGHT, 0.45); // 大模型模式用颜色提亮表示选中
  235. part.inst.setColorAt(part.instanceId, c);
  236. if (part.inst.instanceColor) part.inst.instanceColor.needsUpdate = true;
  237. } else if (part.obj) {
  238. eachMaterial(part.obj, (m) => { if (m.color) m.color.set(base); });
  239. }
  240. }
  241. function setPartColor(part, hex, status) {
  242. part.colorHex = toHex(new THREE.Color(hex));
  243. part.status = status || null;
  244. applyPartDisplay(part);
  245. }
  246. function resetPartColor(part) {
  247. part.colorHex = null;
  248. part.status = null;
  249. applyPartDisplay(part);
  250. }
  251. function refreshSelectionColors() {
  252. if (!largeMode) return;
  253. const changed = new Set();
  254. selected.forEach((p) => { if (!prevSelected.has(p)) changed.add(p); });
  255. prevSelected.forEach((p) => { if (!selected.has(p)) changed.add(p); });
  256. changed.forEach(applyPartDisplay);
  257. prevSelected = new Set(selected);
  258. }
  259. function resetAllParts() {
  260. parts.forEach((p) => resetPartColor(p));
  261. refreshRows();
  262. renderPartDetail();
  263. scheduleSave();
  264. }
  265. function applyToSelection(hex, status) {
  266. if (!selected.size) { toast('请先选择一个部件'); return; }
  267. selected.forEach((p) => setPartColor(p, hex, status));
  268. refreshRows();
  269. renderPartDetail();
  270. scheduleSave();
  271. }
  272. function resetSelected() {
  273. if (!selected.size) { toast('请先选择一个部件'); return; }
  274. selected.forEach((p) => resetPartColor(p));
  275. refreshRows();
  276. renderPartDetail();
  277. scheduleSave();
  278. }
  279. /* ------------------------------------------------------------------ */
  280. /* 时序着色演示:本色 → 预警 → 故障,每 1 秒切换 */
  281. /* ------------------------------------------------------------------ */
  282. const CYCLE_STATES = [
  283. { label: '本色', apply: (p) => resetPartColor(p) },
  284. { label: '预警', apply: (p) => setPartColor(p, '#F9A825', 'warn') },
  285. { label: '故障', apply: (p) => setPartColor(p, '#E53935', 'fault') },
  286. ];
  287. let cycleTimer = null;
  288. let cyclePart = null;
  289. let cycleStep = 0;
  290. /* 只更新该部件对应的一行,避免每秒重建整个列表 */
  291. function updatePartRow(part) {
  292. const list = $('partList');
  293. for (const row of list.children) {
  294. if (!row.dataset || row.dataset.id !== part.id) continue;
  295. const sw = row.querySelector('.swatch');
  296. if (sw) sw.style.background = part.colorHex || part.defaults[0];
  297. let tag = row.querySelector('.tag');
  298. const preset = part.status ? PRESETS.find((s) => s.key === part.status) : null;
  299. if (preset) {
  300. if (!tag) { tag = document.createElement('span'); tag.className = 'tag'; row.appendChild(tag); }
  301. tag.textContent = preset.label;
  302. tag.style.background = preset.color;
  303. } else if (tag) {
  304. tag.remove();
  305. }
  306. break;
  307. }
  308. }
  309. function stopCycle() {
  310. if (cycleTimer) { clearInterval(cycleTimer); cycleTimer = null; }
  311. if (cyclePart) { resetPartColor(cyclePart); updatePartRow(cyclePart); }
  312. cyclePart = null;
  313. cycleStep = 0;
  314. const el = $('cycleProgress');
  315. if (el) { el.style.display = 'none'; el.textContent = ''; }
  316. }
  317. function startCycle(part) {
  318. stopCycle();
  319. if (!part) return;
  320. cyclePart = part;
  321. cycleStep = 0;
  322. const el = $('cycleProgress');
  323. if (el) el.style.display = 'inline-block';
  324. const tick = () => {
  325. const s = CYCLE_STATES[cycleStep];
  326. s.apply(cyclePart);
  327. updatePartRow(cyclePart);
  328. if (el) el.textContent = `${cycleStep + 1}/3 ${s.label}`;
  329. log(`时序切换 ${cycleStep + 1}/3 ${s.label}`);
  330. cycleStep = (cycleStep + 1) % CYCLE_STATES.length;
  331. };
  332. tick(); // 立即进入 1/3
  333. cycleTimer = setInterval(tick, 1000); // 每 1 秒切换
  334. }
  335. /* ------------------------------------------------------------------ */
  336. /* 选择管理 */
  337. /* ------------------------------------------------------------------ */
  338. function clearSelection() {
  339. selected.clear();
  340. updateSelectionUI();
  341. }
  342. function selectOnly(part) {
  343. selected.clear();
  344. if (part) selected.add(part);
  345. updateSelectionUI();
  346. }
  347. function toggleSelect(part, additive) {
  348. if (!additive) { selectOnly(part); return; }
  349. if (selected.has(part)) selected.delete(part); else selected.add(part);
  350. updateSelectionUI();
  351. }
  352. function updateSelectionUI() {
  353. refreshRows();
  354. renderPartDetail();
  355. if (largeMode) {
  356. outline.selectedObjects = [];
  357. refreshSelectionColors();
  358. } else {
  359. outline.selectedObjects = [...selected].map((p) => p.obj);
  360. }
  361. }
  362. function renderPartDetail() {
  363. const el = $('partDetail');
  364. if (!el) return;
  365. el.innerHTML = '';
  366. const add = (text, cls) => {
  367. const d = document.createElement('div');
  368. d.className = 'dline' + (cls ? ' ' + cls : '');
  369. d.textContent = text;
  370. el.appendChild(d);
  371. };
  372. const n = selected.size;
  373. if (n === 0) {
  374. add('未选中部件。点击模型中的部件,或点下方「打开查询部件…」选择。', 'dim');
  375. return;
  376. }
  377. if (n > 1) {
  378. const names = [...selected].slice(0, 6).map((p) => p.label).join('、');
  379. add(`已选中 ${n} 个部件:${names}${n > 6 ? '…' : ''}`);
  380. return;
  381. }
  382. const p = selected.values().next().value;
  383. const st = partStats(p);
  384. const color = p.colorHex || p.defaults[0];
  385. const preset = PRESETS.find((s) => s.key === p.status);
  386. add(`名称:${p.label}`);
  387. add(`顶点数:${st.vtx.toLocaleString()} 三角面:${st.tri.toLocaleString()}`);
  388. add(`尺寸:${st.size} 颜色:${color} 状态:${preset ? preset.label : '未设置'}`);
  389. }
  390. /* ------------------------------------------------------------------ */
  391. /* 部件列表面板 */
  392. /* ------------------------------------------------------------------ */
  393. const MAX_LIST_ROWS = 800;
  394. function refreshRows() {
  395. const list = $('partList');
  396. if (!list) return;
  397. const q = ($('search').value || '').trim().toLowerCase();
  398. const frag = document.createDocumentFragment();
  399. const matched = q
  400. ? parts.filter((p) => p.label.toLowerCase().includes(q) || p.name.toLowerCase().includes(q))
  401. : parts;
  402. const shown = matched.slice(0, MAX_LIST_ROWS);
  403. shown.forEach((p) => {
  404. const st = partStats(p);
  405. const row = document.createElement('div');
  406. row.className = 'part-row' + (selected.has(p) ? ' selected' : '');
  407. row.dataset.id = p.id;
  408. const name = document.createElement('span');
  409. name.className = 'name';
  410. name.textContent = p.label;
  411. name.title = p.label;
  412. const vtx = document.createElement('span');
  413. vtx.className = 'vtx';
  414. vtx.textContent = st.vtx.toLocaleString();
  415. const tri = document.createElement('span');
  416. tri.className = 'tri';
  417. tri.textContent = st.tri.toLocaleString();
  418. const size = document.createElement('span');
  419. size.className = 'size';
  420. size.textContent = st.size;
  421. const color = document.createElement('span');
  422. color.className = 'color';
  423. const swatch = document.createElement('i');
  424. swatch.className = 'swatch';
  425. swatch.style.background = p.colorHex || p.defaults[0];
  426. const hex = document.createElement('span');
  427. hex.textContent = p.colorHex || p.defaults[0];
  428. color.appendChild(swatch);
  429. color.appendChild(hex);
  430. const status = document.createElement('span');
  431. status.className = 'status';
  432. const preset = PRESETS.find((s) => s.key === p.status);
  433. if (preset) {
  434. const tag = document.createElement('span');
  435. tag.className = 'tag';
  436. tag.textContent = preset.label;
  437. tag.style.background = preset.color;
  438. status.appendChild(tag);
  439. } else {
  440. status.textContent = '—';
  441. }
  442. row.appendChild(name);
  443. row.appendChild(vtx);
  444. row.appendChild(tri);
  445. row.appendChild(size);
  446. row.appendChild(color);
  447. row.appendChild(status);
  448. row.addEventListener('click', () => selectOnly(p));
  449. row.addEventListener('mouseenter', () => setHover(p, true));
  450. row.addEventListener('mouseleave', () => setHover(p, false));
  451. frag.appendChild(row);
  452. });
  453. list.innerHTML = '';
  454. list.appendChild(frag);
  455. $('partCount').textContent = `共 ${parts.length} 个部件${largeMode ? ' · 大模型模式' : ''}`;
  456. const note = $('partListNote');
  457. if (note) {
  458. if (matched.length > MAX_LIST_ROWS) {
  459. note.style.display = 'block';
  460. note.textContent = `匹配 ${matched.length} 个,仅显示前 ${MAX_LIST_ROWS} 个,请用搜索缩小范围`;
  461. } else if (!matched.length) {
  462. note.style.display = 'block';
  463. note.textContent = '没有匹配的部件';
  464. } else {
  465. note.style.display = 'none';
  466. }
  467. }
  468. }
  469. /* ------------------------------------------------------------------ */
  470. /* 悬停提示 */
  471. /* ------------------------------------------------------------------ */
  472. function setHover(part, on) {
  473. if (on && part !== hoveredPart) {
  474. hoveredPart = part;
  475. renderer.domElement.style.cursor = 'pointer';
  476. const tip = $('tooltip');
  477. tip.textContent = part.label;
  478. tip.style.display = 'block';
  479. } else if (!on && part === hoveredPart) {
  480. hoveredPart = null;
  481. renderer.domElement.style.cursor = 'default';
  482. $('tooltip').style.display = 'none';
  483. }
  484. }
  485. viewport.addEventListener('mousemove', (e) => {
  486. const r = renderer.domElement.getBoundingClientRect();
  487. const tip = $('tooltip');
  488. if (hoveredPart) {
  489. tip.style.left = (e.clientX - r.left + 14) + 'px';
  490. tip.style.top = (e.clientY - r.top + 14) + 'px';
  491. }
  492. });
  493. /* 场景拾取(悬停 + 点选) */
  494. function pickPart(clientX, clientY) {
  495. const rect = renderer.domElement.getBoundingClientRect();
  496. pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
  497. pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
  498. raycaster.setFromCamera(pointer, camera);
  499. if (largeMode) {
  500. if (!instancedMeshes.length) return null;
  501. const hits = raycaster.intersectObjects(instancedMeshes, false);
  502. if (!hits.length) return null;
  503. const h = hits[0];
  504. return h.object.userData.instPartMap[h.instanceId] || null;
  505. }
  506. const meshes = parts.map((p) => p.obj);
  507. const hits = raycaster.intersectObjects(meshes, false);
  508. return hits.length ? hits[0].object.userData.part : null;
  509. }
  510. let lastPick = 0;
  511. renderer.domElement.addEventListener('pointermove', (e) => {
  512. const now = performance.now();
  513. if (now - lastPick < 60) return; // 大模型下限制拾取频率,避免卡顿
  514. lastPick = now;
  515. const p = pickPart(e.clientX, e.clientY);
  516. setHover(p, true);
  517. setHover(hoveredPart && hoveredPart !== p ? hoveredPart : null, false);
  518. });
  519. /* 区分“点击”与“拖动旋转”:位移小于阈值才算点击 */
  520. let downXY = null;
  521. renderer.domElement.addEventListener('pointerdown', (e) => {
  522. downXY = [e.clientX, e.clientY];
  523. });
  524. renderer.domElement.addEventListener('pointerup', (e) => {
  525. if (!downXY) return;
  526. const dist = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]);
  527. downXY = null;
  528. if (dist > 5) return;
  529. const p = pickPart(e.clientX, e.clientY);
  530. toggleSelect(p, (e.ctrlKey || e.metaKey) && p !== null);
  531. });
  532. /* ------------------------------------------------------------------ */
  533. /* 相机取景 */
  534. /* ------------------------------------------------------------------ */
  535. function fitCamera(group, zoom = 1.2) {
  536. const box = new THREE.Box3().setFromObject(group);
  537. if (box.isEmpty()) return;
  538. const center = box.getCenter(new THREE.Vector3());
  539. const size = box.getSize(new THREE.Vector3());
  540. const maxDim = Math.max(size.x, size.y, size.z);
  541. const fovRad = THREE.MathUtils.degToRad(camera.fov);
  542. const fitDist = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.35;
  543. modelCenter.copy(center);
  544. modelSize.copy(size);
  545. modelDist = fitDist / zoom; // zoom=1.2 -> 比标准取景放大到 120%
  546. camera.near = maxDim / 100;
  547. camera.far = maxDim * 50;
  548. const dir = new THREE.Vector3(0.7, 0.55, 1).normalize();
  549. camera.position.copy(center).add(dir.multiplyScalar(modelDist));
  550. camera.lookAt(center);
  551. controls.target.copy(center);
  552. controls.update();
  553. updateCameraAspect();
  554. if (grid) scene.remove(grid);
  555. const g = new THREE.GridHelper(maxDim * 2.2, 20, 0x3a424f, 0x232831);
  556. g.position.set(center.x, box.min.y, center.z);
  557. g.material.transparent = true;
  558. g.material.opacity = 0.5;
  559. scene.add(g);
  560. grid = g;
  561. updateCamInfo();
  562. }
  563. /* 应用一个视角:
  564. - 固定坐标视角:X/Y 固定,Z 按"零件中位位置"随模型平移(模块沿 Z 排列);
  565. - 轴向视角(axis):相机放在模型中心沿该轴方向、按横截面大小取距离,正对该轴端面 */
  566. function applyView(v) {
  567. if (!modelDist) return;
  568. let target, cam;
  569. if (v.axis) {
  570. const dir = new THREE.Vector3(...v.axis).normalize();
  571. const ai = v.axis.findIndex((x) => x !== 0);
  572. const cross = Math.max(...[0, 1, 2].filter((i) => i !== ai).map((i) => modelSize.getComponent(i)));
  573. const fovRad = THREE.MathUtils.degToRad(camera.fov);
  574. const crossFit = (cross / (2 * Math.tan(fovRad / 2))) * 1.2;
  575. const halfAxis = modelSize.getComponent(ai) / 2;
  576. const dist = halfAxis + crossFit; // 保证相机在该轴端面之外
  577. target = modelCenter.clone();
  578. cam = modelCenter.clone().addScaledVector(dir, dist);
  579. } else {
  580. const dz = modelMedianZ - REF_MEDIAN_Z;
  581. target = new THREE.Vector3(v.target[0], v.target[1], v.target[2] + dz);
  582. cam = new THREE.Vector3(v.camera[0], v.camera[1], v.camera[2] + dz);
  583. }
  584. camera.position.copy(cam);
  585. camera.lookAt(target);
  586. controls.target.copy(target);
  587. controls.update();
  588. updateCamInfo();
  589. }
  590. /* 相机沿视线方向前后移动(保持朝向),用于"走近/走远"查看 */
  591. function moveForward(sign) {
  592. const dir = new THREE.Vector3().subVectors(controls.target, camera.position).normalize();
  593. const step = (modelDist || 10) * 0.08;
  594. camera.position.addScaledVector(dir, sign * step);
  595. controls.target.addScaledVector(dir, sign * step);
  596. controls.update();
  597. }
  598. window.addEventListener('keydown', (e) => {
  599. const t = e.target;
  600. if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
  601. if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') { moveForward(1); e.preventDefault(); }
  602. else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') { moveForward(-1); e.preventDefault(); }
  603. });
  604. let viewButtonEls = [];
  605. function activateView(i) {
  606. applyView(VIEWS[i]);
  607. viewButtonEls.forEach((b, j) => b.classList.toggle('active', j === i));
  608. }
  609. /* 实时显示当前相机角度参数,便于调好角度后固化到按钮 */
  610. function updateCamInfo() {
  611. const el = $('camInfo');
  612. if (!el) return;
  613. const off = camera.position.clone().sub(controls.target);
  614. if (off.lengthSq() === 0) { el.textContent = ''; return; }
  615. const sph = new THREE.Spherical().setFromVector3(off);
  616. const az = ((THREE.MathUtils.radToDeg(sph.theta) % 360) + 360) % 360;
  617. const elev = 90 - THREE.MathUtils.radToDeg(sph.phi);
  618. const p = camera.position, t = controls.target;
  619. el.textContent =
  620. `方位角 Az ${az.toFixed(1)}° 俯仰角 El ${elev.toFixed(1)}° 距离 ${sph.radius.toFixed(1)}\n` +
  621. `相机 (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\n` +
  622. `目标 (${t.x.toFixed(1)}, ${t.y.toFixed(1)}, ${t.z.toFixed(1)})`;
  623. }
  624. controls.addEventListener('change', updateCamInfo);
  625. /* 旋转模式:轨道(OrbitControls,绕上轴) / 自由(TrackballControls,可任意翻滚) */
  626. function ensureTrackball() {
  627. if (trackball) return trackball;
  628. trackball = new TrackballControls(camera, renderer.domElement);
  629. trackball.rotateSpeed = 3.0;
  630. trackball.zoomSpeed = 1.2;
  631. trackball.panSpeed = 0.8;
  632. trackball.staticMoving = false;
  633. trackball.dynamicDampingFactor = 0.15;
  634. trackball.enabled = false;
  635. trackball.addEventListener('change', updateCamInfo);
  636. return trackball;
  637. }
  638. function setFreeMode(on) {
  639. freeMode = on;
  640. if (on) {
  641. ensureTrackball();
  642. trackball.target.copy(controls.target);
  643. trackball.enabled = true;
  644. orbitControls.enabled = false;
  645. controls = trackball;
  646. } else {
  647. if (trackball) trackball.enabled = false;
  648. camera.up.set(0, 1, 0); // 回到轨道模式时恢复默认上轴
  649. orbitControls.target.copy(controls.target);
  650. orbitControls.enabled = true;
  651. controls = orbitControls;
  652. }
  653. controls.update();
  654. updateCamInfo();
  655. }
  656. /* 视角预设:模型已把 CAD 的 Z 朝上转成 Y 朝上(rotation.x = -90°),
  657. 坐标均为新坐标系 (x, z, -y)_旧。加载时按当前模型的零件中位位置只平移 Z
  658. (三个模块沿 Z 排列,X/Y 不变),从而三个模块都能套用同一视角。
  659. 俯视: 相机 (2.0, 22.0, -3.2) -> 目标 (2.0, 0, -22.0)
  660. 角度A: 相机 (5.4, 16.7, -6.3) -> 目标 (9.4, 0, -22.5)
  661. 短边: 沿长轴(X)方向正对短边,相机/距离自动按模型横截面计算 */
  662. const REF_MEDIAN_Z = -22.46; // 参考模型零件 Z 中位数(新坐标系)
  663. let modelMedianZ = REF_MEDIAN_Z; // 当前模型零件 Z 中位数(加载后更新)
  664. const VIEWS = [
  665. { label: '俯视', camera: [2.0, 22.0, -3.2], target: [2.0, 0.0, -22.0] },
  666. { label: '角度A', camera: [5.4, 16.7, -6.3], target: [9.4, 0.0, -22.5] },
  667. { label: '短边', axis: [1, 0, 0] },
  668. ];
  669. /* 零件包围盒中心在指定轴上的中位数:对地板等少数大件不敏感,能稳定代表压缩机位置 */
  670. function computeMedian(group, axis = 2) {
  671. const vals = [];
  672. group.traverse((o) => {
  673. if (!o.isMesh || !o.geometry) return;
  674. if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
  675. const c = new THREE.Vector3();
  676. o.geometry.boundingBox.getCenter(c).applyMatrix4(o.matrixWorld);
  677. vals.push(c.getComponent(axis));
  678. });
  679. if (!vals.length) return 0;
  680. vals.sort((a, b) => a - b);
  681. return vals[Math.floor(vals.length / 2)];
  682. }
  683. function updateCameraAspect() {
  684. const w = viewport.clientWidth, h = viewport.clientHeight;
  685. if (!w || !h) return;
  686. camera.aspect = w / h;
  687. camera.updateProjectionMatrix();
  688. renderer.setSize(w, h);
  689. composer.setSize(w, h);
  690. outline.setSize(w, h);
  691. if (trackball) trackball.handleResize();
  692. }
  693. window.addEventListener('resize', updateCameraAspect);
  694. /* ------------------------------------------------------------------ */
  695. /* 模型加载 */
  696. /* ------------------------------------------------------------------ */
  697. async function loadModel(name) {
  698. if (!isValidModelFile(name)) return;
  699. stopCycle();
  700. clearSelection();
  701. clearRendered();
  702. parts.length = 0;
  703. prevSelected = new Set();
  704. if (modelGroup) scene.remove(modelGroup);
  705. $('loading').style.display = 'flex';
  706. $('loading').textContent = '模型加载中…';
  707. $('partList').innerHTML = '';
  708. modelName = name;
  709. try {
  710. const gltf = await new Promise((resolve, reject) => {
  711. loader.load(
  712. '/models/' + encodeURIComponent(name),
  713. resolve,
  714. (ev) => {
  715. if (ev.lengthComputable) {
  716. const mb = (n) => (n / 1048576).toFixed(1);
  717. $('loading').textContent =
  718. `模型加载中… ${mb(ev.loaded)} / ${mb(ev.total)} MB (${Math.round(ev.loaded / ev.total * 100)}%)`;
  719. } else {
  720. $('loading').textContent = `模型加载中… ${(ev.loaded / 1048576).toFixed(1)} MB`;
  721. }
  722. },
  723. reject
  724. );
  725. });
  726. $('loading').textContent = '正在构建部件…';
  727. modelGroup = gltf.scene;
  728. /* 压缩机模型是 CAD 的 Z 轴朝上,转到 three.js 的 Y 轴朝上,让底板水平 */
  729. const usePreset = /^压缩机2326/.test(name);
  730. if (usePreset) modelGroup.rotation.x = -Math.PI / 2;
  731. log('模型解析完成');
  732. let t0 = performance.now();
  733. const meshCount = countMeshNodes(modelGroup);
  734. largeMode = meshCount > LARGE_THRESHOLD;
  735. log('网格节点数', meshCount, `(${(performance.now() - t0).toFixed(0)}ms)`);
  736. if (largeMode) {
  737. $('loading').textContent = `大模型模式:正在合并 ${meshCount} 个部件…`;
  738. scene.add(modelGroup);
  739. modelGroup.visible = false; // 原始网格不渲染,改用实例化批量渲染
  740. t0 = performance.now();
  741. collectPartsInstanced(modelGroup);
  742. log('实例化构建完成', parts.length, '部件', instancedMeshes.length, '个批次',
  743. `(${(performance.now() - t0).toFixed(0)}ms)`);
  744. } else {
  745. scene.add(modelGroup);
  746. collectParts(modelGroup);
  747. }
  748. await applySavedState();
  749. t0 = performance.now();
  750. fitCamera(modelGroup, 1.2);
  751. log('取景完成', `(${(performance.now() - t0).toFixed(0)}ms)`);
  752. /* 视角预设只对"压缩机2326"开头的模型生效 */
  753. $('viewBtns').style.display = usePreset ? 'flex' : 'none';
  754. if (usePreset) {
  755. modelGroup.updateMatrixWorld(true);
  756. modelMedianZ = computeMedian(modelGroup, 2);
  757. log('零件Z中位数', modelMedianZ.toFixed(2));
  758. activateView(0); // 加载/切换模型后默认回到"俯视"
  759. } else {
  760. viewButtonEls.forEach((b) => b.classList.remove('active'));
  761. }
  762. refreshRows();
  763. log('全部就绪');
  764. /* 压缩机…模块2:对指定部件做时序着色演示 */
  765. if (/^压缩机2326/.test(name) && /模块2/.test(name)) {
  766. const target = parts.find((p) => p.label === '三维立体_1687' || p.name === '三维立体_1687');
  767. if (target) startCycle(target);
  768. else toast('未找到部件 三维立体_1687');
  769. }
  770. if (DEBUG) {
  771. setTimeout(() => {
  772. const r = renderer.domElement.getBoundingClientRect();
  773. const p = pickPart(r.left + r.width / 2, r.top + r.height / 2);
  774. log('中心拾取测试:', p ? p.label : '未命中');
  775. log(`模型中心 (${modelCenter.x.toFixed(2)}, ${modelCenter.y.toFixed(2)}, ${modelCenter.z.toFixed(2)})`);
  776. if (usePreset) {
  777. VIEWS.forEach((v, i) => {
  778. activateView(i);
  779. const p2 = camera.position, t2 = controls.target;
  780. log(`视角「${v.label}」相机 (${p2.x.toFixed(1)}, ${p2.y.toFixed(1)}, ${p2.z.toFixed(1)}) ` +
  781. `目标 (${t2.x.toFixed(1)}, ${t2.y.toFixed(1)}, ${t2.z.toFixed(1)})`);
  782. });
  783. activateView(0);
  784. }
  785. }, 800);
  786. }
  787. $('loading').style.display = 'none';
  788. } catch (err) {
  789. console.error(err);
  790. $('loading').innerHTML = '模型加载失败:' + err.message;
  791. setTimeout(() => { $('loading').style.display = 'none'; }, 4000);
  792. }
  793. }
  794. /* ------------------------------------------------------------------ */
  795. /* 配色状态持久化:GET/POST /api/state?model=xx */
  796. /* ------------------------------------------------------------------ */
  797. async function applySavedState() {
  798. try {
  799. const res = await fetch('/api/state?model=' + encodeURIComponent(modelName));
  800. const state = await res.json();
  801. parts.forEach((p) => {
  802. const s = state[p.id];
  803. if (s && s.color) setPartColor(p, s.color, s.status || null);
  804. });
  805. } catch (e) { console.warn('读取配色失败', e); }
  806. }
  807. function scheduleSave() {
  808. clearTimeout(saveTimer);
  809. $('saveStatus').textContent = '保存中…';
  810. saveTimer = setTimeout(saveState, 400);
  811. }
  812. async function saveState() {
  813. if (!modelName) return;
  814. const state = {};
  815. parts.forEach((p) => {
  816. if (p.colorHex) state[p.id] = { color: p.colorHex, status: p.status };
  817. });
  818. try {
  819. const res = await fetch('/api/state?model=' + encodeURIComponent(modelName), {
  820. method: 'POST',
  821. headers: { 'Content-Type': 'application/json' },
  822. body: JSON.stringify(state),
  823. });
  824. if (res.ok) $('saveStatus').textContent = '✓ 已保存 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false });
  825. else $('saveStatus').textContent = '保存失败';
  826. } catch (e) {
  827. $('saveStatus').textContent = '保存失败';
  828. }
  829. }
  830. /* ------------------------------------------------------------------ */
  831. /* 简易提示 */
  832. /* ------------------------------------------------------------------ */
  833. let toastTimer = null;
  834. function toast(msg) {
  835. const t = $('saveStatus');
  836. t.textContent = msg;
  837. clearTimeout(toastTimer);
  838. toastTimer = setTimeout(() => { t.textContent = ''; }, 2500);
  839. }
  840. /* ------------------------------------------------------------------ */
  841. /* 模型下拉 */
  842. /* ------------------------------------------------------------------ */
  843. async function loadModelList() {
  844. try {
  845. const res = await fetch('/api/models');
  846. const names = await res.json();
  847. const sel = $('modelSelect');
  848. sel.innerHTML = '';
  849. (names.filter(isValidModelFile)).forEach((n) => {
  850. const o = document.createElement('option');
  851. o.value = n; o.textContent = n;
  852. sel.appendChild(o);
  853. });
  854. sel.addEventListener('change', () => loadModel(sel.value));
  855. const wanted = new URLSearchParams(location.search).get('model');
  856. if (wanted && names.includes(wanted)) sel.value = wanted;
  857. if (sel.options.length) await loadModel(sel.value);
  858. else $('loading').innerHTML = 'models 目录中没有可用的 .glb 模型';
  859. } catch (e) { console.error(e); }
  860. }
  861. /* ------------------------------------------------------------------ */
  862. /* UI 事件绑定 */
  863. /* ------------------------------------------------------------------ */
  864. function initUI() {
  865. /* 部件状态控制:正常 / 预警 / 故障 / 恢复 */
  866. const presetRow = $('presetRow');
  867. PRESETS.forEach((s) => {
  868. const btn = document.createElement('button');
  869. btn.className = 'preset-btn';
  870. btn.innerHTML = `<span class="dot" style="background:${s.color}"></span>${s.label}`;
  871. btn.title = `将选中部件设为「${s.label}」`;
  872. btn.addEventListener('click', () => applyToSelection(s.color, s.key));
  873. presetRow.appendChild(btn);
  874. });
  875. const resetBtn = document.createElement('button');
  876. resetBtn.className = 'preset-btn';
  877. resetBtn.textContent = '恢复';
  878. resetBtn.title = '恢复选中部件的默认颜色';
  879. resetBtn.addEventListener('click', resetSelected);
  880. presetRow.appendChild(resetBtn);
  881. $('resetAll').addEventListener('click', resetAllParts);
  882. /* 部件查询弹窗 */
  883. const modal = $('partModal');
  884. const openModal = () => {
  885. modal.classList.add('show');
  886. refreshRows();
  887. const s = $('search');
  888. if (s) { s.value = ''; s.focus(); }
  889. };
  890. const closeModal = () => modal.classList.remove('show');
  891. $('openParts').addEventListener('click', openModal);
  892. $('closeParts').addEventListener('click', closeModal);
  893. modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
  894. window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
  895. $('search').addEventListener('input', refreshRows);
  896. /* 视角按钮 */
  897. const viewBtns = $('viewBtns');
  898. viewButtonEls = [];
  899. VIEWS.forEach((v, i) => {
  900. const b = document.createElement('button');
  901. b.className = 'view-btn';
  902. b.textContent = v.label;
  903. b.title = v.axis
  904. ? `正对 ${v.axis.join('')} 轴端面`
  905. : `相机 (${v.camera.join(', ')}) → 目标 (${v.target.join(', ')})`;
  906. b.addEventListener('click', () => activateView(i));
  907. viewBtns.appendChild(b);
  908. viewButtonEls.push(b);
  909. });
  910. /* 轨道 / 自由 旋转切换 */
  911. const rotBtn = $('rotModeBtn');
  912. rotBtn.addEventListener('click', () => {
  913. setFreeMode(!freeMode);
  914. rotBtn.textContent = freeMode ? '轨道旋转' : '自由旋转';
  915. rotBtn.classList.toggle('active', freeMode);
  916. });
  917. }
  918. /* ------------------------------------------------------------------ */
  919. /* 动画循环 */
  920. /* ------------------------------------------------------------------ */
  921. const QS = new URLSearchParams(location.search);
  922. const DEBUG = QS.has('debug');
  923. const NO_RENDER = QS.has('norender');
  924. function log(...a) { if (DEBUG) console.log('[3d]', ...a); }
  925. function animate() {
  926. requestAnimationFrame(animate);
  927. controls.update();
  928. composer.render();
  929. }
  930. if (!NO_RENDER) animate();
  931. /* ------------------------------------------------------------------ */
  932. initUI();
  933. loadModelList();
  934. updateCameraAspect();