summaryrefslogtreecommitdiff
path: root/rust/crates/graphics/src/gradient/stops.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/stops.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/stops.rs')
-rw-r--r--rust/crates/graphics/src/gradient/stops.rs151
1 files changed, 151 insertions, 0 deletions
diff --git a/rust/crates/graphics/src/gradient/stops.rs b/rust/crates/graphics/src/gradient/stops.rs
new file mode 100644
index 00000000..e719ae96
--- /dev/null
+++ b/rust/crates/graphics/src/gradient/stops.rs
@@ -0,0 +1,151 @@
+use super::ast::{Color, ColorStop, Distance};
+use crate::color::{is_transparent, parse_color_to_rgb};
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct NormalizedColorStop {
+ pub color: String,
+ pub offset: f64,
+}
+
+pub fn color_to_string(color: &Color) -> String {
+ match color {
+ Color::Hex(value) => format!("#{value}"),
+ Color::Literal(value) => value.clone(),
+ Color::Rgb(values) => format!("rgb({})", values.join(",")),
+ Color::Rgba(values) => format!("rgba({})", values.join(",")),
+ Color::Hsl(values) => format!("hsl({})", values.join(",")),
+ Color::Hsla(values) => format!("hsla({})", values.join(",")),
+ Color::Var(value) => format!("var({value})"),
+ }
+}
+
+fn clamp01(value: f64) -> f64 {
+ value.clamp(0.0, 1.0)
+}
+
+fn resolve_stop_offset(stop: &ColorStop, gradient_length: f64) -> Option<f64> {
+ match stop.length.as_ref()? {
+ Distance::Percent(value) => value.parse::<f64>().ok().map(|v| v / 100.0),
+ Distance::Px(value) => value.parse::<f64>().ok().map(|v| v / gradient_length),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .ok()
+ .map(|v| (v * 16.0) / gradient_length),
+ _ => None,
+ }
+}
+
+fn fix_transparent_stops(stops: &[(String, Option<f64>)]) -> Vec<(String, Option<f64>)> {
+ let mut result = stops.to_vec();
+ for i in 0..result.len() {
+ if !is_transparent(&result[i].0) {
+ continue;
+ }
+ let mut donor = None;
+ for j in (0..i).rev() {
+ if !is_transparent(&result[j].0) {
+ donor = parse_color_to_rgb(&result[j].0);
+ if donor.is_some() {
+ break;
+ }
+ }
+ }
+ if donor.is_none() {
+ for item in result.iter().skip(i + 1) {
+ if !is_transparent(&item.0) {
+ donor = parse_color_to_rgb(&item.0);
+ if donor.is_some() {
+ break;
+ }
+ }
+ }
+ }
+ if let Some(donor) = donor {
+ result[i].0 = format!("rgba({},{},{},0)", donor.r, donor.g, donor.b);
+ }
+ }
+ result
+}
+
+pub fn normalize_color_stops(
+ color_stops: &[ColorStop],
+ gradient_length: f64,
+) -> Vec<NormalizedColorStop> {
+ let mapped: Vec<(String, Option<f64>)> = color_stops
+ .iter()
+ .map(|stop| {
+ (
+ color_to_string(&stop.color),
+ resolve_stop_offset(stop, gradient_length),
+ )
+ })
+ .collect();
+
+ let fixed = fix_transparent_stops(&mapped);
+ let mut resolved = fixed;
+ let known_indices: Vec<usize> = resolved
+ .iter()
+ .enumerate()
+ .filter_map(|(index, stop)| stop.1.map(|_| index))
+ .collect();
+
+ if known_indices.is_empty() {
+ let step = if resolved.len() > 1 {
+ 1.0 / (resolved.len() - 1) as f64
+ } else {
+ 1.0
+ };
+ for (index, stop) in resolved.iter_mut().enumerate() {
+ stop.1 = Some(step * index as f64);
+ }
+ return resolved
+ .into_iter()
+ .map(|(color, offset)| NormalizedColorStop {
+ color,
+ offset: clamp01(offset.unwrap_or(0.0)),
+ })
+ .collect();
+ }
+
+ let first_known = known_indices[0];
+ if resolved[first_known].1.is_none() {
+ resolved[first_known].1 = Some(0.0);
+ }
+ for index in 0..first_known {
+ let next_offset = resolved[first_known].1.unwrap_or(0.0);
+ resolved[index].1 = Some((next_offset * index as f64) / first_known as f64);
+ }
+
+ for window in known_indices.windows(2) {
+ let start_index = window[0];
+ let end_index = window[1];
+ let start_offset = resolved[start_index].1.unwrap_or(0.0);
+ let end_offset = resolved[end_index].1.unwrap_or(start_offset);
+ let gap = end_index - start_index;
+ if gap <= 1 {
+ continue;
+ }
+ let step = (end_offset - start_offset) / gap as f64;
+ for index in 1..gap {
+ resolved[start_index + index].1 = Some(start_offset + step * index as f64);
+ }
+ }
+
+ let last_known = known_indices[known_indices.len() - 1];
+ let last_offset = resolved[last_known].1.unwrap_or(1.0);
+ if last_known < resolved.len() - 1 {
+ let gap = resolved.len() - 1 - last_known;
+ let step = (1.0 - last_offset) / gap as f64;
+ for index in 1..=gap {
+ resolved[last_known + index].1 = Some(last_offset + step * index as f64);
+ }
+ }
+
+ resolved
+ .into_iter()
+ .map(|(color, offset)| NormalizedColorStop {
+ color,
+ offset: clamp01(offset.unwrap_or(0.0)),
+ })
+ .collect()
+}