Parcourir la source

统一三端头像数据并采用 LaoA 双眼与完整轮廓

LSamien il y a 1 mois
Parent
commit
8ea8563946

+ 4 - 0
THIRD_PARTY_NOTICES.md

@@ -2,3 +2,7 @@
 ## OpenMausBot avatar renderer
 
 Avatar geometry, face data and animation adapted from OpenMausBot commit 3a84701 (Apache-2.0), including its Blob Studio export. Local adaptations integrate Vue/SwiftUI, Yaoyao identity storage, activity and visibility. See licenses/OpenMausBot/LICENSE and NOTICE.
+
+## LaoA-GrokBot
+
+Avatar silhouette paths and original two-eye expressions from zhulin025/LaoA-GrokBot, commit 527c3b5746bed34da3a6d4f9747e483c84534a41. Copyright (c) 2026 老A玩AI. MIT License: see `licenses/LaoA-GrokBot/LICENSE`. Existing OpenMausBot geometry and animation retain their Apache-2.0 notices.

Fichier diff supprimé car celui-ci est trop grand
+ 9987 - 0
assets/mascot/laoa-source.json


+ 2 - 0
docs/verification/avatar-v2.md

@@ -1,5 +1,7 @@
 # 头像 v2 验收
 
+> 以下为初始 OpenMausBot 版本的历史验收记录。当前双眼样式及已更新的对照命令见 [LaoA 三端验收](laoa-avatars.md)。
+
 参考 OpenMausBot 提交 `3a84701`,参考仓库仅作读取。Web 移植为 Vue 与独立 SVG 动画控制器;iOS 移植对应 Swift 绘制代码与数据。没有新增运行服务或图片生成能力。
 
 ## 外观

+ 46 - 0
docs/verification/laoa-avatars.md

@@ -0,0 +1,46 @@
+# LaoA 双眼头像三端验收
+
+2026-09-07。采用 LaoA-GrokBot `527c3b5` 的双眼表达和基础轮廓;沿用 v2 配置、颜色、图片及额外造型。没有添加配饰,没有修改服务端协议或用户存量配置。
+
+## 统一数据与尺寸
+
+规范源为 `assets/mascot/laoa-source.json`,记录完整上游提交与保留的 OpenMausBot 造型。生成器统一转换路径、计算真实轮廓边界,并为较窄造型计算可容纳全部表情的双眼缩放。SVG、Swift 与 Kotlin 使用相同结果,各仓库可独立构建。
+
+```sh
+node scripts/generate-laoa-avatars.mjs --ios ../hermes-mobile --android ../hermes-android
+node scripts/generate-laoa-avatars.mjs --check --ios ../hermes-mobile --android ../hermes-android
+```
+
+列表容器继续为 44;最大可见边从圆形原先约 36.76 增至 44,约放大 19.7%。窄、扁轮廓保持自身比例,文字起点和行距不变。静态列表不运行连续动画,运行态沿用暂停与可见性处理。iOS 为运行态画布留出绘制空间,外部布局仍为原尺寸。
+
+## 已完成验证
+
+- Web:头像与配置单元测试 13 项通过;类型检查及生产构建通过。
+- 浏览器:53 个固定样例通过原始 SVG 路径渲染对照,覆盖 8 个基础形状、10 个额外造型、10 个可选表情和 25 套原始双眼。检查真实边界、居中、原始眼睛数据、无嘴巴和减少动态效果。
+- iOS:8 项头像单元测试、固定样例/编辑 UI、深浅色列表 UI 和隔离服务回读 UI 全部通过。使用独立 iPhone 17 Pro 模拟器,无失败或跳过。
+- 安卓:core:model 28 项、core:network 34 项测试通过;53 样例/深浅色、基础编辑、真实服务回读共 3 项 UI 测试通过;Debug APK 和 Lint 通过。
+- 三端 53 个样例分别归一到 96×96 比较。对 Web 截图的最大平均 RGB 差值:iOS 2.0737/255、安卓 0.6831/255,主要为边缘抗锯齿与像素取整。逐项数值见 [pixel-comparison.json](laoa-avatars/pixel-comparison.json)。
+
+## 跨端回读流程
+
+全部使用一次性测试服务和合成账号。Web 种下星星;iOS 界面保存菱形,并将测试照片改为圆角裁剪;安卓读取该菱形,界面改为橙色云朵和开心表情;Web 再经真实登录页面及接口读回。图片字节与 iOS 设置的裁剪方式保持一致。
+
+```sh
+WORKSPACE_FIXTURE_PORT=18804 WORKSPACE_FIXTURE_UPSTREAM_PORT=19124 node --import tsx tests/fixtures/workspace-server.ts
+node scripts/seed-avatar-fixture.mjs
+# 随后在独立 iOS 模拟器运行 testAvatarV2ChangesRoundTripThroughWebStorage,
+# 在 Android 模拟器运行 AgentAvatarRoundTripTest,再执行:
+npx playwright test -c playwright.avatar-roundtrip.config.ts
+# 53 个图形样例可单独验证:
+npx playwright test -c playwright.avatar.config.ts
+```
+
+安卓真实回读验证同时修复了包含较大头像的 JSON 响应在主线程读取的问题;响应体读取和解析现在在 IO dispatcher 完成。安卓原生 Bot 会在列表展示后读取桌面头像资源,失败保留共享头像,并阻止旧账号/旧刷新结果回写。
+
+## 截图与交付边界
+
+- [Web 全部头像](laoa-avatars/web-avatar-grid.png)、[深色](laoa-avatars/web-avatar-grid-dark.png)
+- [Web 实际列表](laoa-avatars/web-workspace-roundtrip.png)、[375 宽度网页](laoa-avatars/web-workspace-mobile.png)
+- iOS 与安卓截图分别保存在对应仓库的头像验收文档。
+
+本次为源码、构建和模拟器验收;未进行生产发布或真实手机安装。产品官网与桌面应用仓库未修改。

+ 438 - 0
docs/verification/laoa-avatars/pixel-comparison.json

@@ -0,0 +1,438 @@
+{
+  "ios": {
+    "samples": 53,
+    "maximumMeanRGBDifference": 2.0737,
+    "results": [
+      {
+        "sample": "body-blob",
+        "meanRGBDifference": 1.4665
+      },
+      {
+        "sample": "body-drop",
+        "meanRGBDifference": 1.3388
+      },
+      {
+        "sample": "raw-19",
+        "meanRGBDifference": 1.306
+      },
+      {
+        "sample": "body-hexagon",
+        "meanRGBDifference": 1.3551
+      },
+      {
+        "sample": "face-happy",
+        "meanRGBDifference": 1.3796
+      },
+      {
+        "sample": "shape-hexagon",
+        "meanRGBDifference": 1.5426
+      },
+      {
+        "sample": "raw-24",
+        "meanRGBDifference": 1.25
+      },
+      {
+        "sample": "raw-18",
+        "meanRGBDifference": 1.2566
+      },
+      {
+        "sample": "face-listening",
+        "meanRGBDifference": 1.1323
+      },
+      {
+        "sample": "raw-20",
+        "meanRGBDifference": 1.6207
+      },
+      {
+        "sample": "face-working",
+        "meanRGBDifference": 1.1316
+      },
+      {
+        "sample": "raw-21",
+        "meanRGBDifference": 1.5023
+      },
+      {
+        "sample": "body-squircle",
+        "meanRGBDifference": 0.9068
+      },
+      {
+        "sample": "raw-23",
+        "meanRGBDifference": 1.4092
+      },
+      {
+        "sample": "body-capsule",
+        "meanRGBDifference": 1.3342
+      },
+      {
+        "sample": "shape-capsule",
+        "meanRGBDifference": 1.4596
+      },
+      {
+        "sample": "raw-22",
+        "meanRGBDifference": 1.3703
+      },
+      {
+        "sample": "face-proud",
+        "meanRGBDifference": 1.0016
+      },
+      {
+        "sample": "shape-circle",
+        "meanRGBDifference": 1.3104
+      },
+      {
+        "sample": "face-idle",
+        "meanRGBDifference": 0.9954
+      },
+      {
+        "sample": "raw-3",
+        "meanRGBDifference": 1.4447
+      },
+      {
+        "sample": "raw-2",
+        "meanRGBDifference": 1.5977
+      },
+      {
+        "sample": "body-star",
+        "meanRGBDifference": 1.6651
+      },
+      {
+        "sample": "shape-cloud",
+        "meanRGBDifference": 1.4915
+      },
+      {
+        "sample": "raw-0",
+        "meanRGBDifference": 1.1332
+      },
+      {
+        "sample": "raw-1",
+        "meanRGBDifference": 1.3476
+      },
+      {
+        "sample": "face-curious",
+        "meanRGBDifference": 1.2729
+      },
+      {
+        "sample": "raw-5",
+        "meanRGBDifference": 1.4187
+      },
+      {
+        "sample": "raw-4",
+        "meanRGBDifference": 1.4052
+      },
+      {
+        "sample": "shape-ellipse",
+        "meanRGBDifference": 1.2429
+      },
+      {
+        "sample": "raw-6",
+        "meanRGBDifference": 1.2229
+      },
+      {
+        "sample": "raw-7",
+        "meanRGBDifference": 1.2732
+      },
+      {
+        "sample": "shape-droplet",
+        "meanRGBDifference": 1.333
+      },
+      {
+        "sample": "shape-triangle",
+        "meanRGBDifference": 2.0737
+      },
+      {
+        "sample": "face-drowsy",
+        "meanRGBDifference": 1.2359
+      },
+      {
+        "sample": "face-suspicious",
+        "meanRGBDifference": 1.1495
+      },
+      {
+        "sample": "raw-9",
+        "meanRGBDifference": 1.3334
+      },
+      {
+        "sample": "raw-8",
+        "meanRGBDifference": 1.3118
+      },
+      {
+        "sample": "face-sleeping",
+        "meanRGBDifference": 1.29
+      },
+      {
+        "sample": "body-circle",
+        "meanRGBDifference": 1.4234
+      },
+      {
+        "sample": "raw-13",
+        "meanRGBDifference": 1.4625
+      },
+      {
+        "sample": "body-cursor",
+        "meanRGBDifference": 1.2363
+      },
+      {
+        "sample": "shape-square",
+        "meanRGBDifference": 0.7469
+      },
+      {
+        "sample": "raw-12",
+        "meanRGBDifference": 1.5339
+      },
+      {
+        "sample": "raw-10",
+        "meanRGBDifference": 1.2836
+      },
+      {
+        "sample": "face-thinking",
+        "meanRGBDifference": 1.1352
+      },
+      {
+        "sample": "raw-11",
+        "meanRGBDifference": 1.5155
+      },
+      {
+        "sample": "raw-15",
+        "meanRGBDifference": 1.1644
+      },
+      {
+        "sample": "body-shield",
+        "meanRGBDifference": 1.0854
+      },
+      {
+        "sample": "raw-14",
+        "meanRGBDifference": 1.3333
+      },
+      {
+        "sample": "raw-16",
+        "meanRGBDifference": 1.3854
+      },
+      {
+        "sample": "body-diamond",
+        "meanRGBDifference": 1.5029
+      },
+      {
+        "sample": "raw-17",
+        "meanRGBDifference": 1.2628
+      }
+    ]
+  },
+  "android": {
+    "samples": 53,
+    "maximumMeanRGBDifference": 0.6831,
+    "results": [
+      {
+        "sample": "body-blob",
+        "meanRGBDifference": 0.605
+      },
+      {
+        "sample": "body-drop",
+        "meanRGBDifference": 0.5005
+      },
+      {
+        "sample": "raw-19",
+        "meanRGBDifference": 0.5964
+      },
+      {
+        "sample": "body-hexagon",
+        "meanRGBDifference": 0.4885
+      },
+      {
+        "sample": "face-happy",
+        "meanRGBDifference": 0.5866
+      },
+      {
+        "sample": "shape-hexagon",
+        "meanRGBDifference": 0.6831
+      },
+      {
+        "sample": "raw-24",
+        "meanRGBDifference": 0.5397
+      },
+      {
+        "sample": "raw-18",
+        "meanRGBDifference": 0.5409
+      },
+      {
+        "sample": "face-listening",
+        "meanRGBDifference": 0.5162
+      },
+      {
+        "sample": "raw-20",
+        "meanRGBDifference": 0.6456
+      },
+      {
+        "sample": "face-working",
+        "meanRGBDifference": 0.4851
+      },
+      {
+        "sample": "raw-21",
+        "meanRGBDifference": 0.6067
+      },
+      {
+        "sample": "body-squircle",
+        "meanRGBDifference": 0.377
+      },
+      {
+        "sample": "raw-23",
+        "meanRGBDifference": 0.5663
+      },
+      {
+        "sample": "body-capsule",
+        "meanRGBDifference": 0.6063
+      },
+      {
+        "sample": "shape-capsule",
+        "meanRGBDifference": 0.4368
+      },
+      {
+        "sample": "raw-22",
+        "meanRGBDifference": 0.5611
+      },
+      {
+        "sample": "face-proud",
+        "meanRGBDifference": 0.4526
+      },
+      {
+        "sample": "shape-circle",
+        "meanRGBDifference": 0.5468
+      },
+      {
+        "sample": "face-idle",
+        "meanRGBDifference": 0.4665
+      },
+      {
+        "sample": "raw-3",
+        "meanRGBDifference": 0.6126
+      },
+      {
+        "sample": "raw-2",
+        "meanRGBDifference": 0.65
+      },
+      {
+        "sample": "body-star",
+        "meanRGBDifference": 0.5958
+      },
+      {
+        "sample": "shape-cloud",
+        "meanRGBDifference": 0.5617
+      },
+      {
+        "sample": "raw-0",
+        "meanRGBDifference": 0.5359
+      },
+      {
+        "sample": "raw-1",
+        "meanRGBDifference": 0.5736
+      },
+      {
+        "sample": "face-curious",
+        "meanRGBDifference": 0.5239
+      },
+      {
+        "sample": "raw-5",
+        "meanRGBDifference": 0.5888
+      },
+      {
+        "sample": "raw-4",
+        "meanRGBDifference": 0.5757
+      },
+      {
+        "sample": "shape-ellipse",
+        "meanRGBDifference": 0.6108
+      },
+      {
+        "sample": "raw-6",
+        "meanRGBDifference": 0.5278
+      },
+      {
+        "sample": "raw-7",
+        "meanRGBDifference": 0.561
+      },
+      {
+        "sample": "shape-droplet",
+        "meanRGBDifference": 0.5092
+      },
+      {
+        "sample": "shape-triangle",
+        "meanRGBDifference": 0.6741
+      },
+      {
+        "sample": "face-drowsy",
+        "meanRGBDifference": 0.4987
+      },
+      {
+        "sample": "face-suspicious",
+        "meanRGBDifference": 0.5247
+      },
+      {
+        "sample": "raw-9",
+        "meanRGBDifference": 0.5332
+      },
+      {
+        "sample": "raw-8",
+        "meanRGBDifference": 0.54
+      },
+      {
+        "sample": "face-sleeping",
+        "meanRGBDifference": 0.4992
+      },
+      {
+        "sample": "body-circle",
+        "meanRGBDifference": 0.5808
+      },
+      {
+        "sample": "raw-13",
+        "meanRGBDifference": 0.5723
+      },
+      {
+        "sample": "body-cursor",
+        "meanRGBDifference": 0.5305
+      },
+      {
+        "sample": "shape-square",
+        "meanRGBDifference": 0.4013
+      },
+      {
+        "sample": "raw-12",
+        "meanRGBDifference": 0.6144
+      },
+      {
+        "sample": "raw-10",
+        "meanRGBDifference": 0.5922
+      },
+      {
+        "sample": "face-thinking",
+        "meanRGBDifference": 0.4751
+      },
+      {
+        "sample": "raw-11",
+        "meanRGBDifference": 0.6654
+      },
+      {
+        "sample": "raw-15",
+        "meanRGBDifference": 0.5201
+      },
+      {
+        "sample": "body-shield",
+        "meanRGBDifference": 0.4822
+      },
+      {
+        "sample": "raw-14",
+        "meanRGBDifference": 0.5833
+      },
+      {
+        "sample": "raw-16",
+        "meanRGBDifference": 0.5655
+      },
+      {
+        "sample": "body-diamond",
+        "meanRGBDifference": 0.4791
+      },
+      {
+        "sample": "raw-17",
+        "meanRGBDifference": 0.572
+      }
+    ]
+  }
+}

BIN
docs/verification/laoa-avatars/web-avatar-grid-dark.png


BIN
docs/verification/laoa-avatars/web-avatar-grid.png


BIN
docs/verification/laoa-avatars/web-workspace-mobile.png


BIN
docs/verification/laoa-avatars/web-workspace-roundtrip.png


+ 21 - 0
licenses/LaoA-GrokBot/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 老A玩AI
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 3 - 0
playwright.avatar-roundtrip.config.ts

@@ -0,0 +1,3 @@
+import {defineConfig,devices} from '@playwright/test'
+// The explicit three-client acceptance chain uses the already running disposable fixture.
+export default defineConfig({testDir:'./tests/e2e',outputDir:'test-results/avatar-roundtrip',testMatch:'avatarRoundTrip.spec.ts',workers:1,timeout:30000,reporter:'list',use:{...devices['Desktop Chrome'],baseURL:'http://127.0.0.1:18804',viewport:{width:1200,height:850}}})

+ 1 - 1
playwright.avatar.config.ts

@@ -1,2 +1,2 @@
 import {defineConfig,devices} from '@playwright/test'
-export default defineConfig({testDir:'./tests/e2e',testMatch:'avatarReference.spec.ts',timeout:60000,workers:1,reporter:'list',use:{viewport:{width:1000,height:660},screenshot:'only-on-failure'},projects:[{name:'chromium',use:{...devices['Desktop Chrome']}}],webServer:{command:'node scripts/serve-avatar-reference.mjs',url:'http://127.0.0.1:18807',reuseExistingServer:false,timeout:30000}})
+export default defineConfig({testDir:'./tests/e2e',outputDir:'test-results/avatar-canonical',testMatch:'avatarReference.spec.ts',timeout:60000,workers:1,reporter:'list',use:{viewport:{width:1000,height:660},screenshot:'only-on-failure'},projects:[{name:'chromium',use:{...devices['Desktop Chrome']}}],webServer:{command:'node scripts/serve-avatar-reference.mjs',url:'http://127.0.0.1:18807',reuseExistingServer:false,timeout:30000}})

+ 115 - 0
scripts/generate-laoa-avatars.mjs

@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+// Canonical data is vendored: generation and builds never need the source checkout.
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const source = JSON.parse(readFileSync(resolve(root, 'assets/mascot/laoa-source.json'), 'utf8'))
+const check = process.argv.includes('--check')
+const option = key => { const i = process.argv.indexOf(key); return i < 0 ? undefined : resolve(process.argv[i + 1]) }
+const ios = option('--ios'), android = option('--android')
+const number = n => Number(n.toFixed(6))
+
+// Convert the source's absolute SVG commands into M/C/Z once. This is also the
+// input accepted by the native CGPath parser; no per-frame SVG parsing is needed.
+function geometry(path, fit = '') {
+  const tokens = path.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?/g)
+  const translate = fit.match(/translate\(([-\d.]+)[ ,]+([-\d.]+)\)/), scale = fit.match(/scale\(([-\d.]+)\)/)
+  const k = scale ? Number(scale[1]) : 1, tx = translate ? Number(translate[1]) : 0, ty = translate ? Number(translate[2]) : 0
+  const at = (x, y) => [number(x * k + tx), number(y * k + ty)]
+  let i = 0, command, x = 0, y = 0, start = [0, 0]
+  const out = [], xs = [], ys = []
+  const read = () => Number(tokens[i++])
+  const include = p => { xs.push(p[0]); ys.push(p[1]) }
+  function cubic(a, b, c, d) {
+    const points = [a, b, c, d].map(p => at(...p))
+    include(points[0]); include(points[3])
+    for (let axis = 0; axis < 2; axis++) {
+      const [p, q, r, s] = points.map(v => v[axis])
+      const aa = -p + 3*q - 3*r + s, bb = 2*(p - 2*q + r), cc = q-p
+      const disc = bb*bb - 4*aa*cc
+      const roots = Math.abs(aa) < 1e-12 ? (Math.abs(bb) < 1e-12 ? [] : [-cc/bb]) : disc < 0 ? [] : [(-bb+Math.sqrt(disc))/(2*aa),(-bb-Math.sqrt(disc))/(2*aa)]
+      for (const t of roots.filter(t => t > 0 && t < 1)) {
+        const v = (1-t)**3*p + 3*(1-t)**2*t*q + 3*(1-t)*t*t*r + t**3*s
+        ;(axis === 0 ? xs : ys).push(v)
+      }
+    }
+    out.push('C' + points.slice(1).flat().join(' '))
+  }
+  while (i < tokens.length) {
+    if (/^[A-Za-z]$/.test(tokens[i])) command = tokens[i++]
+    const a = [x,y]
+    if (command === 'M') { x=read(); y=read(); start=[x,y]; out.push('M'+at(x,y).join(' ')); include(at(x,y)); command='L' }
+    else if (command === 'C') { const b=[read(),read()],c=[read(),read()];x=read();y=read();cubic(a,b,c,[x,y]) }
+    else if (command === 'Q') { const b=[read(),read()];x=read();y=read();cubic(a,[a[0]+2/3*(b[0]-a[0]),a[1]+2/3*(b[1]-a[1])],[x+2/3*(b[0]-x),y+2/3*(b[1]-y)],[x,y]) }
+    else if (['L','H','V'].includes(command)) { if(command!=='V')x=read();if(command!=='H')y=read();cubic(a,a,[x,y],[x,y]) }
+    else if (command === 'Z') { out.push('Z');[x,y]=start;command=undefined }
+    else throw new Error('Unsupported path command: '+command)
+  }
+  const bounds = [Math.min(...xs),Math.min(...ys),Math.max(...xs)-Math.min(...xs),Math.max(...ys)-Math.min(...ys)].map(number)
+  const side = Math.max(bounds[2],bounds[3])
+  const viewport = [bounds[0]+(bounds[2]-side)/2,bounds[1]+(bounds[3]-side)/2,side,side].map(number)
+  return {path:out.join(' '),bounds,viewport}
+}
+const aliases = {circle:'blob',square:'squircle',triangle:'triangle',capsule:'capsule',hexagon:'hex',cloud:'cloud',droplet:'drop'}
+const shapes = Object.fromEntries(Object.entries(aliases).map(([id, key]) => [id, {...geometry(source.shapes[key].path), anchor:[120,122.5,1]}]))
+// Retain the saved ellipse's aspect ratio. Cubic approximation is shared by all clients.
+shapes.ellipse = {...geometry('M222.2705 114.2705 C222.2705 162.87156 173.91726 202.2705 114.2705 202.2705 C54.62374 202.2705 6.2705 162.87156 6.2705 114.2705 C6.2705 65.66944 54.62374 26.2705 114.2705 26.2705 C173.91726 26.2705 222.2705 65.66944 222.2705 114.2705 Z'),anchor:[120,122.5,1]}
+const bodies = Object.fromEntries(Object.entries(source.legacyBodies).map(([id,b]) => [id, {...geometry(b.path,b.fit),anchor:[b.anchor.x,b.anchor.y,b.anchor.scale]}]))
+// Fit the complete expression catalog inside narrow silhouettes. Keep the original
+// look direction; solve a single per-shape scale rather than moving each expression.
+function safeAnchor(g, anchor) {
+  const tokens = g.path.match(/[MCZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?/g)
+  const edges = []; let i=0, p=[0,0], start=p
+  const point = () => [Number(tokens[i++]),Number(tokens[i++])]
+  const edge = q => { if(p[1]!==q[1])edges.push([p,q]);p=q }
+  while(i<tokens.length) {
+    const command=tokens[i++]
+    if(command==='M') {p=point();start=p}
+    else if(command==='Z')edge(start)
+    else if(command==='C') {
+      const a=p,b=point(),c=point(),d=point()
+      for(let n=1;n<=16;n++){const t=n/16,u=1-t;edge([0,1].map(k=>u**3*a[k]+3*u*u*t*b[k]+3*u*t*t*c[k]+t**3*d[k]))}
+    }
+  }
+  const inside = (x,y) => { let result=false; for(const [a,b] of edges)if((a[1]>y)!==(b[1]>y) && x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])result=!result; return result }
+  const points=source.expressions.flat(2)
+  const fits = scale => points.every(p=>{const x=anchor[0]+(p[0]-120)*scale,y=anchor[1]+(p[1]-122.5)*scale;return inside(x-1,y)&&inside(x+1,y)&&inside(x,y-1)&&inside(x,y+1)})
+  let lo=0,hi=anchor[2]
+  if(fits(hi))return anchor
+  for(let n=0;n<18;n++){const mid=(lo+hi)/2;if(fits(mid))lo=mid;else hi=mid}
+  return [anchor[0],anchor[1],number(lo)]
+}
+const centers={triangle:[114.2705,135,1],cloud:[114.2705,125,1],droplet:[114.2705,140,1]}
+for(const [id,g] of Object.entries(shapes))g.anchor=safeAnchor(g,centers[id]??g.anchor)
+for(const g of Object.values(bodies))g.anchor=safeAnchor(g,g.anchor)
+const pools = {...source.pools, 'thinking-dots':source.pools.thinking}
+const data = {source:source.source,faceBox:source.faceBox,shapes,bodies,expressions:source.expressions,pools}
+const banner = '// Generated by hermes-yaoyao/scripts/generate-laoa-avatars.mjs. Do not hand-edit.\n// LaoA-GrokBot '+source.source.commit+' (MIT); legacy bodies OpenMausBot (Apache-2.0).\n'
+function write(path,text) { if(check) {if(readFileSync(path,'utf8')!==text)throw new Error('Generated file differs: '+path)}else{mkdirSync(dirname(path),{recursive:true});writeFileSync(path,text)} }
+write(resolve(root,'src/client/components/common/maus/laoa-data.ts'),banner+'export const LAOA_DATA = '+JSON.stringify(data)+' as const\n')
+if (ios) {
+  const record = b => 'Geometry(path: "'+b.path+'", viewport: CGRect(x: '+b.viewport[0]+', y: '+b.viewport[1]+', width: '+b.viewport[2]+', height: '+b.viewport[3]+'), anchor: ('+b.anchor.join(', ')+'))'
+  const rows = Object.entries({...Object.fromEntries(Object.entries(shapes).map(([k,v])=>['shape:'+k,v])),...Object.fromEntries(Object.entries(bodies).map(([k,v])=>['body:'+k,v]))}).map(([k,v])=>'        "'+k+'": '+record(v)).join(',\n')
+  const names = {'powering-down':'poweringDown','thinking-dots':'thinkingDots'}
+  write(resolve(ios,'YaoYaoAI/Features/Bots/OpenMaus/LaoAMascotData.swift'),banner+`import CoreGraphics
+enum LaoAMascotData {
+    struct Geometry { let path: String; let viewport: CGRect; let anchor: (x: CGFloat, y: CGFloat, scale: CGFloat) }
+    static let geometries: [String: Geometry] = [
+${rows}
+    ]
+    static func geometry(shape: String, bodyId: String?) -> Geometry {
+        if let bodyId, let body = geometries["body:" + bodyId] { return body }
+        return geometries["shape:" + (shape == "oval" ? "ellipse" : shape)] ?? geometries["shape:circle"]!
+    }
+    static let expressions: [CGFloat] = [
+${source.expressions.flat(3).reduce((a,v,i)=>{if(i%24===0)a.push('        ');a[a.length-1]+=v+', ';return a},[]).map(line => line.trimEnd()).join('\n')}
+    ]
+    static let pools: [MausState: [Int]] = [
+${Object.entries(pools).map(([k,v])=>'        .'+(names[k]??k)+': ['+v.join(', ')+']').join(',\n')}
+    ]
+}
+`)
+}
+if(android)write(resolve(android,'core/ui/src/main/res/raw/laoa_mascot.json'),JSON.stringify(data)+'\n')
+console.log(`${check?'Verified':'Generated'} ${Object.keys(shapes).length} shapes, ${Object.keys(bodies).length} bodies, ${source.expressions.length} expressions`)

+ 18 - 20
scripts/serve-avatar-reference.mjs

@@ -4,28 +4,26 @@ import {mkdirSync,mkdtempSync,readFileSync,writeFileSync} from 'node:fs';
 import {resolve,join} from 'node:path';
 import {tmpdir} from 'node:os';
 import {fileURLToPath} from 'node:url';
-const target=resolve(fileURLToPath(new URL('..',import.meta.url))), source=process.env.OPENMAUS_REFERENCE_ROOT || resolve(target,'../OpenMausBot'), root=mkdtempSync(join(tmpdir(),'yaoyao-avatar-reference-'));
-mkdirSync(root+'/reference',{recursive:true});mkdirSync(root+'/ported',{recursive:true});
+import {LAOA_DATA} from '../src/client/components/common/maus/laoa-data.ts';
+const target=resolve(fileURLToPath(new URL('..',import.meta.url))),root=mkdtempSync(join(tmpdir(),'yaoyao-avatar-reference-'));
+const source=JSON.parse(readFileSync(target+'/assets/mascot/laoa-source.json','utf8'));
+const aliases={circle:'blob',square:'squircle',triangle:'triangle',capsule:'capsule',hexagon:'hex',cloud:'cloud',droplet:'drop'};
 const shapes=['circle','ellipse','square','capsule','triangle','hexagon','cloud','droplet'];
 const bodies=['cursor','blob','circle','squircle','capsule','drop','shield','hexagon','diamond','star'];
 const expressions=['idle','happy','curious','drowsy','working','thinking','listening','sleeping','suspicious','proud'];
-const cases=[...shapes.map(shape=>({id:'shape-'+shape,shape,color:'green',hex:'#00c875',expression:'idle'})),...bodies.map(bodyId=>({id:'body-'+bodyId,shape:'circle',bodyId,color:'blue',hex:'#1488ff',expression:'happy'})),...expressions.map(expression=>({id:'face-'+expression,shape:'circle',color:'pink',hex:'#f52ba5',expression}))];
-const html='<html><head><style>body{margin:0;background:white;font:12px Arial}.grid{display:grid;grid-template-columns:repeat(7,128px);gap:12px;padding:12px}.case{display:flex;flex-direction:column;align-items:center;gap:6px;width:128px;height:128px}.inline-flex{display:inline-flex}</style></head><body><div id="app"></div><script type="module" src="/main.tsx"></script></body></html>';
-for(const sub of ['reference','ported'])writeFileSync(root+'/'+sub+'/index.html',html);
-let cursor=readFileSync(source+'/src/components/CursorAvatar.tsx','utf8');
-cursor=cursor.replace('"../../shared/mascot-bodies"',JSON.stringify(source+'/shared/mascot-bodies.ts')).replace('"./cursor-face-data"',JSON.stringify(source+'/src/components/cursor-face-data.ts'));
-// Canonical resting-frame capture: settle the spring and hold elapsed time at 0.
-// This only controls the test clock; all original geometry/drawing code is unchanged.
-cursor=cursor.replace('if (p.paused) {','if (p.paused) { e.morph = 1; e.stateStart = now;');
-writeFileSync(root+'/reference/CursorAvatar.tsx',cursor);
-let avatar=readFileSync(source+'/src/components/Avatar.tsx','utf8').replace('"../../shared/bot-avatar"',JSON.stringify(source+'/shared/bot-avatar.ts')).replace('"../../shared/mascot-bodies"',JSON.stringify(source+'/shared/mascot-bodies.ts'));
-writeFileSync(root+'/reference/Avatar.tsx',avatar);
-writeFileSync(root+'/reference/main.tsx',`import React from 'react';import{createRoot}from'react-dom/client';import{MausAvatar}from'./Avatar';const cases=${JSON.stringify(cases)};createRoot(document.getElementById('app')!).render(<div className="grid">{cases.map(c=><div className="case" data-testid={c.id} key={c.id}><MausAvatar color={c.color as any} shape={(c.shape==='ellipse'?'oval':c.shape) as any} bodyId={c.bodyId as any} state={c.expression as any} size={96} animated={false}/><span>{c.id}</span></div>)}</div>);`);
-writeFileSync(root+'/ported/main.tsx',`import{createApp,h}from'vue';import AgentAvatar from '${target}/src/client/components/common/AgentAvatar.vue';import{defaultAgentIdentity,encodeAgentAvatar}from'${target}/src/shared/agentIdentity.ts';const cases=${JSON.stringify(cases)};createApp({render:()=>h('div',{class:'grid'},cases.map(c=>h('div',{class:'case','data-testid':c.id},[h(AgentAvatar,{name:c.id,avatar:encodeAgentAvatar({...defaultAgentIdentity(c.id),shape:c.shape,bodyId:c.bodyId??null,color:c.hex,expression:c.expression}),size:96,animated:false,fixedTime:0}),h('span',c.id)])))}).mount('#app');`);
-writeFileSync(root+'/cases.json',JSON.stringify(cases));
-const servers=[];
-for(const [sub,port]of[['reference',18806],['ported',18807]]){
- const server=await createServer({configFile:false,root:root+'/'+sub,plugins:sub==='ported'?[vue()]:[],resolve:{alias:{'@':sub==='reference'?source+'/src':target+'/src/client','@shared':target+'/src/shared',react:source+'/node_modules/react','react-dom':source+'/node_modules/react-dom',vue:target+'/node_modules/vue'}},server:{host:'127.0.0.1',port,strictPort:true,fs:{allow:[root,source,target]}}});
- await server.listen();servers.push(server);console.log(sub,port);
+const cases=[...shapes.map(shape=>({id:'shape-'+shape,shape,color:'#00c875',expression:'idle'})),...bodies.map(bodyId=>({id:'body-'+bodyId,shape:'circle',bodyId,color:'#1488ff',expression:'happy'})),...expressions.map(expression=>({id:'face-'+expression,shape:'circle',color:'#f52ba5',expression})),...Array.from({length:25},(_,expressionIndex)=>({id:'raw-'+expressionIndex,shape:'circle',color:'#1488ff',expression:'idle',expressionIndex}))];
+const style='body{margin:0;background:white;font:12px Arial}.grid{display:grid;grid-template-columns:repeat(7,128px);gap:12px;padding:12px}.case{display:flex;flex-direction:column;align-items:center;gap:6px;width:128px;height:128px}svg{width:96px;height:96px;overflow:visible}';
+let reference='';
+for(const c of cases){
+ const g=c.bodyId?LAOA_DATA.bodies[c.bodyId]:LAOA_DATA.shapes[c.shape], original=c.bodyId?source.legacyBodies[c.bodyId]:source.shapes[aliases[c.shape]];
+ const path=original?.path??g.path,fit=original?.fit??'',[x,y,k]=g.anchor;
+ const rings=source.expressions[c.expressionIndex??source.pools[c.expression][0]],eyePath=r=>'M'+r.map(p=>p.join(' ')).join('L')+'Z';
+ reference+=`<div class="case" data-testid="${c.id}"><svg viewBox="${g.viewport.join(' ')}"><defs><clipPath id="clip-${c.id}"><path d="${path}" transform="${fit}"/></clipPath></defs><path data-body d="${path}" transform="${fit}" fill="${c.color}"/><g clip-path="url(#clip-${c.id})"><g transform="translate(${x} ${y}) scale(${k}) translate(-120 -122.5)">${rings.map(r=>`<path data-eye fill="white" d="${eyePath(r)}"/>`).join('')}</g></g></svg><span>${c.id}</span></div>`;
 }
+for(const sub of ['reference','ported'])mkdirSync(root+'/'+sub,{recursive:true});
+writeFileSync(root+'/reference/index.html',`<html><head><style>${style}</style></head><body><div class="grid">${reference}</div></body></html>`);
+writeFileSync(root+'/ported/index.html',`<html><head><style>${style}</style></head><body><div id="app"></div><script type="module" src="/main.ts"></script></body></html>`);
+writeFileSync(root+'/ported/main.ts',`import{createApp,h}from'vue';import AgentAvatar from '${target}/src/client/components/common/AgentAvatar.vue';import{defaultAgentIdentity,encodeAgentAvatar}from'${target}/src/shared/agentIdentity.ts';const cases=${JSON.stringify(cases)};createApp({render:()=>h('div',{class:'grid'},cases.map(c=>h('div',{class:'case','data-testid':c.id},[h(AgentAvatar,{name:c.id,avatar:encodeAgentAvatar({...defaultAgentIdentity(c.id),shape:c.shape,bodyId:c.bodyId??null,color:c.color,expression:c.expression}),size:96,animated:false,fixedTime:0,expressionIndex:c.expressionIndex}),h('span',c.id)])))}).mount('#app');`);
+const servers=[];
+for(const [sub,port]of[['reference',18806],['ported',18807]]){const server=await createServer({configFile:false,root:root+'/'+sub,plugins:sub==='ported'?[vue()]:[],resolve:{alias:{'@':target+'/src/client','@shared':target+'/src/shared',vue:target+'/node_modules/vue'}},server:{host:'127.0.0.1',port,strictPort:true,fs:{allow:[root,target]}}});await server.listen();servers.push(server);console.log(sub,port)}
 for(const signal of ['SIGINT','SIGTERM'])process.once(signal,async()=>{for(const server of servers)await server.close();process.exit()});

+ 3 - 4
src/client/components/common/AgentAvatar.vue

@@ -2,10 +2,9 @@
 import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { decodeAgentAvatar, defaultAgentIdentity } from '@shared/agentIdentity'
 import { mountCursorAvatar, type CursorState } from './maus/cursor-engine'
-import { MASCOT_BODIES } from './maus/mascot-bodies'
-import { MASCOT_SILHOUETTES } from './maus/silhouettes'
+import { MASCOT_SILHOUETTES, LAOA_BODIES } from './maus/silhouettes'
 type MascotState = 'idle' | 'working' | 'loading' | 'notifying' | 'waiting' | 'success' | 'failure'
-const props = withDefaults(defineProps<{ name: string; avatar?: string; size?: number; state?: MascotState; animated?: boolean; fixedTime?: number; activityKey?: string | number }>(), { avatar: '', size: 32, state: 'idle', animated: undefined })
+const props = withDefaults(defineProps<{ name: string; avatar?: string; size?: number; state?: MascotState; animated?: boolean; fixedTime?: number; expressionIndex?: number; activityKey?: string | number }>(), { avatar: '', size: 32, state: 'idle', animated: undefined })
 const host = ref<HTMLElement>(), svg = ref<SVGSVGElement>(), visible = ref(true), foreground = ref(true), reduced = ref(false), hover = ref(false)
 const gaze = ref({x:0,y:0})
 const oneShot = ref<CursorState>()
@@ -22,7 +21,7 @@ const runtimeActive = computed(() => props.state !== 'idle' && !beatEnded.value)
 const state = computed<CursorState>(() => runtimeActive.value ? states[props.state] : oneShot.value ?? identity.value.expression)
 const live = computed(() => visible.value && foreground.value && !reduced.value && props.animated !== false && (props.animated === true || props.size >= 56 || runtimeActive.value || !!oneShot.value || hover.value))
 const options = computed(() => ({ state: state.value, paused: !live.value, color: identity.value.color, gaze: gaze.value,
-  silhouette: identity.value.bodyId ? MASCOT_BODIES[identity.value.bodyId] : MASCOT_SILHOUETTES[identity.value.shape === 'ellipse' ? 'oval' : identity.value.shape], fixedTime: props.fixedTime }))
+  silhouette: identity.value.bodyId ? LAOA_BODIES[identity.value.bodyId] : MASCOT_SILHOUETTES[identity.value.shape === 'ellipse' ? 'oval' : identity.value.shape], fixedTime: props.fixedTime, expression: props.expressionIndex }))
 function sync() {
   if (!svg.value || hasImage.value) { renderer?.dispose();renderer=undefined;return }
   if (!renderer) renderer=mountCursorAvatar(svg.value, options.value)

+ 13 - 271
src/client/components/common/maus/cursor-engine.ts

@@ -1,3 +1,4 @@
+import { LAOA_DATA } from './laoa-data'
 // Adapted from OpenMausBot (3a84701), Apache-2.0. See THIRD_PARTY_NOTICES.md.
 /**
  * CursorAvatar — an animated mascot built on the "cursor" silhouette.
@@ -16,7 +17,6 @@
  *   expression   pin a single face and stop the cycling
  *   lookAround   how much each expression glances around. 0 = always straight ahead
  *   gaze / turn  aim the eyes, or rotate the head around its implied sphere
- *   showMouth    false for an eyes-only face
  *
  * Made with Blob Studio.
  */
@@ -27,11 +27,7 @@ import {
   EXPRESSION_COUNT,
   FACE_BOX,
   FACE_CENTRE,
-  GAZE,
   GAZE_TRAVEL,
-  MOUTHS,
-  MOUTH_STROKE,
-  mouthFrame,
   type Ring,
 } from "./cursor-face-data"
 
@@ -40,17 +36,14 @@ export {
   EXPRESSION_COUNT,
   FACE_BOX,
   FACE_CENTRE,
-  GAZE,
   GAZE_TRAVEL,
-  MOUTHS,
-  MOUTH_STROKE,
-  mouthFrame,
 }
 export type { Ring }
 
 /* ------------------------------------------------------------------- shape */
 
 export interface CursorSilhouette {
+  viewBox?: string
   /** Human-readable name, used for the accessible label. */
   name: string
   /** Transform mapping the artwork into the 228.541-unit face box. '' for none. */
@@ -477,204 +470,7 @@ export type CursorState =
  * Which expressions a state cycles through. The first is its resting face, chosen as the
  * pool's most forward-facing member so a mascot at rest looks at you rather than past you.
  */
-export const POOLS = {
-  sleeping: [
-    22,
-    13,
-    4
-  ],
-  waking: [
-    13
-  ],
-  idle: [
-    6,
-    0,
-    8
-  ],
-  listening: [
-    1,
-    10,
-    19
-  ],
-  thinking: [
-    17,
-    8,
-    16,
-    14,
-    5
-  ],
-  searching: [
-    20,
-    15,
-    9,
-    3,
-    12,
-    18
-  ],
-  working: [
-    10,
-    7,
-    16,
-    11
-  ],
-  excited: [
-    2,
-    17,
-    21,
-    3,
-    11
-  ],
-  surprised: [
-    21,
-    3
-  ],
-  suspicious: [
-    5,
-    14,
-    23
-  ],
-  angry: [
-    7,
-    16
-  ],
-  drowsy: [
-    22,
-    4,
-    13
-  ],
-  happy: [
-    19,
-    2,
-    11,
-    17
-  ],
-  curious: [
-    21,
-    3,
-    0,
-    15
-  ],
-  confused: [
-    8,
-    14,
-    5
-  ],
-  bored: [
-    0,
-    4,
-    22
-  ],
-  proud: [
-    2,
-    15,
-    8
-  ],
-  shy: [
-    24,
-    0,
-    13
-  ],
-  sad: [
-    22,
-    4,
-    13
-  ],
-  laughing: [
-    2,
-    11,
-    17
-  ],
-  scared: [
-    21,
-    3
-  ],
-  playful: [
-    2,
-    17,
-    11,
-    8
-  ],
-  celebrate: [
-    2,
-    8,
-    17
-  ],
-  orbit: [
-    6,
-    0,
-    8
-  ],
-  radar: [
-    6,
-    0,
-    8
-  ],
-  progress: [
-    6,
-    0,
-    8
-  ],
-  spawning: [
-    3,
-    0
-  ],
-  humming: [
-    6,
-    0,
-    8
-  ],
-  loading: [
-    6,
-    0,
-    8
-  ],
-  dictating: [
-    1,
-    10,
-    19
-  ],
-  sending: [
-    6,
-    0,
-    8
-  ],
-  receiving: [
-    19,
-    0,
-    8
-  ],
-  uploading: [
-    15,
-    9,
-    8
-  ],
-  writing: [
-    15,
-    9
-  ],
-  notifying: [
-    21,
-    3,
-    0
-  ],
-  alerting: [
-    21,
-    3
-  ],
-  bouncing: [
-    2,
-    17
-  ],
-  dragging: [
-    3,
-    15,
-    0
-  ],
-  "powering-down": [
-    22,
-    13
-  ]
-} satisfies Record<CursorState, number[]>
+export const POOLS: Record<CursorState, number[]> = Object.fromEntries(Object.entries(LAOA_DATA.pools).map(([key,values]) => [key,[...values]])) as Record<CursorState, number[]>
 
 /** How long a state holds an expression before drifting to another, in ms. */
 const EXPR_CADENCE = {
@@ -1013,7 +809,7 @@ const toPath = (ring: Ring) =>
 const clone = (rings: Ring[]): Ring[] =>
   rings.map(r => r.map((p): [number, number] => [p[0], p[1]]))
 
-/** Ring centroid — same computation cursor-face-data.ts uses for mouthFrame, needed here too for eye projection. */
+/** Ring centroid for eye projection. */
 const ringCentre = (ring: Ring): [number, number] => {
   let x = 0
   let y = 0
@@ -1024,23 +820,6 @@ const ringCentre = (ring: Ring): [number, number] => {
   return [x / ring.length, y / ring.length]
 }
 
-export function mouthPath(frame: { x: number; y: number; angle: number }, spec: number[]) {
-  const ca = Math.cos(frame.angle)
-  const sa = Math.sin(frame.angle)
-  const at = (lx: number, ly: number): [number, number] => [
-    frame.x + lx * ca - ly * sa,
-    frame.y + lx * sa + ly * ca,
-  ]
-  const a = at(-spec[0], 0)
-  const c = at(0, spec[1])
-  const b = at(spec[0], 0)
-  return (
-    'M' + a[0].toFixed(2) + ' ' + a[1].toFixed(2) +
-    ' Q' + c[0].toFixed(2) + ' ' + c[1].toFixed(2) +
-    ' ' + b[0].toFixed(2) + ' ' + b[1].toFixed(2)
-  )
-}
-
 /** Face-space transform placing the face inside a silhouette. */
 export const anchorTransform = (a: { x: number; y: number; scale: number }) =>
   `translate(${a.x} ${a.y}) scale(${a.scale}) translate(${-FACE_CENTRE[0]} ${-FACE_CENTRE[1]})`
@@ -1140,8 +919,8 @@ export interface CursorOptions {
 }
 type Engine = ReturnType<typeof newEngine>
 function newEngine() {
-  return { current: clone(EXPRESSIONS[0]), target: EXPRESSIONS[0], currentMouth: MOUTHS[0].slice(), targetMouth: MOUTHS[0], currentGaze: [...GAZE[0]], targetGaze: [...GAZE[0]], expression: 0, morph: 1, velocity: 0, blinkStart: noTimestamp(), spinStart: noTimestamp(), spinDuration: 900, last: 0, stateStart: 0, lastState: 'idle' as CursorState, lastBodyTransform: '',
-    props: {state: 'idle' as CursorState, expression: undefined as number | undefined, gaze: undefined as {x?:number;y?:number}|undefined, turn:0, spring:7, eyeScale:1, paused:true, lookAround:0, motionStrength:0, effects:true, glyphs:true} }
+  return { current: clone(EXPRESSIONS[0]), target: EXPRESSIONS[0], expression: 0, morph: 1, velocity: 0, blinkStart: noTimestamp(), spinStart: noTimestamp(), spinDuration: 900, last: 0, stateStart: 0, lastState: 'idle' as CursorState, lastBodyTransform: '',
+    props: {state: 'idle' as CursorState, expression: undefined as number | undefined, gaze: undefined as {x?:number;y?:number}|undefined, turn:0, spring:7, eyeScale:1, paused:true, motionStrength:0, effects:true, glyphs:true} }
 }
 let nextId = 0
 export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
@@ -1150,25 +929,18 @@ export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
   const paintRef = { current: initial.color }
   const node = <T extends Element>(id: string) => ({ current: svg.querySelector<T>(`[data-part="${id}"]`)! })
   svg.setAttribute('viewBox', VIEW_BOX)
-  svg.innerHTML = `<defs><clipPath id="${uid}-clip" data-part="clip" /></defs><g data-part="trails"/><g data-part="bodyGroup"><g data-part="bodyContent"><g data-part="outline"/><g clip-path="url(#${uid}-clip)"><g data-part="face"><path data-part="eye0" fill="white"/><path data-part="eye1" fill="white"/><path data-part="mouth" fill="none" stroke="white" stroke-width="${MOUTH_STROKE}" stroke-linecap="round"/></g></g></g><g data-part="glyph" opacity="0"/></g><g data-part="confetti"/>`
-  const eye0=node<SVGPathElement>('eye0'), eye1=node<SVGPathElement>('eye1'), mouth=node<SVGPathElement>('mouth')
+  svg.innerHTML = `<defs><clipPath id="${uid}-clip" data-part="clip" /></defs><g data-part="trails"/><g data-part="bodyGroup"><g data-part="bodyContent"><g data-part="outline"/><g clip-path="url(#${uid}-clip)"><g data-part="face"><path data-part="eye0" fill="white"/><path data-part="eye1" fill="white"/></g></g></g><g data-part="glyph" opacity="0"/></g><g data-part="confetti"/>`
+  const eye0=node<SVGPathElement>('eye0'), eye1=node<SVGPathElement>('eye1')
   const bodyGroup=node<SVGGElement>('bodyGroup'), bodyContent=node<SVGGElement>('bodyContent'), trailLayer=node<SVGGElement>('trails'), confettiLayer=node<SVGGElement>('confetti'), glyphLayer=node<SVGGElement>('glyph')
   const selectExpression = (index:number, immediate = false) => {
     const e=engine.current, i=((index % EXPRESSION_COUNT)+EXPRESSION_COUNT)%EXPRESSION_COUNT
     if (i === e.expression && e.morph >= 1 && !immediate) return
-    e.current=displayed(e);e.currentMouth=displayedMouth(e);e.currentGaze=displayedGaze(e)
-    e.target=EXPRESSIONS[i];e.targetMouth=MOUTHS[i];e.targetGaze=GAZE[i];e.expression=i;e.morph=immediate?1:0;e.velocity=0
+    e.current=displayed(e)
+    e.target=EXPRESSIONS[i];e.expression=i;e.morph=immediate?1:0;e.velocity=0
   }
       const draw = (e: Engine, now: number, spinTurn: number) => {
         const p = e.props
-        // Re-apply a fraction of this expression's own look-direction.
-        const g = displayedGaze(e)
-        const look = p.lookAround ?? 0.35
-        const ox = g[0] * look
-        const oy = g[1] * look
-        const rings = displayed(e).map(ring =>
-          ring.map((pt): [number, number] => [pt[0] + ox, pt[1] + oy])
-        )
+        const rings = displayed(e)
         const gx = clamp(p.gaze?.x ?? 0, -1, 1) * GAZE_TRAVEL.x
         const gy = clamp(p.gaze?.y ?? 0, -1, 1) * GAZE_TRAVEL.y
         const radians = (((p.turn ?? 0) + spinTurn) * Math.PI) / 180
@@ -1197,27 +969,6 @@ export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
           el.style.opacity = depth > 0.02 ? '1' : '0'
         })
 
-        // Mouth: same sphere projection as the eyes, but blinking never touches it.
-        const mouthEl = mouth.current
-        if (mouthEl) {
-          const spec = displayedMouth(e)
-          const frameGeom = mouthFrame(rings, spec)
-          const baseLongitude = Math.asin(clamp((frameGeom.x - SPHERE_C) / SPHERE_R, -1, 1))
-          const longitude = baseLongitude + radians
-          const depth = Math.cos(longitude)
-          const perspective = Math.max(depth, 0.02) / Math.max(Math.cos(baseLongitude), 0.02)
-          mouthEl.setAttribute('d', mouthPath(frameGeom, spec))
-          mouthEl.setAttribute(
-            'transform',
-            `translate(${(SPHERE_C + SPHERE_R * Math.sin(longitude) + gx).toFixed(2)} ${(
-              frameGeom.y + gy
-            ).toFixed(2)}) scale(${clamp(perspective, 0.02, 2.4).toFixed(4)} 1) translate(${(
-              -frameGeom.x
-            ).toFixed(2)} ${(-frameGeom.y).toFixed(2)})`
-          )
-          mouthEl.style.opacity = depth > 0.02 ? '1' : '0'
-        }
-
         // The body. One-shot entrances need time since the state began, so track that here
         // rather than in an effect — the loop already has the clock.
         const bodyEl = bodyGroup.current
@@ -1278,8 +1029,9 @@ export function mountCursorAvatar(svg: SVGSVGElement, initial: CursorOptions) {
     const changed=options.state!==next.state||options.expression!==next.expression
     const resume=options.paused!==next.paused||options.fixedTime!==next.fixedTime
     options=next
+    svg.setAttribute('viewBox', next.silhouette.viewBox ?? VIEW_BOX)
     const e=engine.current, now=performance.now();paintRef.current=next.color
-    e.props={...e.props,state:next.state,expression:next.expression,gaze:next.gaze,paused:next.paused,motionStrength:1}
+    e.props={...e.props,state:next.state,expression:next.expression,gaze:next.gaze,paused:next.paused,motionStrength:next.paused?0:1}
     const outline=node<SVGGElement>('outline').current, clip=node<SVGClipPathElement>('clip').current, face=node<SVGGElement>('face').current
     const body=next.silhouette.body.replace(/\{\{GRADIENT\}\}/g,next.color)
     if(lastBody!==body){outline.innerHTML=body;lastBody=body}
@@ -1305,16 +1057,6 @@ function displayed(e: { current: Ring[]; target: Ring[]; morph: number }): Ring[
   )
 }
 
-function displayedMouth(e: { currentMouth: number[]; targetMouth: number[]; morph: number }) {
-  const m = clamp(e.morph, 0, 1)
-  return e.currentMouth.map((v, i) => v + (e.targetMouth[i] - v) * m)
-}
-
-function displayedGaze(e: { currentGaze: number[]; targetGaze: number[]; morph: number }) {
-  const m = clamp(e.morph, 0, 1)
-  return e.currentGaze.map((v, i) => v + (e.targetGaze[i] - v) * m)
-}
-
 function blinkScale(e: { blinkStart: number | null }, now: number) {
   if (e.blinkStart === null) return 1
   const t = (now - e.blinkStart) / 320

+ 4 - 149
src/client/components/common/maus/cursor-face-data.ts

@@ -1,154 +1,9 @@
-// Adapted from OpenMausBot (3a84701), Apache-2.0. See THIRD_PARTY_NOTICES.md.
-/**
- * The mascot's face tables — 25 expressions x 2 eye rings x 48 points, a mouth
- * per expression, and the face-space constants every coordinate is expressed in.
- *
- * Split out of CursorAvatar.tsx so `scripts/gen-mascot-bodies.ts` can import it
- * from plain Node: the renderer is JSX and `--experimental-strip-types` cannot
- * load it, but the generator needs exactly these numbers to solve where a face
- * fits inside a new silhouette. The phone already draws this line in the same
- * place (MausFaceData.swift vs MausAvatar.swift).
- */
-
-/** The face box every coordinate in this file is expressed in. */
+// Original two-eye geometry from LaoA-GrokBot (MIT). See THIRD_PARTY_NOTICES.md.
+import { LAOA_DATA } from './laoa-data'
 export const FACE_BOX = 228.541
-
-/** Face-space centre the expressions are normalised around. */
 export const FACE_CENTRE: [number, number] = [120, 122.5]
-
-/** How far pointer gaze may pull the eyes, in face-space units. */
 export const GAZE_TRAVEL = { x: 13.2, y: 8.4 }
-
-/* ------------------------------------------------------------- expressions */
-
 export type Ring = [number, number][]
-
-/** 25 expressions × 2 eyes × 48 points, as "x,y x,y…|x,y x,y…". */
-const ENCODED: string[] = [
-  "130.36,45.98 132.71,46.19 134.98,46.81 137.11,47.83 138.97,49.28 140.47,51.09 141.68,53.12 142.73,55.23 143.76,57.36 144.78,59.49 145.79,61.62 146.79,63.76 147.76,65.91 148.71,68.07 149.63,70.25 150.52,72.43 151.37,74.63 151.99,76.91 152.1,79.26 151.64,81.57 150.59,83.68 149.04,85.45 147.1,86.78 144.9,87.62 142.56,87.93 140.22,87.71 137.98,86.99 135.93,85.82 134.17,84.24 132.78,82.34 131.69,80.25 130.77,78.08 129.87,75.89 128.94,73.72 128,71.56 127.03,69.4 126.05,67.26 125.05,65.12 124.03,62.99 122.93,60.9 121.87,58.79 121.03,56.59 120.72,54.26 121.1,51.93 122.15,49.83 123.75,48.1 125.76,46.89 128.01,46.19|176.61,37.08 178.72,37.59 180.7,38.48 182.52,39.65 184.2,41.03 185.71,42.59 187.03,44.31 188.2,46.14 189.26,48.03 190.27,49.96 191.26,51.89 192.23,53.84 193.16,55.8 194.05,57.78 194.92,59.77 195.74,61.78 196.53,63.8 197.27,65.84 197.97,67.9 198.47,70.01 198.63,72.18 198.4,74.33 197.58,76.33 195.95,77.72 193.83,78.08 191.71,77.65 189.76,76.69 188.03,75.38 186.53,73.82 185.28,72.05 184.25,70.13 183.4,68.14 182.63,66.11 181.87,64.07 181.07,62.05 180.25,60.04 179.39,58.05 178.49,56.07 177.57,54.1 176.61,52.15 175.62,50.22 174.59,48.31 173.53,46.41 172.54,44.48 171.86,42.42 171.76,40.26 172.62,38.3 174.45,37.19",
-  "86.14,124.79 89.16,124.92 92.06,125.76 94.68,127.27 96.86,129.36 98.48,131.92 99.51,134.76 100.12,137.73 100.56,140.72 101.01,143.72 101.52,146.71 102.06,149.69 102.62,152.67 103.2,155.64 103.81,158.61 104.44,161.57 105.11,164.52 105.81,167.47 106.49,170.42 106.72,173.44 106.01,176.37 104.37,178.9 102.03,180.81 99.26,181.99 96.27,182.46 93.25,182.25 90.34,181.43 87.65,180.04 85.32,178.11 83.51,175.7 82.37,172.9 81.66,169.96 80.98,167 80.32,164.05 79.69,161.08 79.08,158.12 78.5,155.14 77.94,152.16 77.4,149.18 76.89,146.2 76.4,143.21 75.93,140.21 75.56,137.21 75.7,134.19 76.6,131.3 78.22,128.76 80.47,126.74 83.18,125.4|147.23,112.87 150.24,113.13 153.07,114.19 155.53,115.94 157.46,118.26 158.73,121.01 159.53,123.93 160.21,126.88 160.88,129.84 161.52,132.8 162.15,135.76 162.74,138.73 163.32,141.71 163.87,144.69 164.39,147.67 164.9,150.66 165.39,153.65 165.8,156.65 165.76,159.67 164.98,162.59 163.56,165.26 161.61,167.58 159.23,169.45 156.52,170.77 153.57,171.46 150.55,171.4 147.67,170.52 145.19,168.79 143.45,166.33 142.57,163.44 142.08,160.45 141.61,157.46 141.11,154.47 140.59,151.48 140.04,148.5 139.47,145.53 138.87,142.56 138.25,139.59 137.6,136.63 136.94,133.68 136.25,130.72 135.59,127.77 135.47,124.75 136.07,121.78 137.34,119.04 139.19,116.65 141.53,114.74 144.26,113.44",
-  "104.84,104.08 109.39,104.89 113.63,106.73 117.32,109.5 120.25,113.08 122.22,117.25 123.15,121.77 122.92,126.39 121.86,130.89 120.61,135.35 119.37,139.81 118.15,144.27 116.94,148.74 115.74,153.21 114.56,157.69 113.39,162.17 112.24,166.65 111.13,171.15 110.04,175.65 108.89,180.13 106.98,184.33 103.8,187.67 99.75,189.87 95.25,190.93 90.63,190.93 86.11,189.97 81.86,188.16 78.05,185.54 74.89,182.18 72.62,178.16 71.54,173.67 71.94,169.08 73.01,164.57 74.13,160.08 75.26,155.59 76.42,151.11 77.59,146.63 78.77,142.15 79.97,137.68 81.18,133.22 82.41,128.75 83.65,124.29 84.87,119.83 86.33,115.43 88.67,111.46 91.9,108.15 95.83,105.72 100.23,104.35|174.26,115.55 178.59,116.54 182.37,118.86 185.31,122.2 187.23,126.21 188.12,130.57 188.06,135.02 187.52,139.44 186.86,143.85 186.11,148.25 185.25,152.62 184.3,156.98 183.24,161.31 182.07,165.61 180.79,169.88 179.39,174.12 177.89,178.31 176.27,182.47 174.47,186.55 172.17,190.35 169.21,193.68 165.73,196.46 161.84,198.64 157.66,200.16 153.27,200.91 148.83,200.69 144.65,199.19 141.47,196.14 140.18,191.91 140.82,187.53 142.46,183.38 144.16,179.26 145.73,175.09 147.18,170.87 148.51,166.62 149.73,162.33 150.84,158.01 151.84,153.67 152.74,149.3 153.54,144.91 154.24,140.51 154.85,136.1 155.58,131.7 156.93,127.46 159.15,123.6 162.12,120.29 165.73,117.68 169.84,116",
-  "44.72,103.35 47.95,103.93 51.04,105.05 53.93,106.62 56.58,108.57 58.97,110.83 61.09,113.35 62.93,116.07 64.5,118.97 65.79,121.99 66.8,125.13 67.52,128.34 67.94,131.6 68.08,134.89 67.9,138.17 67.4,141.42 66.56,144.6 65.33,147.66 63.71,150.52 61.69,153.11 59.3,155.36 56.54,157.16 53.51,158.42 50.29,159.07 47,159.09 43.76,158.5 40.67,157.39 37.78,155.82 35.13,153.87 32.75,151.6 30.63,149.09 28.78,146.36 27.21,143.47 25.92,140.44 24.92,137.31 24.2,134.1 23.77,130.84 23.64,127.55 23.81,124.27 24.31,121.01 25.16,117.84 26.38,114.78 28,111.92 30.02,109.33 32.42,107.08 35.17,105.28 38.21,104.02 41.43,103.37|114.62,83.64 118.31,83.88 121.94,84.6 125.44,85.77 128.77,87.38 131.86,89.4 134.67,91.81 137.13,94.56 139.2,97.62 140.85,100.93 142.05,104.42 142.79,108.05 143.05,111.73 142.83,115.42 142.13,119.05 140.95,122.55 139.34,125.88 137.3,128.96 134.88,131.75 132.11,134.2 129.05,136.27 125.75,137.93 122.25,139.13 118.63,139.86 114.94,140.1 111.25,139.86 107.63,139.14 104.12,137.97 100.8,136.36 97.7,134.34 94.9,131.93 92.44,129.18 90.36,126.12 88.72,122.81 87.51,119.31 86.78,115.69 86.52,112.01 86.74,108.32 87.44,104.69 88.61,101.18 90.23,97.86 92.27,94.78 94.69,91.99 97.45,89.54 100.51,87.47 103.82,85.81 107.31,84.61 110.94,83.88",
-  "90.44,99.25 93.11,99.26 95.74,99.79 98.37,100.3 101,100.82 103.64,101.33 106.27,101.85 108.9,102.37 111.53,102.89 114.16,103.41 116.79,103.94 119.42,104.47 122.06,105 124.69,105.53 127.32,106.06 129.94,106.6 132.57,107.14 135.2,107.68 137.65,108.73 139.5,110.65 140.44,113.14 140.36,115.81 139.35,118.28 137.48,120.18 135,121.14 132.32,121.11 129.7,120.56 127.08,120 124.45,119.46 121.82,118.93 119.19,118.4 116.56,117.87 113.93,117.35 111.29,116.83 108.66,116.32 106.02,115.82 103.39,115.31 100.76,114.79 98.12,114.28 95.49,113.75 92.86,113.22 90.23,112.68 87.77,111.66 85.93,109.74 85.01,107.24 85.06,104.57 86.07,102.1 87.96,100.22|156.6,112.56 158.91,112.66 161.18,113.15 163.45,113.64 165.72,114.13 167.99,114.62 170.26,115.12 172.53,115.62 174.8,116.12 177.07,116.62 179.34,117.13 181.6,117.64 183.87,118.15 186.14,118.67 188.4,119.18 190.67,119.7 192.94,120.2 195.12,120.96 196.7,122.63 197.45,124.82 197.53,127.14 197.07,129.41 196.08,131.51 194.49,133.18 192.33,133.97 190.04,133.68 187.79,133.09 185.52,132.57 183.26,132.05 180.99,131.54 178.72,131.03 176.45,130.53 174.18,130.03 171.91,129.54 169.64,129.05 167.37,128.56 165.1,128.07 162.83,127.58 160.56,127.08 158.29,126.57 156.02,126.05 153.9,125.14 152.26,123.51 151.32,121.4 151.08,119.1 151.53,116.82 152.65,114.8 154.41,113.31",
-  "79.94,130.44 83.08,130.57 86.11,131.41 88.89,132.89 91.27,134.94 93.15,137.46 94.39,140.35 95.11,143.42 95.65,146.52 96.2,149.62 96.78,152.72 97.39,155.81 98.04,158.89 98.72,161.97 99.43,165.04 100.18,168.1 100.97,171.15 101.81,174.18 102.64,177.22 103.01,180.34 102.35,183.4 100.66,186.04 98.19,187.98 95.28,189.16 92.17,189.6 89.03,189.37 85.99,188.56 83.16,187.2 80.63,185.33 78.55,182.97 77.05,180.21 76.1,177.21 75.28,174.17 74.5,171.11 73.74,168.06 73.02,164.99 72.33,161.92 71.68,158.83 71.06,155.75 70.47,152.65 69.91,149.55 69.37,146.45 68.97,143.32 69.12,140.18 70.04,137.18 71.7,134.51 74.03,132.41 76.86,131.04|168.95,136.28 171.31,136.97 172.99,138.76 173.66,141.13 173.36,143.58 172.24,145.79 170.45,147.49 168.22,148.58 165.89,149.43 163.57,150.31 161.24,151.18 158.9,152.02 156.56,152.85 154.21,153.65 151.85,154.44 149.48,155.2 147.11,155.94 144.73,156.65 142.34,157.34 139.95,158.01 137.55,158.66 135.14,159.27 132.73,159.86 130.31,160.43 127.84,160.54 125.53,159.69 123.77,157.96 122.89,155.66 123.09,153.21 124.36,151.09 126.34,149.61 128.69,148.83 131.1,148.24 133.51,147.62 135.91,146.98 138.31,146.32 140.7,145.65 143.08,144.95 145.46,144.23 147.83,143.49 150.2,142.73 152.56,141.95 154.91,141.15 157.25,140.32 159.59,139.48 161.92,138.61 164.24,137.72 166.54,136.78",
-  "119.79,105.35 121.94,105.42 124.03,105.87 126,106.72 127.77,107.93 129.27,109.47 130.45,111.26 131.29,113.23 131.73,115.33 131.79,117.47 131.53,119.6 131.11,121.71 130.66,123.81 130.27,125.92 129.88,128.03 129.49,130.14 129.07,132.25 128.62,134.35 127.96,136.39 126.96,138.29 125.62,139.96 123.99,141.36 122.13,142.43 120.11,143.15 118,143.5 115.85,143.45 113.75,143 111.79,142.15 110.03,140.93 108.54,139.38 107.36,137.59 106.53,135.61 106.1,133.51 106.07,131.37 106.35,129.24 106.77,127.14 107.22,125.04 107.64,122.93 108.05,120.82 108.45,118.71 108.84,116.6 109.25,114.49 109.86,112.44 110.83,110.52 112.16,108.84 113.78,107.44 115.65,106.38 117.67,105.68|174.73,114.1 176.71,114.59 178.5,115.55 180.04,116.88 181.27,118.5 182.17,120.33 182.71,122.29 182.9,124.32 182.82,126.36 182.56,128.38 182.23,130.39 181.89,132.4 181.53,134.41 181.16,136.41 180.75,138.41 180.3,140.4 179.82,142.38 179.18,144.31 178.28,146.15 177.14,147.83 175.74,149.31 174.11,150.53 172.29,151.44 170.32,151.97 168.29,152.07 166.29,151.69 164.45,150.83 162.87,149.54 161.66,147.91 160.83,146.06 160.38,144.07 160.29,142.04 160.53,140.01 160.98,138.02 161.43,136.03 161.84,134.04 162.24,132.04 162.62,130.03 162.93,128.02 163.24,126 163.6,124 164.11,122.03 164.91,120.15 165.99,118.43 167.35,116.91 168.94,115.64 170.74,114.69 172.7,114.15",
-  "105.72,72.58 108.82,72.78 111.75,73.82 114.27,75.64 116.39,77.92 118.42,80.29 120.45,82.66 122.47,85.03 124.48,87.41 126.49,89.8 128.48,92.2 130.47,94.6 132.45,97.01 134.43,99.42 136.4,101.83 138.37,104.25 140.3,106.7 141.85,109.39 142.65,112.4 142.63,115.51 141.8,118.5 140.21,121.17 137.95,123.31 135.17,124.7 132.11,125.26 129.02,124.98 126.12,123.86 123.67,121.94 121.61,119.6 119.66,117.17 117.69,114.75 115.73,112.33 113.75,109.92 111.77,107.52 109.77,105.12 107.77,102.73 105.76,100.34 103.75,97.96 101.72,95.59 99.68,93.23 97.63,90.89 95.91,88.3 94.95,85.34 94.92,82.24 95.84,79.27 97.56,76.68 99.9,74.63 102.67,73.22|176.73,63.75 179.52,64.43 182.02,65.85 184.15,67.78 185.85,70.1 187.08,72.7 187.76,75.5 187.76,78.37 187.23,81.2 186.58,84.01 185.89,86.8 185.16,89.59 184.39,92.36 183.59,95.13 182.76,97.89 181.88,100.64 180.98,103.37 180.02,106.09 179.03,108.79 177.99,111.48 176.9,114.15 175.5,116.66 173.51,118.72 171,120.11 168.18,120.66 165.34,120.29 162.75,119.04 160.62,117.12 159.02,114.73 158,112.04 157.63,109.19 157.98,106.34 158.83,103.59 159.86,100.9 160.87,98.2 161.85,95.49 162.79,92.76 163.7,90.03 164.57,87.29 165.41,84.53 166.21,81.76 166.98,78.99 167.7,76.2 168.38,73.4 169,70.58 169.79,67.82 171.41,65.46 173.88,64.03",
-  "64.12,83.43 66.69,84.08 68.9,85.53 70.57,87.6 71.56,90.05 71.86,92.69 71.53,95.32 70.55,97.78 69.06,99.99 67.36,102.03 65.64,104.06 63.95,106.11 62.28,108.18 60.63,110.27 59,112.38 57.41,114.5 55.84,116.65 54.29,118.82 52.78,121 51.31,123.22 49.86,125.45 48.42,127.69 46.67,129.68 44.36,130.96 41.73,131.22 39.22,130.38 37.16,128.72 35.64,126.55 34.65,124.08 34.2,121.46 34.29,118.81 34.94,116.24 36.12,113.86 37.54,111.61 39.04,109.41 40.54,107.21 42.06,105.03 43.61,102.87 45.18,100.73 46.79,98.6 48.41,96.5 50.07,94.42 51.74,92.35 53.45,90.31 55.17,88.28 56.94,86.3 59.04,84.67 61.48,83.65|104.23,93.09 106.97,93.77 109.4,95.24 111.32,97.31 112.59,99.85 113.4,102.57 114.13,105.32 114.87,108.06 115.62,110.8 116.37,113.54 117.14,116.28 117.9,119.02 118.67,121.75 119.45,124.49 120.23,127.22 121.03,129.95 121.83,132.68 122.6,135.41 122.98,138.22 122.57,141.02 121.34,143.57 119.44,145.67 117.05,147.19 114.34,148.04 111.51,148.14 108.77,147.45 106.33,146.01 104.38,143.96 103.07,141.44 102.21,138.73 101.43,136 100.65,133.27 99.87,130.53 99.09,127.8 98.32,125.06 97.55,122.33 96.79,119.59 96.04,116.85 95.28,114.11 94.53,111.37 93.79,108.62 93.05,105.88 92.73,103.07 93.18,100.27 94.42,97.72 96.32,95.62 98.7,94.08 101.4,93.21",
-  "108.14,135.9 111.13,136.06 114.08,136.56 116.95,137.39 119.71,138.55 122.31,140.03 124.71,141.81 126.86,143.89 128.73,146.23 130.27,148.8 131.44,151.55 132.22,154.44 132.56,157.41 132.47,160.4 131.93,163.34 130.96,166.17 129.6,168.83 127.87,171.28 125.83,173.46 123.51,175.35 120.96,176.91 118.23,178.13 115.36,179 112.42,179.52 109.43,179.7 106.44,179.54 103.49,179.04 100.62,178.21 97.86,177.06 95.26,175.58 92.86,173.79 90.71,171.71 88.84,169.37 87.3,166.81 86.13,164.06 85.35,161.17 85.01,158.2 85.11,155.21 85.64,152.26 86.61,149.43 87.97,146.77 89.7,144.33 91.74,142.14 94.06,140.25 96.61,138.69 99.34,137.47 102.21,136.6 105.15,136.08|175.67,128.81 177.16,128.85 178.62,129.14 180.01,129.67 181.28,130.44 182.41,131.42 183.36,132.57 184.12,133.85 184.68,135.23 185.05,136.68 185.24,138.15 185.27,139.64 185.13,141.13 184.85,142.59 184.42,144.02 183.85,145.4 183.15,146.72 182.33,147.96 181.38,149.11 180.32,150.16 179.15,151.08 177.88,151.87 176.53,152.49 175.1,152.91 173.62,153.11 172.13,153.07 170.67,152.78 169.28,152.24 168.01,151.47 166.88,150.49 165.93,149.35 165.18,148.06 164.61,146.68 164.24,145.24 164.05,143.76 164.02,142.27 164.16,140.78 164.44,139.32 164.87,137.89 165.44,136.51 166.14,135.2 166.96,133.95 167.91,132.8 168.97,131.75 170.14,130.83 171.4,130.04 172.76,129.43 174.19,129.01",
-  "103.39,78.25 106.56,78.38 109.59,79.28 112.29,80.94 114.43,83.28 115.86,86.1 116.52,89.2 116.32,92.37 115.62,95.46 114.86,98.55 114.1,101.63 113.36,104.72 112.63,107.81 111.91,110.91 111.2,114.01 110.51,117.1 109.82,120.21 109.16,123.31 108.52,126.42 107.84,129.53 106.8,132.52 105.04,135.16 102.66,137.25 99.81,138.63 96.7,139.2 93.54,138.96 90.53,137.95 87.87,136.24 85.71,133.92 84.21,131.13 83.48,128.05 83.5,124.88 84,121.74 84.7,118.64 85.4,115.54 86.08,112.44 86.77,109.34 87.48,106.24 88.19,103.15 88.92,100.05 89.66,96.96 90.41,93.88 91.18,90.8 91.95,87.71 93.1,84.76 94.95,82.19 97.4,80.19 100.28,78.86|161.99,91.57 165.07,92 167.9,93.28 170.32,95.23 172.22,97.69 173.49,100.53 174.05,103.59 173.93,106.7 173.45,109.78 172.9,112.85 172.33,115.91 171.71,118.97 171.06,122.02 170.38,125.06 169.66,128.09 168.91,131.12 168.13,134.14 167.33,137.15 166.5,140.16 165.58,143.14 164.33,145.98 162.5,148.5 160.15,150.53 157.38,151.95 154.34,152.61 151.24,152.43 148.32,151.38 145.83,149.52 143.99,147.01 142.95,144.08 142.73,140.98 143.25,137.91 144.09,134.91 144.94,131.91 145.76,128.9 146.54,125.89 147.29,122.86 148.01,119.83 148.69,116.79 149.34,113.74 149.95,110.68 150.54,107.62 151.09,104.55 151.6,101.47 152.44,98.48 154,95.79 156.21,93.6 158.94,92.12",
-  "47.23,91.05 51.61,92.47 55.26,95.29 57.96,99.03 59.7,103.31 60.56,107.85 61.28,112.42 62.11,116.97 63.06,121.51 64.11,126.01 65.28,130.49 66.56,134.94 67.95,139.35 69.44,143.73 71.05,148.07 72.77,152.37 74.62,156.61 76.52,160.83 78.06,165.19 78.52,169.78 77.62,174.3 75.2,178.22 71.44,180.86 66.96,181.91 62.36,181.55 57.99,180.06 54.03,177.68 50.59,174.58 47.77,170.92 45.62,166.83 43.74,162.6 41.96,158.33 40.29,154.01 38.72,149.66 37.27,145.26 35.93,140.83 34.69,136.37 33.56,131.88 32.55,127.37 31.65,122.83 30.85,118.27 30.2,113.69 30.08,109.07 30.85,104.51 32.49,100.19 35.01,96.32 38.44,93.24 42.64,91.35|108.93,73.38 113.76,73.6 118.38,75.01 122.49,77.56 125.82,81.07 128.1,85.34 129.65,89.93 131.04,94.58 132.43,99.22 133.82,103.87 135.22,108.51 136.6,113.16 137.99,117.81 139.36,122.46 140.73,127.11 142.13,131.76 143.51,136.41 144.82,141.08 145.51,145.86 144.91,150.66 143.09,155.14 140.17,158.99 136.38,162.01 132,164.05 127.26,165.03 122.42,164.86 117.79,163.48 113.67,160.95 110.36,157.43 108.17,153.11 106.72,148.49 105.36,143.83 103.99,139.18 102.62,134.53 101.24,129.88 99.87,125.23 98.48,120.58 97.09,115.93 95.7,111.29 94.29,106.64 92.89,102 91.49,97.36 90.81,92.57 91.38,87.77 93.14,83.26 96.02,79.37 99.79,76.34 104.18,74.32",
-  "102.86,129.56 106.39,129.96 109.85,130.76 113.21,131.94 116.4,133.5 119.39,135.42 122.13,137.69 124.56,140.28 126.64,143.16 128.33,146.29 129.59,149.61 130.37,153.08 130.66,156.62 130.43,160.16 129.68,163.63 128.42,166.96 126.7,170.07 124.54,172.89 122.02,175.39 119.18,177.52 116.07,179.25 112.76,180.54 109.31,181.4 105.78,181.82 102.23,181.83 98.7,181.42 95.24,180.62 91.88,179.44 88.69,177.88 85.7,175.96 82.96,173.69 80.53,171.1 78.45,168.22 76.76,165.1 75.5,161.77 74.72,158.31 74.44,154.77 74.67,151.22 75.41,147.75 76.67,144.42 78.39,141.32 80.55,138.49 83.07,135.99 85.91,133.86 89.02,132.14 92.33,130.84 95.78,129.99 99.31,129.56|180.44,141.06 183.61,141.1 186.72,141.68 189.66,142.86 192.28,144.63 194.43,146.96 196,149.71 197,152.72 197.48,155.86 197.51,159.03 197.15,162.18 196.43,165.27 195.38,168.26 194.07,171.15 192.52,173.92 190.74,176.55 188.77,179.03 186.6,181.35 184.25,183.48 181.74,185.41 179.06,187.12 176.23,188.56 173.28,189.71 170.21,190.52 167.07,190.95 163.9,190.91 160.78,190.33 157.84,189.15 155.22,187.37 153.08,185.04 151.51,182.29 150.51,179.28 150.03,176.15 150,172.98 150.36,169.83 151.07,166.74 152.12,163.74 153.43,160.85 154.99,158.09 156.76,155.46 158.74,152.98 160.91,150.66 163.25,148.53 165.77,146.59 168.45,144.89 171.27,143.45 174.23,142.3 177.29,141.48",
-  "86.02,131.33 88.42,131.69 90.54,132.87 92.12,134.71 93.03,136.97 93.17,139.39 92.41,141.69 90.78,143.48 88.59,144.52 86.19,144.97 83.78,145.34 81.37,145.72 78.96,146.09 76.54,146.45 74.13,146.81 71.72,147.16 69.3,147.5 66.88,147.84 64.47,148.16 62.05,148.47 59.62,148.76 57.2,149.03 54.77,149.27 52.34,149.49 49.91,149.68 47.53,149.28 45.52,147.92 44.04,145.99 43.12,143.74 42.85,141.32 43.44,138.97 45.04,137.17 47.33,136.4 49.76,136.2 52.19,135.96 54.62,135.7 57.04,135.43 59.46,135.15 61.89,134.85 64.31,134.54 66.72,134.22 69.14,133.89 71.56,133.54 73.97,133.19 76.38,132.83 78.79,132.45 81.2,132.06 83.61,131.67|153.42,116.84 155.92,117.69 157.78,119.56 158.72,122.03 158.73,124.68 157.84,127.18 156.08,129.15 153.71,130.33 151.13,131 148.55,131.65 145.98,132.32 143.4,132.98 140.82,133.63 138.23,134.27 135.65,134.9 133.06,135.53 130.47,136.15 127.88,136.76 125.29,137.36 122.69,137.94 120.09,138.51 117.49,139.06 114.88,139.59 112.27,140.1 109.62,140.12 107.17,139.14 105.28,137.29 104.18,134.88 104.06,132.23 105.03,129.78 106.92,127.93 109.35,126.87 111.96,126.34 114.57,125.8 117.17,125.24 119.77,124.67 122.37,124.09 124.96,123.5 127.56,122.9 130.15,122.29 132.74,121.68 135.32,121.05 137.91,120.41 140.49,119.76 143.07,119.11 145.65,118.44 148.22,117.76 150.78,117.03",
-  "83.36,65.84 86.61,66.42 89.57,67.88 92,70.12 93.67,72.97 94.46,76.17 94.31,79.47 93.61,82.71 92.8,85.92 92.02,89.14 91.27,92.36 90.56,95.6 89.88,98.84 89.23,102.09 88.62,105.34 88.03,108.61 87.51,111.88 87.01,115.15 86.5,118.42 85.65,121.62 84.11,124.54 81.9,126.99 79.13,128.79 75.98,129.8 72.68,129.91 69.47,129.13 66.6,127.49 64.24,125.18 62.49,122.38 61.42,119.25 61.08,115.97 61.36,112.67 61.87,109.4 62.45,106.13 63.01,102.87 63.61,99.61 64.24,96.36 64.9,93.11 65.6,89.87 66.33,86.64 67.09,83.42 67.89,80.2 68.73,77 69.89,73.9 71.7,71.14 74.1,68.86 76.93,67.16 80.07,66.12|119.49,99.16 121.99,99.28 124.44,99.82 126.9,100.34 129.36,100.86 131.81,101.38 134.27,101.91 136.72,102.44 139.17,102.98 141.62,103.52 144.08,104.07 146.52,104.62 148.97,105.18 151.42,105.74 153.86,106.31 156.31,106.89 158.75,107.47 161.2,108.02 163.49,109.03 165.16,110.87 165.95,113.24 165.87,115.74 164.94,118.05 163.17,119.8 160.79,120.55 158.31,120.29 155.88,119.65 153.44,119.06 150.99,118.49 148.55,117.92 146.1,117.36 143.65,116.81 141.2,116.26 138.75,115.73 136.29,115.2 133.84,114.67 131.38,114.15 128.93,113.62 126.47,113.1 124.02,112.57 121.56,112.04 119.11,111.5 116.81,110.53 115.08,108.73 114.22,106.39 114.29,103.9 115.28,101.61 117.12,99.93",
-  "168.71,85.67 170.73,86 172.64,86.75 174.37,87.85 175.88,89.24 177.11,90.88 178.04,92.71 178.71,94.65 179.21,96.64 179.64,98.65 180.05,100.66 180.45,102.67 180.83,104.69 181.18,106.71 181.5,108.74 181.77,110.78 181.88,112.82 181.71,114.87 181.22,116.86 180.41,118.74 179.27,120.45 177.83,121.91 176.12,123.03 174.2,123.74 172.16,123.99 170.13,123.75 168.21,123.04 166.49,121.93 165.03,120.48 163.88,118.79 163.02,116.93 162.49,114.95 162.17,112.92 161.85,110.89 161.51,108.87 161.14,106.84 160.77,104.83 160.37,102.81 159.96,100.8 159.52,98.79 159.24,96.76 159.3,94.71 159.71,92.7 160.48,90.8 161.58,89.08 163.01,87.61 164.73,86.49 166.67,85.83|206.57,78.24 208.3,78.8 209.77,79.87 210.99,81.22 212.01,82.73 212.85,84.34 213.53,86.03 214.08,87.77 214.54,89.54 214.95,91.31 215.34,93.09 215.7,94.88 216.04,96.67 216.36,98.46 216.64,100.26 216.89,102.07 217.11,103.88 217.25,105.7 217.26,107.52 217.1,109.33 216.75,111.12 216.16,112.84 215.3,114.44 214.04,115.75 212.34,116.35 210.58,115.96 209.11,114.89 207.97,113.48 207.1,111.88 206.44,110.18 205.97,108.42 205.62,106.63 205.35,104.83 205.11,103.02 204.85,101.22 204.55,99.42 204.22,97.63 203.87,95.84 203.49,94.05 203.09,92.28 202.67,90.5 202.32,88.71 202.1,86.91 202.1,85.08 202.32,83.28 202.77,81.51 203.54,79.86 204.82,78.59",
-  "39.78,104.3 42.64,105.01 45.03,106.74 46.73,109.15 47.75,111.93 48.35,114.83 48.81,117.76 49.31,120.68 49.87,123.6 50.48,126.5 51.15,129.39 51.86,132.27 52.63,135.13 53.44,137.98 54.3,140.82 55.19,143.65 56.13,146.46 57.1,149.26 58.1,152.05 58.86,154.92 58.96,157.87 58.18,160.72 56.43,163.09 53.84,164.48 50.9,164.62 48.08,163.75 45.58,162.16 43.51,160.05 41.89,157.57 40.67,154.87 39.67,152.08 38.73,149.26 37.81,146.44 36.94,143.61 36.11,140.76 35.34,137.9 34.61,135.03 33.92,132.14 33.28,129.24 32.7,126.34 32.16,123.42 31.67,120.5 31.23,117.57 31.14,114.61 31.65,111.69 32.74,108.94 34.47,106.54 36.89,104.86|108.97,125.73 111.9,126.2 114.63,127.37 117,129.16 118.84,131.49 119.99,134.23 120.32,137.18 119.85,140.11 118.59,142.8 116.78,145.16 114.84,147.42 112.89,149.67 110.93,151.92 108.97,154.16 107.01,156.39 105.03,158.62 103.05,160.85 101.07,163.06 99.07,165.28 97.09,167.5 95.09,169.71 93.05,171.88 90.69,173.67 87.89,174.66 84.93,174.81 82.02,174.22 79.31,173 76.92,171.24 74.99,168.98 73.7,166.3 73.26,163.37 73.74,160.45 75.2,157.86 77.17,155.63 79.18,153.43 81.18,151.23 83.17,149.02 85.16,146.8 87.14,144.58 89.11,142.35 91.08,140.11 93.04,137.87 95,135.63 96.95,133.38 98.89,131.12 100.87,128.89 103.25,127.12 106.02,126.04",
-  "115.43,87.59 118.19,88.24 120.69,89.59 122.69,91.59 124.03,94.09 124.63,96.87 124.41,99.7 123.7,102.45 122.9,105.18 122.12,107.92 121.34,110.65 120.57,113.39 119.79,116.13 119.02,118.87 118.25,121.61 117.48,124.35 116.72,127.09 115.97,129.83 115.23,132.58 114.45,135.32 113.3,137.91 111.49,140.09 109.13,141.65 106.41,142.48 103.58,142.56 100.82,141.91 98.33,140.54 96.33,138.53 94.99,136.03 94.38,133.26 94.57,130.43 95.25,127.67 96.02,124.93 96.79,122.19 97.55,119.45 98.31,116.7 99.08,113.96 99.85,111.22 100.62,108.49 101.4,105.75 102.18,103.01 102.95,100.27 103.74,97.54 104.51,94.8 105.68,92.21 107.5,90.04 109.88,88.5 112.59,87.67|155.37,78.64 158.04,79.15 160.48,80.37 162.53,82.16 164.33,84.22 166.1,86.3 167.85,88.4 169.58,90.51 171.29,92.64 172.98,94.78 174.65,96.94 176.3,99.12 177.92,101.32 179.51,103.54 181.06,105.79 182.57,108.07 184.03,110.37 185.2,112.83 185.77,115.5 185.73,118.23 185.07,120.87 183.8,123.28 181.94,125.26 179.57,126.6 176.89,127.03 174.24,126.43 171.99,124.92 170.26,122.8 168.75,120.53 167.22,118.27 165.66,116.03 164.07,113.81 162.45,111.6 160.82,109.41 159.16,107.25 157.47,105.1 155.76,102.97 154.03,100.85 152.28,98.76 150.51,96.68 148.74,94.6 147.29,92.29 146.49,89.69 146.36,86.97 146.94,84.31 148.23,81.91 150.19,80.02 152.66,78.89",
-  "92.71,76.24 95.76,76.59 98.73,77.35 101.56,78.53 104.21,80.08 106.63,81.96 108.78,84.16 110.61,86.62 112.09,89.31 113.19,92.17 113.91,95.15 114.24,98.2 114.18,101.27 113.73,104.31 112.9,107.26 111.7,110.09 110.15,112.73 108.28,115.17 106.12,117.35 103.71,119.24 101.08,120.83 98.27,122.07 95.33,122.95 92.3,123.44 89.23,123.51 86.18,123.17 83.21,122.41 80.38,121.23 77.73,119.69 75.31,117.8 73.16,115.61 71.33,113.14 69.86,110.45 68.75,107.59 68.02,104.61 67.7,101.56 67.76,98.49 68.22,95.46 69.05,92.5 70.25,89.68 71.8,87.03 73.67,84.59 75.83,82.41 78.24,80.52 80.87,78.93 83.67,77.69 86.61,76.81 89.64,76.32|157.73,102.08 159.31,102.13 160.86,102.42 162.35,102.95 163.74,103.69 165.01,104.62 166.15,105.72 167.13,106.95 167.96,108.29 168.63,109.72 169.13,111.22 169.45,112.76 169.59,114.34 169.55,115.91 169.31,117.47 168.88,118.99 168.28,120.45 167.52,121.83 166.58,123.1 165.48,124.23 164.25,125.22 162.89,126.02 161.43,126.63 159.91,127.02 158.34,127.2 156.76,127.14 155.21,126.85 153.73,126.33 152.33,125.59 151.06,124.65 149.93,123.56 148.94,122.33 148.11,120.99 147.44,119.56 146.95,118.06 146.62,116.51 146.48,114.94 146.52,113.37 146.76,111.8 147.18,110.28 147.78,108.83 148.56,107.45 149.5,106.18 150.59,105.05 151.83,104.07 153.18,103.26 154.64,102.65 156.17,102.26",
-  "57.98,112.03 61.01,112.26 63.82,113.41 66.22,115.27 68.09,117.67 69.36,120.44 70.14,123.39 70.64,126.39 71.07,129.41 71.58,132.42 72.15,135.41 72.76,138.4 73.4,141.38 74.09,144.35 74.82,147.31 75.59,150.26 76.42,153.2 77.3,156.12 78.16,159.04 78.68,162.04 78.42,165.07 77.28,167.88 75.25,170.13 72.54,171.49 69.52,171.87 66.51,171.44 63.69,170.32 61.16,168.61 59.03,166.44 57.38,163.89 56.23,161.06 55.34,158.15 54.53,155.21 53.73,152.27 52.97,149.31 52.25,146.35 51.57,143.38 50.93,140.4 50.34,137.41 49.78,134.41 49.26,131.4 48.78,128.39 48.37,125.37 48.37,122.33 49.06,119.36 50.44,116.65 52.47,114.39 55.04,112.78|117.45,101.46 120.57,101.84 123.48,103.02 125.99,104.91 127.97,107.35 129.25,110.23 130.05,113.27 130.76,116.34 131.46,119.41 132.15,122.49 132.85,125.56 133.55,128.63 134.24,131.71 134.93,134.78 135.61,137.86 136.32,140.93 137.01,144 137.66,147.09 137.87,150.22 137.25,153.3 135.83,156.1 133.73,158.45 131.15,160.23 128.22,161.38 125.11,161.83 121.98,161.5 119.05,160.37 116.52,158.51 114.57,156.05 113.37,153.14 112.64,150.08 111.95,147 111.27,143.93 110.58,140.85 109.89,137.78 109.2,134.7 108.51,131.63 107.81,128.56 107.12,125.48 106.42,122.41 105.71,119.34 105.02,116.27 104.83,113.13 105.42,110.04 106.79,107.22 108.84,104.83 111.41,103.02 114.33,101.87",
-  "113.75,120.08 118.16,120.78 122.31,122.43 125.98,124.96 128.94,128.29 131.02,132.23 132.12,136.56 132.1,141.02 131.16,145.38 129.94,149.68 128.69,153.98 127.44,158.27 126.19,162.56 124.92,166.85 123.64,171.14 122.35,175.42 121.05,179.69 119.76,183.97 118.46,188.25 117.05,192.49 114.84,196.36 111.47,199.27 107.42,201.12 103.04,201.99 98.58,201.97 94.18,201.19 89.97,199.7 86.1,197.48 82.8,194.48 80.41,190.72 79.31,186.41 79.87,182 81.24,177.74 82.57,173.47 83.88,169.2 85.19,164.92 86.48,160.64 87.75,156.36 89.02,152.07 90.28,147.78 91.53,143.49 92.77,139.19 93.97,134.88 95.5,130.69 97.93,126.95 101.16,123.87 105.02,121.64 109.3,120.36|181.19,127.71 185.4,128.03 189.23,129.79 192.19,132.8 194.06,136.59 194.87,140.74 194.76,144.98 194.21,149.18 193.55,153.37 192.79,157.55 191.91,161.69 190.91,165.82 189.8,169.91 188.57,173.97 187.22,177.99 185.72,181.96 184.07,185.87 182.26,189.7 180.26,193.44 177.85,196.92 174.93,199.99 171.62,202.64 168,204.85 164.13,206.59 160.07,207.78 155.86,208.24 151.69,207.6 148.31,205.16 147.33,201.11 148.48,197.06 150.4,193.28 152.24,189.46 153.93,185.56 155.48,181.61 156.89,177.62 158.17,173.57 159.32,169.49 160.35,165.38 161.26,161.23 162.06,157.07 162.7,152.87 163.29,148.67 164.12,144.52 165.46,140.5 167.49,136.78 170.16,133.49 173.37,130.73 177.08,128.7",
-  "52.43,111.88 55.76,112.3 59,113.21 62.08,114.57 64.96,116.3 67.61,118.36 70.01,120.71 72.16,123.3 74.02,126.1 75.61,129.06 76.9,132.17 77.89,135.38 78.56,138.68 78.89,142.02 78.86,145.39 78.45,148.72 77.63,151.98 76.39,155.11 74.74,158.03 72.67,160.68 70.2,162.96 67.38,164.79 64.28,166.08 61,166.78 57.64,166.91 54.3,166.49 51.07,165.57 47.99,164.22 45.11,162.49 42.46,160.42 40.05,158.07 37.91,155.48 36.04,152.68 34.46,149.72 33.16,146.62 32.17,143.4 31.51,140.11 31.18,136.76 31.21,133.4 31.62,130.06 32.44,126.8 33.67,123.68 35.33,120.75 37.4,118.1 39.86,115.82 42.68,114 45.79,112.71 49.07,112.01|126.89,95.19 130.56,95.44 134.16,96.17 137.64,97.36 140.94,98.99 143.98,101.06 146.73,103.5 149.13,106.29 151.14,109.37 152.72,112.69 153.86,116.19 154.54,119.8 154.74,123.47 154.45,127.14 153.67,130.74 152.43,134.2 150.76,137.48 148.69,140.51 146.25,143.27 143.48,145.69 140.43,147.75 137.14,149.39 133.66,150.59 130.06,151.32 126.39,151.56 122.72,151.31 119.11,150.59 115.63,149.4 112.34,147.76 109.29,145.7 106.54,143.26 104.14,140.47 102.13,137.39 100.55,134.07 99.41,130.57 98.73,126.96 98.53,123.28 98.83,119.62 99.6,116.02 100.84,112.56 102.51,109.28 104.59,106.25 107.03,103.49 109.79,101.07 112.84,99.01 116.13,97.36 119.61,96.16 123.22,95.43",
-  "72.34,79.33 74.93,79.64 77.52,79.94 80.12,80.25 82.71,80.57 85.3,80.91 87.88,81.26 90.47,81.63 93.05,82 95.63,82.39 98.21,82.79 100.79,83.21 103.37,83.63 105.94,84.07 108.51,84.52 111.09,84.92 113.65,85.44 115.99,86.56 117.75,88.47 118.68,90.89 118.57,93.48 117.47,95.83 115.57,97.6 113.16,98.56 110.56,98.58 108,98.1 105.43,97.64 102.85,97.19 100.28,96.76 97.7,96.33 95.12,95.93 92.54,95.53 89.96,95.14 87.38,94.77 84.79,94.4 82.2,94.06 79.62,93.72 77.02,93.41 74.43,93.11 71.84,92.81 69.24,92.5 66.78,91.69 64.99,89.83 64.25,87.35 64.5,84.76 65.58,82.39 67.38,80.53 69.74,79.45|136.83,90.25 139.33,90.71 141.8,91.34 144.28,91.93 146.75,92.53 149.22,93.14 151.69,93.77 154.15,94.4 156.62,95.05 159.08,95.71 161.53,96.38 163.98,97.07 166.43,97.76 168.88,98.47 171.32,99.2 173.75,99.94 176.19,100.67 178.49,101.74 180.18,103.62 181.12,105.98 181.31,108.51 180.69,110.96 179.21,113.01 176.98,114.19 174.46,114.13 172.02,113.41 169.58,112.66 167.14,111.93 164.7,111.22 162.25,110.52 159.8,109.84 157.34,109.17 154.88,108.52 152.42,107.88 149.95,107.26 147.48,106.64 145.01,106.03 142.54,105.42 140.06,104.81 137.59,104.21 135.12,103.61 132.78,102.62 130.96,100.86 129.93,98.55 129.77,96.02 130.49,93.59 132.06,91.61 134.31,90.46",
-  "107.05,116.19 110.26,116.71 113.26,118 115.85,119.97 117.92,122.49 119.29,125.45 120.04,128.63 120.54,131.86 121.04,135.09 121.54,138.31 122.06,141.54 122.59,144.77 123.12,147.99 123.67,151.21 124.22,154.43 124.8,157.65 125.4,160.86 125.95,164.08 126.04,167.34 125.2,170.49 123.53,173.28 121.14,175.49 118.26,177.02 115.11,177.87 111.85,178.04 108.63,177.56 105.58,176.41 102.87,174.58 100.72,172.14 99.35,169.18 98.61,166 98.04,162.78 97.47,159.57 96.91,156.35 96.36,153.13 95.83,149.9 95.3,146.68 94.78,143.45 94.27,140.22 93.76,137 93.26,133.77 92.82,130.53 92.96,127.27 93.87,124.15 95.55,121.35 97.88,119.07 100.68,117.41 103.79,116.44|189.21,121.96 191.36,122.71 192.8,124.5 193.41,126.73 193.32,129.04 192.59,131.23 191.22,133.09 189.28,134.34 187.12,135.19 184.95,136 182.78,136.83 180.61,137.63 178.43,138.43 176.24,139.2 174.05,139.95 171.85,140.69 169.64,141.4 167.43,142.09 165.21,142.76 162.98,143.42 160.75,144.05 158.51,144.66 156.27,145.25 154.02,145.81 151.72,145.98 149.57,145.18 148.01,143.49 147.25,141.31 147.31,139 148.16,136.86 149.7,135.14 151.74,134.05 153.97,133.43 156.21,132.84 158.45,132.22 160.68,131.59 162.91,130.93 165.13,130.26 167.34,129.58 169.55,128.87 171.76,128.15 173.95,127.41 176.15,126.65 178.33,125.87 180.51,125.08 182.68,124.26 184.84,123.41 186.98,122.53",
-  "98.62,121.53 100.72,121.6 102.78,122.03 104.72,122.83 106.49,123.97 108.01,125.42 109.24,127.12 110.15,129.02 110.66,131.05 110.78,133.15 110.62,135.25 110.34,137.33 110.04,139.42 109.76,141.5 109.49,143.59 109.21,145.68 108.92,147.76 108.6,149.84 108.08,151.88 107.18,153.78 105.92,155.45 104.34,156.83 102.51,157.86 100.51,158.52 98.43,158.81 96.33,158.72 94.28,158.27 92.33,157.47 90.57,156.33 89.03,154.89 87.78,153.21 86.85,151.32 86.29,149.3 86.13,147.2 86.28,145.11 86.57,143.02 86.9,140.94 87.2,138.86 87.49,136.78 87.79,134.69 88.08,132.61 88.4,130.53 88.96,128.5 89.88,126.61 91.15,124.94 92.73,123.56 94.56,122.52 96.54,121.84|155.31,127.29 157.36,127.43 159.33,127.99 161.14,128.96 162.7,130.29 163.95,131.92 164.84,133.77 165.35,135.75 165.49,137.8 165.35,139.85 165.07,141.89 164.79,143.92 164.53,145.96 164.26,148 163.97,150.04 163.65,152.06 163.27,154.09 162.7,156.06 161.83,157.92 160.68,159.62 159.29,161.13 157.69,162.41 155.9,163.42 153.97,164.1 151.94,164.41 149.89,164.32 147.9,163.82 146.06,162.92 144.47,161.62 143.24,159.98 142.41,158.1 142,156.09 142.01,154.04 142.32,152.01 142.64,149.98 142.96,147.95 143.25,145.92 143.51,143.88 143.73,141.83 143.97,139.79 144.27,137.76 144.74,135.76 145.53,133.87 146.62,132.13 147.98,130.59 149.57,129.28 151.35,128.27 153.28,127.58",
-]
-
-const RAW: Ring[][] = ENCODED.map(line =>
-  line.split('|').map(
-    ring =>
-      ring.split(' ').map((pair): [number, number] => {
-        const [x, y] = pair.split(',')
-        return [Number(x), Number(y)]
-      })
-  )
-)
-
-const ringCentre = (ring: Ring): [number, number] => {
-  let x = 0
-  let y = 0
-  for (const p of ring) {
-    x += p[0]
-    y += p[1]
-  }
-  return [x / ring.length, y / ring.length]
-}
-
-/**
- * Each expression's look-direction: the eye-pair centroid relative to face centre.
- * Split out so the resting face can look straight ahead.
- */
-export const GAZE: [number, number][] = RAW.map(ex => {
-  const a = ringCentre(ex[0])
-  const b = ringCentre(ex[1])
-  return [(a[0] + b[0]) / 2 - FACE_CENTRE[0], (a[1] + b[1]) / 2 - FACE_CENTRE[1]]
-})
-
-/** Expressions with their look-direction removed — every one looks straight ahead. */
-export const EXPRESSIONS: Ring[][] = RAW.map((ex, i) =>
-  ex.map(ring => ring.map((p): [number, number] => [p[0] - GAZE[i][0], p[1] - GAZE[i][1]]))
-)
-
+/** Preserve the authored eye positions, including each expression's look direction. */
+export const EXPRESSIONS: Ring[][] = LAOA_DATA.expressions.map(ex => ex.map(ring => ring.map(p => [p[0], p[1]])))
 export const EXPRESSION_COUNT = EXPRESSIONS.length
-
-/**
- * One mouth per expression: [halfWidth, curve, gap, skew].
- *   curve  + bows the middle down => smile (U);  - => frown
- *   gap    clearance below the lowest eye edge
- *   skew   extra tilt in degrees on top of the eye-pair tilt, for smirks
- */
-export const MOUTHS: number[][] = [
-  [14,3,20,0],
-  [13,1,20,0],
-  [25,14,13,0],
-  [11,4,18,0],
-  [16,-2,22,0],
-  [15,-3,20,6],
-  [12,2,20,0],
-  [17,-7,18,0],
-  [15,5,20,0],
-  [12,3,20,0],
-  [14,1,20,0],
-  [23,12,13,0],
-  [12,2,20,0],
-  [16,-3,22,0],
-  [15,-2,20,-6],
-  [12,5,20,0],
-  [16,-4,18,0],
-  [20,11,18,0],
-  [11,2,20,0],
-  [17,8,20,0],
-  [15,5,10,0],
-  [12,4,18,0],
-  [16,-3,22,0],
-  [15,-5,20,7],
-  [11,6,20,0],
-]
-
-/** Mouth thickness, in face-space units. */
-export const MOUTH_STROKE = 7.5
-
-/**
- * The mouth hangs off the eyes: centred under the pair, tilted with them, and pushed clear
- * of whichever eye is tallest. It stays coherent for free as the eyes move, so only four
- * numbers per expression have to be authored.
- */
-export function mouthFrame(rings: Ring[], spec: number[]) {
-  const c0 = ringCentre(rings[0])
-  const c1 = ringCentre(rings[1])
-  const theta = Math.atan2(c1[1] - c0[1], c1[0] - c0[0])
-  let halfHeight = 0
-  for (const ring of rings) {
-    let lo = Infinity
-    let hi = -Infinity
-    for (const p of ring) {
-      if (p[1] < lo) lo = p[1]
-      if (p[1] > hi) hi = p[1]
-    }
-    halfHeight = Math.max(halfHeight, (hi - lo) / 2)
-  }
-  const drop = halfHeight + spec[2]
-  return {
-    x: (c0[0] + c1[0]) / 2 - Math.sin(theta) * drop,
-    y: (c0[1] + c1[1]) / 2 + Math.cos(theta) * drop,
-    angle: theta + (spec[3] * Math.PI) / 180,
-  }
-}

Fichier diff supprimé car celui-ci est trop grand
+ 2 - 0
src/client/components/common/maus/laoa-data.ts


+ 8 - 21
src/client/components/common/maus/silhouettes.ts

@@ -1,22 +1,9 @@
-// Adapted from OpenMausBot (3a84701), Apache-2.0. See THIRD_PARTY_NOTICES.md.
+// Shared LaoA-GrokBot geometry; see THIRD_PARTY_NOTICES.md.
 import type { CursorSilhouette } from './cursor-engine'
-type MascotShape = 'circle' | 'oval' | 'square' | 'capsule' | 'triangle' | 'hexagon' | 'cloud' | 'droplet'
-const SOFT_TRIANGLE_PATH = "M86.3935 61.1307 Q114.2705 8 142.1475 61.1307 L192.664 157.4103 Q220.541 210.541 160.541 210.541 H68 Q8 210.541 35.877 157.4103 Z";
-const SOFT_HEXAGON_PATH = "M126.2705 15 Q114.2705 8 102.2705 15 L32 55 Q20 62 20 76 V152 Q20 166 32 173 L102.2705 213 Q114.2705 220 126.2705 213 L196.541 173 Q208.541 166 208.541 152 V76 Q208.541 62 196.541 55 Z";
-const CLOUD_PATH = "M55 193 C6 193 -2 136 28 113 C7 62 61 30 96 54 C122 12 184 30 183 78 C230 83 239 137 208 159 C214 194 174 216 145 196 C116 221 76 216 55 193 Z";
-const DROPLET_PATH = "M104.2705 17 Q114.2705 5 124.2705 17 C148 46 207 97 207 136 C207 185 169 220 114.2705 220 C59 220 21 185 21 136 C21 97 80 46 104.2705 17 Z";
-const pathSilhouette = (name: string, path: string, y: number, scale: number): CursorSilhouette => ({
-  name, fit: "", body: `<path d="${path}" fill="{{GRADIENT}}"/>`, clip: `<path d="${path}"/>`,
-  anchor: { x: 114.2705, y, scale },
-});
-export const MASCOT_SILHOUETTES: Record<MascotShape, CursorSilhouette> = {
-  circle: { name: "circle", fit: "", body: '<circle cx="114.2705" cy="114.2705" r="108" fill="{{GRADIENT}}"/>', clip: '<circle cx="114.2705" cy="114.2705" r="108"/>', anchor: { x: 114.2705, y: 114.2705, scale: 1 } },
-  oval: { name: "oval", fit: "", body: '<ellipse cx="114.2705" cy="114.2705" rx="108" ry="88" fill="{{GRADIENT}}"/>', clip: '<ellipse cx="114.2705" cy="114.2705" rx="108" ry="88"/>', anchor: { x: 114.2705, y: 114.2705, scale: 0.82 } },
-  square: { name: "square", fit: "", body: '<rect x="8" y="8" width="212.541" height="212.541" rx="44" fill="{{GRADIENT}}"/>', clip: '<rect x="8" y="8" width="212.541" height="212.541" rx="44"/>', anchor: { x: 114.2705, y: 114.2705, scale: 1 } },
-  capsule: { name: "capsule", fit: "", body: '<rect x="8" y="48" width="212.541" height="132.541" rx="66.2705" fill="{{GRADIENT}}"/>', clip: '<rect x="8" y="48" width="212.541" height="132.541" rx="66.2705"/>', anchor: { x: 114.2705, y: 114.2705, scale: 0.65 } },
-  triangle: { name: "triangle", fit: "", body: `<path d="${SOFT_TRIANGLE_PATH}" fill="{{GRADIENT}}"/>`, clip: `<path d="${SOFT_TRIANGLE_PATH}"/>`, anchor: { x: 114.2705, y: 125, scale: 0.88 } },
-  hexagon: pathSilhouette("hexagon", SOFT_HEXAGON_PATH, 114.2705, 0.88),
-  cloud: pathSilhouette("cloud", CLOUD_PATH, 131, 0.78),
-  droplet: pathSilhouette("droplet", DROPLET_PATH, 140, 0.78),
-};
-
+import { LAOA_DATA } from './laoa-data'
+function silhouette(name: string, geometry: {path:string;anchor:readonly number[];viewport:readonly number[]}): CursorSilhouette {
+  const [x,y,scale] = geometry.anchor
+  return {name,fit:'',body:`<path d="${geometry.path}" fill="{{GRADIENT}}"/>`,clip:`<path d="${geometry.path}"/>`,anchor:{x,y,scale},viewBox:geometry.viewport.join(' ')}
+}
+export const MASCOT_SILHOUETTES = Object.fromEntries(Object.entries(LAOA_DATA.shapes).map(([id,value]) => [id==='ellipse'?'oval':id,silhouette(id,value)])) as Record<string,CursorSilhouette>
+export const LAOA_BODIES = Object.fromEntries(Object.entries(LAOA_DATA.bodies).map(([id,value]) => [id,silhouette(id,value)])) as Record<keyof typeof LAOA_DATA.bodies,CursorSilhouette>

+ 2 - 2
tests/client/agentAvatar.test.ts

@@ -22,7 +22,7 @@ describe('AgentAvatar', () => {
     expect(wrapper.classes()).not.toContain('agent-avatar--image')
     expect(wrapper.find('img').exists()).toBe(false)
     expect(wrapper.find('svg').exists()).toBe(true)
-    expect(wrapper.find('[data-part=outline] circle').attributes('fill')).toBe('#00c875')
+    expect(wrapper.find('[data-part=outline] path').attributes('fill')).toBe('#00c875')
     expect(wrapper.attributes('data-animated')).toBe('false')
     expect(wrapper.findAll('[data-part=eye0], [data-part=eye1]')).toHaveLength(2)
     wrapper.unmount()
@@ -94,6 +94,6 @@ it('renders all picture crops from the same bytes and falls back cleanly on deco
   expect(wrapper.get('img').attributes('src')).toBe(transparentPng)
   await wrapper.get('img').trigger('error');await nextTick()
   expect(wrapper.find('img').exists()).toBe(false)
-  expect(wrapper.get('[data-part=outline] circle').attributes('fill')).toBe('#00c875')
+  expect(wrapper.get('[data-part=outline] path').attributes('fill')).toBe('#00c875')
   wrapper.unmount()
 })

+ 31 - 18
tests/e2e/avatarReference.spec.ts

@@ -1,29 +1,42 @@
 import {expect,test} from '@playwright/test'
-// Standalone reference comparison; the regular app suite uses its own fixture.
-test('matches all OpenMausBot canonical resting frames',async({page,context},testInfo)=>{
+import {readFileSync} from 'node:fs'
+const source=JSON.parse(readFileSync(new URL('../../assets/mascot/laoa-source.json',import.meta.url),'utf8'))
+
+test('matches LaoA original paths and all 25 eye expressions at full visible size',async({page,context},testInfo)=>{
   const reference=await context.newPage()
   await reference.goto('http://127.0.0.1:18806')
   await page.goto('http://127.0.0.1:18807')
-  await expect(reference.locator('[data-testid="shape-circle"] svg path[fill="#ffffff"]').first()).toHaveAttribute('d',/.+/)
   await expect(page.locator('[data-part=eye0]').first()).toHaveAttribute('d',/.+/)
   const ids=await page.locator('[data-testid]').evaluateAll(nodes=>nodes.map(n=>n.getAttribute('data-testid')!))
-  expect(ids).toHaveLength(28)
+  expect(ids).toHaveLength(53)
   for(const id of ids){
-    const expected=await reference.getByTestId(id).locator('svg').evaluate(svg=>{
-      const eyes=[...svg.querySelectorAll('path[fill="#ffffff"]')].slice(0,2)
-      const mouth=svg.querySelector('path[stroke="#ffffff"]')!
-      const body=svg.querySelector('[fill^="url("]')!
-      const attrs=(el:Element)=>Object.fromEntries([...el.attributes].filter(a=>!['fill','data-part'].includes(a.name)).map(a=>[a.name,a.value]))
-      return {viewBox:svg.getAttribute('viewBox'),eyes:eyes.map(e=>({d:e.getAttribute('d'),transform:e.getAttribute('transform')})),mouth:{d:mouth.getAttribute('d'),transform:mouth.getAttribute('transform')},anchor:eyes[0]!.parentElement!.getAttribute('transform'),body:{tag:body.tagName,attrs:attrs(body)},color:svg.querySelector('stop')!.getAttribute('stop-color')!.toLowerCase()}
+    const svg=page.getByTestId(id).locator('svg')
+    await expect(svg.locator('[data-part=mouth]')).toHaveCount(0)
+    const measure=await svg.evaluate(svg=>{
+      const viewport=(svg as SVGSVGElement).viewBox.baseVal,b=(svg.querySelector('[data-part=outline] path') as SVGGraphicsElement).getBBox()
+      return {side:viewport.width,body:Math.max(b.width,b.height),centerX:viewport.x+viewport.width/2,bodyX:b.x+b.width/2,centerY:viewport.y+viewport.height/2,bodyY:b.y+b.height/2}
     })
-    const actual=await page.getByTestId(id).locator('svg').evaluate(svg=>{
-      const eyes=[svg.querySelector('[data-part=eye0]')!,svg.querySelector('[data-part=eye1]')!],mouth=svg.querySelector('[data-part=mouth]')!,body=svg.querySelector('[data-part=outline]')!.firstElementChild!
-      const attrs=(el:Element)=>Object.fromEntries([...el.attributes].filter(a=>!['fill','data-part'].includes(a.name)).map(a=>[a.name,a.value]))
-      return {viewBox:svg.getAttribute('viewBox'),eyes:eyes.map(e=>({d:e.getAttribute('d'),transform:e.getAttribute('transform')})),mouth:{d:mouth.getAttribute('d'),transform:mouth.getAttribute('transform')},anchor:eyes[0]!.parentElement!.getAttribute('transform'),body:{tag:body.tagName,attrs:attrs(body)},color:body.getAttribute('fill')!.toLowerCase()}
-    })
-    expect(actual,id).toEqual(expected)
+    expect(measure.side,id).toBeCloseTo(measure.body,3)
+    expect(measure.centerX,id).toBeCloseTo(measure.bodyX,3);expect(measure.centerY,id).toBeCloseTo(measure.bodyY,3)
+    if(id.startsWith('raw-')){
+      const index=Number(id.slice(4))
+      for(let eye=0;eye<2;eye++)await expect(svg.locator(`[data-part=eye${eye}]`)).toHaveAttribute('d','M'+source.expressions[index][eye].map((p:number[])=>p.map(n=>n.toFixed(2)).join(' ')).join('L')+'Z')
+    }
+    async function pixels(locator:typeof svg){return locator.evaluate(async svg=>{
+      const clone=svg.cloneNode(true) as SVGElement;clone.setAttribute('xmlns','http://www.w3.org/2000/svg');clone.setAttribute('width','96');clone.setAttribute('height','96')
+      const image=new Image();image.src='data:image/svg+xml;base64,'+btoa(new XMLSerializer().serializeToString(clone));await image.decode()
+      const canvas=document.createElement('canvas');canvas.width=96;canvas.height=96;const ctx=canvas.getContext('2d')!;ctx.fillStyle='white';ctx.fillRect(0,0,96,96);ctx.drawImage(image,0,0,96,96);return Array.from(ctx.getImageData(0,0,96,96).data)
+    })}
+    const a=await pixels(svg),b=await pixels(reference.getByTestId(id).locator('svg'))
+    const mean=a.reduce((sum,v,i)=>sum+Math.abs(v-b[i]!),0)/a.length
+    expect(mean,id).toBeLessThan(0.5)
+    await svg.screenshot({path:testInfo.outputPath(id+'.png')})
   }
-  await reference.screenshot({path:testInfo.outputPath('openmaus-reference.png'),fullPage:true})
-  await page.screenshot({path:testInfo.outputPath('yaoyao-v2.png'),fullPage:true})
+  await reference.screenshot({path:testInfo.outputPath('laoa-reference.png'),fullPage:true})
+  await page.screenshot({path:testInfo.outputPath('web-avatar-grid.png'),fullPage:true})
+  await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'})
+  await page.addStyleTag({content:'body{background:#151515;color:#eee}'})
+  await page.screenshot({path:testInfo.outputPath('web-avatar-grid-dark.png'),fullPage:true})
+  await expect(page.locator('[data-animated=true]')).toHaveCount(0)
   await reference.close()
 })

+ 28 - 0
tests/e2e/avatarRoundTrip.spec.ts

@@ -0,0 +1,28 @@
+import {expect,test} from '@playwright/test'
+import {readFileSync} from 'node:fs'
+
+test('reads the Android edit and preserves the iOS photo through the Web UI',async({page},testInfo)=>{
+  await page.goto('/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await expect(page.getByText('跨端造型',{exact:true}).first()).toBeVisible()
+  const response=await page.request.get('/api/app/agents',{headers:{Origin:'http://127.0.0.1:18804'}})
+  expect(response.ok(),await response.text()).toBe(true)
+  const agents=(await response.json()).agents
+  const edited=agents.find((a:{name:string})=>a.name==='跨端造型')
+  const identity=JSON.parse(edited.avatar.slice('yaoyao-avatar:v2:'.length))
+  expect(identity).toMatchObject({shape:'cloud',bodyId:null,color:'#ff6b00',expression:'happy'})
+  const photo=JSON.parse(agents.find((a:{name:string})=>a.name==='跨端照片').avatar.slice('yaoyao-avatar:v2:'.length))
+  expect(photo.imageCrop).toBe('rounded')
+  expect(photo.imageDataURL).toBe('data:image/png;base64,'+readFileSync(new URL('../../public/brand/AppIcon-1024.png',import.meta.url)).toString('base64'))
+  await expect(page.getByText('跨端造型',{exact:true}).first()).toBeVisible()
+  const avatar=page.locator('.desktop-sidebar .agent-avatar[aria-label="跨端造型 的头像"]')
+  await expect(avatar.locator('[data-part=outline] path')).toHaveAttribute('fill','#ff6b00')
+  await expect(avatar.locator('[data-part=mouth]')).toHaveCount(0)
+  await page.screenshot({path:testInfo.outputPath('web-workspace-roundtrip.png'),fullPage:true})
+  await page.setViewportSize({width:375,height:812})
+  const openNavigation=page.getByRole('button',{name:'打开导航',exact:true})
+  if(await openNavigation.isVisible())await openNavigation.click()
+  await page.screenshot({path:testInfo.outputPath('web-workspace-mobile.png'),fullPage:true})
+})

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff