mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 05:55:32 +00:00
Merge pull request #7511 from dotCooCoo/fix/live-paint-make-drawn-rows
Live Paint: Make keeps every fill and stroke row that draws on the source
This commit is contained in:
@@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path with the lengths set, without the fit of dashes aligned to corners and path ends, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -414,22 +414,274 @@ fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
|
||||
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
|
||||
}
|
||||
|
||||
/// Does appearance row `i` draw: it is shown, has paint and an opacity above 0, and a stroke also
|
||||
/// has a weight above 0?
|
||||
fn row_draws(i: &AppearanceItem) -> bool {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && f.opacity > 0.0,
|
||||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.opacity > 0.0 && s.width > 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a source path gives the faces and edges made from it.
|
||||
struct SourceStyle {
|
||||
/// The fill rows that draw, in paint order (none when the path's opacity is 0).
|
||||
fills: Vec<AppearanceItem>,
|
||||
/// The stroke rows that draw, in paint order (none when the path's opacity is 0).
|
||||
strokes: Vec<AppearanceItem>,
|
||||
/// The top stroke's weight, for an edge given no stroke that draws.
|
||||
width: f64,
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
}
|
||||
|
||||
impl SourceStyle {
|
||||
/// The style of source path `n`: its rows keep their order and settings, gradients fitted to
|
||||
/// its bounds keep that fit, and the strokes' pen pressure, tilt, bearing and rotation stay
|
||||
/// with the path.
|
||||
fn of(n: &Node) -> Self {
|
||||
let mut a = (*n.appearance).clone().without_pressure();
|
||||
if a.has_unplaced_gradient()
|
||||
&& let Some(bounds) = n.geometric_bounds()
|
||||
{
|
||||
a.pin_gradients(bounds);
|
||||
}
|
||||
let width = a.stroke().map_or(1.0, |s| s.width);
|
||||
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||
if n.opacity > 0.0 {
|
||||
for i in a.items.into_iter().filter(row_draws) {
|
||||
if i.is_fill() { fills.push(i) } else { strokes.push(i) }
|
||||
}
|
||||
}
|
||||
Self { fills, strokes, width, opacity: n.opacity, blend: n.blend }
|
||||
}
|
||||
|
||||
/// Give part `n` the rows `rows` of this source with its opacity and blending mode; with no
|
||||
/// rows, `n` keeps the unpainted row it has.
|
||||
fn paint(&self, n: &mut Node, rows: &[AppearanceItem]) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
n.appearance = Appearance { items: rows.to_vec(), ..Default::default() }.into();
|
||||
n.opacity = self.opacity;
|
||||
n.blend = self.blend;
|
||||
}
|
||||
}
|
||||
|
||||
/// How far, in points, a place on an edge may be from its source path and still be on it.
|
||||
const ON_SOURCE: f64 = 0.05;
|
||||
|
||||
/// Does stroke row `i` have settings that depend on where along its path a piece of it lies or
|
||||
/// which way it runs: dashes, arrowheads, a width profile, a brush, or a gradient along or across
|
||||
/// the stroke?
|
||||
fn depends_on_place(i: &AppearanceItem) -> bool {
|
||||
matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some() || s.gradient_mode != vectorcraft_doc::StrokeGradientMode::Within)
|
||||
}
|
||||
|
||||
/// One subpath's segments with the length along it where each starts, then its whole length, and
|
||||
/// a grid of square cells listing the segments whose bounds, grown by [`ON_SOURCE`], reach into
|
||||
/// each cell, so finding the segments near a point does not look at every segment.
|
||||
struct Track {
|
||||
segs: Vec<kurbo::PathSeg>,
|
||||
at: Vec<f64>,
|
||||
closed: bool,
|
||||
cell: f64,
|
||||
grid: HashMap<(i64, i64), Vec<usize>>,
|
||||
/// Segments that reach into more than [`Track::MAX_CELLS`] cells, or that come after the grid
|
||||
/// holds [`Track::CELLS_PER_SEGMENT`] entries per segment, looked at for every point.
|
||||
wide: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
const ACCURACY: f64 = 1e-4;
|
||||
const MAX_CELLS: i64 = 4096;
|
||||
const CELLS_PER_SEGMENT: usize = 16;
|
||||
|
||||
fn new(sp: &SubPath) -> Self {
|
||||
use kurbo::{ParamCurveArclen, Shape as _};
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
|
||||
let mut at = Vec::with_capacity(segs.len() + 1);
|
||||
let mut len = 0.0;
|
||||
for s in &segs {
|
||||
at.push(len);
|
||||
len += s.arclen(Self::ACCURACY);
|
||||
}
|
||||
at.push(len);
|
||||
let boxes: Vec<kurbo::Rect> = segs.iter().map(|s| s.bounding_box().inflate(ON_SOURCE, ON_SOURCE)).collect();
|
||||
// Cells about as wide as an average segment's bounds.
|
||||
let mean = boxes.iter().map(|b| b.width().max(b.height())).sum::<f64>() / boxes.len().max(1) as f64;
|
||||
let cell = if mean.is_finite() { mean.max(4.0 * ON_SOURCE) } else { 4.0 * ON_SOURCE };
|
||||
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||
let mut wide = vec![];
|
||||
// The grid holds at most this many entries in all.
|
||||
let mut budget = boxes.len().saturating_mul(Self::CELLS_PER_SEGMENT);
|
||||
for (k, b) in boxes.iter().enumerate() {
|
||||
let (x0, y0) = Self::cell_of(Point::new(b.x0, b.y0), cell);
|
||||
let (x1, y1) = Self::cell_of(Point::new(b.x1, b.y1), cell);
|
||||
let cells = x1.saturating_sub(x0).saturating_add(1).saturating_mul(y1.saturating_sub(y0).saturating_add(1));
|
||||
let Some(cells) = usize::try_from(cells).ok().filter(|&c| cells <= Self::MAX_CELLS && c <= budget) else {
|
||||
wide.push(k);
|
||||
continue;
|
||||
};
|
||||
budget -= cells;
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
grid.entry((x, y)).or_default().push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { segs, at, closed: sp.closed, cell, grid, wide }
|
||||
}
|
||||
|
||||
fn cell_of(p: Point, cell: f64) -> (i64, i64) {
|
||||
((p.x / cell).floor() as i64, (p.y / cell).floor() as i64)
|
||||
}
|
||||
|
||||
fn len(&self) -> f64 {
|
||||
self.at.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The lengths along the subpath to each of its segments' places nearest `p` that are within
|
||||
/// [`ON_SOURCE`] of it (more than one where the subpath passes `p` again).
|
||||
fn places(&self, p: Point) -> Vec<f64> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let near = self.grid.get(&Self::cell_of(p, self.cell)).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut out = vec![];
|
||||
for &k in near.iter().chain(&self.wide) {
|
||||
let Some(s) = self.segs.get(k) else { continue };
|
||||
let n = s.nearest(p, 1e-9);
|
||||
if n.distance_sq <= ON_SOURCE * ON_SOURCE {
|
||||
out.push(self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..n.t).arclen(Self::ACCURACY));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The signed length from `a` to `b` along the subpath (the shorter way round a closed one).
|
||||
fn step(&self, a: f64, b: f64) -> f64 {
|
||||
let (d, len) = (b - a, self.len());
|
||||
if self.closed && len > 0.0 { d - len * (d / len).round() } else { d }
|
||||
}
|
||||
|
||||
/// Of `places`, the one nearest along the subpath to `target`.
|
||||
fn nearest(&self, places: &[f64], target: f64) -> Option<f64> {
|
||||
places.iter().copied().min_by(|x, y| self.step(*x, target).abs().total_cmp(&self.step(*y, target).abs()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where edge `edge` runs along its source path, whose subpaths' tracks are `tracks`: the length
|
||||
/// along the source subpath to the edge's start, whether the edge starts at that subpath's start and
|
||||
/// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the
|
||||
/// edge's first segment places it, and its ends are taken where they lie that far along the source,
|
||||
/// so a path that passes a point twice is told apart. An edge that runs against the direction of its
|
||||
/// source is reversed first, and a closed edge as long as its closed source is started at the anchor
|
||||
/// where its source starts (one with no anchor there, or a shorter loop cut from a source that
|
||||
/// crosses itself, does not count as running the whole subpath). `None` when the edge is
|
||||
/// not a single piece found on the source.
|
||||
fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> {
|
||||
use kurbo::{ParamCurve, ParamCurveArclen};
|
||||
let [sp] = edge.subpaths.as_slice() else { return None };
|
||||
if sp.segment_count() == 0 {
|
||||
return None;
|
||||
}
|
||||
let (a, z) = (sp.anchors.first()?.p, if sp.closed { sp.anchors.first()?.p } else { sp.anchors.last()?.p });
|
||||
let first = sp.segment(0);
|
||||
let mid = first.eval(0.5);
|
||||
let half = first.subsegment(0.0..0.5).arclen(Track::ACCURACY);
|
||||
let length = sp.anchor_arc_positions().last().copied().unwrap_or(0.0);
|
||||
let edge_closed = sp.closed;
|
||||
// (how far off the fit is, track, start, end, runs forward) for the subpath that fits best.
|
||||
let mut found: Option<(f64, &Track, f64, f64, bool)> = None;
|
||||
for track in tracks {
|
||||
// The start lies half the first segment before its middle along the source, or after it
|
||||
// when the edge runs the other way: take the place and way that fit best.
|
||||
let starts_at = track.places(a);
|
||||
let mut best: Option<(f64, f64, bool)> = None;
|
||||
for pm in track.places(mid) {
|
||||
for forward in [true, false] {
|
||||
let Some(pa) = track.nearest(&starts_at, if forward { pm - half } else { pm + half }) else { continue };
|
||||
let miss = (if forward { track.step(pa, pm) } else { track.step(pm, pa) } - half).abs();
|
||||
if best.is_none_or(|(m, _, _)| miss < m) {
|
||||
best = Some((miss, pa, forward));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((miss, pa, forward)) = best else { continue };
|
||||
let target = if forward { pa + length } else { pa - length };
|
||||
let Some(pz) = track.nearest(&track.places(z), target) else { continue };
|
||||
let miss = miss + track.step(pz, target).abs();
|
||||
if found.is_none_or(|(m, ..)| miss < m) {
|
||||
found = Some((miss, track, pa, pz, forward));
|
||||
}
|
||||
}
|
||||
let (_, track, pa, pz, forward) = found?;
|
||||
let (from, to) = if forward { (pa, pz) } else { (pz, pa) };
|
||||
if !forward {
|
||||
edge.reverse();
|
||||
}
|
||||
let open = !track.closed;
|
||||
let starts = open && from <= ON_SOURCE;
|
||||
let ends = open && to >= track.len() - ON_SOURCE;
|
||||
if open {
|
||||
return Some((from, starts, ends, starts && ends));
|
||||
}
|
||||
// A closed edge runs the whole closed subpath, from the anchor the planar map starts it at.
|
||||
// Start it at the anchor where the source starts, so settings that follow the path begin there.
|
||||
let start = track.segs.first().map(|s| s.start())?;
|
||||
let moved = edge_closed
|
||||
&& (length - track.len()).abs() <= ON_SOURCE.max(track.len() * 1e-4)
|
||||
&& edge.subpaths.first_mut().is_some_and(|sp| match sp.anchors.iter().position(|a| a.p.distance(start) <= ON_SOURCE) {
|
||||
Some(k) => {
|
||||
sp.anchors.rotate_left(k);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
Some((if moved { 0.0 } else { from }, false, false, moved))
|
||||
}
|
||||
|
||||
/// Fit stroke row `row` of a source path to an edge cut from it, which runs as [`edge_run`] says
|
||||
/// (`None`: not known): its dashes go on from where the edge starts along the source, with the
|
||||
/// lengths set and without the fit of dashes aligned to corners and path ends, its arrowheads stay only at the source's own ends, and its width profile and brush, which spread
|
||||
/// over the whole path, stay only on an edge that runs the whole path.
|
||||
fn fit_to_edge(row: &mut AppearanceItem, run: Option<(f64, bool, bool, bool)>) {
|
||||
let AppearanceItem::Stroke(s) = row else { return };
|
||||
let (from, starts, ends, whole) = run.unwrap_or((0.0, false, false, false));
|
||||
if !whole {
|
||||
if let Some(d) = &mut s.dash
|
||||
&& run.is_some()
|
||||
{
|
||||
d.offset += from;
|
||||
d.align_corners = false;
|
||||
}
|
||||
s.profile = None;
|
||||
s.brush = None;
|
||||
}
|
||||
if !starts {
|
||||
s.start_arrow = None;
|
||||
}
|
||||
if !ends {
|
||||
s.end_arrow = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the
|
||||
/// fill, stroke and stroke width of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<(Paint, Paint, f64)>) {
|
||||
/// style of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<SourceStyle>) {
|
||||
let mut lv = vec![];
|
||||
for n in sources {
|
||||
b::leaf_shapes(n, &mut lv);
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
let mut styles: Vec<(Paint, Paint, f64)> = vec![];
|
||||
let mut styles: Vec<SourceStyle> = vec![];
|
||||
for l in lv {
|
||||
if let Some((path, rule)) = b::node_outline(l)
|
||||
&& !path.is_empty()
|
||||
{
|
||||
shapes.push(po::Shape::new(path, rule, styles.len() as u64));
|
||||
let a = &l.appearance;
|
||||
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
|
||||
styles.push(SourceStyle::of(l));
|
||||
}
|
||||
}
|
||||
(shapes, styles)
|
||||
@@ -462,17 +714,35 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64
|
||||
let nfaces = regs.len();
|
||||
for r in regs {
|
||||
let id = d.alloc_id();
|
||||
let fill = r.top().and_then(|t| styles.get(t)).map_or(Paint::None, |s| s.0.clone());
|
||||
let mut n = Node::path(id, r.path, fill_only(fill));
|
||||
let mut n = Node::path(id, r.path, fill_only(Paint::None));
|
||||
n.name = Some(FACE_NAME.into());
|
||||
// The front-most covering source with a fill that draws gives the face its fill rows.
|
||||
if let Some(s) = r.sources.iter().rev().filter_map(|&t| styles.get(t)).find(|s| !s.fills.is_empty()) {
|
||||
s.paint(&mut n, &s.fills);
|
||||
}
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
let nedges = edges.len();
|
||||
// The tracks of the sources whose edges need them, measured once each.
|
||||
let mut tracks: HashMap<usize, Vec<Track>> = HashMap::new();
|
||||
for e in edges {
|
||||
let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue };
|
||||
let key = e.key as usize;
|
||||
let Some(s) = styles.get(key) else { continue };
|
||||
let mut path = e.path;
|
||||
let mut rows = s.strokes.clone();
|
||||
if rows.iter().any(depends_on_place) {
|
||||
let source = tracks
|
||||
.entry(key)
|
||||
.or_insert_with(|| shapes.get(key).map(|src| src.path.subpaths.iter().map(Track::new).collect()).unwrap_or_default());
|
||||
let run = edge_run(source, &mut path);
|
||||
for r in &mut rows {
|
||||
fit_to_edge(r, run);
|
||||
}
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w));
|
||||
let mut n = Node::path(id, path, stroke_only(Paint::None, s.width));
|
||||
n.name = Some(EDGE_NAME.into());
|
||||
s.paint(&mut n, &rows);
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
Ok((children, nfaces, nedges))
|
||||
@@ -481,8 +751,9 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64
|
||||
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
|
||||
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
|
||||
/// appearance, none included, its transparency and the rest) of the old face its inside point is
|
||||
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
|
||||
/// keep the paint of their sources.
|
||||
/// in, and each edge that of the old edge its middle lies on, with the old edge's stroke rows fitted
|
||||
/// to the part of it the new edge runs, as [`fit_to_edge`] fits a source's rows. Faces and edges
|
||||
/// where `old` had none keep the paint of their sources.
|
||||
fn repaint_from(g: &mut Node, old: &Node) {
|
||||
/// How far an edge's middle may be from the old edge it lies on.
|
||||
const ON_EDGE: f64 = 1e-3;
|
||||
@@ -505,7 +776,30 @@ fn repaint_from(g: &mut Node, old: &Node) {
|
||||
n.id = c.id;
|
||||
n.kind = c.kind.clone();
|
||||
n.bbox_angle = c.bbox_angle;
|
||||
// An edge cut from the old one takes the old edge's dashes, arrowheads, width profile
|
||||
// and brush as they fall on its part of the old edge.
|
||||
if c.name.as_deref() == Some(EDGE_NAME)
|
||||
&& let Some(old_path) = was.path_data()
|
||||
{
|
||||
fit_to_old_edge(&mut n, old_path);
|
||||
}
|
||||
*c = Arc::new(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit the stroke rows of edge `n`, which it took from an old edge whose outline is `old`, to the
|
||||
/// part of the old edge that `n` runs, as [`fit_to_edge`] fits a source's rows to an edge cut from
|
||||
/// it; `n` is reversed when it runs against the old edge.
|
||||
fn fit_to_old_edge(n: &mut Node, old: &PathData) {
|
||||
if !n.appearance.items.iter().any(depends_on_place) {
|
||||
return;
|
||||
}
|
||||
let tracks: Vec<Track> = old.subpaths.iter().map(Track::new).collect();
|
||||
let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path));
|
||||
let mut a = (*n.appearance).clone();
|
||||
for r in &mut a.items {
|
||||
fit_to_edge(r, run);
|
||||
}
|
||||
n.appearance = a.into();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::{
|
||||
Appearance, AppearanceItem, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId,
|
||||
PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json,
|
||||
is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes,
|
||||
switch_or,
|
||||
PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite,
|
||||
fit_to_old_edge, ids_json, is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids,
|
||||
source_nodes, source_shapes, switch_or,
|
||||
};
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
@@ -85,6 +85,9 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
|
||||
/// How far, in points, the middle of an edge Merge makes may be from the old edge it lies on.
|
||||
const ON_OLD_EDGE: f64 = 1e-3;
|
||||
|
||||
/// What Merge carries from a face or edge to the part it makes again in its place, another path:
|
||||
/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep
|
||||
/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind.
|
||||
@@ -125,18 +128,11 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
|
||||
check_live_paint_takes(d, roots)?;
|
||||
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
|
||||
// Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge,
|
||||
// paint) for every edge.
|
||||
// Paint to carry over: (point inside face, paint) for faces that draw; edges are matched to the
|
||||
// old edges below.
|
||||
let keep_faces: Vec<(Point, Carried)> =
|
||||
b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect();
|
||||
let keep_edges: Vec<(Point, Carried)> = b::edges(&old)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let pd = e.path_data()?;
|
||||
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
|
||||
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e)))
|
||||
})
|
||||
.collect();
|
||||
let old_parts = old.clone();
|
||||
let mut sources = source_nodes(&old);
|
||||
for r in roots {
|
||||
if *r != target {
|
||||
@@ -162,12 +158,24 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
carried.onto(Arc::make_mut(f));
|
||||
}
|
||||
}
|
||||
for (pt, carried) in keep_edges {
|
||||
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
|
||||
{
|
||||
carried.onto(Arc::make_mut(e));
|
||||
// Each edge whose middle lies on an old edge, every piece of an edge an added path cuts
|
||||
// included, takes the old edge's paint, with its rows fitted to its part of the old edge.
|
||||
if let Some(ch) = g.children_mut() {
|
||||
for c in ch.iter_mut().filter(|c| c.name.as_deref() == Some(EDGE_NAME)) {
|
||||
let Some(mid) = c
|
||||
.path_data()
|
||||
.and_then(|pd| pd.subpaths.first())
|
||||
.filter(|sp| sp.segment_count() > 0)
|
||||
.map(|sp| kurbo::ParamCurve::eval(&sp.segment(0), 0.5))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(was) = edge_near(&old_parts, mid, ON_OLD_EDGE) else { continue };
|
||||
let e = Arc::make_mut(c);
|
||||
Carried::of(was).onto(e);
|
||||
if let Some(outline) = was.path_data() {
|
||||
fit_to_old_edge(e, outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, roots);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::appearance::{Effect, FillLayer, StrokeLayer};
|
||||
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
@@ -2684,6 +2685,401 @@ fn live_paint_make_and_expand_keep_abutting_compound_colors() {
|
||||
check_colors(&node(&s, group));
|
||||
}
|
||||
|
||||
/// Gives `id` the appearance rows `items` (paint order) and returns them.
|
||||
fn set_rows(s: &mut Session, id: NodeId, items: Vec<AppearanceItem>) -> Vec<AppearanceItem> {
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(id).unwrap().appearance = vectorcraft_doc::Appearance { items: items.clone(), ..Default::default() }.into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
items
|
||||
}
|
||||
|
||||
/// A fill row of `p`, changed by `f`.
|
||||
fn fill_row(p: Paint, f: impl FnOnce(&mut FillLayer)) -> AppearanceItem {
|
||||
let mut l = FillLayer::new(p);
|
||||
f(&mut l);
|
||||
AppearanceItem::Fill(l)
|
||||
}
|
||||
|
||||
/// A stroke row of `p` `width` wide, changed by `f`.
|
||||
fn stroke_row(p: Paint, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> AppearanceItem {
|
||||
let mut l = StrokeLayer::new(p, width);
|
||||
f(&mut l);
|
||||
AppearanceItem::Stroke(l)
|
||||
}
|
||||
|
||||
/// The faces and edges of Live Paint group `g` whose bounds lie within x `x0`..`x1`.
|
||||
fn parts_within(s: &Session, g: NodeId, x0: f64, x1: f64) -> (Vec<Node>, Vec<Node>) {
|
||||
let n = node(s, g);
|
||||
let within = |c: &&Arc<Node>| c.geometric_bounds().is_some_and(|b| b.x0 >= x0 - 1e-6 && b.x1 <= x1 + 1e-6);
|
||||
let faces = vectorcraft_tools::builder::faces(&n).into_iter().filter(within).map(|c| (**c).clone()).collect();
|
||||
let edges = vectorcraft_tools::builder::edges(&n).into_iter().filter(within).map(|c| (**c).clone()).collect();
|
||||
(faces, edges)
|
||||
}
|
||||
|
||||
/// Make gives each edge every stroke row of its source that draws, a lower one under an
|
||||
/// unpainted or hidden top row included, with the rows' settings and the source's opacity and
|
||||
/// blending mode; rows that draw nothing stay only with the stored source, which Release returns.
|
||||
#[test]
|
||||
fn live_paint_make_keeps_lower_drawn_stroke_rows() {
|
||||
let mut s = session();
|
||||
let (a, b) = two_at(&mut s, 200.0);
|
||||
let black = Paint::solid(Color::BLACK);
|
||||
let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0));
|
||||
let thick = stroke_row(black.clone(), 3.0, |l| {
|
||||
l.opacity = 0.5;
|
||||
l.blend = BlendMode::Multiply;
|
||||
l.cap = LineCap::Round;
|
||||
});
|
||||
// A: the top stroke has no paint.
|
||||
let a_rows =
|
||||
set_rows(&mut s, a, vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {}), thick.clone(), stroke_row(Paint::None, 1.0, |_| {})]);
|
||||
// B: the top stroke is hidden; a zero-weight and a transparent stroke draw nothing either.
|
||||
set_rows(
|
||||
&mut s,
|
||||
b,
|
||||
vec![
|
||||
stroke_row(green.clone(), 2.0, |_| {}),
|
||||
stroke_row(black.clone(), 0.0, |_| {}),
|
||||
stroke_row(black, 2.0, |l| l.opacity = 0.0),
|
||||
stroke_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 5.0, |l| l.visible = false),
|
||||
],
|
||||
);
|
||||
s.edit("t", |d, _| {
|
||||
let n = d.node_mut(a).unwrap();
|
||||
n.opacity = 0.75;
|
||||
n.blend = BlendMode::Screen;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let (_, a_edges) = parts_within(&s, g, 0.0, 100.0);
|
||||
let (_, b_edges) = parts_within(&s, g, 200.0, 300.0);
|
||||
assert!(!a_edges.is_empty() && !b_edges.is_empty());
|
||||
for e in &a_edges {
|
||||
assert_eq!(e.appearance.items, vec![thick.clone()], "A's edge takes the stroke that draws");
|
||||
assert_eq!((e.opacity, e.blend), (0.75, BlendMode::Screen));
|
||||
}
|
||||
for e in &b_edges {
|
||||
assert_eq!(e.appearance.items, vec![stroke_row(green.clone(), 2.0, |_| {})], "B's edge takes only its green stroke");
|
||||
assert_eq!((e.opacity, e.blend), (1.0, BlendMode::Normal));
|
||||
}
|
||||
// Release returns the sources with every row they had.
|
||||
let released = ids(&s.execute("livePaint.release", &json!({})).unwrap());
|
||||
assert!(released.contains(&a));
|
||||
assert_eq!(node(&s, a).appearance.items, a_rows);
|
||||
}
|
||||
|
||||
/// When Make cuts a dashed stroke with arrowheads, a width profile and a brush into edges, each
|
||||
/// edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own
|
||||
/// ends, and the profile and brush, which spread over the whole path, are left off the pieces.
|
||||
#[test]
|
||||
fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() {
|
||||
use vectorcraft_doc::{Arrowhead, Dash};
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let l = line(&mut s, (-20.0, 50.0), (120.0, 50.0));
|
||||
let dash = Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: true };
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(dash.clone());
|
||||
st.start_arrow = Some(Arrowhead::Triangle);
|
||||
st.end_arrow = Some(Arrowhead::Circle);
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
let rows = set_rows(&mut s, l, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let mut pieces: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&gn)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone();
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x, st))
|
||||
})
|
||||
.collect();
|
||||
pieces.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
let ends: Vec<(f64, f64)> = pieces.iter().map(|p| ((p.0 * 1e3).round() / 1e3, (p.1 * 1e3).round() / 1e3)).collect();
|
||||
assert_eq!(ends, vec![(-20.0, 0.0), (0.0, 100.0), (100.0, 120.0)], "each piece runs the way the line does");
|
||||
let offsets: Vec<f64> = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect();
|
||||
assert_eq!(offsets, vec![2.0, 22.0, 122.0], "the dashes go on along the line");
|
||||
assert!(pieces.iter().all(|p| !p.2.dash.as_ref().unwrap().align_corners && p.2.brush.is_none()));
|
||||
let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect();
|
||||
assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, None), (None, Some(Arrowhead::Circle))]);
|
||||
// A path that crosses itself: the pieces that start at the crossing take their place on the
|
||||
// path from where they run, not from the first time the path passes that point.
|
||||
let mut s3 = session();
|
||||
let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let z = NodeId(s3.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap());
|
||||
set_rows(&mut s3, z, rows.clone());
|
||||
s3.execute("select.set", &json!({"ids": [z.0]})).unwrap();
|
||||
let g3 = NodeId(s3.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let mut cut: Vec<(f64, Option<Arrowhead>, Option<Arrowhead>)> = vectorcraft_tools::builder::edges(&node(&s3, g3))
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let st = e.appearance.stroke()?;
|
||||
Some(((st.dash.as_ref()?.offset * 1e3).round() / 1e3, st.start_arrow.clone(), st.end_arrow.clone()))
|
||||
})
|
||||
.collect();
|
||||
cut.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
let d = 2.0 + 50.0 * 2f64.sqrt();
|
||||
let r = |v: f64| (v * 1e3).round() / 1e3;
|
||||
assert_eq!(
|
||||
cut,
|
||||
vec![(2.0, Some(Arrowhead::Triangle), None), (r(d), None, None), (r(d + 100.0 + 100.0 * 2f64.sqrt()), None, Some(Arrowhead::Circle))]
|
||||
);
|
||||
// An uncut path keeps all of it.
|
||||
let mut s2 = session();
|
||||
let l2 = line(&mut s2, (0.0, 0.0), (50.0, 0.0));
|
||||
set_rows(&mut s2, l2, rows.clone());
|
||||
s2.execute("select.set", &json!({"ids": [l2.0]})).unwrap();
|
||||
let g2 = NodeId(s2.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let edges: Vec<_> = vectorcraft_tools::builder::edges(&node(&s2, g2)).iter().map(|e| e.appearance.items.clone()).collect();
|
||||
assert_eq!(edges, vec![rows]);
|
||||
}
|
||||
|
||||
/// When Gap Options or Merge cuts an edge in two, each piece keeps the edge's dashes going on from
|
||||
/// where it starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush.
|
||||
#[test]
|
||||
fn live_paint_gap_options_and_merge_fit_an_edge_look_to_each_piece() {
|
||||
use vectorcraft_doc::{Arrowhead, Dash};
|
||||
let mut s = session();
|
||||
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
|
||||
let stub = line(&mut s, (50.0, 0.0), (50.0, 48.0));
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false });
|
||||
st.start_arrow = Some(Arrowhead::Triangle);
|
||||
st.end_arrow = Some(Arrowhead::Circle);
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
let rows = set_rows(&mut s, l, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [l.0, stub.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let dashed = |s: &Session, g: NodeId| {
|
||||
let mut out: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&node(s, g))
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone();
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
Some(((sp.anchors.first()?.p.x * 1e3).round() / 1e3, (sp.anchors.last()?.p.x * 1e3).round() / 1e3, st))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
out
|
||||
};
|
||||
let before = dashed(&s, g);
|
||||
assert_eq!(before.len(), 1, "nothing cuts the line yet");
|
||||
assert_eq!(vec![AppearanceItem::Stroke(before[0].2.clone())], rows);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap();
|
||||
assert_eq!(r["closed"], json!(1));
|
||||
let pieces = dashed(&s, g);
|
||||
let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(ends, vec![(0.0, 50.0), (50.0, 100.0)], "the line is cut where the gap closes");
|
||||
let offsets: Vec<f64> = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect();
|
||||
assert_eq!(offsets, vec![2.0, 52.0], "the dashes go on along the line");
|
||||
let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect();
|
||||
assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]);
|
||||
assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line");
|
||||
// Merge: the piece of the edge that an added path cuts off, which takes the edge's look, gets
|
||||
// it fitted to where it lies along the edge in the same way.
|
||||
let mut s = session();
|
||||
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
|
||||
set_rows(&mut s, l, rows.clone());
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let cut = line(&mut s, (25.0, 0.0), (25.0, 100.0));
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap();
|
||||
let pieces = dashed(&s, g);
|
||||
let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect();
|
||||
assert_eq!(ends, vec![(0.0, 25.0), (25.0, 100.0)], "the added line cuts the edge");
|
||||
let offsets: Vec<f64> = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect();
|
||||
assert_eq!(offsets, vec![2.0, 27.0], "the dashes go on along the line");
|
||||
let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect();
|
||||
assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]);
|
||||
assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line");
|
||||
// A repainted edge that Merge cuts keeps its paint on every piece.
|
||||
let mut s = session();
|
||||
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 50], "color": "#ff0000", "width": 3})).unwrap();
|
||||
let cut = line(&mut s, (50.0, 0.0), (50.0, 100.0));
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap();
|
||||
let gn = node(&s, g);
|
||||
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
|
||||
let mut on_line: Vec<(f64, bool)> = vectorcraft_tools::builder::edges(&gn)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
let (a, z) = (sp.anchors.first()?.p, sp.anchors.last()?.p);
|
||||
((a.y - 50.0).abs() < 1e-6 && (z.y - 50.0).abs() < 1e-6)
|
||||
.then(|| ((a.x.min(z.x) * 1e3).round() / 1e3, e.appearance.stroke().is_some_and(|st| st.paint == red)))
|
||||
})
|
||||
.collect();
|
||||
on_line.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
assert_eq!(on_line, vec![(0.0, true), (50.0, true)], "both pieces keep the edge's paint");
|
||||
}
|
||||
|
||||
/// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts
|
||||
/// at and runs the way the path does, whichever anchor that is, so its dashes and brush lie where
|
||||
/// they do on the path, and it keeps the path's stroke row as it is.
|
||||
#[test]
|
||||
fn live_paint_make_starts_a_closed_edge_where_its_path_starts() {
|
||||
use vectorcraft_doc::Dash;
|
||||
let corners = [(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)];
|
||||
for clockwise in [true, false] {
|
||||
for start in 0..4 {
|
||||
let mut s = session();
|
||||
let mut pts: Vec<(f64, f64)> = (0..4).map(|k| corners[(k + start) % 4]).collect();
|
||||
if !clockwise {
|
||||
pts.reverse();
|
||||
}
|
||||
let anchors: Vec<_> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap());
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false });
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
let rows = set_rows(&mut s, z, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [z.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let edges = vectorcraft_tools::builder::edges(&gn);
|
||||
let [e] = edges.as_slice() else { panic!("one edge, got {}", edges.len()) };
|
||||
let sp = e.path_data().unwrap().subpaths.first().unwrap().clone();
|
||||
let at: Vec<(f64, f64)> = sp.anchors.iter().map(|a| ((a.p.x * 1e3).round() / 1e3, (a.p.y * 1e3).round() / 1e3)).collect();
|
||||
assert!(sp.closed);
|
||||
assert_eq!(at, pts, "clockwise {clockwise}, start {start}");
|
||||
assert_eq!(e.appearance.items, rows, "clockwise {clockwise}, start {start}");
|
||||
}
|
||||
}
|
||||
// A closed path that crosses itself is cut into loops, and the loop that holds the path's
|
||||
// first anchor is still only part of the path: it keeps no brush.
|
||||
let mut s = session();
|
||||
let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap());
|
||||
let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| {
|
||||
st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false });
|
||||
st.brush = Some("Charcoal".into());
|
||||
});
|
||||
set_rows(&mut s, z, vec![row]);
|
||||
s.execute("select.set", &json!({"ids": [z.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let edges = vectorcraft_tools::builder::edges(&gn);
|
||||
assert!(edges.len() > 1, "the crossing cuts the path, got {} edge", edges.len());
|
||||
assert!(edges.iter().all(|e| e.appearance.stroke().is_some_and(|st| st.brush.is_none())), "no loop is the whole path");
|
||||
}
|
||||
|
||||
/// When Make cuts a stroke whose only setting that depends on direction is a gradient along or
|
||||
/// across it, each edge runs the way its source path does, so the gradient keeps its direction.
|
||||
#[test]
|
||||
fn live_paint_make_keeps_the_direction_of_gradient_strokes() {
|
||||
use vectorcraft_doc::StrokeGradientMode;
|
||||
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
|
||||
for (x0, x1) in [(-20.0, 120.0), (120.0, -20.0)] {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let l = line(&mut s, (x0, 50.0), (x1, 50.0));
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
set_rows(&mut s, l, vec![stroke_row(gradient, 2.0, |st| st.gradient_mode = mode)]);
|
||||
s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let pieces: Vec<(f64, f64)> = vectorcraft_tools::builder::edges(&node(&s, g))
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
e.appearance.stroke().filter(|st| st.gradient_mode == mode)?;
|
||||
let sp = e.path_data()?.subpaths.first()?;
|
||||
Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(pieces.len(), 3, "{mode:?}, from {x0}");
|
||||
assert!(
|
||||
pieces.iter().all(|&(p, q)| (q - p).signum() == (x1 - x0).signum()),
|
||||
"{mode:?}, from {x0}: each piece runs the way the line does: {pieces:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make gives each face every fill row of its source that draws, in order and with their
|
||||
/// opacity, blending mode, overprint and effects, and the source's gradients keep their fit on
|
||||
/// the source; Expand keeps the rows, and Undo and Redo bring the group back with them.
|
||||
#[test]
|
||||
fn live_paint_make_keeps_all_drawn_fill_rows_and_attributes() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let lower = fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |l| {
|
||||
l.opacity = 0.4;
|
||||
l.blend = BlendMode::Multiply;
|
||||
l.overprint = true;
|
||||
l.effects.push(Effect { id: "distort.roughen".into(), params: Value::Null, visible: true });
|
||||
});
|
||||
// A: two fills that draw, with a hidden one between them and an unpainted one above them.
|
||||
set_rows(
|
||||
&mut s,
|
||||
a,
|
||||
vec![
|
||||
lower.clone(),
|
||||
fill_row(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), |l| l.visible = false),
|
||||
fill_row(Paint::None, |_| {}),
|
||||
stroke_row(Paint::None, 1.0, |_| {}),
|
||||
],
|
||||
);
|
||||
// The top fill (and the stroke) become a gradient fitted to A's bounds.
|
||||
let (want_fill, _) = paint_with_gradients(&mut s, a);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let want = vec![lower, fill_row(want_fill, |_| {})];
|
||||
let rows_within = |s: &Session, x0: f64, x1: f64| parts_within(s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()], "A's face takes both fills that draw, the gradient at its fit on A");
|
||||
let blue = vec![fill_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), |_| {})];
|
||||
assert_eq!(rows_within(&s, 50.0, 100.0), vec![blue], "B is in front");
|
||||
s.execute("livePaint.expand", &json!({})).unwrap();
|
||||
let expanded = node(&s, g);
|
||||
assert!(!vectorcraft_tools::builder::is_live_paint(&expanded));
|
||||
let kept =
|
||||
expanded.children().unwrap().iter().filter(|c| c.geometric_bounds().is_some_and(|b| b.x1 <= 50.0 + 1e-6)).any(|c| c.appearance.items == want);
|
||||
assert!(kept, "Expand keeps both fills");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(rows_within(&s, 0.0, 50.0), vec![want]);
|
||||
}
|
||||
|
||||
/// A face takes the fill rows of the front-most covering source whose fill draws: a source in
|
||||
/// front whose fill is hidden or transparent, or whose opacity is 0, does not hide the paint of the
|
||||
/// source behind it, and a face that no such source covers has no paint.
|
||||
#[test]
|
||||
fn live_paint_make_uses_frontmost_source_with_drawn_fill() {
|
||||
for front in ["hidden", "clear", "transparent object"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
s.edit("t", |d, _| {
|
||||
let n = d.node_mut(b).unwrap();
|
||||
let mut app = (*n.appearance).clone();
|
||||
match front {
|
||||
"hidden" => app.fill_mut().unwrap().visible = false,
|
||||
"clear" => app.fill_mut().unwrap().opacity = 0.0,
|
||||
_ => n.opacity = 0.0,
|
||||
}
|
||||
n.appearance = app.into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let rows_within = |x0: f64, x1: f64| parts_within(&s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::<Vec<_>>();
|
||||
let red = vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {})];
|
||||
assert_eq!(rows_within(50.0, 100.0), vec![red], "{front}: the overlap shows A");
|
||||
assert_eq!(rows_within(100.0, 150.0), vec![vec![fill_row(Paint::None, |_| {})]], "{front}: B alone paints nothing");
|
||||
assert_eq!(parts_within(&s, g, 100.0, 150.0).0[0].opacity, 1.0, "{front}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_fill_assigns_the_face_under_the_point() {
|
||||
let mut s = session();
|
||||
|
||||
+14
-2
@@ -3698,8 +3698,20 @@ already walks Image Trace. Short agent entry points:
|
||||
`ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog.
|
||||
- Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`,
|
||||
`livePaint.info {group?|id?}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?,
|
||||
color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most
|
||||
covering source, including compound paths with shared curved boundaries. Expand keeps those face fills.
|
||||
color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill rows that draw of
|
||||
its front-most covering source that has one, including compound paths with shared curved boundaries, and each edge
|
||||
the stroke rows that draw of its source. A row draws when it is shown and has paint and an opacity above 0; a
|
||||
stroke also needs a weight above 0, and a source whose opacity is 0 has no rows that draw. The rows keep their order
|
||||
and settings (opacity, blending mode, effects, overprint, and a stroke's weight, caps, joins, dashes and alignment),
|
||||
gradients keep their fit on the source, and the face or edge takes the source's opacity and blending mode. Where a
|
||||
path is cut into several edges, each edge runs the way the path does, its dashes go on from where it starts along
|
||||
the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width
|
||||
profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on
|
||||
each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path.
|
||||
When `livePaint.gapOptions` or `livePaint.merge` cuts an edge into pieces, each piece takes the edge's paint, with
|
||||
its rows fitted to its part of the edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which
|
||||
`livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried.
|
||||
Expand keeps those face fills.
|
||||
`livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity
|
||||
is above 0, and a fill or stroke row is shown, has paint and an opacity above 0 (a stroke also a weight above 0),
|
||||
whichever row is on top. A painted lower row under an unpainted top row keeps the part; a part whose only painted
|
||||
|
||||
@@ -1529,6 +1529,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`.
|
||||
Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks that painted it; a first click that made the Live Paint group counts when the face it filled is the clicked face. With Space held for the view tools, a click, double-click or triple-click paints nothing and no face or edge is outlined, and with Cmd/Ctrl held a double-click or triple-click paints nothing. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face, bucket_triple_click_with_a_click_on_another_face_between_matches_the_first_fill, bucket_highlights_nothing_with_space_held}`, `tests::build::{live_paint_scoped_fill_and_stroke_skip_parts_with_the_paint, live_paint_bucket_cmd_double_and_triple_click_paint_nothing}`, `tests::livepaint::{a_triple_click_fills_every_face_of_the_same_fill, clicks_with_space_held_paint_nothing}` (MCP).
|
||||
Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`.
|
||||
Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path with the lengths set (dashes aligned to corners and path ends are not fitted to the cut edges), the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; each edge runs the way its path does, a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_starts_a_closed_edge_where_its_path_starts, live_paint_make_keeps_the_direction_of_gradient_strokes, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`.
|
||||
Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision.
|
||||
Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients, live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts}`.
|
||||
`livePaint.info` lists every fill and stroke row of each face and edge, bottom first, with its paint, opacity, visibility, weight for a stroke, and whether it draws by the rule above, beside the top fill, stroke and weight it already reported. Evidence: `tests::build::live_paint_info_lists_every_appearance_row`.
|
||||
|
||||
Reference in New Issue
Block a user