Browse / github.com/storytold/photocraft / patch
perf(selection): crop Color Range sampling to the active selection
perf(selection): crop Color Range sampling to the active selection Color Range rendered the entire document and converted every pixel to Lab even when Replace mode discarded everything outside an existing selection. A 24 MP RGBA f32 composite alone occupied 384 MB. Replace now samples the selection's nonzero bounds, clipped to the canvas. Composite rendering or active-layer reads, Lab conversion, coverage, clipping to prior coverage, and selection replacement share that rectangle. Hue, tonal and skin-tone presets use the same bounded sampling. Eyedropper and subtract points still sample anywhere on the original canvas. Localized Color Clusters retain canvas
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- wanikhawar (imported from pull request #9814, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +395 −39 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
dec4bce6a4bae126b5fa274b
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/ci.yml crates/engine/src/cmd/selection_cmds.rs crates/engine/src/cmd/selection_cmds/color_range_performance.rs crates/engine/src/cmd/selection_cmds/color_range_window_tests.rs crates/engine/src/cmd/selection_cmds/tests.rs docs/color-range-performance.md
@@ -11,6 +11,10 @@ on: types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs:+ color_range:+ description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)"+ type: boolean+ default: false selection_morphology: description: "Compare selection morphology on 36 MP (Linux, exact outputs)" type: boolean@@ -424,6 +428,36 @@ jobs: name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} path: crates/${{ matrix.target.crate }}/fuzz/artifacts + color-range-performance:+ if: inputs.color_range == true+ runs-on: ubuntu-latest+ timeout-minutes: 45+ env:+ RAYON_NUM_THREADS: "4"+ steps:+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1+ with:+ persist-credentials: false+ - name: Install Linux build dependencies+ uses: storytold/craft-common-actions/actions/install-apt-packages@main+ with:+ packages: >-+ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev+ libgtk-3-dev+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3+ with:+ key: color-range-perf+ - name: Compare exact outputs and release timings on 24 MP+ run: |+ set -o pipefail+ cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2+ if: always()+ with:+ name: color-range-performance+ path: color-range-perf.txt+ selection-morphology: if: inputs.selection_morphology == true runs-on: ubuntu-latest@@ -459,7 +493,7 @@ jobs: path: selection-morphology-perf.txt selection-morphology-quick-perf:- if: inputs.selection_morphology == true+ if: inputs.selection_morphology == true || inputs.color_range == true # Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by # bevel_bench, so its required GPU rows cannot be measured there. runs-on: macos-15@@ -13,6 +13,11 @@ use crate::{EngineError, Result, Session}; mod modify_window; +#[cfg(test)]+mod color_range_performance;+#[cfg(test)]+mod color_range_window_tests;+ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) }@@ -45,15 +50,18 @@ fn scope_color_range(mask: &mut [f32], doc: &Document, area: Rect, m: SelectionM } /// RGBA pixels the tools sample: the composite, or the active layer.-fn sample_pixels(s: &Session, all_layers: bool) -> Result<(Rect, Vec<[f32; 4]>)> {+fn sample_pixels(s: &Session, all_layers: bool, window: Option<Rect>) -> Result<(Rect, Vec<[f32; 4]>)> { let d = s.active().ok_or(EngineError::NoDocument)?;- let area = d.doc.bounds();+ let area = window.map_or(d.doc.bounds(), |r| r.intersect(&d.doc.bounds())); // Match the decoders' raster budget before either the layer or composite path allocates. let len = area.size().area(); let max_bytes: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; if len > max_bytes / std::mem::size_of::<[f32; 4]>() as u64 { return Err(EngineError::Other("the canvas is too large to sample for a selection".into())); }+ if area.is_empty() {+ return Ok((area, Vec::new()));+ } let layer = d.active_layer.and_then(|id| d.doc.layer(id)).and_then(|l| l.surface()); match (all_layers, layer) { (false, Some(surf)) => {@@ -149,31 +157,6 @@ fn parse_color(p: &Value) -> [f32; 3] { } } -/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point-/// and where it was picked (area-local, for Localized Color Clusters).-fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result<Vec<sel::RangeSample>> {- let Some(pts) = p.get(key) else { return Ok(Vec::new()) };- let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?;- let w = area.width() as usize;- let mut out = Vec::with_capacity(pts.len().min(4096));- for pt in pts {- let xy = pt.as_array().and_then(|a| match a.as_slice() {- [x, y] => Some((x.as_f64()?, y.as_f64()?)),- _ => None,- });- let Some((x, y)) = xy.filter(|(x, y)| x.is_finite() && y.is_finite()) else {- return Err(bad(format!("bad point {pt} in `{key}` (want [x, y])")));- };- let (xi, yi) = (x.floor() as i32, y.floor() as i32);- let q = if area.contains(xi, yi) { px.get((yi - area.y0) as usize * w + (xi - area.x0) as usize) } else { None };- let Some(&[r, g, b, _]) = q else {- return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas")));- };- out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) });- }- Ok(out)-}- /// Composite RGB (the compositor's output profile for the document) → CIE Lab D50. fn lab_transform(s: &Session) -> Result<std::sync::Arc<photocraft_cms::Transform>> { let d = s.active().ok_or(EngineError::NoDocument)?;@@ -346,6 +329,23 @@ impl ColorRangeSamples { } } +/// Replace refines the existing selection; no output outside its nonzero bounds survives.+fn color_range_area(doc: &Document, m: SelectionMode) -> Rect {+ let canvas = doc.bounds();+ let Some(selection) = doc.selection.as_ref().filter(|_| m == SelectionMode::Replace) else { return canvas };+ // content_bounds describes differences from the default, not implicit selected pixels.+ if selection.default_pixel().first().is_some_and(|v| *v != 0.0) {+ return canvas;+ }+ // content_bounds orders raw tile coordinates by their differences. Reject coordinates+ // outside the pixel-addressable range before those differences can overflow.+ let valid = (i32::MIN / photocraft_geom::TILE_SIZE)..=(i32::MAX / photocraft_geom::TILE_SIZE);+ if selection.tiles().any(|(tile, _)| !valid.contains(&tile.tx) || !valid.contains(&tile.ty)) {+ return canvas;+ }+ selection.content_bounds().intersect(&canvas)+}+ fn color_range(s: &mut Session, p: &Value) -> Result<Value> { const CMD: &str = "select.colorRange"; let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg };@@ -357,17 +357,16 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> { if b(p, "detectFaces", false) { return Err(bad("detectFaces: face detection isn't available yet; select without it".into())); }+ let area = color_range_area(&s.active().ok_or(EngineError::NoDocument)?.doc, selection_mode); let (area, mut mask) = match preset { "sampledColors" => {- let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+ let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; let w = area.width() as usize; // The dialog's eyedroppers: colours picked on the image, at their positions. // Like Photoshop's, they may sample anywhere on the canvas: an active selection only // limits the result (scope_color_range), not where colours are picked (#5244).- let plus = eyedropper_samples(p, "points", area, &px, &bad)?;- let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?;- let query = ColorRangeSamples::with_samples(s, p, area, &plus, &minus)?;- let mask = query.coverage(&px, w, 1.0, [0.0, 0.0]);+ let query = ColorRangeSamples::new(s, p)?;+ let mask = query.coverage(&px, w, 1.0, [area.x0 as f32, area.y0 as f32]); (area, mask) } "reds" | "yellows" | "greens" | "cyans" | "blues" | "magentas" => {@@ -379,11 +378,11 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> { "blues" => 240.0, _ => 300.0, };- let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+ let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::hue_range(&px, center)) } "skinTones" => {- let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+ let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::skin_tones(&px, fuzz(40.0).min(200.0))) } "highlights" | "midtones" | "shadows" => {@@ -406,7 +405,7 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> { .filter(|(l, h)| l <= h) .ok_or_else(|| bad(format!("tonalRange: want [low, high] levels 0..255 (got {v})")))?, };- let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?;+ let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true), Some(area))?; (area, sel::tone_range(&px, lo, hi, fuzz(20.0).min(100.0) / 100.0 * 255.0)) } "outOfGamut" => {@@ -545,7 +544,7 @@ fn crop_mask(m: &[f32], pw: usize, pad: usize, w: usize, h: usize) -> Vec<f32> { /// the selected pixels' colour range widened by the tolerance. fn grow_similar(s: &mut Session, p: &Value, contiguous: bool) -> Result<Value> { let tol = f(p, "tolerance", 32.0) / 255.0;- let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false))?;+ let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", false), None)?; let (_, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc); let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); for (q, v) in px.iter().zip(&m) {new file mode 100644@@ -0,0 +1,70 @@+//! Opt-in release comparison; full-canvas buffers belong on a CI runner.+use super::*;+use photocraft_color::{ColorMode, PixelFormat, SampleType};+use photocraft_doc::{Layer, LayerContent, Size};+use std::time::Instant;++fn reference(s: &mut Session, p: &Value) {+ let (canvas, px) = sample_pixels(s, true, None).unwrap();+ // Previous path: eyedropper colour came from the full composite already in memory.+ let [r, g, b, _] = px[1500 * canvas.width() as usize + 2000];+ let point = sel::RangeSample { color: [r, g, b], at: Some((2000.0, 1500.0)) };+ let query = ColorRangeSamples::with_samples(s, p, canvas, &[point], &[]).unwrap();+ let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [0.0, 0.0]);+ drop(px);+ scope_color_range(&mut mask, &s.active().unwrap().doc, canvas, SelectionMode::Replace);+ set_selection(s, "Color Range", canvas, mask, SelectionMode::Replace).unwrap();+}++#[test]+#[ignore = "24 MP release comparison: run on GitHub Actions"]+fn color_range_release_comparison() {+ let mut doc = Document::new("Color Range performance", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8);+ let mut pixels = Surface::with_default(PixelFormat::RGBA8, &[0.65, 0.3, 0.2, 1.0]);+ pixels.fill_rect(Rect::new(3000, 2000, 3600, 2400), &[0.2, 0.3, 0.65, 1.0]);+ doc.layers.push(Layer::new("image", LayerContent::Raster(pixels)));+ let p = json!({"points": [[2000, 1500]], "fuzziness": 40, "localized": true, "range": 80});+ // Small, 1%, 4%, and full selections: include dense fallback cost in the comparison.+ for bounds in [Rect::new(2975, 1975, 3025, 2025), Rect::new(2700, 1800, 3300, 2200), Rect::new(2400, 1600, 3600, 2400), doc.bounds()] {+ let mut selection = Surface::new(PixelFormat::GRAY8);+ selection.fill_rect(bounds, &[128.0 / 255.0]);+ doc.selection = Some(selection);+ let mut old_times = Vec::new();+ let mut new_times = Vec::new();+ let mut expected = None;+ // One warmup pair, followed by three alternating measured pairs.+ for round in 0..4 {+ for old in if round % 2 == 0 { [true, false] } else { [false, true] } {+ let mut session = Session::new();+ session.add_document(doc.clone(), None);+ let now = Instant::now();+ if old {+ reference(&mut session, &p);+ } else {+ session.execute("select.colorRange", p.clone()).unwrap();+ }+ let elapsed = now.elapsed().as_secs_f64() * 1000.0;+ if round > 0 {+ if old { old_times.push(elapsed) } else { new_times.push(elapsed) }+ }+ // Outside timing, compare every stored sample over the whole canvas.+ let actual = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds());+ if let Some(ref expected) = expected {+ assert_eq!(&actual, expected);+ } else {+ expected = Some(actual);+ }+ }+ }+ old_times.sort_by(f64::total_cmp);+ new_times.sort_by(f64::total_cmp);+ let (old, new) = (old_times[1], new_times[1]);+ println!(+ "24 MP Color Range {}x{}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x rgba_bytes={} exact=true",+ bounds.width(),+ bounds.height(),+ old / new,+ bounds.size().area() * 16+ );+ }+}new file mode 100644@@ -0,0 +1,211 @@+use super::*;+use photocraft_color::{ColorMode, PixelFormat, SampleType};+use photocraft_geom::Size;+use serde_json::json;++fn fixture(depth: SampleType, p3: bool) -> Session {+ let mut s = Session::new();+ let mut doc = Document::with_background("color range", Size::new(64, 48), ColorMode::Rgb, depth, photocraft_doc::Color::WHITE);+ if p3 {+ doc.icc_profile = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes());+ } else {+ doc.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes());+ }+ let bg = doc.layers[0].surface_mut().unwrap();+ bg.fill_rect(Rect::new(0, 0, 64, 48), &[0.92, 0.92, 0.92, 1.0]);+ bg.fill_rect(Rect::new(7, 8, 22, 23), &[0.85, 0.12, 0.08, 1.0]);+ bg.fill_rect(Rect::new(35, 8, 50, 23), &[0.12, 0.72, 0.18, 1.0]);+ bg.fill_rect(Rect::new(7, 29, 22, 44), &[0.12, 0.20, 0.86, 1.0]);+ bg.fill_rect(Rect::new(35, 29, 50, 44), &[0.72, 0.48, 0.31, 1.0]);+ s.add_document(doc, None);+ s+}++fn coverage_at(s: &Session, x: i32, y: i32) -> f32 {+ s.active().and_then(|d| d.doc.selection.as_ref()).map_or(0.0, |m| m.sample_channel(x, y, 0))+}++fn full_sampled_reference(s: &Session, params: &Value) -> Vec<f32> {+ let doc = &s.active().unwrap().doc;+ let canvas = doc.bounds();+ let (_, px) = sample_pixels(s, b(params, "sampleAllLayers", true), None).unwrap();+ // Reproduce the previous command: read eyedropper colours from the full composite.+ let samples = |key: &str| -> Vec<sel::RangeSample> {+ params+ .get(key)+ .and_then(Value::as_array)+ .into_iter()+ .flatten()+ .map(|point| {+ let x = point[0].as_f64().unwrap().floor() as usize;+ let y = point[1].as_f64().unwrap().floor() as usize;+ let pixel = px[y * canvas.width() as usize + x];+ sel::RangeSample { color: [pixel[0], pixel[1], pixel[2]], at: Some((x as f32, y as f32)) }+ })+ .collect()+ };+ let query = ColorRangeSamples::with_samples(s, params, canvas, &samples("points"), &samples("subtractPoints")).unwrap();+ let mut mask = query.coverage(&px, canvas.width() as usize, 1.0, [canvas.x0 as f32, canvas.y0 as f32]);+ if b(params, "invert", false) {+ mask.iter_mut().for_each(|v| *v = 1.0 - *v);+ }+ scope_color_range(&mut mask, doc, canvas, mode(params));+ sel::mask_from_surface(sel::combine(doc.selection.as_ref(), &mask, canvas, mode(params)).as_ref(), canvas)+}++#[test]+fn sampled_color_cropped_results_match_full_canvas_for_depths_profiles_and_sample_options() {+ for (depth, p3) in [SampleType::U8, SampleType::U16, SampleType::F32].into_iter().flat_map(|depth| [false, true].map(|p3| (depth, p3))) {+ for params in [+ json!({"points": [[40, 15]], "fuzziness": 12}),+ json!({"points": [[40, 15]], "subtractPoints": [[10, 15]], "order": ["-", "+"], "fuzziness": 18}),+ json!({"points": [[40, 15]], "localized": true, "range": 80, "invert": true}),+ json!({"points": [[10, 15]], "localized": true, "range": 25}),+ json!({"points": [[40, 15]], "sampleAllLayers": false, "fuzziness": 0}),+ ] {+ let mut s = fixture(depth, p3);+ s.execute("select.rect", json!({"x": 3, "y": 5, "width": 22, "height": 25})).unwrap();+ let expected = full_sampled_reference(&s, ¶ms);+ s.execute("select.colorRange", params.clone()).unwrap();+ let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds());+ assert_eq!(actual, expected, "depth={depth:?} p3={p3} params={params}");+ }+ }+}++#[test]+fn range_presets_are_scoped_and_non_replace_modes_keep_full_canvas_semantics() {+ for preset in ["reds", "yellows", "greens", "cyans", "blues", "magentas", "highlights", "midtones", "shadows", "skinTones"] {+ let mut s = fixture(SampleType::U8, false);+ s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap();+ s.execute("select.colorRange", json!({"select": preset})).unwrap();+ assert_eq!(coverage_at(&s, 42, 15), 0.0, "preset {preset} escaped selection");+ }+ let mut s = fixture(SampleType::U8, false);+ s.execute("select.rect", json!({"x": 2, "y": 4, "width": 24, "height": 25})).unwrap();+ s.execute("select.colorRange", json!({"points": [[40, 15]], "fuzziness": 0, "mode": "add"})).unwrap();+ assert_eq!(coverage_at(&s, 42, 15), 1.0);+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(coverage_at(&s, 42, 15), 0.0);+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(coverage_at(&s, 42, 15), 1.0);+}++#[test]+fn window_defaults_and_hostile_tiles_fall_back_and_empty_selection_is_safe() {+ let canvas = Rect::new(0, 0, 64, 48);+ let mut doc = Document::new("window", Size::new(64, 48), ColorMode::Rgb, SampleType::U8);+ doc.selection = Some(Surface::with_default(PixelFormat::GRAY8, &[1.0]));+ assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas);+ doc.selection = Some(Surface::new(PixelFormat::GRAY8));+ assert_eq!(color_range_area(&doc, SelectionMode::Replace), Rect::new(0, 0, 0, 0));+ use photocraft_geom::TileCoord;+ use std::sync::Arc;+ let mut hostile = Surface::new(PixelFormat::GRAY8);+ let tile = hostile.solid_tile(&[1.0]);+ hostile.put_tiles([(TileCoord::new(i32::MIN, i32::MIN), Arc::clone(&tile)), (TileCoord::new(i32::MAX, i32::MAX), tile)]);+ doc.selection = Some(hostile);+ assert_eq!(color_range_area(&doc, SelectionMode::Replace), canvas);+ assert_eq!(color_range_area(&doc, SelectionMode::Add), canvas);+ doc.selection = Some(Surface::new(PixelFormat::GRAY8));+ let mut s = Session::new();+ s.add_document(doc, None);+ assert!(sample_pixels(&s, true, Some(color_range_area(&s.active().unwrap().doc, SelectionMode::Replace))).unwrap().1.is_empty());+ let err = s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": -1})).unwrap_err();+ assert!(err.to_string().contains("tonalRange"));+ for params in [json!({"points": [[64, 0]]}), json!({"points": "invalid"}), json!({"localized": true}), json!({"order": ["+"]})] {+ assert!(s.execute("select.colorRange", params).is_err());+ }+ s.execute("select.colorRange", json!({"points": [[1, 1]], "invert": true})).unwrap();+ assert!(s.active().unwrap().doc.selection.is_none());+}++#[test]+fn sparse_large_selection_samples_only_its_clipped_window() {+ let mut doc = Document::new("sparse", Size::new(6000, 4000), ColorMode::Rgb, SampleType::U8);+ let selection = sel::mask_to_surface(&vec![1.0; 24 * 20], Rect::new(2990, 1990, 3014, 2010));+ doc.selection = Some(selection);+ let mut s = Session::new();+ s.add_document(doc, None);+ let win = color_range_area(&s.active().unwrap().doc, SelectionMode::Replace);+ assert_eq!(win, Rect::new(2990, 1990, 3014, 2010));+ let (area, pixels) = sample_pixels(&s, true, Some(win)).unwrap();+ assert_eq!(pixels.len(), area.size().area() as usize);+ s.execute("select.colorRange", json!({"select": "highlights"})).unwrap();+}++#[test]+fn cropped_range_matches_full_canvas_combinations_with_soft_holes_and_clipped_masks() {+ let canvas = Rect::new(0, 0, 64, 48);+ for case in 0..6 {+ for selection_mode in ["replace", "add", "subtract", "intersect"] {+ for preset in ["sampledColors", "reds", "greens", "blues", "highlights", "midtones", "shadows", "skinTones"] {+ for invert in [false, true] {+ let mut s = fixture(SampleType::U16, true);+ let mut selection = match case {+ 0 => Surface::new(PixelFormat::GRAY8),+ 1 => Surface::with_default(PixelFormat::GRAY8, &[128.0 / 255.0]),+ _ => sel::mask_to_surface(&vec![128.0 / 255.0; 30 * 30], Rect::new(-5, 5, 25, 35)),+ };+ if case == 3 {+ selection.fill_rect(Rect::new(8, 10, 15, 20), &[0.0]);+ }+ if case == 5 {+ selection = Surface::new(PixelFormat::GRAY8);+ }+ if case == 4 {+ selection = sel::mask_to_surface(&[1.0; 4], Rect::new(-8, -8, -6, -6));+ }+ let original = (case != 0).then_some(selection);+ let mut prior = s.active().unwrap().doc.as_ref().clone();+ prior.selection = original;+ let before = sel::mask_from_surface(prior.selection.as_ref(), canvas);+ let mut unselected = prior.clone();+ unselected.selection = None;+ let mut full = Session::new();+ full.add_document(unselected, None);+ let params = json!({"select": preset, "points": [[40, 15]], "fuzziness": 40, "invert": invert});+ full.execute("select.colorRange", params.clone()).unwrap();+ let mut mask = sel::mask_from_surface(full.active().unwrap().doc.selection.as_ref(), canvas);+ let m = SelectionMode::parse(selection_mode);+ scope_color_range(&mut mask, &prior, canvas, m);+ let expected = sel::combine(prior.selection.as_ref(), &mask, canvas, m);+ s.edit("prior selection", |doc, _| {+ doc.selection = prior.selection.clone();+ Ok(())+ })+ .unwrap();+ let mut params = params;+ params["mode"] = json!(selection_mode);+ s.execute("select.colorRange", params).unwrap();+ let actual = sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas);+ assert_eq!(actual, sel::mask_from_surface(expected.as_ref(), canvas), "case {case}, {selection_mode}, {preset}, invert {invert}");+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), before);+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), canvas), actual);+ }+ }+ }+ }+}++#[test]+fn cropped_samples_match_full_composite_with_translucent_layers_and_active_layer_sampling() {+ use photocraft_doc::{Layer, LayerContent};+ for all_layers in [false, true] {+ let mut s = fixture(SampleType::F32, true);+ s.edit("overlay", |doc, _| {+ let mut pixels = Surface::new(PixelFormat::RGBA32F);+ pixels.fill_rect(Rect::new(0, 0, 64, 48), &[0.3, 0.1, 0.6, 0.35]);+ doc.layers.push(Layer::new("overlay", LayerContent::Raster(pixels)));+ Ok(())+ })+ .unwrap();+ s.execute("select.rect", json!({"x": 5, "y": 6, "width": 20, "height": 30})).unwrap();+ let params = json!({"points": [[40, 15], [10, 15]], "subtractPoints": [[40, 35]], "sampleAllLayers": all_layers, "localized": true, "range": 70});+ let expected = full_sampled_reference(&s, ¶ms);+ s.execute("select.colorRange", params).unwrap();+ assert_eq!(sel::mask_from_surface(s.active().unwrap().doc.selection.as_ref(), s.active().unwrap().doc.bounds()), expected);+ }+}@@ -60,7 +60,7 @@ fn selection_sampling_refuses_oversized_canvases_before_reading_pixels() { None, ); for all_layers in [false, true] {- assert!(sample_pixels(&s, all_layers).unwrap_err().to_string().contains("too large to sample"));+ assert!(sample_pixels(&s, all_layers, None).unwrap_err().to_string().contains("too large to sample")); } assert!(!s.active().unwrap().history.can_undo()); }@@ -275,7 +275,7 @@ fn color_range_prepared_query_samples_visible_layers_not_the_active_layer() { clear_selection(&mut s); let p = json!({"points": [[7, 7]], "sampleAllLayers": all_layers, "fuzziness": 0}); let query = ColorRangeSamples::new(&s, &p).unwrap();- let (area, px) = sample_pixels(&s, all_layers).unwrap();+ let (area, px) = sample_pixels(&s, all_layers, None).unwrap(); let shown = query.coverage(&px, area.width() as usize, 1.0, [0.0, 0.0]); s.execute("select.colorRange", p).unwrap(); assert_eq!(shown[7 * 40 + 7], coverage(&s, 7, 7));new file mode 100644@@ -0,0 +1,42 @@+# Color Range selection sampling++`select.colorRange` in Replace mode refines an existing selection. Its sampled-colour,+hue, skin-tone and tonal queries render/read only `selection.content_bounds()` clipped+to the canvas. RGBA buffers, Lab conversion, coverage, selection clipping and replacement+use that same rectangle. Invert happens before clipping to the old coverage, including+soft edges and holes.++Eyedropper points and subtract points still sample anywhere on the original canvas.+Localized Color Clusters retain the full canvas's range and sample positions; the+coverage query receives the sampled rectangle's origin. Without a selection, or with+Add, Subtract or Intersect, sampling covers the full canvas. Nonzero implicit selection+defaults and malformed tile coordinates also use the full canvas. An explicitly empty+selection produces an empty result while still validating query parameters.++The Out of Gamut preset uses the existing proof gamut-mask path and is not cropped.+Interactive preview resolution and scheduling are unchanged.++On a 6000×4000 canvas, the RGBA f32 buffer normally contains 24,000,000 pixels+(384,000,000 bytes). A 50×50 selection uses 2,500 pixels (40,000 bytes); a 600×400+selection uses 240,000 pixels (3,840,000 bytes). Lab input/output and mask buffers+shrink by the same area ratio. These are buffer sizes, not measured peak RSS.++## Reproduce the comparison++On a CI runner, run:++```sh+RAYON_NUM_THREADS=4 cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture+```++Or dispatch `ci.yml` with `color_range=true`. It runs the Linux release comparison,+full normal CI, and the general quick performance suite on macOS. The release fixture+is 24 MP with two colour regions and localized eyedropper sampling. It compares the+full-canvas reference with the cropped command for 50×50, 600×400, 1200×800 and+full-canvas soft selections. Each has a warm-up pair and three alternating timed+pairs; reported timings are medians. Both include the undoable selection edit, and the+cropped path includes command dispatch. Every stored coverage sample is compared over+the whole canvas outside timing. No GUI frame latency is measured.++This optimization does not change an existing scorecard scenario or baseline. The+request's P5 label is unrelated to scorecard P5 (layer visibility).