@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.
- package/cloud-os/README.md +4 -3
- package/cloud-os/assets/followup-arrow.svg +3 -0
- package/cloud-os/assets/loading-corner.svg +3 -0
- package/cloud-os/assets/loading-mark.svg +16 -0
- package/cloud-os/assets/loading-spark.svg +3 -0
- package/cloud-os/assets/model-selected.svg +5 -0
- package/cloud-os/assets/thinking-star.svg +9 -0
- package/cloud-os/capability-chip.tsx +1 -1
- package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
- package/cloud-os/chat/code-runner.tsx +106 -0
- package/cloud-os/chat/image-generation.tsx +76 -0
- package/cloud-os/chat/loading-state.tsx +25 -0
- package/cloud-os/chat/markdown-message.tsx +144 -41
- package/cloud-os/chat/range.ts +8 -0
- package/cloud-os/chat/rich-blocks.tsx +285 -227
- package/cloud-os/chat/surfaces.tsx +90 -0
- package/cloud-os/chat/tool-call.tsx +94 -0
- package/cloud-os/chat/tool-presentation.ts +31 -22
- package/cloud-os/chat/tool-rows.tsx +195 -143
- package/cloud-os/chat/tool-timeline.tsx +123 -0
- package/cloud-os/chat/transcript-model.ts +258 -20
- package/cloud-os/chat/web-search.tsx +135 -0
- package/cloud-os/composer/cloud-os-chat-input.tsx +38 -88
- package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
- package/cloud-os/file-view.tsx +195 -12
- package/cloud-os/index.ts +24 -3
- package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
- package/cloud-os/primitives/collapsible.tsx +21 -0
- package/cloud-os/primitives/workshop-controls.tsx +2 -2
- package/cloud-os/styles/cloud-os.css +132 -1
- package/demo/camel-chat-showcase.tsx +21 -13
- package/demo/chat-scenarios.ts +214 -40
- package/demo/cloud-os-chat-showcase.tsx +37 -14
- package/demo/fixtures.ts +4 -5
- package/demo/message-panel-gallery.tsx +16 -2
- package/package.json +8 -4
- package/springbrand-pet/LICENSE.bloub +21 -0
- package/springbrand-pet/bot/cycles.test.ts +221 -0
- package/springbrand-pet/bot/cycles.ts +225 -0
- package/springbrand-pet/bot/decor.ts +285 -0
- package/springbrand-pet/bot/engine.test.ts +543 -0
- package/springbrand-pet/bot/engine.ts +558 -0
- package/springbrand-pet/bot/expressions.test.ts +135 -0
- package/springbrand-pet/bot/expressions.ts +192 -0
- package/springbrand-pet/bot/eyefit.ts +455 -0
- package/springbrand-pet/bot/face.test.ts +101 -0
- package/springbrand-pet/bot/face.ts +179 -0
- package/springbrand-pet/bot/math.ts +42 -0
- package/springbrand-pet/bot/profiles.ts +22 -0
- package/springbrand-pet/bot/repere.ts +31 -0
- package/springbrand-pet/bot/shape.test.ts +97 -0
- package/springbrand-pet/bot/shape.ts +310 -0
- package/springbrand-pet/bot/skins.test.ts +325 -0
- package/springbrand-pet/bot/skins.ts +161 -0
- package/springbrand-pet/bot/states.ts +616 -0
- package/springbrand-pet/drag.test.ts +19 -0
- package/springbrand-pet/index.tsx +569 -0
- package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/springbrand-pet/vitest.config.ts +9 -0
- package/src/camel/camel-chat-messages.tsx +78 -78
- package/src/camel/camel-tool-presentation.tsx +19 -15
- package/src/camel/camel-turn.ts +1 -17
- package/src/composer/composer-attachments.tsx +1 -1
- package/src/composer/composer.tsx +2 -2
- package/src/contracts.ts +0 -2
- package/src/message-panel.tsx +1 -24
- 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 }
|