Files
Leantime/dev/STEPViewer/app.js

2248 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = '<div class="tree-empty">加载模型后显示零件树</div>';
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.23attributes.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 = '<div class="tree-empty">无模型结构</div>'; 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 = `<img src="${ICONS.open}" alt="">`;
expander.addEventListener('click', (ev) => {
ev.stopPropagation();
const collapsed = box.classList.toggle('hidden');
expander.innerHTML = `<img src="${collapsed ? ICONS.close : ICONS.open}" alt="">`;
});
}
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 = `<img src="./icons/${m.icon}" alt="${m.label}"><span>${m.label}</span>`;
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 * offsetplane.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 = '<div class="stats-empty">暂无测量结果</div>';
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.php404 时尝试下一个。
// 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 = `<iframe src="${link}" width="100%" height="600" allowFullScreen></iframe>`;
}
$('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 代码'));