Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions crates/rustmotion-core/src/schema/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,8 +558,29 @@ pub struct HaloZone {
pub y: f32,
/// Radius as a fraction of that surface's `max(width, height)` — so the
/// same value covers proportionally the same area whichever view it is in.
/// This is also the fallback for `radius_x`/`radius_y` when either is
/// omitted, which is what keeps a zone written before those fields
/// existed a perfect, unrotated circle.
#[serde(default = "default_halo_radius")]
pub radius: f32,
/// Horizontal radius, same fraction-of-surface units as
/// [`HaloZone::radius`]. Omitted (the default) falls back to `radius`.
/// Set it together with `radius_y` to draw an ellipse instead of a
/// circle — a wide, thin light band wants `radius_x` far larger than
/// `radius_y`.
#[serde(default)]
pub radius_x: Option<f32>,
/// Vertical radius, same fraction-of-surface units as
/// [`HaloZone::radius`]. Omitted (the default) falls back to `radius` —
/// see [`HaloZone::radius_x`].
#[serde(default)]
pub radius_y: Option<f32>,
/// Rotation of the ellipse in degrees, clockwise about its own center.
/// Ignored on a circular zone (`radius_x == radius_y`, which includes
/// every zone that only sets `radius`) — a rotated circle is a circle,
/// so it is never worth the extra draw call.
#[serde(default)]
pub rotation: f32,
/// Zone opacity, multiplied with any alpha already encoded in `color`.
///
/// Default `1.0` is a true no-op: it leaves `color`'s own alpha (opaque
Expand All @@ -571,6 +592,20 @@ pub struct HaloZone {
pub opacity: f32,
}

impl HaloZone {
pub fn effective_radius_x(&self) -> f32 {
self.radius_x.unwrap_or(self.radius)
}

pub fn effective_radius_y(&self) -> f32 {
self.radius_y.unwrap_or(self.radius)
}

pub fn is_circular(&self) -> bool {
self.effective_radius_x() == self.effective_radius_y()
}
}

/// Transition configuration for background interpolation between scenes.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BackgroundTransition {
Expand Down Expand Up @@ -834,6 +869,9 @@ mod halo_zone_opacity_tests {
x: 0.5,
y: 0.5,
radius: 0.4,
radius_x: None,
radius_y: None,
rotation: 0.0,
opacity: 0.6,
};
let v = serde_json::to_value(&zone).unwrap();
Expand Down Expand Up @@ -877,6 +915,98 @@ mod halo_zone_opacity_tests {
}
}

#[cfg(test)]
mod halo_zone_ellipse_tests {
use super::*;

fn zone_with_radius_only(radius: f32) -> HaloZone {
serde_json::from_value(serde_json::json!({ "color": "#FFFFFF", "radius": radius })).unwrap()
}

#[test]
fn radius_x_and_radius_y_default_to_none_when_omitted() {
let zone = zone_with_radius_only(0.4);
assert_eq!(zone.radius_x, None);
assert_eq!(zone.radius_y, None);
assert_eq!(zone.rotation, 0.0);
}

#[test]
fn effective_radius_falls_back_to_radius_when_axis_radii_are_absent() {
let zone = zone_with_radius_only(0.4);
assert_eq!(zone.effective_radius_x(), 0.4);
assert_eq!(zone.effective_radius_y(), 0.4);
}

#[test]
fn effective_radius_honours_explicit_axis_values() {
let zone: HaloZone = serde_json::from_value(serde_json::json!({
"color": "#FFFFFF",
"radius": 0.4,
"radius_x": 0.8,
"radius_y": 0.05
}))
.unwrap();
assert_eq!(zone.effective_radius_x(), 0.8);
assert_eq!(zone.effective_radius_y(), 0.05);
}

#[test]
fn a_radius_only_zone_is_circular() {
assert!(zone_with_radius_only(0.4).is_circular());
}

#[test]
fn explicit_equal_radius_x_and_radius_y_is_still_circular() {
let zone: HaloZone = serde_json::from_value(serde_json::json!({
"color": "#FFFFFF",
"radius": 0.4,
"radius_x": 0.4,
"radius_y": 0.4
}))
.unwrap();
assert!(zone.is_circular());
}

#[test]
fn differing_axis_radii_are_not_circular() {
let zone: HaloZone = serde_json::from_value(serde_json::json!({
"color": "#FFFFFF",
"radius": 0.4,
"radius_x": 0.8,
"radius_y": 0.1
}))
.unwrap();
assert!(!zone.is_circular());
}

#[test]
fn a_nonzero_rotation_does_not_affect_circularity() {
let zone: HaloZone = serde_json::from_value(serde_json::json!({
"color": "#FFFFFF",
"radius": 0.4,
"rotation": 45.0
}))
.unwrap();
assert!(
zone.is_circular(),
"rotation has no visible effect on a circle, so it must not change how it renders"
);
}

#[test]
fn rotation_defaults_to_zero_degrees() {
let zone: HaloZone = serde_json::from_value(serde_json::json!({
"color": "#FFFFFF",
"radius": 0.4,
"radius_x": 0.8,
"radius_y": 0.1
}))
.unwrap();
assert_eq!(zone.rotation, 0.0);
}
}

#[cfg(test)]
mod animated_background_silent_sink_tests {
use super::*;
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con

- [rules/html-css-mental-model.md](rules/html-css-mental-model.md) - **CRITICAL:** Think HTML/CSS — flow layout first, absolute only for decorative/overlay elements
- [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting
- [rules/halo-shapes.md](rules/halo-shapes.md) - `halo` beyond circles: `radius_x`/`radius_y`/`rotation` for a wide thin band of light, and why the blur follows the short axis
- [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds
- [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet
- [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding
Expand Down
93 changes: 93 additions & 0 deletions crates/rustmotion/skills/rules/halo-shapes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# `halo` — zones elliptiques (`radius_x`/`radius_y`/`rotation`)

Jusqu'ici, une zone `halo` n'avait qu'un `radius` : un cercle, point. Trois
champs supplémentaires sur chaque zone permettent un ovale — utile pour un
filet de lumière fin et large en haut de cadre, une ambiance beaucoup plus
proche d'une vraie key light que le blob rond par défaut.

| Champ | Type | Défaut | Rôle |
|---|---|---|---|
| `radius` | `f32` | `0.4` | Rayon du cercle, en fraction de `max(largeur, hauteur)` de la surface (viewport en vue `slide`, monde en vue `world`). Inchangé. |
| `radius_x` | `f32?` | absent → retombe sur `radius` | Rayon horizontal, mêmes unités que `radius`. |
| `radius_y` | `f32?` | absent → retombe sur `radius` | Rayon vertical, mêmes unités que `radius`. |
| `rotation` | `f32` | `0.0` | Rotation de l'ellipse en degrés, sens horaire, autour de son propre centre. |

Tous en **snake_case** dans le JSON (`radius_x`, pas `radius-x`) — contrairement
au kebab-case de `animated-background` ou `world-position` au niveau scène.
`HaloZone` est un objet imbriqué (`zones: [...]`) et suit la casse de ses
voisins directs (`radius`, `opacity`), pas celle du schéma racine.

## Compatibilité : un cercle reste un cercle, au bit près

Omettre `radius_x`/`radius_y` retombe sur `radius` pour les deux axes — une
zone écrite avant l'existence de ces champs continue à produire exactement
les mêmes pixels. Ce n'est pas une promesse de "même rendu visuel" : le
moteur détecte qu'une zone est circulaire (`radius_x == radius_y` une fois
les valeurs par défaut appliquées) et prend alors le même chemin de code que
l'ancien `draw_circle`, sans jamais passer par l'ellipse ni par la rotation.
Fixer explicitement `radius_x`/`radius_y` à la même valeur que `radius`
produit donc le rendu identique à ne rien fixer du tout — c'est la même
branche qui s'exécute.

**Corollaire :** `rotation` sur une zone circulaire est un pur no-op, pas
seulement "sans effet visuel" — le champ n'est même pas lu. Un cercle tourné
est un cercle ; ça n'aurait forcé qu'un calcul de matrice de rotation pour
rien, avec le risque de décaler l'anti-aliasing au bord d'un fragment de
pixel entre deux exécutions. `rotation` ne prend effet que si l'ellipse est
réellement ovale (`radius_x != radius_y`).

## Recette : filet de lumière large et fin en haut de cadre

```json
{
"preset": "halo",
"zones": [
{
"color": "#8B5CF6AA",
"x": 0.5,
"y": 0.02,
"radius_x": 0.85,
"radius_y": 0.07,
"rotation": 0
}
]
}
```

`x`/`y` restent le centre de l'ellipse (pas un coin) : `y: 0.02` place ce
centre presque au bord haut, et comme `radius_y` est petit, la moitié basse
de l'ellipse qui déborderait sous le cadre ne se voit simplement pas — pas
besoin de la sortir du viewport à la main. Une inclinaison légère se fait
avec `"rotation": -8` : le filet suit alors une diagonale au lieu d'être
parfaitement à plat.

## Flou : calé sur l'axe le plus fin, pas sur le plus large

Le flou gaussien de la zone est proportionnel au **plus petit** des deux
rayons effectifs (`min(radius_x, radius_y) * 0.15`), pas à leur moyenne ni au
plus grand. Un ovale large de `radius_x: 0.85` et fin de `radius_y: 0.07`
garde un bord net à l'échelle de son épaisseur réelle ; caler le flou sur
`radius_x` aurait noyé tout le filet dans un flou disproportionné par rapport
à sa hauteur.

## Respiration (`breath`) : les deux axes bougent ensemble

L'animation de respiration existante (le halo qui pulse doucement, pilotée
par `speed` sur le fond animé) multiplie `radius_x` et `radius_y` par le
**même** facteur à chaque frame — l'ellipse pulse en conservant son rapport
d'aspect, elle ne devient jamais plus ronde ou plus écrasée en respirant.

## Ça marche aussi dans `view.background` (vue `world`)

Rien de spécifique à `radius_x`/`radius_y`/`rotation` par rapport au reste de
`HaloZone` : la même zone posée en `view.background` d'une composition
`world` (voir [world-view.md](world-view.md) §4) hérite du même comportement,
`x`/`y`/`radius*` restant des fractions du monde plutôt que du viewport.

## Transition entre deux fonds `halo`

L'interpolation utilisée pour un `background.transition` entre deux scènes
`halo` lisse maintenant `radius_x`, `radius_y` et `rotation` au même titre que
la couleur ou la position — plus de saut brutal de forme à la coupe si la
scène d'arrivée a une ellipse différente (ou une rotation différente) de la
scène de départ.
Loading
Loading