refactor(viewer): 单文件查看器拆成 webapp/ 多文件,走本地 http;Worker 换成真文件

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

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

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

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

关卡结果:python -m arm.build_model 退出码 0;10 项数据哈希与基线一致;
IK exact 段逐字节一致;99 张截图逐字节一致 + 6 张在噪声阈内;线束干涉测试 1800 点 0 穿透。
This commit is contained in:
cbb
2026-10-15 04:31:31 +08:00
parent 649bd69810
commit 531a293688
43 changed files with 1755 additions and 1397 deletions
+7 -5
View File
@@ -21,17 +21,19 @@ Desktop.ini
# 构建产物:由 python -m arm.* 重新生成,体积大(mesh 14MB + 单文件查看器 16MB×3), # 构建产物:由 python -m arm.* 重新生成,体积大(mesh 14MB + 单文件查看器 16MB×3),
# 过去被跟踪导致仓库背着 47MB 生成物;取消跟踪后重建不再脏 git status。 # 过去被跟踪导致仓库背着 47MB 生成物;取消跟踪后重建不再脏 git status。
web/mesh_arm.json # 旧 web/ 单文件产物目录(已删,源码在 webapp/)
web/sheets.jsdata web/
web/index.html
web/机器人手臂3D查看器.html
/机器人手臂3D查看器.html /机器人手臂3D查看器.html
webapp/data/ webapp/data/
webapp/generated/
dist/ dist/
data/cache/ data/cache/
# 这 3 张线束 x-ray 截图在同一份构建下就不逐字节复现(抗锯齿噪声), # 这 6 张在同一份构建下不逐字节复现(抗锯齿/量化噪声),
# 每次跑关卡都会改写它们;参考像素已存进 tests/baselines/flaky/,不再入库。 # 每次跑关卡都会改写它们;参考像素已存进 tests/baselines/flaky/,不再入库。
docs/shots/harness_p04_p05_xray.png docs/shots/harness_p04_p05_xray.png
docs/shots/harness_p07_xray.png docs/shots/harness_p07_xray.png
docs/shots/harness_xray_internal.png docs/shots/harness_xray_internal.png
docs/shots/boss_p07.png
docs/shots/m3_sheet_exploded.png
docs/shots/p03_mount_loose.png
+2 -2
View File
@@ -9,7 +9,7 @@ import sys
import numpy as np import numpy as np
from arm.paths import WEB, CACHE from arm.paths import DATA, CACHE
from arm import kinematics as HL from arm import kinematics as HL
Y_AX = HL.Y_AXIS # 128 Y_AX = HL.Y_AXIS # 128
@@ -75,7 +75,7 @@ def main():
print("包络经纬网: 顶点%d 线段%d(经10° x 纬8mm)" % (len(Vl), len(sl) // 2)) print("包络经纬网: 顶点%d 线段%d(经10° x 纬8mm)" % (len(Vl), len(sl) // 2))
# ---- 追加进 mesh_arm.json ---- # ---- 追加进 mesh_arm.json ----
path = WEB + "/mesh_arm.json" path = DATA + "/mesh_arm.json"
data = json.load(open(path, encoding="utf-8")) data = json.load(open(path, encoding="utf-8"))
data["parts"] = [p for p in data["parts"] if not p["name"].startswith("ENV_")] data["parts"] = [p for p in data["parts"] if not p["name"].startswith("ENV_")]
mx = max(p["idx"] for p in data["parts"]) mx = max(p["idx"] for p in data["parts"])
+3 -3
View File
@@ -24,7 +24,7 @@ try:
except Exception: except Exception:
pass pass
import cadquery as cq import cadquery as cq
from arm.paths import OUT, WEB, DOCS from arm.paths import OUT, DATA, DOCS
from arm.cad.helpers import solids_of, bb, mesh_of from arm.cad.helpers import solids_of, bb, mesh_of
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.BRepGProp import BRepGProp from OCP.BRepGProp import BRepGProp
@@ -156,7 +156,7 @@ def section_props(sh, normal, origin, bend=2, size=400.0):
def arm_masses(): def arm_masses():
"""Everything distal to each joint, in grams, from the model itself.""" """Everything distal to each joint, in grams, from the model itself."""
meta = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8")) meta = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
TREE = ["root", "J1", "J2", "J3", "J4", "J5", "J6"] TREE = ["root", "J1", "J2", "J3", "J4", "J5", "J6"]
tot = {} tot = {}
for p in meta["parts"]: for p in meta["parts"]:
@@ -199,7 +199,7 @@ def _mesh_vol_cg(v, t):
def part_table(payload_kg=None): def part_table(payload_kg=None):
"""[(name, mass_g, cg_xyz, grp)],含末端负载这个集中质量点。""" """[(name, mass_g, cg_xyz, grp)],含末端负载这个集中质量点。"""
meta = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8")) meta = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
rows = [] rows = []
for p in meta["parts"]: for p in meta["parts"]:
nm = p["name"] nm = p["name"]
+6 -6
View File
@@ -9,7 +9,7 @@ bore + PCBA pocket + pinch screws; driven hub mating plane at motor rear datum
the existing face-spline halves + split clamp bolt straight on), the part the existing face-spline halves + split clamp bolt straight on), the part
registry, and the STEP/STL/mesh/BOM/collision export pipeline. registry, and the STEP/STL/mesh/BOM/collision export pipeline.
Run: python arm_model.py -> out/, web/mesh_arm.json, docs/ Run: python -m arm.build_model -> out/, webapp/data/mesh_arm.json, docs/
""" """
import math import math
import os import os
@@ -22,7 +22,7 @@ except Exception:
pass pass
import cadquery as cq import cadquery as cq
from arm.paths import OUT, WEB, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP from arm.paths import OUT, WEB, DATA, DOCS, SRC_STEP, HALF_STEP, CLAMP_STEP
from arm.constants import M, C, MOTOR_TOL from arm.constants import M, C, MOTOR_TOL
from arm.cad.helpers import V, trs, place, bb, bbox_str, cyl, ring, box, holes, solids_of, mesh_of from arm.cad.helpers import V, trs, place, bb, bbox_str, cyl, ring, box, holes, solids_of, mesh_of
from OCP.gp import gp_Trsf, gp_Vec, gp_Pnt, gp_Dir, gp_Ax1 from OCP.gp import gp_Trsf, gp_Vec, gp_Pnt, gp_Dir, gp_Ax1
@@ -460,7 +460,7 @@ import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
def export(JS): def export(JS):
for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", WEB, DOCS): for d in (OUT, OUT + "/parts_step", OUT + "/print_stl", OUT + "/assembly", DATA, DOCS):
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
print("=" * 80, flush=True) print("=" * 80, flush=True)
@@ -499,7 +499,7 @@ def export(JS):
# 2. 多线程并行生成网页 3D Mesh # 2. 多线程并行生成网页 3D Mesh
print("=" * 80, flush=True) print("=" * 80, flush=True)
print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (web/mesh_arm.json)...", flush=True) print("【第三阶段 3/5】多线程并行生成网页 3D 轻量化几何网格 (webapp/data/mesh_arm.json)...", flush=True)
print("=" * 80, flush=True) print("=" * 80, flush=True)
done_mesh = 0 done_mesh = 0
@@ -541,12 +541,12 @@ def export(JS):
L1=round(d3(JS["J2"].rear, JS["J4"].rear), 1), L1=round(d3(JS["J2"].rear, JS["J4"].rear), 1),
L2=round(d3(JS["J4"].rear, JS["J5"].rear), 1)), L2=round(d3(JS["J4"].rear, JS["J5"].rear), 1)),
measured=dict(M=M, C=C, HUB_FACE_Z=HUB_FACE_Z, CLAMP_SPAN=list(CLAMP_SPAN))), measured=dict(M=M, C=C, HUB_FACE_Z=HUB_FACE_Z, CLAMP_SPAN=list(CLAMP_SPAN))),
open(WEB + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False) open(DATA + "/mesh_arm.json", "w", encoding="utf-8"), ensure_ascii=False)
bom = [dict(件号=p["name"], 名称=p["cn"], 类型="3D打印" if p["printed"] else "已有/电机", bom = [dict(件号=p["name"], 名称=p["cn"], 类型="3D打印" if p["printed"] else "已有/电机",
数量=1, 体积cm3=round(p["vol"] / 1000.0, 1), 数量=1, 体积cm3=round(p["vol"] / 1000.0, 1),
质量g_PLA=round(p["vol"] * 1.24e-3, 1), 包络=p["bb"], 说明=p["note"]) for p in PARTS] 质量g_PLA=round(p["vol"] * 1.24e-3, 1), 包络=p["bb"], 说明=p["note"]) for p in PARTS]
json.dump(bom, open(DOCS + "/bom.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) json.dump(bom, open(DOCS + "/bom.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"-> 3D网格与BOM清单已保存至 web/mesh_arm.json 与 docs/bom.json ✓\n", flush=True) print(f"-> 3D网格与BOM清单已保存至 webapp/data/mesh_arm.json 与 docs/bom.json ✓\n", flush=True)
return bom return bom
+3 -4
View File
@@ -17,7 +17,7 @@ import os
import sys import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
from arm.paths import WEB, DOCS from arm.paths import DATA, DOCS
# 这两个脚本的顶层就是"算完并写文件",被 import 时会静默重写 travel/mesh 派生产物 # 这两个脚本的顶层就是"算完并写文件",被 import 时会静默重写 travel/mesh 派生产物
# (实测:一次 importlib 冒烟测试就把 docs/travel.json 重写了一遍)。这里改成直接炸。 # (实测:一次 importlib 冒烟测试就把 docs/travel.json 重写了一遍)。这里改成直接炸。
@@ -26,7 +26,7 @@ if __name__ != "__main__":
"draw_sheet 是脚本:顶层代码一执行就重写产物,不能 import。" "draw_sheet 是脚本:顶层代码一执行就重写产物,不能 import。"
"要用就跑 python -m arm.dataio.draw_sheet。") "要用就跑 python -m arm.dataio.draw_sheet。")
D = json.load(io.open(WEB + "/mesh_arm.json", encoding="utf-8")) D = json.load(io.open(DATA + "/mesh_arm.json", encoding="utf-8"))
P, LAY, MS = D["parts"], D["layout"], D["measured"] P, LAY, MS = D["parts"], D["layout"], D["measured"]
MT, CT = MS["M"], MS["C"] MT, CT = MS["M"], MS["C"]
JOINTS = {j["id"]: j for j in D["joints"]} JOINTS = {j["id"]: j for j in D["joints"]}
@@ -333,6 +333,5 @@ sheets = [
os.makedirs(DOCS, exist_ok=True) os.makedirs(DOCS, exist_ok=True)
for n, s in zip(("sheet_front", "sheet_joint", "sheet_exploded", "sheet_side"), sheets): for n, s in zip(("sheet_front", "sheet_joint", "sheet_exploded", "sheet_side"), sheets):
io.open(DOCS + "/" + n + ".svg", "w", encoding="utf-8").write(s) io.open(DOCS + "/" + n + ".svg", "w", encoding="utf-8").write(s)
io.open(WEB + "/sheets.jsdata", "w", encoding="utf-8").write(json.dumps(sheets, ensure_ascii=False)) print("sheets: %d (%d KB) -> docs/sheet_{front,joint,exploded,side}.svg(查看器读这 4 个文件)" % (
print("sheets: %d (%d KB) -> docs/sheet_{front,joint,exploded,side}.svg" % (
len(sheets), sum(len(s) for s in sheets) // 1024)) len(sheets), sum(len(s) for s in sheets) // 1024))
+3 -3
View File
@@ -3,7 +3,7 @@
docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量 / 位置 / 备注)。 docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量 / 位置 / 备注)。
本模块把清单里的每一颗螺钉按其真实装配孔位还原成三维实例(承载头面坐标 + 拧入方向 + 本模块把清单里的每一颗螺钉按其真实装配孔位还原成三维实例(承载头面坐标 + 拧入方向 +
随动关节组),生成轻量三角网格后追加进 web/mesh_arm.json 的 parts 中。 随动关节组),生成轻量三角网格后追加进 webapp/data/mesh_arm.json 的 parts 中。
查看器因此可以:显示全部螺钉、点击 BOM 螺丝行高亮定位、点击螺钉反查清单。 查看器因此可以:显示全部螺钉、点击 BOM 螺丝行高亮定位、点击螺钉反查清单。
实例坐标来源(与 arm_model.py 成孔代码一一对应,改模型后按同样规则复核): 实例坐标来源(与 arm_model.py 成孔代码一一对应,改模型后按同样规则复核):
@@ -51,7 +51,7 @@ try:
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
except Exception: except Exception:
pass pass
from arm.paths import WEB, DOCS from arm.paths import DATA, DOCS
from arm import kinematics as HL from arm import kinematics as HL
from arm.cad.part_tools import CLAMP_BCD, CLAMP_CLOCK from arm.cad.part_tools import CLAMP_BCD, CLAMP_CLOCK
X_COL = -115.0 # 立柱中心(与 parts/p01_column.py 的 X_COL 一致) X_COL = -115.0 # 立柱中心(与 parts/p01_column.py 的 X_COL 一致)
@@ -287,7 +287,7 @@ def instances(m2_bcd, m2_clock):
# ---------------------------------------------------------------- 注入 # ---------------------------------------------------------------- 注入
def main(): def main():
path = WEB + "/mesh_arm.json" path = DATA + "/mesh_arm.json"
print("读取 %s ..." % path, flush=True) print("读取 %s ..." % path, flush=True)
d = json.load(open(path, encoding="utf-8")) d = json.load(open(path, encoding="utf-8"))
M = d["measured"]["M"] M = d["measured"]["M"]
+7 -3
View File
@@ -15,10 +15,14 @@ SRC_STEP = MODEL + r"/电机.STEP"
HALF_STEP = MODEL + r"/配件/端面齿半联轴器_相同件x2.STEP" HALF_STEP = MODEL + r"/配件/端面齿半联轴器_相同件x2.STEP"
CLAMP_STEP = MODEL + r"/配件/端面齿联轴器_抱箍_两片装配.STEP" CLAMP_STEP = MODEL + r"/配件/端面齿联轴器_抱箍_两片装配.STEP"
OUT = REPO + r"/out" OUT = REPO + r"/out"
WEB = REPO + r"/web" WEB = REPO + r"/web" # 旧单文件产物目录,S8 删除;新代码不要再往里写
# 查看器运行时数据与生成脚本(gitignore,由 build_viewer 重写)
DATA = REPO + r"/webapp/data"
GEN = REPO + r"/webapp/generated"
DOCS = REPO + r"/docs" DOCS = REPO + r"/docs"
# 可达包络 FK 采样的缓存(ws_cloud.npz / ws_metrics.json)。原先躺在 gitignore 的 # 可达包络 FK 采样的缓存(ws_cloud.npz / ws_metrics.json)。原先躺在 gitignore 的
# scratch/ 里,构建却依赖它——缺了新克隆就跑不出 ENV 件。 # scratch/ 里,构建却依赖它——缺了新克隆就跑不出 ENV 件。
CACHE = REPO + r"/data/cache" CACHE = REPO + r"/data/cache"
# 查看器模板。S4 拆成 webapp/index.html + js/** 后这条即作废。 # 前端源码目录与分发产物
TPL = REPO + r"/src/viewer_tpl.html" WEBAPP = REPO + r"/webapp"
DIST = REPO + r"/dist"
+81 -59
View File
@@ -1,76 +1,98 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Assemble the single-file viewer: three.js (inline, no CDN) + mesh data + 2D sheets.""" """把生成物落到 webapp/,并打一份单文件分发版。
单文件时代这里做 8 路字符串替换再写 3 份 16MB 副本;现在前端本身就是 webapp/ 下的
文件,本脚本只剩两件事:
write_data() 网格/工程图/行程/碰撞/螺钉 -> generated/00_data.js(普通脚本 const)
build_single() 把 webapp/ 的 css/js 原样内联成 dist/机器人手臂3D查看器.html(分发用)
S5 数据改 fetch() 后 write_data() 的 00_data.js 退役;S8 由 pack.py 的受限 ESM 内联器
接管 build_single()。
"""
import io import io
import json import json
import os import os
import re
import sys import sys
import time
try: try:
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
except Exception: except Exception:
pass pass
from arm.paths import WEB, DOCS, REPO, TPL
import time from arm.paths import DATA, DIST, DOCS, GEN, WEBAPP
# 与 SHEET_METAS 的顺序一致;工程图读 draw_sheet 写出的真 SVG,
# 不再维护第二份 sheets.jsdata(单文件时代的内联载体,内容与 SVG 完全重复)。
SHEET_ORDER = ["sheet_front", "sheet_joint", "sheet_exploded", "sheet_side"]
def _read(path, dflt=None):
if os.path.exists(path):
return io.open(path, encoding="utf-8").read()
if dflt is None:
raise SystemExit("缺少查看器数据文件: %s(先跑 python -m arm.build_model)" % path)
return dflt
def write_data():
t0 = time.time()
print("【数据】汇总查看器数据 -> webapp/generated/00_data.js", flush=True)
os.makedirs(GEN, exist_ok=True)
mesh = _read(DATA + "/mesh_arm.json")
svgs = [_read(DOCS + "/%s.svg" % n) for n in SHEET_ORDER]
trv = _read(DOCS + "/travel.json", "[]")
col = _read(DOCS + "/collide.json", '{"caps":[],"parts":{}}')
scr = _read(DOCS + "/screws.json", "[]")
print(" - 网格 %d KB / 工程图 %d 张 / travel %d B / collide %d KB / screws %d B"
% (len(mesh) // 1024, len(svgs), len(trv), len(col) // 1024, len(scr)), flush=True)
out = ["/* 生成物,勿手改:由 python -m arm.viewer.build_viewer 写入。",
" 普通脚本顶层 const —— 经典脚本共享全局词法作用域,后续脚本直接可见。 */"]
for name, raw in (("DATA", mesh), ("TRAVEL", trv), ("COLLIDE", col), ("SCREWS", scr)):
out.append("const %s = %s;" % (name, raw.strip()))
out.append("const SHEETS = %s;" % json.dumps(svgs, ensure_ascii=False))
text = "\n".join(out) + "\n"
tmp = GEN + "/00_data.js.tmp"
io.open(tmp, "w", encoding="utf-8").write(text)
os.replace(tmp, GEN + "/00_data.js")
print(" 完成:%d KB, %.2fs ✓" % (len(text) // 1024, time.time() - t0), flush=True)
def build_single():
"""分发用单文件:仍是普通脚本,所以内联是平凡的(S5 变 ESM 后交给 pack.py)。"""
t0 = time.time()
os.makedirs(DIST, exist_ok=True)
html = _read(WEBAPP + "/index.html")
total = len(html)
def sub(m, kind):
nonlocal total
body = _read(WEBAPP + "/" + m.group(1))
total += len(body)
return ("<style>\n" + body + "\n</style>") if kind == "css" \
else ("<script>\n" + body + "\n</script>")
out = re.sub(r'<link rel="stylesheet" href="([^"]+)">', lambda m: sub(m, "css"), html)
out = re.sub(r'<script src="([^"]+)"></script>', lambda m: sub(m, "js"), out)
left = re.findall(r'(?:src|href)="(?!data:)([^"]+)"', out)
assert not left, "单文件里仍有外部引用: %s" % left[:5]
assert len(out) >= total * 0.95, "内联后 %d 远小于各部之和 %d,疑似漏文件" % (len(out), total)
dst = DIST + "/机器人手臂3D查看器.html"
# Windows 上被浏览器打开的 html 是独占锁;写不进去就直接报,不要静默留旧版。
io.open(dst, "w", encoding="utf-8").write(out)
print(" - 单文件分发版 %.2f MB, %.2fs ✓ %s"
% (os.path.getsize(dst) / 1e6, time.time() - t0, dst), flush=True)
def rebuild_viewer(): def rebuild_viewer():
print("=" * 80, flush=True) """build_model 流水线的最后一步(沿用旧函数名,调用点不改)。"""
print("【第五阶段 5/5】打包单文件三维交互查看器 (机器人手臂3D查看器.html)...", flush=True) write_data()
print("=" * 80, flush=True) build_single()
t0 = time.time()
print(" - 读取 Three.js 离线 3D 渲染内核...", end="", flush=True)
three = io.open(WEB + "/three_inline.js", encoding="utf-8").read()
assert len(three) > 400000, len(three)
print(f" 完成 ({len(three)/1024:.0f} KB) ✓", flush=True)
print(" - 读取几何网格与关节运动学数据...", end="", flush=True)
data = io.open(WEB + "/mesh_arm.json", encoding="utf-8").read()
sheets = io.open(WEB + "/sheets.jsdata", encoding="utf-8").read()
trv = io.open(DOCS + "/travel.json", encoding="utf-8").read() if os.path.exists(
DOCS + "/travel.json") else "[]"
col = io.open(DOCS + "/collide.json", encoding="utf-8").read() if os.path.exists(
DOCS + "/collide.json") else '{"caps":[],"parts":{}}'
screws = io.open(DOCS + "/screws.json", encoding="utf-8").read() if os.path.exists(
DOCS + "/screws.json") else "[]"
ik_solver = io.open(WEB + "/arm-ik-solver.js", encoding="utf-8").read()
ik_glue = io.open(WEB + "/ik-glue.js", encoding="utf-8").read()
tpl = io.open(TPL, encoding="utf-8").read()
print(f" 完成 ({len(data)/1024:.0f} KB) ✓", flush=True)
print(" - 打包生成全内置单文件 HTML...", end="", flush=True)
out = (tpl.replace("/*__THREE__*/", three)
.replace("/*__DATA__*/", data)
.replace("/*__SHEETS__*/", sheets)
.replace("/*__TRAVEL__*/", trv)
.replace("/*__COLLIDE__*/", col)
.replace("/*__SCREWS__*/", screws)
.replace("/*__IKSOLVER__*/", ik_solver)
.replace("/*__IKGLUE__*/", ik_glue))
assert "/*__" not in out, [l for l in out.split("\n") if "/*__" in l][:3]
dst = WEB + "/机器人手臂3D查看器.html"
# 【2026-10-06】三个副本逐个写、逐个报错,不要一把梭。
# 原来三行连写,任一份被浏览器独占(Windows 上打开中的 html 是独占锁)就抛异常,
# 而调用方 arm_model.py 把整个 rebuild_viewer() 包在 try/except 里,
# 于是只写成功第一份、另外两份静默停在旧版本 —— 表现为"代码改了但网页没变"。
# 实测就是这个坑:18:52 那次 dst 是新的,web/index.html 和根目录那份还停在 18:12。
ok, failed = True, []
for path in (dst, WEB + "/index.html", REPO + "/机器人手臂3D查看器.html"):
try:
io.open(path, "w", encoding="utf-8").write(out)
except Exception as e:
ok = False
failed.append("%s (%s)" % (path, e))
sz_mb = os.path.getsize(dst) / 1e6
print(f" 完成 ({sz_mb:.2f} MB, 耗时: {time.time()-t0:.2f}s) ✓", flush=True)
if failed:
print("!! 以下副本写入失败(多半是浏览器正打开着该文件,Windows 独占锁):", flush=True)
for f in failed:
print(" - %s" % f, flush=True)
print(" 关掉对应页面后重跑 python -m arm.viewer.build_viewer 即可。\n", flush=True)
else:
print(f"-> 3D查看器已更新至: {dst}、{WEB}/index.html 与 根目录/机器人手臂3D查看器.html\n",
flush=True)
if __name__ == "__main__": if __name__ == "__main__":
rebuild_viewer() rebuild_viewer()
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""查看器的本地静态服务器(前端拆分后,多文件 + ES Module + Worker 都需要 http 才能跑)。
python -m arm.viewer.serve [--port 8123] [--print-only]
要点:
- docroot = 仓库根,`/` 直接给 webapp/index.html;/docs/*.json 这类数据同域可读。
- Cache-Control: no-store —— 重新生成后旧标签页仍显示旧版是这里踩过的坑,
过去只能人肉 Ctrl+F5;不缓存之后刷新即新构建。
- 显式 MIME:Windows 上 .js 常被注册成 text/plain,那会让 <script type=module>
静默不执行(页面全黑且没有明显报错),所以这里写死。
- 屏蔽 .git / backups / 定位图 / out 等目录,不把整个仓库暴露到端口上。
"""
import argparse
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from arm.paths import REPO
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
DENY = (".git", "backups", ".bak", "定位图", "out", "scratch", "node_modules")
MIME = {
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".npz": "application/octet-stream",
".png": "image/png",
".wasm": "application/wasm",
}
class H(SimpleHTTPRequestHandler):
# HTTP/1.1 + keep-alive + 大缓冲:默认 HTTP/1.0 每请求一条连接,
# 4 个 headless Edge 同时各拉 14MB 数据脚本时会把页面加载拖到截图之后。
protocol_version = "HTTP/1.1"
wbufsize = 1 << 20
# 按 (路径, mtime) 缓存字节:同一份 14MB 数据在一次批量截图里要发几十次。
_CACHE = {}
def __init__(self, *a, **k):
super().__init__(*a, directory=REPO, **k)
def _cached_bytes(self, path):
try:
st = os.stat(path)
except OSError:
return None
key = (path, st.st_mtime_ns, st.st_size)
body = H._CACHE.get(key)
if body is None:
with open(path, "rb") as f:
body = f.read()
if st.st_size <= 64 << 20:
if len(H._CACHE) > 12:
H._CACHE.clear()
H._CACHE[key] = body
return body
def send_head(self):
rel = self.path.split("?", 1)[0].split("#", 1)[0].lstrip("/").replace("\\", "/")
top = rel.split("/")[0].lower()
if top in [d.lower() for d in DENY] or ".." in rel:
self.send_error(404, "denied")
return None
fs = self.translate_path(self.path)
if os.path.isfile(fs):
body = self._cached_bytes(fs)
if body is not None:
self.send_response(200)
self.send_header("Content-Type", self.guess_type(fs))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return None
return super().send_head()
def guess_type(self, path):
ext = os.path.splitext(path)[1].lower()
return MIME.get(ext) or super().guess_type(path)
def do_GET(self):
"""`/` 必须 302 到 /webapp/index.html,不能在原地把内容发出去:
那样文档基址是 `/`,页面里所有相对路径(styles/… js/… vendor/…)都会
解析到 /styles/base.css 之类而 404 —— 实测整页样式与脚本全丢。"""
if self.path.split("?", 1)[0].rstrip("/") in ("", "/"):
q = self.path.split("?", 1)[1] if "?" in self.path else ""
self.send_response(302)
self.send_header("Location", "/webapp/index.html" + ("?" + q if q else ""))
self.send_header("Content-Length", "0")
self.end_headers()
return
super().do_GET()
def end_headers(self):
self.send_header("Cache-Control", "no-store")
super().end_headers()
def log_message(self, fmt, *args):
sys.stderr.write("%s %s\n" % (self.address_string(), fmt % args))
def serve(port=8123):
srv = None
for p in range(port, port + 20):
try:
srv = ThreadingHTTPServer(("127.0.0.1", p), H)
port = p
break
except OSError:
continue
if srv is None:
raise SystemExit("端口 %d..%d 都被占用" % (port, port + 19))
url = "http://127.0.0.1:%d/" % port
print("docroot %s" % REPO, flush=True)
print("查看器: %s (Ctrl+C 停止;已禁用缓存,改完刷新即新构建)" % url, flush=True)
srv.serve_forever()
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="查看器本地静态服务器")
ap.add_argument("--port", type=int, default=8123)
serve(ap.parse_args().port)
+71 -10
View File
@@ -11,9 +11,15 @@ try:
except Exception: except Exception:
pass pass
from arm.paths import WEB, DOCS from arm.paths import DOCS, REPO
SHOTS = DOCS + "/shots" SHOTS = DOCS + "/shots"
HTML = WEB + "/机器人手臂3D查看器.html" # 虚拟时间预算:多文件版每页要拉 14MB 数据脚本 + 668KB three,4 并发下 20s 会不够(实测 8/10 视图两轮不一致)
# 时可上调。默认仍给 20000,用 ARM_VTB 覆盖做对照实验。
VTB = os.environ.get("ARM_VTB", "40000")
WORKERS = int(os.environ.get("ARM_SHOT_WORKERS", "2"))
# 前端拆分后是多文件 + Worker,只能走 http(file:// 下模块与 Worker 会被 CORS 拦掉)。
PORT = int(os.environ.get("ARM_PORT", "8123"))
BASE = "http://127.0.0.1:%d/webapp/index.html" % PORT
CAND = [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", CAND = [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"] r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"]
EDGE = next((c for c in CAND if os.path.exists(c)), None) EDGE = next((c for c in CAND if os.path.exists(c)), None)
@@ -119,9 +125,9 @@ VIEWS = {
"user_p07_check": "?az=1.5708&po=1.5708&rd=260&tx=19.5&ty=220&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P06,P09,M1,M2,M3,M4,M5,M6,ENV", "user_p07_check": "?az=1.5708&po=1.5708&rd=260&tx=19.5&ty=220&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P06,P09,M1,M2,M3,M4,M5,M6,ENV",
"boss_p06": "?az=1.5708&po=1.5708&rd=190&tx=18.5&ty=332&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P07,P08,P09,M1,M2,M3,M5,M6,ENV", "boss_p06": "?az=1.5708&po=1.5708&rd=190&tx=18.5&ty=332&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P07,P08,P09,M1,M2,M3,M5,M6,ENV",
"boss_p07": "?az=0&po=0.0&rd=210&tx=19.5&ty=215&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P06,P08,P09,M1,M2,M3,M4,M6,ENV", "boss_p07": "?az=0&po=0.0&rd=210&tx=19.5&ty=215&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P06,P08,P09,M1,M2,M3,M4,M6,ENV",
"svg_side": f"file:///{DOCS}/sheet_side.svg", "svg_side": f"http://127.0.0.1:{PORT}/docs/sheet_side.svg",
"svg_joint": f"file:///{DOCS}/sheet_joint.svg", "svg_joint": f"http://127.0.0.1:{PORT}/docs/sheet_joint.svg",
"svg_exploded": f"file:///{DOCS}/sheet_exploded.svg", "svg_exploded": f"http://127.0.0.1:{PORT}/docs/sheet_exploded.svg",
"user_circle_check": "?az=0.8&po=1.95&rd=240&tx=19.5&ty=430&tz=-128&xray=0&hide=M1,M2,M3,M4,M5,M6,ENV", "user_circle_check": "?az=0.8&po=1.95&rd=240&tx=19.5&ty=430&tz=-128&xray=0&hide=M1,M2,M3,M4,M5,M6,ENV",
"user_exact_view": "?az=1.5708&po=1.92&rd=150&tx=19.5&ty=440&tz=-128&xray=0&hide=P01,P02,P03,M1,M2,M3,M4,M5,M6,ENV", "user_exact_view": "?az=1.5708&po=1.92&rd=150&tx=19.5&ty=440&tz=-128&xray=0&hide=P01,P02,P03,M1,M2,M3,M4,M5,M6,ENV",
"shot_p08_user": "?az=1.5708&po=1.92&rd=150&tx=19.5&ty=176&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P06,P07,P09,M1,M2,M3,M4,M5,M6,ENV", "shot_p08_user": "?az=1.5708&po=1.92&rd=150&tx=19.5&ty=176&tz=-128&xray=0&hide=P01,P02,P03,P04,P05,P06,P07,P09,M1,M2,M3,M4,M5,M6,ENV",
@@ -153,15 +159,16 @@ def take_shot(nm):
out = "%s/%s.png" % (SHOTS, nm) out = "%s/%s.png" % (SHOTS, nm)
import time, tempfile import time, tempfile
t_bust = int(time.time() * 1000) t_bust = int(time.time() * 1000)
target = q if q.startswith("file://") else "file:///" + HTML.replace("\\", "/") + q + ( # serve.py 已下发 Cache-Control: no-store,不再需要 _t= 破缓存
f"&still=1&_t={t_bust}" if "?" in q else f"?still=1&_t={t_bust}") target = q if q.startswith("http") else BASE + q + (
"&still=1" if "?" in q else "?still=1")
udd = tempfile.mkdtemp(prefix="eshot") udd = tempfile.mkdtemp(prefix="eshot")
cmd = [EDGE, "--headless=new", "--use-gl=angle", "--use-angle=d3d11", cmd = [EDGE, "--headless=new", "--use-gl=angle", "--use-angle=d3d11",
"--disable-cache", "--disable-application-cache", "--disk-cache-size=0", "--disable-cache", "--disable-application-cache", "--disk-cache-size=0",
"--user-data-dir=" + udd, "--user-data-dir=" + udd,
"--enable-gpu-rasterization", "--ignore-gpu-blocklist", "--enable-gpu-rasterization", "--ignore-gpu-blocklist",
"--hide-scrollbars", "--force-device-scale-factor=1", "--hide-scrollbars", "--force-device-scale-factor=1",
"--window-size=1500,860", "--virtual-time-budget=20000", "--window-size=1500,860", "--virtual-time-budget=" + VTB,
"--screenshot=" + out, target] "--screenshot=" + out, target]
r = subprocess.run(cmd, capture_output=True, timeout=180) r = subprocess.run(cmd, capture_output=True, timeout=180)
shutil.rmtree(udd, ignore_errors=True) shutil.rmtree(udd, ignore_errors=True)
@@ -171,10 +178,64 @@ def take_shot(nm):
print("%-18s %s %8s %s" % (nm, "OK " if ok else "FAIL", sz, err), flush=True) print("%-18s %s %8s %s" % (nm, "OK " if ok else "FAIL", sz, err), flush=True)
return ok return ok
def warmup():
"""拉一次最大的那个数据脚本,成功才算服务可用。"""
import urllib.request
try:
with urllib.request.urlopen(BASE, timeout=5) as r:
r.read()
with urllib.request.urlopen("http://127.0.0.1:%d/webapp/generated/00_data.js" % PORT,
timeout=30) as r:
n = len(r.read())
return n > 1000000
except Exception:
return False
def ensure_server():
"""端口没开就自己拉一个 serve 子进程,跑完回收;不让截图依赖"你先把服务开着"。"""
import socket
import time
sk = socket.socket()
try:
sk.connect(("127.0.0.1", PORT))
sk.close()
return None
except OSError:
pass
errf = open(os.path.join(REPO, "scratch", "_serve_err.txt"), "wb")
p = subprocess.Popen([sys.executable, "-m", "arm.viewer.serve", "--port", str(PORT)],
cwd=REPO, stdout=subprocess.DEVNULL, stderr=errf)
for _ in range(60):
time.sleep(0.25)
sk = socket.socket()
try:
sk.connect(("127.0.0.1", PORT))
sk.close()
if warmup():
print("serve 已拉起 :%d(首屏数据已预热)" % PORT, flush=True)
return p
except OSError:
pass
finally:
try:
sk.close()
except Exception:
pass
p.kill()
err = open(os.path.join(REPO, "scratch", "_serve_err.txt"), "rb").read().decode("utf-8", "ignore").strip()[-400:]
raise SystemExit("serve 拉不起来:端口 %d 可能被占;子进程输出:\n%s" % (PORT, err))
if __name__ == "__main__": if __name__ == "__main__":
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(4, os.cpu_count() or 2)) as executor: srv = ensure_server()
results = list(executor.map(take_shot, want)) try:
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
results = list(executor.map(take_shot, want))
finally:
if srv:
srv.terminate()
print("edge:", EDGE) print("edge:", EDGE)
print("all shots ok:", all(results)) print("all shots ok:", all(results))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 131 KiB

+5 -1
View File
@@ -3,4 +3,8 @@ c66c230557548bd03f003bb4bd52ff6a travel_detail.json
4a8f14be95ed32121166a9bd37e74752 collide.json 4a8f14be95ed32121166a9bd37e74752 collide.json
82f8fa459be497ae0cfc92d5816497bf screws.json 82f8fa459be497ae0cfc92d5816497bf screws.json
83dfad6817c766ee893f54cca8fa7774 mesh_arm.json 83dfad6817c766ee893f54cca8fa7774 mesh_arm.json
f74cf5148cfc03c2d785829f45a608f9 sheets.jsdata d94da355770a8b3da81f762bbaf3c843 00_data.js
ba7342b1c6dd3f6d6895ddec3e5736af sheet_front.svg
b12e59f6affee2ddc0e7d175922f7037 sheet_joint.svg
de9cd78f6070ec124b3ebcb116809d19 sheet_exploded.svg
f2504dea7e1a0e5b140f524b7570d962 sheet_side.svg
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

+19 -7
View File
@@ -1,19 +1,31 @@
{ {
"note": "实测同一构建两轮:mean<=0.00174, 单像素最大差 12, 无像素差>8。阈值取约 10x 余量;超过即视为真实渲染回归。", "note": "同一构建两轮实测:mean<=0.0019、单像素最大差 7、无像素差>8。阈值 mean<=0.02 / 单像素<=32 约留 20x 余量;超过即视为真实渲染回归。p03_mount_loose 是 ±1 量化噪声(曾两轮碰巧逐字节相同而漏进稳定清单)。",
"mean_max": 0.02, "mean_max": 0.02,
"pixel_max": 32, "pixel_max": 32,
"views": { "views": {
"m3_sheet_exploded": {
"mean": 7e-05,
"mx": 1
},
"boss_p07": {
"mean": 0.0,
"mx": 1
},
"harness_xray_internal": { "harness_xray_internal": {
"mean": 0.00087, "mean": 0.00137,
"mx": 6 "mx": 7
}, },
"harness_p07_xray": { "harness_p07_xray": {
"mean": 0.00097, "mean": 0.00112,
"mx": 5 "mx": 4
}, },
"harness_p04_p05_xray": { "harness_p04_p05_xray": {
"mean": 0.00174, "mean": 0.00145,
"mx": 12 "mx": 5
},
"p03_mount_loose": {
"mean": 0.0,
"mx": 1
} }
} }
} }
+72 -77
View File
@@ -1,22 +1,5 @@
af5c9d74bddf7d795bd38f4371fa61ec docs/shots/01_iso.png af5c9d74bddf7d795bd38f4371fa61ec docs/shots/01_iso.png
d1719a9f6fb775fd63cef27c5ff17f54 docs/shots/zoom_user.png
1389d052730b4bb09eb0a17cf9c49354 docs/shots/user_harness_view.png
eebfbfbea1375770fbc333f83f64e959 docs/shots/user_harness_zoom.png
606e209a6d3dc64ee3a59d1fc22339f3 docs/shots/user_m1_rear.png
99188cb1c040a2c6494322a9845ac6cb docs/shots/user_m1_exact.png
d3234ff06df60213391dab75589d65ab docs/shots/user_m1_bypass_oblique.png
02cfa0aa5681fec061f73b9d69fef41c docs/shots/user_smooth_harness.png
d25a25539d00674d608dca7234e331e0 docs/shots/user_contour_harness.png
c315879cfd09f35efa4ca4ecc1564b65 docs/shots/02_front.png c315879cfd09f35efa4ca4ecc1564b65 docs/shots/02_front.png
30ab6da268de109cd087b93b8b175cf9 docs/shots/m1_sheet_front.png
45050caba87c801ab0c29ac939e51e43 docs/shots/m2_sheet_joint.png
615621995924b8bcaf67d79a020cd8d2 docs/shots/m3_sheet_exploded.png
2b1950dda519fac21d5e71d4a9c1f990 docs/shots/m4_sheet_side.png
86098e0fada6f54d9ea3d285b3e0ed6e docs/shots/m5_guide_step1.png
8366cefeca8b935f9fef31bfd2454047 docs/shots/m6_guide_step5.png
40f4663eef6aa61f36e47e1fb1b1ab07 docs/shots/m7_sheet_blueprint.png
2702f107e6152f05a5016b916f38cb89 docs/shots/m8_sheet_dark.png
c84473f41febb608aea0ec2ec12ef317 docs/shots/m9_motion.png
b3157f4453cf96b930f065e134a2d18b docs/shots/03_side.png b3157f4453cf96b930f065e134a2d18b docs/shots/03_side.png
068fc4bc3eec481ff7d7b1d4da2aad88 docs/shots/04_top.png 068fc4bc3eec481ff7d7b1d4da2aad88 docs/shots/04_top.png
fbb95ea7a9229195d80c6f487021ae2b docs/shots/05_exploded.png fbb95ea7a9229195d80c6f487021ae2b docs/shots/05_exploded.png
@@ -28,77 +11,89 @@ af5c9d74bddf7d795bd38f4371fa61ec docs/shots/10_solid.png
8daa2255af050a225609cebbc490d3b9 docs/shots/11_tool_flange.png 8daa2255af050a225609cebbc490d3b9 docs/shots/11_tool_flange.png
002a1f4a2b65744515f5b54364fcc2a8 docs/shots/12_elbow_flare.png 002a1f4a2b65744515f5b54364fcc2a8 docs/shots/12_elbow_flare.png
b2f1454a6009a8b44ddd41be9efdaff5 docs/shots/13_forearm_side.png b2f1454a6009a8b44ddd41be9efdaff5 docs/shots/13_forearm_side.png
87a8889ba4031cdf2e378fca7e1b475c docs/shots/user_target.png
d51cde4a5e2450c529d1c8897e16cf29 docs/shots/view_yz_pos_x.png
1d8277f0b43938423f2ca53b34df0c56 docs/shots/view_yz_neg_x.png
64a199b2c65e7f2d0ad5e2a80c5caef8 docs/shots/view_iso_forearm.png
22f62024f51ee1827f662d8754019f57 docs/shots/user_view1.png
e96c715833f9321b9fd1501bc4bdf87a docs/shots/user_view2.png
d34d5d15434bc5a980e34e5511cc49a4 docs/shots/user_p04.png
6ea6b98ad978b993868bd302ad1dd8c6 docs/shots/user_p04_opp.png
85d13ea862263aa055924df2beca98fb docs/shots/user_elbow_rear.png
a17303b12669be77a97dc18f2463c809 docs/shots/user_p07_wrist.png
3ed51a9cbad23f7c9c7eb79ac5471eb1 docs/shots/user_elbow_front.png
729025864df7708c900e110ec175f0da docs/shots/user_p06_xz.png
095da56acdc9b512709786df26691481 docs/shots/user_p06_yz.png
fe0b30e16a673beb200224a0e6607faa docs/shots/user_p06_iso.png
abf7113f27b6443541e3a0859d77b3fe docs/shots/user_p07_creases.png
959e4bf08e174d90a1fe03a75a042070 docs/shots/bay_p06_inside.png
f9d8d58365b4ca693525af5540d39d32 docs/shots/bay_p07_inside.png
1b07f37e5fed9d82886b9518a3839860 docs/shots/bay_p08_inside.png
2c7f53c7bed7d25175948f09960ed634 docs/shots/user_p04_side.png
51d318f3584444ea78f2078d9ef3eddd docs/shots/user_p04_bottom.png
b31cd81ca26198896378bc38c4393b9d docs/shots/user_p04_bottom2.png
a60f07bb05bba7a58bdd29e5202c478f docs/shots/user_shoulder_solid.png
a60f07bb05bba7a58bdd29e5202c478f docs/shots/user_shoulder_xray.png
86aab5a8eb32216b4f712c730cc67068 docs/shots/user_shoulder_top.png
c22cef65ca2a74b1332ecaba6b9a484a docs/shots/p03_light_iso.png
2a9e076fa310b0c78b1a0e8375e8aae8 docs/shots/p03_light_side.png
5671bcc5a150d4899d186abd3658ef99 docs/shots/p03_light_rear.png
ebdeb9a180e95afd38f5a42d12743579 docs/shots/p03_light_join.png
0aa155c87743426668114d876478582c docs/shots/p03_light_xray.png
c55a40d4bc4b4e73a672326fa02d573c docs/shots/hollow_p04_top.png
7fe0cb0d52c1f7d97bf1c8fd47b22e0c docs/shots/hollow_p04_iso.png
f3b85ea576a6aa99a61953c4ff2a5ff4 docs/shots/hollow_p07_top.png
1464e622ec8090b6052ab82f7bcd6b22 docs/shots/hollow_p07_iso.png
8379d580ff746c859587e29e34700d7c docs/shots/hollow_p06_iso.png
78f62639acb79e30fa88cc5fda87cfea docs/shots/p03_mount_top.png
d0c82d07ab6055c1b67c03c126fe267a docs/shots/p03_mount_iso.png
21db2e9ca69192cce0be4467b4701028 docs/shots/p03_mount_loose.png
83325d3ca1b9e56fd2c70661052fd063 docs/shots/j3_前端压盖_装配.png
e169359e86a61f7ab80d4adea0231436 docs/shots/j3_前端压盖_全件.png
90b79bb61cacea2323ca19790d4158b2 docs/shots/boss_p04.png
6a86499dd36b2a0a1ac50ae28ea138e4 docs/shots/user_fork_fixed.png
e39999d2ef28536f24d7f6178fa9a15c docs/shots/bay_p04_inside.png e39999d2ef28536f24d7f6178fa9a15c docs/shots/bay_p04_inside.png
959e4bf08e174d90a1fe03a75a042070 docs/shots/bay_p06_inside.png 959e4bf08e174d90a1fe03a75a042070 docs/shots/bay_p06_inside.png
f9d8d58365b4ca693525af5540d39d32 docs/shots/bay_p07_inside.png f9d8d58365b4ca693525af5540d39d32 docs/shots/bay_p07_inside.png
ae0305c092707e0c73944e1d889d076a docs/shots/p04_both_ends.png 1b07f37e5fed9d82886b9518a3839860 docs/shots/bay_p08_inside.png
f241acda8fdae460eb38f5a054e841f9 docs/shots/p04_m2_side.png 90b79bb61cacea2323ca19790d4158b2 docs/shots/boss_p04.png
4c6014a754e233411f6e89e4112d6cde docs/shots/p06_m4_side.png
aa3cb2e1e7bff6e3ca4af66bd6c70854 docs/shots/p07_m5_side.png
515b204e42d75d98afe0600f8b8b419d docs/shots/p05_m3_side.png
dcc54259caf06f796e3dec84496abb12 docs/shots/boss_p05.png dcc54259caf06f796e3dec84496abb12 docs/shots/boss_p05.png
309cc9ac3238e747104e6ceee7bd68bc docs/shots/user_compare_view.png
152eb1db302b62238f3051b1f48fcf45 docs/shots/user_p07_check.png
9f2c134297f2c7a555a6eaca2e74e4de docs/shots/boss_p06.png 9f2c134297f2c7a555a6eaca2e74e4de docs/shots/boss_p06.png
b789564315ca1ad7d6d9d18c96972cba docs/shots/boss_p07.png
4a9a34e534496cb2c3be499ea2029d87 docs/shots/svg_side.png
eadcf7d6ac9853950496a559dbd8de72 docs/shots/svg_joint.png
f1e5bd79e6921b39d0785e4208ef5cca docs/shots/svg_exploded.png
1e1d29129833b35e43b925b25eae4b8f docs/shots/user_circle_check.png
6e1c039c236d28a4bec7e7b589fb2ed3 docs/shots/user_exact_view.png
7635f1d057b06f014c1683261a7237b8 docs/shots/shot_p08_user.png
3d261042388c8b2924848d8a29f71931 docs/shots/p05_flank_view.png
af5c9d74bddf7d795bd38f4371fa61ec docs/shots/harness_full_iso.png af5c9d74bddf7d795bd38f4371fa61ec docs/shots/harness_full_iso.png
8896124b0c45d9796e983f026134cff6 docs/shots/harness_overview.png 8896124b0c45d9796e983f026134cff6 docs/shots/harness_overview.png
9fca41ada43f36a338cce31797152c3c docs/shots/harness_p07_window.png
7cbf2c699501bff3c7ebb4beb7cd34e2 docs/shots/harness_p04_p05.png 7cbf2c699501bff3c7ebb4beb7cd34e2 docs/shots/harness_p04_p05.png
687e2184dd3e149939ce6d4214ffc698 docs/shots/harness_p06_window.png 687e2184dd3e149939ce6d4214ffc698 docs/shots/harness_p06_window.png
9fca41ada43f36a338cce31797152c3c docs/shots/harness_p07_window.png
84fb87bb5ce6cef73866401642ab0f9d docs/shots/harness_xray_full.png 84fb87bb5ce6cef73866401642ab0f9d docs/shots/harness_xray_full.png
70772a99b8c5d81619127eed7bc1dab5 docs/shots/tie_probe_negY.png 7fe0cb0d52c1f7d97bf1c8fd47b22e0c docs/shots/hollow_p04_iso.png
a3bf9bd7fb796283bbb9767cc790167a docs/shots/tie_probe_posY.png c55a40d4bc4b4e73a672326fa02d573c docs/shots/hollow_p04_top.png
8379d580ff746c859587e29e34700d7c docs/shots/hollow_p06_iso.png
1464e622ec8090b6052ab82f7bcd6b22 docs/shots/hollow_p07_iso.png
f3b85ea576a6aa99a61953c4ff2a5ff4 docs/shots/hollow_p07_top.png
e169359e86a61f7ab80d4adea0231436 docs/shots/j3_前端压盖_全件.png
83325d3ca1b9e56fd2c70661052fd063 docs/shots/j3_前端压盖_装配.png
30ab6da268de109cd087b93b8b175cf9 docs/shots/m1_sheet_front.png
45050caba87c801ab0c29ac939e51e43 docs/shots/m2_sheet_joint.png
2b1950dda519fac21d5e71d4a9c1f990 docs/shots/m4_sheet_side.png
86098e0fada6f54d9ea3d285b3e0ed6e docs/shots/m5_guide_step1.png
8366cefeca8b935f9fef31bfd2454047 docs/shots/m6_guide_step5.png
40f4663eef6aa61f36e47e1fb1b1ab07 docs/shots/m7_sheet_blueprint.png
2702f107e6152f05a5016b916f38cb89 docs/shots/m8_sheet_dark.png
a6375fdce42c33395cad1586c9d6d8c5 docs/shots/m9_motion.png
c22cef65ca2a74b1332ecaba6b9a484a docs/shots/p03_light_iso.png
ebdeb9a180e95afd38f5a42d12743579 docs/shots/p03_light_join.png
5671bcc5a150d4899d186abd3658ef99 docs/shots/p03_light_rear.png
2a9e076fa310b0c78b1a0e8375e8aae8 docs/shots/p03_light_side.png
0aa155c87743426668114d876478582c docs/shots/p03_light_xray.png
d0c82d07ab6055c1b67c03c126fe267a docs/shots/p03_mount_iso.png
78f62639acb79e30fa88cc5fda87cfea docs/shots/p03_mount_top.png
ae0305c092707e0c73944e1d889d076a docs/shots/p04_both_ends.png
f241acda8fdae460eb38f5a054e841f9 docs/shots/p04_m2_side.png
3d261042388c8b2924848d8a29f71931 docs/shots/p05_flank_view.png
515b204e42d75d98afe0600f8b8b419d docs/shots/p05_m3_side.png
4c6014a754e233411f6e89e4112d6cde docs/shots/p06_m4_side.png
aa3cb2e1e7bff6e3ca4af66bd6c70854 docs/shots/p07_m5_side.png
7635f1d057b06f014c1683261a7237b8 docs/shots/shot_p08_user.png
f1e5bd79e6921b39d0785e4208ef5cca docs/shots/svg_exploded.png
eadcf7d6ac9853950496a559dbd8de72 docs/shots/svg_joint.png
4a9a34e534496cb2c3be499ea2029d87 docs/shots/svg_side.png
d3bf901601ff221ecd1b09dc93dd2ccc docs/shots/tie_after_close.png
7c2be3a8488997de588e3760f966fd7d docs/shots/tie_after_negY.png 7c2be3a8488997de588e3760f966fd7d docs/shots/tie_after_negY.png
0b3e26c6e2fd664f91764d26b8b1ed0c docs/shots/tie_after_oblique.png 0b3e26c6e2fd664f91764d26b8b1ed0c docs/shots/tie_after_oblique.png
d3bf901601ff221ecd1b09dc93dd2ccc docs/shots/tie_after_close.png
c39cee9deabedb4dc8fad8ec76b439e5 docs/shots/tie_after_p0607.png c39cee9deabedb4dc8fad8ec76b439e5 docs/shots/tie_after_p0607.png
b51a4e5424297c9012cc0562bbeec296 docs/shots/tie_after_p0607_obl.png b51a4e5424297c9012cc0562bbeec296 docs/shots/tie_after_p0607_obl.png
70772a99b8c5d81619127eed7bc1dab5 docs/shots/tie_probe_negY.png
a3bf9bd7fb796283bbb9767cc790167a docs/shots/tie_probe_posY.png
1e1d29129833b35e43b925b25eae4b8f docs/shots/user_circle_check.png
309cc9ac3238e747104e6ceee7bd68bc docs/shots/user_compare_view.png
d25a25539d00674d608dca7234e331e0 docs/shots/user_contour_harness.png
3ed51a9cbad23f7c9c7eb79ac5471eb1 docs/shots/user_elbow_front.png
85d13ea862263aa055924df2beca98fb docs/shots/user_elbow_rear.png
6e1c039c236d28a4bec7e7b589fb2ed3 docs/shots/user_exact_view.png
6a86499dd36b2a0a1ac50ae28ea138e4 docs/shots/user_fork_fixed.png
1389d052730b4bb09eb0a17cf9c49354 docs/shots/user_harness_view.png
eebfbfbea1375770fbc333f83f64e959 docs/shots/user_harness_zoom.png
d3234ff06df60213391dab75589d65ab docs/shots/user_m1_bypass_oblique.png
99188cb1c040a2c6494322a9845ac6cb docs/shots/user_m1_exact.png
606e209a6d3dc64ee3a59d1fc22339f3 docs/shots/user_m1_rear.png
d34d5d15434bc5a980e34e5511cc49a4 docs/shots/user_p04.png
51d318f3584444ea78f2078d9ef3eddd docs/shots/user_p04_bottom.png
b31cd81ca26198896378bc38c4393b9d docs/shots/user_p04_bottom2.png
6ea6b98ad978b993868bd302ad1dd8c6 docs/shots/user_p04_opp.png
2c7f53c7bed7d25175948f09960ed634 docs/shots/user_p04_side.png
fe0b30e16a673beb200224a0e6607faa docs/shots/user_p06_iso.png
729025864df7708c900e110ec175f0da docs/shots/user_p06_xz.png
095da56acdc9b512709786df26691481 docs/shots/user_p06_yz.png
152eb1db302b62238f3051b1f48fcf45 docs/shots/user_p07_check.png
abf7113f27b6443541e3a0859d77b3fe docs/shots/user_p07_creases.png
a17303b12669be77a97dc18f2463c809 docs/shots/user_p07_wrist.png
a60f07bb05bba7a58bdd29e5202c478f docs/shots/user_shoulder_solid.png
86aab5a8eb32216b4f712c730cc67068 docs/shots/user_shoulder_top.png
a60f07bb05bba7a58bdd29e5202c478f docs/shots/user_shoulder_xray.png
02cfa0aa5681fec061f73b9d69fef41c docs/shots/user_smooth_harness.png
87a8889ba4031cdf2e378fca7e1b475c docs/shots/user_target.png
22f62024f51ee1827f662d8754019f57 docs/shots/user_view1.png
e96c715833f9321b9fd1501bc4bdf87a docs/shots/user_view2.png
64a199b2c65e7f2d0ad5e2a80c5caef8 docs/shots/view_iso_forearm.png
1d8277f0b43938423f2ca53b34df0c56 docs/shots/view_yz_neg_x.png
d51cde4a5e2450c529d1c8897e16cf29 docs/shots/view_yz_pos_x.png
d1719a9f6fb775fd63cef27c5ff17f54 docs/shots/zoom_user.png
+3 -2
View File
@@ -28,8 +28,9 @@ const argVal = (name, dflt) => {
}; };
const SOLVER = argVal('solver', first( const SOLVER = argVal('solver', first(
path.join(ROOT, 'webapp/js/ik/solver.js'), path.join(ROOT, 'webapp/js/ik/solver.js'), // S5 之后
path.join(ROOT, 'web/arm-ik-solver.js'))); path.join(ROOT, 'webapp/js/ik-solver.js'), // S4 现在
path.join(ROOT, 'web/arm-ik-solver.js'))); // 拆分前
const MESH = first(path.join(ROOT, 'webapp/data/mesh_arm.json'), const MESH = first(path.join(ROOT, 'webapp/data/mesh_arm.json'),
path.join(ROOT, 'web/mesh_arm.json')); path.join(ROOT, 'web/mesh_arm.json'));
const TRAVEL = path.join(ROOT, 'docs/travel.json'); const TRAVEL = path.join(ROOT, 'docs/travel.json');
+5 -1
View File
@@ -48,7 +48,7 @@ def main():
# 扫候选文件而不是写死一个路径,前端拆分时这条检查不用跟着改;一个都找不到就直接失败, # 扫候选文件而不是写死一个路径,前端拆分时这条检查不用跟着改;一个都找不到就直接失败,
# 绝不能"没匹配到 = 没有通道 = 零干涉"地假通过。 # 绝不能"没匹配到 = 没有通道 = 零干涉"地假通过。
from arm.paths import REPO from arm.paths import REPO
cands = [REPO + "/src/viewer_tpl.html", REPO + "/webapp/js/harness.js"] cands = [REPO + "/webapp/js/harness.js", REPO + "/webapp/js/app.js"]
found = [c for c in cands if os.path.exists(c)] found = [c for c in cands if os.path.exists(c)]
if not found: if not found:
print("找不到线束源码,检查项无法执行:", cands, flush=True) print("找不到线束源码,检查项无法执行:", cands, flush=True)
@@ -57,6 +57,10 @@ def main():
tpl = f.read() tpl = f.read()
tube_matches = re.findall(r'makeHarnTube\(\[\s*([\s\S]*?)\s*\]\s*,\s*[\d\.]+\s*,\s*\w+\s*,\s*\'([^\']+)\'', tpl) tube_matches = re.findall(r'makeHarnTube\(\[\s*([\s\S]*?)\s*\]\s*,\s*[\d\.]+\s*,\s*\w+\s*,\s*\'([^\']+)\'', tpl)
# 读到 0 个通道会被下面当成"零穿透"而 PASS —— 那是假通过,必须先炸。
if not tube_matches:
print("找不到任何线束通道定义,源码结构变了:", cands)
sys.exit(2)
total_hits = 0 total_hits = 0
checked_count = 0 checked_count = 0
+25 -3
View File
@@ -23,7 +23,11 @@ BASE = os.path.join(HERE, "baselines")
# 每个产物允许出现的目录,按优先级;找到第一个即取。 # 每个产物允许出现的目录,按优先级;找到第一个即取。
CANDIDATES = { CANDIDATES = {
"mesh_arm.json": ["webapp/data", "web"], "mesh_arm.json": ["webapp/data", "web"],
"sheets.jsdata": ["webapp/data", "web"], "00_data.js": ["webapp/generated"],
"sheet_front.svg": ["docs"],
"sheet_joint.svg": ["docs"],
"sheet_exploded.svg": ["docs"],
"sheet_side.svg": ["docs"],
"travel.json": ["docs", "webapp/data"], "travel.json": ["docs", "webapp/data"],
"travel_detail.json": ["docs", "webapp/data"], "travel_detail.json": ["docs", "webapp/data"],
"collide.json": ["docs", "webapp/data"], "collide.json": ["docs", "webapp/data"],
@@ -124,6 +128,12 @@ def check_shots():
print(" [MISS] 截图失败 %d 个: %s" % (len(bad), bad[:3])) print(" [MISS] 截图失败 %d 个: %s" % (len(bad), bad[:3]))
fails = len(bad) fails = len(bad)
def shoot(names):
subprocess.run([sys.executable, "-u", "-m", "arm.viewer.shot"] + names,
capture_output=True, text=True, cwd=REPO,
encoding="utf-8", errors="replace")
diff = []
for fname, want in sorted(base.items()): for fname, want in sorted(base.items()):
p = os.path.join(REPO, fname.replace("/", os.sep)) p = os.path.join(REPO, fname.replace("/", os.sep))
if not os.path.exists(p): if not os.path.exists(p):
@@ -131,8 +141,20 @@ def check_shots():
fails += 1 fails += 1
continue continue
if md5(p) != want: if md5(p) != want:
print(" [DIFF] %-40s" % fname) diff.append(fname)
fails += 1 if diff:
# 偶发"半加载截图"确实存在(实测同一构建重跑就回到基线)。串行重拍一次:
# 真回归重拍也回不来,只有竞态会消失 —— 不重拍就会变成随机假 FAIL,
# 关卡一旦不可信就会被忽略。
print(" %d 张首轮不一致,串行重拍复核: %s" % (len(diff), [os.path.basename(x) for x in diff]))
shoot([os.path.basename(x).replace(".png", "") for x in diff])
for fname in diff:
p = os.path.join(REPO, fname.replace("/", os.sep))
if md5(p) != base[fname]:
print(" [DIFF] %-40s 重拍后仍不一致" % fname)
fails += 1
else:
print(" [RETRY-OK] %-36s 竞态,非回归" % fname)
print(" 逐字节比对: %d 张稳定视图,%d 张不一致" % (len(base), fails - len(bad))) print(" 逐字节比对: %d 张稳定视图,%d 张不一致" % (len(base), fails - len(bad)))
tol_fails = 0 tol_fails = 0
File diff suppressed because one or more lines are too long
+357
View File
@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>六电机人形机器人手臂 · 装配与打印模型</title>
<link rel="stylesheet" href="styles/base.css">
<link rel="stylesheet" href="styles/sheet-chrome.css">
<link rel="stylesheet" href="styles/sheet-theme.css">
<link rel="stylesheet" href="styles/sheet-hud.css">
<link rel="stylesheet" href="styles/guide.css">
<link rel="stylesheet" href="styles/dock.css">
<link rel="stylesheet" href="styles/ikbar.css">
<link rel="stylesheet" href="styles/viewmode.css">
</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 src="vendor/three.min.js"></script>
<!-- 数据与螺钉清单是生成物(webapp/generated/00_data.js),由 python -m arm.viewer.build_viewer 写 -->
<script src="generated/00_data.js"></script>
<script src="js/app.js"></script>
<script src="js/ik-solver.js"></script>
<script src="js/ik-glue.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+103 -44
View File
@@ -542,7 +542,60 @@ function renderResult(res, src) {
全量回落也不再卡顿。Worker 不可用(老浏览器/CSP/file 限制)时自动回落 全量回落也不再卡顿。Worker 不可用(老浏览器/CSP/file 限制)时自动回落
主线程同步求解,行为与旧版逐位一致。碰撞 dynLimit/clampAxis 仍留主线程 主线程同步求解,行为与旧版逐位一致。碰撞 dynLimit/clampAxis 仍留主线程
(滑块交互需要同步边界),主线程的 S 实例保留给 FK/渲染/控制台 API。 */ (滑块交互需要同步边界),主线程的 S 实例保留给 FK/渲染/控制台 API。 */
let IKW = null, IKWseq = 0, IKWlast = 0; let IKW = null, IKWseq = 0, IKWlast = 0, IKWready = false;
/* 后端状态是给测试和人一起看的:worker=真后台线程,main=同步兜底。
旧实现失败时只把 IKW 置 false,页面照常跑但拖动会卡,谁也发现不了。 */
let IK_BACKEND = 'init';
const IK_BACKEND_LOG = [];
function ikBackend(kind, msg) {
IK_BACKEND = kind;
IK_BACKEND_LOG.push([kind, msg || '']);
if (kind === 'worker') {
console.log('[IK] 求解后端 = worker');
} else {
console.error('[IK] 求解后端 = ' + kind + (msg ? ':' + msg : ''));
const b = document.getElementById('ikBadge');
if (b) { b.textContent = '后台线程未运行 → 主线程同步求解(拖动会卡顿)'; b.className = 'badge badb'; }
}
}
const ikBootCfg = () => ({ joints: JMap, travel: TR, tcp: TCP0, collide: COL });
/* 给自动化测试与控制台用:后端状态与失败原因,不依赖面板文字 */
window.__IK_BACKEND = () => IK_BACKEND;
window.__IK_BACKEND_LOG = () => IK_BACKEND_LOG.slice();
/* 首选真 Worker 文件:需要 http(file:// 下浏览器拒绝跨源脚本)。 */
function ikWorkerFromFile() {
const w = new Worker('workers/ik.worker.js');
w.postMessage({ op: 'init', cfg: ikBootCfg() });
return w;
}
/* 兜底:单文件分发版是 file:// 打开的,那里没有可加载的 worker 文件,
只能把页面里内联的求解器源码做成 Blob Worker。 */
function ikWorkerFromBlob() {
let src = null;
for (const sc of document.querySelectorAll('script')) {
if (sc.textContent.indexOf('六轴手臂逆运动学求解器') >= 0) { src = sc.textContent; break; }
}
if (!src) throw new Error('页面内没有内联求解器源码,Blob 兜底也不可用');
const boot = String.raw`
let S = null;
onmessage = e => {
const d = e.data || {};
if (d.op === 'init') { S = ArmIK.make(d.cfg); postMessage({ op: 'ready' }); return; }
let r;
try { r = S[d.op].apply(S, d.args); } catch (err) { r = { __err: String(err && err.message || err) }; }
postMessage({ id: d.id, r: r });
};
`;
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' }));
try {
const w = new Worker(u);
w.postMessage({ op: 'init', cfg: ikBootCfg() });
return w;
} finally {
URL.revokeObjectURL(u); // Blob 只在构造时被读一次,失败路径也不留常驻对象
}
}
/* 每个请求按 id 记账,回包必定派发一次;「是否还是最新一帧」用第二参交给调用方判断。 /* 每个请求按 id 记账,回包必定派发一次;「是否还是最新一帧」用第二参交给调用方判断。
旧写法只留一个 IKWjob 槽:live 在飞时被 runSolve 插队,那一发的回调就彻底蒸发, 旧写法只留一个 IKWjob 槽:live 在飞时被 runSolve 插队,那一发的回调就彻底蒸发,
而它是唯一给 liveBusy 复位的地方——一次竞态即让实时追踪永久停摆。 */ 而它是唯一给 liveBusy 复位的地方——一次竞态即让实时追踪永久停摆。 */
@@ -550,36 +603,44 @@ const IKWjobs = new Map();
function ikWorker() { function ikWorker() {
if (IKW) return IKW; if (IKW) return IKW;
if (IKW === false) return null; if (IKW === false) return null;
try { /* file:// 下不能用 URL 构造 Worker(错误还是异步 onerror 才到,构造阶段不抛),
let src = null; 所以按协议选路;选错时再互相兜底。 */
for (const sc of document.querySelectorAll('script')) { const tries = location.protocol === 'file:' ? [ikWorkerFromBlob, ikWorkerFromFile]
if (sc.textContent.indexOf('六轴手臂逆运动学求解器') >= 0) { src = sc.textContent; break; } : [ikWorkerFromFile, ikWorkerFromBlob];
let w = null, why = null;
for (const mk of tries) {
try { w = mk(); break; } catch (e) { why = ((why ? why + ' / ' : '') + (e && e.message || e)); }
}
if (!w) { IKW = false; ikBackend('main', why); return null; }
w.onmessage = e => {
const d = e.data || {};
if (d.op === 'ready') {
IKWready = true;
clearTimeout(IKWtimer);
ikBackend('worker');
return;
} }
if (!src) throw new Error('ArmIK script 未找到'); const job = IKWjobs.get(d.id);
const boot = '\nconst CFGW = ' + JSON.stringify({ joints: JMap, travel: TR, tcp: TCP0 }) + ';\n' + if (!job) return; // 已结清过(正常不会发生)
'const COLW = ' + JSON.stringify(COL) + ';\n' + IKWjobs.delete(d.id);
'const SW = ArmIK.make({ joints: CFGW.joints, travel: CFGW.travel, tcp: CFGW.tcp, collide: COLW });\n' + job(d.r, d.id === IKWlast); // 过期请求也派发,只是标为不新鲜
'onmessage = e => { const d = e.data; let r;\n' + };
' try { r = SW[d.op].apply(SW, d.args); } catch (err) { r = { __err: String(err && err.message || err) }; }\n' + w.onerror = ev => { // Worker 挂了:把在飞的账全部结清并说清楚
' postMessage({ id: d.id, r: r }); };\n'; const pend = Array.from(IKWjobs.values()); IKWjobs.clear();
/* Blob URL 只在构造 Worker 时被读取,构造后立即回收(失败路径同样不留常驻 Blob) */ for (const j of pend) j(null, false);
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' })); IKW = false; IKWready = false;
let w; ikBackend('main', 'worker onerror: ' + ((ev && ev.message) || 'unknown'));
try { w = new Worker(u); } finally { URL.revokeObjectURL(u); } };
w.onmessage = e => { /* 握手超时同样是失败:不报就是"看起来在跑、其实一直同步" */
const job = IKWjobs.get(e.data.id); const IKWtimer = setTimeout(() => {
if (!job) return; // 已结清过(正常不会发生) if (!IKWready) {
IKWjobs.delete(e.data.id); try { w.terminate(); } catch (e) {}
job(e.data.r, e.data.id === IKWlast); // 过期请求也派发,只是标为不新鲜 IKW = false; IKWready = false;
}; ikBackend('main', '3s 内没收到 worker ready');
w.onerror = () => { // Worker 挂了:把在飞的账全部结清 }
IKW = false; }, 10000);
const pend = Array.from(IKWjobs.values()); IKWjobs.clear(); IKW = w;
for (const j of pend) j(null, false); return IKW;
};
IKW = w;
} catch (err) { IKW = false; }
return IKW || null;
} }
function solveAsync(op, args, job) { function solveAsync(op, args, job) {
const w = ikWorker(); const w = ikWorker();
@@ -1037,24 +1098,22 @@ function boot() {
const cb = $('ikChkBox'); const cb = $('ikChkBox');
if (cb) cb.addEventListener('toggle', () => { if (cb.open && !CHK.sigOk && !CHK.pose) runRecompute(); }); if (cb) cb.addEventListener('toggle', () => { if (cb.open && !CHK.sigOk && !CHK.pose) runRecompute(); });
} }
/* 打包自检:确认所有依赖都在本文件内(便于单文件分发,防止被拆开后才出问题) */ /* 依赖加载自检:单文件时代这里断言"外部引用必须为 0",拆分后那条不变量正好反过来。
const ext = document.querySelectorAll( 现在报的是"每样东西到没到、从哪来、求解跑在哪个后端",右下角面板与测试都读它。 */
'script[src],link[href],img[src],iframe[src],source[src],embed[src],object[data],use[href]').length;
const nSheet = (typeof SHEETS !== 'undefined' && SHEETS) ? String(SHEETS).split('<svg').length - 1 : 0; const nSheet = (typeof SHEETS !== 'undefined' && SHEETS) ? String(SHEETS).split('<svg').length - 1 : 0;
const ok = (t) => '<span class="' + (t ? 'good' : 'bad') + '">'; const ok = (t) => '<span class="' + (t ? 'good' : 'bad') + '">';
const nScript = document.querySelectorAll('script:not([src])').length; const mode = location.protocol === 'file:' ? 'file://(单文件分发版)' : 'http(webapp/ 多文件)';
$('ikDep').innerHTML = $('ikDep').innerHTML =
'<i>打包方式</i><span class="good">单文件自包含,可直接拷走</span>' + '<i>加载方式</i><span class="good">' + mode + '</span>' +
'<i>three.js</i>' + ok(typeof THREE !== 'undefined') + '已内联 · r' + '<i>three.js</i>' + ok(typeof THREE !== 'undefined') +
((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '</span>' + 'r' + ((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '</span>' +
'<i>几何数据</i>' + ok(typeof DATA !== 'undefined') + '已内联 · ' + DATA.parts.length + ' 件零件</span>' + '<i>几何数据</i>' + ok(typeof DATA !== 'undefined') +
((typeof DATA !== 'undefined') ? DATA.parts.length + ' 件零件' : '缺失') + '</span>' +
'<i>关节 / 限位</i>' + ok(typeof JO !== 'undefined' && typeof TRAVEL !== 'undefined') + '<i>关节 / 限位</i>' + ok(typeof JO !== 'undefined' && typeof TRAVEL !== 'undefined') +
'已内联 · ' + JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位</span>' + JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位</span>' +
'<i>二维图纸</i>' + ok(nSheet > 0) + '已内联 · ' + nSheet + ' 张 SVG</span>' + '<i>二维图纸</i>' + ok(nSheet > 0) + nSheet + ' 张 SVG</span>' +
'<i>IK 求解器</i>' + ok(typeof ArmIK !== 'undefined') + '已内联(纯 JS,无依赖)</span>' + '<i>IK 求解器</i>' + ok(typeof ArmIK !== 'undefined') + 'ArmIK</span>' +
'<i>内联脚本</i><span>' + nScript + ' 个(三维引擎 / 模型数据 / 求解器 / 界面)</span>' + '<i>求解后端</i>' + ok(IK_BACKEND === 'worker') + IK_BACKEND + '</span>';
'<i>外部引用</i>' + ok(ext === 0) +
(ext === 0 ? '0 个(script/link/img/iframe 等全类型已查)' : ext + ' 个 —— 已破坏自包含!') + '</span>';
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
const tgt = q.get('tgt'); // tgt=x,y,z:直接以该目标进入运动模拟(复现用) const tgt = q.get('tgt'); // tgt=x,y,z:直接以该目标进入运动模拟(复现用)
+69
View File
@@ -0,0 +1,69 @@
/* 设计令牌、reset、整体栅格与左栏/树/页脚 */
: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%}
+28
View File
@@ -0,0 +1,28 @@
/* 右边缘可收起 BOM 抽屉 */
/* ---- 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}}
+154
View File
@@ -0,0 +1,154 @@
/* 分步装配向导面板 */
/* 分步装配向导面板样式 */
#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)}
+67
View File
@@ -0,0 +1,67 @@
/* IK 控制条与左栏结果面板 */
/* ===================== 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}
+191
View File
@@ -0,0 +1,191 @@
/* 装配图工具栏、页签、主视口与画布 */
/* ==================== 交互式装配图查看器 (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;
}
+128
View File
@@ -0,0 +1,128 @@
/* 悬停 Tooltip 与 SVG 零件高亮 */
/* 悬停 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;
}
+149
View File
@@ -0,0 +1,149 @@
/* 图纸主题(白图/蓝图/深色)、浮动工具条、缩放徽标、信息条 */
/* 视觉风格主题 */
#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);
}
+33
View File
@@ -0,0 +1,33 @@
/* 视图模式切换时的布局与显隐规则 */
/* ============ 视图模式:三维模型(静态) / 装配图 / 运动模拟 ============ */
/* 三维模型 与 装配图 下,全部运动学 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}
}
+31
View File
@@ -0,0 +1,31 @@
/* IK 求解 Worker(module 之外的经典 Worker,用 importScripts 取 UMD 求解器)。
协议:主线程先发 {op:'init', cfg} → 回 {op:'ready'};之后 {id, op, args} → {id, r}。
旧实现是从页面 <script> 里刮求解器源码再拼 boot 字符串,那是单文件时代的产物:
拆成 <script src> 之后 textContent 为空,会静默退回主线程同步求解。 */
importScripts('../js/ik-solver.js');
let S = null;
onmessage = e => {
const d = e.data || {};
if (d.op === 'init') {
try {
S = ArmIK.make(d.cfg);
postMessage({ op: 'ready' });
} catch (err) {
postMessage({ id: d.id, r: { __err: 'init 失败: ' + String(err && err.message || err) } });
}
return;
}
if (!S) {
postMessage({ id: d.id, r: { __err: 'worker 未初始化(没收到 init)' } });
return;
}
let r;
try {
r = S[d.op].apply(S, d.args);
} catch (err) {
r = { __err: String(err && err.message || err) };
}
postMessage({ id: d.id, r: r });
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB