# go2-physics-lab **Repository Path**: giser_km/go2-physics-lab ## Basic Information - **Project Name**: go2-physics-lab - **Description**: 用 Vue3 + Three.js + cannon-es 写了个纯前端的四足机器人仿真台。12 个关节各一个 PD 环,1/240 定步长跑物理,所以它会真的打滑、真的摔。能调步态、能绕桩避障、能躺下再站起来,打开网页就能玩。 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-11-14 - **Last Updated**: 2026-11-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # GO2 · PHYSICS LAB 纯前端(Vue3 + TypeScript)的 **Unitree Go2 四足机器人物理仿真与步态调试平台**。 Three.js 负责高保真渲染,cannon-es 负责刚体物理,12 个伺服关节各自跑独立的 PD 力矩环。 无后端、无外部仿真服务,`npm run dev` 即可使用,`npm run build` 产物可直接静态托管。 ![overview](docs/screenshots/01-kinematic.png) --- ## 视觉方向:校准台式仪器(Calibration Bench) 界面不是「深色科技大屏」,而是一台**放在实验台上的测量仪器**。五个决策: | 决策 | 做法 | 为什么 | | --- | --- | --- | | 调性 | 工业实用 / 仪器面板 | 用户是调参的人,长会话、看数字,装饰会让位给读数 | | 配色 | 暖石墨底(`#080706 → #1f1a14`)+ **唯一强调色**钨丝橙 `#ff8a2b` | 避开「深底+蓝色发光+紫蓝渐变」这套模板味;暖灰也压住了机器人白塑料的塑料感 | | 字体 | 展示字 **Archivo**(DIN 系骨架,本地 woff2)+ 数据字 **IBM Plex Mono** | 数据密集界面用等宽数字对齐;中文回落系统黑体,不为了「风格」牺牲小字可读性 | | 构图 | 面板间 **1px 发丝线**、零圆角浮动卡片、整屏读作一块机箱 | 「一叠圆角卡片 + 统一阴影」是 AI 生成界面的最强指纹 | | 记忆点 | 视口当光学仪器:上/左刻度尺、取景角标、中心十字、**说真话的比例尺** | 比例尺按相机距离换算真实 mm(10/20/50/100…),缩放时数字跟着变——这是只有真仪器才会做的事 | **视觉令牌**(`src/styles/theme.css`) ```css --ink-950 #080706 /* 画布 */ --ink-850 #100e0b /* 面板 */ --line #201c16 /* 结构线 */ --text #ebe5d9 /* 暖纸白(不用纯白,避免刺眼) */ --amber #ff8a2b /* 唯一强调色:激活态、关键动作、实测值 */ --ok/warn/danger = 状态色,仅表达状态,不做装饰 --r 2px · --font-display Archivo · --font-mono IBM Plex Mono /* 亮色主题不新写组件样式,只覆盖同一组变量,见下一节「两套主题」 */ ``` **排版纪律**:所有数值 `font-variant-numeric: tabular-nums`;段落编号 `01/02/03…` 用 CSS 计数器自动生成;参数名与数值之间用极细下划线区分「标签 / 读数」。 **动效**:一次开机编排(顶栏 → 左栏 → 视口 → 右栏 → 底部两栏,70–270 ms 错峰,480 ms `cubic-bezier(.16,1,.3,1)`)+ 视口顶边一次扫描线 + 运行状态灯的 2.6 s 呼吸。全部包在 `prefers-reduced-motion` 降级里。 **反 AI 味自检**(`references/zh-CN.md` 检查表) | 项 | 结果 | | --- | --- | | 展示字体不是 Inter/Roboto/system-ui | ✅ Archivo + 本地 woff2(离线可用) | | 无白底紫/蓝渐变、无平均分配配色 | ✅ 单一强调色,占比 ≈ 8% | | 没有「中心标题 + 三卡」式布局 | ✅ 三栏机箱,视口是唯一主角 | | 卡片不是同一套圆角 + 阴影 | ✅ 无圆角卡片,1px 分隔 | | 有纹理/氛围层 | ✅ 3.2% 胶片颗粒 + 视口暗角 + 刻度尺 | | 悬停/聚焦/按下/禁用/空态齐全 | ✅ `:focus-visible` 2px 琥珀描边、按钮热区纵向外扩、日志/遥测空态 | | 对比度 | ✅ 正文 `#ebe5d9`/`#100e0b` ≈ 15:1;小字灰 `#8a8375` ≈ 5.1:1(`--muted-2` 只用于刻度线,不再承载文字) | | 降级动效 | ✅ 全局 `prefers-reduced-motion` 关闭 | | 375 / 768 / 1440 / 1680 | ✅ 1024 以下改单列仪器栈,视口前置,顶栏换行,无横向溢出 | 窄屏策略:**先压信息密度,再拆列**。1440 收起副标题与单位条 → 1200 收窄三栏 → 1024 变单列纵向滚动(视口 56vh 置顶)→ 760 收起叠加开关与帮助文案,只留读数、比例尺、相机档位、稳定状态。 ![physics](docs/screenshots/02-physics-trot.png) --- ## 两套主题:暗房 / 明室 顶栏右侧「暗 / 亮」两格开关切换,选择写进 `localStorage`,刷新时由 `index.html` 的内联脚本在首帧前定好, 不会闪一下白或黑。**换的不只是 CSS**:3D 视口、曲线、轨迹线、力箭头会一起换。 | | 暗房 Dark | 明室 Light | | --- | --- | --- | | 定位 | 夜间实验室,屏幕是唯一光源 | 纸面仪器,像放在台面上的校准台 | | 底 / 面 | `#080706` / `#100e0b` | `#e7e1d4` / `#fbf8f2` | | 墨 / 次级灰 | `#ebe5d9` / `#6e6353` | `#211c16` / `#6e6353` | | 强调色 | `#ff8a2b` | `#b4530c`(压深,保住纸面 4.7:1) | | 3D 布景 | 黑底工作室、微弱网格 | 纸白摄影棚、远处雾化、强主光 + 接触阴影 | | 数据色 | 亮琥珀 / 纸白 / 灰绿 / 石板蓝 | 同色相压深一档,纸面上才看得见 | ![light](docs/screenshots/09-light-kinematic.png) 实现要点: 1. `:root` 写暗房令牌,`[data-theme='light']` 覆盖**同一组语义变量**;组件里没有任何一处写两套颜色。 中途把散落的 `rgba(235,225,208,…)`、`rgba(9,8,6,…)` 收敛成 `--tick / --glass-bg / --hover / --on-amber` 等令牌。 2. Element Plus 的暗色变量挂在 `html.dark` 上,切亮色时会同时摘掉这个类,并把 `--el-*` 令牌整套改成纸面值。 3. 3D:`SceneManager.setTheme()` 换背景球、地面、雾、灯光强度与曝光;`GridHelper` 的颜色烘在顶点色里, 只能重建(12 m / 48 段,代价可忽略)。远处 `THREE.Fog` 让网格边缘自然消失,不会出现一条硬地平线。 4. 数据色统一放到 `--leg-1..4`:`Go2Model.applyPalette()` 改轨迹线/目标环,`ChartPanel.restyle()` 改图例、 网格与曲线色,两边共用同一份变量,不会出现「界面亮了、曲线还是夜配色」。 明室不是把暗色反相:纸面用暖白、墨用暖黑,强调色压深一档;3D 换成浅色摄影棚后, 白色机身靠深色伺服、面板分缝与接触阴影立住体积。 --- ## 机器人造型:机加工体块 早期的模型是「圆角盒 + 浅灰塑料」,读起来像玩具。现在的造型语言来自参考图的实物逻辑: **钣金件 + 阳极氧化件 + 抛光钢**,所有体积都由**平面和 45° 倒角**构成,没有一个圆角盒。 | 部位 | 做法 | | --- | --- | | 机身 | 下盘 / 主体 / 顶盖 / 脊线四层叠起来,每层比下一层宽或窄 6–10 mm,靠宽度差拉出一条硬阴影线;机头、机尾是**锥台棱柱**(前后截面不同),一刀切出楔形 | | 大腿 | 绕髋关节的**锥形刀片**:顶边直切、向膝端收窄,中间开**真孔减重窗**(`ExtrudeGeometry` 带孔的侧壁 + 深色内衬),两侧压一排螺栓 | | 关节 | 12 台伺服统一三层:深色桶身 → **齿圈**(一条轮廓 + 一个孔挤出成型的传动齿)→ 车削平面端盖 + 8 颗内六角螺栓 + 中心六角孔 | | 小腿 | 抛光钢主杆 + 一根细后拉杆,在足端收拢成三角;足垫是橡胶锥面 + 钢箍。整条小腿挂在膝关节**外侧面**(夹板 + 两颗螺栓压在伺服端面上),杆件越过大腿板的厚度区间,向足端收拢 | | 表面 | 压边螺栓(沿顶盖两侧各 10 颗)、纵向凹槽、竖向散热槽、尾部百叶、检修舱盖、拖车式提手、天线 | 三个实现上的要点: 1. **倒角靠挤出而不是加圆角**。`src/three/shapes.ts` 的 `plate()` 用 `ExtrudeGeometry` + `bevelSegments: 1`,得到单段 45° 切角——高光会在边上收成一条细亮线,这正是机加工件 最容易被识别的特征。`wedgeGeometry()` 用 8 个顶点直接拼锥台,`gearRing()` 用一条带齿的 轮廓 + 一个孔挤出,12 台伺服只多花 12 个网格而不是 240 个。 2. **小腿必须挂在膝关节外侧**。大腿板 34 mm 厚(±17),小腿若跟它同一个厚度平面, 膝关节屈到 90° 以上时杆件会直接从大腿刀片里穿出来(深蹲、躺下、抬腿都能看到)。 现在叉板在 `side*31.2`(贴着膝伺服端面,23.2~39.2 mm),杆件在 `side*31` → `side*6` 收拢, 全程避开大腿的 ±17:屈到极限角度只剩投影重叠,没有实体相交。 四肢的横向偏移都用 `side` 镜像,左右腿不会再一边朝内一边朝外。 3. **圆柱默认沿局部 Y 轴**。电机的「线束」最早写成 `CylinderGeometry(…0.045…)` 且没给旋转, 于是它沿**电机轴**捅出去 45 mm、末端悬空 —— 屏幕上就是每台电机上都长了一根黑色小杆 (12 台电机 = 12 根)。现在只保留 9 mm 的橡胶护套,且朝径向(-Z)穿过护线套。 同一处还有个小坑:护线套原来的 `rotation.x = π/2` 把它转成了平躺的垫圈, 线缆根本不可能从环里穿出来。 4. **「深色金属」在正面会发白**。金属正对镜头时反射的是环境光,`metalness` 越高越接近镜子, 深灰反而照成亮灰。所以凡是需要读作「洞」的地方(减重窗内衬、光学窗、分缝、散热槽) 都用低金属度的 `cavity` 材质;需要读作「亮件」的端盖用缎面钢(`metalness 0.78`), 纯镜面会在暗房里反射成一个黑洞。 5. **单位只在写代码时是 mm**。`plateMM / lidMM` 先按 mm 造几何再统一 × 1000 缩放, 读代码不用换算;`lidMM` 专门处理「轮廓在 XZ 平面、厚度沿 Y」的水平盖板 (`plate()` 是沿 Z 挤出,直接拿来当盖板会立起来)。 ![closeup](docs/screenshots/05-closeup.png) --- ## 快速开始 ```bash npm install npm run dev # http://127.0.0.1:5178 npm run build # 产物在 dist/(base 为相对路径),用 npm run preview 或任意静态服务器打开 npm run typecheck # vue-tsc 严格类型检查 npm run sim:test # 无头物理回归(Node + cannon-es,不依赖浏览器) npm run nav:test # 无头导航回归(5 个场地:规划 + 跟踪 + 碰撞统计) npm run nav:corner # 拐角专项(17 个目标:墙角、墙内、桶心) npm run nav:escape # 贴角脱困(起点就在膨胀带里) npm run nav:sweep # 随机目标压力测试(40 例) npm run pose:test # 躺下/站起回归(需要 dev server 起在 5178) npm run record:tour # 整页录屏:驱动 Edge 走一遍固定流程,输出 webm(同上,需要 5178) npm run record:tour:long # 2 分半导览录屏(同上,需要 5178) npm run cover # 生成发布封面与 hero 界面图(同上,需要 5178) ``` 浏览器建议 Chromium / Edge(WebGL2 + ES2020)。首次进入为「纯运动学」模式,点顶部 「物理 PD 仿真」即切换到力矩驱动的物理模式。 想看避障与规划:左侧参数台切到 **「导航」** 页 → 选场地 → **规划** 看一眼路线 → **出发**(会自动切到物理模式与场地俯视机位)。地图上点一下就能改目标点。 --- ## 录屏与发布素材 录屏走 Playwright 的 `recordVideo`(只录画面,没有音轨),配乐是另外合成后合进去的: ```bash npm run record:tour:long # 1) 录 2 分半导览(需要 dev server 在 5178) node scripts/publish-video.mjs # 2) 剪白屏 + 配乐 + 合成 mp4 / webm npm run cover # 3) 出封面与 hero 图 ``` - **配乐**在 `src/audio/bgm.ts`:96 BPM、Am7→Fmaj7→Cmaj7→G6 循环,pad/bass/琶音/鼓/铃音分轨, 一条程序生成脉冲响应的卷积混响。`AudioContext` 与 `OfflineAudioContext` 共用同一份排程, 所以「应用内录屏时勾选顶栏『配乐』」和「离线渲染成 WAV 再合成」出来的声音完全一致。 - **`publish-video.mjs`** 会先用 ffmpeg 逐帧读亮度(`signalstats` 的 YAVG),把开头那段浏览器 还没渲染的白屏剪掉,再按剪完的时长渲染配乐 —— 音乐仍从第 0 帧淡入。 它需要一个**带音频编码器的完整 ffmpeg**(Playwright 自带的那个是裁剪版,只有 VP8/PNG 编码器), 用环境变量 `FFMPEG=/path/to/ffmpeg` 指定。 - **封面**由真实应用截图 + HTML 模板合成:`render-cover.mjs` 先把应用跑到「绕桩场地导航中」 按 2× 截三张界面图,再把截图与 Archivo / IBM Plex Mono 字体全部 base64 内联进 `scripts/cover/index.html`,渲染出 1920×1080 与 1200×630 两个尺寸。 封面与文案素材生成在本地 `docs/publish/`(已被 `.gitignore` 忽略,不入库): `cover-1920x1080.png`、`cover-1200x630.png` 与三张 hero 界面图由 `npm run cover` 产出。 --- ## 功能对照(对应需求文档模块 1–6 + 导航扩展 7) ### 模块 1 · URDF 与运动学 - 内置 `public/go2.urdf`(17 连杆 / 16 关节),启动时自动解析并回填关节限位与连杆质量; 顶部「载入 URDF」可导入官方 Go2 URDF(拖入文件即可),解析结果写入日志(匹配 12/12)。 - 单腿 3-DOF 解析逆解(`src/kinematics/leg.ts`):给定躯干系足端目标 → 3 关节角, 含可达域夹紧与关节限位检查,返回实际足端位置与残差。 - 正解逐帧校验:足端世界坐标、足底离地间隙、支撑相滑移、IK 残差在右侧「足端 / 接触」 与画布左下角实时显示。 - 关节面板:12 个关节的实测/目标角度、力矩、误差,可切换「手动关节调试」用滑块直接 拖动(旁路步态),并支持「同步当前姿态 / 全部归零」。 ### 模块 2 · 步态编辑器 - 内置 **Trot / Walk / Bound / Pace** 四套相位模板(对角、四拍、前后、同侧)。 - 参数:周期、支撑相占比、抬脚高度、步长、转向角速度、横移速度、躯干站高。 - 轨迹:支撑相直线(足端相对躯干匀速后移),摆动相三次贝塞尔抬腿(峰值归一到抬脚高度)。 - 导出步态序列 JSON(时间戳 + 12 关节角,240 Hz 采样)/ 导入回放;暂停、单步、0.1–3× 倍速。 - 步态切换会同步刷新足端目标点(浅色圆环)、摆动轨迹线与支撑轨迹虚线。 ### 模块 3 · 物理仿真(核心) - cannon-es 世界,**固定步长**(默认 1/240 s,可切 1/120、1/200、1/480),渲染帧只做累加与显示。 - 13 刚体 / 12 铰链:躯干 + 每腿 (髋外展支架 / 大腿 / 小腿+足垫),铰链轴为本工程约定 (外展绕 X,髋/膝绕 Z)。 - **PD 伺服环**:`τ = Kp·(q_target − q) − Kd·ω* + τ_ff`,逐关节限幅 ±τ_max 后施加为力矩, 不直接锁角;阻尼项用半隐式求解(见下文),保证轻质连杆下不发散。 - 接触:足垫用**圆柱盘**碰撞体(不是球体,避免支撑相"滚动"代替滑动导致摩擦失效), 地面为无限平面,摩擦/恢复系数可调;机器人与自身连杆之间碰撞过滤,避免自撞。 - 遥测:足底最低离地、支撑/摆动标记、法向力、摩擦力、支撑相滑移、躯干位姿与速度、 每关节目标/实际/误差/力矩,全部 20 Hz 推送到面板与曲线。 - 两种模式:**纯运动学动画**(无重力无碰撞,调轨迹)与**物理 PD 仿真**(会摔、会打滑)。 ### 模块 4 · 可视化 - PBR 材质(铝合金 / 工程塑料 / 橡胶 / 铜热管 / 黑色阳极氧化)+ PMREM 程序化 IBL 环境 (无需外部 HDR 文件,离线可用)。 - 每台伺服由 8 类子部件构成:分段定子壳、车削端盖(LatheGeometry)、8 颗内六角螺栓、 六边形内孔转子、编码器盖、出线座+护线套+9 mm 橡胶护套、铜热管+压块(`src/three/servo.ts`), 可按类别单独显隐(`ServoPartKey`)。整机 470 个 Mesh / 12 台伺服。 - PCF 软阴影、方向主光 + 补光 + 轮廓光、渐变/HDR/纯色三种背景、渲染档位(像素比)。 - 辅助显示:关节坐标系、足端目标点与轨迹线、碰撞体线框、接触力箭头(按力大小变色)。 - 轨道控制器(左键旋转 / 滚轮缩放 / 右键平移)、等轴/侧视/前视/俯视/后视预设、相机跟随。 - 第 6 个机位「场地」:按场地包络自动取景的正俯视,用来整体看路线与障碍关系(出发导航时自动切换)。 ### 模块 5 · 调试与导出 - 实时日志:模式切换、URDF 解析、关节限位告警、失稳告警、滑移告警、导出记录。 - ECharts 四联实时曲线:足端离地、躯干俯仰/横滚、关节误差峰值、接触法向力。 - 截图 PNG;两档录屏: - **录屏**(默认):`canvas.captureStream(30)` 只录 3D 视口,免授权、画面干净; - **整窗**:`getDisplayMedia` 抓标签页/窗口,顶栏、参数台、曲线、日志都在画面里 (浏览器会弹一次授权框,选「此标签页」;浏览器自己的地址栏属于浏览器 UI,网页拿不到)。 点浏览器自带的「停止共享」也会自动收尾并保存。 - 导出配置 JSON(模型 / 步态 / PD / 物理 / 姿态环 / 渲染)、导出步态序列、重置、回零。 - **躺下 / 站起**:两键一组,按固定速率升降躯干高度(110 / 150 mm/s),不起作用在「标称站高」上; 物理模式下是**真的靠腿撑起来**(足端目标在躯干系缓慢下移 → 支撑腿蹬地)。 - 扰动测试:前/后/左/右/上五个方向的冲量(大小可调),作用于躯干质心上方。 ### 模块 6 · 扩展预留 - 键盘控制:`W/S` 前后、`A/D` 转向、`Q/E` 横移、空格暂停(直接改写步长/偏航/横移参数)。 - 步态序列导入回放即为「外部下发角度指令」的最小闭环,可对接外部算法导出序列。 ### 模块 7 · 障碍物避让与路线规划 - **5 个预设场地**:绕桩 / 错位门 / S 形绕行 / 散布场地(程序化生成)/ 空场标定。 障碍是真实的静态刚体,进入 cannon 世界参与碰撞——撞上去会顶住、被判定受阻,不会穿模。 - **全局规划**:5 cm 占据栅格 + 机体包络膨胀(默认 340 mm)+ 8 邻域 A*(禁止穿角)+ 视线拉直。 已知静态地图上用栅格法而不是 RRT/势场,为的是**确定、可复现**——回归测试才有意义。 - **路径跟踪**:纯跟踪(pure pursuit)沿弧长推进,前视距离随速度自适应;控制器只输出 `(vx, ω)`, 由引擎折算成 `stride / yawRate`,导航过程中不污染面板上的基础步态参数。 - **安全层**:按「机体中心到最近障碍面」的净距缩放速度;净距低于阈值且前方不通 → 停车, 受阻 0.45 s 后**倒车脱困**(退到净距恢复为止)再重规划;连续 4 次出不来就判定不可达并说明原因。 速度有下限(0.12 m/s):低于这个量级 trot 推不动躯干,只会原地打滑。 - **拐角处理**:前视距离按前方转折角自适应缩短、直角弯降速 40%——长前视在拐角等于主动抄近道。 - **目标吸附**:点选落点在障碍内/膨胀带里时自动吸附到最近可通行点;到达判定用这个点, 3D 与小地图都会标出「实际会停在这里」。 - **无进展判定**:3 s 内剩余里程没减少 4 cm → 倒车重试;再不行就报「不可达」,不无声挂着。 - **小地图**:面板内画布实时绘制栅格、障碍、规划折线、实际轨迹、机体位姿与目标点; **点击地图即改目标**并立即重规划。 - **指标**:规划全长 / 实走里程 / 最小净距 / 指令速度 / 航向误差 / 重规划次数 / 障碍接触次数 / 耗时, 全部来自控制器自身的状态,不做事后估算。 **验收(`npm run nav:test`,无头物理 + 导航闭环)** | 场地 | 结果 | 用时 | 规划 / 实走 | 姿态峰值 (roll/pitch) | 碰撞 | | --- | --- | --- | --- | --- | --- | | 绕桩 | 到达 | 26.2 s | 5.91 / 5.79 m | 2.7° / 4.2° | 0 | | 错位门 | 到达 | 20.8 s | 5.27 / 5.14 m | 1.5° / 2.6° | 0 | | S 形绕行 | 到达 | 27.1 s | 6.38 / 6.25 m | 2.9° / 4.4° | 0 | | 散布场地 | 到达 | 19.2 s | 5.05 / 4.93 m | 1.7° / 3.2° | 0 | | 空场标定 | 到达 | 14.8 s | 3.98 / 3.84 m | 1.8° / 3.3° | 0 | (实走/规划 ≈ 0.98~1.01,即基本按规划路线走;每行都是 1 次规划、0 次重规划。 连跑 3 轮结果逐秒一致;把巡航提到 0.36 m/s(步长 160 mm)仍全部到达、峰值 5°。 `npx tsx scripts/nav-debug.ts all` 会把「起步 / 巡航 / 停车」三段峰值分开打印: 起步 3.5~6.8°、停车 0.4~1.7°,都不会像早期版本那样"起步猛拽、到点一顿"。) --- ## 目录结构 ``` src/ core/spec.ts # 本体参数(mm)+ 步态/伺服/物理/渲染参数与默认值 core/engine.ts # 仿真引擎:固定步长子步、模式切换、遥测、导出/回放、扰动 kinematics/leg.ts # 单腿 3-DOF 正解 / 逆解 gait/generator.ts # 步态相位、支撑/摆动轨迹、轨迹预览采样 control/posture.ts # 姿态稳定环(roll/pitch → 支撑腿高度修正) sim/physics.ts # cannon-es 世界、刚体链、PD、接触/滑移、遥测 sim/urdf.ts # URDF 解析与参数映射(限位符号归一化) three/scene.ts # 渲染器、IBL、灯光、地面、相机预设、截图 three/shapes.ts # 硬边几何:倒角挤出板 / 锥台棱柱 / 齿圈 / 水平盖板 three/go2Model.ts # 参数化 Go2 模型(四层机身 + 锥形腿刀片)、关节枢轴、辅助显示 three/servo.ts # 单台伺服:桶身 + 齿圈 + 平端盖 + 螺栓圈 + 编码器 + 铜绕组 three/materials.ts # PBR 材质库(亮/拉丝/深灰阳极氧化、缎面钢、枪灰、腔体、橡胶) three/palette.ts # 从 CSS 变量读数据色,图表与 3D 轨迹共用 three/courseView.ts # 场地可视化:倒角体块障碍 + 警示边、虚线路径带、目标标记 nav/types.ts # 导航数据模型(场地 / 障碍 / 参数 / 遥测) nav/courses.ts # 5 个预设场地(散布场地按导引线生成,保证有解) nav/grid.ts # 占据栅格 + A* + 视线拉直 nav/geometry.ts # 障碍足迹距离 / 朝向换算 nav/navigator.ts # 纯跟踪 + 安全层 + 状态机(纯逻辑,无头可用) components/ # 顶栏、视口(HUD/刻度/比例尺)、四类参数面板、遥测、图表、日志 styles/theme.css # 设计令牌 + 全部布局/组件样式(仪器风唯一入口) assets/fonts/ # 本地 woff2:Archivo(可变) / IBM Plex Mono 400·500·600 audio/bgm.ts # 演示配乐合成器(Web Audio,实时播放与离线渲染共用一份排程) stores/sim.ts # Pinia:参数(reactive 直连引擎)、遥测、日志 scripts/ sim-test.ts # 无头物理回归(站立/行走/增益扫描) sweep.ts # 参数扫描与稳定性评估 nav-test.ts # 无头导航回归(物理 + 规划 + 跟踪,5 个场地) nav-corner.ts # 拐角专项:死锁 / 原地踏步 / 目标点在障碍内 nav-escape.ts # 贴角脱困:起点就在膨胀带里,验证倒车退出 nav-sweep.ts # 随机目标压力测试(含压在障碍上的退化目标) qa.mjs # Playwright 功能回归(需本机 Edge + playwright-core) nav-qa.mjs # Playwright 导航回归(场地切换/规划/出发/到达/点选目标/主题) pose-qa.mjs # Playwright 躺下/站起回归(重置/切模式/导航/循环起落/参数不被改写) record-tour.mjs # 整页录屏(Playwright recordVideo):空场行走→规划→导航→明暗主题→躺下/站起 record-tour-long.mjs # 2 分半导览:步态/姿态/参数/三段导航/渲染全覆盖,逐段打印姿态检查点 publish-video.mjs # 出片:读时长 → 剪掉开头白屏 → 按成片时长渲染配乐 → mp4 + webm render-bgm.mjs # 离线渲染配乐(OfflineAudioContext,与应用内「配乐」同一份代码) render-cover.mjs # 生成发布封面(1920×1080 / 1200×630)与三张 hero 界面图 cover/index.html # 封面版式模板(字体与截图在渲染时内联进去) record/ # 录屏/配乐产物(已在 .gitignore 里忽略,脚本随时可重跑) leg-shot.mjs # 腿部特写取景:站立/膝极限/深折叠/外展/髋前摆,检查穿模 leg-body-shot.mjs # 全身多视角:站姿/四肢极限/躺姿 leg-walk-shot.mjs # 行进中相位取景(物理模式跑几秒再拍) ``` --- ## 物理实现里的几个关键决定 1. **固定步长 + 半隐式 PD**。显式 `τ = Kp·e − Kd·ω` 在轻质支架上会数值自激(Δt·Kd/I ≫ 1), 因此阻尼项按步末角速度求解:`ω* = (ω + Δt·Kp·e/I) / (1 + Δt·Kd/I)`,并对每个刚体设置 「反射惯量下限」(伺服+减速器折算惯量),使 UI 上的 Kp/Kd 在 40–1600 区间都可稳定运行。 2. **重力前馈 τ_ff**。真机伺服普遍是「模型前馈 + PD 反馈」。这里用静力学雅可比 `τ_ff = −Jᵀ·F`(F 为单腿竖向载荷,按支撑腿数分摊)算前馈,使站立无静差、行走不下沉。 面板可关闭,退回纯 PD 做对照。 3. **摩擦模型修正**。cannon 的摩擦上限按「约化质量 × g × μ」估算,四足场景只有小腿质量, 约为真实法向力的 1/6,且求解器把 minForce/maxForce 当归一化冲量比较。因此重写了 `narrowphase.createFrictionEquationsFromContact`,把上限设为 `μ · N · Δt`(N 取整机重量 / 支撑腿数),面板里的 μ 才是真正的摩擦系数,滑移量才有物理意义。 4. **足垫必须是盘不是球**。球形足垫会以「滚动」替代「滑动」,摩擦完全失效(表现为原地踏步)。 改为轴向沿小腿的圆柱盘后,支撑相才真正受摩擦约束。 5. **渲染与碰撞分离**。渲染是带螺栓/端盖/热管的分部件模型,物理是球/圆柱/盒的简化体 (避免细长 Box 在 cannon 里抖动),满足需求「视觉精细、物理简化」的要求。 6. **离地高度按圆柱最低点计算**:`y − (h/2)|a·ŷ| − R√(1−(a·ŷ)²)`,腿倾斜时也准确 (若按「足端 − 半径」估算,小腿前倾 30° 会虚高 5–8 mm)。 7. **躯干碰撞必须单独软化**:足垫 0.42 kg 用 5e7 的接触刚度是稳的,但躯干 6.2 kg 在同一刚度下, 1 mm 穿透就是上万牛的冲量——实测撞桶后机体以 0.85 m/s 被弹飞、连翻两圈。 给躯干单配 `ContactMaterial`(k = 3e5、relaxation = 4)后,撞上去是「顶住」而不是「被弹开」。 8. **受阻即停**:机体与障碍接触、且步态仍在推进时,自动关闭步态输出。 四足一边顶住障碍一边迈步,会在 0.6 s 内把身体顶翻;真机同样是「检测到受阻 → 停止推进」。 --- ## 导航实现里的几个关键决定 1. **坐标与航向统一**。世界 X 前 / Z 左,航向沿用躯干遥测的 yaw(绕 +Y), 期望航向 = `atan2(−dz, dx)`;步态的 `yawRate` 正方向与之一致,控制器不需要额外取反。 2. **膨胀半径 = 机体包络**。躯干半长 250 mm、半宽 95 mm,取 340 mm 作规划膨胀, 等价于把机器人当半径 300 mm 的圆盘:保守,但不会因为转向时「机头扫到」而蹭上。 3. **安全阈值要卡在「膨胀半径」与「机身前半长」之间**。设成 170 mm 时小于膨胀 340 mm, 整条路径一直在触发停车(25 s 刷 125 次重规划);而只要小于半长 250 mm,机体已经贴上障碍时 控制器还会认为"很安全"继续往前顶。现在取 260 mm:路径上正常净距 ≥340 mm 不会误触发, 偏差 80 mm 以上才介入。 4. **卡死检测按「剩余里程」判,不按单步位移**。240 Hz 下正常行走每步只走约 1 mm, 用「单步位移 < 4 mm」会把每一次正常行走都误判(实测 25 s 内 17 次重规划); 改看「3 s 内剩余里程没减少 4 cm」,才是真的在原地兜圈。 5. **S 形场地的墙距要按转弯半径给**。Trot 在 0.26 m/s、55 °/s 下最小转弯半径约 0.27 m; 两墙间距曾做到 1.5 m,横向摆动 2 m 需要 R≈0.14 m 的曲率,物理上走不出来(实测蹭墙 355 次)。 改为 2.8 m 间距后,同样的 S 形只需 R≈0.45 m。 ### 为什么"导航走着走着会摔"(一次完整排查) 现象是起步时晃一下、巡航中偶尔直接侧翻。按"先量化、再改"的路子做下来,根因有四个, 互相叠加: 6. **速度不能阶跃**。步态的 `stride` 是直接下发给伺服的量,`stride` 从 0 跳到 106 mm 的那一帧, 支撑腿的足端目标瞬间平移了 53 mm,等于用 PD 猛拽机身。实测:立即给 160 mm 步长, 姿态从 6° 冲到 **225°(当场翻车)**;同样的步长用 0.6 s 斜坡给进去只有 14.5°。 现在线加速度限 0.45 m/s²、角加速度限 2.8 rad/s²,起步/停车/变向全部走斜坡。 7. **大航向误差时不能刹停**。原先 `cos(err)` 降速会在 err→90° 时把速度压到 0, 于是只剩原地转向;而 Trot 原地转向几乎全靠足端打滑,又慢又飘,形成"停-转-走"循环 (实测绕桩 100 s 都没到、S 形实走里程是规划的 2.3 倍)。改成保留最低前进速度、 边走边转之后,实走/规划回到 0.98~1.01。 8. **占空比是稳定裕度的主导项**。0.68 能走直线,但一带转向就积累侧倾:同一套控制器下 0.68 时 detour 在 4.4 s 翻车(roll 180°),0.74 时全程峰值 4.4°。代价是同步长下速度低 22%, 这个取舍写进了默认参数表。 9. **停车也要减速**。到达时直接 `enabled=false` 会让四条腿在一步内回到标称站姿, 实测停车冲击 5.9°;改成先按加速度上限减速、停稳后才冻结,降到 0.4~1.7°。 另外补了两道闸:**姿态保护**(roll/pitch 超 16° 先停车,回落后继续)和**受阻即停** (机体与障碍接触时把速度压到蠕行;非导航模式下直接关步态输出)。 ### 为什么"拐角处容易卡住"(第二次排查) 现象是过墙角/门框时明显顿住,有时干脆再也不动。用 `scripts/nav-corner.ts` 把 「命令在走但不动」和「命令就是 0」分开统计后,复现出三类,根因各不相同: 10. **速度死区**(最主要)。净距贴近膨胀半径时,`(clear − safety)/0.08` 这个系数 把速度压到 0.03~0.09 m/s,而 trot 在这个量级推不动躯干(步长只有 20~40 mm, 支撑相全在打滑):指令一直在走、里程几乎不增,轨迹上就是"贴着墙角原地踏步"。 实测在桶边一次踏步 3 s,直到无进展超时才倒车重来。 现在只要决定往前走,速度就不低于 `minSpeed`(0.12 m/s);真该慢就由安全层直接给 0, 不给"走不动的中间值"。 11. **硬死锁**。安全层里那句 `if (|err| < 0.3) enabled = false`:航向已经对正、又走不了, 于是 v=0 且步态关闭,机器人就那么站着;重规划出来的还是同一条路径,永远出不来。 实测绕桩场地原地卡死 **92 s**。现在改成受阻 0.45 s 后**倒车脱困**(退到净距恢复为止, 最多 2.2 s),再重规划;连续 4 次仍出不来就判定 `failed` 并说明原因,不无声挂着。 12. **目标点落在障碍里**。小地图点选很容易点到桶上或点在墙里,A* 会把终点顶到膨胀带 **边缘**(正好擦着障碍),而到达判定用的是原始点——永远满足不了,于是绕着墙角转圈 (实测 100 s 内重规划 29 次仍未到达)。现在规划时把目标吸附到「周围 2 圈都自由」的 最近点,到达判定、路径终点、小地图标记全部用这个点;吸附超过 0.3 m 时状态记为 `arrived/approximate` 并在日志里说明偏移量。3D 里原始靶标之外会多画一个小环 + 几段 刻线,指向"实际会停在这里"。 顺带把纯跟踪的**拐角切内道**也治了:前视点改按前方 0.35 m 内的最大转折角自适应缩短 (直角弯缩到 55%)、拐角处再降速 40%。少了这条,拐角上等于主动抄近道,抄进膨胀带以后 就只剩"停车 + 重规划"。 ### 为什么"躺下后无法站起来"(第三次排查) 现象是点「躺下」之后再也回不到站立:点重置还是蹲着,切物理模式还是蹲着。 根因不是动作做不出来,而是**「躺下」把结果写进了参数**: ```ts function onLie() { store.engine?.resetPose('lie') store.gait.standHeight = 165 // ← 这一行改的是「标称站高」这个全局参数 } ``` `store.gait` 就是 `engine.params.gait`(同一个响应式对象),而 `standHeight` 是 **步态、重置、切模式、导航出发**共用的标称站高。改成 165 之后:重置用 165、切物理模式用 165、 出发导航也用 165 —— 于是界面看起来就是"躺下后站不起来",而且**根本不存在「站起」这个动作**, 只能去拖「躯干目标站高」滑杆,而滑杆一次跳 135 mm 等于把四条支撑腿同时瞬间平移,大概率被拽翻。 修法是把「姿态」和「参数」分开: | 概念 | 归属 | 谁改它 | | --- | --- | --- | | 标称站高 `gait.standHeight` | 参数(面板滑杆) | 只由用户拖滑杆 | | 当前生效高度 `engine.heightNow` | 运行时状态 | 躺下/站起按 110 / 150 mm/s 升降 | - `lieDown()` / `standUp()` 只动 `heightNow`,标称站高保持 300; - 步态每步拿到的 `standHeight` 是 `heightNow`(`Object.assign` 到复用对象,240 Hz 不新建); - 物理上的"站起"不需要关键帧:足端目标在**躯干系**里缓慢下移,支撑腿蹬地、配合重力前馈 与 PD 就把机身顶上来。关键是**不能阶跃**——和之前"起步 stride 突变会翻"是同一个道理; - 顶栏按钮的可用状态按**实测躯干高度**判断(`y < 标称站高 − 40 mm`), 这样走路摔了(`poseState` 仍是 stand、但躯干趴在地上)也能直接点「站起」; - 躺下后再出发导航:先自动起立,起立到位由 `substep` 续上导航(`navPending`),不会半蹲着走; - 拖动标称站高滑杆会自动解除躺下(`lieStandRef` 快照对比),不会出现"拖了没反应"。 **验收(`npm run pose:test`,19 项)** | 用例 | 结果 | | --- | --- | | 躺下 → 高度 165 mm,标称站高仍 300 | ✓ | | 站起 → 高度 300 mm,roll/pitch < 5°、未翻倒 | ✓ | | 躺下 → 重置 → 立刻站起(旧实现卡在 165) | ✓ | | 躺下 → 切物理模式 → 高度不跳变(171 mm) | ✓ | | 物理模式下从蹲姿起立 | ✓(y=311,roll 0°) | | 拖动标称站高 → 自动解除躺下 | ✓ | | 躺下 → 出发 → 排队起立、到位后自动跟踪 | ✓ | | 连做 3 次躺下/站起:高度回到 309→311 mm、水平漂移 34 mm、参数不变 | ✓ | | 导航途中点躺下 → 导航中止 | ✓ | **验收(三个专项脚本,全部无头)** | 脚本 | 内容 | 结果 | | --- | --- | --- | | `npm run nav:corner` | 5 个场地 × 17 个目标(含贴着墙角、墙里、桶心) | 17/17 到达,0 死锁 | | `npm run nav:escape` | 5 个「起点就在膨胀带里」的贴角场景 | 5/5 到达,最多 1 次脱困,姿态峰值 ≤12° | | `npm run nav:sweep` | 5 个场地 × 40 个随机目标(一半故意压在障碍上) | 40/40 停稳并给出结论 | 改前同样这三组里有 3 例跑满 100 s 仍在 `tracking`(分别是死锁 92 s、重规划 29 次、34 次); 改后 5 个预设场地的「近乎静止时间」占比全部为 **0%**(改前桶侧可达 3 s/次), 用时基本不变(绕桩 26.5 s、错位门 20.7 s、S 形 30.3 s、散布 19.0 s、空场 14.5 s)。 --- ## 默认参数与需求模板的差异(需知悉) | 参数 | 需求模板 | 本工程默认 | 原因 | | --- | --- | --- | --- | | 步态周期 | 0.6 s | 0.6 s | 一致 | | 支撑相占比 | 0.5 | **0.74** | 0.5 时支撑相只有对角两腿,纯 PD + 单级姿态环长时间会侧翻;0.68 能走直线,但**一带转向就积累侧倾**(导航实测空场都能翻)。0.74 把双支撑窗口加宽,同样转向指令下侧倾峰值 37°→4°。面板「需求 0.50」档位可一键恢复 0.5 做对照 | | 抬脚高度 | 70 mm | 70 mm | 一致 | | 步长 | — | 80 mm(≈0.2 m/s) | 需求未给默认步长;0.5 占空比下 96 mm 步长速度过快易失稳 | | 关节限位 | 官方值 | 外展 ±57°、髋 −135°…75°、膝 26°…158° | 官方 URDF 符号约定与本工程不同,导入时会自动做符号归一化 | Trot @0.74/80 mm 的实测(无头回归,16 s):位移 2307 mm(≈144 mm/s)、pitch RMS 1.0°、 roll RMS 0.7°、支撑相滑移 ≤26 mm、无翻倒。作为对照,0.68 能走到 2957 mm(≈185 mm/s), 但转向时会积累侧倾——0.68 配 160 mm 步长做瞬态冲刺,实测当场翻车。 --- ## 假设、限制与待确认 1. **单位**:UI、导出、遥测统一 mm / s / kg / N;物理内核与 Three 场景内部用 m(cannon 的接触 刚度、重力默认值都以米为尺度),换算只在渲染/物理边界发生(`spec.ts` 的 `MM`)。 2. **URDF 只用于参数**,不加载 `.dae/.stl` 网格(工程内不含官方网格资源,也不应把二进制造型 塞进仓库)。因此换 URDF 只改变质量、限位、拓扑校验,不改变外形;渲染是参数化模型。 若需要「导入 URDF 即换外观」,需要额外提供网格文件并接 `urdf-loader`。 3. **无全局平衡控制器**。只有单级姿态环(roll/pitch → 支撑腿高度),没有 MPC/WBC, 因此大步长、低占空比、低摩擦时**会摔**——这是需求允许的(用于验证步态稳定性), 也是模型诚实的地方。直行时仍会有轻微偏航漂移(无航向环)。 4. **手动关节模式属于调试态**:直接把关节角写进物理目标,若强行给到与地面干涉的角度, 刚体可能瞬时穿透地面,重置即可恢复。 5. **摩擦上限用「整机重量 / 支撑腿数」估算**,不是逐接触求解真实法向力(cannon 的求解顺序 决定了拿不到当步 N)。接触力面板显示的是求解器回读的 `multiplier`,两者会有差异。 6. **录屏**依赖 `MediaRecorder`(Chromium/Edge 支持 webm/vp9);Safari 可能只支持 mp4。 7. `npm run sim:test` / `sweep.ts` 与浏览器端共用同一套 `sim/physics.ts` 和 `kinematics`, 是无头验证物理内核的快捷方式(不需要 WebGL)。 8. **站起有前提**:起立是把四条支撑腿的足端目标缓慢下移、靠蹬地顶起机身,因此要求 **躯干大致水平**(|roll|/|pitch| < 45°)且足端还有点地。真翻成侧躺/四脚朝天时起不来, 顶栏会直接提示改用「重置」——不做假的自动翻身。 9. **产物启动方式**:整包是 ESM,浏览器安全策略禁止 `file://` 加载 module script,因此 `dist/` 需要 `npm run preview`(或任意静态服务器)打开;应用本身没有任何后端依赖、不发起外部请求 (URDF 为同源相对路径,读取失败也不影响仿真)。若要真正做到双击 `.html` 就能跑,需改用 `vite-plugin-singlefile` 之类的内联打包(会把 three/echarts/element 共 ~2.7 MB 塞进单文件)。