diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index ac0546395..66c71c07c 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -86,6 +86,19 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option bool) -> Option { + let finish = |mut h: Hit| { + h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); + h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); + h + }; + // Isolation mode: only what is inside the isolated container hits. The art around it is + // locked, and clicks pass through it (#833). + if let Some(scope) = opt.scope + && let Some(container) = doc.node(scope).filter(|n| n.is_container()) + && let Some(mut chain) = doc.ancestry(scope) + { + return hit_children(container, p, opt, skip, &mut chain).map(finish); + } let mut chain = Vec::new(); for layer in doc.layers.iter().rev() { if !layer.visible || layer.locked { @@ -100,10 +113,8 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No } chain.push(layer.id); let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; - if let Some(mut h) = hit_children(layer, p, opt, skip, &mut chain) { - h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); - h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); - return Some(h); + if let Some(h) = hit_children(layer, p, opt, skip, &mut chain) { + return Some(finish(h)); } chain.pop(); } @@ -560,7 +571,11 @@ world", d.insert(Some(l), 9, far).unwrap(); let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope); assert_eq!(at(30.0, None), vec![g, a_id]); - assert_eq!(at(30.0, Some(g))[..2], [c_id, b_id], "isolated: the group's own objects"); + assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects"); + // Isolated, the art around the group is locked: clicks pass through it (#833). + assert!(at(5.0, Some(g)).is_empty()); + assert!(at(130.0, Some(g)).is_empty()); + assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none()); assert_eq!(at(5.0, None), vec![a_id]); assert!(at(90.0, None).is_empty()); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 386732e3c..301812861 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -107,8 +107,14 @@ pub struct RenderOptions { /// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image /// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off). pub smooth_images: bool, + /// Screen view in isolation mode: the isolated group or layer. Everything around it draws + /// dimmed ([`ISOLATION_DIM`]). + pub isolated: Option, } +/// The opacity of the art around an isolated group or layer. +pub const ISOLATION_DIM: f32 = 0.5; + /// How edges are rasterized (raster export option). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AntiAlias { @@ -174,6 +180,7 @@ impl Default for RenderOptions { progressive_placed: false, trace_views: false, smooth_images: true, + isolated: None, } } } @@ -355,6 +362,11 @@ pub struct Renderer { /// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing /// it): drawing stops at [`MAX_INLINE_DEPTH`]. inline_depth: u32, + /// [`RenderOptions::isolated`]'s layers and groups, from the top layer down to it, for the + /// frame (`stamp`) and document they were found in. + isolation: Option<(u64, usize, NodeId, Vec)>, + /// Drawing inside the isolated container or a dimmed object: nothing further dims. + isolation_settled: bool, } /// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. @@ -441,6 +453,8 @@ impl Renderer { adjusted: Default::default(), dim_images: None, inline_depth: 0, + isolation: None, + isolation_settled: false, } } @@ -876,6 +890,46 @@ impl Renderer { /// [`Self::draw_node`] after culling and Layer Options. fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { + // Isolation mode: the art around the isolated container draws dimmed (#833). + if let Some((isolated, path)) = self.isolation_path(f) { + // A container on the way down to it draws as usual: its members decide. + if path.contains(&n.id) && n.id != isolated { + return self.draw_layer_node_now(ctx, f, n); + } + self.isolation_settled = true; + if n.id == isolated { + self.draw_layer_node_now(ctx, f, n); + } else { + self.dimmed(ctx, f, &mut |r, c, fr| r.draw_layer_node_now(c, fr, n)); + } + self.isolation_settled = false; + return; + } + self.draw_layer_node_now(ctx, f, n); + } + + /// In isolation mode, while drawing outside the isolated container and the art dimmed around + /// it: the isolated container and its layers and groups, from the top layer down to it. + fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec)> { + let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?; + let doc = std::ptr::from_ref(f.doc) as usize; + if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) { + self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default())); + } + let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?; + Some((isolated, path)) + } + + /// `draw` at [`ISOLATION_DIM`], with nothing inside dimmed again. + fn dimmed(&mut self, ctx: &mut RenderContext, f: &Frame, draw: &mut group::Content) { + let settled = std::mem::replace(&mut self.isolation_settled, true); + let comp = Composite { opacity: ISOLATION_DIM, ..Default::default() }; + self.group(ctx, f, comp, draw); + self.isolation_settled = settled; + } + + /// [`Self::draw_layer_node`] past isolation mode's dimming. + fn draw_layer_node_now(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { if fx::has_object_fx(n) { return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); } @@ -998,6 +1052,22 @@ impl Renderer { fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], knockout: bool) { if knockout { self.draw_knockout(ctx, f, children); + } else if let Some((_, path)) = self.isolation_path(f) { + // Isolation mode, in a container on the way down to the isolated one: each run of + // members around it draws dimmed as one (#833). + let mut rest = children; + while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { + let (around, from) = rest.split_at(i); + if !around.is_empty() { + self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); + } + let Some((on_path, after)) = from.split_first() else { break }; + self.draw_arc(ctx, f, on_path); + rest = after; + } + if !rest.is_empty() { + self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); + } } else { for c in children { self.draw_arc(ctx, f, c); diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 1db73de1d..138bb14b0 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -218,3 +218,30 @@ fn copying_the_backdrop_changes_nothing_without_blending() { let worst = a.iter().zip(&b).map(|(x, y)| (*x as i32 - *y as i32).abs()).max().unwrap(); assert!(worst <= 1, "differs by {worst}/255"); } + +/// Isolation mode (#833): the art around the isolated group draws at half opacity, above it as +/// well as below; the group's own art as usual. +#[test] +fn isolation_mode_dims_the_art_around_the_isolated_group() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [0.0, 0.0, 0.0]); + let inside = rect(&mut d, Rect::new(60.0, 60.0, 100.0, 100.0), [0.0, 0.0, 0.0]); + let above = rect(&mut d, Rect::new(60.0, 0.0, 100.0, 40.0), [0.0, 0.0, 0.0]); + let g = group(&mut d, vec![inside], 1.0, Blend::Normal, false); + let gid = g.id; + for n in [below, g, above] { + d.insert(Some(l), usize::MAX, n).unwrap(); + } + let alpha = |img: &Rendered, x: usize, y: usize| img.pixels[(y * 100 + x) * 4 + 3]; + let plain = render(&d); + let isolated = |id| render_with(&mut Renderer::new(), &d, &RenderOptions { isolated: Some(id), ..Default::default() }); + let dimmed = isolated(gid); + assert_eq!(alpha(&dimmed, 80, 80), 255, "the isolated group's art"); + for (x, y) in [(20, 20), (80, 20)] { + assert_eq!(alpha(&plain, x, y), 255); + assert!((120..=135).contains(&alpha(&dimmed, x, y)), "({x}, {y}): {}", alpha(&dimmed, x, y)); + } + // An object that isn't in the document dims nothing. + assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c5085533..04392757f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -249,6 +249,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { placed: vectorcraft_render::placed_document::generation(), pixel, smooth_images, + isolated: st.isolation.map(|id| id.0), }; // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { @@ -298,6 +299,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { progressive_placed: true, trace_views: true, smooth_images, + // Isolation mode: the art around the isolated group or layer is dimmed (#833). + isolated: key.isolated.map(vectorcraft_doc::NodeId), ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 89086af33..6c3ccdff2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -293,6 +293,8 @@ pub struct CacheKey { pub pixel: Option<[i64; 4]>, /// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]). pub smooth_images: bool, + /// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]). + pub isolated: Option, } #[derive(Clone, Copy, Debug, Default)]