generate-laoa-avatars.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. #!/usr/bin/env node
  2. // Canonical data is vendored: generation and builds never need the source checkout.
  3. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  4. import { resolve, dirname } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
  7. const source = JSON.parse(readFileSync(resolve(root, 'assets/mascot/laoa-source.json'), 'utf8'))
  8. const check = process.argv.includes('--check')
  9. const option = key => { const i = process.argv.indexOf(key); return i < 0 ? undefined : resolve(process.argv[i + 1]) }
  10. const ios = option('--ios'), android = option('--android')
  11. const number = n => Number(n.toFixed(6))
  12. // Convert the source's absolute SVG commands into M/C/Z once. This is also the
  13. // input accepted by the native CGPath parser; no per-frame SVG parsing is needed.
  14. function geometry(path, fit = '') {
  15. const tokens = path.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?/g)
  16. const translate = fit.match(/translate\(([-\d.]+)[ ,]+([-\d.]+)\)/), scale = fit.match(/scale\(([-\d.]+)\)/)
  17. const k = scale ? Number(scale[1]) : 1, tx = translate ? Number(translate[1]) : 0, ty = translate ? Number(translate[2]) : 0
  18. const at = (x, y) => [number(x * k + tx), number(y * k + ty)]
  19. let i = 0, command, x = 0, y = 0, start = [0, 0]
  20. const out = [], xs = [], ys = []
  21. const read = () => Number(tokens[i++])
  22. const include = p => { xs.push(p[0]); ys.push(p[1]) }
  23. function cubic(a, b, c, d) {
  24. const points = [a, b, c, d].map(p => at(...p))
  25. include(points[0]); include(points[3])
  26. for (let axis = 0; axis < 2; axis++) {
  27. const [p, q, r, s] = points.map(v => v[axis])
  28. const aa = -p + 3*q - 3*r + s, bb = 2*(p - 2*q + r), cc = q-p
  29. const disc = bb*bb - 4*aa*cc
  30. 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)]
  31. for (const t of roots.filter(t => t > 0 && t < 1)) {
  32. const v = (1-t)**3*p + 3*(1-t)**2*t*q + 3*(1-t)*t*t*r + t**3*s
  33. ;(axis === 0 ? xs : ys).push(v)
  34. }
  35. }
  36. out.push('C' + points.slice(1).flat().join(' '))
  37. }
  38. while (i < tokens.length) {
  39. if (/^[A-Za-z]$/.test(tokens[i])) command = tokens[i++]
  40. const a = [x,y]
  41. if (command === 'M') { x=read(); y=read(); start=[x,y]; out.push('M'+at(x,y).join(' ')); include(at(x,y)); command='L' }
  42. else if (command === 'C') { const b=[read(),read()],c=[read(),read()];x=read();y=read();cubic(a,b,c,[x,y]) }
  43. 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]) }
  44. else if (['L','H','V'].includes(command)) { if(command!=='V')x=read();if(command!=='H')y=read();cubic(a,a,[x,y],[x,y]) }
  45. else if (command === 'Z') { out.push('Z');[x,y]=start;command=undefined }
  46. else throw new Error('Unsupported path command: '+command)
  47. }
  48. const bounds = [Math.min(...xs),Math.min(...ys),Math.max(...xs)-Math.min(...xs),Math.max(...ys)-Math.min(...ys)].map(number)
  49. const side = Math.max(bounds[2],bounds[3])
  50. const viewport = [bounds[0]+(bounds[2]-side)/2,bounds[1]+(bounds[3]-side)/2,side,side].map(number)
  51. return {path:out.join(' '),bounds,viewport}
  52. }
  53. const aliases = {circle:'blob',square:'squircle',triangle:'triangle',capsule:'capsule',hexagon:'hex',cloud:'cloud',droplet:'drop'}
  54. const shapes = Object.fromEntries(Object.entries(aliases).map(([id, key]) => [id, {...geometry(source.shapes[key].path), anchor:[120,122.5,1]}]))
  55. // Retain the saved ellipse's aspect ratio. Cubic approximation is shared by all clients.
  56. 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]}
  57. 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]}]))
  58. // Fit the complete expression catalog inside narrow silhouettes. Keep the original
  59. // look direction; solve a single per-shape scale rather than moving each expression.
  60. function safeAnchor(g, anchor) {
  61. const tokens = g.path.match(/[MCZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?/g)
  62. const edges = []; let i=0, p=[0,0], start=p
  63. const point = () => [Number(tokens[i++]),Number(tokens[i++])]
  64. const edge = q => { if(p[1]!==q[1])edges.push([p,q]);p=q }
  65. while(i<tokens.length) {
  66. const command=tokens[i++]
  67. if(command==='M') {p=point();start=p}
  68. else if(command==='Z')edge(start)
  69. else if(command==='C') {
  70. const a=p,b=point(),c=point(),d=point()
  71. 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]))}
  72. }
  73. }
  74. 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 }
  75. const points=source.expressions.flat(2)
  76. 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)})
  77. let lo=0,hi=anchor[2]
  78. if(fits(hi))return anchor
  79. for(let n=0;n<18;n++){const mid=(lo+hi)/2;if(fits(mid))lo=mid;else hi=mid}
  80. return [anchor[0],anchor[1],number(lo)]
  81. }
  82. const centers={triangle:[114.2705,135,1],cloud:[114.2705,125,1],droplet:[114.2705,140,1]}
  83. for(const [id,g] of Object.entries(shapes))g.anchor=safeAnchor(g,centers[id]??g.anchor)
  84. for(const g of Object.values(bodies))g.anchor=safeAnchor(g,g.anchor)
  85. const pools = {...source.pools, 'thinking-dots':source.pools.thinking}
  86. const data = {source:source.source,faceBox:source.faceBox,shapes,bodies,expressions:source.expressions,pools}
  87. 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'
  88. 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)} }
  89. write(resolve(root,'src/client/components/common/maus/laoa-data.ts'),banner+'export const LAOA_DATA = '+JSON.stringify(data)+' as const\n')
  90. if (ios) {
  91. 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(', ')+'))'
  92. 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')
  93. const names = {'powering-down':'poweringDown','thinking-dots':'thinkingDots'}
  94. write(resolve(ios,'YaoYaoAI/Features/Bots/OpenMaus/LaoAMascotData.swift'),banner+`import CoreGraphics
  95. enum LaoAMascotData {
  96. struct Geometry { let path: String; let viewport: CGRect; let anchor: (x: CGFloat, y: CGFloat, scale: CGFloat) }
  97. static let geometries: [String: Geometry] = [
  98. ${rows}
  99. ]
  100. static func geometry(shape: String, bodyId: String?) -> Geometry {
  101. if let bodyId, let body = geometries["body:" + bodyId] { return body }
  102. return geometries["shape:" + (shape == "oval" ? "ellipse" : shape)] ?? geometries["shape:circle"]!
  103. }
  104. static let expressions: [CGFloat] = [
  105. ${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')}
  106. ]
  107. static let pools: [MausState: [Int]] = [
  108. ${Object.entries(pools).map(([k,v])=>' .'+(names[k]??k)+': ['+v.join(', ')+']').join(',\n')}
  109. ]
  110. }
  111. `)
  112. }
  113. if(android)write(resolve(android,'core/ui/src/main/res/raw/laoa_mascot.json'),JSON.stringify(data)+'\n')
  114. console.log(`${check?'Verified':'Generated'} ${Object.keys(shapes).length} shapes, ${Object.keys(bodies).length} bodies, ${source.expressions.length} expressions`)