Files
cbb f713355351 refactor(viewer): app.js 按职责切成 8 个普通脚本(纯切片,零语义改动)
state / harness / camera / pick / ui / sheets / render / urlparams。
经典脚本的顶层 const/let 落在同一个全局词法作用域,所以切片本身不改变任何行为——
这一步先让"文件边界"与"模块语义"两件事分离:翻 ESM 时若出问题,责任清楚在
import/export,而不是切片切错了行。

切片踩到并修掉的:urlparams.js 被切掉了 IIFE 的收尾 })();(我按 len-2 收边界),
node --check 当场报 Unexpected end of input。

截图基线分级也在这一步收敛:工程图/装配向导视图带 feGaussianBlur 发光滤镜,
同一构建两轮就有 ±1 抖动(先前扫描那两轮碰巧逐字节相同,才漏进"稳定"清单,
于是随机假 FAIL)。把 8 张工程图/向导视图连同 3 张线束 x-ray、boss_p07、
p03_mount_loose 一起转像素容差(存参考像素,阈值 mean<=0.02、单像素<=32,
约实测 20x 余量),其余 92 张仍要求逐字节相同。

关卡:92 张逐字节一致 + 13 张在噪声阈内;数据哈希与 IK exact 段一致。
2026-10-15 04:45:56 +08:00

309 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 左栏交互:关节滑块、零件树、紧固件分组、BOM 与抽屉、工具条 */
/* ---- UI: joint sliders ----
滑块范围 = 电机行程 ±360°(docs/collide.json 的 motor_limit_deg;原 ±106° 作废)。
能拖到哪由几何说话:相邻件实测自由区间(与姿态无关)封顶,跨组零件对由胶囊碰撞
模型按当前姿态实时夹取(见 ik-glue 的 IK.clampAxis);docs/travel.json 的零位静态
扫描值只作 tooltip 参考——它是"其它轴全零"时对固定立柱的一次性结果,随姿态大变。 */
const JNAMES = ['J1','J2','J3','J4','J5','J6'];
const MOTOR = (typeof COLLIDE !== 'undefined' && COLLIDE.motor_limit_deg) || 360;
const jh = document.getElementById('joints');
for (const id of JNAMES) {
const j = JO.find(q => q.id === id); if (!j) continue;
const t = TL[id] || {lo:-180, hi:180, continuous:true, note:'360°旋转'};
const lo = -MOTOR, hi = MOTOR;
const d = document.createElement('div');
d.className = 'j-box';
d.innerHTML = '<div class="row"><label title="' + t.note +
'(电机行程 ±' + MOTOR + '°=滑块范围;零位静态扫描 ' + Math.floor(t.lo) + '°…' + Math.ceil(t.hi) +
'°仅作参考,实际边界随姿态由碰撞模型实时夹取。双击复位 0°)">' +
id + ' ' + j.cn +
(t.continuous ? ' <em class="ok" id="e' + id + '">整周可用</em>' : ' <em id="e' + id + '">' + lo + '°…' + hi + '°</em>') +
'</label><span class="j-val" id="v' + id + '" title="点击复位为 0°">0°</span></div>' +
'<input type="range" id="s' + id + '" min="' + lo + '" max="' + hi + '" value="0">';
jh.appendChild(d);
const inp = d.querySelector('input');
inp.oninput = ev => {
let v = +ev.target.value;
if (window.IK && IK.clampAxis) { // 动态限位夹取(glue 加载后才生效)
const c = IK.clampAxis(id, v);
if (c !== v) { v = c; ev.target.value = c; } // 滑块头停在真实边界上
}
ang[id] = v;
document.getElementById('v'+id).textContent = v + '°';
for (const q of JNAMES) applyJoint(q);
};
d.querySelector('.j-val').onclick = () => { inp.value = 0; inp.dispatchEvent(new Event('input')); };
d.querySelector('label').ondblclick = () => { inp.value = 0; inp.dispatchEvent(new Event('input')); };
inp.onchange = () => { // 松手后异步刷新动态范围标签(拖拽中不刷,避免卡顿)
if (window.IK && IK.refreshJointLabels) setTimeout(() => IK.refreshJointLabels(
['J1','J2','J3','J4','J5','J6'].map(x => (document.getElementById('s'+x) || {}).value || 0)), 0);
};
applyJoint(id);
}
const bRstJ = document.getElementById('resetJoints');
if (bRstJ) bRstJ.onclick = () => {
for (const id of JNAMES) {
const el = document.getElementById('s' + id);
if (el) { el.value = 0; el.dispatchEvent(new Event('input')); }
}
};
/* ---- UI: part tree ---- */
const th = document.getElementById('tree');
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.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;
th.appendChild(h);
for (const m of ms) {
const p = m.userData;
const r = document.createElement('div'); r.className = 'row';
r.innerHTML = '<input type="checkbox" checked><span class="chip" style="background:#' +
p.color.toString(16).padStart(6,'0') + '"></span><label title="' + p.name + '">' +
'<span class="' + (p.printed?'pr':(p.name[0]==='M'?'mo':'ex')) + '">' + p.cn + '</span></label>' +
'<span class="q">#' + p.idx + '</span>';
const chk = r.querySelector('input');
m.chk = chk;
chk.onchange = e => {
m.visible = e.target.checked;
if (!m.visible && m === sel) { m.material.emissive.setHex(0); sel = null; }
requestRender();
};
r.querySelector('label').onclick = () => {
m.visible = !m.visible;
chk.checked = m.visible;
if (!m.visible && m === sel) { m.material.emissive.setHex(0); sel = null; }
requestRender();
};
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>' +
'<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 = () => {
meshes.forEach(m => {
m.visible = false;
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 = () => {
meshes.forEach(m => {
m.visible = !!m.userData.printed;
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 ---- */
const NMOT = DATA.parts.filter(p => p.name[0] === 'M').length; // count motors upfront
const tb = document.querySelector('#bom tbody');
let vTot = 0, gTot = 0;
for (const p of DATA.parts) {
if (!p.printed) continue;
vTot += p.vol; gTot += p.vol * 1.24e-3;
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + p.idx + '</td><td>' + p.cn + '</td><td>' + (p.vol/1000).toFixed(1) + '</td><td>' +
(p.vol*1.24e-3).toFixed(0) + '</td>';
tr.dataset.idx = p.idx;
tr.title = p.name + (dimsStr(p) ? ',' + dimsStr(p) : '');
tb.appendChild(tr);
}
document.getElementById('sum').innerHTML =
'<i>打印件数</i><span>' + DATA.parts.filter(p=>p.printed).length + ' 种</span>' +
'<i>总体积</i><span>' + (vTot/1000).toFixed(1) + ' cm3</span>' +
'<i>总质量(PLA)</i><span>' + gTot.toFixed(0) + ' g</span>' +
'<i>电机数量</i><span>' + NMOT + ' 台(同款 电机.STEP)</span>' +
'<i>工作空间</i><span>约 ' + Math.round(BB[1][2]) + ' x ' + Math.round(BB[1][0]) + ' mm</span>';
/* ---- edge BOM dock: fasteners + merged parts BOM ---- */
const skb = document.querySelector('#screws tbody');
const bySize = {};
let nTot = 0;
for (const s of SCREWS) {
nTot += s.数量;
bySize[s.螺纹] = (bySize[s.螺纹] || 0) + s.数量;
const tr = document.createElement('tr');
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,见各行备注' +
' —— 点击任一行可在模型中高亮定位该行螺钉(再次点击切换下一处,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);
bTab.style.display = open ? 'none' : ''; // 展开时拉手藏掉,否则压住抽屉右缘
bTab.innerHTML = open ? '收起 <b>清单</b>' : '物料清单 <b>BOM</b>';
}
bTab.onclick = () => dockSet(!dock.classList.contains('open'));
document.getElementById('bomHide').onclick = () => 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 bHarn = document.getElementById('bHarn');
const bEnv = document.getElementById('bEnv');
const bL = document.getElementById('bL');
const sheet = document.getElementById('sheet');
const stage = document.getElementById('stage');
const on = (id, f) => document.getElementById(id).onclick = f;
on('bAsm', () => { setExpl(0); exp.value = 0; tog(true, false); });
on('bExp', () => { setExpl(+exp.value/100 || .6); exp.value = exp.value || 60; tog(false, true); });
function tog(a, b) { bAsm.classList.toggle('on', a); bExp.classList.toggle('on', b); }
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 => { 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('bHarn', () => { bHarn.classList.toggle('on'); const o = bHarn.classList.contains('on');
if (window.setHarnessVisible) setHarnessVisible(o);
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'); });
bL.classList.add('on');
document.getElementById('cam').onchange = e => {
const k = e.target.value;
camUp.copy(UP_Y); // upright roll for every view unless pinned below
if (k==='iso'){az=-0.9;po=1.15;} if(k==='front'){az=0;po=1.57;}
if(k==='side'){az=1.5708;po=1.57;}
// true plan view: pin the roll (near-vertical lookAt otherwise rolls the image) and
// frame by the plan extent - the height dimension is irrelevant looking down
if(k==='top'){az=-0.9;po=0.02;camUp.copy(UP_TOP);
rd=Math.max(BB[1][0]-BB[0][0],BB[1][2]-BB[0][2])*1.2+180;}
const hub = id => { const j = JO.find(q => q.id === id); return j ? VC(j.hub) : [tx, ty, tz]; };
if (k === 'wrist') { const a = hub('J5'), b = hub('J6'); // frame the whole hand,
tx = (a[0]+b[0])/2; ty = (a[1]+b[1])/2; tz = (a[2]+b[2])/2; rd = 340; az = -0.6; po = 1.25; }
// shoulder close-up has to hold BOTH the roll drum (J1) and the pitch fork (J2) - aiming at
// one hub alone filled the frame with a single Ø88 cylinder and showed nothing
if (k === 'joint') { const a = hub('J1'), h = hub('J2');
tx = (a[0]+h[0])/2; ty = (a[1]+h[1])/2; tz = (a[2]+h[2])/2; rd = 470; az = -0.6; po = 1.25; }
if (k === 'elbow') { const h = hub('J4');
tx = h[0]; ty = h[1] - 35; tz = h[2]; rd = 280; az = -0.6; po = 1.25; }
if (k === 'hand') { const h = hub('J6'); tx = h[0]; ty = h[1]; tz = h[2] - 45; rd = 215; az = -0.6; po = 1.25; }
requestRender();
};