fix: gradient hatch param default; hull viz shows response gradient

gradient_hatch param bug:
  defaultPass always set param=1.0. The slider rendered pass.param??p.default
  which resolved to 1.0 regardless of the strategy's declared default (0.25).
  min_scale=1.0 gives uniform spacing, indistinguishable from parallel_hatch.
  Fix: PassPanel now sets param=strategy_default when a strategy is selected,
  so switching to gradient_hatch immediately uses min_scale=0.25.

Hull visualization:
  Was SVG-filled contours with flat hash_color per hull — no gradient info.
  Now renders a per-pixel JPEG where each hull pixel is colored by its
  response value: intensity=(255-resp)/255, so darkest ink=full hue,
  near-threshold pixels fade toward black. The response_map stored in
  PassState after each detect run is used directly — no extra computation.
  Background stays dark gray (15,15,15). Falls back to full hue if
  response_map is empty (pre-process-pass).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 21:30:09 -07:00
parent 3ff65a93ef
commit 41917df26d
2 changed files with 17 additions and 13 deletions

View File

@@ -79,7 +79,7 @@ export default function PassPanel({
<div className="flex flex-wrap gap-1 mb-2">
{FILL_STRATEGIES.map(s => (
<button key={s}
onClick={() => setFill({ strategy: s })}
onClick={() => setFill({ strategy: s, param: FILL_STRATEGY_PARAMS[s]?.default ?? 1.0 })}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
pass.strategy === s
? 'bg-purple-700 text-white'

View File

@@ -659,21 +659,25 @@ fn get_pass_viz(pass_index: usize, mode: String, state: State<Mutex<AppState>>)
match mode.as_str() {
"hulls" => {
let mut svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><rect width="{w}" height="{h}" fill="#0f0f0f"/>"##
);
// Per-pixel raster: hull hue modulated by response intensity.
// intensity = (255 - response) / 255: max ink (resp=0) → full hue,
// threshold edge (resp≈threshold) → near black.
let response = &pass.response_map;
let mut rgba = vec![15u8; (w * h * 4) as usize];
for px in rgba.chunks_mut(4) { px[3] = 255; }
for hull in &pass.hulls {
let Some(mut d) = contour_path_d(&hull.contour) else { continue };
if let Some(holes) = hull_holes.get(&hull.id) {
for hole in holes {
if let Some(hd) = hole_path_d(hole) { d.push(' '); d.push_str(&hd); }
}
let (hr, hg, hb) = hash_color(hull.id);
for &(px, py) in &hull.pixels {
let resp = response.get((py * w + px) as usize).copied().unwrap_or(0);
let intensity = (255u32 - resp as u32) as f32 / 255.0;
let i = ((py * w + px) * 4) as usize;
rgba[i] = (hr as f32 * intensity) as u8;
rgba[i+1] = (hg as f32 * intensity) as u8;
rgba[i+2] = (hb as f32 * intensity) as u8;
}
let (r, g, b) = hash_color(hull.id);
svg.push_str(&format!(r#"<path d="{d}" fill="rgb({r},{g},{b})" fill-rule="evenodd"/>"#));
}
svg.push_str("</svg>");
Ok(B64.encode(svg.as_bytes()))
Ok(rgba_to_b64_png(&rgba, w, h))
}
"contours" => {
let mut svg = format!(