use crate::{catmull_rom, clamp01, lerp, AnimationCurve, MotionSpec, Rect, Rgba, Vec2};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MaterialTokens {
pub fill: Rgba,
pub stroke: Rgba,
pub glow: Rgba,
pub shadow: Rgba,
pub blur_px: f32,
pub stroke_px: f32,
pub glow_px: f32,
pub elevation: f32,
}
impl MaterialTokens {
pub fn glass(accent: Rgba) -> Self {
Self {
fill: Rgba::rgba8(12, 20, 42, 192),
stroke: accent.with_alpha(0.42),
glow: accent.with_alpha(0.58),
shadow: Rgba::rgba8(0, 0, 0, 128),
blur_px: 22.0,
stroke_px: 1.4,
glow_px: 36.0,
elevation: 4.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ShapeAnchor {
Center,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
EdgeLeft,
EdgeRight,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum MorphShape {
RoundedCard,
Capsule,
Blob,
Ticket,
NotchedPanel,
DockIsland,
CommandLens,
}
impl MorphShape {
pub fn default_topology(self) -> CardTopology {
match self {
Self::RoundedCard => CardTopology {
corner_radius: 22.0,
edge_wave: 0.0,
notch_depth: 0.0,
blob_strength: 0.0,
aspect_pull: 0.0,
anchor: ShapeAnchor::Center,
},
Self::Capsule => CardTopology {
corner_radius: 999.0,
edge_wave: 0.0,
notch_depth: 0.0,
blob_strength: 0.0,
aspect_pull: 0.35,
anchor: ShapeAnchor::Center,
},
Self::Blob => CardTopology {
corner_radius: 52.0,
edge_wave: 18.0,
notch_depth: 0.0,
blob_strength: 0.85,
aspect_pull: 0.1,
anchor: ShapeAnchor::Center,
},
Self::Ticket => CardTopology {
corner_radius: 18.0,
edge_wave: 2.0,
notch_depth: 18.0,
blob_strength: 0.0,
aspect_pull: 0.0,
anchor: ShapeAnchor::EdgeLeft,
},
Self::NotchedPanel => CardTopology {
corner_radius: 26.0,
edge_wave: 0.0,
notch_depth: 26.0,
blob_strength: 0.16,
aspect_pull: 0.0,
anchor: ShapeAnchor::TopRight,
},
Self::DockIsland => CardTopology {
corner_radius: 38.0,
edge_wave: 8.0,
notch_depth: 0.0,
blob_strength: 0.28,
aspect_pull: 0.48,
anchor: ShapeAnchor::BottomRight,
},
Self::CommandLens => CardTopology {
corner_radius: 30.0,
edge_wave: 4.0,
notch_depth: 10.0,
blob_strength: 0.08,
aspect_pull: 0.18,
anchor: ShapeAnchor::TopLeft,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CardTopology {
pub corner_radius: f32,
pub edge_wave: f32,
pub notch_depth: f32,
pub blob_strength: f32,
pub aspect_pull: f32,
pub anchor: ShapeAnchor,
}
impl CardTopology {
pub fn mix(self, other: Self, amount: f32) -> Self {
let amount = clamp01(amount);
let left = self.sanitized();
let right = other.sanitized();
Self {
corner_radius: lerp(left.corner_radius, right.corner_radius, amount),
edge_wave: lerp(left.edge_wave, right.edge_wave, amount),
notch_depth: lerp(left.notch_depth, right.notch_depth, amount),
blob_strength: lerp(left.blob_strength, right.blob_strength, amount),
aspect_pull: lerp(left.aspect_pull, right.aspect_pull, amount),
anchor: if amount < 0.5 {
left.anchor
} else {
right.anchor
},
}
}
pub fn sanitized(self) -> Self {
Self {
corner_radius: sanitize_non_negative(self.corner_radius, 4096.0),
edge_wave: sanitize_non_negative(self.edge_wave, 4096.0),
notch_depth: sanitize_non_negative(self.notch_depth, 4096.0),
blob_strength: clamp01(self.blob_strength),
aspect_pull: sanitize_finite(self.aspect_pull, 0.0).clamp(-1.0, 1.0),
anchor: self.anchor,
}
}
pub fn uniform_pack(self) -> [f32; 4] {
let topology = self.sanitized();
[
topology.corner_radius,
topology.edge_wave,
topology.notch_depth,
topology.blob_strength,
]
}
pub fn hit_shape(self, rect: Rect) -> MorphHitShape {
MorphHitShape::new(rect, self)
}
pub fn contains_point(self, rect: Rect, point: Vec2) -> bool {
self.hit_shape(rect).contains(point)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MorphHitShape {
pub rect: Rect,
pub topology: CardTopology,
}
impl MorphHitShape {
pub fn new(rect: Rect, topology: CardTopology) -> Self {
Self { rect, topology }
}
pub fn surface_rect(self) -> Rect {
if self.rect.is_finite() && !self.rect.is_empty() {
self.rect
} else {
Rect::ZERO
}
}
pub fn corner_radius(self) -> f32 {
let rect = self.surface_rect();
if rect.is_empty() {
return 0.0;
}
self.topology
.sanitized()
.corner_radius
.min(rect.width.min(rect.height) * 0.5)
}
pub fn contains(self, point: Vec2) -> bool {
let rect = self.surface_rect();
if rect.is_empty() || !rect.contains(point.x, point.y) {
return false;
}
let topology = self.topology.sanitized();
if point_is_in_notch(rect, topology, point) {
return false;
}
rounded_rect_contains(rect, self.corner_radius(), point)
}
}
fn rounded_rect_contains(rect: Rect, radius: f32, point: Vec2) -> bool {
let radius = sanitize_non_negative(radius, rect.width.min(rect.height) * 0.5);
if radius <= 0.0 {
return true;
}
let inner_left = rect.x + radius;
let inner_right = rect.right() - radius;
let inner_top = rect.y + radius;
let inner_bottom = rect.bottom() - radius;
if (inner_left..=inner_right).contains(&point.x)
|| (inner_top..=inner_bottom).contains(&point.y)
{
return true;
}
let corner_x = if point.x < inner_left {
inner_left
} else {
inner_right
};
let corner_y = if point.y < inner_top {
inner_top
} else {
inner_bottom
};
distance_squared(point, Vec2::new(corner_x, corner_y)) <= radius * radius
}
fn point_is_in_notch(rect: Rect, topology: CardTopology, point: Vec2) -> bool {
let radius = topology.notch_depth.min(rect.width.min(rect.height) * 0.5);
if radius <= 0.0 {
return false;
}
match topology.anchor {
ShapeAnchor::Center => {
in_circle_notch(point, Vec2::new(rect.x, rect.center().y), radius)
|| in_circle_notch(point, Vec2::new(rect.right(), rect.center().y), radius)
}
ShapeAnchor::EdgeLeft => in_circle_notch(point, Vec2::new(rect.x, rect.center().y), radius),
ShapeAnchor::EdgeRight => {
in_circle_notch(point, Vec2::new(rect.right(), rect.center().y), radius)
}
ShapeAnchor::TopLeft => in_circle_notch(point, Vec2::new(rect.x, rect.y), radius),
ShapeAnchor::TopRight => in_circle_notch(point, Vec2::new(rect.right(), rect.y), radius),
ShapeAnchor::BottomLeft => in_circle_notch(point, Vec2::new(rect.x, rect.bottom()), radius),
ShapeAnchor::BottomRight => {
in_circle_notch(point, Vec2::new(rect.right(), rect.bottom()), radius)
}
}
}
fn in_circle_notch(point: Vec2, center: Vec2, radius: f32) -> bool {
distance_squared(point, center) <= radius * radius
}
fn distance_squared(left: Vec2, right: Vec2) -> f32 {
let dx = left.x - right.x;
let dy = left.y - right.y;
dx * dx + dy * dy
}
fn sanitize_non_negative(value: f32, max: f32) -> f32 {
sanitize_finite(value, 0.0).clamp(0.0, max)
}
fn sanitize_finite(value: f32, fallback: f32) -> f32 {
if value.is_finite() {
value
} else {
fallback
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MorphProfile {
pub from: MorphShape,
pub to: MorphShape,
pub motion: MotionSpec,
pub overshoot: f32,
}
impl MorphProfile {
pub fn material_default(from: MorphShape, to: MorphShape) -> Self {
Self {
from,
to,
motion: MotionSpec::new(520, 0, AnimationCurve::Emphasized),
overshoot: 0.08,
}
}
pub fn sample(self, elapsed_ms: u32) -> MorphState {
let progress = self.motion.progress(elapsed_ms);
let overshoot = if self.overshoot.is_finite() {
self.overshoot.clamp(0.0, 1.0)
} else {
0.0
};
let eased = if overshoot > 0.0 {
let sampled = catmull_rom(0.0, 0.0, 1.0 + overshoot, 1.0, progress);
sampled.clamp(0.0, 1.0 + overshoot)
} else {
progress
};
MorphState {
progress,
eased_progress: eased,
topology: self
.from
.default_topology()
.mix(self.to.default_topology(), eased.min(1.0)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MorphState {
pub progress: f32,
pub eased_progress: f32,
pub topology: CardTopology,
}
impl MorphState {
pub fn hit_shape(self, rect: Rect) -> MorphHitShape {
self.topology.hit_shape(rect)
}
pub fn contains_point(self, rect: Rect, point: Vec2) -> bool {
self.hit_shape(rect).contains(point)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MorphCacheKey {
pub from: &'static str,
pub to: &'static str,
pub width_px: u16,
pub height_px: u16,
pub frame_bucket: u16,
}
impl MorphCacheKey {
pub fn new(from: MorphShape, to: MorphShape, size: Vec2, progress: f32) -> Self {
Self {
from: shape_name(from),
to: shape_name(to),
width_px: quantize_px(size.x),
height_px: quantize_px(size.y),
frame_bucket: (clamp01(progress) * 240.0).round() as u16,
}
}
}
fn quantize_px(value: f32) -> u16 {
if !value.is_finite() || value <= 0.0 {
0
} else {
value.round().min(u16::MAX as f32) as u16
}
}
fn shape_name(shape: MorphShape) -> &'static str {
match shape {
MorphShape::RoundedCard => "rounded_card",
MorphShape::Capsule => "capsule",
MorphShape::Blob => "blob",
MorphShape::Ticket => "ticket",
MorphShape::NotchedPanel => "notched_panel",
MorphShape::DockIsland => "dock_island",
MorphShape::CommandLens => "command_lens",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn morph_profile_samples_between_shapes() {
let profile = MorphProfile::material_default(MorphShape::RoundedCard, MorphShape::Blob);
let start = profile.sample(0).topology;
let end = profile.sample(10_000).topology;
assert!(end.blob_strength > start.blob_strength);
assert!(end.edge_wave > start.edge_wave);
}
#[test]
fn cache_key_quantizes_progress() {
let a = MorphCacheKey::new(
MorphShape::RoundedCard,
MorphShape::Capsule,
Vec2::new(100.2, 50.7),
0.5,
);
let b = MorphCacheKey::new(
MorphShape::RoundedCard,
MorphShape::Capsule,
Vec2::new(100.2, 50.7),
0.501,
);
assert_eq!(a.width_px, 100);
assert_eq!(a.height_px, 51);
assert_eq!(a.frame_bucket, b.frame_bucket);
}
#[test]
fn morph_profile_sanitizes_non_finite_overshoot() {
let mut profile = MorphProfile::material_default(MorphShape::RoundedCard, MorphShape::Blob);
profile.overshoot = f32::INFINITY;
let state = profile.sample(260);
assert!(state.eased_progress.is_finite());
assert!((0.0..=1.0).contains(&state.eased_progress));
assert!(state.topology.blob_strength.is_finite());
}
#[test]
fn topology_uniforms_and_mixing_sanitize_public_values() {
let malformed = CardTopology {
corner_radius: f32::NAN,
edge_wave: f32::INFINITY,
notch_depth: -20.0,
blob_strength: f32::NAN,
aspect_pull: f32::INFINITY,
anchor: ShapeAnchor::Center,
};
let extreme = CardTopology {
corner_radius: f32::MAX,
edge_wave: f32::MAX,
notch_depth: f32::MAX,
blob_strength: 2.0,
aspect_pull: -2.0,
anchor: ShapeAnchor::BottomRight,
};
assert_eq!(malformed.uniform_pack(), [0.0, 0.0, 0.0, 0.0]);
let mixed = malformed.mix(extreme, 0.5);
assert!(mixed.uniform_pack().iter().all(|value| value.is_finite()));
assert!((0.0..=1.0).contains(&mixed.blob_strength));
assert!((-1.0..=1.0).contains(&mixed.aspect_pull));
}
#[test]
fn topology_hit_shape_respects_rounded_corners() {
let topology = CardTopology {
corner_radius: 24.0,
edge_wave: 0.0,
notch_depth: 0.0,
blob_strength: 0.0,
aspect_pull: 0.0,
anchor: ShapeAnchor::Center,
};
let rect = Rect::new(0.0, 0.0, 100.0, 64.0);
let shape = topology.hit_shape(rect);
assert!(shape.contains(Vec2::new(50.0, 32.0)));
assert!(shape.contains(Vec2::new(24.0, 2.0)));
assert!(!shape.contains(Vec2::new(1.0, 1.0)));
}
#[test]
fn topology_hit_shape_respects_anchor_notches() {
let topology = CardTopology {
corner_radius: 0.0,
edge_wave: 0.0,
notch_depth: 14.0,
blob_strength: 0.0,
aspect_pull: 0.0,
anchor: ShapeAnchor::EdgeLeft,
};
let rect = Rect::new(0.0, 0.0, 120.0, 48.0);
let shape = topology.hit_shape(rect);
assert!(!shape.contains(Vec2::new(0.0, 24.0)));
assert!(!shape.contains(Vec2::new(10.0, 24.0)));
assert!(shape.contains(Vec2::new(20.0, 24.0)));
assert!(shape.contains(Vec2::new(119.0, 24.0)));
}
#[test]
fn topology_hit_shape_ignores_malformed_public_values() {
let topology = CardTopology {
corner_radius: f32::INFINITY,
edge_wave: f32::NAN,
notch_depth: f32::MAX,
blob_strength: f32::NAN,
aspect_pull: f32::NAN,
anchor: ShapeAnchor::BottomRight,
};
let malformed = topology.hit_shape(Rect::new(0.0, 0.0, f32::INFINITY, 20.0));
let rect = Rect::new(0.0, 0.0, 40.0, 40.0);
assert_eq!(malformed.surface_rect(), Rect::ZERO);
assert!(!malformed.contains(Vec2::new(1.0, 1.0)));
assert!(topology.hit_shape(rect).corner_radius().is_finite());
assert!(!topology.contains_point(rect, Vec2::new(40.0, 40.0)));
}
#[test]
fn morph_state_exposes_topology_hit_shape() {
let state = MorphProfile::material_default(MorphShape::RoundedCard, MorphShape::Ticket)
.sample(10_000);
let rect = Rect::new(0.0, 0.0, 120.0, 60.0);
assert!(state.contains_point(rect, Vec2::new(60.0, 30.0)));
}
#[test]
fn cache_key_quantizes_malformed_sizes_explicitly() {
let key = MorphCacheKey::new(
MorphShape::RoundedCard,
MorphShape::Capsule,
Vec2::new(f32::INFINITY, f32::NAN),
f32::NAN,
);
assert_eq!(key.width_px, 0);
assert_eq!(key.height_px, 0);
assert_eq!(key.frame_bucket, 0);
}
}