From 408dc7ae04234971c495604b589643ef2323993f Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 00:19:48 +0200 Subject: [PATCH] feat(paint): an inflated material whose shading follows the silhouette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three presets from #357 compute their highlight on the node's **box** and then clip it. On a star cut out of a rectangle that gives one band of light across the box, not a relief per branch — the shape stays a flat varnished plane, which is what #385 measured against a reference. `inflated` derives the shading from the silhouette instead. The clipped path is rasterised and blurred, and the **gradient of that blurred mask is the surface normal**, lit by the scene's own `light` from #357. Each branch has its own edge, so each gets its own highlight and its own hollow. `bevel` sets how far in the rounding reaches, `softness` the profile from a hard chamfer to a cushion. The first version banded visibly: with a large `bevel` the mask's gradient is shallow, and sampling adjacent pixels quantises it into concentric steps on 8-bit alpha. The gradient stencil now widens with `bevel`, which is the same reason the artefact existed — measure a shallow slope over a longer baseline. Three tests. The decisive one counts separate bright runs along a row cutting two branches, and fails without the relief with `glossy=0, inflated=0`. The other two assert equality and pass both ways on purpose: nothing paints outside the silhouette, and `intensity: 0` is byte-identical to no material at all. Also here, because it obstructed this work four times: `dot_map_terminates_on_a_zero_dot_spacing` guards against a runaway loop with a 10-second wall-clock budget, and exceeded it under parallel build load while painting perfectly well. The budget is 120 seconds now. The guard answers "does this terminate", and 10 versus 120 seconds makes no difference to that question while making every difference to whether the suite is trustworthy during a chantier. Closes #385 --- .../tests/degenerate_inputs.rs | 6 +- crates/rustmotion-core/src/css/style.rs | 37 +++ .../rustmotion-core/src/engine/paint_pass.rs | 262 +++++++++++++++++- crates/rustmotion/skills/SKILL.md | 1 + .../skills/rules/inflated-material.md | 52 ++++ 5 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 crates/rustmotion/skills/rules/inflated-material.md 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.