mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:42:42 +00:00
Live Paint: Make keeps every fill and stroke row that draws on the source
Object > Live Paint > Make gave each new face and edge one fresh row built from only the source's topmost fill or stroke paint and the top stroke's weight. A lower row that draws was lost, a hidden top row became visible, and the rows' opacity, blending mode, effects and stroke settings were dropped. - A 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, in order with their settings. A source in front whose fill does not draw no longer hides the fill behind it. - An edge takes every stroke row of its source that draws (also with a weight above 0). Faces and edges take the source's opacity and blending mode; gradients keep their fit on the source, and pen pressure stays with the source. - 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, 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. - Rows that draw nothing stay with the stored sources, which Release returns unchanged. Merge and Gap Options build their new parts the same way. Docs: livePaint.make params, docs/mcp.md, docs/target-app-parity.md.
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, 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,256 @@ 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:
|
||||
/// dashes, arrowheads, a width profile or a brush?
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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. `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;
|
||||
let whole = if open { starts && ends } else { edge_closed };
|
||||
Some((from, starts, ends, whole))
|
||||
}
|
||||
|
||||
/// 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, 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 +696,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))
|
||||
|
||||
@@ -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};
|
||||
@@ -2597,6 +2598,237 @@ 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]);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
+13
-2
@@ -3525,8 +3525,19 @@ 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}`, `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.
|
||||
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.
|
||||
With `detect: true`, paint stops at the gaps that run
|
||||
from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a
|
||||
number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview
|
||||
|
||||
@@ -1457,6 +1457,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([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)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`.
|
||||
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 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, 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; 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_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.
|
||||
Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off.
|
||||
View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)).
|
||||
|
||||
Reference in New Issue
Block a user