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 要求, 含逐文件映射与对齐方法)。
This commit is contained in:
1 parent
958ced2d7e
commit
29f65b8e6f
16 files changed
+1791
-378
No files matched your search
@@ -18,13 +18,37 @@ Copyright 2026 AskaEth (YeiJ)
|
||||
|
||||
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
|
||||
|
||||
2. Rust crates 生态
|
||||
2. Google Material Color Utilities(https://github.com/material-foundation/material-color-utilities)
|
||||
|
||||
本产品的色彩令牌(crates/design 的 color 模块)是 Material 3 色彩算法(HCT 色彩空间 +
|
||||
ColorSpec2021 调色板/角色映射)的 **Rust 重写移植**,依据 Apache License 2.0 使用,未把其任何语言的源码作为依赖
|
||||
引入;除下面注明的那张生成常数表外,也没有复制其代码文件。
|
||||
|
||||
实际移植的部分(均为纯算法,逐函数对照后以 Rust 重写):
|
||||
|
||||
- `hct/Cam16.java`、`hct/ViewingConditions.java`、`hct/Hct.java` → `crates/design/src/color/cam16.rs`、
|
||||
`viewing_conditions.rs`、`hct.rs`(CAM16 色相/彩度 + CIELAB `L*` 亮度)
|
||||
- `hct/HctSolver.java` → `crates/design/src/color/hct_solver.rs` + `critical_planes.rs`
|
||||
(色域映射求解器;其中 255 项 `CRITICAL_PLANES` 常数表是原样搬运的生成数据)
|
||||
- `utils/ColorUtils.java`、`utils/MathUtils.java` → `crates/design/src/color/space.rs`
|
||||
- `palettes/TonalPalette.java`、`dynamiccolor/ColorSpec2021.java`、`dynamiccolor/DynamicScheme.java`
|
||||
→ `crates/design/src/color/tonal.rs`、`palettes.rs`、`scheme.rs`(Tonal Spot 变体的彩度常数与
|
||||
49 个角色的 tone 映射)
|
||||
|
||||
对齐验证:`crates/design/tests/official_baseline.rs` 与 `tests/official_accent.rs` 的期望色值,
|
||||
是把上述官方 Java 实现(Apache-2.0)在本机编译运行后输出的结果抄本——242 项 × 逐通道
|
||||
比对,误差上限 1(实测为 0)。
|
||||
|
||||
Apache License 2.0 全文见 https://www.apache.org/licenses/LICENSE-2.0
|
||||
上游版权声明:Copyright Google LLC。
|
||||
|
||||
3. Rust crates 生态
|
||||
|
||||
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
|
||||
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
|
||||
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
|
||||
|
||||
3. 参考实现致谢
|
||||
4. 参考实现致谢
|
||||
|
||||
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
|
||||
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
|
||||
@@ -0,0 +1,120 @@
|
||||
//! CAM16 正向换算:sRGB 色值 → CAM16 的 **hue**(色相角)与 **chroma**(彩度)。
|
||||
//!
|
||||
//! HCT = **H**ue / **C**hroma from CAM16 + **T**one from `L*`。这里只实现 M3 用到的方向
|
||||
//! (色值 → hue/chroma);反向(hue/chroma/tone → 色值)在 [`super::hct_solver`] 里按官方
|
||||
//! 求解器内联实现——两边如果各写一份,数值会对不上。
|
||||
//!
|
||||
//! 照搬 `java/hct/Cam16.java`(Apache-2.0,见 `NOTICE`),逐行对应,包括 `Math.signum` 的
|
||||
//! 「零返回 0」这种 Java 语义差别(见 [`space::signum`])。
|
||||
|
||||
use super::space::{self, signum};
|
||||
use super::viewing_conditions::{default_conditions, ViewingConditions};
|
||||
use super::Color;
|
||||
|
||||
/// XYZ → CAM16 的「锥体响应」矩阵。
|
||||
pub(crate) const XYZ_TO_CAM16_RGB: [[f64; 3]; 3] = [
|
||||
[0.401_288, 0.650_173, -0.051_461],
|
||||
[-0.250_268, 1.204_414, 0.045_854],
|
||||
[-0.002_079, 0.048_952, 0.953_127],
|
||||
];
|
||||
|
||||
/// CAM16 里 M3 真正用到的两个坐标(`J` 明度不参与 HCT,HCT 的 tone 走 `L*`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Cam16 {
|
||||
/// 色相角(度,`0..360`)。
|
||||
pub(crate) hue: f64,
|
||||
/// 彩度(无上限;sRGB 立方体内最大值约 130)。
|
||||
pub(crate) chroma: f64,
|
||||
}
|
||||
|
||||
impl Cam16 {
|
||||
/// 从色值取 CAM16 色相/彩度(默认 sRGB 观察条件)。
|
||||
pub(crate) fn from_color(color: Color) -> Self {
|
||||
Self::from_xyz(space::xyz_from_color(color), default_conditions())
|
||||
}
|
||||
|
||||
/// 从 XYZ 取 CAM16 色相/彩度。
|
||||
pub(crate) fn from_xyz(xyz: [f64; 3], vc: &ViewingConditions) -> Self {
|
||||
let [r_t, g_t, b_t] = space::matrix_multiply(xyz, XYZ_TO_CAM16_RGB);
|
||||
|
||||
// 折扣光源(色适应)
|
||||
let r_d = vc.rgb_d[0] * r_t;
|
||||
let g_d = vc.rgb_d[1] * g_t;
|
||||
let b_d = vc.rgb_d[2] * b_t;
|
||||
|
||||
// 非线性压缩(视觉响应)
|
||||
let r_af = (vc.fl * r_d.abs() / 100.0).powf(0.42);
|
||||
let g_af = (vc.fl * g_d.abs() / 100.0).powf(0.42);
|
||||
let b_af = (vc.fl * b_d.abs() / 100.0).powf(0.42);
|
||||
let r_a = signum(r_d) * 400.0 * r_af / (r_af + 27.13);
|
||||
let g_a = signum(g_d) * 400.0 * g_af / (g_af + 27.13);
|
||||
let b_a = signum(b_d) * 400.0 * b_af / (b_af + 27.13);
|
||||
|
||||
// 红绿 / 黄蓝对立通道
|
||||
let a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0;
|
||||
let b = (r_a + g_a - 2.0 * b_a) / 9.0;
|
||||
|
||||
// 辅助量
|
||||
let u = (20.0 * r_a + 20.0 * g_a + 21.0 * b_a) / 20.0;
|
||||
let p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0;
|
||||
|
||||
let hue = space::sanitize_degrees(b.atan2(a).to_degrees());
|
||||
let ac = p2 * vc.nbb;
|
||||
let j = 100.0 * (ac / vc.aw).powf(vc.c * vc.z);
|
||||
|
||||
// 彩度:eHue 的 20.14° 断点是官方原样(把橙红段挪到 0 附近做插值)
|
||||
let hue_prime = if hue < 20.14 { hue + 360.0 } else { hue };
|
||||
let e_hue = 0.25 * ((hue_prime.to_radians() + 2.0).cos() + 3.8);
|
||||
let p1 = 50_000.0 / 13.0 * e_hue * vc.nc * vc.ncb;
|
||||
let t = p1 * a.hypot(b) / (u + 0.305);
|
||||
let alpha = (1.64 - 0.29_f64.powf(vc.n)).powf(0.73) * t.powf(0.9);
|
||||
let chroma = alpha * (j / 100.0).sqrt();
|
||||
|
||||
Self { hue, chroma }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn primaries_have_the_official_hue_angles() {
|
||||
// 官方 material-color-utilities 对纯红/绿/蓝解出的 CAM16 色相(Java 实测)。
|
||||
let cases = [
|
||||
(Color::rgb(255, 0, 0), 27.408_225, 113.357_887),
|
||||
(Color::rgb(0, 255, 0), 142.139_894, 108.410_061),
|
||||
(Color::rgb(0, 0, 255), 282.788_18, 87.230_694),
|
||||
];
|
||||
for (color, hue, chroma) in cases {
|
||||
let cam = Cam16::from_color(color);
|
||||
assert!(
|
||||
(cam.hue - hue).abs() < 1.0e-4,
|
||||
"{} 的 hue 是 {},官方 {hue}",
|
||||
color.to_hex(),
|
||||
cam.hue
|
||||
);
|
||||
assert!(
|
||||
(cam.chroma - chroma).abs() < 1.0e-4,
|
||||
"{} 的 chroma 是 {},官方 {chroma}",
|
||||
color.to_hex(),
|
||||
cam.chroma
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_has_near_zero_chroma() {
|
||||
for value in [16_u8, 64, 128, 200] {
|
||||
let cam = Cam16::from_color(Color::rgb(value, value, value));
|
||||
assert!(cam.chroma < 2.5, "灰 {value} 的彩度 {}", cam.chroma);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_is_finite() {
|
||||
let cam = Cam16::from_color(Color::BLACK);
|
||||
assert!(cam.hue.is_finite() && cam.chroma.is_finite());
|
||||
assert_eq!(cam.chroma, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! 官方 `HctSolver.CRITICAL_PLANES` 表(255 项,**原样搬运**)。
|
||||
//!
|
||||
//! 这不是可以推导的常数集:它是 HCT 求解器在 sRGB 立方体上按 `Y` 找边界、再做色相二分时的
|
||||
//! 「关键平面」索引表,由 Google 的脚本一次性生成后冻结。逐值复制是唯一忠实的做法——
|
||||
//! 表错了,极端 tone(深色/浅色端)的颜色会偏一两个通道。
|
||||
//!
|
||||
//! 来源:`material-color-utilities/java/hct/HctSolver.java`(Apache-2.0),见仓库根 `NOTICE`。
|
||||
|
||||
/// sRGB 立方体上按 `Y` 递增排序的关键平面(线性 RGB 0..=100 标度)。
|
||||
///
|
||||
/// 手写密度由 `#[rustfmt::skip]` 保住:这张表是数据,不是代码,一列一个数会撑到 266 行。
|
||||
#[rustfmt::skip]
|
||||
pub(crate) const CRITICAL_PLANES: [f64; 255] = [
|
||||
0.015176349177441876, 0.045529047532325624, 0.07588174588720938, 0.10623444424209313,
|
||||
0.13658714259697685, 0.16693984095186062, 0.19729253930674434, 0.2276452376616281,
|
||||
0.2579979360165119, 0.28835063437139563, 0.3188300904430532, 0.350925934958123,
|
||||
0.3848314933096426, 0.42057480301049466, 0.458183274052838, 0.4976837250274023,
|
||||
0.5391024159806381, 0.5824650784040898, 0.6277969426914107, 0.6751227633498623,
|
||||
0.7244668422128921, 0.775853049866786, 0.829304845476233, 0.8848452951698498,
|
||||
0.942497089126609, 1.0022825574869039, 1.0642236851973577, 1.1283421258858297,
|
||||
1.1946592148522128, 1.2631959812511864, 1.3339731595349034, 1.407011200216447,
|
||||
1.4823302800086415, 1.5599503113873272, 1.6398909516233677, 1.7221716113234105,
|
||||
1.8068114625156377, 1.8938294463134073, 1.9832442801866852, 2.075074464868551,
|
||||
2.1693382909216234, 2.2660538449872063, 2.36523901573795, 2.4669114995532007,
|
||||
2.5710888059345764, 2.6777882626779785, 2.7870270208169257, 2.898822059350997,
|
||||
3.0131901897720907, 3.1301480604002863, 3.2497121605402226, 3.3718988244681087,
|
||||
3.4967242352587946, 3.624204428461639, 3.754355295633311, 3.887192587735158,
|
||||
4.022731918402185, 4.160988767090289, 4.301978482107941, 4.445716283538092,
|
||||
4.592217266055746, 4.741496401646282, 4.893568542229298, 5.048448422192488,
|
||||
5.20615066083972, 5.3666897647573375, 5.5300801301023865, 5.696336044816294,
|
||||
5.865471690767354, 6.037501145825082, 6.212438385869475, 6.390297286737924,
|
||||
6.571091626112461, 6.7548350853498045, 6.941541251256611, 7.131223617812143,
|
||||
7.323895587840543, 7.5195704746346665, 7.7182615035334345, 7.919981813454504,
|
||||
8.124744458384042, 8.332562408825165, 8.543448553206703, 8.757415699253682,
|
||||
8.974476575321063, 9.194643831691977, 9.417930041841839, 9.644347703669503,
|
||||
9.873909240696694, 10.106627003236781, 10.342513269534024, 10.58158024687427,
|
||||
10.8238400726681, 11.069304815507364, 11.317986476196008, 11.569896988756009,
|
||||
11.825048221409341, 12.083451977536606, 12.345119996613247, 12.610063955123938,
|
||||
12.878295467455942, 13.149826086772048, 13.42466730586372, 13.702830557985108,
|
||||
13.984327217668513, 14.269168601521828, 14.55736596900856, 14.848930523210871,
|
||||
15.143873411576273, 15.44220572664832, 15.743938506781891, 16.04908273684337,
|
||||
16.35764934889634, 16.66964922287304, 16.985093187232053, 17.30399201960269,
|
||||
17.62635644741625, 17.95219714852476, 18.281524751807332, 18.614349837764564,
|
||||
18.95068293910138, 19.290534541298456, 19.633915083172692, 19.98083495742689,
|
||||
20.331304511189067, 20.685334046541502, 21.042933821039977, 21.404114048223256,
|
||||
21.76888489811322, 22.137256497705877, 22.50923893145328, 22.884842241736916,
|
||||
23.264076429332462, 23.6469514538663, 24.033477234264016, 24.42366364919083,
|
||||
24.817520537484558, 25.21505769858089, 25.61628489293138, 26.021211842414342,
|
||||
26.429848230738664, 26.842203703840827, 27.258287870275353, 27.678110301598522,
|
||||
28.10168053274597, 28.529008062403893, 28.96010235337422, 29.39497283293396,
|
||||
29.83362889318845, 30.276079891419332, 30.722335150426627, 31.172403958865512,
|
||||
31.62629557157785, 32.08401920991837, 32.54558406207592, 33.010999283389665,
|
||||
33.4802739966603, 33.953417292456834, 34.430438229418264, 34.911345834551085,
|
||||
35.39614910352207, 35.88485700094671, 36.37747846067349, 36.87402238606382,
|
||||
37.37449765026789, 37.87891309649659, 38.38727753828926, 38.89959975977785,
|
||||
39.41588851594697, 39.93615253289054, 40.460400508064545, 40.98864111053629,
|
||||
41.520882981230194, 42.05713473317016, 42.597404951718396, 43.141702194811224,
|
||||
43.6900349931913, 44.24241185063697, 44.798841244188324, 45.35933162437017,
|
||||
45.92389141541209, 46.49252901546552, 47.065252796817916, 47.64207110610409,
|
||||
48.22299226451468, 48.808024568002054, 49.3971762874833, 49.9904556690408,
|
||||
50.587870934119984, 51.189430279724725, 51.79514187861014, 52.40501387947288,
|
||||
53.0190544071392, 53.637271562750364, 54.259673423945976, 54.88626804504493,
|
||||
55.517063457223934, 56.15206766869424, 56.79128866487574, 57.43473440856916,
|
||||
58.08241284012621, 58.734331877617365, 59.39049941699807, 60.05092333227251,
|
||||
60.715611475655585, 61.38457167773311, 62.057811747619894, 62.7353394731159,
|
||||
63.417162620860914, 64.10328893648692, 64.79372614476921, 65.48848194977529,
|
||||
66.18756403501224, 66.89098006357258, 67.59873767827808, 68.31084450182222,
|
||||
69.02730813691093, 69.74813616640164, 70.47333615344107, 71.20291564160104,
|
||||
71.93688215501312, 72.67524319850172, 73.41800625771542, 74.16517879925733,
|
||||
74.9167682708136, 75.67278210128072, 76.43322770089146, 77.1981124613393, 77.96744375590167,
|
||||
78.74122893956174, 79.51947534912904, 80.30219030335869, 81.08938110306934,
|
||||
81.88105503125999, 82.67721935322541, 83.4778813166706, 84.28304815182372,
|
||||
85.09272707154808, 85.90692527145302, 86.72564993000343, 87.54890820862819,
|
||||
88.3767072518277, 89.2090541872801, 90.04595612594655, 90.88742016217518, 91.73345337380438,
|
||||
92.58406282226491, 93.43925555268066, 94.29903859396902, 95.16341895893969,
|
||||
96.03240364439274, 96.9059996312159, 97.78421388448044, 98.6670533535366, 99.55452497210776,
|
||||
];
|
||||
@@ -0,0 +1,160 @@
|
||||
//! **HCT** 色彩空间:色相/彩度取自 CAM16,亮度取自 CIELAB 的 `L*`(即 M3 的 tone)。
|
||||
//!
|
||||
//! 为什么不是 OKLab/HSL:`L*` 与人眼感知线性的**对比度**直接挂钩(`Y` 是它的单调函数),
|
||||
//! 所以「tone 差 40 ⇒ 对比度 ≥ 3:1」「tone 差 50 ⇒ ≥ 4.5:1」这类性质在 HCT 里是恒等式,
|
||||
//! 换任何种子色都成立。CAM16 的色相/彩度则保证「同一条调色板只有一种色相」在感知上也是真的。
|
||||
//!
|
||||
//! 语义与官方 `java/hct/Hct.java` 一致:`Hct` 的真身是那个 **8 bit 色值**,`hue/chroma/tone`
|
||||
//! 是它的测量值。所以 [`Hct::from_components`] 解出来的 `tone` 可能比请求值差一点点
|
||||
//! (例如请求 tone 40、实测 40.08),官方也是这样——请求值只是求解目标。
|
||||
//!
|
||||
//! 来源:`material-color-utilities`(Apache-2.0),见仓库根 `NOTICE`。
|
||||
|
||||
use super::space;
|
||||
use super::{cam16::Cam16, hct_solver, Color};
|
||||
|
||||
/// 一个色值的 HCT 坐标。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Hct {
|
||||
/// CAM16 色相角(度,`0..360`)。
|
||||
pub hue: f64,
|
||||
/// CAM16 彩度(无上限;sRGB 立方体内约 0..=130)。
|
||||
pub chroma: f64,
|
||||
/// CIELAB `L*`(0 = 黑 / 100 = 白)——M3 里叫 **tone**。
|
||||
pub tone: f64,
|
||||
/// 这个坐标对应的 8 bit 色值(HCT 的真身)。
|
||||
color: Color,
|
||||
}
|
||||
|
||||
impl Hct {
|
||||
/// 量一个色值的 HCT 坐标。
|
||||
pub fn from_color(color: Color) -> Self {
|
||||
let cam = Cam16::from_color(color);
|
||||
Self {
|
||||
hue: cam.hue,
|
||||
chroma: cam.chroma,
|
||||
tone: space::lstar_from_color(color),
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解 HCT 方程:给定色相/彩度/tone,求最接近的 sRGB 色值。
|
||||
///
|
||||
/// 彩度装不下时**只压彩度**(色相与 tone 保住),这就是 M3 的 gamut 映射;
|
||||
/// 返回值的 `tone` 是实测值,可能与 `tone` 参数相差千分之几。
|
||||
pub fn from_components(hue: f64, chroma: f64, tone: f64) -> Self {
|
||||
Self::from_color(hct_solver::solve_to_color(hue, chroma, tone))
|
||||
}
|
||||
|
||||
/// 该坐标对应的色值。
|
||||
pub fn to_color(self) -> Color {
|
||||
self.color
|
||||
}
|
||||
|
||||
/// 色相是否落在「黄」段(官方 `TonalPalette` 的 tone 99 特例用它)。
|
||||
pub fn is_yellow(hue: f64) -> bool {
|
||||
(105.0..125.0).contains(&hue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 官方 Java 实测的种子色 HCT 坐标(`hct.Count` 打印,APCA/CAM16 D65 默认条件)。
|
||||
const SEED_HCT: [(&str, f64, f64, f64); 6] = [
|
||||
("#6750A4", 298.980_997, 47.856_526, 40.083_244),
|
||||
("#FF0000", 27.408_225, 113.357_887, 53.232_882),
|
||||
("#00FF00", 142.139_894, 108.410_061, 87.737_033),
|
||||
("#0000FF", 282.788_18, 87.230_694, 32.302_587),
|
||||
("#6E7C8A", 245.036_867, 14.675_86, 51.342_101),
|
||||
("#D000FF", 324.929_26, 99.544_477, 52.542_037),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn seed_coordinates_match_official() {
|
||||
for (hex, hue, chroma, tone) in SEED_HCT {
|
||||
let hct = Hct::from_color(Color::from_hex(hex).expect("合法 hex"));
|
||||
assert!((hct.hue - hue).abs() < 1.0e-4, "{hex} hue {}", hct.hue);
|
||||
assert!(
|
||||
(hct.chroma - chroma).abs() < 1.0e-4,
|
||||
"{hex} chroma {}",
|
||||
hct.chroma
|
||||
);
|
||||
assert!((hct.tone - tone).abs() < 1.0e-4, "{hex} tone {}", hct.tone);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reproduces_official_hexes() {
|
||||
// 官方 Java 实测(`hct.Probe`):同一组 (h, c, t) 必须解出同一个 hex。
|
||||
let cases = [
|
||||
(95.0, 36.0, 40.0, "#705D0D"),
|
||||
(298.98, 36.0, 90.0, "#E9DDFF"),
|
||||
(25.0, 84.0, 80.0, "#FFB4AB"),
|
||||
(245.0, 16.0, 80.0, "#B8C8D9"),
|
||||
(245.0, 42.0, 30.0, "#004B72"),
|
||||
(10.0, 120.0, 5.0, "#2C000A"),
|
||||
(245.0, 4.0, 30.0, "#45474A"),
|
||||
];
|
||||
for (hue, chroma, tone, hex) in cases {
|
||||
assert_eq!(
|
||||
hct_solver::solve_to_color(hue, chroma, tone).to_hex(),
|
||||
hex,
|
||||
"HCT({hue}, {chroma}, {tone}) 与官方不一致"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_keeps_hue_and_tone_when_chroma_is_available() {
|
||||
for (hue, chroma, tone) in [
|
||||
(95.0, 36.0, 40.0),
|
||||
(298.98, 36.0, 90.0),
|
||||
(245.0, 42.0, 30.0),
|
||||
(25.0, 84.0, 80.0),
|
||||
(245.0, 16.0, 80.0),
|
||||
] {
|
||||
let solved = Hct::from_components(hue, chroma, tone);
|
||||
assert_eq!(
|
||||
solved.to_color(),
|
||||
hct_solver::solve_to_color(hue, chroma, tone)
|
||||
);
|
||||
assert!(
|
||||
(solved.tone - tone).abs() < 1.0,
|
||||
"tone {} vs {tone}",
|
||||
solved.tone
|
||||
);
|
||||
let delta = (solved.hue - hue)
|
||||
.abs()
|
||||
.min(360.0 - (solved.hue - hue).abs());
|
||||
assert!(delta < 1.0, "色相 {} vs {hue}", solved.hue);
|
||||
assert!(
|
||||
solved.chroma <= chroma + 1.0,
|
||||
"彩度反被拉高:{}",
|
||||
solved.chroma
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_chroma_hue_is_ill_conditioned_even_officially() {
|
||||
// 彩度 8 在 tone 30 上,8 bit 量化会让 CAM16 色相跳 6°——官方同样如此
|
||||
// (Java 实测 HCT(245,8,30) → #42474E / hue 251.5965)。这条钉的是「别去修它」。
|
||||
let solved = Hct::from_components(245.0, 8.0, 30.0);
|
||||
assert_eq!(solved.to_color().to_hex(), "#42474E");
|
||||
assert!(
|
||||
(solved.hue - 251.596_498).abs() < 1.0e-4,
|
||||
"hue {}",
|
||||
solved.hue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yellow_band_matches_official_definition() {
|
||||
assert!(Hct::is_yellow(105.0));
|
||||
assert!(Hct::is_yellow(124.9));
|
||||
assert!(!Hct::is_yellow(125.0));
|
||||
assert!(!Hct::is_yellow(104.9));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! HCT 求解器:给定 `(hue, chroma, tone)` 求 sRGB 色值——**这就是 M3 的 gamut 映射**。
|
||||
//!
|
||||
//! M3 不做「越界就硬裁」:它先假设色域足够,用 Newton 迭代解出线性 RGB;只有当解落进 sRGB
|
||||
//! 立方体之外时,才退到「在色调平面(`Y` 恒定)上沿色相二分」,把彩度压到该色相能取到的最大值,
|
||||
//! **色相与 tone 都保持不变**。所以深色/浅色端的颜色是「变灰」而不是「变色」。
|
||||
//!
|
||||
//! 照搬 `java/hct/HctSolver.java`(Apache-2.0,见 `NOTICE`):两段(Newton + 二分)、
|
||||
//! 迭代次数、`100.01` 这类边界容差都一致。关键平面表见 [`super::critical_planes`]。
|
||||
|
||||
use super::space::{self, signum};
|
||||
use super::viewing_conditions::default_conditions;
|
||||
use super::Color;
|
||||
|
||||
/// 线性 RGB → 「折扣光源」标度的矩阵(求解器内联版,与 [`super::cam16`] 等价但省一次除法)。
|
||||
const SCALED_DISCOUNT_FROM_LINRGB: [[f64; 3]; 3] = [
|
||||
[
|
||||
0.001_200_833_568_784_504,
|
||||
0.002_389_694_492_170_889,
|
||||
0.000_279_574_288_586_112_4,
|
||||
],
|
||||
[
|
||||
0.000_589_108_665_137_599_9,
|
||||
0.002_978_550_257_343_875_8,
|
||||
0.000_327_066_610_400_839_8,
|
||||
],
|
||||
[
|
||||
0.000_101_466_924_916_405_72,
|
||||
0.000_536_421_435_918_669_4,
|
||||
0.003_297_940_177_071_207_6,
|
||||
],
|
||||
];
|
||||
|
||||
/// 「折扣光源」标度 → 线性 RGB([`SCALED_DISCOUNT_FROM_LINRGB`] 的逆)。
|
||||
const LINRGB_FROM_SCALED_DISCOUNT: [[f64; 3]; 3] = [
|
||||
[
|
||||
1_373.219_870_959_423_1,
|
||||
-1_100.425_119_075_482_1,
|
||||
-7.278_681_089_101_213,
|
||||
],
|
||||
[
|
||||
-271.815_969_077_903,
|
||||
559.658_046_594_073_3,
|
||||
-32.460_474_827_911_94,
|
||||
],
|
||||
[
|
||||
1.962_289_959_966_566_6,
|
||||
-57.173_814_538_844_006,
|
||||
308.723_319_781_238_5,
|
||||
],
|
||||
];
|
||||
|
||||
/// 线性 RGB 的 `Y` 权重(Rec.709 亮度系数)。
|
||||
const Y_FROM_LINRGB: [f64; 3] = [0.2126, 0.7152, 0.0722];
|
||||
|
||||
/// `(hue, chroma, tone)` → 色值。彩度取不到时压到该色相/色调下的最大值(色相与 tone 不变)。
|
||||
pub(crate) fn solve_to_color(hue_degrees: f64, chroma: f64, lstar: f64) -> Color {
|
||||
// 端点与极低彩度直接走灰:官方用同一断点,保证 tone 0/100 是纯黑/纯白。
|
||||
if chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999 {
|
||||
return space::color_from_lstar(lstar);
|
||||
}
|
||||
let hue_radians = space::sanitize_degrees(hue_degrees).to_radians();
|
||||
let y = space::y_from_lstar(lstar);
|
||||
if let Some(exact) = find_result_by_j(hue_radians, chroma, y) {
|
||||
return exact;
|
||||
}
|
||||
space::color_from_linrgb(bisect_to_limit(y, hue_radians))
|
||||
}
|
||||
|
||||
/// 牛顿迭代直接解(色域内能解出来时,这条路径是精确解)。
|
||||
fn find_result_by_j(hue_radians: f64, chroma: f64, y: f64) -> Option<Color> {
|
||||
let vc = default_conditions();
|
||||
let mut j = y.sqrt() * 11.0;
|
||||
let t_inner_coeff = 1.0 / (1.64 - 0.29_f64.powf(vc.n)).powf(0.73);
|
||||
let e_hue = 0.25 * ((hue_radians + 2.0).cos() + 3.8);
|
||||
let p1 = e_hue * (50_000.0 / 13.0) * vc.nc * vc.ncb;
|
||||
let h_sin = hue_radians.sin();
|
||||
let h_cos = hue_radians.cos();
|
||||
for round in 0..5 {
|
||||
let j_normalized = j / 100.0;
|
||||
let alpha = if chroma == 0.0 || j == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
chroma / j_normalized.sqrt()
|
||||
};
|
||||
let t = (alpha * t_inner_coeff).powf(1.0 / 0.9);
|
||||
let ac = vc.aw * j_normalized.powf(1.0 / vc.c / vc.z);
|
||||
let p2 = ac / vc.nbb;
|
||||
let gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * h_cos + 108.0 * t * h_sin);
|
||||
let a = gamma * h_cos;
|
||||
let b = gamma * h_sin;
|
||||
let r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
|
||||
let g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
|
||||
let b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
|
||||
let linrgb = space::matrix_multiply(
|
||||
[
|
||||
inverse_chromatic_adaptation(r_a),
|
||||
inverse_chromatic_adaptation(g_a),
|
||||
inverse_chromatic_adaptation(b_a),
|
||||
],
|
||||
LINRGB_FROM_SCALED_DISCOUNT,
|
||||
);
|
||||
if linrgb[0] < 0.0 || linrgb[1] < 0.0 || linrgb[2] < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let f_nj = Y_FROM_LINRGB[0] * linrgb[0]
|
||||
+ Y_FROM_LINRGB[1] * linrgb[1]
|
||||
+ Y_FROM_LINRGB[2] * linrgb[2];
|
||||
if f_nj <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if round == 4 || (f_nj - y).abs() < 0.002 {
|
||||
if linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01 {
|
||||
return None;
|
||||
}
|
||||
return Some(space::color_from_linrgb(linrgb));
|
||||
}
|
||||
// Newton:用 2 * fn(j) / j 近似 fn'(j)
|
||||
j -= (f_nj - y) * j / (2.0 * f_nj);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 色域外时的兜底:在 `Y` 恒定的平面上按色相二分,取边界上的颜色。
|
||||
fn bisect_to_limit(y: f64, target_hue: f64) -> [f64; 3] {
|
||||
let (mut left, mut right) = bisect_to_segment(y, target_hue);
|
||||
let mut left_hue = hue_of(left);
|
||||
for axis in 0..3 {
|
||||
if left[axis] == right[axis] {
|
||||
continue;
|
||||
}
|
||||
let (mut l_plane, mut r_plane) = if left[axis] < right[axis] {
|
||||
(
|
||||
critical_plane_below(space::true_delinearized(left[axis])),
|
||||
critical_plane_above(space::true_delinearized(right[axis])),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
critical_plane_above(space::true_delinearized(left[axis])),
|
||||
critical_plane_below(space::true_delinearized(right[axis])),
|
||||
)
|
||||
};
|
||||
for _ in 0..8 {
|
||||
if (r_plane - l_plane).abs() <= 1 {
|
||||
break;
|
||||
}
|
||||
let m_plane = ((f64::from(l_plane) + f64::from(r_plane)) / 2.0).floor() as i32;
|
||||
let coordinate = super::critical_planes::CRITICAL_PLANES[m_plane as usize];
|
||||
let mid = set_coordinate(left, coordinate, right, axis);
|
||||
let mid_hue = hue_of(mid);
|
||||
if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
|
||||
right = mid;
|
||||
r_plane = m_plane;
|
||||
} else {
|
||||
left = mid;
|
||||
left_hue = mid_hue;
|
||||
l_plane = m_plane;
|
||||
}
|
||||
}
|
||||
}
|
||||
midpoint(left, right)
|
||||
}
|
||||
|
||||
/// 找到包含目标色相的边界线段(sRGB 立方体与 `Y` 平面的交是多边形)。
|
||||
fn bisect_to_segment(y: f64, target_hue: f64) -> ([f64; 3], [f64; 3]) {
|
||||
let mut left = [-1.0_f64; 3];
|
||||
let mut right = left;
|
||||
let mut left_hue = 0.0;
|
||||
let mut initialized = false;
|
||||
let mut uncut = true;
|
||||
for n in 0..12 {
|
||||
let Some(mid) = nth_vertex(y, n) else {
|
||||
continue;
|
||||
};
|
||||
let mid_hue = hue_of(mid);
|
||||
if !initialized {
|
||||
initialized = true;
|
||||
left = mid;
|
||||
right = mid;
|
||||
left_hue = mid_hue;
|
||||
continue;
|
||||
}
|
||||
if uncut || are_in_cyclic_order(left_hue, mid_hue, hue_of(right)) {
|
||||
uncut = false;
|
||||
if are_in_cyclic_order(left_hue, target_hue, mid_hue) {
|
||||
right = mid;
|
||||
} else {
|
||||
left = mid;
|
||||
left_hue = mid_hue;
|
||||
}
|
||||
}
|
||||
}
|
||||
(left, right)
|
||||
}
|
||||
|
||||
/// `Y` 平面与立方体的第 `n` 个顶点(不在立方体内返回 `None`)。
|
||||
fn nth_vertex(y: f64, n: usize) -> Option<[f64; 3]> {
|
||||
let [k_r, k_g, k_b] = Y_FROM_LINRGB;
|
||||
let coord_a = if n % 4 <= 1 { 0.0 } else { 100.0 };
|
||||
let coord_b = if n.is_multiple_of(2) { 0.0 } else { 100.0 };
|
||||
let vertex = if n < 4 {
|
||||
let (g, b) = (coord_a, coord_b);
|
||||
[((y - g * k_g - b * k_b) / k_r), g, b]
|
||||
} else if n < 8 {
|
||||
let (b, r) = (coord_a, coord_b);
|
||||
[r, ((y - r * k_r - b * k_b) / k_g), b]
|
||||
} else {
|
||||
let (r, g) = (coord_a, coord_b);
|
||||
[r, g, ((y - r * k_r - g * k_g) / k_b)]
|
||||
};
|
||||
if vertex.iter().all(|c| (0.0..=100.0).contains(c)) {
|
||||
Some(vertex)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 线性 RGB 的色相(弧度,未归一)。
|
||||
fn hue_of(linrgb: [f64; 3]) -> f64 {
|
||||
let scaled = space::matrix_multiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB);
|
||||
let r_a = chromatic_adaptation(scaled[0]);
|
||||
let g_a = chromatic_adaptation(scaled[1]);
|
||||
let b_a = chromatic_adaptation(scaled[2]);
|
||||
let a = (11.0 * r_a - 12.0 * g_a + b_a) / 11.0;
|
||||
let b = (r_a + g_a - 2.0 * b_a) / 9.0;
|
||||
b.atan2(a)
|
||||
}
|
||||
|
||||
/// 角度上 `b` 是否落在 `a → c` 的前进区间内。
|
||||
fn are_in_cyclic_order(a: f64, b: f64, c: f64) -> bool {
|
||||
space::sanitize_radians(b - a) < space::sanitize_radians(c - a)
|
||||
}
|
||||
|
||||
/// 把线段 `source → target` 在 `axis` 上截到 `coordinate`。
|
||||
fn set_coordinate(source: [f64; 3], coordinate: f64, target: [f64; 3], axis: usize) -> [f64; 3] {
|
||||
let t = (coordinate - source[axis]) / (target[axis] - source[axis]);
|
||||
[
|
||||
source[0] + (target[0] - source[0]) * t,
|
||||
source[1] + (target[1] - source[1]) * t,
|
||||
source[2] + (target[2] - source[2]) * t,
|
||||
]
|
||||
}
|
||||
|
||||
/// 线段中点。
|
||||
fn midpoint(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[
|
||||
(a[0] + b[0]) / 2.0,
|
||||
(a[1] + b[1]) / 2.0,
|
||||
(a[2] + b[2]) / 2.0,
|
||||
]
|
||||
}
|
||||
|
||||
/// 非线性压缩的正向(与 CAM16 里那段同公式)。
|
||||
fn chromatic_adaptation(component: f64) -> f64 {
|
||||
let af = component.abs().powf(0.42);
|
||||
signum(component) * 400.0 * af / (af + 27.13)
|
||||
}
|
||||
|
||||
/// 非线性压缩的逆。
|
||||
fn inverse_chromatic_adaptation(adapted: f64) -> f64 {
|
||||
let adapted_abs = adapted.abs();
|
||||
let base = (27.13 * adapted_abs / (400.0 - adapted_abs)).max(0.0);
|
||||
signum(adapted) * base.powf(1.0 / 0.42)
|
||||
}
|
||||
|
||||
/// 关键平面索引:向下取(官方 `(int) Math.floor(x - 0.5)`)。
|
||||
fn critical_plane_below(x: f64) -> i32 {
|
||||
(x - 0.5).floor() as i32
|
||||
}
|
||||
|
||||
/// 关键平面索引:向上取(官方 `(int) Math.ceil(x - 0.5)`)。
|
||||
fn critical_plane_above(x: f64) -> i32 {
|
||||
(x - 0.5).ceil() as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn in_gamut_requests_are_met_exactly() {
|
||||
// 官方 Java 实测:HCT(95, 36, 40) → #705D0D。
|
||||
assert_eq!(solve_to_color(95.0, 36.0, 40.0).to_hex(), "#705D0D");
|
||||
assert_eq!(solve_to_color(25.0, 84.0, 40.0).to_hex(), "#BA1A1A");
|
||||
assert_eq!(solve_to_color(95.0, 36.0, 90.0).to_hex(), "#FCE186");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_gamut_requests_keep_hue_and_tone() {
|
||||
// 请求 chroma 200(sRGB 装不下):官方只在彩度上退让,色相与 tone 必须保住。
|
||||
let color = solve_to_color(200.0, 200.0, 70.0);
|
||||
assert_eq!(color.to_hex(), "#00BEC3");
|
||||
let hct = super::super::Hct::from_color(color);
|
||||
assert!((hct.tone - 70.0).abs() < 1.0, "tone 掉了:{}", hct.tone);
|
||||
assert!(
|
||||
(hct.hue - 200.0).abs() < 1.0,
|
||||
"色相偏了:{}(应 ≈200)",
|
||||
hct.hue
|
||||
);
|
||||
assert!(hct.chroma < 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_requests_return_exact_grays() {
|
||||
for lstar in [0.0_f64, 4.0, 10.0, 50.0, 87.0, 98.0, 100.0] {
|
||||
let color = solve_to_color(120.0, 0.0, lstar);
|
||||
assert!(color.r == color.g && color.g == color.b, "{:?}", color);
|
||||
}
|
||||
assert_eq!(solve_to_color(0.0, 0.0, 0.0), Color::BLACK);
|
||||
assert_eq!(solve_to_color(0.0, 0.0, 100.0), Color::WHITE);
|
||||
}
|
||||
}
|
||||
@@ -16,42 +16,66 @@
|
||||
//! assert_eq!(custom.get(Role::Surface), surface);
|
||||
//! ```
|
||||
//!
|
||||
//! ## 口径声明:这是 M3 **近似**,不是标准 HCT 动态取色
|
||||
//! ## 口径声明:真 HCT,不是近似
|
||||
//!
|
||||
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB `L*`)上。本 crate 不实现 CAM16,
|
||||
//! 用的是 **OKLCh**(色相/彩度)+ **CIELAB `L*`**(tone 轴)。具体差异:
|
||||
//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB 的 `L*`)上。本模块**就是**那套算法:
|
||||
//! CAM16 正换算(`cam16` 模块)取色相/彩度,`L*` 取 tone,色域外走官方的 `HctSolver`
|
||||
//! (`hct_solver` 模块:Newton 迭代 + 色调平面上的色相二分,**只压彩度、不硬裁**)。
|
||||
//!
|
||||
//! | 轴 | 标准 M3 | 本实现 | 影响 |
|
||||
//! | 轴 | 标准 M3 | 本实现 | 一致性 |
|
||||
//! |---|---|---|---|
|
||||
//! | tone | CIELAB `L*` | CIELAB `L*`(二分反解 OKLab 明度,误差 ≤1 tone) | **无偏差**:tone 定义严格对齐 |
|
||||
//! | 色相 | CAM16 hue | OKLCh hue(种子色相原样保留) | 绝对色相角不同,但「同族色相恒定」这个性质成立;派生色(tertiary = +60°)偏移量与 M3 不完全相同 |
|
||||
//! | 彩度 | CAM16 chroma(如 primary 36 / secondary 16 / neutral 6) | OKLCh chroma(标定值见 [`palettes`]) | 数值量纲不同,语义等价;饱和度观感与官方色板会有可见差别(不是抄错,是量纲换算不通用) |
|
||||
//! | gamut | CAM16 gamut mapping(按色相压缩彩度) | 按 tone 二分压 OKLCh 彩度进 sRGB | 极端 tone(5/95+)的饱和度可能比官方色板略低 |
|
||||
//! | 动态色 | Material You 从壁纸取 5 组种子 | 只用**一个**种子 + 固定派生规则 | 没有「多色壁纸取色」能力;本启动器用不到(壁纸取色是安卓端特性) |
|
||||
//! | 对比度模式 | Dynamic Contrast 三档(standard/medium/high) | 只有 standard | 不提供高对比方案;WCAG AA 在 standard 下已满足(见 [`super::color::contrast`] 与测试) |
|
||||
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
|
||||
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
|
||||
//! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 |
|
||||
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同 | 逐值一致 |
|
||||
//! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 |
|
||||
//!
|
||||
//! 结论:**不许对外宣称「标准 M3 动态取色」**。要对比官方色板时按上表解释差异,
|
||||
//! 不要试图逐值对齐 material-color-utilities 的输出(那需要 CAM16 + HCT,是另一个量级的实现)。
|
||||
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
|
||||
//! `material-color-utilities`(官方 Java 实现,Apache-2.0)跑出来,**每通道误差 ≤1** 才通过。
|
||||
//! 所以本 crate 可以对外说「M3 动态取色(Tonal Spot 变体 / standard 对比度)」。
|
||||
//!
|
||||
//! ## 未实现的部分(gap 登记,别以为已经有)
|
||||
//!
|
||||
//! | 能力 | 官方叫什么 | 状态 |
|
||||
//! |---|---|---|
|
||||
//! | 对比度三档(default/medium/high) | `ContrastCurve` + `contrastLevel` | **未实现**:只做 `contrastLevel = 0`。在 0 档下官方那层「tone 差对 + 对比度修正」机制的输入全是常数,等价于本模块的静态 tone 表(已由对齐测试反证) |
|
||||
//! | 其他配色变体 | `Vibrant` / `Expressive` / `Fidelity` / `Content` / `Monochrome` / `Neutral` / `Rainbow` / `FruitSalad` / `CMF` | **未实现**:只有 `TonalSpot`(M3 默认、也是 Material Theme Builder 默认) |
|
||||
//! | 壁纸多色取色 | `QuantizerCelebi` / `Score` / 多 source 色 | **未实现**:桌面启动器用不到(安卓端特性),也避免引入图像依赖 |
|
||||
//! | 换观察条件 | `Hct.inViewingConditions` | **未实现**:只有 sRGB 屏的默认条件(D65 / 200 lux / 背景 `L* 50`) |
|
||||
//!
|
||||
//! ## 「自定义强调色」与官方 `from_seed` 的关系
|
||||
//!
|
||||
//! 官方 `SchemeTonalSpot(accent)` 会把**中性表面也染上**种子的色相;本项目的产品语义是
|
||||
//! 「换强调色不该让整个窗口底色变脏」,所以 [`Scheme::from_accent`] 只换 primary/secondary/
|
||||
//! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上
|
||||
//! (`tests/official_accent.rs`,6 个种子 × 12 个强调族角色 × 2 模式)。
|
||||
//!
|
||||
//! ## 对比度纪律(WCAG AA)
|
||||
//!
|
||||
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
|
||||
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
|
||||
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 L*)。
|
||||
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`)。
|
||||
//! - 例外(显式登记,非缺陷):
|
||||
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
|
||||
//! 目标 3:1,实测 light 4.29:1 / dark 6.5:1;
|
||||
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
|
||||
//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限;
|
||||
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
|
||||
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
|
||||
|
||||
pub mod hct;
|
||||
pub mod palettes;
|
||||
pub mod roles;
|
||||
pub mod scheme;
|
||||
pub mod space;
|
||||
pub mod state;
|
||||
pub mod tonal;
|
||||
|
||||
mod cam16;
|
||||
mod critical_planes;
|
||||
mod hct_solver;
|
||||
mod space;
|
||||
mod viewing_conditions;
|
||||
|
||||
pub use hct::Hct;
|
||||
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED};
|
||||
pub use roles::Role;
|
||||
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
|
||||
@@ -143,17 +167,19 @@ impl Color {
|
||||
self.a == 255
|
||||
}
|
||||
|
||||
/// WCAG 相对亮度(0..1,按 sRGB→XYZ 的 Y 行计算)。
|
||||
/// WCAG 相对亮度(0..1,即 XYZ 的 `Y` 除以 100)。
|
||||
///
|
||||
/// 与 WCAG 2.x 的定义同一条 `Y` 轴(Rec.709 系数 0.2126/0.7152/0.0722),
|
||||
/// 所以 [`Color::contrast_ratio`] 就是标准对比度公式。
|
||||
pub fn relative_luminance(self) -> f32 {
|
||||
let lin = |c: u8| space::srgb_to_linear(f32::from(c) / 255.0);
|
||||
space::luminance([lin(self.r), lin(self.g), lin(self.b)])
|
||||
(space::xyz_from_color(self)[1] / 100.0) as f32
|
||||
}
|
||||
|
||||
/// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。
|
||||
///
|
||||
/// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。
|
||||
pub fn lstar(self) -> f32 {
|
||||
space::lstar_from_luminance(self.relative_luminance())
|
||||
space::lstar_from_color(self) as f32
|
||||
}
|
||||
|
||||
/// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。
|
||||
@@ -200,16 +226,6 @@ impl Color {
|
||||
a: mix(self.a, other.a),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从线性 RGB(0..1,可越界)构造:先夹取再 sRGB 编码。
|
||||
fn from_linear_f32(rgb: [f32; 3]) -> Self {
|
||||
let enc = |c: f32| {
|
||||
(space::linear_to_srgb(c.clamp(0.0, 1.0)) * 255.0)
|
||||
.round()
|
||||
.clamp(0.0, 255.0) as u8
|
||||
};
|
||||
Self::rgb(enc(rgb[0]), enc(rgb[1]), enc(rgb[2]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -250,4 +266,24 @@ mod tests {
|
||||
assert_eq!(a.lerp(b, 1.0), b);
|
||||
assert_eq!(a.lerp(b, 0.5).r, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lstar_anchors_at_the_ends() {
|
||||
assert!(Color::BLACK.lstar().abs() < 1.0e-3);
|
||||
assert!((Color::WHITE.lstar() - 100.0).abs() < 1.0e-3);
|
||||
// 中灰(sRGB 128)的 L* 是 53.6 左右,不是 50——这正是「L* 不是 sRGB 编码值」的证据。
|
||||
let mid = Color::rgb(128, 128, 128).lstar();
|
||||
assert!((mid - 53.585).abs() < 0.01, "中灰 L* = {mid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_delta_implies_contrast_floor() {
|
||||
// M3 的招牌性质:tone 差 50 ⇒ 对比度 ≥ 4.5:1(与色相/彩度无关)。
|
||||
for (low, high) in [(10.0_f64, 60.0), (30.0, 80.0), (40.0, 90.0), (20.0, 70.0)] {
|
||||
let a = space::color_from_lstar(low);
|
||||
let b = space::color_from_lstar(high);
|
||||
let ratio = a.contrast_ratio(b);
|
||||
assert!(ratio >= 4.5, "tone {low}→{high} 只有 {ratio:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
//! 六条 tone 调色板的集合(M3 scheme 的原料)。
|
||||
//!
|
||||
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」。
|
||||
//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」,
|
||||
//! 以及**官方 Tonal Spot 变体**的彩度常数。
|
||||
//!
|
||||
//! 彩度值来自 `material-color-utilities` 的 `ColorSpec2021.get*Palette(Variant.TONAL_SPOT)`
|
||||
//! (Apache-2.0,见 `NOTICE`):primary 恒定 **36**、secondary **16**、tertiary **24**
|
||||
//! (色相 +60°)、neutral **6**、neutral-variant **8**、error 固定色相 **25** 彩度 **84**。
|
||||
//! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体,
|
||||
//! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。
|
||||
|
||||
use super::tonal::TonalPalette;
|
||||
use super::Color;
|
||||
@@ -11,36 +18,36 @@ pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
||||
/// 调色板槽位(M3 的六条 tonal palette)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaletteSlot {
|
||||
/// 主调色板(种子色相)。
|
||||
/// 主调色板(种子色相,彩度 36)。
|
||||
Primary,
|
||||
/// 次调色板(同色相、低彩度)。
|
||||
/// 次调色板(同色相,彩度 16)。
|
||||
Secondary,
|
||||
/// 第三调色板(种子色相 +60°,中彩度)。
|
||||
/// 第三调色板(种子色相 +60°,彩度 24)。
|
||||
Tertiary,
|
||||
/// 中性调色板(近零彩度的表面色)。
|
||||
/// 中性调色板(同色相,彩度 6)。
|
||||
Neutral,
|
||||
/// 中性变体调色板(比中性略彩色,做分隔与次级容器)。
|
||||
/// 中性变体调色板(同色相,彩度 8)。
|
||||
NeutralVariant,
|
||||
/// 错误调色板(固定红相)。
|
||||
/// 错误调色板(固定红相 25°,彩度 84)。
|
||||
Error,
|
||||
}
|
||||
|
||||
// 下面几个常数是**我们的标定值**(OKLCh 彩度单位),不是 M3 的 CAM16 chroma:
|
||||
// CAM16 的 chroma 36/16/24/6/8 量纲与本实现不同,直接照抄会得到过饱和的色板。
|
||||
/// 次色的彩度上限。
|
||||
const SECONDARY_CHROMA: f32 = 0.055;
|
||||
/// 第三色的彩度上限。
|
||||
const TERTIARY_CHROMA: f32 = 0.085;
|
||||
/// 中性的彩度上限(几乎是灰)。
|
||||
const NEUTRAL_CHROMA: f32 = 0.008;
|
||||
/// 中性变体的彩度上限。
|
||||
const NEUTRAL_VARIANT_CHROMA: f32 = 0.016;
|
||||
/// 错误色的色相(OKLCh 红相)。
|
||||
const ERROR_HUE_DEG: f32 = 25.0;
|
||||
/// 错误色的彩度上限。
|
||||
const ERROR_CHROMA: f32 = 0.18;
|
||||
/// 第三色调色板的色相偏移。
|
||||
const TERTIARY_HUE_SHIFT_DEG: f32 = 60.0;
|
||||
/// Tonal Spot 的 primary 彩度(官方常数,与种子彩度无关)。
|
||||
const PRIMARY_CHROMA: f64 = 36.0;
|
||||
/// secondary 彩度。
|
||||
const SECONDARY_CHROMA: f64 = 16.0;
|
||||
/// tertiary 彩度。
|
||||
const TERTIARY_CHROMA: f64 = 24.0;
|
||||
/// neutral 彩度(几乎是灰)。
|
||||
const NEUTRAL_CHROMA: f64 = 6.0;
|
||||
/// neutral-variant 彩度。
|
||||
const NEUTRAL_VARIANT_CHROMA: f64 = 8.0;
|
||||
/// error 的色相(HCT 红相)。
|
||||
const ERROR_HUE_DEG: f64 = 25.0;
|
||||
/// error 的彩度。
|
||||
const ERROR_CHROMA: f64 = 84.0;
|
||||
/// tertiary 相对种子的色相偏移。
|
||||
const TERTIARY_HUE_SHIFT_DEG: f64 = 60.0;
|
||||
|
||||
/// 一套完整的六条调色板。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -57,50 +64,45 @@ pub struct Palettes {
|
||||
pub neutral_variant: TonalPalette,
|
||||
/// 错误调色板。
|
||||
pub error: TonalPalette,
|
||||
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
|
||||
///
|
||||
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
|
||||
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
|
||||
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
|
||||
reference_chroma: f32,
|
||||
}
|
||||
|
||||
impl Palettes {
|
||||
/// 出厂基线(种子 `#6750A4`,等价 M3 baseline)。
|
||||
/// 出厂基线(种子 `#6750A4`)。
|
||||
pub fn baseline() -> Self {
|
||||
Self::from_seed(BASELINE_SEED)
|
||||
}
|
||||
|
||||
/// 从一个种子色派生全套调色板。
|
||||
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体)。
|
||||
pub fn from_seed(seed: Color) -> Self {
|
||||
let primary = TonalPalette::from_color(seed);
|
||||
let hue = super::Hct::from_color(seed).hue;
|
||||
Self::tonal_spot(hue)
|
||||
}
|
||||
|
||||
/// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走,
|
||||
/// **表面中性色与错误色保持不变**。
|
||||
///
|
||||
/// 这是本项目的产品语义(换强调色不该让整个窗口底色变脏,见 `DesignTokens::with_accent`):
|
||||
/// 强调族的色值与官方 `from_seed(accent)` 逐个对得上,中性表面则仍走基线——
|
||||
/// 官方 `from_seed` 会把中性色也染上种子的色相,那是「整窗染色」,不是我们要的。
|
||||
pub fn with_accent(self, accent: Color) -> Self {
|
||||
let hue = super::Hct::from_color(accent).hue;
|
||||
Self {
|
||||
primary,
|
||||
secondary: primary.limited_chroma(SECONDARY_CHROMA),
|
||||
tertiary: primary
|
||||
.rotated(TERTIARY_HUE_SHIFT_DEG)
|
||||
.limited_chroma(TERTIARY_CHROMA),
|
||||
neutral: primary.with_chroma(NEUTRAL_CHROMA),
|
||||
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
|
||||
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
||||
reference_chroma: primary.chroma(),
|
||||
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
|
||||
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
|
||||
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// 只换强调色:primary/secondary/tertiary 跟着新强调色走,**表面中性色保持不变**。
|
||||
///
|
||||
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
|
||||
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
|
||||
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
|
||||
pub fn with_accent(self, accent: Color) -> Self {
|
||||
let accent_primary = TonalPalette::from_color(accent);
|
||||
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数)。
|
||||
fn tonal_spot(hue: f64) -> Self {
|
||||
Self {
|
||||
primary: accent_primary,
|
||||
secondary: accent_primary.limited_chroma(SECONDARY_CHROMA),
|
||||
tertiary: accent_primary
|
||||
.rotated(TERTIARY_HUE_SHIFT_DEG)
|
||||
.limited_chroma(TERTIARY_CHROMA),
|
||||
..self
|
||||
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
|
||||
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
|
||||
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
|
||||
neutral: TonalPalette::new(hue, NEUTRAL_CHROMA),
|
||||
neutral_variant: TonalPalette::new(hue, NEUTRAL_VARIANT_CHROMA),
|
||||
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,16 +118,9 @@ impl Palettes {
|
||||
}
|
||||
}
|
||||
|
||||
/// 方案级取色:以 [`Palettes::reference_chroma`] 为**共享基准**压所有调色板(见
|
||||
/// [`TonalPalette::tone_scaled`]),保证同 tone 下 primary/secondary/tertiary 不会撞色,
|
||||
/// 且换强调色时中性表面逐像素不变。
|
||||
pub fn tone(&self, slot: PaletteSlot, tone: f32) -> Color {
|
||||
self.slot(slot).tone_scaled(tone, self.reference_chroma)
|
||||
}
|
||||
|
||||
/// 彩度压缩的共享基准(调试/测试用)。
|
||||
pub fn reference_chroma(&self) -> f32 {
|
||||
self.reference_chroma
|
||||
/// 方案级取色:槽位 + tone → 色值。
|
||||
pub fn tone(&self, slot: PaletteSlot, tone: f64) -> Color {
|
||||
self.slot(slot).tone(tone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,26 +129,48 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn baseline_seed_is_the_primary_palette_hue() {
|
||||
fn baseline_palettes_use_the_official_tonal_spot_chromas() {
|
||||
let palettes = Palettes::baseline();
|
||||
let seed_lch = super::super::space::Oklch::from_color(BASELINE_SEED);
|
||||
let delta = (palettes.primary.hue_deg() - seed_lch.hue_deg).abs();
|
||||
assert!(delta < 0.01, "primary 调色板应保持种子色相,偏差 {delta}°");
|
||||
assert_eq!(palettes.primary.chroma(), 36.0);
|
||||
assert_eq!(palettes.secondary.chroma(), 16.0);
|
||||
assert_eq!(palettes.tertiary.chroma(), 24.0);
|
||||
assert_eq!(palettes.neutral.chroma(), 6.0);
|
||||
assert_eq!(palettes.neutral_variant.chroma(), 8.0);
|
||||
assert_eq!(palettes.error.hue_deg(), 25.0);
|
||||
assert_eq!(palettes.error.chroma(), 84.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_override_keeps_surfaces() {
|
||||
fn baseline_hue_comes_from_the_seed() {
|
||||
let palettes = Palettes::baseline();
|
||||
let seed = super::super::Hct::from_color(BASELINE_SEED);
|
||||
assert!((palettes.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||
assert!((palettes.neutral.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||
assert!((palettes.tertiary.hue_deg() - (seed.hue + 60.0)).abs() < 1.0e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_chroma_never_leaks_into_the_primary_ramp() {
|
||||
// 官方口径:Tonal Spot 的彩度是常数,纯灰种子也会得到彩度 36 的主色板。
|
||||
let gray = Palettes::from_seed(Color::rgb(0x6E, 0x6E, 0x6E));
|
||||
assert_eq!(gray.primary.chroma(), 36.0);
|
||||
let hot = Palettes::from_seed(Color::rgb(0xFF, 0x00, 0x00));
|
||||
assert_eq!(hot.primary.chroma(), 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_override_keeps_surfaces_and_error() {
|
||||
let base = Palettes::baseline();
|
||||
let accent = Color::rgb(0x00, 0x6A, 0x5C);
|
||||
let swapped = base.with_accent(accent);
|
||||
let swapped = base.with_accent(Color::rgb(0x00, 0x6A, 0x5C));
|
||||
assert_eq!(swapped.neutral, base.neutral);
|
||||
assert_eq!(swapped.neutral_variant, base.neutral_variant);
|
||||
assert_eq!(swapped.error, base.error);
|
||||
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
|
||||
assert_eq!(swapped.primary.chroma(), 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_variant_is_less_chromatic_than_primary() {
|
||||
fn neutral_variant_sits_between_neutral_and_secondary() {
|
||||
let palettes = Palettes::baseline();
|
||||
assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma());
|
||||
assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma());
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。
|
||||
//!
|
||||
//! tone 表取自 M3 基础方案的官方映射(light/dark 各一列)。固定角色
|
||||
//! (`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
|
||||
//! tone 表取自 **ColorSpec2021** 的官方映射(`MaterialDynamicColors` 的 light/dark 两列,
|
||||
//! 见 `NOTICE`)。固定角色(`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。
|
||||
//!
|
||||
//! ## 只实现了 contrast level = 0(standard)
|
||||
//!
|
||||
//! 官方的 `ColorSpec2021.getTone()` 还有一层机制:`toneDeltaPair`(成对角色保持 tone 差)
|
||||
//! 与 `ContrastCurve`(按对比度等级修正 tone)。在 **contrastLevel = 0** 下这层机制的输入
|
||||
//! 全部是常数:`ContrastCurve.get(0)` 恒等于 normal 档的 tone,而「背景 tone」也都是本表里的
|
||||
//! 常数,所以它对 49 个角色的调整结果**就是这张表本身**。测试
|
||||
//! `tests/official_baseline.rs` 用官方 Java 实现的逐角色输出反证了这一点(每通道误差 ≤1):
|
||||
//! 表错一格,对齐测试就会红。三档对比度(medium/high)不在本 crate 范围内,见
|
||||
//! [`super`] 的 gap 登记。
|
||||
|
||||
use super::palettes::{PaletteSlot, Palettes};
|
||||
use super::roles::Role;
|
||||
@@ -48,33 +58,33 @@ pub const SCRIM_ALPHA: f32 = 0.32;
|
||||
struct ToneRow {
|
||||
role: Role,
|
||||
slot: PaletteSlot,
|
||||
light: f32,
|
||||
dark: f32,
|
||||
light: f64,
|
||||
dark: f64,
|
||||
}
|
||||
|
||||
/// 49 个角色的 tone 映射表,**下标必须等于 `Role as usize`**(由测试钉死)。
|
||||
/// 49 个角色的 tone 映射表(ColorSpec2021),**下标必须等于 `Role as usize`**(由测试钉死)。
|
||||
const TONE_TABLE: [ToneRow; 49] = [
|
||||
row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0),
|
||||
row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0),
|
||||
row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0),
|
||||
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 10.0, 90.0),
|
||||
row(Role::OnPrimaryContainer, PaletteSlot::Primary, 30.0, 90.0),
|
||||
row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0),
|
||||
row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0),
|
||||
row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0),
|
||||
row(
|
||||
Role::OnSecondaryContainer,
|
||||
PaletteSlot::Secondary,
|
||||
10.0,
|
||||
30.0,
|
||||
90.0,
|
||||
),
|
||||
row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0),
|
||||
row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0),
|
||||
row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0),
|
||||
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 10.0, 90.0),
|
||||
row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 30.0, 90.0),
|
||||
row(Role::Error, PaletteSlot::Error, 40.0, 80.0),
|
||||
row(Role::OnError, PaletteSlot::Error, 100.0, 20.0),
|
||||
row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0),
|
||||
row(Role::OnErrorContainer, PaletteSlot::Error, 10.0, 90.0),
|
||||
row(Role::OnErrorContainer, PaletteSlot::Error, 30.0, 90.0),
|
||||
row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0),
|
||||
row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0),
|
||||
row(
|
||||
@@ -89,7 +99,7 @@ const TONE_TABLE: [ToneRow; 49] = [
|
||||
30.0,
|
||||
80.0,
|
||||
),
|
||||
row(Role::Background, PaletteSlot::Neutral, 99.0, 10.0),
|
||||
row(Role::Background, PaletteSlot::Neutral, 98.0, 6.0),
|
||||
row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0),
|
||||
row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0),
|
||||
row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0),
|
||||
@@ -151,7 +161,7 @@ const TONE_TABLE: [ToneRow; 49] = [
|
||||
];
|
||||
|
||||
/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。
|
||||
const fn row(role: Role, slot: PaletteSlot, light: f32, dark: f32) -> ToneRow {
|
||||
const fn row(role: Role, slot: PaletteSlot, light: f64, dark: f64) -> ToneRow {
|
||||
ToneRow {
|
||||
role,
|
||||
slot,
|
||||
@@ -168,12 +178,12 @@ pub struct Scheme {
|
||||
}
|
||||
|
||||
impl Scheme {
|
||||
/// 出厂基线方案(种子 `#6750A4`)。
|
||||
/// 出厂基线方案(种子 `#6750A4`,官方 Tonal Spot 输出)。
|
||||
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
||||
Self::from_palettes(&Palettes::baseline(), flavor)
|
||||
}
|
||||
|
||||
/// 指定种子色的方案(表面色与强调色同源)。
|
||||
/// 指定种子色的方案(表面色与强调色同源,等价官方 `SchemeTonalSpot`)。
|
||||
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
|
||||
Self::from_palettes(&Palettes::from_seed(seed), flavor)
|
||||
}
|
||||
@@ -187,17 +197,13 @@ impl Scheme {
|
||||
pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self {
|
||||
let colors = std::array::from_fn(|i| {
|
||||
let entry = &TONE_TABLE[i];
|
||||
let tone = match flavor {
|
||||
SchemeFlavor::Light => entry.light,
|
||||
SchemeFlavor::Dark => entry.dark,
|
||||
};
|
||||
palettes.tone(entry.slot, tone)
|
||||
palettes.tone(entry.slot, Scheme::tone_of(entry.role, flavor))
|
||||
});
|
||||
Self { flavor, colors }
|
||||
}
|
||||
|
||||
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
|
||||
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f32 {
|
||||
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f64 {
|
||||
let entry = &TONE_TABLE[role as usize];
|
||||
debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号");
|
||||
match flavor {
|
||||
@@ -309,5 +315,6 @@ mod tests {
|
||||
teal.get(Role::Outline),
|
||||
"强调色覆盖不该动中性描边"
|
||||
);
|
||||
assert_eq!(base.get(Role::Error), teal.get(Role::Error));
|
||||
}
|
||||
}
|
||||
+160
-155
@@ -1,198 +1,203 @@
|
||||
//! 色彩空间转换:sRGB ↔ 线性 RGB ↔ CIELAB(tone 轴)+ OKLab/OKLCh(色相/彩度轴)。
|
||||
//! 色彩空间底层换算:sRGB ↔ 线性 RGB ↔ CIE XYZ ↔ `L*`(tone 轴)。
|
||||
//!
|
||||
//! 这里只放**纯数学**,不认识任何语义色角色。口径说明见 [`super`] 的模块文档。
|
||||
//! 只放**纯数学**,不认识任何语义色角色。数值口径逐函数照搬 Google
|
||||
//! `material-color-utilities` 的 `java/utils/ColorUtils.java`(Apache-2.0,见 `NOTICE`):
|
||||
//! 矩阵、阈值、取整方式都一致——差一位小数,HCT 求解器就会与官方差一个通道。
|
||||
//!
|
||||
//! 内部一律用 `f64`(官方实现是 Java `double`)。公开 API 仍以 `u8` 通道与 `f32` 标量对外,
|
||||
//! 因为令牌消费方(绘制层)不需要比 8 bit 更高的精度。
|
||||
|
||||
use super::Color;
|
||||
|
||||
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
|
||||
const GAMUT_EPS: f32 = 1.0e-4;
|
||||
/// sRGB → XYZ(D65)。官方用的是 IEC 6-1 的舍入值,不是更精确的 sRGB 矩阵。
|
||||
const SRGB_TO_XYZ: [[f64; 3]; 3] = [
|
||||
[0.412_338_95, 0.357_620_64, 0.180_510_42],
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
[0.019_321_41, 0.119_163_82, 0.950_344_78],
|
||||
];
|
||||
|
||||
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
|
||||
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
|
||||
/// D65 白点(XYZ,Y 归一化到 100)。
|
||||
pub(crate) const WHITE_POINT_D65: [f64; 3] = [95.047, 100.0, 108.883];
|
||||
|
||||
/// sRGB 编码值(0..1)→ 线性光强。
|
||||
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.040_45 {
|
||||
c / 12.92
|
||||
/// 行向量 × 3×3 矩阵。
|
||||
pub(crate) fn matrix_multiply(row: [f64; 3], matrix: [[f64; 3]; 3]) -> [f64; 3] {
|
||||
let mul = |m: [f64; 3]| row[0] * m[0] + row[1] * m[1] + row[2] * m[2];
|
||||
[mul(matrix[0]), mul(matrix[1]), mul(matrix[2])]
|
||||
}
|
||||
|
||||
/// sRGB 编码值(8 bit 通道)→ 线性光强(0..=100,官方标度)。
|
||||
pub(crate) fn linearized(component: u8) -> f64 {
|
||||
let normalized = f64::from(component) / 255.0;
|
||||
if normalized <= 0.040_449_936 {
|
||||
normalized / 12.92 * 100.0
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
((normalized + 0.055) / 1.055).powf(2.4) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 线性光强 → sRGB 编码值(0..1),结果不夹取,越界由调用方处理。
|
||||
pub(crate) fn linear_to_srgb(c: f32) -> f32 {
|
||||
if c <= 0.003_130_8 {
|
||||
c * 12.92
|
||||
/// 线性光强(0..=100)→ sRGB 编码值,四舍五入并夹取到 0..=255。
|
||||
pub(crate) fn delinearized(component: f64) -> u8 {
|
||||
let value = true_delinearized(component).round().clamp(0.0, 255.0);
|
||||
value as u8
|
||||
}
|
||||
|
||||
/// 同 [`delinearized`],但不取整也不夹取(求解器二分时需要连续值)。
|
||||
pub(crate) fn true_delinearized(component: f64) -> f64 {
|
||||
let normalized = component / 100.0;
|
||||
let encoded = if normalized <= 0.003_130_8 {
|
||||
normalized * 12.92
|
||||
} else {
|
||||
1.055 * c.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
1.055 * normalized.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
encoded * 255.0
|
||||
}
|
||||
|
||||
/// 线性 RGB → CIE XYZ(D65 白点,sRGB 原色)。
|
||||
pub(crate) fn linear_to_xyz(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let [r, g, b] = rgb;
|
||||
[
|
||||
0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b,
|
||||
0.212_672_9 * r + 0.715_152_2 * g + 0.072_175_0 * b,
|
||||
0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b,
|
||||
]
|
||||
/// 线性 RGB(0..=100)→ 不透明颜色。
|
||||
pub(crate) fn color_from_linrgb(linrgb: [f64; 3]) -> Color {
|
||||
Color::rgb(
|
||||
delinearized(linrgb[0]),
|
||||
delinearized(linrgb[1]),
|
||||
delinearized(linrgb[2]),
|
||||
)
|
||||
}
|
||||
|
||||
/// 相对亮度 Y(WCAG 与 CIELAB 共用这条 Y,白点 1.0)。
|
||||
pub(crate) fn luminance(rgb: [f32; 3]) -> f32 {
|
||||
linear_to_xyz(rgb)[1]
|
||||
/// 色值 → XYZ(Y 在 0..=100 标度)。
|
||||
pub(crate) fn xyz_from_color(color: Color) -> [f64; 3] {
|
||||
let linear = [
|
||||
linearized(color.r),
|
||||
linearized(color.g),
|
||||
linearized(color.b),
|
||||
];
|
||||
matrix_multiply(linear, SRGB_TO_XYZ)
|
||||
}
|
||||
|
||||
/// CIELAB `L*`(M3 的 **tone** 就是这个轴,0 = 黑 / 100 = 白)。
|
||||
pub(crate) fn lstar_from_luminance(y: f32) -> f32 {
|
||||
let t = y.max(0.0);
|
||||
if t > 0.008_856 {
|
||||
116.0 * t.cbrt() - 16.0
|
||||
/// `L*` 的比较函数(CIE 分段线性/立方根)。
|
||||
fn lab_f(t: f64) -> f64 {
|
||||
const EPSILON: f64 = 216.0 / 24389.0;
|
||||
const KAPPA: f64 = 24389.0 / 27.0;
|
||||
if t > EPSILON {
|
||||
t.powf(1.0 / 3.0)
|
||||
} else {
|
||||
903.296_3 * t
|
||||
(KAPPA * t + 16.0) / 116.0
|
||||
}
|
||||
}
|
||||
|
||||
/// `L*` → 相对亮度 Y([`lstar_from_luminance`] 的逆)。
|
||||
pub(crate) fn luminance_from_lstar(lstar: f32) -> f32 {
|
||||
let l = lstar.clamp(0.0, 100.0);
|
||||
if l > 8.0 {
|
||||
((l + 16.0) / 116.0).powi(3)
|
||||
/// [`lab_f`] 的逆。
|
||||
fn lab_invf(ft: f64) -> f64 {
|
||||
const EPSILON: f64 = 216.0 / 24389.0;
|
||||
const KAPPA: f64 = 24389.0 / 27.0;
|
||||
let cubed = ft * ft * ft;
|
||||
if cubed > EPSILON {
|
||||
cubed
|
||||
} else {
|
||||
l / 903.296_3
|
||||
(116.0 * ft - 16.0) / KAPPA
|
||||
}
|
||||
}
|
||||
|
||||
/// 线性 RGB → OKLab。
|
||||
pub(crate) fn linear_to_oklab(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let [r, g, b] = rgb;
|
||||
let l = 0.412_221_47 * r + 0.536_332_55 * g + 0.051_445_99 * b;
|
||||
let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b;
|
||||
let s = 0.088_302_46 * r + 0.281_718_84 * g + 0.629_978_7 * b;
|
||||
let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
|
||||
[
|
||||
0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_,
|
||||
1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_,
|
||||
0.025_904_037 * l_ + 0.782_771_77 * m_ - 0.808_675_77 * s_,
|
||||
]
|
||||
/// `L*` → 相对亮度 Y(0..=100)。
|
||||
pub(crate) fn y_from_lstar(lstar: f64) -> f64 {
|
||||
100.0 * lab_invf((lstar + 16.0) / 116.0)
|
||||
}
|
||||
|
||||
/// OKLab → 线性 RGB(可能越界,负值表示超出 sRGB gamut)。
|
||||
pub(crate) fn oklab_to_linear(lab: [f32; 3]) -> [f32; 3] {
|
||||
let [l, a, b] = lab;
|
||||
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
||||
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
|
||||
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
||||
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||
[
|
||||
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
|
||||
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
|
||||
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
|
||||
]
|
||||
/// 相对亮度 Y(0..=100)→ `L*`。
|
||||
pub(crate) fn lstar_from_y(y: f64) -> f64 {
|
||||
lab_f(y / 100.0) * 116.0 - 16.0
|
||||
}
|
||||
|
||||
/// OKLCh → 线性 RGB。
|
||||
pub(crate) fn oklch_to_linear(l: f32, chroma: f32, hue_deg: f32) -> [f32; 3] {
|
||||
let h = hue_deg.to_radians();
|
||||
oklab_to_linear([l, chroma * h.cos(), chroma * h.sin()])
|
||||
/// 色值 → `L*`(即 M3 的 **tone**)。
|
||||
pub(crate) fn lstar_from_color(color: Color) -> f64 {
|
||||
lstar_from_y(xyz_from_color(color)[1])
|
||||
}
|
||||
|
||||
/// 线性 RGB 是否落在 sRGB 立方体内([`GAMUT_EPS`] 容差)。
|
||||
pub(crate) fn in_gamut(rgb: [f32; 3]) -> bool {
|
||||
rgb.iter()
|
||||
.all(|c| *c >= -GAMUT_EPS && *c <= 1.0 + GAMUT_EPS)
|
||||
/// `L*` → 中性灰色值(`L*` 与彩度无关,纯灰)。
|
||||
pub(crate) fn color_from_lstar(lstar: f64) -> Color {
|
||||
let component = delinearized(y_from_lstar(lstar));
|
||||
Color::rgb(component, component, component)
|
||||
}
|
||||
|
||||
/// 求「在保证 `L* = lstar` 的前提下,OKLab 明度该取多少」。
|
||||
///
|
||||
/// OKLab 的 L 不是 Y 的纯函数(彩色会偏离),所以给定 tone 与色相/彩度时用二分反解,
|
||||
/// 让最终颜色的 **CIELAB L\* 精确等于 tone**——M3 的 tone 定义在 L\* 上,这一点必须对齐。
|
||||
pub(crate) fn oklab_lightness_for_tone(chroma: f32, hue_deg: f32, lstar: f32) -> f32 {
|
||||
let target = luminance_from_lstar(lstar);
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = 1.0_f32;
|
||||
for _ in 0..32 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if luminance(oklch_to_linear(mid, chroma, hue_deg)) < target {
|
||||
lo = mid;
|
||||
/// Java `Math.signum` 的口径:负数 −1、零 0、正数 1(**Rust 的 `f64::signum(0.0)` 是 1**,
|
||||
/// 直接换会改变中性色的求解结果)。
|
||||
pub(crate) fn signum(value: f64) -> f64 {
|
||||
if value < 0.0 {
|
||||
-1.0
|
||||
} else if value == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
hi = mid;
|
||||
1.0
|
||||
}
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
}
|
||||
|
||||
/// 在该 tone / 色相下,把彩度压进 sRGB gamut 后能取到的最大值(上限 `chroma_max`)。
|
||||
pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32 {
|
||||
let hi0 = chroma_max.max(0.0);
|
||||
if in_gamut(oklch_to_linear(
|
||||
oklab_lightness_for_tone(hi0, hue_deg, lstar),
|
||||
hi0,
|
||||
hue_deg,
|
||||
)) {
|
||||
return hi0;
|
||||
}
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = hi0;
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
let ok = in_gamut(oklch_to_linear(
|
||||
oklab_lightness_for_tone(mid, hue_deg, lstar),
|
||||
mid,
|
||||
hue_deg,
|
||||
));
|
||||
if ok {
|
||||
lo = mid;
|
||||
/// 角度归一到 `0.0..360.0`。
|
||||
pub(crate) fn sanitize_degrees(degrees: f64) -> f64 {
|
||||
let wrapped = degrees % 360.0;
|
||||
if wrapped < 0.0 {
|
||||
wrapped + 360.0
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// 该 tone / 色相下 sRGB 能表达的最大彩度(P3.2 的「按比例压彩度」用它做基准)。
|
||||
pub(crate) fn max_chroma(lstar: f32, hue_deg: f32) -> f32 {
|
||||
chroma_in_gamut(lstar, hue_deg, GAMUT_CHROMA_CAP)
|
||||
}
|
||||
|
||||
/// OKLCh 坐标(色相用度,0..360)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Oklch {
|
||||
/// 明度 0..1(中性灰时 ≈ 相对亮度的立方根)。
|
||||
pub lightness: f32,
|
||||
/// 彩度(0 = 中性灰)。
|
||||
pub chroma: f32,
|
||||
/// 色相角,单位度,0..360。
|
||||
pub hue_deg: f32,
|
||||
}
|
||||
|
||||
impl Oklch {
|
||||
/// 从一个 sRGB 颜色取色相/彩度(用作调色板的种子)。
|
||||
pub fn from_color(color: Color) -> Self {
|
||||
let lin = color.to_linear_f32();
|
||||
let [l, a, b] = linear_to_oklab(lin);
|
||||
let mut hue = b.atan2(a).to_degrees();
|
||||
if hue < 0.0 {
|
||||
hue += 360.0;
|
||||
}
|
||||
Self {
|
||||
lightness: l,
|
||||
chroma: (a * a + b * b).sqrt(),
|
||||
hue_deg: hue,
|
||||
}
|
||||
}
|
||||
|
||||
/// 在色相环上旋转(用于派生 tertiary 这类同族但不同色的调色板)。
|
||||
pub fn rotated(mut self, deg: f32) -> Self {
|
||||
self.hue_deg = (self.hue_deg + deg).rem_euclid(360.0);
|
||||
self
|
||||
wrapped
|
||||
}
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// 线性光强三元组(0..1,越界不夹取)。
|
||||
fn to_linear_f32(self) -> [f32; 3] {
|
||||
let f = |c: u8| srgb_to_linear(f32::from(c) / 255.0);
|
||||
[f(self.r), f(self.g), f(self.b)]
|
||||
/// 弧度归一到 `0.0..2π`(官方 `sanitizeRadians`,多加了 8π 保证非负)。
|
||||
pub(crate) fn sanitize_radians(angle: f64) -> f64 {
|
||||
(angle + std::f64::consts::PI * 8.0) % (std::f64::consts::PI * 2.0)
|
||||
}
|
||||
|
||||
/// 线性插值(官方 `lerp`)。
|
||||
pub(crate) fn lerp(start: f64, stop: f64, amount: f64) -> f64 {
|
||||
(1.0 - amount) * start + amount * stop
|
||||
}
|
||||
|
||||
/// 夹取到 `min..=max`。
|
||||
pub(crate) fn clamp(min: f64, max: f64, input: f64) -> f64 {
|
||||
input.clamp(min, max)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lstar_and_luminance_are_inverse() {
|
||||
for lstar in [0.0_f64, 8.0, 20.0, 50.0, 87.0, 98.0, 100.0] {
|
||||
let back = lstar_from_y(y_from_lstar(lstar));
|
||||
assert!((back - lstar).abs() < 1.0e-9, "L* {lstar} 往返成 {back}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_and_black_anchor_the_scale() {
|
||||
assert_eq!(delinearized(100.0), 255);
|
||||
assert_eq!(delinearized(0.0), 0);
|
||||
assert_eq!(color_from_lstar(100.0), Color::WHITE);
|
||||
assert_eq!(color_from_lstar(0.0), Color::BLACK);
|
||||
assert!((lstar_from_color(Color::WHITE) - 100.0).abs() < 1.0e-6);
|
||||
assert!(lstar_from_color(Color::BLACK).abs() < 1.0e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_point_matches_d65() {
|
||||
// 用官方那组舍入矩阵,纯白必须落在 D65 白点上(容差 1e-3 对应矩阵末位舍入)。
|
||||
let xyz = xyz_from_color(Color::WHITE);
|
||||
for (got, want) in xyz.iter().zip(WHITE_POINT_D65) {
|
||||
assert!(
|
||||
(got - want).abs() < 1.0e-3,
|
||||
"{xyz:?} vs D65 {WHITE_POINT_D65:?}"
|
||||
);
|
||||
}
|
||||
assert!(xyz_from_color(Color::BLACK).iter().all(|v| *v == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signum_matches_java_semantics() {
|
||||
assert_eq!(signum(-1.5), -1.0);
|
||||
assert_eq!(signum(0.0), 0.0);
|
||||
assert_eq!(signum(-0.0), 0.0);
|
||||
assert_eq!(signum(2.5), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degrees_sanitize_wraps_negatives() {
|
||||
assert!((sanitize_degrees(-30.0) - 330.0).abs() < 1.0e-9);
|
||||
assert!(sanitize_degrees(360.0).abs() < 1.0e-9);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +1,83 @@
|
||||
//! tone 调色板:一个种子色 → 一条「固定色相/彩度、tone 任取」的调色板。
|
||||
//! tone 调色板:**固定 HCT 色相 + 固定 HCT 彩度**,沿 tone 轴取色。
|
||||
//!
|
||||
//! 这是 M3 tonal palette 的**近似实现**:色相恒定、按 tone 取色、按 tone 压彩度进 sRGB gamut。
|
||||
//! 口径与已知偏差见 [`super`] 模块文档(OKLCh 而非 CAM16/HCT)。
|
||||
//! 这就是 M3 的 `TonalPalette`:一条调色板只回答一个问题——「我的第 N 号 tone 是什么色」。
|
||||
//! 因为 HCT 的 tone 轴是 `L*`,同一条调色板上「tone 差 40 ⇒ 对比度 ≥ 3:1」天然成立;
|
||||
//! 又因为 hue 固定,整条调色板在感知上是同一个色相(这正是 OKLCh 近似做不到的那一点)。
|
||||
//!
|
||||
//! 语义对齐 `java/palettes/TonalPalette.java`(Apache-2.0,见 `NOTICE`)。
|
||||
|
||||
use super::space::{self, Oklch};
|
||||
use super::Color;
|
||||
use super::{Color, Hct};
|
||||
|
||||
/// 一条 tone 调色板(色相固定 + 彩度上限)。
|
||||
/// 一条 tone 调色板(HCT 色相 + 彩度上限)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TonalPalette {
|
||||
hue_deg: f32,
|
||||
chroma: f32,
|
||||
hue_deg: f64,
|
||||
chroma: f64,
|
||||
}
|
||||
|
||||
impl TonalPalette {
|
||||
/// 直接给色相(度)与彩度上限(OKLCh 单位,0 = 中性灰)建调色板。
|
||||
pub fn new(hue_deg: f32, chroma: f32) -> Self {
|
||||
/// 直接给 HCT 色相(度)与 HCT 彩度建调色板(彩度 0 = 中性灰)。
|
||||
pub fn new(hue_deg: f64, chroma: f64) -> Self {
|
||||
Self {
|
||||
hue_deg: hue_deg.rem_euclid(360.0),
|
||||
chroma: chroma.max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从种子色取色相与彩度(等价 M3 的 `palette from seed`)。
|
||||
/// 从种子色取 HCT 色相与彩度(等价官方 `TonalPalette.fromInt`)。
|
||||
pub fn from_color(seed: Color) -> Self {
|
||||
let lch = Oklch::from_color(seed);
|
||||
Self::new(lch.hue_deg, lch.chroma)
|
||||
let hct = Hct::from_color(seed);
|
||||
Self::new(hct.hue, hct.chroma)
|
||||
}
|
||||
|
||||
/// 固定色相,改彩度上限(用于把种子彩度压到 secondary/neutral 档)。
|
||||
pub fn with_chroma(self, chroma: f32) -> Self {
|
||||
/// 固定色相,换彩度(M3 用它把种子的彩度压到 secondary/neutral 档)。
|
||||
pub fn with_chroma(self, chroma: f64) -> Self {
|
||||
Self::new(self.hue_deg, chroma)
|
||||
}
|
||||
|
||||
/// 彩度不超过 `limit`(种子本身更灰时保持更灰,不硬拉饱和)。
|
||||
pub fn limited_chroma(self, limit: f32) -> Self {
|
||||
self.with_chroma(self.chroma.min(limit))
|
||||
}
|
||||
|
||||
/// 旋转色相(derived hue)。
|
||||
pub fn rotated(self, deg: f32) -> Self {
|
||||
/// 旋转色相(derived hue,例如 tertiary = 种子色相 +60°)。
|
||||
pub fn rotated(self, deg: f64) -> Self {
|
||||
Self::new(self.hue_deg + deg, self.chroma)
|
||||
}
|
||||
|
||||
/// 该调色板的色相(度)。
|
||||
pub fn hue_deg(self) -> f32 {
|
||||
/// 该调色板的 HCT 色相(度)。
|
||||
pub fn hue_deg(self) -> f64 {
|
||||
self.hue_deg
|
||||
}
|
||||
|
||||
/// 该调色板的彩度上限(OKLCh 单位)。
|
||||
pub fn chroma(self) -> f32 {
|
||||
/// 该调色板的 HCT 彩度。
|
||||
pub fn chroma(self) -> f64 {
|
||||
self.chroma
|
||||
}
|
||||
|
||||
/// 取某个 tone 的颜色(tone 0..100,超出夹取)。
|
||||
/// 取某个 tone 的颜色(tone 超出 0..=100 时自然夹到纯黑/纯白)。
|
||||
///
|
||||
/// 实现口径:CIELAB `L*` 精确等于 tone(M3 的 tone 定义),彩度按该 tone 的 gamut 上限
|
||||
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
|
||||
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
|
||||
pub fn tone(self, tone: f32) -> Color {
|
||||
self.tone_scaled(tone, self.chroma)
|
||||
/// 彩度装不下时按官方的 gamut 映射**只压彩度**,色相与 tone 不变。
|
||||
pub fn tone(self, tone: f64) -> Color {
|
||||
self.hct(tone).to_color()
|
||||
}
|
||||
|
||||
/// 带「共享彩度基准」的取色:**所有调色板按同一比例压彩度进 gamut**。
|
||||
/// 取某个 tone 的 HCT 坐标(`TonalPalette.hct` / 官方 `getHct`)。
|
||||
///
|
||||
/// `reference_chroma` 一般是方案里最饱和那条调色板(primary)的彩度。压缩系数
|
||||
/// `f = min(1, C_max(tone,hue) / reference)`,每条调色板取 `C_req × f`——
|
||||
/// 这样 `primary` 与 `secondary` 在高 tone(tone 90/96 这类浅容器)上不会被**一起裁到**
|
||||
/// 同一个 gamut 上限而撞色,`secondary` 永远比 `primary` 更灰(M3 的语义就靠这个)。
|
||||
/// 单独用 [`TonalPalette::tone`] 时没有基准,等价于 `reference = 自身彩度`(直接裁剪)。
|
||||
pub fn tone_scaled(self, tone: f32, reference_chroma: f32) -> Color {
|
||||
let lstar = tone.clamp(0.0, 100.0);
|
||||
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
|
||||
if lstar <= 0.0 {
|
||||
return Color::BLACK;
|
||||
/// 黄相(105°..125°)的 tone 99 是官方的特例:那里 `L*` 与 sRGB 平面不平滑,
|
||||
/// 直接横向取自 tone 98 与 tone 100 的中点,避免出一格色带。
|
||||
pub fn hct(self, tone: f64) -> Hct {
|
||||
if tone == 99.0 && Hct::is_yellow(self.hue_deg) {
|
||||
let low = self.hct(98.0).to_color();
|
||||
let high = self.hct(100.0).to_color();
|
||||
return Hct::from_color(average(low, high));
|
||||
}
|
||||
if lstar >= 100.0 {
|
||||
return Color::WHITE;
|
||||
Hct::from_components(self.hue_deg, self.chroma, tone)
|
||||
}
|
||||
let c_max = space::max_chroma(lstar, self.hue_deg);
|
||||
let factor = if reference_chroma > 1.0e-6 {
|
||||
(c_max / reference_chroma).min(1.0)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// 两色的通道中点(官方 `averageArgb`,四舍五入)。
|
||||
fn average(a: Color, b: Color) -> Color {
|
||||
let mid = |x: u8, y: u8| {
|
||||
let sum = u16::from(x) + u16::from(y);
|
||||
u8::try_from(sum.div_ceil(2)).unwrap_or(u8::MAX)
|
||||
};
|
||||
let chroma = (self.chroma * factor).min(c_max).max(0.0);
|
||||
let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar);
|
||||
Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg))
|
||||
}
|
||||
Color::rgb(mid(a.r, b.r), mid(a.g, b.g), mid(a.b, b.b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -96,20 +86,17 @@ mod tests {
|
||||
|
||||
const SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
||||
|
||||
fn lstar_of(color: Color) -> f32 {
|
||||
color.lstar()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_matches_lstar_within_one_unit() {
|
||||
let palette = TonalPalette::from_color(SEED);
|
||||
fn tone_is_hit_within_quantisation_error() {
|
||||
let palette = TonalPalette::from_color(SEED).with_chroma(36.0);
|
||||
for tone in [
|
||||
0.0_f32, 6.0, 10.0, 30.0, 40.0, 50.0, 80.0, 90.0, 95.0, 98.0, 100.0,
|
||||
4.0_f64, 6.0, 10.0, 12.0, 17.0, 20.0, 30.0, 40.0, 50.0, 80.0, 87.0, 90.0, 92.0, 94.0,
|
||||
95.0, 96.0, 98.0,
|
||||
] {
|
||||
let got = lstar_of(palette.tone(tone));
|
||||
let got = Hct::from_color(palette.tone(tone)).tone;
|
||||
assert!(
|
||||
(got - tone).abs() <= 1.0,
|
||||
"tone {tone} 的 L* 是 {got},偏差超过 1(量化误差上限)"
|
||||
"tone {tone} 的 L* 是 {got},偏差超过 1(8 bit 量化上限)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,40 +111,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_hue_is_preserved_across_tones() {
|
||||
let palette = TonalPalette::from_color(SEED);
|
||||
for tone in [30.0_f32, 50.0, 70.0] {
|
||||
let lch = Oklch::from_color(palette.tone(tone));
|
||||
let delta = (lch.hue_deg - palette.hue_deg()).abs();
|
||||
fn hue_is_preserved_across_the_whole_ramp() {
|
||||
let palette = TonalPalette::new(298.980_997, 36.0);
|
||||
for tone in [10.0_f64, 30.0, 50.0, 70.0, 90.0] {
|
||||
let hct = palette.hct(tone);
|
||||
let delta = (hct.hue - palette.hue_deg()).abs();
|
||||
let delta = delta.min(360.0 - delta);
|
||||
assert!(delta < 3.0, "tone {tone} 色相偏移 {delta}° 过大");
|
||||
assert!(delta < 1.0, "tone {tone} 色相偏移 {delta}°");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_palette_is_gray() {
|
||||
fn chroma_zero_is_neutral_gray() {
|
||||
let neutral = TonalPalette::new(120.0, 0.0);
|
||||
let c = neutral.tone(50.0);
|
||||
let spread = f32::from(c.r.max(c.g).max(c.b) - c.r.min(c.g).min(c.b));
|
||||
assert!(
|
||||
spread <= 1.0,
|
||||
"彩度 0 的调色板应当接近中性灰,实测通道差 {spread}"
|
||||
);
|
||||
for tone in [6.0_f64, 30.0, 50.0, 90.0, 98.0] {
|
||||
let color = neutral.tone(tone);
|
||||
assert!(color.r == color.g && color.g == color.b, "{color:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chroma_never_leaves_gamut() {
|
||||
// 高彩度种子在极端 tone 上必须被裁到 gamut 内(不出现「负 RGB」削顶后的色带)。
|
||||
let hot = TonalPalette::new(30.0, 0.4);
|
||||
for tone in [5.0_f32, 20.0, 40.0, 60.0, 80.0, 95.0] {
|
||||
let lch = Oklch::from_color(hot.tone(tone));
|
||||
assert!(
|
||||
lch.chroma <= 0.4 + 1.0e-3,
|
||||
"tone {tone} 彩度 {} 超出请求上限",
|
||||
lch.chroma
|
||||
);
|
||||
let got = lstar_of(hot.tone(tone));
|
||||
assert!((got - tone).abs() <= 1.0, "tone {tone} 的 L* 是 {got}");
|
||||
fn yellow_tone_99_uses_the_official_average() {
|
||||
let palette = TonalPalette::new(110.0, 40.0);
|
||||
let expected = average(palette.tone(98.0), palette.tone(100.0));
|
||||
assert_eq!(palette.tone(99.0), expected);
|
||||
// 非黄相不触发特例:tone 99 走正常求解。
|
||||
let normal = TonalPalette::new(300.0, 40.0);
|
||||
assert_eq!(normal.tone(99.0), normal.hct(99.0).to_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_chroma_is_respected_when_the_gamut_allows() {
|
||||
// tone 50 是最「宽」的色调面,彩度 36 一定装得下。
|
||||
let palette = TonalPalette::new(298.980_997, 36.0);
|
||||
let hct = palette.hct(50.0);
|
||||
assert!((hct.chroma - 36.0).abs() < 0.5, "彩度 {}", hct.chroma);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! CAM16 的**观察条件**(viewing conditions)。
|
||||
//!
|
||||
//! HCT 的色相/彩度来自 CAM16,而 CAM16 是「在外界光环境下看起来如何」的模型——同一个 hex,
|
||||
//! 换观察条件就是不同的 hue/chroma。M3 固定用 sRGB 默认条件:D65 白点、200 lux(适应亮度)、
|
||||
//! 背景 `L* = 50`、surround = 2.0、**不做**光源折扣(自发光屏幕)。
|
||||
//!
|
||||
//! 照搬 `java/hct/ViewingConditions.java`(Apache-2.0,见 `NOTICE`)。
|
||||
|
||||
use super::space;
|
||||
|
||||
/// CAM16 观察条件里与颜色无关的那堆中间量(只为避免重复计算)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct ViewingConditions {
|
||||
/// 白点响应(achromatic response to the white point)。
|
||||
pub(crate) aw: f64,
|
||||
/// 背景亮度因子。
|
||||
pub(crate) n: f64,
|
||||
/// 亮度指数。
|
||||
pub(crate) nbb: f64,
|
||||
/// 彩度指数。
|
||||
pub(crate) ncb: f64,
|
||||
/// 环绕度决定的对比度系数。
|
||||
pub(crate) c: f64,
|
||||
/// 环绕度。
|
||||
pub(crate) nc: f64,
|
||||
/// 三通道的色适应折扣系数。
|
||||
pub(crate) rgb_d: [f64; 3],
|
||||
/// 亮度适应因子。
|
||||
pub(crate) fl: f64,
|
||||
/// 基函数。
|
||||
pub(crate) z: f64,
|
||||
}
|
||||
|
||||
// 与 XYZ 有关的常量矩阵在 CAM16 里也要用,从 space 引一次保持单一来源。
|
||||
use super::cam16::XYZ_TO_CAM16_RGB;
|
||||
|
||||
impl ViewingConditions {
|
||||
/// 按官方参数构造(`white_point` 是 XYZ,`adapting_luminance` 由 lux 换算)。
|
||||
pub(crate) fn make(
|
||||
white_point: [f64; 3],
|
||||
adapting_luminance: f64,
|
||||
background_lstar: f64,
|
||||
surround: f64,
|
||||
discounting_illuminant: bool,
|
||||
) -> Self {
|
||||
// 纯黑背景是非物理的,会让「任何颜色都看不见」这类无穷大出现,官方取下限 0.1。
|
||||
let background_lstar = background_lstar.max(0.1);
|
||||
let [r_w, g_w, b_w] = space::matrix_multiply(white_point, XYZ_TO_CAM16_RGB);
|
||||
let f = 0.8 + surround / 10.0;
|
||||
let c = if f >= 0.9 {
|
||||
space::lerp(0.59, 0.69, (f - 0.9) * 10.0)
|
||||
} else {
|
||||
space::lerp(0.525, 0.59, (f - 0.8) * 10.0)
|
||||
};
|
||||
let d = if discounting_illuminant {
|
||||
1.0
|
||||
} else {
|
||||
let raw = f * (1.0 - (1.0 / 3.6) * ((-adapting_luminance - 42.0) / 92.0).exp());
|
||||
space::clamp(0.0, 1.0, raw)
|
||||
};
|
||||
let nc = f;
|
||||
let rgb_d = [
|
||||
d * (100.0 / r_w) + 1.0 - d,
|
||||
d * (100.0 / g_w) + 1.0 - d,
|
||||
d * (100.0 / b_w) + 1.0 - d,
|
||||
];
|
||||
let k = 1.0 / (5.0 * adapting_luminance + 1.0);
|
||||
let k4 = k * k * k * k;
|
||||
let k4_f = 1.0 - k4;
|
||||
let fl = k4 * adapting_luminance + 0.1 * k4_f * k4_f * (5.0 * adapting_luminance).cbrt();
|
||||
let n = space::y_from_lstar(background_lstar) / white_point[1];
|
||||
let z = 1.48 + n.sqrt();
|
||||
let nbb = 0.725 / n.powf(0.2);
|
||||
let ncb = nbb;
|
||||
let rgb_a_factors = [
|
||||
(fl * rgb_d[0] * r_w / 100.0).powf(0.42),
|
||||
(fl * rgb_d[1] * g_w / 100.0).powf(0.42),
|
||||
(fl * rgb_d[2] * b_w / 100.0).powf(0.42),
|
||||
];
|
||||
let rgb_a = [
|
||||
400.0 * rgb_a_factors[0] / (rgb_a_factors[0] + 27.13),
|
||||
400.0 * rgb_a_factors[1] / (rgb_a_factors[1] + 27.13),
|
||||
400.0 * rgb_a_factors[2] / (rgb_a_factors[2] + 27.13),
|
||||
];
|
||||
let aw = (2.0 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2]) * nbb;
|
||||
Self {
|
||||
aw,
|
||||
n,
|
||||
nbb,
|
||||
ncb,
|
||||
c,
|
||||
nc,
|
||||
rgb_d,
|
||||
fl,
|
||||
z,
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定背景 `L*` 的 sRGB 默认条件。
|
||||
pub(crate) fn default_with_background_lstar(background_lstar: f64) -> Self {
|
||||
Self::make(
|
||||
space::WHITE_POINT_D65,
|
||||
200.0 / std::f64::consts::PI * space::y_from_lstar(50.0) / 100.0,
|
||||
background_lstar,
|
||||
2.0,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 全仓共用的默认观察条件(sRGB 屏,背景 `L* = 50`)。
|
||||
pub(crate) fn default_conditions() -> &'static ViewingConditions {
|
||||
static DEFAULT: std::sync::OnceLock<ViewingConditions> = std::sync::OnceLock::new();
|
||||
DEFAULT.get_or_init(|| ViewingConditions::default_with_background_lstar(50.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_conditions_match_the_official_constants() {
|
||||
// 官方 material-color-utilities 的 ViewingConditions.DEFAULT 解出来的值(Java 实测,
|
||||
// `hct.Count` 打印)。
|
||||
let vc = default_conditions();
|
||||
assert!(
|
||||
(vc.n - 0.184_186_518_512_444_16).abs() < 1.0e-12,
|
||||
"n = {}",
|
||||
vc.n
|
||||
);
|
||||
assert!(
|
||||
(vc.aw - 29.980_997_194_447_333).abs() < 1.0e-12,
|
||||
"aw = {}",
|
||||
vc.aw
|
||||
);
|
||||
assert!(
|
||||
(vc.nbb - 1.016_919_180_445_875_5).abs() < 1.0e-12,
|
||||
"nbb = {}",
|
||||
vc.nbb
|
||||
);
|
||||
assert!((vc.c - 0.69).abs() < 1.0e-12);
|
||||
assert!((vc.nc - 1.0).abs() < 1.0e-12);
|
||||
assert!(
|
||||
(vc.fl - 0.388_481_453_780_035_3).abs() < 1.0e-12,
|
||||
"fl = {}",
|
||||
vc.fl
|
||||
);
|
||||
assert!(
|
||||
(vc.z - 1.909_169_568_483_652).abs() < 1.0e-12,
|
||||
"z = {}",
|
||||
vc.z
|
||||
);
|
||||
assert!((vc.rgb_d[0] - 1.021_177_702_757_52).abs() < 1.0e-12);
|
||||
assert!((vc.rgb_d[1] - 0.986_307_729_428_012_4).abs() < 1.0e-12);
|
||||
assert!((vc.rgb_d[2] - 0.933_960_508_280_229_9).abs() < 1.0e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_point_has_zero_chroma_in_cam16() {
|
||||
// CAM16 的白点在默认条件下应当接近中性(这才是「色适应」的意义)。
|
||||
use super::super::cam16::Cam16;
|
||||
let white = Cam16::from_color(super::super::Color::WHITE);
|
||||
assert!(white.chroma < 5.0, "白点彩度 {}", white.chroma);
|
||||
}
|
||||
}
|
||||
@@ -54,13 +54,13 @@ const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
|
||||
Role::Outline,
|
||||
Role::Surface,
|
||||
AA_NON_TEXT,
|
||||
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.29:1、dark 6.5:1 已高于目标)",
|
||||
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.26:1、dark 5.89:1 已高于目标)",
|
||||
),
|
||||
(
|
||||
Role::OutlineVariant,
|
||||
Role::Surface,
|
||||
AA_NON_TEXT * 0.5,
|
||||
"分隔线是纯装饰,M3 不要求对比度下限;1.6:1 是「几乎看不见但要能数出层级」的刻意取值",
|
||||
"分隔线是纯装饰,M3 不要求对比度下限;light 1.62:1 / dark 1.99:1 是「几乎看不见但要能数出层级」的取值",
|
||||
),
|
||||
(
|
||||
Role::SurfaceVariant,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! 自定义强调色 × 官方算法的**逐角色对齐**(`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}°");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! 与官方 `material-color-utilities` 的**逐角色对齐**(baseline 方案)。
|
||||
//!
|
||||
//! 期望值不是「我们觉得对的颜色」,而是把官方 Java 实现(Apache-2.0)编译跑出来的结果:
|
||||
//! `new SchemeTonalSpot(Hct.fromInt(0xFF6750A4), isDark, 0.0, SpecVersion.SPEC_2021, Platform.PHONE)`
|
||||
//! 配上 `ColorSpec2021` 的 49 个动态色。跑法见 `NOTICE` 的归属声明——期望值表就是那份输出的
|
||||
//! 抄本(`seed,flavor,role,hex`)。
|
||||
//!
|
||||
//! 判据是**每通道误差 ≤1**:8 bit 量化本身就有半格误差,再加上 Java `double` 与 Rust `f64`
|
||||
//! 的末位差异,1 是「同一个算法」的合理上限;2 以上就是实现真的不一样了。
|
||||
|
||||
use unlucky_design::color::{Color, Hct, Role, Scheme, SchemeFlavor, BASELINE_SEED};
|
||||
|
||||
/// 每通道允许的最大误差。
|
||||
const CHANNEL_TOLERANCE: i16 = 1;
|
||||
|
||||
/// 官方 baseline 输出(seed `#6750A4`,Tonal Spot,ColorSpec2021,contrastLevel 0)。
|
||||
const BASELINE: [(Role, &str, &str); 49] = [
|
||||
(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"),
|
||||
(Role::Error, "#BA1A1A", "#FFB4AB"),
|
||||
(Role::OnError, "#FFFFFF", "#690005"),
|
||||
(Role::ErrorContainer, "#FFDAD6", "#93000A"),
|
||||
(Role::OnErrorContainer, "#93000A", "#FFDAD6"),
|
||||
(Role::Surface, "#FDF7FF", "#141218"),
|
||||
(Role::OnSurface, "#1D1B20", "#E6E0E9"),
|
||||
(Role::SurfaceVariant, "#E7E0EB", "#49454E"),
|
||||
(Role::OnSurfaceVariant, "#49454E", "#CAC4CF"),
|
||||
(Role::Background, "#FDF7FF", "#141218"),
|
||||
(Role::OnBackground, "#1D1B20", "#E6E0E9"),
|
||||
(Role::SurfaceDim, "#DED8E0", "#141218"),
|
||||
(Role::SurfaceBright, "#FDF7FF", "#3B383E"),
|
||||
(Role::SurfaceContainerLowest, "#FFFFFF", "#0F0D13"),
|
||||
(Role::SurfaceContainerLow, "#F8F2FA", "#1D1B20"),
|
||||
(Role::SurfaceContainer, "#F2ECF4", "#211F24"),
|
||||
(Role::SurfaceContainerHigh, "#ECE6EE", "#2B292F"),
|
||||
(Role::SurfaceContainerHighest, "#E6E0E9", "#36343A"),
|
||||
(Role::Outline, "#7A757F", "#948F99"),
|
||||
(Role::OutlineVariant, "#CAC4CF", "#49454E"),
|
||||
(Role::InverseSurface, "#322F35", "#E6E0E9"),
|
||||
(Role::InverseOnSurface, "#F5EFF7", "#322F35"),
|
||||
(Role::InversePrimary, "#CFBDFE", "#65558F"),
|
||||
(Role::Shadow, "#000000", "#000000"),
|
||||
(Role::Scrim, "#000000", "#000000"),
|
||||
(Role::SurfaceTint, "#65558F", "#CFBDFE"),
|
||||
(Role::PrimaryFixed, "#E9DDFF", "#E9DDFF"),
|
||||
(Role::PrimaryFixedDim, "#CFBDFE", "#CFBDFE"),
|
||||
(Role::OnPrimaryFixed, "#201047", "#201047"),
|
||||
(Role::OnPrimaryFixedVariant, "#4D3D75", "#4D3D75"),
|
||||
(Role::SecondaryFixed, "#E8DEF8", "#E8DEF8"),
|
||||
(Role::SecondaryFixedDim, "#CBC2DB", "#CBC2DB"),
|
||||
(Role::OnSecondaryFixed, "#1E192B", "#1E192B"),
|
||||
(Role::OnSecondaryFixedVariant, "#4A4458", "#4A4458"),
|
||||
(Role::TertiaryFixed, "#FFD9E3", "#FFD9E3"),
|
||||
(Role::TertiaryFixedDim, "#EFB8C8", "#EFB8C8"),
|
||||
(Role::OnTertiaryFixed, "#31101D", "#31101D"),
|
||||
(Role::OnTertiaryFixedVariant, "#633B48", "#633B48"),
|
||||
];
|
||||
|
||||
/// 官方 `Hct.from(hue, chroma, tone)` 的色值(求解器与 gamut 映射的直接证据)。
|
||||
const HCT_PROBES: [(f64, f64, f64, &str); 12] = [
|
||||
(95.0, 36.0, 40.0, "#705D0D"),
|
||||
(95.0, 36.0, 90.0, "#FCE186"),
|
||||
(95.0, 16.0, 90.0, "#F0E2BB"),
|
||||
(96.0, 24.0, 80.0, "#D7C68D"),
|
||||
(25.0, 84.0, 40.0, "#BA1A1A"),
|
||||
(0.0, 0.0, 50.0, "#777777"),
|
||||
(200.0, 120.0, 70.0, "#00BEC3"),
|
||||
(30.0, 0.5, 98.0, "#FDF8F7"),
|
||||
(270.0, 200.0, 10.0, "#001847"),
|
||||
(120.0, 60.0, 99.0, "#FCFFDD"),
|
||||
(60.0, 6.0, 6.0, "#19120C"),
|
||||
(95.0, 36.0, 30.0, "#554500"),
|
||||
];
|
||||
|
||||
/// 三通道里最大的绝对误差。
|
||||
fn worst_channel(got: Color, want: Color) -> i16 {
|
||||
let pairs = [(got.r, want.r), (got.g, want.g), (got.b, want.b)];
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_matches_official_on_all_forty_nine_roles() {
|
||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||
let dark = Scheme::baseline(SchemeFlavor::Dark);
|
||||
let mut worst = 0;
|
||||
let mut off_by_one = 0;
|
||||
let mut failures = Vec::new();
|
||||
for (role, want_light, want_dark) in BASELINE {
|
||||
for (flavor, scheme, want_hex) in [
|
||||
(SchemeFlavor::Light, light, want_light),
|
||||
(SchemeFlavor::Dark, dark, want_dark),
|
||||
] {
|
||||
let got = scheme.get(role);
|
||||
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||
let delta = worst_channel(got, want);
|
||||
worst = worst.max(delta);
|
||||
if delta > 0 {
|
||||
off_by_one += 1;
|
||||
}
|
||||
if delta > CHANNEL_TOLERANCE {
|
||||
failures.push(format!(
|
||||
"{}/{} 是 {},官方 {want_hex}(差 {delta})",
|
||||
flavor.name(),
|
||||
role.name(),
|
||||
got.to_hex()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{} 个角色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
|
||||
failures.len(),
|
||||
failures.join("\n")
|
||||
);
|
||||
// 把误差统计打出来(`cargo test -- --nocapture` 可见),报告里要引用。
|
||||
println!("baseline 对齐:98 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hct_solver_matches_official_color_spec_2021() {
|
||||
for (hue, chroma, tone, want_hex) in HCT_PROBES {
|
||||
let got = Hct::from_components(hue, chroma, tone).to_color();
|
||||
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
|
||||
assert!(
|
||||
worst_channel(got, want) <= CHANNEL_TOLERANCE,
|
||||
"HCT({hue}, {chroma}, {tone}) 是 {},官方 {want_hex}",
|
||||
got.to_hex()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
||||
// 常见误解:baseline() 的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
|
||||
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
|
||||
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
|
||||
let seed = Hct::from_color(BASELINE_SEED);
|
||||
assert!(
|
||||
(seed.chroma - 47.856_526).abs() < 1.0e-4,
|
||||
"种子彩度 {}",
|
||||
seed.chroma
|
||||
);
|
||||
let primary = Scheme::baseline(SchemeFlavor::Light).get(Role::Primary);
|
||||
assert_eq!(primary.to_hex(), "#65558F");
|
||||
assert_ne!(primary, BASELINE_SEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_tone_axis_is_still_exact_lstar() {
|
||||
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::baseline(flavor);
|
||||
for (role, color) in scheme.iter() {
|
||||
let tone = Scheme::tone_of(role, flavor);
|
||||
let got = f64::from(color.lstar());
|
||||
assert!(
|
||||
(got - tone).abs() <= 1.0,
|
||||
"{}/{} 的 tone 声明 {tone}、实测 {got}",
|
||||
flavor.name(),
|
||||
role.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_family_containers_are_separated_by_hct_chroma() {
|
||||
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
|
||||
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
|
||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||
let primary = light.get(Role::PrimaryContainer);
|
||||
let secondary = light.get(Role::SecondaryContainer);
|
||||
assert_ne!(primary, secondary);
|
||||
let c_primary = Hct::from_color(primary).chroma;
|
||||
let c_secondary = Hct::from_color(secondary).chroma;
|
||||
assert!(
|
||||
c_secondary < c_primary,
|
||||
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||
);
|
||||
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅紫」而不是被裁到灰。
|
||||
assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}");
|
||||
}
|
||||
@@ -212,11 +212,11 @@ fn design_tokens_entry_point() {
|
||||
|
||||
#[test]
|
||||
fn tonal_palette_hits_requested_tones() {
|
||||
let palette = TonalPalette::new(150.0, 0.1);
|
||||
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
|
||||
let palette = TonalPalette::new(150.0, 36.0);
|
||||
for tone in [20.0_f64, 40.0, 60.0, 80.0] {
|
||||
let color = palette.tone(tone);
|
||||
assert!(
|
||||
(color.lstar() - tone).abs() <= 1.0,
|
||||
(f64::from(color.lstar()) - tone).abs() <= 1.0,
|
||||
"tone {tone} 实际 {}",
|
||||
color.lstar()
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::motion::{DurationToken, Easing};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
fn expect_tone(role: Role, light: f32, dark: f32) {
|
||||
fn expect_tone(role: Role, light: f64, dark: f64) {
|
||||
assert_eq!(
|
||||
Scheme::tone_of(role, SchemeFlavor::Light),
|
||||
light,
|
||||
@@ -28,24 +28,24 @@ fn all_forty_nine_tones_are_pinned() {
|
||||
expect_tone(Role::Primary, 40.0, 80.0);
|
||||
expect_tone(Role::OnPrimary, 100.0, 20.0);
|
||||
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnPrimaryContainer, 10.0, 90.0);
|
||||
expect_tone(Role::OnPrimaryContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Secondary, 40.0, 80.0);
|
||||
expect_tone(Role::OnSecondary, 100.0, 20.0);
|
||||
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnSecondaryContainer, 10.0, 90.0);
|
||||
expect_tone(Role::OnSecondaryContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Tertiary, 40.0, 80.0);
|
||||
expect_tone(Role::OnTertiary, 100.0, 20.0);
|
||||
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnTertiaryContainer, 10.0, 90.0);
|
||||
expect_tone(Role::OnTertiaryContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Error, 40.0, 80.0);
|
||||
expect_tone(Role::OnError, 100.0, 20.0);
|
||||
expect_tone(Role::ErrorContainer, 90.0, 30.0);
|
||||
expect_tone(Role::OnErrorContainer, 10.0, 90.0);
|
||||
expect_tone(Role::OnErrorContainer, 30.0, 90.0);
|
||||
expect_tone(Role::Surface, 98.0, 6.0);
|
||||
expect_tone(Role::OnSurface, 10.0, 90.0);
|
||||
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
|
||||
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
|
||||
expect_tone(Role::Background, 99.0, 10.0);
|
||||
expect_tone(Role::Background, 98.0, 6.0);
|
||||
expect_tone(Role::OnBackground, 10.0, 90.0);
|
||||
expect_tone(Role::SurfaceDim, 87.0, 6.0);
|
||||
expect_tone(Role::SurfaceBright, 98.0, 24.0);
|
||||
@@ -241,8 +241,8 @@ fn same_family_containers_do_not_collide() {
|
||||
"同族容器撞色(彩度压缩没生效):{primary:?}"
|
||||
);
|
||||
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
|
||||
let c_primary = unlucky_design::color::space::Oklch::from_color(primary).chroma;
|
||||
let c_secondary = unlucky_design::color::space::Oklch::from_color(secondary).chroma;
|
||||
let c_primary = unlucky_design::color::Hct::from_color(primary).chroma;
|
||||
let c_secondary = unlucky_design::color::Hct::from_color(secondary).chroma;
|
||||
assert!(
|
||||
c_secondary < c_primary,
|
||||
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||
|
||||
Reference in new issue
Block a user