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,那会让
+
+
+
+
+
+