feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)

D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基:
令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖
(cargo tree -p unlucky-design --edges normal 只有它自己一行)。

- 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板;
  tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在
  模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色)
- 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记
  (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色)
- 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体)
- 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与
  surface container 映射、时长/缓动/弹簧(纯函数可采样)
- 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加;
  写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异,
  换算封装成 Icon::stroke_width
- 组件档位表:20 个组件的形状/高程/字体/动效一次取全

TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值,
接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
This commit is contained in:
AskaEth committed 2026-11-01 15:34:04 +08:00
1 parent fef0922073
commit 65ca0ed29a
25 files changed
+4622 -1

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
/// 弹簧参数。
///
/// **口径说明**:Material 的弹簧预置是「按体感标定」的一组 token,官方没有公开统一公式
/// (Android 侧是 `SpringForce` 参数 + 阻尼比)。[`Spring::sample`] 走的是标准二阶系统阶跃响应
/// **闭式解**——数学是真的,参数是我们的标定值(待 M63 视觉规格对齐)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
/// 刚度(越大越快)。
pub stiffness: f32,
/// 阻尼系数(临界阻尼 = 2·√(k·m))。
pub damping: f32,
/// 质量。
pub mass: f32,
}
impl Spring {
/// 构造(`mass` 取 1.0 时 stiffness/damping 就是常见标定值)。
pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
Self {
stiffness,
damping,
mass,
}
}
/// 自然角频率 ωn(rad/s)。
pub fn natural_frequency(self) -> f32 {
(self.stiffness / self.mass).sqrt()
}
/// 阻尼比 ζ(1.0 = 临界阻尼;< 1 会轻微弹过)。
pub fn damping_ratio(self) -> f32 {
let denom = 2.0 * (self.stiffness * self.mass).sqrt();
if denom <= 0.0 {
1.0
} else {
self.damping / denom
}
}
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
pub fn sample(self, t_ms: f32) -> f32 {
if t_ms <= 0.0 {
return 0.0;
}
let t = t_ms / 1000.0;
let wn = self.natural_frequency();
let zeta = self.damping_ratio();
if wn <= 0.0 {
return 1.0;
}
if zeta < 1.0 {
let wd = wn * (1.0 - zeta * zeta).sqrt();
let decay = (-zeta * wn * t).exp();
1.0 - decay * ((wd * t).cos() + (zeta * wn / wd) * (wd * t).sin())
} else if (zeta - 1.0).abs() < 1.0e-4 {
let decay = (-wn * t).exp();
1.0 - decay * (1.0 + wn * t)
} else {
let root = (zeta * zeta - 1.0).sqrt();
let r1 = -wn * (zeta - root);
let r2 = -wn * (zeta + root);
let denom = r2 - r1;
if denom.abs() < f32::EPSILON {
return 1.0;
}
1.0 - (r2 * (r1 * t).exp() - r1 * (r2 * t).exp()) / denom
}
}
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
pub fn settle_ms(self, threshold: f32) -> f32 {
let wn = self.natural_frequency();
let zeta = self.damping_ratio();
if wn <= 0.0 {
return 0.0;
}
let thr = threshold.clamp(1.0e-6, 0.5);
let seconds = if zeta < 1.0 {
let wd = (1.0 - zeta * zeta).sqrt().max(1.0e-4);
-(thr * wd).ln() / (zeta * wn)
} else if (zeta - 1.0).abs() < 1.0e-4 {
-(thr).ln() / wn
} else {
let root = (zeta * zeta - 1.0).sqrt();
let slow = wn * (zeta - root);
-(thr).ln() / slow
};
(seconds * 1000.0).max(0.0)
}
}
/// 空间类快速弹簧(位移/尺寸):紧、几乎不弹。
///
/// TODO(M63): 取值待视觉规格(四组弹簧是我们的临时标定,M63 的动效表落地后对齐)。
pub const SPRING_SPATIAL_FAST: Spring = Spring::new(1400.0, 76.0, 1.0);
/// 空间类默认弹簧(面板、卡片):标准手感。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_SPATIAL_DEFAULT: Spring = Spring::new(800.0, 55.0, 1.0);
/// 效应类快速弹簧(透明度/颜色):不带过冲。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_EFFECTS_FAST: Spring = Spring::new(3800.0, 120.0, 1.0);
/// 效应类默认弹簧。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_EFFECTS_DEFAULT: Spring = Spring::new(1600.0, 80.0, 1.0);
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionScale {
factor: f32,
}
impl MotionScale {
/// 标准(1.0)。
pub const STANDARD: MotionScale = MotionScale { factor: 1.0 };
/// 关闭动效(时长归零;适配层应直接落终态)。
pub const NONE: MotionScale = MotionScale { factor: 0.0 };
/// 半速(弱动效机器/低帧率兜底)。
pub const HALF: MotionScale = MotionScale { factor: 0.5 };
/// 自定义缩放(夹取到 0.0..=1.0;不放大,避免慢到不可用)。
pub fn new(factor: f32) -> Self {
Self {
factor: factor.clamp(0.0, 1.0),
}
}
/// 缩放系数。
pub fn factor(self) -> f32 {
self.factor
}
/// 是否完全关闭(适配层据此跳过动画)。
pub fn is_off(self) -> bool {
self.factor <= 0.0
}
/// 把某一档时长换算成实际毫秒。
pub fn ms(self, token: super::DurationToken) -> f32 {
token.ms() as f32 * self.factor
}
/// 把弹簧的收敛时间换算成实际毫秒。
pub fn spring_ms(self, spring: Spring) -> f32 {
spring.settle_ms(0.001) * self.factor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::DurationToken;
const PRESETS: [Spring; 4] = [
SPRING_SPATIAL_FAST,
SPRING_SPATIAL_DEFAULT,
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
];
#[test]
fn spring_presets_behave() {
for spring in PRESETS {
assert!(spring.damping_ratio() > 0.5, "弹簧应明显欠阻尼/近临界");
assert_eq!(spring.sample(0.0), 0.0);
let settle = spring.settle_ms(0.001);
assert!(
(60.0..1200.0).contains(&settle),
"收敛时间 {settle}ms 不合理"
);
let end = spring.sample(settle);
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
}
}
#[test]
fn critically_damped_spring_does_not_overshoot() {
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
let critical = Spring::new(wn * wn, 2.0 * wn, 1.0);
assert!((critical.damping_ratio() - 1.0).abs() < 1.0e-3);
for step in 1..=40 {
let v = critical.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
}
}
#[test]
fn overdamped_spring_converges_slowly_without_overshoot() {
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
let over = Spring::new(wn * wn, 4.0 * wn, 1.0);
assert!(over.damping_ratio() > 1.5);
let settle = over.settle_ms(0.001);
assert!((over.sample(settle) - 1.0).abs() < 0.01);
for step in 1..=40 {
assert!(over.sample(step as f32 * 10.0) <= 1.0 + 1.0e-3);
}
}
#[test]
fn motion_scale_scales_or_kills_duration() {
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Medium2), 0.0);
assert!(MotionScale::NONE.is_off());
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
assert!(MotionScale::STANDARD.spring_ms(SPRING_SPATIAL_DEFAULT) > 0.0);
}
}