@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,101 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, eyePoses, type HeadGaze } from './face'
3
+
4
+ /**
5
+ * Valeurs relevees image par image sur la video de reference (unites : rayon de
6
+ * la boule au repos = 1, y vers le bas). Le modele de sphere doit les
7
+ * reproduire : c'est lui qui garantit que l'oeil proche du bord se comprime
8
+ * exactement comme dans l'original.
9
+ */
10
+ const MESURES: Array<{
11
+ nom: string
12
+ gaze: HeadGaze
13
+ split: number
14
+ w: number
15
+ h: number
16
+ yeux: Array<{ x: number; y: number; court: number; long: number }>
17
+ }> = [
18
+ {
19
+ nom: 'repos',
20
+ gaze: REST_GAZE,
21
+ split: EYE_SPLIT,
22
+ w: EYE_W,
23
+ h: EYE_H,
24
+ yeux: [
25
+ { x: 0.189, y: -0.412, court: 0.178, long: 0.39 },
26
+ { x: 0.614, y: -0.51, court: 0.12, long: 0.395 }
27
+ ]
28
+ },
29
+ {
30
+ nom: 'yeux ecarquilles',
31
+ gaze: { yaw: 6.92, pitch: -21.96, roll: 11.6 },
32
+ split: 18.43,
33
+ w: 0.356,
34
+ h: 0.875,
35
+ yeux: [
36
+ { x: -0.198, y: 0.295, court: 0.353, long: 0.82 },
37
+ { x: 0.412, y: 0.415, court: 0.315, long: 0.826 }
38
+ ]
39
+ },
40
+ {
41
+ nom: 'notification',
42
+ gaze: { yaw: -21.94, pitch: -5.82, roll: -12.2 },
43
+ split: 18.89,
44
+ w: 0.505,
45
+ h: 0.498,
46
+ yeux: [
47
+ { x: -0.675, y: 0.172, court: 0.39, long: 0.495 },
48
+ { x: -0.059, y: 0.027, court: 0.495, long: 0.5 }
49
+ ]
50
+ }
51
+ ]
52
+
53
+ const court = (e: ReturnType<typeof eyePoses>[number], w: number) => Math.hypot(e.a, e.b) * w
54
+ const long = (e: ReturnType<typeof eyePoses>[number], h: number) => Math.hypot(e.c, e.d) * h
55
+
56
+ describe('yeux poses sur une sphere', () => {
57
+ for (const m of MESURES) {
58
+ it(`reproduit la pose "${m.nom}" mesuree sur la video`, () => {
59
+ const poses = eyePoses(m.gaze, 1, m.split)
60
+ for (let i = 0; i < 2; i++) {
61
+ const p = poses[i]!
62
+ const attendu = m.yeux[i]!
63
+ // 0.04 rayon = ~7 px sur la boule de 190 px de la video
64
+ expect(p.x).toBeCloseTo(attendu.x, 1)
65
+ expect(p.y).toBeCloseTo(attendu.y, 1)
66
+ expect(Math.abs(court(p, m.w) - attendu.court)).toBeLessThan(0.04)
67
+ expect(Math.abs(long(p, m.h) - attendu.long)).toBeLessThan(0.04)
68
+ }
69
+ })
70
+ }
71
+
72
+ it('comprime l oeil exactement du facteur de profondeur de la sphere', () => {
73
+ // Invariant exact : le determinant du repere tangent projete vaut z. C'est
74
+ // lui qui fait que l'aire de l'oeil suit la courbure (mesure : 0.663).
75
+ for (const e of eyePoses(REST_GAZE, 1)) {
76
+ expect(e.a * e.d - e.b * e.c).toBeCloseTo(e.depth, 6)
77
+ }
78
+ const [proche, loin] = eyePoses(REST_GAZE, 1)
79
+ expect(loin.depth / proche.depth).toBeCloseTo(0.663, 1)
80
+ // mesure video de la largeur : 0.120 / 0.178 = 0.674
81
+ expect(court(loin, EYE_W) / court(proche, EYE_W)).toBeCloseTo(0.674, 1)
82
+ })
83
+
84
+ it('garde la meme longueur pour les deux yeux (axe tangentiel non deforme)', () => {
85
+ const [a, b] = eyePoses(REST_GAZE, 1)
86
+ expect(long(a, EYE_H)).toBeCloseTo(long(b, EYE_H), 3)
87
+ })
88
+
89
+ it('conserve la separation angulaire de 31 degres quel que soit le regard', () => {
90
+ for (const gaze of [REST_GAZE, { yaw: -40, pitch: 10, roll: 5 }, { yaw: 0, pitch: 0, roll: 0 }]) {
91
+ const [a, b] = eyePoses(gaze, 1)
92
+ const dot = a.x * b.x + a.y * b.y + a.depth * b.depth
93
+ expect((Math.acos(dot) * 180) / Math.PI).toBeCloseTo(EYE_SPLIT * 2, 4)
94
+ }
95
+ })
96
+
97
+ it('fait passer un oeil derriere la sphere quand la tete tourne fort', () => {
98
+ const [, loin] = eyePoses({ yaw: 80, pitch: 0, roll: 0 }, 1)
99
+ expect(loin.depth).toBeLessThan(0)
100
+ })
101
+ })
@@ -0,0 +1,179 @@
1
+ import { clamp, createRng, loopNoise } from './math'
2
+
3
+ /**
4
+ * Les yeux sont peints sur une sphere, pas poses a plat.
5
+ *
6
+ * Mesure sur la video : l'oeil le plus proche du bord fait 0.69 fois la largeur
7
+ * de l'autre, et son aire 0.663 fois — exactement le facteur de profondeur
8
+ * (z = 0.669) d'un point de sphere a cette distance du centre. On modelise donc
9
+ * une vraie orientation de tete : chaque oeil recupere le repere tangent de la
10
+ * sphere, projete en orthographique. La compression et l'inclinaison en
11
+ * decoulent toutes seules, c'est ce qui donne le volume.
12
+ *
13
+ * Les constantes ci-dessous ne sont pas choisies a la main : elles sortent d'un
14
+ * ajustement du modele sur les positions et tailles relevees image par image
15
+ * (erreur residuelle ~1 px sur un rayon de 190 px).
16
+ */
17
+
18
+ type Vec3 = [number, number, number]
19
+
20
+ /** Demi-ecart des yeux sur la sphere, en degres (separation totale ~31deg). */
21
+ export const EYE_SPLIT = 15.46
22
+ /** Taille de l'oeil au repos, en unites de rayon de boule. */
23
+ export const EYE_W = 0.186
24
+ export const EYE_H = 0.412
25
+
26
+ /** Orientation de tete au repos, ajustee sur les frames de reference. */
27
+ export const REST_GAZE: HeadGaze = { yaw: 28.49, pitch: 28.62, roll: -13 }
28
+
29
+ export interface EyePose {
30
+ x: number
31
+ y: number
32
+ /** matrice tangente 2x2 : [a b c d] au sens SVG matrix(a,b,c,d,e,f) */
33
+ a: number
34
+ b: number
35
+ c: number
36
+ d: number
37
+ /** composante z de la normale : > 0 = face visible */
38
+ depth: number
39
+ }
40
+
41
+ export interface HeadGaze {
42
+ /** lacet, degres, positif = regarde a droite */
43
+ yaw: number
44
+ /** tangage, degres, positif = regarde en haut */
45
+ pitch: number
46
+ /** roulis, degres, inclinaison de la tete */
47
+ roll: number
48
+ }
49
+
50
+ const deg = (d: number) => (d * Math.PI) / 180
51
+
52
+ /** Fait tourner deux vecteurs d'un repere orthonorme dans leur plan commun. */
53
+ function spin(u: Vec3, v: Vec3, angle: number): [Vec3, Vec3] {
54
+ const c = Math.cos(angle)
55
+ const s = Math.sin(angle)
56
+ return [
57
+ [u[0] * c + v[0] * s, u[1] * c + v[1] * s, u[2] * c + v[2] * s],
58
+ [v[0] * c - u[0] * s, v[1] * c - u[1] * s, v[2] * c - u[2] * s]
59
+ ]
60
+ }
61
+
62
+ /**
63
+ * Repere de la tete puis des deux yeux.
64
+ * Repere ecran : x a droite, y vers le bas, z vers le spectateur.
65
+ * L'indice 0 est l'oeil interieur, l'indice 1 l'oeil exterieur.
66
+ */
67
+ export function eyePoses(gaze: HeadGaze, scale: number, split = EYE_SPLIT): [EyePose, EyePose] {
68
+ let f: Vec3 = [0, 0, 1]
69
+ let right: Vec3 = [1, 0, 0]
70
+ let down: Vec3 = [0, 1, 0]
71
+
72
+ // lacet : forward bascule vers right
73
+ ;[f, right] = spin(f, right, deg(gaze.yaw))
74
+ // tangage : forward bascule vers le haut (donc a l'oppose de down)
75
+ ;[down, f] = spin(down, f, deg(gaze.pitch))
76
+ // roulis : la tete penche dans son propre plan
77
+ ;[right, down] = spin(right, down, deg(gaze.roll))
78
+
79
+ const build = (side: number): EyePose => {
80
+ const [ef, er] = spin(f, right, deg(split * side))
81
+ return {
82
+ x: ef[0] * scale,
83
+ y: ef[1] * scale,
84
+ a: er[0],
85
+ b: er[1],
86
+ c: down[0],
87
+ d: down[1],
88
+ depth: ef[2]
89
+ }
90
+ }
91
+
92
+ return [build(-1), build(1)]
93
+ }
94
+
95
+ /**
96
+ * Vie au repos : derive lente du regard, saccades, clignements.
97
+ *
98
+ * Fonction pure du temps (aucun etat interne), donc pause, reprise et saut a
99
+ * une date arbitraire donnent toujours la meme image. Les valeurs sont des
100
+ * ECARTS a ajouter a la pose de l'etat courant.
101
+ */
102
+ export interface Liveliness {
103
+ dYaw: number
104
+ dPitch: number
105
+ dRoll: number
106
+ /** 1 = oeil ouvert, 0 = ferme (ecrasement vertical en repere ecran) */
107
+ lid: number
108
+ driftX: number
109
+ driftY: number
110
+ breath: number
111
+ }
112
+
113
+ const BLINK_RNG = createRng(0x5eed)
114
+ /** Calendrier de clignements pre-tire : deterministe et sans etat. */
115
+ const BLINKS: number[] = (() => {
116
+ const out: number[] = []
117
+ let t = 1.4
118
+ while (t < 900) {
119
+ out.push(t)
120
+ // 1.9 a 4.6 s entre deux clignements, plus un double clignement parfois
121
+ t += 1.9 + BLINK_RNG() * 2.7
122
+ if (BLINK_RNG() < 0.18) {
123
+ out.push(t)
124
+ t += 0.24
125
+ }
126
+ }
127
+ return out
128
+ })()
129
+
130
+ /** Mesure : 1 a 2 frames a 10 fps. */
131
+ const BLINK_DUR = 0.18
132
+
133
+ function blinkLid(t: number): number {
134
+ for (let i = 0; i < BLINKS.length; i++) {
135
+ const start = BLINKS[i]!
136
+ if (t < start) break
137
+ const k = (t - start) / BLINK_DUR
138
+ if (k >= 0 && k <= 1) {
139
+ // fermeture rapide, reouverture un peu plus lente
140
+ return k < 0.45 ? 1 - k / 0.45 : (k - 0.45) / 0.55
141
+ }
142
+ }
143
+ return 1
144
+ }
145
+
146
+ export interface LivelinessOptions {
147
+ wander?: number
148
+ blink?: boolean
149
+ float?: boolean
150
+ }
151
+
152
+ export function liveliness(t: number, opt: LivelinessOptions = {}): Liveliness {
153
+ const { wander = 1, blink = true, float = true } = opt
154
+
155
+ // Periodes premieres entre elles : la derive ne se repete jamais a l'oeil.
156
+ return {
157
+ dYaw: (loopNoise(t, 11.3, 0.4) * 5.5 + loopNoise(t, 3.7, 2.1) * 1.6) * wander,
158
+ dPitch: (loopNoise(t, 9.1, 1.3) * 4.2 + loopNoise(t, 4.3, 0.7) * 1.3) * wander,
159
+ dRoll: loopNoise(t, 13.7, 3.2) * 2.2 * wander,
160
+ lid: blink ? blinkLid(t) : 1,
161
+ // Au repos la video est quasiment immobile (centre stable a +-0.003, rayon
162
+ // constant) : toute la vie passe par le regard et les clignements. On garde
163
+ // juste de quoi ne pas figer completement l'image.
164
+ driftX: float ? loopNoise(t, 7.9, 1.9) * 0.006 : 0,
165
+ driftY: float ? loopNoise(t, 5.3, 0.3) * 0.007 : 0,
166
+ // La largeur est constante, seule la hauteur respire tres legerement.
167
+ breath: float ? 1 + Math.sin((t / 3.4) * Math.PI * 2) * 0.005 : 1
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Le clignement est un ecrasement VERTICAL en repere ecran autour du centre de
173
+ * l'oeil (mesure : la largeur de bbox est conservee, la hauteur tombe a ~0.35),
174
+ * pas un retrecissement le long de l'axe incline de la gelule. On le compose
175
+ * donc apres la matrice tangente, en n'affectant que les sorties en y.
176
+ */
177
+ export function blinkScale(lid: number): number {
178
+ return 0.06 + 0.94 * clamp(lid)
179
+ }
@@ -0,0 +1,42 @@
1
+ export const TAU = Math.PI * 2
2
+
3
+ export const clamp = (v: number, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v)
4
+ export const lerp = (a: number, b: number, t: number) => a + (b - a) * t
5
+
6
+ export type Easing = (t: number) => number
7
+
8
+ /**
9
+ * Mesure sur la video : les transitions sont des ease-out exponentiels, sans
10
+ * depassement du corps. Les seuls effets de ressort sont locaux (le pop de la
11
+ * pastille de notification, l'ouverture des yeux) et sont ecrits directement
12
+ * dans l'etat concerne.
13
+ */
14
+ export const easings = {
15
+ easeOutCubic: (t: number) => 1 - (1 - t) ** 3,
16
+ easeInOutCubic: (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2),
17
+ easeOutQuint: (t: number) => 1 - (1 - t) ** 5
18
+ } satisfies Record<string, Easing>
19
+
20
+ /** Bruit 1D periodique : boucle sans couture sur `period`, utile pour la derive du regard. */
21
+ export function loopNoise(t: number, period: number, seed = 0): number {
22
+ const p = (t / period) * TAU
23
+ return (
24
+ 0.55 * Math.sin(p + seed) +
25
+ 0.3 * Math.sin(2 * p + seed * 1.7 + 1.1) +
26
+ 0.15 * Math.sin(3 * p + seed * 2.3 + 2.4)
27
+ )
28
+ }
29
+
30
+ /** PRNG deterministe (mulberry32) : meme sequence a chaque lecture. */
31
+ export function createRng(seed: number) {
32
+ let a = seed >>> 0
33
+ return () => {
34
+ a = (a + 0x6d2b79f5) >>> 0
35
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
36
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
37
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
38
+ }
39
+ }
40
+
41
+ /** Arrondi court : divise par ~2 le poids des chaines de path generees a 60 fps. */
42
+ export const r2 = (v: number) => Math.round(v * 100) / 100
@@ -0,0 +1,22 @@
1
+ // Profils radiaux r(theta) releves au pixel sur la video de reference.
2
+ // theta = 0 pointe vers la droite et croit dans le sens horaire (y vers le bas).
3
+ // Unite : rayon de la boule au repos = 1.
4
+ //
5
+ // Genere par tools/extract-profiles.py — ne pas editer a la main.
6
+
7
+ export const PROFILE_SAMPLES = 64
8
+
9
+ export const PROFILES = {
10
+ // oeuf : meme hauteur que la boule, retreci en largeur
11
+ // image 164, empreinte mesuree 1.647 x 2.000
12
+ egg: [0.8369,0.8424,0.8497,0.8585,0.8674,0.8775,0.8878,0.8983,0.9089,0.9185,0.9288,0.9374,0.9445,0.9504,0.9543,0.9559,0.9555,0.9519,0.9466,0.9389,0.9302,0.9193,0.9085,0.8969,0.8852,0.8734,0.8625,0.8513,0.8411,0.8325,0.8243,0.8179,0.8137,0.8112,0.8102,0.8128,0.8178,0.8262,0.8374,0.8518,0.8702,0.8922,0.9169,0.9446,0.9741,1.0023,1.0267,1.0433,1.0481,1.0393,1.0216,0.9970,0.9697,0.9418,0.9169,0.8949,0.8760,0.8604,0.8490,0.8394,0.8337,0.8314,0.8305,0.8326],
13
+ // hexagone pointe en haut, coins tres arrondis
14
+ // image 174, empreinte mesuree 1.826 x 2.011
15
+ hexagon: [0.9210,0.9282,0.9441,0.9706,0.9984,1.0059,0.9896,0.9562,0.9290,0.9124,0.9047,0.9058,0.9157,0.9349,0.9642,0.9873,0.9882,0.9665,0.9336,0.9105,0.8968,0.8918,0.8955,0.9080,0.9293,0.9611,0.9820,0.9812,0.9590,0.9282,0.9089,0.8978,0.8964,0.9026,0.9189,0.9439,0.9778,0.9990,0.9964,0.9713,0.9439,0.9274,0.9196,0.9206,0.9308,0.9502,0.9799,1.0121,1.0226,1.0071,0.9752,0.9510,0.9366,0.9316,0.9351,0.9485,0.9711,1.0026,1.0213,1.0155,0.9863,0.9547,0.9347,0.9232],
16
+ // triangle pointe en haut, coins tres arrondis
17
+ // image 190, empreinte mesuree 1.995 x 1.884
18
+ triangle: [0.7819,0.8211,0.8747,0.9440,1.0223,1.0960,1.1401,1.1340,1.0808,1.0047,0.9265,0.8603,0.8104,0.7730,0.7450,0.7273,0.7151,0.7118,0.7148,0.7245,0.7427,0.7680,0.8037,0.8518,0.9148,0.9876,1.0583,1.1073,1.1109,1.0667,0.9940,0.9164,0.8482,0.7948,0.7555,0.7261,0.7056,0.6925,0.6859,0.6869,0.6938,0.7084,0.7305,0.7615,0.8040,0.8595,0.9311,1.0092,1.0791,1.1171,1.1054,1.0501,0.9779,0.9050,0.8450,0.7990,0.7656,0.7413,0.7258,0.7160,0.7146,0.7204,0.7330,0.7528],
19
+ } as const
20
+
21
+ export type ProfileName = keyof typeof PROFILES
22
+
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Le repere de tout ce que le moteur rend.
3
+ *
4
+ * `engine.sample()` sort des coordonnees en unites de viewBox, et ces deux nombres en sont
5
+ * la definition : sans eux, une sortie du moteur ne veut rien dire. Ils vivaient dans
6
+ * `BloubBot.vue`, donc hors d'atteinte — un `<script setup>` n'exporte rien — et
7
+ * `export.ts` en redisait un a la main avec le commentaire qui nommait le probleme.
8
+ *
9
+ * Ils sont ici parce que `src/bot/` est ce qui se lit et se consomme du dehors : le
10
+ * composant Vue est UN client du moteur, pas sa definition.
11
+ */
12
+
13
+ /**
14
+ * Rayon de la boule au repos, en unites de viewBox. C'est le `scale` que le composant
15
+ * passe a `BotEngine`.
16
+ *
17
+ * Choisi et non mesure : c'est l'unite de travail. Tout le reste du dossier s'exprime en
18
+ * fractions de ce rayon, ce qui rend les mesures relevees sur la video independantes de la
19
+ * taille d'affichage.
20
+ */
21
+ export const RAYON = 100
22
+
23
+ /**
24
+ * Demi-cote du viewBox affiche. La marge au-dela du rayon loge les anneaux.
25
+ *
26
+ * Ce n'est pas une valeur libre : les anneaux de l'orbite et le swoosh de la comete montent
27
+ * a 1,4 fois le rayon, soit 140. Rien ne les borne au runtime — c'est le reglage a la main
28
+ * des tableaux `RINGS` et `SWOOSH` (`decor.ts`) qui les tient sous 158, et un test le
29
+ * verrouille.
30
+ */
31
+ export const DEMI_VIEWBOX = 158
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { TAU } from './math'
3
+ import { PROFILE_SAMPLES } from './profiles'
4
+ import { radiusAtAngle, silhouette } from './shape'
5
+ import { SHAPES } from './skins'
6
+
7
+ /**
8
+ * `radiusAtAngle`, la fonction qui recolle sur le contour reel tout ce qui est pose « sur »
9
+ * le corps — les yeux et la pastille de notification.
10
+ *
11
+ * Elle n'avait aucun test, alors que le moteur l'appelle en `atan2(y, x) - pose.sil.rot` et
12
+ * qu'`orbit` pousse `rot` jusqu'a environ -30 rad : son argument sort donc largement de
13
+ * `[0, 2*PI)`. Toute simplification de son double modulo decollerait les yeux de la
14
+ * silhouette pendant l'orbite, soit exactement la panne que cette fonction existe pour
15
+ * eviter.
16
+ */
17
+
18
+ /** Un profil franchement non circulaire : sur un cercle, tout angle donnerait 1. */
19
+ const TRIANGLE = silhouette('triangle').radii
20
+
21
+ describe('radiusAtAngle', () => {
22
+ it('rend le rayon du profil, pas une constante', () => {
23
+ const vus = new Set(Array.from({ length: 16 }, (_, i) => radiusAtAngle(TRIANGLE, (i / 16) * TAU)))
24
+ expect(vus.size).toBeGreaterThan(8)
25
+ })
26
+
27
+ it('enroule les angles negatifs', () => {
28
+ expect(radiusAtAngle(TRIANGLE, -0.1)).toBeCloseTo(radiusAtAngle(TRIANGLE, TAU - 0.1), 12)
29
+ expect(radiusAtAngle(TRIANGLE, -1)).toBeCloseTo(radiusAtAngle(TRIANGLE, TAU - 1), 12)
30
+ })
31
+
32
+ /**
33
+ * Le cas d'`orbit` : plusieurs tours dans le negatif. C'est ce que le double modulo de
34
+ * `((x % 1) + 1) % 1` gere et qu'un modulo simple casse.
35
+ */
36
+ it('enroule sur plusieurs tours, dans les deux sens', () => {
37
+ for (const base of [-30, -12.5, 7.3]) {
38
+ for (const tours of [-3, -1, 1, 5]) {
39
+ expect(
40
+ radiusAtAngle(TRIANGLE, base),
41
+ `base=${base} tours=${tours}`
42
+ ).toBeCloseTo(radiusAtAngle(TRIANGLE, base + tours * TAU), 10)
43
+ }
44
+ }
45
+ })
46
+
47
+ /**
48
+ * Continue au passage par zero, la ou l'enroulement fait sauter l'index de 63 a 0. Une
49
+ * simplification qui renverrait une valeur de repli — 1, typiquement — se verrait ici
50
+ * comme une marche de 0,22 sur ce profil.
51
+ *
52
+ * Huit decimales et pas neuf : l'ecart mesure vaut 7e-10, du bruit flottant sur des
53
+ * indices calcules par modulo. C'est trois ordres de grandeur sous ce qu'une vraie
54
+ * discontinuite produirait.
55
+ */
56
+ it('est continue au passage par zero', () => {
57
+ expect(radiusAtAngle(TRIANGLE, -1e-9)).toBeCloseTo(radiusAtAngle(TRIANGLE, 1e-9), 8)
58
+ // et la valeur y est bien celle du profil, pas un repli
59
+ expect(radiusAtAngle(TRIANGLE, 0)).toBeCloseTo(TRIANGLE[0]!, 12)
60
+ })
61
+ })
62
+
63
+ /**
64
+ * Les formes du personnalisateur sont baties analytiquement, sans passer par le generateur
65
+ * qui produit `profiles.ts`. Rien ne verifiait leur echantillonnage.
66
+ *
67
+ * C'est ce qui rend le controle necessaire : `blend` interpole par INDEX et retombe sur
68
+ * `?? 1` quand l'index manque, donc une forme construite avec un autre nombre
69
+ * d'echantillons morphe silencieusement vers un cercle unite au lieu d'echouer. Elle serait
70
+ * juste au repos et fausse dans toutes ses transitions — le pire des deux mondes, parce que
71
+ * personne ne penserait a regarder un morph.
72
+ */
73
+ describe('profils des formes du personnalisateur', () => {
74
+ it('ont tous le meme echantillonnage angulaire, fini et positif', () => {
75
+ for (const forme of SHAPES) {
76
+ expect(forme.radii, forme.id).toHaveLength(PROFILE_SAMPLES)
77
+ for (const [i, r] of forme.radii.entries()) {
78
+ expect(Number.isFinite(r), `${forme.id}[${i}] = ${r}`).toBe(true)
79
+ expect(r, `${forme.id}[${i}]`).toBeGreaterThan(0)
80
+ }
81
+ }
82
+ })
83
+
84
+ /**
85
+ * Bornes larges, uniquement la pour attraper une forme aberrante : un rayon sous 0,3
86
+ * ferait sortir les yeux, un rayon au-dela de 1,6 sortirait du viewBox. Ce ne sont pas des
87
+ * mesures, c'est le domaine dans lequel le reste du dossier a du sens.
88
+ */
89
+ it('restent dans un domaine ou le reste du moteur tient', () => {
90
+ for (const forme of SHAPES) {
91
+ const min = Math.min(...forme.radii)
92
+ const max = Math.max(...forme.radii)
93
+ expect(min, `${forme.id} min`).toBeGreaterThan(0.3)
94
+ expect(max, `${forme.id} max`).toBeLessThan(1.6)
95
+ }
96
+ })
97
+ })