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