From c10ca9568fe2c5a06ec77a26082e358c401dc4b4 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 12:27:17 +0200 Subject: [PATCH] feat(paint): style.material and a scene light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #355's other half. It was held back because it needed a decision first, and the decision is: **a scene-level light with a sensible default, plus `material` on the node.** What makes a tile read as rendered is not the highlight on its own, it is that several shapes are lit the *same way*. A per-node preset cannot agree with its neighbours about where the light is, so a baked look was the wrong shape. Requiring every scenario to declare a light would be friction for a generator, so `material` alone works and assumes upper-left — where an eye expects it without being told — and `scene.light` overrides it for everything at once. Three layers, all derived from one direction vector: a radial highlight on the lit side, a specular edge along it, a soft shade opposite. Three presets: glossy broad bright highlight, marked edge, soft shade metal tighter dimmer highlight, harder edge, deeper shade matte shade only — takes light, returns none `matte` exists so a flat surface can sit beside glossy ones without looking unlit. `"material": { "preset": "glossy", "intensity": 0.6 }` doses a single node; the node's intensity is multiplied by the scene's, so `light.intensity: 0` flattens a whole scene without editing any node. `x`/`y` point **toward** the light, not along its travel. That is the natural reading of "negative is on the left", and getting it backwards the first time is what the tests caught: `lit=98.0, shaded=167.0` on a tile that was supposed to be brighter upper-left. The schema doc now says which way it points. ## The boundary, and why it decides layout The material follows the node's **box**, clipped by `border-radius` and by `clip-path`. It is not clipped by what a `shape` component draws, because a `shape` paints its own geometry and the paint pass has no access to it. So a sphere is a `div` with `border-radius: "50%"`, not a `shape: circle` whose material would spill into the corners of the bounding square. An octagon is a `clip-path` polygon. Give the silhouette to the box — `rules/material-and-light.md` leads with that. Six tests. Five fail without the paint call. The sixth asserts equality and passes both ways on purpose: `intensity: 0` is byte-identical to declaring no material, so nothing written before this renders differently. Closes #355 --- .../src/legacy_dispatch.rs | 5 + .../rustmotion-components/tests/audit_ws_i.rs | 1 + .../tests/caption_presets.rs | 1 + .../tests/degenerate_inputs.rs | 1 + .../tests/relative_font_size.rs | 1 + .../tests/text_autofit.rs | 1 + crates/rustmotion-core/src/css/style.rs | 64 +++ .../rustmotion-core/src/engine/paint_pass.rs | 400 +++++++++++++++++- crates/rustmotion-core/src/schema/scenario.rs | 55 +++ crates/rustmotion-core/tests/audit_ws_a.rs | 1 + crates/rustmotion/skills/SKILL.md | 1 + .../skills/rules/material-and-light.md | 58 +++ crates/rustmotion/src/engine/render/scene.rs | 22 + crates/rustmotion/src/tests.rs | 6 + 14 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 crates/rustmotion/skills/rules/material-and-light.md diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 94016ac..23c92d7 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -172,6 +172,7 @@ mod tests { let canvas = surface.canvas(); let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -236,6 +237,7 @@ mod tests { let canvas = surface.canvas(); let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -335,6 +337,7 @@ mod tests { let canvas = surface.canvas(); let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -434,6 +437,7 @@ mod tests { canvas.clear(skia_safe::Color::BLACK); let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { + light: Default::default(), time, scenario_time: time, frame_index: 0, @@ -503,6 +507,7 @@ mod tests { ..Default::default() }; let frame = PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, diff --git a/crates/rustmotion-components/tests/audit_ws_i.rs b/crates/rustmotion-components/tests/audit_ws_i.rs index 6d95d17..dc25755 100644 --- a/crates/rustmotion-components/tests/audit_ws_i.rs +++ b/crates/rustmotion-components/tests/audit_ws_i.rs @@ -83,6 +83,7 @@ fn paint_card_with_child(card_json: serde_json::Value) -> PaintedScene { canvas.clear(skia_safe::Color::BLACK); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index bc68258..af7a0b0 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -47,6 +47,7 @@ fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec { ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time, scenario_time: time, frame_index: (time * 30.0) as u32, diff --git a/crates/rustmotion-components/tests/degenerate_inputs.rs b/crates/rustmotion-components/tests/degenerate_inputs.rs index 43cdb6b..7407a43 100644 --- a/crates/rustmotion-components/tests/degenerate_inputs.rs +++ b/crates/rustmotion-components/tests/degenerate_inputs.rs @@ -45,6 +45,7 @@ fn paint(json: serde_json::Value, time: f64) { ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time, scenario_time: time, frame_index: (time * 30.0) as u32, diff --git a/crates/rustmotion-components/tests/relative_font_size.rs b/crates/rustmotion-components/tests/relative_font_size.rs index ac9dac6..7e625f1 100644 --- a/crates/rustmotion-components/tests/relative_font_size.rs +++ b/crates/rustmotion-components/tests/relative_font_size.rs @@ -43,6 +43,7 @@ fn render(json: serde_json::Value) -> Vec { ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time: 0.5, scenario_time: 0.5, frame_index: 15, diff --git a/crates/rustmotion-components/tests/text_autofit.rs b/crates/rustmotion-components/tests/text_autofit.rs index aed3af0..3f21150 100644 --- a/crates/rustmotion-components/tests/text_autofit.rs +++ b/crates/rustmotion-components/tests/text_autofit.rs @@ -64,6 +64,7 @@ fn render_at(content: &str, autofit: bool, time: f64) -> Vec { ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time, scenario_time: time, frame_index: (time * 30.0) as u32, diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 73700b9..2202b18 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -133,6 +133,15 @@ pub struct CssStyle { pub opacity: Option, pub mix_blend_mode: Option, pub clip_path: Option, + /// Glossy surface treatment: a highlight on the lit side, a specular + /// edge along it, and a soft shade opposite. All three are derived from + /// the scene's `light`, so several materials in one scene agree about + /// where the light is. + /// + /// Follows the node's **box**, clipped by `border-radius` and by + /// `clip-path` when set — not by a `shape` component's own geometry, + /// which paints itself. Give the box the silhouette. + pub material: Option, /// Gradient-colored border painted instead of `border` when present. /// `{ "colors": [...], "width": 2, "angle": 0 }` — angle follows the same /// convention as `background` linear gradients. @@ -261,6 +270,7 @@ struct CssStyleWire { opacity: Option, mix_blend_mode: Option, clip_path: Option, + material: Option, gradient_border: Option, backdrop_blur: Option, @@ -1325,6 +1335,60 @@ pub enum BlendMode { PlusLighter, } +/// `style.material`, as either a bare preset name or an object tuning it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Material { + /// `"material": "glossy"`. + Preset(MaterialPreset), + /// `"material": { "preset": "glossy", "intensity": 0.6 }`. + Tuned { + #[serde(default)] + preset: MaterialPreset, + /// Scales all three layers together. `1.0` is the calibrated look, + /// `0.0` paints nothing. Multiplied by the scene light's own + /// `intensity`. + #[serde(default = "default_material_intensity")] + intensity: f32, + }, +} + +/// Which surface a `material` imitates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MaterialPreset { + /// A lit plastic or glass tile: bright highlight, hard specular edge, + /// soft shade opposite. + #[default] + Glossy, + /// The same light read as brushed metal: a tighter, dimmer highlight + /// and a stronger edge. + Metal, + /// Light with no specular return — shade only, no highlight and no + /// edge. Sits a flat surface next to glossy ones without looking unlit. + Matte, +} + +fn default_material_intensity() -> f32 { + 1.0 +} + +impl Material { + pub fn preset(&self) -> MaterialPreset { + match self { + Material::Preset(p) => *p, + Material::Tuned { preset, .. } => *preset, + } + } + + pub fn intensity(&self) -> f32 { + match self { + Material::Preset(_) => 1.0, + Material::Tuned { intensity, .. } => *intensity, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum ClipPath { diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index e7dcaeb..4292f8f 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -8,7 +8,7 @@ use skia_safe::{ use crate::css::style::{ Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, ClipPath, - Color, CssStyle, Edges, Overflow, TransformFn, TransformOrigin, + Color, CssStyle, Edges, Material, MaterialPreset, Overflow, TransformFn, TransformOrigin, }; use crate::css::units::{parse_origin_component, LengthContext, LengthPercentage, ParsedLength}; use crate::engine::box_tree::{BoxKind, BoxNode, NodeId}; @@ -16,6 +16,7 @@ use crate::engine::layout_pass::{BoxLayout, LayoutResult}; #[derive(Debug, Clone, Copy)] pub struct PaintFrame { + pub light: LightDirection, pub time: f64, pub scenario_time: f64, pub frame_index: u32, @@ -361,6 +362,16 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u if let Some(bg) = node.css.background.as_ref() { paint_background(canvas, box_layout, &node.css, bg, &length_ctx); } + if let Some(material) = node.css.material.as_ref() { + paint_material( + canvas, + box_layout, + &node.css, + material, + &ctx.frame.light, + &length_ctx, + ); + } if let Some(gb) = node.css.gradient_border.as_ref() { paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx); } else if let Some(border) = node.css.border.as_ref() { @@ -1360,6 +1371,219 @@ fn paint_gradient_border( canvas.draw_drrect(outer, inner, &paint); } +#[derive(Debug, Clone, Copy)] +pub struct LightDirection { + pub x: f32, + pub y: f32, + pub intensity: f32, + pub color: SColor, +} + +impl Default for LightDirection { + fn default() -> Self { + LightDirection { + x: -0.35, + y: -0.8, + intensity: 1.0, + color: SColor::from_argb(255, 255, 255, 255), + } + } +} + +impl LightDirection { + fn normalized(&self) -> (f32, f32) { + let length = (self.x * self.x + self.y * self.y).sqrt(); + if length <= f32::EPSILON { + return (0.0, -1.0); + } + (self.x / length, self.y / length) + } +} + +struct MaterialRecipe { + highlight_alpha: f32, + highlight_spread: f32, + edge_alpha: f32, + shade_alpha: f32, +} + +fn material_recipe(preset: MaterialPreset) -> MaterialRecipe { + match preset { + MaterialPreset::Glossy => MaterialRecipe { + highlight_alpha: 0.42, + highlight_spread: 0.85, + edge_alpha: 0.55, + shade_alpha: 0.30, + }, + MaterialPreset::Metal => MaterialRecipe { + highlight_alpha: 0.26, + highlight_spread: 0.42, + edge_alpha: 0.78, + shade_alpha: 0.38, + }, + MaterialPreset::Matte => MaterialRecipe { + highlight_alpha: 0.0, + highlight_spread: 0.0, + edge_alpha: 0.0, + shade_alpha: 0.22, + }, + } +} + +fn paint_material( + canvas: &Canvas, + layout: &BoxLayout, + css: &CssStyle, + material: &Material, + light: &LightDirection, + ctx: &LengthContext, +) { + let strength = (material.intensity() * light.intensity).clamp(0.0, 4.0); + if strength <= 0.0 { + return; + } + let recipe = material_recipe(material.preset()); + let (lx, ly) = light.normalized(); + + let radius = css + .border_radius + .as_ref() + .map(|r| resolve_border_radius(r, layout, ctx)) + .unwrap_or([0.0; 4]); + let rrect = padding_rrect(layout, radius); + let rect = *rrect.rect(); + + canvas.save(); + match 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), + }; + + let tint = light.color; + let half_diagonal = (rect.width().powi(2) + rect.height().powi(2)).sqrt() / 2.0; + let centre = (rect.center_x(), rect.center_y()); + let lit_point = ( + centre.0 + lx * rect.width() * 0.38, + centre.1 + ly * rect.height() * 0.38, + ); + let shaded_point = ( + centre.0 - lx * rect.width() * 0.45, + centre.1 - ly * rect.height() * 0.45, + ); + + if recipe.shade_alpha > 0.0 { + let alpha = (recipe.shade_alpha * strength).clamp(0.0, 1.0); + let stops = [ + Color4f::new(0.0, 0.0, 0.0, alpha), + Color4f::new(0.0, 0.0, 0.0, 0.0), + ]; + let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None); + let grad = Gradient::new(gradient_colors, gradient::Interpolation::default()); + if let Some(shader) = gradient::shaders::radial_gradient( + ( + Point::new(shaded_point.0, shaded_point.1), + half_diagonal * 1.05, + ), + &grad, + None, + ) { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_shader(shader); + canvas.draw_rect(rect, &paint); + } + } + + if recipe.highlight_alpha > 0.0 { + let alpha = (recipe.highlight_alpha * strength).clamp(0.0, 1.0); + let stops = [ + Color4f::new( + tint.r() as f32 / 255.0, + tint.g() as f32 / 255.0, + tint.b() as f32 / 255.0, + alpha, + ), + Color4f::new( + tint.r() as f32 / 255.0, + tint.g() as f32 / 255.0, + tint.b() as f32 / 255.0, + 0.0, + ), + ]; + let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None); + let grad = Gradient::new(gradient_colors, gradient::Interpolation::default()); + if let Some(shader) = gradient::shaders::radial_gradient( + ( + Point::new(lit_point.0, lit_point.1), + (half_diagonal * recipe.highlight_spread).max(1.0), + ), + &grad, + None, + ) { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_shader(shader); + canvas.draw_rect(rect, &paint); + } + } + + if recipe.edge_alpha > 0.0 { + let alpha = (recipe.edge_alpha * strength).clamp(0.0, 1.0); + let width = (rect.width().min(rect.height()) * 0.012).clamp(1.0, 3.0); + let from = ( + rect.center_x() + lx * rect.width() / 2.0, + rect.center_y() + ly * rect.height() / 2.0, + ); + let to = ( + rect.center_x() - lx * rect.width() / 2.0, + rect.center_y() - ly * rect.height() / 2.0, + ); + let stops = [ + Color4f::new( + tint.r() as f32 / 255.0, + tint.g() as f32 / 255.0, + tint.b() as f32 / 255.0, + alpha, + ), + Color4f::new( + tint.r() as f32 / 255.0, + tint.g() as f32 / 255.0, + tint.b() as f32 / 255.0, + 0.0, + ), + ]; + let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None); + let grad = Gradient::new(gradient_colors, gradient::Interpolation::default()); + if let Some(shader) = gradient::shaders::linear_gradient( + (Point::new(from.0, from.1), Point::new(to.0, to.1)), + &grad, + None, + ) { + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(width); + paint.set_shader(shader); + let inset = skia_safe::RRect::new_rect_radii( + rect.with_inset((width / 2.0, width / 2.0)), + &[ + (radius[0], radius[0]).into(), + (radius[1], radius[1]).into(), + (radius[2], radius[2]).into(), + (radius[3], radius[3]).into(), + ], + ); + canvas.draw_rrect(inset, &paint); + } + } + + canvas.restore(); +} + fn clip_path_to_skia( clip: &ClipPath, layout: &BoxLayout, @@ -1638,6 +1862,7 @@ mod hit_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -1959,6 +2184,7 @@ mod transform_origin_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -2347,6 +2573,7 @@ mod glassmorphism_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -2741,6 +2968,7 @@ mod paint_order_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -2970,6 +3198,175 @@ mod paint_order_tests { ); } + fn material_tile(material: Option) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(100.0)), + top: Some(CLP::Px(100.0)), + width: Some(CSize::Length(CLP::Px(200.0))), + height: Some(CSize::Length(CLP::Px(200.0))), + background: Some(Background::Color(CssColor::String("#808080".into()))), + material, + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn render_lit(node: BoxNode, light: Option) -> Vec { + let mut root = root_node(400.0, 400.0, "#000000", vec![node]); + root.assign_ids(0); + let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap(); + let mut frame = test_frame(400, 400); + if let Some(l) = light { + frame.light = l; + } + paint_tree(surface.canvas(), &root, &layout, &frame, &NoopDispatcher); + let info = skia_safe::ImageInfo::new( + (400, 400), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; 400 * 400 * 4]; + surface.read_pixels(&info, &mut buf, 400 * 4, (0, 0)); + buf + } + + fn luma_at(buf: &[u8], x: usize, y: usize) -> f32 { + let i = (y * 400 + x) * 4; + 0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32 + } + + #[test] + fn a_node_without_a_material_is_untouched() { + let plain = render_lit(material_tile(None), None); + let flat = render_lit( + material_tile(Some(Material::Tuned { + preset: MaterialPreset::Glossy, + intensity: 0.0, + })), + None, + ); + assert_eq!( + plain, flat, + "intensity 0 must be byte-identical to declaring no material at all" + ); + } + + #[test] + fn glossy_is_brighter_on_the_lit_side_than_opposite_it() { + let buf = render_lit( + material_tile(Some(Material::Preset(MaterialPreset::Glossy))), + None, + ); + let lit = luma_at(&buf, 140, 140); + let shaded = luma_at(&buf, 260, 260); + assert!( + lit > shaded + 20.0, + "the default light is upper-left, so the upper-left of the tile must be clearly \ + brighter than the lower-right: lit={lit:.1}, shaded={shaded:.1}" + ); + } + + #[test] + fn turning_the_light_around_turns_the_gradient_around() { + let upper_left = render_lit( + material_tile(Some(Material::Preset(MaterialPreset::Glossy))), + None, + ); + let lower_right = render_lit( + material_tile(Some(Material::Preset(MaterialPreset::Glossy))), + Some(LightDirection { + x: 0.35, + y: 0.8, + ..Default::default() + }), + ); + assert!( + luma_at(&upper_left, 140, 140) > luma_at(&upper_left, 260, 260), + "sanity: the default light lights the upper-left" + ); + assert!( + luma_at(&lower_right, 260, 260) > luma_at(&lower_right, 140, 140), + "a light declared from the lower-right must light the lower-right — this is what \ + makes two materials in one scene agree about where the light is" + ); + } + + #[test] + fn matte_shades_without_a_highlight() { + let matte = render_lit( + material_tile(Some(Material::Preset(MaterialPreset::Matte))), + None, + ); + let plain = render_lit(material_tile(None), None); + let lit_delta = luma_at(&matte, 140, 140) - luma_at(&plain, 140, 140); + let shade_delta = luma_at(&plain, 260, 260) - luma_at(&matte, 260, 260); + assert!( + lit_delta <= 1.0, + "matte returns no specular light, so its lit side must not brighten: +{lit_delta:.1}" + ); + assert!( + shade_delta > 10.0, + "matte still takes light, so its far side must darken: -{shade_delta:.1}" + ); + } + + #[test] + fn the_scene_light_intensity_scales_every_material_at_once() { + let full = render_lit( + material_tile(Some(Material::Preset(MaterialPreset::Glossy))), + None, + ); + let dimmed = render_lit( + material_tile(Some(Material::Preset(MaterialPreset::Glossy))), + Some(LightDirection { + intensity: 0.25, + ..Default::default() + }), + ); + let plain = render_lit(material_tile(None), None); + let full_lift = luma_at(&full, 140, 140) - luma_at(&plain, 140, 140); + let dim_lift = luma_at(&dimmed, 140, 140) - luma_at(&plain, 140, 140); + assert!( + dim_lift > 0.0 && dim_lift < full_lift * 0.6, + "a quarter-intensity light must still lift, but far less: full=+{full_lift:.1}, \ + dimmed=+{dim_lift:.1}" + ); + } + + #[test] + fn a_material_never_paints_outside_its_clip_path() { + let mut node = material_tile(Some(Material::Preset(MaterialPreset::Glossy))); + node.css.background = None; + node.css.clip_path = Some(ClipPath::Inset { + top: CLP::Px(0.0), + right: CLP::Px(100.0), + bottom: CLP::Px(0.0), + left: CLP::Px(0.0), + radius: None, + }); + let buf = render_lit(node, None); + assert!( + luma_at(&buf, 120, 200) > 1.0, + "sanity: the material paints inside the clip" + ); + assert_eq!( + luma_at(&buf, 260, 200), + 0.0, + "the material follows the node's silhouette — clip-path included — and must not \ + spill into the region the clip removed" + ); + } + fn dof_camera(focus: f32, aperture: f32) -> PlaneCamera { PlaneCamera { pan_x: 0.0, @@ -3247,6 +3644,7 @@ mod paint_order_tests { let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); let frame = PaintFrame { + light: Default::default(), time, ..test_frame(w, h) }; diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index fe71c1b..26fd1f5 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -676,6 +676,15 @@ pub struct Scene { /// (world) Keep this scene visible after its time window ends. #[serde(default)] pub persist: bool, + /// Direction the light comes from, shared by every `style.material` in + /// the scene. Absent means upper-left, the convention an eye reads as + /// "lit from above" without being told. + /// + /// Declaring it once is what makes several materials look like they + /// belong to the same frame: a per-node preset cannot agree with its + /// neighbours about where the light is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub light: Option, /// Post-processing effects applied to the full frame buffer after Skia renders. /// Effects are additive and applied in declaration order. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -874,6 +883,52 @@ pub struct Camera { pub keyframes: Vec, } +/// Where the light comes from, and how strong it is. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SceneLight { + /// Horizontal component of the vector pointing **toward** the light. + /// Negative puts the light on the left. Default `-0.35`. + #[serde(default = "default_light_x")] + pub x: f32, + /// Vertical component, same convention: negative puts the light above. + /// Default `-0.8`, so by default the light sits upper-left — where an + /// eye expects it without being told. The vector need not be a unit + /// vector; only its direction is read. + #[serde(default = "default_light_y")] + pub y: f32, + /// Overall strength of every material in the scene. `0` flattens them + /// all without editing each node. Default `1.0`. + #[serde(default = "default_light_intensity")] + pub intensity: f32, + /// Colour of the highlight and the specular edge. Default `#FFFFFF`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +fn default_light_x() -> f32 { + -0.35 +} + +fn default_light_y() -> f32 { + -0.8 +} + +fn default_light_intensity() -> f32 { + 1.0 +} + +impl Default for SceneLight { + fn default() -> Self { + SceneLight { + x: default_light_x(), + y: default_light_y(), + intensity: default_light_intensity(), + color: None, + } + } +} + /// Focal point of the camera in frame pixels. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] diff --git a/crates/rustmotion-core/tests/audit_ws_a.rs b/crates/rustmotion-core/tests/audit_ws_a.rs index 9e25151..520f39e 100644 --- a/crates/rustmotion-core/tests/audit_ws_a.rs +++ b/crates/rustmotion-core/tests/audit_ws_a.rs @@ -12,6 +12,7 @@ use rustmotion_core::schema::SpringConfig; fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 03f1d36..4c17ca7 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/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/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 diff --git a/crates/rustmotion/skills/rules/material-and-light.md b/crates/rustmotion/skills/rules/material-and-light.md new file mode 100644 index 0000000..478e047 --- /dev/null +++ b/crates/rustmotion/skills/rules/material-and-light.md @@ -0,0 +1,58 @@ +# `style.material` et `scene.light` : des surfaces éclairées + +Ce qui fait lire une tuile comme « rendue en 3D » n'est pas le reflet pris +isolément, c'est que **plusieurs formes soient éclairées pareil**. D'où deux +pièces et non une : le nœud déclare sa matière, la scène déclare d'où vient la +lumière. + +```json +"scenes": [{ + "light": { "x": -0.4, "y": -0.85, "intensity": 1.0 }, + "children": [ + { "type": "div", "style": { "background": "#6D28D9", "border-radius": 28, + "material": "glossy" } } + ] +}] +``` + +`light` est **optionnel** : sans lui, la lumière vient du haut-gauche, là où l'œil +l'attend sans qu'on le lui dise. Un scénario peut donc n'écrire que `material`. + +## Les trois presets + +| Preset | Reflet | Arête spéculaire | Ombre | +|---|---|---|---| +| `glossy` | large et vif | oui, marquée | douce, opposée | +| `metal` | resserré, plus sourd | oui, plus dure | plus marquée | +| `matte` | aucun | aucune | seule couche peinte | + +`matte` existe pour poser une surface plate à côté de surfaces brillantes sans +qu'elle ait l'air non éclairée : elle prend la lumière, elle n'en renvoie pas. + +Forme longue pour doser : `"material": { "preset": "glossy", "intensity": 0.6 }`. +L'intensité du nœud est **multipliée** par celle de la scène, donc +`light.intensity: 0` aplatit toute la scène d'un coup sans toucher à chaque nœud. + +## La convention de direction + +`x` et `y` pointent **vers** la source. `(-0.35, -0.8)` = lumière en haut à gauche. +Ce n'est pas la direction dans laquelle la lumière voyage — c'est l'inverse, et +c'est la lecture naturelle de « négatif = à gauche ». Le vecteur n'a pas besoin +d'être unitaire, seule sa direction compte. + +## Le piège qui décide de ta mise en page + +**Le matériau suit la boîte, pas la géométrie d'un composant.** Il est clippé par +`border-radius` et par `clip-path`, jamais par ce qu'un `shape` dessine lui-même : +`shape` peint sa propre forme, et le pass de peinture n'y a pas accès. + +Donc pour une bille, écris un `div` avec `border-radius: "50%"` — pas un +`shape: circle`, dont le matériau déborderait dans les coins du carré englobant. +Pour un octogone, `clip-path` en polygone. **Donne la silhouette à la boîte.** + +## Ce que ça ne fait pas + +Pas d'ombre portée entre éléments, pas d'occlusion, pas de reflet de l'un dans +l'autre : c'est un habillage de surface par nœud, pas un moteur de rendu. Pour +séparer les plans, c'est la profondeur de champ qu'il faut — voir +[depth-of-field.md](depth-of-field.md), qui lit la même `style.depth`. diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index e514512..835927e 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -70,6 +70,26 @@ fn scene_uses_depth(children: &[ChildComponent]) -> bool { .any(|c| c.component.as_styled().style_config().depth.is_some()) } +fn scene_light(scene: &Scene) -> rustmotion_core::engine::paint_pass::LightDirection { + use rustmotion_core::engine::paint_pass::LightDirection; + match scene.light.as_ref() { + Some(light) => LightDirection { + x: light.x, + y: light.y, + intensity: light.intensity, + color: light + .color + .as_deref() + .map(|hex| { + let (r, g, b, a) = rustmotion_core::engine::renderer::parse_hex_color(hex); + skia_safe::Color::from_argb(a, r, g, b) + }) + .unwrap_or(skia_safe::Color::from_argb(255, 255, 255, 255)), + }, + None => LightDirection::default(), + } +} + fn scene_shake_offset(scene: &Scene, time: f32) -> ShakeOffset { match &scene.shake { Some(shake) => { @@ -570,6 +590,7 @@ fn render_with_new_pipeline_iter<'a, I>( let layout = run_layout(&built.root, viewport, &conversion); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: scene_light(scene), time: ctx.time.seconds(), scenario_time: ctx.scenario_time, frame_index: ctx.frame_index, @@ -841,6 +862,7 @@ pub fn render_scene_hits( ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: scene_light(scene), time, scenario_time: time, frame_index: frame_in_scene, diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 335cb63..9ce7039 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -305,6 +305,7 @@ mod component_smoke { skia_safe::surfaces::raster_n32_premul((400, 300)).expect("raster surface"); let canvas = surface.canvas(); let frame = PaintFrame { + light: Default::default(), time: 0.5, scenario_time: 0.5, frame_index: 15, @@ -366,6 +367,7 @@ mod component_smoke { skia_safe::surfaces::raster_n32_premul((400, 300)).expect("raster surface"); let canvas = surface.canvas(); let frame = PaintFrame { + light: Default::default(), time: 0.0, scenario_time: 0.0, frame_index: 0, @@ -473,6 +475,7 @@ mod component_smoke { ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time, scenario_time: time, frame_index: (time * 30.0) as u32, @@ -1403,6 +1406,7 @@ mod svg_draw_on_tests { ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time: progress, scenario_time: progress, frame_index: (progress * 30.0) as u32, @@ -1683,6 +1687,7 @@ mod audio_tests { &built.root, &layout, &PaintFrame { + light: Default::default(), time, scenario_time: time, frame_index: (time * fps as f64) as u32, @@ -2281,6 +2286,7 @@ mod motion_blur_trail { ); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { + light: Default::default(), time, scenario_time: time, frame_index: (time * FPS as f64) as u32,