mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
735 lines
35 KiB
Rust
735 lines
35 KiB
Rust
//! Hit testing over the document tree.
|
||
|
||
use std::collections::HashSet;
|
||
use std::sync::OnceLock;
|
||
|
||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
|
||
|
||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum HitKind {
|
||
Fill,
|
||
Stroke,
|
||
/// Hit a path outline in outline mode, or an unfilled path's edge.
|
||
Outline,
|
||
Bounds,
|
||
}
|
||
|
||
/// The result of a hit test: the leaf that was hit and its ancestry (layer first).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Hit {
|
||
pub leaf: NodeId,
|
||
pub ancestry: Vec<NodeId>,
|
||
pub kind: HitKind,
|
||
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
|
||
/// its content, not the envelope, is the object clicked.
|
||
pub contents_of: Option<NodeId>,
|
||
/// How many entries at the start of `ancestry` are layers (the layer and its sublayers).
|
||
pub layers: usize,
|
||
}
|
||
|
||
impl Hit {
|
||
/// The object the Selection tool selects: the child of the layer (the outermost group).
|
||
/// Inside isolation mode (`scope`), the child of the isolated container instead.
|
||
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
|
||
// A single object isolated: it is the only object.
|
||
if scope == Some(self.leaf) {
|
||
return self.leaf;
|
||
}
|
||
// The innermost of the isolated container and an envelope whose contents are edited.
|
||
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
|
||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
|
||
}
|
||
// Skip layers and sublayers: they are not objects.
|
||
self.ancestry.get(self.layers.max(1)).copied().unwrap_or(self.leaf)
|
||
}
|
||
}
|
||
|
||
/// Options for hit testing.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct HitOptions {
|
||
/// The isolated construction container, whose retained Shaper sources are hittable.
|
||
pub scope: Option<NodeId>,
|
||
/// Tolerance in document units (screen tolerance / zoom).
|
||
pub tol: f64,
|
||
/// Outline mode: only outlines are hittable.
|
||
pub outline: bool,
|
||
/// "Object Selection by Path Only" preference.
|
||
pub path_only: bool,
|
||
/// "Type Object Selection by Path Only" preference: type is picked on its type path only
|
||
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
|
||
/// bounds.
|
||
pub type_path_only: bool,
|
||
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
|
||
/// on or the guides are hidden.
|
||
pub skip_guides: bool,
|
||
/// The selection tools: a clip group's clipping path is hit on its outline, within the
|
||
/// tolerance, before the art it clips, so that the mask can be reached where the art covers
|
||
/// its edge. Off for the tools that act on what is drawn (sampling, type, gradients…);
|
||
/// [`hit_test_visible`] never does it.
|
||
pub clip_outlines: bool,
|
||
}
|
||
|
||
impl Default for HitOptions {
|
||
fn default() -> Self {
|
||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None }
|
||
}
|
||
}
|
||
|
||
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
|
||
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
|
||
/// above this crate, so the engine registers one with [`set_image_paint`].
|
||
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
|
||
|
||
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
|
||
|
||
/// Register how the shapes of an image's own fills and strokes are found (the first registration
|
||
/// stays).
|
||
pub fn set_image_paint(f: ImagePaint) {
|
||
// Already set: the same engine, registered by another session.
|
||
let _ = IMAGE_PAINT.set(f);
|
||
}
|
||
|
||
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
|
||
/// nothing is registered.
|
||
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
|
||
}
|
||
|
||
/// Topmost editable object under `p`.
|
||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||
hit_test_skipping(doc, p, opt, &|_| false, true)
|
||
}
|
||
|
||
/// Topmost visible art for read-only sampling: locked objects and template layers participate,
|
||
/// including art outside the editing scope. This never changes their editability.
|
||
pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||
hit_test_skipping(doc, p, opt, &|_| false, false)
|
||
}
|
||
|
||
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
|
||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
|
||
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
|
||
// while a group (including a clipping group) is selected as one object. Repeatedly
|
||
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
|
||
// Isolation and Edit Contents change where selection groups begin; keep the original
|
||
// traversal for those cases rather than change their Select Behind semantics.
|
||
if opt.scope.is_some() {
|
||
return objects_at_repeated(doc, p, opt, scope);
|
||
}
|
||
let mut tops = Vec::new();
|
||
let mut seen = HashSet::new();
|
||
let symbols = doc.symbol_boxes();
|
||
for layer in doc.layers.iter().rev() {
|
||
// Malformed files may contain non-layer roots; let the general traversal handle them.
|
||
if !layer.is_layer() {
|
||
return objects_at_repeated(doc, p, opt, scope);
|
||
}
|
||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||
continue;
|
||
}
|
||
if !objects_at_layer(layer, p, opt, &symbols, &mut tops, &mut seen) {
|
||
// An envelope in Edit Contents may expose objects *within* a top-level group.
|
||
// The repeated traversal handles that mixed hierarchy precisely.
|
||
return objects_at_repeated(doc, p, opt, scope);
|
||
}
|
||
}
|
||
// The objects on a locked artboard can't be selected, as `hit_test_skipping` passes over them.
|
||
let locks = doc.locked_artboards();
|
||
if !locks.is_empty() {
|
||
tops.retain(|id| !doc.node(*id).is_some_and(|n| locks.holds(n)));
|
||
}
|
||
tops
|
||
}
|
||
|
||
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
|
||
/// inspected once (only until its first hit). `false` asks the caller to use the general
|
||
/// algorithm for an envelope whose contents are currently being edited. Symbol instances are
|
||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
|
||
let Some(children) = layer.children() else { return true };
|
||
if layer.clips()
|
||
&& let Some(clip) = children.first()
|
||
{
|
||
// As the hit test does: the clipping mask's outline first, then only inside the mask.
|
||
if clip_outline_hit(layer, clip, p, opt, symbols, true).is_some() && seen.insert(clip.id) {
|
||
tops.push(clip.id);
|
||
}
|
||
if !clip_contains(clip, p) {
|
||
return true;
|
||
}
|
||
}
|
||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||
for (i, c) in children.iter().enumerate().rev() {
|
||
if !may_hit(layer, i, c, p, opt, symbols, true) || seen.contains(&c.id) {
|
||
continue;
|
||
}
|
||
if c.is_layer() {
|
||
if !objects_at_layer(c, p, opt, symbols, tops, seen) {
|
||
return false;
|
||
}
|
||
continue;
|
||
}
|
||
// Editing an envelope selects its contents rather than the containing object. Even
|
||
// when nested in a group, the old traversal may select both a member and the group.
|
||
if c.is_container() {
|
||
let mut editing = false;
|
||
c.walk(&mut |n| editing |= edits_contents(n));
|
||
if editing {
|
||
return false;
|
||
}
|
||
}
|
||
let skip = |id| seen.contains(&id);
|
||
let mut chain = Vec::new();
|
||
let hit = match &c.kind {
|
||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
|
||
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(),
|
||
_ => hit_leaf(c, p, opt, symbols).is_some(),
|
||
};
|
||
if hit && seen.insert(c.id) {
|
||
tops.push(c.id);
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// The general selection order also handles isolation and nested Edit Contents.
|
||
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||
let mut tops: Vec<NodeId> = vec![];
|
||
// Each round leaves out the objects found so far (and everything inside them).
|
||
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
|
||
if tops.contains(&top) {
|
||
break;
|
||
}
|
||
tops.push(top);
|
||
}
|
||
tops
|
||
}
|
||
|
||
/// The most objects [`locked_objects_at`] lists.
|
||
pub const MAX_LOCKED_AT: usize = 20;
|
||
|
||
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
|
||
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
|
||
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
|
||
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
|
||
/// (`opt.scope`) only what is inside the isolated group or object.
|
||
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
|
||
let symbols = doc.symbol_boxes();
|
||
let mut out = Vec::new();
|
||
if let Some(scope) = opt.scope
|
||
&& let Some(isolated) = doc.node(scope)
|
||
{
|
||
if isolated.is_container() {
|
||
locked_children(isolated, p, opt, &symbols, &mut out);
|
||
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
|
||
out.push(isolated.id);
|
||
}
|
||
return out;
|
||
}
|
||
for layer in doc.layers.iter().rev() {
|
||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||
continue;
|
||
}
|
||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||
locked_children(layer, p, opt, &symbols, &mut out);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
|
||
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
|
||
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
|
||
let Some(children) = parent.children() else { return };
|
||
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||
return;
|
||
}
|
||
for i in hit_order(parent, children, p) {
|
||
if out.len() >= MAX_LOCKED_AT {
|
||
return;
|
||
}
|
||
let Some(c) = children.get(i) else { continue };
|
||
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
|
||
continue;
|
||
}
|
||
if c.locked {
|
||
if locked_hit(c, p, opt, symbols) {
|
||
out.push(c.id);
|
||
}
|
||
continue;
|
||
}
|
||
match &c.kind {
|
||
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
|
||
NodeKind::Layer { .. }
|
||
| NodeKind::Group { .. }
|
||
| NodeKind::Intertwine { .. }
|
||
| NodeKind::Blend { .. }
|
||
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||
match &n.kind {
|
||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||
_ => hit_leaf(n, p, opt, symbols).is_some(),
|
||
}
|
||
}
|
||
|
||
/// [`hit_test`] passing over the nodes `skip` names (and their contents), and with
|
||
/// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of
|
||
/// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art
|
||
/// ([`Node::geometric_bounds_with`]).
|
||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
|
||
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
|
||
};
|
||
let locks = doc.locked_artboards();
|
||
let on_locked = |id: NodeId| {
|
||
editable_only
|
||
&& !locks.is_empty()
|
||
&& doc.parent_of(id).and_then(|l| doc.node(l)).is_some_and(Node::is_layer)
|
||
&& doc.node(id).is_some_and(|n| locks.holds(n))
|
||
};
|
||
let skip = &|id| skip(id) || on_locked(id);
|
||
let symbols = doc.symbol_boxes();
|
||
// Isolation mode: only what is inside the isolated container hits. The art around it is
|
||
// locked, and clicks pass through it (#833).
|
||
if editable_only
|
||
&& let Some(scope) = opt.scope
|
||
&& let Some(isolated) = doc.node(scope)
|
||
&& let Some(mut chain) = doc.ancestry(scope)
|
||
{
|
||
// Isolated before its artboard was locked: nothing in it hits.
|
||
if locks.holds_id(scope) {
|
||
return None;
|
||
}
|
||
if isolated.is_container() {
|
||
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
|
||
}
|
||
// A single object isolated (a path, an image…): only it hits, among its siblings.
|
||
chain.pop();
|
||
let parent = chain.last().and_then(|id| doc.node(*id))?;
|
||
return hit_children(parent, p, opt, &symbols, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
|
||
}
|
||
let mut chain = Vec::new();
|
||
for layer in doc.layers.iter().rev() {
|
||
if !layer.visible || editable_only && (layer.locked || layer.is_template()) {
|
||
continue;
|
||
}
|
||
// Pattern editing mode: only the tile art is editable.
|
||
if editable_only && doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||
continue;
|
||
}
|
||
chain.push(layer.id);
|
||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||
if let Some(h) = hit_children(layer, p, opt, &symbols, skip, &mut chain, editable_only) {
|
||
return Some(finish(h));
|
||
}
|
||
chain.pop();
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
|
||
fn edits_contents(n: &Node) -> bool {
|
||
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
|
||
}
|
||
|
||
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
|
||
/// glyph outlines need the font engine, above this crate.
|
||
fn clip_contains(clip: &Node, p: Point) -> bool {
|
||
clip_regions(clip).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||
}
|
||
|
||
/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in
|
||
/// [`clip_contains`].
|
||
fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> {
|
||
let frame = |n: &Node| {
|
||
let b = n.geometric_bounds()?;
|
||
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
|
||
};
|
||
clip.clip_shapes(Some(&frame))
|
||
}
|
||
|
||
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
|
||
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
|
||
/// isn't shown, or when `p` is outside its reach.
|
||
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
|
||
if shaper_hides(parent, i, opt.scope) {
|
||
return false;
|
||
}
|
||
// Read-only samples can read locked/template art; hidden art never participates.
|
||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||
return false;
|
||
}
|
||
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||
return false;
|
||
}
|
||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||
edits_contents(c)
|
||
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|
||
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||
}
|
||
|
||
/// Is the first child of `n` the isolated container `scope`?
|
||
fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
|
||
n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == scope)
|
||
}
|
||
|
||
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
|
||
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
|
||
/// isolated, the art the group shows.
|
||
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
|
||
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
|
||
}
|
||
|
||
/// A press on the outline of clipping path `clip` (a path or a compound path; type clips by its
|
||
/// glyphs, which this crate cannot outline), within the tolerance.
|
||
fn clip_outline(clip: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||
let bp = match &clip.kind {
|
||
NodeKind::Path { path, .. } => path.to_bezpath(),
|
||
NodeKind::Compound { .. } => clip.stroke_path()?,
|
||
_ => return None,
|
||
};
|
||
stroke_contains(&bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||
}
|
||
|
||
/// For the selection tools ([`HitOptions::clip_outlines`]), a press on the outline of `clip`, the
|
||
/// clipping path of `parent` (a clip group or a layer with a clipping mask), within the tolerance:
|
||
/// it is hit before the art it clips. There Direct Selection takes its segments, and the
|
||
/// Selection tool the clip group. The reference app reshapes a clipping path with Direct Selection
|
||
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>);
|
||
/// without this, art drawn over the path's edge is hit first and its segments can't be dragged.
|
||
/// The clipping path must be one a click may hit ([`may_hit`]: visible, editable, within reach).
|
||
fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> Option<HitKind> {
|
||
if !opt.clip_outlines || !editable_only || !may_hit(parent, 0, clip, p, opt, symbols, editable_only) {
|
||
return None;
|
||
}
|
||
clip_outline(clip, p, opt)
|
||
}
|
||
|
||
fn hit_children(
|
||
parent: &Node,
|
||
p: Point,
|
||
opt: HitOptions,
|
||
symbols: &SymbolBoxes,
|
||
skip: &dyn Fn(NodeId) -> bool,
|
||
chain: &mut Vec<NodeId>,
|
||
editable_only: bool,
|
||
) -> Option<Hit> {
|
||
let children = parent.children()?;
|
||
if parent.clips()
|
||
&& let Some(clip) = children.first()
|
||
{
|
||
if let Some(kind) = clip_outline_hit(parent, clip, p, opt, symbols, editable_only).filter(|_| !skip(clip.id)) {
|
||
chain.push(clip.id);
|
||
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
|
||
}
|
||
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
|
||
if !clip_contains(clip, p) {
|
||
return None;
|
||
}
|
||
}
|
||
for i in hit_order(parent, children, p) {
|
||
let Some(c) = children.get(i) else { continue };
|
||
if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) {
|
||
continue;
|
||
}
|
||
chain.push(c.id);
|
||
let hit = match &c.kind {
|
||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only),
|
||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Intertwine { .. } => {
|
||
hit_children(c, p, opt, symbols, skip, chain, editable_only)
|
||
}
|
||
// Edit Contents: the envelope's content hits, undistorted.
|
||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
|
||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||
// as the blend.
|
||
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only)
|
||
.or_else(|| hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
|
||
_ => hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
|
||
};
|
||
if hit.is_some() {
|
||
return hit;
|
||
}
|
||
chain.pop();
|
||
}
|
||
None
|
||
}
|
||
|
||
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
|
||
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
|
||
/// come first (from the top among them too).
|
||
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
|
||
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
|
||
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
|
||
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
|
||
} else {
|
||
vec![]
|
||
};
|
||
let rest = (0..children.len()).rev().filter({
|
||
let over = over.clone();
|
||
move |i| !over.contains(i)
|
||
});
|
||
over.into_iter().chain(rest)
|
||
}
|
||
|
||
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
|
||
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
|
||
/// outline within the tolerance. Outline mode hits the outline only.
|
||
fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::FillRule, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||
if !opt.outline && n.appearance.stroke_hit(bp, rule, p, opt.tol) {
|
||
return Some(HitKind::Stroke);
|
||
}
|
||
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||
}
|
||
|
||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||
match &n.kind {
|
||
NodeKind::Path { path, rule, .. } => {
|
||
let bp = path.to_bezpath();
|
||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||
return Some(k);
|
||
}
|
||
let filled = !n.appearance.fill_paint().is_none();
|
||
if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) {
|
||
return Some(HitKind::Fill);
|
||
}
|
||
None
|
||
}
|
||
NodeKind::Compound { rule, .. } => {
|
||
let bp = n.stroke_path()?;
|
||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||
return Some(k);
|
||
}
|
||
(!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
|
||
}
|
||
// The type path lies within the bounds: they rule out the rest before it is measured.
|
||
NodeKind::Text(t) if opt.type_path_only => {
|
||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
|
||
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
|
||
}
|
||
// An image is hit within its frame (on the frame's edge only with Selection by Path Only,
|
||
// #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline
|
||
// mode.
|
||
NodeKind::Image(im) => {
|
||
if opt.path_only {
|
||
use vectorcraft_geom::kurbo::Shape;
|
||
let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1);
|
||
frame.apply_affine(im.xf);
|
||
if stroke_contains(&frame, 0.0, opt.tol, p) {
|
||
return Some(HitKind::Outline);
|
||
}
|
||
} else if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||
return Some(HitKind::Bounds);
|
||
}
|
||
if opt.outline {
|
||
return None;
|
||
}
|
||
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
|
||
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
|
||
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
|
||
})
|
||
}
|
||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||
}
|
||
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||
NodeKind::Mesh(m) => {
|
||
let bp = m.outline().to_bezpath();
|
||
if stroke_contains(&bp, 0.0, opt.tol, p) {
|
||
return Some(HitKind::Outline);
|
||
}
|
||
(!opt.path_only && fill_contains(&bp, vectorcraft_geom::FillRule::NonZero, p)).then_some(HitKind::Fill)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Is `p` within `tol` of the type path of `t` (in `n`), what "Type Object Selection by Path
|
||
/// Only" picks type by: its baselines ([`crate::TextObject::baselines`]), area type's frame and
|
||
/// type on a path's path, drawn through `n`'s perspective projection if it has one.
|
||
fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
|
||
use crate::TextKind;
|
||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||
let h = n.projection();
|
||
let project = |q: Point| h.as_ref().and_then(|h| h.apply(q)).unwrap_or(q);
|
||
// A homography keeps straight lines straight: a baseline's ends are enough.
|
||
let near = |(a, b): (Point, Point)| vectorcraft_geom::Line::new(project(t.xf * a), project(t.xf * b)).nearest(p, 1e-9).distance_sq <= tol * tol;
|
||
if t.baselines().any(near) {
|
||
return true;
|
||
}
|
||
let (TextKind::Area { frame: path } | TextKind::OnPath { path, .. }) = &t.kind else { return false };
|
||
let mut path = path.transformed(t.xf);
|
||
if h.is_some() {
|
||
let piece = path.bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25));
|
||
path = crate::live::map_nonlinear(&path, piece, project);
|
||
}
|
||
stroke_contains(&path.to_bezpath(), 0.0, tol, p)
|
||
}
|
||
|
||
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
|
||
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||
match &n.kind {
|
||
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
|
||
_ => hit_leaf(n, p, opt, symbols),
|
||
}
|
||
}
|
||
|
||
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
|
||
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
|
||
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
|
||
pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
|
||
let NodeKind::Compound { children, rule } = &n.kind else { return None };
|
||
let parts: Vec<_> = children.iter().rev().filter_map(|c| c.path_data().map(|pd| (c.id, pd.to_bezpath(), pd.bounds()))).collect();
|
||
if let Some((id, ..)) = parts.iter().find(|(_, bp, _)| stroke_contains(bp, 0.0, tol, p)) {
|
||
return Some(*id);
|
||
}
|
||
// The innermost by the size of its box.
|
||
let size = |b: &Option<Rect>| b.map_or(f64::INFINITY, |b| b.area());
|
||
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
|
||
}
|
||
|
||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving
|
||
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
|
||
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
|
||
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
|
||
/// only within that region's bounds.
|
||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||
let mut out = Vec::new();
|
||
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
|
||
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
|
||
Some(s) => match (s.children(), marquee_in(s, r)) {
|
||
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
|
||
(Some(_), None) => vec![],
|
||
// A single object isolated: only it.
|
||
(None, _) => doc
|
||
.parent_of(s.id)
|
||
.and_then(|p| doc.node(p))
|
||
.and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
|
||
.into_iter()
|
||
.flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
|
||
.collect(),
|
||
},
|
||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||
// are left out).
|
||
None => doc
|
||
.layers
|
||
.iter()
|
||
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||
.flat_map(|l| layer_objects(l, r))
|
||
.collect(),
|
||
};
|
||
/// The objects of layer `l`, each with the part of `r` that the clipping masks of `l` and of
|
||
/// the sublayers it is in leave.
|
||
fn layer_objects(l: &Node, r: Rect) -> Vec<(&std::sync::Arc<Node>, Rect)> {
|
||
let mut out = vec![];
|
||
let Some(r) = marquee_in(l, r) else { return out };
|
||
for c in l.children().into_iter().flatten() {
|
||
if c.is_layer() {
|
||
if c.visible && !c.locked && !c.is_template() {
|
||
out.extend(layer_objects(c, r));
|
||
}
|
||
} else {
|
||
out.push((c, r));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
|
||
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
|
||
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
|
||
/// compound paths, layers and groups are touched by their box.
|
||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
|
||
match &n.kind {
|
||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
|
||
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
|
||
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
|
||
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)),
|
||
// A box with no width or no height (an instance of a symbol that draws a straight
|
||
// line) is touched where the marquee crosses it.
|
||
_ => n.geometric_bounds_with(s).is_some_and(|b| {
|
||
let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
|
||
b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses
|
||
}),
|
||
}
|
||
}
|
||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
|
||
match &n.kind {
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
|
||
let Some(r) = marquee_in(n, r) else { return };
|
||
for (i, c) in children.iter().enumerate() {
|
||
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
|
||
collect_leaves(c, s, r, scope, out);
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
if touches(n, s, r, scope) {
|
||
out.push(n.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let symbols = doc.symbol_boxes();
|
||
let locks = doc.locked_artboards();
|
||
for (t, r) in tops {
|
||
if !t.visible || t.locked || locks.holds_id(t.id) {
|
||
continue;
|
||
}
|
||
if leaves {
|
||
collect_leaves(t, &symbols, r, scope, &mut out);
|
||
} else if touches(t, &symbols, r, scope) {
|
||
out.push(t.id);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a
|
||
/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None
|
||
/// when the marquee misses it); otherwise it is all of `r`.
|
||
fn marquee_in(parent: &Node, r: Rect) -> Option<Rect> {
|
||
match parent.children().and_then(|c| c.first()) {
|
||
Some(clip) if parent.clips() => marquee_in_clip(clip, r),
|
||
_ => Some(r),
|
||
}
|
||
}
|
||
|
||
/// The part of a marquee `r` in which it can touch the art that `clip` clips (the clipping path of
|
||
/// a clip group or of a layer with a clipping mask): `r` cut to the bounds of the regions `clip`
|
||
/// clips to ([`clip_regions`]), or `None` when `r` meets none of those regions. Within those
|
||
/// bounds, art outside a region that is not a rectangle still counts.
|
||
fn marquee_in_clip(clip: &Node, r: Rect) -> Option<Rect> {
|
||
use vectorcraft_geom::kurbo::Shape;
|
||
let regions = clip_regions(clip);
|
||
// The edge of a region is its outline with each open subpath closed, as `fill_contains` fills it.
|
||
let meets = |(bp, rule): &(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)| {
|
||
let mut edge = vectorcraft_geom::PathData::from_bezpath(bp);
|
||
edge.subpaths.iter_mut().for_each(|sp| sp.closed = true);
|
||
fill_contains(bp, *rule, r.center()) || vectorcraft_geom::hit::intersects_rect(&edge, r)
|
||
};
|
||
if !regions.iter().any(meets) {
|
||
return None;
|
||
}
|
||
let bounds = regions.iter().map(|(bp, _)| bp.bounding_box()).reduce(|a, b| a.union(b))?;
|
||
Some(r.intersect(bounds))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|