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

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

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

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

716 lines
28 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.
/* 二维工程图与分步装配向导 */
/* =========================================================================
交互式装配图与分步装配向导引擎 (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();
}
});