@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,192 @@
1
+ import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face'
2
+ import { lerp } from './math'
3
+ import type { EyeCfg } from './states'
4
+
5
+ /**
6
+ * Expression de repos du bot.
7
+ *
8
+ * Le visage ne tient qu'à deux gélules, donc tout se joue sur quatre leviers :
9
+ * l'orientation de la tête, l'écart des yeux, leurs proportions, et
10
+ * l'inclinaison propre de chaque œil. C'est ce dernier qui permet la colère et
11
+ * la tristesse : elles demandent des inclinaisons EN MIROIR (les hauts qui
12
+ * convergent ou divergent), impossible avec le seul roulis de tête qui incline
13
+ * les deux yeux du même côté.
14
+ *
15
+ * Seul l'état de repos porte cette expression. Les états expressifs de la vidéo
16
+ * (clin d'œil, yeux écarquillés, notification) gardent la leur : c'est elle
17
+ * qu'on est venu reproduire.
18
+ *
19
+ * Les amplitudes s'appuient sur bible-strong-avatar-lab, qui expose le même
20
+ * modèle (tête X/Y/Z, largeur et hauteur par œil, écart, angle par œil) : chez
21
+ * eux la largeur va de 0,8 à 2,7 fois le neutre, la hauteur de 0,3 à 1,5, et
22
+ * les angles jusqu'à ±80°. On reste dans cette enveloppe.
23
+ */
24
+ /** Enumeres pour que la couche i18n verifie leurs traductions a la compilation. */
25
+ export type ExpressionId =
26
+ | 'neutre'
27
+ | 'attentif'
28
+ | 'surpris'
29
+ | 'excite'
30
+ | 'heureux'
31
+ | 'hilare'
32
+ | 'colere'
33
+ | 'triste'
34
+ | 'effraye'
35
+ | 'mefiant'
36
+ | 'confus'
37
+ | 'curieux'
38
+ | 'fier'
39
+ | 'timide'
40
+ | 'blase'
41
+ | 'somnolent'
42
+
43
+ export interface BotExpression {
44
+ id: ExpressionId
45
+ gaze: HeadGaze
46
+ split: number
47
+ eyes: [EyeCfg, EyeCfg]
48
+ }
49
+
50
+ /** `tilt` en degrés, positif = le haut de la gélule part vers la droite. */
51
+ const eye = (w: number, h: number, tilt = 0, open = 1): EyeCfg => ({ w, h, tilt, open })
52
+
53
+ /** Les deux yeux identiques, inclinaisons en miroir si `tilt` est fourni. */
54
+ const pair = (w: number, h: number, tilt = 0, open = 1): [EyeCfg, EyeCfg] => [
55
+ eye(w, h, tilt, open),
56
+ eye(w, h, -tilt, open)
57
+ ]
58
+
59
+ export const EXPRESSIONS: BotExpression[] = [
60
+ {
61
+ // la pose relevée image par image sur la vidéo de référence
62
+ id: 'neutre',
63
+ gaze: { ...REST_GAZE },
64
+ split: EYE_SPLIT,
65
+ eyes: [eye(EYE_W, EYE_H), eye(EYE_W, EYE_H)]
66
+ },
67
+ {
68
+ id: 'attentif',
69
+ gaze: { yaw: 4, pitch: 5, roll: -4 },
70
+ split: 16,
71
+ eyes: pair(0.21, 0.44)
72
+ },
73
+ {
74
+ id: 'surpris',
75
+ gaze: { yaw: 3, pitch: -3, roll: 0 },
76
+ split: 19,
77
+ eyes: pair(0.45, 0.47)
78
+ },
79
+ {
80
+ id: 'excite',
81
+ gaze: { yaw: 6, pitch: -14, roll: 0 },
82
+ split: 19.5,
83
+ eyes: pair(0.4, 0.56, -10)
84
+ },
85
+ {
86
+ // yeux plissés en arc : les hauts convergent légèrement
87
+ id: 'heureux',
88
+ gaze: { yaw: 5, pitch: 9, roll: 0 },
89
+ split: 17,
90
+ eyes: pair(0.27, 0.17, 14)
91
+ },
92
+ {
93
+ id: 'hilare',
94
+ gaze: { yaw: 4, pitch: 14, roll: 0 },
95
+ split: 18,
96
+ eyes: pair(0.34, 0.13, 20)
97
+ },
98
+ {
99
+ // hauts des yeux qui convergent fort vers le centre + yeux étrécis
100
+ id: 'colere',
101
+ gaze: { yaw: 3, pitch: 7, roll: 0 },
102
+ split: 17,
103
+ eyes: pair(0.34, 0.15, 30)
104
+ },
105
+ {
106
+ // l'inverse : les hauts divergent, et le regard tombe
107
+ id: 'triste',
108
+ gaze: { yaw: 3, pitch: -13, roll: 0 },
109
+ split: 16,
110
+ eyes: pair(0.22, 0.4, -28)
111
+ },
112
+ {
113
+ id: 'effraye',
114
+ gaze: { yaw: 2, pitch: -20, roll: 0 },
115
+ split: 20.5,
116
+ eyes: pair(0.4, 0.6)
117
+ },
118
+ {
119
+ // un œil franchement plus fermé que l'autre
120
+ id: 'mefiant',
121
+ gaze: { yaw: 12, pitch: 6, roll: -6 },
122
+ split: 16,
123
+ eyes: [eye(0.21, 0.4), eye(0.22, 0.15)]
124
+ },
125
+ {
126
+ // asymétrique sur les deux axes : tailles ET inclinaisons dépareillées.
127
+ // L'œil plissé est volontairement plat (rapport 1,6) : à un rapport proche
128
+ // de 1 il serait rond, et son inclinaison ne se verrait pas.
129
+ id: 'confus',
130
+ gaze: { yaw: -14, pitch: 3, roll: 8 },
131
+ split: 16.5,
132
+ eyes: [eye(0.2, 0.44, -18), eye(0.28, 0.17, 14)]
133
+ },
134
+ {
135
+ // la tête penche : c'est le roulis qui porte la curiosité
136
+ id: 'curieux',
137
+ gaze: { yaw: 16, pitch: -9, roll: -15 },
138
+ split: 16.5,
139
+ eyes: [eye(0.24, 0.46, -8), eye(0.2, 0.38, -8)]
140
+ },
141
+ {
142
+ id: 'fier',
143
+ gaze: { yaw: 5, pitch: 17, roll: 0 },
144
+ split: 17,
145
+ eyes: pair(0.3, 0.15, 18)
146
+ },
147
+ {
148
+ id: 'timide',
149
+ gaze: { yaw: -19, pitch: -14, roll: -7 },
150
+ split: 14,
151
+ eyes: pair(0.17, 0.3)
152
+ },
153
+ {
154
+ // fentes horizontales et regard qui part sur le côté
155
+ id: 'blase',
156
+ gaze: { yaw: -22, pitch: 2, roll: 0 },
157
+ split: 16,
158
+ eyes: pair(0.3, 0.12)
159
+ },
160
+ {
161
+ // paupières à moitié tombées : on passe par `open`, donc l'écrasement
162
+ // vertical à l'écran, le même mécanisme que le clignement
163
+ id: 'somnolent',
164
+ gaze: { yaw: 6, pitch: -9, roll: -3 },
165
+ split: 16,
166
+ eyes: pair(0.2, 0.42, 0, 0.42)
167
+ }
168
+ ]
169
+
170
+ export const EXPRESSION_BY_ID = new Map<string, BotExpression>(EXPRESSIONS.map((e) => [e.id, e]))
171
+ export const DEFAULT_EXPRESSION = 'neutre'
172
+
173
+ const lerpEyeCfg = (a: EyeCfg, b: EyeCfg, t: number): EyeCfg => ({
174
+ w: lerp(a.w, b.w, t),
175
+ h: lerp(a.h, b.h, t),
176
+ tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t),
177
+ open: lerp(a.open, b.open, t)
178
+ })
179
+
180
+ /** Interpolation de deux expressions : le changement se fait en glissant. */
181
+ export function blendExpression(a: BotExpression, b: BotExpression, t: number): BotExpression {
182
+ return {
183
+ id: b.id,
184
+ gaze: {
185
+ yaw: lerp(a.gaze.yaw, b.gaze.yaw, t),
186
+ pitch: lerp(a.gaze.pitch, b.gaze.pitch, t),
187
+ roll: lerp(a.gaze.roll, b.gaze.roll, t)
188
+ },
189
+ split: lerp(a.split, b.split, t),
190
+ eyes: [lerpEyeCfg(a.eyes[0], b.eyes[0], t), lerpEyeCfg(a.eyes[1], b.eyes[1], t)]
191
+ }
192
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Ou poser le visage sur une forme du personnalisateur.
3
+ *
4
+ * Les yeux vivent sur une sphere, et `radiusAtAngle` les recolle au contour reel au
5
+ * prorata du rayon local. Ce prorata place bien leur CENTRE, mais l'oeil a une taille :
6
+ * la marge qui lui reste devant le bord est multipliee par le meme facteur, donc une
7
+ * silhouette etroite dans sa direction le pousse contre le bord jusqu'a ce que le
8
+ * masque l'ouvre vers l'exterieur. La gelule apparaissait comme une encoche dans le
9
+ * corps sur `capsule`, `triangle`, `nuage` et `goutte`.
10
+ *
11
+ * Ce module resout le probleme UNE FOIS, au chargement, et rend une table de decalages.
12
+ * Ce choix est l'essentiel du correctif, bien plus que la geometrie qui suit :
13
+ *
14
+ * Resolue dans la boucle de rendu, la correction reagit a tout ce qui bouge a soixante
15
+ * images par seconde — la derive du regard, le pointeur, l'expression en cours de
16
+ * morph, le bord le plus proche qui change, l'oeil le plus contraint qui change. Sept
17
+ * variantes ont ete ecrites ainsi et toutes produisaient un artefact de mouvement
18
+ * visible : tremblement permanent, saut de direction de 26 unites quand le bord de
19
+ * reference basculait, grossissement brusque quand la taille entrait dans le calcul.
20
+ * Le defaut n'etait dans aucune de leurs geometries, il etait dans le fait de resoudre
21
+ * par image.
22
+ *
23
+ * Le reste du moteur ne travaille pas comme ca : les poses sont DECLAREES et il ne fait
24
+ * que les interpoler avec des courbes connues. Un decalage tabule rentre dans ce moule.
25
+ * Il ne bouge pas quand le regard derive ni quand le pointeur bouge, et sur un changement
26
+ * de forme ou d'expression il ne fait qu'aller d'une entree de table a l'autre, sur la
27
+ * courbe de ce morph. Le tremblement devient impossible par construction, au lieu d'etre
28
+ * repousse : interpoler entre deux constantes est monotone, alors que re-resoudre le
29
+ * probleme sur un regard en cours d'interpolation ne l'est pas.
30
+ *
31
+ * Corollaire agreable : le solveur n'a plus aucune contrainte de continuite, puisqu'il ne
32
+ * tourne pas pendant l'animation. Il peut donc sonder tout un faisceau de directions et
33
+ * couvrir le pire cas de la derive du regard, ce qu'une version par image ne pouvait pas
34
+ * se permettre.
35
+ *
36
+ * La table est une constante de module, batie a l'import a partir de donnees pures :
37
+ * meme nature que le calendrier de clignements de `face.ts`, deterministe et sans etat,
38
+ * donc sans effet sur la purete de `engine.sample(t)`.
39
+ */
40
+
41
+ import { EXPRESSIONS, type BotExpression } from './expressions'
42
+ import { eyePoses } from './face'
43
+ import { radiusAtAngle, toPoints, type Point } from './shape'
44
+ import { SHAPES } from './skins'
45
+ import { STATES, type Pose, type StateDef, type StateId } from './states'
46
+
47
+ /** Rayon de reference du solveur. Le decalage rendu est en unites de ce rayon. */
48
+ const R = 100
49
+
50
+ /**
51
+ * Amplitudes maximales de la vie au repos, lues sur `liveliness` : `loopNoise` est
52
+ * borne a 1 en valeur absolue, donc ces sommes sont des bornes exactes et non des
53
+ * estimations.
54
+ *
55
+ * Il faut les couvrir, sinon la correction est juste sur la pose nominale et fausse une
56
+ * seconde plus tard : 7 degres de lacet deplacent l'oeil d'une douzaine d'unites sur une
57
+ * boule de rayon 100. C'est precisement ce qui faisait deborder `capsule` + `effraye`
58
+ * alors qu'une mesure a un seul instant le declarait bon.
59
+ */
60
+ const DERIVE_YAW = 5.5 + 1.6
61
+ const DERIVE_PITCH = 4.2 + 1.3
62
+ /** Flottement du centre, en unites de rayon de boule. */
63
+ const DERIVE_X = 0.006
64
+ const DERIVE_Y = 0.007
65
+
66
+ /** Le visage d'une pose, ce dont le solveur a besoin pour placer ses gelules. */
67
+ interface Visage {
68
+ gaze: Pose['gaze']
69
+ split: number
70
+ eyes: Pose['eyes']
71
+ }
72
+
73
+ /**
74
+ * Une gelule prete a etre mesuree : le segment de son axe, et de quoi calculer le rayon
75
+ * a degager DANS UNE DIRECTION donnee.
76
+ *
77
+ * Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par la
78
+ * matrice tangente est donc un segment epaissi d'une ELLIPSE, et le rayon a degager
79
+ * depend de la direction : c'est la fonction d'appui de cette ellipse, `r * |A^T u|`.
80
+ *
81
+ * Prendre a la place sa plus grande valeur singuliere serait conservateur mais faux dans
82
+ * la seule direction qui compte, et ca se paie cher : la marge de reference sur le cercle
83
+ * ressortait NEGATIVE, donc la demande devenait sans dents et 34 combinaisons
84
+ * continuaient de deborder.
85
+ */
86
+ interface Empreinte {
87
+ /** centre, en unites de viewBox */
88
+ x: number
89
+ y: number
90
+ /** demi-vecteur de l'axe */
91
+ ax: number
92
+ ay: number
93
+ /** rayon du disque local, avant transformation */
94
+ r: number
95
+ /** colonnes de la matrice tangente, pour la fonction d'appui */
96
+ m: [number, number, number, number]
97
+ }
98
+
99
+ /**
100
+ * Empreintes des deux yeux d'un visage, posees sur un profil.
101
+ *
102
+ * Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par
103
+ * la matrice tangente est donc un segment epaissi d'une ELLIPSE, et un disque du rayon
104
+ * de son grand axe la couvre : d'ou la plus grande valeur singuliere. La mesure reste
105
+ * ainsi conservatrice au sens strict, une marge positive garantissant que la gelule est
106
+ * dedans.
107
+ *
108
+ * Le clignement n'y est pas : un oeil ferme n'a pas besoin qu'on lui fasse de la place.
109
+ */
110
+ function empreintes(visage: Visage, sil: Pose['sil'], radii: number[]): Empreinte[] {
111
+ const out: Empreinte[] = []
112
+ const poses = eyePoses(visage.gaze, R, visage.split)
113
+ for (let i = 0; i < 2; i++) {
114
+ const e = poses[i]!
115
+ if (e.depth <= 0.02) continue
116
+ const cfg = visage.eyes[i]!
117
+ const phi = ((cfg.tilt ?? 0) * Math.PI) / 180
118
+ const cp = Math.cos(phi)
119
+ const sp = Math.sin(phi)
120
+ const ax = e.a * cp + e.c * sp
121
+ const ay = e.b * cp + e.d * sp
122
+ const cx = -e.a * sp + e.c * cp
123
+ const cy = -e.b * sp + e.d * cp
124
+
125
+ const hw = Math.max(cfg.w * R, 0.01) / 2
126
+ const hh = Math.max(cfg.h * R, 0.01) / 2
127
+ const r = Math.min(hw, hh)
128
+ // l'axe est celui de la plus grande dimension
129
+ const long = hh > hw
130
+ const demi = long ? hh - r : hw - r
131
+ // le prorata du rayon local, exactement comme le fait le moteur
132
+ const fit = radiusAtAngle(radii, Math.atan2(e.y, e.x) - sil.rot)
133
+ out.push({
134
+ x: e.x * fit,
135
+ y: e.y * fit,
136
+ ax: (long ? cx : ax) * demi,
137
+ ay: (long ? cy : ay) * demi,
138
+ r,
139
+ m: [ax, ay, cx, cy]
140
+ })
141
+ }
142
+ return out
143
+ }
144
+
145
+ /**
146
+ * Approche la plus courte entre un contour et un segment : la distance, et le vecteur
147
+ * qui va du contour vers le segment — le sens qui degage.
148
+ *
149
+ * Les deux sortent de la MEME passe. Les calculer separement doublait le seul vrai cout
150
+ * de ce module, qui est ce balayage.
151
+ */
152
+ function approche(pts: Point[], x0: number, y0: number, x1: number, y1: number) {
153
+ const sx = x1 - x0
154
+ const sy = y1 - y0
155
+ const len2 = sx * sx + sy * sy
156
+ let best = Infinity
157
+ let vx = 0
158
+ let vy = 0
159
+ for (let i = 0; i < pts.length; i++) {
160
+ const p = pts[i]!
161
+ let t = len2 > 0 ? ((p.x - x0) * sx + (p.y - y0) * sy) / len2 : 0
162
+ t = t < 0 ? 0 : t > 1 ? 1 : t
163
+ const ex = x0 + t * sx - p.x
164
+ const ey = y0 + t * sy - p.y
165
+ const d2 = ex * ex + ey * ey
166
+ if (d2 < best) {
167
+ best = d2
168
+ vx = ex
169
+ vy = ey
170
+ }
171
+ }
172
+ const d = Math.sqrt(best)
173
+ return { d, ux: d > 1e-9 ? vx / d : 0, uy: d > 1e-9 ? vy / d : 0 }
174
+ }
175
+
176
+ /** Une epreuve : des gelules a faire tenir dans un contour, et le contour de reference. */
177
+ interface Epreuve {
178
+ empreintes: Empreinte[]
179
+ reference: Empreinte[]
180
+ contour: Point[]
181
+ calContour: Point[]
182
+ }
183
+
184
+ /**
185
+ * Flottement du centre au repos, en unites de viewBox. Il est ajoute au rayon de la
186
+ * gelule : moins d'une unite, donc l'absorber ainsi coute moins cher que de multiplier les
187
+ * epreuves par ses quatre coins.
188
+ */
189
+ const FLOTTEMENT = Math.hypot(DERIVE_X, DERIVE_Y) * R
190
+
191
+ /** Marge de la gelule la plus serree, et le sens qui la degage. */
192
+ function pire(pts: Point[], emps: Empreinte[], tx: number, ty: number) {
193
+ let marge = Infinity
194
+ let ux = 0
195
+ let uy = 0
196
+ for (const e of emps) {
197
+ const x = e.x + tx
198
+ const y = e.y + ty
199
+ const a = approche(pts, x - e.ax, y - e.ay, x + e.ax, y + e.ay)
200
+ // fonction d'appui de l'ellipse dans la direction de l'approche
201
+ const [m0, m1, m2, m3] = e.m
202
+ const rayon =
203
+ e.r * Math.hypot(m0 * a.ux + m1 * a.uy, m2 * a.ux + m3 * a.uy) + FLOTTEMENT
204
+ if (a.d - rayon < marge) {
205
+ marge = a.d - rayon
206
+ ux = a.ux
207
+ uy = a.uy
208
+ }
209
+ }
210
+ return { marge, ux, uy }
211
+ }
212
+
213
+ /**
214
+ * Directions sondees et pas de la dichotomie. Le produit des deux est le cout de
215
+ * construction de la table, seul chiffre a surveiller ici.
216
+ */
217
+ const DIRECTIONS = 12
218
+ const DICHOTOMIE = 8
219
+
220
+ /**
221
+ * Le decalage a poser sur les deux yeux pour cette forme, cet etat et cette expression.
222
+ *
223
+ * Une TRANSLATION commune aux deux yeux, donc une isometrie : ecart entre les yeux,
224
+ * tailles et inclinaisons sont conserves au pixel. Le visage est seulement pose un peu
225
+ * plus bas sur un corps qui n'a pas de place en haut, ce qui est le geste qu'on ferait a
226
+ * la main. Les variantes qui bornaient chaque oeil separement ecartaient la paire, et
227
+ * celles qui mettaient le visage a l'echelle rapetissaient les yeux — visiblement.
228
+ *
229
+ * La marge visee est celle du profil D'ORIGINE, pas un degagement strict : sur le cercle
230
+ * l'oeil exterieur frole deja le bord, 17,3 unites pour une boule de rayon 100, et c'est
231
+ * voulu, c'est ce qui donne le volume. Elle est plafonnee par ce que la forme offre en son
232
+ * centre, sinon la demande est intenable sur un corps plat.
233
+ *
234
+ * RECHERCHE DIRECTIONNELLE et non descente. On cherche la translation de plus petite
235
+ * norme qui tient, donc on sonde une couronne de directions et on dichotomie la distance
236
+ * le long de chacune. Une descente de gradient a ete ecrite d'abord et elle ne converge
237
+ * pas : degager la paire d'un bord la rapproche de l'autre, si bien qu'elle tatonne et ne
238
+ * fait que garder son meilleur essai — passer ses tours de 40 a 18 suffisait a faire
239
+ * reapparaitre 34 debordements. Ici le resultat ne depend pas d'une convergence : chaque
240
+ * direction est resolue exactement, au pas de dichotomie pres.
241
+ */
242
+ function resous(epreuves: Epreuve[]): { x: number; y: number } {
243
+ if (!epreuves.length) return { x: 0, y: 0 }
244
+
245
+ /** La marge la plus serree sur toutes les epreuves, pour une translation donnee. */
246
+ const marge = (tx: number, ty: number) => {
247
+ let m = Infinity
248
+ for (const ep of epreuves) m = Math.min(m, pire(ep.contour, ep.empreintes, tx, ty).marge)
249
+ return m
250
+ }
251
+
252
+ // Marge exigee : la plus serree que le profil d'origine tolere, sur toutes les
253
+ // epreuves. Puis plafonnee par le plus degage que la forme puisse offrir a la paire,
254
+ // son centre.
255
+ let requis = Infinity
256
+ for (const ep of epreuves) {
257
+ requis = Math.min(requis, pire(ep.calContour, ep.reference, 0, 0).marge)
258
+ }
259
+ /*
260
+ * La course doit pouvoir atteindre le centre du corps : `wide` a des gelules de 87
261
+ * unites de long, et sur un triangle elles ne tiennent que vers le milieu, a une
262
+ * cinquantaine d'unites de leur place nominale. Une course fixe les laissait dehors.
263
+ */
264
+ let mx = 0
265
+ let my = 0
266
+ const emps = epreuves[0]!.empreintes
267
+ for (const e of emps) {
268
+ mx -= e.x / emps.length
269
+ my -= e.y / emps.length
270
+ }
271
+ const course = Math.max(0.35 * R, Math.hypot(mx, my) * 1.25)
272
+
273
+ // Plafond de la demande : ce que la forme offre en son centre, toujours atteignable.
274
+ requis = Math.min(requis, marge(mx, my))
275
+
276
+ /*
277
+ * Deja bon : le cas du cercle, et de toute forme assez large. La gelule doit RENTRER
278
+ * en plus de n'etre pas plus serree que sur le profil d'origine — sans cette seconde
279
+ * condition, une forme ou rien ne rentre satisfait la premiere de facon degeneree et
280
+ * on abandonnait. `wide` a des gelules de 87 unites de long, `notify` de 50 de
281
+ * diametre : sur un triangle ou une goutte elles debordent quoi qu'on fasse, et il
282
+ * faut alors viser le moins pire, pas renoncer.
283
+ */
284
+ const depart = marge(0, 0)
285
+ if (depart >= requis && depart >= 0) return { x: 0, y: 0 }
286
+ const cible = Math.max(requis, 0)
287
+
288
+ let meilleurX = 0
289
+ let meilleurY = 0
290
+ let meilleureNorme = Infinity
291
+ // repli quand rien ne rentre : la translation qui degage le plus, sondee au passage
292
+ let secoursX = 0
293
+ let secoursY = 0
294
+ let secours = depart
295
+
296
+ for (let d = 0; d < DIRECTIONS; d++) {
297
+ const a = (d / DIRECTIONS) * Math.PI * 2
298
+ const ux = Math.cos(a)
299
+ const uy = Math.sin(a)
300
+ if (marge(ux * course, uy * course) < cible) {
301
+ // cette direction ne mene nulle part ; on garde quand meme le meilleur degagement
302
+ // pas de solution par la, mais peut-etre un meilleur degagement
303
+ for (const k of [0.3, 0.6, 1]) {
304
+ const m = marge(ux * course * k, uy * course * k)
305
+ if (m > secours) {
306
+ secours = m
307
+ secoursX = ux * course * k
308
+ secoursY = uy * course * k
309
+ }
310
+ }
311
+ continue
312
+ }
313
+ // la plus courte distance qui tient, le long de cette direction
314
+ let bas = 0
315
+ let haut = course
316
+ for (let i = 0; i < DICHOTOMIE; i++) {
317
+ const mid = (bas + haut) / 2
318
+ if (marge(ux * mid, uy * mid) >= cible) haut = mid
319
+ else bas = mid
320
+ }
321
+ if (haut < meilleureNorme) {
322
+ meilleureNorme = haut
323
+ meilleurX = ux * haut
324
+ meilleurY = uy * haut
325
+ }
326
+ }
327
+
328
+ const x = meilleureNorme === Infinity ? secoursX : meilleurX
329
+ const y = meilleureNorme === Infinity ? secoursY : meilleurY
330
+ // rendu en unites de RAYON DE BOULE : le moteur le remet a son echelle
331
+ return { x: +(x / R).toFixed(6), y: +(y / R).toFixed(6) }
332
+ }
333
+
334
+ /**
335
+ * Le visage a couvrir : celui de l'expression si l'etat l'accepte, le sien sinon.
336
+ *
337
+ * UNE entree de table par expression, et non un pire cas commun a toutes. Un pire cas
338
+ * paraissait plus sur — un decalage constant ne peut pas bouger quand l'expression change
339
+ * — mais il est intenable : sur une capsule, `neutre` a les yeux hauts et demande a
340
+ * descendre quand `effraye` les a bas et demande a monter. Aucune translation unique ne
341
+ * satisfait les deux, et la mesure le confirme (4 debordements de 4,8 unites).
342
+ *
343
+ * Une entree par expression n'est pas moins fluide pour autant : le moteur interpole
344
+ * entre DEUX CONSTANTES, ce qui est monotone par construction. Ce qui tremblait, c'etait
345
+ * de re-resoudre le probleme sur un regard en cours d'interpolation.
346
+ */
347
+ function visageDe(def: StateDef, pose: Pose, expr: BotExpression | null): Visage {
348
+ if (def.baseFace && expr) return { gaze: expr.gaze, split: expr.split, eyes: expr.eyes }
349
+ return { gaze: pose.gaze, split: pose.split, eyes: pose.eyes }
350
+ }
351
+
352
+ /** Les dates a echantillonner dans un etat : une seule si sa pose ne bouge pas. */
353
+ function dates(def: StateDef): number[] {
354
+ /** Tout ce dont le solveur se sert : si rien ne bouge, une date suffit. */
355
+ const signature = (p: Pose) =>
356
+ JSON.stringify([p.gaze, p.split, p.eyes, p.sil.rot, p.sil.cx, p.sil.cy, p.sil.sx, p.sil.sy])
357
+ if (signature(def.pose(0)) === signature(def.pose(def.duration))) return [0]
358
+ const n = 3
359
+ return Array.from({ length: n }, (_, i) => (i / (n - 1)) * def.duration)
360
+ }
361
+
362
+ /** Le decalage d'une forme sur un etat et une expression, derive comprise. */
363
+ function decalagePour(
364
+ def: StateDef,
365
+ radii: number[],
366
+ expr: BotExpression | null
367
+ ): { x: number; y: number } {
368
+ const epreuves: Epreuve[] = []
369
+ for (const t of dates(def)) {
370
+ const pose = def.pose(t)
371
+ const contour = toPoints({ ...pose.sil, radii }, R)
372
+ const calContour = toPoints(pose.sil, R)
373
+ const v = visageDe(def, pose, expr)
374
+ // Les quatre coins de la derive bornent la pose nominale, qui est leur centre : la
375
+ // tester en plus ne changerait aucune marge et coute une epreuve sur cinq.
376
+ const coins: Visage[] = []
377
+ for (const dy of [-DERIVE_YAW, DERIVE_YAW]) {
378
+ for (const dp of [-DERIVE_PITCH, DERIVE_PITCH]) {
379
+ coins.push({
380
+ ...v,
381
+ gaze: { yaw: v.gaze.yaw + dy, pitch: v.gaze.pitch + dp, roll: v.gaze.roll }
382
+ })
383
+ }
384
+ }
385
+ for (const c of coins) {
386
+ epreuves.push({
387
+ empreintes: empreintes(c, pose.sil, radii),
388
+ reference: empreintes(c, pose.sil, pose.sil.radii),
389
+ contour,
390
+ calContour
391
+ })
392
+ }
393
+ }
394
+ return resous(epreuves)
395
+ }
396
+
397
+ /** Zero, la valeur commune a tout ce qui n'a rien a corriger. */
398
+ const NUL = { x: 0, y: 0 } as const
399
+
400
+ /** Clef d'une entree : l'etat, et l'expression quand l'etat l'accepte. */
401
+ const clef = (state: StateId, expr: string | null) => `${state}|${expr ?? ''}`
402
+
403
+ /**
404
+ * Table des decalages, batie a l'import : une entree par (forme, etat a corps de base,
405
+ * expression). Seuls `idle` et `swirl` portent le visage de repos, donc seuls eux se
406
+ * declinent par expression — les trois autres etats a corps de base ont un visage releve
407
+ * sur la video et une seule entree.
408
+ *
409
+ * Clef par REFERENCE du tableau de rayons, ce qui est deja la convention du moteur : ses
410
+ * gardes `radii === this.shape` et `expression === this.expr` reposent sur la meme
411
+ * stabilite. Un profil inconnu, ou `null`, ne corrige rien — l'API accepte n'importe quel
412
+ * tableau et le moteur n'a pas a dependre de la prudence de ses appelants.
413
+ */
414
+ function batir(): Map<number[], Map<string, { x: number; y: number }>> {
415
+ return new Map(
416
+ SHAPES.map((forme) => {
417
+ const par = new Map<string, { x: number; y: number }>()
418
+ for (const def of STATES) {
419
+ if (!def.baseBody) continue
420
+ const expressions = def.baseFace ? [null, ...EXPRESSIONS] : [null]
421
+ for (const expr of expressions) {
422
+ par.set(clef(def.id, expr?.id ?? null), decalagePour(def, forme.radii, expr))
423
+ }
424
+ }
425
+ return [forme.radii, par]
426
+ })
427
+ )
428
+ }
429
+
430
+ const DECALAGES = batir()
431
+
432
+ /**
433
+ * Decalage a appliquer aux deux yeux pour cette forme sur cet etat, en unites de rayon
434
+ * de boule — le moteur le remet a son echelle.
435
+ *
436
+ * Vaut zero des que la forme n'est pas au catalogue, ce qui couvre `null` et le cercle :
437
+ * sur le cercle les deux profils sont le meme, donc la marge est deja celle exigee et la
438
+ * descente sort au premier tour. La forme relevee sur la video ne bouge donc pas, sans
439
+ * cas particulier.
440
+ */
441
+ export function decalageDesYeux(
442
+ radii: number[] | null,
443
+ state: StateId,
444
+ expr: string | null
445
+ ): { x: number; y: number } {
446
+ if (!radii) return NUL
447
+ const par = DECALAGES.get(radii)
448
+ if (!par) return NUL
449
+ // un etat sans visage de repos n'a qu'une entree, quelle que soit l'expression
450
+ return par.get(clef(state, expr)) ?? par.get(clef(state, null)) ?? NUL
451
+ }
452
+
453
+ /** Pour les tests : de quoi verifier la table sans refaire la geometrie. */
454
+ /** Pour les tests : de quoi chronometrer la construction de la table. */
455
+ export const POUR_TESTS = { batir }