Files
Unlucky-Launcher/crates/design/tests/official_accent.rs
T
AskaEth 29f65b8e6f feat(design): M65 真 HCT 色彩 —— CAM16 + ColorSpec2021 + 官方逐角色对齐
把 M61 的「OKLCh 近似 CAM16 + gamut 硬裁」换成官方的 HCT 算法,与
material-color-utilities(Apache-2.0)逐角色对上:baseline 49 角色 ×
light/dark、8 个种子 × 12 个强调族角色 × 2 模式,**实测每通道误差 0**
(判据写入测试:≤1)。

实现(逐函数对照官方 Java 重写为 Rust,f64 内部精度):
- color/cam16.rs + viewing_conditions.rs:CAM16 色相/彩度(D65、200 lux、
  背景 L* 50),默认观察条件的中间量与 Java 实测逐位对齐
- color/hct_solver.rs + critical_planes.rs:官方 gamut 映射(Newton 迭代 +
  色调平面上的色相二分,255 项关键平面表原样搬运),只压彩度不硬裁
- color/hct.rs:HCT 坐标(hue/chroma/tone),公开给工具与测试
- color/space.rs:官方 ColorUtils 的 sRGB↔线性↔XYZ↔L* 换算(矩阵、阈值、
  取整口径一致)
- color/palettes.rs:Tonal Spot 官方彩度常数 36/16/24/6/8,error 25°/84
- color/scheme.rs:tone 表换 ColorSpec2021(on-*container 10→30、
  background 99/10→98/6)

两个旧缺陷关门:
- primary-container 与 secondary-container 不再撞色(彩度 36 vs 16,
  tone 90 装得下,无需「共享基准压彩度」补丁);reference_chroma /
  tone_scaled 随之删除
- 强调色色相不再被 gamut 硬裁(只压彩度,实测偏移 <1°)

对齐证据:tests/official_baseline.rs(98 项 + 12 个 HCT 探针)、
tests/official_accent.rs(192 项)。期望值是把官方 Java 实现本机编译运行
的输出抄本,并用 Flutter 的 ColorScheme.fromSeed 用例交叉验证
(#00FF00 → #406836、#000000 → #8C4A60、#FFFFFF → #006874 一致)。

诚实口径:只做 Tonal Spot 变体 + contrastLevel 0;对比度三档、其他变体、
壁纸多色取色、非默认观察条件都在模块文档的 gap 表里登记。
注:token_pins 的 tone 值变化来自官方算法(ColorSpec2021),不是为过测试而改。

NOTICE 增「Google Material Color Utilities」归属(Apache-2.0 要求,
含逐文件映射与对齐方法)。
2026-11-01 16:40:36 +08:00

308 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 自定义强调色 × 官方算法的**逐角色对齐**(`Scheme::from_accent`)。
//!
//! 期望值来自官方 Java 实现:`SchemeTonalSpot(seed, isDark, 0, SPEC_2021)` 的强调族角色。
//! 判据:每通道误差 ≤1(见 `official_baseline.rs` 的说明)。
//!
//! 为什么只比强调族(primary/secondary/tertiary 共 12 个角色):本项目的产品语义是
//! 「换强调色不动底色」——中性表面与 error 刻意留在 baseline(见 `Palettes::with_accent`),
//! 而官方 `from_seed` 会把中性色也染上种子色相。中性派生的正确性由本文件末尾的
//! `from_seed_*` 用例单独验证(那一处必须与官方完全一致)。
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
/// 每通道允许的最大误差。
const CHANNEL_TOLERANCE: i16 = 1;
/// 一条官方期望值:角色 + light 色值 + dark 色值。
type RoleRow = (Role, &'static str, &'static str);
/// 一个种子 + 它 12 个强调族角色的官方色值。
type SeedRows = (&'static str, [RoleRow; 12]);
/// 八个种子:官方基线紫、纯红/绿/蓝、低彩度灰蓝、高彩度紫,外加纯黑与纯白两个边缘用例
/// (无彩色种子的色相是病态的,官方的处理方式见 `Hct`:黑 → 色相 0、白 → 色相 209.5)。
/// 每个种子带 12 个强调族角色的官方色值(light, dark)。
const ACCENT_SEEDS: [SeedRows; 8] = [
(
"#6750A4",
[
(Role::Primary, "#65558F", "#CFBDFE"),
(Role::OnPrimary, "#FFFFFF", "#36275D"),
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
(Role::Secondary, "#625B71", "#CBC2DB"),
(Role::OnSecondary, "#FFFFFF", "#332D41"),
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
(Role::Tertiary, "#7E5260", "#EFB8C8"),
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
],
),
(
"#FF0000",
[
(Role::Primary, "#904B40", "#FFB4A8"),
(Role::OnPrimary, "#FFFFFF", "#561E16"),
(Role::PrimaryContainer, "#FFDAD4", "#73342A"),
(Role::OnPrimaryContainer, "#73342A", "#FFDAD4"),
(Role::Secondary, "#775651", "#E7BDB6"),
(Role::OnSecondary, "#FFFFFF", "#442925"),
(Role::SecondaryContainer, "#FFDAD4", "#5D3F3B"),
(Role::OnSecondaryContainer, "#5D3F3B", "#FFDAD4"),
(Role::Tertiary, "#705C2E", "#DEC48C"),
(Role::OnTertiary, "#FFFFFF", "#3E2E04"),
(Role::TertiaryContainer, "#FBDFA6", "#564419"),
(Role::OnTertiaryContainer, "#564419", "#FBDFA6"),
],
),
(
"#00FF00",
[
(Role::Primary, "#406836", "#A5D395"),
(Role::OnPrimary, "#FFFFFF", "#11380B"),
(Role::PrimaryContainer, "#C0EFB0", "#285020"),
(Role::OnPrimaryContainer, "#285020", "#C0EFB0"),
(Role::Secondary, "#54634D", "#BBCBB2"),
(Role::OnSecondary, "#FFFFFF", "#263422"),
(Role::SecondaryContainer, "#D7E8CD", "#3C4B37"),
(Role::OnSecondaryContainer, "#3C4B37", "#D7E8CD"),
(Role::Tertiary, "#386568", "#A0CFD2"),
(Role::OnTertiary, "#FFFFFF", "#003739"),
(Role::TertiaryContainer, "#BCEBEE", "#1E4D50"),
(Role::OnTertiaryContainer, "#1E4D50", "#BCEBEE"),
],
),
(
"#0000FF",
[
(Role::Primary, "#555992", "#BEC2FF"),
(Role::OnPrimary, "#FFFFFF", "#272B60"),
(Role::PrimaryContainer, "#E0E0FF", "#3E4278"),
(Role::OnPrimaryContainer, "#3E4278", "#E0E0FF"),
(Role::Secondary, "#5C5D72", "#C5C4DD"),
(Role::OnSecondary, "#FFFFFF", "#2E2F42"),
(Role::SecondaryContainer, "#E1E0F9", "#444559"),
(Role::OnSecondaryContainer, "#444559", "#E1E0F9"),
(Role::Tertiary, "#78536B", "#E8B9D5"),
(Role::OnTertiary, "#FFFFFF", "#46263B"),
(Role::TertiaryContainer, "#FFD8EE", "#5E3C52"),
(Role::OnTertiaryContainer, "#5E3C52", "#FFD8EE"),
],
),
(
"#6E7C8A",
[
(Role::Primary, "#2B638B", "#98CCF9"),
(Role::OnPrimary, "#FFFFFF", "#003350"),
(Role::PrimaryContainer, "#CCE5FF", "#044B71"),
(Role::OnPrimaryContainer, "#044B71", "#CCE5FF"),
(Role::Secondary, "#50606F", "#B8C8D9"),
(Role::OnSecondary, "#FFFFFF", "#23323F"),
(Role::SecondaryContainer, "#D4E4F6", "#394856"),
(Role::OnSecondaryContainer, "#394856", "#D4E4F6"),
(Role::Tertiary, "#66587B", "#D1BFE7"),
(Role::OnTertiary, "#FFFFFF", "#372A4A"),
(Role::TertiaryContainer, "#ECDCFF", "#4E4162"),
(Role::OnTertiaryContainer, "#4E4162", "#ECDCFF"),
],
),
(
"#D000FF",
[
(Role::Primary, "#794F81", "#E9B5EF"),
(Role::OnPrimary, "#FFFFFF", "#472150"),
(Role::PrimaryContainer, "#FDD6FF", "#603768"),
(Role::OnPrimaryContainer, "#603768", "#FDD6FF"),
(Role::Secondary, "#6A596C", "#D6C0D6"),
(Role::OnSecondary, "#FFFFFF", "#3A2B3C"),
(Role::SecondaryContainer, "#F3DBF2", "#524153"),
(Role::OnSecondaryContainer, "#524153", "#F3DBF2"),
(Role::Tertiary, "#82524C", "#F5B7B0"),
(Role::OnTertiary, "#FFFFFF", "#4C2521"),
(Role::TertiaryContainer, "#FFDAD6", "#673B36"),
(Role::OnTertiaryContainer, "#673B36", "#FFDAD6"),
],
),
(
"#000000",
[
(Role::Primary, "#8C4A60", "#FFB1C8"),
(Role::OnPrimary, "#FFFFFF", "#541D32"),
(Role::PrimaryContainer, "#FFD9E2", "#703348"),
(Role::OnPrimaryContainer, "#703348", "#FFD9E2"),
(Role::Secondary, "#74565F", "#E3BDC6"),
(Role::OnSecondary, "#FFFFFF", "#422931"),
(Role::SecondaryContainer, "#FFD9E2", "#5A3F47"),
(Role::OnSecondaryContainer, "#5A3F47", "#FFD9E2"),
(Role::Tertiary, "#7C5635", "#EFBD94"),
(Role::OnTertiary, "#FFFFFF", "#48290B"),
(Role::TertiaryContainer, "#FFDCC1", "#623F20"),
(Role::OnTertiaryContainer, "#623F20", "#FFDCC1"),
],
),
(
"#FFFFFF",
[
(Role::Primary, "#006874", "#82D3E0"),
(Role::OnPrimary, "#FFFFFF", "#00363D"),
(Role::PrimaryContainer, "#9EEFFD", "#004F58"),
(Role::OnPrimaryContainer, "#004F58", "#9EEFFD"),
(Role::Secondary, "#4A6267", "#B1CBD0"),
(Role::OnSecondary, "#FFFFFF", "#1C3438"),
(Role::SecondaryContainer, "#CDE7EC", "#334B4F"),
(Role::OnSecondaryContainer, "#334B4F", "#CDE7EC"),
(Role::Tertiary, "#525E7D", "#BAC6EA"),
(Role::OnTertiary, "#FFFFFF", "#24304D"),
(Role::TertiaryContainer, "#DAE2FF", "#3B4664"),
(Role::OnTertiaryContainer, "#3B4664", "#DAE2FF"),
],
),
];
/// 官方 `SchemeTonalSpot(seed)` 的中性表面(surface 与 surface-container-highest,light/dark)。
const SEED_NEUTRALS: [(&str, &str, &str, &str, &str); 8] = [
("#6750A4", "#FDF7FF", "#141218", "#E6E0E9", "#36343A"),
("#FF0000", "#FFF8F6", "#1A1110", "#F1DFDC", "#3D3230"),
("#00FF00", "#F8FBF1", "#11140F", "#E1E4DA", "#32362F"),
("#0000FF", "#FBF8FF", "#131318", "#E4E1E9", "#34343A"),
("#6E7C8A", "#F7F9FF", "#101418", "#E0E2E8", "#313539"),
("#D000FF", "#FFF7FA", "#171217", "#EAE0E7", "#393338"),
("#000000", "#FFF8F8", "#191113", "#EFDFE1", "#3C3235"),
("#FFFFFF", "#F5FAFB", "#0E1415", "#DEE3E5", "#303637"),
];
fn worst_channel(got: Color, want: Color) -> i16 {
[(got.r, want.r), (got.g, want.g), (got.b, want.b)]
.iter()
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
.max()
.unwrap_or(0)
}
#[test]
fn accent_schemes_match_official_on_the_accent_families() {
let mut worst = 0;
let mut off_by_one = 0;
let mut checked = 0;
let mut failures = Vec::new();
for (seed_hex, roles) in ACCENT_SEEDS {
let accent = Color::from_hex(seed_hex).expect("合法 hex");
for (flavor, is_dark) in [(SchemeFlavor::Light, false), (SchemeFlavor::Dark, true)] {
let scheme = Scheme::from_accent(flavor, accent);
for (role, want_light, want_dark) in roles {
let want_hex = if is_dark { want_dark } else { want_light };
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
let delta = worst_channel(got, want);
worst = worst.max(delta);
checked += 1;
if delta > 0 {
off_by_one += 1;
}
if delta > CHANNEL_TOLERANCE {
failures.push(format!(
"{seed_hex}/{}/{} 是 {},官方 {want_hex}(差 {delta})",
flavor.name(),
role.name(),
got.to_hex()
));
}
}
}
}
assert!(
failures.is_empty(),
"{} 项强调色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
failures.len(),
failures.join("\n")
);
println!("强调色对齐:{checked} 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
}
#[test]
fn accent_scheme_keeps_baseline_surfaces() {
// 产品语义:换强调色不许让底色变脏(这是 `from_accent` 与官方 `from_seed` 的唯一差别)。
let base = Scheme::baseline(SchemeFlavor::Light);
for (seed_hex, _) in ACCENT_SEEDS {
let accent = Color::from_hex(seed_hex).expect("合法 hex");
let custom = Scheme::from_accent(SchemeFlavor::Light, accent);
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
assert_eq!(custom.get(Role::Outline), base.get(Role::Outline));
assert_eq!(custom.get(Role::Error), base.get(Role::Error));
}
}
#[test]
fn from_seed_matches_official_neutrals() {
// 全量 `from_seed` 的中性派生必须与官方一致(否则「整窗染色」的色相就是错的)。
for (seed_hex, light_surface, dark_surface, light_highest, dark_highest) in SEED_NEUTRALS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
for (flavor, want_surface, want_highest) in [
(SchemeFlavor::Light, light_surface, light_highest),
(SchemeFlavor::Dark, dark_surface, dark_highest),
] {
let scheme = Scheme::from_seed(flavor, seed);
for (role, want_hex) in [
(Role::Surface, want_surface),
(Role::SurfaceContainerHighest, want_highest),
] {
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
assert!(
worst_channel(got, want) <= CHANNEL_TOLERANCE,
"{seed_hex}/{}/{} 是 {},官方 {want_hex}",
flavor.name(),
role.name(),
got.to_hex()
);
}
}
}
}
#[test]
fn from_seed_and_from_accent_agree_on_the_accent_families() {
// 两个入口的强调族应当逐像素相同:换种子时中性色不同,强调色只取决于种子色相。
for (seed_hex, _) in ACCENT_SEEDS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let by_seed = Scheme::from_seed(flavor, seed);
let by_accent = Scheme::from_accent(flavor, seed);
for role in [
Role::Primary,
Role::OnPrimary,
Role::PrimaryContainer,
Role::OnPrimaryContainer,
Role::Secondary,
Role::SecondaryContainer,
Role::Tertiary,
Role::TertiaryContainer,
] {
assert_eq!(
by_seed.get(role),
by_accent.get(role),
"{seed_hex}/{} 的 {} 两个入口不一致",
flavor.name(),
role.name()
);
}
}
}
}
#[test]
fn accent_hue_is_not_clipped_away() {
// 旧缺陷②:强调色色相被 gamut 硬裁导致偏移。现在色相由 HCT 保住(只压彩度)。
use unlucky_design::color::Hct;
for (seed_hex, _) in ACCENT_SEEDS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
let seed_hue = Hct::from_color(seed).hue;
let primary = Scheme::from_accent(SchemeFlavor::Light, seed).get(Role::Primary);
let got = Hct::from_color(primary).hue;
let delta = (got - seed_hue).abs().min(360.0 - (got - seed_hue).abs());
assert!(delta < 1.0, "{seed_hex} 的 primary 色相偏了 {delta}°");
}
}