use super::*;
pub mod path_smoothing;
pub mod path_trim;
pub mod stroke_utils;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(crate = "crate::serde")]
pub enum PathElement {
#[default]
Empty,
Point(Size, Size),
Line,
Quadratic(Size, Size),
Cubic(Size, Size, Size, Size),
Close,
}
impl Interpolatable for PathElement {}
impl HelperFunctions for PathElement {}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
#[serde(crate = "crate::serde")]
pub enum PathSmoothing {
#[default]
None,
Light,
Strong,
}
impl Interpolatable for PathSmoothing {
fn interpolate(&self, other: &Self, t: f64) -> Self {
if t < 1.0 {
*self
} else {
*other
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
#[serde(crate = "crate::serde")]
pub enum StrokeCap {
#[default]
Butt,
Round,
Square,
}
impl Interpolatable for StrokeCap {
fn interpolate(&self, other: &Self, t: f64) -> Self {
if t < 1.0 {
self.clone()
} else {
other.clone()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
#[serde(crate = "crate::serde")]
pub enum StrokeJoin {
#[default]
Miter,
Round,
Bevel,
}
impl Interpolatable for StrokeJoin {
fn interpolate(&self, other: &Self, t: f64) -> Self {
if t < 1.0 {
self.clone()
} else {
other.clone()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "crate::serde")]
pub struct Fill {
pub paint: Property<Paint>,
pub material: Property<Material>,
pub opacity: Property<Opacity>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "crate::serde")]
pub struct Stroke {
pub paint: Property<Paint>,
pub material: Property<Material>,
pub opacity: Property<Opacity>,
pub width: Property<Size>,
pub cap: Property<StrokeCap>,
pub join: Property<StrokeJoin>,
}
impl Default for Fill {
fn default() -> Self {
Self {
paint: Default::default(),
material: Default::default(),
opacity: Default::default(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedStroke {
pub paint: Paint,
pub material: ResolvedMaterial,
pub opacity: Opacity,
pub width: Size,
pub cap: StrokeCap,
pub join: StrokeJoin,
}
impl Stroke {
pub fn resolve_in_graph(&self) -> ResolvedStroke {
ResolvedStroke {
paint: self.paint.local().get(),
material: self.material.local().get().resolve_in_graph(),
opacity: self.opacity.local().get(),
width: self.width.local().get(),
cap: self.cap.local().get(),
join: self.join.local().get(),
}
}
pub fn snapshot(&self) -> ResolvedStroke {
ResolvedStroke {
paint: self.paint.get(),
material: self.material.get().snapshot(),
opacity: self.opacity.get(),
width: self.width.get(),
cap: self.cap.get(),
join: self.join.get(),
}
}
}
impl Default for Stroke {
fn default() -> Self {
Self {
paint: Default::default(),
material: Default::default(),
opacity: Default::default(),
width: Property::new(Size::Pixels(1.into())),
cap: Default::default(),
join: Default::default(),
}
}
}
impl From<Paint> for Fill {
fn from(paint: Paint) -> Self {
Self {
paint: Property::new(paint),
..Self::default()
}
}
}
impl From<Color> for Fill {
fn from(color: Color) -> Self {
Paint::Solid(color).into()
}
}
impl From<Paint> for Stroke {
fn from(paint: Paint) -> Self {
Self {
paint: Property::new(paint),
..Self::default()
}
}
}
impl From<Color> for Stroke {
fn from(color: Color) -> Self {
Paint::Solid(color).into()
}
}
impl Stroke {
pub fn width_pixels(&self) -> f64 {
match self.width.get() {
Size::Pixels(value) if value.to_float().is_finite() => value.to_float().max(0.0),
_ => {
log::warn!("Stroke width must be finite logical pixels");
0.0
}
}
}
}
impl ResolvedStroke {
pub fn width_pixels(&self) -> f64 {
match self.width.clone() {
Size::Pixels(value) if value.to_float().is_finite() => value.to_float().max(0.0),
_ => {
log::warn!("Stroke width must be finite logical pixels");
0.0
}
}
}
}
macro_rules! appearance_layer_traits {
($ty:ident, $($field:ident),+) => {
impl PartialEq for $ty {
fn eq(&self, other: &Self) -> bool { $(self.$field.get() == other.$field.get())&&+ }
}
impl Hash for $ty {
fn hash<H: Hasher>(&self, state: &mut H) { $(self.$field.get().hash(state);)+ }
}
impl Interpolatable for $ty {
fn interpolate(&self, other: &Self, t: f64) -> Self {
Self { $($field: Property::new(self.$field.get().interpolate(&other.$field.get(), t))),+ }
}
}
}
}
appearance_layer_traits!(Fill, paint, material, opacity);
appearance_layer_traits!(Stroke, paint, material, opacity, width, cap, join);
pub enum NavigationTarget {
Current,
New,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(crate = "crate::serde")]
pub enum Paint {
Solid(Color),
LinearGradient(LinearGradient),
RadialGradient(RadialGradient),
Blend(Vec<(Paint, f64)>),
MeshGradient(MeshGradient),
}
impl From<Color> for Paint {
fn from(color: Color) -> Self {
Self::Solid(color)
}
}
impl Hash for Paint {
fn hash<H: Hasher>(&self, state: &mut H) {
match self {
Paint::Solid(color) => {
state.write_u8(0);
color.hash(state);
}
Paint::LinearGradient(linear) => {
state.write_u8(1);
linear.start.hash(state);
linear.end.hash(state);
linear.stops.hash(state);
}
Paint::RadialGradient(radial) => {
state.write_u8(2);
radial.start.hash(state);
radial.end.hash(state);
radial.radius.to_bits().hash(state);
radial.stops.hash(state);
}
Paint::Blend(terms) => {
state.write_u8(3);
terms.len().hash(state);
for (fill, weight) in terms {
fill.hash(state);
weight.to_bits().hash(state);
}
}
Paint::MeshGradient(mesh) => {
state.write_u8(4);
mesh.hash(state);
}
}
}
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, Default, PartialEq, PartialOrd)]
#[serde(crate = "crate::serde")]
pub struct Depth(pub Numeric);
impl Depth {
pub fn to_float(&self) -> f64 {
self.0.to_float()
}
}
impl From<Numeric> for Depth {
fn from(value: Numeric) -> Self {
Self(value)
}
}
impl From<f64> for Depth {
fn from(value: f64) -> Self {
Self(Numeric::F64(value))
}
}
impl Hash for Depth {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
impl Interpolatable for Depth {
fn interpolate(&self, other: &Self, t: f64) -> Self {
Self(self.0.interpolate(&other.0, t))
}
}
impl HelperFunctions for Depth {}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)]
#[serde(crate = "crate::serde")]
pub struct Vector3 {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl Vector3 {
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
}
impl Default for Vector3 {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
z: 0.0,
}
}
}
impl Hash for Vector3 {
fn hash<H: Hasher>(&self, state: &mut H) {
self.x.to_bits().hash(state);
self.y.to_bits().hash(state);
self.z.to_bits().hash(state);
}
}
impl Interpolatable for Vector3 {
fn interpolate(&self, other: &Self, t: f64) -> Self {
Self {
x: self.x.interpolate(&other.x, t),
y: self.y.interpolate(&other.y, t),
z: self.z.interpolate(&other.z, t),
}
}
}
impl HelperFunctions for Vector3 {
fn register_all_functions() {
register_function(
"Vector3".to_string(),
"new".to_string(),
std::sync::Arc::new(|args| {
if args.len() != 3 {
return Err("Expected 3 arguments for function Vector3::new".to_string());
}
let mut itr = args.into_iter();
let x = f64::try_coerce(itr.next().unwrap())?;
let y = f64::try_coerce(itr.next().unwrap())?;
let z = f64::try_coerce(itr.next().unwrap())?;
Ok(Vector3::new(x, y, z).to_pax_value())
}),
);
}
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
#[serde(crate = "crate::serde")]
pub enum LightShape {
#[default]
Point,
Directional,
}
impl Interpolatable for LightShape {}
impl HelperFunctions for LightShape {}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "crate::serde")]
pub struct MaterialParams {
pub ambient: Property<f64>,
pub diffuse: Property<f64>,
pub specular: Property<f64>,
pub roughness: Property<f64>,
pub metallic: Property<f64>,
pub emissive: Property<Color>,
pub emissive_intensity: Property<f64>,
}
impl Default for MaterialParams {
fn default() -> Self {
Self {
ambient: Property::new(1.0),
diffuse: Property::new(0.82),
specular: Property::new(0.08),
roughness: Property::new(0.78),
metallic: Property::new(0.0),
emissive: Property::new(Color::BLACK),
emissive_intensity: Property::new(0.0),
}
}
}
impl PartialEq for MaterialParams {
fn eq(&self, other: &Self) -> bool {
self.ambient.get() == other.ambient.get()
&& self.diffuse.get() == other.diffuse.get()
&& self.specular.get() == other.specular.get()
&& self.roughness.get() == other.roughness.get()
&& self.metallic.get() == other.metallic.get()
&& self.emissive.get() == other.emissive.get()
&& self.emissive_intensity.get() == other.emissive_intensity.get()
}
}
impl Hash for MaterialParams {
fn hash<H: Hasher>(&self, state: &mut H) {
self.ambient.get().to_bits().hash(state);
self.diffuse.get().to_bits().hash(state);
self.specular.get().to_bits().hash(state);
self.roughness.get().to_bits().hash(state);
self.metallic.get().to_bits().hash(state);
self.emissive.get().hash(state);
self.emissive_intensity.get().to_bits().hash(state);
}
}
impl Interpolatable for MaterialParams {
fn interpolate(&self, other: &Self, t: f64) -> Self {
Self {
ambient: Property::new(self.ambient.get().interpolate(&other.ambient.get(), t)),
diffuse: Property::new(self.diffuse.get().interpolate(&other.diffuse.get(), t)),
specular: Property::new(self.specular.get().interpolate(&other.specular.get(), t)),
roughness: Property::new(self.roughness.get().interpolate(&other.roughness.get(), t)),
metallic: Property::new(self.metallic.get().interpolate(&other.metallic.get(), t)),
emissive: Property::new(self.emissive.get().interpolate(&other.emissive.get(), t)),
emissive_intensity: Property::new(
self.emissive_intensity
.get()
.interpolate(&other.emissive_intensity.get(), t),
),
}
}
}
impl HelperFunctions for MaterialParams {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(crate = "crate::serde")]
pub enum Material {
Lit(MaterialParams),
Unlit,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedMaterialParams {
pub ambient: f64,
pub diffuse: f64,
pub specular: f64,
pub roughness: f64,
pub metallic: f64,
pub emissive: Color,
pub emissive_intensity: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ResolvedMaterial {
Lit(ResolvedMaterialParams),
Unlit,
}
impl Material {
pub fn resolve_in_graph(&self) -> ResolvedMaterial {
match self {
Self::Unlit => ResolvedMaterial::Unlit,
Self::Lit(p) => ResolvedMaterial::Lit(ResolvedMaterialParams {
ambient: p.ambient.local().get(),
diffuse: p.diffuse.local().get(),
specular: p.specular.local().get(),
roughness: p.roughness.local().get(),
metallic: p.metallic.local().get(),
emissive: p.emissive.local().get(),
emissive_intensity: p.emissive_intensity.local().get(),
}),
}
}
pub fn snapshot(&self) -> ResolvedMaterial {
match self {
Self::Unlit => ResolvedMaterial::Unlit,
Self::Lit(p) => ResolvedMaterial::Lit(ResolvedMaterialParams {
ambient: p.ambient.get(),
diffuse: p.diffuse.get(),
specular: p.specular.get(),
roughness: p.roughness.get(),
metallic: p.metallic.get(),
emissive: p.emissive.get(),
emissive_intensity: p.emissive_intensity.get(),
}),
}
}
}
impl Default for Material {
fn default() -> Self {
Self::matte()
}
}
impl Material {
pub fn matte() -> Self {
Self::Lit(MaterialParams::default())
}
pub fn glossy(specular: f64) -> Self {
let mut params = MaterialParams::default();
params.specular = Property::new(specular.clamp(0.0, 1.0));
params.roughness = Property::new(0.28);
Self::Lit(params)
}
pub fn metallic(metallic: f64) -> Self {
let mut params = MaterialParams::default();
params.metallic = Property::new(metallic.clamp(0.0, 1.0));
params.diffuse = Property::new(0.42);
params.specular = Property::new(0.55);
params.roughness = Property::new(0.36);
Self::Lit(params)
}
pub fn emissive(color: Color, intensity: f64) -> Self {
let mut params = MaterialParams::default();
params.emissive = Property::new(color);
params.emissive_intensity = Property::new(intensity.max(0.0));
Self::Lit(params)
}
pub fn custom(params: MaterialParams) -> Self {
Self::Lit(params)
}
pub fn unlit() -> Self {
Self::Unlit
}
}
impl Hash for Material {
fn hash<H: Hasher>(&self, state: &mut H) {
match self {
Material::Lit(params) => {
state.write_u8(0);
params.hash(state);
}
Material::Unlit => {
state.write_u8(1);
}
}
}
}
impl Interpolatable for Material {
fn interpolate(&self, other: &Self, t: f64) -> Self {
match (self, other) {
(Self::Lit(lhs), Self::Lit(rhs)) => Self::Lit(lhs.interpolate(rhs, t)),
_ if t < 1.0 => self.clone(),
_ => other.clone(),
}
}
}
impl HelperFunctions for Material {
fn register_all_functions() {
register_function(
"Material".to_string(),
"matte".to_string(),
std::sync::Arc::new(|args| {
if !args.is_empty() {
return Err("Expected 0 arguments for function Material::matte".to_string());
}
Ok(Material::matte().to_pax_value())
}),
);
register_function(
"Material".to_string(),
"glossy".to_string(),
std::sync::Arc::new(|args| {
if args.len() != 1 {
return Err("Expected 1 argument for function Material::glossy".to_string());
}
let specular = f64::try_coerce(args.into_iter().next().unwrap())?;
Ok(Material::glossy(specular).to_pax_value())
}),
);
register_function(
"Material".to_string(),
"metallic".to_string(),
std::sync::Arc::new(|args| {
if args.len() != 1 {
return Err("Expected 1 argument for function Material::metallic".to_string());
}
let metallic = f64::try_coerce(args.into_iter().next().unwrap())?;
Ok(Material::metallic(metallic).to_pax_value())
}),
);
register_function(
"Material".to_string(),
"emissive".to_string(),
std::sync::Arc::new(|args| {
if args.len() != 2 {
return Err("Expected 2 arguments for function Material::emissive".to_string());
}
let mut itr = args.into_iter();
let color = Color::try_coerce(itr.next().unwrap())?;
let intensity = f64::try_coerce(itr.next().unwrap())?;
Ok(Material::emissive(color, intensity).to_pax_value())
}),
);
register_function(
"Material".to_string(),
"custom".to_string(),
std::sync::Arc::new(|args| {
if args.len() != 1 {
return Err("Expected 1 argument for function Material::custom".to_string());
}
let params = MaterialParams::try_coerce(args.into_iter().next().unwrap())?;
Ok(Material::custom(params).to_pax_value())
}),
);
register_function(
"Material".to_string(),
"unlit".to_string(),
std::sync::Arc::new(|args| {
if !args.is_empty() {
return Err("Expected 0 arguments for function Material::unlit".to_string());
}
Ok(Material::unlit().to_pax_value())
}),
);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(crate = "crate::serde")]
pub struct SceneAmbientLight {
pub color: Color,
pub intensity: f64,
}
impl Default for SceneAmbientLight {
fn default() -> Self {
Self {
color: Color::WHITE,
intensity: 1.0,
}
}
}
impl Interpolatable for SceneAmbientLight {
fn interpolate(&self, other: &Self, t: f64) -> Self {
Self {
color: self.color.interpolate(&other.color, t),
intensity: self.intensity.interpolate(&other.intensity, t),
}
}
}
impl HelperFunctions for SceneAmbientLight {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(crate = "crate::serde")]
pub struct SceneLight {
pub shape: LightShape,
pub position: Vector3,
pub direction: Vector3,
pub color: Color,
pub intensity: f64,
pub radius: f64,
pub enabled: bool,
}
impl Default for SceneLight {
fn default() -> Self {
Self {
shape: LightShape::Point,
position: Vector3::default(),
direction: Vector3::new(0.0, 0.0, -1.0),
color: Color::WHITE,
intensity: 1.0,
radius: 240.0,
enabled: true,
}
}
}
impl Interpolatable for SceneLight {
fn interpolate(&self, other: &Self, t: f64) -> Self {
Self {
shape: if t < 1.0 { self.shape } else { other.shape },
position: self.position.interpolate(&other.position, t),
direction: self.direction.interpolate(&other.direction, t),
color: self.color.interpolate(&other.color, t),
intensity: self.intensity.interpolate(&other.intensity, t),
radius: self.radius.interpolate(&other.radius, t),
enabled: if t < 1.0 { self.enabled } else { other.enabled },
}
}
}
impl HelperFunctions for SceneLight {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(crate = "crate::serde")]
pub struct SceneLighting {
pub active: bool,
pub ambient_is_authored: bool,
pub ambient: SceneAmbientLight,
pub lights: Vec<SceneLight>,
}
impl Default for SceneLighting {
fn default() -> Self {
Self {
active: false,
ambient_is_authored: false,
ambient: SceneAmbientLight::default(),
lights: vec![],
}
}
}
impl SceneLighting {
pub const MAX_LIGHTS: usize = 8;
pub const DEFAULT_AMBIENT_INTENSITY: f64 = 0.35;
pub fn identity() -> Self {
Self::default()
}
pub fn with_default_ambient(lights: Vec<SceneLight>) -> Self {
Self {
active: !lights.is_empty(),
ambient_is_authored: false,
ambient: SceneAmbientLight {
color: Color::WHITE,
intensity: Self::DEFAULT_AMBIENT_INTENSITY,
},
lights,
}
}
}
impl Interpolatable for SceneLighting {}
impl HelperFunctions for SceneLighting {}
impl Interpolatable for Paint {
fn interpolate(&self, other: &Self, t: f64) -> Self {
if let (Self::Solid(from), Self::Solid(to)) = (self, other) {
return Self::Solid(from.interpolate(to, t));
}
if t <= 0.0 || self == other {
return self.clone();
}
if t >= 1.0 {
return other.clone();
}
Self::blend(vec![(self.clone(), 1.0 - t), (other.clone(), t)])
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash)]
#[serde(crate = "crate::serde")]
pub struct LinearGradient {
pub start: (Size, Size),
pub end: (Size, Size),
pub stops: Vec<GradientStop>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Hash)]
#[serde(crate = "crate::serde")]
pub struct MeshPoint {
pub position: (Size, Size),
pub color: Color,
}
impl Interpolatable for MeshPoint {
fn interpolate(&self, other: &Self, t: f64) -> Self {
Self {
position: self.position.interpolate(&other.position, t),
color: self.color.interpolate(&other.color, t),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Hash)]
#[serde(crate = "crate::serde")]
pub struct MeshGradient {
pub rows: Vec<Vec<MeshPoint>>,
}
impl Interpolatable for MeshGradient {}
impl MeshGradient {
pub fn validate(&self) -> Result<(), String> {
let width = self.rows.first().map_or(0, Vec::len);
if !(2..=8).contains(&self.rows.len())
|| !(2..=8).contains(&width)
|| self.rows.iter().any(|row| row.len() != width)
{
return Err("mesh gradients require 2–8 equal-length rows of 2–8 anchors".into());
}
let finite_size = |size: &Size| match size {
Size::Pixels(v) | Size::Percent(v) => v.to_float().is_finite(),
Size::Combined(px, percent) => {
px.to_float().is_finite() && percent.to_float().is_finite()
}
};
if self.rows.iter().flatten().any(|p| {
!finite_size(&p.position.0)
|| !finite_size(&p.position.1)
|| p.color.to_rgba_0_1().iter().any(|c| !c.is_finite())
}) {
return Err("mesh gradient positions and colors must be finite".into());
}
Ok(())
}
}
#[cfg(test)]
mod mesh_value_tests {
use super::*;
#[test]
fn mesh_values_roundtrip_and_opacity_scales_the_sampled_field() {
let point = MeshPoint {
position: (
Size::Combined(5.into(), 40.into()),
Size::Percent(70.into()),
),
color: Color::CYAN,
};
let mesh = MeshGradient {
rows: vec![vec![point.clone(); 2]; 2],
};
assert!(mesh.validate().is_ok());
let paint = Paint::MeshGradient(mesh.clone());
assert_eq!(
Paint::try_coerce(paint.clone().to_pax_value()).unwrap(),
paint
);
assert_eq!(
MeshPoint::try_coerce(point.clone().to_pax_value()).unwrap(),
point
);
let Paint::Blend(terms) = paint.with_alpha_factor(0.25) else {
panic!("post-sampling opacity")
};
assert_eq!(terms[0], (paint.clone(), 0.25));
assert_eq!(paint.with_alpha_factor(0.0).max_alpha_0_1(), 0.0);
assert_eq!(paint.interpolate(&Paint::Solid(Color::WHITE), 0.0), paint);
assert!(matches!(
paint.interpolate(&Paint::Solid(Color::WHITE), 0.5),
Paint::Blend(_)
));
let mut invalid = mesh;
invalid.rows[0].pop();
assert!(invalid.validate().is_err());
}
#[test]
fn malformed_mesh_coordinates_never_panic_or_truncate() {
for coordinates in [
vec![],
vec![Size::Percent(0.into())],
vec![Size::Percent(0.into()); 3],
] {
let value = PaxValue::Object(vec![
("position".into(), coordinates.to_pax_value()),
("color".into(), Color::CYAN.to_pax_value()),
]);
assert!(MeshPoint::try_coerce(value).is_err());
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(crate = "crate::serde")]
pub struct RadialGradient {
pub end: (Size, Size),
pub start: (Size, Size),
pub radius: f64,
pub stops: Vec<GradientStop>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResolvedRadialGradient {
pub focal_point: kurbo::Point,
pub center: kurbo::Point,
pub radius: f64,
}
impl RadialGradient {
pub fn resolve_geometry(&self, rect: kurbo::Rect) -> Option<ResolvedRadialGradient> {
let bounds = (rect.width(), rect.height());
let resolve = |p: (Size, Size)| {
kurbo::Point::new(
rect.x0 + p.0.evaluate(bounds, Axis::X),
rect.y0 + p.1.evaluate(bounds, Axis::Y),
)
};
let focal_point = resolve(self.start);
let center = resolve(self.end);
if self.radius <= 0.0
|| ![
self.radius,
focal_point.x,
focal_point.y,
center.x,
center.y,
]
.into_iter()
.all(f64::is_finite)
{
return None;
}
Some(ResolvedRadialGradient {
focal_point,
center,
radius: self.radius,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash)]
#[serde(crate = "crate::serde")]
pub struct GradientStop {
pub position: Size,
pub color: Color,
}
impl GradientStop {
pub fn get(color: Color, position: Size) -> GradientStop {
GradientStop { position, color }
}
pub fn with_alpha_factor(&self, factor: f64) -> GradientStop {
GradientStop {
position: self.position.clone(),
color: self.color.with_alpha_factor(factor),
}
}
}
impl Default for Paint {
fn default() -> Self {
Self::Solid(Color::default())
}
}
impl Paint {
pub fn blend(terms: Vec<(Paint, f64)>) -> Self {
fn append(out: &mut Vec<(Paint, f64)>, terms: Vec<(Paint, f64)>, scale: f64) {
let max = terms
.iter()
.map(|(_, w)| *w)
.filter(|w| w.is_finite())
.fold(0.0, f64::max);
if max <= 0.0 {
return;
}
let sum: f64 = terms
.iter()
.map(|(_, w)| *w)
.filter(|w| w.is_finite() && *w > 0.0)
.map(|w| w / max)
.sum();
for (fill, weight) in terms {
if !weight.is_finite() || weight <= 0.0 {
continue;
}
let weight = scale * (weight / max) / sum;
if weight == 0.0 {
continue;
}
if let Paint::Blend(terms) = fill {
append(out, terms, weight);
} else if let Some((_, existing)) = out.iter_mut().find(|(f, _)| *f == fill) {
*existing += weight;
} else {
out.push((fill, weight));
}
}
}
let mut result = Vec::new();
append(&mut result, terms, 1.0);
if result.is_empty() {
return Paint::Solid(Color::from_rgba_0_1([0.0; 4]));
}
if result.len() == 1 {
result.pop().unwrap().0
} else {
Paint::Blend(result)
}
}
pub fn representative_color(&self) -> Color {
match self {
Self::Solid(color) => color.clone(),
Self::LinearGradient(g) => g.stops.first().map(|s| s.color.clone()).unwrap_or_default(),
Self::RadialGradient(g) => g.stops.first().map(|s| s.color.clone()).unwrap_or_default(),
Self::MeshGradient(_) => Color::TRANSPARENT,
Self::Blend(terms) => Color::blend_premultiplied(
terms
.iter()
.map(|(fill, weight)| (fill.representative_color(), *weight)),
),
}
}
fn gradient_stop_position_0_1(position: &Size) -> Option<f64> {
match position {
Size::Percent(p) => Some((p.to_float() / 100.0).clamp(0.0, 1.0)),
Size::Pixels(_) | Size::Combined(_, _) => None,
}
}
fn integrated_gradient_alpha_0_1(stops: &[GradientStop]) -> f64 {
if stops.is_empty() {
return 0.0;
}
let mut positioned_stops = stops
.iter()
.filter_map(|stop| {
Self::gradient_stop_position_0_1(&stop.position)
.map(|position| (position, stop.color.alpha_0_1()))
})
.collect::<Vec<_>>();
if positioned_stops.is_empty() {
return stops
.iter()
.map(|stop| stop.color.alpha_0_1())
.fold(0.0, f64::max);
}
positioned_stops.sort_by(|lhs, rhs| lhs.0.total_cmp(&rhs.0));
if positioned_stops
.first()
.map(|stop| stop.0 > 0.0)
.unwrap_or(false)
{
let alpha = positioned_stops[0].1;
positioned_stops.insert(0, (0.0, alpha));
}
if positioned_stops
.last()
.map(|stop| stop.0 < 1.0)
.unwrap_or(false)
{
let alpha = positioned_stops.last().map(|stop| stop.1).unwrap_or(0.0);
positioned_stops.push((1.0, alpha));
}
let mut integrated_alpha = 0.0;
for window in positioned_stops.windows(2) {
let [(start_pos, start_alpha), (end_pos, end_alpha)] = window else {
continue;
};
let span = (end_pos - start_pos).clamp(0.0, 1.0);
if span <= f64::EPSILON {
continue;
}
integrated_alpha += (start_alpha + end_alpha) * 0.5 * span;
}
integrated_alpha.clamp(0.0, 1.0)
}
pub fn to_unit_point((x, y): (Size, Size), (width, height): (f64, f64)) -> UnitPoint {
let normalized_x = match x {
Size::Pixels(val) => val.to_float() / width,
Size::Percent(val) => val.to_float() / 100.0,
Size::Combined(pix, per) => (pix.to_float() / width) + (per.to_float() / 100.0),
};
let normalized_y = match y {
Size::Pixels(val) => val.to_float() / height,
Size::Percent(val) => val.to_float() / 100.0,
Size::Combined(pix, per) => (pix.to_float() / width) + (per.to_float() / 100.0),
};
UnitPoint::new(normalized_x, normalized_y)
}
pub fn to_piet_gradient_stops(stops: Vec<GradientStop>) -> Vec<piet::GradientStop> {
let mut ret = Vec::new();
for gradient_stop in stops {
match gradient_stop.position {
Size::Pixels(_) => {
panic!("Gradient stops must be specified in percentages");
}
Size::Percent(p) => {
ret.push(piet::GradientStop {
pos: (p.to_float() / 100.0) as f32,
color: gradient_stop.color.to_piet_color(),
});
}
Size::Combined(_, _) => {
panic!("Gradient stops must be specified in percentages");
}
}
}
ret
}
#[allow(non_snake_case)]
pub fn linearGradient(
start: (Size, Size),
end: (Size, Size),
stops: Vec<GradientStop>,
) -> Paint {
Paint::LinearGradient(LinearGradient { start, end, stops })
}
pub fn with_alpha_factor(&self, factor: f64) -> Paint {
match self {
Paint::Blend(terms) => Paint::Blend(
terms
.iter()
.map(|(fill, weight)| (fill.with_alpha_factor(factor), *weight))
.collect(),
),
Paint::Solid(color) => Paint::Solid(color.with_alpha_factor(factor)),
Paint::MeshGradient(_) => {
let factor = factor.clamp(0.0, 1.0);
Paint::blend(vec![
(self.clone(), factor),
(Paint::Solid(Color::TRANSPARENT), 1.0 - factor),
])
}
Paint::LinearGradient(gradient) => Paint::LinearGradient(LinearGradient {
start: gradient.start.clone(),
end: gradient.end.clone(),
stops: gradient
.stops
.iter()
.map(|stop| stop.with_alpha_factor(factor))
.collect(),
}),
Paint::RadialGradient(gradient) => Paint::RadialGradient(RadialGradient {
start: gradient.start.clone(),
end: gradient.end.clone(),
radius: gradient.radius,
stops: gradient
.stops
.iter()
.map(|stop| stop.with_alpha_factor(factor))
.collect(),
}),
}
}
pub fn max_alpha_0_1(&self) -> f64 {
match self {
Paint::Blend(terms) => terms
.iter()
.map(|(fill, weight)| fill.max_alpha_0_1() * weight)
.sum::<f64>()
.clamp(0.0, 1.0),
Paint::Solid(color) => color.alpha_0_1(),
Paint::MeshGradient(mesh) => {
if mesh
.rows
.iter()
.flatten()
.any(|p| p.color.alpha_0_1() > 0.0)
{
1.0
} else {
0.0
}
}
Paint::LinearGradient(gradient) => gradient
.stops
.iter()
.map(|stop| stop.color.alpha_0_1())
.fold(0.0, f64::max),
Paint::RadialGradient(gradient) => gradient
.stops
.iter()
.map(|stop| stop.color.alpha_0_1())
.fold(0.0, f64::max),
}
}
pub fn coverage_alpha_0_1(&self) -> f64 {
match self {
Paint::Blend(terms) => terms
.iter()
.map(|(fill, weight)| fill.coverage_alpha_0_1() * weight)
.sum::<f64>()
.clamp(0.0, 1.0),
Paint::Solid(color) => color.alpha_0_1(),
Paint::LinearGradient(gradient) => Self::integrated_gradient_alpha_0_1(&gradient.stops),
Paint::RadialGradient(gradient) => Self::integrated_gradient_alpha_0_1(&gradient.stops),
Paint::MeshGradient(_) => self.max_alpha_0_1(),
}
}
}
#[cfg(test)]
mod fill_coverage_tests {
fn gradient(color: Color) -> Paint {
Paint::linearGradient(
(Size::Percent(0.into()), Size::Percent(0.into())),
(Size::Percent(100.into()), Size::Percent(100.into())),
vec![GradientStop::get(color, Size::Percent(0.into()))],
)
}
#[test]
fn transparent_paint_crossfade_uses_premultiplied_color_and_alpha() {
use super::*;
let from = gradient(Color::from_rgba_0_1([1.0, 0.0, 0.0, 0.0]));
let to = Paint::Solid(Color::from_rgba_0_1([0.0, 0.0, 1.0, 0.5]));
let halfway = from.interpolate(&to, 0.5);
assert_eq!(
halfway.representative_color().to_rgba_0_1(),
[0.0, 0.0, 1.0, 0.25]
);
assert_eq!(halfway.max_alpha_0_1(), 0.25);
assert_eq!(halfway.with_alpha_factor(0.5).max_alpha_0_1(), 0.125);
assert_eq!(from.interpolate(&to, 0.0), from);
assert_eq!(from.interpolate(&to, 1.0), to);
}
#[test]
fn interrupted_gradient_reversals_remain_flat_continuous_and_bounded() {
use super::*;
let dark = gradient(Color::BLACK);
let light = gradient(Color::WHITE);
let mut displayed = dark.clone();
for i in 0..1000 {
let target = if i % 2 == 0 { &light } else { &dark };
assert_eq!(displayed.interpolate(target, 0.0), displayed);
let before = displayed.representative_color().to_rgba_0_1();
displayed = displayed.interpolate(target, 0.07);
let after = displayed.representative_color().to_rgba_0_1();
let expected = target.representative_color().to_rgba_0_1();
for channel in 0..4 {
assert!(
(after[channel] - (before[channel] * 0.93 + expected[channel] * 0.07)).abs()
< 1e-12
);
}
let Paint::Blend(terms) = &displayed else {
panic!("expected mixture");
};
assert_eq!(terms.len(), 2);
assert!(terms
.iter()
.all(|(fill, _)| !matches!(fill, Paint::Blend(_))));
let roundtrip = Paint::try_coerce(displayed.clone().to_pax_value()).unwrap();
for (a, b) in roundtrip
.representative_color()
.to_rgba_0_1()
.into_iter()
.zip(after)
{
assert!((a - b).abs() < 1e-12);
}
}
assert_eq!(displayed.interpolate(&light, 1.0), light);
}
#[test]
fn crossfade_keeps_different_gradient_kinds_geometry_and_stop_lists() {
use super::*;
let from = gradient(Color::BLACK);
let to = Paint::RadialGradient(RadialGradient {
start: (Size::Pixels(12.into()), Size::Percent(50.into())),
end: (Size::Percent(100.into()), Size::Percent(50.into())),
radius: 0.5,
stops: vec![
GradientStop::get(Color::WHITE, Size::Percent(20.into())),
GradientStop::get(Color::BLACK, Size::Percent(80.into())),
],
});
assert_eq!(
from.interpolate(&to, 0.25),
Paint::Blend(vec![(from, 0.75), (to, 0.25)])
);
}
#[test]
fn solid_fill_interpolates_rgba_channels() {
use super::*;
let from = Paint::Solid(Color::BLACK);
let to = Paint::Solid(Color::WHITE);
assert_eq!(
from.interpolate(&to, 0.5),
Paint::Solid(Color::BLACK.interpolate(&Color::WHITE, 0.5))
);
}
use super::{Color, ColorChannel, GradientStop, LinearGradient, Numeric, Paint, Size};
fn rgba_alpha(alpha: u8) -> Color {
Color::rgba(
ColorChannel::Integer(0),
ColorChannel::Integer(0),
ColorChannel::Integer(0),
ColorChannel::Integer(alpha),
)
}
#[test]
fn coverage_alpha_matches_solid_alpha() {
let fill = Paint::Solid(rgba_alpha(128));
assert!((fill.coverage_alpha_0_1() - (128.0 / 255.0)).abs() < 1e-6);
}
#[test]
fn coverage_alpha_integrates_gradient_stops() {
let fill = Paint::LinearGradient(LinearGradient {
start: (
Size::Percent(Numeric::F64(0.0)),
Size::Percent(Numeric::F64(50.0)),
),
end: (
Size::Percent(Numeric::F64(100.0)),
Size::Percent(Numeric::F64(50.0)),
),
stops: vec![
GradientStop::get(rgba_alpha(0), Size::Percent(Numeric::F64(0.0))),
GradientStop::get(rgba_alpha(255), Size::Percent(Numeric::F64(50.0))),
GradientStop::get(rgba_alpha(0), Size::Percent(Numeric::F64(100.0))),
],
});
assert!((fill.coverage_alpha_0_1() - 0.5).abs() < 1e-6);
}
}
pub fn paint_bounds(mut rect: kurbo::Rect) -> Option<kurbo::Rect> {
if ![rect.x0, rect.y0, rect.x1, rect.y1]
.into_iter()
.all(f64::is_finite)
{
log::warn!("Paint geometry must be finite");
return None;
}
if rect.width() == 0.0 {
rect.x0 -= 0.5;
rect.x1 += 0.5;
}
if rect.height() == 0.0 {
rect.y0 -= 0.5;
rect.y1 += 0.5;
}
Some(rect)
}
impl Fill {
pub(crate) fn validate_layer(&self) -> Result<(), String> {
Ok(())
}
}
impl Stroke {
pub(crate) fn validate_layer(&self) -> Result<(), String> {
if matches!(self.width.get(), Size::Pixels(_)) {
Ok(())
} else {
Err("Stroke.width: expected logical pixels; percentage widths are unsupported".into())
}
}
}
#[cfg(test)]
mod appearance_tests {
use super::*;
use crate::properties::drain_effects;
#[test]
fn appearance_defaults_shorthand_and_errors() {
let red = Color::RED.to_pax_value();
let fills = Vec::<Fill>::try_coerce(red.clone()).unwrap();
let strokes = Vec::<Stroke>::try_coerce(red).unwrap();
assert_eq!(fills[0].paint.get(), Paint::Solid(Color::RED));
assert_eq!(strokes[0].width_pixels(), 1.0);
assert_eq!(fills[0].material.get(), Material::default());
assert_eq!(fills[0].opacity.get().to_float_0_1(), 1.0);
assert!(Vec::<Fill>::try_coerce(PaxValue::Vec(vec![]))
.unwrap()
.is_empty());
assert!(Vec::<Numeric>::try_coerce(1.to_pax_value()).is_err());
for invalid in [
Stroke::default().to_pax_value(),
PaxValue::Vec(vec![]),
PaxValue::Option(Box::new(None)),
PaxValue::Object(vec![("color".into(), Color::RED.to_pax_value())]),
] {
let error =
Vec::<Fill>::try_coerce(PaxValue::Vec(vec![Color::RED.to_pax_value(), invalid]))
.unwrap_err();
assert!(error.contains("[1]"), "{error}");
}
assert!(Stroke::try_coerce(PaxValue::Object(vec![(
"width".into(),
Size::Percent(10.into()).to_pax_value()
)]))
.is_err());
}
#[test]
fn layers_interpolate_by_position_and_switch_lengths_immediately() {
let a = vec![Fill::from(Color::RED)];
let b = vec![Fill::from(Color::BLUE)];
assert_eq!(
a.interpolate(&b, 0.5)[0].paint.get(),
Paint::Solid(Color::RED.interpolate(&Color::BLUE, 0.5))
);
let longer = vec![Fill::from(Color::BLUE), Fill::from(Color::GREEN)];
assert_eq!(a.interpolate(&longer, 0.1), longer);
let a = Stroke::default();
let b = Stroke::default();
b.cap.set(StrokeCap::Round);
assert_eq!(a.interpolate(&b, 0.99).cap.get(), StrokeCap::Butt);
assert_eq!(a.interpolate(&b, 1.0).cap.get(), StrokeCap::Round);
}
#[test]
fn expression_adapter_observes_nested_edits_and_releases_removed_layers() {
let graph = crate::properties::PropertyGraph::new(|| {});
let _entered = graph.enter();
let first = Fill::from(Color::RED);
let source = Property::new(vec![first.clone()]);
let variable = Variable::new_from_typed_property(source.clone());
let read = || Vec::<Fill>::try_coerce(variable.get_as_pax_value()).unwrap();
assert_eq!(read()[0].paint.get(), Paint::Solid(Color::RED));
first.paint.local().set(Paint::Solid(Color::BLUE));
assert!(drain_effects(100) < 100);
assert_eq!(read()[0].paint.get(), Paint::Solid(Color::BLUE));
source.local().set(vec![]);
assert!(read().is_empty());
first.paint.local().set(Paint::Solid(Color::WHITE));
assert!(drain_effects(100) < 100);
assert!(read().is_empty());
}
#[test]
fn nested_appearance_subscriptions_retire_after_replacement_and_disposal() {
let graph = crate::properties::PropertyGraph::new(|| {});
let _entered = graph.enter();
use crate::properties::property_table_total_properties_count;
let baseline = property_table_total_properties_count();
for _ in 0..30 {
let scope = graph.binding_scope();
let _scope = scope.enter();
let source = Property::new(vec![Fill::from(Color::RED)]);
let variable = Variable::new_from_typed_property(source.clone());
variable.get_as_pax_value();
source.local().get()[0]
.opacity
.local()
.set(Opacity::from(0.25));
drain_effects(100);
variable.get_as_pax_value();
source
.local()
.set(vec![Fill::from(Color::BLUE), Fill::from(Color::GREEN)]);
variable.get_as_pax_value();
source.local().set(vec![]);
variable.get_as_pax_value();
scope.close();
}
drain_effects(100);
graph.collect();
assert_eq!(property_table_total_properties_count(), baseline);
}
#[test]
fn degenerate_paint_domain_and_nonfinite_opacity_are_finite() {
let rect = paint_bounds(kurbo::Rect::new(25.0, 40.0, 125.0, 40.0)).unwrap();
assert_eq!(rect, kurbo::Rect::new(25.0, 39.5, 125.0, 40.5));
assert!(paint_bounds(kurbo::Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none());
for alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(Opacity::from(alpha).to_float_0_1(), 0.0);
}
}
}