mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 20:36:02 +00:00
Merge pull request #7342 from dotCooCoo/feat/live-paint-selection-double-click
Live Paint: a Live Paint Selection double-click selects the connected faces or edges
This commit is contained in:
@@ -3878,6 +3878,37 @@ fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() {
|
||||
assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_selection_double_click_selects_the_faces_no_stroked_edge_parts() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
// The edge between the middle and right faces draws no stroke.
|
||||
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100.0, 50.0], "none": true})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintSelection", v).unwrap();
|
||||
let gn = node(&s, g);
|
||||
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id;
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let double_click = |s: &mut Session, x: f64, m: Mods| {
|
||||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
|
||||
s.pointer(&PointerEvent::new(kind, x, 50.0).with_mods(m), v).unwrap();
|
||||
}
|
||||
let mut ids = s.doc().unwrap().selection.objects.clone();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
let sorted = |mut ids: Vec<NodeId>| {
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
assert_eq!(double_click(&mut s, 125.0, Mods::default()), sorted(vec![face(75.0), face(125.0)]));
|
||||
// The stroked edge at x = 50 parts the left face from the others.
|
||||
assert_eq!(double_click(&mut s, 25.0, Mods::default()), vec![face(25.0)]);
|
||||
// Shift-double-click adds the faces reached to the selection.
|
||||
assert_eq!(double_click(&mut s, 75.0, Mods { shift: true, ..Default::default() }), sorted(vec![face(25.0), face(75.0), face(125.0)]));
|
||||
assert_eq!(s.doc().unwrap().doc, before, "selecting changes no paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_selection_marquee_selects_what_it_touches() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -41,9 +41,9 @@ pub use live_paint::{
|
||||
};
|
||||
use live_paint::{check_live_paint_options, lp_color, lp_rgb_color};
|
||||
pub use lookup::{
|
||||
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces,
|
||||
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
|
||||
sources, visible_edge_near, visible_face_at,
|
||||
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, LP_JOIN_TOL, SOURCES_NAME, builder_members, connected_edges, drawn_outlines,
|
||||
edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap,
|
||||
path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at,
|
||||
};
|
||||
pub use map::{
|
||||
BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::cache::RegionCache;
|
||||
use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at};
|
||||
use super::lookup::{
|
||||
connected_edges, flood_faces, is_live_paint, live_paint_at, live_paint_at_where, lp_stroke_paint, visible_edge_near, visible_face_at,
|
||||
};
|
||||
use super::map::{HIGHLIGHT_RED, sorted_roots};
|
||||
use super::{
|
||||
Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent,
|
||||
@@ -540,7 +542,11 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0);
|
||||
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws
|
||||
/// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection;
|
||||
/// with Shift held at the press, each of them leaves the selection if it is selected and joins it
|
||||
/// otherwise. A click and a marquee change the selection on release, so Escape between the press
|
||||
/// otherwise. A double-click on a face selects it and the faces of its group that are not parted
|
||||
/// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end
|
||||
/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them
|
||||
/// leaves the selection if it is selected and joins it otherwise.
|
||||
/// A click and a marquee change the selection on release, so Escape between the press
|
||||
/// and the release (which removes the marquee) leaves the selection as it was. The tool does not
|
||||
/// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer
|
||||
/// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options
|
||||
@@ -627,6 +633,18 @@ impl LivePaintSelectionTool {
|
||||
};
|
||||
(hit, cursor)
|
||||
}
|
||||
|
||||
/// What a double-click at `p` selects, Alt held or not: on the face a click there picks
|
||||
/// ([`LivePaintSelectionTool::picks_edge`]), that face and the faces of its group a flood from it
|
||||
/// reaches across edges that are hidden or draw no stroke ([`flood_faces`]); on the edge a click
|
||||
/// picks, that edge and the edges of its group joined to it end to end through edges that draw
|
||||
/// with the stroke paint it draws with ([`connected_edges`], [`lp_stroke_paint`]). Empty where a click selects no face or edge.
|
||||
fn reach_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec<NodeId> {
|
||||
let Some(edge) = self.picks_edge(alt) else { return vec![] };
|
||||
let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] };
|
||||
let Some(group) = cx.doc.node(gid) else { return vec![] };
|
||||
if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and
|
||||
@@ -748,7 +766,16 @@ impl Tool for LivePaintSelectionTool {
|
||||
(false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))],
|
||||
}
|
||||
}
|
||||
_ => vec![],
|
||||
PointerKind::DoubleClick => {
|
||||
// The two clicks before it already selected the part under the pointer (or, with
|
||||
// Shift, toggled it twice); the double-click selects the part with what it reaches.
|
||||
let ids = self.reach_at(cx, p, ev.mods.alt);
|
||||
match (ids.is_empty(), ev.mods.shift) {
|
||||
(true, _) => vec![],
|
||||
(false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))],
|
||||
(false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,349 @@ fn nearest_edge<'a>(edges: impl IntoIterator<Item = &'a Arc<Node>>, p: Point, to
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// The greatest distance (pt) between the ends of two Live Paint edges that the Live Paint tools take
|
||||
/// as one point. Every edge comes from one planar map, so their ends meet this closely.
|
||||
pub const LP_JOIN_TOL: f64 = 1e-4;
|
||||
|
||||
/// The greatest distance (pt) between the middle of a Live Paint face's outline segment and an edge
|
||||
/// it runs along. The planar map refits outlines to [`vectorcraft_pathops::DEFAULT_PRECISION`],
|
||||
/// keeping each refit segment's ends, and with Gap Detection on, the faces and the edges come from
|
||||
/// two maps, so an outline can lie that far off its edges between its ends.
|
||||
const LP_FACE_EDGE_TOL: f64 = vectorcraft_pathops::DEFAULT_PRECISION;
|
||||
|
||||
/// The cell [`LP_JOIN_TOL`] wide that holds `p`, for finding the points near it; two points within
|
||||
/// [`LP_JOIN_TOL`] of each other lie in the same or neighboring cells. The cast saturates, so a
|
||||
/// coordinate too large or not finite does not overflow.
|
||||
fn lp_cell(p: Point) -> (i64, i64) {
|
||||
((p.x / LP_JOIN_TOL).round() as i64, (p.y / LP_JOIN_TOL).round() as i64)
|
||||
}
|
||||
|
||||
/// The cell that holds `p` ([`lp_cell`]) and the eight cells around it.
|
||||
fn lp_cells_around(p: Point) -> impl Iterator<Item = (i64, i64)> {
|
||||
let (x, y) = lp_cell(p);
|
||||
(-1..=1).flat_map(move |dx: i64| (-1..=1).map(move |dy: i64| (x.saturating_add(dx), y.saturating_add(dy))))
|
||||
}
|
||||
|
||||
/// The stroke paint Live Paint edge `edge` draws with: the paint of its topmost stroke that is shown
|
||||
/// with a paint, a weight above 0 and an opacity above 0, or [`Paint::None`] when the edge is fully
|
||||
/// transparent or has no such stroke.
|
||||
pub(super) fn lp_stroke_paint(edge: &Node) -> Paint {
|
||||
if edge.opacity <= 0.0 {
|
||||
return Paint::None;
|
||||
}
|
||||
edge.appearance
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|i| match i {
|
||||
vectorcraft_doc::appearance::AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => {
|
||||
Some(s.paint.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(Paint::None)
|
||||
}
|
||||
|
||||
/// Whether Live Paint edge `edge` draws no stroke ([`lp_stroke_paint`] is [`Paint::None`]).
|
||||
fn unstroked(edge: &Node) -> bool {
|
||||
lp_stroke_paint(edge).is_none()
|
||||
}
|
||||
|
||||
/// The segments of `pd`.
|
||||
fn lp_segments(pd: &PathData) -> Vec<kurbo::CubicBez> {
|
||||
pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect()
|
||||
}
|
||||
|
||||
/// The faces of Live Paint group `group` that a flood from face `start` reaches, `start` first:
|
||||
/// `start`, and each face that shares with a face already reached an edge that is hidden or draws
|
||||
/// no stroke. Hidden faces are not reached and do not pass the flood on. A face runs along the parts
|
||||
/// of the edge segments its outline segments lie on ([`lp_lies_on`]) or overlap ([`lp_overlap`]).
|
||||
/// Two faces share an edge where those parts overlap along more than [`LP_FACE_EDGE_TOL`], or, where
|
||||
/// both outlines lie within [`LP_JOIN_TOL`] of the edge, along more than half the shorter part. So
|
||||
/// two faces that a Gap Detection gap parts on the same side of an edge, whose parts only touch, do
|
||||
/// not share it, and two faces whose shared edge is shorter than [`LP_FACE_EDGE_TOL`] do. Empty when
|
||||
/// `start` is not a face of `group` that is not hidden.
|
||||
pub fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let shown: Vec<&Arc<Node>> = faces(group).into_iter().filter(|f| f.visible).collect();
|
||||
if !shown.iter().any(|f| f.id == start) {
|
||||
return vec![];
|
||||
}
|
||||
// Each edge segment, and whether a flood crosses it.
|
||||
let edge_segs: Vec<(kurbo::CubicBez, bool)> = edges(group)
|
||||
.into_iter()
|
||||
.flat_map(|e| {
|
||||
let cross = !e.visible || unstroked(e);
|
||||
e.path_data().map(lp_segments).unwrap_or_default().into_iter().map(move |s| (s, cross))
|
||||
})
|
||||
.collect();
|
||||
// The faces along each edge segment a flood crosses, each with the part of the segment it runs
|
||||
// along (from and to, in the segment's own parameter). A face outline segment usually lies on
|
||||
// one edge segment, stroked or not ([`lp_lies_on`]); one that does not (a piece of an edge
|
||||
// segment where a Gap Detection line meets it, the line itself, or an outline segment that spans
|
||||
// several edge segments) is measured against the edge segments a flood crosses that its bounds
|
||||
// meet ([`lp_overlap`]). Each part also records whether its outline segment lies within
|
||||
// [`LP_JOIN_TOL`] of it ([`lp_close`]).
|
||||
let all: Vec<(kurbo::CubicBez, bool)> = edge_segs.iter().map(|&(s, _)| (s, true)).collect();
|
||||
let all_grid = LpEdgeGrid::new(&all);
|
||||
let mut along: HashMap<usize, Vec<(NodeId, f64, f64, bool)>> = HashMap::new();
|
||||
let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![];
|
||||
for f in &shown {
|
||||
for piece in f.path_data().map(lp_segments).unwrap_or_default() {
|
||||
let m = piece.eval(0.5);
|
||||
let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL);
|
||||
// The edge segment the outline segment lies on whose middle is nearest its own.
|
||||
let on = all_grid
|
||||
.near_box(bounds)
|
||||
.into_iter()
|
||||
.filter_map(|k| edge_segs.get(k).and_then(|(s, cross)| lp_lies_on(s, &piece).map(|part| (k, s, *cross, part))))
|
||||
.min_by(|x, y| (x.1.eval(0.5) - m).hypot().total_cmp(&(y.1.eval(0.5) - m).hypot()));
|
||||
match on {
|
||||
Some((k, s, cross, (from, to))) => {
|
||||
if cross {
|
||||
along.entry(k).or_default().push((f.id, from, to, lp_close(s, from, to, &piece)));
|
||||
}
|
||||
}
|
||||
None => pieces.push((piece, f.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !pieces.is_empty() {
|
||||
let grid = LpEdgeGrid::new(&edge_segs);
|
||||
for (piece, id) in pieces {
|
||||
let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL);
|
||||
for k in grid.near_box(bounds) {
|
||||
if let Some((s, true)) = edge_segs.get(k)
|
||||
&& let Some((a, c)) = lp_overlap(s, &piece)
|
||||
{
|
||||
along.entry(k).or_default().push((id, a, c, lp_close(s, a, c, &piece)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
|
||||
// Sorted by where each part starts, a part is compared only with the parts after it that start
|
||||
// before it ends. Two parts that overlap along [`LP_FACE_EDGE_TOL`] or less join only when both
|
||||
// lie within [`LP_JOIN_TOL`] of the edge and the overlap is more than half the shorter part: an
|
||||
// edge that short is shared, while an outline that only crosses it lies off it.
|
||||
for (k, on) in &mut along {
|
||||
let Some((s, _)) = edge_segs.get(*k) else { continue };
|
||||
on.sort_by(|x, y| x.1.total_cmp(&y.1));
|
||||
for (i, &(a, a0, a1, a_close)) in on.iter().enumerate() {
|
||||
for &(c, c0, c1, c_close) in on.iter().skip(i + 1).take_while(|x| x.1 < a1) {
|
||||
let (from, to) = (a0.max(c0), a1.min(c1));
|
||||
let len = |x: f64, y: f64| s.subsegment(x..y).arclen(1e-6);
|
||||
let shared = if a != c && from < to { len(from, to) } else { 0.0 };
|
||||
let short_shared = a_close && c_close && shared > 0.5 * len(a0, a1).min(len(c0, c1));
|
||||
if shared > 0.0 && (shared > LP_FACE_EDGE_TOL || short_shared) {
|
||||
next.entry(a).or_default().push(c);
|
||||
next.entry(c).or_default().push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut reached = vec![start];
|
||||
let mut seen = HashSet::from([start]);
|
||||
let mut i = 0;
|
||||
while let Some(&f) = reached.get(i) {
|
||||
i += 1;
|
||||
for &n in next.get(&f).map(Vec::as_slice).unwrap_or_default() {
|
||||
if seen.insert(n) {
|
||||
reached.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
reached
|
||||
}
|
||||
|
||||
/// The part of edge segment `s` that outline segment `piece` runs along, as (from, to) in `s`'s
|
||||
/// own parameter, or None when they share no length. The part runs between the ends of `s` that lie
|
||||
/// within [`LP_FACE_EDGE_TOL`] of `piece` and the ends of `piece` that lie within [`LP_JOIN_TOL`] of
|
||||
/// `s` (the map keeps the ends of an outline segment it refits), and its middle lies within
|
||||
/// [`LP_FACE_EDGE_TOL`] of `piece`, where the two run along each other ([`lp_runs_along`]). When no
|
||||
/// end of `piece` lies on `s`, `piece` must lie within [`LP_JOIN_TOL`] of the part ([`lp_close`]). So
|
||||
/// two segments that only cross or touch, or that only lie near each other, share nothing.
|
||||
fn lp_overlap(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
let on_piece = |p: Point| piece.nearest(p, 1e-9).distance_sq.sqrt() <= LP_FACE_EDGE_TOL;
|
||||
let mut ts: Vec<f64> = [(s.p0, 0.0), (s.p3, 1.0)].into_iter().filter(|(p, _)| on_piece(*p)).map(|(_, t)| t).collect();
|
||||
let mut piece_end_on = false;
|
||||
for p in [piece.p0, piece.p3] {
|
||||
let n = s.nearest(p, 1e-9);
|
||||
if n.distance_sq.sqrt() <= LP_JOIN_TOL {
|
||||
ts.push(n.t);
|
||||
piece_end_on = true;
|
||||
}
|
||||
}
|
||||
let from = ts.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let to = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
if from >= to {
|
||||
return None;
|
||||
}
|
||||
let mid = (from + to) / 2.0;
|
||||
let n = piece.nearest(s.eval(mid), 1e-9);
|
||||
let along = if piece_end_on { lp_runs_along(s, from, to, piece, mid, n.t) } else { lp_close(s, from, to, piece) };
|
||||
(n.distance_sq.sqrt() <= LP_FACE_EDGE_TOL && along).then_some((from, to))
|
||||
}
|
||||
|
||||
/// The part of edge segment `s` that outline segment `piece` lies on as a whole, as (from, to) in
|
||||
/// `s`'s own parameter: both ends of `piece` lie within [`LP_JOIN_TOL`] of `s`, its middle lies
|
||||
/// within [`LP_FACE_EDGE_TOL`] of `s`, and the two run along each other ([`lp_runs_along`]).
|
||||
fn lp_lies_on(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
let (a, c, m) = (s.nearest(piece.p0, 1e-9), s.nearest(piece.p3, 1e-9), s.nearest(piece.eval(0.5), 1e-9));
|
||||
let (from, to) = (a.t.min(c.t), a.t.max(c.t));
|
||||
let near = a.distance_sq.sqrt() <= LP_JOIN_TOL && c.distance_sq.sqrt() <= LP_JOIN_TOL && m.distance_sq.sqrt() <= LP_FACE_EDGE_TOL;
|
||||
(near && lp_runs_along(s, from, to, piece, m.t, 0.5)).then_some((from, to))
|
||||
}
|
||||
|
||||
/// Whether outline segment `piece` runs along the part of edge segment `s` from `from` to `to`
|
||||
/// rather than crossing it: it lies within [`LP_JOIN_TOL`] of that part ([`lp_close`]), or the two
|
||||
/// run the same way or opposite ways at `ts` on `s` and `tp` on `piece` ([`lp_parallel`]).
|
||||
fn lp_runs_along(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez, ts: f64, tp: f64) -> bool {
|
||||
lp_close(s, from, to, piece) || lp_parallel(s, ts, piece, tp)
|
||||
}
|
||||
|
||||
/// Whether outline segment `piece` lies within [`LP_JOIN_TOL`] of edge segment `s` at the ends and
|
||||
/// the middle of the part of `s` from `from` to `to`.
|
||||
fn lp_close(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez) -> bool {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
[from, (from + to) / 2.0, to].into_iter().all(|t| piece.nearest(s.eval(t), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL)
|
||||
}
|
||||
|
||||
/// Whether segment `s` at parameter `ts` and segment `piece` at parameter `tp` run the same way or
|
||||
/// opposite ways: the sine of the angle between their directions there is at most 0.2. False where
|
||||
/// either has no direction.
|
||||
fn lp_parallel(s: &kurbo::CubicBez, ts: f64, piece: &kurbo::CubicBez, tp: f64) -> bool {
|
||||
use kurbo::{ParamCurve, ParamCurveDeriv};
|
||||
let (u, v) = (s.deriv().eval(ts).to_vec2(), piece.deriv().eval(tp).to_vec2());
|
||||
let n = u.hypot() * v.hypot();
|
||||
n > 0.0 && u.cross(v).abs() <= 0.2 * n
|
||||
}
|
||||
|
||||
/// Most grid cells one edge segment is listed in by [`LpEdgeGrid`]; a longer one is tried for
|
||||
/// every point.
|
||||
const LP_GRID_SPAN: i64 = 1024;
|
||||
|
||||
/// The farthest cell from the grid's origin, on each axis, that [`LpEdgeGrid`] numbers a point in.
|
||||
const LP_GRID_LIMIT: f64 = 1_048_576.0;
|
||||
|
||||
/// The edge segments a flood crosses, by the cells of a square grid (about one cell per segment)
|
||||
/// that their bounds, widened by [`LP_FACE_EDGE_TOL`], cover.
|
||||
struct LpEdgeGrid {
|
||||
origin: Point,
|
||||
cell: f64,
|
||||
/// Segment indices by cell.
|
||||
cells: std::collections::HashMap<(i64, i64), Vec<usize>>,
|
||||
/// Segments whose bounds cover more than [`LP_GRID_SPAN`] cells, near every point.
|
||||
wide: Vec<usize>,
|
||||
}
|
||||
|
||||
impl LpEdgeGrid {
|
||||
/// The grid of the segments of `edges` that a flood crosses (those marked true).
|
||||
fn new(edges: &[(kurbo::CubicBez, bool)]) -> Self {
|
||||
let boxes: Vec<(usize, Rect)> = edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| e.1)
|
||||
.map(|(k, (s, _))| (k, kurbo::ParamCurveExtrema::bounding_box(s).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL)))
|
||||
.collect();
|
||||
let all = boxes.iter().map(|b| b.1).reduce(|a, c| a.union(c)).unwrap_or_default();
|
||||
let side = (boxes.len() as f64).sqrt().ceil().max(1.0);
|
||||
let cell = all.width().max(all.height()) / side;
|
||||
let cell = if cell.is_finite() && cell > LP_FACE_EDGE_TOL { cell } else { LP_FACE_EDGE_TOL };
|
||||
let mut grid = Self { origin: all.origin(), cell, cells: std::collections::HashMap::new(), wide: vec![] };
|
||||
for (k, b) in boxes {
|
||||
let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1)));
|
||||
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN {
|
||||
grid.wide.push(k);
|
||||
continue;
|
||||
}
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
grid.cells.entry((x, y)).or_default().push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
grid
|
||||
}
|
||||
|
||||
/// The cell `p` is in, counted from the origin (at most [`LP_GRID_LIMIT`] cells away on each
|
||||
/// axis).
|
||||
fn cell_of(&self, p: Point) -> (i64, i64) {
|
||||
let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-LP_GRID_LIMIT, LP_GRID_LIMIT) as i64;
|
||||
(at(p.x, self.origin.x), at(p.y, self.origin.y))
|
||||
}
|
||||
|
||||
/// The segments whose widened bounds may meet `r`, each once, in ascending order. When `r`
|
||||
/// covers more than [`LP_GRID_SPAN`] cells, every listed segment is returned.
|
||||
fn near_box(&self, r: Rect) -> Vec<usize> {
|
||||
let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1)));
|
||||
let mut out: Vec<usize> = self.wide.clone();
|
||||
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN {
|
||||
out.extend(self.cells.values().flatten());
|
||||
} else {
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
out.extend(self.cells.get(&(x, y)).into_iter().flatten());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges of Live Paint group `group` that are not hidden and are joined to edge `start` through
|
||||
/// edges that draw with stroke paint `paint` ([`lp_stroke_paint`]), `start` first: `start`, and each
|
||||
/// edge that draws with that stroke paint and has an end (the first or last point of one of its subpaths) within [`LP_JOIN_TOL`] of an
|
||||
/// end of an edge already reached.
|
||||
/// Empty when `start` is not an edge of `group` that is not hidden.
|
||||
pub fn connected_edges(group: &Node, start: NodeId, paint: &Paint) -> Vec<NodeId> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let es: Vec<&Arc<Node>> = edges(group).into_iter().filter(|e| e.visible && (e.id == start || lp_stroke_paint(e) == *paint)).collect();
|
||||
// The first and last points of each subpath of each edge.
|
||||
let ends: Vec<Vec<Point>> = es
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let Some(pd) = e.path_data() else { return vec![] };
|
||||
pd.subpaths
|
||||
.iter()
|
||||
.filter_map(|sp| {
|
||||
let n = sp.segment_count();
|
||||
(n > 0).then(|| [sp.segment(0).p0, sp.segment(n - 1).p3])
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let mut at: HashMap<(i64, i64), Vec<(Point, usize)>> = HashMap::new();
|
||||
for (i, pts) in ends.iter().enumerate() {
|
||||
for &p in pts {
|
||||
at.entry(lp_cell(p)).or_default().push((p, i));
|
||||
}
|
||||
}
|
||||
let Some(first) = es.iter().position(|e| e.id == start) else { return vec![] };
|
||||
let mut reached = vec![first];
|
||||
let mut seen = HashSet::from([first]);
|
||||
let mut k = 0;
|
||||
while let Some(&i) = reached.get(k) {
|
||||
k += 1;
|
||||
for &p in ends.get(i).map(Vec::as_slice).unwrap_or_default() {
|
||||
for c in lp_cells_around(p) {
|
||||
for &(q, j) in at.get(&c).map(Vec::as_slice).unwrap_or_default() {
|
||||
if (q - p).hypot() <= LP_JOIN_TOL && seen.insert(j) {
|
||||
reached.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reached.into_iter().filter_map(|i| es.get(i).map(|e| e.id)).collect()
|
||||
}
|
||||
|
||||
/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order).
|
||||
pub fn live_paint_at(doc: &Document, p: Point) -> Option<NodeId> {
|
||||
live_paint_at_where(doc, p, |id| doc.is_visible(id))
|
||||
|
||||
@@ -1733,6 +1733,583 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights()
|
||||
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
||||
}
|
||||
|
||||
/// A Live Paint group of three faces in a row, A (0..50), B (50..100) and C (100..150), 50 pt tall,
|
||||
/// with these edges: AB between A and B, with no stroke; BC between B and C, stroked red; the tops
|
||||
/// of A and B stroked black; the top of C stroked red; and one bottom edge under all three,
|
||||
/// stroked black. Returns the group, the faces [A, B, C] and the edges [AB, BC, top A, top B,
|
||||
/// top C, bottom].
|
||||
fn live_strip(d: &mut Document) -> (NodeId, [NodeId; 3], [NodeId; 6]) {
|
||||
let l = d.layers[0].id;
|
||||
let black = Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
let mut kids = vec![empty_sources(d)];
|
||||
let mut fs = [NodeId(0); 3];
|
||||
for (k, x) in [0.0, 50.0, 100.0].into_iter().enumerate() {
|
||||
fs[k] = d.alloc_id();
|
||||
let mut face = Node::path(fs[k], shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default());
|
||||
face.name = Some(FACE_NAME.into());
|
||||
kids.push(Arc::new(face));
|
||||
}
|
||||
let pt = Point::new;
|
||||
let lines = [
|
||||
(pt(50.0, 0.0), pt(50.0, 50.0), None),
|
||||
(pt(100.0, 0.0), pt(100.0, 50.0), Some(red.clone())),
|
||||
(pt(0.0, 0.0), pt(50.0, 0.0), Some(black.clone())),
|
||||
(pt(50.0, 0.0), pt(100.0, 0.0), Some(black.clone())),
|
||||
(pt(100.0, 0.0), pt(150.0, 0.0), Some(red)),
|
||||
(pt(0.0, 50.0), pt(150.0, 50.0), Some(black)),
|
||||
];
|
||||
let mut es = [NodeId(0); 6];
|
||||
for (k, (from, to, stroke)) in lines.into_iter().enumerate() {
|
||||
es[k] = d.alloc_id();
|
||||
let mut a = Appearance::default();
|
||||
if let Some(s) = stroke {
|
||||
a.set_stroke(s);
|
||||
}
|
||||
let mut edge = Node::path(es[k], shapes::line(from, to), a);
|
||||
edge.name = Some(EDGE_NAME.into());
|
||||
kids.push(Arc::new(edge));
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, kids);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
let at = d.layers[0].children().map_or(0, |c| c.len());
|
||||
d.insert(Some(l), at, g).unwrap();
|
||||
(gid, fs, es)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_flood_crosses_unstroked_edges_and_stops_at_stroked_ones() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let (g, [a, b, c], [ab, bc, ..]) = live_strip(&mut d);
|
||||
let group = |d: &Document| d.node(g).unwrap().clone();
|
||||
assert_eq!(flood_faces(&group(&d), a), vec![a, b]);
|
||||
assert_eq!(flood_faces(&group(&d), b), vec![b, a]);
|
||||
assert_eq!(flood_faces(&group(&d), c), vec![c]);
|
||||
// A hidden edge does not part the faces either; a hidden face is not reached and does not start
|
||||
// a flood; an id that is not a face of the group reaches nothing.
|
||||
let mut hidden = d.clone();
|
||||
hidden.node_mut(bc).unwrap().visible = false;
|
||||
assert_eq!(flood_faces(&group(&hidden), a), vec![a, b, c]);
|
||||
hidden.node_mut(b).unwrap().visible = false;
|
||||
assert_eq!(flood_faces(&group(&hidden), a), vec![a]);
|
||||
assert!(flood_faces(&group(&hidden), b).is_empty());
|
||||
assert!(flood_faces(&group(&d), ab).is_empty());
|
||||
// An edge whose stroke is hidden, 0 pt wide or fully transparent, or that is itself fully
|
||||
// transparent, draws no stroke, so the flood crosses it.
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
|
||||
let base = d.node(bc).unwrap().appearance.stroke().unwrap().clone();
|
||||
let changes: [fn(&mut StrokeLayer); 3] = [|s| s.visible = false, |s| s.width = 0.0, |s| s.opacity = 0.0];
|
||||
for change in changes {
|
||||
let mut e = d.clone();
|
||||
let mut s = base.clone();
|
||||
change(&mut s);
|
||||
e.node_mut(bc).unwrap().appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
|
||||
e.node_mut(bc).unwrap().appearance.items.push(AppearanceItem::Stroke(s));
|
||||
assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]);
|
||||
}
|
||||
let mut e = d.clone();
|
||||
e.node_mut(bc).unwrap().opacity = 0.0;
|
||||
assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]);
|
||||
}
|
||||
|
||||
/// Faces P (0..50) and Q (50..100) above one edge from (0, 0) to (100, 0) and face R (0..100) below
|
||||
/// it, with no edge between P and Q (as where a Gap Detection line parts them): P and Q each run
|
||||
/// along part of the edge, and R along all of it.
|
||||
#[test]
|
||||
fn live_paint_flood_measures_faces_along_part_of_an_edge() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut kids = vec![empty_sources(&mut d)];
|
||||
let mut ids = [NodeId(0); 3];
|
||||
for (k, r) in [Rect::new(0.0, -50.0, 50.0, 0.0), Rect::new(50.0, -50.0, 100.0, 0.0), Rect::new(0.0, 0.0, 100.0, 50.0)].into_iter().enumerate() {
|
||||
ids[k] = d.alloc_id();
|
||||
let mut face = Node::path(ids[k], shapes::rectangle(r), Appearance::default());
|
||||
face.name = Some(FACE_NAME.into());
|
||||
kids.push(Arc::new(face));
|
||||
}
|
||||
let [p, q, r] = ids;
|
||||
let mut edge = Node::path(d.alloc_id(), shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), Appearance::default());
|
||||
edge.name = Some(EDGE_NAME.into());
|
||||
kids.push(Arc::new(edge));
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, kids);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let group = d.node(gid).unwrap().clone();
|
||||
// P reaches R across the edge, and Q through R; P and Q only touch at (50, 0).
|
||||
assert_eq!(flood_faces(&group, p), vec![p, r, q]);
|
||||
let mut without_r = d.clone();
|
||||
without_r.node_mut(r).unwrap().visible = false;
|
||||
assert_eq!(flood_faces(without_r.node(gid).unwrap(), p), vec![p]);
|
||||
}
|
||||
|
||||
/// Face P (0..100) above unstroked edges along y = 0 split at `splits`, whose outline runs along
|
||||
/// all of them with one segment, and below them one face between each two splits, the faces parted
|
||||
/// from each other by stroked edges. A flood from any face below reaches P and, through it, the
|
||||
/// others; this holds when the outline's middle is no edge's middle (30), is the end shared by two
|
||||
/// edges (50), and is the middle of an edge shorter than the outline (25 and 75).
|
||||
#[test]
|
||||
fn live_paint_flood_crosses_an_outline_segment_that_spans_several_edges() {
|
||||
for splits in [vec![30.0], vec![50.0], vec![25.0, 75.0]] {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut kids = vec![empty_sources(&mut d)];
|
||||
let xs: Vec<f64> = std::iter::once(0.0).chain(splits.iter().copied()).chain(std::iter::once(100.0)).collect();
|
||||
let add_face = |d: &mut Document, kids: &mut Vec<Arc<Node>>, r: Rect| {
|
||||
let id = d.alloc_id();
|
||||
let mut face = Node::path(id, shapes::rectangle(r), Appearance::default());
|
||||
face.name = Some(FACE_NAME.into());
|
||||
kids.push(Arc::new(face));
|
||||
id
|
||||
};
|
||||
let p = add_face(&mut d, &mut kids, Rect::new(0.0, -50.0, 100.0, 0.0));
|
||||
let below: Vec<NodeId> = xs.windows(2).map(|w| add_face(&mut d, &mut kids, Rect::new(w[0], 0.0, w[1], 50.0))).collect();
|
||||
let pt = Point::new;
|
||||
let lines = xs
|
||||
.windows(2)
|
||||
.map(|w| (pt(w[0], 0.0), pt(w[1], 0.0), false))
|
||||
.chain(splits.iter().map(|&x| (pt(x, 0.0), pt(x, 50.0), true)))
|
||||
.collect::<Vec<_>>();
|
||||
for (from, to, stroked) in lines {
|
||||
let mut a = Appearance::default();
|
||||
if stroked {
|
||||
a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
}
|
||||
let mut edge = Node::path(d.alloc_id(), shapes::line(from, to), a);
|
||||
edge.name = Some(EDGE_NAME.into());
|
||||
kids.push(Arc::new(edge));
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, kids);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let group = d.node(gid).unwrap().clone();
|
||||
let mut all = below.clone();
|
||||
all.push(p);
|
||||
all.sort();
|
||||
for &f in &below {
|
||||
let mut got = flood_faces(&group, f);
|
||||
assert_eq!(got.first(), Some(&f), "{splits:?}");
|
||||
got.sort();
|
||||
assert_eq!(got, all, "{splits:?} from {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Live Paint group built from `paths` as `livePaint.make` builds one, with Gap Detection closing
|
||||
/// gaps up to `gap` pt when given, and with no stroke on any edge. Returns the group and its faces.
|
||||
fn live_from_paths(paths: Vec<PathData>, gap: Option<f64>) -> (Node, Vec<NodeId>) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let shapes: Vec<po::Shape> = paths.into_iter().enumerate().map(|(k, p)| po::Shape::new(p, FillRule::NonZero, k as u64)).collect();
|
||||
let (regions, lines) = match gap {
|
||||
Some(g) => {
|
||||
let (r, e, _) = po::live_paint_with_gaps(&shapes, g);
|
||||
(r, e)
|
||||
}
|
||||
None => po::live_paint(&shapes),
|
||||
};
|
||||
let mut kids = vec![empty_sources(&mut d)];
|
||||
let mut ids = vec![];
|
||||
for r in regions {
|
||||
let id = d.alloc_id();
|
||||
let mut face = Node::path(id, r.path, Appearance::default());
|
||||
face.name = Some(FACE_NAME.into());
|
||||
kids.push(Arc::new(face));
|
||||
ids.push(id);
|
||||
}
|
||||
for e in lines {
|
||||
let mut edge = Node::path(d.alloc_id(), e.path, Appearance::default());
|
||||
edge.name = Some(EDGE_NAME.into());
|
||||
kids.push(Arc::new(edge));
|
||||
}
|
||||
let mut g = Node::group(d.alloc_id(), kids);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
(g, ids)
|
||||
}
|
||||
|
||||
/// Live Paint groups built from curves, one with a T-junction where lines end inside an ellipse and
|
||||
/// one with Gap Detection closing the gap between a curve's end and a circle: with no stroke on any
|
||||
/// edge, a flood from any face reaches every face.
|
||||
#[test]
|
||||
fn live_paint_flood_crosses_unstroked_curves_in_built_groups() {
|
||||
let pt = Point::new;
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0));
|
||||
let t_junction = vec![
|
||||
frame,
|
||||
shapes::ellipse(Rect::new(50.0, 60.0, 150.0, 140.0)),
|
||||
shapes::line(pt(113.0, 0.0), pt(113.0, 80.0)),
|
||||
shapes::line(pt(113.0, 200.0), pt(113.0, 120.0)),
|
||||
];
|
||||
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Smooth };
|
||||
let (start, end) = (pt(102.7, 20.0), pt(109.0, 100.0 + (2500.0f64 - 81.0).sqrt() - 2.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![a(start, start, start + kurbo::Vec2::new(40.0, 40.0)), a(end, end + kurbo::Vec2::new(-30.0, -40.0), end)],
|
||||
false,
|
||||
));
|
||||
let gap = vec![shapes::ellipse(Rect::new(50.0, 50.0, 150.0, 150.0)), curve];
|
||||
for (name, paths, max_gap, count) in [("T-junction", t_junction, None, 3), ("Gap Detection", gap, Some(3.0), 2)] {
|
||||
let (g, fs) = live_from_paths(paths, max_gap);
|
||||
assert_eq!(fs.len(), count, "{name}");
|
||||
for &f in &fs {
|
||||
let mut got = flood_faces(&g, f);
|
||||
got.sort();
|
||||
assert_eq!(got, fs, "{name} from {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed outline: `curve`, then straight lines through `rest` and back to the curve's start.
|
||||
fn curve_then_lines(curve: kurbo::CubicBez, rest: &[Point]) -> PathData {
|
||||
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner };
|
||||
let mut anchors = vec![a(curve.p0, curve.p0, curve.p1), a(curve.p3, curve.p2, curve.p3)];
|
||||
anchors.extend(rest.iter().map(|&p| a(p, p, p)));
|
||||
PathData::single(SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// `c` with its two handles moved 0.006 pt along y: its ends stay put and its middle moves 0.0045 pt,
|
||||
/// as a face outline refit within the planar map's precision can lie off its edge.
|
||||
fn refit(c: kurbo::CubicBez) -> kurbo::CubicBez {
|
||||
let up = kurbo::Vec2::new(0.0, 0.006);
|
||||
kurbo::CubicBez::new(c.p0, c.p1 + up, c.p2 + up, c.p3)
|
||||
}
|
||||
|
||||
/// A Live Paint group of `faces` and of `lines`, each line with or without a stroke. Returns the
|
||||
/// group and the ids of the faces.
|
||||
fn live_of(faces: Vec<PathData>, lines: Vec<(PathData, bool)>) -> (Node, Vec<NodeId>) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let mut kids = vec![empty_sources(&mut d)];
|
||||
let mut ids = vec![];
|
||||
for pd in faces {
|
||||
let id = d.alloc_id();
|
||||
let mut face = Node::path(id, pd, Appearance::default());
|
||||
face.name = Some(FACE_NAME.into());
|
||||
kids.push(Arc::new(face));
|
||||
ids.push(id);
|
||||
}
|
||||
for (pd, stroked) in lines {
|
||||
let mut a = Appearance::default();
|
||||
if stroked {
|
||||
a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
}
|
||||
let mut edge = Node::path(d.alloc_id(), pd, a);
|
||||
edge.name = Some(EDGE_NAME.into());
|
||||
kids.push(Arc::new(edge));
|
||||
}
|
||||
let mut g = Node::group(d.alloc_id(), kids);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
(g, ids)
|
||||
}
|
||||
|
||||
/// Faces whose outlines along an unstroked curved edge lie up to 0.0045 pt off it, as the planar map
|
||||
/// can leave them when it refits an outline across a T-junction or, with Gap Detection on, builds the
|
||||
/// faces and the edges from two maps: the flood still crosses the edge. Two faces that a gap parts
|
||||
/// on the same side of the edge, which only touch, are still not joined.
|
||||
#[test]
|
||||
fn live_paint_flood_crosses_curved_edges_that_outlines_lie_slightly_off() {
|
||||
use kurbo::ParamCurve;
|
||||
let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(30.0, 40.0), Point::new(70.0, 40.0), Point::new(100.0, 0.0));
|
||||
let edge = |s: kurbo::CubicBez| {
|
||||
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner };
|
||||
PathData::single(SubPath::new(vec![a(s.p0, s.p0, s.p1), a(s.p3, s.p2, s.p3)], false))
|
||||
};
|
||||
// T-junction: a stroked line ends on the curve at t = 0.4 and parts the faces below it (L and R);
|
||||
// the curve is two edges, and the face above (U) runs along both with one refit segment.
|
||||
let j = c.eval(0.4);
|
||||
let (left, right) = (c.subsegment(0.0..0.4), c.subsegment(0.4..1.0));
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
curve_then_lines(left, &[Point::new(j.x, 80.0), Point::new(0.0, 80.0)]),
|
||||
curve_then_lines(right, &[Point::new(100.0, 80.0), Point::new(j.x, 80.0)]),
|
||||
curve_then_lines(refit(c), &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]),
|
||||
],
|
||||
vec![(edge(left), false), (edge(right), false), (shapes::line(j, Point::new(j.x, 80.0)), true)],
|
||||
);
|
||||
let [l, r, u] = [ids[0], ids[1], ids[2]];
|
||||
assert_eq!(flood_faces(&g, l), vec![l, u, r]);
|
||||
let mut from_u = flood_faces(&g, u);
|
||||
assert_eq!(from_u.first(), Some(&u));
|
||||
from_u.sort();
|
||||
assert_eq!(from_u, ids);
|
||||
// Gap Detection: a gap line, which is no edge, parts P and Q above the curve at t = 0.5; the curve
|
||||
// is one edge, and Q and the face below (B) run along it with refit segments.
|
||||
let m = c.eval(0.5);
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
curve_then_lines(c.subsegment(0.0..0.5), &[Point::new(m.x, -50.0), Point::new(0.0, -50.0)]),
|
||||
curve_then_lines(refit(c.subsegment(0.5..1.0)), &[Point::new(100.0, -50.0), Point::new(m.x, -50.0)]),
|
||||
curve_then_lines(refit(c), &[Point::new(100.0, 80.0), Point::new(0.0, 80.0)]),
|
||||
],
|
||||
vec![(edge(c), false)],
|
||||
);
|
||||
let [p, q, b] = [ids[0], ids[1], ids[2]];
|
||||
assert_eq!(flood_faces(&g, p), vec![p, b, q]);
|
||||
let mut without_b = g.clone();
|
||||
if let Some(kids) = without_b.children_mut()
|
||||
&& let Some(n) = kids.iter_mut().find(|n| n.id == b)
|
||||
{
|
||||
Arc::make_mut(n).visible = false;
|
||||
}
|
||||
assert_eq!(flood_faces(&without_b, p), vec![p]);
|
||||
}
|
||||
|
||||
/// Two large faces whose only shared edge, with no stroke, is 0.005 pt long: the flood crosses it.
|
||||
#[test]
|
||||
fn live_paint_flood_crosses_a_shared_edge_shorter_than_the_precision() {
|
||||
let pt = Point::new;
|
||||
let (a, b) = (pt(50.0, 0.0), pt(50.0, 0.005));
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
PathData::single(SubPath::polyline(&[pt(0.0, -50.0), a, b, pt(0.0, 50.0)], true)),
|
||||
PathData::single(SubPath::polyline(&[pt(100.0, -50.0), pt(100.0, 50.0), b, a], true)),
|
||||
],
|
||||
vec![(shapes::line(a, b), false)],
|
||||
);
|
||||
assert_eq!(flood_faces(&g, ids[0]), ids);
|
||||
}
|
||||
|
||||
/// A stroked edge along y = 0 and an edge with no stroke 0.008 pt below it, with a face above the
|
||||
/// first whose outline ends on it and bows 0.005 pt off it in the middle, nearer the second, and a
|
||||
/// face below the second: the stroked edge parts the two faces, so the flood does not join them.
|
||||
#[test]
|
||||
fn live_paint_flood_stops_where_a_stroked_edge_runs_along_an_unstroked_one() {
|
||||
let pt = Point::new;
|
||||
let bow = kurbo::CubicBez::new(pt(100.0, 0.0), pt(70.0, 0.02 / 3.0), pt(30.0, 0.02 / 3.0), pt(0.0, 0.0));
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
curve_then_lines(bow, &[pt(0.0, -50.0), pt(100.0, -50.0)]),
|
||||
PathData::single(SubPath::polyline(&[pt(0.0, 0.008), pt(0.0, 50.0), pt(100.0, 50.0), pt(100.0, 0.008)], true)),
|
||||
],
|
||||
vec![(shapes::line(pt(0.0, 0.0), pt(100.0, 0.0)), true), (shapes::line(pt(0.0, 0.008), pt(100.0, 0.008)), false)],
|
||||
);
|
||||
assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]);
|
||||
assert_eq!(flood_faces(&g, ids[1]), vec![ids[1]]);
|
||||
}
|
||||
|
||||
/// An edge with no stroke from (10, 0) to (90, 0), and two faces that meet on a line 0.005 pt below
|
||||
/// it from (0, 0.005) to (100, 0.005) (as a Gap Detection line): neither face runs along the edge, so
|
||||
/// the flood does not join them.
|
||||
#[test]
|
||||
fn live_paint_flood_does_not_cross_an_edge_beside_a_line_two_faces_meet_on() {
|
||||
let pt = Point::new;
|
||||
let (a, b) = (pt(0.0, 0.005), pt(100.0, 0.005));
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
PathData::single(SubPath::polyline(&[a, b, pt(100.0, -50.0), pt(0.0, -50.0)], true)),
|
||||
PathData::single(SubPath::polyline(&[b, a, pt(0.0, 50.0), pt(100.0, 50.0)], true)),
|
||||
],
|
||||
vec![(shapes::line(pt(10.0, 0.0), pt(90.0, 0.0)), false)],
|
||||
);
|
||||
assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]);
|
||||
}
|
||||
|
||||
/// A stroked edge and an edge with no stroke that leave one point at a slope of 0.01, so they lie
|
||||
/// within 0.01 pt of each other for their first point or so: the face between them and the face
|
||||
/// beyond the edge with no stroke share that edge, so the flood joins them.
|
||||
#[test]
|
||||
fn live_paint_flood_crosses_an_unstroked_edge_beside_a_stroked_one() {
|
||||
let pt = Point::new;
|
||||
let (o, a, b) = (pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 1.0));
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
PathData::single(SubPath::polyline(&[o, a, b], true)),
|
||||
PathData::single(SubPath::polyline(&[o, b, pt(100.0, 50.0), pt(0.0, 50.0)], true)),
|
||||
],
|
||||
vec![(shapes::line(o, a), true), (shapes::line(o, b), false), (shapes::line(a, b), true)],
|
||||
);
|
||||
assert_eq!(flood_faces(&g, ids[0]), ids);
|
||||
}
|
||||
|
||||
/// Two faces on either side of an edge with no stroke whose curve has a cusp at its middle (no
|
||||
/// direction there): the flood crosses it.
|
||||
#[test]
|
||||
fn live_paint_flood_crosses_an_edge_with_a_cusp_at_its_middle() {
|
||||
let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0), Point::new(100.0, 0.0));
|
||||
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner };
|
||||
let edge = PathData::single(SubPath::new(vec![a(c.p0, c.p0, c.p1), a(c.p3, c.p2, c.p3)], false));
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
curve_then_lines(c, &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]),
|
||||
curve_then_lines(c, &[Point::new(100.0, 100.0), Point::new(0.0, 100.0)]),
|
||||
],
|
||||
vec![(edge, false)],
|
||||
);
|
||||
assert_eq!(flood_faces(&g, ids[0]), ids);
|
||||
}
|
||||
|
||||
/// Two faces parted by a line that is no edge (as a Gap Detection line), which crosses an edge with
|
||||
/// no stroke: one 0.006 pt long, upright or at a slope of 0.1, and one 1 pt long at a slope of 0.09.
|
||||
/// The faces only cross the edge and do not run along it, so the flood does not join them.
|
||||
#[test]
|
||||
fn live_paint_flood_does_not_cross_an_edge_an_outline_only_crosses() {
|
||||
let pt = Point::new;
|
||||
let short = pt(0.006, 0.0);
|
||||
for (a, b, end) in
|
||||
[(pt(0.003, -0.004), pt(0.003, 0.02), short), (pt(-0.037, -0.004), pt(0.043, 0.004), short), (pt(0.1, -0.009), pt(0.3, 0.009), pt(1.0, 0.0))]
|
||||
{
|
||||
let (g, ids) = live_of(
|
||||
vec![
|
||||
PathData::single(SubPath::polyline(&[a, b, pt(-50.0, 50.0), pt(-50.0, -50.0)], true)),
|
||||
PathData::single(SubPath::polyline(&[b, a, pt(50.0, -50.0), pt(50.0, 50.0)], true)),
|
||||
],
|
||||
vec![(shapes::line(pt(0.0, 0.0), end), false)],
|
||||
);
|
||||
assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]], "{a:?} to {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_connected_edges_follow_the_same_stroke_paint() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let (g, [a, ..], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d);
|
||||
let group = d.node(g).unwrap().clone();
|
||||
let black = Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
// Top A and top B join end to end; top C joins them but is red, and the black bottom does not
|
||||
// join them.
|
||||
assert_eq!(connected_edges(&group, top_a, &black), vec![top_a, top_b]);
|
||||
// BC and top C, both red, join at (100, 0).
|
||||
assert_eq!(connected_edges(&group, bc, &red), vec![bc, top_c]);
|
||||
assert_eq!(connected_edges(&group, bottom, &black), vec![bottom]);
|
||||
// The edge started from is taken whatever its paint.
|
||||
assert_eq!(connected_edges(&group, ab, &Paint::None), vec![ab]);
|
||||
assert!(connected_edges(&group, a, &black).is_empty());
|
||||
}
|
||||
|
||||
/// An edge from (0, 0) through (50, 0) to (100, 0) and a second edge with the same stroke from
|
||||
/// (50, 0) to (50, 50): the second one ends at an anchor in the middle of the first, not at one of
|
||||
/// its ends, so the two are not joined end to end. A third edge from (100, 0) is.
|
||||
#[test]
|
||||
fn live_paint_connected_edges_join_only_at_path_ends() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let black = Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
let mut kids = vec![empty_sources(&mut d)];
|
||||
let pt = Point::new;
|
||||
let paths = [
|
||||
PathData::single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0)], false)),
|
||||
shapes::line(pt(50.0, 0.0), pt(50.0, 50.0)),
|
||||
shapes::line(pt(100.0, 0.0), pt(100.0, 50.0)),
|
||||
];
|
||||
let mut ids = vec![];
|
||||
for pd in paths {
|
||||
let mut a = Appearance::default();
|
||||
a.set_stroke(black.clone());
|
||||
let id = d.alloc_id();
|
||||
let mut edge = Node::path(id, pd, a);
|
||||
edge.name = Some(EDGE_NAME.into());
|
||||
kids.push(Arc::new(edge));
|
||||
ids.push(id);
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, kids);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let group = d.node(gid).unwrap().clone();
|
||||
assert_eq!(connected_edges(&group, ids[0], &black), vec![ids[0], ids[2]]);
|
||||
assert_eq!(connected_edges(&group, ids[1], &black), vec![ids[1]]);
|
||||
}
|
||||
|
||||
/// Three edges end to end along (0, 0) to (150, 0) under one face: a red one, one drawn red with a
|
||||
/// hidden blue stroke over it, and one drawn blue with a hidden red stroke over it. Edges join by the
|
||||
/// paint they draw with, not by a hidden stroke's paint, for `connected_edges` and a double-click.
|
||||
#[test]
|
||||
fn live_paint_connected_edges_follow_the_stroke_paint_they_draw_with() {
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
|
||||
let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
|
||||
let mut kids = vec![empty_sources(&mut d)];
|
||||
let fid = d.alloc_id();
|
||||
let mut face = Node::path(fid, shapes::rectangle(Rect::new(0.0, 0.0, 150.0, 50.0)), Appearance::default());
|
||||
face.name = Some(FACE_NAME.into());
|
||||
kids.push(Arc::new(face));
|
||||
let pt = Point::new;
|
||||
let hidden = |p: &Paint| AppearanceItem::Stroke(StrokeLayer { visible: false, ..StrokeLayer::new(p.clone(), 1.0) });
|
||||
let shown = |p: &Paint| AppearanceItem::Stroke(StrokeLayer::new(p.clone(), 1.0));
|
||||
let edges = [(0.0, vec![shown(&red)]), (50.0, vec![shown(&red), hidden(&blue)]), (100.0, vec![shown(&blue), hidden(&red)])];
|
||||
let mut ids = vec![];
|
||||
for (x, items) in edges {
|
||||
let id = d.alloc_id();
|
||||
let mut edge = Node::path(id, shapes::line(pt(x, 0.0), pt(x + 50.0, 0.0)), Appearance { items, ..Appearance::default() });
|
||||
edge.name = Some(EDGE_NAME.into());
|
||||
kids.push(Arc::new(edge));
|
||||
ids.push(id);
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, kids);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let group = d.node(gid).unwrap().clone();
|
||||
assert_eq!(connected_edges(&group, ids[0], &red), vec![ids[0], ids[1]]);
|
||||
assert_eq!(connected_edges(&group, ids[2], &blue), vec![ids[2]]);
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let mut t = LivePaintSelectionTool::default();
|
||||
let mut dbl = |x: f64| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, 1.0).with_mods(alt));
|
||||
assert_eq!(dbl(75.0), set(&[ids[1], ids[0]]));
|
||||
assert_eq!(dbl(125.0), set(&[ids[2]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_selection_double_click_selects_connected_faces_or_edges() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let (_, [a, b, c], [_, _, top_a, top_b, ..]) = live_strip(&mut d);
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
|
||||
let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let dbl = |t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, y).with_mods(m));
|
||||
let mut t = LivePaintSelectionTool::default();
|
||||
// A face: the faces not parted from it by a stroked edge.
|
||||
assert_eq!(dbl(&mut t, 25.0, 25.0, Mods::default()), set(&[a, b]));
|
||||
assert_eq!(dbl(&mut t, 125.0, 25.0, Mods::default()), set(&[c]));
|
||||
// An edge (Alt): the edges joined to it end to end with its stroke paint.
|
||||
assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[top_a, top_b]));
|
||||
// With Shift held, the parts reached leave the selection or join it.
|
||||
assert_eq!(dbl(&mut t, 75.0, 25.0, shift), toggle(&[b, a]));
|
||||
assert_eq!(dbl(&mut t, 75.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_b, top_a]));
|
||||
// Away from the group, a double-click adds nothing to what its clicks did.
|
||||
assert!(dbl(&mut t, 300.0, 300.0, Mods::default()).is_empty());
|
||||
// Select Fills off: the double-click picks the edge nearest the pointer as a click does.
|
||||
t.set_option("selectFills", &json!(false));
|
||||
assert_eq!(dbl(&mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b]));
|
||||
assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty());
|
||||
// Select Strokes off: Alt picks the face.
|
||||
t.set_option("selectFills", &json!(true));
|
||||
t.set_option("selectStrokes", &json!(false));
|
||||
assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[a, b]));
|
||||
// Both off: nothing.
|
||||
t.set_option("selectFills", &json!(false));
|
||||
assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty());
|
||||
assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_selection_double_click_skips_locked_groups_and_art_outside_isolation() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let (g, _, _) = live_strip(&mut d);
|
||||
let (other, _) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let dbl = |d: &Document, iso: Option<NodeId>, x: f64| {
|
||||
let cx = ToolContext { isolation: iso, ..cx(d, &s, &p) };
|
||||
LivePaintSelectionTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, x, 25.0))
|
||||
};
|
||||
assert!(!dbl(&d, None, 25.0).is_empty());
|
||||
assert!(dbl(&d, Some(other), 25.0).is_empty(), "outside the isolated art");
|
||||
d.node_mut(g).unwrap().locked = true;
|
||||
assert!(dbl(&d, None, 25.0).is_empty(), "a locked group");
|
||||
assert!(!dbl(&d, None, 250.0).is_empty());
|
||||
}
|
||||
|
||||
/// The Live Paint Selection pointer shows what a click at the point selects, from the point and
|
||||
/// the modifier keys alone (no move first): a face, an edge, or nothing. Groups the tool does not
|
||||
/// take (locked, hidden, outside isolation) show nothing to select. A press keeps its look until
|
||||
|
||||
+10
@@ -3650,6 +3650,16 @@ already walks Image Trace. Short agent entry points:
|
||||
a locked artboard, or in isolation mode outside the isolated art. In the app, the tool's cursor shows what a click
|
||||
at the pointer selects under these rules: the arrow with a filled square for a face, with a short line for an edge,
|
||||
and with an x for nothing. It keeps the look it had at the press until the release.
|
||||
A double-click (`pointer_gesture` kind `doubleclick`, after the two clicks' `down` and `up`) on the face a click there
|
||||
picks runs `select.set {ids}` with that face and the faces of its group that a flood from it reaches across edges
|
||||
that are hidden or draw no stroke (an edge that is fully transparent, or whose strokes are each hidden, without
|
||||
paint, 0 pt wide or fully transparent); a stroked edge and a Gap Detection gap stop the flood, and hidden faces are
|
||||
left out. On the edge a click
|
||||
there picks, it runs `select.set {ids}` with that edge and the edges of its group, not hidden, joined to it end to
|
||||
end through edges that draw with the stroke paint it draws with (the paint of an edge's topmost stroke that is
|
||||
shown, with paint, above 0 pt and not fully transparent; none for an edge that draws no stroke). With Shift held it runs `select.toggle {ids}` with the same faces or edges,
|
||||
and where a click selects no face or edge it runs nothing. The same options, locks and isolation rules apply as for
|
||||
a click.
|
||||
The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens
|
||||
them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `cursorSwatchPreview` (on),
|
||||
`highlight` (on), `color` (the
|
||||
|
||||
@@ -1508,6 +1508,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`.
|
||||
As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`.
|
||||
A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected.
|
||||
A double-click with the Live Paint Selection tool on a face selects it and the faces of its group that no stroked edge parts from it ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)), and on an edge selects it and the edges joined to it end to end that have its stroke paint ([the help page](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)); Shift-double-click adds them to the selection or removes them from it. Triple-click is not handled yet.
|
||||
When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`.
|
||||
Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected.
|
||||
The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`.
|
||||
|
||||
Reference in New Issue
Block a user