/** * STEP 3D 查看器(对标新迪 NDS 查看器) * - STEP 直读:occt-import-js(浏览器内 WASM 转换,无需任何云服务) * - 渲染:three.js r160 * - 功能:零件树 / 测量 / 剖切 / 爆炸 / 隐藏 / 拖动 / 复位 / 视角 */ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/OrbitControls.js'; import { DragControls } from 'three/addons/DragControls.js'; import { CSS2DRenderer, CSS2DObject } from 'three/addons/CSS2DRenderer.js'; /* ================= 全局状态 ================= */ const S = { occt: null, result: null, modelGroup: null, meshObjs: [], // THREE.Mesh 数组(与 result.meshes 一一对应) meshMeta: [], // { center, dir, dragOffset } edgeObjs: [], edgeLines: [], // 轮廓线(挂在 mesh 下) wireframeOn: true, treeNodes: [], // 平铺的树节点 { node, row, children, parent, solidIds, isLeaf } rootNode: null, selected: null, // 当前高亮的树节点 tool: 'select', modelCenter: new THREE.Vector3(), modelRadius: 1, bbox: new THREE.Box3(), explodeAmount: 0, sectionOn: false, sectionAxis: 'z', sectionOffset: 0, sectionPlane: new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), sectionVisual: null, // 半透明剖切面指示器 measurements: [], measurePts: [], // 待连线的测量点 dragMembers: null, // 当前拖动的成员集合 dragLastPos: new THREE.Vector3(), axesHelper: null, bboxHelper: null, viewIndex: 0, meshFaces: [], // 每个 mesh 的 brep 面数据({first,last,color}[]) colorOverrides: {}, // meshIndex -> '#rrggbb' partBboxHelper: null, thicknessFaces: [], thicknessMarkers: null, meshEntryMap: {}, // meshIndex -> 最具体的树行(零件行),视口点选/拖动用 modelSource: { type: 'none', value: '' }, // 模型来源:local / file(同源相对) / url(远程) faceHighlight: null, // 面拾取高亮覆盖层列表(S.faceHighlights) faceHighlights: [], }; // 网格细分精度档位(occt-import-js 参数) const QUALITY = { low: { linearDeflection: 0.5, angularDeflection: 0.6, meshQuality: 'normal' }, medium: { linearDeflection: 0.2, angularDeflection: 0.4, meshQuality: 'normal' }, high: { linearDeflection: 0.05, angularDeflection: 0.2, meshQuality: 'fine' }, }; /* ================= DOM ================= */ const $ = (id) => document.getElementById(id); const dom = { canvasWrap: $('canvasWrap'), tree: $('tree'), treeSearch: $('treeSearch'), toolbar: $('toolbar'), status: $('statusText'), modelInfo: $('modelInfo'), fileName: $('fileName'), fileInput: $('fileInput'), loading: $('loading'), loadingText: $('loadingText'), dropOverlay: $('dropOverlay'), explodePanel: $('explodePanel'), explodeSlider: $('explodeSlider'), explodeVal: $('explodeVal'), sectionPanel: $('sectionPanel'), sectionSlider: $('sectionSlider'), sectionVal: $('sectionVal'), qualitySelect: $('qualitySelect'), }; /* ================= 场景初始化 ================= */ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.localClippingEnabled = true; // 不使用色调映射:上色所见即所得(选红就是红),显示风格与最初版本一致 dom.canvasWrap.appendChild(renderer.domElement); const cssRenderer = new CSS2DRenderer(); cssRenderer.domElement.style.position = 'absolute'; cssRenderer.domElement.style.inset = '0'; cssRenderer.domElement.style.pointerEvents = 'none'; dom.canvasWrap.appendChild(cssRenderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf1f3f2); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); camera.position.set(100, 80, 120); // 灯光:环境光 + 主光 + 补光(明亮清晰),整体亮度可调 const BRIGHTNESS = { ambient: 1.15, key: 1.35, fill: 0.55 }; const ambientLight = new THREE.AmbientLight(0xffffff, BRIGHTNESS.ambient); scene.add(ambientLight); const keyLight = new THREE.DirectionalLight(0xffffff, BRIGHTNESS.key); keyLight.position.set(1, 1.6, 1); scene.add(keyLight); const fillLight = new THREE.DirectionalLight(0xffffff, BRIGHTNESS.fill); fillLight.position.set(-1, -0.4, -0.8); scene.add(fillLight); // 亮度调节(0.5x ~ 2x) function setBrightness(mult) { ambientLight.intensity = BRIGHTNESS.ambient * mult; keyLight.intensity = BRIGHTNESS.key * mult; fillLight.intensity = BRIGHTNESS.fill * mult; $('brightnessVal').textContent = Math.round(mult * 100) + '%'; } $('brightnessSlider').addEventListener('input', () => { setBrightness(Number($('brightnessSlider').value) / 100); }); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.1; // 按键映射:中键=旋转,Shift+中键=平移(左键旋转、右键平移保持默认) controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN, }; // 捕获阶段先于 OrbitControls 内部监听器执行,按下中键时按 Shift 状态切换映射 renderer.domElement.addEventListener('pointerdown', (e) => { if (e.button === 1) { controls.mouseButtons.MIDDLE = e.shiftKey ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE; } }, true); renderer.domElement.addEventListener('pointerup', () => { controls.mouseButtons.MIDDLE = THREE.MOUSE.ROTATE; }, true); S.modelGroup = new THREE.Group(); scene.add(S.modelGroup); const dragControls = new DragControls([], camera, renderer.domElement); dragControls.enabled = false; const raycaster = new THREE.Raycaster(); // 按需渲染:场景无变化时不重绘,空闲时零 GPU 占用,交互更流畅 let needsRender = true; function invalidate() { needsRender = true; } controls.addEventListener('change', invalidate); // 旋转/缩放/惯性阻尼 renderer.domElement.addEventListener('pointermove', invalidate); // 悬停高亮 renderer.domElement.addEventListener('pointerdown', invalidate); // 选中/测量 renderer.domElement.addEventListener('pointerup', invalidate); // 拖动零件 renderer.domElement.addEventListener('wheel', invalidate); window.addEventListener('input', invalidate); // 亮度/爆炸/剖切滑杆 function resize() { const w = dom.canvasWrap.clientWidth, h = dom.canvasWrap.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); cssRenderer.setSize(w, h); invalidate(); } window.addEventListener('resize', resize); resize(); function animate() { requestAnimationFrame(animate); controls.update(); if (!needsRender) return; needsRender = false; renderer.render(scene, camera); cssRenderer.render(scene, camera); } animate(); /* ================= 工具函数 ================= */ function setStatus(text) { dom.status.textContent = text; } function defaultColor() { return new THREE.Color(0.82, 0.84, 0.86); } // occt-import-js 0.0.23 的 attributes/index 是 { array: 普通数组 } 包装,转成 TypedArray function toTypedArray(v, Ctor) { let a = v; while (a && a.array && !ArrayBuffer.isView(a) && !Array.isArray(a)) a = a.array; if (ArrayBuffer.isView(a)) return a; if (Array.isArray(a) || (a && typeof a.length === 'number')) return new Ctor(a); return null; } // 按角度平滑法线:相邻面夹角 < angleDeg 时合并法线(曲面光滑、锐边保持) function smoothNormals(geo, angleDeg) { const cosTh = Math.cos(THREE.MathUtils.degToRad(angleDeg)); const pos = geo.attributes.position; const idx = geo.index; const vertTris = new Map(); const triNormals = []; const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3(); const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), n = new THREE.Vector3(); for (let t = 0; t < idx.count; t += 3) { const a = idx.getX(t), b = idx.getX(t + 1), c = idx.getX(t + 2); vA.fromBufferAttribute(pos, a); vB.fromBufferAttribute(pos, b); vC.fromBufferAttribute(pos, c); e1.subVectors(vB, vA); e2.subVectors(vC, vA); n.crossVectors(e1, e2); triNormals.push(n.lengthSq() > 1e-20 ? n.clone().normalize() : new THREE.Vector3(0, 0, 1)); for (const v of [a, b, c]) { if (!vertTris.has(v)) vertTris.set(v, []); vertTris.get(v).push(t / 3); } } const normals = new Float32Array(pos.count); const acc = new THREE.Vector3(); vertTris.forEach((tris, v) => { acc.set(0, 0, 0); const base = triNormals[tris[0]]; for (const ti of tris) { const tn = triNormals[ti]; if (tn.dot(base) >= cosTh) acc.add(tn); } if (acc.lengthSq() < 1e-12) acc.copy(base); acc.normalize(); normals[v * 3] = acc.x; normals[v * 3 + 1] = acc.y; normals[v * 3 + 2] = acc.z; }); geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); } // 过暗的 CAD 颜色提亮到可见中灰(保持色相),纯黑/近黑直接用默认灰,避免渲染成黑色区域 function liftDarkColor(c) { if (!Array.isArray(c) || c.length < 3) return null; let [r, g, b] = c; const m = Math.max(r, g, b); if (m < 0.01) return [0.52, 0.54, 0.57]; // 纯黑面 → 可见深灰 if (m >= 0.45) return [r, g, b]; const s = 0.45 / Math.max(m, 1e-4); return [Math.min(r * s, 1), Math.min(g * s, 1), Math.min(b * s, 1)]; } function occtColor(c) { if (!Array.isArray(c) || c.length < 3) return defaultColor(); const lifted = liftDarkColor(c) || [0.82, 0.84, 0.86]; const col = new THREE.Color(lifted[0], lifted[1], lifted[2]); if (col.r === 0 && col.g === 0 && col.b === 0) return defaultColor(); return col; } function disposeScene() { S.meshObjs.forEach(m => { m.geometry.dispose(); m.material.dispose(); }); S.edgeObjs.forEach(e => { e.geometry.dispose(); e.material.dispose(); }); S.edgeLines.forEach(l => { l.geometry.dispose(); l.material.dispose(); }); while (S.modelGroup.children.length) S.modelGroup.remove(S.modelGroup.children[0]); clearMeasurements(); clearHighlight(); S.meshObjs = []; S.meshMeta = []; S.edgeObjs = []; S.edgeLines = []; S.meshFaces = []; S.colorOverrides = {}; S.meshEntryMap = {}; S.treeNodes = []; S.rootNode = null; S.selected = null; S.explodeAmount = 0; setSectionOn(false); dom.explodeSlider.value = 0; dom.explodeVal.textContent = '0%'; if (S.bboxHelper) { scene.remove(S.bboxHelper); S.bboxHelper.geometry.dispose(); S.bboxHelper = null; } if (S.sectionVisual) { scene.remove(S.sectionVisual); S.sectionVisual.geometry.dispose(); S.sectionVisual.material.dispose(); S.sectionVisual = null; } dom.tree.innerHTML = '
加载模型后显示零件树
'; dom.modelInfo.textContent = ''; } /* ================= STEP 加载 ================= */ function showLoading(text) { dom.loadingText.textContent = text; dom.loading.classList.remove('hidden'); } function hideLoading() { dom.loading.classList.add('hidden'); } async function loadFile(file, source) { S.modelSource = source || { type: 'local', value: file.name }; const name = (file.name || 'model.step').toLowerCase(); if (!/\.(step|stp)$/.test(name)) { setStatus('请选择 .step / .stp 文件'); return; } showLoading('模型加载中...'); try { if (!S.occt) { S.occt = await (typeof occtimportjs === 'function' ? occtimportjs({ locateFile: (p) => './libs/' + p }) : occtimportjs.default({ locateFile: (p) => './libs/' + p })); } const buf = new Uint8Array(await file.arrayBuffer()); const q = EMBED_MODE ? QUALITY.high : (QUALITY[dom.qualitySelect.value] || QUALITY.high); showLoading(`正在转换 STEP 模型(精度:${dom.qualitySelect.selectedOptions[0].textContent})...`); // 转换期间把进度日志显示到遮罩上 const origLog = console.log; console.log = (...args) => { origLog(...args); const msg = String(args.join(' ')); if (/%|mesh|conver/i.test(msg)) dom.loadingText.textContent = msg.slice(0, 80); }; const readFn = S.occt.ReadStepFileAsync || S.occt.ReadStepFile; const result = await readFn.call(S.occt, buf, { meshUnit: 'millimeter', linearDeflection: q.linearDeflection, angularDeflection: q.angularDeflection, meshQuality: q.meshQuality, enableProgressLog: true, }); console.log = origLog; if (!result || result.error) { hideLoading(); setStatus('转换失败:' + (result && result.error ? result.error : '未知错误')); return; } buildModel(result, file.name); hideLoading(); invalidate(); dom.fileName.textContent = file.name; // 本地文件留着引用,嵌入面板里点「上传当前模型」才上传(与 2D 查看器一致) if (S.modelSource.type === 'local') S.currentFile = file; } catch (err) { console.error(err); hideLoading(); setStatus('加载失败:' + err.message); } } /* ================= 模型构建 ================= */ function buildModel(result, fileName) { disposeScene(); S.result = result; // ---- 网格 ---- // occt-import-js 0.0.23:attributes.position / index 是 { array: 普通数组 } 包装对象 const meshes = result.meshes || []; let triCount = 0; S.meshFaces = []; meshes.forEach((m, i) => { const geo = new THREE.BufferGeometry(); const posArr = toTypedArray(m.attributes.position, Float32Array); const nrmArr = toTypedArray(m.attributes.normal, Float32Array); const idxArr = toTypedArray(m.index, Uint32Array); if (!posArr) return; geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3)); geo.setAttribute('normal', new THREE.BufferAttribute(nrmArr, 3)); geo.setIndex(new THREE.BufferAttribute(idxArr, 1)); geo.computeBoundingBox(); // 按角度平滑法线:曲面光滑、锐边保持(30° 阈值) smoothNormals(geo, 30); triCount += geo.index.count / 3; const baseColor = occtColor(m.color || (m.name && m.name.color)); // B-rep 面数据:{first,last,color},供面测量(面积/半径/壁厚)与面级配色 const faces = Array.isArray(m.brep_faces) ? m.brep_faces : null; S.meshFaces[i] = faces; const mat = new THREE.MeshPhongMaterial({ color: baseColor, specular: 0x222222, shininess: 30, side: THREE.DoubleSide, }); // 面级颜色(CAD 原色,过暗自动提亮):逐三角形展开成顶点颜色 // 注意:顶点颜色不走材质 color 的 sRGB→线性转换,必须手动指定 sRGB 空间 if (faces && faces.length > 0) { const colors = new Float32Array(idxArr.length); const col = new THREE.Color(); for (const f of faces) { const raw = Array.isArray(f.color) && f.color.length >= 3 ? f.color : [0.82, 0.84, 0.86]; const fc = liftDarkColor(raw) || [0.82, 0.84, 0.86]; col.setRGB(fc[0], fc[1], fc[2], THREE.SRGBColorSpace); const triStart = f.first * 3; const triEnd = Math.min((f.last + 1) * 3, idxArr.length); for (let k = triStart; k < triEnd; k++) { const v = idxArr[k] * 3; colors[v] = col.r; colors[v + 1] = col.g; colors[v + 2] = col.b; } } geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); mat.vertexColors = true; } const obj = new THREE.Mesh(geo, mat); obj.userData.index = i; obj.userData.baseColor = baseColor.clone(); S.modelGroup.add(obj); S.meshObjs.push(obj); }); // ---- 轮廓线框(occt 0.0.23 不输出边线,用二面角 20° 提取锐边)---- S.meshObjs.forEach((m) => { const edgeGeo = new THREE.EdgesGeometry(m.geometry, 20); const line = new THREE.LineSegments(edgeGeo, new THREE.LineBasicMaterial({ color: 0x2b2b2b, transparent: true, opacity: 0.55, })); line.raycast = () => {}; // 不参与拾取 line.userData.isEdge = true; line.visible = S.wireframeOn; m.add(line); // 挂在 mesh 下:随爆炸/拖动移动、随隐藏消失 S.edgeLines.push(line); }); // ---- 边线(装配体轮廓,更像 CAD 查看器)---- const edges = result.edges || []; edges.forEach((e) => { const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(e.attributes.position, 3)); const hasColor = e.attributes.color && e.attributes.color.length === e.attributes.position.length; if (hasColor) geo.setAttribute('color', new THREE.BufferAttribute(e.attributes.color, 3)); const mat = new THREE.LineBasicMaterial({ color: 0x333333, vertexColors: hasColor, transparent: true, opacity: 0.55, }); const lines = new THREE.LineSegments(geo, mat); lines.userData.isEdge = true; S.modelGroup.add(lines); S.edgeObjs.push(lines); }); // ---- 包围盒 / 中心 / 爆炸方向 ---- S.bbox = new THREE.Box3(); S.meshObjs.forEach((m) => S.bbox.expandByObject(m)); if (S.bbox.isEmpty()) S.bbox.setFromCenterAndSize(new THREE.Vector3(), new THREE.Vector3(1, 1, 1)); S.bbox.getCenter(S.modelCenter); S.modelRadius = S.bbox.getSize(new THREE.Vector3()).length() / 2; S.meshMeta = S.meshObjs.map((m) => { const c = new THREE.Vector3(); m.geometry.boundingBox.getCenter(c); const dir = c.clone().sub(S.modelCenter); if (dir.lengthSq() < 1e-12) dir.set(0, 0, 1); return { center: c, dir: dir.normalize(), dragOffset: new THREE.Vector3() }; }); // ---- 零件树 ---- buildTree(result.root, fileName); // ---- 剖切范围(按新模型包围盒重设)---- const size = S.bbox.getSize(new THREE.Vector3()); const maxHalf = Math.max(size.x, size.y, size.z) / 2; dom.sectionSlider.min = String(Math.round(-maxHalf)); dom.sectionSlider.max = String(Math.round(maxHalf)); S.sectionOffset = 0; dom.sectionSlider.value = 0; updateSectionVal(); updateSectionPlane(); if (S.sectionOn) updateSectionPlane(); // ---- 相机适配 ---- fitCamera(); // ---- 状态 ---- const partCount = S.meshObjs.length; const mm = (v) => Math.round(v) + ' mm'; dom.modelInfo.textContent = `${partCount} 个零件 | ${(triCount / 1000).toFixed(0)}k 三角面 | 尺寸 ${mm(size.x)} × ${mm(size.y)} × ${mm(size.z)}`; setStatus(`模型加载完成:${fileName}`); updateDragControlsObjects(); } function fitCamera() { const dir = new THREE.Vector3(1, 0.6, 1).normalize(); camera.position.copy(S.modelCenter).addScaledVector(dir, S.modelRadius * 2.4); camera.near = Math.max(S.modelRadius / 1000, 0.1); camera.far = S.modelRadius * 100; camera.updateProjectionMatrix(); controls.target.copy(S.modelCenter); controls.update(); } /* ================= 零件树 ================= */ const ICONS = { root: './icons/tree-root.48094390.png', part: './icons/tree-Part.e3fb53a4.png', views: './icons/tree-views.7d4f57c8.png', captures: './icons/tree-captures.7dc1f3b5.png', open: './icons/tree-node-open.f45d6681.png', close: './icons/tree-node-close.9d625f70.png', check: './icons/ico-tree-check.7c4874a5.png', checked: './icons/ico-tree-checked.905447ca.png', partial: './icons/ico-tree-checking.bd34b24b.png', }; function meshVisibility(meshIndex) { const m = S.meshObjs[meshIndex]; return m ? m.visible : true; } function buildTree(root, fileName) { dom.tree.innerHTML = ''; if (!root) { dom.tree.innerHTML = '
无模型结构
'; return; } const rootName = (fileName || '').replace(/\.(step|stp)$/i, ''); // 无名根节点且只有一个子节点时,直接用子节点作树根(贴合 STEP 原始结构) let treeRoot = root; if ((!root.name || !String(root.name).trim()) && root.children && root.children.length === 1) { treeRoot = root.children[0]; } const createRow = (node, depth, parentEntry, isRoot) => { const row = document.createElement('div'); row.className = 'tree-row'; row.style.paddingLeft = (6 + depth * 16) + 'px'; const nodeChildren = node.children || []; const nodeMeshes = node.meshes || node.solidIds || []; const hasChildren = nodeChildren.length > 0; const expander = document.createElement('span'); expander.className = hasChildren ? 'tree-expander' : 'tree-spacer'; const box = document.createElement('div'); box.className = 'tree-children'; if (hasChildren) { expander.innerHTML = ``; expander.addEventListener('click', (ev) => { ev.stopPropagation(); const collapsed = box.classList.toggle('hidden'); expander.innerHTML = ``; }); } row.appendChild(expander); // 图标(0.0.23 无 productType,用 children 判断装配/零件) const icon = document.createElement('img'); icon.className = 'tree-icon'; icon.src = isRoot ? ICONS.root : (hasChildren ? ICONS.views : ICONS.part); row.appendChild(icon); // 勾选框 const entry = { node, row, parent: parentEntry, children: [], solidIds: nodeMeshes, depth, box, checkImg: null }; const check = document.createElement('img'); check.className = 'tree-check'; check.src = ICONS.checked; check.addEventListener('click', (ev) => { ev.stopPropagation(); const target = allVisible(entry); setNodeVisible(entry, !target); }); row.appendChild(check); entry.checkImg = check; // 名称(保持 STEP 原名,只有完全无名时才兜底) const label = document.createElement('span'); label.className = 'tree-label'; const displayName = (node.name && String(node.name).trim()) || (isRoot ? rootName : '未命名'); label.textContent = displayName; label.title = displayName; row.appendChild(label); row.addEventListener('click', () => selectNode(entry)); // 挂载 if (parentEntry) { parentEntry.box.appendChild(row); parentEntry.box.appendChild(box); parentEntry.children.push(entry); } else { dom.tree.appendChild(row); dom.tree.appendChild(box); } S.treeNodes.push(entry); return entry; }; const walk = (node, depth, parentEntry, isRoot) => { const entry = createRow(node, depth, parentEntry, isRoot); // 每个实体一行,名称用 STEP 原始名(无名则「实体-N」),保证可独立选中/上色/隐藏 const nodeMeshes = node.meshes || node.solidIds || []; nodeMeshes.forEach((mi) => { const m = (S.result && S.result.meshes) ? S.result.meshes[mi] : null; const raw = m ? m.name : null; const name = (typeof raw === 'string' && raw.trim()) || `实体-${mi + 1}`; const pe = createRow({ name, meshes: [mi], children: [] }, depth + 1, entry, false); S.meshEntryMap[mi] = pe; }); (node.children || []).forEach((c) => walk(c, depth + 1, entry, false)); return entry; }; S.rootNode = walk(treeRoot, 0, null, true); refreshTreeChecks(); } function allVisible(entry) { const ids = []; collectSolidIds(entry, ids); if (ids.length === 0) return true; return ids.every((i) => meshVisibility(i)); } function collectSolidIds(entry, out) { // 有子行时只收集子行(父行的 solidIds 是子行并集,避免重复计数) if (entry.children.length === 0) { entry.solidIds.forEach((i) => out.push(i)); } else { entry.children.forEach((c) => collectSolidIds(c, out)); } } function setNodeVisible(entry, visible) { const ids = []; collectSolidIds(entry, ids); ids.forEach((i) => { if (S.meshObjs[i]) S.meshObjs[i].visible = visible; }); if (entry.children.length === 0 && ids.length > 0) { // 叶子勾选后联动父级(父级全勾/半勾在 refreshTreeChecks 中处理) } refreshTreeChecks(); } function refreshTreeChecks() { const visit = (entry) => { entry.children.forEach(visit); const ids = []; collectSolidIds(entry, ids); if (ids.length === 0) { entry.checkImg.src = ICONS.checked; return; } const shown = ids.filter((i) => meshVisibility(i)).length; entry.checkImg.src = shown === 0 ? ICONS.check : (shown === ids.length ? ICONS.checked : ICONS.partial); }; visit(S.rootNode); } function selectNode(entry) { if (S.selected === entry) { clearHighlight(); return; } clearHighlight(); S.selected = entry; entry.row.classList.add('highlight'); const ids = []; collectSolidIds(entry, ids); ids.forEach((i) => { const m = S.meshObjs[i]; if (m) { m.material.emissive = new THREE.Color(0x2b9ae8); m.material.emissiveIntensity = 0.4; // 深色零件也能看清选中高亮 } }); setStatus(`已选中:${entry.node.name || '未命名'}(${ids.length} 个几何体)`); } function clearHighlight() { if (S.selected) { S.selected.row.classList.remove('highlight'); const ids = []; collectSolidIds(S.selected, ids); ids.forEach((i) => { const m = S.meshObjs[i]; if (m) { m.material.emissive = new THREE.Color(0x000000); } }); } S.selected = null; } // 树搜索 dom.treeSearch.addEventListener('input', () => { const q = dom.treeSearch.value.trim().toLowerCase(); let firstMatch = null; S.treeNodes.forEach((entry) => { const name = (entry.node.name || '').toLowerCase(); const hit = q !== '' && name.includes(q); entry.row.classList.toggle('matched', hit); if (hit && !firstMatch) firstMatch = entry; if (q === '') entry.row.style.display = ''; else entry.row.style.display = hit ? '' : 'none'; if (hit) { // 展开命中节点的祖先,使其可见 let p = entry.parent; while (p) { p.box.classList.remove('hidden'); const ex = p.row.querySelector('.tree-expander img'); if (ex) ex.src = ICONS.open; p = p.parent; } } }); if (firstMatch) firstMatch.row.scrollIntoView({ block: 'center' }); }); /* ================= 工具切换 ================= */ const toolButtons = document.querySelectorAll('.tool-btn[data-tool]'); function setTool(tool) { // 再次点击「测量」= 关闭测量,恢复工具栏 if (tool === 'measure' && S.tool === 'measure') tool = 'select'; S.tool = tool; toolButtons.forEach((b) => b.classList.toggle('active', b.dataset.tool === tool)); dragControls.enabled = (tool === 'drag' || tool === 'singalDrag'); controls.enabled = !dragControls.enabled; renderer.domElement.style.cursor = tool === 'measure' ? 'crosshair' : (dragControls.enabled ? 'grab' : 'default'); // 测量激活时隐藏底部工具栏、显示水平单行测量面板;退出时恢复 $('toolbar').classList.toggle('hidden', tool === 'measure'); $('measurePanel').classList.toggle('hidden', tool !== 'measure'); // 其他面板显隐 dom.explodePanel.classList.toggle('hidden', tool !== 'explode'); dom.sectionPanel.classList.toggle('hidden', tool !== 'section'); if (tool !== 'measure') resetLengthTemp(); if (tool === 'measure') setMeasureModeStatus(); else if (tool === 'drag') setStatus('拖动:按住零件移动,松开结束;点空白处回到选择模式'); else if (tool === 'singalDrag') setStatus('单件拖动:按住单个零件移动;点空白处回到选择模式'); else if (tool === 'explode') setStatus('爆炸:拖动滑块分解装配体'); else if (tool === 'section') setStatus('剖切:选择方向并拖动滑块定位剖面'); else setStatus('选择:点击零件高亮,点击空白取消 | 左键/中键旋转 · Shift+中键/右键平移 · 滚轮缩放'); S.measurePts = []; S.thicknessFaces = []; if (S.thicknessMarkers) { disposeGroup(S.thicknessMarkers); S.thicknessMarkers = null; } if (tool !== 'measure') clearFaceHighlight(); } toolButtons.forEach((b) => b.addEventListener('click', () => setTool(b.dataset.tool))); function setMeasureModeStatus() { const hints = { coordinate: '坐标:点击模型上的点,显示 X/Y/Z', distance: '距离:依次点击两个点', length: '长度:连续取点,双击或按 Enter 结束', angle: '角度:依次点击 顶点→边1点→边2点', radius: '半径:点击圆柱/圆弧面', ellipse: '椭圆:点击椭圆面', linear: '线性测量:依次点击两个点', thickness: '壁厚:依次点击两个相对的面', area: '面积:点击要测量的面', volume: '体积重量:点击实体零件(密度按钢 7.85 g/cm³)', bbox: '包围盒:点击零件显示其长宽高', }; setStatus(`测量[${measureModeLabel(S.measureMode)}]:${hints[S.measureMode] || ''}`); } // 构建测量子面板(新迪同款 13 个测量按钮) function buildMeasurePanel() { const box = $('measureModes'); box.innerHTML = ''; MEASURE_MODES.forEach((m) => { const b = document.createElement('button'); b.className = 'measure-btn'; b.dataset.mode = m.key; b.innerHTML = `${m.label}${m.label}`; b.title = m.label; b.addEventListener('click', () => { S.measureMode = m.key; S.measurePts = []; S.thicknessFaces = []; if (S.thicknessMarkers) { disposeGroup(S.thicknessMarkers); S.thicknessMarkers = null; } resetLengthTemp(); box.querySelectorAll('.measure-btn').forEach((x) => x.classList.toggle('active', x === b)); setMeasureModeStatus(); }); if (m.key === S.measureMode) b.classList.add('active'); box.appendChild(b); }); } /* ================= 爆炸 ================= */ function applyExplode() { const amount = S.modelRadius * 0.6 * (S.explodeAmount / 100); S.meshMeta.forEach((meta, i) => { const m = S.meshObjs[i]; m.position.copy(meta.dragOffset).addScaledVector(meta.dir, amount); }); } dom.explodeSlider.addEventListener('input', () => { S.explodeAmount = Number(dom.explodeSlider.value); dom.explodeVal.textContent = S.explodeAmount + '%'; applyExplode(); }); /* ================= 剖切 ================= */ function setSectionOn(on) { S.sectionOn = on; if (on) updateSectionPlane(); // 关键:开启时按当前模型包围盒重算平面位置 S.meshObjs.forEach((m) => { m.material.clippingPlanes = on ? [S.sectionPlane] : null; }); S.edgeLines.forEach((l) => { l.material.clippingPlanes = on ? [S.sectionPlane] : null; }); updateSectionVisual(); const btn = $('btnSectionToggle'); btn.classList.toggle('on', on); btn.textContent = on ? '开启' : '关闭'; } function updateSectionPlane() { const axisMap = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1) }; const n = axisMap[S.sectionAxis] || axisMap.z; if (S.sectionPlane.normal.equals(n) === false) { S.sectionPlane.normal.copy(n); S.sectionOffset = 0; dom.sectionSlider.value = 0; updateSectionVal(); } S.sectionPlane.constant = n.dot(S.modelCenter) + S.sectionOffset; updateSectionVisual(); } // 半透明剖切面指示器(帮助看清剖切位置) function updateSectionVisual() { if (!S.sectionOn || S.meshObjs.length === 0) { if (S.sectionVisual) S.sectionVisual.visible = false; return; } if (!S.sectionVisual) { const g = new THREE.PlaneGeometry(2, 2); const m = new THREE.MeshBasicMaterial({ color: 0x2b9ae8, transparent: true, opacity: 0.22, side: THREE.DoubleSide, depthTest: false, }); S.sectionVisual = new THREE.Mesh(g, m); S.sectionVisual.renderOrder = 998; scene.add(S.sectionVisual); } const r = Math.max(S.modelRadius * 1.2, 1); S.sectionVisual.scale.set(r, r, 1); // 平面过点 modelCenter + normal * offset(plane.constant = n·center + offset) S.sectionVisual.position.copy(S.modelCenter).addScaledVector(S.sectionPlane.normal, S.sectionOffset); S.sectionVisual.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), S.sectionPlane.normal); S.sectionVisual.visible = true; } function updateSectionVal() { dom.sectionVal.textContent = String(Math.round(S.sectionOffset)); } document.querySelectorAll('.axis-btn').forEach((b) => b.addEventListener('click', () => { document.querySelectorAll('.axis-btn').forEach((x) => x.classList.toggle('active', x === b)); S.sectionAxis = b.dataset.axis; updateSectionPlane(); })); dom.sectionSlider.addEventListener('input', () => { S.sectionOffset = Number(dom.sectionSlider.value); updateSectionVal(); updateSectionPlane(); }); $('btnSectionFlip').addEventListener('click', () => { S.sectionPlane.normal.multiplyScalar(-1); }); $('btnSectionToggle').addEventListener('click', () => setSectionOn(!S.sectionOn)); /* ================= 测量(全套件) ================= */ const MEASURE_MODES = [ { key: 'coordinate', label: '坐标', icon: 'view-tool-coordinate.png', clicks: 1 }, { key: 'distance', label: '距离', icon: 'view-tool-distance.png', clicks: 2 }, { key: 'length', label: '长度', icon: 'view-tool-length.png', clicks: -1 }, { key: 'angle', label: '角度', icon: 'view-tool-angel.png', clicks: 3 }, { key: 'radius', label: '半径', icon: 'view-tool-radius.png', clicks: 1 }, { key: 'ellipse', label: '椭圆', icon: 'view-tool-ellipse.png', clicks: 1 }, { key: 'linear', label: '线性测量', icon: 'view-tool-Lineargauge.png', clicks: 2 }, { key: 'thickness', label: '壁厚', icon: 'view-tool-wallThickness.png', clicks: 2 }, { key: 'area', label: '面积', icon: 'view-tool-area.png', clicks: 1 }, { key: 'volume', label: '体积重量', icon: 'view-tool-volumeWeight.png', clicks: 1 }, { key: 'bbox', label: '包围盒', icon: 'view-tool-boundBox.png', clicks: 1 }, ]; S.measureMode = 'distance'; S.results = []; S.lengthTemp = null; let resultSeq = 0; function measureModeLabel(key) { const m = MEASURE_MODES.find((x) => x.key === key); return m ? m.label : key; } // ---- 结果统计 ---- function addResult(type, text, group) { const rec = { id: ++resultSeq, type, text, group }; S.results.push(rec); renderStats(); setStatus(`${type}:${text}`); return rec; } function disposeGroup(group) { if (!group) return; S.modelGroup.remove(group); group.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); } function removeResult(id) { const i = S.results.findIndex((r) => r.id === id); if (i < 0) return; disposeGroup(S.results[i].group); S.results.splice(i, 1); renderStats(); } function renderStats() { const box = $('statsList'); box.innerHTML = ''; if (S.results.length === 0) { box.innerHTML = '
暂无测量结果
'; return; } S.results.forEach((r) => { const row = document.createElement('div'); row.className = 'stats-row'; const t = document.createElement('span'); t.className = 'stats-type'; t.textContent = r.type; const v = document.createElement('span'); v.className = 'stats-value'; v.textContent = r.text; const d = document.createElement('span'); d.className = 'stats-del'; d.textContent = '✕'; d.title = '删除该结果'; d.addEventListener('click', () => removeResult(r.id)); row.append(t, v, d); box.appendChild(row); }); } function clearMeasurements() { S.results.slice().forEach((r) => disposeGroup(r.group)); S.results = []; S.measurePts = []; resetLengthTemp(); if (S.partBboxHelper) { disposeGroup(S.partBboxHelper); S.partBboxHelper = null; } clearFaceHighlight(); dom.canvasWrap.querySelectorAll('.measure-label').forEach((e) => e.remove()); renderStats(); } // ---- 基础绘制 ---- function makeMarker(p) { const r = Math.max(S.modelRadius * 0.004, 0.5); const g = new THREE.SphereGeometry(r, 12, 12); const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color: 0x2b9ae8, depthTest: false })); m.renderOrder = 999; m.position.copy(p); return m; } function makeLabel(text, pos) { const el = document.createElement('div'); el.className = 'measure-label'; el.textContent = text; const label = new CSS2DObject(el); label.position.copy(pos); return label; } function makeLine(pts, color = 0x2b9ae8) { const geo = new THREE.BufferGeometry().setFromPoints(pts); const line = new THREE.Line(geo, new THREE.LineBasicMaterial({ color, depthTest: false })); line.renderOrder = 999; return line; } // ---- 拾取 ---- function pickPoint(event) { const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1 ); raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(S.meshObjs, false).filter((h) => h.object.visible); return hits.length ? hits[0] : null; } // 三角形索引 → brep 面(first/last 有序,二分查找) function triToFace(meshIndex, triIndex) { const faces = S.meshFaces[meshIndex]; if (!faces || !faces.length) return null; let lo = 0, hi = faces.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (triIndex < faces[mid].first) hi = mid - 1; else if (triIndex > faces[mid].last) lo = mid + 1; else return { face: faces[mid], faceIndex: mid }; } return null; } // 面分析:面积 / 质心 / 法向 / 去重顶点 function faceAnalysis(meshIndex, face) { const mesh = S.meshObjs[meshIndex]; const geo = mesh.geometry; const pos = geo.attributes.position; const idx = geo.index; const area = new THREE.Vector3(); // 面积加权法向累计 let totalArea = 0; const centroid = new THREE.Vector3(); const verts = new Map(); const va = new THREE.Vector3(), vb = new THREE.Vector3(), vc = new THREE.Vector3(); const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), n = new THREE.Vector3(); const end = Math.min(face.last, idx.count / 3 - 1); for (let t = face.first; t <= end; t++) { const a = idx.getX(t * 3), b = idx.getX(t * 3 + 1), c = idx.getX(t * 3 + 2); va.fromBufferAttribute(pos, a); vb.fromBufferAttribute(pos, b); vc.fromBufferAttribute(pos, c); e1.subVectors(vb, va); e2.subVectors(vc, va); n.crossVectors(e1, e2); const a2 = n.length() * 0.5; // 三角形面积 if (a2 <= 0) continue; n.normalize(); area.addScaledVector(n, a2); totalArea += a2; centroid.addScaledVector(va.add(vb).add(vc), a2 / 3); const key = (x) => Math.round(x * 100); for (const p of [va, vb, vc]) verts.set(`${key(p.x)},${key(p.y)},${key(p.z)}`, p.clone()); } if (totalArea > 0) centroid.multiplyScalar(1 / totalArea); const normal = area.clone(); const len = normal.length(); const planar = len > 0 && (area.dot(area) / (totalArea * totalArea)) > 0.85; normal.normalize(); return { area: totalArea, centroid, normal, verts: Array.from(verts.values()), planar }; } // 投影到过质心、法向为 normal 的平面,返回 2D 点 function projectToPlane(points, normal, centroid) { const u = new THREE.Vector3(); const ref = Math.abs(normal.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); u.crossVectors(normal, ref).normalize(); const v = new THREE.Vector3().crossVectors(normal, u); return { basis: [u, v], pts: points.map((p) => { const d = p.clone().sub(centroid); return [d.dot(u), d.dot(v)]; }), }; } // Kasa 圆拟合(最小二乘,代数法) function circleFit2D(pts) { const n = pts.length; if (n < 5) return null; let sx = 0, sy = 0, sx2 = 0, sy2 = 0, sx3 = 0, sy3 = 0, sxy = 0, sx2y = 0, sxy2 = 0; for (const [x, y] of pts) { sx += x; sy += y; const x2 = x * x, y2 = y * y; sx2 += x2; sy2 += y2; sx3 += x2 * x; sy3 += y2 * y; sxy += x * y; sx2y += x2 * y; sxy2 += x * y2; } const A = n * sx2 - sx * sx; const B = n * sxy - sx * sy; const C = n * sy2 - sy * sy; const D = 0.5 * (n * sx2y - sx2 * sy + n * sx3 - sx2 * sx); const E = 0.5 * (n * sxy2 - sy2 * sx + n * sy3 - sy2 * sy); const det = A * C - B * B; if (Math.abs(det) < 1e-12) return null; const cx = (D * C - B * E) / det; const cy = (A * E - B * D) / det; let r = 0; for (const [x, y] of pts) r += Math.hypot(x - cx, y - cy); r /= n; let rms = 0; for (const [x, y] of pts) { const d = Math.hypot(x - cx, y - cy) - r; rms += d * d; } rms = Math.sqrt(rms / n); return { cx, cy, r, rms }; } // 归一化圆锥曲线拟合 → 椭圆参数(2x2 特征值闭式解) function ellipseFit2D(pts) { const n = pts.length; if (n < 6) return null; // 最小二乘解 [x², xy, y², x, y, 1]·p = 0,约束 A+C=1 const M = Array.from({ length: 5 }, () => new Array(5).fill(0)); const b = new Array(5).fill(0); for (const [x, y] of pts) { const x2 = x * x, y2 = y * y, xy = x * y; const row = [x2 - y2, xy, x, y, 1]; for (let i = 0; i < 5; i++) { for (let j = 0; j < 5; j++) M[i][j] += row[i] * row[j]; b[i] += row[i] * (-y2); } } const p = gaussSolve(M, b); if (!p) return null; // A+C=1 → A = (p0+1)/2, C = (1-p0)/2 const A = (p[0] + 1) / 2, Bc = p[1], C = (1 - p[0]) / 2, D = p[2], E = p[3], F = p[4]; const disc = Bc * Bc - 4 * A * C; if (disc >= -1e-9) return null; // 非椭圆 // 2x2 特征值闭式 const tr = A + C; const dq = Math.sqrt(Math.max(tr * tr - 4 * (A * C - Bc * Bc / 4), 0)); const l1 = (tr + dq) / 2, l2 = (tr - dq) / 2; if (l1 <= 0 || l2 <= 0) return null; const theta = 0.5 * Math.atan2(Bc, A - C); const cx = (Bc * E / 2 - C * D) / (A * C - Bc * Bc / 4); const cy = (Bc * D / 2 - A * E) / (A * C - Bc * Bc / 4); // 中心化后常数项:F' = A·cx² + B·cx·cy + C·cy² + D·cx + E·cy + F const Fp = A * cx * cx + Bc * cx * cy + C * cy * cy + D * cx + E * cy + F; const den = -Fp; if (den <= 0) return null; const a = Math.sqrt(den / l1), bb = Math.sqrt(den / l2); // 拟合残差 let rms = 0; for (const [x, y] of pts) { const v = A * x * x + Bc * x * y + C * y * y + D * x + E * y + F; rms += v * v; } rms = Math.sqrt(rms / n); return { cx, cy, a: Math.max(a, bb), b: Math.min(a, bb), theta, rms }; } // ---- 3x3 对称矩阵特征分解(Jacobi 旋转,特征值升序) ---- function jacobi3(M) { const A = [ [M[0], M[1], M[2]], [M[1], M[3], M[4]], [M[2], M[4], M[5]], ]; const V = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; for (let iter = 0; iter < 50; iter++) { let off = Math.abs(A[0][1]) + Math.abs(A[0][2]) + Math.abs(A[1][2]); if (off < 1e-12) break; let p = 0, q = 1, mx = Math.abs(A[0][1]); if (Math.abs(A[0][2]) > mx) { mx = Math.abs(A[0][2]); p = 0; q = 2; } if (Math.abs(A[1][2]) > mx) { p = 1; q = 2; } const apq = A[p][q], app = A[p][p], aqq = A[q][q]; const theta = 0.5 * Math.atan2(2 * apq, app - aqq); const c = Math.cos(theta), s = Math.sin(theta); for (let k = 0; k < 3; k++) { const akp = A[k][p], akq = A[k][q]; A[k][p] = c * akp - s * akq; A[k][q] = s * akp + c * akq; } for (let k = 0; k < 3; k++) { const apk = A[p][k], aqk = A[q][k]; A[p][k] = c * apk - s * aqk; A[q][k] = s * apk + c * aqk; } for (let k = 0; k < 3; k++) { const vkp = V[k][p], vkq = V[k][q]; V[k][p] = c * vkp - s * vkq; V[k][q] = s * vkp + c * vkq; } } const idx = [0, 1, 2].sort((a, b) => A[a][a] - A[b][b]); const vals = idx.map((i) => A[i][i]); const vecs = idx.map((i) => new THREE.Vector3(V[0][i], V[1][i], V[2][i])); return { vals, vecs }; } // 面三角形法向 → 圆柱轴(Σnnᵀ 最小特征向量) function axisFromTriangleNormals(meshIndex, face) { const geo = S.meshObjs[meshIndex].geometry; const pos = geo.attributes.position; const idx = geo.index; const M = [0, 0, 0, 0, 0, 0]; const va = new THREE.Vector3(), vb = new THREE.Vector3(), vc = new THREE.Vector3(); const e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), n = new THREE.Vector3(); const end = Math.min(face.last, idx.count / 3 - 1); for (let t = face.first; t <= end; t++) { va.fromBufferAttribute(pos, idx.getX(t * 3)); vb.fromBufferAttribute(pos, idx.getX(t * 3 + 1)); vc.fromBufferAttribute(pos, idx.getX(t * 3 + 2)); e1.subVectors(vb, va); e2.subVectors(vc, va); n.crossVectors(e1, e2); const l = n.length(); if (l < 1e-20) continue; n.multiplyScalar(1 / l); M[0] += n.x * n.x; M[1] += n.x * n.y; M[2] += n.x * n.z; M[3] += n.y * n.y; M[4] += n.y * n.z; M[5] += n.z * n.z; } const { vals, vecs } = jacobi3(M); // 最小特征值方向 = 轴线(全圆柱时该值接近 0) return { axis: vecs[0], minEigen: vals[0], secondEigen: vals[1] }; } // 平面面片的边界环(用于识别孔等圆边) function faceBoundaryLoops(meshIndex, face) { const geo = S.meshObjs[meshIndex].geometry; const pos = geo.attributes.position; const idx = geo.index; const end = Math.min(face.last, idx.count / 3 - 1); const edgeCount = new Map(); for (let t = face.first; t <= end; t++) { const a = idx.getX(t * 3), b = idx.getX(t * 3 + 1), c = idx.getX(t * 3 + 2); for (const [u, v] of [[a, b], [b, c], [c, a]]) { const k = u < v ? u + ',' + v : v + ',' + u; edgeCount.set(k, (edgeCount.get(k) || 0) + 1); } } const boundary = new Map(); for (const [k, cnt] of edgeCount) { if (cnt !== 1) continue; const [u, v] = k.split(',').map(Number); if (!boundary.has(u)) boundary.set(u, []); if (!boundary.has(v)) boundary.set(v, []); boundary.get(u).push(v); boundary.get(v).push(u); } const loops = []; const visited = new Set(); for (const start of boundary.keys()) { if (visited.has(start)) continue; const loop = [start]; visited.add(start); let cur = start, prev = -1; while (true) { const nexts = boundary.get(cur).filter((n) => n !== prev); if (!nexts.length) break; const nxt = nexts[0]; if (nxt === start) { loops.push(loop); break; } if (visited.has(nxt)) break; visited.add(nxt); loop.push(nxt); prev = cur; cur = nxt; } } return loops.map((loop) => loop.map((vi) => new THREE.Vector3().fromBufferAttribute(pos, vi))); } // 面高亮覆盖层(拾取反馈,可叠加多个面) function highlightFaceOverlay(meshIndex, face) { const geo = S.meshObjs[meshIndex].geometry; const g = new THREE.BufferGeometry(); const posAttr = geo.attributes.position; const idxAttr = geo.index; const end = Math.min(face.last, idxAttr.count / 3 - 1); const verts = []; const inds = []; const vmap = new Map(); for (let t = face.first; t <= end; t++) { for (const vi of [idxAttr.getX(t * 3), idxAttr.getX(t * 3 + 1), idxAttr.getX(t * 3 + 2)]) { if (!vmap.has(vi)) { vmap.set(vi, verts.length / 3); verts.push(posAttr.getX(vi), posAttr.getY(vi), posAttr.getZ(vi)); } inds.push(vmap.get(vi)); } } g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(verts), 3)); g.setIndex(new THREE.BufferAttribute(new Uint32Array(inds), 1)); const mat = new THREE.MeshBasicMaterial({ color: 0x2b9ae8, transparent: true, opacity: 0.35, side: THREE.DoubleSide, depthTest: false, }); const overlay = new THREE.Mesh(g, mat); overlay.renderOrder = 997; overlay.userData.isFaceOverlay = true; S.faceHighlights.push(overlay); S.modelGroup.add(overlay); } function clearFaceHighlight() { S.faceHighlights.forEach((o) => { o.geometry.dispose(); o.material.dispose(); S.modelGroup.remove(o); }); S.faceHighlights = []; } // 高斯消元(5x5 对称正定近似) function gaussSolve(M, b) { const n = b.length; const A = M.map((row, i) => row.concat(b[i])); for (let c = 0; c < n; c++) { let piv = c; for (let r = c + 1; r < n; r++) if (Math.abs(A[r][c]) > Math.abs(A[piv][c])) piv = r; if (Math.abs(A[piv][c]) < 1e-12) return null; [A[c], A[piv]] = [A[piv], A[c]]; for (let r = c + 1; r < n; r++) { const f = A[r][c] / A[c][c]; for (let k = c; k <= n; k++) A[r][k] -= f * A[c][k]; } } const x = new Array(n).fill(0); for (let r = n - 1; r >= 0; r--) { let s = A[r][n]; for (let k = r + 1; k < n; k++) s -= A[r][k] * x[k]; x[r] = s / A[r][r]; } return x; } // 网格体积(散度定理,实体需封闭) function meshVolume(meshObj) { const geo = meshObj.geometry; const pos = geo.attributes.position; const idx = geo.index; let v = 0, va = 0; const p0 = new THREE.Vector3(), p1 = new THREE.Vector3(), p2 = new THREE.Vector3(); for (let t = 0; t < idx.count; t += 3) { p0.fromBufferAttribute(pos, idx.getX(t)); p1.fromBufferAttribute(pos, idx.getX(t + 1)); p2.fromBufferAttribute(pos, idx.getX(t + 2)); const d = p0.dot(p1.clone().cross(p2)); v += d / 6; va += Math.abs(d) / 6; } return { volume: v, closure: va > 0 ? Math.abs(v) / va : 0 }; } // ---- 圆/椭圆叠加线 ---- function drawCircleOnPlane(center3, basis, r) { const pts = []; for (let k = 0; k <= 64; k++) { const t = (k / 64) * Math.PI * 2; pts.push(center3.clone().addScaledVector(basis[0], Math.cos(t) * r).addScaledVector(basis[1], Math.sin(t) * r)); } return makeLine(pts, 0xe67e22); } function drawEllipseOnPlane(center3, basis, a, b, theta) { const pts = []; for (let k = 0; k <= 64; k++) { const t = (k / 64) * Math.PI * 2; const x = Math.cos(t) * a, y = Math.sin(t) * b; const rx = x * Math.cos(theta) - y * Math.sin(theta); const ry = x * Math.sin(theta) + y * Math.cos(theta); pts.push(center3.clone().addScaledVector(basis[0], rx).addScaledVector(basis[1], ry)); } return makeLine(pts, 0xe67e22); } // ---- 长度模式临时线段 ---- function resetLengthTemp() { if (S.lengthTemp) disposeGroup(S.lengthTemp); S.lengthTemp = new THREE.Group(); S.modelGroup.add(S.lengthTemp); } // ---- 各测量模式 ---- function handleMeasurePointer(event) { const mode = MEASURE_MODES.find((m) => m.key === S.measureMode); if (!mode) return; if (S.measureMode === 'length') { const hit = pickPoint(event); if (!hit) return; const p = hit.point.clone(); S.measurePts.push(p); if (S.measurePts.length > 1) { const prev = S.measurePts[S.measurePts.length - 2]; S.lengthTemp.add(makeLine([prev, p])); const total = pathLength(S.measurePts); setStatus(`长度:已取 ${S.measurePts.length} 点,累计 ${total.toFixed(2)} mm(双击或按 Enter 结束)`); } else { setStatus('长度:继续取点(双击或按 Enter 结束)'); } return; } if (S.measureMode === 'radius' || S.measureMode === 'ellipse' || S.measureMode === 'area') { const hit = pickPoint(event); if (!hit) { setStatus('测量:请点击模型上的面'); return; } const meshIndex = hit.object.userData.index; const tf = hit.faceIndex != null ? triToFace(meshIndex, hit.faceIndex) : null; if (!tf) { setStatus('所选位置没有面数据'); return; } const fa = faceAnalysis(meshIndex, tf.face); const group = new THREE.Group(); group.add(makeMarker(hit.point)); clearFaceHighlight(); highlightFaceOverlay(meshIndex, tf.face); // 拾取反馈:高亮所选面 if (S.measureMode === 'area') { const label = makeLabel(`${fa.area.toFixed(2)} mm²`, fa.centroid); group.add(label); S.modelGroup.add(group); addResult('面积', `${fa.area.toFixed(2)} mm²`, group); } else if (S.measureMode === 'radius') { if (fa.planar) { // 平面:边界环检测 → 识别圆面与孔(最小内环即孔) const loops = faceBoundaryLoops(meshIndex, tf.face); const cands = []; for (const loop of loops) { if (loop.length < 8) continue; const lp = projectToPlane(loop, fa.normal, fa.centroid); const fit = circleFit2D(lp.pts); if (fit && fit.rms <= Math.max(fit.r * 0.03, S.modelRadius * 0.001)) { const c3 = fa.centroid.clone().addScaledVector(lp.basis[0], fit.cx).addScaledVector(lp.basis[1], fit.cy); cands.push({ fit, c3, basis: lp.basis }); } } if (!cands.length) { disposeGroup(group); setStatus('该平面没有可识别的圆边/孔'); return; } cands.sort((a, b) => a.fit.r - b.fit.r); const isHole = cands.length > 1; // 有外环+内环 → 最小环是孔 const c = cands[0]; group.add(drawCircleOnPlane(c.c3, c.basis, c.fit.r)); const labelText = isHole ? `孔 R ${c.fit.r.toFixed(2)} mm` : `R ${c.fit.r.toFixed(2)} mm`; group.add(makeLabel(labelText, c.c3)); S.modelGroup.add(group); addResult('半径', labelText, group); } else { // 曲面(圆柱面/孔壁):法向张量最小特征向量 = 轴线,垂直轴平面拟合圆 const { axis } = axisFromTriangleNormals(meshIndex, tf.face); const proj = projectToPlane(fa.verts, axis, fa.centroid); const fit = circleFit2D(proj.pts); if (!fit || fit.rms > Math.max(fit.r * 0.03, S.modelRadius * 0.001)) { disposeGroup(group); setStatus('该面不是圆柱/圆弧面,无法测量半径'); return; } const center3 = fa.centroid.clone().addScaledVector(proj.basis[0], fit.cx).addScaledVector(proj.basis[1], fit.cy); group.add(drawCircleOnPlane(center3, proj.basis, fit.r)); group.add(makeLabel(`R ${fit.r.toFixed(2)} mm`, center3)); S.modelGroup.add(group); addResult('半径', `R ${fit.r.toFixed(2)} mm`, group); } } else { // 椭圆:平面用边界环拟合,曲面用全部顶点拟合 let proj, loopFit = null; if (fa.planar) { const loops = faceBoundaryLoops(meshIndex, tf.face); const loop = loops.sort((a, b) => b.length - a.length)[0]; if (loop && loop.length >= 8) { proj = projectToPlane(loop, fa.normal, fa.centroid); loopFit = ellipseFit2D(proj.pts); } } else { proj = projectToPlane(fa.verts, fa.normal, fa.centroid); loopFit = ellipseFit2D(proj.pts); } const fit = loopFit; if (!fit || fit.rms > Math.max(fit.a * 0.03, S.modelRadius * 0.001)) { disposeGroup(group); setStatus('该面不是椭圆面,无法测量'); return; } const center3 = fa.centroid.clone().addScaledVector(proj.basis[0], fit.cx).addScaledVector(proj.basis[1], fit.cy); group.add(drawEllipseOnPlane(center3, proj.basis, fit.a, fit.b, fit.theta)); group.add(makeLabel(`椭圆 a ${fit.a.toFixed(2)} / b ${fit.b.toFixed(2)} mm`, center3)); S.modelGroup.add(group); addResult('椭圆', `长轴 ${fit.a.toFixed(2)} / 短轴 ${fit.b.toFixed(2)} mm`, group); } return; } if (S.measureMode === 'volume') { const hit = pickPoint(event); if (!hit) { setStatus('体积重量:请点击实体零件'); return; } const obj = hit.object; const v = meshVolume(obj); const volAbs = Math.abs(v.volume) / 1000; // cm³ const weight = volAbs * 7.85; // 钢 7.85 g/cm³ const group = new THREE.Group(); group.add(makeMarker(hit.point)); const closureNote = v.closure > 0.95 ? '' : '(面未封闭,结果仅供参考)'; const label = makeLabel(`V ${volAbs.toFixed(2)} cm³ | 重 ${weight.toFixed(2)} g${closureNote}`, hit.point); group.add(label); S.modelGroup.add(group); addResult('体积重量', `V ${volAbs.toFixed(2)} cm³ | ${weight.toFixed(2)} g(钢 7.85)${closureNote}`, group); return; } if (S.measureMode === 'bbox') { const hit = pickPoint(event); if (!hit) { setStatus('包围盒:请点击零件'); return; } const obj = hit.object; const bb = new THREE.Box3().setFromObject(obj); const size = bb.getSize(new THREE.Vector3()); const c = bb.getCenter(new THREE.Vector3()); if (S.partBboxHelper) disposeGroup(S.partBboxHelper); S.partBboxHelper = new THREE.Group(); const helper = new THREE.Box3Helper(bb, 0xe67e22); S.partBboxHelper.add(helper); S.partBboxHelper.add(makeLabel(`${size.x.toFixed(1)} × ${size.y.toFixed(1)} × ${size.z.toFixed(1)} mm`, c)); S.modelGroup.add(S.partBboxHelper); addResult('包围盒', `${size.x.toFixed(1)} × ${size.y.toFixed(1)} × ${size.z.toFixed(1)} mm`, S.partBboxHelper); return; } // 壁厚:两次点击取两个面,计算面间距 if (S.measureMode === 'thickness') { const hit = pickPoint(event); if (!hit) { S.thicknessFaces = []; if (S.thicknessMarkers) { disposeGroup(S.thicknessMarkers); S.thicknessMarkers = null; } clearFaceHighlight(); setStatus('壁厚:点击空白处已取消'); return; } const meshIndex = hit.object.userData.index; const tf = hit.faceIndex != null ? triToFace(meshIndex, hit.faceIndex) : null; if (!tf) { setStatus('所选位置没有面数据'); return; } const fa = faceAnalysis(meshIndex, tf.face); highlightFaceOverlay(meshIndex, tf.face); // 两个面都高亮 S.thicknessFaces.push({ fa, point: hit.point.clone() }); if (S.thicknessFaces.length === 1) { S.thicknessMarkers = new THREE.Group(); S.thicknessMarkers.add(makeMarker(hit.point)); S.modelGroup.add(S.thicknessMarkers); setStatus('壁厚:已取第一面(高亮),请点击对面的面'); return; } const [f1, f2] = S.thicknessFaces; S.thicknessFaces = []; const group = S.thicknessMarkers || new THREE.Group(); S.thicknessMarkers = null; const n1 = f1.fa.normal, n2 = f2.fa.normal; const c1 = f1.fa.centroid, c2 = f2.fa.centroid; let thickness; if (n1.dot(n2) < -0.7) { thickness = Math.abs(c2.clone().sub(c1).dot(n1)); // 平行对面:法向间距 } else { thickness = c1.distanceTo(c2); // 非平行:质心距离 } group.add(makeMarker(f2.point)); group.add(makeLine([c1, c2])); group.add(makeLabel(`${thickness.toFixed(2)} mm`, c1.clone().add(c2).multiplyScalar(0.5))); addResult('壁厚', `${thickness.toFixed(2)} mm`, group); return; } if (S.measureMode === 'coordinate') { const hit = pickPoint(event); if (!hit) return; const p = hit.point; const group = new THREE.Group(); group.add(makeMarker(p)); const text = `X ${p.x.toFixed(2)} Y ${p.y.toFixed(2)} Z ${p.z.toFixed(2)}`; group.add(makeLabel(text, p)); S.modelGroup.add(group); addResult('坐标', text, group); return; } // 需要多点的模式:distance / angle / linear const hit = pickPoint(event); if (!hit) { S.measurePts = []; setStatus(`${measureModeLabel(S.measureMode)}:点击空白处已取消,重新取点`); return; } S.measurePts.push(hit.point.clone()); const need = mode.clicks; if (S.measurePts.length < need) { const hints = { distance: '已取第一点,请点击第二点', angle: `已取 ${S.measurePts.length}/3 点(顶点→边1→边2)`, linear: '已取第一点,请点击第二点' }; setStatus(hints[S.measureMode] || `已取 ${S.measurePts.length}/${need} 点`); return; } const pts = S.measurePts; S.measurePts = []; const group = new THREE.Group(); pts.forEach((p) => group.add(makeMarker(p))); if (S.measureMode === 'distance') { group.add(makeLine(pts)); const d = pts[0].distanceTo(pts[1]); group.add(makeLabel(`${d.toFixed(2)} mm`, pts[0].clone().add(pts[1]).multiplyScalar(0.5))); S.modelGroup.add(group); addResult('距离', `${d.toFixed(2)} mm`, group); } else if (S.measureMode === 'angle') { const [A, B, C] = pts; group.add(makeLine([A, B])); group.add(makeLine([A, C])); const v1 = B.clone().sub(A).normalize(); const v2 = C.clone().sub(A).normalize(); const ang = (Math.acos(THREE.MathUtils.clamp(v1.dot(v2), -1, 1)) * 180) / Math.PI; group.add(makeLabel(`${ang.toFixed(2)}°`, A)); S.modelGroup.add(group); addResult('角度', `${ang.toFixed(2)}°`, group); } else if (S.measureMode === 'linear') { // 工程图风格尺寸:主尺寸线 + 两端延伸线 + 箭头 const [p1, p2] = pts; const mid = p1.clone().add(p2).multiplyScalar(0.5); const d = p1.distanceTo(p2); group.add(makeLine([p1, p2], 0x2b9ae8)); const ext = Math.max(S.modelRadius * 0.03, 3); const dir = p2.clone().sub(p1); if (dir.lengthSq() < 1e-12) { dir.set(1, 0, 0); } const up = new THREE.Vector3(); const ref = Math.abs(dir.normalize().z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); up.crossVectors(dir, ref).normalize(); group.add(makeLine([p1.clone().addScaledVector(up, ext), p1.clone().addScaledVector(up, -ext)])); group.add(makeLine([p2.clone().addScaledVector(up, ext), p2.clone().addScaledVector(up, -ext)])); group.add(makeLabel(`${d.toFixed(2)} mm`, mid.clone().addScaledVector(up, ext * 1.6))); S.modelGroup.add(group); addResult('线性测量', `${d.toFixed(2)} mm`, group); } } function pathLength(pts) { let sum = 0; for (let i = 1; i < pts.length; i++) sum += pts[i].distanceTo(pts[i - 1]); return sum; } function finishLengthMode() { if (S.measurePts.length >= 2) { const total = pathLength(S.measurePts); const group = S.lengthTemp; group.add(makeLabel(`总长 ${total.toFixed(2)} mm`, S.measurePts[S.measurePts.length - 1])); S.lengthTemp = null; addResult('长度', `${total.toFixed(2)} mm`, group); setStatus(`长度:${total.toFixed(2)} mm`); } else { resetLengthTemp(); setStatus('长度:已取消'); } S.measurePts = []; } // 测量子面板与统计/清空按钮(放在 MEASURE_MODES 定义之后初始化) buildMeasurePanel(); $('btnStats').addEventListener('click', () => { $('statsPanel').classList.toggle('hidden'); renderStats(); }); $('btnMeasureClear').addEventListener('click', () => { clearMeasurements(); setStatus('测量结果已清空'); }); $('btnMeasureClose').addEventListener('click', () => { setTool('select'); setStatus('已关闭测量 | 左键/中键旋转 · Shift+中键/右键平移 · 滚轮缩放'); }); /* ================= 拖动 ================= */ function updateDragControlsObjects() { // DragControls 内部持有传入的数组引用,原地更新即可 const objs = dragControls.getObjects(); objs.length = 0; S.meshObjs.forEach((m) => objs.push(m)); } dragControls.addEventListener('dragstart', (e) => { const grab = e.object; const idx = grab.userData.index; const entry = S.meshEntryMap[idx] || null; // 精确命中零件行 const members = []; if (S.tool === 'singalDrag') { members.push(grab); } else { const ids = []; if (entry) collectSolidIds(entry, ids); if (ids.length === 0) ids.push(idx); ids.forEach((i) => S.meshObjs[i] && members.push(S.meshObjs[i])); } S.dragMembers = members; S.dragLastPos.copy(grab.position); controls.enabled = false; renderer.domElement.style.cursor = 'grabbing'; }); dragControls.addEventListener('drag', (e) => { if (!S.dragMembers) return; const delta = e.object.position.clone().sub(S.dragLastPos); S.dragMembers.forEach((m) => { if (m !== e.object) m.position.add(delta); }); S.dragLastPos.copy(e.object.position); }); dragControls.addEventListener('dragend', () => { if (S.dragMembers) { const amount = S.modelRadius * 0.6 * (S.explodeAmount / 100); S.dragMembers.forEach((m) => { const idx = m.userData.index; const meta = S.meshMeta[idx]; // 拖动位移 = 当前位置 - 爆炸偏移 meta.dragOffset.copy(m.position).addScaledVector(meta.dir, -amount); }); } S.dragMembers = null; controls.enabled = !dragControls.enabled; renderer.domElement.style.cursor = 'grab'; }); /* ================= 点击选择 / 测量 ================= */ // 记录按下位置,区分「点击」与「旋转拖动」 let downPos = null; renderer.domElement.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; downPos = { x: event.clientX, y: event.clientY }; if (S.tool === 'measure') { handleMeasurePointer(event); return; } // 拖动模式下点击空白处 → 自动回到选择模式(恢复旋转/点选) if (S.tool === 'drag' || S.tool === 'singalDrag') { const hit = pickPoint(event); if (!hit) { setTool('select'); setStatus('已回到选择模式 | 左键/中键旋转 · Shift+中键/右键平移 · 滚轮缩放'); } } }); renderer.domElement.addEventListener('dblclick', (event) => { if (S.tool === 'measure' && S.measureMode === 'length') finishLengthMode(); }); renderer.domElement.addEventListener('pointerup', (event) => { if (event.button !== 0) return; const moved = downPos && Math.hypot(event.clientX - downPos.x, event.clientY - downPos.y); downPos = null; if (S.tool !== 'select') return; if (moved > 5) return; // 是旋转/平移操作,不触发选择 const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1 ); raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(S.meshObjs, false).filter((h) => h.object.visible); if (hits.length === 0) { clearHighlight(); return; } const idx = hits[0].object.userData.index; const entry = S.meshEntryMap[idx]; // 精确命中零件行,而非父级 if (entry) selectNode(entry); }); /* ================= 零件右键菜单 ================= */ let ctxTarget = null; function showCtxMenu(x, y) { const menu = $('ctxMenu'); $('ctxMenuTitle').textContent = ctxTarget.node.name || '零件'; menu.classList.remove('hidden'); // 防止溢出屏幕 const r = menu.getBoundingClientRect(); menu.style.left = Math.min(x, window.innerWidth - r.width - 8) + 'px'; menu.style.top = Math.min(y, window.innerHeight - r.height - 8) + 'px'; } function hideCtxMenu() { $('ctxMenu').classList.add('hidden'); ctxTarget = null; } function toggleTransparentEntry(entry) { const ids = []; collectSolidIds(entry, ids); if (!ids.length) return; const first = S.meshObjs[ids[0]]; if (!first) return; const toTransparent = !first.material.transparent; ids.forEach((i) => { const m = S.meshObjs[i]; if (m) { m.material.transparent = toTransparent; m.material.opacity = toTransparent ? 0.25 : 1; m.material.depthWrite = !toTransparent; } }); return toTransparent; } renderer.domElement.addEventListener('contextmenu', (e) => { e.preventDefault(); const hit = pickPoint(e); if (!hit) { hideCtxMenu(); return; } const entry = S.meshEntryMap[hit.object.userData.index]; if (!entry) { hideCtxMenu(); return; } ctxTarget = entry; selectNode(entry); showCtxMenu(e.clientX, e.clientY); }); document.querySelectorAll('#ctxMenu .ctx-item').forEach((item) => { item.addEventListener('click', () => { const entry = ctxTarget; const act = item.dataset.act; hideCtxMenu(); if (!entry) return; if (act === 'hide') { setNodeVisible(entry, false); setStatus(`已隐藏:${entry.node.name}`); } else if (act === 'isolate') { S.meshObjs.forEach((m) => { m.visible = false; }); setNodeVisible(entry, true); refreshTreeChecks(); setStatus(`仅显示:${entry.node.name}(点「显示」按钮恢复全部)`); } else if (act === 'showAll') { S.meshObjs.forEach((m) => { m.visible = true; }); refreshTreeChecks(); setStatus('已全部显示'); } else if (act === 'color') { $('colorPanel').classList.remove('hidden'); $('colorPicker').focus(); setStatus(`已选中「${entry.node.name}」,选色后点「应用到选中」`); } else if (act === 'transparent') { const on = toggleTransparentEntry(entry); setStatus(on ? '已透明显示,再次操作恢复' : '已恢复不透明'); } else if (act === 'drag') { setTool('drag'); } else if (act === 'reset') { const ids = []; collectSolidIds(entry, ids); ids.forEach((i) => { if (S.meshMeta[i]) S.meshMeta[i].dragOffset.set(0, 0, 0); }); applyExplode(); setStatus(`「${entry.node.name}」已复位`); } }); }); // 点击空白处 / Esc 关闭菜单 document.addEventListener('click', (e) => { if (!$('ctxMenu').contains(e.target)) hideCtxMenu(); }); window.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideCtxMenu(); }); window.addEventListener('keydown', (e) => { if (e.key === 'Escape') { S.measurePts = []; S.thicknessFaces = []; if (S.thicknessMarkers) { disposeGroup(S.thicknessMarkers); S.thicknessMarkers = null; } resetLengthTemp(); if (S.tool !== 'select') setTool('select'); else clearHighlight(); } if (e.key === 'Enter' && S.tool === 'measure' && S.measureMode === 'length') finishLengthMode(); }); /* ================= 上色 ================= */ const PALETTE = [ '#2b9ae8', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#3498db', '#e91e63', '#00bcd4', '#8bc34a', '#ff9800', '#607d8b', '#ffc107', '#795548', ]; function applyColorOverrides() { S.meshObjs.forEach((m) => { const i = m.userData.index; const ov = S.colorOverrides[i]; if (ov) { m.material.vertexColors = false; m.material.color.set(ov); } else { m.material.vertexColors = !!m.geometry.getAttribute('color'); m.material.color.copy(m.userData.baseColor); } }); } function colorNode(entry, hex) { const ids = []; collectSolidIds(entry, ids); ids.forEach((i) => { S.colorOverrides[i] = hex; }); applyColorOverrides(); } function colorAllParts() { // 一键上色:按树零件行依次取调色板颜色 const partRows = S.treeNodes.filter((t) => t.solidIds.length > 0 && t.node.meshes && t.depth > 0); partRows.forEach((entry, k) => { const ids = []; collectSolidIds(entry, ids); const c = PALETTE[k % PALETTE.length]; ids.forEach((i) => { S.colorOverrides[i] = c; }); }); applyColorOverrides(); setStatus('已一键上色(按零件分配颜色)'); } $('btnColorApply').addEventListener('click', () => { if (!S.selected) { setStatus('请先在模型树中选择零件'); return; } colorNode(S.selected, $('colorPicker').value); setStatus(`已上色:${S.selected.node.name || S.selected.row.querySelector('.tree-label').textContent}`); }); $('btnColorOneClick').addEventListener('click', () => { if (!S.meshObjs.length) { setStatus('请先加载模型'); return; } colorAllParts(); }); $('btnColorReset').addEventListener('click', () => { S.colorOverrides = {}; applyColorOverrides(); setStatus('已恢复原始颜色'); }); // 上色面板开关 $('btnColorPanel').addEventListener('click', () => { $('colorPanel').classList.toggle('hidden'); }); // 结构树显隐:桌面默认显示、点击隐藏/显示;手机端是抽屉(默认隐藏,点按钮弹出) $('btnTree').addEventListener('click', (e) => { e.stopPropagation(); // 别让「点击外部收起抽屉」把这次打开又关掉 const p = $('treePanel'); if (window.innerWidth <= 768) { const open = document.body.classList.toggle('tree-open'); $('btnTree').classList.toggle('active', !open); setStatus(open ? '结构树已显示' : '结构树已隐藏'); } else { const hidden = p.classList.toggle('hidden'); $('viewport').style.left = hidden ? '0px' : '280px'; $('btnTree').classList.toggle('active', hidden); setStatus(hidden ? '结构树已隐藏' : '结构树已显示'); } requestAnimationFrame(resize); }); // 视口切回桌面宽度时清掉手机上可能残留的内联 left window.addEventListener('resize', () => { if (window.innerWidth <= 768) $('viewport').style.left = ''; }); // 手机抽屉:✕ 按钮关闭 + 点击抽屉外部自动收起 if ($('btnTreeClose')) $('btnTreeClose').addEventListener('click', () => document.body.classList.remove('tree-open')); document.addEventListener('click', (e) => { if (window.innerWidth > 768 || !document.body.classList.contains('tree-open')) return; if (!$('treePanel').contains(e.target)) document.body.classList.remove('tree-open'); }); /* ================= 其他工具按钮 ================= */ $('btnWireframe').classList.add('active'); // 线框默认开启 $('btnWireframe').addEventListener('click', () => { S.wireframeOn = !S.wireframeOn; S.edgeLines.forEach((l) => { l.visible = S.wireframeOn; }); $('btnWireframe').classList.toggle('active', S.wireframeOn); setStatus(S.wireframeOn ? '已显示轮廓线框' : '已隐藏轮廓线框'); }); $('btnShowAll').addEventListener('click', () => { S.meshObjs.forEach((m) => { m.visible = true; }); refreshTreeChecks(); setStatus('已全部显示'); }); $('btnTransparent').addEventListener('click', () => { if (!S.selected) { setStatus('请先选择要变透明的零件'); return; } const ids = []; collectSolidIds(S.selected, ids); const first = S.meshObjs[ids[0]]; if (!first) return; const toTransparent = !first.material.transparent; ids.forEach((i) => { const m = S.meshObjs[i]; if (m) { m.material.transparent = toTransparent; m.material.opacity = toTransparent ? 0.25 : 1; m.material.depthWrite = !toTransparent; } }); setStatus(toTransparent ? '已透明显示,再次点击恢复' : '已恢复不透明'); }); $('btnBbox').addEventListener('click', () => { if (S.bboxHelper) { scene.remove(S.bboxHelper); S.bboxHelper.geometry.dispose(); S.bboxHelper = null; setStatus('已关闭包围盒'); return; } S.bboxHelper = new THREE.Box3Helper(S.bbox, 0x2b9ae8); scene.add(S.bboxHelper); setStatus('已显示整体包围盒'); }); $('btnClear').addEventListener('click', () => { clearMeasurements(); setStatus('测量已清除'); }); $('btnSingleReset').addEventListener('click', () => { const targets = S.selected ? (() => { const ids = []; collectSolidIds(S.selected, ids); return ids; })() : null; const ids = targets && targets.length ? targets : S.meshMeta.map((_, i) => i); ids.forEach((i) => { if (S.meshMeta[i]) S.meshMeta[i].dragOffset.set(0, 0, 0); }); applyExplode(); setStatus(S.selected ? `「${S.selected.node.name}」已复位` : '所有拖动已复位'); }); $('btnAllReset').addEventListener('click', () => { S.meshMeta.forEach((m) => m.dragOffset.set(0, 0, 0)); S.explodeAmount = 0; dom.explodeSlider.value = 0; dom.explodeVal.textContent = '0%'; applyExplode(); setSectionOn(false); S.sectionOffset = 0; dom.sectionSlider.value = 0; updateSectionVal(); updateSectionPlane(); clearMeasurements(); clearHighlight(); S.meshObjs.forEach((m) => { m.visible = true; m.material.transparent = false; m.material.opacity = 1; m.material.depthWrite = true; }); refreshTreeChecks(); setTool('select'); fitCamera(); setStatus('已全部复位'); }); $('btnVport').addEventListener('click', () => { const views = [ { name: '等轴测', dir: new THREE.Vector3(1, 0.6, 1) }, { name: '前视', dir: new THREE.Vector3(0, 0, 1) }, { name: '后视', dir: new THREE.Vector3(0, 0, -1) }, { name: '左视', dir: new THREE.Vector3(-1, 0, 0) }, { name: '右视', dir: new THREE.Vector3(1, 0, 0) }, { name: '俯视', dir: new THREE.Vector3(0, 1, 0) }, { name: '仰视', dir: new THREE.Vector3(0, -1, 0) }, ]; const v = views[S.viewIndex % views.length]; S.viewIndex++; camera.position.copy(S.modelCenter).addScaledVector(v.dir.normalize(), S.modelRadius * 2.4); controls.target.copy(S.modelCenter); controls.update(); setStatus('视角:' + v.name); }); $('btnAxes').addEventListener('click', () => { if (S.axesHelper) { scene.remove(S.axesHelper); S.axesHelper.geometry.dispose(); S.axesHelper = null; setStatus('已关闭坐标系'); return; } S.axesHelper = new THREE.AxesHelper(S.modelRadius * 0.5); scene.add(S.axesHelper); setStatus('已显示坐标系'); }); function toggleFullscreen() { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); } $('btnFullscreen').addEventListener('click', toggleFullscreen); // 底部工具栏也放一个:嵌入模式下顶栏是隐藏的,那里点不到 $('btnFull').addEventListener('click', toggleFullscreen); /* ================= 面板关闭按钮 ================= */ document.querySelectorAll('.panel-close').forEach((b) => b.addEventListener('click', () => { const p = b.dataset.panel; if (p === 'stats') $('statsPanel').classList.add('hidden'); else if (p === 'color') $('colorPanel').classList.add('hidden'); else if (p === 'embed') $('embedPanel').classList.add('hidden'); else setTool('select'); })); /* ================= 文件打开 / 拖拽 ================= */ $('btnOpen').addEventListener('click', () => dom.fileInput.click()); dom.fileInput.addEventListener('change', () => { if (dom.fileInput.files.length) loadFile(dom.fileInput.files[0]); dom.fileInput.value = ''; }); // 统一入口(../index.html)在 iframe 里把用户选的 STEP 文件传进来 window.addEventListener('message', (e) => { const data = e.data; if (!data || data.type !== 'viewer-file' || data.kind !== 'step') return; try { if (e.origin !== location.origin) return; } catch { return; } const f = new File([data.buffer], data.name || 'model.step'); loadFile(f); }); try { window.parent.postMessage({ type: 'viewer-ready' }, '*'); } catch { /* 没嵌在入口里 */ } let dragDepth = 0; window.addEventListener('dragenter', (e) => { e.preventDefault(); if (e.dataTransfer.types.includes('Files')) { dragDepth++; dom.dropOverlay.classList.remove('hidden'); } }); window.addEventListener('dragleave', (e) => { e.preventDefault(); if (--dragDepth <= 0) { dragDepth = 0; dom.dropOverlay.classList.add('hidden'); } }); window.addEventListener('dragover', (e) => e.preventDefault()); window.addEventListener('drop', (e) => { e.preventDefault(); dragDepth = 0; dom.dropOverlay.classList.add('hidden'); const f = e.dataTransfer.files && e.dataTransfer.files[0]; if (f) loadFile(f); }); setStatus('就绪:点击「打开模型」或把 .step/.stp 文件拖入页面 | 左键/中键旋转 · Shift+中键/右键平移 · 滚轮缩放'); /* ================= URL 参数自动加载(iframe 嵌入用) ================= * 用法:index.html?file=samples/xxx.stp (同源相对路径) * index.html?url=https://.../x.stp (完整地址,跨域需对方服务器允许 CORS) * index.html?embed=1 (嵌入模式:隐藏顶栏、强制高精度) * index.html?embed=1&menu=min (嵌入+精简菜单:只留 剖切/线框/透明/视角/测量, 欧霓博目录专用) */ const EMBED_MODE = new URLSearchParams(location.search).get('embed') === '1'; if (EMBED_MODE) document.body.classList.add('embed'); const MIN_MENU = new URLSearchParams(location.search).get('menu') === 'min'; if (MIN_MENU) document.body.classList.add('menu-min'); // API 端点自动探测:Python 版(/name)或 PHP 版(./api/name.php),404 时尝试下一个。 // name 可带查询串(如 "token.php?f=xxx")——.php 必须拼在 ? 之前,否则 URL 变成 // "api/token?f=xxx.php",PHP 服务器上永远 404,令牌流程整体失效。 async function apiRequest(name, opts) { const qi = name.indexOf('?'); const base = (qi < 0 ? name : name.slice(0, qi)).replace(/\.php$/i, ''); const query = qi < 0 ? '' : name.slice(qi); // 候选顺序:先看相对本页的 api/ 目录(合并入口下也正确), // 再试站点根(旧部署习惯),最后是相对于当前路径的 api/ const candidates = [`./api/${base}.php${query}`, `api/${base}.php${query}`, `/${name}`]; let lastErr = null; for (const url of candidates) { try { const resp = await fetch(url, opts); if (resp.status === 404) { lastErr = new Error('HTTP 404'); continue; } return resp; } catch (e) { lastErr = e; } } throw lastErr || new Error('no api endpoint'); } // 受保护模型(.stp/.step/.enc)走令牌通道;服务器禁止直接下载 async function fetchModelBlob(path) { if (/^https?:\/\//i.test(path)) { const resp = await fetch(path); if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.blob(); } // 站内路径优先走令牌接口;接口没部署(本地 python 服务器、或纯静态托管) // 就退回直接 fetch。判断依据是响应内容而不是状态码:python 服务器会把 // token.php 源码当静态文件 200 发回来,只看状态码会误判。 let tok = null; try { const tokResp = await apiRequest('token.php?f=' + encodeURIComponent(path)); if (tokResp.ok && /application\/json/i.test(tokResp.headers.get('content-type') || '')) { tok = await tokResp.json(); } } catch (e) { /* 接口不存在 */ } if (tok && tok.token) { const resp = await apiRequest(`model.php?f=${encodeURIComponent(path)}&t=${tok.token}&e=${tok.expires}`); if (!resp.ok) throw new Error('模型获取失败 HTTP ' + resp.status); return resp.blob(); } const resp = await fetch(path); if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.blob(); } async function loadFromUrlParams() { const q = new URLSearchParams(location.search); const file = q.get('file'); const url = q.get('url'); if (!file && !url) return; try { showLoading('正在下载模型文件...'); const blob = await fetchModelBlob(url || file); hideLoading(); const name = decodeURIComponent((url || file).split('/').pop()).replace(/\.enc$/i, '') || 'model.step'; const source = url ? { type: 'url', value: url } : { type: 'file', value: file }; await loadFile(new File([blob], name), source); } catch (e) { hideLoading(); setStatus('自动加载失败:' + e.message); } } loadFromUrlParams(); // 调试钩子(供自动化测试使用,不影响正常功能) window.__S = S; /* ================= 上传当前模型(嵌入面板按钮,与 2D 查看器同流程) ================= */ async function uploadCurrentModel() { const file = S.currentFile; if (!file) throw new Error('还没有打开本地模型'); const headers = { 'X-Filename': encodeURIComponent(file.name), 'Content-Type': 'application/octet-stream' }; let body = await file.arrayBuffer(); // STEP 是纯文本,gzip 可压到约 1/6;上传前先压缩,节省 80%+ 带宽 if (typeof CompressionStream !== 'undefined') { try { const stream = file.stream().pipeThrough(new CompressionStream('gzip')); body = await new Response(stream).arrayBuffer(); headers['X-Encoding'] = 'gzip'; } catch (e) { console.warn('gzip 压缩失败,改传原始文件:', e); } } const resp = await apiRequest('upload.php', { method: 'POST', headers, body }); const j = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(j.error || ('HTTP ' + resp.status)); return j; } $('btnUploadModel').addEventListener('click', async () => { const btn = $('btnUploadModel'); btn.disabled = true; btn.textContent = '上传中...'; try { const j = await uploadCurrentModel(); if (!j || !j.path) throw new Error('上传接口返回异常(接口未部署)'); S.modelSource = { type: 'file', value: j.path }; $('embedPath').value = j.path; $('embedHint').textContent = ''; refreshEmbed(); setStatus(`已加密上传:${j.path}`); // 嵌在入口页里时通知它刷新首页数模库 try { window.parent.postMessage({ type: 'library-updated' }, '*') } catch (e) { /* 独立打开 */ } } catch (e) { $('embedHint').textContent = `上传失败:${e.message}(本地开发服务器没有 PHP,需要在正式站点上试)`; setStatus('上传失败:' + e.message); } finally { btn.disabled = false; btn.textContent = '上传当前模型'; } }); /* ================= 嵌入链接生成 ================= */ function viewerBaseUrl() { return location.origin + location.pathname; // 当前 index.html 的完整地址 } function refreshEmbed() { const v = $('embedPath').value.trim(); const base = viewerBaseUrl(); if (!v) { $('embedUrl').value = ''; $('embedCode').value = ''; return; } const link = (/^https?:\/\//i.test(v) ? `${base}?url=${encodeURIComponent(v)}` : `${base}?file=${encodeURIComponent(v)}`) + '&embed=1'; $('embedUrl').value = link; $('embedCode').value = ``; } $('btnEmbed').addEventListener('click', () => { const src = S.modelSource; if (src.type === 'file') { $('embedPath').value = src.value; $('embedHint').textContent = ''; } else if (src.type === 'url') { $('embedPath').value = src.value; $('embedHint').textContent = '跨域加载需对方服务器允许 CORS,同源部署最稳定。'; } else if (src.type === 'local') { $('embedPath').value = ''; $('embedHint').textContent = '当前模型是本地文件:点「上传当前模型」加密上传到服务器后自动生成链接;也可以手动填服务器上的路径(如 samples/我的模型.stp)。'; } else { $('embedPath').value = ''; $('embedHint').textContent = '还未加载模型。也可以手动填入模型路径(相对路径或 http(s) 完整地址)。'; } $('embedPanel').classList.toggle('hidden'); refreshEmbed(); }); $('embedPath').addEventListener('input', refreshEmbed); async function copyText(text, label) { try { await navigator.clipboard.writeText(text); setStatus(label + '已复制到剪贴板'); } catch (e) { // 剪贴板不可用(非安全上下文)时选中文本供手动复制 setStatus('复制失败,请手动选择文本框内容复制'); } } $('btnCopyUrl').addEventListener('click', () => copyText($('embedUrl').value, '嵌入链接')); $('btnCopyCode').addEventListener('click', () => copyText($('embedCode').value, 'iframe 代码'));