Files
Robot_Arm/arm/viewer/serve.py
T
cbb 531a293688 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 穿透。
2026-10-15 04:31:31 +08:00

133 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)