Files
Robot_Arm/src/viewer_tpl.html
T

2971 lines
130 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>六电机人形机器人手臂 · 装配与打印模型</title>
<style>
:root{--bg:#0f1115;--pan:#171a21;--pan2:#1f242e;--tx:#e6e8ee;--dim:#8b93a7;--ac:#4c9aff;--ok:#3ddc97;--warn:#ffb454;--line:#2b323e}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--tx);
font:13px/1.55 "Microsoft YaHei","Segoe UI",system-ui,sans-serif;overflow:hidden;
-webkit-user-select:none;user-select:none}
#app{display:grid;grid-template-columns:322px minmax(430px,1fr);grid-template-rows:46px auto 1fr 26px;
height:100%;min-width:0;overflow-x:auto}
header{grid-column:1/-1;display:flex;align-items:center;gap:8px;padding:0 12px;flex-wrap:nowrap;
overflow-x:auto;overflow-y:hidden;background:var(--pan);border-bottom:1px solid var(--line)}
header .q{white-space:nowrap;flex:none;color:var(--dim);font-size:12px}
header b{font-size:14px;white-space:nowrap;flex:none}
header .sp{flex:1 1 auto;min-width:6px}
button,select{background:var(--pan2);color:var(--tx);border:1px solid var(--line);
border-radius:5px;padding:4px 9px;font:inherit;cursor:pointer;white-space:nowrap;flex:none}
button:hover{border-color:var(--ac);color:#fff}
button.on{background:#22406b;border-color:var(--ac)}
#bHarn.on{background:#5c3b1e;border-color:#f59e0b;color:#fbbf24}
aside{background:var(--pan);overflow:auto;padding:8px;min-width:0}
#left{border-right:1px solid var(--line)}
#app.l{grid-template-columns:322px minmax(430px,1fr)}
#stage{position:relative;overflow:hidden;min-width:0;
background:radial-gradient(120% 120% at 50% 0%,#1b2028 0%,#0d0f13 70%)}
canvas{display:block;width:100%;height:100%}
footer{grid-column:1/-1;background:var(--pan);border-top:1px solid var(--line);
display:flex;align-items:center;gap:14px;padding:0 12px;color:var(--dim);font-size:12px;
white-space:nowrap;overflow:hidden}
@media (max-width:1150px){
#app{grid-template-columns:300px minmax(400px,1fr)}
}
@media (max-width:760px){
#app{grid-template-columns:minmax(0,1fr)}
#left{display:none}
#app.l #left{display:block}
}
h3{font-size:12px;margin:10px 0 6px;color:var(--ac);letter-spacing:.4px;-webkit-user-select:none;user-select:none}
.row{display:flex;align-items:center;gap:6px;margin:3px 0}
.row label{flex:1;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;-webkit-user-select:none;user-select:none}
.row .q{color:var(--dim);font-size:11px;-webkit-user-select:none;user-select:none}
.chip{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;flex:none}
.pr{color:var(--ok)}.ex{color:var(--warn)}.mo{color:#9aa4b8}
table{width:100%;border-collapse:collapse;font-size:11.5px;-webkit-user-select:text;user-select:text}
th,td{border-bottom:1px solid var(--line);padding:3px 4px;text-align:left;vertical-align:top}
th{color:var(--dim);font-weight:400;position:sticky;top:0;background:var(--pan);-webkit-user-select:none;user-select:none}
.j-box{margin-bottom:7px;padding:3px 4px 1px;border-radius:4px;transition:background .15s}
.j-box:hover{background:rgba(255,255,255,.03)}
.j-box label{cursor:default;font-weight:500}
.j-val{font-size:11px;color:var(--ac);cursor:pointer;padding:1px 5px;border-radius:3px;
background:rgba(76,154,255,.1);font-variant-numeric:tabular-nums;transition:all .15s;
-webkit-user-select:none;user-select:none}
.j-val:hover{background:var(--ac);color:#fff}
input[type=range]{width:100%;height:18px;accent-color:var(--ac);cursor:pointer;display:block;margin:1px 0}
label em{font-style:normal;color:var(--mu);font-size:10.5px;margin-left:4px}
label em.ok{color:#5ddc90}
.gr{font-size:11px;color:var(--dim);margin:2px 0 4px;-webkit-user-select:none;user-select:none}
#hud{position:absolute;left:10px;top:8px;pointer-events:none;font-size:11.5px;color:var(--dim);
background:rgba(15,17,21,.72);border:1px solid var(--line);border-radius:6px;padding:6px 9px;max-width:60%}
#hud b{color:var(--tx)}
#hud:empty{display:none}
#note{position:absolute;right:10px;bottom:10px;max-width:330px;background:rgba(15,17,21,.88);
border:1px solid var(--ac);border-radius:7px;padding:8px 10px;font-size:12px;display:none;
-webkit-user-select:text;user-select:text}
.kv{display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11.5px;-webkit-user-select:text;user-select:text}
.kv i{color:var(--dim);font-style:normal;-webkit-user-select:none;user-select:none}
.tab{padding:4px 9px;-webkit-user-select:none;user-select:none}
.bad{color:#ff6b6b}.good{color:var(--ok)}
svg{display:block;width:100%;height:auto;background:#fff;border-radius:6px}
#sheet{display:none;padding:10px;overflow:auto;height:100%}
/* ==================== 交互式装配图查看器 (Interactive Assembly Drawing) ==================== */
#sheet {
display: none;
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
flex-direction: column;
background: #11141a;
overflow: hidden;
z-index: 10;
padding: 0;
}
body.sheet-mode #sheet {
display: flex !important;
}
/* 隐藏 3D 专属工具条按钮(当处于装配图时) */
body.sheet-mode #bAsm,
body.sheet-mode #bExp,
body.sheet-mode #exp,
body.sheet-mode #cam,
body.sheet-mode #bWire,
body.sheet-mode #bXRay,
body.sheet-mode #bScr,
body.sheet-mode #bHarn,
body.sheet-mode #bEnv,
body.sheet-mode #bFit {
display: none !important;
}
body.sheet-mode footer #footModel { display: none !important; }
body.sheet-mode footer #footSheet { display: inline !important; }
/* 顶部装配图工具栏 */
#sheetToolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--pan);
border-bottom: 1px solid var(--line);
flex: none;
min-height: 40px;
overflow-x: auto;
white-space: nowrap;
}
.sheet-tabs {
display: flex;
gap: 5px;
align-items: center;
}
.sheet-tab {
background: var(--pan2);
color: var(--tx);
border: 1px solid var(--line);
border-radius: 5px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all .15s ease;
}
.sheet-tab:hover {
border-color: var(--ac);
color: #fff;
}
.sheet-tab.on {
background: #1e3a66;
border-color: var(--ac);
color: #fff;
font-weight: 600;
box-shadow: 0 0 10px rgba(76,154,255,.25);
}
.sheet-tab.guide-tab {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.4);
color: #f59e0b;
}
.sheet-tab.guide-tab.on {
background: #78350f;
border-color: #f59e0b;
color: #fef3c7;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
}
.sheet-vsep {
width: 1px;
height: 20px;
background: var(--line);
margin: 0 4px;
flex: none;
}
.sheet-btn-group {
display: flex;
border: 1px solid var(--line);
border-radius: 5px;
overflow: hidden;
flex: none;
}
.sheet-btn-group .sheet-btn {
border: none;
border-radius: 0;
padding: 3px 8px;
font-size: 11.5px;
background: var(--pan2);
border-right: 1px solid var(--line);
cursor: pointer;
}
.sheet-btn-group .sheet-btn:last-child {
border-right: none;
}
.sheet-btn-group .sheet-btn.on {
background: var(--ac);
color: #fff;
font-weight: 600;
}
.sheet-layers {
display: flex;
gap: 8px;
align-items: center;
font-size: 11.5px;
color: var(--dim);
}
.sheet-layers label {
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.sheet-layers input[type=checkbox] {
accent-color: var(--ac);
cursor: pointer;
}
.sheet-space {
flex: 1;
min-width: 8px;
}
.sheet-btn-accent {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: #fff;
border: 1px solid #60a5fa;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 5px;
cursor: pointer;
transition: all .15s;
}
.sheet-btn-accent:hover {
background: linear-gradient(135deg, #2563eb, #3b82f6);
box-shadow: 0 0 12px rgba(96,165,250,.4);
}
/* 主视口区域 */
#sheetViewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
cursor: grab;
background: #0f1217;
transition: background 0.2s;
}
#sheetViewport.is-dragging {
cursor: grabbing !important;
}
/* 画布与缩放平移 */
#sheetCanvas {
position: absolute;
top: 0; left: 0;
transform-origin: 0 0;
will-change: transform;
}
#sheetSvgWrapper {
display: inline-block;
vertical-align: top;
box-shadow: 0 8px 32px rgba(0,0,0,.5);
border-radius: 6px;
overflow: hidden;
transition: box-shadow 0.2s;
}
#sheetSvgWrapper svg {
display: block;
border-radius: 6px;
}
/* 视觉风格主题 */
#sheet.theme-paper #sheetViewport {
background: #cbd5e1;
}
#sheet.theme-paper #sheetSvgWrapper {
box-shadow: 0 10px 40px rgba(0,0,0,.35);
}
#sheet.theme-blueprint #sheetViewport {
background: #06182c;
background-image:
linear-gradient(rgba(14, 116, 144, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(14, 116, 144, 0.15) 1px, transparent 1px);
background-size: 24px 24px;
}
#sheet.theme-blueprint #sheetSvgWrapper {
box-shadow: 0 0 40px rgba(14, 165, 233, 0.25);
border: 1px solid rgba(56, 189, 248, 0.3);
}
#sheet.theme-blueprint .sheet-bg {
fill: #0a2240 !important;
}
#sheet.theme-blueprint .sheet-border-outer,
#sheet.theme-blueprint .sheet-border-inner,
#sheet.theme-blueprint .sheet-title-line,
#sheet.theme-blueprint .sheet-meta-line {
stroke: #38bdf8 !important;
}
#sheet.theme-blueprint .sheet-title-text {
fill: #f0f9ff !important;
}
#sheet.theme-blueprint .sheet-note-text,
#sheet.theme-blueprint .sheet-scale-text,
#sheet.theme-blueprint .sheet-ver-text {
fill: #bae6fd !important;
}
#sheet.theme-blueprint .sheet-axis,
#sheet.theme-blueprint .sheet-axis-label,
#sheet.theme-blueprint .sheet-dim-line,
#sheet.theme-blueprint .sheet-dim-ext,
#sheet.theme-blueprint .sheet-dim-text {
stroke: #7dd3fc !important;
fill: #7dd3fc !important;
}
#sheet.theme-dark #sheetViewport {
background: #090b0e;
}
#sheet.theme-dark .sheet-bg {
fill: #14171d !important;
}
#sheet.theme-dark .sheet-border-outer,
#sheet.theme-dark .sheet-border-inner,
#sheet.theme-dark .sheet-title-line,
#sheet.theme-dark .sheet-meta-line {
stroke: #374151 !important;
}
#sheet.theme-dark .sheet-title-text {
fill: #f3f4f6 !important;
}
#sheet.theme-dark .sheet-note-text,
#sheet.theme-dark .sheet-scale-text,
#sheet.theme-dark .sheet-ver-text {
fill: #9ca3af !important;
}
/* 浮动工具条 */
#sheetFloatBar {
position: absolute;
right: 14px;
top: 14px;
display: flex;
flex-direction: column;
gap: 5px;
z-index: 25;
}
.sfb-btn {
background: rgba(23, 26, 33, 0.88);
backdrop-filter: blur(8px);
color: var(--tx);
border: 1px solid rgba(255,255,255,.12);
border-radius: 6px;
padding: 6px 10px;
font-size: 11.5px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,.35);
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
transition: all .15s ease;
}
.sfb-btn:hover {
background: #1e3a66;
border-color: var(--ac);
color: #fff;
transform: translateY(-1px);
}
/* 缩放指示徽标 */
#sheetZoomBadge {
position: absolute;
right: 14px;
bottom: 12px;
background: rgba(15, 17, 21, 0.82);
backdrop-filter: blur(6px);
border: 1px solid var(--line);
color: var(--dim);
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
font-variant-numeric: tabular-nums;
cursor: pointer;
z-index: 20;
}
#sheetZoomBadge:hover {
color: var(--ac);
border-color: var(--ac);
}
/* 信息条 */
#sheetInfoBar {
position: absolute;
left: 14px;
bottom: 12px;
max-width: 70%;
background: rgba(15, 17, 21, 0.85);
backdrop-filter: blur(6px);
border: 1px solid var(--line);
border-radius: 5px;
padding: 4px 10px;
font-size: 11.5px;
color: var(--dim);
z-index: 20;
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#sheetInfoBar b {
color: var(--tx);
}
.sheet-info-sep {
color: var(--line);
}
/* 悬停 Tooltip HUD */
.sheet-tooltip {
position: absolute;
z-index: 99;
background: rgba(15, 17, 21, 0.94);
backdrop-filter: blur(10px);
border: 1px solid var(--ac);
border-radius: 7px;
padding: 8px 12px;
font-size: 12px;
color: var(--tx);
box-shadow: 0 10px 28px rgba(0,0,0,.6);
pointer-events: none;
display: none;
max-width: 310px;
line-height: 1.5;
}
.sheet-tooltip.pinned {
pointer-events: auto;
border-color: #38bdf8;
box-shadow: 0 12px 36px rgba(0, 168, 255, 0.35);
}
.sheet-tooltip .st-title {
font-size: 13px;
font-weight: bold;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255,255,255,.1);
}
.sheet-tooltip .st-tag {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
font-weight: normal;
}
.st-tag.pr { background: rgba(61,220,151,.2); color: var(--ok); }
.st-tag.ex { background: rgba(255,180,84,.2); color: var(--warn); }
.sheet-tooltip .st-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 11px;
color: var(--dim);
margin: 2px 0;
}
.sheet-tooltip .st-row span:last-child {
color: var(--tx);
text-align: right;
}
.sheet-tooltip .st-btn {
margin-top: 6px;
width: 100%;
padding: 4px 0;
font-size: 11px;
background: #1d4ed8;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: background .15s;
}
.sheet-tooltip .st-btn:hover {
background: #2563eb;
}
/* SVG 交互高亮样式 */
.sheet-part-path {
cursor: pointer;
transition: stroke .12s, stroke-width .12s, filter .12s;
}
.sheet-part-path.is-hovered {
stroke: #00d2ff !important;
stroke-width: 2.8px !important;
fill: rgba(0, 210, 255, 0.16) !important;
filter: drop-shadow(0 0 5px #00a8ff) !important;
}
.sheet-balloon {
cursor: pointer;
transition: transform .15s ease;
}
.sheet-balloon:hover .balloon-circle,
.sheet-balloon.is-hovered .balloon-circle {
fill: #0284c7 !important;
stroke: #ffffff !important;
stroke-width: 2px !important;
filter: drop-shadow(0 0 6px rgba(2,132,199,.8)) !important;
}
.sheet-balloon:hover .balloon-text,
.sheet-balloon.is-hovered .balloon-text {
fill: #ffffff !important;
font-weight: bold !important;
}
.sheet-leader.is-hovered {
stroke: #00d2ff !important;
stroke-width: 1.8px !important;
}
.sheet-legend-row {
cursor: pointer;
}
.sheet-legend-row:hover .legend-row-bg,
.sheet-legend-row.is-hovered .legend-row-bg {
fill: rgba(2, 132, 199, 0.3) !important;
}
.sheet-legend-row:hover text,
.sheet-legend-row.is-hovered text {
fill: #00d2ff !important;
font-weight: bold !important;
}
.joint-geom-part {
cursor: pointer;
transition: all .15s;
}
.joint-geom-part:hover, .joint-geom-part.is-hovered {
stroke: #00d2ff !important;
stroke-width: 2.2px !important;
fill: rgba(0, 210, 255, 0.15) !important;
}
/* 分步装配向导面板样式 */
#sheetGuideView {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--bg);
display: flex;
flex-direction: column;
z-index: 15;
overflow: hidden;
}
.guide-header {
padding: 8px 14px;
background: var(--pan);
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: none;
}
.guide-steps-bar {
display: flex;
gap: 6px;
overflow-x: auto;
align-items: center;
flex: 1;
}
.guide-step-chip {
background: var(--pan2);
border: 1px solid var(--line);
border-radius: 20px;
padding: 4px 12px;
font-size: 11.5px;
color: var(--dim);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
transition: all .15s;
}
.guide-step-chip:hover {
border-color: var(--warn);
color: var(--tx);
}
.guide-step-chip.active {
background: #78350f;
border-color: #f59e0b;
color: #fff;
font-weight: bold;
box-shadow: 0 0 10px rgba(245,158,11,.3);
}
.guide-body {
display: grid;
grid-template-columns: 360px 1fr;
flex: 1;
min-height: 0;
}
@media (max-width: 900px) {
.guide-body {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
}
.guide-panel-left {
background: var(--pan);
border-right: 1px solid var(--line);
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.guide-panel-right {
position: relative;
overflow: hidden;
background: #0f1217;
display: flex;
align-items: center;
justify-content: center;
}
.guide-card {
background: var(--pan2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
}
.guide-card-title {
font-size: 12px;
font-weight: bold;
color: var(--warn);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.guide-part-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.guide-part-badge {
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.1);
border-radius: 4px;
padding: 3px 7px;
font-size: 11px;
color: var(--tx);
cursor: pointer;
transition: all .15s;
}
.guide-part-badge:hover {
border-color: var(--ac);
background: rgba(76,154,255,.15);
color: #fff;
}
.guide-actions {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 10px;
}
.guide-btn {
flex: 1;
padding: 7px 0;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
transition: all .15s;
}
.guide-btn-nav {
background: var(--pan2);
border: 1px solid var(--line);
color: var(--tx);
}
.guide-btn-nav:hover {
border-color: var(--ac);
color: #fff;
}
.guide-btn-3d {
background: linear-gradient(135deg, #b45309, #d97706);
border: 1px solid #f59e0b;
color: #fff;
font-weight: bold;
}
.guide-btn-3d:hover {
background: linear-gradient(135deg, #d97706, #f59e0b);
}
small{color:var(--dim)}
/* ---- edge BOM dock (collapsible drawer at the right edge) ---- */
#bomTab{position:fixed;right:0;top:64px;z-index:30;writing-mode:vertical-rl;text-orientation:mixed;
background:var(--pan2);color:var(--tx);border:1px solid var(--line);border-right:none;
border-radius:6px 0 0 6px;padding:12px 6px;font:inherit;cursor:pointer;letter-spacing:3px}
#bomTab:hover{border-color:var(--ac);color:#fff}
#bomTab b{color:var(--ac)}
#bomDock{position:fixed;right:0;top:46px;bottom:26px;width:min(430px,94vw);z-index:29;
transform:translateX(103%);transition:transform .22s ease;display:flex;flex-direction:column;
background:var(--pan);border-left:1px solid var(--ac);box-shadow:-10px 0 28px rgba(0,0,0,.5)}
#bomDock.open{transform:none}
#bomDock .dk-hd{display:flex;align-items:center;gap:8px;padding:9px 10px;flex:none;
border-bottom:1px solid var(--line)}
#bomDock .dk-hd b{font-size:13px}
#bomDock .dk-hd .q{color:var(--dim);font-size:11px;min-width:0;flex:0 1 auto;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#bomDock .dk-x{margin-left:auto}
#bomDock .dk-bd{overflow:auto;padding:8px 11px 16px;flex:1;min-height:0}
.dk-sec{margin:4px 0 5px;color:var(--ac);font-size:12px;letter-spacing:.4px}
.dk-note{color:var(--dim);font-size:11px;margin:1px 0 6px}
#screws td{white-space:normal}
#screws td:first-child{white-space:nowrap}
#screws tbody tr{cursor:pointer;transition:background .12s}
#screws tbody tr:hover{background:rgba(76,154,255,.14)}
#screws tbody tr.scrHl{background:#1d3f74;box-shadow:inset 3px 0 0 var(--warn)}
.hudlink{pointer-events:auto;cursor:pointer;color:var(--ac);text-decoration:underline;margin-left:8px}
@media (max-width:760px){#bomTab{top:auto;bottom:36px}#bomDock{top:46px;bottom:26px}}
/* ===================== IK 控制条(两行:目标+姿态 / 模式+状态) ===================== */
#ikbar{grid-column:1/-1;display:flex;flex-direction:column;gap:4px;padding:5px 10px;
background:linear-gradient(180deg,#1c212b,#161922);border-bottom:1px solid var(--line)}
#ikbar .ikrow{display:flex;align-items:center;gap:6px;min-height:26px;
overflow-x:auto;overflow-y:hidden;white-space:nowrap}
#ikbar .ik-t{color:var(--ac);font-weight:700;letter-spacing:.8px;flex:none;font-size:11.5px;
border:1px solid var(--ac);border-radius:4px;padding:1px 6px}
#ikbar .lb{color:var(--dim);flex:none;font-size:12px}
#ikbar input[type=number]{width:68px;background:#0d1015;color:var(--tx);border:1px solid var(--line);
border-radius:4px;padding:3px 5px;font:inherit;font-variant-numeric:tabular-nums;flex:none}
#ikbar input[type=number]:focus{outline:none;border-color:var(--ac)}
#ikbar .vsep{width:1px;height:20px;background:var(--line);flex:none;margin:0 3px}
#ikbar .st{color:var(--dim);flex:none;font-size:11.5px;font-variant-numeric:tabular-nums}
#ikbar .sep{color:#3b4453;margin:0 5px}
#ikbar .hint{color:var(--dim);font-size:11px;flex:none}
#ikbar .good{color:var(--ok)} #ikbar .bad{color:#ff6b6b} #ikbar .hi{color:var(--warn)}
#ikSolve{background:#1d3f74;border-color:var(--ac);color:#fff;font-weight:600}
#ikSolve:hover{background:#24508f}
#ikPose.on{background:#3a2b63;border-color:#a06bff;color:#fff;font-weight:600}
#ikPoseBox{display:none;align-items:center;gap:6px}
#ikPoseBox input[type=number]{width:54px}
#ikMixBox{display:none;align-items:center;gap:6px;flex:none}
#ikMixBox input[type=range]{width:118px;flex:none;accent-color:var(--ac);cursor:pointer}
#ikMixBox .st{min-width:56px}
/* ===================== IK 结果面板(左栏顶部) ===================== */
#ikpanel{margin:0 0 10px;border:1px solid var(--line);border-radius:6px;background:#141821;padding:7px 8px}
/* 徽标必须可换行:CJK 的最小内容宽度=一个字,若徽标 nowrap+flex:none,
长状态文案会把标题压成竖排一字一行(实测 h3 缩到 12px) */
#ikpanel .ikh{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-bottom:5px}
#ikpanel .ikh h3{margin:0;font-size:12px;color:var(--ac);letter-spacing:.3px;
min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{margin-left:auto;font-size:10.5px;padding:1px 7px;border-radius:9px;border:1px solid;
flex:0 1 auto;max-width:100%;text-align:center;line-height:1.35;text-wrap:balance}
.badge.wait{color:var(--dim);border-color:var(--line)}
.badge.goodb{color:#0c1a12;background:var(--ok);border-color:var(--ok)}
.badge.warnb{color:#241a06;background:var(--warn);border-color:var(--warn)}
.badge.badb{color:#2a0a0a;background:#ff6b6b;border-color:#ff6b6b}
#ikTab{font-size:11px}
#ikTab td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
#ikTab td.dim{color:var(--dim);white-space:nowrap}
#ikpanel .kv{margin-top:6px}
#ikpanel .kv i{white-space:nowrap}
#ikpanel .kv span,#ikpanel .kv b{overflow-wrap:anywhere;word-break:break-word;min-width:0}
#ikpanel .good{color:var(--ok)} #ikpanel .bad{color:#ff6b6b}
#ikpanel .kv .hi{color:var(--warn);font-variant-numeric:tabular-nums}
#ikAltN{font-size:10.5px;color:var(--dim);margin:7px 0 3px;line-height:1.45;white-space:normal}
.altrow{display:flex;align-items:center;gap:5px;padding:2px 4px;border-radius:4px;cursor:pointer;
font-size:11px;font-variant-numeric:tabular-nums;color:var(--dim)}
.altrow:hover{background:rgba(76,154,255,.16)}
.altrow .ai{color:#5b6478;width:10px;flex:none;text-align:right}
.altrow b{color:var(--ac);width:54px;flex:none;text-align:right;font-weight:500}
.altrow .aq{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#ikpanel details{margin-top:8px;border-top:1px solid var(--line);padding-top:6px}
#ikpanel details>summary{cursor:pointer;color:var(--ac);font-size:11.5px;outline:none}
#ikpanel details .doc{margin-top:5px;font-size:11px;color:var(--dim);line-height:1.7;white-space:normal;
-webkit-user-select:text;user-select:text}
#ikpanel details .doc b{color:var(--tx)}
#ikpanel details .doc code{color:var(--warn);font-family:Consolas,"Courier New",monospace;font-size:10.5px}
#ikpanel details .doc ol{margin:3px 0 3px 16px;padding:0}
#ikpanel details .doc li{margin:3px 0}
#ikpanel details .doc hr{border:none;border-top:1px solid var(--line);margin:6px 0}
#ikpanel .src{font-size:10.5px;color:var(--dim);margin-top:5px;white-space:normal;line-height:1.5}
#ikChkBox{margin-top:7px}
#ikChkBox summary{margin-bottom:2px}
/* ============ 视图模式:三维模型(静态) / 装配图 / 运动模拟 ============ */
/* 三维模型 与 装配图 下,全部运动学 UI 一律隐藏 */
body:not(.motion) #ikbar,
body:not(.motion) #ikpanel,
body:not(.motion) #joints,
body:not(.motion) #jointsHead,
body:not(.motion) #footIk{display:none!important}
/* 运动模拟只看运动:左栏去掉零件树与打印清单(其它模式保留) */
body.motion #treeBlock{display:none!important}
/* 运动模拟左栏紧凑化:目标一屏放下、不下拉(实测原需下拉 127px) */
#ikpanel{margin:0 0 6px;padding:5px 8px}
#ikpanel .ikh{margin-bottom:3px}
#ikpanel #ikTab th,#ikpanel #ikTab td{padding:1px 4px}
#ikAltN{margin:5px 0 2px}
.altrow{padding:1px 4px}
#joints .j-box{margin-bottom:4px}
#joints input[type=range]{height:15px}
/* 显式行定位:IK 控制条在「三维模型/装配图」下被移除后,
若不固定行号,网格自动排布会把页脚挤到 1fr 行并压扁(已实测) */
#app>header{grid-row:1}
#ikbar{grid-row:2}
#left{grid-row:3}
#stage{grid-row:3}
#app>footer{grid-row:4}
/* BOM 抽屉:运动模拟多一条两行 IK 控制条(实测 75px),需整体下移 */
#bomDock{top:46px} #bomTab{top:64px}
body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
@media (max-width:760px){
#bomTab,body.motion #bomTab{top:auto;bottom:36px}
#bomDock,body.motion #bomDock{top:86px;bottom:26px}
}
</style>
</head>
<body>
<div id="app">
<header>
<b id="ttl">人形机器人手臂</b>
<span class="q" id="ver"></span>
<button class="tab on" data-view="model">三维模型</button>
<button class="tab" data-view="sheet">装配图</button><button class="tab" data-view="motion">运动模拟</button>
<span class="sp"></span>
<button id="bAsm" class="on">装配视图</button>
<button id="bExp">爆炸视图</button>
<input id="exp" type="range" min="0" max="100" value="0" style="width:96px">
<select id="cam">
<option value="iso">等测</option><option value="front">前视</option>
<option value="side">侧视</option><option value="top">顶视</option>
<option value="wrist">腕部特写</option><option value="joint">关节特写</option><option value="elbow">肘部特写</option><option value="hand">手部特写</option>
</select>
<button id="bWire">线框</button>
<button id="bXRay">电机半透</button>
<button id="bScr" class="on" title="显示/隐藏全部螺钉(S01~S10);点右侧 BOM 螺丝行可在模型中高亮定位">螺钉</button>
<button id="bHarn" class="on" title="显示/隐藏线束走线指示(主干4芯总线、电机出线分支、过关节U型/螺旋缓冲环、T接小板)">线束</button>
<button id="bEnv" title="显示/隐藏可达外包络(运动学边界)">可达包络</button>
<button id="bFit">复位</button>
<button id="bL" title="显示/隐藏左栏">左栏</button>
</header>
<div id="ikbar">
<div class="ikrow">
<span class="ik-t">IK</span>
<span class="lb">目标点 X</span><input type="number" id="ikX" step="1" title="CAD 世界坐标 X / mm">
<span class="lb">Y</span><input type="number" id="ikY" step="1" title="CAD 世界坐标 Y / mm">
<span class="lb">Z</span><input type="number" id="ikZ" step="1" title="CAD 世界坐标 Z / mm(桌面 z=0,向上为正)">
<span class="vsep"></span>
<span class="lb">工具长</span><input type="number" id="ikTool" value="0" step="5" title="在工具法兰面之外、沿 J6 轴线再伸出的工具长度 / mm">
<span class="lb">mm</span>
<span class="vsep"></span>
<span class="lb">扫描精度</span>
<select id="ikStep" title="冗余维 (J4,J5) 的粗扫步长:只在可行段内采样;位置任务取全局最优,姿态任务的精确解由孤立根枚举给出、与步长无关">
<option value="1">快速 1°</option>
<option value="0.5">较快 0.5°</option>
<option value="0.25" selected>标准 0.25°</option>
<option value="0.1">精细 0.1°</option>
<option value="0.05">参考级 0.05°</option>
</select>
<button id="ikSolve" title="求解并把手臂动画移动到该位姿(取相对当前姿态行程最小的构型)">求解并移动</button>
<button id="ikPose" title="开启姿态控制:除 TCP 位置外,同时指定末端工具姿态(工具轴指向 + 绕轴滚转)">姿态控制</button>
<span id="ikPoseBox">
<span class="lb">方位</span><input type="number" id="ikAz" step="5" value="0" title="工具轴(法兰朝外方向)绕 CAD Z 轴的方位角 / 度">
<span class="lb">仰角</span><input type="number" id="ikEl" step="5" value="90" title="工具轴(法兰朝外方向)自 CAD +Z 抬起的仰角 / 度:0 = 竖直向上,90 = 水平,180 = 竖直向下">
<span class="lb">滚转</span><input type="number" id="ikRoll" step="5" value="0" title="绕工具轴自身的滚转角 / 度(右手系)">
<button id="ikPoseCur" title="把当前真实工具姿态读回三个输入框">取当前</button>
<button id="ikPoseDown" title="快捷设为工具轴竖直向下(爪取常用姿态)">垂直向下</button>
<span class="lb">优先级</span>
<select id="ikPrio" title="位置与姿态不能同时满足时的取舍:位置优先 = 保 TCP 点位精确、姿态取最接近可达值;姿态优先 = 保指定姿态精确、TCP 取最接近可达点(位置差在结果栏如实给出);混合优先 = 用右侧滑杆自由分配两端比重,0% = 姿态优先、100% = 位置优先,中间值在两端锚点之间按归一化折中代价加权搜索">
<option value="pos" selected>位置优先</option>
<option value="ori">姿态优先</option>
<option value="mix">混合优先</option>
</select>
<span id="ikMixBox" title="混合优先的比重(自由分配位置与姿态):向左 = 姿态优先、向右 = 位置优先;0% / 100% 与两条纯算法逐位一致。折中代价 J = w·(e/e_ref)² + (1−w)·(o/o_ref)²,两端尺度取各自锚点(位置端 = 位置优先解的姿态残差、姿态端 = 姿态优先解的位置差),故读数即「沿折中历程的进度」。滑动即算即动,松手全量折中">
<span class="lb">姿态</span>
<input type="range" id="ikMixW" min="0" max="100" step="1" value="50">
<span class="lb">位置</span>
<span class="st" id="ikMixV">50% 位置</span>
</span>
</span>
</div>
<div class="ikrow">
<button id="ikPick" title="开启后在视图中单击,把目标点投到当前高度的水平面上">视图拾取</button>
<button id="ikRt" title="开启后拖动黄球即算即动:手臂实时追踪目标点移动(松手做一次精修求解)">实时追踪</button>
<button id="ikHome" title="所有关节角归零">归零位</button>
<button id="ikZero" title="载入一个示例目标并求解">示例目标</button>
<span class="vsep"></span>
<span class="hint" id="ikHint">拖黄球 = 移动目标(Shift 仅上下);拖箭头 = 调工具轴指向;拖箭杆或粉球 = 滚转;松手自动求解</span>
<span class="vsep"></span>
<span class="st" id="ikBar">—</span>
</div>
</div>
<aside id="left">
<div id="ikpanel">
<div class="ikh">
<h3>逆运动学 · 位置与姿态 · 最小总关节角</h3>
<span id="ikBadge" class="badge wait">待求解</span>
</div>
<table id="ikTab">
<thead><tr><th>轴</th><th>关节</th><th>角度</th><th>|θ|</th><th>限位</th></tr></thead>
<tbody></tbody>
</table>
<div class="kv" id="ikSum"></div>
<div class="src" id="ikSrc"></div>
<div id="ikAltN"></div>
<div id="ikAlts"></div>
<details id="ikChkBox"><summary>结构与精度自检 / 工作空间</summary><div class="kv" id="ikChk"></div>
<div class="kv" id="ikDep"></div></details>
<details id="ikDoc">
<summary>算法说明与公式推导</summary>
<div class="doc">
<b>模式</b>:本页「<b>运动模拟</b>」承载全部运动学能力;「三维模型」为纯静态模型视图(关节锁定在设计零位、无目标点与求解控件),「装配图」为二维图纸。运动模拟下左栏只保留 IK 结果与关节角度,零件树/打印清单自动收走。<hr>
<b>目标</b>:使 TCP(末端工具法兰 P09 安装面中心,可加“工具长”)到达给定点,并在一切满足限位的可行构型中取
<b>各电机需转过的角度之和最小</b>者(相对当前姿态的行程;连续轴 θ 与 θ±360 是同一物理位形,角差一律取 ±180° 内的短弧,见第 6 条;离线批算不给参考姿态时退化为 Σ|θᵢ| 最小)。开启上方“<b>姿态控制</b>”后,
约束升级为 <b>位置 3 + 姿态 3</b>(工具轴指向 + 绕轴滚转),见第 5 条。
<br><b>姿态操控</b>:姿态开启后目标球处出现方向箭头与粉色侧球——<b>箭头指向即工具朝外方向</b>(法兰面法向朝外,也就是工具伸出的方向)。
<b>拖箭头头部</b>用弧球法(指针射线与以目标为球心、半径=箭长的球求交,方向=交点−球心)连续调整方位/俯仰;
<b>拖箭杆或粉色侧球</b>按指针绕轴的转角增量改滚转;右侧数字框随时同步,可精确微调,
「取当前」把真实工具姿态读回、「垂直向下」一键设为爪取常用姿态。
<br><b>约定</b>:求解器内部工具坐标系 z 轴取法兰面法向(朝臂内,与 TCP 外伸反向);界面上的方位/仰角/滚转、
箭头与绿色当前轴线均以朝外法向描述,入栈时绕工具 x 轴翻 180° 换算,两者看到的物理姿态完全一致。
<ol>
<li><b>先消去一维</b>(仅位置任务):TCP 恰好落在 J6 轴线上,绕 J6 转动不改变 TCP 位置 ⇒ 对“到点”任务取
<code>θ6 = 0</code> 即已最优。</li>
<li><b>识别结构</b>:J2 / J4 / J5 三轴严格平行(轴向 Y,过 x = 19.5),J1 轴为 X,J3 轴沿臂长方向 Z。
记 <code>A = X−X0</code>、<code>R = |(Y−Y0, Z−Z1)|</code>、<code>φ = atan2(Z−Z1, Y−Y0)</code>,
则 <code>X = X0 + a</code>,而 <code>(Y, Z)</code> 由 <code>(b, c)</code> 绕 J1 轴旋转 φ 得到:<br>
<code>a = Vx·cosθ3·cosθ2 + Vz·sinθ2</code>,<code>b = −Vx·sinθ3</code>,
<code>c = C0 + (−Vx·cosθ3·sinθ2 + Vz·cosθ2)</code>,
其中 <code>Vx, Vz</code> 只是 (θ4, θ5) 的函数。</li>
<li><b>两式联立消元(关键一步)</b>:由 <code>a = A</code> 得恒等式
<code>A² + n² = Vx² + Vz²</code>(<code>n = −Vx·cosθ3·sinθ2 + Vz·cosθ2</code>);
代入 <code>b² + c² = R²</code> 后 θ3 的项<u>全部抵消</u>,于是<br>
<code>n = (R² + A² − Vx² − Vz² − C0²) / (2·C0)</code> —— <b>唯一确定</b>;<br>
再由 <code>n² + A² = Vx²·cos²θ3 + Vz²</code> 得
<code>cos²θ3 = (n² + A² − Vz²) / Vx²</code> ⇒ θ3 共四支 <code>±σ, ±(π−σ)</code>;<br>
最后 θ2 与 θ1 各由一个 2×2 线性方程组唯一确定:
<code>cosθ2 = (κA + Vz·n)/(κ² + Vz²)</code>,<code>sinθ2 = (Vz·A − κ·n)/(κ² + Vz²)</code>,
<code>κ = Vx·cosθ3</code>;<code>θ1 = φ − atan2(c, b)</code>。</li>
<li><b>遍历冗余(按行裁剪到可行段)</b>:(θ4, θ5) 就是二维冗余。闭式支只在平面支有解时才存在,
限位盒内只有 <b>7~27%</b> 的网格点能过判据,且实测(5 个目标 × 全部 θ4 行)<b>每行的可行集都是单一连续段</b>
—— 于是先用探测步找出每行的可行段、向两端各放宽一个探测步兜住段界,段内再按步长精扫。
段内密度与全域扫一致,因此<b>解的质量不动</b>,粗扫采样量降约 4.6×(但全量总耗时只降约 1.10×——局部加密窗口是 ±2h 步长 h/8,格数恒为 32×32/种子、与 h 无关,精修才是耗时地板)。
之所以不把粗扫一起放松:根枚举目前不保证完备,漏根时要靠粗扫的窄带候选兜底,
实测把粗扫同步放松会换来上百度级的代价劣化。</li>
<li><b>姿态任务(6 自由度)</b>:工具坐标系姿态 <code>R = R₁·R₂·…·R₆</code>(与场景图 6 级矩阵链一致)。
结构上 <b>J5 轴与 J6 轴在腕心 (19.5, 128, 222) 垂直相交</b>,因此 θ6 只提供绕工具自身轴的滚转:
<u>工具轴指向 = (R₁…R₅)·ẑ,与 θ6 无关,TCP 位置也与 θ6 无关</u>。每条位置闭式支都附带确定的 R₅
(= R₁…R₅),滚转角随之闭式解出:<code>N = R₅ᵀ·R_des</code>(需为 Rz(−θ6))⇒
<code>θ6 = −atan2(N₁₀, N₀₀)</code>;姿态偏差 <code>e = ∠(R₅·Rz(−θ6), R_des)</code> ≤ 2° 的支才收入。</li>
<li><b>姿态 = 孤立根求解</b>:闭式支把 θ1..θ3 吸收掉之后,姿态任务只剩「工具轴指向 = 目标轴」
这 2 个方程、2 个未知数 (θ4,θ5),解是<b>孤立点</b>(独立密集种子 + 牛顿普查实测只有 1~2 个根),
滚转再由 θ6 闭式补上。所以不再用网格密度去撞那条斜窄谷:逐行对残差两个分量找<b>变号括号</b>、
从括号中点起跑<b>阻尼高斯-牛顿</b>(奇异时加大阻尼而非退出),并对每支的 ‖e‖ 最小节点补种以收
切向相切的根;代价最优的若干条入选候选再各收口一次。于是
<b>姿态残差量级从 ~10⁻²° 变为 10⁻⁶°(工程意义上的精确)</b>,且 Σ|θ| 不劣于原路径。</li>
<li><b>两处判据陷阱</b>(都验过,写在这里防止被自证):其一,切向残差在工具轴<b>反平</b>时同样为零,
同向判据只能在收口时验,当作迭代中止条件会让每支一进来就作废;其二,求根按<b>支下标</b>对齐
prev/cur,不可行的支必须留 null 占位,否则下标错开、括号判据失配,实测会整支漏掉一个真根、
多付 130° 关节角。确有少数奇异「刀尖」构型<b>无精确根</b>,此时牛顿收敛到 ‖e‖ 的真实最小,
报出的残差不再是采样伪影。</li>
<li><b>混合优先(比重可调折中)</b>:优先级下拉的第三种模式——一根滑杆自由分配两端:
<b>0% 即姿态优先、100% 即位置优先</b>(两端直接交给上面两条专用算法,结果逐位一致),
中间值在两端锚点之间最小化<b>归一化折中代价</b>
<code>J = w·(e/e_ref)² + (1−w)·(o/o_ref)²</code>。两端尺度取锚点自身:
<code>e_ref</code> = 姿态优先解的 TCP 位置差、<code>o_ref</code> = 位置优先解的姿态残差,
于是<b>滑杆读数就是「沿折中历程的进度」</b>、与目标的绝对误差量级无关(50% ≈ 两端各自归一后对半)。
之所以不用「1 mm ↔ 1°」的绝对量纲:实测两端误差常在 100 mm / 40° 量级,绝对量纲会把读数整体挤向
位置端(折线中点落到 ~13% 处),滑杆就不好用了。两端锚点复用上面两条算法——位置端 = 位置精确族 +
闭式滚转 + 孤立根收口(位置不可达时退化为最近可达位置),姿态端 = 工具轴闭式对齐 + θ6 补滚转
(姿态精确、位置最近);中间值以两锚点及<b>关节空间插值</b>为种子,对 6 维残差
<code>[√w·(p−t)/e_ref , √(1−w)·RV°/o_ref]</code> 做<b>阻尼高斯-牛顿 + 单轴抛光</b>,取 J 最小者
(J 同量级 2% 带内取行程 — 相对当前姿态的 Σ|wrap(θᵢ−refᵢ)| — 更小者,防等价支横跳)。两端中任一端在另一维也达标
(≤0.05)时折中无意义、直接返回该端——注意位置端不达标而姿态端在位置上达标时返回<b>姿态端</b>
(它两维同时占优:位置优先族收不到的到达解,姿态端收得到)。全程无随机数,同一输入逐位可复现;
拖动走快路(同「实时续解」条哲学,归一化尺度沿用最近一次全量的缓存),松手必走全量。</li>
<li><b>连续优先(最小移动角 / 防倒摆)</b>:等价多分支(θ3 的 ±σ/±(π−σ)、肘上/肘下、J1/J3 镜像族)之间只按 Σ|θ| 选优时,
目标微动就会在分支间跳来跳去、绕远路(实测目标挪 20 mm 就翻族:垂直向下时左右移动单步最大 74.8°、
累计绕出 348°,而目标实际只扫过 160°)。交互求解以<b>当前手臂姿态</b>为参考位姿,选解判据取
<b>所有电机需要转过的角度之和最小</b>——<code>Σ|wrap180(θᵢ−refᵢ)|</code>:连续轴(J1/J3/J6)无机械限位,
θ 与 θ±360 是同一物理位形,故角差一律取短弧;末尾 <code>1e-6·Σ|θ|</code> 只作并列时的次序稳定项。
同一条扫描路径实测:单步最大 5.0°、累计 160.0°,与目标实际扫过的角度一致。
拖动动画同样对连续轴取「离起始角最近的等价角」再插值,避免 +151.9°→−167.0° 反向扫 318.9° 绕一整圈。</li>
<li><b>实时续解(拖动快路)</b>:目标每帧只挪几毫米时,真解在 (θ4,θ5) 平面上连续移动,
没必要为重扫整个冗余维付全价。<code>solveWarm</code> 只在上一帧解附近取<b>同一闭式支</b>、走一小步同支搜索,
姿态任务再收口一次;<b>任一校验不过(换支、越限、位置残差超差、姿态没收口、关节跳太大)就返回 null
自动回落全量粗扫</b>,松手后的求解一定不带 warm、走全量。快路只负责「能快的时候快」,不改变正确性判据。</li>
<li><b>代表角</b>:位姿以 360° 为周期,解角一律取 ±180° 内的等价角(|θ| 最小);J2 / J4 / J5 另受相邻件实测机械区间约束,越限即丢弃。</li>
<li><b>自检</b>:每次求解都用闭式 FK 与场景图真实 6 级矩阵链各算一次 TCP 和工具姿态并与目标比对(见上表残差)。</li>
</ol>
<hr>
<b>验证数据(本机实测,可复现)</b>
<table>
<thead><tr><th>检查项</th><th>结果</th></tr></thead>
<tbody>
<tr><td>闭式 FK vs 6 级矩阵链(1500 组随机位形)</td><td class="good">4.6 × 10⁻¹³ mm</td></tr>
<tr><td>逆解分支重构(200 组随机可行位形)</td><td class="good">200 / 200 命中</td></tr>
<tr><td>θ6 不变性(0° → 137°,TCP 位移)</td><td class="good">0.00 mm</td></tr>
<tr><td>姿态参数 ↔ 旋转矩阵往返(2000 组随机)</td><td class="good">2.4 × 10⁻⁶°</td></tr>
<tr><td>姿态重构达标率(200 组随机可行位形,step 0.5,A/B 同机)</td><td class="good">中位残差 0.0234° → <b>0.000000°</b>;&gt;0.05° 的例数 2 → <b>0</b>(改造前文档口径 185–191/200)</td></tr>
<tr><td>姿态解 Σ|θ| vs 改造前(60 组,step 0.25 + 连续项)</td><td>中位 +0.03°,最大 +8.4°(该例是新解更贴参考位姿、Σ|θ| 反而更大,属连续项的正常取舍而非漏解)</td></tr>
<tr><td>位置解 vs 0.05° 参考网格(100 目标,step 0.25)</td><td class="good">中位 +0.000°,最大 +0.49°,&gt;1° 例数 0</td></tr>
<tr><td>全量求解耗时(60 组,step 0.25 + 连续项)</td><td>仅位置 70.6 → 63.3 ms;位置+姿态 88.8 → 81.0 ms(约 1.10×;粗扫采样量降 4.6×,但精修才是地板)</td></tr>
<tr><td>实时续解快路(每帧 3 mm + 1.5°,240 帧)</td><td class="good">位置命中 96%、均 0.058 ms;姿态命中 65%、均 0.59 ms —— 对全量 81 ms 约 140~1400×,是交互延迟的主要收益来源</td></tr>
<tr><td>快路结果的独立复核(fkChain / 全 6 轴 orient,非求解器自报)</td><td class="good">位置 5.5×10⁻¹³ mm · 姿态 1.7×10⁻⁶°</td></tr>
<tr><td>姿态模式候选表长度(供互异构型/碰撞改选取样)</td><td>26 → 13(精确根集合本就只有 1~2 个,达标门收紧后可选样变少;动态碰撞改选若依赖宽候选需留意)</td></tr>
<tr><td>滚转角交互 roll 0→90(参考位姿连续)</td><td class="good">Δθ6 = 89.99°,θ1..θ5 变化 &lt; 0.01°</td></tr>
<tr><td>60 个可达目标:失败次数 / 最大残差</td><td class="good">0 次 / 3.5 × 10⁻¹³ mm</td></tr>
<tr><td>相对 0.05° 极细网格参考的最大劣化</td><td class="good">0.02°</td></tr>
<tr><td>vs 4000 次随机多起点数值解</td><td class="good">0 / 20 次劣于数值解</td></tr>
<tr><td>等价分支跳变 · 4 条扫描路径(修复前 → 后)</td><td class="good">&gt;10° 跳变 14 → 5 次;最大单步 163.5° → 16.8°</td></tr>
<tr><td>单次求解耗时</td><td>拖动跟随走快路 ~0.1 ms;全量求解默认步长下位置 ~20 ms、姿态 ~100 ms(node 侧本机实测,浏览器同量级)</td></tr>
</tbody>
</table>
<hr>
<b>限位</b>:电机行程各轴 ±360°(=滑块范围);解角取 ±180° 内的等价角——位姿以 360° 为周期,超出 ±180° 不产生新位姿。
相邻打印件的实测机械区间封顶:J2 ∈ [−107, 128],J4 ∈ [−112, 112],J5 ∈ [−96, 96],J1 / J3 / J6 可整周。
<br><b>参考</b>:零位静态扫描 J2 [−106, 9]、J4 [−111, 16]、J5 [−95, 34] 只在其它轴全零时对固定立柱成立,已不再当可行域;
跨组零件对(腕/法兰撞立柱那类)由胶囊碰撞模型按当前姿态实时判定(实测 J1=90° 时 J2 正端从 +8° 放到 +107°)。
<br><b>局限</b>:胶囊模型故意不检相邻组零件对(|Δg|=1),那部分由上面的实测机械区间兜底;换模型须重跑
scratch/probe_adj.py 更新 collide.json 的 adj。若需要更舒展的姿态,可从上方“互异构型”列表改选次优解。
<br><b>不可达</b>:若目标超出工作空间,自动求最近可达位姿——阻尼最小二乘<b>从当前姿态锚定起步</b>(抑制退化方向的无用漂移)、固定种子的确定性重启(同一点重复解算逐位一致)、再经零空间抛光取最小总关节角的代表值;报告的距离为抛光后真实值。
<br><b>姿态不可达</b>:姿态任务超可达空间时,阻尼最小二乘误差向量升级为 6 维(位置 mm + 姿态 deg,按 20 mm/rad 折算),返回位置与姿态联合最近的位姿,两个残差分别显示。
<br><b>混合优先不可达</b>:位置超可达空间时,位置端锚点退化为「最近可达位置」、姿态端仍取「姿态精确、位置最近」,
再按同一权重折中,两端各自的残差在结果栏如实列出(与位置优先的历史兜底——6 维联合 DLS——是两个不同的代表值,切换模式可得)。
<br><b>API</b>:控制台可用 <code>IK.solver.solve([x,y,z],{step:0.25,tool:0,pose:{R:ArmIK.poseMat(az,el,roll)}})</code>;
<code>IK.setPose(az,el,roll)</code> 可直接设定目标姿态;<code>IK.setPrio('mix',0.3)</code> 切到混合优先并设比重;
<code>IK.solver.solveTo([x,y,z],{pose:{R:…},priority:'mix',mixW:0.3})</code> 直接取折中解(0 与 1 分别等于姿态优先 / 位置优先)。
</div>
</details>
</div>
<div class="row" id="jointsHead" style="justify-content:space-between;margin:8px 0 4px">
<h3 style="margin:0">关节角度(实时联动)</h3>
<button id="resetJoints" class="tab" style="padding:1px 7px;font-size:11px" title="将所有关节角度复位为 0°">全部归零</button>
</div>
<div id="joints"></div>
<div id="treeBlock">
<h3>零件树</h3>
<div class="row"><button id="allOn" class="tab">全选</button><button id="allOff" class="tab">全不选</button>
<button id="onlyPrint" class="tab">只看打印件</button></div>
<div id="tree"></div>
<h3>打印清单(PLA)</h3>
<div class="kv" id="sum"></div>
</div>
</aside>
<div id="stage">
<canvas id="cv"></canvas>
<div id="hud"></div>
<div id="note"></div>
<div id="sheet" class="theme-paper">
<div id="sheetToolbar">
<div class="sheet-tabs" id="sheetTabs">
<button class="sheet-tab on" data-sheet="0"><span>📐</span> 01 正视图</button>
<button class="sheet-tab" data-sheet="1"><span>🔍</span> 02 A-A剖视</button>
<button class="sheet-tab" data-sheet="2"><span>💥</span> 03 爆炸图</button>
<button class="sheet-tab" data-sheet="3"><span>📐</span> 04 侧视图</button>
<button class="sheet-tab guide-tab" data-sheet="guide"><span>🛠️</span> 分步装配向导</button>
</div>
<div class="sheet-vsep"></div>
<div class="sheet-btn-group" title="图纸风格主题">
<button class="sheet-btn on" data-theme="paper" title="工程白纸图纸">白图</button>
<button class="sheet-btn" data-theme="blueprint" title="经典工程蓝图">蓝图</button>
<button class="sheet-btn" data-theme="dark" title="工业深色风格">深色</button>
</div>
<div class="sheet-vsep"></div>
<div class="sheet-layers" title="图层显示控制">
<label title="显示/隐藏气球标号与引线"><input type="checkbox" id="chkLyrBalloons" checked> 标号</label>
<label title="显示/隐藏尺寸与基准标注"><input type="checkbox" id="chkLyrDims" checked> 尺寸</label>
<label title="显示/隐藏关节轴线"><input type="checkbox" id="chkLyrAxes" checked> 轴线</label>
<label title="显示/隐藏图内零件明细表"><input type="checkbox" id="chkLyrLegend" checked> 零件表</label>
</div>
<div class="sheet-space"></div>
<button class="sheet-btn sheet-btn-accent" id="btnSheetTo3D" title="在三维模型中定位查看当前选中的部件">
<span>🧊 3D模型联动</span>
</button>
</div>
<div id="sheetViewport">
<div id="sheetCanvas">
<div id="sheetSvgWrapper"></div>
</div>
<div id="sheetFloatBar">
<button class="sfb-btn" id="sfbIn" title="放大 (滚轮向上)">➕</button>
<button class="sfb-btn" id="sfbOut" title="缩小 (滚轮向下)">➖</button>
<button class="sfb-btn" id="sfbFit" title="自适应窗口居中 (双击空白处)">⛶ 适屏</button>
<button class="sfb-btn" id="sfb100" title="100% 原始比例">1:1</button>
<button class="sfb-btn" id="sfbReset" title="重置视角">↺ 复位</button>
<button class="sfb-btn" id="sfbFull" title="全屏视口">⛶ 全屏</button>
</div>
<div id="sheetZoomBadge" title="点击重置为 100%">100%</div>
<div id="sheetTooltip" class="sheet-tooltip"></div>
</div>
<!-- 分步装配向导面板 -->
<div id="sheetGuideView" style="display:none">
<div class="guide-header">
<div class="guide-steps-bar" id="guideStepsBar">
<!-- 步骤 chips 动态渲染 -->
</div>
<button class="sheet-btn" id="btnExitGuide" style="margin-left:auto">✕ 返回图纸</button>
</div>
<div class="guide-body">
<div class="guide-panel-left" id="guidePanelLeft">
<!-- 当前步骤详情动态渲染 -->
</div>
<div class="guide-panel-right">
<div id="guideCanvasWrapper" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;position:relative;">
<!-- 步骤关联图纸与高亮 -->
</div>
</div>
</div>
</div>
</div>
</div>
<footer>
<span id="footModel">左键旋转 · 滚轮缩放 · 右键平移(拖动) · 单击选中高亮 · <b>BOM 点螺丝行 = 高亮定位该规格螺钉(穿透显示)</b> · <b>右键单击隐藏部件(或按H键)</b> · 左侧全选/勾选恢复<span id="footIk"> · <b>运动模拟</b>:拖动黄色目标球到点(Shift 仅上下)· 开「姿态控制」后可拖箭头调末端朝向、拖箭杆或粉色侧球调滚转</span></span>
<span id="footSheet" style="display:none"><b>装配工程图纸</b>:滚轮缩放 · 按住左键/中键拖拽平移 · 双击自适应视口 · 悬停气球标号/零件轮廓双向高亮 · <b>点击部件卡片「在 3D 中定位」</b></span>
<span class="sp"></span>
<span id="stat"></span>
</footer>
</div>
<button id="bomTab" title="展开 / 收起 物料清单 BOM">物料清单 <b>BOM</b></button>
<aside id="bomDock">
<div class="dk-hd">
<b>物料清单 BOM</b><span class="q" id="bomStat"></span>
<button id="bomHide" class="dk-x" title="收起">收起 ▶</button>
</div>
<div class="dk-bd">
<div class="dk-sec">紧固件 / 螺丝清单(全部紧固点)</div>
<div class="dk-note" id="scrSum"></div>
<table id="screws"><thead><tr><th>规格</th><th>长 mm</th><th>数量</th><th>位置 / 用途</th></tr></thead><tbody></tbody></table>
<div class="dk-sec">打印件 BOM(原明细,PLA)</div>
<table id="bom"><thead><tr><th>#</th><th>件号</th><th>cm3</th><th>g</th></tr></thead><tbody></tbody></table>
</div>
</aside>
<script>/*__THREE__*/</script>
<script>
const DATA = /*__DATA__*/;
const SHEETS = /*__SHEETS__*/;
const TRAVEL = /*__TRAVEL__*/;
const COLLIDE = /*__COLLIDE__*/;
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 SCREWS = /*__SCREWS__*/;
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();
})();
</script>
<script>/*__IKSOLVER__*/</script>
<script>/*__IKGLUE__*/</script>
</body>
</html>