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:
@@ -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"])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user