/**
* 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}`;
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 (嵌入模式:隐藏顶栏、强制高精度)
*/
const EMBED_MODE = new URLSearchParams(location.search).get('embed') === '1';
if (EMBED_MODE) document.body.classList.add('embed');
// 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 代码'));