feat: 新增螺丝网格生成与强度校核功能,重建模型数据与查看器,补充定位图与BOM文档

新增 screw_mesh.py 螺丝网格划分模块与 strength_check.py 强度校核逻辑,arm_model.py 同步集成网格生成与校核流程;viewer_tpl.html、web/index.html 与 web/机器人手臂3D查看器.html 更新查看器模板以支持新数据展示;docs/bom.json 与 docs/travel_detail.json 调整 BOM 清单与行程参数;docs/print_settings.md 新增打印设置说明,新增螺丝采购清单 HTML;补充大量定位图与验证截图。
This commit is contained in:
cbb
2026-09-28 03:33:27 +08:00
parent 5518d12abb
commit 79c1265202
41 changed files with 1265 additions and 182 deletions
+165 -18
View File
@@ -91,6 +91,10 @@ small{color:var(--dim)}
.dk-note{color:var(--dim);font-size:11px;margin:1px 0 6px}
#screws td{white-space:normal}
#screws td:first-child{white-space:nowrap}
#screws tbody tr{cursor:pointer;transition:background .12s}
#screws tbody tr:hover{background:rgba(76,154,255,.14)}
#screws tbody tr.scrHl{background:#1d3f74;box-shadow:inset 3px 0 0 var(--warn)}
.hudlink{pointer-events:auto;cursor:pointer;color:var(--ac);text-decoration:underline;margin-left:8px}
@media (max-width:760px){#bomTab{top:auto;bottom:36px}#bomDock{top:46px;bottom:26px}}
</style>
</head>
@@ -111,7 +115,9 @@ small{color:var(--dim)}
<option value="wrist">腕部特写</option><option value="joint">关节特写</option><option value="elbow">肘部特写</option><option value="hand">手部特写</option>
</select>
<button id="bWire">线框</button>
<button id="bXRay" class="on">电机半透</button>
<button id="bXRay">电机半透</button>
<button id="bScr" class="on" title="显示/隐藏全部螺钉(S01~S10);点右侧 BOM 螺丝行可在模型中高亮定位">螺钉</button>
<button id="bEnv" title="显示/隐藏可达外包络(运动学边界)">可达包络</button>
<button id="bFit">复位</button>
<button id="bL" title="显示/隐藏左栏">左栏</button>
</header>
@@ -138,7 +144,7 @@ small{color:var(--dim)}
</div>
<footer>
<span>左键旋转 · 滚轮缩放 · 右键平移(拖动) · 单击选中高亮 · <b>右键单击隐藏部件(或按H键)</b> · 左侧全选/勾选恢复</span>
<span>左键旋转 · 滚轮缩放 · 右键平移(拖动) · 单击选中高亮 · <b>BOM 点螺丝行 = 高亮定位该规格螺钉(穿透显示)</b> · <b>右键单击隐藏部件(或按H键)</b> · 左侧全选/勾选恢复</span>
<span class="sp"></span>
<span id="stat"></span>
</footer>
@@ -212,6 +218,25 @@ for (const p of DATA.parts) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(p.v.length * 3);
for (let i = 0; i < p.v.length; i++) { pos[i*3] = p.v[i][0]; pos[i*3+1] = p.v[i][2]; pos[i*3+2] = -p.v[i][1]; }
p.triCount = p.t ? p.t.length : 0;
p.vertCount = p.v.length;
const isEnv = p.name.slice(0, 4) === 'ENV_'; // 可达包络:运动学边界,不随关节动
if (p.kind === 'line') { // 经纬线:LineSegments,无三角面
const sIdx = new Uint32Array(p.s);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setIndex(new THREE.BufferAttribute(sIdx, 1));
geo.computeBoundingSphere(); geo.computeBoundingBox();
const lm = new THREE.LineBasicMaterial({color: p.color, transparent: true, opacity: 0.8});
const ls = new THREE.LineSegments(geo, lm);
ls.userData = p;
ls.position.set(p.ex[0], p.ex[2], -p.ex[1]);
ls.base = ls.position.clone();
ls.visible = !isEnv; // 包络默认隐藏,走开关显示
JG[p.grp || 'root'].add(ls);
meshes.push(ls); byName[p.name] = ls;
delete p.v; delete p.s;
continue;
}
const idx = new Uint32Array(p.t.length * 3);
// the vertex map (x,y,z)->(x,z,-y) has determinant +1, so triangle winding must be kept
// as-is; swapping two indices here inverted every face and made the normals point inward
@@ -221,19 +246,22 @@ for (const p of DATA.parts) {
geo.computeVertexNormals();
geo.computeBoundingSphere();
geo.computeBoundingBox();
p.triCount = p.t.length;
p.vertCount = p.v.length;
delete p.v;
delete p.t;
const isMo = !p.printed && p.name[0] === 'M';
const isScr = !!p.screw; // 紧固件(螺钉):金属亮色,可被 BOM 行高亮
const mat = new THREE.MeshStandardMaterial({
color: p.color, metalness: isMo ? .55 : .05, roughness: isMo ? .42 : .78,
transparent: isMo, opacity: isMo ? .34 : 1, flatShading: false, side: THREE.FrontSide});
color: p.color, metalness: isScr ? .78 : (isMo ? .55 : .05),
roughness: isScr ? .30 : (isMo ? .42 : .78),
transparent: isEnv, opacity: isEnv ? .06 : 1,
depthWrite: !isEnv,
flatShading: false, side: isEnv ? THREE.DoubleSide : THREE.FrontSide});
const ms = new THREE.Mesh(geo, mat);
ms.userData = p;
ms.position.set(p.ex[0], p.ex[2], -p.ex[1]);
ms.base = ms.position.clone();
ms.zero = [0,0,0];
if (isEnv) ms.visible = false; // 包络默认隐藏,走开关显示
JG[p.grp || 'root'].add(ms);
meshes.push(ms); byName[p.name] = ms;
}
@@ -286,7 +314,8 @@ let BB = [[1e9,1e9,1e9],[-1e9,-1e9,-1e9]];
/* robust global bounds in the viewer frame (x, z, -y) */
(function(){
let lo=[1e9,1e9,1e9], hi=[-1e9,-1e9,-1e9];
for (const m of meshes) { const b=m.geometry.boundingBox;
for (const m of meshes) { if (m.userData.name.slice(0,4) === 'ENV_') continue; // 取景只按手臂本体,包络不入包围盒
const b=m.geometry.boundingBox;
const a=[b.min.x+m.base.x,b.min.y+m.base.y,b.min.z+m.base.z];
const c=[b.max.x+m.base.x,b.max.y+m.base.y,b.max.z+m.base.z];
for(let i=0;i<3;i++){lo[i]=Math.min(lo[i],a[i]);hi[i]=Math.max(hi[i],c[i]);} }
@@ -381,6 +410,7 @@ window.addEventListener('keydown', e => {
if (e.key === 'h' || e.key === 'H' || e.key === 'Delete') {
if (sel) hidePart(sel);
} else if (e.key === 'Escape') {
clearScrewHl();
if (sel) {
sel.material.emissive.setHex(0);
sel.material.emissiveIntensity = 0;
@@ -398,18 +428,21 @@ cv.addEventListener('click', e => {
if (o === sel) {
if (sel) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; sel = null; }
const tb = document.querySelector('#bom tbody'); if (tb) for (const tr of tb.children) tr.style.background = '';
const hud = document.getElementById('hud'); if (hud) hud.textContent = '';
clearScrewHl();
const hud = document.getElementById('hud'); if (hud) { hud.textContent = ''; delete hud.dataset.scrHl; }
requestRender();
return;
}
if (sel) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; }
if (sel && !sel.userData.screw) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; }
sel = o;
if (sel) { sel.material.emissive.setHex(0x2f6bff); sel.material.emissiveIntensity = .55; }
if (sel && !sel.userData.screw) { sel.material.emissive.setHex(0x2f6bff); sel.material.emissiveIntensity = .55; }
const tb = document.querySelector('#bom tbody'); for (const tr of tb.children) tr.style.background = '';
const hud = document.getElementById('hud');
hud.textContent = '';
delete hud.dataset.scrHl;
if (sel) {
const u = sel.userData;
if (u.screw) { highlightSpec(u.spec, false); requestRender(); return; } // 点中螺钉 -> 反查 BOM 行并定位
hud.innerHTML = '<b>#' + u.idx + ' ' + u.cn + '</b> · ' + (u.vol / 1000).toFixed(1) +
' cm3 · ' + (u.printed ? '打印件' : '电机/已有件') +
' <span style="margin-left:8px;color:var(--warn);font-size:11px">[右键单击或按H键隐藏此部件]</span>';
@@ -450,9 +483,10 @@ if (bRstJ) bRstJ.onclick = () => {
};
/* ---- UI: part tree ---- */
const th = document.getElementById('tree');
const groups = [['打印件(新建)', m => m.userData.printed],
const groups = [['可达包络(运动学)', m => m.userData.name.slice(0,4) === 'ENV_'],
['打印件(新建)', m => m.userData.printed],
['电机模组(复用)', m => !m.userData.printed && m.userData.name[0]==='M'],
['端面齿联轴器/抱箍(已有)', m => !m.userData.printed && m.userData.name[0]!=='M']];
['端面齿联轴器/抱箍(已有)', m => !m.userData.printed && !m.userData.screw && m.userData.name[0]!=='M' && m.userData.name.slice(0,4) !== 'ENV_']];
for (const [gt, test] of groups) {
const ms = meshes.filter(test); if (!ms.length) continue;
const h = document.createElement('div'); h.className = 'gr'; h.textContent = gt + ' · ' + ms.length;
@@ -480,11 +514,42 @@ for (const [gt, test] of groups) {
th.appendChild(r);
}
}
/* ---- 紧固件(螺钉)分组:按规格聚合,一行 = 一种规格,勾选/点击作用于该规格全部实例 ---- */
const scrAll = meshes.filter(m => m.userData.screw);
const bySpec = {}, specChk = {};
for (const m of scrAll) (bySpec[m.userData.spec] = bySpec[m.userData.spec] || []).push(m);
if (scrAll.length) {
const h = document.createElement('div'); h.className = 'gr';
h.textContent = '紧固件 / 螺钉(按规格聚合) · ' + Object.keys(bySpec).length + ' 种 ' +
scrAll.length + ' 颗(点击名称定位)';
th.appendChild(h);
for (const sid of Object.keys(bySpec).sort()) {
const ms = bySpec[sid], p0 = ms[0].userData;
const r = document.createElement('div'); r.className = 'row';
r.innerHTML = '<input type="checkbox" checked><span class="chip" style="background:#' +
p0.color.toString(16).padStart(6,'0') + '"></span><label title="' + p0.note + '">' +
'<span class="ex">' + sid + ' ' + p0.cn.split(' · ')[0] + '</span></label>' +
'<span class="q">' + ms.length + ' 颗</span>';
const chk = r.querySelector('input');
specChk[sid] = chk;
chk.onchange = e => {
for (const m of ms) {
m.visible = e.target.checked;
if (!m.visible && m === sel) { sel.material.emissive.setHex(0); sel = null; }
}
requestRender();
};
r.querySelector('label').onclick = () => highlightSpec(sid);
for (const m of ms) m.chk = chk;
th.appendChild(r);
}
}
document.getElementById('allOn').onclick = () => {
meshes.forEach(m => {
m.visible = true;
if (m.chk) m.chk.checked = true;
});
bEnv.classList.toggle('on', meshes.some(m => m.userData.name.slice(0,4) === 'ENV_' && m.visible));
requestRender();
};
document.getElementById('allOff').onclick = () => {
@@ -493,6 +558,9 @@ document.getElementById('allOff').onclick = () => {
if (m.chk) m.chk.checked = false;
if (sel) { sel.material.emissive.setHex(0); sel = null; }
});
bEnv.classList.remove('on');
bScr.classList.remove('on');
clearScrewHl();
requestRender();
};
document.getElementById('onlyPrint').onclick = () => {
@@ -501,6 +569,9 @@ document.getElementById('onlyPrint').onclick = () => {
if (m.chk) m.chk.checked = m.visible;
if (!m.visible && m === sel) { sel.material.emissive.setHex(0); sel = null; }
});
bEnv.classList.remove('on');
bScr.classList.remove('on');
clearScrewHl();
requestRender();
};
/* ---- BOM ---- */
@@ -532,16 +603,79 @@ for (const s of SCREWS) {
nTot += s.数量;
bySize[s.螺纹] = (bySize[s.螺纹] || 0) + s.数量;
const tr = document.createElement('tr');
tr.innerHTML = '<td><b>' + s.规格 + '</b><br><small>' + s.头型 + '</small></td>' +
tr.innerHTML = '<td><b>' + s.id + '</b> ' + s.规格 + '<br><small>' + s.头型 + '</small></td>' +
'<td>' + s.长度mm + '</td><td>' + s.数量 + '</td>' +
'<td>' + s.位置 + '<br><small>' + s.备注 + '</small></td>';
tr.dataset.spec = s.id;
tr.title = '点击:在三维模型中高亮该规格全部 ' + s.数量 + ' 颗并定位(再次点击切换到下一处)';
tr.onclick = () => highlightSpec(s.id);
skb.appendChild(tr);
}
const tally = Object.keys(bySize).sort().map(k => k + 'x' + bySize[k]).join(' / ');
document.getElementById('scrSum').textContent =
'共 ' + SCREWS.length + ' 种 ' + nTot + ' 颗(' + tally + ');另需 M4 螺母 4、M5 螺母 4、M6 螺母+平垫圈各 6,见各行备注';
'共 ' + SCREWS.length + ' 种 ' + nTot + ' 颗(' + tally + ');另需 M4 螺母 4、M5 螺母 4、M6 螺母+平垫圈各 6,见各行备注' +
' —— 点击任一行可在模型中高亮定位该行螺钉(再次点击切换下一处,Esc 清除)';
document.getElementById('bomStat').textContent =
'紧固件 ' + nTot + ' 颗 / 打印件 ' + DATA.parts.filter(p=>p.printed).length + ' 种';
/* ---- 紧固件高亮定位(BOM 螺丝行 / 零件树 / 三维点选 三处共用)----
螺钉多数埋在沉孔或螺丝刀通道里,因此高亮时同时关闭深度测试做"穿透"显示,
让位置直接透过零件亮出来;相机再对该孔组自动对焦。 */
let hlSpec = null, hlIdx = 0;
function specMeshes(sid) { return meshes.filter(m => m.userData.screw && m.userData.spec === sid); }
function clearScrewHl() {
if (!hlSpec) return;
for (const m of specMeshes(hlSpec)) {
const mt = m.material;
mt.emissive.setHex(0); mt.emissiveIntensity = 0;
mt.transparent = false; mt.opacity = 1;
mt.depthTest = true; mt.depthWrite = true;
mt.needsUpdate = true; m.renderOrder = 0;
}
hlSpec = null;
for (const tr of document.querySelectorAll('#screws tbody tr')) tr.classList.remove('scrHl');
const hud = document.getElementById('hud');
if (hud && hud.dataset.scrHl) { hud.textContent = ''; delete hud.dataset.scrHl; }
requestRender();
}
function highlightSpec(sid, step) {
if (hlSpec !== sid) { clearScrewHl(); hlSpec = sid; hlIdx = 0; }
else if (step !== false) hlIdx++; // 再次点击同一行 -> 切换到下一处
const ms = specMeshes(sid);
if (!ms.length) return;
const inf = SCREWS.find(s => s.id === sid) || {规格: sid, 头型: '', 数量: ms.length, 位置: '', 备注: ''};
for (const m of ms) {
const mt = m.material;
if (!m.visible) { m.visible = true; if (m.chk) m.chk.checked = true; }
mt.emissive.setHex(0xff8a1e); mt.emissiveIntensity = 1.0;
mt.transparent = true; mt.opacity = .96;
mt.depthTest = false; mt.depthWrite = false; mt.needsUpdate = true;
m.renderOrder = 999;
}
scene.updateMatrixWorld(true);
const pts = ms.map(m => new V3(...m.userData.hl).applyMatrix4(m.parent.matrixWorld));
hlIdx = ((hlIdx % pts.length) + pts.length) % pts.length;
const c0 = pts[hlIdx];
const cl = pts.filter(p => p.distanceTo(c0) < 40.0); // 同一孔组(如某关节 3 颗)一同入框
const lo = [1e9,1e9,1e9], hi = [-1e9,-1e9,-1e9];
for (const p of cl) for (let i = 0; i < 3; i++) {
lo[i] = Math.min(lo[i], p.getComponent(i)); hi[i] = Math.max(hi[i], p.getComponent(i));
}
tx = (lo[0]+hi[0])/2; ty = (lo[1]+hi[1])/2; tz = (lo[2]+hi[2])/2;
const sp = Math.max(hi[0]-lo[0], hi[1]-lo[1], hi[2]-lo[2]);
rd = Math.max(150, sp * 2.6 + 130);
for (const tr of document.querySelectorAll('#screws tbody tr'))
tr.classList.toggle('scrHl', tr.dataset.spec === sid);
const tr0 = document.querySelector('#screws tbody tr[data-spec="' + sid + '"]');
if (tr0) tr0.scrollIntoView({block: 'nearest'});
const hud = document.getElementById('hud');
hud.dataset.scrHl = '1';
hud.innerHTML = '<b>' + sid + ' ' + inf.规格 + ' ' + inf.头型 + '</b> · ' + inf.数量 +
' 颗 —— 第 ' + (hlIdx + 1) + '/' + pts.length + ' 处:' + ms[hlIdx].userData.cn.split(' · ')[1] +
'<span class="hudlink" id="scrClr">清除高亮</span><br><small>' + inf.位置 + ';' + inf.备注 + '</small>';
const scl = document.getElementById('scrClr');
if (scl) scl.onclick = () => clearScrewHl();
requestRender();
}
const dock = document.getElementById('bomDock'), bTab = document.getElementById('bomTab');
function dockSet(open) {
dock.classList.toggle('open', open);
@@ -550,12 +684,14 @@ function dockSet(open) {
}
bTab.onclick = () => dockSet(!dock.classList.contains('open'));
document.getElementById('bomHide').onclick = () => dockSet(false);
document.addEventListener('keydown', e => { if (e.key === 'Escape') dockSet(false); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !hlSpec) dockSet(false); });
/* ---- toolbar ---- */
const bAsm = document.getElementById('bAsm');
const bExp = document.getElementById('bExp');
const bWire = document.getElementById('bWire');
const bXRay = document.getElementById('bXRay');
const bScr = document.getElementById('bScr');
const bEnv = document.getElementById('bEnv');
const bL = document.getElementById('bL');
const sheet = document.getElementById('sheet');
const stage = document.getElementById('stage');
@@ -566,9 +702,16 @@ function tog(a, b) { bAsm.classList.toggle('on', a); bExp.classList.toggle('on',
const exp = document.getElementById('exp');
exp.oninput = () => setExpl(exp.value/100);
on('bWire', () => { bWire.classList.toggle('on'); const w = bWire.classList.contains('on');
meshes.forEach(m => { m.material.wireframe = w; }); requestRender(); });
meshes.forEach(m => { if (m.userData.name.slice(0,4) !== 'ENV_') m.material.wireframe = w; }); requestRender(); });
on('bXRay', () => { bXRay.classList.toggle('on'); const o = bXRay.classList.contains('on');
meshes.forEach(m => { if (!m.userData.printed && m.userData.name[0]==='M') { m.material.opacity = o ? .34 : 1; m.material.transparent = o; } }); requestRender(); });
on('bEnv', () => { bEnv.classList.toggle('on'); const o = bEnv.classList.contains('on');
meshes.forEach(m => { if (m.userData.name.slice(0,4) === 'ENV_') { m.visible = o; if (m.chk) m.chk.checked = o; } });
requestRender(); });
on('bScr', () => { bScr.classList.toggle('on'); const o = bScr.classList.contains('on');
meshes.forEach(m => { if (m.userData.screw) { m.visible = o; if (m.chk) m.chk.checked = o; } });
if (!o) clearScrewHl();
requestRender(); });
on('bFit', () => { fitTo(BB); az = -0.9; po = 1.15; camUp.copy(UP_Y); requestRender(); });
const app = document.getElementById('app');
on('bL', () => { app.classList.toggle('l'); bL.classList.toggle('on'); });
@@ -628,8 +771,10 @@ document.getElementById('ver').textContent = DATA.layout.ver + ' · 迭代 ' + D
function updStat() {
const el = document.getElementById('stat');
const tri = (typeof meshes !== 'undefined' && meshes) ? meshes.reduce((a, m) => a + (m.userData.triCount || 0), 0) : 0;
if (el) el.textContent = '零件 ' + (typeof meshes !== 'undefined' ? meshes.length : 0) + ' · 三角面 ' + (tri/1000).toFixed(0) + 'k · ' +
'爆炸 ' + Math.round(expl*100) + '%';
const nScr = (typeof meshes !== 'undefined' && meshes) ? meshes.filter(m => m.userData.screw).length : 0;
const nTot = (typeof meshes !== 'undefined' && meshes) ? meshes.length : 0;
if (el) el.textContent = '零件 ' + (nTot - nScr) + (nScr ? ' + 螺钉 ' + nScr : '') +
' · 三角面 ' + (tri/1000).toFixed(0) + 'k · ' + '爆炸 ' + Math.round(expl*100) + '%';
}
updStat();
@@ -659,6 +804,8 @@ requestAnimationFrame(loop);
if (q.has('tx')) tx = +q.get('tx');
if (q.has('ty')) ty = +q.get('ty');
if (q.has('tz')) tz = +q.get('tz');
if (q.get('env') === '1') { const b = document.getElementById('bEnv');
if (b && !b.classList.contains('on')) b.onclick(); }
if (q.get('xray') === '0') { const b = document.getElementById('bXRay'); if (b.classList.contains('on')) b.onclick(); }
if (q.has('j6')) { const el = document.getElementById('sJ6');
if (el) { el.value = q.get('j6'); el.dispatchEvent(new Event('input')); } }