Files
Robot_Arm/webapp/js/app.js
T
cbb 531a293688 refactor(viewer): 单文件查看器拆成 webapp/ 多文件,走本地 http;Worker 换成真文件
前端不再是"一个 16MB HTML + 一个 2955 行模板"。现在:
  webapp/index.html      343 行 DOM 骨架,零内联数据
  webapp/styles/*.css    811 行 CSS 按段切成 8 个文件
  webapp/js/app.js       原内联主脚本逐字搬出(仍是普通脚本,S5 才改 ESM)
  webapp/js/ik-solver.js / ik-glue.js / vendor/three.min.js(原 web/ 三个文件)
  webapp/workers/ik.worker.js  真 Worker 文件
  webapp/generated/00_data.js  生成物(网格/工程图/行程/碰撞/螺钉)
  dist/机器人手臂3D查看器.html  分发用单文件,由 build_viewer 内联上面这些文件产出

入口:python -m arm.viewer.serve 后开 http://127.0.0.1:8123/
(serve 带 no-store,改完刷新即新构建,不再需要 Ctrl+F5;显式 MIME,
 .js 被 Windows 注册表判成 text/plain 会让模块静默不执行)

删掉 src/viewer_tpl.html 与 web/ 目录(旧产物移入 backups/pre_split_20261015/)。
工程图不再维护第二份 sheets.jsdata —— 直接读 draw_sheet 写出的 4 个真 SVG。

实测中修掉的问题:
- serve 的 / 原先"内部映射"到 webapp/index.html,导致文档基址是 /、相对路径的
  8 个 CSS 与 4 个 JS 全部 404(整页无样式无脚本)。改成 302 跳转。
- file:// 下 new Worker('workers/…') 构造阶段不抛错、错误以异步 onerror 到达,
  所以"构造失败才试 Blob 兜底"永远不会触发;改为按协议选路。两条路径都已实测
  backend=worker(http 走文件、file:// 走 Blob)。
- Worker 握手 3s 在 headless 并发下会误判"没起来"(截图里红徽标即此),放宽到 10s,
  并把后端状态暴露成 window.__IK_BACKEND / __IK_BACKEND_LOG 供测试直读。
- 截图竞态:4 并发 × 每页 14MB 数据时,虚拟时间预算会在页面加载完成前到期,
  拍到半初始化画面(且能"稳定地坏",旧基线里 m9_motion 就是这么混进来的)。
  并发降到 2、预算 40s、serve 加 HTTP/1.1+大缓冲+字节缓存,并用"同一构建两轮"
  重新界定稳定性:99 张逐字节一致、6 张属噪声改走像素容差(mean<=0.02、单像素<=32)。
- 关卡自身两处假通过已修:node 输出被 GBK 解码炸掉时不再算 PASS;
  线束干涉测试读不到通道源码时会 exit 2,而不是"零通道=零穿透=通过"。

关卡结果:python -m arm.build_model 退出码 0;10 项数据哈希与基线一致;
IK exact 段逐字节一致;99 张截图逐字节一致 + 6 张在噪声阈内;线束干涉测试 1800 点 0 穿透。
2026-10-15 04:31:31 +08:00

1806 lines
77 KiB
JavaScript
Raw 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.
/* 查看器主脚本(原 viewer_tpl.html 内联脚本,逐字搬来)。仍是普通脚本:S5 才改 ES Module。 */
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);
}
/* 轴心 P / 轴向 D 只与 CAD 参数有关:预计算一次;矩阵合成改用 3 个共享实例。
旧版每次调用新建 2 个 Vector3 + 3 个 Matrix4(一次关节写入 = 6 次调用 = 30 个临时对象),
动画与实时追踪高频触发时纯属垃圾。数学与原式逐位相同:T(P)·R(D,θ)·T(−P)。 */
const JP = {}, JD = {};
for (const j of JO) { JP[j.id] = new V3(...VC(j.rear)); JD[j.id] = new V3(...VC(j.d)).normalize(); }
const _jmT = new THREE.Matrix4(), _jmR = new THREE.Matrix4(), _jmTi = new THREE.Matrix4();
function applyJoint(id) {
// 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 = JP[id], D = JD[id]; if (!P) return;
const a = (ang[id] || 0) * Math.PI / 180;
const m = _jmT.makeTranslation(P.x, P.y, P.z)
.multiply(_jmR.makeRotationAxis(D, a))
.multiply(_jmTi.makeTranslation(-P.x, -P.y, -P.z));
JG[id].matrix.copy(m);
JG[id].matrixWorldNeedsUpdate = true;
if (window.updateHarnessBridges && window.isHarnessVisible && window.isHarnessVisible()) {
updateHarnessBridges();
}
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]; }
p.triCount = p.t ? p.t.length : 0;
p.vertCount = p.v.length;
/* 外形三围:模型坐标系 X×Y×Z 包围盒(装配零位、打印朝向 Z=高),悬停面板显示用 */
{ let x0=1e9,y0=1e9,z0=1e9,x1=-1e9,y1=-1e9,z1=-1e9;
for (let i = 0; i < p.v.length; i++) { const a = p.v[i];
if (a[0]<x0)x0=a[0]; if (a[0]>x1)x1=a[0];
if (a[1]<y0)y0=a[1]; if (a[1]>y1)y1=a[1];
if (a[2]<z0)z0=a[2]; if (a[2]>z1)z1=a[2]; }
p.dims = [x1-x0, y1-y0, z1-z0]; }
const isEnv = p.name.slice(0, 4) === 'ENV_'; // 可达包络:运动学边界,不随关节动
/* 索引按顶点数降位:单件 ≤65535 顶点时 Uint16 合法(92 件实测最大 47483),
索引内存与上传带宽减半;超大件保留 Uint32 兜底。 */
const IDX16 = p.v.length <= 65535;
if (p.kind === 'line') { // 经纬线:LineSegments,无三角面
const sIdx = IDX16 ? new Uint16Array(p.s) : 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 = IDX16 ? new Uint16Array(p.t.length * 3) : 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();
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: 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;
}
/* ===================== 线束与走线指示系统 (Wire Harness System) ===================== */
const harnessGroup = new THREE.Group();
harnessGroup.name = 'HARNESS_ROOT';
scene.add(harnessGroup);
const matTrunk = new THREE.MeshStandardMaterial({
color: 0xf59e0b, roughness: 0.35, metalness: 0.25
});
const matDrop = new THREE.MeshStandardMaterial({
color: 0x10b981, roughness: 0.3, metalness: 0.2
});
const matTap = new THREE.MeshStandardMaterial({
color: 0x8b5cf6, roughness: 0.4, metalness: 0.3
});
const matTerm = new THREE.MeshStandardMaterial({
color: 0xef4444, roughness: 0.2, metalness: 0.5
});
const matClip = new THREE.MeshStandardMaterial({
color: 0x1e293b, roughness: 0.5, metalness: 0.2
});
const harnessMeshes = [];
function makeHarnTube(ptsCAD, r, mat, name, cn, parentGrp, segs = 18, radSegs = 8) {
const pts = ptsCAD.map(p => new THREE.Vector3(...VC(p)));
const curve = new THREE.CatmullRomCurve3(pts);
const geo = new THREE.TubeGeometry(curve, segs, r, radSegs, false);
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = {
idx: 'H-CABLE', name: name || '4芯高柔拖链总线 (CAN+24V)',
cn: cn || '主干总线', vol: 8000, printed: false, harness: true
};
(parentGrp || harnessGroup).add(mesh);
harnessMeshes.push(mesh);
return mesh;
}
function makeHarnTap(pCAD, label, parentGrp) {
const geo = new THREE.BoxGeometry(10, 8, 14);
const mesh = new THREE.Mesh(geo, matTap);
mesh.position.set(...VC(pCAD));
mesh.userData = {
idx: 'H-TAP', name: label || 'T接分线小板',
cn: '微型T型总线分线节点', vol: 1100, printed: false, harness: true
};
const ledGeo = new THREE.SphereGeometry(1.2, 8, 8);
const ledMat = new THREE.MeshBasicMaterial({ color: 0x34d399 });
const led = new THREE.Mesh(ledGeo, ledMat);
led.position.set(0, 4.5, 0);
mesh.add(led);
(parentGrp || harnessGroup).add(mesh);
harnessMeshes.push(mesh);
return mesh;
}
function makeHarnClip(pCAD, parentGrp) {
const geo = new THREE.CylinderGeometry(3.0, 3.0, 4.0, 12, 1, true);
const mesh = new THREE.Mesh(geo, matClip);
mesh.position.set(...VC(pCAD));
(parentGrp || harnessGroup).add(mesh);
harnessMeshes.push(mesh);
return mesh;
}
function makeHarnTerm(pCAD, parentGrp) {
const geo = new THREE.CylinderGeometry(2.5, 2.5, 8.0, 12);
const mesh = new THREE.Mesh(geo, matTerm);
mesh.position.set(...VC(pCAD));
mesh.rotation.z = Math.PI / 2;
mesh.userData = {
idx: 'H-TERM', name: '120Ω 终端电阻 (总线末端阻抗匹配)',
cn: 'CAN总线终端匹配电阻', vol: 500, printed: false, harness: true
};
(parentGrp || harnessGroup).add(mesh);
harnessMeshes.push(mesh);
return mesh;
}
// 1. Root group harness (P01 立柱、P03 吊臂、M1 电机)
const hRoot = new THREE.Group();
hRoot.name = 'HARN_ROOT_SUB';
JG['root'].add(hRoot);
makeHarnTube([
[-115.0, 128.0, 35.0], [-115.0, 128.0, 250.0], [-115.0, 128.0, 470.0],
[-115.0, 128.0, 500.0]
], 2.0, matTrunk, 'P01立柱内穿线管主干 (24V+CAN,穿44x44内腔与P03减重窗)', '基座立柱主干', hRoot);
makeHarnTube([
[-115.0, 128.0, 500.0], [-115.0, 101.5, 520.0], [-105.0, 90.0, 545.0],
[-85.0, 89.8, 570.0], [-70.0, 87.4, 592.0], [-56.0, 85.6, 610.0],
[-20.0, 85.8, 610.0], [5.5, 86.1, 610.0]
], 1.8, matTrunk, 'P03吊臂侧向骨架主干 (扎带拱桥锁附,线心贴桥外露面)', 'P03吊臂实体主干', hRoot, 24, 8);
makeHarnTap([-115.0, 128.0, 505.0], 'T接小板① (M1供电分线节点)', hRoot);
// M1 外部大空间绕行通道:线束自基座 +Y 侧向外绕过 P03 法兰外廓(免打孔),在外部宽阔自由空间大半径弧形引入 M1 电机侧向缺口
makeHarnTube([
[-115.0, 128.0, 505.0], [-115.0, 155.0, 515.0], [-115.0, 172.0, 530.0],
[-95.0, 172.0, 565.0], [-75.0, 172.0, 595.0], [-55.0, 172.0, 610.15],
[-46.0, 166.0, 610.15], [-48.5, 158.0, 610.15], [-49.5, 154.5, 610.15]
], 1.5, matDrop, 'P03吊臂M1供电支干 (外侧大空间绕行引入)', 'M1 供电支干', hRoot, 16, 8);
makeHarnTap([-46.0, 166.0, 610.15], 'M1大空间侧向接插件 (20x20mm容纳位)', hRoot);
makeHarnClip([-115.0, 128.0, 300.0], hRoot);
makeHarnClip([-85.0, 89.8, 570.0], hRoot);
makeHarnClip([-56.0, 85.6, 610.0], hRoot);
makeHarnClip([5.5, 86.1, 610.0], hRoot);
const anchorA0 = new THREE.Object3D();
anchorA0.position.set(...VC([5.5, 86.1, 610.0]));
hRoot.add(anchorA0);
// 2. J1 group harness (P04 肩叉、M2 电机)
const hJ1 = new THREE.Group();
hJ1.name = 'HARN_J1';
JG['J1'].add(hJ1);
const anchorB1 = new THREE.Object3D();
anchorB1.position.set(...VC([20.0, 87.0, 615.0]));
hJ1.add(anchorB1);
makeHarnTube([
[20.0, 87.0, 615.0], [20.0, 87.0, 585.0], [20.0, 87.0, 560.0],
[20.0, 87.0, 535.0], [20.0, 87.0, 515.0], [5.0, 87.0, 500.0],
[-10.0, 87.0, 492.0], [-20.0, 88.0, 490.0], [-22.0, 96.0, 490.0],
[-20.0, 102.5, 490.0], [-17.5, 102.5, 485.0]
], 1.8, matTrunk, 'P04肩叉侧向外廓线卡走线 (扎带拱桥锁附)', 'P04 侧向走线', hJ1, 24, 8);
makeHarnTap([-17.5, 102.5, 490.0], 'T接小板② (J2/M2 分线节点)', hJ1);
// M2 侧向缺口出线 (CAD Notch: [-8.04, 102.50, 487.28],电机后端盖圆形PCBA侧向出线槽)
makeHarnTube([
[-8.04, 102.50, 487.28], [-13.0, 102.50, 488.5], [-17.5, 102.50, 490.0]
], 1.3, matDrop, 'M2 电机侧向缺口引出线 (2xCAN + 2x电源)', 'M2 出线分支', hJ1, 10, 6);
makeHarnClip([20.0, 87.0, 615.0], hJ1);
makeHarnClip([20.0, 87.0, 560.0], hJ1);
makeHarnClip([20.0, 87.0, 515.0], hJ1);
const anchorA1 = new THREE.Object3D();
anchorA1.position.set(...VC([-17.5, 102.5, 485.0]));
hJ1.add(anchorA1);
// 3. J2 group harness (P05 大臂旋转座、M3 电机)
const hJ2 = new THREE.Group();
hJ2.name = 'HARN_J2';
JG['J2'].add(hJ2);
const anchorB2 = new THREE.Object3D();
anchorB2.position.set(...VC([-17.5, 102.5, 445.0]));
hJ2.add(anchorB2);
makeHarnTube([
[-17.5, 102.5, 445.0], [-17.5, 102.5, 440.0]
], 1.8, matTrunk, 'P05大臂旋转座侧向外廓走线 (外侧平滑过渡,不穿电机)', 'P05 侧向走线', hJ2, 6, 6);
makeHarnTap([-17.5, 102.5, 440.0], 'T接小板③ (J3/M3 分线节点)', hJ2);
// M3 侧向缺口出线 (CAD Notch: [22.09, 100.30, 442.50],前向敞开侧圆形PCBA径向缺口)
makeHarnTube([
[22.09, 100.30, 442.50], [22.09, 93.0, 443.0], [5.0, 94.0, 444.0],
[-10.0, 98.0, 445.0], [-17.5, 102.50, 440.0]
], 1.3, matDrop, 'M3 电机侧向缺口引出线 (2xCAN + 2x电源)', 'M3 出线分支', hJ2, 12, 6);
makeHarnClip([-17.5, 102.5, 425.0], hJ2);
const anchorA2 = new THREE.Object3D();
anchorA2.position.set(...VC([-17.5, 102.5, 400.0]));
hJ2.add(anchorA2);
makeHarnTube([
[-17.5, 102.5, 440.0], [-17.5, 102.5, 425.0], [-17.5, 102.5, 400.0]
], 1.8, matTrunk, 'P05大臂旋转座侧向外壁引出段', 'P05引出', hJ2, 8, 6);
// 4. J3 group harness (P06 大臂骨架、M4 电机)
const hJ3 = new THREE.Group();
hJ3.name = 'HARN_J3';
JG['J3'].add(hJ3);
const anchorB3 = new THREE.Object3D();
anchorB3.position.set(...VC([-17.5, 102.5, 360.0]));
hJ3.add(anchorB3);
makeHarnTube([
[-17.5, 102.5, 360.0], [-20.7, 102.5, 345.0]
], 1.8, matTrunk, 'P06大臂外侧线槽走线', 'P06 侧向走线', hJ3, 8, 6);
makeHarnTap([-17.5, 102.5, 332.0], 'T接小板④ (J4/M4 肘部分线节点)', hJ3);
// M4 肘电机侧向缺口出线 (CAD Notch: [-8.04, 102.50, 329.28],电机后端盖圆形PCBA侧向出线槽)
makeHarnTube([
[-8.04, 102.50, 329.28], [-13.0, 102.50, 330.5], [-17.5, 102.50, 332.0]
], 1.3, matDrop, 'M4 肘电机侧向引出线 (2xCAN + 2x电源)', 'M4 出线分支', hJ3, 10, 6);
makeHarnClip([-20.7, 102.5, 345.0], hJ3);
makeHarnTube([
[-20.7, 102.5, 345.0], [-17.5, 102.5, 320.0], [-17.5, 102.5, 305.0]
], 1.8, matTrunk, 'P06大臂侧向外廊引出段', 'P06引出', hJ3, 8, 6);
const anchorA3 = new THREE.Object3D();
anchorA3.position.set(...VC([-17.5, 102.5, 305.0]));
hJ3.add(anchorA3);
// 5. J4 group harness (P07 小臂骨架、M5 电机)
const hJ4 = new THREE.Group();
hJ4.name = 'HARN_J4';
JG['J4'].add(hJ4);
const anchorB4 = new THREE.Object3D();
anchorB4.position.set(...VC([-17.5, 102.5, 255.0]));
hJ4.add(anchorB4);
makeHarnTube([
[-17.5, 102.5, 255.0], [-20.7, 102.5, 235.0]
], 1.8, matTrunk, 'P07小臂侧向外廓线槽走线 (外侧平滑过渡,不强行内穿)', 'P07 侧向走线', hJ4, 10, 6);
makeHarnTap([-17.5, 102.5, 222.0], 'T接小板⑤ (J5/M5 分线节点)', hJ4);
// M5 腕仰俯电机侧向缺口出线 (CAD Notch: [-8.04, 102.50, 219.28],电机后端盖圆形PCBA侧向出线槽)
makeHarnTube([
[-8.04, 102.50, 219.28], [-13.0, 102.50, 220.5], [-17.5, 102.50, 222.0]
], 1.3, matDrop, 'M5 电机侧向引出线 (2xCAN + 2x电源)', 'M5 出线分支', hJ4, 10, 6);
makeHarnClip([-20.7, 102.5, 235.0], hJ4);
makeHarnTube([
[-20.7, 102.5, 235.0], [-17.5, 102.5, 215.0], [-17.5, 102.5, 205.0]
], 1.8, matTrunk, 'P07小臂侧向外壁引出段', 'P07引出', hJ4, 8, 6);
const anchorA4 = new THREE.Object3D();
anchorA4.position.set(...VC([-17.5, 102.5, 205.0]));
hJ4.add(anchorA4);
// 6. J5 group harness (P08 腕偏转座、M6 电机)
const hJ5 = new THREE.Group();
hJ5.name = 'HARN_J5';
JG['J5'].add(hJ5);
const anchorB5 = new THREE.Object3D();
anchorB5.position.set(...VC([-17.5, 102.5, 175.0]));
hJ5.add(anchorB5);
makeHarnTube([
[-17.5, 102.5, 175.0], [-17.5, 102.5, 172.0]
], 1.8, matTrunk, 'P08腕偏转座侧向走线', 'P08 腕部外侧走线', hJ5, 6, 6);
makeHarnTap([-17.5, 102.5, 172.0], 'T接小板⑥ (J6/M6 终端分线节点)', hJ5);
// M6 腕自转电机侧向缺口出线 (CAD Notch: [22.09, 100.30, 173.50],前向敞开侧圆形PCBA径向缺口)
makeHarnTube([
[22.09, 100.30, 173.50], [22.09, 93.0, 174.0], [5.0, 94.0, 174.5],
[-10.0, 98.0, 175.0], [-17.5, 102.50, 172.0]
], 1.3, matDrop, 'M6 腕电机侧向引出线 (2xCAN + 2x电源)', 'M6 出线分支', hJ5, 12, 6);
makeHarnTerm([-17.5, 102.5, 168.0], hJ5);
// 7. Dynamic Flexible Bridges across J1~J5 (Service Loop / 弓形弹性缓冲回环)
const bridgeMeshes = [];
for (let i = 0; i < 5; i++) {
const geo = new THREE.BufferGeometry();
const mesh = new THREE.Mesh(geo, matTrunk);
mesh.userData = {
idx: 'H-BRIDGE-' + (i + 1),
name: 'J' + (i + 1) + ' 跨关节柔性服务回环 (Service Loop / 弓形缓冲弯曲段)',
cn: 'J' + (i + 1) + ' 柔性缓冲回环', vol: 2500, printed: false, harness: true
};
harnessGroup.add(mesh);
bridgeMeshes.push(mesh);
harnessMeshes.push(mesh);
}
const allHarnessContainers = [hRoot, hJ1, hJ2, hJ3, hJ4, hJ5, harnessGroup];
let harnessVisibleState = true;
function setHarnessVisible(v) {
harnessVisibleState = v;
allHarnessContainers.forEach(g => g.visible = v);
if (v) updateHarnessBridges();
requestRender();
}
window.setHarnessVisible = setHarnessVisible;
window.isHarnessVisible = () => harnessVisibleState;
const _pA = new THREE.Vector3(), _pB = new THREE.Vector3();
function updateHarnessBridges() {
if (!harnessVisibleState) return;
scene.updateMatrixWorld();
const pairs = [
{ a: anchorA0, b: anchorB1, type: 'roll1', slackL: 25, minH: 5, dir: new THREE.Vector3(0, 0.1, 0.99) }, // J1 roll
{ a: anchorA1, b: anchorB2, type: 'pitch2', slackL: 48, minH: 8, dir: new THREE.Vector3(-1, 0, 0) }, // J2 pitch
{ a: anchorA2, b: anchorB3, type: 'roll3', slackL: 46, minH: 7, dir: new THREE.Vector3(-0.7, 0, 0.7) }, // J3 roll
{ a: anchorA3, b: anchorB4, type: 'pitch4', slackL: 56, minH: 8, dir: new THREE.Vector3(-1, 0, 0) }, // J4 elbow pitch
{ a: anchorA4, b: anchorB5, type: 'pitch5', slackL: 36, minH: 6, dir: new THREE.Vector3(-1, 0, 0) }, // J5 wrist pitch
];
for (let i = 0; i < 5; i++) {
const pr = pairs[i];
pr.a.getWorldPosition(_pA);
pr.b.getWorldPosition(_pB);
const m = bridgeMeshes[i];
const dist = _pA.distanceTo(_pB);
const halfL = Math.max(dist * 0.5 + 2, pr.slackL * 0.5);
const halfD = dist * 0.5;
const h = Math.sqrt(Math.max(pr.minH * pr.minH, halfL * halfL - halfD * halfD));
const dirNorm = pr.dir.clone().normalize();
// 13 点正弦弹性拱形样条插值:端点切向连续,中间大半径圆滑过渡,彻底杜绝折角与多边形棱角
const pts = [];
const N = 12;
for (let k = 0; k <= N; k++) {
const u = k / N;
const base = new THREE.Vector3().lerpVectors(_pA, _pB, u);
const bulge = Math.sin(Math.PI * u) * h;
base.addScaledVector(dirNorm, bulge);
pts.push(base);
}
const curve = new THREE.CatmullRomCurve3(pts, false, 'centripetal', 0.5);
if (m.geometry) m.geometry.dispose();
m.geometry = new THREE.TubeGeometry(curve, 32, 1.8, 10, false);
}
}
window.updateHarnessBridges = updateHarnessBridges;
window.harnessGroup = harnessGroup;
window.harnessMeshes = harnessMeshes;
updateHarnessBridges();
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);
}
if (window.setHarnessVisible) {
const bH = document.getElementById('bHarn');
const on = bH ? bH.classList.contains('on') : true;
setHarnessVisible(on && (expl < 0.05));
}
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;
// lookAt with the world up-axis degenerate-rolls the image when looking near-vertically,
// so top view pins the screen-up to -Z: model X runs right, depth runs down-screen
const UP_Y = new V3(0, 1, 0), UP_TOP = new V3(0, 0, -1);
let camUp = UP_Y.clone();
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) { 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]);} }
BB=[lo,hi];
})();
fitTo(BB);
function updCam() {
cam.up.copy(camUp);
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);
});
/* 悬浮拾取限流:射线要对 34 万三角面全量求交(实测 ≈0.5~1.5 ms/次),
旧版每个 pointermove 都算(rAF 合帧后仍≈显示器刷新率次数);
这里加 60 ms 时间配额 + 位置去重,指针静止时一次都不算,尾随仍会补最新位置,
tooltip 观感不变。点击 / 右键拾取不经过此函数,仍即时求交。 */
let hoverPending = false, lastHoverEv = null, hoverT = -1e9, hoverX = -1, hoverY = -1;
const HOVER_MS = 60;
function schedHover(e) {
if (e.clientX === hoverX && e.clientY === hoverY) return; // 没移动:结果不可能变
hoverX = e.clientX; hoverY = e.clientY; lastHoverEv = e;
if (hoverPending) return;
hoverPending = true;
setTimeout(() => {
hoverPending = false;
if (!lastHoverEv || drag) return;
hoverT = performance.now();
hover(lastHoverEv);
}, Math.max(0, HOVER_MS - (performance.now() - hoverT)));
}
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;
/* 拾取列表与 NDC 向量复用同一实例:旧版每次 pointermove 都 filter 出一个新数组 +
新建 Vector2(高频下纯垃圾)。可见性逐次重算,语义与 filter 完全一致
——three r159 的 Raycaster 不检查 object.visible,只能靠这里过滤。 */
const pickList = [], ndcPick = new THREE.Vector2();
function pick(e) {
const r = cv.getBoundingClientRect();
ndcPick.set(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1);
ray.setFromCamera(ndcPick, cam);
pickList.length = 0;
for (const m of meshes) if (m.visible) pickList.push(m);
if (window.isHarnessVisible && isHarnessVisible() && window.harnessMeshes) {
for (const m of harnessMeshes) if (m.visible) pickList.push(m);
}
const hit = ray.intersectObjects(pickList, false)[0];
return hit ? hit.object : null;
}
/* BOM 表高亮行只记一行:旧版每次点选/ESC 都要把整张表(13 行零件 + 10 行螺钉)
的 style.background 全刷一遍,纯属多余 DOM 写入。 */
let bomHlRow = null;
function bomHlClear() { if (bomHlRow) { bomHlRow.style.background = ''; bomHlRow = null; } }
function dimsStr(p) {
if (!p.dims) return '';
const f = d => String(Math.round(d * 10) / 10);
return '外形 ' + f(p.dims[0]) + ' × ' + f(p.dims[1]) + ' × ' + f(p.dims[2]) + ' mm(X·Y·Z)';
}
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';
if (p.harness) {
n.innerHTML = '<b>' + p.cn + '</b><br><small style="color:#fbbf24">' + p.name + '</small>';
} else {
n.innerHTML = '<b>#' + p.idx + ' ' + p.cn + '</b><br><small>' + p.name +
' · ' + (p.printed ? '3D打印件' : '电机/已有件') +
(dimsStr(p) ? '<br>' + dimsStr(p) : '') + '</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') {
clearScrewHl();
if (sel) {
sel.material.emissive.setHex(0);
sel.material.emissiveIntensity = 0;
sel = null;
const hud = document.getElementById('hud');
if (hud) hud.textContent = '';
bomHlClear();
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; }
bomHlClear();
clearScrewHl();
const hud = document.getElementById('hud'); if (hud) { hud.textContent = ''; delete hud.dataset.scrHl; }
requestRender();
return;
}
if (sel && !sel.userData.screw && !sel.userData.harness) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; }
sel = o;
if (sel && !sel.userData.screw && !sel.userData.harness) { sel.material.emissive.setHex(0x2f6bff); sel.material.emissiveIntensity = .55; }
const tb = document.querySelector('#bom tbody');
bomHlClear();
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 行并定位
if (u.harness) {
hud.innerHTML = '<b>' + u.cn + '</b> · <span style="color:#f59e0b">' + u.name +
'</span> · 2×CAN通信 + 2×24V供电 · <span style="color:var(--ok)">4芯高柔拖链电缆/T型分接</span>';
requestRender();
return;
}
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'; bomHlRow = tr; tr.scrollIntoView({block:'nearest'}); }
}
requestRender();
});
/* ---- 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();
};
/* =========================================================================
交互式装配图与分步装配向导引擎 (Interactive Assembly Drawing Engine)
========================================================================= */
let sheetInited = false;
let curSheetIdx = 0; // 0: front, 1: joint, 2: exploded, 3: side, 'guide': assembly guide
let curSheetTheme = 'paper';
let sheetScale = 1.0;
let sheetPanX = 0, sheetPanY = 0;
let isSheetDragging = false;
let sheetDragStartX = 0, sheetDragStartY = 0;
let pinnedPartIdx = null;
let currentGuideStep = 0;
const SHEET_METAS = [
{ id: 'front', title: '01 总装图 - 正视', icon: '📐', desc: '沿 -X 投影;肢体在纸面内,显示左右交替堆叠。全部贴合面共用 Y=157 平面。' },
{ id: 'joint', title: '02 回转副 A-A 剖视', icon: '🔍', desc: '回转副局部放大 6 倍 — 实测数值基准:端面齿联轴器、对开抱箍与从动盘。' },
{ id: 'exploded', title: '03 轴测爆炸装配图', icon: '💥', desc: '轴测爆炸展开:逐级装配顺序,电机与端面齿组件沿轴线抽出。' },
{ id: 'side', title: '04 总装图 - 侧视', icon: '📐', desc: '沿 +Y 投影;俯仰轴垂直纸面。肩抬举偏置 92mm,肩吊臂侧板无干涉。' }
];
const ASSEMBLY_STEPS = [
{
step: 1,
title: '基座与三段立柱总装',
subtitle: '底脚锁紧 · 内部中空走线孔道贯通',
desc: '将 P02 Center/Front/Rear 底板组合锁紧,插入三段分体打印的 P01 立柱(A下段、B中段、C上段)。立柱段间通过止口插紧,内腔预留 Ø28mm 走线孔道,保证后续主干总线平顺穿入。',
parts: [
{ idx: 4, name: 'P02_Center 立柱锁紧底盘', role: '承重基板' },
{ idx: 5, name: 'P02_Front 前支承脚', role: '防前倾支撑' },
{ idx: 6, name: 'P02_Rear 后配重脚', role: '配重稳定' },
{ idx: 1, name: 'P01_A 立柱下段', role: '立柱下部' },
{ idx: 2, name: 'P01_B 立柱中段', role: '立柱中部' },
{ idx: 3, name: 'P01_C 立柱上段', role: '立柱顶部与J1过渡' }
],
screws: [
{ spec: 'M5 x 25 (S07)', qty: 4, role: '底盘主定位紧固' },
{ spec: 'M5 x 25 (S08)', qty: 4, role: '前后支承脚拼合' },
{ spec: 'M6 x 35 (S10)', qty: 6, role: '重载沉孔锚固' },
{ spec: 'M4 x 16 (S05)', qty: 8, role: '立柱三段段间自攻插合' }
],
tips: '安装前核对立柱内部走线通道无打印拉丝残留,螺丝按对角线分次拧紧。',
sheetRef: 0,
camera: { az: -0.6, po: 1.15, rd: 550, tx: 0, ty: 220, tz: 350 }
},
{
step: 2,
title: '肩部双自由度关节总成 (J1 & J2)',
subtitle: '肩外展抬举 + 肩俯仰正交布置',
desc: '在立柱顶端固定 P03 肩部关节安装架,装入 J1 伺服电机;通过端面齿联轴器连接 P04 正交肩肘;装入 J2 俯仰电机,形成沿 X 轴偏置 92mm 的正交双关节。',
parts: [
{ idx: 7, name: 'P03 肩部关节安装架', role: '肩部主承力座' },
{ idx: 14, name: 'M1_J1 电机模组', role: '肩抬举驱动' },
{ idx: 8, name: 'P04 正交肩肘', role: '90°正交转接' },
{ idx: 15, name: 'M2_J2 电机模组', role: '肩俯仰驱动' }
],
screws: [
{ spec: 'M2 x 10 (S01)', qty: 6, role: '电机输出端面固定' },
{ spec: 'M3 x 10 (S02)', qty: 8, role: '电机机座轴向夹紧' },
{ spec: 'M3 x 8 (S04)', qty: 4, role: '端面齿防脱抱箍锁紧' },
{ spec: 'M3 x 16 (S03)', qty: 4, role: 'P03 顶盖贯穿紧固' }
],
tips: '抱箍安装前务必确认两半端面齿盘脸已 100% 贴死,抱箍两半对开缝隙均匀。',
sheetRef: 1,
camera: { az: -0.8, po: 1.25, rd: 420, tx: -15, ty: 120, tz: 550 }
},
{
step: 3,
title: '大臂旋转座与主受力骨架 (J3 & P05/P06)',
subtitle: '大臂内旋自由度 · 桁架式轻量化骨架',
desc: '将 P05 大臂旋转座与 J2 输出端面贴合,装入 J3 大臂旋转电机;连接 P06 大臂骨架。P06 采用箱梁减重设计,侧壁开设有主总线走线过孔与扎带绑扎凸台。',
parts: [
{ idx: 9, name: 'P05 大臂旋转座', role: 'J3旋转基座' },
{ idx: 16, name: 'M3_J3 电机模组', role: '大臂自旋驱动' },
{ idx: 10, name: 'P06 大臂骨架', role: '大臂主连杆 (L1=158mm)' }
],
screws: [
{ spec: 'M2 x 10 (S01)', qty: 3, role: 'J3 输出法兰固定' },
{ spec: 'M3 x 10 (S02)', qty: 4, role: 'J3 外壳压紧' },
{ spec: 'M3 x 8 (S12)', qty: 4, role: 'P06 侧向加强壁锁紧' }
],
tips: 'J3 尾部排线由 P05 侧边专用豁口引出,避免受力压线或磨破外绝缘皮。',
sheetRef: 0,
camera: { az: -0.7, po: 1.2, rd: 380, tx: 20, ty: 100, tz: 410 }
},
{
step: 4,
title: '肘部俯仰关节与前臂驱动 (J4 & P07)',
subtitle: '肘关节俯仰驱动 · 偏置布置消除干涉',
desc: '在大臂末端装入 J4 肘部电机,连接 P07 小臂骨架。小臂骨架向上加高保护电机,侧面设置 U 型过关节线束缓冲槽,确保大角度折叠时总线不紧绷。',
parts: [
{ idx: 17, name: 'M4_J4 电机模组', role: '肘关节俯仰驱动' },
{ idx: 11, name: 'P07 小臂骨架', role: '前臂连杆 (L2=110mm)' }
],
screws: [
{ spec: 'M2 x 10 (S01)', qty: 3, role: 'J4 输出法兰' },
{ spec: 'M3 x 10 (S02)', qty: 4, role: 'J4 抱紧沉孔' },
{ spec: 'M3 x 8 (S12)', qty: 4, role: 'P07 顶部补强板锁紧' }
],
tips: '检查肘关节在全行程运动范围内线束无卡死或拉扯。',
sheetRef: 3,
camera: { az: -0.6, po: 1.2, rd: 320, tx: 20, ty: 65, tz: 330 }
},
{
step: 5,
title: '腕部双自由度与末端工具法兰 (J5, J6, P08, P09)',
subtitle: '腕部俯仰与自转 · 标准 ISO 工具接口',
desc: '在小臂末端装入 J5 腕部俯仰电机,连接 P08 腕部转接座;装入 J6 末端自旋电机,前端安装 P09 工具法兰。法兰带有标准 PCD 孔位,适配电动夹爪或快换工具。',
parts: [
{ idx: 18, name: 'M5_J5 电机模组', role: '腕俯仰驱动' },
{ idx: 12, name: 'P08 腕部转接座', role: '腕偏转过渡' },
{ idx: 19, name: 'M6_J6 电机模组', role: '末端自转驱动' },
{ idx: 13, name: 'P09 末端工具法兰', role: '标准夹爪安装面' }
],
screws: [
{ spec: 'M2 x 10 (S01)', qty: 6, role: 'J5/J6 输出法兰' },
{ spec: 'M3 x 10 (S02)', qty: 8, role: 'J5/J6 机座固定' },
{ spec: 'M4 x 16 (S06)', qty: 4, role: '末端工具安装紧固' }
],
tips: 'J6 法兰盘面与电机旋转中心精密对齐,端面螺钉交替预紧。',
sheetRef: 2,
camera: { az: -0.6, po: 1.25, rd: 240, tx: 20, ty: 80, tz: 200 }
},
{
step: 6,
title: '电气线束主干铺设与扎带固定',
subtitle: '4芯高柔拖链电缆 · 各关节U型缓冲弯 · 防拉扯扎带',
desc: '将 4 芯总线(2×CAN 通信 + 2×24V 供电)从立柱内腔穿入,经各关节 T 接小板向 J1~J6 分支。在各零件外壳的专用扎带固定槽使用阻燃尼龙扎带锁紧,过关节处保留 35mm U 型自由弯曲余量。',
parts: [
{ idx: 14, name: 'J1 电机总线分支', role: '首端分支' },
{ idx: 15, name: 'J2 电机总线分支', role: '肩部供电' },
{ idx: 16, name: 'J3 电机总线分支', role: '大臂供电' },
{ idx: 17, name: 'J4 电机总线分支', role: '肘部供电' },
{ idx: 18, name: 'J5 电机总线分支', role: '腕俯仰供电' },
{ idx: 19, name: 'J6 电机总线分支', role: '末端供电' }
],
screws: [
{ spec: '2.5×100mm 阻燃扎带', qty: 12, role: '线束固定锚点锁紧' }
],
tips: '慢速全行程转动各关节,目视确认线束弯曲半径均大于 15mm 且无边缘摩擦。',
sheetRef: 0,
camera: { az: -0.7, po: 1.15, rd: 580, tx: 0, ty: 180, tz: 380 }
}
];
function applySheetTransform() {
const canvas = document.getElementById('sheetCanvas');
if (canvas) {
canvas.style.transform = `translate(${sheetPanX}px, ${sheetPanY}px) scale(${sheetScale})`;
}
const badge = document.getElementById('sheetZoomBadge');
if (badge) {
badge.textContent = Math.round(sheetScale * 100) + '%';
}
}
function fitSheetToScreen() {
const svg = document.querySelector('#sheetSvgWrapper svg');
if (!svg) return;
const vb = svg.viewBox.baseVal;
const W = (vb && vb.width) ? vb.width : (svg.clientWidth || 1200);
const H = (vb && vb.height) ? vb.height : (svg.clientHeight || 800);
const vp = document.getElementById('sheetViewport');
if (!vp) return;
const vw = vp.clientWidth, vh = vp.clientHeight;
if (!vw || !vh) return;
const pad = 36;
sheetScale = Math.min((vw - pad * 2) / W, (vh - pad * 2) / H);
sheetScale = Math.max(0.15, Math.min(sheetScale, 3.0));
sheetPanX = (vw - W * sheetScale) / 2;
sheetPanY = (vh - H * sheetScale) / 2;
applySheetTransform();
}
function renderSheet(idx) {
curSheetIdx = idx;
const isGuide = idx === 'guide';
const vp = document.getElementById('sheetViewport');
const gv = document.getElementById('sheetGuideView');
// Update tabs
document.querySelectorAll('#sheetTabs .sheet-tab').forEach(b => {
b.classList.toggle('on', b.dataset.sheet === String(idx));
});
if (isGuide) {
if (vp) vp.style.display = 'none';
if (gv) gv.style.display = 'flex';
renderGuideStep(currentGuideStep);
return;
}
if (gv) gv.style.display = 'none';
if (vp) vp.style.display = 'block';
const wrapper = document.getElementById('sheetSvgWrapper');
if (wrapper && typeof SHEETS !== 'undefined' && SHEETS[idx]) {
wrapper.innerHTML = SHEETS[idx];
const svg = wrapper.querySelector('svg');
if (svg) {
const vb = svg.viewBox && svg.viewBox.baseVal;
const w = (vb && vb.width) || 1200;
const h = (vb && vb.height) || 800;
svg.setAttribute('width', w);
svg.setAttribute('height', h);
svg.style.width = w + 'px';
svg.style.height = h + 'px';
}
}
// Update sheet title & desc
const meta = SHEET_METAS[idx] || {};
const tEl = document.getElementById('sheetInfoTitle');
const dEl = document.getElementById('sheetInfoDesc');
if (tEl) tEl.innerHTML = `<b>${meta.title || ''}</b>`;
if (dEl) dEl.textContent = meta.desc || '';
// Apply layer toggles
applySheetLayers();
// Reset highlight & tooltip
pinnedPartIdx = null;
clearSheetPartHighlight();
// Fit to screen
setTimeout(fitSheetToScreen, 20);
}
function applySheetLayers() {
const svg = document.querySelector('#sheetSvgWrapper svg');
if (!svg) return;
const bBal = document.getElementById('chkLyrBalloons');
const bDim = document.getElementById('chkLyrDims');
const bAxe = document.getElementById('chkLyrAxes');
const bLeg = document.getElementById('chkLyrLegend');
const showBal = bBal ? bBal.checked : true;
const showDim = bDim ? bDim.checked : true;
const showAxe = bAxe ? bAxe.checked : true;
const showLeg = bLeg ? bLeg.checked : true;
svg.querySelectorAll('.sheet-balloons-group, .sheet-balloon, .sheet-leader').forEach(el => {
el.style.display = showBal ? '' : 'none';
});
svg.querySelectorAll('.sheet-dims-group, .sheet-dim, .sheet-dim-line, .sheet-dim-ext, .sheet-dim-text').forEach(el => {
el.style.display = showDim ? '' : 'none';
});
svg.querySelectorAll('.sheet-axes-group, .sheet-axis, .sheet-axis-label').forEach(el => {
el.style.display = showAxe ? '' : 'none';
});
svg.querySelectorAll('.sheet-legend-box, .sheet-legend-row').forEach(el => {
el.style.display = showLeg ? '' : 'none';
});
}
function highlightSheetPart(idx, mouseEvent) {
const svg = document.querySelector('#sheetSvgWrapper svg');
if (!svg) return;
// Remove previous hover classes
svg.querySelectorAll('.is-hovered').forEach(el => el.classList.remove('is-hovered'));
// Highlight all elements with data-idx=idx
const targets = svg.querySelectorAll(`[data-idx="${idx}"]`);
targets.forEach(el => el.classList.add('is-hovered'));
// Highlight row in Left Tree / BOM
highlightLeftTreeRow(idx);
// Show Tooltip HUD
showSheetTooltip(idx, mouseEvent);
}
function clearSheetPartHighlight() {
const svg = document.querySelector('#sheetSvgWrapper svg');
if (svg) {
svg.querySelectorAll('.is-hovered').forEach(el => el.classList.remove('is-hovered'));
}
const tt = document.getElementById('sheetTooltip');
if (tt && !pinnedPartIdx) tt.style.display = 'none';
clearLeftTreeRow();
}
function showSheetTooltip(idx, e) {
const tt = document.getElementById('sheetTooltip');
if (!tt) return;
const part = DATA.parts.find(p => p.idx === idx);
if (!part) {
tt.style.display = 'none';
return;
}
const isPrinted = part.printed;
const volCm3 = (part.vol / 1000).toFixed(1);
const wtG = Math.round((part.vol / 1000) * 1.25);
const dimsStr = part.dims ? `${Math.round(part.dims[0])} × ${Math.round(part.dims[1])} × ${Math.round(part.dims[2])} mm` : '-';
const screwText = part.name.startsWith('P') ? 'M2/M3 内六角紧固螺钉' : '输出法兰配合';
tt.innerHTML = `
<div class="st-title">
<span>#${idx} ${part.cn || part.name}</span>
<span class="st-tag ${isPrinted ? 'pr' : 'ex'}">${isPrinted ? 'PLA 打印件' : '外购模组'}</span>
</div>
<div class="st-row"><span>设计编号:</span><span>${part.name.split('_')[0] || part.name}</span></div>
<div class="st-row"><span>包络三围:</span><span>${dimsStr}</span></div>
<div class="st-row"><span>净重估算:</span><span>${wtG} g (${volCm3} cm³)</span></div>
<div class="st-row"><span>连接关系:</span><span>${screwText}</span></div>
<button class="st-btn" onclick="jumpToPart3D(${idx})">🧊 在 3D 模型中定位高亮</button>
`;
tt.style.display = 'block';
// Position tooltip
const vp = document.getElementById('sheetViewport');
if (e && vp) {
const vr = vp.getBoundingClientRect();
let x = e.clientX - vr.left + 16;
let y = e.clientY - vr.top + 16;
if (x + 310 > vr.width) x = e.clientX - vr.left - 320;
if (y + 180 > vr.height) y = e.clientY - vr.top - 180;
tt.style.left = Math.max(10, x) + 'px';
tt.style.top = Math.max(10, y) + 'px';
}
}
function highlightLeftTreeRow(idx) {
document.querySelectorAll('#tree .row').forEach(r => {
if (r.dataset && r.dataset.idx === String(idx)) {
r.style.background = '#1e3a66';
r.style.borderRadius = '3px';
} else {
r.style.background = '';
}
});
}
function clearLeftTreeRow() {
document.querySelectorAll('#tree .row').forEach(r => r.style.background = '');
}
function jumpToPart3D(idx) {
const tabModel = document.querySelector('.tab[data-view="model"]');
if (tabModel) tabModel.click();
setTimeout(() => {
const m = meshes.find(x => x.userData && x.userData.idx === idx);
if (!m) return;
if (sel && !sel.userData.screw && !sel.userData.harness) {
sel.material.emissive.setHex(0);
sel.material.emissiveIntensity = 0;
}
sel = m;
m.visible = true;
if (m.chk) m.chk.checked = true;
m.material.emissive.setHex(0x2f6bff);
m.material.emissiveIntensity = 0.8;
const box = new THREE.Box3().setFromObject(m);
const center = new THREE.Vector3();
box.getCenter(center);
tx = center.x; ty = center.y; tz = center.z;
rd = Math.max(220, box.getSize(new THREE.Vector3()).length() * 2.2);
requestRender();
const hud = document.getElementById('hud');
if (hud) {
hud.innerHTML = `<b>已在3D中定位: #${idx} ${m.userData.cn || ''}</b> · ` +
(m.userData.printed ? 'PLA打印件' : '外购电机/标准件') +
` <span class="hudlink" onclick="document.querySelector('.tab[data-view=sheet]').click()">[返回装配图]</span>`;
}
}, 60);
}
function renderGuideStep(stepIdx) {
currentGuideStep = Math.max(0, Math.min(stepIdx, ASSEMBLY_STEPS.length - 1));
const step = ASSEMBLY_STEPS[currentGuideStep];
if (!step) return;
// Render step chips
const bar = document.getElementById('guideStepsBar');
if (bar) {
bar.innerHTML = ASSEMBLY_STEPS.map((s, i) => `
<div class="guide-step-chip ${i === currentGuideStep ? 'active' : ''}" onclick="renderGuideStep(${i})">
<b>步骤 ${s.step}</b> ${s.title.split(' ')[0]}
</div>
`).join('');
}
// Render left panel
const pLeft = document.getElementById('guidePanelLeft');
if (pLeft) {
pLeft.innerHTML = `
<div style="font-size:11px;color:var(--warn);font-weight:bold;letter-spacing:.6px">工序 ${step.step} / ${ASSEMBLY_STEPS.length}</div>
<div style="font-size:15px;font-weight:bold;color:#fff">${step.title}</div>
<div style="font-size:12px;color:var(--dim);margin-top:-6px">${step.subtitle}</div>
<div class="guide-card">
<div class="guide-card-title">📖 装配任务说明</div>
<div style="font-size:12px;line-height:1.6;color:var(--tx)">${step.desc}</div>
</div>
<div class="guide-card">
<div class="guide-card-title">📦 本工序构件 (${step.parts.length} 件)</div>
<div class="guide-part-list">
${step.parts.map(p => `
<div class="guide-part-badge" onclick="jumpToPart3D(${p.idx})">
<b>#${p.idx}</b> ${p.name} <span style="color:var(--dim);font-size:10px">(${p.role})</span>
</div>
`).join('')}
</div>
</div>
<div class="guide-card">
<div class="guide-card-title">🔩 紧固件与螺钉清单</div>
<div style="display:flex;flex-direction:column;gap:5px;font-size:11.5px">
${step.screws.map(s => `
<div style="display:flex;justify-content:space-between;color:var(--tx)">
<span><b>${s.spec}</b> × ${s.qty}</span>
<span style="color:var(--dim)">${s.role}</span>
</div>
`).join('')}
</div>
</div>
<div class="guide-card" style="border-color:rgba(245,158,11,.4);background:rgba(245,158,11,.06)">
<div class="guide-card-title">⚠️ 工艺公差与装配要点</div>
<div style="font-size:11.5px;color:#fef3c7;line-height:1.5">${step.tips}</div>
</div>
<div class="guide-actions">
<button class="guide-btn guide-btn-nav" ${currentGuideStep === 0 ? 'disabled style="opacity:.4"' : ''} onclick="renderGuideStep(${currentGuideStep - 1})">⬅ 上一步</button>
<button class="guide-btn guide-btn-nav" ${currentGuideStep === ASSEMBLY_STEPS.length - 1 ? 'disabled style="opacity:.4"' : ''} onclick="renderGuideStep(${currentGuideStep + 1})">下一步 ➡</button>
<button class="guide-btn guide-btn-3d" onclick="showAssemblyStepIn3D(${currentGuideStep})">🧊 3D模拟装配</button>
</div>
`;
}
// Render right preview: show relevant sheet for this step
const cw = document.getElementById('guideCanvasWrapper');
if (cw && typeof SHEETS !== 'undefined') {
cw.innerHTML = SHEETS[step.sheetRef];
const svg = cw.querySelector('svg');
if (svg) {
svg.style.maxWidth = '92%';
svg.style.maxHeight = '92%';
svg.style.width = 'auto';
svg.style.height = 'auto';
svg.style.boxShadow = '0 10px 40px rgba(0,0,0,.6)';
svg.style.borderRadius = '6px';
// Highlight step parts in SVG
const stepPartIdxs = step.parts.map(p => p.idx);
svg.querySelectorAll('.sheet-part-path').forEach(p => {
const idx = parseInt(p.dataset.idx);
if (stepPartIdxs.includes(idx)) {
p.style.stroke = '#f59e0b';
p.style.strokeWidth = '2.8px';
p.style.filter = 'drop-shadow(0 0 6px #f59e0b)';
} else {
p.style.opacity = '0.35';
}
});
svg.querySelectorAll('.sheet-balloon').forEach(b => {
const idx = parseInt(b.dataset.idx);
if (stepPartIdxs.includes(idx)) {
const c = b.querySelector('.balloon-circle');
if (c) { c.style.fill = '#f59e0b'; c.style.stroke = '#ffffff'; }
const t = b.querySelector('.balloon-text');
if (t) t.style.fill = '#ffffff';
} else {
b.style.opacity = '0.3';
}
});
}
}
}
function showAssemblyStepIn3D(stepIdx) {
const step = ASSEMBLY_STEPS[stepIdx];
if (!step) return;
const tabModel = document.querySelector('.tab[data-view="model"]');
if (tabModel) tabModel.click();
setTimeout(() => {
if (step.camera) {
az = step.camera.az; po = step.camera.po; rd = step.camera.rd;
tx = step.camera.tx; ty = step.camera.ty; tz = step.camera.tz;
}
const stepPartIdxs = step.parts.map(p => p.idx);
meshes.forEach(m => {
if (m.userData && stepPartIdxs.includes(m.userData.idx)) {
m.visible = true;
if (m.chk) m.chk.checked = true;
m.material.emissive.setHex(0xd97706);
m.material.emissiveIntensity = 0.65;
} else if (m.userData && !m.userData.screw && !m.userData.harness) {
m.material.emissive.setHex(0);
m.material.emissiveIntensity = 0;
}
});
requestRender();
const hud = document.getElementById('hud');
if (hud) {
hud.innerHTML = `<b>装配向导第 ${step.step} 步: ${step.title}</b> · 构件高亮呈现 ` +
`<span class="hudlink" onclick="document.querySelector('.tab[data-view=sheet]').click()">[返回装配向导]</span>`;
}
}, 60);
}
function initSheetViewer() {
if (sheetInited) return;
sheetInited = true;
const vp = document.getElementById('sheetViewport');
const canvas = document.getElementById('sheetCanvas');
// Sheet Tabs Click
document.querySelectorAll('#sheetTabs .sheet-tab').forEach(b => {
b.onclick = () => {
const idx = b.dataset.sheet === 'guide' ? 'guide' : parseInt(b.dataset.sheet);
renderSheet(idx);
};
});
// Exit Guide button
const btnExit = document.getElementById('btnExitGuide');
if (btnExit) btnExit.onclick = () => renderSheet(0);
// Theme Switcher Click
document.querySelectorAll('#sheetToolbar [data-theme]').forEach(b => {
b.onclick = () => {
document.querySelectorAll('#sheetToolbar [data-theme]').forEach(x => x.classList.remove('on'));
b.classList.add('on');
curSheetTheme = b.dataset.theme;
const sh = document.getElementById('sheet');
if (sh) sh.className = 'theme-' + curSheetTheme;
};
});
// Layer Checkboxes
['chkLyrBalloons', 'chkLyrDims', 'chkLyrAxes', 'chkLyrLegend'].forEach(id => {
const el = document.getElementById(id);
if (el) el.onchange = () => applySheetLayers();
});
// 3D Bridge Button
const btn3D = document.getElementById('btnSheetTo3D');
if (btn3D) {
btn3D.onclick = () => {
if (pinnedPartIdx) jumpToPart3D(pinnedPartIdx);
else {
const tabModel = document.querySelector('.tab[data-view="model"]');
if (tabModel) tabModel.click();
}
};
}
// Float Bar Buttons
const sfbIn = document.getElementById('sfbIn');
if (sfbIn) sfbIn.onclick = () => {
sheetScale = Math.min(6.0, sheetScale * 1.25);
applySheetTransform();
};
const sfbOut = document.getElementById('sfbOut');
if (sfbOut) sfbOut.onclick = () => {
sheetScale = Math.max(0.15, sheetScale / 1.25);
applySheetTransform();
};
const sfbFit = document.getElementById('sfbFit');
if (sfbFit) sfbFit.onclick = () => fitSheetToScreen();
const sfb100 = document.getElementById('sfb100');
if (sfb100) sfb100.onclick = () => {
sheetScale = 1.0;
const vp = document.getElementById('sheetViewport');
const svg = document.querySelector('#sheetSvgWrapper svg');
if (vp && svg) {
sheetPanX = (vp.clientWidth - (svg.viewBox.baseVal.width || 1200)) / 2;
sheetPanY = (vp.clientHeight - (svg.viewBox.baseVal.height || 800)) / 2;
}
applySheetTransform();
};
const sfbReset = document.getElementById('sfbReset');
if (sfbReset) sfbReset.onclick = () => fitSheetToScreen();
const sfbFull = document.getElementById('sfbFull');
if (sfbFull) {
sfbFull.onclick = () => {
const sh = document.getElementById('sheet');
if (!document.fullscreenElement) {
if (sh && sh.requestFullscreen) sh.requestFullscreen();
} else {
if (document.exitFullscreen) document.exitFullscreen();
}
};
}
const badge = document.getElementById('sheetZoomBadge');
if (badge) badge.onclick = () => fitSheetToScreen();
// Viewport Wheel Zoom
if (vp) {
vp.addEventListener('wheel', e => {
e.preventDefault();
const rect = vp.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.15 : 0.87;
const newScale = Math.max(0.15, Math.min(6.0, sheetScale * factor));
sheetPanX = mx - (mx - sheetPanX) * (newScale / sheetScale);
sheetPanY = my - (my - sheetPanY) * (newScale / sheetScale);
sheetScale = newScale;
applySheetTransform();
}, { passive: false });
// Drag Pan
vp.addEventListener('mousedown', e => {
if (e.button !== 0 && e.button !== 1) return;
isSheetDragging = true;
vp.classList.add('is-dragging');
sheetDragStartX = e.clientX - sheetPanX;
sheetDragStartY = e.clientY - sheetPanY;
});
window.addEventListener('mousemove', e => {
if (!isSheetDragging) return;
sheetPanX = e.clientX - sheetDragStartX;
sheetPanY = e.clientY - sheetDragStartY;
applySheetTransform();
});
window.addEventListener('mouseup', () => {
if (isSheetDragging) {
isSheetDragging = false;
vp.classList.remove('is-dragging');
}
});
vp.addEventListener('dblclick', e => {
if (e.target.closest('.sheet-part-path') || e.target.closest('.sheet-balloon')) return;
fitSheetToScreen();
});
}
// Interactive Hover & Click on Canvas
if (canvas) {
canvas.addEventListener('mouseover', e => {
const hit = e.target.closest('[data-idx]');
if (!hit) return;
const idx = parseInt(hit.dataset.idx);
if (!isNaN(idx)) highlightSheetPart(idx, e);
});
canvas.addEventListener('mouseout', e => {
const hit = e.target.closest('[data-idx]');
if (!hit) return;
if (!pinnedPartIdx) clearSheetPartHighlight();
});
canvas.addEventListener('click', e => {
const hit = e.target.closest('[data-idx]');
if (hit) {
const idx = parseInt(hit.dataset.idx);
if (!isNaN(idx)) {
if (pinnedPartIdx === idx) {
pinnedPartIdx = null;
clearSheetPartHighlight();
} else {
pinnedPartIdx = idx;
highlightSheetPart(idx, e);
const tt = document.getElementById('sheetTooltip');
if (tt) tt.classList.add('pinned');
}
}
} else {
pinnedPartIdx = null;
clearSheetPartHighlight();
const tt = document.getElementById('sheetTooltip');
if (tt) { tt.classList.remove('pinned'); tt.style.display = 'none'; }
}
});
}
// Initial render of first sheet
renderSheet(0);
}
function openSheetView() {
initSheetViewer();
const footM = document.getElementById('footModel');
const footS = document.getElementById('footSheet');
if (footM) footM.style.display = 'none';
if (footS) footS.style.display = 'inline';
setTimeout(fitSheetToScreen, 40);
}
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';
document.body.classList.toggle('sheet-mode', s);
sheet.style.display = s ? 'flex' : 'none';
cv.style.display = s ? 'none' : 'block';
const footM = document.getElementById('footModel');
const footS = document.getElementById('footSheet');
if (s) {
if (footM) footM.style.display = 'none';
if (footS) footS.style.display = 'inline';
openSheetView();
} else {
if (footM) footM.style.display = 'inline';
if (footS) footS.style.display = 'none';
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;
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();
/* 按需渲染:needRender 由 requestRender() 置位(所有交互与状态变化都已调用它;
旧版置位后全文件无读取点,等于恒以显示器刷新率空转满帧渲染)。
装配图页画布隐藏时【不清标志】,切回三维页由 tab 处理器 resize()+requestRender() 补渲染。
preserveDrawingBuffer 必须保持 true:不重绘时靠它保住最后一帧画面。 */
function loop() {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
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('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 && b.classList.contains('on')) b.onclick(); }
if (q.get('xray') === '1') { const b = document.getElementById('bXRay'); if (b && !b.classList.contains('on')) b.onclick(); }
if (q.get('wire') === '1') { const b = document.getElementById('bWire'); if (b && !b.classList.contains('on')) b.onclick(); }
if (q.has('alpha')) {
const a = +q.get('alpha');
meshes.forEach(m => {
if (m.userData.printed) {
m.material.transparent = true;
m.material.opacity = a;
m.material.depthWrite = a > 0.8;
}
});
}
if (q.get('harn') === '0') { const b = document.getElementById('bHarn'); if (b && b.classList.contains('on')) b.onclick(); }
/* 视图模式与工程图状态也要能用 URL 驱动:否则工程图/装配向导这两种模式(约占一半
CSS 与 700 行 JS)没法用 headless 截图回归,改动只能靠人眼抽查。 */
if (q.has('view')) {
const t = document.querySelector('.tab[data-view="' + q.get('view') + '"]');
if (t) t.click();
}
if (q.has('sheet')) {
const b = document.querySelector('#sheetTabs .sheet-tab[data-sheet="' + q.get('sheet') + '"]');
if (b) b.click();
}
if (q.has('theme')) {
const b = document.querySelector('.sheet-btn[data-theme="' + q.get('theme') + '"]');
if (b) b.click();
}
if (q.has('guide')) { const g = +q.get('guide'); if (isFinite(g)) renderGuideStep(g); }
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();
})();