diff --git a/crates/rustmotion-components/tests/degenerate_inputs.rs b/crates/rustmotion-components/tests/degenerate_inputs.rs index 7407a43..5933b28 100644 --- a/crates/rustmotion-components/tests/degenerate_inputs.rs +++ b/crates/rustmotion-components/tests/degenerate_inputs.rs @@ -58,6 +58,10 @@ fn paint(json: serde_json::Value, time: f64) { paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); } +fn runaway_budget() -> Duration { + Duration::from_secs(120) +} + fn paint_within(json: serde_json::Value, time: f64, budget: Duration, what: &str) { let (tx, rx) = mpsc::channel(); let worker = std::thread::spawn(move || { @@ -126,7 +130,7 @@ fn dot_map_terminates_on_a_zero_dot_spacing() { "style": { "width": 300, "height": 150 } }), 0.5, - Duration::from_secs(10), + runaway_budget(), "dot_map with dot_spacing: 0", ); } diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index e51015f..3d60c54 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -1350,6 +1350,16 @@ pub enum Material { /// `intensity`. #[serde(default = "default_material_intensity")] intensity: f32, + /// `inflated` only: how far inward from the silhouette the rounded + /// edge reaches, in pixels. Small values give a crisp bevel, large + /// ones a pillow. Default 18. + #[serde(default = "default_material_bevel")] + bevel: f32, + /// `inflated` only: how rounded the profile is across that bevel, + /// 0..1. Near 0 reads as a hard chamfer, near 1 as an inflated + /// cushion. Default 0.6. + #[serde(default = "default_material_softness")] + softness: f32, }, } @@ -1367,12 +1377,25 @@ pub enum MaterialPreset { /// Light with no specular return — shade only, no highlight and no /// edge. Sits a flat surface next to glossy ones without looking unlit. Matte, + /// A volume rather than a varnished plane: the shading is derived from + /// the **silhouette itself**, so a five-branch star gets light on each + /// branch instead of one band across its bounding box. Tuned by `bevel` + /// and `softness`, which the other three presets ignore. + Inflated, } fn default_material_intensity() -> f32 { 1.0 } +fn default_material_bevel() -> f32 { + 18.0 +} + +fn default_material_softness() -> f32 { + 0.6 +} + impl Material { pub fn preset(&self) -> MaterialPreset { match self { @@ -1387,6 +1410,20 @@ impl Material { Material::Tuned { intensity, .. } => *intensity, } } + + pub fn bevel(&self) -> f32 { + match self { + Material::Preset(_) => default_material_bevel(), + Material::Tuned { bevel, .. } => *bevel, + } + } + + pub fn softness(&self) -> f32 { + match self { + Material::Preset(_) => default_material_softness(), + Material::Tuned { softness, .. } => *softness, + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 2a0048f..fd5dfc6 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -1491,7 +1491,119 @@ fn material_recipe(preset: MaterialPreset) -> MaterialRecipe { edge_alpha: 0.0, shade_alpha: 0.22, }, + MaterialPreset::Inflated => MaterialRecipe { + highlight_alpha: 0.0, + highlight_spread: 0.0, + edge_alpha: 0.0, + shade_alpha: 0.0, + }, + } +} + +fn silhouette_alpha_field( + path: &skia_safe::Path, + layout: &BoxLayout, + bevel: f32, +) -> Option<(Vec, usize, usize)> { + let width = layout.width.ceil() as i32; + let height = layout.height.ceil() as i32; + if width <= 0 || height <= 0 { + return None; + } + + let info = skia_safe::ImageInfo::new( + (width, height), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut surface = skia_safe::surfaces::raster(&info, None, None)?; + let canvas = surface.canvas(); + canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); + canvas.translate((-layout.x, -layout.y)); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(SColor::from_argb(255, 255, 255, 255)); + paint.set_image_filter(skia_safe::image_filters::blur( + (bevel.max(1.0) / 2.0, bevel.max(1.0) / 2.0), + skia_safe::TileMode::Decal, + None, + None, + )?); + canvas.draw_path(path, &paint); + + let mut buf = vec![0u8; (width * height * 4) as usize]; + surface.read_pixels(&info, &mut buf, (width * 4) as usize, (0, 0)); + + let field = buf + .as_chunks::<4>() + .0 + .iter() + .map(|px| px[3] as f32 / 255.0) + .collect(); + Some((field, width as usize, height as usize)) +} + +fn paint_inflated_material( + canvas: &Canvas, + layout: &BoxLayout, + silhouette: &skia_safe::Path, + light: &LightDirection, + strength: f32, + bevel: f32, + softness: f32, +) { + let Some((field, width, height)) = silhouette_alpha_field(silhouette, layout, bevel) else { + return; + }; + let (lx, ly) = light.normalized(); + let curvature = 1.0 + softness.clamp(0.0, 1.0) * 3.0; + + let reach = ((bevel.max(1.0) * 0.25) as usize).clamp(1, 12); + let relief = 6.0 / reach as f32; + + let mut lit = vec![0u8; width * height * 4]; + for y in 0..height { + for x in 0..width { + let at = |ix: usize, iy: usize| field[iy * width + ix]; + let left = at(x.saturating_sub(reach), y); + let right = at((x + reach).min(width - 1), y); + let up = at(x, y.saturating_sub(reach)); + let down = at(x, (y + reach).min(height - 1)); + + let slope_x = (right - left) * relief; + let slope_y = (down - up) * relief; + let lambert = (-(slope_x * lx + slope_y * ly)).clamp(-1.0, 1.0); + let shaped = lambert.abs().powf(1.0 / curvature) * lambert.signum(); + let amount = (shaped * strength).clamp(-1.0, 1.0); + + let index = (y * width + x) * 4; + let coverage = field[y * width + x]; + let alpha = (amount.abs() * coverage * 255.0) as u8; + let tone = if amount >= 0.0 { + (light.color.r(), light.color.g(), light.color.b()) + } else { + (0, 0, 0) + }; + lit[index] = (tone.0 as f32 * alpha as f32 / 255.0) as u8; + lit[index + 1] = (tone.1 as f32 * alpha as f32 / 255.0) as u8; + lit[index + 2] = (tone.2 as f32 * alpha as f32 / 255.0) as u8; + lit[index + 3] = alpha; + } } + + let info = skia_safe::ImageInfo::new( + (width as i32, height as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let data = skia_safe::Data::new_copy(&lit); + let Some(image) = skia_safe::images::raster_from_data(&info, data, width * 4) else { + return; + }; + canvas.draw_image(&image, (layout.x, layout.y), None); } fn paint_material( @@ -1517,15 +1629,34 @@ fn paint_material( let rrect = padding_rrect(layout, radius); let rect = *rrect.rect(); - canvas.save(); - match css + let silhouette = css .clip_path .as_ref() .and_then(|c| clip_path_to_skia(c, layout, ctx)) - { - Some(path) => canvas.clip_path(&path, ClipOp::Intersect, true), - None => canvas.clip_rrect(rrect, ClipOp::Intersect, true), - }; + .unwrap_or_else(|| { + let mut builder = PathBuilder::new(); + builder.add_rrect(rrect, None, None); + builder.detach() + }); + + if material.preset() == MaterialPreset::Inflated { + canvas.save(); + canvas.clip_path(&silhouette, ClipOp::Intersect, true); + paint_inflated_material( + canvas, + layout, + &silhouette, + light, + strength, + material.bevel(), + material.softness(), + ); + canvas.restore(); + return; + } + + canvas.save(); + canvas.clip_path(&silhouette, ClipOp::Intersect, true); let tint = light.color; let half_diagonal = (rect.width().powi(2) + rect.height().powi(2)).sqrt() / 2.0; @@ -3628,6 +3759,123 @@ mod paint_order_tests { 0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32 } + fn star_points() -> Vec<(CLP, CLP)> { + (0..10) + .map(|i| { + let r = if i % 2 == 0 { 90.0f32 } else { 38.0 }; + let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0; + (CLP::Px(100.0 + r * a.cos()), CLP::Px(100.0 + r * a.sin())) + }) + .collect() + } + + fn star_tile(material: Material) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(0.0)), + top: Some(CLP::Px(0.0)), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(200.0))), + background: Some(Background::Color(CssColor::String("#7C3AED".into()))), + clip_path: Some(ClipPath::Polygon { + points: star_points(), + }), + material: Some(material), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + /// Bright runs along one scanline: one per lit branch if the shading + /// follows the silhouette, one for the whole box if it does not. + fn bright_runs(buf: &[u8], y: usize) -> usize { + let mut runs = 0; + let mut inside = false; + for x in 0..400 { + let i = (y * 400 + x) * 4; + let luma = + 0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32; + let bright = luma > 150.0; + if bright && !inside { + runs += 1; + } + inside = bright; + } + runs + } + + #[test] + fn inflated_lights_each_branch_where_glossy_lights_the_whole_box() { + let glossy = render_lit(star_tile(Material::Preset(MaterialPreset::Glossy)), None); + let inflated = render_lit( + star_tile(Material::Tuned { + preset: MaterialPreset::Inflated, + intensity: 1.0, + bevel: 10.0, + softness: 0.2, + }), + None, + ); + + let scanline = 78; + let glossy_runs = bright_runs(&glossy, scanline); + let inflated_runs = bright_runs(&inflated, scanline); + assert!( + inflated_runs > glossy_runs, + "across a row cutting two branches, an inflated star must show more separate \ + bright runs than a glossy one, which lights the bounding box as a single band: \ + glossy={glossy_runs}, inflated={inflated_runs}" + ); + } + + #[test] + fn inflated_paints_nothing_outside_the_silhouette() { + let buf = render_lit( + star_tile(Material::Tuned { + preset: MaterialPreset::Inflated, + intensity: 1.0, + bevel: 14.0, + softness: 0.5, + }), + None, + ); + let corner = luma_at(&buf, 6, 6); + assert_eq!( + corner, 0.0, + "the shading is derived from the silhouette and clipped to it — a star's \ + bounding-box corner must stay background" + ); + } + + #[test] + fn inflated_at_zero_intensity_is_the_bare_shape() { + let flat = render_lit( + star_tile(Material::Tuned { + preset: MaterialPreset::Inflated, + intensity: 0.0, + bevel: 14.0, + softness: 0.5, + }), + None, + ); + let none = { + let mut node = star_tile(Material::Preset(MaterialPreset::Matte)); + node.css.material = None; + render_lit(node, None) + }; + assert_eq!( + flat, none, + "intensity 0 must be byte-identical to declaring no material at all" + ); + } + #[test] fn a_node_without_a_material_is_untouched() { let plain = render_lit(material_tile(None), None); @@ -3635,6 +3883,8 @@ mod paint_order_tests { material_tile(Some(Material::Tuned { preset: MaterialPreset::Glossy, intensity: 0.0, + bevel: 18.0, + softness: 0.6, })), None, ); diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 36784c5..b24eb95 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -236,6 +236,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [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/zoom-blur-transition.md](rules/zoom-blur-transition.md) - The radial "tunnel" cut: `zoom_blur`'s `strength`/`origin`, why it had to be a transition and not an effect, and the pivot-coincident-edge trap - [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap +- [rules/inflated-material.md](rules/inflated-material.md) - `material: "inflated"`: shading derived from the clipped silhouette, so each branch of a star gets its own relief — and why `bevel` must stay small relative to the shape - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry - [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths - [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops` diff --git a/crates/rustmotion/skills/rules/inflated-material.md b/crates/rustmotion/skills/rules/inflated-material.md new file mode 100644 index 0000000..992293c --- /dev/null +++ b/crates/rustmotion/skills/rules/inflated-material.md @@ -0,0 +1,52 @@ +# `material: "inflated"` : un volume, pas un vernis + +Les trois presets de [material-and-light.md](material-and-light.md) calculent leur +reflet sur la **boîte** du nœud, puis le clippent. Sur une étoile découpée dans un +rectangle, ça donne une seule bande de lumière en travers de la boîte — pas un +relief par branche. L'objet reste une surface plate vernie. + +`inflated` calcule l'ombrage à partir de la **silhouette elle-même**. + +```json +"style": { + "clip-path": { "kind": "polygon", "points": [ … une étoile … ] }, + "material": { "preset": "inflated", "bevel": 24, "softness": 0.6 } +} +``` + +| Champ | Défaut | Rôle | +|---|---|---| +| `bevel` | `18` | distance en pixels sur laquelle le bord s'arrondit vers l'intérieur | +| `softness` | `0.6` | profil du bord, 0..1 — près de 0 un chanfrein net, près de 1 un coussin | + +`intensity` et la `light` de la scène agissent comme pour les autres presets : +`intensity: 0` rend exactement la forme nue. + +## Comment ça marche, parce que ça décide des réglages + +La silhouette est rastérisée puis floutée ; le **dégradé du masque flouté est la +normale de surface**. On l'éclaire avec la direction de `scene.light`. Chaque +branche a son propre bord, donc chacune reçoit sa propre lumière et son propre +creux. + +Conséquence directe : `bevel` est à la fois la largeur du flou **et** l'échelle à +laquelle la pente est mesurée. Un grand `bevel` sur une petite forme aplatit tout +— le flou noie la silhouette avant d'avoir pu en tirer une normale. Compte un +`bevel` d'au plus un quart de la plus petite dimension de la forme. + +## Trois choses à savoir + +**Ça coûte des pixels.** Contrairement aux autres presets, qui sont trois +dégradés Skia, celui-ci rastérise, floute et parcourt le masque à chaque frame. +Sur un nœud plein écran, ça se sent. C'est fait pour des étoiles, des pastilles et +des icônes, pas pour un fond. + +**Il ignore `highlight` et `edge`.** Il n'a ni reflet spéculaire ni arête : sa +lumière vient entièrement de la géométrie. Le poser à côté d'un `glossy` mélange +deux modèles d'éclairage — ça peut être voulu, mais ce n'est pas une variante de +réglage. + +**Donne la silhouette à la boîte.** Comme pour les autres presets, c'est +`clip-path` ou `border-radius` qui définit la forme, jamais la géométrie d'un +composant `shape`, que le pass de peinture ne voit pas. Sans `clip-path`, une +boîte carrée donne un bourrelet sur ses quatre bords — correct, mais sans intérêt.