diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index c0a80a8964..d6888eb065 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1202,7 +1202,9 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// group's original shapes, which the canvas does not draw, and guide paths with /// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice /// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not -/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +/// draw. Art in a layer of the symbol whose Preview is off is tested in outline +/// ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or +/// no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { struct Find<'a> { doc: &'a Document, @@ -1212,35 +1214,34 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> symbols: &'a SymbolBoxes<'a>, } impl Find<'_> { - fn hit(&self, n: &Node, whole: Option) -> bool { - self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, opt, self.symbols).is_some()) } fn skips_guide(&self, n: &Node) -> bool { self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) } - fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + /// `outline`: the view is in Outline, or a layer around `n` has Preview off. + fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> bool { if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { return false; } - let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + let outline = outline || previews_off(n); + let opt = HitOptions { outline, ..self.opt }; + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { - return self.hit(n, whole); + return self.hit(n, whole, opt); } let whole = self.art.whole(n, self.doc, whole); if n.clips() && let Some(first) = children.first() { let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; - if self.opt.clip_outlines - && first.visible - && !first.locked - && !self.skips_guide(first) - && clip_outline(&clip, self.p, self.opt).is_some() + if self.opt.clip_outlines && first.visible && !first.locked && !self.skips_guide(first) && clip_outline(&clip, self.p, opt).is_some() { path.push(0); return true; } - if !clip_contains(&clip, self.p, self.opt.outline) { + if !clip_contains(&clip, self.p, outline) { return false; } } @@ -1251,7 +1252,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> } let Ok(i) = u32::try_from(i) else { continue }; path.push(i); - if self.find(c, path, whole) { + if self.find(c, path, whole, outline) { return true; } path.pop(); @@ -1262,7 +1263,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; - Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, opt.outline).then_some(path) } /// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), @@ -1271,9 +1272,17 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> /// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An /// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut /// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] -/// picks it. +/// picks it: the bounds of the region its live geometry effects give it ([`clip_region`]) when it +/// has any. /// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + symbol_part_outline_in_view(doc, inst, path, false) +} + +/// [`symbol_part_outline`] in the view the canvas shows: with `outline` (Outline view), and below a +/// layer of the art whose Preview is off, the outlines are cut to the bounds of the plain clipping +/// paths, without their live effects, as the clicks of [`HitOptions::outline`] count them. +pub fn symbol_part_outline_in_view(doc: &Document, inst: &Node, path: &[u32], mut outline: bool) -> Option { let art = InstanceArt::of(doc, inst)?; let part = crate::symbol_overrides::part(&art.art, path)?; let mut out = BezPath::new(); @@ -1283,10 +1292,11 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< for &i in path { let children = n.children()?; whole = art.whole(n, doc, whole); + outline = outline || previews_off(n); if i != 0 && n.clips() && let Some(first) = children.first() - && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + && let Some(clip) = clip_limit(&art.place_unclipped(first, doc, whole)?, outline) { match meet(Some(clip), limit) { Some(r) => limit = Some(r), @@ -1295,12 +1305,24 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< } n = children.get(usize::try_from(i).ok()?)?; } - outline_within(&art.place(part, doc, whole)?, limit, &mut out); + outline_within(&art.place(part, doc, whole)?, limit, outline, &mut out); Some(out) } -/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. -fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { +/// The box that the art clipped by `clip` is drawn in: the bounds of the region the live geometry +/// effects of `clip` give it ([`clip_region`]), or the bounds of `clip` when it has no such effects +/// and with `outline`, where the canvas draws no effects. +fn clip_limit(clip: &Node, outline: bool) -> Option { + use vectorcraft_geom::kurbo::Shape; + let region = if outline { None } else { clip_region(clip) }; + match region.as_deref() { + Some(Some((bp, _))) if !bp.elements().is_empty() => Some(bp.bounding_box()), + _ => clip.geometric_bounds(), + } +} + +/// [`symbol_part_outline_in_view`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, outline: bool, out: &mut BezPath) { use vectorcraft_geom::kurbo::Shape; if let Some(pd) = n.path_data() { match limit { @@ -1314,20 +1336,21 @@ fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { out.extend(b.to_path(0.1)); } let Some(children) = n.children() else { return }; + let outline = outline || previews_off(n); if n.clips() && let Some((first, rest)) = children.split_first() { - let inner = match first.geometric_bounds() { + let inner = match clip_limit(first, outline) { Some(c) => match meet(Some(c), limit) { Some(r) => Some(r), None => return, }, None => limit, }; - rest.iter().for_each(|c| outline_within(c, inner, out)); + rest.iter().for_each(|c| outline_within(c, inner, outline, out)); return; } - children.iter().for_each(|c| outline_within(c, limit, out)); + children.iter().for_each(|c| outline_within(c, limit, outline, out)); } /// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 4584421200..e0c2f8de4d 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1788,6 +1788,50 @@ fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); } +/// In a dynamic symbol's art, an object in a clipping layer whose clipping path has a live effect +/// is picked and outlined in the region the effect gives the clipping path. In Outline view, and +/// when the layer's Preview is off, the plain clipping path counts, as the canvas draws it. +#[test] +fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { + set_clip_region(moved_clip_region); + let instance = |preview: bool| { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mut mask, art) = half_clipped(&mut d); + mask.appearance.effects.push(test_move()); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, preview: p, .. } = &mut layer.kind { + (*children, *p) = (vec![Arc::new(mask), Arc::new(art)], preview); + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + (d, inst) + }; + let outline_box = |d: &Document, inst: NodeId, outline: bool| { + let o = symbol_part_outline_in_view(d, d.node(inst).unwrap(), &[0, 1], outline).unwrap(); + vectorcraft_geom::kurbo::Shape::bounding_box(&o) + }; + let opt = HitOptions::default(); + let (d, inst) = instance(true); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), Some(vec![0, 1]), "in the moved region"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), None, "where the clipping path was"); + let b = outline_box(&d, inst, false); + assert!((b.x0 - 100.0).abs() < 1e-6 && (b.x1 - 200.0).abs() < 1e-6, "the outline is cut to the moved region: {b:?}"); + let b = outline_box(&d, inst, true); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "in Outline view, to the plain clipping path: {b:?}"); + // With the layer's Preview off, the art's top edge counts within the plain clipping path. + let (d, inst) = instance(false); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 0.5), opt), Some(vec![0, 1]), "on the edge the canvas draws"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 0.5), opt), None, "past the plain clipping path"); + let b = outline_box(&d, inst, false); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "Preview off: the plain clipping path: {b:?}"); +} + /// In a dynamic symbol's art that placing through its 9-slice guides could make more than /// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the /// canvas draws nothing of it. At the art's natural size the same art is picked. diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index cee2c7dfa1..ebcbeb4489 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -55,7 +55,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline}; +use vectorcraft_doc::hit::{Hit, HitKind, compound_part_at, hit_test, selected_hidden_at, symbol_part_at, symbol_part_outline_in_view}; use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection, symbol_overrides}; use vectorcraft_geom::{Anchor, PathData, PathSeg, Point, Rect, Vec2}; @@ -1029,7 +1029,7 @@ impl Tool for DirectSelectionTool { out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { if let Some(sp) = &cx.selection.symbol_part - && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline(cx.doc, n, &sp.path)) + && let Some(path) = cx.doc.node(sp.instance).and_then(|n| symbol_part_outline_in_view(cx.doc, n, &sp.path, cx.outline)) { out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false }); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d23d645f3f..1a1462788a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1610,7 +1610,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export. When the clipping path has a fill or stroke of its own, the geometry effects of those fills and strokes apply after the group's geometry effects instead. Type used as a clipping path keeps its geometry effects when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. - Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. A hidden member of a group used as a clipping path does not clip, and a geometry effect on it does not change where clicks land. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. `doc::hit::tests` and the engine's `tests::clip` cover it. + Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. A hidden member of a group used as a clipping path does not clip, and a geometry effect on it does not change where clicks land. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. Direct Selection picks and outlines an object of a dynamic symbol's art by the same rules. `doc::hit::tests` and the engine's `tests::clip` cover it. - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports.