2251 lines
84 KiB
JavaScript
2251 lines
84 KiB
JavaScript
/**
|
||
* 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.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 = '<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 * 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 = '<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 (嵌入模式:隐藏顶栏、强制高精度)
|
||
* index.html?embed=1&menu=min (嵌入+精简菜单:只留 剖切/线框/透明/视角/测量, 欧霓博目录专用)
|
||
*/
|
||
const EMBED_MODE = new URLSearchParams(location.search).get('embed') === '1';
|
||
if (EMBED_MODE) document.body.classList.add('embed');
|
||
const MIN_MENU = new URLSearchParams(location.search).get('menu') === 'min';
|
||
if (MIN_MENU) document.body.classList.add('menu-min');
|
||
|
||
// API 端点自动探测:Python 版(/name)或 PHP 版(./api/name.php),404 时尝试下一个。
|
||
// name 可带查询串(如 "token.php?f=xxx")——.php 必须拼在 ? 之前,否则 URL 变成
|
||
// "api/token?f=xxx.php",PHP 服务器上永远 404,令牌流程整体失效。
|
||
async function apiRequest(name, opts) {
|
||
const qi = name.indexOf('?');
|
||
const base = (qi < 0 ? name : name.slice(0, qi)).replace(/\.php$/i, '');
|
||
const query = qi < 0 ? '' : name.slice(qi);
|
||
// 候选顺序:先看相对本页的 api/ 目录(合并入口下也正确),
|
||
// 再试站点根(旧部署习惯),最后是相对于当前路径的 api/
|
||
const candidates = [`./api/${base}.php${query}`, `api/${base}.php${query}`, `/${name}`];
|
||
let lastErr = null;
|
||
for (const url of candidates) {
|
||
try {
|
||
const resp = await fetch(url, opts);
|
||
if (resp.status === 404) { lastErr = new Error('HTTP 404'); continue; }
|
||
return resp;
|
||
} catch (e) { lastErr = e; }
|
||
}
|
||
throw lastErr || new Error('no api endpoint');
|
||
}
|
||
|
||
// 受保护模型(.stp/.step/.enc)走令牌通道;服务器禁止直接下载
|
||
async function fetchModelBlob(path) {
|
||
if (/^https?:\/\//i.test(path)) {
|
||
const resp = await fetch(path);
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
return resp.blob();
|
||
}
|
||
// 站内路径优先走令牌接口;接口没部署(本地 python 服务器、或纯静态托管)
|
||
// 就退回直接 fetch。判断依据是响应内容而不是状态码:python 服务器会把
|
||
// token.php 源码当静态文件 200 发回来,只看状态码会误判。
|
||
let tok = null;
|
||
try {
|
||
const tokResp = await apiRequest('token.php?f=' + encodeURIComponent(path));
|
||
if (tokResp.ok && /application\/json/i.test(tokResp.headers.get('content-type') || '')) {
|
||
tok = await tokResp.json();
|
||
}
|
||
} catch (e) { /* 接口不存在 */ }
|
||
if (tok && tok.token) {
|
||
const resp = await apiRequest(`model.php?f=${encodeURIComponent(path)}&t=${tok.token}&e=${tok.expires}`);
|
||
if (!resp.ok) throw new Error('模型获取失败 HTTP ' + resp.status);
|
||
return resp.blob();
|
||
}
|
||
const resp = await fetch(path);
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
return resp.blob();
|
||
}
|
||
|
||
async function loadFromUrlParams() {
|
||
const q = new URLSearchParams(location.search);
|
||
const file = q.get('file');
|
||
const url = q.get('url');
|
||
if (!file && !url) return;
|
||
try {
|
||
showLoading('正在下载模型文件...');
|
||
const blob = await fetchModelBlob(url || file);
|
||
hideLoading();
|
||
const name = decodeURIComponent((url || file).split('/').pop()).replace(/\.enc$/i, '') || 'model.step';
|
||
const source = url ? { type: 'url', value: url } : { type: 'file', value: file };
|
||
await loadFile(new File([blob], name), source);
|
||
} catch (e) {
|
||
hideLoading();
|
||
setStatus('自动加载失败:' + e.message);
|
||
}
|
||
}
|
||
loadFromUrlParams();
|
||
|
||
// 调试钩子(供自动化测试使用,不影响正常功能)
|
||
window.__S = S;
|
||
|
||
/* ================= 上传当前模型(嵌入面板按钮,与 2D 查看器同流程) ================= */
|
||
async function uploadCurrentModel() {
|
||
const file = S.currentFile;
|
||
if (!file) throw new Error('还没有打开本地模型');
|
||
const headers = { 'X-Filename': encodeURIComponent(file.name), 'Content-Type': 'application/octet-stream' };
|
||
let body = await file.arrayBuffer();
|
||
// STEP 是纯文本,gzip 可压到约 1/6;上传前先压缩,节省 80%+ 带宽
|
||
if (typeof CompressionStream !== 'undefined') {
|
||
try {
|
||
const stream = file.stream().pipeThrough(new CompressionStream('gzip'));
|
||
body = await new Response(stream).arrayBuffer();
|
||
headers['X-Encoding'] = 'gzip';
|
||
} catch (e) { console.warn('gzip 压缩失败,改传原始文件:', e); }
|
||
}
|
||
const resp = await apiRequest('upload.php', { method: 'POST', headers, body });
|
||
const j = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) throw new Error(j.error || ('HTTP ' + resp.status));
|
||
return j;
|
||
}
|
||
|
||
$('btnUploadModel').addEventListener('click', async () => {
|
||
const btn = $('btnUploadModel');
|
||
btn.disabled = true;
|
||
btn.textContent = '上传中...';
|
||
try {
|
||
const j = await uploadCurrentModel();
|
||
if (!j || !j.path) throw new Error('上传接口返回异常(接口未部署)');
|
||
S.modelSource = { type: 'file', value: j.path };
|
||
$('embedPath').value = j.path;
|
||
$('embedHint').textContent = '';
|
||
refreshEmbed();
|
||
setStatus(`已加密上传:${j.path}`);
|
||
// 嵌在入口页里时通知它刷新首页数模库
|
||
try { window.parent.postMessage({ type: 'library-updated' }, '*') } catch (e) { /* 独立打开 */ }
|
||
} catch (e) {
|
||
$('embedHint').textContent = `上传失败:${e.message}(本地开发服务器没有 PHP,需要在正式站点上试)`;
|
||
setStatus('上传失败:' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '上传当前模型';
|
||
}
|
||
});
|
||
|
||
/* ================= 嵌入链接生成 ================= */
|
||
function viewerBaseUrl() {
|
||
return location.origin + location.pathname; // 当前 index.html 的完整地址
|
||
}
|
||
|
||
function refreshEmbed() {
|
||
const v = $('embedPath').value.trim();
|
||
const base = viewerBaseUrl();
|
||
if (!v) {
|
||
$('embedUrl').value = '';
|
||
$('embedCode').value = '';
|
||
return;
|
||
}
|
||
const link = (/^https?:\/\//i.test(v)
|
||
? `${base}?url=${encodeURIComponent(v)}`
|
||
: `${base}?file=${encodeURIComponent(v)}`) + '&embed=1';
|
||
$('embedUrl').value = link;
|
||
$('embedCode').value = `<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 代码'));
|