初始提交:机械臂 CadQuery 建模源码、设计文档与 3D 查看器
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>六电机人形机器人手臂 · 装配与打印模型</title>
|
||||
<style>
|
||||
:root{--bg:#0f1115;--pan:#171a21;--pan2:#1f242e;--tx:#e6e8ee;--dim:#8b93a7;--ac:#4c9aff;--ok:#3ddc97;--warn:#ffb454;--line:#2b323e}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--bg);color:var(--tx);
|
||||
font:13px/1.55 "Microsoft YaHei","Segoe UI",system-ui,sans-serif;overflow:hidden;
|
||||
-webkit-user-select:none;user-select:none}
|
||||
#app{display:grid;grid-template-columns:274px minmax(430px,1fr) 320px;grid-template-rows:46px 1fr 26px;
|
||||
height:100%;min-width:0;overflow-x:auto}
|
||||
header{grid-column:1/4;display:flex;align-items:center;gap:8px;padding:0 12px;flex-wrap:nowrap;
|
||||
overflow-x:auto;overflow-y:hidden;background:var(--pan);border-bottom:1px solid var(--line)}
|
||||
header .q{white-space:nowrap;flex:none;color:var(--dim);font-size:12px}
|
||||
header b{font-size:14px;white-space:nowrap;flex:none}
|
||||
header .sp{flex:1 1 auto;min-width:6px}
|
||||
button,select{background:var(--pan2);color:var(--tx);border:1px solid var(--line);
|
||||
border-radius:5px;padding:4px 9px;font:inherit;cursor:pointer;white-space:nowrap;flex:none}
|
||||
button:hover{border-color:var(--ac);color:#fff}
|
||||
button.on{background:#22406b;border-color:var(--ac)}
|
||||
aside{background:var(--pan);overflow:auto;padding:8px;min-width:0}
|
||||
#left{border-right:1px solid var(--line)}
|
||||
#right{border-left:1px solid var(--line)}
|
||||
#app.r{grid-template-columns:274px minmax(430px,1fr) 320px}
|
||||
#app.l{grid-template-columns:274px minmax(430px,1fr)}
|
||||
#stage{position:relative;overflow:hidden;min-width:0;
|
||||
background:radial-gradient(120% 120% at 50% 0%,#1b2028 0%,#0d0f13 70%)}
|
||||
canvas{display:block;width:100%;height:100%}
|
||||
footer{grid-column:1/4;background:var(--pan);border-top:1px solid var(--line);
|
||||
display:flex;align-items:center;gap:14px;padding:0 12px;color:var(--dim);font-size:12px;
|
||||
white-space:nowrap;overflow:hidden}
|
||||
@media (max-width:1150px){
|
||||
#app{grid-template-columns:250px minmax(400px,1fr)}
|
||||
#right{display:none}
|
||||
#app.r #right{display:block}
|
||||
}
|
||||
@media (max-width:760px){
|
||||
#app{grid-template-columns:minmax(0,1fr)}
|
||||
#left{display:none}
|
||||
#app.l #left{display:block}
|
||||
}
|
||||
h3{font-size:12px;margin:10px 0 6px;color:var(--ac);letter-spacing:.4px;-webkit-user-select:none;user-select:none}
|
||||
.row{display:flex;align-items:center;gap:6px;margin:3px 0}
|
||||
.row label{flex:1;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;-webkit-user-select:none;user-select:none}
|
||||
.row .q{color:var(--dim);font-size:11px;-webkit-user-select:none;user-select:none}
|
||||
.chip{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;flex:none}
|
||||
.pr{color:var(--ok)}.ex{color:var(--warn)}.mo{color:#9aa4b8}
|
||||
table{width:100%;border-collapse:collapse;font-size:11.5px;-webkit-user-select:text;user-select:text}
|
||||
th,td{border-bottom:1px solid var(--line);padding:3px 4px;text-align:left;vertical-align:top}
|
||||
th{color:var(--dim);font-weight:400;position:sticky;top:0;background:var(--pan);-webkit-user-select:none;user-select:none}
|
||||
.j-box{margin-bottom:7px;padding:3px 4px 1px;border-radius:4px;transition:background .15s}
|
||||
.j-box:hover{background:rgba(255,255,255,.03)}
|
||||
.j-box label{cursor:default;font-weight:500}
|
||||
.j-val{font-size:11px;color:var(--ac);cursor:pointer;padding:1px 5px;border-radius:3px;
|
||||
background:rgba(76,154,255,.1);font-variant-numeric:tabular-nums;transition:all .15s;
|
||||
-webkit-user-select:none;user-select:none}
|
||||
.j-val:hover{background:var(--ac);color:#fff}
|
||||
input[type=range]{width:100%;height:18px;accent-color:var(--ac);cursor:pointer;display:block;margin:1px 0}
|
||||
label em{font-style:normal;color:var(--mu);font-size:10.5px;margin-left:4px}
|
||||
label em.ok{color:#5ddc90}
|
||||
.gr{font-size:11px;color:var(--dim);margin:2px 0 4px;-webkit-user-select:none;user-select:none}
|
||||
#hud{position:absolute;left:10px;top:8px;pointer-events:none;font-size:11.5px;color:var(--dim);
|
||||
background:rgba(15,17,21,.72);border:1px solid var(--line);border-radius:6px;padding:6px 9px;max-width:60%}
|
||||
#hud b{color:var(--tx)}
|
||||
#hud:empty{display:none}
|
||||
#note{position:absolute;right:10px;bottom:10px;max-width:330px;background:rgba(15,17,21,.88);
|
||||
border:1px solid var(--ac);border-radius:7px;padding:8px 10px;font-size:12px;display:none;
|
||||
-webkit-user-select:text;user-select:text}
|
||||
.kv{display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11.5px;-webkit-user-select:text;user-select:text}
|
||||
.kv i{color:var(--dim);font-style:normal;-webkit-user-select:none;user-select:none}
|
||||
.tab{padding:4px 9px;-webkit-user-select:none;user-select:none}
|
||||
.bad{color:#ff6b6b}.good{color:var(--ok)}
|
||||
svg{display:block;width:100%;height:auto;background:#fff;border-radius:6px}
|
||||
#sheet{display:none;padding:10px;overflow:auto;height:100%}
|
||||
small{color:var(--dim)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<b id="ttl">人形机器人手臂</b>
|
||||
<span class="q" id="ver"></span>
|
||||
<button class="tab on" data-view="model">三维模型</button>
|
||||
<button class="tab" data-view="sheet">装配图</button>
|
||||
<span class="sp"></span>
|
||||
<button id="bAsm" class="on">装配视图</button>
|
||||
<button id="bExp">爆炸视图</button>
|
||||
<input id="exp" type="range" min="0" max="100" value="0" style="width:96px">
|
||||
<select id="cam">
|
||||
<option value="iso">等测</option><option value="front">前视</option>
|
||||
<option value="side">侧视</option><option value="top">顶视</option>
|
||||
<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="bFit">复位</button>
|
||||
<button id="bL" title="显示/隐藏左栏">左栏</button>
|
||||
<button id="bR" title="显示/隐藏右栏">右栏</button>
|
||||
</header>
|
||||
|
||||
<aside id="left">
|
||||
<div class="row" style="justify-content:space-between;margin:8px 0 4px">
|
||||
<h3 style="margin:0">关节角度(实时联动)</h3>
|
||||
<button id="resetJoints" class="tab" style="padding:1px 7px;font-size:11px" title="将所有关节角度复位为 0°">全部归零</button>
|
||||
</div>
|
||||
<div id="joints"></div>
|
||||
<h3>零件树</h3>
|
||||
<div class="row"><button id="allOn" class="tab">全选</button><button id="allOff" class="tab">全不选</button>
|
||||
<button id="onlyPrint" class="tab">只看打印件</button></div>
|
||||
<div id="tree"></div>
|
||||
<h3>打印清单(PLA)</h3>
|
||||
<div class="kv" id="sum"></div>
|
||||
</aside>
|
||||
|
||||
<div id="stage">
|
||||
<canvas id="cv"></canvas>
|
||||
<div id="hud"></div>
|
||||
<div id="note"></div>
|
||||
<div id="sheet"></div>
|
||||
</div>
|
||||
|
||||
<aside id="right">
|
||||
<h3>BOM / 明细</h3>
|
||||
<table id="bom"><thead><tr><th>#</th><th>件号</th><th>cm3</th><th>g</th></tr></thead><tbody></tbody></table>
|
||||
<h3>接口自检(实测值)</h3>
|
||||
<div class="kv" id="iface"></div>
|
||||
<h3>碰撞检查</h3>
|
||||
<div id="col"></div>
|
||||
</aside>
|
||||
|
||||
<footer>
|
||||
<span>左键旋转 · 滚轮缩放 · 右键平移(拖动) · 单击选中高亮 · <b>右键单击隐藏部件(或按H键)</b> · 左侧全选/勾选恢复</span>
|
||||
<span class="sp"></span>
|
||||
<span id="stat"></span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>/*__THREE__*/</script>
|
||||
<script>
|
||||
const DATA = /*__DATA__*/;
|
||||
const SHEETS = /*__SHEETS__*/;
|
||||
const TRAVEL = /*__TRAVEL__*/;
|
||||
const TL = {}; for (const t of TRAVEL) TL[t.axis] = t;
|
||||
const V3 = THREE.Vector3;
|
||||
const VC = p => [p[0], p[2], -p[1]]; // CAD (x,y,z) -> viewer (x,z,-y); must precede use
|
||||
const cv = document.getElementById('cv');
|
||||
const rend = new THREE.WebGLRenderer({canvas:cv, antialias:true, preserveDrawingBuffer:true});
|
||||
rend.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0d0f13);
|
||||
const cam = new THREE.PerspectiveCamera(38, 1, 1, 6000);
|
||||
scene.add(new THREE.HemisphereLight(0xcfe4ff, 0x22262e, 1.15));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.5); key.position.set(420, 620, 520); scene.add(key);
|
||||
const key2 = new THREE.DirectionalLight(0x9dc4ff, .7); key2.position.set(-520, 300, -420); scene.add(key2);
|
||||
const grid = new THREE.GridHelper(600, 30, 0x2b323e, 0x1b2028); grid.position.y = -0.5; scene.add(grid);
|
||||
|
||||
let needRender = true;
|
||||
function requestRender() { needRender = true; }
|
||||
|
||||
/* ---- kinematic groups: nested pivot matrices, serial chain ---- */
|
||||
const ROOT = new THREE.Object3D(); scene.add(ROOT);
|
||||
const JG = {root: ROOT};
|
||||
const JO = DATA.joints;
|
||||
const parentOf = {J1:'root', J2:'J1', J3:'J2', J4:'J3', J5:'J4', J6:'J5'};
|
||||
const ang = {J1:0, J2:0, J3:0, J4:0, J5:0, J6:0};
|
||||
for (const j of JO) {
|
||||
const g = new THREE.Object3D(); g.matrixAutoUpdate = false;
|
||||
JG[j.id] = g; JG[parentOf[j.id]].add(g);
|
||||
}
|
||||
function applyJoint(id) {
|
||||
const j = JO.find(q => q.id === id); if (!j) return;
|
||||
// geometry vertices were built in the viewer frame (x, z, -y), so the pivot point and the
|
||||
// axis must go through the same swap - using raw CAD coords rotates about the wrong line
|
||||
const P = new V3(...VC(j.rear)), D = new V3(...VC(j.d)).normalize();
|
||||
const a = (ang[id] || 0) * Math.PI / 180;
|
||||
const m = new THREE.Matrix4().makeTranslation(P.x, P.y, P.z)
|
||||
.multiply(new THREE.Matrix4().makeRotationAxis(D, a))
|
||||
.multiply(new THREE.Matrix4().makeTranslation(-P.x, -P.y, -P.z));
|
||||
JG[id].matrix.copy(m);
|
||||
JG[id].matrixWorldNeedsUpdate = true;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/* ---- meshes ---- */
|
||||
const meshes = [], byName = {};
|
||||
let expl = 0;
|
||||
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]; }
|
||||
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
|
||||
for (let i = 0; i < p.t.length; i++) { idx[i*3] = p.t[i][0]; idx[i*3+1] = p.t[i][1]; idx[i*3+2] = p.t[i][2]; }
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setIndex(new THREE.BufferAttribute(idx, 1));
|
||||
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 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});
|
||||
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];
|
||||
JG[p.grp || 'root'].add(ms);
|
||||
meshes.push(ms); byName[p.name] = ms;
|
||||
}
|
||||
const J6j = JO.find(j => j.id === 'J6');
|
||||
// the jaws slide along the gripper motor's own local X, so that axis must come from J6
|
||||
const JAWD = J6j ? new V3(...VC(J6j.xdir)).normalize() : new V3(0, 0, 1);
|
||||
const PIN_R = 14.0;
|
||||
const JMAX = TL.J6 ? PIN_R * (1 - Math.cos(TL.J6.hi * Math.PI / 180)) : 9.0;
|
||||
const JAW_MAX = Math.min(9.0, Math.max(2.0, JMAX));
|
||||
let jawMM = 0;
|
||||
function updXform() {
|
||||
for (const m of meshes) {
|
||||
m.position.copy(m.base).multiplyScalar(expl);
|
||||
if (m.userData.jaw) m.position.addScaledVector(JAWD, m.userData.jaw * jawMM);
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
function setJaw(t) {
|
||||
jawMM = Math.max(0, Math.min(JAW_MAX, t));
|
||||
// the crank pins sit at radius 14 along the jaw axis as authored, so a rotation of phi
|
||||
// puts the pin at x = 14*cos(phi) and each jaw rides out by exactly that much
|
||||
const phi = Math.acos(Math.max(-1, Math.min(1, (PIN_R - jawMM) / PIN_R)));
|
||||
ang.J6 = -phi * 180 / Math.PI;
|
||||
applyJoint('J6');
|
||||
updXform();
|
||||
const e = document.getElementById('vJ6'); if (e) e.textContent = (2 * jawMM).toFixed(1) + ' mm';
|
||||
}
|
||||
function setExpl(k) {
|
||||
expl = k;
|
||||
updXform();
|
||||
if (typeof updStat === 'function') updStat();
|
||||
requestRender();
|
||||
}
|
||||
setExpl(0); // start assembled, not exploded
|
||||
|
||||
/* ---- camera orbit ---- */
|
||||
let az = -0.9, po = 1.15, rd = 620, tx = 120, ty = 190, tz = -30;
|
||||
function fitTo(b) {
|
||||
const c = [(b[0][0]+b[1][0])/2, (b[0][1]+b[1][1])/2, (b[0][2]+b[1][2])/2];
|
||||
tx = c[0]; ty = c[1]; tz = c[2];
|
||||
const s = Math.max(b[1][0]-b[0][0], b[1][1]-b[0][1], b[1][2]-b[0][2]);
|
||||
rd = s * 1.78;
|
||||
requestRender();
|
||||
}
|
||||
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;
|
||||
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]);} }
|
||||
BB=[lo,hi];
|
||||
})();
|
||||
fitTo(BB);
|
||||
function updCam() {
|
||||
cam.position.set(tx + rd*Math.sin(po)*Math.cos(az), ty + rd*Math.cos(po), tz + rd*Math.sin(po)*Math.sin(az));
|
||||
cam.lookAt(tx, ty, tz);
|
||||
}
|
||||
let drag = null, panDist = 0;
|
||||
cv.addEventListener('pointerdown', e => {
|
||||
drag = {x:e.offsetX, y:e.offsetY, b:e.button};
|
||||
panDist = 0;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
});
|
||||
let hoverPending = false, lastHoverEv = null;
|
||||
function schedHover(e) {
|
||||
lastHoverEv = e;
|
||||
if (!hoverPending) {
|
||||
hoverPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
hoverPending = false;
|
||||
if (lastHoverEv && !drag) hover(lastHoverEv);
|
||||
});
|
||||
}
|
||||
}
|
||||
cv.addEventListener('pointermove', e => {
|
||||
if (!drag) { schedHover(e); return; }
|
||||
const dx = e.offsetX - drag.x, dy = e.offsetY - drag.y;
|
||||
panDist += Math.abs(dx) + Math.abs(dy);
|
||||
drag = {x:e.offsetX, y:e.offsetY, b:drag.b};
|
||||
if (drag.b === 0) { az -= dx*0.008; po = Math.min(3.04, Math.max(.05, po - dy*0.008)); }
|
||||
else { const r = rd*0.0016; const f = new V3(Math.sin(az),0,Math.cos(az)), ri = new V3().crossVectors(new V3(0,1,0), f).normalize();
|
||||
tx -= ri.x*dx*r + f.x*dy*r*0.0; tz -= ri.z*dx*r + f.z*dy*r*0.0; ty += dy*r; }
|
||||
requestRender();
|
||||
});
|
||||
addEventListener('pointerup', () => drag = null);
|
||||
cv.addEventListener('wheel', e => { e.preventDefault(); rd *= (1 + Math.sign(e.deltaY)*0.09); requestRender(); }, {passive:false});
|
||||
|
||||
/* ---- picking & hiding ---- */
|
||||
const ray = new THREE.Raycaster(); let sel = null, hov = null;
|
||||
function pick(e) {
|
||||
const r = cv.getBoundingClientRect();
|
||||
ray.setFromCamera(new THREE.Vector2(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1), cam);
|
||||
const hit = ray.intersectObjects(meshes.filter(m => m.visible), false)[0];
|
||||
return hit ? hit.object : null;
|
||||
}
|
||||
function hover(e) {
|
||||
const o = pick(e); if (o === hov) return; hov = o;
|
||||
const n = document.getElementById('note');
|
||||
if (!o) { n.style.display = 'none'; return; }
|
||||
const p = o.userData;
|
||||
n.style.display = 'block';
|
||||
n.innerHTML = '<b>#' + p.idx + ' ' + p.cn + '</b><br><small>' + p.name +
|
||||
' · ' + (p.printed ? '3D打印件' : '电机/已有件') + '<br>' + p.note + '</small>';
|
||||
}
|
||||
function hidePart(m) {
|
||||
if (!m) return;
|
||||
const p = m.userData;
|
||||
if (m === sel) {
|
||||
m.material.emissive.setHex(0);
|
||||
m.material.emissiveIntensity = 0;
|
||||
sel = null;
|
||||
}
|
||||
m.visible = false;
|
||||
if (m.chk) m.chk.checked = false;
|
||||
const n = document.getElementById('note');
|
||||
if (n) n.style.display = 'none';
|
||||
const hud = document.getElementById('hud');
|
||||
if (hud) {
|
||||
hud.innerHTML = '已隐藏:<b>#' + p.idx + ' ' + p.cn + '</b> <span id="unhideBtn" style="color:var(--ac);cursor:pointer;text-decoration:underline;margin-left:6px">撤销</span> <small style="color:var(--dim)">(可在左侧零件树勾选或点击“全选”恢复)</small>';
|
||||
const ub = document.getElementById('unhideBtn');
|
||||
if (ub) ub.onclick = () => {
|
||||
m.visible = true;
|
||||
if (m.chk) m.chk.checked = true;
|
||||
hud.textContent = '';
|
||||
requestRender();
|
||||
};
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
cv.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
if (panDist > 6) return; // ignore drag panning
|
||||
let target = sel;
|
||||
if (!target) target = pick(e);
|
||||
if (target) hidePart(target);
|
||||
});
|
||||
window.addEventListener('keydown', e => {
|
||||
if (e.key === 'h' || e.key === 'H' || e.key === 'Delete') {
|
||||
if (sel) hidePart(sel);
|
||||
} else if (e.key === 'Escape') {
|
||||
if (sel) {
|
||||
sel.material.emissive.setHex(0);
|
||||
sel.material.emissiveIntensity = 0;
|
||||
sel = null;
|
||||
const hud = document.getElementById('hud');
|
||||
if (hud) hud.textContent = '';
|
||||
const tb = document.querySelector('#bom tbody');
|
||||
if (tb) for (const tr of tb.children) tr.style.background = '';
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
});
|
||||
cv.addEventListener('click', e => {
|
||||
const o = pick(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 = '';
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
if (sel) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; }
|
||||
sel = o;
|
||||
if (sel) { 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 = '';
|
||||
if (sel) {
|
||||
const u = sel.userData;
|
||||
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>';
|
||||
const tr = tb.querySelector('[data-idx="' + u.idx + '"]');
|
||||
if (tr) { tr.style.background = '#22406b'; tr.scrollIntoView({block:'nearest'}); }
|
||||
}
|
||||
requestRender();
|
||||
});
|
||||
|
||||
/* ---- UI: joint sliders, ranges from the swept hard stops (docs/travel.json) ---- */
|
||||
const JNAMES = ['J1','J2','J3','J4','J5','J6'];
|
||||
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 = Math.floor(t.lo), hi = Math.ceil(t.hi);
|
||||
const d = document.createElement('div');
|
||||
d.className = 'j-box';
|
||||
d.innerHTML = '<div class="row"><label title="' + t.note + '(双击复位 0°)">' + id + ' ' + j.cn +
|
||||
(t.continuous ? ' <em class="ok">360°连续</em>' : ' <em>' + 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 => { ang[id] = +ev.target.value;
|
||||
document.getElementById('v'+id).textContent = ang[id] + '°';
|
||||
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')); };
|
||||
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.printed],
|
||||
['电机模组(复用)', m => !m.userData.printed && m.userData.name[0]==='M'],
|
||||
['端面齿联轴器/抱箍(已有)', m => !m.userData.printed && m.userData.name[0]!=='M']];
|
||||
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.note + '">' +
|
||||
'<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);
|
||||
}
|
||||
}
|
||||
document.getElementById('allOn').onclick = () => {
|
||||
meshes.forEach(m => {
|
||||
m.visible = true;
|
||||
if (m.chk) m.chk.checked = true;
|
||||
});
|
||||
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; }
|
||||
});
|
||||
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; }
|
||||
});
|
||||
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.note + ' / ' + p.name;
|
||||
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>';
|
||||
/* ---- measured interfaces ---- */
|
||||
const md = DATA.measured, Mt = md.M, Ct = md.C;
|
||||
document.getElementById('iface').innerHTML = [
|
||||
['电机外形', 'Ø' + Mt.hous_d + ' x ' + Mt.hous_len + ',整机长 ' + Mt.cover_z + ',尾部 PCBA 方 ' + Mt.pcba_w],
|
||||
['减速', '两级 0.5M 行星:24T 太阳 / 3x36T 行星 / 96T 内齿'],
|
||||
['输出接口', 'Ø' + Mt.neck_d + 'x' + Mt.neck_depth + ' 颈孔 + Ø' + Mt.centre_d + ' 中心孔'],
|
||||
['螺栓', '3 x M2 @ BCD ' + Mt.m2_bcd + ',时钟 ' + Mt.m2_clock.join('/')],
|
||||
['贴合面', '电机尾基准 + ' + md.HUB_FACE_Z + ' = 从动盘贴合面(联轴器两盖板面跨距 ' + Ct.gap + ')'],
|
||||
['抱箍', '带宽 ' + Ct.clamp_od_r*2 + ',凸耳 R' + Ct.lug_r1 + ',轴向 ' + md.CLAMP_SPAN.join('~')],
|
||||
['齿', Ct.n_teeth + ' 齿端面花键 R' + Ct.tooth_r0 + '~' + Ct.tooth_r1 + ',齿高 ' + Ct.tooth_s],
|
||||
].map(r => '<i>' + r[0] + '</i><span>' + r[1] + '</span>').join('');
|
||||
/* ---- collision ---- */
|
||||
const CL = /*__COL__*/;
|
||||
document.getElementById('col').innerHTML = CL.length
|
||||
? '<table><tr><th>零件对</th><th>mm3</th></tr>' +
|
||||
CL.map(r => '<tr><td>' + r[0] + ' x ' + r[1] + '</td><td class="bad">' + r[2] + '</td></tr>').join('') + '</table>'
|
||||
: '<span class="good">0 对干涉(全部零件布尔交集体积 < 2 mm3)</span>';
|
||||
/* ---- toolbar ---- */
|
||||
const bAsm = document.getElementById('bAsm');
|
||||
const bExp = document.getElementById('bExp');
|
||||
const bWire = document.getElementById('bWire');
|
||||
const bXRay = document.getElementById('bXRay');
|
||||
const bL = document.getElementById('bL');
|
||||
const bR = document.getElementById('bR');
|
||||
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 => { 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('bFit', () => { fitTo(BB); az = -0.9; po = 1.15; requestRender(); });
|
||||
const app = document.getElementById('app');
|
||||
on('bL', () => { app.classList.toggle('l'); bL.classList.toggle('on'); });
|
||||
on('bR', () => { app.classList.toggle('r'); bR.classList.toggle('on'); });
|
||||
bL.classList.add('on'); bR.classList.add('on');
|
||||
document.getElementById('cam').onchange = e => {
|
||||
const k = e.target.value;
|
||||
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;}
|
||||
// looking down the arm, the height dimension is irrelevant - frame by the PLAN extent
|
||||
if(k==='top'){az=-0.9;po=0.12;rd=Math.max(BB[1][0]-BB[0][0],BB[1][2]-BB[0][2])*1.55+90;}
|
||||
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; } // not just its hub
|
||||
// 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; }
|
||||
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; }
|
||||
requestRender();
|
||||
};
|
||||
document.querySelectorAll('.tab[data-view]').forEach(b => b.onclick = () => {
|
||||
document.querySelectorAll('.tab[data-view]').forEach(x => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
const s = b.dataset.view === 'sheet';
|
||||
sheet.style.display = s ? 'block' : 'none'; cv.style.display = s ? 'none' : 'block';
|
||||
if (s && !sheet.dataset.done) { sheet.dataset.done = 1; sheet.innerHTML = SHEETS; }
|
||||
if (!s) { resize(); requestRender(); }
|
||||
});
|
||||
/* ---- loop & on-demand rendering ---- */
|
||||
let curW = 0, curH = 0;
|
||||
function resize() {
|
||||
if (cv.style.display === 'none') return;
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
if (!w || !h) return;
|
||||
if (w !== curW || h !== curH) {
|
||||
curW = w; curH = h;
|
||||
rend.setSize(w, h, false);
|
||||
cam.aspect = w / h;
|
||||
cam.updateProjectionMatrix();
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(() => resize()).observe(stage);
|
||||
} else {
|
||||
window.addEventListener('resize', resize);
|
||||
}
|
||||
|
||||
document.getElementById('ttl').textContent = NMOT + ' 电机人形机器人手臂';
|
||||
document.getElementById('ver').textContent = DATA.layout.ver + ' · 迭代 ' + DATA.layout.it +
|
||||
' · 上臂' + DATA.layout.L1 + ' 前臂' + DATA.layout.L2 + ' mm';
|
||||
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) + '%';
|
||||
}
|
||||
updStat();
|
||||
|
||||
function loop() {
|
||||
resize();
|
||||
if (cv.style.display !== 'none') {
|
||||
updCam();
|
||||
rend.render(scene, cam);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
/* ---- url params so views can be scripted / shared: ?expl=60&j1=20&cam=iso&xray=0 ---- */
|
||||
(function(){
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (q.has('expl')) { exp.value = q.get('expl'); setExpl(+exp.value/100);
|
||||
if (+exp.value) tog(false, true); }
|
||||
for (const id of JNAMES) {
|
||||
const k = id.toLowerCase();
|
||||
if (q.has(k)) { const el = document.getElementById('s'+id);
|
||||
el.value = q.get(k); el.dispatchEvent(new Event('input')); }
|
||||
}
|
||||
if (q.has('cam')) { const s = document.getElementById('cam'); s.value = q.get('cam'); s.dispatchEvent(new Event('change')); }
|
||||
if (q.has('az')) az = +q.get('az');
|
||||
if (q.has('po')) po = +q.get('po');
|
||||
if (q.has('rd')) rd = +q.get('rd');
|
||||
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('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')); } }
|
||||
if (q.has('hide')) {
|
||||
const h = q.get('hide').split(',');
|
||||
meshes.forEach(m => {
|
||||
const nm = (m.userData && m.userData.name) || '';
|
||||
const cn = (m.userData && m.userData.cn) || '';
|
||||
if (h.some(k => nm.includes(k) || cn.includes(k))) {
|
||||
m.visible = false;
|
||||
if (m.chk) m.chk.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
requestRender();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user