diff --git a/tellur-core/src/geometry.rs b/tellur-core/src/geometry.rs index c2e694a..208e3e7 100644 --- a/tellur-core/src/geometry.rs +++ b/tellur-core/src/geometry.rs @@ -198,3 +198,97 @@ impl EdgeInsets { Vec2(self.left, self.top) } } + +/// Layout constraints handed from a parent to a child during the +/// `layout` pass. The child must return a size in the closed interval +/// `[min, max]` on each axis. `max` may be `f32::INFINITY` to express +/// "no upper bound" (the parent does not constrain this axis); `min` is +/// usually `0.0` for "no lower bound" and equals `max` for fully tight +/// constraints. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Constraints { + pub min: Vec2, + pub max: Vec2, +} + +impl Constraints { + /// No upper bound on either axis. Children fall back to their + /// intrinsic size. + pub const UNBOUNDED: Self = Self { + min: Vec2::ZERO, + max: Vec2(f32::INFINITY, f32::INFINITY), + }; + + /// Tight constraints: the child must use exactly `size`. + pub const fn tight(size: Vec2) -> Self { + Self { + min: size, + max: size, + } + } + + /// Loose constraints: the child may use anywhere from zero up to + /// `max` on each axis. + pub const fn loose(max: Vec2) -> Self { + Self { + min: Vec2::ZERO, + max, + } + } + + /// Clamps `size` into `[min, max]` on each axis. Children pass their + /// preferred intrinsic size through this to obtain a legal result. + pub fn constrain(&self, size: Vec2) -> Vec2 { + Vec2( + size.0.clamp(self.min.0, self.max.0), + size.1.clamp(self.min.1, self.max.1), + ) + } + + /// Tightens the constraints' max to the provided size on each axis + /// (capped at the existing max), and clamps min not to exceed the + /// new max. + pub fn with_max(&self, max: Vec2) -> Self { + let new_max = Vec2(max.0.min(self.max.0), max.1.min(self.max.1)); + Self { + min: Vec2(self.min.0.min(new_max.0), self.min.1.min(new_max.1)), + max: new_max, + } + } + + /// Shrinks `max` by `by` on each axis (clamped to zero from below). + /// Used by `Padding` to subtract its insets before laying out the + /// child. + pub fn shrink(&self, by: Vec2) -> Self { + let new_max = Vec2((self.max.0 - by.0).max(0.0), (self.max.1 - by.1).max(0.0)); + Self { + min: Vec2((self.min.0 - by.0).max(0.0), (self.min.1 - by.1).max(0.0)), + max: new_max, + } + } + + /// Replaces the cross-axis bound with a tight `value` while leaving + /// the main axis unchanged. Used by `Stack`'s `CrossAlign::Stretch`. + pub fn tighten_cross(&self, axis: Axis, value: f32) -> Self { + match axis { + Axis::Horizontal => Self { + min: Vec2(self.min.0, value), + max: Vec2(self.max.0, value), + }, + Axis::Vertical => Self { + min: Vec2(value, self.min.1), + max: Vec2(value, self.max.1), + }, + } + } +} + +/// One of the two axes of a 2D coordinate system. Re-exported by the +/// layout module for stack-axis selection, but kept in `geometry` so +/// helpers like [`Constraints::tighten_cross`] can refer to it without a +/// dependency cycle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Axis { + Horizontal, + Vertical, +} diff --git a/tellur-core/src/layer.rs b/tellur-core/src/layer.rs index 32c932c..8d3662c 100644 --- a/tellur-core/src/layer.rs +++ b/tellur-core/src/layer.rs @@ -1,30 +1,27 @@ //! Layer types for composing components into a scene. //! //! Both layer types share the same coordinate model: each layer has a -//! logical `size` defining its coordinate space (top-left at `(0, 0)`), -//! and children are placed at logical positions within it. +//! fixed logical `size` defining its coordinate space (top-left at +//! `(0, 0)`), and children are placed at logical positions within it via +//! [`Placed`]. //! -//! Children are stored as [`Placed`](Placed) — the position lives on -//! a wrapper around the component rather than as a field of the component -//! itself, keeping the component types focused on intrinsic shape. Use the -//! placement extension traits in [`crate::placement`] -//! ([`VectorPlacement`](crate::placement::VectorPlacement) / -//! [`RasterPlacement`](crate::placement::RasterPlacement)) to construct -//! placed children with `at`, `anchor(...).snap_to(...)`, etc. +//! Layers participate in the constraint-based layout protocol: +//! `layout(constraints)` returns `size` (clamped to the constraints), and +//! `render(size)` lays out each child with constraints loose to `size`, +//! then composes them at their stored positions. //! //! `VectorLayer` composes `VectorComponent` children into a single -//! `VectorGraphic`. Each child is placed by wrapping it in a translating -//! `Group` so the composed result remains pure vector data. +//! `VectorGraphic`. Each child is wrapped in a translating `Group` so +//! the composed result remains pure vector data. //! -//! `Layer` composes `RasterComponent` children by rendering each one at a -//! pixel sub-resolution that matches its logical size and source-over -//! compositing it onto the output at the corresponding pixel offset. -//! Vector content has to be rasterized before being added — see the -//! `Rasterizable::rasterize` extension in `tellur-renderer`. +//! `Layer` composes `RasterComponent` children by rendering each one at +//! a pixel sub-resolution matching its logical paint bounds and +//! source-over compositing it onto the output at the corresponding pixel +//! offset. use bytes::Bytes; -use crate::geometry::{Transform, Vec2}; +use crate::geometry::{Constraints, Rect, Transform, Vec2}; use crate::placement::Placed; use crate::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; use crate::vector::{Group, Node, VectorComponent, VectorGraphic}; @@ -49,25 +46,30 @@ impl VectorLayer { } impl VectorComponent for VectorLayer { - fn view_box(&self) -> Vec2 { - self.size + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(self.size) } - fn render(&self) -> VectorGraphic { - let children = self + fn render(&self, size: Vec2) -> VectorGraphic { + let child_constraints = Constraints::loose(size); + let children: Vec = self .children .iter() .map(|placed| { - let child = placed.child.render(); + let child_size = placed.child.layout(child_constraints); + let child_graphic = placed.child.render(child_size); Node::Group(Group { transform: Transform::translate(placed.position), opacity: 1.0, - children: vec![child.root], + children: vec![child_graphic.root], }) }) .collect(); VectorGraphic { - view_box: self.size, + view_box: Rect { + origin: Vec2::ZERO, + size, + }, root: Node::Group(Group { transform: Transform::IDENTITY, opacity: 1.0, @@ -97,48 +99,84 @@ impl Layer { } impl RasterComponent for Layer { - fn view_box(&self) -> Vec2 { - self.size + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(self.size) } - fn render(&self, target: Resolution) -> RasterImage { - let placed: Vec<(Vec2, &dyn RasterComponent)> = self + fn paint_bounds(&self, size: Vec2) -> Rect { + let child_constraints = Constraints::loose(size); + let mut bounds = Rect { + origin: Vec2::ZERO, + size, + }; + for placed in &self.children { + let child_size = placed.child.layout(child_constraints); + let child_paint = placed.child.paint_bounds(child_size); + bounds = union_rect(bounds, translate_rect(child_paint, placed.position)); + } + bounds + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let paint_rect = self.paint_bounds(size); + let child_constraints = Constraints::loose(size); + let placed: Vec<(Vec2, Vec2, &dyn RasterComponent)> = self .children .iter() - .map(|p| (p.position, p.child.as_ref())) + .map(|p| { + let child_size = p.child.layout(child_constraints); + (p.position, child_size, p.child.as_ref()) + }) .collect(); - composite_children(self.size, target, &placed) + composite_children(paint_rect, target, &placed) } } -/// Rasterizes a set of placed raster components into a `container_size` -/// logical coordinate space and returns the composited image at `target` -/// pixel resolution. +/// Rasterizes a set of placed-and-sized raster components into the +/// `paint_rect` logical region and returns the composited image at +/// `target` pixel resolution. +/// +/// `paint_rect` is the parent's own paint bounds expressed in the +/// parent's logical coordinate space (its origin may be negative). +/// `target` pixels span exactly that rectangle, so 1 target pixel +/// equals `paint_rect.size / target` logical units on each axis. +/// +/// Each entry's tuple is `(position, child_size, child)`: +/// - `position` is the child's layout origin in the parent's logical +/// coordinate space (i.e. relative to `paint_rect.origin = (0,0)` in +/// the layout sense, not relative to `paint_rect.origin`). +/// - `child_size` is the size returned by the child's `layout`. +/// - The child's `paint_bounds(child_size)` decides the actual pixel +/// region (the rectangle may have a negative origin or be larger than +/// `child_size` for effects like drop shadows); the child renders +/// into a buffer matching that paint-bounds size and the parent +/// composites it at `position + child_paint_bounds.origin - +/// paint_rect.origin` (i.e. shifted into the buffer's local space). /// -/// Shared between `Layer::render` and the raster layout containers, which -/// all need the same "place children at logical offsets, then source-over -/// composite" pipeline. +/// Any spill beyond the buffer is clipped at the buffer's edge — that +/// is how containers like `DecoratedBox` (whose own paint_bounds equals +/// its layout box) act as natural clip rectangles. pub(crate) fn composite_children( - container_size: Vec2, + paint_rect: Rect, target: Resolution, - placed: &[(Vec2, &dyn RasterComponent)], + placed: &[(Vec2, Vec2, &dyn RasterComponent)], ) -> RasterImage { let pixel_count = (target.width as usize) * (target.height as usize); let mut accum = vec![0u8; pixel_count * 4]; - // Pixels per logical unit on each axis. SVG's `preserveAspectRatio="none"` - // — independent scaling on each axis. - let scale_x = target.width as f32 / container_size.0; - let scale_y = target.height as f32 / container_size.1; + let scale_x = target.width as f32 / paint_rect.size.0; + let scale_y = target.height as f32 / paint_rect.size.1; - for (position, child) in placed { - let child_size = child.view_box(); - let child_px_w = (child_size.0 * scale_x).round().max(1.0) as u32; - let child_px_h = (child_size.1 * scale_y).round().max(1.0) as u32; - let offset_x = (position.0 * scale_x).round() as i32; - let offset_y = (position.1 * scale_y).round() as i32; + for (position, child_size, child) in placed { + let bounds = child.paint_bounds(*child_size); + let child_px_w = (bounds.size.0 * scale_x).round().max(1.0) as u32; + let child_px_h = (bounds.size.1 * scale_y).round().max(1.0) as u32; + let paint_x = position.0 + bounds.origin.0 - paint_rect.origin.0; + let paint_y = position.1 + bounds.origin.1 - paint_rect.origin.1; + let offset_x = (paint_x * scale_x).round() as i32; + let offset_y = (paint_y * scale_y).round() as i32; - let image = child.render(Resolution::new(child_px_w, child_px_h)); + let image = child.render(*child_size, Resolution::new(child_px_w, child_px_h)); composite_at(&mut accum, target, &image, offset_x, offset_y); } @@ -150,6 +188,26 @@ pub(crate) fn composite_children( } } +/// Smallest axis-aligned rectangle containing both `a` and `b`. +pub(crate) fn union_rect(a: Rect, b: Rect) -> Rect { + let a_end = Vec2(a.origin.0 + a.size.0, a.origin.1 + a.size.1); + let b_end = Vec2(b.origin.0 + b.size.0, b.origin.1 + b.size.1); + let origin = Vec2(a.origin.0.min(b.origin.0), a.origin.1.min(b.origin.1)); + let end = Vec2(a_end.0.max(b_end.0), a_end.1.max(b_end.1)); + Rect { + origin, + size: Vec2(end.0 - origin.0, end.1 - origin.1), + } +} + +/// Translates a rect by `delta`, leaving its size unchanged. +pub(crate) fn translate_rect(r: Rect, delta: Vec2) -> Rect { + Rect { + origin: Vec2(r.origin.0 + delta.0, r.origin.1 + delta.1), + size: r.size, + } +} + // Source-over compositing of `src` onto `dst` at pixel offset // `(offset_x, offset_y)`. Both buffers hold 8-bit straight-alpha RGBA. // Pixels of `src` that fall outside `dst_size` are clipped away. diff --git a/tellur-core/src/layout.rs b/tellur-core/src/layout.rs index 8204399..e12452b 100644 --- a/tellur-core/src/layout.rs +++ b/tellur-core/src/layout.rs @@ -1,40 +1,39 @@ //! Layout containers for composing components. //! -//! These containers describe CSS-Box / Flexbox-style arrangements without -//! introducing a constraint-based layout pass: every container reports its -//! `view_box` as a pure function of its children's intrinsic sizes (plus -//! any explicit sizing it owns), matching the existing -//! [`VectorComponent`](crate::vector::VectorComponent) / -//! [`RasterComponent`](crate::raster::RasterComponent) model. +//! All containers participate in the constraint-based layout protocol +//! defined by [`VectorComponent`](crate::vector::VectorComponent) / +//! [`RasterComponent`](crate::raster::RasterComponent): they accept a +//! [`Constraints`] block from the parent, decide their layout size, and +//! then render at that size. //! //! - [`Padding`] adds an outer border of empty space around a child. -//! - [`Align`] places a child inside a fixed-size box at a chosen anchor. -//! - [`Stack`] arranges children along an axis with spacing and alignment. -//! - [`DecoratedBox`] paints a background fill (and optionally a border for -//! the vector variant) underneath its child. -//! - [`SizedBox`] is an empty placeholder of a given size, useful as a -//! spacer or to reserve a region. +//! - [`Sized`] picks the outer width / height per axis with `SizeMode` +//! (Fill / Hug / Fixed) and renders the child top-left. +//! - [`Place`] fills the parent and snaps the child by an anchor pair. +//! - [`Frame`] combines `Sized` + `Place` in one container. +//! - [`Stack`] arranges children along an axis with spacing and +//! alignment; the `CrossAlign::Stretch` mode propagates a tight +//! cross-axis constraint so children can fill the stack's cross +//! extent. +//! - [`DecoratedBox`] paints a background fill (and optionally a border +//! on the vector variant) behind the child. +//! - [`SizedBox`] is an empty placeholder of a given size. //! //! Vector containers live at the module root and operate on //! `Box`. Their raster counterparts share the same //! names under [`raster`] and operate on `Box`. use crate::color::Color; -use crate::geometry::{Anchor, EdgeInsets, Transform, Vec2}; +pub use crate::geometry::Axis; +use crate::geometry::{Anchor, Constraints, EdgeInsets, Rect, Transform, Vec2}; use crate::vector::{ Fill, Group, Node, Paint, Path, PathCommand, Stroke, VectorComponent, VectorGraphic, }; -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Axis { - Horizontal, - Vertical, -} - /// Main-axis distribution of children in a [`Stack`]. The `Space*` variants -/// override `Stack::spacing` and derive the gap from the leftover space on -/// the main axis; `Start` / `Center` / `End` keep `Stack::spacing` as the -/// inter-child gap. +/// override `Stack::spacing` and derive the gap from the leftover space +/// on the main axis; `Start` / `Center` / `End` keep `Stack::spacing` as +/// the inter-child gap. #[derive(Debug, Clone, Copy, PartialEq)] pub enum MainAlign { Start, @@ -46,55 +45,293 @@ pub enum MainAlign { } /// Cross-axis alignment of each child inside the stack's cross extent. +/// `Stretch` propagates a tight cross-axis constraint to the child so +/// it can fill the stack's full cross extent. #[derive(Debug, Clone, Copy, PartialEq)] pub enum CrossAlign { Start, Center, End, + Stretch, } -// ─── shared stack measurement ──────────────────────────────────────────── +/// How a sizing-container picks its size on one axis, given the parent's +/// constraints and the child's intrinsic size. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SizeMode { + /// Take the parent's max constraint on this axis (collapse to `0.0` + /// if the max is unbounded). Equivalent to CSS `width: 100%` or + /// SwiftUI's `.frame(maxWidth: .infinity)`. + Fill, + /// Hug the child's intrinsic size on this axis. The child is + /// queried for its own preferred size and the result is used. + Hug, + /// Use exactly the given number of logical units on this axis. + Fixed(f32), +} -pub(crate) struct StackLayout { - pub own_size: Vec2, - /// Top-left position of each child in the stack's coordinate space, - /// same length as the input `child_sizes`. - pub placements: Vec, +pub(crate) fn resolve_size_mode Vec2>( + width: SizeMode, + height: SizeMode, + constraints: Constraints, + child_layout: F, +) -> Vec2 { + let needs_hug = matches!(width, SizeMode::Hug) || matches!(height, SizeMode::Hug); + let hug = needs_hug.then(|| child_layout(constraints)); + let w = match width { + SizeMode::Fill => finite_axis(constraints.max.0), + SizeMode::Hug => hug.unwrap().0, + SizeMode::Fixed(v) => v, + }; + let h = match height { + SizeMode::Fill => finite_axis(constraints.max.1), + SizeMode::Hug => hug.unwrap().1, + SizeMode::Fixed(v) => v, + }; + constraints.constrain(Vec2(w, h)) } -/// Pure layout math shared by the vector and raster [`Stack`] variants. +pub(crate) fn finite_axis(v: f32) -> f32 { + if v.is_finite() { + v + } else { + 0.0 + } +} + +// ─── vector containers ─────────────────────────────────────────────────── + +/// Wraps a child with empty space on each side. +pub struct Padding { + pub insets: EdgeInsets, + pub child: Box, +} + +impl Padding { + fn inset_size(&self) -> Vec2 { + Vec2(self.insets.horizontal(), self.insets.vertical()) + } +} + +impl VectorComponent for Padding { + fn layout(&self, constraints: Constraints) -> Vec2 { + let inset = self.inset_size(); + let child_size = self.child.layout(constraints.shrink(inset)); + Vec2(child_size.0 + inset.0, child_size.1 + inset.1) + } + + fn render(&self, size: Vec2) -> VectorGraphic { + let inset = self.inset_size(); + let inner_size = Vec2((size.0 - inset.0).max(0.0), (size.1 - inset.1).max(0.0)); + let inner = self.child.render(inner_size); + VectorGraphic { + view_box: Rect { + origin: Vec2::ZERO, + size, + }, + root: Node::Group(Group { + transform: Transform::translate(self.insets.top_left()), + opacity: 1.0, + children: vec![inner.root], + }), + } + } +} + +/// Sizes the outer box independently on each axis (`Fill` / `Hug` / +/// `Fixed`) and places the child at the outer box's top-left. +/// +/// Use [`Place`] alone if you need an anchor placement at the parent's +/// max size, or [`Frame`] when you want sizing and anchored placement +/// in one container. +pub struct Sized { + pub width: SizeMode, + pub height: SizeMode, + pub child: Box, +} + +impl VectorComponent for Sized { + fn layout(&self, constraints: Constraints) -> Vec2 { + resolve_size_mode(self.width, self.height, constraints, |c| { + self.child.layout(c) + }) + } + + fn render(&self, size: Vec2) -> VectorGraphic { + let child_size = self.child.layout(Constraints::loose(size)); + let inner = self.child.render(child_size); + VectorGraphic { + view_box: Rect { + origin: Vec2::ZERO, + size, + }, + root: inner.root, + } + } +} + +/// Fills the parent's max constraint and places the child by snapping +/// the child's `child_anchor` onto the `at` anchor of the outer box. /// -/// `explicit_size` is the stack's outer size when the caller set it -/// (`Stack::size`); otherwise the layout collapses to the intrinsic size -/// `sum(children) + spacing*(n-1)` on the main axis and `max(children)` on -/// the cross axis, and `main_align` / `cross_align` become no-ops because -/// there is no free space to distribute. -pub(crate) fn compute_stack_layout( +/// For an anchor placement that doesn't claim the whole available +/// region, wrap a [`Sized`] inside `Place`, or use [`Frame`] which +/// combines both in one container. +pub struct Place { + pub child_anchor: Anchor, + pub at: Anchor, + pub child: Box, +} + +impl VectorComponent for Place { + fn layout(&self, constraints: Constraints) -> Vec2 { + let max = Vec2( + finite_axis(constraints.max.0), + finite_axis(constraints.max.1), + ); + constraints.constrain(max) + } + + fn render(&self, size: Vec2) -> VectorGraphic { + let child_size = self.child.layout(Constraints::loose(size)); + let pos = child_size + .anchored(self.child_anchor) + .snap_to(self.at.point(size)); + let inner = self.child.render(child_size); + VectorGraphic { + view_box: Rect { + origin: Vec2::ZERO, + size, + }, + root: Node::Group(Group { + transform: Transform::translate(pos), + opacity: 1.0, + children: vec![inner.root], + }), + } + } +} + +/// Shorthand for `Sized` + `Place`: declares the outer size on each +/// axis with a `SizeMode` and anchors the child inside that box. Pass +/// `Anchor::TOP_LEFT` for both anchors to get pure top-left placement. +pub struct Frame { + pub width: SizeMode, + pub height: SizeMode, + pub child_anchor: Anchor, + pub at: Anchor, + pub child: Box, +} + +impl VectorComponent for Frame { + fn layout(&self, constraints: Constraints) -> Vec2 { + resolve_size_mode(self.width, self.height, constraints, |c| { + self.child.layout(c) + }) + } + + fn render(&self, size: Vec2) -> VectorGraphic { + let child_size = self.child.layout(Constraints::loose(size)); + let pos = child_size + .anchored(self.child_anchor) + .snap_to(self.at.point(size)); + let inner = self.child.render(child_size); + VectorGraphic { + view_box: Rect { + origin: Vec2::ZERO, + size, + }, + root: Node::Group(Group { + transform: Transform::translate(pos), + opacity: 1.0, + children: vec![inner.root], + }), + } + } +} + +/// Arranges children along [`axis`](Self::axis) with +/// [`spacing`](Self::spacing) between them. +/// +/// `size` lets the caller pin the stack's own outer size. When `None`, +/// the stack expands to the parent's max constraint on the main axis +/// (or collapses to the intrinsic sum if the constraint is unbounded), +/// and follows the cross-align rule on the cross axis. +pub struct Stack { + pub axis: Axis, + pub size: Option, + pub spacing: f32, + pub main_align: MainAlign, + pub cross_align: CrossAlign, + pub children: Vec>, +} + +pub(crate) struct StackPass { + pub own_size: Vec2, + /// `(position, size)` for each child in the input order. + pub children: Vec<(Vec2, Vec2)>, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn compute_stack_pass( axis: Axis, explicit_size: Option, spacing: f32, main_align: MainAlign, cross_align: CrossAlign, - child_sizes: &[Vec2], -) -> StackLayout { - let n = child_sizes.len(); - let (mains, crosses): (Vec, Vec) = match axis { - Axis::Horizontal => child_sizes.iter().map(|s| (s.0, s.1)).unzip(), - Axis::Vertical => child_sizes.iter().map(|s| (s.1, s.0)).unzip(), + parent_constraints: Constraints, + child_count: usize, + mut layout_child: impl FnMut(usize, Constraints) -> Vec2, +) -> StackPass { + let horizontal = matches!(axis, Axis::Horizontal); + let (parent_main_max, parent_cross_max) = if horizontal { + (parent_constraints.max.0, parent_constraints.max.1) + } else { + (parent_constraints.max.1, parent_constraints.max.0) + }; + let explicit_main = explicit_size.map(|s| if horizontal { s.0 } else { s.1 }); + let explicit_cross = explicit_size.map(|s| if horizontal { s.1 } else { s.0 }); + + // Decide the cross extent the children should target. `Stretch` + // tightens children's cross axis to it; other modes leave the cross + // axis loose and use the child's natural cross size for placement. + let stretch_cross = match cross_align { + CrossAlign::Stretch => Some( + explicit_cross + .or_else(|| parent_cross_max.is_finite().then_some(parent_cross_max)) + .unwrap_or(0.0), + ), + _ => None, + }; + + let base_child_constraints = Constraints::loose(parent_constraints.max); + let child_constraints = match stretch_cross { + Some(t) => base_child_constraints.tighten_cross(axis, t), + None => base_child_constraints, }; + + let n = child_count; + let child_sizes: Vec = (0..n).map(|i| layout_child(i, child_constraints)).collect(); + let (mains, crosses): (Vec, Vec) = if horizontal { + child_sizes.iter().map(|s| (s.0, s.1)).unzip() + } else { + child_sizes.iter().map(|s| (s.1, s.0)).unzip() + }; + let total_main_children: f32 = mains.iter().sum(); let max_cross_children: f32 = crosses.iter().cloned().fold(0.0_f32, f32::max); - let gap_count = n.saturating_sub(1) as f32; let intrinsic_main = total_main_children + spacing * gap_count; - let intrinsic_cross = max_cross_children; - - let (own_main, own_cross) = match explicit_size { - Some(s) => match axis { - Axis::Horizontal => (s.0, s.1), - Axis::Vertical => (s.1, s.0), - }, - None => (intrinsic_main, intrinsic_cross), + + let own_main = explicit_main.unwrap_or_else(|| { + if parent_main_max.is_finite() { + parent_main_max + } else { + intrinsic_main + } + }); + let own_cross = match cross_align { + CrossAlign::Stretch => stretch_cross.unwrap_or(max_cross_children), + _ => explicit_cross.unwrap_or(max_cross_children), }; let (start_offset, gap) = if n == 0 { @@ -136,148 +373,75 @@ pub(crate) fn compute_stack_layout( for (i, &main_size) in mains.iter().enumerate() { let cross_size = crosses[i]; let cross_pos = match cross_align { - CrossAlign::Start => 0.0, + CrossAlign::Start | CrossAlign::Stretch => 0.0, CrossAlign::Center => (own_cross - cross_size) * 0.5, CrossAlign::End => own_cross - cross_size, }; - let pos = match axis { - Axis::Horizontal => Vec2(cursor, cross_pos), - Axis::Vertical => Vec2(cross_pos, cursor), + let pos = if horizontal { + Vec2(cursor, cross_pos) + } else { + Vec2(cross_pos, cursor) }; - placements.push(pos); + placements.push((pos, child_sizes[i])); cursor += main_size + gap; } - let own_size = match axis { - Axis::Horizontal => Vec2(own_main, own_cross), - Axis::Vertical => Vec2(own_cross, own_main), + let own_size = if horizontal { + Vec2(own_main, own_cross) + } else { + Vec2(own_cross, own_main) }; - StackLayout { + StackPass { own_size, - placements, - } -} - -// ─── vector containers ─────────────────────────────────────────────────── - -/// Wraps a child with empty space on each side. -pub struct Padding { - pub insets: EdgeInsets, - pub child: Box, -} - -impl VectorComponent for Padding { - fn view_box(&self) -> Vec2 { - let c = self.child.view_box(); - Vec2(c.0 + self.insets.horizontal(), c.1 + self.insets.vertical()) - } - - fn render(&self) -> VectorGraphic { - let inner = self.child.render(); - let view_box = Vec2( - inner.view_box.0 + self.insets.horizontal(), - inner.view_box.1 + self.insets.vertical(), - ); - VectorGraphic { - view_box, - root: Node::Group(Group { - transform: Transform::translate(self.insets.top_left()), - opacity: 1.0, - children: vec![inner.root], - }), - } + children: placements, } } -/// Places a child inside a fixed-size box, snapping the child's `anchor` -/// onto the same anchor point of the parent box. For example, -/// `anchor: Anchor::CENTER` produces center-in-box; `Anchor::BOTTOM_RIGHT` -/// pins to the bottom-right corner. -pub struct Align { - pub size: Vec2, - pub anchor: Anchor, - pub child: Box, -} - -impl VectorComponent for Align { - fn view_box(&self) -> Vec2 { - self.size - } - - fn render(&self) -> VectorGraphic { - let inner = self.child.render(); - let pos = inner - .view_box - .anchored(self.anchor) - .snap_to(self.anchor.point(self.size)); - VectorGraphic { - view_box: self.size, - root: Node::Group(Group { - transform: Transform::translate(pos), - opacity: 1.0, - children: vec![inner.root], - }), - } - } -} - -/// Arranges children along [`axis`](Self::axis) with [`spacing`](Self::spacing) -/// between them. -/// -/// When [`size`](Self::size) is `None`, the stack reports its intrinsic -/// extent and `main_align` / `cross_align` only have an observable effect -/// for the cross axis (no free space on the main axis). Set `size` to -/// `Some(...)` to expand into a fixed box and let `main_align` distribute -/// the leftover main-axis space. -pub struct Stack { - pub axis: Axis, - pub size: Option, - pub spacing: f32, - pub main_align: MainAlign, - pub cross_align: CrossAlign, - pub children: Vec>, -} - impl VectorComponent for Stack { - fn view_box(&self) -> Vec2 { - let sizes: Vec = self.children.iter().map(|c| c.view_box()).collect(); - compute_stack_layout( + fn layout(&self, constraints: Constraints) -> Vec2 { + compute_stack_pass( self.axis, self.size, self.spacing, self.main_align, self.cross_align, - &sizes, + constraints, + self.children.len(), + |i, c| self.children[i].layout(c), ) .own_size } - fn render(&self) -> VectorGraphic { - let sizes: Vec = self.children.iter().map(|c| c.view_box()).collect(); - let layout = compute_stack_layout( + fn render(&self, size: Vec2) -> VectorGraphic { + let pass = compute_stack_pass( self.axis, self.size, self.spacing, self.main_align, self.cross_align, - &sizes, + Constraints::tight(size), + self.children.len(), + |i, c| self.children[i].layout(c), ); let nodes: Vec = self .children .iter() - .zip(layout.placements.iter()) - .map(|(child, pos)| { - let inner = child.render(); + .zip(pass.children.iter()) + .map(|(child, &(pos, child_size))| { + let inner = child.render(child_size); Node::Group(Group { - transform: Transform::translate(*pos), + transform: Transform::translate(pos), opacity: 1.0, children: vec![inner.root], }) }) .collect(); VectorGraphic { - view_box: layout.own_size, + view_box: Rect { + origin: Vec2::ZERO, + size: pass.own_size, + }, root: Node::Group(Group { transform: Transform::IDENTITY, opacity: 1.0, @@ -288,7 +452,7 @@ impl VectorComponent for Stack { } /// Paints a background fill and/or stroke behind a child, sized to the -/// child's own view_box. Combine with [`Padding`] for the typical +/// child's layout size. Combine with [`Padding`] for the typical /// CSS-style "padded box with a background". pub struct DecoratedBox { pub child: Box, @@ -297,13 +461,12 @@ pub struct DecoratedBox { } impl VectorComponent for DecoratedBox { - fn view_box(&self) -> Vec2 { - self.child.view_box() + fn layout(&self, constraints: Constraints) -> Vec2 { + self.child.layout(constraints) } - fn render(&self) -> VectorGraphic { - let inner = self.child.render(); - let size = inner.view_box; + fn render(&self, size: Vec2) -> VectorGraphic { + let inner = self.child.render(size); let mut children: Vec = Vec::new(); if self.background.is_some() || self.border.is_some() { children.push(Node::Path(Path { @@ -321,7 +484,10 @@ impl VectorComponent for DecoratedBox { } children.push(inner.root); VectorGraphic { - view_box: size, + view_box: Rect { + origin: Vec2::ZERO, + size, + }, root: Node::Group(Group { transform: Transform::IDENTITY, opacity: 1.0, @@ -338,13 +504,16 @@ pub struct SizedBox { } impl VectorComponent for SizedBox { - fn view_box(&self) -> Vec2 { - self.size + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(self.size) } - fn render(&self) -> VectorGraphic { + fn render(&self, size: Vec2) -> VectorGraphic { VectorGraphic { - view_box: self.size, + view_box: Rect { + origin: Vec2::ZERO, + size, + }, root: Node::Group(Group { transform: Transform::IDENTITY, opacity: 1.0, @@ -355,8 +524,9 @@ impl VectorComponent for SizedBox { } /// Fluent extension adding `.padding(...)`, `.background(...)`, -/// `.border(...)`, `.align(...)` to every [`VectorComponent`]. -pub trait VectorLayoutExt: VectorComponent + Sized + 'static { +/// `.border(...)` to every [`VectorComponent`]. For `Sized` / `Place` / +/// `Frame` use the struct literal form directly. +pub trait VectorLayoutExt: VectorComponent + ::core::marker::Sized + 'static { fn padding(self, insets: EdgeInsets) -> Padding { Padding { insets, @@ -379,14 +549,6 @@ pub trait VectorLayoutExt: VectorComponent + Sized + 'static { border: Some(stroke), } } - - fn align(self, size: Vec2, anchor: Anchor) -> Align { - Align { - size, - anchor, - child: Box::new(self), - } - } } impl VectorLayoutExt for T {} @@ -394,14 +556,17 @@ impl VectorLayoutExt for T {} // ─── raster containers ─────────────────────────────────────────────────── pub mod raster { - //! Raster equivalents of the vector layout containers. Same shape and - //! semantics; operate on `Box`. + //! Raster equivalents of the vector layout containers. Same shape + //! and semantics; operate on `Box`. use bytes::Bytes; - use super::{compute_stack_layout, Axis, Color, CrossAlign, EdgeInsets, MainAlign, Vec2}; - use crate::geometry::Anchor; - use crate::layer::composite_children; + use super::{ + compute_stack_pass, resolve_size_mode, Axis, Color, CrossAlign, EdgeInsets, MainAlign, + SizeMode, Vec2, + }; + use crate::geometry::{Anchor, Constraints, Rect}; + use crate::layer::{composite_children, translate_rect, union_rect}; use crate::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; pub struct Padding { @@ -409,39 +574,170 @@ pub mod raster { pub child: Box, } + impl Padding { + fn inset_size(&self) -> Vec2 { + Vec2(self.insets.horizontal(), self.insets.vertical()) + } + } + impl RasterComponent for Padding { - fn view_box(&self) -> Vec2 { - let c = self.child.view_box(); - Vec2(c.0 + self.insets.horizontal(), c.1 + self.insets.vertical()) + fn layout(&self, constraints: Constraints) -> Vec2 { + let inset = self.inset_size(); + let child_size = self.child.layout(constraints.shrink(inset)); + Vec2(child_size.0 + inset.0, child_size.1 + inset.1) } - fn render(&self, target: Resolution) -> RasterImage { - let outer = self.view_box(); + fn paint_bounds(&self, size: Vec2) -> Rect { + let inset = self.inset_size(); + let inner_size = Vec2((size.0 - inset.0).max(0.0), (size.1 - inset.1).max(0.0)); + let child_paint = self.child.paint_bounds(inner_size); + union_rect( + Rect { + origin: Vec2::ZERO, + size, + }, + translate_rect(child_paint, self.insets.top_left()), + ) + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let inset = self.inset_size(); + let inner_size = Vec2((size.0 - inset.0).max(0.0), (size.1 - inset.1).max(0.0)); + let paint_rect = self.paint_bounds(size); composite_children( - outer, + paint_rect, target, - &[(self.insets.top_left(), self.child.as_ref())], + &[(self.insets.top_left(), inner_size, self.child.as_ref())], ) } } - pub struct Align { - pub size: Vec2, - pub anchor: Anchor, + /// Sizes the outer box on each axis (`Fill` / `Hug` / `Fixed`) and + /// places the child at the outer box's top-left. + pub struct Sized { + pub width: SizeMode, + pub height: SizeMode, pub child: Box, } - impl RasterComponent for Align { - fn view_box(&self) -> Vec2 { - self.size + impl RasterComponent for Sized { + fn layout(&self, constraints: Constraints) -> Vec2 { + resolve_size_mode(self.width, self.height, constraints, |c| { + self.child.layout(c) + }) } - fn render(&self, target: Resolution) -> RasterImage { - let child_size = self.child.view_box(); + fn paint_bounds(&self, size: Vec2) -> Rect { + let child_size = self.child.layout(Constraints::loose(size)); + let child_paint = self.child.paint_bounds(child_size); + union_rect( + Rect { + origin: Vec2::ZERO, + size, + }, + child_paint, + ) + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let child_size = self.child.layout(Constraints::loose(size)); + let paint_rect = self.paint_bounds(size); + composite_children( + paint_rect, + target, + &[(Vec2::ZERO, child_size, self.child.as_ref())], + ) + } + } + + /// Fills the parent's max constraint and places the child via + /// anchor snapping. + pub struct Place { + pub child_anchor: Anchor, + pub at: Anchor, + pub child: Box, + } + + impl RasterComponent for Place { + fn layout(&self, constraints: Constraints) -> Vec2 { + let max = Vec2( + super::finite_axis(constraints.max.0), + super::finite_axis(constraints.max.1), + ); + constraints.constrain(max) + } + + fn paint_bounds(&self, size: Vec2) -> Rect { + let child_size = self.child.layout(Constraints::loose(size)); + let pos = child_size + .anchored(self.child_anchor) + .snap_to(self.at.point(size)); + let child_paint = self.child.paint_bounds(child_size); + union_rect( + Rect { + origin: Vec2::ZERO, + size, + }, + translate_rect(child_paint, pos), + ) + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let child_size = self.child.layout(Constraints::loose(size)); + let pos = child_size + .anchored(self.child_anchor) + .snap_to(self.at.point(size)); + let paint_rect = self.paint_bounds(size); + composite_children( + paint_rect, + target, + &[(pos, child_size, self.child.as_ref())], + ) + } + } + + /// Shorthand for `Sized` + `Place` combined. + pub struct Frame { + pub width: SizeMode, + pub height: SizeMode, + pub child_anchor: Anchor, + pub at: Anchor, + pub child: Box, + } + + impl RasterComponent for Frame { + fn layout(&self, constraints: Constraints) -> Vec2 { + resolve_size_mode(self.width, self.height, constraints, |c| { + self.child.layout(c) + }) + } + + fn paint_bounds(&self, size: Vec2) -> Rect { + let child_size = self.child.layout(Constraints::loose(size)); + let pos = child_size + .anchored(self.child_anchor) + .snap_to(self.at.point(size)); + let child_paint = self.child.paint_bounds(child_size); + union_rect( + Rect { + origin: Vec2::ZERO, + size, + }, + translate_rect(child_paint, pos), + ) + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let child_size = self.child.layout(Constraints::loose(size)); let pos = child_size - .anchored(self.anchor) - .snap_to(self.anchor.point(self.size)); - composite_children(self.size, target, &[(pos, self.child.as_ref())]) + .anchored(self.child_anchor) + .snap_to(self.at.point(size)); + let paint_rect = self.paint_bounds(size); + composite_children( + paint_rect, + target, + &[(pos, child_size, self.child.as_ref())], + ) } } @@ -455,36 +751,61 @@ pub mod raster { } impl RasterComponent for Stack { - fn view_box(&self) -> Vec2 { - let sizes: Vec = self.children.iter().map(|c| c.view_box()).collect(); - compute_stack_layout( + fn layout(&self, constraints: Constraints) -> Vec2 { + compute_stack_pass( self.axis, self.size, self.spacing, self.main_align, self.cross_align, - &sizes, + constraints, + self.children.len(), + |i, c| self.children[i].layout(c), ) .own_size } - fn render(&self, target: Resolution) -> RasterImage { - let sizes: Vec = self.children.iter().map(|c| c.view_box()).collect(); - let layout = compute_stack_layout( + fn paint_bounds(&self, size: Vec2) -> Rect { + let pass = compute_stack_pass( + self.axis, + self.size, + self.spacing, + self.main_align, + self.cross_align, + Constraints::tight(size), + self.children.len(), + |i, c| self.children[i].layout(c), + ); + let mut bounds = Rect { + origin: Vec2::ZERO, + size, + }; + for (child, &(pos, child_size)) in self.children.iter().zip(pass.children.iter()) { + let child_paint = child.paint_bounds(child_size); + bounds = union_rect(bounds, translate_rect(child_paint, pos)); + } + bounds + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let pass = compute_stack_pass( self.axis, self.size, self.spacing, self.main_align, self.cross_align, - &sizes, + Constraints::tight(size), + self.children.len(), + |i, c| self.children[i].layout(c), ); - let placed: Vec<(Vec2, &dyn RasterComponent)> = self + let placed: Vec<(Vec2, Vec2, &dyn RasterComponent)> = self .children .iter() - .zip(layout.placements.iter()) - .map(|(c, p)| (*p, c.as_ref())) + .zip(pass.children.iter()) + .map(|(child, &(pos, child_size))| (pos, child_size, child.as_ref())) .collect(); - composite_children(layout.own_size, target, &placed) + let paint_rect = self.paint_bounds(size); + composite_children(paint_rect, target, &placed) } } @@ -497,20 +818,34 @@ pub mod raster { } impl RasterComponent for DecoratedBox { - fn view_box(&self) -> Vec2 { - self.child.view_box() + fn layout(&self, constraints: Constraints) -> Vec2 { + self.child.layout(constraints) } - fn render(&self, target: Resolution) -> RasterImage { - let size = self.view_box(); + // paint_bounds intentionally falls back to the default + // `Rect { origin: 0, size }`, so a `.background()` acts as a + // clip rectangle for children whose paint bounds spill outward + // (e.g. drop shadows on outer children). + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let paint_rect = Rect { + origin: Vec2::ZERO, + size, + }; match self.background { Some(color) => { - let bg = SolidRect { size, color }; - let placed: Vec<(Vec2, &dyn RasterComponent)> = - vec![(Vec2::ZERO, &bg), (Vec2::ZERO, self.child.as_ref())]; - composite_children(size, target, &placed) + let bg = SolidRect { color }; + let placed: Vec<(Vec2, Vec2, &dyn RasterComponent)> = vec![ + (Vec2::ZERO, size, &bg as &dyn RasterComponent), + (Vec2::ZERO, size, self.child.as_ref()), + ]; + composite_children(paint_rect, target, &placed) } - None => composite_children(size, target, &[(Vec2::ZERO, self.child.as_ref())]), + None => composite_children( + paint_rect, + target, + &[(Vec2::ZERO, size, self.child.as_ref())], + ), } } } @@ -520,11 +855,11 @@ pub mod raster { } impl RasterComponent for SizedBox { - fn view_box(&self) -> Vec2 { - self.size + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(self.size) } - fn render(&self, target: Resolution) -> RasterImage { + fn render(&self, _size: Vec2, target: Resolution) -> RasterImage { let bytes = (target.width as usize) * (target.height as usize) * 4; RasterImage { width: target.width, @@ -535,19 +870,18 @@ pub mod raster { } } - /// Internal helper: a solid-color rectangle of the given logical size, - /// rasterized by directly filling the pixel buffer. + /// Internal helper: a solid-color rectangle that fills any layout + /// size the parent assigns, rasterized by buffer-filling. struct SolidRect { - size: Vec2, color: Color, } impl RasterComponent for SolidRect { - fn view_box(&self) -> Vec2 { - self.size + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(constraints.max) } - fn render(&self, target: Resolution) -> RasterImage { + fn render(&self, _size: Vec2, target: Resolution) -> RasterImage { let pixels = (target.width as usize) * (target.height as usize); let mut buf = Vec::with_capacity(pixels * 4); let r = (self.color.r * 255.0).round().clamp(0.0, 255.0) as u8; @@ -570,7 +904,7 @@ pub mod raster { } /// Fluent extension mirroring [`super::VectorLayoutExt`] for raster. - pub trait RasterLayoutExt: RasterComponent + Sized + 'static { + pub trait RasterLayoutExt: RasterComponent + ::core::marker::Sized + 'static { fn padding(self, insets: EdgeInsets) -> Padding { Padding { insets, @@ -584,14 +918,6 @@ pub mod raster { background: Some(color), } } - - fn align(self, size: Vec2, anchor: Anchor) -> Align { - Align { - size, - anchor, - child: Box::new(self), - } - } } impl RasterLayoutExt for T {} diff --git a/tellur-core/src/placement.rs b/tellur-core/src/placement.rs index 50a8e24..8b6ba57 100644 --- a/tellur-core/src/placement.rs +++ b/tellur-core/src/placement.rs @@ -21,7 +21,7 @@ //! [`AnchoredSize::snap_to`](crate::geometry::AnchoredSize::snap_to) so the //! geometry vocabulary carries over directly to component placement. -use crate::geometry::{Anchor, Vec2}; +use crate::geometry::{Anchor, Constraints, Vec2}; use crate::raster::RasterComponent; use crate::vector::VectorComponent; @@ -74,14 +74,12 @@ pub struct AnchoredVectorComponent { } impl AnchoredVectorComponent { - /// Places the component so the chosen anchor on its `view_box` lands on + /// Places the component so the chosen anchor on its intrinsic layout + /// size (obtained via `layout(Constraints::UNBOUNDED)`) lands on /// `target_point` in the parent's coordinate space. pub fn snap_to(self, target_point: Vec2) -> Placed { - let position = self - .component - .view_box() - .anchored(self.anchor) - .snap_to(target_point); + let intrinsic = self.component.layout(Constraints::UNBOUNDED); + let position = intrinsic.anchored(self.anchor).snap_to(target_point); Placed { position, child: Box::new(self.component), @@ -123,14 +121,12 @@ pub struct AnchoredRasterComponent { } impl AnchoredRasterComponent { - /// Places the component so the chosen anchor on its `view_box` lands on + /// Places the component so the chosen anchor on its intrinsic layout + /// size (obtained via `layout(Constraints::UNBOUNDED)`) lands on /// `target_point` in the parent's coordinate space. pub fn snap_to(self, target_point: Vec2) -> Placed { - let position = self - .component - .view_box() - .anchored(self.anchor) - .snap_to(target_point); + let intrinsic = self.component.layout(Constraints::UNBOUNDED); + let position = intrinsic.anchored(self.anchor).snap_to(target_point); Placed { position, child: Box::new(self.component), diff --git a/tellur-core/src/raster.rs b/tellur-core/src/raster.rs index 0a3f2ce..f8069b8 100644 --- a/tellur-core/src/raster.rs +++ b/tellur-core/src/raster.rs @@ -3,7 +3,7 @@ use std::io::Write; use bytes::Bytes; use thiserror::Error; -use crate::geometry::Vec2; +use crate::geometry::{Constraints, Rect, Vec2}; #[derive(Debug, Clone)] pub struct RasterImage { @@ -34,47 +34,39 @@ impl Resolution { } } -/// A component that can produce a `RasterImage` at a caller-specified -/// resolution. +/// A component that can produce a `RasterImage` at a parent-chosen +/// resolution. Mirrors [`VectorComponent`](crate::vector::VectorComponent) +/// with the same two-pass `layout` / `render` protocol, but `render` +/// takes an extra `target: Resolution` so that the parent can request a +/// specific pixel output size for the component's logical `size`. /// -/// `target` flows from the top-level call down through the component tree. -/// Each intermediate component decides what resolution to request from its -/// own children so that the final image is produced with the minimum work -/// needed to fill `target`. -/// -/// Two roles share this trait, mirroring `VectorComponent`. **Element -/// components** are leaves that own a concrete `render()` implementation -/// (e.g. `Layer`, `Rasterize`); they keep the default `body()` returning -/// `RasterBody::Element` and override `render()` themselves. **Composite -/// components** delegate to another component by overriding `body()` to -/// return `RasterBody::Of(...)`; the default `render()` then forwards -/// `target` through the chain until it reaches an element. -/// -/// Implementors must keep `view_box()` consistent with the logical size -/// they occupy in a parent's coordinate space, so layers can lay them out -/// without forcing a render. +/// 1. Parent calls [`layout`](RasterComponent::layout) with constraints; +/// child returns its layout size. +/// 2. Parent calls [`render`](RasterComponent::render) with that size +/// and the pixel resolution the child should produce. +/// 3. Optionally [`paint_bounds`](RasterComponent::paint_bounds) tells +/// the parent how far the component paints outside the layout box, +/// so `Layer` can grow the sub-resolution accordingly. pub trait RasterComponent { - fn view_box(&self) -> Vec2; - - /// Identifies whether this component is a render-owning element or a - /// delegate to another component. Default is `Element` so that leaf types - /// only need to override `render()`. - fn body(&self) -> RasterBody { - RasterBody::Element - } - - /// Produces the rasterized output. The default walks `body()` until it - /// reaches an `Element`, threading `target` through unchanged; elements - /// must override this with their own implementation. - fn render(&self, target: Resolution) -> RasterImage { - match self.body() { - RasterBody::Element => unimplemented!( - "RasterComponent with body() == RasterBody::Element must override render()" - ), - RasterBody::Of(c) => c.render(target), + /// Decide the layout size given the parent's constraints. + fn layout(&self, constraints: Constraints) -> Vec2; + + /// Paint bounds for the component once `size` has been chosen. The + /// default returns a rectangle whose `origin` is `(0, 0)` and whose + /// `size` equals the layout size; effects override to widen it. + fn paint_bounds(&self, size: Vec2) -> Rect { + Rect { + origin: Vec2::ZERO, + size, } } + /// Render the component at `size` (logical) into a `target`-sized + /// pixel buffer. The pixel buffer covers exactly the `paint_bounds` + /// rectangle for `size` — for default `paint_bounds`, that's the + /// `(0, 0)..size` region. + fn render(&self, size: Vec2, target: Resolution) -> RasterImage; + /// Type-erases `self` into a heap-allocated trait object. Useful for /// constructing heterogeneous containers like `Layer.children` in /// struct-literal form. @@ -86,15 +78,6 @@ pub trait RasterComponent { } } -/// Discriminates element components (own their `render`) from composite -/// components (delegate to another component). -pub enum RasterBody { - /// This component owns a concrete `render()` implementation. - Element, - /// Delegate to another component — `render()` forwards `target` through it. - Of(Box), -} - // Compile-time guarantee that `RasterComponent` is dyn-safe. const _: Option<&dyn RasterComponent> = None; diff --git a/tellur-core/src/shapes.rs b/tellur-core/src/shapes.rs index e39939a..ebb612e 100644 --- a/tellur-core/src/shapes.rs +++ b/tellur-core/src/shapes.rs @@ -1,11 +1,12 @@ //! Basic shape components that implement `VectorComponent`. //! -//! Each shape produces a `VectorGraphic` whose `view_box` is the shape's -//! tight bounding box, with its top-left corner anchored at the local -//! origin `(0, 0)`. Positioning within a parent coordinate space is the -//! parent's responsibility (e.g. via `VectorLayer::add`). +//! Each shape declares its intrinsic size through `layout` and produces +//! a `VectorGraphic` covering the layout-chosen size in `render`. The +//! shape will adapt if the parent imposes tight constraints — e.g. a +//! `Circle` placed under tight non-square constraints renders as an +//! ellipse. -use crate::geometry::{Transform, Vec2}; +use crate::geometry::{Constraints, Rect, Transform, Vec2}; use crate::vector::{Fill, Node, Path, PathCommand, Stroke, VectorComponent, VectorGraphic}; #[derive(Debug, Clone)] @@ -16,12 +17,12 @@ pub struct Rectangle { } impl VectorComponent for Rectangle { - fn view_box(&self) -> Vec2 { - self.size + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(self.size) } - fn render(&self) -> VectorGraphic { - let Vec2(w, h) = self.size; + fn render(&self, size: Vec2) -> VectorGraphic { + let Vec2(w, h) = size; let commands = vec![ PathCommand::MoveTo(Vec2(0.0, 0.0)), PathCommand::LineTo(Vec2(w, 0.0)), @@ -30,7 +31,10 @@ impl VectorComponent for Rectangle { PathCommand::Close, ]; VectorGraphic { - view_box: self.size, + view_box: Rect { + origin: Vec2::ZERO, + size, + }, root: Node::Path(Path { commands, fill: self.fill.clone(), @@ -49,13 +53,13 @@ pub struct Circle { } impl VectorComponent for Circle { - fn view_box(&self) -> Vec2 { - Vec2(self.radius * 2.0, self.radius * 2.0) + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(Vec2(self.radius * 2.0, self.radius * 2.0)) } - fn render(&self) -> VectorGraphic { + fn render(&self, size: Vec2) -> VectorGraphic { ellipse_to_graphic( - Vec2(self.radius, self.radius), + Vec2(size.0 * 0.5, size.1 * 0.5), self.fill.clone(), self.stroke.clone(), ) @@ -70,12 +74,16 @@ pub struct Ellipse { } impl VectorComponent for Ellipse { - fn view_box(&self) -> Vec2 { - Vec2(self.radii.0 * 2.0, self.radii.1 * 2.0) + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(Vec2(self.radii.0 * 2.0, self.radii.1 * 2.0)) } - fn render(&self) -> VectorGraphic { - ellipse_to_graphic(self.radii, self.fill.clone(), self.stroke.clone()) + fn render(&self, size: Vec2) -> VectorGraphic { + ellipse_to_graphic( + Vec2(size.0 * 0.5, size.1 * 0.5), + self.fill.clone(), + self.stroke.clone(), + ) } } @@ -118,7 +126,10 @@ fn ellipse_to_graphic(radii: Vec2, fill: Option, stroke: Option) - ]; VectorGraphic { - view_box: Vec2(rx * 2.0, ry * 2.0), + view_box: Rect { + origin: Vec2::ZERO, + size: Vec2(rx * 2.0, ry * 2.0), + }, root: Node::Path(Path { commands, fill, diff --git a/tellur-core/src/vector.rs b/tellur-core/src/vector.rs index a6d5bfd..0a7171c 100644 --- a/tellur-core/src/vector.rs +++ b/tellur-core/src/vector.rs @@ -1,53 +1,61 @@ use crate::color::Color; -use crate::geometry::{Transform, Vec2}; +use crate::geometry::{Constraints, Rect, Transform, Vec2}; -/// A piece of vector content with an intrinsic size. +/// A piece of vector content with a paint-bounds rectangle. /// -/// The graphic's coordinate space spans `(0, 0)..view_box` (top-left origin). -/// Anything outside that box may still be present in the path commands but -/// will be clipped when rasterized into the box-sized output region. Place -/// the graphic in a parent coordinate space by composing it through a -/// `Group` transform or a `VectorLayer`. +/// `view_box` is the rectangle (in the graphic's local coordinate space) +/// that should be rasterized to capture everything the graphic paints. +/// It may have a negative `origin` (e.g. an offset drop shadow that +/// spills to the upper-left) or a `size` larger than the layout size. +/// Place the graphic in a parent coordinate space by composing it +/// through a `Group` transform or a `VectorLayer`. #[derive(Debug, Clone)] pub struct VectorGraphic { - pub view_box: Vec2, + pub view_box: Rect, pub root: Node, } -/// A component that can produce a `VectorGraphic`. +/// A component that can produce a `VectorGraphic` through a two-pass +/// constraint-based layout protocol: /// -/// Two roles share this trait. **Element components** are leaves that own a -/// concrete `render()` implementation (e.g. `Rectangle`, `VectorLayer`); they -/// keep the default `body()` returning `VectorBody::Element` and override -/// `render()` themselves. **Composite components** are usually user-defined -/// and delegate to another component by overriding `body()` to return -/// `VectorBody::Of(...)`; the default `render()` then walks the chain until -/// it reaches an element. +/// 1. The parent calls [`layout`](VectorComponent::layout) with a +/// [`Constraints`] block. The child returns the size it wants within +/// those constraints. +/// 2. The parent calls [`render`](VectorComponent::render) with the +/// chosen size to obtain the graphic. /// -/// Implementors must keep `view_box()` consistent with `render().view_box`, -/// so callers can query the intrinsic size without paying for a full render. +/// Optionally the parent calls [`paint_bounds`](VectorComponent::paint_bounds) +/// with the chosen size to know how far the component paints outside the +/// layout box (useful for `Layer::render` sub-resolution sizing and for +/// rasterize buffer allocation). +/// +/// Element components implement `layout` and `render` directly. Composite +/// components (produced by `#[vector_component]`) usually do the same, +/// internally building a child component and forwarding the protocol. pub trait VectorComponent { - fn view_box(&self) -> Vec2; - - /// Identifies whether this component is a render-owning element or a - /// delegate to another component. Default is `Element` so that leaf types - /// only need to override `render()`. - fn body(&self) -> VectorBody { - VectorBody::Element - } - - /// Produces the flattened graphic. The default walks `body()` until it - /// reaches an `Element`; elements must override this with their own - /// implementation. - fn render(&self) -> VectorGraphic { - match self.body() { - VectorBody::Element => unimplemented!( - "VectorComponent with body() == VectorBody::Element must override render()" - ), - VectorBody::Of(c) => c.render(), + /// Decide the layout size for this component given the parent's + /// constraints. The returned `Vec2` must satisfy `min <= size <= max` + /// on each axis. + fn layout(&self, constraints: Constraints) -> Vec2; + + /// Paint bounds for the component once `size` has been chosen. The + /// default returns a rectangle whose `origin` is `(0, 0)` and whose + /// `size` equals the layout size. Effects that paint outside the + /// layout box (drop shadows, blurs) override this to widen the + /// rectangle. + fn paint_bounds(&self, size: Vec2) -> Rect { + Rect { + origin: Vec2::ZERO, + size, } } + /// Produce the flattened graphic at `size`. `size` is always the + /// value previously returned by `layout` for the same constraints, + /// so children may rely on it without re-checking against the + /// constraints. + fn render(&self, size: Vec2) -> VectorGraphic; + /// Type-erases `self` into a heap-allocated trait object. Useful for /// constructing heterogeneous containers like `VectorLayer.children` /// in struct-literal form. @@ -59,15 +67,6 @@ pub trait VectorComponent { } } -/// Discriminates element components (own their `render`) from composite -/// components (delegate to another component). -pub enum VectorBody { - /// This component owns a concrete `render()` implementation. - Element, - /// Delegate to another component — `render()` walks through it. - Of(Box), -} - // Compile-time guarantee that `VectorComponent` is dyn-safe. const _: Option<&dyn VectorComponent> = None; diff --git a/tellur-macros/src/lib.rs b/tellur-macros/src/lib.rs index b847044..99a79b6 100644 --- a/tellur-macros/src/lib.rs +++ b/tellur-macros/src/lib.rs @@ -3,14 +3,23 @@ //! //! ```ignore //! #[vector_component] -//! fn bouncing_dot(t: LocalTime, scene_width: f32) -> impl VectorComponent { -//! // ...returns any VectorComponent... +//! fn BouncingDot(#[available] available: Vec2, t: LocalTime) -> impl VectorComponent { +//! // `available` is the parent-assigned size at render time. +//! // `t` is a regular struct field. +//! // The body returns a component tree. //! } //! ``` //! -//! Expands to a `BouncingDot` struct (PascalCase of the fn name) whose fields -//! are the function arguments. The function body becomes the component's -//! `body()` implementation, wrapped in `VectorBody::Of(Box::new(...))`. +//! Expands to a `BouncingDot` struct (PascalCase of the fn name) whose +//! fields are the non-`#[available]` function arguments. The function +//! body becomes a private `__tellur_build` helper; the trait impl +//! forwards `layout`, `paint_bounds`, and `render` to the built body. +//! +//! When an argument is annotated with `#[available]`, that argument is +//! *not* a struct field — instead it's threaded through the layout +//! protocol: `layout(c)` builds the body with `c.max` (collapsed to 0 +//! on unbounded axes), `paint_bounds(size)` and `render(size, ...)` +//! build it with `size`. use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; @@ -23,9 +32,7 @@ pub fn vector_component(_attr: TokenStream, item: TokenStream) -> TokenStream { expand_entry(item, Kind::Vector) } -/// Attribute macro for raster components. The runtime `target: Resolution` -/// argument is *not* surfaced in the function signature — it threads through -/// the generated default `render()` via `RasterBody::Of`. +/// Attribute macro for raster components. See crate-level docs. #[proc_macro_attribute] pub fn raster_component(_attr: TokenStream, item: TokenStream) -> TokenStream { expand_entry(item, Kind::Raster) @@ -71,6 +78,8 @@ fn expand(func: ItemFn, kind: Kind) -> syn::Result { let mut field_idents: Vec<&syn::Ident> = Vec::new(); let mut field_types: Vec<&syn::Type> = Vec::new(); + let mut available_ident: Option<&syn::Ident> = None; + let mut available_type: Option<&syn::Type> = None; for arg in &func.sig.inputs { match arg { FnArg::Receiver(r) => { @@ -79,38 +88,108 @@ fn expand(func: ItemFn, kind: Kind) -> syn::Result { "component fn must not take a self receiver", )); } - FnArg::Typed(PatType { pat, ty, .. }) => { + FnArg::Typed(PatType { pat, ty, attrs, .. }) => { let Pat::Ident(pi) = pat.as_ref() else { return Err(syn::Error::new_spanned( pat, "component fn argument must be a plain identifier", )); }; - field_idents.push(&pi.ident); - field_types.push(ty.as_ref()); + let is_available = attrs.iter().any(|a| a.path().is_ident("available")); + if is_available { + if available_ident.is_some() { + return Err(syn::Error::new_spanned( + pi, + "component fn can have at most one #[available] argument", + )); + } + available_ident = Some(&pi.ident); + available_type = Some(ty.as_ref()); + } else { + field_idents.push(&pi.ident); + field_types.push(ty.as_ref()); + } } } } let body = &func.block; - let (trait_path, body_path, body_of_variant, vec2_path) = match kind { + let (trait_path, graphic_path, render_sig, render_args) = match kind { Kind::Vector => ( quote!(::tellur_core::vector::VectorComponent), - quote!(::tellur_core::vector::VectorBody), - quote!(::tellur_core::vector::VectorBody::Of), - quote!(::tellur_core::geometry::Vec2), + quote!(::tellur_core::vector::VectorGraphic), + quote!(size: ::tellur_core::geometry::Vec2), + quote!(size), ), Kind::Raster => ( quote!(::tellur_core::raster::RasterComponent), - quote!(::tellur_core::raster::RasterBody), - quote!(::tellur_core::raster::RasterBody::Of), - quote!(::tellur_core::geometry::Vec2), + quote!(::tellur_core::raster::RasterImage), + quote!( + size: ::tellur_core::geometry::Vec2, + target: ::tellur_core::raster::Resolution + ), + quote!(size, target), ), }; let build_method = syn::Ident::new("__tellur_build", fn_ident.span()); + let (build_fn, build_call_layout, build_call_render, build_call_paint_bounds) = + if let (Some(av_ident), Some(av_type)) = (available_ident, available_type) { + ( + quote! { + #[doc(hidden)] + fn #build_method(&self, #av_ident: #av_type) -> impl #trait_path + 'static { + let Self { #( #field_idents ),* } = ::core::clone::Clone::clone(self); + #body + } + }, + // layout: the parent's max constraint is what's available + // (collapsed to 0 on unbounded axes — see `finite_or_zero`). + quote! { + let __available = ::tellur_core::geometry::Vec2( + if constraints.max.0.is_finite() { constraints.max.0 } else { 0.0 }, + if constraints.max.1.is_finite() { constraints.max.1 } else { 0.0 }, + ); + let __child = self.#build_method(__available); + #trait_path::layout(&__child, constraints) + }, + // render: the assigned size *is* the available size. + quote! { + let __child = self.#build_method(size); + #trait_path::render(&__child, #render_args) + }, + // paint_bounds: also use the assigned size. + quote! { + let __child = self.#build_method(size); + #trait_path::paint_bounds(&__child, size) + }, + ) + } else { + ( + quote! { + #[doc(hidden)] + fn #build_method(&self) -> impl #trait_path + 'static { + let Self { #( #field_idents ),* } = ::core::clone::Clone::clone(self); + #body + } + }, + quote! { + let __child = self.#build_method(); + #trait_path::layout(&__child, constraints) + }, + quote! { + let __child = self.#build_method(); + #trait_path::render(&__child, #render_args) + }, + quote! { + let __child = self.#build_method(); + #trait_path::paint_bounds(&__child, size) + }, + ) + }; + let output = quote! { #[derive(::core::clone::Clone)] #vis struct #struct_ident { @@ -118,19 +197,26 @@ fn expand(func: ItemFn, kind: Kind) -> syn::Result { } impl #struct_ident { - #[doc(hidden)] - fn #build_method(&self) -> impl #trait_path + 'static { - let Self { #( #field_idents ),* } = ::core::clone::Clone::clone(self); - #body - } + #build_fn } impl #trait_path for #struct_ident { - fn view_box(&self) -> #vec2_path { - #trait_path::view_box(&self.#build_method()) + fn layout( + &self, + constraints: ::tellur_core::geometry::Constraints, + ) -> ::tellur_core::geometry::Vec2 { + #build_call_layout } - fn body(&self) -> #body_path { - #body_of_variant(::std::boxed::Box::new(self.#build_method())) + + fn paint_bounds( + &self, + size: ::tellur_core::geometry::Vec2, + ) -> ::tellur_core::geometry::Rect { + #build_call_paint_bounds + } + + fn render(&self, #render_sig) -> #graphic_path { + #build_call_render } } }; diff --git a/tellur-renderer/examples/raster_layer_to_png.rs b/tellur-renderer/examples/raster_layer_to_png.rs index f0ca726..944bb17 100644 --- a/tellur-renderer/examples/raster_layer_to_png.rs +++ b/tellur-renderer/examples/raster_layer_to_png.rs @@ -63,7 +63,7 @@ fn main() { ], }; - let image = scene.render(Resolution::new(1280, 720)); + let image = scene.render(scene_size, Resolution::new(1280, 720)); let path = "/tmp/raster-scene.png"; let file = File::create(path).expect("create output file"); diff --git a/tellur-renderer/examples/scene_to_png.rs b/tellur-renderer/examples/scene_to_png.rs index 0783c3d..be1563e 100644 --- a/tellur-renderer/examples/scene_to_png.rs +++ b/tellur-renderer/examples/scene_to_png.rs @@ -39,7 +39,9 @@ fn main() { ], }; - let image = scene.rasterize().render(Resolution::new(1280, 720)); + let image = scene + .rasterize() + .render(scene_size, Resolution::new(1280, 720)); let path = "/tmp/scene.png"; let file = File::create(path).expect("create output file"); diff --git a/tellur-renderer/examples/timeline_to_mp4.rs b/tellur-renderer/examples/timeline_to_mp4.rs index 0034fe1..b956948 100644 --- a/tellur-renderer/examples/timeline_to_mp4.rs +++ b/tellur-renderer/examples/timeline_to_mp4.rs @@ -1,78 +1,99 @@ //! Compare 60 / 30 / 24 / 16 fps quantization on a back-and-forth dot. //! //! Renders a 5-second timeline where four blue dots bounce horizontally. -//! Each dot is a `BouncingDot` whose motion is driven by the time fed to -//! it; the four instances receive the same `t` quantized to different -//! framerates via `Time::fps`. A vertical `Stack` distributes the four -//! tracks evenly inside the scene, with a `DecoratedBox` painting the -//! dark background. +//! Each dot is a `BouncingDot` whose motion is driven by the time fed +//! to it; the four instances receive the same `t` quantized to +//! different framerates via `Time::fps`. A vertical `Stack` +//! distributes the four tracks evenly inside a padded scene with +//! `CrossAlign::Stretch`. The dot itself is purely a tree of layout +//! containers — `Frame` declares its outer shape and anchors the +//! shadowed circle inside it; `.padding(...)` keeps the dot off the +//! track edges. The `DropShadow` is wrapped directly around the +//! circle, where the shadow conceptually belongs. use std::path::Path; use tellur_core::color::Color; -use tellur_core::geometry::{EdgeInsets, Vec2}; -use tellur_core::layer::VectorLayer; -use tellur_core::layout::{Axis, CrossAlign, MainAlign, Stack, VectorLayoutExt}; -use tellur_core::placement::VectorPlacement; +use tellur_core::geometry::{Anchor, EdgeInsets, Vec2}; +use tellur_core::layout::raster::{Frame, RasterLayoutExt, Stack}; +use tellur_core::layout::{Axis, CrossAlign, MainAlign, SizeMode}; use tellur_core::raster::{RasterComponent, Resolution}; +use tellur_core::raster_component; use tellur_core::shapes::Circle; use tellur_core::time::{LocalTime, Time}; use tellur_core::timeline::timeline; -use tellur_core::vector::{Paint, VectorComponent}; -use tellur_core::vector_component; -use tellur_renderer::{FfmpegEncoder, Rasterizable}; +use tellur_core::vector::Paint; +use tellur_renderer::{DropShadow, FfmpegEncoder, Rasterizable}; -/// A circle that triangle-wave scrubs left-to-right-to-left across a track -/// of `scene_width`, with one full round trip per `PERIOD` seconds. The -/// motion is driven entirely by `t` — a `LocalTime` clock that the -/// component reads independently of the global timeline. Callers can pass -/// `TimelineTime` directly via `.into()` since it converts to `LocalTime`. -#[vector_component] -fn BouncingDot(t: LocalTime, scene_width: f32) -> impl VectorComponent { +/// A circle that triangle-wave scrubs left-to-right-to-left across the +/// track's width. `Frame` declares the track's outer shape (fill the +/// parent width, fix the height at 60) and anchors the circle so it +/// stays fully inside: both `child_anchor` and `at` use the same +/// bounce-driven ratio, so the dot's left edge touches the frame's +/// left at `rx = 0` and its right edge touches at `rx = 1`. The whole +/// track is wrapped in a `DropShadow`. +#[raster_component] +fn BouncingDot(t: LocalTime) -> impl RasterComponent { let (phase, _) = t.bounce(2.5); - let size = Vec2(scene_width - 200.0, 60.0); - let x = phase.interpolate(0.0, 1.0) * size.0; - - VectorLayer { - size, - children: vec![Circle { - radius: 30.0, - fill: Paint::Solid(Color::hsl(200.0, 0.7, 0.6)).into(), - stroke: None, + let rx = phase.interpolate(0.0, 1.0); + let radius = 30.0; + Frame { + width: SizeMode::Fill, + height: SizeMode::Fixed(60.0), + child_anchor: Anchor::CENTER, + at: Anchor::new(rx, 0.5), + child: DropShadow { + offset: Vec2(0.0, 8.0), + blur: 4.0, + color: Color::rgba_u8(255, 255, 255, 100), + child: Circle { + radius, + fill: Paint::Solid(Color::hsl(200.0, 0.7, 0.6)).into(), + stroke: None, + } + .rasterize() + .boxed(), } - .anchored(tellur_core::geometry::Anchor::CENTER) - .snap_to(Vec2(x, size.1 * 0.5))], + .boxed(), } } fn main() { let scene_size = Vec2(1280.0, 720.0); let tl = timeline(5.0, move |t, target| { - let track = |fps: u32| { - BouncingDot { - t: t.fps(fps).into(), - scene_width: scene_size.0, - } - .boxed() - }; - - let scene = Stack { + Stack { axis: Axis::Vertical, - size: Some(scene_size), + size: None, spacing: 0.0, main_align: MainAlign::SpaceEvenly, - cross_align: CrossAlign::Center, - children: vec![track(60), track(30), track(24), track(16)], + cross_align: CrossAlign::Stretch, + children: vec![ + BouncingDot { + t: t.fps(60).into(), + } + .boxed(), + BouncingDot { + t: t.fps(30).into(), + } + .boxed(), + BouncingDot { + t: t.fps(24).into(), + } + .boxed(), + BouncingDot { + t: t.fps(16).into(), + } + .boxed(), + ], } .padding(EdgeInsets::all(100.0)) - .background(Paint::Solid(Color::rgb_u8(20, 20, 30))); - - scene.rasterize().render(target) + .background(Color::rgb_u8(20, 20, 30)) + .render(scene_size, target) }); let out = Path::new("/tmp/timeline.mp4"); FfmpegEncoder::new(Resolution::new(1920, 1080), 60) - .args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20"]) + .args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"]) .encode(&tl, out) .expect("encode mp4"); diff --git a/tellur-renderer/src/lib.rs b/tellur-renderer/src/lib.rs index a6d7393..1ce3427 100644 --- a/tellur-renderer/src/lib.rs +++ b/tellur-renderer/src/lib.rs @@ -1,5 +1,7 @@ pub mod rasterize; +pub mod shadow; pub mod video; pub use rasterize::{Rasterizable, Rasterize}; +pub use shadow::DropShadow; pub use video::{FfmpegEncoder, FfmpegError}; diff --git a/tellur-renderer/src/rasterize.rs b/tellur-renderer/src/rasterize.rs index b4d2675..5a6f4ee 100644 --- a/tellur-renderer/src/rasterize.rs +++ b/tellur-renderer/src/rasterize.rs @@ -1,22 +1,28 @@ use bytes::Bytes; use tellur_core::color::Color; -use tellur_core::geometry::{Transform, Vec2}; +use tellur_core::geometry::{Constraints, Rect, Transform, Vec2}; use tellur_core::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; use tellur_core::vector::{Node, Paint, Path, PathCommand, VectorComponent, VectorGraphic}; -/// A `RasterComponent` that rasterizes a `VectorComponent` at the resolution -/// requested by the caller of `render`. +/// A `RasterComponent` that rasterizes a `VectorComponent`. The layout +/// protocol forwards to the wrapped vector: layout / paint_bounds / +/// render(size) all delegate, and `render(size, target)` rasterizes the +/// vector's `render(size)` output into a `target`-sized pixel buffer. pub struct Rasterize { pub vector: V, } impl RasterComponent for Rasterize { - fn view_box(&self) -> Vec2 { - self.vector.view_box() + fn layout(&self, constraints: Constraints) -> Vec2 { + self.vector.layout(constraints) } - fn render(&self, target: Resolution) -> RasterImage { - let graphic = self.vector.render(); + fn paint_bounds(&self, size: Vec2) -> Rect { + self.vector.paint_bounds(size) + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let graphic = self.vector.render(size); rasterize(&graphic, target.width, target.height) } } @@ -55,13 +61,20 @@ fn rasterize(graphic: &VectorGraphic, width: u32, height: u32) -> RasterImage { } } -/// Transform that maps the graphic's local coordinate space `(0, 0)..view_box` -/// into pixel space `(0, 0)..(width, height)`. -/// Equivalent to SVG's `preserveAspectRatio="none"` (each axis is scaled independently). -fn view_box_transform(view_box: Vec2, width: u32, height: u32) -> tiny_skia::Transform { - let sx = width as f32 / view_box.0; - let sy = height as f32 / view_box.1; - tiny_skia::Transform::from_row(sx, 0.0, 0.0, sy, 0.0, 0.0) +/// Transform that maps the graphic's local coordinate space +/// `view_box.origin..view_box.origin + view_box.size` into pixel space +/// `(0, 0)..(width, height)`. Equivalent to SVG's +/// `preserveAspectRatio="none"` (each axis is scaled independently). The +/// `view_box.origin` offset shifts the graphic so the top-left of +/// `view_box` lands on pixel `(0, 0)`, which is required for effects +/// like drop shadows whose paint bounds extend into negative +/// coordinates. +fn view_box_transform(view_box: Rect, width: u32, height: u32) -> tiny_skia::Transform { + let sx = width as f32 / view_box.size.0; + let sy = height as f32 / view_box.size.1; + let tx = -view_box.origin.0 * sx; + let ty = -view_box.origin.1 * sy; + tiny_skia::Transform::from_row(sx, 0.0, 0.0, sy, tx, ty) } fn render_node(pixmap: &mut tiny_skia::Pixmap, node: &Node, parent_xform: tiny_skia::Transform) { diff --git a/tellur-renderer/src/shadow.rs b/tellur-renderer/src/shadow.rs new file mode 100644 index 0000000..2307643 --- /dev/null +++ b/tellur-renderer/src/shadow.rs @@ -0,0 +1,289 @@ +//! Drop-shadow effect for raster components. +//! +//! Wraps a `RasterComponent` and paints a blurred, color-tinted copy of +//! the child's alpha shape behind it. The component's `paint_bounds` +//! expands to include the shadow so the surrounding `Layer` allocates +//! enough pixels; its `layout_box` is left unchanged so shadows do not +//! disturb layout. + +use bytes::Bytes; +use tellur_core::color::Color; +use tellur_core::geometry::{Constraints, Rect, Vec2}; +use tellur_core::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; + +pub struct DropShadow { + /// Offset of the shadow relative to the child, in logical units. + pub offset: Vec2, + /// Gaussian-equivalent blur radius (logical units). + pub blur: f32, + /// Shadow color (the alpha channel is multiplied with the child's). + pub color: Color, + pub child: Box, +} + +/// 3-pass box blur with kernel radius `r` has a total convolution +/// support of `3 * r` on each side of the source. Both `paint_bounds` +/// and the per-pixel `make_shadow` padding must agree on this extent so +/// the shadow does not get hard-cut at the edge of the paint region. +const BLUR_EXTENT_MULTIPLIER: f32 = 3.0; + +impl RasterComponent for DropShadow { + fn layout(&self, constraints: Constraints) -> Vec2 { + self.child.layout(constraints) + } + + fn paint_bounds(&self, size: Vec2) -> Rect { + let inner = self.child.paint_bounds(size); + let blur_extent = self.blur.max(0.0) * BLUR_EXTENT_MULTIPLIER; + let shadow_origin = Vec2( + inner.origin.0 + self.offset.0 - blur_extent, + inner.origin.1 + self.offset.1 - blur_extent, + ); + let shadow_end = Vec2( + inner.origin.0 + inner.size.0 + self.offset.0 + blur_extent, + inner.origin.1 + inner.size.1 + self.offset.1 + blur_extent, + ); + let inner_end = Vec2(inner.origin.0 + inner.size.0, inner.origin.1 + inner.size.1); + let union_origin = Vec2( + inner.origin.0.min(shadow_origin.0), + inner.origin.1.min(shadow_origin.1), + ); + let union_end = Vec2(inner_end.0.max(shadow_end.0), inner_end.1.max(shadow_end.1)); + Rect { + origin: union_origin, + size: Vec2(union_end.0 - union_origin.0, union_end.1 - union_origin.1), + } + } + + fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + let paint = self.paint_bounds(size); + let child_paint = self.child.paint_bounds(size); + if paint.size.0 <= 0.0 || paint.size.1 <= 0.0 { + return blank_image(target); + } + let sx = target.width as f32 / paint.size.0; + let sy = target.height as f32 / paint.size.1; + + // Render the child at its own paint-bounds pixel size. + let child_px_w = (child_paint.size.0 * sx).round().max(1.0) as u32; + let child_px_h = (child_paint.size.1 * sy).round().max(1.0) as u32; + let child_image = self + .child + .render(size, Resolution::new(child_px_w, child_px_h)); + + // Build a padded shadow image whose alpha is a blurred copy of + // the child's alpha, tinted with `color`. Padding equals the + // 3-pass box-blur extent (3 * radius) so the shadow can spread + // beyond the child's own bounds. + let blur_px = (self.blur * sx.max(sy)).round().max(0.0) as u32; + let shadow_image = make_shadow(&child_image, blur_px, self.color); + + // Composite shadow then child into a buffer covering `paint`. + let mut accum = vec![0u8; (target.width as usize) * (target.height as usize) * 4]; + + // Position the shadow's top-left in the output buffer. The + // shadow image's local origin corresponds to + // `(child_paint.origin + offset - pad)` in our paint-bounds + // coordinate space, where `pad` is the 3-pass extent in pixels. + let pad_px = blur_px as f32 * BLUR_EXTENT_MULTIPLIER; + let pad_lu_x = pad_px / sx; + let pad_lu_y = pad_px / sy; + let shadow_local_x = (child_paint.origin.0 + self.offset.0 - pad_lu_x) - paint.origin.0; + let shadow_local_y = (child_paint.origin.1 + self.offset.1 - pad_lu_y) - paint.origin.1; + let shadow_px_x = (shadow_local_x * sx).round() as i32; + let shadow_px_y = (shadow_local_y * sy).round() as i32; + composite_at(&mut accum, target, &shadow_image, shadow_px_x, shadow_px_y); + + // Position the child relative to the paint-bounds origin. + let child_local_x = child_paint.origin.0 - paint.origin.0; + let child_local_y = child_paint.origin.1 - paint.origin.1; + let child_px_x = (child_local_x * sx).round() as i32; + let child_px_y = (child_local_y * sy).round() as i32; + composite_at(&mut accum, target, &child_image, child_px_x, child_px_y); + + RasterImage { + width: target.width, + height: target.height, + format: PixelFormat::Rgba8, + pixels: Bytes::from(accum), + } + } +} + +fn blank_image(target: Resolution) -> RasterImage { + let bytes = (target.width as usize) * (target.height as usize) * 4; + RasterImage { + width: target.width, + height: target.height, + format: PixelFormat::Rgba8, + pixels: Bytes::from(vec![0u8; bytes]), + } +} + +fn make_shadow(image: &RasterImage, blur_radius: u32, color: Color) -> RasterImage { + assert_eq!(image.format, PixelFormat::Rgba8); + let pad = (blur_radius as f32 * BLUR_EXTENT_MULTIPLIER).round() as usize; + let in_w = image.width as usize; + let in_h = image.height as usize; + let out_w = in_w + 2 * pad; + let out_h = in_h + 2 * pad; + + let mut alpha = vec![0u8; out_w * out_h]; + let pixels = image.pixels.as_ref(); + for y in 0..in_h { + for x in 0..in_w { + let src_idx = (y * in_w + x) * 4 + 3; + let dst_idx = (y + pad) * out_w + (x + pad); + alpha[dst_idx] = pixels[src_idx]; + } + } + + if blur_radius > 0 { + box_blur_3pass(&mut alpha, out_w, out_h, blur_radius as usize); + } + + let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8; + let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8; + let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8; + let alpha_scale = color.a.clamp(0.0, 1.0); + + let mut out = Vec::with_capacity(out_w * out_h * 4); + for &alpha_value in &alpha { + let a = ((alpha_value as f32) * alpha_scale) + .round() + .clamp(0.0, 255.0) as u8; + out.push(r); + out.push(g); + out.push(b); + out.push(a); + } + + RasterImage { + width: out_w as u32, + height: out_h as u32, + format: PixelFormat::Rgba8, + pixels: Bytes::from(out), + } +} + +fn box_blur_3pass(buf: &mut [u8], w: usize, h: usize, radius: usize) { + let mut temp = vec![0u8; buf.len()]; + for _ in 0..3 { + box_blur_h(buf, &mut temp, w, h, radius); + box_blur_v(&temp, buf, w, h, radius); + } +} + +fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, radius: usize) { + if w == 0 || h == 0 { + return; + } + for y in 0..h { + let row = y * w; + let mut sum: u32 = 0; + let mut count: u32 = 0; + // Initialize window covering [0, radius]. + let init_end = radius.min(w - 1); + for x in 0..=init_end { + sum += src[row + x] as u32; + count += 1; + } + for x in 0..w { + dst[row + x] = (sum / count) as u8; + // Slide: add x+radius+1 if in range, drop x-radius if in range. + let add_idx = x + radius + 1; + if add_idx < w { + sum += src[row + add_idx] as u32; + count += 1; + } + if x >= radius { + sum -= src[row + x - radius] as u32; + count -= 1; + } + } + } +} + +fn box_blur_v(src: &[u8], dst: &mut [u8], w: usize, h: usize, radius: usize) { + if w == 0 || h == 0 { + return; + } + for x in 0..w { + let mut sum: u32 = 0; + let mut count: u32 = 0; + let init_end = radius.min(h - 1); + for y in 0..=init_end { + sum += src[y * w + x] as u32; + count += 1; + } + for y in 0..h { + dst[y * w + x] = (sum / count) as u8; + let add_idx = y + radius + 1; + if add_idx < h { + sum += src[add_idx * w + x] as u32; + count += 1; + } + if y >= radius { + sum -= src[(y - radius) * w + x] as u32; + count -= 1; + } + } + } +} + +// Source-over compositing of `src` onto `dst` at pixel offset +// `(offset_x, offset_y)`. Both buffers hold 8-bit straight-alpha RGBA. +fn composite_at( + dst: &mut [u8], + dst_size: Resolution, + src: &RasterImage, + offset_x: i32, + offset_y: i32, +) { + assert_eq!(src.format, PixelFormat::Rgba8); + let src_pixels = src.pixels.as_ref(); + let dst_w = dst_size.width as i32; + let dst_h = dst_size.height as i32; + let src_w = src.width as i32; + let src_h = src.height as i32; + + let x_start = offset_x.max(0); + let y_start = offset_y.max(0); + let x_end = (offset_x + src_w).min(dst_w); + let y_end = (offset_y + src_h).min(dst_h); + + for dy in y_start..y_end { + for dx in x_start..x_end { + let sx = dx - offset_x; + let sy = dy - offset_y; + let src_idx = ((sy * src_w + sx) * 4) as usize; + let dst_idx = ((dy * dst_w + dx) * 4) as usize; + + let sr = src_pixels[src_idx] as f32 / 255.0; + let sg = src_pixels[src_idx + 1] as f32 / 255.0; + let sb = src_pixels[src_idx + 2] as f32 / 255.0; + let sa = src_pixels[src_idx + 3] as f32 / 255.0; + let dr = dst[dst_idx] as f32 / 255.0; + let dg = dst[dst_idx + 1] as f32 / 255.0; + let db = dst[dst_idx + 2] as f32 / 255.0; + let da = dst[dst_idx + 3] as f32 / 255.0; + + let inv_sa = 1.0 - sa; + let out_a = sa + da * inv_sa; + let (out_r, out_g, out_b) = if out_a > 0.0 { + ( + (sr * sa + dr * da * inv_sa) / out_a, + (sg * sa + dg * da * inv_sa) / out_a, + (sb * sa + db * da * inv_sa) / out_a, + ) + } else { + (0.0, 0.0, 0.0) + }; + + dst[dst_idx] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8; + dst[dst_idx + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8; + dst[dst_idx + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8; + dst[dst_idx + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8; + } + } +}