前端不再是"一个 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 穿透。
133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
# -*- 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)
|