@springbrand/message-panel 0.1.3-alpha.8 → 0.2.0-alpha.37

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 +38 -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 +132 -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,558 @@
1
+ import { arcRender, type ArcRender, type DotRender } from './decor'
2
+ import { blendExpression, type BotExpression } from './expressions'
3
+ import { decalageDesYeux } from './eyefit'
4
+ import { blinkScale, eyePoses, liveliness } from './face'
5
+ import { clamp, easings, lerp, r2 } from './math'
6
+ import {
7
+ blend,
8
+ capsulePath,
9
+ closedPath,
10
+ radiusAtAngle,
11
+ toPoints,
12
+ type Point,
13
+ type Silhouette
14
+ } from './shape'
15
+ import { STATE_BY_ID, type Pose, type StateDef, type StateId } from './states'
16
+
17
+ export interface RenderedEye {
18
+ d: string
19
+ matrix: string
20
+ alpha: number
21
+ }
22
+
23
+ export interface BotFrame {
24
+ bodyPath: string
25
+ bodyAlpha: number
26
+ eyes: RenderedEye[]
27
+ dots: DotRender[]
28
+ /** true = les points passent derriere le corps (particules de l'eclatement) */
29
+ dotsBehind: boolean
30
+ arcs: ArcRender[]
31
+ notif: { x: number; y: number; r: number } | null
32
+ notch: { x: number; y: number; r: number } | null
33
+ }
34
+
35
+ /**
36
+ * Ou le bot porte son regard quand quelque chose d'exterieur le pilote — le
37
+ * pointeur de la souris, aujourd'hui.
38
+ *
39
+ * `yaw` et `pitch` sont des directions ABSOLUES, qui remplacent celles de la pose
40
+ * a mesure que `mix` monte. Deux raisons, chacune un piege deja tombe :
41
+ *
42
+ * - c'est le MOTEUR qui doit faire ce melange, pas l'appelant, parce que lui seul
43
+ * connait la pose A CET INSTANT. Un appelant qui compenserait l'orientation de
44
+ * l'expression lirait sa valeur d'arrivee pendant que le morph est encore en
45
+ * cours, et les yeux sautaient a chaque changement d'humeur ;
46
+ * - et il faut que ce soit absolu sur les DEUX axes. En relatif, la hauteur des
47
+ * yeux suivait celle de chaque expression — « neutre » regarde a +28,6deg quand
48
+ * les autres sont entre -9 et +9 — donc les yeux tombaient d'un coup au premier
49
+ * changement d'humeur. Ce qui fait le caractere d'une expression pendant le
50
+ * suivi, c'est la FORME de ses yeux (plisses, ronds, dissymetriques), pas
51
+ * l'endroit ou elle regarde : celui-la, c'est le curseur qui le decide.
52
+ *
53
+ * `mix` dit a quel point l'exterieur commande la DIRECTION (0 = pas du tout).
54
+ *
55
+ * `wander` dit, separement, ce qui reste de derive automatique. Les deux ne se
56
+ * confondent pas : quand le pointeur bouge, la derive doit s'eteindre — cumulees,
57
+ * le bot aurait l'air de chercher le curseur sans jamais le tenir. Mais quand il
58
+ * n'y a PAS de pointeur (arrivee au clavier, au tactile, ou souris sortie de la
59
+ * fenetre), la tete doit rester tournee ET continuer de vivre. Les confondre
60
+ * figeait le regard des que la vue s'ouvrait.
61
+ *
62
+ * `spin` est un tour a parcourir EN CHEMIN, en degres, qu'on fait fondre vers 0
63
+ * avec l'arrivee. Comme les yeux vivent sur une sphere, un tour les fait passer
64
+ * derriere la boule et revenir de l'autre cote — et `-360deg` etant le meme
65
+ * angle que `0`, il ne change rien a l'endroit ou ils se posent.
66
+ */
67
+ export interface Look {
68
+ yaw: number
69
+ pitch: number
70
+ mix: number
71
+ spin: number
72
+ wander: number
73
+ }
74
+
75
+ const NO_LOOK: Look = { yaw: 0, pitch: 0, mix: 0, spin: 0, wander: 1 }
76
+
77
+ const lerpLook = (a: Look, b: Look, t: number): Look => ({
78
+ yaw: lerp(a.yaw, b.yaw, t),
79
+ pitch: lerp(a.pitch, b.pitch, t),
80
+ mix: lerp(a.mix, b.mix, t),
81
+ spin: lerp(a.spin, b.spin, t),
82
+ wander: lerp(a.wander, b.wander, t)
83
+ })
84
+
85
+ const lerpEye = (a: Pose['eyes'][number], b: Pose['eyes'][number], t: number) => ({
86
+ w: lerp(a.w, b.w, t),
87
+ h: lerp(a.h, b.h, t),
88
+ open: lerp(a.open, b.open, t),
89
+ tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t)
90
+ })
91
+
92
+ /** Interpolation de deux poses. Le decor se croise en opacite, pas en geometrie. */
93
+ function blendPose(a: Pose, b: Pose, t: number): Pose {
94
+ const out = 1 - t
95
+ return {
96
+ sil: blend(a.sil, b.sil, t),
97
+ offX: lerp(a.offX, b.offX, t),
98
+ offY: lerp(a.offY, b.offY, t),
99
+ gaze: {
100
+ yaw: lerp(a.gaze.yaw, b.gaze.yaw, t),
101
+ pitch: lerp(a.gaze.pitch, b.gaze.pitch, t),
102
+ roll: lerp(a.gaze.roll, b.gaze.roll, t)
103
+ },
104
+ split: lerp(a.split, b.split, t),
105
+ eyes: [lerpEye(a.eyes[0], b.eyes[0], t), lerpEye(a.eyes[1], b.eyes[1], t)],
106
+ eyeAlpha: lerp(a.eyeAlpha, b.eyeAlpha, t),
107
+ bodyAlpha: lerp(a.bodyAlpha, b.bodyAlpha, t),
108
+ dots: [
109
+ ...a.dots.map((d) => ({ ...d, opacity: d.opacity * out })),
110
+ ...b.dots.map((d) => ({ ...d, opacity: d.opacity * t }))
111
+ ],
112
+ arcs: [
113
+ ...a.arcs.map((r) => ({ ...r, id: `a${r.id}`, opacity: r.opacity * out })),
114
+ ...b.arcs.map((r) => ({ ...r, id: `b${r.id}`, opacity: r.opacity * t }))
115
+ ],
116
+ // la pastille appartient a un seul des deux etats, elle ne se melange pas
117
+ notif: t < 0.5 ? a.notif : b.notif,
118
+ dotsBehind: t < 0.5 ? a.dotsBehind : b.dotsBehind
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Moteur sans horloge : `sample(t)` est une fonction pure du temps.
124
+ *
125
+ * Consequence pratique : pause, reprise, ralenti et saut a une date arbitraire
126
+ * donnent exactement la meme image, et le rendu est testable sans DOM.
127
+ */
128
+ export class BotEngine {
129
+ /** rayon de la boule au repos, en unites de viewBox */
130
+ readonly scale: number
131
+
132
+ private cur: StateId
133
+ private prev: StateId | null = null
134
+ /**
135
+ * Pose de depart FIGEE, posee seulement quand un changement d'etat arrive alors qu'un
136
+ * fondu est deja en cours. Cf. `setState`.
137
+ */
138
+ private departFige: Pose | null = null
139
+ private tCur = 0
140
+ private tPrev = 0
141
+ private blinkAt = -10
142
+ private pts: Point[] = []
143
+ private shape: number[] | null = null
144
+ private shapePrev: number[] | null = null
145
+ private shapeAt = -10
146
+ private expr: BotExpression | null = null
147
+ private exprPrev: BotExpression | null = null
148
+ private exprAt = -10
149
+ private look: Look = NO_LOOK
150
+ private lookPrev: Look = NO_LOOK
151
+ private lookAt = -10
152
+ /** duree de rattrapage en cours ; voir `LOOK_MORPH`, sa valeur par defaut */
153
+ private lookMorph = 0.24
154
+
155
+ /** duree du morph quand on change la forme du corps */
156
+ static readonly SHAPE_MORPH = 0.45
157
+
158
+ /**
159
+ * Duree de rattrapage du regard vers la cible. Plus court que `SHAPE_MORPH` :
160
+ * un regard qui suit doit paraitre attentif, pas visqueux. Comme la cible est
161
+ * reposee a chaque mouvement de souris, c'est cette duree qui donne au suivi
162
+ * son inertie — le regard n'atteint jamais tout a fait un curseur qui bouge.
163
+ */
164
+ static readonly LOOK_MORPH = 0.24
165
+
166
+ constructor(
167
+ scale = 100,
168
+ initial: StateId = 'idle',
169
+ shape: number[] | null = null,
170
+ expression: BotExpression | null = null
171
+ ) {
172
+ this.scale = scale
173
+ this.cur = initial
174
+ this.shape = shape
175
+ this.expr = expression
176
+ }
177
+
178
+ /**
179
+ * Expression de repos choisie dans le personnalisateur. Comme la forme, elle
180
+ * glisse vers la nouvelle valeur au lieu de sauter.
181
+ */
182
+ setExpression(expression: BotExpression | null, now = 0) {
183
+ if (expression === this.expr) return
184
+ this.exprPrev = this.expr
185
+ this.expr = expression
186
+ this.exprAt = now
187
+ }
188
+
189
+ /** Expression effective a l'instant `now`, morph en cours compris. */
190
+ private exprAtTime(now: number): BotExpression | null {
191
+ const to = this.expr
192
+ const from = this.exprPrev
193
+ if (!to || !from) return to
194
+ const k = (now - this.exprAt) / BotEngine.SHAPE_MORPH
195
+ if (k >= 1) return to
196
+ return blendExpression(from, to, easings.easeOutQuint(clamp(k)))
197
+ }
198
+
199
+ /**
200
+ * Forme choisie dans le personnalisateur. Elle ne remplace le corps que sur
201
+ * les etats au repos (`baseBody`) : sur les autres, la silhouette EST
202
+ * l'animation et ne doit pas etre ecrasee.
203
+ *
204
+ * Le changement se fait en morph, pas d'un coup : comme toutes les formes sont
205
+ * echantillonnees aux memes angles, il suffit d'interpoler les rayons.
206
+ */
207
+ setShape(radii: number[] | null, now = 0) {
208
+ if (radii === this.shape) return
209
+ this.shapePrev = this.shape
210
+ this.shape = radii
211
+ this.shapeAt = now
212
+ }
213
+
214
+ /**
215
+ * Forme effective a l'instant `now`, morph en cours compris.
216
+ *
217
+ * Ne remet PAS `shapePrev` a null en fin de morph : `sample` doit rester une
218
+ * fonction pure du temps, donc relire une date passee doit redonner l'image
219
+ * intermediaire. On garde juste une reference de plus.
220
+ */
221
+ private shapeAtTime(now: number): number[] | null {
222
+ const to = this.shape
223
+ const from = this.shapePrev
224
+ if (!to || !from) return to
225
+ const k = (now - this.shapeAt) / BotEngine.SHAPE_MORPH
226
+ if (k >= 1) return to
227
+ const t = easings.easeOutQuint(clamp(k))
228
+ // alloue seulement pendant le morph ; hors morph on rend le tableau tel quel
229
+ return to.map((r, i) => lerp(from[i] ?? r, r, t))
230
+ }
231
+
232
+ /**
233
+ * Nouvelle cible de regard, `null` pour revenir a celui de l'etat.
234
+ *
235
+ * Elle repart de la valeur COURANTE, et non de la cible precedente comme
236
+ * `setShape` : cette methode est appelee a chaque mouvement de pointeur, et
237
+ * repartir de l'ancienne cible ferait reculer le regard d'un cran avant
238
+ * chaque rattrapage — le suivi tremblerait au lieu de glisser.
239
+ *
240
+ * Meme contrat que `setShape` par ailleurs : l'etat externe entre par un
241
+ * setter horodate, jamais par une variable lue pendant `sample`, sinon le
242
+ * moteur cesse d'etre une fonction pure du temps.
243
+ */
244
+ setLook(look: Look | null, now: number, morph = BotEngine.LOOK_MORPH) {
245
+ /*
246
+ * Une cible non finie est refusee. Le moteur GARDE la derniere : un `NaN`
247
+ * pose une seule fois se propagerait a chaque image et le bot ne se
248
+ * reposerait plus jamais. C'est arrive pour de vrai — un
249
+ * `getBoundingClientRect` sur une boite de taille nulle donne `0 / 0` chez
250
+ * l'appelant. Celui-la est corrige, mais le moteur n'a pas a dependre de la
251
+ * prudence de ses appelants pour rester rejouable.
252
+ */
253
+ if (look && !Number.isFinite(look.yaw + look.pitch + look.mix + look.spin + look.wander)) {
254
+ return
255
+ }
256
+ this.lookPrev = this.lookAtTime(now)
257
+ this.look = look ?? NO_LOOK
258
+ this.lookAt = now
259
+ this.lookMorph = morph
260
+ }
261
+
262
+ /** Regard effectif a l'instant `now`, rattrapage en cours compris. */
263
+ private lookAtTime(now: number): Look {
264
+ const k = (now - this.lookAt) / this.lookMorph
265
+ if (k >= 1) return this.look
266
+ return lerpLook(this.lookPrev, this.look, easings.easeOutQuint(clamp(k)))
267
+ }
268
+
269
+ private posed(
270
+ def: StateDef,
271
+ t: number,
272
+ shape: number[] | null,
273
+ expr: BotExpression | null
274
+ ): Pose {
275
+ let pose = def.pose(t)
276
+ if (def.baseBody && shape) {
277
+ // on garde la pose (rotation, decalage, squash) et on n'echange que le profil
278
+ pose = { ...pose, sil: { ...pose.sil, radii: shape } }
279
+ }
280
+ if (def.baseFace && expr) {
281
+ pose = { ...pose, gaze: expr.gaze, split: expr.split, eyes: expr.eyes }
282
+ }
283
+ return pose
284
+ }
285
+
286
+ /**
287
+ * Decalage des yeux a l'instant `now` pour un etat donne, en unites de rayon de boule.
288
+ *
289
+ * Il est LU dans une table et interpole, jamais recalcule : `eyefit.ts` explique
290
+ * pourquoi cette distinction est tout le correctif. Ici il ne reste qu'a l'interpoler
291
+ * sur l'axe de la forme, avec exactement la courbe et la duree du morph de silhouette
292
+ * — c'est la meme cause, donc ce doit etre le meme mouvement.
293
+ *
294
+ * On interroge la table sur les BORNES du morph (`shapePrev` et `shape`) et non sur le
295
+ * profil que rend `shapeAtTime` : celui-la est un tableau neuf alloue a chaque image,
296
+ * donc sans identite, et il n'existe dans aucune table.
297
+ */
298
+ private decalageAtTime(now: number, state: StateId): { x: number; y: number } {
299
+ /**
300
+ * Un axe de morph : on lit la table sur ses deux BORNES et on interpole avec sa
301
+ * courbe. Jamais sur la valeur interpolee — celle-la n'a pas d'identite et n'existe
302
+ * dans aucune table, et c'est en la lui donnant a manger que les versions
303
+ * precedentes tremblaient.
304
+ */
305
+ const surAxe = (
306
+ debut: number,
307
+ duree: number,
308
+ a: { x: number; y: number },
309
+ b: { x: number; y: number }
310
+ ) => {
311
+ if (a === b) return b
312
+ const k = (now - debut) / duree
313
+ if (k >= 1) return b
314
+ const t = easings.easeOutQuint(clamp(k))
315
+ return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) }
316
+ }
317
+
318
+ // axe de l'expression, pour chacune des deux formes en presence
319
+ const parForme = (radii: number[] | null) =>
320
+ surAxe(
321
+ this.exprAt,
322
+ BotEngine.SHAPE_MORPH,
323
+ decalageDesYeux(radii, state, this.exprPrev?.id ?? null),
324
+ decalageDesYeux(radii, state, this.expr?.id ?? null)
325
+ )
326
+
327
+ // puis axe de la forme
328
+ return surAxe(
329
+ this.shapeAt,
330
+ BotEngine.SHAPE_MORPH,
331
+ parForme(this.shapePrev),
332
+ parForme(this.shape)
333
+ )
334
+ }
335
+
336
+ get state(): StateId {
337
+ return this.cur
338
+ }
339
+
340
+ /**
341
+ * Repart sur `id` SANS etat precedent, comme un moteur neuf pose sur cet etat.
342
+ *
343
+ * C'est ce que veut dire « rembobiner » pour ce moteur. `setState` seul ne peut pas le
344
+ * faire : il garde l'etat quitte pour le fondre, ce qui est exactement son role en
345
+ * lecture, et exactement ce qu'il ne faut pas quand on revient au debut d'une sequence.
346
+ * Rejouer l'image 0 apres une passe complete melangeait le premier etat avec le DERNIER,
347
+ * et l'export GIF s'ouvrait sur une boule sans yeux — la comete a un `eyeAlpha` nul.
348
+ *
349
+ * `sample` reste une fonction pure du temps : comme `setState`, ceci est un setter DATE,
350
+ * appele par le pilote de la sequence, jamais pendant un echantillonnage.
351
+ */
352
+ reset(id: StateId, now: number) {
353
+ this.cur = id
354
+ this.prev = null
355
+ this.departFige = null
356
+ this.tCur = now
357
+ this.tPrev = now
358
+ this.blinkAt = -10
359
+ }
360
+
361
+ /**
362
+ * Origine du fondu en cours : la pose figee s'il y en a une, sinon l'etat quitte evalue
363
+ * a son propre temps ecoule — donc encore en train de s'animer, ce qui est voulu.
364
+ */
365
+ private origine(
366
+ now: number,
367
+ shape: number[] | null,
368
+ expr: BotExpression | null
369
+ ): Pose | null {
370
+ if (this.departFige) return this.departFige
371
+ if (!this.prev) return null
372
+ const prevDef = STATE_BY_ID.get(this.prev)!
373
+ return this.posed(prevDef, Math.max(0, now - this.tPrev), shape, expr)
374
+ }
375
+
376
+ /**
377
+ * Pose composite a l'instant `now`, fondu en cours compris : exactement ce que `sample`
378
+ * melange, avant la couche de vie au repos et de regard. Extraite pour que `setState`
379
+ * puisse la figer.
380
+ */
381
+ private poseComposee(now: number): Pose {
382
+ const def = STATE_BY_ID.get(this.cur)!
383
+ const shape = this.shapeAtTime(now)
384
+ const expr = this.exprAtTime(now)
385
+ const pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr)
386
+ const since = now - this.tCur
387
+ if (since >= def.morph) return pose
388
+ const origine = this.origine(now, shape, expr)
389
+ if (!origine) return pose
390
+ return blendPose(origine, pose, easings.easeOutQuint(clamp(since / def.morph)))
391
+ }
392
+
393
+ /**
394
+ * Changement d'etat, date.
395
+ *
396
+ * Le moteur ne garde qu'UNE case d'historique, donc un changement qui arrive pendant un
397
+ * fondu remplacait l'origine du melange par la pose PLEINE de l'etat qu'on quittait, au
398
+ * lieu de l'image partiellement melangee qui etait a l'ecran. Mesure sur
399
+ * `idle -> wide -> idle` a 100 ms : 35,9 px de saut contre 8,0 px de mouvement normal.
400
+ *
401
+ * On fige donc la pose composite courante et on melange depuis elle. Continu par
402
+ * construction, quel que soit le nombre de changements enchaines.
403
+ *
404
+ * Et SEULEMENT dans ce cas. Figer a chaque changement arreterait net l'animation de
405
+ * l'etat qu'on quitte pendant tout le fondu — le « ! » d'`alert` se figerait en pleine
406
+ * course — alors qu'il n'y a rien a corriger hors morph : l'etat quitte y est deja
407
+ * exactement l'image affichee. La lecture d'un montage, dont les blocs durent au moins
408
+ * le plus long fondu (`MIN_BLOCK`), ne fige donc jamais rien et rend au bit ce qu'elle
409
+ * rendait.
410
+ */
411
+ setState(id: StateId, now: number) {
412
+ if (id === this.cur) return
413
+ const morph = STATE_BY_ID.get(this.cur)!.morph
414
+ const enPleinFondu = this.prev !== null && now - this.tCur < morph
415
+ this.departFige = enPleinFondu ? this.poseComposee(now) : null
416
+ this.prev = this.cur
417
+ this.tPrev = this.tCur
418
+ this.cur = id
419
+ this.tCur = now
420
+ // Dans la video, chaque changement de forme est masque par un clignement.
421
+ if (STATE_BY_ID.get(id)?.blinkIn) this.blinkAt = now
422
+ }
423
+
424
+ sample(now: number): BotFrame {
425
+ const R = this.scale
426
+ const def = STATE_BY_ID.get(this.cur)!
427
+ const shape = this.shapeAtTime(now)
428
+ const expr = this.exprAtTime(now)
429
+ let pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr)
430
+ let decalage = this.decalageAtTime(now, this.cur)
431
+
432
+ // --- transition -------------------------------------------------------
433
+ const since = now - this.tCur
434
+ // L'etat precedent n'est jamais purge : `since < def.morph` suffit a
435
+ // l'ignorer une fois le fondu passe, et l'oublier rendrait le moteur non
436
+ // rejouable — relire une date d'avant la fin du fondu ne le retrouverait
437
+ // plus. C'est l'optimisation qui parait innocente et qui casse tout.
438
+ const origine = since < def.morph ? this.origine(now, shape, expr) : null
439
+ if (origine) {
440
+ // Ease-out exponentiel : c'est la courbe mesuree sur la video. Le corps
441
+ // n'a pas d'overshoot (seuls la pastille et l'ouverture des yeux en ont).
442
+ // Le ratio est borne : relire une date ANTERIEURE au changement d'etat
443
+ // donnerait un ratio negatif, que l'ease-out extrapole — la silhouette
444
+ // part alors trente fois trop loin.
445
+ const ratio = easings.easeOutQuint(clamp(since / def.morph))
446
+ pose = blendPose(origine, pose, ratio)
447
+ // Le decalage des yeux suit la MEME courbe que la silhouette qui le motive. Il vient
448
+ // de l'etat quitte, que `setState` renseigne toujours en meme temps que l'origine —
449
+ // le test est la pour le typage, pas pour un cas reel.
450
+ const quitte = this.prev
451
+ if (quitte) {
452
+ const avant = this.decalageAtTime(now, quitte)
453
+ decalage = {
454
+ x: lerp(avant.x, decalage.x, ratio),
455
+ y: lerp(avant.y, decalage.y, ratio)
456
+ }
457
+ }
458
+ }
459
+
460
+ // --- vie au repos -----------------------------------------------------
461
+ const alive = pose.eyeAlpha > 0.01
462
+ const look = this.lookAtTime(now)
463
+ const life = liveliness(now, { wander: alive ? look.wander : 0, blink: alive })
464
+
465
+ const gaze = {
466
+ // Les deux visees REMPLACENT celles de la pose au lieu de s'y ajouter (voir
467
+ // `Look`), et le tour se retranche en chemin. La derive s'ajoute APRES le
468
+ // melange, sinon la cible l'annulerait en meme temps que la pose — or elle
469
+ // doit survivre a une tete tournee sans pointeur.
470
+ yaw: lerp(pose.gaze.yaw, look.yaw, look.mix) + life.dYaw - look.spin,
471
+ pitch: lerp(pose.gaze.pitch, look.pitch, look.mix) + life.dPitch,
472
+ // le roulis, lui, ne suit rien : la tete du bot est penchee de -13deg dans
473
+ // la video, et la faire rouler avec le curseur casse cette signature
474
+ roll: pose.gaze.roll + life.dRoll
475
+ }
476
+
477
+ // clignement declenche par le changement d'etat, en plus du calendrier
478
+ const forced = clamp((now - this.blinkAt) / 0.2)
479
+ const forcedLid = forced < 1 ? Math.abs(forced * 2 - 1) : 1
480
+ const lid = Math.min(life.lid, forcedLid)
481
+
482
+ const offX = pose.offX + life.driftX
483
+ const offY = pose.offY + life.driftY
484
+
485
+ // --- corps ------------------------------------------------------------
486
+ const sil: Silhouette = {
487
+ ...pose.sil,
488
+ cx: pose.sil.cx + offX,
489
+ cy: pose.sil.cy + offY,
490
+ sy: pose.sil.sy * life.breath
491
+ }
492
+ const bodyPath = closedPath(toPoints(sil, R, this.pts))
493
+
494
+ // --- yeux -------------------------------------------------------------
495
+ // Les yeux vivent sur une sphere de rayon 1 ; des que la silhouette n'est
496
+ // plus un cercle, on les ramene au prorata du rayon reel dans leur
497
+ // direction, sinon ils debordent et le masque les coupe.
498
+ const bodyRadius = (x: number, y: number) =>
499
+ radiusAtAngle(pose.sil.radii, Math.atan2(y, x) - pose.sil.rot)
500
+
501
+ const eyes: RenderedEye[] = []
502
+ if (pose.eyeAlpha > 0.01) {
503
+ const poses = eyePoses(gaze, R, pose.split)
504
+ for (let i = 0; i < 2; i++) {
505
+ const e = poses[i]!
506
+ if (e.depth <= 0.02) continue
507
+ const cfg = pose.eyes[i]!
508
+ const fit = bodyRadius(e.x, e.y)
509
+ // Inclinaison propre de l'oeil : on compose le repere tangent avec une
510
+ // rotation dans le plan de l'oeil (Basis x Rot). C'est ce qui permet des
511
+ // inclinaisons en miroir entre les deux yeux.
512
+ const phi = ((cfg.tilt ?? 0) * Math.PI) / 180
513
+ const cp = Math.cos(phi)
514
+ const sp = Math.sin(phi)
515
+ const ax = e.a * cp + e.c * sp
516
+ const ay = e.b * cp + e.d * sp
517
+ const cx2 = -e.a * sp + e.c * cp
518
+ const cy2 = -e.b * sp + e.d * cp
519
+ // Le clignement s'applique APRES tout ca : c'est un ecrasement vertical
520
+ // a l'ecran, pas le long de l'axe de la gelule.
521
+ const k = blinkScale(Math.min(lid, cfg.open))
522
+ eyes.push({
523
+ d: capsulePath(cfg.w * R, cfg.h * R),
524
+ matrix: `matrix(${r2(ax)},${r2(ay * k)},${r2(cx2)},${r2(cy2 * k)},${r2(e.x * fit + (offX + decalage.x) * R)},${r2(e.y * fit + (offY + decalage.y) * R)})`,
525
+ alpha: pose.eyeAlpha * clamp(e.depth / 0.12)
526
+ })
527
+ }
528
+ }
529
+
530
+ // --- decor ------------------------------------------------------------
531
+ const dots = pose.dots
532
+ .filter((p) => p.opacity > 0.01 && p.r > 0.0005)
533
+ .map((p) => ({ ...p, x: (p.x + offX) * R, y: (p.y + offY) * R, r: p.r * R }))
534
+
535
+ // la pastille est posee sur le contour : elle suit donc la forme aussi
536
+ const nFit = pose.notif ? bodyRadius(pose.notif.x, pose.notif.y) : 1
537
+ const nx = pose.notif ? (pose.notif.x * nFit + offX) * R : 0
538
+ const ny = pose.notif ? (pose.notif.y * nFit + offY) * R : 0
539
+ const notif = pose.notif ? { x: nx, y: ny, r: pose.notif.r * R } : null
540
+ const notch = pose.notif ? { x: nx, y: ny, r: pose.notif.notch * R } : null
541
+
542
+ return {
543
+ bodyPath,
544
+ bodyAlpha: pose.bodyAlpha,
545
+ eyes,
546
+ dots,
547
+ dotsBehind: pose.dotsBehind,
548
+ // Les etats declarent des arcs en unites de rayon de boule ; le moteur
549
+ // est le seul a connaitre l'echelle du viewBox, donc c'est lui qui trace.
550
+ arcs: pose.arcs
551
+ .filter((a) => a.opacity > 0.01)
552
+ .map((a) => arcRender(a.seed, a.t, R, a.id, a.opacity)),
553
+ notif,
554
+ notch
555
+ }
556
+ }
557
+ }
558
+
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { BotEngine } from './engine'
3
+ import { EXPRESSIONS, EXPRESSION_BY_ID, blendExpression } from './expressions'
4
+ import { radiusAtAngle } from './shape'
5
+ import { SHAPE_BY_ID } from './skins'
6
+
7
+ const cercle = () => SHAPE_BY_ID.get('cercle')!.radii
8
+
9
+ /** Matrice de l'oeil rendu -> position, dimensions ecran et angle du grand axe. */
10
+ function rendu(matrix: string, w: number, h: number) {
11
+ const [a, b, c, d, e, f] = /matrix\(([^)]+)\)/.exec(matrix)![1]!.split(',').map(Number) as number[]
12
+ return {
13
+ x: e!,
14
+ y: f!,
15
+ largeur: Math.hypot(a!, b!) * w,
16
+ hauteur: Math.hypot(c!, d!) * h,
17
+ axe: (Math.atan2(d!, c!) * 180) / Math.PI - 90
18
+ }
19
+ }
20
+
21
+ describe('catalogue des expressions', () => {
22
+ it('expose 16 expressions aux identifiants uniques', () => {
23
+ expect(EXPRESSIONS).toHaveLength(16)
24
+ expect(new Set(EXPRESSIONS.map((e) => e.id)).size).toBe(16)
25
+ expect(EXPRESSION_BY_ID.size).toBe(16)
26
+ })
27
+
28
+ /**
29
+ * Le piege sur lequel on est tombe : un oeil dont le rapport largeur/hauteur
30
+ * approche 1 est un cercle, il a la meme allure a tout angle et son
31
+ * inclinaison est invisible. Toute expression qui compte sur une inclinaison
32
+ * doit donc avoir des yeux franchement allonges.
33
+ */
34
+ it('n incline que des yeux assez allonges pour que ca se voie', () => {
35
+ for (const e of EXPRESSIONS) {
36
+ for (const oeil of e.eyes) {
37
+ const tilt = Math.abs(oeil.tilt ?? 0)
38
+ if (tilt < 1) continue
39
+ const rapport = oeil.w / oeil.h
40
+ const seuil = tilt >= 20 ? [0.6, 1.7] : [0.8, 1.25]
41
+ expect(
42
+ rapport < seuil[0]! || rapport > seuil[1]!,
43
+ `${e.id}: rapport ${rapport.toFixed(2)} trop proche de 1 pour une inclinaison de ${tilt}deg`
44
+ ).toBe(true)
45
+ }
46
+ }
47
+ })
48
+
49
+ it('incline la colere et la tristesse en miroir, et dans des sens opposes', () => {
50
+ const angles = (id: string) => {
51
+ const f = new BotEngine(100, 'idle', cercle(), EXPRESSION_BY_ID.get(id)!).sample(1)
52
+ const e = EXPRESSION_BY_ID.get(id)!
53
+ return f.eyes.map((oeil, i) => rendu(oeil.matrix, e.eyes[i]!.w, e.eyes[i]!.h).axe)
54
+ }
55
+ const colere = angles('colere')
56
+ const triste = angles('triste')
57
+ // en miroir : les deux yeux penchent a l'oppose l'un de l'autre
58
+ expect(Math.sign(colere[0]!)).toBe(-Math.sign(colere[1]!))
59
+ expect(Math.sign(triste[0]!)).toBe(-Math.sign(triste[1]!))
60
+ // et les deux emotions sont inversees l'une par rapport a l'autre
61
+ expect(Math.sign(colere[0]!)).toBe(-Math.sign(triste[0]!))
62
+ })
63
+
64
+ it('garde les deux yeux dans la silhouette, sur les 16 expressions', () => {
65
+ for (const e of EXPRESSIONS) {
66
+ const f = new BotEngine(100, 'idle', cercle(), e).sample(1)
67
+ expect(f.eyes, e.id).toHaveLength(2)
68
+ for (let i = 0; i < 2; i++) {
69
+ const r = rendu(f.eyes[i]!.matrix, e.eyes[i]!.w, e.eyes[i]!.h)
70
+ // demi-diagonale de l'oeil : le coin le plus lointain doit rester dedans
71
+ const demi = Math.hypot(r.largeur, r.hauteur) / 2
72
+ const bord = radiusAtAngle(cercle(), Math.atan2(r.y, r.x)) * 100
73
+ expect(Math.hypot(r.x, r.y) + demi, `${e.id} oeil ${i}`).toBeLessThan(bord * 1.02)
74
+ }
75
+ }
76
+ })
77
+ })
78
+
79
+ describe('changement d expression', () => {
80
+ it('interpole la geometrie de facon monotone', () => {
81
+ // On mesure sur blendExpression, pas sur le rendu : la derive du regard au
82
+ // repos fait varier la projection, donc la hauteur a l'ecran n'est pas
83
+ // monotone meme quand l'interpolation, elle, l'est.
84
+ const de = EXPRESSION_BY_ID.get('neutre')!
85
+ const vers = EXPRESSION_BY_ID.get('effraye')!
86
+ const hauteurs = [0, 0.25, 0.5, 0.75, 1].map((t) => blendExpression(de, vers, t).eyes[0]!.h)
87
+ for (let i = 1; i < hauteurs.length; i++) {
88
+ expect(hauteurs[i]!).toBeGreaterThan(hauteurs[i - 1]!)
89
+ }
90
+ expect(hauteurs[0]!).toBeCloseTo(de.eyes[0]!.h, 5)
91
+ expect(hauteurs[4]!).toBeCloseTo(vers.eyes[0]!.h, 5)
92
+ })
93
+
94
+ it('glisse vers la nouvelle expression au lieu de sauter', () => {
95
+ const cible = EXPRESSION_BY_ID.get('effraye')!
96
+ const e = new BotEngine(100, 'idle', cercle(), EXPRESSION_BY_ID.get('neutre')!)
97
+ e.setExpression(cible, 1)
98
+
99
+ // juste apres le changement, l'oeil n'a pas encore la forme de la cible...
100
+ const tot = e.sample(1.02).eyes[0]!.d
101
+ const arrive = new BotEngine(100, 'idle', cercle(), cible).sample(1).eyes[0]!.d
102
+ expect(tot).not.toBe(arrive)
103
+ // ...et il l'a une fois le morph termine
104
+ expect(e.sample(1 + BotEngine.SHAPE_MORPH + 0.05).eyes[0]!.d).toBe(arrive)
105
+ })
106
+
107
+ it('reste une fonction pure du temps pendant le morph', () => {
108
+ const e = new BotEngine(100, 'idle', cercle(), EXPRESSION_BY_ID.get('neutre')!)
109
+ e.setExpression(EXPRESSION_BY_ID.get('colere')!, 1)
110
+ const milieu = e.sample(1.12).eyes[0]!.matrix
111
+ e.sample(3)
112
+ expect(e.sample(1.12).eyes[0]!.matrix).toBe(milieu)
113
+ })
114
+
115
+ it('n applique l expression qu a l etat de repos', () => {
116
+ const expr = EXPRESSION_BY_ID.get('effraye')!
117
+ // wink a son expression propre, relevee sur la video : elle doit survivre
118
+ const nu = new BotEngine(100, 'wink', cercle()).sample(1)
119
+ const habille = new BotEngine(100, 'wink', cercle(), expr).sample(1)
120
+ expect(habille.eyes[0]!.d).toBe(nu.eyes[0]!.d)
121
+
122
+ const repos = new BotEngine(100, 'idle', cercle(), expr).sample(1)
123
+ const reposNu = new BotEngine(100, 'idle', cercle()).sample(1)
124
+ expect(repos.eyes[0]!.d).not.toBe(reposNu.eyes[0]!.d)
125
+ })
126
+
127
+ it('interpole toutes les composantes, inclinaison comprise', () => {
128
+ const a = EXPRESSION_BY_ID.get('colere')!
129
+ const b = EXPRESSION_BY_ID.get('triste')!
130
+ const m = blendExpression(a, b, 0.5)
131
+ expect(m.eyes[0]!.tilt).toBeCloseTo(((a.eyes[0]!.tilt ?? 0) + (b.eyes[0]!.tilt ?? 0)) / 2, 5)
132
+ expect(m.split).toBeCloseTo((a.split + b.split) / 2, 5)
133
+ expect(m.gaze.pitch).toBeCloseTo((a.gaze.pitch + b.gaze.pitch) / 2, 5)
134
+ })
135
+ })