diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index f4297343a6..7fd8707a99 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -9,13 +9,16 @@ //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). //! * The **active settings** are process-wide: the working settings (Edit → Color Settings), with //! the profiles [`use_document_profiles`] last put in place of the working spaces; -//! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`]. +//! [`crate::Color::to_rgb`] uses them. [`with_active`] puts other settings in their place on one +//! thread, as for a document drawn through its own profiles ([`for_document`]). Code that needs +//! deterministic numbers builds its own [`Cms`] with [`Cms::new`]. pub mod display; pub mod generic; pub mod icc; pub mod lab; +use std::cell::{Cell, RefCell}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; @@ -910,9 +913,32 @@ static WORKING: RwLock>> = RwLock::new(None); static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true); static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false); +thread_local! { + /// The settings [`with_active`] put in place of the active settings on this thread. + static SCOPED: RefCell>> = const { RefCell::new(None) }; + /// [`SCOPED`]'s flags for the fast paths: [`SCOPED_SET`] while it is set, [`SCOPED_SRGB`] when + /// its RGB space is sRGB, and [`SCOPED_DEVICE`] when its CMYK space is device CMYK. + static SCOPED_FLAGS: Cell = const { Cell::new(0) }; +} +const SCOPED_SET: u8 = 1; +const SCOPED_SRGB: u8 = 2; +const SCOPED_DEVICE: u8 = 4; + /// The active color-management context: the working settings, with the profiles -/// [`use_document_profiles`] last put in place of the working spaces. +/// [`use_document_profiles`] last put in place of the working spaces. Inside [`with_active`] on +/// this thread: the settings it was given. pub fn active() -> Arc { + if SCOPED_FLAGS.with(Cell::get) & SCOPED_SET != 0 + && let Some(c) = SCOPED.with(|s| s.borrow().clone()) + { + return c; + } + installed() +} + +/// The active settings of the process, as [`use_document_profiles`] or [`set_active`] last put them +/// in place. +fn installed() -> Arc { if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() { return c.clone(); } @@ -954,14 +980,8 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { /// the active settings changed. pub fn use_document_profiles(rgb: Option<&str>, cmyk: Option<&str>) -> bool { let working = working(); - let mut st = working.settings.clone(); - if let Some(p) = rgb.and_then(profile).filter(|p| p.kind == ProfileKind::Rgb) { - st.rgb = p.name; - } - if let Some(p) = cmyk.and_then(profile).filter(|p| p.kind == ProfileKind::Cmyk) { - st.cmyk = p.name; - } - if st == active().settings { + let st = document_settings(&working.settings, rgb, cmyk); + if st == installed().settings { return false; } if st == working.settings { @@ -976,6 +996,72 @@ pub fn use_document_profiles(rgb: Option<&str>, cmyk: Option<&str>) -> bool { true } +/// The working settings `working` with the profiles a document is assigned (`None`: none) in place +/// of the working spaces, as [`use_document_profiles`] takes them. +fn document_settings(working: &ColorSettings, rgb: Option<&str>, cmyk: Option<&str>) -> ColorSettings { + let mut st = working.clone(); + if let Some(p) = rgb.and_then(profile).filter(|p| p.kind == ProfileKind::Rgb) { + st.rgb = p.name; + } + if let Some(p) = cmyk.and_then(profile).filter(|p| p.kind == ProfileKind::Cmyk) { + st.cmyk = p.name; + } + st +} + +/// How many contexts [`for_document`] keeps. +const DOCUMENT_CONTEXTS: usize = 8; + +/// The color-management context a document assigned the profiles `rgb` and `cmyk` (`None`: none) +/// draws through: the one [`use_document_profiles`] would make active for it. A context that is the +/// active or the working one is that one; the others are kept for the same working settings and +/// loaded profiles, so the same document gets the same context again. A profile that cannot be +/// loaded gives the working settings. +pub fn for_document(rgb: Option<&str>, cmyk: Option<&str>) -> Arc { + static KEPT: Mutex)>> = Mutex::new(Vec::new()); + let working = working(); + let st = document_settings(&working.settings, rgb, cmyk); + let installed = installed(); + if st == installed.settings { + return installed; + } + if st == working.settings { + return working; + } + let generation = profiles_generation(); + let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner()); + kept.retain(|(g, c)| *g == generation && c.working.as_ref().is_some_and(|w| Arc::ptr_eq(w, &working))); + if let Some((_, c)) = kept.iter().find(|(_, c)| c.settings == st) { + return c.clone(); + } + let Ok(mut cms) = Cms::new(&st) else { return working }; + cms.working = Some(working); + let cms = Arc::new(cms); + if kept.len() >= DOCUMENT_CONTEXTS { + kept.remove(0); + } + kept.push((generation, cms.clone())); + cms +} + +/// Run `f` with `cms` as the active settings on this thread ([`active`], [`crate::Color::to_rgb`] +/// and the conversions that read them), as when drawing a document other than the front one +/// through its own profiles. Other threads, and this thread after `f`, keep the settings they had. +pub fn with_active(cms: Arc, f: impl FnOnce() -> R) -> R { + /// Puts back the settings this thread had before, when `f` returns or unwinds. + struct Restore(Option>, u8); + impl Drop for Restore { + fn drop(&mut self) { + SCOPED.with(|s| *s.borrow_mut() = self.0.take()); + SCOPED_FLAGS.with(|c| c.set(self.1)); + } + } + let flags = SCOPED_SET | if cms.rgb_is_srgb() { SCOPED_SRGB } else { 0 } | if matches!(cms.cmyk, CmykSpace::Device) { SCOPED_DEVICE } else { 0 }; + let before = SCOPED.with(|s| s.borrow_mut().replace(cms)); + let _restore = Restore(before, SCOPED_FLAGS.with(|c| c.replace(flags))); + f() +} + fn install(cms: Arc) { RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed); CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed); @@ -984,10 +1070,16 @@ fn install(cms: Arc) { /// Fast path for [`Color::to_rgb`]: RGB colours need no conversion while the working space is sRGB. pub(crate) fn rgb_is_srgb() -> bool { - RGB_IS_SRGB.load(Ordering::Relaxed) + match SCOPED_FLAGS.with(Cell::get) { + f if f & SCOPED_SET != 0 => f & SCOPED_SRGB != 0, + _ => RGB_IS_SRGB.load(Ordering::Relaxed), + } } pub(crate) fn cmyk_is_device() -> bool { - CMYK_IS_DEVICE.load(Ordering::Relaxed) + match SCOPED_FLAGS.with(Cell::get) { + f if f & SCOPED_SET != 0 => f & SCOPED_DEVICE != 0, + _ => CMYK_IS_DEVICE.load(Ordering::Relaxed), + } } #[cfg(test)] diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 57709abe66..7b22163525 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -403,3 +403,42 @@ fn lab_colours_round_trip_through_the_cms() { let Color::Gray { k } = c.convert(&Color::lab(50.0, 0.0, 0.0), Model::Gray, ri) else { panic!("Gray") }; assert!((k - 0.53).abs() < 0.01, "{k}"); } + +#[test] +fn with_active_puts_settings_in_place_on_this_thread_only() { + let p3 = Arc::new(Cms::new(&ColorSettings { rgb: DISPLAY_P3.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap()); + let (rgb, ink) = (Color::Rgb { r: 0.2, g: 0.6, b: 0.4 }, Color::Cmyk { c: 0.2, m: 0.3, y: 0.4, k: 0.1 }); + let before = (rgb.to_rgb(), ink.to_rgb()); + let (inside, ink_inside, other_thread, nested) = with_active(p3.clone(), || { + let other = std::thread::spawn(move || rgb.to_rgb()).join().unwrap(); + let nested = with_active(Arc::new(cms()), || rgb.to_rgb()); + (rgb.to_rgb(), ink.to_rgb(), other, nested) + }); + assert_eq!(inside, p3.display_rgb(&rgb), "RGB is drawn through the settings given"); + assert_ne!(inside, before.0); + assert_eq!(ink_inside, naive_cmyk_to_rgb([0.2, 0.3, 0.4, 0.1]), "CMYK takes the device formula of the settings given"); + assert_eq!(other_thread, before.0, "another thread keeps the active settings"); + assert_eq!(nested, before.0, "an inner call puts its own settings in place"); + assert_eq!((rgb.to_rgb(), ink.to_rgb()), before, "the active settings come back after"); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| with_active(p3.clone(), || panic!("unwinds")))); + assert!(unwound.is_err()); + assert_eq!((rgb.to_rgb(), ink.to_rgb()), before, "the active settings come back after a panic"); +} + +#[test] +fn a_document_gets_the_same_context_for_the_same_profiles() { + // Other tests load profiles meanwhile, and a load makes the contexts again: try until one pair + // of calls sees none. + let same = (0..100).any(|_| { + let generation = profiles_generation(); + let (a, b) = (for_document(Some(DISPLAY_P3), None), for_document(Some(DISPLAY_P3), None)); + profiles_generation() == generation && Arc::ptr_eq(&a, &b) + }); + assert!(same, "the same profiles give the same context"); + let p3 = for_document(Some(DISPLAY_P3), None); + assert_eq!(p3.settings().rgb, DISPLAY_P3); + assert_eq!(p3.settings().cmyk, working_settings().cmyk, "the working CMYK space stays"); + assert_eq!(for_document(None, None).settings(), &working_settings(), "no profiles: the working settings"); + let unknown = for_document(Some("no such profile"), Some(SRGB)); + assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space"); +} diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 66295f50f2..4e4da57f9c 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -9,7 +9,8 @@ //! ([`use_front_profiles`]): when they are assigned, when the working settings change, and when the //! document comes to the front or its assignment comes back (an open, a switch, a close, a revert, //! an undo or a rollback, [`Session::sync_front_profiles`]); the working spaces stay as Edit → Color -//! Settings set them. +//! Settings set them. A document drawn while another is in front draws through its own profiles +//! ([`doc_cms`]). use std::collections::BTreeMap; use std::sync::Arc; @@ -236,6 +237,9 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { } else if proof_targets(&info.name) { // A new file of the CMYK profile View → Proof Setup targets: redraw. touch_all(s); + } else { + // Documents behind the front one draw through their own profiles: those assigned it redraw. + touch_assigned(s, &info.name); } Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) } @@ -266,9 +270,21 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result { if proof_targets(&info.name) { touch_all(s); } + // Documents behind the front one draw through their own profiles: those assigned it redraw. + touch_assigned(s, &info.name); Ok(json!({"removed": info.name})) } +/// Redraw the open documents assigned the profile `name`. +fn touch_assigned(s: &mut Session, name: &str) { + for d in &mut s.docs { + let (rgb, cmyk) = doc_profiles(&d.doc); + if [rgb, cmyk].iter().any(|n| n.as_deref() == Some(name)) { + d.revision += 1; + } + } +} + /// Whether View → Proof Setup targets the CMYK profile `name`. fn proof_targets(name: &str) -> bool { matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name) @@ -285,6 +301,14 @@ pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option, Option Arc { + let (rgb, cmyk) = doc_profiles(d); + cms::for_document(rgb.as_deref(), cmyk.as_deref()) +} + fn assign_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.assignProfile"; let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc); diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index e8b3ab0906..b02e34690b 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -149,6 +149,34 @@ fn loading_an_assigned_profile_again_keeps_it_in_use() { assert_eq!(cms::working_settings(), working); } +/// A document behind the front one draws through its own assigned profile: loading that profile's +/// file again or removing it redraws the document, and the front document stays as it was. +#[test] +fn loading_or_removing_a_back_documents_profile_redraws_it() { + const NAME: &str = "Back RGB P"; + let _g = settings(); + let dir = fresh_dir("back-assigned"); + let _reset = ColorSettingsReset { profile: Some(NAME), paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("back.icc"); + std::fs::write(&path, icc_named(NAME)).unwrap(); + let mut s = session(); + let load = |s: &mut Session| assert_eq!(exec(s, "color.loadProfile", json!({"path": path.to_str().unwrap()}))["name"], NAME); + load(&mut s); + exec(&mut s, "edit.assignProfile", json!({"rgb": NAME})); + exec(&mut s, "file.new", json!({"width": 100, "height": 100})); + let revisions = |s: &Session| s.documents().iter().map(|d| d.revision).collect::>(); + let before = revisions(&s); + load(&mut s); + let loaded = revisions(&s); + assert!(loaded[0] > before[0], "the back document assigned the profile redraws after a load"); + assert_eq!(loaded[1], before[1], "the front document does not use it"); + exec(&mut s, "color.removeProfile", json!({"name": NAME})); + let removed = revisions(&s); + assert!(removed[0] > loaded[0], "the back document assigned the profile redraws after a removal"); + assert_eq!(removed[1], loaded[1], "the front document does not use it"); +} + /// A batch that assigns a profile and then changes the color mode, as Document Setup's OK does, /// converts the colors through the profile it assigns. #[test] diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 2d67db2073..7bcc117a23 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -239,19 +239,29 @@ fn inks(c: &Color) -> [f32; 4] { let key = c.key(); let cms = cms::active(); SEPARATED.with(|s| { - let (owner, seen) = &mut *s.borrow_mut(); + let kept = &mut *s.borrow_mut(); + let i = match kept.iter().position(|(owner, _)| Arc::ptr_eq(owner, &cms)) { + Some(i) => i, + None => { + if kept.len() >= KEPT_TABLES { + kept.remove(0); + } + kept.push((cms.clone(), HashMap::new())); + kept.len() - 1 + } + }; + let seen = &mut kept[i].1; // Large enough for the many colours of gradient meshes and blends, which otherwise cleared // it over and over and separated every colour through the profile again. - if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 1 << 16 { + if seen.len() >= 1 << 16 { seen.clear(); - *owner = Some(cms.clone()); } *seen.entry(key).or_insert_with(|| cms.to_cmyk(c, cms.settings().intent)) }) } -/// Separated colours, for the colour settings they were separated with. -type Separated = (Option>, HashMap<[u32; 5], [f32; 4]>); +/// Separated colours, for each of the colour settings they were separated with, the newest last. +type Separated = Vec<(Arc, HashMap<[u32; 5], [f32; 4]>)>; thread_local! { static SEPARATED: RefCell = RefCell::default(); @@ -276,31 +286,37 @@ impl InkLut { } } -/// A table built for the colour settings it was built with, and its generation (see [`cached`]). -type Cached = Mutex, Arc, u64)>>; +/// Tables built for the colour settings they were built with, and their generations, the newest +/// last (see [`cached`]). +type Cached = Mutex, Arc, u64)>>; + +/// How many tables each [`Cached`] keeps: documents drawn side by side through their own profiles +/// don't make them again and again. +const KEPT_TABLES: usize = 4; /// The last table generation handed out: images made with a table are cached under its /// generation (see [`crate::images`]), and none is ever reused. static GENERATION: AtomicU64 = AtomicU64::new(0); -static DISPLAY: Cached = Mutex::new(None); +static DISPLAY: Cached = Mutex::new(Vec::new()); /// Screen colours → [`Ink::Cmy`] and [`Ink::K`] values (placed images). -static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(None); +static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(Vec::new()); -/// The table in `slot` for the active colour settings, built by `build` when they changed, and -/// its generation. +/// The table in `slot` for the active colour settings, built by `build` when none is kept for +/// them, and its generation. fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> (Arc, u64) { let cms = cms::active(); let mut s = slot.lock().unwrap_or_else(|e| e.into_inner()); - match &*s { - Some((owner, t, g)) if Arc::ptr_eq(owner, &cms) => (t.clone(), *g), - _ => { - let t = Arc::new(build(&cms)); - let g = GENERATION.fetch_add(1, Ordering::Relaxed) + 1; - *s = Some((cms, t.clone(), g)); - (t, g) - } + if let Some((_, t, g)) = s.iter().find(|(owner, _, _)| Arc::ptr_eq(owner, &cms)) { + return (t.clone(), *g); } + let t = Arc::new(build(&cms)); + let g = GENERATION.fetch_add(1, Ordering::Relaxed) + 1; + if s.len() >= KEPT_TABLES { + s.remove(0); + } + s.push((cms, t.clone(), g)); + (t, g) } /// [`compose`] puts at least this many pixels in each band but the last. @@ -585,6 +601,18 @@ mod tests { assert_eq!(pm.data()[0].r, 204, "source pixels remain unchanged"); } + #[test] + fn tables_of_two_color_settings_are_kept_side_by_side() { + // Two documents drawn in turn through their own settings each keep their tables. + let settings = || Arc::new(Cms::new(&cms::ColorSettings::default()).unwrap()); + let (a, b) = (settings(), settings()); + let tables = |c: &Arc| cms::with_active(c.clone(), || (display_lut().1, separation().1)); + let (first_a, first_b) = (tables(&a), tables(&b)); + assert_ne!(first_a, first_b, "each settings get their own tables"); + assert_eq!(tables(&a), first_a, "the first settings' tables are not made again"); + assert_eq!(tables(&b), first_b, "the second settings' tables are not made again"); + } + #[test] fn the_ink_table_matches_the_profile() { let cms = cms::active(); diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 76fb599fda..0a26f26843 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -107,17 +107,16 @@ impl Panes { } let current = self.shown.get(&slot).is_some_and(|(k, _)| *k == key) || self.pending.get(&slot) == Some(&key); if !current { + // The document draws through its own assigned profiles, not the front document's, on + // the worker too. + let cms = vectorcraft_engine::cmd::colormgmt::doc_cms(&doc); let mut queued = false; if let Some(worker) = &self.worker { - let (job_doc, job_opts, job_display) = (doc.clone(), opts.clone(), display.clone()); + let (job_doc, job_opts, job_display, job_cms) = (doc.clone(), opts.clone(), display.clone(), cms.clone()); queued = worker.submit( slot, (slot, key.clone()), - Box::new(move |r| { - let mut img = r.render(&job_doc, w, h, view, &job_opts); - crate::display_profile::convert(job_display.as_deref(), &mut img.pixels); - Some(img) - }), + Box::new(move |r| Some(render_pane(r, (&job_doc, w, h, view, &job_opts), job_display.as_deref(), job_cms))), ); if queued { self.pending.insert(slot, key.clone()); @@ -126,8 +125,8 @@ impl Panes { } } if !queued { - let mut img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); - crate::display_profile::convert(display.as_deref(), &mut img.pixels); + let r = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new); + let img = render_pane(r, (&doc, w, h, view, &opts), display.as_deref(), cms); self.upload(ctx, slot, key, &img); } } @@ -146,6 +145,19 @@ impl Panes { } } +/// Render `doc` (`w` × `h` pixels through `view`, with `opts`) through the color settings `cms`, +/// then convert it into the display profile `display`. +fn render_pane( + r: &mut vectorcraft_render::Renderer, + (doc, w, h, view, opts): (&vectorcraft_doc::Document, u32, u32, Affine, &vectorcraft_render::RenderOptions), + display: Option<&vectorcraft_color::cms::display::DisplayTransform>, + cms: Arc, +) -> vectorcraft_render::Rendered { + let mut img = vectorcraft_color::cms::with_active(cms, || r.render(doc, w, h, view, opts)); + crate::display_profile::convert(display, &mut img.pixels); + img +} + /// The grid of `n` panes in `area`, filled row by row: as many columns as the square root of `n` /// rounded up, and as many rows as those columns need. The panes meet edge to edge and cover /// `area`; the spare cells of the last row stay empty. @@ -294,8 +306,16 @@ fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect, ui_ /// art, grid, artboards, guides and selected objects, and its rulers and the room of the canvas's /// scroll bars, so the document stays where it is when the pane becomes the canvas. True when a /// press in the pane asks for its window to become the current one. A floating window draws its -/// view with this too ([`crate::doc_windows`]). +/// view with this too ([`crate::doc_windows`]). The document's colors, its paper and transparency +/// grid included, draw through its own assigned profiles, not the front document's. pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> bool { + let Some(st) = app.session.documents().get(w.0) else { return false }; + let cms = vectorcraft_engine::cmd::colormgmt::doc_cms(&st.doc); + vectorcraft_color::cms::with_active(cms, || draw_pane(app, ui, w, cell)) +} + +/// [`pane`], drawn through the color settings in place. +fn draw_pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> bool { let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); let (full, rect) = view_rects(app, cell, ppp); diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index cb1394f612..005b930d01 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -358,3 +358,147 @@ fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() { } assert_eq!(app.views[1].zoom, zoomed.zoom, "the zoomed document keeps its zoom"); } + +/// The RGB of the middle pixel of the render that document `i`'s pane uploaded in `out`. +fn pane_middle(app: &VectorcraftApp, out: &egui::FullOutput, i: usize) -> [u8; 3] { + let uid = app.session.documents()[i].uid; + let id = app.canvas.panes.shown.iter().find(|(slot, _)| slot.0 == uid).map(|(_, (_, tex))| tex.id()).unwrap(); + let image = out.textures_delta.set.iter().find(|(t, _)| **t == id).and_then(|(_, d)| d.last()).map(|d| d.image.clone()).unwrap(); + let egui::ImageData::Color(img) = image; + let p = img.pixels[img.size[1] / 2 * img.size[0] + img.size[0] / 2]; + [p.r(), p.g(), p.b()] +} + +#[test] +fn other_panes_draw_through_their_documents_profiles() { + const CHILD: &str = "VECTORCRAFT_TEST_PANE_PROFILES"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: assign profiles in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "canvas::panes::tests::other_panes_draw_through_their_documents_profiles", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated pane profile test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms::{self, Cms, ColorSettings}; + let mut app = app_with(2); + app.session.prefs.monitor_profile_display = false; + // Each document is covered by one RGB rectangle; the second is assigned Display P3. + for i in 0..2 { + app.run("document.activate", json!({"index": i})).unwrap(); + app.run("shape.rectangle", json!({"x": -50, "y": -50, "width": 500, "height": 400})).unwrap(); + app.run("paint.setFill", json!({"color": "#339966"})).unwrap(); + } + app.run("edit.assignProfile", json!({"rgb": cms::DISPLAY_P3})).unwrap(); + // Its simulated paper shows in another color. + app.run("document.setup", json!({"gridColors": ["#cc6633", "#ffffff"], "simulatePaper": true})).unwrap(); + let in_p3 = |r: u8, g: u8, b: u8| { + let color = vectorcraft_color::Color::Rgb { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0 }; + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..cms::working_settings() }).unwrap().display_rgb(&color); + (color, p3.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) + }; + let (color, p3) = in_p3(0x33, 0x99, 0x66); + let srgb = [0x33, 0x99, 0x66]; + assert_ne!(p3, srgb, "Display P3 draws the color differently"); + let (_, paper_p3) = in_p3(0xcc, 0x66, 0x33); + let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1); + // Whether `out` fills a shape with the RGB `rgb`, give or take one. + let fills = |out: &egui::FullOutput, rgb: [u8; 3]| { + fn any(s: &Shape, f: &dyn Fn(Color32) -> bool) -> bool { + match s { + Shape::Vec(v) => v.iter().any(|s| any(s, f)), + Shape::Path(p) => f(p.fill), + _ => false, + } + } + out.shapes.iter().any(|c| any(&c.shape, &|c: Color32| c.a() == 255 && near([c.r(), c.g(), c.b()], rgb))) + }; + let ctx = egui::Context::default(); + let raw = || egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), + max_texture_side: Some(8192), + ..Default::default() + }; + app.run("window.arrange.tile", json!({})).unwrap(); + // The first document in front: the second one's pane draws through Display P3, its paper too. + app.run("document.activate", json!({"index": 0})).unwrap(); + let mut out = ctx.run_ui(raw(), |ui| app.canvas_area(ui)); + let shown = pane_middle(&app, &out, 1); + assert!(fills(&out, paper_p3), "the back document's paper draws through its Display P3: {paper_p3:?}"); + assert!(!fills(&out, [0xcc, 0x66, 0x33]), "the back document's paper does not draw through the working space"); + out.textures_delta.clear(); + assert!(near(shown, p3), "the back document draws through its Display P3: {shown:?}, not {p3:?}"); + // The same in a floating window. + app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + app.run("window.arrange.floatInWindow", json!({})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + let floating = |app: &mut VectorcraftApp| { + ctx.run_ui(raw(), |ui| { + app.canvas_area(ui); + crate::doc_windows::show(app, ui.ctx()); + }) + }; + // The window takes its place in the first frame and fades in over the next few. + for _ in 0..30 { + floating(&mut app).textures_delta.clear(); + } + app.canvas.panes.clear(); + let mut out = floating(&mut app); + let shown = pane_middle(&app, &out, 1); + assert!(fills(&out, paper_p3), "the floating document's paper draws through its Display P3: {paper_p3:?}"); + out.textures_delta.clear(); + assert!(near(shown, p3), "the floating document draws through its Display P3: {shown:?}, not {p3:?}"); + app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(); + app.run("window.arrange.tile", json!({})).unwrap(); + app.canvas.panes.clear(); + // The second document in front: the first one's pane draws through the working sRGB. + app.run("document.activate", json!({"index": 1})).unwrap(); + let mut out = ctx.run_ui(raw(), |ui| app.canvas_area(ui)); + let shown = pane_middle(&app, &out, 0); + out.textures_delta.clear(); + assert!(near(shown, srgb), "the back document draws through the working space: {shown:?}, not {srgb:?}"); + // The same off the UI thread. + app.canvas.panes.clear(); + app.canvas.panes.worker = crate::thumb_worker::ThumbWorker::start(&ctx, "test-panes"); + app.canvas.panes.worker_started = true; + ctx.run_ui(raw(), |ui| app.canvas_area(ui)).textures_delta.clear(); + let done = app.canvas.panes.worker.as_ref().unwrap().wait().unwrap(); + assert_eq!(done.key.0.0, app.session.documents()[0].uid); + let img = done.img.unwrap(); + let at = ((img.height / 2 * img.width + img.width / 2) * 4) as usize; + let shown = [img.pixels[at], img.pixels[at + 1], img.pixels[at + 2]]; + assert!(near(shown, srgb), "the worker draws the back document through the working space: {shown:?}, not {srgb:?}"); + // A CMYK document behind the front one draws through its own assigned CMYK profile. + app.run("document.activate", json!({"index": 0})).unwrap(); + let steps = json!([ + {"command": "edit.assignProfile", "params": {"cmyk": cms::DEVICE_CMYK}}, + {"command": "file.documentColorMode", "params": {"mode": "cmyk"}}, + ]); + app.run("command.batch", json!({"commands": steps})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("paint.setFill", json!({"color": {"c": 60, "m": 10, "y": 80, "k": 0}})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + let ink = vectorcraft_color::Color::cmyk(0.6, 0.1, 0.8, 0.0); + let to_u8 = |c: [f32; 3]| c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let device = to_u8(Cms::new(&ColorSettings { cmyk: cms::DEVICE_CMYK.into(), ..cms::working_settings() }).unwrap().display_rgb(&ink)); + let front = to_u8(cms::active().display_rgb(&ink)); + let close = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 3); + assert!(!close(device, front), "the two CMYK profiles draw the color differently: {device:?}, {front:?}"); + app.canvas.panes.clear(); + ctx.run_ui(raw(), |ui| app.canvas_area(ui)).textures_delta.clear(); + let done = app.canvas.panes.worker.as_ref().unwrap().wait().unwrap(); + assert_eq!(done.key.0.0, app.session.documents()[0].uid); + let img = done.img.unwrap(); + let at = ((img.height / 2 * img.width + img.width / 2) * 4) as usize; + let shown = [img.pixels[at], img.pixels[at + 1], img.pixels[at + 2]]; + assert!(close(shown, device), "the back CMYK document draws through its Device CMYK: {shown:?}, not {device:?} (front: {front:?})"); + assert!(near(cms::active().display_rgb(&color).map(|v| (v * 255.0).round() as u8), p3), "the front document's profile stays active"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..becf0cd6df 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -727,7 +727,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. -- **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. With Window › Arrange › Tile or floating document windows, each document behind the front one draws its art, paper and transparency grid through its own assigned profiles, and loading or removing a profile redraws the documents assigned it; placed documents inside it still draw through the front document's profiles. How documents behind the front one draw is VectorCraft's own rule. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `profile_errors_name_the_file_by_its_name_outside_the_read_roots`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy).