feat: gradient_hatch fill — density driven by response map
Adds a new fill strategy that uses the per-pixel response map (0=dark/ink, 255=background) to modulate scan-line spacing, producing tighter hatching in darker areas and wider spacing in lighter areas within each hull. fill.rs — gradient_hatch(hull, response, img_width, spacing, angle, min_scale): Adaptive step: local_spacing = spacing × lerp(min_scale, 1.0, resp/255) Floored at 1.0px so each integer v-row is visited at most once → O(N log N), same complexity class as parallel_hatch. Arc<[u8]> shared across rayon threads. Tests: - gradient_hatch_dark_denser_than_light: dark hull → more strokes than light - gradient_hatch_monotone_density: stroke count non-increasing with response - gradient_hatch_min_scale_one_matches_parallel: min_scale=1.0 → identical to baseline - gradient_hatch_all_points_inside_hull: containment check - gradient_hatch_performance: 256×256 dark hull < 2s in debug mode - gradient_hatch_perf_ratio_vs_parallel: ≤20× baseline (catches O(N²) regressions) lib.rs: PassState gains response_map: Vec<u8>, stored after every process_pass run. generate_fill_work receives (response_map, img_width); wraps map in Arc<[u8]> for zero-copy sharing across the rayon thread pool. pipeline_bench.rs: New section benchmarks gradient_hatch at min_scale 0.5 / 0.25 / 0.1. store.js: gradient_hatch added to FILL_STRATEGIES, FILL_USES_ANGLE, and FILL_STRATEGY_PARAMS (Min Scale slider, default 0.25). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
export const KERNELS = ['Luminance','Sobel','ColorGradient','Laplacian','Canny','Saturation','XDoG']
|
||||
export const BLEND_MODES = ['Average','Min','Max','Multiply','Screen','Difference']
|
||||
|
||||
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow']
|
||||
export const FILL_STRATEGIES = ['hatch','zigzag','offset','spiral','outline','circles','voronoi','hilbert','waves','flow','gradient_hatch']
|
||||
|
||||
// Per-strategy secondary parameter exposed as a slider.
|
||||
// Strategies not listed here have no secondary parameter.
|
||||
@@ -13,12 +13,14 @@ export const FILL_STRATEGY_PARAMS = {
|
||||
hint: 'Min circle radius as a multiple of spacing' },
|
||||
waves: { label: 'Sources', min: 1, max: 9, step: 1, default: 5,
|
||||
hint: 'Number of concentric ring emitters' },
|
||||
flow: { label: 'Bend', min: 0.0, max: 2.0, step: 0.1, default: 1.0,
|
||||
hint: '0 = straight lines · 1 = default ±45° · 2 = wild curves' },
|
||||
flow: { label: 'Bend', min: 0.0, max: 2.0, step: 0.1, default: 1.0,
|
||||
hint: '0 = straight lines · 1 = default ±45° · 2 = wild curves' },
|
||||
gradient_hatch: { label: 'Min Scale', min: 0.05, max: 1.0, step: 0.05, default: 0.25,
|
||||
hint: '1.0 = uniform · 0.05 = 20× denser at darkest ink' },
|
||||
}
|
||||
|
||||
// Strategies that use the angle slider
|
||||
export const FILL_USES_ANGLE = new Set(['hatch', 'zigzag', 'flow'])
|
||||
export const FILL_USES_ANGLE = new Set(['hatch', 'zigzag', 'flow', 'gradient_hatch'])
|
||||
|
||||
export function defaultKernelProps() {
|
||||
return {
|
||||
|
||||
202
src/fill.rs
202
src/fill.rs
@@ -85,6 +85,88 @@ pub fn parallel_hatch(hull: &Hull, spacing_px: f32, angle_deg: f32) -> FillResul
|
||||
FillResult { hull_id: hull.id, strokes }
|
||||
}
|
||||
|
||||
// ── Gradient hatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parallel hatch with adaptive scan-line spacing driven by the response map.
|
||||
///
|
||||
/// `min_scale` ∈ [0.05, 1.0]: spacing ratio applied in the darkest (most-ink) areas.
|
||||
/// 1.0 → uniform spacing (identical to parallel_hatch)
|
||||
/// 0.25 → 4× denser lines where response ≈ 0
|
||||
///
|
||||
/// Adaptive step: `local_spacing = spacing × lerp(min_scale, 1.0, response/255)`
|
||||
/// Floored at 1.0 px so each integer v-row is visited at most once — O(N log N) time.
|
||||
pub fn gradient_hatch(
|
||||
hull: &Hull,
|
||||
response: &[u8],
|
||||
img_width: u32,
|
||||
spacing_px: f32,
|
||||
angle_deg: f32,
|
||||
min_scale: f32,
|
||||
) -> FillResult {
|
||||
if hull.pixels.is_empty() || spacing_px <= 0.0 {
|
||||
return FillResult { hull_id: hull.id, strokes: vec![] };
|
||||
}
|
||||
let min_scale = min_scale.clamp(0.05, 1.0);
|
||||
|
||||
let angle_rad = angle_deg.to_radians();
|
||||
let cos_a = angle_rad.cos();
|
||||
let sin_a = angle_rad.sin();
|
||||
|
||||
// Build per-integer-v-row buckets: v_row → sorted Vec<(u_coord, resp)>
|
||||
let mut v_buckets: HashMap<i32, Vec<(f32, u8)>> = HashMap::new();
|
||||
let (mut v_min, mut v_max) = (f32::MAX, f32::MIN);
|
||||
|
||||
for &(px, py) in &hull.pixels {
|
||||
let (fx, fy) = (px as f32, py as f32);
|
||||
let u = fx * cos_a + fy * sin_a;
|
||||
let v = -fx * sin_a + fy * cos_a;
|
||||
v_min = v_min.min(v);
|
||||
v_max = v_max.max(v);
|
||||
let resp = response.get((py * img_width + px) as usize).copied().unwrap_or(128);
|
||||
v_buckets.entry(v.round() as i32).or_default().push((u, resp));
|
||||
}
|
||||
for entries in v_buckets.values_mut() {
|
||||
entries.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
}
|
||||
|
||||
let mut strokes = Vec::new();
|
||||
let mut v = v_min + spacing_px * 0.5;
|
||||
|
||||
loop {
|
||||
if v > v_max + spacing_px { break; }
|
||||
let v_row = v.round() as i32;
|
||||
|
||||
let (entries, avg_resp): (&[(f32, u8)], u8) = if let Some(e) = v_buckets.get(&v_row) {
|
||||
let sum: u32 = e.iter().map(|&(_, r)| r as u32).sum();
|
||||
let avg = (sum / e.len() as u32) as u8;
|
||||
(e.as_slice(), avg)
|
||||
} else {
|
||||
(&[], 255)
|
||||
};
|
||||
|
||||
// Emit contiguous runs along this scan line
|
||||
if entries.len() >= 2 {
|
||||
let mut run_start = 0;
|
||||
for i in 1..=entries.len() {
|
||||
let end_run = i == entries.len() || entries[i].0 - entries[i-1].0 > 1.5;
|
||||
if end_run && i - run_start >= 2 {
|
||||
let stroke: Vec<(f32, f32)> = entries[run_start..i].iter().map(|&(u_coord, _)| {
|
||||
(u_coord * cos_a - v * sin_a, u_coord * sin_a + v * cos_a)
|
||||
}).collect();
|
||||
if stroke.len() >= 2 { strokes.push(stroke); }
|
||||
run_start = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive step: resp=0 (dark/ink) → min_scale×spacing; resp=255 (bg) → 1×spacing
|
||||
let local_spacing = spacing_px * (min_scale + (1.0 - min_scale) * avg_resp as f32 / 255.0);
|
||||
v += local_spacing.max(1.0); // floor: each integer v-row visited at most once
|
||||
}
|
||||
|
||||
FillResult { hull_id: hull.id, strokes }
|
||||
}
|
||||
|
||||
// ── Outline ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The simplified contour as a single closed stroke.
|
||||
@@ -1822,4 +1904,124 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── gradient_hatch tests ──────────────────────────────────────────────────
|
||||
|
||||
/// Build a uniform response map for a hull (all in-hull pixels set to `val`).
|
||||
fn uniform_response(hull: &Hull, img_width: u32, img_height: u32, val: u8) -> Vec<u8> {
|
||||
let mut resp = vec![255u8; (img_width * img_height) as usize];
|
||||
for &(px, py) in &hull.pixels {
|
||||
resp[(py * img_width + px) as usize] = val;
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_hatch_dark_denser_than_light() {
|
||||
// Same 60×60 hull, two response levels — dark region should produce more strokes.
|
||||
let hull = make_square_hull(4, 4, 60);
|
||||
let w = 68u32; let h = 68u32;
|
||||
let dark = uniform_response(&hull, w, h, 10); // very dark → tight
|
||||
let light = uniform_response(&hull, w, h, 110); // lighter → wider
|
||||
|
||||
let dark_r = gradient_hatch(&hull, &dark, w, 8.0, 0.0, 0.2);
|
||||
let light_r = gradient_hatch(&hull, &light, w, 8.0, 0.0, 0.2);
|
||||
|
||||
assert!(dark_r.strokes.len() > light_r.strokes.len(),
|
||||
"dark hull should produce more strokes than light: dark={} light={}",
|
||||
dark_r.strokes.len(), light_r.strokes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_hatch_monotone_density() {
|
||||
// Stroke count should decrease monotonically as response value increases.
|
||||
let hull = make_square_hull(4, 4, 60);
|
||||
let w = 68u32; let h = 68u32;
|
||||
let counts: Vec<usize> = [10u8, 50, 100, 150, 200].iter().map(|&val| {
|
||||
let resp = uniform_response(&hull, w, h, val);
|
||||
gradient_hatch(&hull, &resp, w, 6.0, 0.0, 0.2).strokes.len()
|
||||
}).collect();
|
||||
|
||||
for window in counts.windows(2) {
|
||||
assert!(window[0] >= window[1],
|
||||
"stroke count should be non-increasing with response: {:?}", counts);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_hatch_min_scale_one_matches_parallel() {
|
||||
// With min_scale=1.0, gradient_hatch is identical to parallel_hatch.
|
||||
let hull = make_square_hull(4, 4, 60);
|
||||
let w = 68u32; let h = 68u32;
|
||||
let resp = uniform_response(&hull, w, h, 128);
|
||||
|
||||
let grad = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 1.0);
|
||||
let base = parallel_hatch(&hull, 5.0, 0.0);
|
||||
|
||||
let diff = (grad.strokes.len() as i64 - base.strokes.len() as i64).abs();
|
||||
assert!(diff <= 2,
|
||||
"gradient with min_scale=1.0 should match parallel_hatch: grad={} base={}",
|
||||
grad.strokes.len(), base.strokes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_hatch_all_points_inside_hull() {
|
||||
let hull = make_square_hull(4, 4, 60);
|
||||
let w = 68u32; let h = 68u32;
|
||||
let resp = uniform_response(&hull, w, h, 30);
|
||||
let result = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 0.25);
|
||||
let pixel_set: HashSet<(u32, u32)> = hull.pixels.iter().copied().collect();
|
||||
for stroke in &result.strokes {
|
||||
for &(x, y) in stroke {
|
||||
let px = x.round() as u32; let py = y.round() as u32;
|
||||
// Allow 1px tolerance at scan-line edges
|
||||
let near = (-1i32..=1).any(|dy| (-1i32..=1).any(|dx| {
|
||||
pixel_set.contains(&((px as i32 + dx).max(0) as u32, (py as i32 + dy).max(0) as u32))
|
||||
}));
|
||||
assert!(near, "point ({x:.1},{y:.1}) is outside hull");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_hatch_performance() {
|
||||
// Worst case: 256×256 dark hull with min_scale=0.1 (tightest possible lines).
|
||||
// Must complete within 2 seconds in debug mode. Catches O(N²) regressions.
|
||||
use std::time::Instant;
|
||||
let hull = make_square_hull(4, 4, 256);
|
||||
let w = 264u32; let h = 264u32;
|
||||
let resp = uniform_response(&hull, w, h, 5); // very dark
|
||||
|
||||
let t0 = Instant::now();
|
||||
let result = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 0.1);
|
||||
let elapsed_ms = t0.elapsed().as_millis();
|
||||
|
||||
assert!(elapsed_ms < 2000,
|
||||
"gradient_hatch 256×256 dark hull took {}ms — expected <2000ms", elapsed_ms);
|
||||
assert!(!result.strokes.is_empty(), "should produce strokes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_hatch_perf_ratio_vs_parallel() {
|
||||
// gradient_hatch should not be more than 20× slower than parallel_hatch on the same hull.
|
||||
// Uses μs timing; generous multiplier covers debug-mode variance.
|
||||
use std::time::Instant;
|
||||
let hull = make_square_hull(4, 4, 128);
|
||||
let w = 136u32; let h = 136u32;
|
||||
let resp = uniform_response(&hull, w, h, 128);
|
||||
|
||||
let t_par = {
|
||||
let t = Instant::now();
|
||||
let _ = parallel_hatch(&hull, 5.0, 0.0);
|
||||
t.elapsed().as_micros().max(1)
|
||||
};
|
||||
let t_grad = {
|
||||
let t = Instant::now();
|
||||
let _ = gradient_hatch(&hull, &resp, w, 5.0, 0.0, 0.5);
|
||||
t.elapsed().as_micros()
|
||||
};
|
||||
|
||||
assert!(t_grad <= t_par * 20 + 50_000,
|
||||
"gradient_hatch ({t_grad}μs) is >20× slower than parallel_hatch ({t_par}μs)");
|
||||
}
|
||||
}
|
||||
|
||||
54
src/lib.rs
54
src/lib.rs
@@ -30,6 +30,7 @@ struct AppState {
|
||||
struct PassState {
|
||||
hulls: Vec<hulls::Hull>,
|
||||
fill_results: Vec<fill::FillResult>,
|
||||
response_map: Vec<u8>, // raw detect output; kept so gradient fills can query it
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -293,7 +294,7 @@ fn rgb_to_b64_jpeg(rgb: &image::RgbImage) -> String {
|
||||
fn process_pass_work(
|
||||
rgb: &image::RgbImage,
|
||||
payload: ProcessPassPayload,
|
||||
) -> (Vec<hulls::Hull>, ProcessResult) {
|
||||
) -> (Vec<hulls::Hull>, Vec<u8>, ProcessResult) {
|
||||
let t0 = Instant::now();
|
||||
let mut steps: Vec<StepTime> = Vec::new();
|
||||
let (w, h) = rgb.dimensions();
|
||||
@@ -343,11 +344,13 @@ fn process_pass_work(
|
||||
|
||||
steps.push(StepTime { label: "total".into(), ms: t0.elapsed().as_millis() as u64 });
|
||||
|
||||
(extracted, ProcessResult { hull_count, coverage_pct, viz_b64, node_previews, timings: steps })
|
||||
(extracted, response, ProcessResult { hull_count, coverage_pct, viz_b64, node_previews, timings: steps })
|
||||
}
|
||||
|
||||
fn generate_fill_work(
|
||||
hulls: Vec<hulls::Hull>,
|
||||
response_map: Vec<u8>,
|
||||
img_width: u32,
|
||||
payload: FillPayload,
|
||||
) -> (Vec<fill::FillResult>, FillResult) {
|
||||
use rayon::prelude::*;
|
||||
@@ -359,19 +362,23 @@ fn generate_fill_work(
|
||||
let param = payload.param;
|
||||
let mut steps: Vec<StepTime> = Vec::new();
|
||||
|
||||
// Share the response map across rayon threads without cloning it per-hull
|
||||
let response_arc: std::sync::Arc<[u8]> = response_map.into();
|
||||
|
||||
let mut t = Instant::now();
|
||||
let raw_results: Vec<fill::FillResult> = hulls.par_iter().map(|hull| {
|
||||
match strategy.as_str() {
|
||||
"outline" => fill::outline(hull),
|
||||
"zigzag" => fill::zigzag_hatch(hull, spacing, angle),
|
||||
"offset" => fill::contour_offset(hull, spacing),
|
||||
"spiral" => fill::spiral(hull, spacing),
|
||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
||||
"voronoi" => fill::voronoi_fill(hull, spacing),
|
||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
||||
_ => fill::parallel_hatch(hull, spacing, angle),
|
||||
"outline" => fill::outline(hull),
|
||||
"zigzag" => fill::zigzag_hatch(hull, spacing, angle),
|
||||
"offset" => fill::contour_offset(hull, spacing),
|
||||
"spiral" => fill::spiral(hull, spacing),
|
||||
"circles" => fill::circle_pack(hull, spacing, param.max(0.1)),
|
||||
"voronoi" => fill::voronoi_fill(hull, spacing),
|
||||
"hilbert" => fill::hilbert_fill(hull, spacing),
|
||||
"waves" => fill::wave_interference(hull, spacing, param.round().max(1.0) as usize),
|
||||
"flow" => fill::flow_field(hull, spacing, angle, param.max(0.0)),
|
||||
"gradient_hatch" => fill::gradient_hatch(hull, &response_arc, img_width, spacing, angle, param.clamp(0.05, 1.0)),
|
||||
_ => fill::parallel_hatch(hull, spacing, angle),
|
||||
}
|
||||
}).collect();
|
||||
t = lap!(steps, "fill gen", t);
|
||||
@@ -414,7 +421,7 @@ fn load_image(path: String, state: State<Mutex<AppState>>) -> Result<ImageInfo,
|
||||
#[tauri::command]
|
||||
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
|
||||
let mut st = state.lock().unwrap();
|
||||
st.passes.resize_with(count, || PassState { hulls: Vec::new(), fill_results: Vec::new() });
|
||||
st.passes.resize_with(count, PassState::default);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -428,7 +435,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
||||
|
||||
let idx = payload.pass_index;
|
||||
|
||||
let (new_hulls, result) = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (new_hulls, response_map, result) = tauri::async_runtime::spawn_blocking(move || {
|
||||
process_pass_work(&rgb, payload)
|
||||
})
|
||||
.await
|
||||
@@ -440,6 +447,7 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
||||
}
|
||||
st.passes[idx].hulls = new_hulls;
|
||||
st.passes[idx].fill_results = Vec::new();
|
||||
st.passes[idx].response_map = response_map;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -448,17 +456,18 @@ async fn process_pass(payload: ProcessPassPayload, state: State<'_, Mutex<AppSta
|
||||
async fn generate_fill(payload: FillPayload, state: State<'_, Mutex<AppState>>) -> Result<FillResult, String> {
|
||||
let idx = payload.pass_index;
|
||||
|
||||
// Clone hulls and release the lock before handing off to the blocking pool.
|
||||
let hulls = {
|
||||
// Clone hulls + response map and release the lock before handing off to the blocking pool.
|
||||
let (hulls, response_map, img_width) = {
|
||||
let st = state.lock().unwrap();
|
||||
if idx >= st.passes.len() || st.passes[idx].hulls.is_empty() {
|
||||
return Err("Process image first".into());
|
||||
}
|
||||
st.passes[idx].hulls.clone()
|
||||
let w = st.image_rgb.as_ref().map(|i| i.width()).unwrap_or(0);
|
||||
(st.passes[idx].hulls.clone(), st.passes[idx].response_map.clone(), w)
|
||||
};
|
||||
|
||||
let (optimised, result) = tauri::async_runtime::spawn_blocking(move || {
|
||||
generate_fill_work(hulls, payload)
|
||||
generate_fill_work(hulls, response_map, img_width, payload)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -925,7 +934,7 @@ mod blocking_tests {
|
||||
"mutex was blocked during heavy processing"
|
||||
);
|
||||
|
||||
let (hulls, result) = work.await.unwrap();
|
||||
let (hulls, _, result) = work.await.unwrap();
|
||||
assert!(result.timings.iter().any(|t| t.label == "total"));
|
||||
assert!(!hulls.is_empty(), "expected hulls from checkerboard image");
|
||||
}
|
||||
@@ -934,13 +943,14 @@ mod blocking_tests {
|
||||
#[tokio::test]
|
||||
async fn generate_fill_does_not_hold_mutex_during_computation() {
|
||||
let rgb = synthetic_image(400, 300);
|
||||
let (hulls, _) = process_pass_work(&rgb, default_process_payload());
|
||||
let (hulls, response_map, _) = process_pass_work(&rgb, default_process_payload());
|
||||
assert!(!hulls.is_empty(), "need hulls to test fill");
|
||||
let img_width = rgb.width();
|
||||
|
||||
let state = Arc::new(Mutex::new(AppState {
|
||||
image_rgb: Some(rgb),
|
||||
image_path: String::new(),
|
||||
passes: vec![PassState { hulls: hulls.clone(), fill_results: Vec::new() }],
|
||||
passes: vec![PassState { hulls: hulls.clone(), fill_results: Vec::new(), response_map: response_map.clone() }],
|
||||
}));
|
||||
|
||||
// Clone hulls and release lock — mirrors what the command handler does.
|
||||
@@ -961,7 +971,7 @@ mod blocking_tests {
|
||||
};
|
||||
|
||||
let work = tokio::task::spawn_blocking(move || {
|
||||
generate_fill_work(work_hulls, payload)
|
||||
generate_fill_work(work_hulls, response_map, img_width, payload)
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
|
||||
@@ -7,7 +7,7 @@ use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
|
||||
use trac3r_lib::detect::{DetectionParams, DetectionLayer, DetectionKernel, apply_stack};
|
||||
use trac3r_lib::hulls::{HullParams, Connectivity, extract_hulls};
|
||||
use trac3r_lib::fill::{parallel_hatch, smooth_fill_result, optimize_travel, FillResult};
|
||||
use trac3r_lib::fill::{parallel_hatch, gradient_hatch, smooth_fill_result, optimize_travel, FillResult};
|
||||
|
||||
fn t(label: &str, start: Instant) -> Instant {
|
||||
println!(" {:40} {:>6}ms", label, start.elapsed().as_millis());
|
||||
@@ -160,6 +160,18 @@ fn main() {
|
||||
let now = t(&format!("serialize ({}KB JSON)", json.len() / 1024), now);
|
||||
drop(now);
|
||||
|
||||
// ── gradient_hatch ────────────────────────────────────────────────────────
|
||||
println!("\n[ gradient_hatch (same hulls, min_scale=0.25) ]");
|
||||
for min_scale in [0.5f32, 0.25, 0.1] {
|
||||
let now = Instant::now();
|
||||
let raw: Vec<FillResult> = hulls.iter()
|
||||
.map(|h| gradient_hatch(h, &response, w, 5.0, 0.0, min_scale))
|
||||
.collect();
|
||||
let strokes: usize = raw.iter().map(|r| r.strokes.len()).sum();
|
||||
let now = t(&format!("min_scale={min_scale:.2} ({strokes} strokes)"), now);
|
||||
drop(now);
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────
|
||||
println!("\n=== SUMMARY ===");
|
||||
println!(" image: {w}×{h} ({} hull px)", total_px);
|
||||
|
||||
Reference in New Issue
Block a user