@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,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
|
+
})
|