Files
designcraft/crates/color/src/gradient.rs
T
Brandon ThomasandClaude Opus 5.5 89891fdfbe Never crash: geom and color return or fall back instead of panicking
Replace the unwrap/expect calls in path closing, bounds, Pathfinder output
and gradient sampling with let-else / if-let; the ASE reader uses checked
offsets (a 32-bit length could overflow on wasm32). Both crates now deny
unwrap/expect/panic/unimplemented/todo/unreachable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:39:33 -04:00

227 lines
7.1 KiB
Rust

//! Gradients: definitions (swatch-able) and their placement on an object.
use kurbo::{Affine, Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
use crate::Color;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientKind {
#[default]
Linear,
Radial,
/// Freeform gradients (points/lines) — rendered via a mesh approximation.
Freeform,
}
impl GradientKind {
pub fn label(self) -> &'static str {
match self {
GradientKind::Linear => "Linear",
GradientKind::Radial => "Radial",
GradientKind::Freeform => "Freeform",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
pub offset: f32,
pub color: Color,
#[serde(default = "one")]
pub opacity: f32,
/// Midpoint to the next stop, 0.13..=0.87 (InDesign's diamond), default 0.5.
#[serde(default = "half")]
pub midpoint: f32,
}
fn one() -> f32 {
1.0
}
fn half() -> f32 {
0.5
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<GradientStop>,
}
impl Default for Gradient {
/// InDesign's default "White, Black" gradient.
fn default() -> Self {
Self {
kind: GradientKind::Linear,
stops: vec![
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
],
}
}
}
impl Gradient {
/// Colour and opacity at `t` (honours midpoints).
pub fn sample(&self, t: f32) -> (Color, f32) {
let stops = &self.stops;
if stops.is_empty() {
return (Color::BLACK, 1.0);
}
if t <= stops[0].offset {
return (stops[0].color, stops[0].opacity);
}
for w in stops.windows(2) {
let (a, b) = (&w[0], &w[1]);
if t <= b.offset {
let span = (b.offset - a.offset).max(1e-6);
let u = (t - a.offset) / span;
// Map through the midpoint: u=mid → 0.5.
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v);
}
}
match stops.last() {
Some(l) => (l.color, l.opacity),
None => (Color::BLACK, 1.0),
}
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
let mut out = Vec::new();
for (i, s) in self.stops.iter().enumerate() {
out.push((s.offset, s.color, s.opacity));
if let Some(n) = self.stops.get(i + 1)
&& (s.midpoint - 0.5).abs() > 1e-3
{
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = self.sample(t);
out.push((t, c, o));
}
}
out
}
pub fn reverse(&mut self) {
self.stops.reverse();
for s in &mut self.stops {
s.offset = 1.0 - s.offset;
}
}
pub fn sort(&mut self) {
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
}
}
/// Where the gradient sits on an object, in document coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientGeom {
pub start: Point,
pub end: Point,
/// Radial aspect ratio (height / width), 1 = circle.
#[serde(default = "one64")]
pub aspect: f64,
}
fn one64() -> f64 {
1.0
}
impl GradientGeom {
/// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`,
/// radial is centred with radius = half the larger dimension.
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
let c = b.center();
match kind {
GradientKind::Radial => {
let r = b.width().max(b.height()) / 2.0;
Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 }
}
_ => {
let a = angle_deg.to_radians();
let d = Vec2::new(a.cos(), -a.sin());
// Project the box corners onto the direction to cover the whole box.
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
Self { start: c - d * half, end: c + d * half, aspect: 1.0 }
}
}
}
pub fn transform(&mut self, a: Affine) {
self.start = a * self.start;
self.end = a * self.end;
}
pub fn angle_deg(&self) -> f64 {
let v = self.end - self.start;
(-v.y).atan2(v.x).to_degrees()
}
pub fn length(&self) -> f64 {
(self.end - self.start).hypot()
}
}
/// A gradient applied to a fill or stroke.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientPaint {
pub gradient: Gradient,
/// None = fit to the object's bounds at `angle` each render (fresh gradients behave like this).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geom: Option<GradientGeom>,
#[serde(default)]
pub angle: f64,
/// Name of the gradient swatch, if linked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swatch: Option<String>,
}
impl GradientPaint {
pub fn new(gradient: Gradient) -> Self {
Self { gradient, geom: None, angle: 0.0, swatch: None }
}
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_endpoints_and_mid() {
let g = Gradient::default();
assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff");
assert_eq!(g.sample(1.0).0.to_hex(), "#000000");
let mid = g.sample(0.5).0.to_rgb()[0];
assert!((mid - 0.5).abs() < 1e-5);
}
#[test]
fn midpoint_shifts() {
let mut g = Gradient::default();
g.stops[0].midpoint = 0.25;
// At t = 0.25 we should be half way.
assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5);
assert_eq!(g.expanded_stops().len(), 3);
}
#[test]
fn fit_linear_horizontal() {
let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
assert_eq!(g.start, Point::new(0.0, 25.0));
assert_eq!(g.end, Point::new(100.0, 25.0));
assert!((g.angle_deg()).abs() < 1e-9);
let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0);
assert!((v.angle_deg() - 90.0).abs() < 1e-9);
assert!((v.length() - 50.0).abs() < 1e-9);
}
#[test]
fn reverse_stops() {
let mut g = Gradient::default();
g.reverse();
assert_eq!(g.stops[0].color.to_hex(), "#000000");
assert_eq!(g.stops[0].offset, 0.0);
}
}