brush-paint: add wall_follow_weight — right-hand wall-following bonus

Per-candidate-direction bonus to the walker's score, computed via a
single perpendicular-CW raycast on the was_ink BitMask. Bonus peaks
at brush_area when the wall-distance equals brush_radius (pen riding
the wall at its natural offset), falls off linearly to 0 at distance
0 or 2×brush_radius.

The hypothesis: at M/W/A apexes the lookahead-coverage score
collapses because the V's interior is past the lookahead horizon.
Wall-following gives the walker an alternative signal that "this
direction follows a stroke corridor" even when greedy lookahead
sees mostly painted ink + bg.

Default 0.0 → no behavior change → alphabet report bit-exact.

The optimizer's 14th axis now (range 0.0–2.0, same as
momentum_weight). If the optimizer dials it nonzero and metrics
improve, the term is doing useful work; if it lands at 0, harmless.
This commit is contained in:
Mitchell Hansen
2026-05-07 21:59:39 -07:00
parent 8043254593
commit 1ce6244340
3 changed files with 61 additions and 3 deletions

View File

@@ -44,6 +44,7 @@ export const DEFAULT_PAINT_PARAMS = {
n_directions: 48, n_directions: 48,
lookahead_steps: 3, lookahead_steps: 3,
momentum_weight: 0.20, momentum_weight: 0.20,
wall_follow_weight: 0.0,
overpaint_penalty: 0.10, overpaint_penalty: 0.10,
walk_bg_penalty: 0.69, walk_bg_penalty: 0.69,
min_score_factor: 0.20, min_score_factor: 0.20,

View File

@@ -81,6 +81,15 @@ pub struct PaintParams {
/// Bonus weight on direction alignment with current velocity. /// Bonus weight on direction alignment with current velocity.
/// 0 = no momentum, 1 = momentum equally weighted with new coverage. /// 0 = no momentum, 1 = momentum equally weighted with new coverage.
pub momentum_weight: f32, pub momentum_weight: f32,
/// Bonus weight for "right-hand on the wall" wall-following. For each
/// candidate direction the walker raycasts perpendicular CW; if the
/// resulting wall distance is near `brush_radius` (= the pen would be
/// riding the wall at its natural offset), the candidate's score gets
/// a `wall_follow_weight × brush_area` bonus that decays linearly to
/// 0 as the wall distance moves away from `brush_radius`. Helps the
/// walker take the down-into-V step at M/W/A apexes where greedy
/// lookahead-coverage alone collapses. 0 = off, no behavior change.
pub wall_follow_weight: f32,
/// Per-overpainted-pixel penalty in scoring (relative to new coverage /// Per-overpainted-pixel penalty in scoring (relative to new coverage
/// which is +1 per pixel). Applied to ink we already painted (mild — /// which is +1 per pixel). Applied to ink we already painted (mild —
/// just discourages backtracking). /// just discourages backtracking).
@@ -131,6 +140,7 @@ impl Default for PaintParams {
n_directions: 48, n_directions: 48,
lookahead_steps: 3, lookahead_steps: 3,
momentum_weight: 0.20, momentum_weight: 0.20,
wall_follow_weight: 0.0,
overpaint_penalty: 0.10, overpaint_penalty: 0.10,
walk_bg_penalty: 0.69, walk_bg_penalty: 0.69,
min_score_factor: 0.20, min_score_factor: 0.20,
@@ -263,7 +273,11 @@ pub struct WalkCandidate {
/// `momentum_weight × max(0, dot(dir, prev_dir)) × brush_area` (0 if /// `momentum_weight × max(0, dot(dir, prev_dir)) × brush_area` (0 if
/// no prev_dir). /// no prev_dir).
pub momentum_bonus: f32, pub momentum_bonus: f32,
/// Final score = new_ink overpaint·repaint walk_bg·bg + momentum_bonus. /// `wall_follow_weight × wall_proximity × brush_area`. Peaks when
/// the right-perpendicular raycast hits a wall at distance ≈
/// brush_radius (pen riding the wall at natural offset).
pub wall_bonus: f32,
/// Final score = new_ink overpaint·repaint walk_bg·bg + momentum_bonus + wall_bonus.
pub score: f32, pub score: f32,
} }
@@ -946,6 +960,43 @@ fn vec_unit(v: (f32, f32)) -> (f32, f32) {
} }
fn vec_dot(a: (f32, f32), b: (f32, f32)) -> f32 { a.0 * b.0 + a.1 * b.1 } fn vec_dot(a: (f32, f32), b: (f32, f32)) -> f32 { a.0 * b.0 + a.1 * b.1 }
/// Cast a ray from `p` in direction `dir` (unit vector) over the grid's
/// `was_ink` mask. Returns the distance, in pixels, to the first non-ink
/// pixel — capped at `max_steps`. The ray walks in 1-px increments; for
/// our use case (max_steps ≈ 2× brush_radius ≈ 510 px), that's a
/// handful of `BitMask::get` lookups per ray.
fn raycast_to_wall(grid: &Grid, p: (f32, f32), dir: (f32, f32), max_steps: u32) -> f32 {
for k in 1..=max_steps {
let kf = k as f32;
let probe_x = (p.0 + dir.0 * kf).round() as i32;
let probe_y = (p.1 + dir.1 * kf).round() as i32;
if !grid.is_ink(probe_x, probe_y) {
return kf - 1.0;
}
}
max_steps as f32
}
/// Right-hand wall-following bonus for one candidate direction.
/// Computes the perpendicular CW raycast distance and rewards positions
/// where it sits near `brush_radius` (the pen's natural offset for
/// riding the wall). Falls off linearly to 0 as the distance drifts
/// away from `brush_radius` by ±brush_radius. Returns a value in
/// [0, brush_area] so it scales the same as `momentum_bonus`.
fn wall_follow_bonus(grid: &Grid, p: (f32, f32), dir: (f32, f32),
brush_radius: f32, brush_area: f32) -> f32 {
if brush_radius <= 0.0 { return 0.0; }
// Perpendicular CW: rotate (dx, dy) by -90° → (dy, -dx).
let perp_cw = (dir.1, -dir.0);
// Cap the raycast at 2× brush_radius — anything farther means the
// pen isn't on a wall at all, so wall-bonus is 0 anyway.
let max_steps = (2.0 * brush_radius).ceil().max(1.0) as u32;
let wall_dist = raycast_to_wall(grid, p, perp_cw, max_steps);
let offset = (wall_dist - brush_radius).abs();
let proximity = (1.0 - offset / brush_radius).max(0.0);
proximity * brush_area
}
// ── Trace a single stroke ─────────────────────────────────────────────── // ── Trace a single stroke ───────────────────────────────────────────────
/// Score one candidate direction by simulating `lookahead_steps` walks /// Score one candidate direction by simulating `lookahead_steps` walks
@@ -1017,16 +1068,20 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>,
let momentum_bonus = if has_momentum { let momentum_bonus = if has_momentum {
params.momentum_weight * vec_dot(dir, prev_dir_unit).max(0.0) * brush_area params.momentum_weight * vec_dot(dir, prev_dir_unit).max(0.0) * brush_area
} else { 0.0 }; } else { 0.0 };
let wall_bonus = if params.wall_follow_weight > 0.0 {
params.wall_follow_weight * wall_follow_bonus(grid, p, dir, brush_radius, brush_area)
} else { 0.0 };
let score = new_ink let score = new_ink
- params.overpaint_penalty * repaint - params.overpaint_penalty * repaint
- params.walk_bg_penalty * bg - params.walk_bg_penalty * bg
+ momentum_bonus; + momentum_bonus
+ wall_bonus;
if recording { if recording {
recorded.push(WalkCandidate { recorded.push(WalkCandidate {
theta, dir, probe, theta, dir, probe,
rejected_back, rejected_off_ink, rejected_back, rejected_off_ink,
new_ink, repaint, bg, momentum_bonus, score, new_ink, repaint, bg, momentum_bonus, wall_bonus, score,
}); });
} }

View File

@@ -59,6 +59,8 @@ pub fn default_axes() -> Vec<Axis> {
set: |p, v| p.n_directions = v as usize, get: |p| p.n_directions as f32 }, set: |p, v| p.n_directions = v as usize, get: |p| p.n_directions as f32 },
Axis { name: "momentum_weight", lo: 0.0, hi: 2.0, is_int: false, Axis { name: "momentum_weight", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.momentum_weight = v, get: |p| p.momentum_weight }, set: |p, v| p.momentum_weight = v, get: |p| p.momentum_weight },
Axis { name: "wall_follow_weight", lo: 0.0, hi: 2.0, is_int: false,
set: |p, v| p.wall_follow_weight = v, get: |p| p.wall_follow_weight },
Axis { name: "min_score_factor", lo: 0.05, hi: 0.30, is_int: false, Axis { name: "min_score_factor", lo: 0.05, hi: 0.30, is_int: false,
set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor }, set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor },
Axis { name: "back_dir_cutoff", lo: -0.95, hi: -0.3, is_int: false, Axis { name: "back_dir_cutoff", lo: -0.95, hi: -0.3, is_int: false,