From dffae2c429b493ef185e0afa4aea47ab4019ec68 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 11:25:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M79=20r2=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E5=88=86=E9=9A=94=E7=BA=BF=E5=AE=88=E5=8D=AB=E6=8D=A2?= =?UTF-8?q?=E6=88=90=E3=80=8C=E9=94=9A=E5=AE=9A=E7=BB=86=E7=BA=BF=E8=A1=8C?= =?UTF-8?q?=20+=20=E6=95=B4=E6=AE=B5=E8=B7=A8=E5=BA=A6=E6=95=B0=E6=AE=B5?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码复审 r2 P1-1:上一条守卫只认 r1 那种「半透明 AA 缝」,四处假通过。 两个结构性原因与对应修法: 1. **窗口被截成 150px**:`longest_h_line` 凑满 150px 就 return,`line_segments` 只在那 150px 里数段 → 分隔线实长 242px,只看前 62%;缺口在窗口外看不见、在窗口内则被 「跳到缺口之后重新起跑」滑过。现在 `find_thin_line` 返回的是**该行整段墨迹跨度**, `line_segments` 在这个跨度上数段。 2. **行的锚定不稳**:原来「第一条 ≥150px 的连续带」能被五态行的 40px 高底色带、 `Primary` 指示条顶替(mutE/mutF 会量到完全不同的元素)。现在四条判据一起锚定: 跨度 ≥200 · 近乎实心(墨迹 ≥ 跨度×4/5)· 薄(跨中点纵向墨迹 ≤3 行)· 低对比 (线色与底色单通道差 ≤120)。 `TOL=6` 的口径写进注释:**单通道**最大可辨差;40% 覆盖的抗锯齿行实测差 `(9,6,4)`, 6 能看见它又挡住 ±3 渲染噪声;相邻那行是满覆盖(线色差 25/17/10),所以行锚定不会丢。 五次验收(真跑 probe,退出码即判据;变异图在 `target/m79/{mutB,mutC,mutE,mutF}/` 由 r2/r1 图改像素生成,不入库): | 图 | 结果 | 实测 | |---|---|---| | r2 原图 | **rc=0 绿** | 1 段 | | `before/`(r1 逐 tab + AA 缝) | **rc=101 红** | 3 段,y=471 x24..265 | | mutB(缺口 x=200..201,旧窗口外) | **rc=101 红** | 2 段,y=471 x24..266 | | mutC(缺口 x=100..101,旧窗口内) | **rc=101 红** | 2 段,y=471 x24..266 | | mutE(r1 两个缝位 102/192 挖真缝) | **rc=101 红** | 3 段,**y=471 x24..266**(仍是分隔线那行,不是 y=503 的五态带) | | mutF(额外:r1 分段线 + 上方 200px Primary 带) | **rc=101 红** | 3 段,y=471(锚定没被 Primary 带顶替) | 门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · 全套 probe 对 r2 图 5 passed · `clippy --all-targets -D warnings` 干净。未改任何组件实现。 --- .../src/components/navigation/probe.rs | 19 +++-- .../src/components/navigation/probe/scan.rs | 84 +++++++++++++++---- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/crates/app-iced/src/components/navigation/probe.rs b/crates/app-iced/src/components/navigation/probe.rs index 9e6b59a..c7ffb24 100644 --- a/crates/app-iced/src/components/navigation/probe.rs +++ b/crates/app-iced/src/components/navigation/probe.rs @@ -198,20 +198,21 @@ fn tabs_indicator_tracks_the_selected_tab() { ); measured.push((bar.width(), ink)); } - // 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留 1px 亮缝, + // 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留缝, // 视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`)。 // - // 判法:1px 的线画在分数像素上 → 真实像素是「线色 ⊗ 页面底色」的**混合值**(实测 - // `(221,233,245)`,不是 `SurfaceVariant` 的原值),所以不能按精确色找块。改成: - // 先找「与页面底色不同、横跨 >150px 的那一行」,再用**该行自己的颜色**数连通段。 + // 判法(三处都是被真实假通过逼出来的,见 `scan::Shot::find_thin_line` 文档): + // ① 按「细 + 近乎实心 + 低对比 + 跨度 ≥200」锚定**分隔线那一行**(跳过标题行、 + // 五态行的 40px 底色带、Primary 指示条); + // ② 在该行的**整段墨迹跨度**上数段——不是在某次 run 的 150px 窗口里数,否则缺口会被滑过。 let surface = rgb(ui.color(Role::Surface)); - let divider = shot - .longest_h_line(surface, 150) - .expect("Secondary 那一行应有一条长横线"); - let segments = shot.line_segments(divider.0, divider.1, divider.2); + let (y, x0, x1) = shot + .find_thin_line(surface, 200, 120) + .expect("Secondary 那一行应有一条细长横线"); + let segments = shot.line_segments(y, x0, x1); assert_eq!( segments, 1, - "Secondary 分隔线应是一条直线,实测 {segments} 段(分段画会留缝)" + "Secondary 分隔线应是一条直线,实测 {segments} 段(量到 y={y}, x{x0}..{x1})—— 分段画会在交界处留缝" ); let (short_bar, long_bar) = (measured[0].0, measured[1].0); diff --git a/crates/app-iced/src/components/navigation/probe/scan.rs b/crates/app-iced/src/components/navigation/probe/scan.rs index 3a9b9c5..4baf324 100644 --- a/crates/app-iced/src/components/navigation/probe/scan.rs +++ b/crates/app-iced/src/components/navigation/probe/scan.rs @@ -181,30 +181,80 @@ impl Shot { span } - /// 全图里**第一行**横跨 ≥ `min_len` 的「与底色不同」的连续像素:返回 `(y, x0, x1)`。 + /// 找「一条**细、低对比、近乎实心**的长横线」,返回 `(y, x0, x1)`: + /// `y` 是线最实的那一行,`x0..x1` 是**该行整段墨迹的跨度**(不是某一次连续 run 的窗口)。 /// - /// 用于找 1px 的分隔线:它的颜色是混合值,不能按精确色找。 - pub(super) fn longest_h_line( + /// 四条判据,每条都对应一次真实的假通过(视觉 r1 P2-2 复核时被点出来的): + /// + /// 1. **跨度 ≥ `min_len`**(200):`Secondary` 分隔线整行 242px;标题/标签行凑不满,能跳过它们。 + /// 2. **近乎实心**(墨迹数 ≥ 跨度 × 4/5):逐 tab 分段时中间是真缺口,但只有 1~2px,仍算实心 + /// ——**必须先能找到这一行**,再由 [`Shot::line_segments`] 把缺口数成多段。 + /// (早先「凑满 150px 就 return,然后在窗口里数段」的写法会滑过缺口,永远数到 1。) + /// 3. **薄**(跨中点纵向墨迹 ≤ 3 行):五态行里的 hover 底色是 40px 高的带,被这条排除 + /// (早先的写法会把那条 224px 的带选中,量到完全不同的元素)。 + /// 4. **低对比**(线色与底色的单通道最大差 ≤ `max_contrast`):`Primary` 指示条是高饱和色,被排除。 + /// + /// `TOL` 的口径:**单通道**最大可辨差,取 6。40% 覆盖的抗锯齿行实测单通道差 `(9,6,4)`, + /// 6 能看见它、又挡住 ±3 的渲染噪声;覆盖再低的行会落到判据外,但**相邻那行是满覆盖** + /// (实测线色差 25/17/10),所以行的锚定不会丢。 + pub(super) fn find_thin_line( &self, background: Rgb, min_len: usize, + max_contrast: u8, ) -> Option<(usize, usize, usize)> { + const TOL: u8 = 6; + let is_ink = |pixel: Rgb| differs(pixel, background, TOL); + let contrast = |pixel: Rgb| { + pixel + .iter() + .zip(background) + .map(|(p, q)| p.abs_diff(q)) + .max() + .unwrap_or(0) + }; + for y in 0..self.height { - let mut run = 0; - let mut start = 0; - for x in 0..self.width { - if differs(self.at(x, y), background, 6) { - if run == 0 { - start = x; - } - run += 1; - if run >= min_len { - return Some((y, start, x)); - } - } else { - run = 0; - } + let ink: Vec = (0..self.width).filter(|x| is_ink(self.at(*x, y))).collect(); + let (Some(&first), Some(&last)) = (ink.first(), ink.last()) else { + continue; + }; + let len = last - first + 1; + if len < min_len || ink.len() * 5 < len * 4 { + continue; } + // 薄:跨中点纵向墨迹 ≤ 3 行 + let mid = (first + last) / 2; + let mut top = y; + while top > 0 && is_ink(self.at(mid, top - 1)) { + top -= 1; + } + let mut bottom = y; + while bottom + 1 < self.height && is_ink(self.at(mid, bottom + 1)) { + bottom += 1; + } + if bottom - top + 1 > 3 { + continue; + } + // 低对比:取这条细带里最实的一行(满覆盖),跨段与线色都按那一行算 + let best = (top..=bottom) + .max_by_key(|candidate| { + (first..=last) + .filter(|x| is_ink(self.at(*x, *candidate))) + .count() + }) + .unwrap_or(y); + let line = self.at( + (first..=last) + .find(|x| is_ink(self.at(*x, best))) + .unwrap_or(mid), + best, + ); + let c = contrast(line); + if c > max_contrast || c <= TOL { + continue; + } + return Some((best, first, last)); } None }