diff --git a/src/brush_paint.rs b/src/brush_paint.rs index 04c2662b..4acb607e 100644 --- a/src/brush_paint.rs +++ b/src/brush_paint.rs @@ -1590,6 +1590,12 @@ pub const SINGLE_STROKE_LETTERS: &str = "CGIJLMNOSUVWZcejilosvwz"; /// penalty applies when stroke count ≠ 2. pub const TWO_STROKE_LETTERS: &str = "TtXxKkYyFfHh"; +/// Letters made entirely of straight strokes — the curvature penalty +/// fires only on these. Curvy letters (O/S/G/c/e/...) need to bend, so +/// applying a uniform curvature cost there penalised the natural form. +/// Excludes ambiguous cases (lowercase k/t/f) where fonts may curve. +pub const STRAIGHT_STROKE_LETTERS: &str = "AEFHIKLMNTVWXYZilvwxz"; + pub fn is_single_stroke_letter(ch: char) -> bool { SINGLE_STROKE_LETTERS.contains(ch) } @@ -1598,6 +1604,36 @@ pub fn is_two_stroke_letter(ch: char) -> bool { TWO_STROKE_LETTERS.contains(ch) } +pub fn is_straight_letter(ch: char) -> bool { + STRAIGHT_STROKE_LETTERS.contains(ch) +} + +/// Exponential rate for the unpainted-cluster penalty. Per-cluster +/// cost is `(exp(α × size / brush_area) − 1) × brush_area`. The shape +/// is scale-invariant in the brush. At α=2.0: +/// - 1-px cluster ≈ 2 units +/// - cluster = brush_area → ~6.4 × brush_area +/// - cluster = 2 × brush_area → ~53 × brush_area +/// - cluster = 3 × brush_area → ~400 × brush_area +/// So one missing tail-leg (a multi-brush blob) outweighs hundreds +/// of single-pixel slop edges, which matches the eye's response. +pub const UNPAINTED_CLUSTER_ALPHA: f32 = 2.0; + +/// Density penalty across one letter's cluster sizes. Same shape used +/// in `score_weighted` and in `CorpusReport`'s tier-2 aggregate, so the +/// inner optimiser and the outer lex comparator agree on what "bad +/// unpainted distribution" means. +pub fn unpainted_density_score(clusters: &[u32], brush_radius: f32) -> f32 { + let brush_area = std::f32::consts::PI * brush_radius * brush_radius; + if brush_area <= 0.0 { return 0.0; } + clusters.iter().map(|&n| { + // Clamp the exponent so a runaway cluster doesn't overflow f32 + // (exp(20) ≈ 4.85e8; multiplied by brush_area still finite). + let exponent = (UNPAINTED_CLUSTER_ALPHA * n as f32 / brush_area).min(20.0); + (exponent.exp() - 1.0) * brush_area + }).sum() +} + /// Letter-aware score: applies the default score plus hard constraint /// failures. A config that trips ANY hard ceiling returns f32::MAX so /// the optimizer rejects it outright. Soft knobs (brush_size bonus, @@ -1613,7 +1649,13 @@ pub fn is_two_stroke_letter(ch: char) -> bool { /// - 0 strokes → +200k (refuse "paint nothing") /// - SINGLE_STROKE_LETTERS with strokes ≠ 1 → +50k per extra stroke pub fn score_for_letter(ch: char, m: &PaintMetrics) -> f32 { - let mut s = default_score(m); + // Curvature penalty fires only on straight-stroke letters. For + // curvy letters (O/S/G/c/e/...) bending IS the natural form, so + // penalising it pushes the optimizer toward zigzag approximations. + // The length-excess term still keeps wandering paths in check. + let mut w = ScoreWeights::default(); + if !is_straight_letter(ch) { w.curvature = 0.0; } + let mut s = score_weighted(m, w); // Hard-ceiling barriers. They're "soft" in the sense that they're // finite (not f32::MAX) so the optimizer can still gradient-descend @@ -1744,9 +1786,7 @@ impl Default for ScoreWeights { pub fn score_weighted(m: &PaintMetrics, w: ScoreWeights) -> f32 { let budget = 1.5 * m.skeleton_length as f32; let excess = (m.total_length - budget).max(0.0); - let density: f32 = m.unpainted_clusters.iter() - .map(|&n| (n as f32).powf(1.5)) - .sum(); + let density = unpainted_density_score(&m.unpainted_clusters, m.brush_radius); w.stroke * m.strokes as f32 + w.length * m.total_length + w.bg * m.bg_painted as f32 diff --git a/src/brush_paint_opt.rs b/src/brush_paint_opt.rs index 2190c293..777fbebb 100644 --- a/src/brush_paint_opt.rs +++ b/src/brush_paint_opt.rs @@ -18,7 +18,8 @@ use serde::{Serialize, Deserialize}; use crate::brush_paint::{ PaintParams, PaintMetrics, ScoreWeights, score_for_letter, metrics_for, rasterize_test_letter, - is_single_stroke_letter, is_two_stroke_letter, + is_single_stroke_letter, is_two_stroke_letter, is_straight_letter, + unpainted_density_score, }; use crate::hulls::Hull; @@ -306,9 +307,12 @@ impl CorpusReport { r.total_strokes += m.strokes as u64; r.total_repaint += m.repaint as u64; r.total_length += m.total_length as f64; - for &cz in &m.unpainted_clusters { - r.total_unpainted_density += (cz as f64).powf(1.5); - } + // Same exponential shape used in `score_weighted` so the + // inner soft signal and the outer lex ranking agree on what + // "bad unpainted distribution" means. + r.total_unpainted_density += unpainted_density_score( + &m.unpainted_clusters, m.brush_radius + ) as f64; } r } @@ -469,7 +473,11 @@ pub fn evaluate_score_weights( /// can degenerate to "paint nothing" under low coverage weight). fn score_for_letter_with_weights(ch: char, m: &PaintMetrics, w: &ScoreWeights) -> f32 { use crate::brush_paint::score_weighted; - let mut s = score_weighted(m, *w); + // Curvature is letter-conditional: only straight-stroke glyphs + // (AEFHIKLMNTVWXYZilvwxz) pay it. Mirror of `score_for_letter`. + let mut w_local = *w; + if !is_straight_letter(ch) { w_local.curvature = 0.0; } + let mut s = score_weighted(m, w_local); if m.strokes == 0 { s += 200_000.0; } if is_single_stroke_letter(ch) && m.strokes != 1 { s += 50_000.0 * ((m.strokes as i64 - 1).abs() as f32);