mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 13:09:16 +00:00
Image Trace: Create Strokes makes a wide shape's thin features strokes and keeps its body a fill
Stroke is the widest feature that can be stroked, and it was applied to each connected shape as a whole: a thick body with thin arms stayed one filled area. Now a shape wider than Stroke somewhere keeps as its area every disc wider than Stroke that fits in it, and each piece outside those discs that reaches out of them at least one and a half times its own width (and 3 px) is traced as a stroked center line. A line's end at the area goes to the cut, so the line's cap closes the join. Corners and edge slivers stay in the area, and a piece that cannot be traced stays in it too. Shapes no wider than Stroke trace as before. Each line keeps one width; without Fills, the area is still a 1 px outline. Docs: docs/target-app-parity.md.
This commit is contained in:
+142
-12
@@ -4,7 +4,10 @@
|
||||
//! 1. **Distance**: an exact Euclidean distance transform in each blob crop gives each pixel
|
||||
//! its distance to the nearest pixel outside the blob.
|
||||
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
|
||||
//! one) is within the stroke width is a line; wider ones stay filled areas.
|
||||
//! one) is within the stroke width is a line. A wider one keeps as its area every disc wider
|
||||
//! than the stroke width that fits in it; each piece outside those discs that reaches out of
|
||||
//! them at least one and a half times its own width (and 3 px) is a thin feature of it, and a
|
||||
//! line, whose end at the area goes to the cut, its cap reaching into the area.
|
||||
//! 3. **Skeleton**: Guo–Hall thins the crop to one pixel, preserving diagonal ends, then
|
||||
//! staircase corners that don't connect anything are dropped.
|
||||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||||
@@ -93,20 +96,111 @@ pub(crate) fn lines(
|
||||
cells_left = left;
|
||||
let Some(dt) = distance::squared(&g.on, g.w, g.h) else { continue };
|
||||
let widest = 2.0 * f64::from(dt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0;
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(g, &dt, comp.len(), max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
if let Some(v) = thin.get_mut(i) {
|
||||
*v = true;
|
||||
let found = if widest > max_width {
|
||||
// A wider shape keeps its wide part as an area; its thin features are lines.
|
||||
arm_lines(&g, &dt, w, max_width, img, ink, style, &mut cells_left)
|
||||
} else {
|
||||
trace_line(g, &dt, comp.len(), max_width, img, ink, style, None).map(|line| (comp, line)).into_iter().collect()
|
||||
};
|
||||
for (pixels, line) in found {
|
||||
for &i in &pixels {
|
||||
if let Some(v) = thin.get_mut(i) {
|
||||
*v = true;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
(thin, out)
|
||||
}
|
||||
|
||||
/// The thin features of component `g` (wider than `max_width` somewhere; `dt` its distances), traced
|
||||
/// as lines, each with its pixels (indices of the `w`-wide image). A feature that cannot be traced, or
|
||||
/// past the work left in `cells_left`, stays part of the area.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn arm_lines(
|
||||
g: &Grid,
|
||||
dt: &[f32],
|
||||
w: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
cells_left: &mut usize,
|
||||
) -> Vec<(Vec<usize>, Line)> {
|
||||
let mut out = vec![];
|
||||
for arm in arms(g, dt, w, max_width).unwrap_or_default() {
|
||||
let pixels: Vec<usize> = arm.iter().map(|&(i, _)| i).collect();
|
||||
let Some(ag) = Grid::from_component(&pixels, w) else { continue };
|
||||
let Some(left) = cells_left.checked_sub(ag.w.saturating_mul(ag.h)) else { break };
|
||||
*cells_left = left;
|
||||
let mut cut = vec![false; ag.on.len()];
|
||||
for &(i, at_cut) in &arm {
|
||||
if let Some(c) = ag.index(i, w).and_then(|k| cut.get_mut(k)) {
|
||||
*c = at_cut;
|
||||
}
|
||||
}
|
||||
let Some(adt) = distance::squared(&ag.on, ag.w, ag.h) else { continue };
|
||||
if 2.0 * f64::from(adt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0 > max_width {
|
||||
continue;
|
||||
}
|
||||
if let Some(line) = trace_line(ag, &adt, pixels.len(), max_width, img, ink, style, Some(&cut)) {
|
||||
out.push((pixels, line));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The thin features of component `g`, which is wider than `max_width` somewhere (`dt` its squared
|
||||
/// distances to the paper): the pieces of it that lie outside every disc wider than `max_width`
|
||||
/// that fits in it (the component opened by such a disc) and reach out of those discs at least one
|
||||
/// and a half times their own width (and 3 px). The rest, with the slivers and corners the discs
|
||||
/// leave, is the area. Each piece is a list of its pixels (indices of the `w`-wide image), each
|
||||
/// marked when it touches the area (where the line is cut from it).
|
||||
fn arms(g: &Grid, dt: &[f32], w: usize, max_width: f64) -> Option<Vec<Vec<(usize, bool)>>> {
|
||||
let r = (max_width + 1.0) / 2.0;
|
||||
let seeds: Vec<bool> = dt.iter().map(|&d| f64::from(d) > r * r).collect();
|
||||
let near = distance::to_seeds(&seeds, g.w, g.h)?;
|
||||
drop(seeds);
|
||||
// (Half a pixel more than the discs' radius, so pixels the discs cut through are area.)
|
||||
let cover = r + 0.5;
|
||||
let rest = |i: usize| g.on.get(i) == Some(&true) && near.get(i).is_some_and(|&d| f64::from(d) > cover * cover);
|
||||
let mut seen = vec![false; g.on.len()];
|
||||
let (mut out, mut stack) = (vec![], vec![]);
|
||||
for start in g.interior() {
|
||||
if !rest(start) || seen.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut piece = vec![];
|
||||
*seen.get_mut(start)? = true;
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
let mut at_cut = false;
|
||||
for j in g.neighbours(i) {
|
||||
if !rest(j) {
|
||||
at_cut = true;
|
||||
} else if !seen.get(j).copied().unwrap_or(true) {
|
||||
*seen.get_mut(j)? = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
piece.push((i, at_cut));
|
||||
}
|
||||
let deepest = piece.iter().filter_map(|&(i, _)| dt.get(i)).copied().fold(0.0f32, f32::max);
|
||||
let width = (2.0 * f64::from(deepest).sqrt() - 1.0).max(1.0);
|
||||
let farthest = piece.iter().filter_map(|&(i, _)| near.get(i)).copied().fold(0.0f32, f32::max);
|
||||
if f64::from(farthest).sqrt() - cover < (1.5 * width).max(3.0) {
|
||||
continue;
|
||||
}
|
||||
let image = |i: usize| {
|
||||
let (x, y) = (usize::try_from(g.x0 + (i % g.w) as i64).ok()?, usize::try_from(g.y0 + (i / g.w) as i64).ok()?);
|
||||
y.checked_mul(w)?.checked_add(x)
|
||||
};
|
||||
out.push(piece.into_iter().map(|(i, at_cut)| Some((image(i)?, at_cut))).collect::<Option<Vec<_>>>()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
|
||||
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
@@ -163,6 +257,23 @@ impl Grid {
|
||||
let comp: Vec<_> = mask.iter().enumerate().filter_map(|(i, &on)| on.then_some(i)).collect();
|
||||
Self::from_component(&comp, w)
|
||||
}
|
||||
/// The grid index of pixel `i` of the `image_width`-wide image, when the grid holds it.
|
||||
fn index(&self, i: usize, image_width: usize) -> Option<usize> {
|
||||
let (x, y) = (i64::try_from(i % image_width.max(1)).ok()? - self.x0, i64::try_from(i / image_width.max(1)).ok()? - self.y0);
|
||||
let (x, y) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?);
|
||||
(x < self.w && y < self.h).then(|| y * self.w + x)
|
||||
}
|
||||
/// Whether a pixel `cut` marks (`cut` is laid out as the grid) has its center within `radius` of
|
||||
/// `p` (image coordinates).
|
||||
fn near_cut(&self, cut: &[bool], p: Point, radius: f64) -> bool {
|
||||
if !p.x.is_finite() || !p.y.is_finite() || !radius.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let (cx, cy) = (p.x - self.x0 as f64 - 0.5, p.y - self.y0 as f64 - 0.5);
|
||||
let span = |c: f64, n: usize| ((c - radius).floor().max(0.0) as usize, ((c + radius).ceil().max(0.0) as usize).min(n.saturating_sub(1)));
|
||||
let ((x0, x1), (y0, y1)) = (span(cx, self.w), span(cy, self.h));
|
||||
(y0..=y1).any(|y| (x0..=x1).any(|x| (x as f64 - cx).hypot(y as f64 - cy) <= radius && cut.get(y * self.w + x).copied().unwrap_or(false)))
|
||||
}
|
||||
fn offsets(&self) -> [isize; 8] {
|
||||
let w = self.w as isize;
|
||||
[-w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1]
|
||||
@@ -193,8 +304,20 @@ impl Grid {
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
/// The center lines of one line component (pixel indices of the `w`-wide image). `cut`, for a thin
|
||||
/// feature of a wider shape, marks (in `g`) its pixels that touch the shape's area: an end there is
|
||||
/// not an end of the ink, and goes to the cut, its cap reaching into the area.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn trace_line(
|
||||
mut g: Grid,
|
||||
dist: &[f32],
|
||||
pixels: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
cut: Option<&[bool]>,
|
||||
) -> Option<Line> {
|
||||
thin::thin(&mut g);
|
||||
let mut graph = graph::build(&g, dist)?;
|
||||
graph.simplify(max_width)?;
|
||||
@@ -268,7 +391,9 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra
|
||||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||||
// better left where it is.)
|
||||
let before = inner - *r.pts.get(at(2 * back))?;
|
||||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||||
// (At a cut from a wider shape, where the skeleton turns along the cut.)
|
||||
let joined = cut.is_some_and(|cut| g.near_cut(cut, tip, width / 2.0 + 1.5));
|
||||
let wide = width >= 3.0 && n > 2 * back && (joined || before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot());
|
||||
let d = if wide { before } else { tip - inner };
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
@@ -282,6 +407,11 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
if joined {
|
||||
// The skeleton stops about half a width short of the cut.
|
||||
*r.pts.get_mut(at(0))? = tip + along * (width / 2.0 + 0.5);
|
||||
continue;
|
||||
}
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
|
||||
@@ -40,7 +40,21 @@ pub(super) fn squared(mask: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
if mask.len() != size || mask.iter().enumerate().any(|(i, &on)| on && (i < w || i >= size - w || i % w == 0 || i % w + 1 == w)) {
|
||||
return None;
|
||||
}
|
||||
let mut grid: Vec<f64> = mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect();
|
||||
two_passes(mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect(), w, h)
|
||||
}
|
||||
|
||||
/// Squared distances from each pixel of a validated crop to the nearest `seeds` pixel (a very large
|
||||
/// value everywhere when there is none).
|
||||
pub(super) fn to_seeds(seeds: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
let size = super::crop_size(w, h)?;
|
||||
if seeds.len() != size {
|
||||
return None;
|
||||
}
|
||||
two_passes(seeds.iter().map(|&seed| if seed { 0.0 } else { FAR }).collect(), w, h)
|
||||
}
|
||||
|
||||
/// The column pass and then the row pass over `grid` (0 at a seed, `FAR` elsewhere).
|
||||
fn two_passes(mut grid: Vec<f64>, w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
let n = w.max(h);
|
||||
let (mut f, mut d, mut envelope) = (vec![0.0; n], vec![0.0; n], Vec::with_capacity(n));
|
||||
for x in 0..w {
|
||||
@@ -97,6 +111,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distances_to_seeds_equal_brute_force() {
|
||||
let mut seed = 7u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..100 {
|
||||
let (w, h) = (3 + rnd() % 20, 3 + rnd() % 20);
|
||||
let seeds: Vec<bool> = (0..w * h).map(|_| rnd() % 9 == 0).collect();
|
||||
let d = to_seeds(&seeds, w, h).unwrap();
|
||||
for (i, &actual) in d.iter().enumerate() {
|
||||
let expected = seeds
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(_, &s)| s)
|
||||
.map(|(j, _)| {
|
||||
let dx = (i % w).abs_diff(j % w);
|
||||
let dy = (i / w).abs_diff(j / w);
|
||||
(dx * dx + dy * dy) as f32
|
||||
})
|
||||
.min_by(f32::total_cmp);
|
||||
match expected {
|
||||
Some(e) => assert_eq!(actual, e, "case {case}, pixel {i}"),
|
||||
None => assert!(actual > 1e18, "case {case}: no seed, pixel {i} at {actual}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(to_seeds(&[true; 8], 3, 3).is_none());
|
||||
assert!(to_seeds(&[], 2, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_crops_are_refused() {
|
||||
assert!(squared(&[], 0, 0).is_none());
|
||||
|
||||
@@ -114,7 +114,8 @@ pub struct TraceParams {
|
||||
pub gradients: bool,
|
||||
/// Create: trace areas as filled shapes.
|
||||
pub fills: bool,
|
||||
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
|
||||
/// Create: trace lines no wider than `stroke_width`, and such thin features of wider shapes, as
|
||||
/// stroked center lines.
|
||||
pub strokes: bool,
|
||||
/// Stroke: the widest feature (px) traced as a stroke.
|
||||
pub stroke_width: f64,
|
||||
|
||||
@@ -932,6 +932,66 @@ fn wide_areas_stay_filled_and_lines_cross_at_junctions() {
|
||||
assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill() {
|
||||
// A disc (r = 20) with a 3 px wide arm from it out to x = 140 and a 5 px wide one up to y = 5.
|
||||
let img = Raster::from_fn(160, 100, |x, y| {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let disc = (px - 50.0).hypot(py - 60.0) <= 20.0;
|
||||
let across = (py - 60.0).abs() <= 1.5 && (50.0..=140.0).contains(&px);
|
||||
let up = (px - 50.0).abs() <= 2.5 && (5.0..=60.0).contains(&py);
|
||||
if disc || across || up { BLACK } else { WHITE }
|
||||
});
|
||||
let res = trace(&img, &strokes(true));
|
||||
let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect();
|
||||
assert_eq!(fills.len(), 1, "the disc");
|
||||
let a = net_area(&fills[0].path);
|
||||
assert!((a - std::f64::consts::PI * 400.0).abs() < 90.0, "disc area {a}");
|
||||
let b = fills[0].path.bounds().unwrap();
|
||||
assert!(b.x1 < 76.0 && b.y0 > 34.0, "the fill stays the disc: {b:?}");
|
||||
let lines = stroked(&res);
|
||||
assert_eq!(lines.len(), 2, "{:?}", lines.iter().map(|(p, s)| (p.stroke, s.anchors.len())).collect::<Vec<_>>());
|
||||
let ends = |s: &vectorcraft_geom::SubPath| (s.anchors[0].p, s.anchors[s.anchors.len() - 1].p);
|
||||
let (across, up) = if ends(lines[0].1).0.y.min(ends(lines[0].1).1.y) > 50.0 { (lines[0], lines[1]) } else { (lines[1], lines[0]) };
|
||||
// The arm across: its free end where the bar ends (less its cap), its other end at the disc, the
|
||||
// cap closing the join.
|
||||
let w = across.0.stroke.unwrap();
|
||||
assert!((2.0..=4.0).contains(&w), "width {w}");
|
||||
let (p, q) = ends(across.1);
|
||||
let (near, far) = if p.x < q.x { (p, q) } else { (q, p) };
|
||||
assert!(far.distance(Point::new(140.0, 60.0)) < 4.0, "free end {far:?}");
|
||||
assert!((60.0..=72.5).contains(&near.x) && (near.y - 60.0).abs() < 1.5 && near.x - w / 2.0 <= 70.5, "joined end {near:?}");
|
||||
// The arm up, 5 px wide.
|
||||
let w = up.0.stroke.unwrap();
|
||||
assert!((4.0..=6.0).contains(&w), "width {w}");
|
||||
let (p, q) = ends(up.1);
|
||||
let (top, low) = if p.y < q.y { (p, q) } else { (q, p) };
|
||||
assert!(top.distance(Point::new(50.0, 5.0)) < 4.0, "free end {top:?}");
|
||||
assert!((38.0..=48.0).contains(&low.y) && (low.x - 50.0).abs() < 1.5 && low.y + w / 2.0 >= 40.0 - 0.5, "joined end {low:?}");
|
||||
// Strokes only: the disc is outlined and both arms stay strokes.
|
||||
let res = trace(&img, &strokes(false));
|
||||
assert!(res.paths.iter().all(|p| p.stroke.is_some()));
|
||||
assert_eq!(res.paths.iter().filter(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed).count(), 1, "the disc's outline");
|
||||
assert_eq!(stroked(&res).iter().filter(|(p, _)| p.stroke.is_some_and(|w| w > 1.5)).count(), 2, "the two arms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_shapes_without_thin_features_stay_one_fill() {
|
||||
// A square, a diamond and a disc: their corners and edges are not thin features.
|
||||
let img = Raster::from_fn(240, 90, |x, y| {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let square = (10.0..=70.0).contains(&px) && (15.0..=75.0).contains(&py);
|
||||
let diamond = (px - 120.0).abs() + (py - 45.0).abs() <= 35.0;
|
||||
let disc = (px - 200.0).hypot(py - 45.0) <= 30.0;
|
||||
if square || diamond || disc { BLACK } else { WHITE }
|
||||
});
|
||||
for width in [3.0, 6.0, 12.0] {
|
||||
let res = trace(&img, &TraceParams { stroke_width: width, ..strokes(true) });
|
||||
assert_eq!(res.paths.len(), 3, "width {width}: {:?}", res.paths.iter().map(|p| p.stroke).collect::<Vec<_>>());
|
||||
assert!(res.paths.iter().all(|p| p.stroke.is_none()), "width {width}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_settings_parse_with_defaults() {
|
||||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap();
|
||||
|
||||
@@ -1350,6 +1350,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
A blend in a `.ai` or EPS opened from its editing data keeps a Specified Distance spacing with its distance ([Blend Options](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-options-overview.html)) where VectorCraft lays out as many steps between each pair of keys as the file draws, in the same places and colors. A Smooth Color blend opens live only where VectorCraft's own Smooth Color step count equals the drawn steps, and VectorCraft often counts a different number. A blend that VectorCraft doesn't lay out as drawn keeps its drawn steps with a note (`import::ai::tests::a_blend_keeps_its_spacing`, `a_blend_of_three_keys_keeps_its_distance_between_each_pair`, `a_blend_whose_spacing_doesnt_lay_out_its_steps_keeps_them`).
|
||||
|
||||
- Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged.
|
||||
- Create Strokes decides stroke or fill per feature, as the [help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html) describes the Strokes width: the widest feature of the image that can become a stroke. A shape wider than Stroke somewhere keeps as its area every disc wider than Stroke that fits in it, and each piece outside those discs that reaches out of them at least one and a half times its own width (and 3 px) is traced as a stroked center line, its end at the area carried to the cut so the line's cap closes the join. Corners and edge slivers stay in the area. Which pieces count as features is VectorCraft's own rule. Each line still has one width; without Fills, the area is a 1 px outline as before. Tests: `thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill`, `wide_shapes_without_thin_features_stay_one_fill` and `distances_to_seeds_equal_brute_force` in `vectorcraft-trace`. The Image Trace score and estimates are unchanged.
|
||||
Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`.
|
||||
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`.
|
||||
|
||||
Reference in New Issue
Block a user