@springbrand/message-panel 0.1.3-alpha.9 → 0.2.0-alpha.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/cloud-os/README.md +4 -3
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-star.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +1 -1
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
  10. package/cloud-os/chat/code-runner.tsx +106 -0
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/range.ts +8 -0
  15. package/cloud-os/chat/rich-blocks.tsx +285 -227
  16. package/cloud-os/chat/surfaces.tsx +90 -0
  17. package/cloud-os/chat/tool-call.tsx +94 -0
  18. package/cloud-os/chat/tool-presentation.ts +31 -22
  19. package/cloud-os/chat/tool-rows.tsx +195 -143
  20. package/cloud-os/chat/tool-timeline.tsx +123 -0
  21. package/cloud-os/chat/transcript-model.ts +258 -20
  22. package/cloud-os/chat/web-search.tsx +135 -0
  23. package/cloud-os/composer/cloud-os-chat-input.tsx +29 -88
  24. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  25. package/cloud-os/file-view.tsx +195 -12
  26. package/cloud-os/index.ts +24 -3
  27. package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
  28. package/cloud-os/primitives/collapsible.tsx +21 -0
  29. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  30. package/cloud-os/styles/cloud-os.css +114 -1
  31. package/demo/camel-chat-showcase.tsx +21 -13
  32. package/demo/chat-scenarios.ts +214 -40
  33. package/demo/cloud-os-chat-showcase.tsx +37 -14
  34. package/demo/fixtures.ts +4 -5
  35. package/demo/message-panel-gallery.tsx +16 -2
  36. package/package.json +8 -4
  37. package/springbrand-pet/LICENSE.bloub +21 -0
  38. package/springbrand-pet/bot/cycles.test.ts +221 -0
  39. package/springbrand-pet/bot/cycles.ts +225 -0
  40. package/springbrand-pet/bot/decor.ts +285 -0
  41. package/springbrand-pet/bot/engine.test.ts +543 -0
  42. package/springbrand-pet/bot/engine.ts +558 -0
  43. package/springbrand-pet/bot/expressions.test.ts +135 -0
  44. package/springbrand-pet/bot/expressions.ts +192 -0
  45. package/springbrand-pet/bot/eyefit.ts +455 -0
  46. package/springbrand-pet/bot/face.test.ts +101 -0
  47. package/springbrand-pet/bot/face.ts +179 -0
  48. package/springbrand-pet/bot/math.ts +42 -0
  49. package/springbrand-pet/bot/profiles.ts +22 -0
  50. package/springbrand-pet/bot/repere.ts +31 -0
  51. package/springbrand-pet/bot/shape.test.ts +97 -0
  52. package/springbrand-pet/bot/shape.ts +310 -0
  53. package/springbrand-pet/bot/skins.test.ts +325 -0
  54. package/springbrand-pet/bot/skins.ts +161 -0
  55. package/springbrand-pet/bot/states.ts +616 -0
  56. package/springbrand-pet/drag.test.ts +19 -0
  57. package/springbrand-pet/index.tsx +569 -0
  58. package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  59. package/springbrand-pet/vitest.config.ts +9 -0
  60. package/src/camel/camel-chat-messages.tsx +78 -78
  61. package/src/camel/camel-tool-presentation.tsx +19 -15
  62. package/src/camel/camel-turn.ts +1 -17
  63. package/src/composer/composer-attachments.tsx +1 -1
  64. package/src/composer/composer.tsx +2 -2
  65. package/src/contracts.ts +0 -2
  66. package/src/message-panel.tsx +1 -24
  67. package/src/parts/index.tsx +103 -64
@@ -0,0 +1,310 @@
1
+ import { TAU, lerp, r2 } from './math'
2
+ import { PROFILES, PROFILE_SAMPLES, type ProfileName } from './profiles'
3
+
4
+ export interface Point {
5
+ x: number
6
+ y: number
7
+ }
8
+
9
+ /**
10
+ * Une silhouette = un profil radial r(theta) plus une pose.
11
+ *
12
+ * Tout passe par des profils echantillonnes au MEME nombre d'angles : deux
13
+ * formes quelconques ont donc des points qui se correspondent un a un, et le
14
+ * morphing se reduit a une interpolation lineaire des rayons. C'est ce qui
15
+ * rend les transitions propres sans librairie de morphing de path.
16
+ */
17
+ export interface Silhouette {
18
+ radii: number[]
19
+ /** rotation du profil, en radians */
20
+ rot: number
21
+ /** decalage du centre, en unites de rayon de boule */
22
+ cx: number
23
+ cy: number
24
+ /** squash & stretch, applique en repere ecran (apres rotation) */
25
+ sx: number
26
+ sy: number
27
+ }
28
+
29
+ const ANGLES = Array.from({ length: PROFILE_SAMPLES }, (_, i) => (i / PROFILE_SAMPLES) * TAU)
30
+ const COS = ANGLES.map(Math.cos)
31
+ const SIN = ANGLES.map(Math.sin)
32
+
33
+ export function silhouette(name: ProfileName, pose: Partial<Silhouette> = {}): Silhouette {
34
+ return {
35
+ radii: [...PROFILES[name]],
36
+ rot: 0,
37
+ cx: 0,
38
+ cy: 0,
39
+ sx: 1,
40
+ sy: 1,
41
+ ...pose
42
+ }
43
+ }
44
+
45
+ /** Cercle parfait : sert de base neutre (point, bulle, cible de fondu). */
46
+ export function circle(radius: number, pose: Partial<Silhouette> = {}): Silhouette {
47
+ return {
48
+ radii: new Array(PROFILE_SAMPLES).fill(radius),
49
+ rot: 0,
50
+ cx: 0,
51
+ cy: 0,
52
+ sx: 1,
53
+ sy: 1,
54
+ ...pose
55
+ }
56
+ }
57
+
58
+ /** Interpolation de deux silhouettes. `out` est reutilise pour eviter d'allouer a 60 fps. */
59
+ export function blend(a: Silhouette, b: Silhouette, t: number, out?: Silhouette): Silhouette {
60
+ const dst = out ?? { radii: new Array<number>(PROFILE_SAMPLES), rot: 0, cx: 0, cy: 0, sx: 1, sy: 1 }
61
+ for (let i = 0; i < PROFILE_SAMPLES; i++) {
62
+ dst.radii[i] = lerp(a.radii[i] ?? 1, b.radii[i] ?? 1, t)
63
+ }
64
+ // Rotation par le chemin le plus court : evite de faire un tour complet
65
+ // quand on passe par exemple de +170deg a -170deg.
66
+ let dRot = b.rot - a.rot
67
+ while (dRot > Math.PI) dRot -= TAU
68
+ while (dRot < -Math.PI) dRot += TAU
69
+ dst.rot = a.rot + dRot * t
70
+ dst.cx = lerp(a.cx, b.cx, t)
71
+ dst.cy = lerp(a.cy, b.cy, t)
72
+ dst.sx = lerp(a.sx, b.sx, t)
73
+ dst.sy = lerp(a.sy, b.sy, t)
74
+ return dst
75
+ }
76
+
77
+ /** Projette la silhouette en points ecran. `scale` = rayon de la boule en unites de viewBox. */
78
+ export function toPoints(s: Silhouette, scale: number, out: Point[] = []): Point[] {
79
+ const cr = Math.cos(s.rot)
80
+ const sr = Math.sin(s.rot)
81
+ for (let i = 0; i < PROFILE_SAMPLES; i++) {
82
+ const r = s.radii[i] ?? 1
83
+ const x = r * (COS[i] ?? 0)
84
+ const y = r * (SIN[i] ?? 0)
85
+ // rotation puis squash en repere ecran, puis translation
86
+ const rx = x * cr - y * sr
87
+ const ry = x * sr + y * cr
88
+ const p = out[i] ?? { x: 0, y: 0 }
89
+ p.x = (rx * s.sx + s.cx) * scale
90
+ p.y = (ry * s.sy + s.cy) * scale
91
+ out[i] = p
92
+ }
93
+ out.length = PROFILE_SAMPLES
94
+ return out
95
+ }
96
+
97
+ /**
98
+ * Polyligne fermee -> cubiques Catmull-Rom.
99
+ *
100
+ * Avec 64 points les tangentes centrees suffisent largement : le contour est
101
+ * lisse au pixel pres meme affiche en 600 px, et la chaine reste courte.
102
+ */
103
+ export function closedPath(pts: Point[], tension = 1 / 6): string {
104
+ const n = pts.length
105
+ if (n < 3) return ''
106
+ const first = pts[0]!
107
+ let d = `M${r2(first.x)} ${r2(first.y)}`
108
+ for (let i = 0; i < n; i++) {
109
+ const p0 = pts[(i - 1 + n) % n]!
110
+ const p1 = pts[i]!
111
+ const p2 = pts[(i + 1) % n]!
112
+ const p3 = pts[(i + 2) % n]!
113
+ const c1x = p1.x + (p2.x - p0.x) * tension
114
+ const c1y = p1.y + (p2.y - p0.y) * tension
115
+ const c2x = p2.x - (p3.x - p1.x) * tension
116
+ const c2y = p2.y - (p3.y - p1.y) * tension
117
+ d += `C${r2(c1x)} ${r2(c1y)} ${r2(c2x)} ${r2(c2y)} ${r2(p2.x)} ${r2(p2.y)}`
118
+ }
119
+ return `${d}Z`
120
+ }
121
+
122
+ /**
123
+ * Polygone quelconque -> profil radial, par lancer de rayon depuis `center`.
124
+ *
125
+ * Sert a fabriquer les formes qui ne s'expriment pas naturellement en r(theta)
126
+ * (la barre tronconique du "!"). Calcule une seule fois au chargement, jamais
127
+ * dans la boucle de rendu.
128
+ */
129
+ export function profileFromPolygon(poly: Point[], cx: number, cy: number): number[] {
130
+ const radii = new Array<number>(PROFILE_SAMPLES).fill(0)
131
+ const n = poly.length
132
+ for (let k = 0; k < PROFILE_SAMPLES; k++) {
133
+ const dx = COS[k] ?? 0
134
+ const dy = SIN[k] ?? 0
135
+ let best = 0
136
+ for (let i = 0; i < n; i++) {
137
+ const a = poly[i]!
138
+ const b = poly[(i + 1) % n]!
139
+ const ex = b.x - a.x
140
+ const ey = b.y - a.y
141
+ const den = dx * ey - dy * ex
142
+ if (Math.abs(den) < 1e-9) continue
143
+ const px = a.x - cx
144
+ const py = a.y - cy
145
+ const t = (px * ey - py * ex) / den // distance le long du rayon
146
+ const u = (px * dy - py * dx) / den // position sur le segment
147
+ if (t > best && u >= 0 && u <= 1) best = t
148
+ }
149
+ radii[k] = best
150
+ }
151
+ return radii
152
+ }
153
+
154
+ /** Enveloppe convexe de deux cercles : la barre tronconique du "!" vertical. */
155
+ export function hullOfCircles(
156
+ x1: number,
157
+ y1: number,
158
+ r1: number,
159
+ x2: number,
160
+ y2: number,
161
+ r2v: number,
162
+ steps = 96
163
+ ): Point[] {
164
+ const dx = x2 - x1
165
+ const dy = y2 - y1
166
+ const dist = Math.hypot(dx, dy) || 1e-6
167
+ // angle des tangentes externes communes
168
+ const base = Math.atan2(dy, dx)
169
+ const spread = Math.acos(Math.max(-1, Math.min(1, (r1 - r2v) / dist)))
170
+ const pts: Point[] = []
171
+ // arc du grand cercle
172
+ for (let i = 0; i <= steps / 2; i++) {
173
+ const a = base + spread + ((TAU - 2 * spread) * i) / (steps / 2)
174
+ pts.push({ x: x1 + Math.cos(a) * r1, y: y1 + Math.sin(a) * r1 })
175
+ }
176
+ // arc du petit cercle
177
+ for (let i = 0; i <= steps / 2; i++) {
178
+ const a = base - spread + ((2 * spread) * i) / (steps / 2)
179
+ pts.push({ x: x2 + Math.cos(a) * r2v, y: y2 + Math.sin(a) * r2v })
180
+ }
181
+ return pts
182
+ }
183
+
184
+ /**
185
+ * Rayon du profil dans une direction quelconque, par interpolation entre les
186
+ * deux echantillons voisins.
187
+ *
188
+ * Sert a recaler ce qui est pose "sur" le corps (les yeux, la pastille de
189
+ * notification) quand la silhouette n'est plus un cercle : sans ca, un oeil
190
+ * place a 0.62 rayon sort d'une forme dont le bord est a 0.55 dans cette
191
+ * direction, et le masque le rogne.
192
+ */
193
+ export function radiusAtAngle(radii: number[], angle: number): number {
194
+ const n = radii.length
195
+ const t = ((((angle / TAU) % 1) + 1) % 1) * n
196
+ const i = Math.floor(t)
197
+ return lerp(radii[i % n] ?? 1, radii[(i + 1) % n] ?? 1, t - i)
198
+ }
199
+
200
+ /**
201
+ * Superellipse : |x/sx|^n + |y/sy|^n = 1.
202
+ * n = 2 donne une ellipse, n ~ 4 le squircle du personnalisateur.
203
+ */
204
+ export function superellipseProfile(n: number, sx = 1, sy = 1): number[] {
205
+ return ANGLES.map((_, i) => {
206
+ const c = Math.abs((COS[i] ?? 0) / sx) ** n
207
+ const s = Math.abs((SIN[i] ?? 0) / sy) ** n
208
+ return (c + s) ** (-1 / n)
209
+ })
210
+ }
211
+
212
+ /**
213
+ * Profil radial de l'UNION de disques : r(theta) = la plus lointaine des
214
+ * intersections rayon/cercle. Exact tant que l'origine est dans l'union — c'est
215
+ * ce qui donne les bosses du nuage sans booleen de path.
216
+ */
217
+ export function unionOfCirclesProfile(circles: Array<{ x: number; y: number; r: number }>): number[] {
218
+ const out = new Array<number>(PROFILE_SAMPLES).fill(0)
219
+ for (let i = 0; i < PROFILE_SAMPLES; i++) {
220
+ const dx = COS[i] ?? 0
221
+ const dy = SIN[i] ?? 0
222
+ let best = 0
223
+ for (const c of circles) {
224
+ const b = dx * c.x + dy * c.y
225
+ const disc = b * b - (c.x * c.x + c.y * c.y - c.r * c.r)
226
+ if (disc < 0) continue
227
+ const t = b + Math.sqrt(disc)
228
+ if (t > best) best = t
229
+ }
230
+ out[i] = best
231
+ }
232
+ return out
233
+ }
234
+
235
+ /**
236
+ * Polygone a coins arrondis, par somme de Minkowski avec un disque : chaque
237
+ * arete est poussee de `rc` vers l'exterieur, chaque sommet devient un arc de
238
+ * rayon `rc`. Les sommets sont donc a poser au rayon voulu MOINS rc.
239
+ * Attend un polygone en sens horaire (repere ecran, y vers le bas).
240
+ */
241
+ function roundedPolygon(verts: Point[], rc: number, arcSteps = 10): Point[] {
242
+ const n = verts.length
243
+ const out: Point[] = []
244
+ const normal = (a: Point, b: Point) => {
245
+ const dx = b.x - a.x
246
+ const dy = b.y - a.y
247
+ const len = Math.hypot(dx, dy) || 1
248
+ // sens horaire + y vers le bas : la normale sortante est (dy, -dx)
249
+ return Math.atan2(-dx / len, dy / len)
250
+ }
251
+ for (let i = 0; i < n; i++) {
252
+ const prev = verts[(i - 1 + n) % n]!
253
+ const cur = verts[i]!
254
+ const next = verts[(i + 1) % n]!
255
+ const a0 = normal(prev, cur)
256
+ const a1 = normal(cur, next)
257
+ let d = a1 - a0
258
+ while (d > Math.PI) d -= TAU
259
+ while (d < -Math.PI) d += TAU
260
+ for (let k = 0; k <= arcSteps; k++) {
261
+ const a = a0 + (d * k) / arcSteps
262
+ out.push({ x: cur.x + Math.cos(a) * rc, y: cur.y + Math.sin(a) * rc })
263
+ }
264
+ }
265
+ return out
266
+ }
267
+
268
+ /** Polygone regulier a coins arrondis, inscrit dans `radius`. */
269
+ export function regularPolygonProfile(
270
+ sides: number,
271
+ radius: number,
272
+ rc: number,
273
+ rotationDeg = 0
274
+ ): number[] {
275
+ const rot = (rotationDeg * Math.PI) / 180
276
+ const verts = Array.from({ length: sides }, (_, i) => {
277
+ // sens horaire a l'ecran : theta croissant avec y vers le bas
278
+ const a = rot + (i / sides) * TAU
279
+ return { x: Math.cos(a) * (radius - rc), y: Math.sin(a) * (radius - rc) }
280
+ })
281
+ return profileFromPolygon(roundedPolygon(verts, rc), 0, 0)
282
+ }
283
+
284
+ /** Polyligne fermee exacte : garde les segments droits (contrairement a closedPath). */
285
+ export function polyPath(pts: Point[], scale = 1): string {
286
+ if (pts.length < 3) return ''
287
+ let d = ''
288
+ for (let i = 0; i < pts.length; i++) {
289
+ const p = pts[i]!
290
+ d += `${i === 0 ? 'M' : 'L'}${r2(p.x * scale)} ${r2(p.y * scale)}`
291
+ }
292
+ return `${d}Z`
293
+ }
294
+
295
+ /** Capsule (stade) centree sur l'origine : la forme exacte des yeux du bot. */
296
+ export function capsulePath(w: number, h: number): string {
297
+ const hw = Math.max(w, 0.01) / 2
298
+ const hh = Math.max(h, 0.01) / 2
299
+ const r = Math.min(hw, hh)
300
+ return (
301
+ `M${r2(-hw)} ${r2(-hh + r)}` +
302
+ `A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw + r)} ${r2(-hh)}` +
303
+ `L${r2(hw - r)} ${r2(-hh)}` +
304
+ `A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw)} ${r2(-hh + r)}` +
305
+ `L${r2(hw)} ${r2(hh - r)}` +
306
+ `A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw - r)} ${r2(hh)}` +
307
+ `L${r2(-hw + r)} ${r2(hh)}` +
308
+ `A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw)} ${r2(hh - r)}Z`
309
+ )
310
+ }
@@ -0,0 +1,325 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { BotEngine, type RenderedEye } from './engine'
3
+ import { decalageDesYeux, POUR_TESTS } from './eyefit'
4
+ import { EXPRESSIONS } from './expressions'
5
+ import { DEFAULT_SHAPE, SHAPES, SHAPE_BY_ID } from './skins'
6
+ import { STATES, type StateId } from './states'
7
+
8
+ /**
9
+ * Les formes du personnalisateur, mesurees contre le corps qu'elles remplacent.
10
+ *
11
+ * Ce fichier verrouille deux choses, et la seconde a coute bien plus que la premiere :
12
+ *
13
+ * 1. **La gelule ne sort pas de la silhouette.** Les yeux vivent sur une sphere, et
14
+ * `radiusAtAngle` les recolle au contour au prorata du rayon local — ce qui divise leur
15
+ * marge par le meme prorata. Une forme etroite dans la direction de l'oeil le poussait
16
+ * contre le bord jusqu'a ce que le masque l'ouvre : la gelule apparaissait comme une
17
+ * encoche dans le corps.
18
+ * 2. **La correction ne se voit pas bouger.** Sept versions l'ont calculee dans la boucle
19
+ * de rendu et toutes tremblaient. Les tests d'oscillation ci-dessous sont ce qui les a
20
+ * disqualifiees, et ils comparent au CERCLE, la forme que la correction ne touche pas.
21
+ *
22
+ * Tout porte sur la GEOMETRIE RENDUE et non sur le calcul : on reprend le contour du corps
23
+ * et celui de chaque gelule tels que le composant les dessine. C'est la seule facon de
24
+ * verifier ce que l'oeil voit, et ca ne suppose rien de la methode employee.
25
+ */
26
+
27
+ const R = 100
28
+
29
+ /**
30
+ * Contour du corps, lu sur le `bodyPath`. `closedPath` emet `M x y` puis un `C` par point,
31
+ * donc les points de la courbe sont la 3e paire de chaque `C`.
32
+ */
33
+ function contourDuCorps(d: string) {
34
+ const pts: Array<{ x: number; y: number }> = []
35
+ for (const seg of d.slice(1).split('C')) {
36
+ const n = seg.match(/-?\d+\.?\d*/g)?.map(Number) ?? []
37
+ if (n.length >= 6) pts.push({ x: n[4]!, y: n[5]! })
38
+ else if (n.length === 2) pts.push({ x: n[0]!, y: n[1]! })
39
+ }
40
+ return pts
41
+ }
42
+
43
+ /** Le point est-il dans le polygone ? Lancer de rayon. */
44
+ function dedans(poly: Array<{ x: number; y: number }>, x: number, y: number) {
45
+ let on = false
46
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
47
+ const a = poly[i]!
48
+ const b = poly[j]!
49
+ if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) on = !on
50
+ }
51
+ return on
52
+ }
53
+
54
+ /**
55
+ * Contour d'une gelule en coordonnees ecran : un stade, echantillonne puis passe par la
56
+ * matrice rendue. Les dimensions sont relues dans le `d` que `capsulePath` a produit
57
+ * (`M-hw -hh+r A r r ...`), pour ne dependre que de la sortie.
58
+ *
59
+ * Trente-deux points suffisent : sur une gelule de 40 unites de long ils sont espaces de
60
+ * moins de trois unites, la ou le plus petit debordement qu'on ait mesure en vaut trois.
61
+ */
62
+ function contourDeLOeil(eye: RenderedEye, N = 32) {
63
+ const g = eye.d.match(/-?\d+\.?\d*/g)!.map(Number)
64
+ const hw = Math.abs(g[0]!)
65
+ const r = Math.abs(g[2]!)
66
+ const droit = Math.abs(g[1]!)
67
+ const m = eye.matrix.match(/-?\d+\.?\d*/g)!.map(Number)
68
+ const [a, b, c, d, e, f] = m as [number, number, number, number, number, number]
69
+ const out: Array<{ x: number; y: number }> = []
70
+ for (let i = 0; i < N; i++) {
71
+ const u = (i / N) * 4
72
+ let x: number
73
+ let y: number
74
+ if (u < 1) {
75
+ const t = Math.PI * (u - 0.5)
76
+ x = Math.cos(t) * r
77
+ y = -droit + Math.sin(t) * r
78
+ } else if (u < 2) {
79
+ x = hw
80
+ y = -droit + (u - 1) * 2 * droit
81
+ } else if (u < 3) {
82
+ const t = Math.PI * (u - 2 + 0.5)
83
+ x = Math.cos(t) * r
84
+ y = droit + Math.sin(t) * r
85
+ } else {
86
+ x = -hw
87
+ y = droit - (u - 3) * 2 * droit
88
+ }
89
+ out.push({ x: a * x + c * y + e, y: b * x + d * y + f })
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** Trois secondes a vingt images par seconde : assez pour plusieurs clignements. */
95
+ const INSTANTS = 60
96
+ const PAS = 1 / 20
97
+
98
+ /**
99
+ * De combien la gelule la plus debordante sort du corps, en unites de viewBox.
100
+ *
101
+ * Balaye le TEMPS et pas seulement la combinaison. La derive du regard deplace l'oeil
102
+ * d'une douzaine d'unites, donc une seule image ne prouve rien : c'est en ne mesurant qu'a
103
+ * `POSES[state]` que `capsule` + `effraye` est passe inapercu, alors qu'il sortait de
104
+ * 4,4 unites une seconde plus tard.
105
+ */
106
+ function debordement(state: StateId, radii: number[], expr: (typeof EXPRESSIONS)[number] | null) {
107
+ const e = new BotEngine(R, state, radii, expr)
108
+ let pire = 0
109
+ for (let i = 0; i < INSTANTS; i++) {
110
+ const f = e.sample(i * PAS)
111
+ const corps = contourDuCorps(f.bodyPath)
112
+ for (const eye of f.eyes) {
113
+ for (const p of contourDeLOeil(eye)) {
114
+ if (dedans(corps, p.x, p.y)) continue
115
+ pire = Math.max(pire, Math.min(...corps.map((q) => Math.hypot(q.x - p.x, q.y - p.y))))
116
+ }
117
+ }
118
+ }
119
+ return pire
120
+ }
121
+
122
+ /** Les etats dont le corps est remplacable par une forme du personnalisateur. */
123
+ const CORPS_DE_BASE = STATES.filter((s) => s.baseBody).map((s) => s.id)
124
+ /** Les autres : leur silhouette EST l'animation, relevee sur la video. */
125
+ const SILHOUETTE_MESUREE = STATES.filter((s) => !s.baseBody).map((s) => s.id)
126
+
127
+ describe('formes du personnalisateur', () => {
128
+ // 680 combinaisons x 60 instants x deux contours : le test le plus lourd du depot, et le
129
+ // seul qui prouve ce que l'oeil voit.
130
+ it("aucune forme ne laisse un oeil sortir de la silhouette", () => {
131
+ const fautifs: string[] = []
132
+ for (const state of CORPS_DE_BASE) {
133
+ for (const forme of SHAPES) {
134
+ for (const expr of [null, ...EXPRESSIONS]) {
135
+ const sortie = debordement(state, forme.radii, expr)
136
+ if (sortie > 0.05) {
137
+ fautifs.push(`${state}/${forme.id}/${expr?.id ?? 'pose'} ${sortie.toFixed(1)}`)
138
+ }
139
+ }
140
+ }
141
+ }
142
+ // Cinq couples etat x forme debordaient : `wide`+`capsule` de 14,5 unites sur une boule
143
+ // de rayon 100, `wide`+`triangle` de 11,9, `idle`+`capsule` et `swirl`+`capsule` de 5,3,
144
+ // `notify`+`goutte` de 3,3.
145
+ expect(fautifs).toEqual([])
146
+ }, 30_000)
147
+
148
+ /**
149
+ * Le cercle est la forme relevee sur la video, et le corps par defaut : le choisir dans
150
+ * le personnalisateur ne doit RIEN changer par rapport a ne rien choisir. C'est ce qui
151
+ * garantit que la correction est neutre sur la reference — y compris son oeil exterieur,
152
+ * qui frole deja le bord et doit continuer de le froler. C'est aussi ce qui protege
153
+ * `public/favicon.svg`, dont les deux matrices d'yeux sont celles de `sample(1)` sur
154
+ * `idle`, au byte.
155
+ */
156
+ it('choisir le cercle rend exactement la meme chose que ne rien choisir', () => {
157
+ expect(DEFAULT_SHAPE).toBe('cercle')
158
+ const cercle = SHAPE_BY_ID.get('cercle')!.radii
159
+ for (const state of CORPS_DE_BASE) {
160
+ for (const expr of [null, ...EXPRESSIONS]) {
161
+ const avec = new BotEngine(R, state, cercle, expr).sample(1)
162
+ const sans = new BotEngine(R, state, null, expr).sample(1)
163
+ expect(avec.eyes, `${state}/${expr?.id ?? 'pose'}`).toEqual(sans.eyes)
164
+ }
165
+ expect(decalageDesYeux(cercle, state, null)).toEqual({ x: 0, y: 0 })
166
+ }
167
+ })
168
+
169
+ /**
170
+ * Les silhouettes relevees sur la video ne sont pas remplacables, donc la forme choisie
171
+ * ne doit pas les atteindre — ni leur corps, ni leurs yeux. `orbit` est le cas qui
172
+ * compte : la marge de son oeil est plus serree que celle du cercle et elle est pourtant
173
+ * juste, puisque relevee ainsi. Une regle de marge appliquee sans distinction la
174
+ * deplacerait.
175
+ */
176
+ it("la forme choisie ne touche pas aux etats a silhouette mesuree", () => {
177
+ for (const state of SILHOUETTE_MESUREE) {
178
+ const nu = new BotEngine(R, state, null, null).sample(1)
179
+ for (const forme of SHAPES) {
180
+ const habille = new BotEngine(R, state, forme.radii, null).sample(1)
181
+ expect(habille.eyes, `${state}/${forme.id}`).toEqual(nu.eyes)
182
+ expect(habille.bodyPath).toBe(nu.bodyPath)
183
+ }
184
+ }
185
+ })
186
+
187
+ /**
188
+ * La correction ne doit RIEN modifier a la taille des yeux.
189
+ *
190
+ * Une version la mettait a l'echelle en meme temps que la position : ca conservait tous
191
+ * les rapports du visage et restait stable, mais les yeux devenaient visiblement plus
192
+ * petits sur un corps plat, et changer d'expression animait alors ce grossissement. Ca se
193
+ * lisait comme un defaut.
194
+ */
195
+ it('la taille des yeux ne depend pas de la forme', () => {
196
+ const tailles = new Set(
197
+ SHAPES.map((f) =>
198
+ new BotEngine(R, 'idle', f.radii, null)
199
+ .sample(1)
200
+ .eyes.map((y) => y.d)
201
+ .join('|')
202
+ )
203
+ )
204
+ expect(tailles.size).toBe(1)
205
+ })
206
+
207
+ /**
208
+ * Le vrai piege : la correction doit etre INVISIBLE en mouvement.
209
+ *
210
+ * Ce qu'on mesure est l'OSCILLATION et non la vitesse. Un morph deplace les yeux vite de
211
+ * toute facon — les expressions ne regardent pas au meme endroit — et c'est du mouvement
212
+ * voulu. Trembler, c'est aller et REVENIR. On compte donc les changements de sens image
213
+ * par image, et on les compare au cercle, que la correction ne touche pas.
214
+ *
215
+ * La mesure discrimine tres bien : 0 a 1 aller-retour sur le moteur d'origine, 4 a 14
216
+ * avec des reculs jusqu'a 26 unites sur les versions qui tremblaient.
217
+ */
218
+ it('la correction ne fait pas osciller les yeux', () => {
219
+ const pas = 1 / 60
220
+
221
+ const trajectoire = (bâti: () => BotEngine, duree = BotEngine.SHAPE_MORPH) => {
222
+ const e = bâti()
223
+ const out: Array<Array<{ x: number; y: number }>> = []
224
+ for (let t = 0; t <= duree + pas; t += pas) {
225
+ out.push(
226
+ e.sample(t).eyes.map((eye) => {
227
+ const n = eye.matrix.match(/-?\d+\.?\d*/g)!.map(Number)
228
+ return { x: n[4]!, y: n[5]! }
229
+ })
230
+ )
231
+ }
232
+ return out
233
+ }
234
+
235
+ /** Combien de fois un oeil repart en arriere, et de combien au plus. */
236
+ const allersRetours = (suite: Array<Array<{ x: number; y: number }>>) => {
237
+ let n = 0
238
+ let amplitude = 0
239
+ const yeux = Math.min(...suite.map((f) => f.length))
240
+ for (let j = 0; j < yeux; j++) {
241
+ let px = 0
242
+ let py = 0
243
+ for (let i = 1; i < suite.length; i++) {
244
+ const dx = suite[i]![j]!.x - suite[i - 1]![j]!.x
245
+ const dy = suite[i]![j]!.y - suite[i - 1]![j]!.y
246
+ const len = Math.hypot(dx, dy)
247
+ if (len <= 0.05) continue
248
+ if ((px || py) && dx * px + dy * py < 0) {
249
+ n++
250
+ amplitude = Math.max(amplitude, len)
251
+ }
252
+ px = dx
253
+ py = dy
254
+ }
255
+ }
256
+ return { n, amplitude }
257
+ }
258
+
259
+ const cercle = SHAPE_BY_ID.get('cercle')!.radii
260
+ const morphDeForme = (radii: number[]) =>
261
+ allersRetours(
262
+ trajectoire(() => {
263
+ const e = new BotEngine(R, 'idle', cercle, null)
264
+ e.setShape(radii, 0)
265
+ return e
266
+ })
267
+ )
268
+ const auRepos = (radii: number[]) =>
269
+ allersRetours(trajectoire(() => new BotEngine(R, 'idle', radii, null), 3))
270
+ const morphsDExpression = (radii: number[]) =>
271
+ EXPRESSIONS.map((expr) =>
272
+ allersRetours(
273
+ trajectoire(() => {
274
+ const e = new BotEngine(R, 'idle', radii, EXPRESSIONS[0]!)
275
+ e.setExpression(expr, 0)
276
+ return e
277
+ })
278
+ )
279
+ ).reduce((a, b) => ({ n: a.n + b.n, amplitude: Math.max(a.amplitude, b.amplitude) }), {
280
+ n: 0,
281
+ amplitude: 0
282
+ })
283
+
284
+ /*
285
+ * Sur le CERCLE, rien du tout : la correction y est nulle, donc elle ne peut ajouter
286
+ * aucun mouvement, et c'est la reference des trois comparaisons.
287
+ */
288
+ expect(morphsDExpression(cercle).n, 'cercle : morphs d expression').toBe(0)
289
+
290
+ for (const forme of SHAPES) {
291
+ // Au repos, la seule chose qui bouge est la derive du regard. Une correction qui la
292
+ // suivrait ferait trembler les yeux en permanence : c'est le defaut le plus visible
293
+ // de tous, et le premier qu'on a eu.
294
+ expect(auRepos(forme.radii).n, `${forme.id} : derive au repos`).toBeLessThanOrEqual(
295
+ auRepos(cercle).n + 1
296
+ )
297
+ // Un changement de forme fait bouger le decalage, mais sur la meme courbe que la
298
+ // silhouette : aucun aller-retour de plus que le cercle.
299
+ expect(morphDeForme(forme.radii).n, `morph de forme vers ${forme.id}`).toBeLessThanOrEqual(
300
+ morphDeForme(cercle).n + 1
301
+ )
302
+ /*
303
+ * Un changement d'expression fait passer le decalage d'une entree de table a l'autre.
304
+ * Sept formes sur huit n'y gagnent aucun aller-retour ; la `goutte` passe de un
305
+ * rebroussement de 6,3 unites — deja present sans correction — a deux de 11,1. La
306
+ * borne laisse passer ca et rien de plus : les versions fautives etaient a 26.
307
+ */
308
+ expect(
309
+ morphsDExpression(forme.radii).amplitude,
310
+ `${forme.id} : morphs d expression`
311
+ ).toBeLessThan(14)
312
+ }
313
+ })
314
+
315
+ /**
316
+ * La table est une constante de module. Elle doit rester assez rapide a batir pour ne pas
317
+ * peser sur le premier affichage : c'est le seul cout que ce correctif ajoute au moteur,
318
+ * qui ne fait plus ensuite qu'y lire deux entrees et les interpoler.
319
+ */
320
+ it('la table se batit en quelques millisecondes', () => {
321
+ const t = performance.now()
322
+ POUR_TESTS.batir()
323
+ expect(performance.now() - t).toBeLessThan(200)
324
+ })
325
+ })