From 531a293688c2288e5ec7e540392a80f045cce30d Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Thu, 15 Oct 2026 04:31:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor(viewer):=20=E5=8D=95=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=99=A8=E6=8B=86=E6=88=90=20webapp/=20?= =?UTF-8?q?=E5=A4=9A=E6=96=87=E4=BB=B6=EF=BC=8C=E8=B5=B0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20http=EF=BC=9BWorker=20=E6=8D=A2=E6=88=90=E7=9C=9F=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端不再是"一个 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 穿透。 --- .gitignore | 12 +- arm/analysis/gen_env_mesh.py | 4 +- arm/analysis/strength_check.py | 6 +- arm/cad/arm_build.py | 12 +- arm/dataio/draw_sheet.py | 7 +- arm/dataio/screw_mesh.py | 6 +- arm/paths.py | 10 +- arm/viewer/build_viewer.py | 140 +- arm/viewer/serve.py | 132 ++ arm/viewer/shot.py | 81 +- docs/shots/boss_p07.png | Bin 116629 -> 0 bytes docs/shots/m9_motion.png | Bin 132307 -> 134551 bytes tests/baselines/data_by_name.md5 | 6 +- tests/baselines/flaky/boss_p07.png | Bin 0 -> 116629 bytes .../baselines/flaky/harness_p04_p05_xray.png | Bin 301266 -> 301358 bytes tests/baselines/flaky/harness_p07_xray.png | Bin 371448 -> 371660 bytes .../baselines/flaky/harness_xray_internal.png | Bin 303364 -> 303310 bytes .../baselines/flaky}/m3_sheet_exploded.png | Bin .../baselines/flaky}/p03_mount_loose.png | Bin 157097 -> 157094 bytes tests/baselines/flaky/thresholds.json | 26 +- tests/baselines/shots_stable.md5 | 149 +-- tests/ik_baseline.mjs | 5 +- tests/test_harness_collision.py | 6 +- tests/verify.py | 28 +- web/sheets.jsdata | 1 - webapp/index.html | 357 +++++ src/viewer_tpl.html => webapp/js/app.js | 1167 +---------------- {web => webapp/js}/ik-glue.js | 147 ++- .../js/ik-solver.js | 0 webapp/styles/base.css | 69 + webapp/styles/dock.css | 28 + webapp/styles/guide.css | 154 +++ webapp/styles/ikbar.css | 67 + webapp/styles/sheet-chrome.css | 191 +++ webapp/styles/sheet-hud.css | 128 ++ webapp/styles/sheet-theme.css | 149 +++ webapp/styles/viewmode.css | 33 + .../vendor/three.min.js | 0 webapp/workers/ik.worker.js | 31 + 定位图/对比_02front_http多文件.png | Bin 0 -> 191994 bytes 定位图/对比_02front_基线单文件.png | Bin 0 -> 103145 bytes 定位图/核对_m9motion_旧基线.png | Bin 0 -> 160392 bytes 定位图/核对_m9motion_现在.png | Bin 0 -> 134551 bytes 43 files changed, 1755 insertions(+), 1397 deletions(-) create mode 100644 arm/viewer/serve.py delete mode 100644 docs/shots/boss_p07.png create mode 100644 tests/baselines/flaky/boss_p07.png rename {docs/shots => tests/baselines/flaky}/m3_sheet_exploded.png (100%) rename {docs/shots => tests/baselines/flaky}/p03_mount_loose.png (60%) delete mode 100644 web/sheets.jsdata create mode 100644 webapp/index.html rename src/viewer_tpl.html => webapp/js/app.js (59%) rename {web => webapp/js}/ik-glue.js (92%) rename web/arm-ik-solver.js => webapp/js/ik-solver.js (100%) create mode 100644 webapp/styles/base.css create mode 100644 webapp/styles/dock.css create mode 100644 webapp/styles/guide.css create mode 100644 webapp/styles/ikbar.css create mode 100644 webapp/styles/sheet-chrome.css create mode 100644 webapp/styles/sheet-hud.css create mode 100644 webapp/styles/sheet-theme.css create mode 100644 webapp/styles/viewmode.css rename web/three_inline.js => webapp/vendor/three.min.js (100%) create mode 100644 webapp/workers/ik.worker.js create mode 100644 定位图/对比_02front_http多文件.png create mode 100644 定位图/对比_02front_基线单文件.png create mode 100644 定位图/核对_m9motion_旧基线.png create mode 100644 定位图/核对_m9motion_现在.png diff --git a/.gitignore b/.gitignore index 7f987a3..8adb0c2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/arm/analysis/gen_env_mesh.py b/arm/analysis/gen_env_mesh.py index 76151df..d22b8d6 100644 --- a/arm/analysis/gen_env_mesh.py +++ b/arm/analysis/gen_env_mesh.py @@ -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"]) diff --git a/arm/analysis/strength_check.py b/arm/analysis/strength_check.py index 98f9e49..a5675f1 100644 --- a/arm/analysis/strength_check.py +++ b/arm/analysis/strength_check.py @@ -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"] diff --git a/arm/cad/arm_build.py b/arm/cad/arm_build.py index 4dc4f9a..9fa24ee 100644 --- a/arm/cad/arm_build.py +++ b/arm/cad/arm_build.py @@ -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 diff --git a/arm/dataio/draw_sheet.py b/arm/dataio/draw_sheet.py index f95899b..4009e9d 100644 --- a/arm/dataio/draw_sheet.py +++ b/arm/dataio/draw_sheet.py @@ -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)) diff --git a/arm/dataio/screw_mesh.py b/arm/dataio/screw_mesh.py index 180984f..877d0be 100644 --- a/arm/dataio/screw_mesh.py +++ b/arm/dataio/screw_mesh.py @@ -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"] diff --git a/arm/paths.py b/arm/paths.py index 0055562..9662bee 100644 --- a/arm/paths.py +++ b/arm/paths.py @@ -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" diff --git a/arm/viewer/build_viewer.py b/arm/viewer/build_viewer.py index 07d5850..827838d 100644 --- a/arm/viewer/build_viewer.py +++ b/arm/viewer/build_viewer.py @@ -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 ("") if kind == "css" \ + else ("") + + out = re.sub(r'', lambda m: sub(m, "css"), html) + out = re.sub(r'', 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() - diff --git a/arm/viewer/serve.py b/arm/viewer/serve.py new file mode 100644 index 0000000..b8ef415 --- /dev/null +++ b/arm/viewer/serve.py @@ -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,那会让 + + + + + + + diff --git a/src/viewer_tpl.html b/webapp/js/app.js similarity index 59% rename from src/viewer_tpl.html rename to webapp/js/app.js index 61d508b..56a2be5 100644 --- a/src/viewer_tpl.html +++ b/webapp/js/app.js @@ -1,1163 +1,4 @@ - - - - -六电机人形机器人手臂 · 装配与打印模型 - - - -
-
- 人形机器人手臂 - - - - - - - - - - - - - - - -
- -
-
- IK - 目标点 X - Y - Z - - 工具长 - mm - - 扫描精度 - - - - - 方位 - 仰角 - 滚转 - - - 优先级 - - - 姿态 - - 位置 - 50% 位置 - - -
-
- - - - - - 拖黄球 = 移动目标(Shift 仅上下);拖箭头 = 调工具轴指向;拖箭杆或粉球 = 滚转;松手自动求解 - - — -
-
- - - - -
- -
-
-
-
-
- - - - - -
- -
- -
- - - -
- -
- -
- - - - -
- -
- - -
- -
-
-
-
- -
- - - - - - -
- -
100%
- -
-
- - - -
-
- - -
- - - - - - - - - - diff --git a/web/ik-glue.js b/webapp/js/ik-glue.js similarity index 92% rename from web/ik-glue.js rename to webapp/js/ik-glue.js index 91c93f8..03a9f56 100644 --- a/web/ik-glue.js +++ b/webapp/js/ik-glue.js @@ -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(' ''; - const nScript = document.querySelectorAll('script:not([src])').length; + const mode = location.protocol === 'file:' ? 'file://(单文件分发版)' : 'http(webapp/ 多文件)'; $('ikDep').innerHTML = - '打包方式单文件自包含,可直接拷走' + - 'three.js' + ok(typeof THREE !== 'undefined') + '已内联 · r' + - ((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '' + - '几何数据' + ok(typeof DATA !== 'undefined') + '已内联 · ' + DATA.parts.length + ' 件零件' + + '加载方式' + mode + '' + + 'three.js' + ok(typeof THREE !== 'undefined') + + 'r' + ((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '' + + '几何数据' + ok(typeof DATA !== 'undefined') + + ((typeof DATA !== 'undefined') ? DATA.parts.length + ' 件零件' : '缺失') + '' + '关节 / 限位' + ok(typeof JO !== 'undefined' && typeof TRAVEL !== 'undefined') + - '已内联 · ' + JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位' + - '二维图纸' + ok(nSheet > 0) + '已内联 · ' + nSheet + ' 张 SVG' + - 'IK 求解器' + ok(typeof ArmIK !== 'undefined') + '已内联(纯 JS,无依赖)' + - '内联脚本' + nScript + ' 个(三维引擎 / 模型数据 / 求解器 / 界面)' + - '外部引用' + ok(ext === 0) + - (ext === 0 ? '0 个(script/link/img/iframe 等全类型已查)' : ext + ' 个 —— 已破坏自包含!') + ''; + JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位' + + '二维图纸' + ok(nSheet > 0) + nSheet + ' 张 SVG' + + 'IK 求解器' + ok(typeof ArmIK !== 'undefined') + 'ArmIK' + + '求解后端' + ok(IK_BACKEND === 'worker') + IK_BACKEND + ''; const q = new URLSearchParams(location.search); const tgt = q.get('tgt'); // tgt=x,y,z:直接以该目标进入运动模拟(复现用) diff --git a/web/arm-ik-solver.js b/webapp/js/ik-solver.js similarity index 100% rename from web/arm-ik-solver.js rename to webapp/js/ik-solver.js diff --git a/webapp/styles/base.css b/webapp/styles/base.css new file mode 100644 index 0000000..f22644c --- /dev/null +++ b/webapp/styles/base.css @@ -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%} + diff --git a/webapp/styles/dock.css b/webapp/styles/dock.css new file mode 100644 index 0000000..a42ae0c --- /dev/null +++ b/webapp/styles/dock.css @@ -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}} + diff --git a/webapp/styles/guide.css b/webapp/styles/guide.css new file mode 100644 index 0000000..f091cb5 --- /dev/null +++ b/webapp/styles/guide.css @@ -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)} diff --git a/webapp/styles/ikbar.css b/webapp/styles/ikbar.css new file mode 100644 index 0000000..485cd64 --- /dev/null +++ b/webapp/styles/ikbar.css @@ -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} + diff --git a/webapp/styles/sheet-chrome.css b/webapp/styles/sheet-chrome.css new file mode 100644 index 0000000..1c62513 --- /dev/null +++ b/webapp/styles/sheet-chrome.css @@ -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; +} + diff --git a/webapp/styles/sheet-hud.css b/webapp/styles/sheet-hud.css new file mode 100644 index 0000000..5a7ccf0 --- /dev/null +++ b/webapp/styles/sheet-hud.css @@ -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; +} + diff --git a/webapp/styles/sheet-theme.css b/webapp/styles/sheet-theme.css new file mode 100644 index 0000000..dfced5f --- /dev/null +++ b/webapp/styles/sheet-theme.css @@ -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); +} + diff --git a/webapp/styles/viewmode.css b/webapp/styles/viewmode.css new file mode 100644 index 0000000..5e31385 --- /dev/null +++ b/webapp/styles/viewmode.css @@ -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} +} diff --git a/web/three_inline.js b/webapp/vendor/three.min.js similarity index 100% rename from web/three_inline.js rename to webapp/vendor/three.min.js diff --git a/webapp/workers/ik.worker.js b/webapp/workers/ik.worker.js new file mode 100644 index 0000000..c7c5aa0 --- /dev/null +++ b/webapp/workers/ik.worker.js @@ -0,0 +1,31 @@ +/* IK 求解 Worker(module 之外的经典 Worker,用 importScripts 取 UMD 求解器)。 + 协议:主线程先发 {op:'init', cfg} → 回 {op:'ready'};之后 {id, op, args} → {id, r}。 + 旧实现是从页面