//! Local adjustments, end to end on a device. //! //! The unit tests either side of this one check halves: `dr-pipeline` asserts //! the generated WGSL says the right thing, and `dr-gpu`'s mask tests assert //! an array of the right shape comes out. Neither would notice if the two //! agreed with each other and both were wrong — a mask sampled with x and y //! swapped satisfies both. //! //! So this renders a real frame and reads the pixels back: the masked region //! must change, the rest must not, and the boundary must fall where the label //! field says it does. use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass}; use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Reveal, RevealStyle}; use dr_pipeline::operation::{compose_full, compose_full_revealing}; use dr_pipeline::spot::SpotSet; use dr_pipeline::{ops, EditGraph, Framing}; use dr_types::ColourSpace; const SIZE: u32 = 32; fn ctx() -> Option { pollster::block_on(GpuContext::new_headless()).ok() } /// A flat mid-grey JPEG-path image, so any change is the adjustment's. fn grey(ctx: &GpuContext) -> DemosaicedImage { grey_at(ctx, SIZE, SIZE) } fn grey_at(ctx: &GpuContext, w: u32, h: u32) -> DemosaicedImage { let data: Vec = (0..w * h).flat_map(|_| [128, 128, 128, 255]).collect(); DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload") } /// Two regions: 0 is the left half, 1 the right. fn split_field(ctx: &GpuContext) -> LabelField { let labels: Vec = (0..SIZE * SIZE) .map(|i| u32::from(i % SIZE >= SIZE / 2)) .collect(); LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload") } /// A layer brightening whatever it covers, by a lot, so it cannot be missed. fn brighten(source: MaskSource) -> MaskLayer { let mut layer = MaskLayer::new("m1", source); layer.set_param("exposure", ParamId("exposure"), 2.0); layer } fn luma_at(pixels: &[u8], x: u32, y: u32) -> u8 { pixels[((y * SIZE + x) * 4) as usize] } /// Render `stack` over flat grey and hand back the RGBA8 result. fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec { render_at(ctx, stack, field, SIZE, SIZE) } fn render_at( ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>, w: u32, h: u32, ) -> Vec { let source = grey_at(ctx, w, h); let shader = compose_full( &ops::chain(), &Framing::new(), ColourSpace::Srgb, stack, &SpotSet::new(), &[], ); let mut masks = MaskPass::new(ctx).expect("mask pass"); let array = masks .render(stack, field, None, None, w, h) .expect("rasterise"); let mut adjust = AdjustPass::new(ctx); adjust .render_masked(&source, &shader, w, h, Some(array)) .expect("render"); adjust.export_pixels().expect("readback").0 } #[test] fn a_region_mask_changes_only_the_regions_it_names() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let mut stack = MaskStack::new(); stack.push(brighten(MaskSource::Regions { signature: 1, level: 2, ids: vec![0], })); let pixels = render(&ctx, &stack, Some(&field)); // Sampled well inside each half, clear of the feathered boundary. let inside = luma_at(&pixels, 4, SIZE / 2); let outside = luma_at(&pixels, SIZE - 5, SIZE / 2); assert!( inside > outside + 40, "the masked half should be much brighter: {inside} vs {outside}" ); assert!( (120..=136).contains(&outside), "the unmasked half must be untouched mid-grey, got {outside}" ); } /// The failure a swapped axis or an inverted comparison would produce, and /// which the "inside is brighter" assertion alone would not catch. #[test] fn inverting_a_region_mask_swaps_which_half_moves() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let mut layer = brighten(MaskSource::Regions { signature: 1, level: 2, ids: vec![0], }); layer.invert = true; let mut stack = MaskStack::new(); stack.push(layer); let pixels = render(&ctx, &stack, Some(&field)); let left = luma_at(&pixels, 4, SIZE / 2); let right = luma_at(&pixels, SIZE - 5, SIZE / 2); assert!( right > left + 40, "inverted, the *other* half should brighten: left {left}, right {right}" ); } #[test] fn opacity_scales_the_effect() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let source = MaskSource::Regions { signature: 1, level: 2, ids: vec![0], }; let mut full = MaskStack::new(); full.push(brighten(source.clone())); let mut half = MaskStack::new(); let mut layer = brighten(source); layer.opacity = 0.5; half.push(layer); let at_full = luma_at(&render(&ctx, &full, Some(&field)), 4, SIZE / 2); let at_half = luma_at(&render(&ctx, &half, Some(&field)), 4, SIZE / 2); let untouched = 128; assert!( at_half > untouched && at_half < at_full, "half opacity should land between neutral and full: {untouched} < {at_half} < {at_full}" ); } #[test] fn a_linear_gradient_ramps_across_the_frame() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(brighten(MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 1.0, })); let pixels = render(&ctx, &stack, None); let left = luma_at(&pixels, 1, SIZE / 2); let middle = luma_at(&pixels, SIZE / 2, SIZE / 2); let right = luma_at(&pixels, SIZE - 2, SIZE / 2); assert!( left < middle && middle < right, "a horizontal ramp should increase left to right: {left}, {middle}, {right}" ); } #[test] fn a_radial_mask_is_strongest_at_its_centre() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(brighten(MaskSource::Radial { centre: (0.5, 0.5), radii: (0.3, 0.3), angle: 0.0, feather: 0.5, })); let pixels = render(&ctx, &stack, None); let centre = luma_at(&pixels, SIZE / 2, SIZE / 2); let corner = luma_at(&pixels, 1, 1); assert!( centre > corner + 40, "the centre should carry the effect: {centre} vs corner {corner}" ); assert!( (120..=136).contains(&corner), "outside the radius must be untouched, got {corner}" ); } /// Two layers must not read each other's slice. #[test] fn stacked_layers_use_their_own_masks() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let mut stack = MaskStack::new(); // Left half up. stack.push(brighten(MaskSource::Regions { signature: 1, level: 2, ids: vec![0], })); // Right half down. let mut darken = MaskLayer::new( "m2", MaskSource::Regions { signature: 1, level: 2, ids: vec![1], }, ); darken.set_param("exposure", ParamId("exposure"), -2.0); stack.push(darken); let pixels = render(&ctx, &stack, Some(&field)); let left = luma_at(&pixels, 4, SIZE / 2); let right = luma_at(&pixels, SIZE - 5, SIZE / 2); assert!(left > 150, "left should have brightened, got {left}"); assert!(right < 100, "right should have darkened, got {right}"); } // --------------------------------------------------------------------------- // Brush strokes (ARCH §5.4) // --------------------------------------------------------------------------- const UNTOUCHED: u8 = 128; fn luma_in(pixels: &[u8], w: u32, x: u32, y: u32) -> u8 { pixels[((y * w + x) * 4) as usize] } /// One gesture: whether it erases, its radius, its flow, and its path. type Gesture = (bool, f32, f32, Vec<(f32, f32)>); /// A brightening layer with the given gestures already painted onto it. fn painted(gestures: &[Gesture]) -> MaskLayer { let mut layer = brighten(MaskSource::brush()); for (erase, radius, flow, path) in gestures { layer.begin_stroke(0, *erase, *radius, 0.9, *flow); for &(x, y) in path { layer.extend_stroke(0, x, y); } layer.end_stroke(0); } layer } fn stack_of(layer: MaskLayer) -> MaskStack { let mut stack = MaskStack::new(); stack.push(layer); stack } /// The whole feature, at its simplest: paint somewhere, and that is where the /// adjustment lands. /// /// Painted across the top rather than down the middle, because a mask drawn /// upside down is symmetric about the middle and a centred stroke would not /// notice — and the vertex shader that draws a stroke has to flip y to reach /// clip space, which is exactly the kind of thing that is wrong once. #[test] fn a_stroke_paints_where_it_was_drawn_and_nowhere_else() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let stack = stack_of(painted(&[( false, 0.1, 1.0, vec![(0.2, 0.25), (0.8, 0.25)], )])); let pixels = render(&ctx, &stack, None); let under = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 4); let below = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 4); assert!( under > UNTOUCHED + 40, "the stroke should have brightened the upper quarter, got {under}" ); assert!( (120..=136).contains(&below), "the lower half was never painted and must be untouched, got {below}" ); } /// The failure a bounding box that is not grown by the radius produces: a tap /// has no extent at all, so its quad has no area and nothing is drawn. Silent, /// and it looks exactly like a brush that ignores short gestures. #[test] fn a_tap_paints_a_dab() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let stack = stack_of(painted(&[(false, 0.2, 1.0, vec![(0.5, 0.5)])])); let pixels = render(&ctx, &stack, None); let centre = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2); let corner = luma_in(&pixels, SIZE, 1, 1); assert!(centre > 180, "the dab should be there, got {centre}"); assert!( (120..=136).contains(&corner), "and only there, got {corner}" ); } /// Painting must be able to erase, or a mask is one mistake away from being /// started again. #[test] fn an_erasing_stroke_takes_back_what_was_painted() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let stack = stack_of(painted(&[ (false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]), (true, 0.12, 1.0, vec![(0.5, 0.5)]), ])); let pixels = render(&ctx, &stack, None); let erased = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2); let kept = luma_in(&pixels, SIZE, 3, SIZE / 2); assert!( (120..=136).contains(&erased), "the erased middle should be back to untouched grey, got {erased}" ); assert!( kept > 180, "the ends of the stroke are still painted, got {kept}" ); } /// Order is the mask. The same two gestures the other way round leave the /// paint alone, and a rasteriser that composited by kind rather than by /// sequence would give the same answer to both. #[test] fn erasing_before_painting_removes_nothing() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let stack = stack_of(painted(&[ (true, 0.12, 1.0, vec![(0.5, 0.5)]), (false, 0.25, 1.0, vec![(0.15, 0.5), (0.85, 0.5)]), ])); let pixels = render(&ctx, &stack, None); let middle = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2); assert!( middle > 180, "an erase before the paint has nothing to take away, got {middle}" ); } /// A stroke that crosses itself must not build up where it did. Summing the /// segments instead of taking the nearest would make every circle and every /// scribble blotchy — and at full flow it would not show at all, which is why /// this paints at half. #[test] fn a_stroke_that_doubles_back_does_not_build_up() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let once = stack_of(painted(&[(false, 0.15, 0.5, vec![(0.1, 0.5), (0.9, 0.5)])])); let twice = stack_of(painted(&[( false, 0.15, 0.5, // Out to the right and back over the last third of itself. vec![(0.1, 0.5), (0.9, 0.5), (0.65, 0.5)], )])); let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE * 3 / 4, SIZE / 2); let crossed = luma_in(&render(&ctx, &twice, None), SIZE, SIZE * 3 / 4, SIZE / 2); assert_eq!( single, crossed, "one pass of the brush, however many times the path went over it" ); } /// Between gestures, though, paint does build up — that is what a flow below /// one is for, and it is the same blend that lets an erase work. #[test] fn two_gestures_at_half_flow_build_up() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let dab = (false, 0.2, 0.5, vec![(0.5, 0.5)]); let once = stack_of(painted(std::slice::from_ref(&dab))); let twice = stack_of(painted(&[dab.clone(), dab])); let single = luma_in(&render(&ctx, &once, None), SIZE, SIZE / 2, SIZE / 2); let doubled = luma_in(&render(&ctx, &twice, None), SIZE, SIZE / 2, SIZE / 2); assert!( doubled > single, "a second pass should deposit more: {single} then {doubled}" ); } /// A brush whose dab is an ellipse is not a brush. The radius is a fraction of /// the *shorter* edge, so on a frame twice as wide as it is tall a circle in /// normalised coordinates would come out twice as wide as it is high. #[test] fn a_dab_is_round_on_a_frame_that_is_not_square() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; const W: u32 = 64; const H: u32 = 32; let stack = stack_of(painted(&[(false, 0.25, 1.0, vec![(0.5, 0.5)])])); let pixels = render_at(&ctx, &stack, None, W, H); let lit = |v: u8| v > 160; let across = (0..W) .filter(|&x| lit(luma_in(&pixels, W, x, H / 2))) .count(); let down = (0..H) .filter(|&y| lit(luma_in(&pixels, W, W / 2, y))) .count(); assert!( across > 4 && down > 4, "the dab should exist: {across}x{down}" ); assert!( across.abs_diff(down) <= 2, "a dab must be as wide as it is tall, got {across} across and {down} down" ); } /// Hardness is the edge, and the edge is what a brush is judged on. A hard /// brush that faded like a soft one would make the control do nothing anyone /// could see. #[test] fn hardness_decides_how_quickly_the_edge_falls_away() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let edge = |hardness: f32| { let mut layer = brighten(MaskSource::brush()); layer.begin_stroke(0, false, 0.4, hardness, 1.0); layer.extend_stroke(0, 0.5, 0.5); layer.end_stroke(0); let pixels = render(&ctx, &stack_of(layer), None); // How many pixels along the centre row are neither fully painted nor // fully clear — the width of the transition. "Fully painted" is read // from the middle of the dab rather than assumed: +2 EV over mid grey // lands wherever the output transform puts it. let solid = luma_in(&pixels, SIZE, SIZE / 2, SIZE / 2); (0..SIZE) .filter(|&x| { let v = luma_in(&pixels, SIZE, x, SIZE / 2); v > UNTOUCHED + 8 && v < solid - 8 }) .count() }; let soft = edge(0.0); let hard = edge(1.0); assert!( hard < soft, "a hard brush should transition in fewer pixels: hard {hard}, soft {soft}" ); assert!(hard <= 4, "and it should be nearly a step, got {hard}"); } /// The loud failure an unpainted mask can produce: empty inverts to /// everything, so a layer created with invert already set would apply its /// adjustment to the whole photograph before a stroke was made. #[test] fn an_inverted_brush_layer_with_no_strokes_changes_nothing() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut layer = brighten(MaskSource::brush()); layer.invert = true; let pixels = render(&ctx, &stack_of(layer), None); for (x, y) in [(1, 1), (SIZE / 2, SIZE / 2), (SIZE - 2, SIZE - 2)] { let v = luma_in(&pixels, SIZE, x, y); assert!( (120..=136).contains(&v), "an unpainted mask covers nothing, inverted or not; got {v} at {x},{y}" ); } } /// A painted layer and a gradient in one stack must not read each other's /// slice — the brush writes its slot through a different pipeline, which is /// exactly where a slot could be got wrong without either alone noticing. #[test] fn a_brush_layer_and_a_gradient_keep_their_own_slices() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(painted(&[(false, 0.15, 1.0, vec![(0.5, 0.15)])])); let mut darken = MaskLayer::new( "m2", MaskSource::Radial { centre: (0.5, 0.85), radii: (0.15, 0.15), angle: 0.0, feather: 0.1, }, ); darken.set_param("exposure", ParamId("exposure"), -2.0); stack.push(darken); let pixels = render(&ctx, &stack, None); let top = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 3 / 20); let bottom = luma_in(&pixels, SIZE, SIZE / 2, SIZE * 17 / 20); assert!(top > 180, "the painted dab should have brightened: {top}"); assert!(bottom < 100, "the radial should have darkened: {bottom}"); } /// A neutral edit must render identically whether or not masks are bound — /// otherwise merely *having* the feature would alter every unedited image. #[test] fn an_empty_stack_renders_exactly_as_the_unmasked_path() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let plain = { let source = grey(&ctx); let mut adjust = AdjustPass::new(&ctx); let shader = EditGraph::default_chain().compose(); adjust.render(&source, &shader, SIZE, SIZE).expect("render"); adjust.export_pixels().expect("readback").0 }; let masked = render(&ctx, &MaskStack::new(), None); assert_eq!(plain, masked, "an empty mask stack must be a no-op"); } // --------------------------------------------------------------------------- // Parts — a mask built from more than one selection // --------------------------------------------------------------------------- /// Everything, so a part joined to it has something to change. fn whole_frame() -> MaskSource { MaskSource::Regions { signature: 1, level: 2, ids: vec![0, 1], } } /// Paint one gesture into a part of a layer, at a radius large enough that a /// 32-pixel frame can tell where it landed. fn paint(layer: &mut MaskLayer, part: usize, erase: bool, path: &[(f32, f32)]) { assert!( layer.begin_stroke(part, erase, 0.2, 1.0, 1.0), "the layer had no room for a stroke" ); for &(x, y) in path { layer.extend_stroke(part, x, y); } layer.end_stroke(part); } #[test] fn a_union_part_adds_what_the_base_did_not_cover() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut layer = brighten(MaskSource::Regions { signature: 1, level: 2, ids: vec![0], }); assert!(layer.push_part(MaskPart::painted("p2", Join::Union))); paint(&mut layer, 1, false, &[(0.85, 0.5)]); let mut stack = MaskStack::new(); stack.push(layer); let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); assert!( luma_at(&pixels, 4, 16) > 200, "the base region is still masked" ); assert!( luma_at(&pixels, 27, 16) > 200, "and the painted part joined the other half in" ); assert_eq!( luma_at(&pixels, 20, 2), 128, "while what neither covers is untouched" ); } #[test] fn a_subtract_part_takes_a_bite_out_of_the_mask() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut layer = brighten(whole_frame()); assert!(layer.push_part(MaskPart::painted("p2", Join::Subtract))); paint(&mut layer, 1, false, &[(0.5, 0.5)]); let mut stack = MaskStack::new(); stack.push(layer); let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); assert_eq!( luma_at(&pixels, 16, 16), 128, "the middle was taken back out of the mask" ); assert!( luma_at(&pixels, 1, 1) > 200, "and the corner the stroke never reached is still in it" ); } /// **The test the scratch texture exists for.** An erase stroke means a hole in /// the part it was painted into, not a hole in the mask: drawn straight onto /// the accumulator it would take away whatever the parts before it had put /// there, so tidying the edge of a correction would punch through the subject /// underneath — and the failure would read as the model's mask having holes. #[test] fn an_erase_stroke_holes_its_own_part_and_not_the_mask() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut layer = brighten(whole_frame()); assert!(layer.push_part(MaskPart::painted("p2", Join::Union))); paint(&mut layer, 1, false, &[(0.5, 0.5)]); paint(&mut layer, 1, true, &[(0.5, 0.5)]); let mut stack = MaskStack::new(); stack.push(layer); let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); assert!( luma_at(&pixels, 16, 16) > 200, "the base still covers where the correction erased itself" ); } #[test] fn an_inverted_part_joins_everything_it_did_not_paint() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; // A base that covers nothing, so what shows is the part alone. let mut layer = brighten(MaskSource::brush()); paint(&mut layer, 0, false, &[(0.1, 0.1)]); assert!(layer.push_part(MaskPart::painted("p2", Join::Union))); layer.parts_mut()[1].invert = true; paint(&mut layer, 1, false, &[(0.5, 0.5)]); let mut stack = MaskStack::new(); stack.push(layer); let pixels = render(&ctx, &stack, Some(&split_field(&ctx))); assert_eq!( luma_at(&pixels, 16, 16), 128, "where the inverted part was painted is outside the mask" ); assert!( luma_at(&pixels, 30, 30) > 200, "and everywhere it was not is inside it" ); } /// Parts fold in order, so the same two selections joined the other way round /// are a different mask. A subtraction that lands before the part it was meant /// to cut into would take away nothing at all. #[test] fn the_order_parts_are_joined_in_is_the_mask() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let render_pair = |cut_first: bool| { let mut layer = brighten(MaskSource::brush()); if cut_first { layer.push_part(MaskPart::painted("p2", Join::Subtract)); layer.push_part(MaskPart::painted("p3", Join::Union)); paint(&mut layer, 1, false, &[(0.5, 0.5)]); paint(&mut layer, 2, false, &[(0.5, 0.5)]); } else { layer.push_part(MaskPart::painted("p2", Join::Union)); layer.push_part(MaskPart::painted("p3", Join::Subtract)); paint(&mut layer, 1, false, &[(0.5, 0.5)]); paint(&mut layer, 2, false, &[(0.5, 0.5)]); } let mut stack = MaskStack::new(); stack.push(layer); render(&ctx, &stack, Some(&split_field(&ctx))) }; assert!( luma_at(&render_pair(true), 16, 16) > 200, "adding after a subtraction leaves the addition standing" ); assert_eq!( luma_at(&render_pair(false), 16, 16), 128, "and subtracting after an addition takes it away again" ); } // --- seeing the mask (FR-DEV-19c) ------------------------------------------ /// A radial that covers the middle of the frame and nothing near the corners. fn middle() -> MaskSource { MaskSource::Radial { centre: (0.5, 0.5), radii: (0.3, 0.3), angle: 0.0, feather: 0.05, } } /// [`render`], with one layer's mask drawn over the result. fn render_revealing(ctx: &GpuContext, stack: &MaskStack, reveal: &Reveal) -> Vec { let source = grey(ctx); let shader = compose_full_revealing( &ops::chain(), &Framing::new(), ColourSpace::Srgb, stack, &SpotSet::new(), &[], Some(reveal), ); let mut masks = MaskPass::new(ctx).expect("mask pass"); let array = masks .render_revealing(stack, None, None, None, SIZE, SIZE, Some(reveal)) .expect("rasterise"); let mut adjust = AdjustPass::new(ctx); adjust .render_masked(&source, &shader, SIZE, SIZE, Some(array)) .expect("render"); adjust.export_pixels().expect("readback").0 } /// TRACES: FR-DEV-19c /// The state every mask is in for its first few seconds: chosen, and not yet /// used for anything. /// /// Such a layer changes no pixel, so it is not active, so it occupied no mask /// slot and was never rasterised — and the reveal drew nothing. That is the /// whole of "I clicked the category and nothing happened": there was a mask, /// and no way to see that there was. #[test] fn a_selection_with_no_adjustment_can_still_be_seen() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(MaskLayer::new("m1", middle())); assert!( stack.is_neutral(), "the fixture must be a selection with nothing done to it" ); let pixels = render_revealing( &ctx, &stack, &Reveal { layer: "m1".into(), style: RevealStyle::Alpha, }, ); assert!( luma_at(&pixels, SIZE / 2, SIZE / 2) > 200, "the middle is inside the mask and should read white" ); assert!( luma_at(&pixels, 1, 1) < 40, "the corner is outside it and should read black" ); } /// And with nobody looking, the same stack changes nothing at all. /// /// The other half of the property above: a layer renders *because* it is being /// revealed, so it must stop when the reveal does — otherwise a selection with /// no adjustment would leave a slice in the array for ever. #[test] fn a_mask_nobody_is_looking_at_draws_nothing() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(MaskLayer::new("m1", middle())); let pixels = render(&ctx, &stack, None); assert_eq!( luma_at(&pixels, SIZE / 2, SIZE / 2), 128, "flat grey, exactly as it went in" ); } /// TRACES: FR-DEV-19c /// A tint has to leave the photograph visible, or it cannot be judged against /// it — which is the one thing an overlay exists for. #[test] fn a_tint_colours_the_mask_and_leaves_the_rest_alone() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(MaskLayer::new("m1", middle())); let pixels = render_revealing( &ctx, &stack, &Reveal { layer: "m1".into(), style: RevealStyle::Tint, }, ); let at = |x: u32, y: u32| { let i = ((y * SIZE + x) * 4) as usize; (pixels[i], pixels[i + 1], pixels[i + 2]) }; let (r, g, _) = at(SIZE / 2, SIZE / 2); assert!(r > g + 40, "the mask should read red, got r={r} g={g}"); assert!( g > 20, "and not opaque — the photograph under it is what the tint is judged \ against, got g={g}" ); let (r, g, b) = at(1, 1); assert!( (120..=136).contains(&r) && r == g && g == b, "outside the mask the photograph is untouched, got ({r}, {g}, {b})" ); } /// TRACES: FR-DEV-19c /// An outline draws where the mask stops and nowhere else — which is the /// point of it, since the other two styles cover the detail the boundary has /// to be judged against. #[test] fn an_outline_draws_the_boundary_and_not_the_interior() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(MaskLayer::new("m1", middle())); let pixels = render_revealing( &ctx, &stack, &Reveal { layer: "m1".into(), style: RevealStyle::Edge, }, ); // Where the line landed, along the row through the centre. Searched // rather than sampled at one place: the radial's edge crosses this row // about 9.6 pixels out from the middle on a 32px frame, and asserting a // particular pixel would be asserting the rounding. let (at, brightest) = (SIZE / 2..SIZE) .map(|x| (x, luma_at(&pixels, x, SIZE / 2))) .max_by_key(|&(_, v)| v) .expect("the row is not empty"); assert!( brightest > 160, "there should be a line somewhere on this row, brightest was {brightest}" ); assert!( (SIZE / 2 + 7..=SIZE / 2 + 12).contains(&at), "and it should be on the mask's boundary, not somewhere else: x={at}" ); assert_eq!( luma_at(&pixels, SIZE / 2, SIZE / 2), 128, "the picture inside the mask is untouched" ); assert_eq!( luma_at(&pixels, 1, 1), 128, "and so is the picture outside it" ); }