summaryrefslogtreecommitdiff
path: root/rust/crates/graphics/src/gradient/raster.rs
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/graphics/src/gradient/raster.rs
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures). - timeline (45): placement, resize/ripple, split, snap, retime, audio separation, scene helpers - animation (73): bezier solver, channel sampling, keyframe ops, split/clamp/clone, param definitions - commands (9): Command trait, BatchCommand, history, 19 concrete commands, freeze-frame port 1:1 - audio (22): audible collection, range mixing, mastering limiter, rms/peak buckets - text (19): cosmic-text layout/measure/raster, backgrounds, decorations - graphics (52): full CSS gradient parser, tiny-skia raster, 4 shape definitions - effect-defs (16): blur definition, registry, pass resolution - mask-defs (19): all 9 mask renderers via tiny-skia - renderer (6): node model + scene builder port - desktop: GPUI projects screen wired to ProjectStore - scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/graphics/src/gradient/raster.rs')
-rw-r--r--rust/crates/graphics/src/gradient/raster.rs549
1 files changed, 549 insertions, 0 deletions
diff --git a/rust/crates/graphics/src/gradient/raster.rs b/rust/crates/graphics/src/gradient/raster.rs
new file mode 100644
index 00000000..12d30a23
--- /dev/null
+++ b/rust/crates/graphics/src/gradient/raster.rs
@@ -0,0 +1,549 @@
+use tiny_skia::{
+ Color as SkColor, GradientStop, LinearGradient, Paint, Pixmap, Point, RadialGradient, Rect,
+ Shader, SpreadMode, Transform,
+};
+
+use super::ast::{
+ Distance, GradientAst, GradientOrientation, LinearOrientation, Position, RadialOrientation,
+ ShapeKind, ShapeStyle,
+};
+use super::parser::parse_gradient;
+use super::stops::normalize_color_stops;
+use crate::color::{Rgba, parse_css_color};
+use crate::error::GraphicsError;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum BackgroundLayer {
+ Color(String),
+ Gradient(GradientAst),
+}
+
+pub fn is_gradient_layer(segment: &str) -> bool {
+ let names = [
+ "linear-gradient",
+ "repeating-linear-gradient",
+ "radial-gradient",
+ "repeating-radial-gradient",
+ ];
+ let mut offset = 0;
+ let mut rest = segment;
+ if rest.starts_with('-') {
+ for vendor in ["-webkit-", "-o-", "-ms-", "-moz-"] {
+ if rest.len() >= vendor.len() && rest[..vendor.len()].eq_ignore_ascii_case(vendor) {
+ offset = vendor.len();
+ break;
+ }
+ }
+ if offset == 0 {
+ return false;
+ }
+ rest = &rest[offset..];
+ }
+ names
+ .iter()
+ .any(|name| rest.len() >= name.len() && rest[..name.len()].eq_ignore_ascii_case(name))
+}
+
+pub fn split_css_layers(css: &str) -> Vec<String> {
+ let mut layers = Vec::new();
+ let mut current = String::new();
+ let mut depth = 0i32;
+ for ch in css.chars() {
+ if ch == '(' {
+ depth += 1;
+ }
+ if ch == ')' {
+ depth = depth.saturating_sub(1);
+ }
+ if ch == ',' && depth == 0 {
+ layers.push(current.trim().to_string());
+ current = String::new();
+ continue;
+ }
+ current.push(ch);
+ }
+ if !current.trim().is_empty() {
+ layers.push(current.trim().to_string());
+ }
+ layers
+}
+
+pub fn parse_background_layers(css: &str) -> Vec<BackgroundLayer> {
+ let mut layers = Vec::new();
+ for segment in split_css_layers(css) {
+ if segment.is_empty() {
+ continue;
+ }
+ if is_gradient_layer(&segment) {
+ match parse_gradient(segment.trim()) {
+ Ok(parsed) => {
+ for gradient in parsed {
+ layers.push(BackgroundLayer::Gradient(gradient));
+ }
+ continue;
+ }
+ Err(_) => {
+ layers.push(BackgroundLayer::Color(segment));
+ continue;
+ }
+ }
+ }
+ layers.push(BackgroundLayer::Color(segment));
+ }
+ layers
+}
+
+pub fn draw_css_background(pixmap: &mut Pixmap, css: &str) -> Result<(), GraphicsError> {
+ let width = f64::from(pixmap.width());
+ let height = f64::from(pixmap.height());
+ let layers = parse_background_layers(css);
+ for layer in layers.iter().rev() {
+ match layer {
+ BackgroundLayer::Color(value) => {
+ let color = parse_css_color(value)
+ .ok_or_else(|| GraphicsError::InvalidColor(value.clone()))?;
+ fill_solid(pixmap, color_to_sk(color));
+ }
+ BackgroundLayer::Gradient(gradient) => {
+ draw_gradient_layer(pixmap, width, height, gradient)?;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn fill_solid(pixmap: &mut Pixmap, color: SkColor) {
+ let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32)
+ else {
+ return;
+ };
+ let paint = Paint {
+ shader: Shader::SolidColor(color),
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+}
+
+fn color_to_sk(color: Rgba) -> SkColor {
+ SkColor::from_rgba(
+ color.r.clamp(0.0, 1.0) as f32,
+ color.g.clamp(0.0, 1.0) as f32,
+ color.b.clamp(0.0, 1.0) as f32,
+ color.a.clamp(0.0, 1.0) as f32,
+ )
+ .unwrap_or(SkColor::TRANSPARENT)
+}
+
+fn build_stops(
+ gradient: &GradientAst,
+ gradient_length: f64,
+) -> Result<Vec<GradientStop>, GraphicsError> {
+ let normalized = normalize_color_stops(&gradient.color_stops, gradient_length);
+ let mut stops = Vec::with_capacity(normalized.len());
+ for stop in normalized {
+ let color = parse_css_color(&stop.color)
+ .ok_or_else(|| GraphicsError::InvalidColor(stop.color.clone()))?;
+ stops.push(GradientStop::new(stop.offset as f32, color_to_sk(color)));
+ }
+ Ok(stops)
+}
+
+fn full_rect(width: f64, height: f64) -> Option<Rect> {
+ Rect::from_xywh(0.0, 0.0, width as f32, height as f32)
+}
+
+fn draw_gradient_layer(
+ pixmap: &mut Pixmap,
+ width: f64,
+ height: f64,
+ gradient: &GradientAst,
+) -> Result<(), GraphicsError> {
+ if gradient.kind.is_linear() {
+ draw_linear_gradient(pixmap, width, height, gradient)
+ } else {
+ draw_radial_gradient(pixmap, width, height, gradient)
+ }
+}
+
+struct LinearPoints {
+ x0: f64,
+ y0: f64,
+ x1: f64,
+ y1: f64,
+ length: f64,
+}
+
+fn resolve_linear_points(
+ width: f64,
+ height: f64,
+ orientation: Option<&GradientOrientation>,
+) -> LinearPoints {
+ let angle = resolve_linear_angle(orientation);
+ let radians = angle.to_radians();
+ let dx = radians.sin();
+ let dy = -radians.cos();
+ let center_x = width / 2.0;
+ let center_y = height / 2.0;
+ let half_length = ((width * dx).abs() + (height * dy).abs()) / 2.0;
+ let x0 = center_x - dx * half_length;
+ let y0 = center_y - dy * half_length;
+ let x1 = center_x + dx * half_length;
+ let y1 = center_y + dy * half_length;
+ let length = (x1 - x0).hypot(y1 - y0);
+ LinearPoints { x0, y0, x1, y1, length }
+}
+
+fn resolve_linear_angle(orientation: Option<&GradientOrientation>) -> f64 {
+ let Some(orientation) = orientation else {
+ return 180.0;
+ };
+ if let GradientOrientation::Linear(linear) = orientation {
+ match linear {
+ LinearOrientation::Angular(value) => {
+ return value.parse::<f64>().unwrap_or(180.0);
+ }
+ LinearOrientation::Directional(value) => {
+ return angle_from_directional(value);
+ }
+ }
+ }
+ 180.0
+}
+
+fn angle_from_directional(value: &str) -> f64 {
+ let normalized = value.to_lowercase().replace("to", "");
+ let mut dx: f64 = 0.0;
+ let mut dy: f64 = 0.0;
+ for part in normalized.split_whitespace() {
+ match part {
+ "left" => dx = -1.0,
+ "right" => dx = 1.0,
+ "top" => dy = -1.0,
+ "bottom" => dy = 1.0,
+ _ => {}
+ }
+ }
+ if dx == 0.0 && dy == 0.0 {
+ return 180.0;
+ }
+ let angle = dx.atan2(-dy).to_degrees();
+ (angle + 360.0) % 360.0
+}
+
+fn draw_linear_gradient(
+ pixmap: &mut Pixmap,
+ width: f64,
+ height: f64,
+ gradient: &GradientAst,
+) -> Result<(), GraphicsError> {
+ let points = resolve_linear_points(width, height, gradient.orientation.as_ref());
+ let stops = build_stops(gradient, points.length)?;
+ let (Some(shader), Some(rect)) = (
+ LinearGradient::new(
+ Point::from_xy(points.x0 as f32, points.y0 as f32),
+ Point::from_xy(points.x1 as f32, points.y1 as f32),
+ stops,
+ SpreadMode::Pad,
+ Transform::default(),
+ ),
+ full_rect(width, height),
+ ) else {
+ return Ok(());
+ };
+ let paint = Paint {
+ shader,
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+ Ok(())
+}
+
+struct RadialDimensions {
+ cx: f64,
+ cy: f64,
+ rx: f64,
+ ry: f64,
+}
+
+fn resolve_radial_dimensions(
+ width: f64,
+ height: f64,
+ orientation: Option<&GradientOrientation>,
+) -> RadialDimensions {
+ let center_fallback = (width / 2.0, height / 2.0);
+ let radial = match orientation {
+ Some(GradientOrientation::Radial(list)) => list.first(),
+ _ => None,
+ };
+
+ let Some(radial) = radial else {
+ let (rx, ry) = resolve_radial_extents(
+ width,
+ height,
+ center_fallback.0,
+ center_fallback.1,
+ ShapeKind::Ellipse,
+ "farthest-corner",
+ );
+ return RadialDimensions {
+ cx: center_fallback.0,
+ cy: center_fallback.1,
+ rx,
+ ry,
+ };
+ };
+
+ match radial {
+ RadialOrientation::Shape(shape) => {
+ let (cx, cy) = resolve_radial_center(width, height, shape.at.as_ref());
+ if let Some(ShapeStyle::Position(position)) = &shape.style {
+ let rx = position
+ .x
+ .as_ref()
+ .map(|d| resolve_ellipse_dimension(d, width))
+ .unwrap_or(width / 2.0);
+ let ry = position
+ .y
+ .as_ref()
+ .map(|d| resolve_ellipse_dimension(d, height))
+ .unwrap_or(height / 2.0);
+ return RadialDimensions { cx, cy, rx, ry };
+ }
+ if let Some(ShapeStyle::Distance(distance)) = &shape.style {
+ let resolved =
+ resolve_distance_in_pixels(distance, width.max(height)).unwrap_or(0.0);
+ return RadialDimensions {
+ cx,
+ cy,
+ rx: resolved,
+ ry: resolved,
+ };
+ }
+ let extent = match &shape.style {
+ Some(ShapeStyle::ExtentKeyword(value)) => value.as_str(),
+ _ => "farthest-corner",
+ };
+ let (rx, ry) = resolve_radial_extents(width, height, cx, cy, shape.value, extent);
+ RadialDimensions { cx, cy, rx, ry }
+ }
+ RadialOrientation::ExtentKeyword { value, at } => {
+ let (cx, cy) = resolve_radial_center(width, height, at.as_ref());
+ let (rx, ry) =
+ resolve_radial_extents(width, height, cx, cy, ShapeKind::Ellipse, value);
+ RadialDimensions { cx, cy, rx, ry }
+ }
+ RadialOrientation::DefaultRadial { at } => {
+ let (cx, cy) = resolve_radial_center(width, height, Some(at));
+ let (rx, ry) = resolve_radial_extents(
+ width,
+ height,
+ cx,
+ cy,
+ ShapeKind::Ellipse,
+ "farthest-corner",
+ );
+ RadialDimensions { cx, cy, rx, ry }
+ }
+ }
+}
+
+fn resolve_radial_center(width: f64, height: f64, position: Option<&Position>) -> (f64, f64) {
+ let Some(position) = position else {
+ return (width / 2.0, height / 2.0);
+ };
+ let normalized = normalize_position_keywords(position);
+ let cx = resolve_position_value(normalized.x.as_ref(), width, true);
+ let cy = resolve_position_value(normalized.y.as_ref(), height, false);
+ (cx, cy)
+}
+
+fn normalize_position_keywords(position: &Position) -> Position {
+ if let (Some(Distance::PositionKeyword(x_kw)), Some(Distance::PositionKeyword(y_kw))) =
+ (&position.x, &position.y)
+ {
+ let x_keyword = x_kw.to_lowercase();
+ let y_keyword = y_kw.to_lowercase();
+ let x_is_vertical = x_keyword == "top" || x_keyword == "bottom";
+ let y_is_horizontal = y_keyword == "left" || y_keyword == "right";
+ if x_is_vertical && y_is_horizontal {
+ return Position {
+ x: Some(Distance::PositionKeyword(y_keyword)),
+ y: Some(Distance::PositionKeyword(x_keyword)),
+ };
+ }
+ }
+ position.clone()
+}
+
+fn resolve_radial_extents(
+ width: f64,
+ height: f64,
+ cx: f64,
+ cy: f64,
+ shape: ShapeKind,
+ extent: &str,
+) -> (f64, f64) {
+ let left = cx;
+ let right = width - cx;
+ let top = cy;
+ let bottom = height - cy;
+
+ if shape == ShapeKind::Circle {
+ let distances = [
+ left.hypot(top),
+ right.hypot(top),
+ left.hypot(bottom),
+ right.hypot(bottom),
+ ];
+ return match extent {
+ "closest-side" => (left.min(right).min(top).min(bottom), 0.0),
+ "farthest-side" => (left.max(right).max(top).max(bottom), 0.0),
+ "closest-corner" => (distances.iter().cloned().fold(f64::INFINITY, f64::min), 0.0),
+ _ => (distances.iter().cloned().fold(f64::NEG_INFINITY, f64::max), 0.0),
+ };
+ }
+
+ match extent {
+ "closest-side" => (left.min(right), top.min(bottom)),
+ "farthest-side" => (left.max(right), top.max(bottom)),
+ _ => {
+ let corners = [(left, top), (right, top), (left, bottom), (right, bottom)];
+ let chosen = if extent == "closest-corner" {
+ corners
+ .iter()
+ .min_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1)))
+ } else {
+ corners
+ .iter()
+ .max_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1)))
+ };
+ let chosen = chosen.copied().unwrap_or((width / 2.0, height / 2.0));
+ (chosen.0.abs(), chosen.1.abs())
+ }
+ }
+}
+
+fn resolve_position_value(distance: Option<&Distance>, axis_size: f64, is_x: bool) -> f64 {
+ let Some(distance) = distance else {
+ return axis_size / 2.0;
+ };
+ match distance {
+ Distance::Percent(value) => value
+ .parse::<f64>()
+ .map(|v| (v / 100.0) * axis_size)
+ .unwrap_or(axis_size / 2.0),
+ Distance::PositionKeyword(value) => keyword_to_position(value, axis_size, is_x),
+ Distance::Px(value) => value.parse::<f64>().unwrap_or(axis_size / 2.0),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .map(|v| v * 16.0)
+ .unwrap_or(axis_size / 2.0),
+ _ => axis_size / 2.0,
+ }
+}
+
+fn keyword_to_position(value: &str, axis_size: f64, is_x: bool) -> f64 {
+ let keyword = value.to_lowercase();
+ if keyword == "center" {
+ return axis_size / 2.0;
+ }
+ if is_x {
+ if keyword == "left" {
+ return 0.0;
+ }
+ if keyword == "right" {
+ return axis_size;
+ }
+ } else {
+ if keyword == "top" {
+ return 0.0;
+ }
+ if keyword == "bottom" {
+ return axis_size;
+ }
+ }
+ axis_size / 2.0
+}
+
+fn resolve_distance_in_pixels(distance: &Distance, axis_size: f64) -> Option<f64> {
+ match distance {
+ Distance::Percent(value) => value.parse::<f64>().ok().map(|v| (v / 100.0) * axis_size),
+ Distance::Px(value) => value.parse::<f64>().ok(),
+ Distance::Em(value) => value.parse::<f64>().ok().map(|v| v * 16.0),
+ _ => None,
+ }
+}
+
+fn resolve_ellipse_dimension(distance: &Distance, axis_size: f64) -> f64 {
+ match distance {
+ Distance::Percent(value) => value
+ .parse::<f64>()
+ .map(|v| (v / 100.0) * axis_size)
+ .unwrap_or(axis_size / 2.0),
+ Distance::Px(value) => value.parse::<f64>().unwrap_or(axis_size / 2.0),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .map(|v| v * 16.0)
+ .unwrap_or(axis_size / 2.0),
+ _ => axis_size / 2.0,
+ }
+}
+
+fn draw_radial_gradient(
+ pixmap: &mut Pixmap,
+ width: f64,
+ height: f64,
+ gradient: &GradientAst,
+) -> Result<(), GraphicsError> {
+ let dims = resolve_radial_dimensions(width, height, gradient.orientation.as_ref());
+ let gradient_length = dims.rx.max(dims.ry);
+ let stops = build_stops(gradient, gradient_length)?;
+
+ if dims.rx == dims.ry || dims.ry == 0.0 {
+ let Some(shader) = RadialGradient::new(
+ Point::from_xy(dims.cx as f32, dims.cy as f32),
+ Point::from_xy(dims.cx as f32, dims.cy as f32),
+ dims.rx as f32,
+ stops,
+ SpreadMode::Pad,
+ Transform::default(),
+ ) else {
+ return Ok(());
+ };
+ if let Some(rect) = full_rect(width, height) {
+ let paint = Paint {
+ shader,
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+ }
+ return Ok(());
+ }
+
+ if dims.rx <= 0.0 {
+ return Ok(());
+ }
+ let scale_y = dims.ry / dims.rx;
+ if !scale_y.is_finite() || scale_y <= 0.0 {
+ return Ok(());
+ }
+ let transform = Transform::from_row(1.0, 0.0, 0.0, scale_y as f32, dims.cx as f32, dims.cy as f32);
+ let Some(shader) = RadialGradient::new(
+ Point::from_xy(0.0, 0.0),
+ Point::from_xy(0.0, 0.0),
+ dims.rx as f32,
+ stops,
+ SpreadMode::Pad,
+ transform,
+ ) else {
+ return Ok(());
+ };
+ if let Some(rect) = full_rect(width, height) {
+ let paint = Paint {
+ shader,
+ ..Paint::default()
+ };
+ pixmap.fill_rect(rect, &paint, Transform::default(), None);
+ }
+ Ok(())
+}