mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-10 10:30:20 -06:00
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>
227 lines
7.1 KiB
Rust
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);
|
|
}
|
|
}
|