From abf93e31d74578f511e1ba2e7555afcc5a5b2f57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:23:59 -0800 Subject: [PATCH] Canvas: translucent art blends with the paper before the monitor profile With Use Monitor Profile for Canvas converting, the art's raster was converted on its own and then drawn over the separately converted paper, so a translucent fill or an antialiased edge showed blend(convert(art), convert(paper)) rather than the converted blend. Each pixel with partial alpha over an artboard's paper, a colored artboard, the transparency grid or Hide Artboards' paper now gets the color that, drawn over the converted paper, shows the art blended with the paper in sRGB and then converted. Its alpha is kept, so a grid drawn in back still shows through. Opaque and empty pixels, art over the pasteboard and Outline convert as before, and with no profile to convert into nothing changes. The frame rendered whole, the part an edit renders again, the render worker's frame (and the pan fill), and the other windows' panes all use the same rule. The paper is part of the canvas cache key, so turning the grid on or hiding the artboards renders the art again. --- crates/ui-egui/src/caches.rs | 11 + crates/ui-egui/src/canvas.rs | 61 ++++- crates/ui-egui/src/canvas/panes.rs | 13 +- crates/ui-egui/src/canvas/raster.rs | 3 +- crates/ui-egui/src/canvas/tests.rs | 202 ++++++++++++++ crates/ui-egui/src/display_profile.rs | 377 +++++++++++++++++++++++++- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 8 files changed, 653 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 286b4d4d02..8c6d669e14 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -164,6 +164,17 @@ pub struct CacheKey { /// The display profile the frame is converted into ([`crate::display_profile::DisplayProfile::key`]), /// 0 for none. pub display: u64, + /// The paper under the art that translucent art is blended with before the conversion + /// ([`crate::display_profile::convert_over`]). None without a display profile, and in Outline. + pub backdrop: Option>, +} + +impl CacheKey { + /// [`Self::backdrop`] with the zoom its grid's cells are measured at, as + /// [`crate::display_profile::convert_over`] takes it. + pub(crate) fn over(&self) -> Option<(&crate::display_profile::Backdrop, f64)> { + self.backdrop.as_deref().map(|b| (b, self.zoom)) + } } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a947dde852..36e842dbaf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -623,6 +623,7 @@ fn cache_key( trace_source: app.ui.trace_hold_source.clone(), // Preferences › Performance › Use Monitor Profile for Canvas. display: app.display_profile.key(app.session.prefs.monitor_profile_display), + backdrop: backdrop(app, st, app.outline_view()), } } @@ -887,9 +888,10 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { let renderer = &mut app.canvas.renderer; - let mut part = - effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); - crate::display_profile::convert(display.as_deref(), &mut part.pixels); + let part_view = Affine::translate((-f64::from(x0), -f64::from(y0))) * view; + let mut part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, part_view, &opts)); + let width = part.width; + crate::display_profile::convert_over(display.as_deref(), &mut part.pixels, width, part_view, key.over()); let color = color_image(&part); let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; tex.set_partial([x0 as usize, y0 as usize], color, options); @@ -927,7 +929,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { let renderer = &mut app.canvas.renderer; let mut img = effects.view(|| renderer.render(doc, w, h, view, &opts)); effects.first_view_done(); - crate::display_profile::convert(display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(display.as_deref(), &mut img.pixels, width, view, key.over()); upload(app, ui.ctx(), img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.canvas.shown = Some((doc.clone(), opts.clone())); @@ -982,13 +985,11 @@ fn paint_paper( let on_display = |c: Color32| crate::display_profile::color32(display.as_deref(), c); if !shows_artboards(app) { // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). - let setup = &doc.setup; - painter.rect_filled(rect, 0.0, on_display(if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE })); + painter.rect_filled(rect, 0.0, on_display(paper_color(&doc.setup))); } else { let setup = &doc.setup; - let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent) - .then(|| checker_texture(ui.ctx(), setup, display.as_deref())); - let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; + let grid = shows_transparency_grid(st).then(|| checker_texture(ui.ctx(), setup, display.as_deref())); + let paper = paper_color(setup); for ab in &doc.artboards { let q = xf.quad(ab.rect); // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). @@ -998,14 +999,52 @@ fn paint_paper( match &grid { Some((tex, cell)) => checker(painter, &q, ab.rect, xf.zoom, tex.id(), *cell), None => { - let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); - painter.add(Shape::convex_polygon(q, on_display(fill), Stroke::NONE)); + painter.add(Shape::convex_polygon(q, on_display(artboard_paper(ab, paper)), Stroke::NONE)); } } } } } +/// The color of the paper: Simulate Colored Paper's, else white. +fn paper_color(setup: &vectorcraft_doc::DocSetup) -> Color32 { + if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE } +} + +/// The color of artboard `ab`'s paper, the paper `paper`: an artboard with a background color +/// multiplies it with the paper, each sRGB channel artboard × paper / 255, rounded. +fn artboard_paper(ab: &vectorcraft_doc::Artboard, paper: Color32) -> Color32 { + ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper) +} + +/// The transparency grid covers the artboards of document `st`: it is shown, and the Background +/// Contents are transparent. +fn shows_transparency_grid(st: &vectorcraft_engine::DocState) -> bool { + st.transparency_grid && st.doc.setup.background == vectorcraft_doc::Background::Transparent +} + +/// The paper [`paint_paper`] draws for document `st`, which translucent art is blended with before +/// the display profile's conversion. None without a display profile, and in Outline (`outline`), +/// whose white canvas is the interface's. +fn backdrop(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, outline: bool) -> Option> { + if outline || app.display_profile.transform(app.session.prefs.monitor_profile_display).is_none() { + return None; + } + let rgb = |c: Color32| [c.r(), c.g(), c.b()]; + let setup = &st.doc.setup; + let paper = paper_color(setup); + let b = if shows_artboards(app) { + crate::display_profile::Backdrop { + everywhere: None, + artboards: st.doc.artboards.iter().map(|ab| (ab.rect, rgb(artboard_paper(ab, paper)))).collect(), + grid: shows_transparency_grid(st).then(|| (setup.grid_colors.map(|c| rgb(crate::panels::c32(&c))), setup.grid_size.cell())), + } + } else { + crate::display_profile::Backdrop { everywhere: Some(rgb(paper)), artboards: vec![], grid: None } + }; + Some(std::sync::Arc::new(b)) +} + /// The last rendered frame, placed through the current view while a new one is on its way. fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, (key_doc, rotation): (usize, f64)) { if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 76fb599fda..aee571d0ce 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -11,7 +11,7 @@ use super::grid::LineLook; use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ - Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key, + Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, backdrop, cache_key, dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects, }; @@ -29,7 +29,7 @@ type Slot = (u64, u32); pub struct Panes { /// Renders off the UI thread (None: on it, as on wasm). worker: Option>, - worker_started: bool, + pub(crate) worker_started: bool, /// Renders on the UI thread when there is no worker. renderer: Option, /// By slot: the render shown and what it shows. @@ -109,13 +109,14 @@ impl Panes { if !current { 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_key) = (doc.clone(), opts.clone(), display.clone(), key.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); + let width = img.width; + crate::display_profile::convert_over(job_display.as_deref(), &mut img.pixels, width, view, job_key.over()); Some(img) }), ); @@ -127,7 +128,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 width = img.width; + crate::display_profile::convert_over(display.as_deref(), &mut img.pixels, width, view, key.over()); self.upload(ctx, slot, key, &img); } } @@ -328,6 +330,7 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); + key.backdrop = backdrop(app, st, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index f57a15a795..2f9a4f981f 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -226,7 +226,8 @@ pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option *kept = keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) }); // The kept frame stays sRGB, so the next frame's copied pixels are converted only once. - crate::display_profile::convert(job.display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(job.display.as_deref(), &mut img.pixels, width, job.view, job.key.over()); WorkerFrame { img, moved, reused } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4d609f2b5e..791415bee0 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2717,6 +2717,140 @@ fn the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on() assert_eq!(count(&mut app, red), Some(srgb)); } +/// With a display profile, translucent art and the anti-aliased edges of art over the paper are +/// blended with the paper in sRGB before the conversion: drawn over the converted paper, each such +/// pixel shows the converted blend. Opaque art, and art over the pasteboard, convert as before. The +/// part an edit renders again and the render worker's frame do the same, a change of the +/// transparency grid renders the art again, and Outline converts the art alone. +#[test] +fn translucent_art_over_the_paper_is_blended_before_the_display_profile() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread + let half = app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [half], "color": "#ff0000"})).unwrap(); + app.run("transparency.set", json!({"ids": [half], "opacity": 50})).unwrap(); + let disc = app.run("shape.ellipse", json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [disc], "color": "#2040c0"})).unwrap(); + // Left of the artboard, over the pasteboard. + let outside = app.run("shape.rectangle", json!({"x": -60, "y": 100, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [outside], "color": "#00ff00"})).unwrap(); + app.run("transparency.set", json!({"ids": [outside], "opacity": 50})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // The art's raster one frame uploads: where it goes ([x, y] for a part), its width and its pixels. + let upload = |app: &mut VectorcraftApp| -> Option<(Option<[usize; 2]>, usize, Vec)> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref()?.id(); + let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + (d.pos, img.width(), img.pixels.clone()) + }); + delta.clear(); + up + }; + upload(&mut app).expect("the first frame"); + let v = app.view_mut().unwrap(); + (v.zoom, v.center) = (1.0, Point::new(120.0, 150.0)); + let (_, width, srgb) = upload(&mut app).expect("the view at 100%"); + let key = app.canvas.key.clone().unwrap(); + assert!(key.backdrop.is_none(), "no display profile: nothing to blend"); + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + app.display_profile.report(Reading { display: Some("DP-1".into()), found: Found::Profile { icc: p3, from: "test".into() } }); + let (_, _, shown) = upload(&mut app).expect("drawn again, converted"); + let key = app.canvas.key.clone().unwrap(); + assert!(key.backdrop.is_some()); + let white = t.apply8([255; 3]); + // What the canvas shows of pixel `c` over the converted paper `under`, as egui blends it. + let over = |c: egui::Color32, under: [u8; 3]| { + let k = 255 - u32::from(c.a()); + [0, 1, 2].map(|i| u32::from(c.to_array()[i]) + (u32::from(under[i]) * k + 127) / 255) + }; + let plain = |s: egui::Color32| { + let mut px = s.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) + }; + let (view_w, view_h) = (key.w, key.h); + let (_, _, view) = frame_view( + None, + (view_w, view_h), + &View { zoom: key.zoom, center: Point::new(key.cx, key.cy), rotation: key.rot, ..View::default() }, + key.ppp, + ); + let to_doc = view.inverse(); + let (mut blended, mut beside) = (0, 0); + for (i, (s, c)) in srgb.iter().zip(&shown).enumerate() { + let p = to_doc * Point::new((i % width) as f64 + 0.5, (i / width) as f64 + 0.5); + if s.a() == 0 || s.a() == 255 { + assert_eq!(*c, plain(*s), "opaque and empty pixels convert as before, at {p:?}"); + } else if (1.0..399.0).contains(&p.x) && (1.0..299.0).contains(&p.y) { + let k = 255 - u32::from(s.a()); + let mix = [0, 1, 2].map(|j| (u32::from(s.to_array()[j]) + (255 * k + 127) / 255).min(255) as u8); + let want = t.apply8(mix); + let got = over(*c, white); + assert!((0..3).all(|j| got[j].abs_diff(u32::from(want[j])) <= 1), "at {p:?}: {got:?} shown, {want:?} wanted"); + blended += 1; + } else if p.x < -1.0 { + assert_eq!(*c, plain(*s), "over the pasteboard the art converts alone, at {p:?}"); + beside += 1; + } + } + assert!(blended > 1000 && beside > 500, "{blended} pixels over the paper, {beside} over the pasteboard"); + // The test tells the two apart: the fill converted alone, over the converted paper, shows another color. + let at = view * Point::new(200.0, 150.0); + let at = at.y as usize * width + at.x as usize; + let (s, c) = (srgb[at], shown[at]); + assert!((50..=205).contains(&s.a()), "the 50% fill at the center: {s:?}"); + let alone = over(plain(s), white); + assert!((0..3).any(|j| alone[j].abs_diff(over(c, white)[j]) > 2), "{alone:?} and {:?}", over(c, white)); + // The render worker's frame of the same key is the same. + let opts = app.canvas.shown.as_ref().unwrap().1.clone(); + let doc = app.session.active().unwrap().doc.clone(); + let display = app.display_profile.transform(true); + let job = |key: &CacheKey, doc: &std::sync::Arc| crate::render_worker::Job { + key: key.clone(), + doc: doc.clone(), + w: view_w, + h: view_h, + view, + opts: opts.clone(), + scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects: Default::default(), + may_copy: false, + may_keep: false, + display: display.clone(), + }; + let worker = crate::canvas::render_job(&mut vectorcraft_render::Renderer::new(), &mut None, &job(&key, &doc)).img; + let near = |a: &[u8], b: egui::Color32| a.iter().zip(b.to_array()).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(worker.pixels.as_chunks::<4>().0.iter().zip(&shown).all(|(a, b)| near(a, *b)), "the worker's frame matches"); + // An edit renders its part again, blended too. + app.run("paint.setFill", json!({"ids": [half], "color": "#0000ff"})).unwrap(); + let (pos, part_w, part) = upload(&mut app).expect("the edit shows"); + let [x0, y0] = pos.expect("only its part"); + let key = app.canvas.key.clone().unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let whole = crate::canvas::render_job(&mut vectorcraft_render::Renderer::new(), &mut None, &job(&key, &doc)).img; + let whole = whole.pixels.as_chunks::<4>().0; + for (i, c) in part.iter().enumerate() { + let at = (y0 + i / part_w) * view_w as usize + x0 + i % part_w; + assert!(near(&whole[at], *c), "the part matches the whole frame at {at}: {c:?}, {:?}", whole[at]); + } + assert!(part.iter().filter(|c| c.a() != 0 && c.a() != 255).count() > 1000, "the blue fill is in the part"); + // The transparency grid replaces the paper under the art: drawn again. + app.run("view.transparencyGrid", json!({"on": true})).unwrap(); + assert!(upload(&mut app).is_some_and(|(pos, ..)| pos.is_none()), "the grid renders the art again"); + assert!(app.canvas.key.as_ref().and_then(|k| k.backdrop.as_ref()).is_some_and(|b| b.grid.is_some())); + // Outline: the white canvas is the interface's, so the art converts alone. + app.run("view.outline", json!({})).unwrap(); + upload(&mut app).expect("Outline renders again"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.outline && k.backdrop.is_none())); +} + /// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template /// draws it again, dimmed as before; each re-renders at once. #[test] @@ -5527,6 +5661,74 @@ fn a_pane_draws_its_own_views_outline() { } } +/// With a display profile, a split-view pane or floating window blends translucent art with the +/// paper before the conversion, as the canvas does, and in its own Outline converts the art alone. +#[test] +fn a_pane_blends_translucent_art_before_the_display_profile() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let half = app.run("shape.rectangle", json!({"x": 50, "y": 25, "width": 100, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [half], "color": "#ff0000"})).unwrap(); + app.run("transparency.set", json!({"ids": [half], "opacity": 50})).unwrap(); + app.run("select.none", json!({})).unwrap(); + crate::doc_windows::new_window(&mut app).unwrap(); + app.canvas.panes.worker_started = true; // every pane render on this thread + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + app.display_profile.report(Reading { display: Some("DP-1".into()), found: Found::Profile { icc: p3, from: "test".into() } }); + let ctx = egui::Context::default(); + let other = (0, 1); + let slot = (app.session.documents()[0].uid, 1); + // The pane's raster one frame uploads, and the key it was rendered for. + let pane = |app: &mut VectorcraftApp| -> (Vec, CacheKey) { + let cell = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + panes::pane(app, ui, other, cell); + }); + let (key, tex) = app.canvas.panes.shown.get(&slot).expect("the pane's render"); + let id = tex.id(); + let pixels = out.textures_delta.set.iter().find(|e| e.0.eq(&id)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.clone() + }); + let key = key.clone(); + out.textures_delta.clear(); + (pixels.expect("the pane uploads"), key) + }; + app.session.prefs.monitor_profile_display = false; + let (srgb, key) = pane(&mut app); + assert!(key.backdrop.is_none(), "no display profile: nothing to blend"); + app.session.prefs.monitor_profile_display = true; + let (shown, key) = pane(&mut app); + assert!(key.backdrop.is_some(), "the pane's Preview blends with its paper"); + assert_eq!(srgb.len(), shown.len()); + let plain = |s: egui::Color32| { + let mut px = s.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) + }; + let mut blended = 0; + for (s, c) in srgb.iter().zip(&shown) { + if s.a() == 0 || s.a() == 255 { + assert_eq!(*c, plain(*s), "opaque and empty pixels convert as before"); + } else { + assert_eq!(c.a(), s.a(), "the alpha is kept"); + let p = plain(*s); + if (0..3).any(|j| c.to_array()[j].abs_diff(p.to_array()[j]) > 2) { + blended += 1; + } + } + } + assert!(blended > 100, "{blended} translucent pixels blended with the paper"); + // The pane's own Outline: the art converts alone, while the current view stays in Preview. + crate::doc_windows::view_mut(&mut app, other).unwrap().outline = true; + app.view_mut().unwrap().outline = false; + let (_, key) = pane(&mut app); + assert!(key.outline && key.backdrop.is_none(), "the pane's Outline has no paper to blend with"); +} + /// While the rulers' origin is dragged out of their corner, its crosshair draws over the canvas in /// the theme's dim text color in Preview, and in Outline in the dark gray that shows on the white /// canvas. diff --git a/crates/ui-egui/src/display_profile.rs b/crates/ui-egui/src/display_profile.rs index 486489e6fe..af3c756e78 100644 --- a/crates/ui-egui/src/display_profile.rs +++ b/crates/ui-egui/src/display_profile.rs @@ -1,6 +1,8 @@ //! Preferences › Performance › Use Monitor Profile for Canvas: the canvas's art, its artboards' //! paper and the transparency grid are converted from sRGB into the ICC profile of the display -//! that shows most of the main window, as the last step before they are drawn. +//! that shows most of the main window, as the last step before they are drawn. Translucent art +//! and the anti-aliased edges of art over the paper or the grid are blended with it in sRGB first, +//! and the blend is converted ([`convert_over`]). //! //! The desktop app reads the display's profile on a background thread and hands the reading to //! [`DisplayProfile::report`], which checks the profile and builds its conversion table there. @@ -14,6 +16,7 @@ use std::sync::{Arc, RwLock}; use serde_json::{Value, json}; use vectorcraft_color::cms::display::DisplayTransform; pub use vectorcraft_color::cms::display::MAX_PROFILE_BYTES; +use vectorcraft_geom::{Affine, Point, Rect}; /// What the desktop app read for the display showing the main window. #[derive(Clone, Debug, PartialEq)] @@ -221,6 +224,220 @@ pub(crate) fn convert(t: Option<&DisplayTransform>, pixels: &mut [u8]) { t.apply_premultiplied(pixels); } +/// The document's paper under the canvas's art, which the canvas draws converted into the display +/// profile before the art: [`convert_over`] blends translucent art with it before converting. +/// The pasteboard and Outline's white canvas are interface colors and are not part of it. +#[derive(Clone, Debug, PartialEq)] +pub struct Backdrop { + /// View › Hide Artboards: the sRGB color of the paper that covers the whole canvas. + pub(crate) everywhere: Option<[u8; 3]>, + /// The artboards in the order they are drawn (the last on top), each with its paper's sRGB color. + pub(crate) artboards: Vec<(Rect, [u8; 3])>, + /// The transparency grid, which covers every artboard's paper: its two sRGB colors, and its cell + /// in screen points, counted from each artboard's top left corner. + pub(crate) grid: Option<([[u8; 3]; 2], f32)>, +} + +/// A color of the backdrop: in sRGB, and as the canvas shows it, converted. +#[derive(Clone, Copy, PartialEq)] +struct Paper { + srgb: [u8; 3], + shown: [u8; 3], +} + +/// An artboard of the backdrop, as [`Over`] looks it up. +struct Board { + /// Its rectangle in the document, normalized. + rect: Rect, + /// The corner the transparency grid's cells are counted from. + origin: Point, + paper: Paper, +} + +/// No artboard at a pixel of a row ([`Over::owners`]). +const NO_BOARD: u32 = u32::MAX; + +/// The first pixel at or after `x` that no artboard has taken yet, in the chain `next` (each +/// pixel's next candidate; a pixel not taken is its own), shortening the chain on the way. +fn untaken(next: &mut [u32], x: usize) -> usize { + let mut root = x; + while next[root] as usize != root { + root = next[root] as usize; + } + let mut at = x; + while next[at] as usize != root { + let n = next[at] as usize; + next[at] = root as u32; + at = n; + } + root +} + +/// [`Backdrop`] converted for a frame: what [`convert_over`] needs for each pixel. +struct Over<'a> { + t: &'a DisplayTransform, + /// The frame's pixels → the document. + to_doc: Affine, + everywhere: Option, + boards: Vec, + /// The grid's two colors and its cell in document points. + grid: Option<([Paper; 2], f64)>, +} + +impl<'a> Over<'a> { + /// None when the backdrop has no paper, or `view` (the document → the pixels) can't be undone. + fn new(t: &'a DisplayTransform, b: &Backdrop, view: Affine, zoom: f64) -> Option { + let det = view.determinant(); + if (b.everywhere.is_none() && b.artboards.is_empty()) || !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let paper = |srgb: [u8; 3]| Paper { srgb, shown: t.apply8(srgb) }; + let boards = b.artboards.iter().map(|&(r, color)| Board { rect: r.abs(), origin: r.origin(), paper: paper(color) }).collect(); + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + Some(Self { + t, + to_doc: view.inverse(), + everywhere: b.everywhere.map(paper), + boards, + grid: b.grid.map(|([a, c], cell)| ([paper(a), paper(c)], (f64::from(cell) / zoom).max(5e-10))), + }) + } + + /// The pixels `start..end` of the row whose centers lie at height `y` that artboard `b` covers, + /// their centers inside its rectangle. None when it covers none of the row's `width` pixels. + fn span(&self, b: &Board, y: f64, width: usize) -> Option<(usize, usize)> { + let at = self.to_doc * Point::new(0.0, y); + let [dx, dy, ..] = self.to_doc.as_coeffs(); + // Where along the row the document point stays within the rectangle, axis by axis. + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for (start, slope, min, max) in [(at.x, dx, b.rect.x0, b.rect.x1), (at.y, dy, b.rect.y0, b.rect.y1)] { + if slope == 0.0 { + if !(min <= start && start < max) { + return None; + } + } else { + let (t0, t1) = ((min - start) / slope, (max - start) / slope); + (lo, hi) = (lo.max(t0.min(t1)), hi.min(t0.max(t1))); + } + } + // Pixel i's center is i + 0.5. + let (start, end) = ((lo - 0.5).ceil().max(0.0), (hi - 0.5).ceil().min(width as f64)); + (start < end).then_some((start as usize, end as usize)) + } + + /// `owner` becomes the artboard on top at each of the `width` pixels of the row whose centers + /// lie at height `y` ([`NO_BOARD`] where there is none). Each pixel is taken once, by the + /// topmost artboard over it, so a row costs its width plus its artboards however they overlap. + fn owners(&self, y: f64, width: usize, owner: &mut Vec, next: &mut Vec) { + owner.clear(); + owner.resize(width, NO_BOARD); + next.clear(); + next.extend(0..=width as u32); + for (k, b) in self.boards.iter().enumerate().rev() { + let Some((start, end)) = self.span(b, y, width) else { continue }; + let mut x = untaken(next, start); + while x < end { + owner[x] = k as u32; + next[x] = x as u32 + 1; + x = untaken(next, x + 1); + } + } + } + + /// The paper at pixel `i` of the row at height `y`, whose artboard on top is `owner`. + fn paper_at(&self, owner: u32, i: usize, y: f64) -> Option { + let b = self.boards.get(owner as usize)?; + Some(match &self.grid { + Some((colors, cell)) => { + let p = self.to_doc * Point::new(i as f64 + 0.5, y); + let cells = ((p.x - b.origin.x) / cell).floor() + ((p.y - b.origin.y) / cell).floor(); + colors[usize::from(cells.rem_euclid(2.0) >= 1.0)] + } + None => b.paper, + }) + } + + /// Rows `first_row..` of the frame, `width` pixels each, in `pixels`. + fn rows(&self, pixels: &mut [u8], first_row: usize, width: usize) { + let mut last_px = ([0u8; 4], [0u8; 4]); + let mut last_mix = (([0u8; 4], [0u8; 3]), [0u8; 4]); + let (mut owner, mut next) = (Vec::new(), Vec::new()); + for (j, line) in pixels.chunks_exact_mut(width * 4).enumerate() { + let y = (first_row + j) as f64 + 0.5; + // Found for the row's first translucent pixel. + let mut found = false; + for (i, px) in line.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let a = px[3]; + if a == 0 { + continue; + } + let paper = if a == 255 { + None + } else if self.everywhere.is_some() { + self.everywhere + } else { + if !found { + self.owners(y, width, &mut owner, &mut next); + found = true; + } + self.paper_at(owner[i], i, y) + }; + match paper { + Some(p) if last_mix.0 == (*px, p.srgb) => *px = last_mix.1, + Some(p) => { + let input = (*px, p.srgb); + // Drawn over the converted paper, the pixel shows `px` over the paper in sRGB, converted. + let under = 255 - u32::from(a); + let mix = |c: usize| (u32::from(px[c]) + (u32::from(p.srgb[c]) * under + 127) / 255).min(255) as u8; + let shown = self.t.apply8([mix(0), mix(1), mix(2)]); + for ((o, s), u) in px.iter_mut().zip(shown).zip(p.shown) { + let paper_part = ((u32::from(u) * under + 127) / 255) as i32; + *o = (i32::from(s) - paper_part).clamp(0, 255) as u8; + } + last_mix = (input, *px); + } + None if last_px.0 == *px => *px = last_px.1, + None => { + let input = *px; + self.t.apply_premultiplied(px); + last_px = (input, *px); + } + } + } + } + } +} + +/// [`convert`], with each translucent pixel over the paper or the transparency grid of `backdrop` +/// blended with it in sRGB before the conversion. The canvas draws the art over the paper +/// converted: such a pixel keeps its alpha and takes the color that, drawn over the converted +/// paper, shows the blend converted. Opaque and fully transparent pixels, and pixels over the +/// pasteboard, convert as [`convert`] converts them. The pixels are rows of `width`; `view` maps the +/// document to them, and the backdrop's `zoom` is the canvas's screen points per document point, +/// in which the grid's cells are measured. +pub(crate) fn convert_over(t: Option<&DisplayTransform>, pixels: &mut [u8], width: u32, view: Affine, backdrop: Option<(&Backdrop, f64)>) { + let Some(t) = t else { return }; + let width = width as usize; + let over = backdrop.filter(|_| width > 0 && pixels.len().is_multiple_of(width * 4)).and_then(|(b, zoom)| Over::new(t, b, view, zoom)); + let Some(over) = over else { return convert(Some(t), pixels) }; + #[cfg(not(target_arch = "wasm32"))] + { + const PIECE: usize = 256 * 1024 * 4; + let threads = std::thread::available_parallelism().map_or(1, usize::from).min(16); + if threads > 1 && pixels.len() > PIECE { + let rows = (pixels.len() / (width * 4)).div_ceil(threads); + let over = &over; + std::thread::scope(|scope| { + for (k, piece) in pixels.chunks_mut(rows * width * 4).enumerate() { + scope.spawn(move || over.rows(piece, k * rows, width)); + } + }); + return; + } + } + over.rows(pixels, 0, width); +} + /// The opaque or translucent color `c` through `t`. pub(crate) fn color32(t: Option<&DisplayTransform>, c: egui::Color32) -> egui::Color32 { let Some(t) = t else { return c }; @@ -308,4 +525,162 @@ pub(crate) mod tests { let [r, g, b] = t.apply8([255, 0, 0]); assert_eq!(color32(Some(&t), egui::Color32::RED), egui::Color32::from_rgb(r, g, b)); } + + /// What the canvas shows of pixel `px` drawn over the converted paper `under`, as egui blends it. + fn shown_over(px: [u8; 4], under: [u8; 3]) -> [u32; 3] { + let k = 255 - u32::from(px[3]); + [0, 1, 2].map(|i| u32::from(px[i]) + (u32::from(under[i]) * k + 127) / 255) + } + + /// `px` blended with the sRGB paper `paper`, converted. + fn blended(t: &DisplayTransform, px: [u8; 4], paper: [u8; 3]) -> [u8; 3] { + let k = 255 - u32::from(px[3]); + t.apply8([0, 1, 2].map(|i| (u32::from(px[i]) + (u32::from(paper[i]) * k + 127) / 255).min(255) as u8)) + } + + fn assert_near(got: [u32; 3], want: [u8; 3], at: &str) { + assert!((0..3).all(|i| got[i].abs_diff(u32::from(want[i])) <= 1), "{at}: {got:?} shown, {want:?} wanted"); + } + + /// One row of `n` pixels, every one `px`. + fn row(n: usize, px: [u8; 4]) -> Vec { + px.repeat(n) + } + + #[test] + fn translucent_pixels_over_the_paper_are_blended_before_they_are_converted() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half_red = [128, 0, 0, 128]; + let b = Backdrop { everywhere: None, artboards: vec![(Rect::new(0.0, 0.0, 10.0, 1.0), [255; 3])], grid: None }; + let mut px = row(20, half_red); + px[12..16].copy_from_slice(&[255, 0, 0, 255]); + px[16..20].copy_from_slice(&[0; 4]); + convert_over(Some(&t), &mut px, 20, Affine::IDENTITY, Some((&b, 1.0))); + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + let white = t.apply8([255; 3]); + assert_near(shown_over(at(0), white), blended(&t, half_red, [255; 3]), "over the paper"); + // Converted alone and drawn over the converted paper, the pixel shows another color. + let mut alone = half_red; + t.apply_premultiplied(&mut alone); + let want = blended(&t, half_red, [255; 3]); + assert!((0..3).any(|i| shown_over(alone, white)[i].abs_diff(u32::from(want[i])) > 2), "{alone:?}"); + assert_eq!(at(0)[3], 128, "the alpha is kept"); + // Opaque and empty pixels, and pixels over the pasteboard, convert as before. + let red = t.apply8([255, 0, 0]); + assert_eq!((at(3), at(4)), ([red[0], red[1], red[2], 255], [0; 4])); + assert_eq!(at(15), alone, "over the pasteboard"); + // Without a backdrop, and without a profile, as before. + let mut plain = row(20, half_red); + convert_over(Some(&t), &mut plain, 20, Affine::IDENTITY, None); + assert_eq!(plain, row(20, alone)); + let mut srgb = row(20, half_red); + convert_over(None, &mut srgb, 20, Affine::IDENTITY, Some((&b, 1.0))); + assert_eq!(srgb, row(20, half_red)); + } + + #[test] + fn the_backdrop_is_found_through_the_view() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + // Papers far apart: a pixel blended with the wrong one shows a color several levels off. + let half = [128, 0, 0, 128]; + let (light, dark) = ([255, 230, 230], [40, 40, 30]); + // A later artboard is drawn over an earlier one. + let b = Backdrop { + everywhere: None, + artboards: vec![(Rect::new(0.0, 0.0, 100.0, 100.0), light), (Rect::new(50.0, 0.0, 100.0, 100.0), dark)], + grid: None, + }; + // A view turned a quarter clockwise and moved: pixel x runs down the document's y axis. + let view = Affine::translate((10.0, 0.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-20.0, -60.0)); + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, view, Some((&b, 1.0))); + // Pixel (2.5, 0.5) is document point (20.5, 67.5): on the first artboard only. + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + assert_near(shown_over(at(2), t.apply8(light)), blended(&t, half, light), "on the first artboard"); + let view = Affine::translate((-60.0, -5.0)); + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, view, Some((&b, 1.0))); + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + assert_near(shown_over(at(0), t.apply8(dark)), blended(&t, half, dark), "where the second covers the first"); + // View › Hide Artboards: the paper everywhere. + let all = Backdrop { everywhere: Some(dark), artboards: vec![], grid: None }; + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, Affine::translate((-5000.0, 0.0)), Some((&all, 1.0))); + assert_near(shown_over(px[..4].try_into().unwrap(), t.apply8(dark)), blended(&t, half, dark), "Hide Artboards"); + // Overlapping artboards under a turned and scaled view, against a direct lookup of the + // artboard on top whose rectangle holds each pixel's center. + let boards: Vec<(Rect, [u8; 3])> = (0..12u8) + .map(|k| { + let (x, y) = (f64::from(k) * 7.0, f64::from(k % 3) * 9.0); + (Rect::new(x, y, x + 15.0, y + 20.0), if k % 2 == 0 { [255, 255, 255 - k] } else { [20 + k, 20, 30] }) + }) + .collect(); + let b = Backdrop { everywhere: None, artboards: boards.clone(), grid: None }; + let view = Affine::rotate(0.3) * Affine::scale(1.7) * Affine::translate((5.0, -3.0)); + let (w, h) = (64, 48); + let mut px = row(w * h, half); + convert_over(Some(&t), &mut px, w as u32, view, Some((&b, 1.0))); + let to_doc = view.inverse(); + let mut on_paper = 0; + for i in 0..w * h { + let p = to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let mut want = half.to_vec(); + match boards.iter().rev().find(|(r, _)| r.contains(p)) { + Some(&(_, paper)) => { + let one = Backdrop { everywhere: Some(paper), artboards: vec![], grid: None }; + convert_over(Some(&t), &mut want, 1, Affine::IDENTITY, Some((&one, 1.0))); + on_paper += 1; + } + None => t.apply_premultiplied(&mut want), + } + assert_eq!(&px[i * 4..i * 4 + 4], &want[..], "pixel {i} at {p:?}"); + } + assert!(on_paper > 500 && on_paper < w * h, "{on_paper} pixels on the artboards"); + } + + #[test] + fn the_transparency_grid_is_blended_cell_by_cell() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half = [128, 0, 0, 128]; + // Far apart, so that a pixel blended with the other cell's color shows several levels off. + let (light, dark) = ([255; 3], [40; 3]); + // Cells of 10 points from the artboard's corner at (5, 0), here 1 pixel a point. + let b = Backdrop { everywhere: None, artboards: vec![(Rect::new(5.0, 0.0, 65.0, 20.0), [255, 0, 0])], grid: Some(([light, dark], 10.0)) }; + let check = |zoom: f64, cells: [(usize, [u8; 3]); 3]| { + let mut px = row(60, half); + convert_over(Some(&t), &mut px, 60, Affine::IDENTITY, Some((&b, zoom))); + for (i, paper) in cells { + assert_near( + shown_over(px[i * 4..i * 4 + 4].try_into().unwrap(), t.apply8(paper)), + blended(&t, half, paper), + &format!("pixel {i} at {zoom}"), + ); + } + }; + check(1.0, [(6, light), (16, dark), (26, light)]); + // At 200% a cell is 5 document points. + check(2.0, [(6, light), (11, dark), (16, light)]); + } + + #[test] + fn large_frames_blend_the_same_on_several_threads() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let b = Backdrop { + everywhere: None, + artboards: vec![(Rect::new(0.0, 0.0, 500.0, 900.0), [240, 230, 220])], + grid: Some(([[255; 3], [200; 3]], 8.0)), + }; + let (w, h) = (1000, 1000); + let mut big: Vec = (0..w * h * 4).map(|i| (i * 7 % 251) as u8).collect(); + for px in big.as_chunks_mut::<4>().0 { + let a = px[3]; + for c in &mut px[..3] { + *c = (*c).min(a); + } + } + let mut one = big.clone(); + Over::new(&t, &b, Affine::IDENTITY, 1.0).unwrap().rows(&mut one, 0, w); + convert_over(Some(&t), &mut big, w as u32, Affine::IDENTITY, Some((&b, 1.0))); + assert_eq!(big, one); + } } diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..3893c4e542 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6010,8 +6010,9 @@ Color on (HDR or Automatic Color Management) and macOS windows are converted by again. In a Wayland session, and where no profile is set or the profile is not an RGB display profile of at most 1 MiB, the canvas stays sRGB. The app reads the display again every 10 seconds and when the window moves to another display. Exports, `ui.render`, the document, panels and thumbnails are not converted; with Proof Colors on, the proof is drawn -first and then converted. The art is converted before it is drawn over the converted paper, so the blend of a -translucent object or an antialiased edge with the paper can differ slightly from a conversion of the blended color. +first and then converted. Translucent art and the antialiased edges of art over an artboard's paper or the +transparency grid are blended with it in sRGB, and the blend is converted; over the pasteboard and in Outline the art +is converted on its own. `ui.inspect` returns `displayProfile`: `{enabled, display, source, profile, from, applied, reason, osManaged}`, where `source` is `profile`, `srgbProfile` (a profile that shows sRGB unchanged), `system`, `none`, `pending` or `unsupported`, `applied` says whether the canvas is converted, and `reason` says why not. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..c9a1e2ec34 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -732,6 +732,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **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`. +- **Translucent art blended before the display profile:** with Use Monitor Profile for Canvas converting, translucent art and the anti-aliased edges of art over an artboard's paper, a colored artboard or the transparency grid are blended with it in sRGB and the blend is converted, so the monitor's profile translates the document's colors as they are composited ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). The frame rendered whole, the part an edit renders again, the render worker's frame, the pan fill and the other windows' panes do the same; over the pasteboard and in Outline the art is converted on its own, and with no profile to convert into nothing changes. Tests `display_profile::tests::translucent_pixels_over_the_paper_are_blended_before_they_are_converted`, `the_transparency_grid_is_blended_cell_by_cell`, `the_backdrop_is_found_through_the_view` and `canvas::tests::translucent_art_over_the_paper_is_blended_before_the_display_profile`. - **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`. - **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.