diff --git a/Cargo.lock b/Cargo.lock index aeb92c8..439944b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,212 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tellur-core" version = "0.1.0" +dependencies = [ + "bytes", + "png", + "thiserror", +] [[package]] name = "tellur-renderer" version = "0.1.0" dependencies = [ + "bytes", "tellur-core", + "tiny-skia", ] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/tellur-core/Cargo.toml b/tellur-core/Cargo.toml index 9227ca5..167733a 100644 --- a/tellur-core/Cargo.toml +++ b/tellur-core/Cargo.toml @@ -2,3 +2,8 @@ name = "tellur-core" version = "0.1.0" edition = "2021" + +[dependencies] +bytes = "1.11.1" +png = "0.18.1" +thiserror = "2.0.18" diff --git a/tellur-core/src/color.rs b/tellur-core/src/color.rs new file mode 100644 index 0000000..d0d273a --- /dev/null +++ b/tellur-core/src/color.rs @@ -0,0 +1,90 @@ +/// sRGB with straight alpha. Each component is in the range `[0.0, 1.0]`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Color { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl Color { + /// Opaque color from 8-bit sRGB components. + pub const fn rgb_u8(r: u8, g: u8, b: u8) -> Self { + Self::rgba_u8(r, g, b, 255) + } + + /// Color from 8-bit sRGB + alpha components. + pub const fn rgba_u8(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: a as f32 / 255.0, + } + } + + /// Opaque color from HSV. + /// + /// `h` is the hue in degrees (wraps around 360); `s` and `v` are in `[0, 1]`. + /// Result is interpreted as sRGB (matches the usual "color picker" intuition, + /// not linear light). + pub fn hsv(h: f32, s: f32, v: f32) -> Self { + Self::hsva(h, s, v, 1.0) + } + + /// Color from HSV + alpha. See [`Color::hsv`] for the input ranges. + pub fn hsva(h: f32, s: f32, v: f32, a: f32) -> Self { + let c = v * s; + let x = chroma_x(h, c); + let m = v - c; + let (r1, g1, b1) = hue_sector(h, c, x); + Self { + r: r1 + m, + g: g1 + m, + b: b1 + m, + a, + } + } + + /// Opaque color from HSL. + /// + /// `h` is the hue in degrees (wraps around 360); `s` and `l` are in `[0, 1]`. + /// Result is interpreted as sRGB. + pub fn hsl(h: f32, s: f32, l: f32) -> Self { + Self::hsla(h, s, l, 1.0) + } + + /// Color from HSL + alpha. See [`Color::hsl`] for the input ranges. + pub fn hsla(h: f32, s: f32, l: f32, a: f32) -> Self { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = chroma_x(h, c); + let m = l - c / 2.0; + let (r1, g1, b1) = hue_sector(h, c, x); + Self { + r: r1 + m, + g: g1 + m, + b: b1 + m, + a, + } + } +} + +// Intermediate `X` value shared by the HSV and HSL conversion formulas. +fn chroma_x(h: f32, c: f32) -> f32 { + let h6 = h.rem_euclid(360.0) / 60.0; + c * (1.0 - (h6 % 2.0 - 1.0).abs()) +} + +// Pick the (R', G', B') components for the hue sector before adding the +// achromatic offset `m`. Hue is wrapped to `[0, 360)`. +fn hue_sector(h: f32, c: f32, x: f32) -> (f32, f32, f32) { + let sector = (h.rem_euclid(360.0) / 60.0) as u32; + match sector { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + } +} diff --git a/tellur-core/src/geometry.rs b/tellur-core/src/geometry.rs new file mode 100644 index 0000000..0c28d19 --- /dev/null +++ b/tellur-core/src/geometry.rs @@ -0,0 +1,132 @@ +//! 2D geometric primitives. +//! +//! The project uses a coordinate system with **origin at the top-left and Y axis +//! pointing down**. + +use std::ops::{Add, Sub}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Vec2(pub f32, pub f32); + +impl Vec2 { + pub const ZERO: Self = Self(0.0, 0.0); + + /// Treats `self` as a size and pairs it with an anchor point on that box, + /// ready to be snapped to another anchored size via [`AnchoredSize::snap_to`]. + pub fn anchor(self, anchor: Anchor) -> AnchoredSize { + AnchoredSize { size: self, anchor } + } +} + +impl Add for Vec2 { + type Output = Vec2; + fn add(self, rhs: Vec2) -> Vec2 { + Vec2(self.0 + rhs.0, self.1 + rhs.1) + } +} + +impl Sub for Vec2 { + type Output = Vec2; + fn sub(self, rhs: Vec2) -> Vec2 { + Vec2(self.0 - rhs.0, self.1 - rhs.1) + } +} + +/// Axis-aligned rectangle. +/// +/// `origin` is the top-left corner (the smaller-coordinate side); `origin + size` +/// is the bottom-right corner. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub origin: Vec2, + pub size: Vec2, +} + +/// 2x3 affine transformation matrix. +/// +/// ```text +/// | a c tx | +/// | b d ty | +/// | 0 0 1 | +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Transform { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub tx: f32, + pub ty: f32, +} + +impl Transform { + pub const IDENTITY: Self = Self { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + tx: 0.0, + ty: 0.0, + }; + + pub const fn translate(offset: Vec2) -> Self { + Self { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + tx: offset.0, + ty: offset.1, + } + } +} + +/// A relative position within an axis-aligned box. +/// +/// `(rx, ry)` are fractions in `[0, 1]`: `(0, 0)` is top-left, `(1, 1)` is +/// bottom-right, `(0.5, 0.5)` is the center. Values outside `[0, 1]` are +/// allowed and address points outside the box, which is occasionally useful. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Anchor { + pub rx: f32, + pub ry: f32, +} + +impl Anchor { + pub const TOP_LEFT: Self = Self::new(0.0, 0.0); + pub const TOP_CENTER: Self = Self::new(0.5, 0.0); + pub const TOP_RIGHT: Self = Self::new(1.0, 0.0); + pub const CENTER_LEFT: Self = Self::new(0.0, 0.5); + pub const CENTER: Self = Self::new(0.5, 0.5); + pub const CENTER_RIGHT: Self = Self::new(1.0, 0.5); + pub const BOTTOM_LEFT: Self = Self::new(0.0, 1.0); + pub const BOTTOM_CENTER: Self = Self::new(0.5, 1.0); + pub const BOTTOM_RIGHT: Self = Self::new(1.0, 1.0); + + pub const fn new(rx: f32, ry: f32) -> Self { + Self { rx, ry } + } + + /// Returns the absolute point this anchor refers to within a box of the + /// given size, assuming the box's top-left is at the origin. + pub fn point(self, size: Vec2) -> Vec2 { + Vec2(size.0 * self.rx, size.1 * self.ry) + } +} + +/// A size paired with an anchor on that size, produced by [`Vec2::anchor`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AnchoredSize { + pub size: Vec2, + pub anchor: Anchor, +} + +impl AnchoredSize { + /// Computes the offset for a box of `self.size` so that `self.anchor` on + /// it lands on `target_anchor` of a box of `target_size`. The returned + /// `Vec2` is the top-left position of the placed box in the target's + /// coordinate space. + pub fn snap_to(self, target_size: Vec2, target_anchor: Anchor) -> Vec2 { + target_anchor.point(target_size) - self.anchor.point(self.size) + } +} diff --git a/tellur-core/src/layer.rs b/tellur-core/src/layer.rs new file mode 100644 index 0000000..f997626 --- /dev/null +++ b/tellur-core/src/layer.rs @@ -0,0 +1,185 @@ +//! 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. +//! +//! `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. +//! +//! `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`. + +use bytes::Bytes; + +use crate::geometry::{Transform, Vec2}; +use crate::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; +use crate::vector::{Group, Node, VectorComponent, VectorGraphic}; + +pub struct VectorLayer { + pub size: Vec2, + children: Vec<(Vec2, Box)>, +} + +impl VectorLayer { + pub fn new(size: Vec2) -> Self { + Self { + size, + children: Vec::new(), + } + } + + pub fn add(&mut self, position: Vec2, child: C) -> &mut Self { + self.children.push((position, Box::new(child))); + self + } +} + +impl VectorComponent for VectorLayer { + fn view_box(&self) -> Vec2 { + self.size + } + + fn render(&self) -> VectorGraphic { + let children = self + .children + .iter() + .map(|(pos, c)| { + let child = c.render(); + Node::Group(Group { + transform: Transform::translate(*pos), + opacity: 1.0, + children: vec![child.root], + }) + }) + .collect(); + VectorGraphic { + view_box: self.size, + root: Node::Group(Group { + transform: Transform::IDENTITY, + opacity: 1.0, + children, + }), + } + } +} + +pub struct Layer { + pub size: Vec2, + children: Vec<(Vec2, Box)>, +} + +impl Layer { + pub fn new(size: Vec2) -> Self { + Self { + size, + children: Vec::new(), + } + } + + pub fn add(&mut self, position: Vec2, child: C) -> &mut Self { + self.children.push((position, Box::new(child))); + self + } +} + +impl RasterComponent for Layer { + fn view_box(&self) -> Vec2 { + self.size + } + + fn render(&self, target: Resolution) -> 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 / self.size.0; + let scale_y = target.height as f32 / self.size.1; + + for (pos, child) in &self.children { + 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 = (pos.0 * scale_x).round() as i32; + let offset_y = (pos.1 * scale_y).round() as i32; + + let image = child.render(Resolution::new(child_px_w, child_px_h)); + composite_at(&mut accum, target, &image, offset_x, offset_y); + } + + RasterImage { + width: target.width, + height: target.height, + format: PixelFormat::Rgba8, + pixels: Bytes::from(accum), + } + } +} + +// 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. +fn composite_at( + dst: &mut [u8], + dst_size: Resolution, + src: &RasterImage, + offset_x: i32, + offset_y: i32, +) { + assert_eq!( + src.format, + PixelFormat::Rgba8, + "Layer only supports Rgba8 children for now" + ); + 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; + + // Iterate only over the overlapping rectangle to skip clipped rows/cols. + 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; + } + } +} diff --git a/tellur-core/src/lib.rs b/tellur-core/src/lib.rs index 8b13789..e579578 100644 --- a/tellur-core/src/lib.rs +++ b/tellur-core/src/lib.rs @@ -1 +1,6 @@ - +pub mod color; +pub mod geometry; +pub mod layer; +pub mod raster; +pub mod shapes; +pub mod vector; diff --git a/tellur-core/src/raster.rs b/tellur-core/src/raster.rs new file mode 100644 index 0000000..f20456c --- /dev/null +++ b/tellur-core/src/raster.rs @@ -0,0 +1,91 @@ +use std::io::Write; + +use bytes::Bytes; +use thiserror::Error; + +use crate::geometry::Vec2; + +#[derive(Debug, Clone)] +pub struct RasterImage { + pub width: u32, + pub height: u32, + pub format: PixelFormat, + pub pixels: Bytes, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PixelFormat { + /// 8-bit per channel sRGB with straight (non-premultiplied) alpha. + Rgba8, + /// 16-bit float per channel linear with alpha. Used for HDR. + Rgba16Float, +} + +/// Target output resolution for a `RasterComponent::render` call, in pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Resolution { + pub width: u32, + pub height: u32, +} + +impl Resolution { + pub const fn new(width: u32, height: u32) -> Self { + Self { width, height } + } +} + +/// A component that can produce a `RasterImage` at a caller-specified +/// resolution. +/// +/// `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`. +/// +/// 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. +pub trait RasterComponent { + fn view_box(&self) -> Vec2; + fn render(&self, target: Resolution) -> RasterImage; +} + +// Compile-time guarantee that `RasterComponent` is dyn-safe. +const _: Option<&dyn RasterComponent> = None; + +#[derive(Debug, Error)] +pub enum PngExportError { + #[error("PNG export is not supported for pixel format {0:?}")] + UnsupportedFormat(PixelFormat), + #[error("pixel buffer size mismatch: expected {expected} bytes, got {actual}")] + SizeMismatch { expected: usize, actual: usize }, + #[error("PNG encoding failed: {0}")] + Encode(#[from] png::EncodingError), +} + +impl RasterImage { + /// Encodes the image as PNG and writes it to `writer`. + /// + /// Only `PixelFormat::Rgba8` is currently supported. HDR formats require + /// linear-to-sRGB conversion and are not yet handled. + pub fn export_png(&self, writer: W) -> Result<(), PngExportError> { + if self.format != PixelFormat::Rgba8 { + return Err(PngExportError::UnsupportedFormat(self.format)); + } + + let expected = (self.width as usize) * (self.height as usize) * 4; + if self.pixels.len() != expected { + return Err(PngExportError::SizeMismatch { + expected, + actual: self.pixels.len(), + }); + } + + let mut encoder = png::Encoder::new(writer, self.width, self.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut png_writer = encoder.write_header()?; + png_writer.write_image_data(&self.pixels)?; + Ok(()) + } +} diff --git a/tellur-core/src/shapes.rs b/tellur-core/src/shapes.rs new file mode 100644 index 0000000..e39939a --- /dev/null +++ b/tellur-core/src/shapes.rs @@ -0,0 +1,129 @@ +//! 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`). + +use crate::geometry::{Transform, Vec2}; +use crate::vector::{Fill, Node, Path, PathCommand, Stroke, VectorComponent, VectorGraphic}; + +#[derive(Debug, Clone)] +pub struct Rectangle { + pub size: Vec2, + pub fill: Option, + pub stroke: Option, +} + +impl VectorComponent for Rectangle { + fn view_box(&self) -> Vec2 { + self.size + } + + fn render(&self) -> VectorGraphic { + let Vec2(w, h) = self.size; + let commands = vec![ + PathCommand::MoveTo(Vec2(0.0, 0.0)), + PathCommand::LineTo(Vec2(w, 0.0)), + PathCommand::LineTo(Vec2(w, h)), + PathCommand::LineTo(Vec2(0.0, h)), + PathCommand::Close, + ]; + VectorGraphic { + view_box: self.size, + root: Node::Path(Path { + commands, + fill: self.fill.clone(), + stroke: self.stroke.clone(), + transform: Transform::IDENTITY, + }), + } + } +} + +#[derive(Debug, Clone)] +pub struct Circle { + pub radius: f32, + pub fill: Option, + pub stroke: Option, +} + +impl VectorComponent for Circle { + fn view_box(&self) -> Vec2 { + Vec2(self.radius * 2.0, self.radius * 2.0) + } + + fn render(&self) -> VectorGraphic { + ellipse_to_graphic( + Vec2(self.radius, self.radius), + self.fill.clone(), + self.stroke.clone(), + ) + } +} + +#[derive(Debug, Clone)] +pub struct Ellipse { + pub radii: Vec2, + pub fill: Option, + pub stroke: Option, +} + +impl VectorComponent for Ellipse { + fn view_box(&self) -> Vec2 { + 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()) + } +} + +// Magic constant for approximating a quarter-circle with a cubic Bezier: +// 4 * (sqrt(2) - 1) / 3. The maximum error is around 0.027% of the radius. +const KAPPA: f32 = 0.552_284_8; + +// Builds an ellipse whose tight bounding box is anchored at the local origin +// `(0, 0)` and has size `2 * radii`. +fn ellipse_to_graphic(radii: Vec2, fill: Option, stroke: Option) -> VectorGraphic { + let Vec2(rx, ry) = radii; + let cx = rx; + let cy = ry; + let ox = rx * KAPPA; + let oy = ry * KAPPA; + + let commands = vec![ + PathCommand::MoveTo(Vec2(cx + rx, cy)), + PathCommand::CubicTo { + c1: Vec2(cx + rx, cy + oy), + c2: Vec2(cx + ox, cy + ry), + to: Vec2(cx, cy + ry), + }, + PathCommand::CubicTo { + c1: Vec2(cx - ox, cy + ry), + c2: Vec2(cx - rx, cy + oy), + to: Vec2(cx - rx, cy), + }, + PathCommand::CubicTo { + c1: Vec2(cx - rx, cy - oy), + c2: Vec2(cx - ox, cy - ry), + to: Vec2(cx, cy - ry), + }, + PathCommand::CubicTo { + c1: Vec2(cx + ox, cy - ry), + c2: Vec2(cx + rx, cy - oy), + to: Vec2(cx + rx, cy), + }, + PathCommand::Close, + ]; + + VectorGraphic { + view_box: Vec2(rx * 2.0, ry * 2.0), + root: Node::Path(Path { + commands, + fill, + stroke, + transform: Transform::IDENTITY, + }), + } +} diff --git a/tellur-core/src/vector.rs b/tellur-core/src/vector.rs new file mode 100644 index 0000000..80a73a6 --- /dev/null +++ b/tellur-core/src/vector.rs @@ -0,0 +1,98 @@ +use crate::color::Color; +use crate::geometry::{Transform, Vec2}; + +/// A piece of vector content with an intrinsic size. +/// +/// 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`. +#[derive(Debug, Clone)] +pub struct VectorGraphic { + pub view_box: Vec2, + pub root: Node, +} + +/// A component that can produce a `VectorGraphic`. +/// +/// Implementors must keep `view_box()` consistent with `render().view_box`, +/// so callers can query the intrinsic size without paying for a full render. +pub trait VectorComponent { + fn view_box(&self) -> Vec2; + fn render(&self) -> VectorGraphic; +} + +// Compile-time guarantee that `VectorComponent` is dyn-safe. +const _: Option<&dyn VectorComponent> = None; + +#[derive(Debug, Clone)] +pub enum Node { + Group(Group), + Path(Path), +} + +#[derive(Debug, Clone)] +pub struct Group { + pub transform: Transform, + pub opacity: f32, + pub children: Vec, +} + +#[derive(Debug, Clone)] +pub struct Path { + pub commands: Vec, + pub fill: Option, + pub stroke: Option, + pub transform: Transform, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PathCommand { + MoveTo(Vec2), + LineTo(Vec2), + QuadTo { control: Vec2, to: Vec2 }, + CubicTo { c1: Vec2, c2: Vec2, to: Vec2 }, + Close, +} + +#[derive(Debug, Clone)] +pub struct Fill { + pub paint: Paint, +} + +#[derive(Debug, Clone)] +pub struct Stroke { + pub paint: Paint, + pub width: f32, +} + +#[derive(Debug, Clone)] +pub enum Paint { + Solid(Color), +} + +impl From for Fill { + fn from(paint: Paint) -> Self { + Self { paint } + } +} + +impl From for Option { + fn from(paint: Paint) -> Self { + Some(Fill { paint }) + } +} + +impl From for Stroke { + fn from(paint: Paint) -> Self { + // Default stroke width mirrors SVG's `stroke-width="1"`. + Self { paint, width: 1.0 } + } +} + +impl From for Option { + fn from(paint: Paint) -> Self { + Some(paint.into()) + } +} diff --git a/tellur-renderer/Cargo.toml b/tellur-renderer/Cargo.toml index e09a3a5..1473833 100644 --- a/tellur-renderer/Cargo.toml +++ b/tellur-renderer/Cargo.toml @@ -4,4 +4,6 @@ version = "0.1.0" edition = "2021" [dependencies] +bytes = "1.11.1" tellur-core = { path = "../tellur-core" } +tiny-skia = "0.12.0" diff --git a/tellur-renderer/examples/raster_layer_to_png.rs b/tellur-renderer/examples/raster_layer_to_png.rs new file mode 100644 index 0000000..a720333 --- /dev/null +++ b/tellur-renderer/examples/raster_layer_to_png.rs @@ -0,0 +1,94 @@ +//! Compose a scene through the raster `Layer`, which rasterizes each child +//! independently and composites them with positional alpha blending. Three +//! overlapping translucent `Blob`s exercise the positional compositing, the +//! per-child sub-resolution rendering, and the source-over alpha math at +//! the overlap regions. + +use std::fs::File; + +use tellur_core::color::Color; +use tellur_core::geometry::{Anchor, Vec2}; +use tellur_core::layer::Layer; +use tellur_core::raster::{RasterComponent, Resolution}; +use tellur_core::shapes::{Circle, Rectangle}; +use tellur_core::vector::{Paint, VectorComponent, VectorGraphic}; +use tellur_renderer::Rasterizable; + +/// A translucent colored circle. The whole shape is parameterised by hue +/// and radius; saturation, lightness and alpha are baked in. +struct Blob { + radius: f32, + hue: f32, +} + +impl VectorComponent for Blob { + fn view_box(&self) -> Vec2 { + Vec2(self.radius * 2.0, self.radius * 2.0) + } + + fn render(&self) -> VectorGraphic { + Circle { + radius: self.radius, + fill: Paint::Solid(Color::hsla(self.hue, 0.7, 0.55, 0.65)).into(), + stroke: None, + } + .render() + } +} + +fn main() { + let mut scene = Layer::new(Vec2(1280.0, 720.0)); + + let background = Rectangle { + size: scene.size, + fill: Paint::Solid(Color::rgb_u8(245, 240, 230)).into(), + stroke: None, + } + .rasterize(); + scene.add(Vec2::ZERO, background); + + let red = Blob { + radius: 200.0, + hue: 0.0, + } + .rasterize(); + scene.add( + red.view_box() + .anchor(Anchor::CENTER) + .snap_to(scene.size, Anchor::new(0.4, 0.4)), + red, + ); + + let green = Blob { + radius: 200.0, + hue: 120.0, + } + .rasterize(); + scene.add( + green + .view_box() + .anchor(Anchor::CENTER) + .snap_to(scene.size, Anchor::new(0.6, 0.4)), + green, + ); + + let blue = Blob { + radius: 200.0, + hue: 240.0, + } + .rasterize(); + scene.add( + blue.view_box() + .anchor(Anchor::CENTER) + .snap_to(scene.size, Anchor::new(0.5, 0.65)), + blue, + ); + + let image = scene.render(Resolution::new(1280, 720)); + + let path = "/tmp/raster-scene.png"; + let file = File::create(path).expect("create output file"); + image.export_png(file).expect("export PNG"); + + println!("Wrote {} ({}x{})", path, image.width, image.height); +} diff --git a/tellur-renderer/examples/scene_to_png.rs b/tellur-renderer/examples/scene_to_png.rs new file mode 100644 index 0000000..503c729 --- /dev/null +++ b/tellur-renderer/examples/scene_to_png.rs @@ -0,0 +1,56 @@ +//! Compose a two-shape scene in a 16:9 layer and write it to PNG. + +use std::fs::File; + +use tellur_core::color::Color; +use tellur_core::geometry::{Anchor, Vec2}; +use tellur_core::layer::VectorLayer; +use tellur_core::raster::{RasterComponent, Resolution}; +use tellur_core::shapes::{Circle, Rectangle}; +use tellur_core::vector::{Paint, VectorComponent}; +use tellur_renderer::Rasterizable; + +fn main() { + let mut scene = VectorLayer::new(Vec2(1280.0, 720.0)); + + let background = Rectangle { + size: scene.size, + fill: Paint::Solid(Color::rgb_u8(255, 255, 255)).into(), + stroke: None, + }; + scene.add(Vec2::ZERO, background); + + let square = Rectangle { + size: Vec2(240.0, 240.0), + fill: Paint::Solid(Color::hsl(200.0, 0.7, 0.55)).into(), + stroke: None, + }; + scene.add( + square + .view_box() + .anchor(Anchor::TOP_LEFT) + .snap_to(scene.size, Anchor::TOP_LEFT), + square, + ); + + let circle = Circle { + radius: 120.0, + fill: Paint::Solid(Color::hsl(20.0, 0.7, 0.55)).into(), + stroke: None, + }; + scene.add( + circle + .view_box() + .anchor(Anchor::BOTTOM_RIGHT) + .snap_to(scene.size, Anchor::BOTTOM_RIGHT), + circle, + ); + + let image = scene.rasterize().render(Resolution::new(1280, 720)); + + let path = "/tmp/scene.png"; + let file = File::create(path).expect("create output file"); + image.export_png(file).expect("export PNG"); + + println!("Wrote {} ({}x{})", path, image.width, image.height); +} diff --git a/tellur-renderer/src/lib.rs b/tellur-renderer/src/lib.rs index 8b13789..4c5ff63 100644 --- a/tellur-renderer/src/lib.rs +++ b/tellur-renderer/src/lib.rs @@ -1 +1,3 @@ +pub mod rasterize; +pub use rasterize::{Rasterizable, Rasterize}; diff --git a/tellur-renderer/src/rasterize.rs b/tellur-renderer/src/rasterize.rs new file mode 100644 index 0000000..b4d2675 --- /dev/null +++ b/tellur-renderer/src/rasterize.rs @@ -0,0 +1,181 @@ +use bytes::Bytes; +use tellur_core::color::Color; +use tellur_core::geometry::{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`. +pub struct Rasterize { + pub vector: V, +} + +impl RasterComponent for Rasterize { + fn view_box(&self) -> Vec2 { + self.vector.view_box() + } + + fn render(&self, target: Resolution) -> RasterImage { + let graphic = self.vector.render(); + rasterize(&graphic, target.width, target.height) + } +} + +/// Extension trait that lets any `VectorComponent` be turned into a +/// `RasterComponent` via `.rasterize()`. +pub trait Rasterizable: VectorComponent + Sized { + fn rasterize(self) -> Rasterize { + Rasterize { vector: self } + } +} + +impl Rasterizable for T {} + +fn rasterize(graphic: &VectorGraphic, width: u32, height: u32) -> RasterImage { + let mut pixmap = + tiny_skia::Pixmap::new(width, height).expect("pixmap dimensions must be non-zero"); + + let view_box_xform = view_box_transform(graphic.view_box, width, height); + render_node(&mut pixmap, &graphic.root, view_box_xform); + + // tiny-skia outputs premultiplied alpha for efficient compositing, but + // `RasterImage` is defined as straight alpha (matching PNG, web, and most + // image libraries). Demultiply here so the public type stays consistent. + let mut straight = Vec::with_capacity(pixmap.data().len()); + for p in pixmap.pixels() { + let c = p.demultiply(); + straight.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]); + } + + RasterImage { + width, + height, + format: PixelFormat::Rgba8, + pixels: Bytes::from(straight), + } +} + +/// 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) +} + +fn render_node(pixmap: &mut tiny_skia::Pixmap, node: &Node, parent_xform: tiny_skia::Transform) { + match node { + Node::Group(group) => { + let xform = parent_xform.pre_concat(to_skia_transform(&group.transform)); + if group.opacity >= 1.0 { + for child in &group.children { + render_node(pixmap, child, xform); + } + } else if group.opacity > 0.0 { + // Children are rendered into a separate layer, then composited + // with the group's opacity. This is required for correct alpha + // blending of overlapping descendants; multiplying opacity into + // each child's alpha would double-darken overlap regions. + let mut layer = tiny_skia::Pixmap::new(pixmap.width(), pixmap.height()) + .expect("pixmap dimensions must be non-zero"); + for child in &group.children { + render_node(&mut layer, child, xform); + } + let pp = tiny_skia::PixmapPaint { + opacity: group.opacity, + ..Default::default() + }; + pixmap.draw_pixmap( + 0, + 0, + layer.as_ref(), + &pp, + tiny_skia::Transform::identity(), + None, + ); + } + // opacity <= 0.0: skip the group entirely. + } + Node::Path(path) => { + let xform = parent_xform.pre_concat(to_skia_transform(&path.transform)); + render_path(pixmap, path, xform); + } + } +} + +fn render_path(pixmap: &mut tiny_skia::Pixmap, path: &Path, xform: tiny_skia::Transform) { + let Some(skia_path) = build_skia_path(&path.commands) else { + return; + }; + + if let Some(fill) = &path.fill { + let mut paint = tiny_skia::Paint { + anti_alias: true, + ..Default::default() + }; + apply_paint(&mut paint, &fill.paint); + pixmap.fill_path( + &skia_path, + &paint, + tiny_skia::FillRule::Winding, + xform, + None, + ); + } + + if let Some(stroke) = &path.stroke { + let mut paint = tiny_skia::Paint { + anti_alias: true, + ..Default::default() + }; + apply_paint(&mut paint, &stroke.paint); + let skia_stroke = tiny_skia::Stroke { + width: stroke.width, + ..Default::default() + }; + pixmap.stroke_path(&skia_path, &paint, &skia_stroke, xform, None); + } +} + +fn build_skia_path(commands: &[PathCommand]) -> Option { + let mut pb = tiny_skia::PathBuilder::new(); + for cmd in commands { + match cmd { + PathCommand::MoveTo(p) => pb.move_to(p.0, p.1), + PathCommand::LineTo(p) => pb.line_to(p.0, p.1), + PathCommand::QuadTo { control, to } => pb.quad_to(control.0, control.1, to.0, to.1), + PathCommand::CubicTo { c1, c2, to } => pb.cubic_to(c1.0, c1.1, c2.0, c2.1, to.0, to.1), + PathCommand::Close => pb.close(), + } + } + pb.finish() +} + +fn apply_paint(paint: &mut tiny_skia::Paint, source: &Paint) { + match source { + Paint::Solid(color) => { + paint.set_color(to_skia_color(color)); + } + } +} + +fn to_skia_color(color: &Color) -> tiny_skia::Color { + tiny_skia::Color::from_rgba( + color.r.clamp(0.0, 1.0), + color.g.clamp(0.0, 1.0), + color.b.clamp(0.0, 1.0), + color.a.clamp(0.0, 1.0), + ) + .expect("clamped components are within [0, 1]") +} + +fn to_skia_transform(t: &Transform) -> tiny_skia::Transform { + // Our Transform: tiny_skia's from_row(sx, ky, kx, sy, tx, ty): + // | a c tx | | sx kx tx | + // | b d ty | | ky sy ty | + tiny_skia::Transform::from_row(t.a, t.b, t.c, t.d, t.tx, t.ty) +} + +// The compile-time dyn-safety guarantee for `Rasterize` is covered by the +// `const _: Option<&dyn RasterComponent> = None;` assertion in `RasterComponent`.