/* 左栏交互:关节滑块、零件树、紧固件分组、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 = '
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 = '' + '#' + p.idx + ''; 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 = '' + '' + ms.length + ' 颗'; 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 = '' + p.idx + '' + p.cn + '' + (p.vol/1000).toFixed(1) + '' + (p.vol*1.24e-3).toFixed(0) + ''; tr.dataset.idx = p.idx; tr.title = p.name + (dimsStr(p) ? ',' + dimsStr(p) : ''); tb.appendChild(tr); } document.getElementById('sum').innerHTML = '打印件数' + DATA.parts.filter(p=>p.printed).length + ' 种' + '总体积' + (vTot/1000).toFixed(1) + ' cm3' + '总质量(PLA)' + gTot.toFixed(0) + ' g' + '电机数量' + NMOT + ' 台(同款 电机.STEP)' + '工作空间约 ' + Math.round(BB[1][2]) + ' x ' + Math.round(BB[1][0]) + ' mm'; /* ---- 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 = '' + s.id + ' ' + s.规格 + '
' + s.头型 + '' + '' + s.长度mm + '' + s.数量 + '' + '' + s.位置 + '
' + s.备注 + ''; 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 = '' + sid + ' ' + inf.规格 + ' ' + inf.头型 + ' · ' + inf.数量 + ' 颗 —— 第 ' + (hlIdx + 1) + '/' + pts.length + ' 处:' + ms[hlIdx].userData.cn.split(' · ')[1] + '清除高亮
' + inf.位置 + ';' + inf.备注 + ''; 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 ? '收起 清单' : '物料清单 BOM'; } 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(); };