diff --git a/NOTICE b/NOTICE index 23e5380..8990ff7 100644 --- a/NOTICE +++ b/NOTICE @@ -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,特此致谢。 diff --git a/crates/design/src/color/cam16.rs b/crates/design/src/color/cam16.rs new file mode 100644 index 0000000..3a0650c --- /dev/null +++ b/crates/design/src/color/cam16.rs @@ -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); + } +} diff --git a/crates/design/src/color/critical_planes.rs b/crates/design/src/color/critical_planes.rs new file mode 100644 index 0000000..46a72aa --- /dev/null +++ b/crates/design/src/color/critical_planes.rs @@ -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, +]; diff --git a/crates/design/src/color/hct.rs b/crates/design/src/color/hct.rs new file mode 100644 index 0000000..228446d --- /dev/null +++ b/crates/design/src/color/hct.rs @@ -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)); + } +} diff --git a/crates/design/src/color/hct_solver.rs b/crates/design/src/color/hct_solver.rs new file mode 100644 index 0000000..02f11e2 --- /dev/null +++ b/crates/design/src/color/hct_solver.rs @@ -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 { + 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); + } +} diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index 0d76ca2..1ab6270 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -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}"); + } + } } diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index 171a061..732d2e3 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -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()); diff --git a/crates/design/src/color/scheme.rs b/crates/design/src/color/scheme.rs index 30e9150..0259a15 100644 --- a/crates/design/src/color/scheme.rs +++ b/crates/design/src/color/scheme.rs @@ -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)); } } diff --git a/crates/design/src/color/space.rs b/crates/design/src/color/space.rs index 7ad29e1..04f92cd 100644 --- a/crates/design/src/color/space.rs +++ b/crates/design/src/color/space.rs @@ -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; - } else { - hi = 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 { + 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; +/// 角度归一到 `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 { + wrapped } - 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; - } 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) +/// 弧度归一到 `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) } -/// 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, +/// 线性插值(官方 `lerp`)。 +pub(crate) fn lerp(start: f64, stop: f64, amount: f64) -> f64 { + (1.0 - amount) * start + amount * stop } -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, +/// 夹取到 `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}"); } } - /// 在色相环上旋转(用于派生 tertiary 这类同族但不同色的调色板)。 - pub fn rotated(mut self, deg: f32) -> Self { - self.hue_deg = (self.hue_deg + deg).rem_euclid(360.0); - self + #[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); } -} -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)] + #[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); } } diff --git a/crates/design/src/color/tonal.rs b/crates/design/src/color/tonal.rs index a9fc8c4..f30e860 100644 --- a/crates/design/src/color/tonal.rs +++ b/crates/design/src/color/tonal.rs @@ -1,115 +1,102 @@ -//! 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; - } - 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 - }; - 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)) + Hct::from_components(self.hue_deg, self.chroma, tone) } } +/// 两色的通道中点(官方 `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) + }; + Color::rgb(mid(a.r, b.r), mid(a.g, b.g), mid(a.b, b.b)) +} + #[cfg(test)] mod tests { use super::*; 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); } } diff --git a/crates/design/src/color/viewing_conditions.rs b/crates/design/src/color/viewing_conditions.rs new file mode 100644 index 0000000..09f993b --- /dev/null +++ b/crates/design/src/color/viewing_conditions.rs @@ -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 = 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); + } +} diff --git a/crates/design/tests/contrast_aa.rs b/crates/design/tests/contrast_aa.rs index a5650b8..e0f365e 100644 --- a/crates/design/tests/contrast_aa.rs +++ b/crates/design/tests/contrast_aa.rs @@ -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, diff --git a/crates/design/tests/official_accent.rs b/crates/design/tests/official_accent.rs new file mode 100644 index 0000000..8481ad5 --- /dev/null +++ b/crates/design/tests/official_accent.rs @@ -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}°"); + } +} diff --git a/crates/design/tests/official_baseline.rs b/crates/design/tests/official_baseline.rs new file mode 100644 index 0000000..44b0bf6 --- /dev/null +++ b/crates/design/tests/official_baseline.rs @@ -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}"); +} diff --git a/crates/design/tests/token_contract.rs b/crates/design/tests/token_contract.rs index 079f146..5940ecb 100644 --- a/crates/design/tests/token_contract.rs +++ b/crates/design/tests/token_contract.rs @@ -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() ); diff --git a/crates/design/tests/token_pins.rs b/crates/design/tests/token_pins.rs index ff1cf60..29c580d 100644 --- a/crates/design/tests/token_pins.rs +++ b/crates/design/tests/token_pins.rs @@ -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}"