fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口: P2-1 冻结测试补全(变异实测证明原来可静默漂移): - tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family) 与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写 - tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、 16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯 - 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED P2-2 emphasized 口径事实错误(用户点名): - 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组): label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、 title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0 - TypeRole::emphasized() 改为官方表(typography/emphasized.rs); 原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方 - 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、 title-medium 0.2)与 TitleLarge 的 Brand 族归属 - crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap 弹簧改为两套 token 结构(用户追加令): - motion::spring(scheme × category × speed) 二维表 12 格: standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、 effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值 - Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量 中文字重覆盖(用户追加令): - FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给 Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记 component_map 高程按官方回调(P2 并入 finding): - FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时 按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言 P3 四条: - 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma), primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变 - femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门 - 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档 - 图标折算口径订正为「居中描边,视觉外扩 <1px」
This commit is contained in:
1 parent
65ca0ed29a
commit
83e19e58f0
20 files changed
+1367
-234
No files matched your search
@@ -12,8 +12,11 @@
|
||||
mod spring;
|
||||
|
||||
pub use spring::{
|
||||
MotionScale, Spring, SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_SPATIAL_DEFAULT,
|
||||
SPRING_SPATIAL_FAST,
|
||||
spring, MotionScale, MotionSchemeKind, Spring, SpringCategory, SpringSpeed,
|
||||
SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW,
|
||||
SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||
SPRING_EXPRESSIVE_SPATIAL_SLOW, SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
|
||||
SPRING_STANDARD_SPATIAL_SLOW,
|
||||
};
|
||||
|
||||
/// 动效时长档位(M3 动效令牌,单位毫秒)。
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
|
||||
//!
|
||||
//! **来源 A 级**:`androidx.compose.material3.tokens.ExpressiveMotionTokens`(v0_14_0)给出六组
|
||||
//! `Spring{Damping, Stiffness}`——注意 Compose 的 `Damping` 是**阻尼比 ζ**(不是绝对阻尼系数),
|
||||
//! 所以本模块的 [`Spring`] 也按 (stiffness, damping_ratio, mass) 存,两者可直接对照。
|
||||
//! 阶跃响应用标准二阶系统闭式解(欠阻尼 / 临界 / 过阻尼三分支),是纯数学、无状态。
|
||||
|
||||
/// 弹簧参数。
|
||||
///
|
||||
/// **口径说明**:Material 的弹簧预置是「按体感标定」的一组 token,官方没有公开统一公式
|
||||
/// (Android 侧是 `SpringForce` 参数 + 阻尼比)。[`Spring::sample`] 走的是标准二阶系统阶跃响应
|
||||
/// **闭式解**——数学是真的,参数是我们的标定值(待 M63 视觉规格对齐)。
|
||||
/// 弹簧参数(Compose 口径:刚度 + 阻尼比 + 质量)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Spring {
|
||||
/// 刚度(越大越快)。
|
||||
/// 刚度 k(越大越快)。
|
||||
pub stiffness: f32,
|
||||
/// 阻尼系数(临界阻尼 = 2·√(k·m))。
|
||||
pub damping: f32,
|
||||
/// 质量。
|
||||
/// 阻尼比 ζ(1.0 = 临界阻尼,< 1 会轻微弹过)。
|
||||
pub damping_ratio: f32,
|
||||
/// 质量 m。
|
||||
pub mass: f32,
|
||||
}
|
||||
|
||||
impl Spring {
|
||||
/// 构造(`mass` 取 1.0 时 stiffness/damping 就是常见标定值)。
|
||||
pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
|
||||
/// 按 M3 令牌口径构造(刚度 + 阻尼比 + 质量)。
|
||||
pub const fn from_ratio(stiffness: f32, damping_ratio: f32, mass: f32) -> Self {
|
||||
Self {
|
||||
stiffness,
|
||||
damping,
|
||||
damping_ratio,
|
||||
mass,
|
||||
}
|
||||
}
|
||||
@@ -30,14 +31,9 @@ impl Spring {
|
||||
(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
|
||||
}
|
||||
/// 绝对阻尼系数 c = 2·ζ·√(k·m)(需要喂给「阻尼系数」式积分器时用)。
|
||||
pub fn damping_coefficient(self) -> f32 {
|
||||
2.0 * self.damping_ratio * (self.stiffness * self.mass).sqrt()
|
||||
}
|
||||
|
||||
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
|
||||
@@ -47,7 +43,7 @@ impl Spring {
|
||||
}
|
||||
let t = t_ms / 1000.0;
|
||||
let wn = self.natural_frequency();
|
||||
let zeta = self.damping_ratio();
|
||||
let zeta = self.damping_ratio;
|
||||
if wn <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -73,7 +69,7 @@ impl Spring {
|
||||
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
|
||||
pub fn settle_ms(self, threshold: f32) -> f32 {
|
||||
let wn = self.natural_frequency();
|
||||
let zeta = self.damping_ratio();
|
||||
let zeta = self.damping_ratio;
|
||||
if wn <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -92,22 +88,118 @@ impl Spring {
|
||||
}
|
||||
}
|
||||
|
||||
/// 空间类快速弹簧(位移/尺寸):紧、几乎不弹。
|
||||
/// 动效方案(Expressive 把弹簧打包成 standard / expressive **两套**,不是一组)。
|
||||
///
|
||||
/// TODO(M63): 取值待视觉规格(四组弹簧是我们的临时标定,M63 的动效表落地后对齐)。
|
||||
pub const SPRING_SPATIAL_FAST: Spring = Spring::new(1400.0, 76.0, 1.0);
|
||||
/// 空间类默认弹簧(面板、卡片):标准手感。
|
||||
/// - [`MotionSchemeKind::Standard`]:保守、几乎不弹(spatial ζ=0.9);
|
||||
/// - [`MotionSchemeKind::Expressive`]:D26 的视觉基线,回弹明显(spatial ζ=0.6~0.8)。
|
||||
///
|
||||
/// 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);
|
||||
/// `effects`(透明度/颜色类)在两套方案里数值相同——效应类不该有可见回弹。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MotionSchemeKind {
|
||||
/// 标准方案(保守)。
|
||||
Standard,
|
||||
/// Expressive 方案(本项目默认)。
|
||||
Expressive,
|
||||
}
|
||||
|
||||
/// 弹簧类别:空间类管**位移/尺寸**,效应类管**颜色/透明度**。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SpringCategory {
|
||||
/// 位移/尺寸(会产生可见回弹)。
|
||||
Spatial,
|
||||
/// 颜色/透明度(不允许过冲)。
|
||||
Effects,
|
||||
}
|
||||
|
||||
/// 弹簧速度档。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SpringSpeed {
|
||||
/// 快速:hover/press、小元素。
|
||||
Fast,
|
||||
/// 默认:面板、卡片、按下形变。
|
||||
Default,
|
||||
/// 慢速:大位移(>120px 的面板/抽屉)。
|
||||
Slow,
|
||||
}
|
||||
|
||||
/// 二维表查询:`方案 × 类别 × 速度 → 弹簧`(A 级:`StandardMotionTokens` 与
|
||||
/// `ExpressiveMotionTokens`,经 `MotionScheme.standard()/expressive()` 组装)。
|
||||
pub const fn spring(
|
||||
scheme: MotionSchemeKind,
|
||||
category: SpringCategory,
|
||||
speed: SpringSpeed,
|
||||
) -> Spring {
|
||||
use MotionSchemeKind::{Expressive, Standard};
|
||||
use SpringCategory::{Effects, Spatial};
|
||||
use SpringSpeed::{Default, Fast, Slow};
|
||||
match (scheme, category, speed) {
|
||||
(Standard, Spatial, Fast) => Spring::from_ratio(1400.0, 0.9, 1.0),
|
||||
(Standard, Spatial, Default) => Spring::from_ratio(700.0, 0.9, 1.0),
|
||||
(Standard, Spatial, Slow) => Spring::from_ratio(300.0, 0.9, 1.0),
|
||||
(Expressive, Spatial, Fast) => Spring::from_ratio(800.0, 0.6, 1.0),
|
||||
(Expressive, Spatial, Default) => Spring::from_ratio(380.0, 0.8, 1.0),
|
||||
(Expressive, Spatial, Slow) => Spring::from_ratio(200.0, 0.8, 1.0),
|
||||
// effects 两套同值:ζ=1.0(临界阻尼,不过冲)。
|
||||
(_, Effects, Fast) => Spring::from_ratio(3800.0, 1.0, 1.0),
|
||||
(_, Effects, Default) => Spring::from_ratio(1600.0, 1.0, 1.0),
|
||||
(_, Effects, Slow) => Spring::from_ratio(800.0, 1.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 标准方案 · 空间 · 快速。
|
||||
pub const SPRING_STANDARD_SPATIAL_FAST: Spring = spring(
|
||||
MotionSchemeKind::Standard,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Fast,
|
||||
);
|
||||
/// 标准方案 · 空间 · 默认。
|
||||
pub const SPRING_STANDARD_SPATIAL_DEFAULT: Spring = spring(
|
||||
MotionSchemeKind::Standard,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Default,
|
||||
);
|
||||
/// 标准方案 · 空间 · 慢速。
|
||||
pub const SPRING_STANDARD_SPATIAL_SLOW: Spring = spring(
|
||||
MotionSchemeKind::Standard,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Slow,
|
||||
);
|
||||
/// Expressive 方案 · 空间 · 快速(D26 基线的默认选择)。
|
||||
pub const SPRING_EXPRESSIVE_SPATIAL_FAST: Spring = spring(
|
||||
MotionSchemeKind::Expressive,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Fast,
|
||||
);
|
||||
/// Expressive 方案 · 空间 · 默认:面板、卡片、按下形变。
|
||||
pub const SPRING_EXPRESSIVE_SPATIAL_DEFAULT: Spring = spring(
|
||||
MotionSchemeKind::Expressive,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Default,
|
||||
);
|
||||
/// Expressive 方案 · 空间 · 慢速:大位移(>120px)。
|
||||
pub const SPRING_EXPRESSIVE_SPATIAL_SLOW: Spring = spring(
|
||||
MotionSchemeKind::Expressive,
|
||||
SpringCategory::Spatial,
|
||||
SpringSpeed::Slow,
|
||||
);
|
||||
/// 效应类 · 快速(两套方案同值):hover/press 颜色。
|
||||
pub const SPRING_EFFECTS_FAST: Spring = spring(
|
||||
MotionSchemeKind::Expressive,
|
||||
SpringCategory::Effects,
|
||||
SpringSpeed::Fast,
|
||||
);
|
||||
/// 效应类 · 默认(两套方案同值)。
|
||||
pub const SPRING_EFFECTS_DEFAULT: Spring = spring(
|
||||
MotionSchemeKind::Expressive,
|
||||
SpringCategory::Effects,
|
||||
SpringSpeed::Default,
|
||||
);
|
||||
/// 效应类 · 慢速(两套方案同值)。
|
||||
pub const SPRING_EFFECTS_SLOW: Spring = spring(
|
||||
MotionSchemeKind::Expressive,
|
||||
SpringCategory::Effects,
|
||||
SpringSpeed::Slow,
|
||||
);
|
||||
|
||||
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -156,33 +248,100 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::motion::DurationToken;
|
||||
|
||||
const PRESETS: [Spring; 4] = [
|
||||
SPRING_SPATIAL_FAST,
|
||||
SPRING_SPATIAL_DEFAULT,
|
||||
const PRESETS: [Spring; 9] = [
|
||||
SPRING_STANDARD_SPATIAL_FAST,
|
||||
SPRING_STANDARD_SPATIAL_DEFAULT,
|
||||
SPRING_STANDARD_SPATIAL_SLOW,
|
||||
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||
SPRING_EXPRESSIVE_SPATIAL_SLOW,
|
||||
SPRING_EFFECTS_FAST,
|
||||
SPRING_EFFECTS_DEFAULT,
|
||||
SPRING_EFFECTS_SLOW,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_cell_of_the_two_dimensional_table_resolves() {
|
||||
for scheme in [MotionSchemeKind::Standard, MotionSchemeKind::Expressive] {
|
||||
for category in [SpringCategory::Spatial, SpringCategory::Effects] {
|
||||
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
|
||||
let spring = spring(scheme, category, speed);
|
||||
assert!(spring.stiffness > 0.0);
|
||||
assert!(spring.damping_ratio > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// effects 两套方案同值(效应类不该有可见回弹差异)。
|
||||
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
|
||||
assert_eq!(
|
||||
spring(MotionSchemeKind::Standard, SpringCategory::Effects, speed),
|
||||
spring(MotionSchemeKind::Expressive, SpringCategory::Effects, speed)
|
||||
);
|
||||
}
|
||||
// expressive 的空间类更弹(阻尼比更低)。
|
||||
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
|
||||
assert!(
|
||||
spring(MotionSchemeKind::Expressive, SpringCategory::Spatial, speed).damping_ratio
|
||||
< spring(MotionSchemeKind::Standard, SpringCategory::Spatial, speed)
|
||||
.damping_ratio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 不合理"
|
||||
(60.0..1500.0).contains(&settle),
|
||||
"收敛时间 {settle}ms 不合理(k={} ζ={})",
|
||||
spring.stiffness,
|
||||
spring.damping_ratio
|
||||
);
|
||||
let end = spring.sample(settle);
|
||||
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_presets_are_critically_damped_and_never_overshoot() {
|
||||
for spring in [
|
||||
SPRING_EFFECTS_FAST,
|
||||
SPRING_EFFECTS_DEFAULT,
|
||||
SPRING_EFFECTS_SLOW,
|
||||
] {
|
||||
assert!((spring.damping_ratio - 1.0).abs() < 1.0e-6);
|
||||
for step in 1..=60 {
|
||||
let v = spring.sample(step as f32 * 5.0);
|
||||
assert!(v <= 1.0 + 1.0e-3, "效应类弹簧不应过冲:{v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_presets_overdamp_into_settle_without_wild_overshoot() {
|
||||
for spring in [
|
||||
SPRING_EXPRESSIVE_SPATIAL_FAST,
|
||||
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
|
||||
SPRING_EXPRESSIVE_SPATIAL_SLOW,
|
||||
] {
|
||||
assert!(
|
||||
spring.damping_ratio < 1.0,
|
||||
"空间类弹簧欠阻尼(允许轻微过冲)"
|
||||
);
|
||||
let mut peak: f32 = 0.0;
|
||||
for step in 1..=200 {
|
||||
peak = peak.max(spring.sample(step as f32 * 2.0));
|
||||
}
|
||||
assert!(peak < 1.15, "过冲 {peak} 太大");
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
|
||||
let critical = Spring::from_ratio(wn * wn, 1.0, 1.0);
|
||||
for step in 1..=40 {
|
||||
let v = critical.sample(step as f32 * 5.0);
|
||||
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
|
||||
@@ -191,9 +350,8 @@ mod tests {
|
||||
|
||||
#[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 wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
|
||||
let over = Spring::from_ratio(wn * wn, 2.0, 1.0);
|
||||
let settle = over.settle_ms(0.001);
|
||||
assert!((over.sample(settle) - 1.0).abs() < 0.01);
|
||||
for step in 1..=40 {
|
||||
@@ -201,6 +359,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damping_coefficient_matches_ratio() {
|
||||
let spring = SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
|
||||
let c = spring.damping_coefficient();
|
||||
let zeta = c / (2.0 * (spring.stiffness * spring.mass).sqrt());
|
||||
assert!((zeta - spring.damping_ratio).abs() < 1.0e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_scale_scales_or_kills_duration() {
|
||||
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
|
||||
@@ -209,6 +375,6 @@ mod tests {
|
||||
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);
|
||||
assert!(MotionScale::STANDARD.spring_ms(SPRING_EXPRESSIVE_SPATIAL_DEFAULT) > 0.0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user