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),
# 过去被跟踪导致仓库背着 47MB 生成物;取消跟踪后重建不再脏 git status。
web/mesh_arm.json
web/sheets.jsdata
web/index.html
web/机器人手臂3D查看器.html
# 旧 web/ 单文件产物目录(已删,源码在 webapp/)
web/
/机器人手臂3D查看器.html
webapp/data/
webapp/generated/
dist/
data/cache/
# 这 3 张线束 x-ray 截图在同一份构建下就不逐字节复现(抗锯齿噪声),
# 这 6 张在同一份构建下不逐字节复现(抗锯齿/量化噪声),
# 每次跑关卡都会改写它们;参考像素已存进 tests/baselines/flaky/,不再入库。
docs/shots/harness_p04_p05_xray.png
docs/shots/harness_p07_xray.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
from arm.paths import WEB, CACHE
from arm.paths import DATA, CACHE
from arm import kinematics as HL
Y_AX = HL.Y_AXIS # 128
@@ -75,7 +75,7 @@ def main():
print("包络经纬网: 顶点%d 线段%d(经10° x 纬8mm)" % (len(Vl), len(sl) // 2))
# ---- 追加进 mesh_arm.json ----
path = WEB + "/mesh_arm.json"
path = DATA + "/mesh_arm.json"
data = json.load(open(path, encoding="utf-8"))
data["parts"] = [p for p in data["parts"] if not p["name"].startswith("ENV_")]
mx = max(p["idx"] for p in data["parts"])
+3 -3
View File
@@ -24,7 +24,7 @@ try:
except Exception:
pass
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 OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.BRepGProp import BRepGProp
@@ -156,7 +156,7 @@ def section_props(sh, normal, origin, bend=2, size=400.0):
def arm_masses():
"""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"]
tot = {}
for p in meta["parts"]:
@@ -199,7 +199,7 @@ def _mesh_vol_cg(v, t):
def part_table(payload_kg=None):
"""[(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 = []
for p in meta["parts"]:
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
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 os
@@ -22,7 +22,7 @@ except Exception:
pass
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.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
@@ -460,7 +460,7 @@ import time
from concurrent.futures import ThreadPoolExecutor
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)
print("=" * 80, flush=True)
@@ -499,7 +499,7 @@ def export(JS):
# 2. 多线程并行生成网页 3D Mesh
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)
done_mesh = 0
@@ -541,12 +541,12 @@ def export(JS):
L1=round(d3(JS["J2"].rear, JS["J4"].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))),
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 "已有/电机",
数量=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]
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
+3 -4
View File
@@ -17,7 +17,7 @@ import os
import sys
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 派生产物
# (实测:一次 importlib 冒烟测试就把 docs/travel.json 重写了一遍)。这里改成直接炸。
@@ -26,7 +26,7 @@ if __name__ != "__main__":
"draw_sheet 是脚本:顶层代码一执行就重写产物,不能 import。"
"要用就跑 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"]
MT, CT = MS["M"], MS["C"]
JOINTS = {j["id"]: j for j in D["joints"]}
@@ -333,6 +333,5 @@ sheets = [
os.makedirs(DOCS, exist_ok=True)
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(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" % (
print("sheets: %d (%d KB) -> docs/sheet_{front,joint,exploded,side}.svg(查看器读这 4 个文件)" % (
len(sheets), sum(len(s) for s in sheets) // 1024))
+3 -3
View File
@@ -3,7 +3,7 @@
docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量 / 位置 / 备注)。
本模块把清单里的每一颗螺钉按其真实装配孔位还原成三维实例(承载头面坐标 + 拧入方向 +
随动关节组),生成轻量三角网格后追加进 web/mesh_arm.json 的 parts 中。
随动关节组),生成轻量三角网格后追加进 webapp/data/mesh_arm.json 的 parts 中。
查看器因此可以:显示全部螺钉、点击 BOM 螺丝行高亮定位、点击螺钉反查清单。
实例坐标来源(与 arm_model.py 成孔代码一一对应,改模型后按同样规则复核):
@@ -51,7 +51,7 @@ try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
from arm.paths import WEB, DOCS
from arm.paths import DATA, DOCS
from arm import kinematics as HL
from arm.cad.part_tools import CLAMP_BCD, CLAMP_CLOCK
X_COL = -115.0 # 立柱中心(与 parts/p01_column.py 的 X_COL 一致)
@@ -287,7 +287,7 @@ def instances(m2_bcd, m2_clock):
# ---------------------------------------------------------------- 注入
def main():
path = WEB + "/mesh_arm.json"
path = DATA + "/mesh_arm.json"
print("读取 %s ..." % path, flush=True)
d = json.load(open(path, encoding="utf-8"))
M = d["measured"]["M"]
+7 -3
View File
@@ -15,10 +15,14 @@ SRC_STEP = MODEL + r"/电机.STEP"
HALF_STEP = MODEL + r"/配件/端面齿半联轴器_相同件x2.STEP"
CLAMP_STEP = MODEL + r"/配件/端面齿联轴器_抱箍_两片装配.STEP"
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"
# 可达包络 FK 采样的缓存(ws_cloud.npz / ws_metrics.json)。原先躺在 gitignore 的
# scratch/ 里,构建却依赖它——缺了新克隆就跑不出 ENV 件。
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 -*-
"""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 json
import os
import re
import sys
import time
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
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():
print("=" * 80, flush=True)
print("【第五阶段 5/5】打包单文件三维交互查看器 (机器人手臂3D查看器.html)...", flush=True)
print("=" * 80, flush=True)
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)
"""build_model 流水线的最后一步(沿用旧函数名,调用点不改)。"""
write_data()
build_single()
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__":
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:
pass
from arm.paths import WEB, DOCS
from arm.paths import DOCS, REPO
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",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"]
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",
"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",
"svg_side": f"file:///{DOCS}/sheet_side.svg",
"svg_joint": f"file:///{DOCS}/sheet_joint.svg",
"svg_exploded": f"file:///{DOCS}/sheet_exploded.svg",
"svg_side": f"http://127.0.0.1:{PORT}/docs/sheet_side.svg",
"svg_joint": f"http://127.0.0.1:{PORT}/docs/sheet_joint.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_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",
@@ -153,15 +159,16 @@ def take_shot(nm):
out = "%s/%s.png" % (SHOTS, nm)
import time, tempfile
t_bust = int(time.time() * 1000)
target = q if q.startswith("file://") else "file:///" + HTML.replace("\\", "/") + q + (
f"&still=1&_t={t_bust}" if "?" in q else f"?still=1&_t={t_bust}")
# serve.py 已下发 Cache-Control: no-store,不再需要 _t= 破缓存
target = q if q.startswith("http") else BASE + q + (
"&still=1" if "?" in q else "?still=1")
udd = tempfile.mkdtemp(prefix="eshot")
cmd = [EDGE, "--headless=new", "--use-gl=angle", "--use-angle=d3d11",
"--disable-cache", "--disable-application-cache", "--disk-cache-size=0",
"--user-data-dir=" + udd,
"--enable-gpu-rasterization", "--ignore-gpu-blocklist",
"--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]
r = subprocess.run(cmd, capture_output=True, timeout=180)
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)
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__":
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(4, os.cpu_count() or 2)) as executor:
results = list(executor.map(take_shot, want))
srv = ensure_server()
try:
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
results = list(executor.map(take_shot, want))
finally:
if srv:
srv.terminate()
print("edge:", EDGE)
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
82f8fa459be497ae0cfc92d5816497bf screws.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,
"pixel_max": 32,
"views": {
"m3_sheet_exploded": {
"mean": 7e-05,
"mx": 1
},
"boss_p07": {
"mean": 0.0,
"mx": 1
},
"harness_xray_internal": {
"mean": 0.00087,
"mx": 6
"mean": 0.00137,
"mx": 7
},
"harness_p07_xray": {
"mean": 0.00097,
"mx": 5
"mean": 0.00112,
"mx": 4
},
"harness_p04_p05_xray": {
"mean": 0.00174,
"mx": 12
"mean": 0.00145,
"mx": 5
},
"p03_mount_loose": {
"mean": 0.0,
"mx": 1
}
}
}
+72 -77
View File
@@ -1,22 +1,5 @@
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
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
068fc4bc3eec481ff7d7b1d4da2aad88 docs/shots/04_top.png
fbb95ea7a9229195d80c6f487021ae2b docs/shots/05_exploded.png
@@ -28,77 +11,89 @@ af5c9d74bddf7d795bd38f4371fa61ec docs/shots/10_solid.png
8daa2255af050a225609cebbc490d3b9 docs/shots/11_tool_flange.png
002a1f4a2b65744515f5b54364fcc2a8 docs/shots/12_elbow_flare.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
959e4bf08e174d90a1fe03a75a042070 docs/shots/bay_p06_inside.png
f9d8d58365b4ca693525af5540d39d32 docs/shots/bay_p07_inside.png
ae0305c092707e0c73944e1d889d076a docs/shots/p04_both_ends.png
f241acda8fdae460eb38f5a054e841f9 docs/shots/p04_m2_side.png
4c6014a754e233411f6e89e4112d6cde docs/shots/p06_m4_side.png
aa3cb2e1e7bff6e3ca4af66bd6c70854 docs/shots/p07_m5_side.png
515b204e42d75d98afe0600f8b8b419d docs/shots/p05_m3_side.png
1b07f37e5fed9d82886b9518a3839860 docs/shots/bay_p08_inside.png
90b79bb61cacea2323ca19790d4158b2 docs/shots/boss_p04.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
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
8896124b0c45d9796e983f026134cff6 docs/shots/harness_overview.png
9fca41ada43f36a338cce31797152c3c docs/shots/harness_p07_window.png
7cbf2c699501bff3c7ebb4beb7cd34e2 docs/shots/harness_p04_p05.png
687e2184dd3e149939ce6d4214ffc698 docs/shots/harness_p06_window.png
9fca41ada43f36a338cce31797152c3c docs/shots/harness_p07_window.png
84fb87bb5ce6cef73866401642ab0f9d docs/shots/harness_xray_full.png
70772a99b8c5d81619127eed7bc1dab5 docs/shots/tie_probe_negY.png
a3bf9bd7fb796283bbb9767cc790167a docs/shots/tie_probe_posY.png
7fe0cb0d52c1f7d97bf1c8fd47b22e0c docs/shots/hollow_p04_iso.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
0b3e26c6e2fd664f91764d26b8b1ed0c docs/shots/tie_after_oblique.png
d3bf901601ff221ecd1b09dc93dd2ccc docs/shots/tie_after_close.png
c39cee9deabedb4dc8fad8ec76b439e5 docs/shots/tie_after_p0607.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(
path.join(ROOT, 'webapp/js/ik/solver.js'),
path.join(ROOT, 'web/arm-ik-solver.js')));
path.join(ROOT, 'webapp/js/ik/solver.js'), // S5 之后
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'),
path.join(ROOT, 'web/mesh_arm.json'));
const TRAVEL = path.join(ROOT, 'docs/travel.json');
+5 -1
View File
@@ -48,7 +48,7 @@ def main():
# 扫候选文件而不是写死一个路径,前端拆分时这条检查不用跟着改;一个都找不到就直接失败,
# 绝不能"没匹配到 = 没有通道 = 零干涉"地假通过。
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)]
if not found:
print("找不到线束源码,检查项无法执行:", cands, flush=True)
@@ -57,6 +57,10 @@ def main():
tpl = f.read()
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
checked_count = 0
+25 -3
View File
@@ -23,7 +23,11 @@ BASE = os.path.join(HERE, "baselines")
# 每个产物允许出现的目录,按优先级;找到第一个即取。
CANDIDATES = {
"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_detail.json": ["docs", "webapp/data"],
"collide.json": ["docs", "webapp/data"],
@@ -124,6 +128,12 @@ def check_shots():
print(" [MISS] 截图失败 %d 个: %s" % (len(bad), bad[:3]))
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()):
p = os.path.join(REPO, fname.replace("/", os.sep))
if not os.path.exists(p):
@@ -131,8 +141,20 @@ def check_shots():
fails += 1
continue
if md5(p) != want:
print(" [DIFF] %-40s" % fname)
fails += 1
diff.append(fname)
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)))
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 限制)时自动回落
主线程同步求解,行为与旧版逐位一致。碰撞 dynLimit/clampAxis 仍留主线程
(滑块交互需要同步边界),主线程的 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 记账,回包必定派发一次;「是否还是最新一帧」用第二参交给调用方判断。
旧写法只留一个 IKWjob 槽:live 在飞时被 runSolve 插队,那一发的回调就彻底蒸发,
而它是唯一给 liveBusy 复位的地方——一次竞态即让实时追踪永久停摆。 */
@@ -550,36 +603,44 @@ const IKWjobs = new Map();
function ikWorker() {
if (IKW) return IKW;
if (IKW === false) return null;
try {
let src = null;
for (const sc of document.querySelectorAll('script')) {
if (sc.textContent.indexOf('六轴手臂逆运动学求解器') >= 0) { src = sc.textContent; break; }
/* file:// 下不能用 URL 构造 Worker(错误还是异步 onerror 才到,构造阶段不抛),
所以按协议选路;选错时再互相兜底。 */
const tries = location.protocol === 'file:' ? [ikWorkerFromBlob, ikWorkerFromFile]
: [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 boot = '\nconst CFGW = ' + JSON.stringify({ joints: JMap, travel: TR, tcp: TCP0 }) + ';\n' +
'const COLW = ' + JSON.stringify(COL) + ';\n' +
'const SW = ArmIK.make({ joints: CFGW.joints, travel: CFGW.travel, tcp: CFGW.tcp, collide: COLW });\n' +
'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' +
' postMessage({ id: d.id, r: r }); };\n';
/* Blob URL 只在构造 Worker 时被读取,构造后立即回收(失败路径同样不留常驻 Blob) */
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' }));
let w;
try { w = new Worker(u); } finally { URL.revokeObjectURL(u); }
w.onmessage = e => {
const job = IKWjobs.get(e.data.id);
if (!job) return; // 已结清过(正常不会发生)
IKWjobs.delete(e.data.id);
job(e.data.r, e.data.id === IKWlast); // 过期请求也派发,只是标为不新鲜
};
w.onerror = () => { // Worker 挂了:把在飞的账全部结清
IKW = false;
const pend = Array.from(IKWjobs.values()); IKWjobs.clear();
for (const j of pend) j(null, false);
};
IKW = w;
} catch (err) { IKW = false; }
return IKW || null;
const job = IKWjobs.get(d.id);
if (!job) return; // 已结清过(正常不会发生)
IKWjobs.delete(d.id);
job(d.r, d.id === IKWlast); // 过期请求也派发,只是标为不新鲜
};
w.onerror = ev => { // Worker 挂了:把在飞的账全部结清并说清楚
const pend = Array.from(IKWjobs.values()); IKWjobs.clear();
for (const j of pend) j(null, false);
IKW = false; IKWready = false;
ikBackend('main', 'worker onerror: ' + ((ev && ev.message) || 'unknown'));
};
/* 握手超时同样是失败:不报就是"看起来在跑、其实一直同步" */
const IKWtimer = setTimeout(() => {
if (!IKWready) {
try { w.terminate(); } catch (e) {}
IKW = false; IKWready = false;
ikBackend('main', '3s 内没收到 worker ready');
}
}, 10000);
IKW = w;
return IKW;
}
function solveAsync(op, args, job) {
const w = ikWorker();
@@ -1037,24 +1098,22 @@ function boot() {
const cb = $('ikChkBox');
if (cb) cb.addEventListener('toggle', () => { if (cb.open && !CHK.sigOk && !CHK.pose) runRecompute(); });
}
/* 打包自检:确认所有依赖都在本文件内(便于单文件分发,防止被拆开后才出问题) */
const ext = document.querySelectorAll(
'script[src],link[href],img[src],iframe[src],source[src],embed[src],object[data],use[href]').length;
/* 依赖加载自检:单文件时代这里断言"外部引用必须为 0",拆分后那条不变量正好反过来。
现在报的是"每样东西到没到、从哪来、求解跑在哪个后端",右下角面板与测试都读它。 */
const nSheet = (typeof SHEETS !== 'undefined' && SHEETS) ? String(SHEETS).split('<svg').length - 1 : 0;
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 =
'<i>打包方式</i><span class="good">单文件自包含,可直接拷走</span>' +
'<i>three.js</i>' + ok(typeof THREE !== 'undefined') + '已内联 · r' +
((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '</span>' +
'<i>几何数据</i>' + ok(typeof DATA !== 'undefined') + '已内联 · ' + DATA.parts.length + ' 件零件</span>' +
'<i>加载方式</i><span class="good">' + mode + '</span>' +
'<i>three.js</i>' + ok(typeof THREE !== 'undefined') +
'r' + ((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '</span>' +
'<i>几何数据</i>' + ok(typeof DATA !== 'undefined') +
((typeof DATA !== 'undefined') ? DATA.parts.length + ' 件零件' : '缺失') + '</span>' +
'<i>关节 / 限位</i>' + ok(typeof JO !== 'undefined' && typeof TRAVEL !== 'undefined') +
'已内联 · ' + JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位</span>' +
'<i>二维图纸</i>' + ok(nSheet > 0) + '已内联 · ' + nSheet + ' 张 SVG</span>' +
'<i>IK 求解器</i>' + ok(typeof ArmIK !== 'undefined') + '已内联(纯 JS,无依赖)</span>' +
'<i>内联脚本</i><span>' + nScript + ' 个(三维引擎 / 模型数据 / 求解器 / 界面)</span>' +
'<i>外部引用</i>' + ok(ext === 0) +
(ext === 0 ? '0 个(script/link/img/iframe 等全类型已查)' : ext + ' 个 —— 已破坏自包含!') + '</span>';
JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位</span>' +
'<i>二维图纸</i>' + ok(nSheet > 0) + nSheet + ' 张 SVG</span>' +
'<i>IK 求解器</i>' + ok(typeof ArmIK !== 'undefined') + 'ArmIK</span>' +
'<i>求解后端</i>' + ok(IK_BACKEND === 'worker') + IK_BACKEND + '</span>';
const q = new URLSearchParams(location.search);
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