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 段一致。
309 lines
16 KiB
JavaScript
309 lines
16 KiB
JavaScript
/* 左栏交互:关节滑块、零件树、紧固件分组、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();
|
||
};
|