Part of #641: Image Trace: Create Strokes
Advanced › Create: Fills and/or Strokes, with Stroke (the widest line, px). With Strokes, each colour's 8-connected parts no wider than the stroke width (chamfer distance transform) trace as open centre lines instead of filled outlines: the line is thinned to one pixel (Zhang-Suen with each removal re-checked, so two-pixel lines survive; staircase corners dropped), its skeleton becomes runs between ends and junctions (junction pixels merged, spurs pruned, runs through two-way junctions joined, rings closed), and each run is simplified and fitted with curves. The stroke width is the line's mean width (pixels over length). Traced lines get round caps and joins in the document. Without Fills, wider areas become 1 px stroked outlines. `imageTrace.make` takes fills, strokes and strokeWidth, and refuses neither fills nor strokes and a non-positive width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
11fceda13e
commit
438f90c751
File diff suppressed because one or more lines are too long
|
|
@ -14,7 +14,7 @@ use std::sync::Arc;
|
|||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
|
|
@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10)}} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
|
|
@ -648,7 +648,14 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
|||
for (k, val) in src {
|
||||
o.insert(k.clone(), val.clone());
|
||||
}
|
||||
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
|
||||
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?;
|
||||
if !params.fills && !params.strokes {
|
||||
return Err(bad("imageTrace.make", "Create needs fills, strokes or both"));
|
||||
}
|
||||
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
|
||||
return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels"));
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
|
||||
|
|
@ -692,6 +699,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
|||
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
|
||||
let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
|
||||
// Document units per raster pixel (stroke widths).
|
||||
let unit = xf.determinant().abs().sqrt();
|
||||
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
|
||||
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
|
||||
let id = s.edit(label, |d, sel| {
|
||||
|
|
@ -714,7 +723,19 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
|||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])));
|
||||
let paint = Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2]));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round ends and corners, as drawn lines have.
|
||||
Some(width) => Appearance {
|
||||
items: vec![AppearanceItem::Stroke(StrokeLayer {
|
||||
cap: LineCap::Round,
|
||||
join: LineJoin::Round,
|
||||
..StrokeLayer::new(paint, width * unit)
|
||||
})],
|
||||
..Default::default()
|
||||
},
|
||||
None => fill_only(paint),
|
||||
};
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
|
|
@ -669,6 +669,39 @@ fn image_trace_make_and_expand_and_color_presets() {
|
|||
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_create_strokes_makes_stroked_centre_lines() {
|
||||
let mut s = session();
|
||||
// A 3 px wide horizontal line from x = 10 to 90 at y = 50, placed at 2× from (100, 100).
|
||||
let r =
|
||||
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
|
||||
add_image(&mut s, &r, Affine::translate((100.0, 100.0)) * Affine::scale(2.0));
|
||||
let res = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}})).unwrap();
|
||||
assert_eq!(res["paths"], 1);
|
||||
let g = NodeId(res["id"].as_u64().unwrap());
|
||||
let paths = traced_paths(&node(&s, g));
|
||||
let line = &paths[0];
|
||||
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
|
||||
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
|
||||
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
|
||||
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
|
||||
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
let sp = &line.path_data().unwrap().subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
|
||||
assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}");
|
||||
let t = node(&s, g).trace.unwrap();
|
||||
assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0)));
|
||||
// Expand keeps the stroked line.
|
||||
s.execute("imageTrace.expand", &json!({})).unwrap();
|
||||
assert!(node(&s, g).children().unwrap()[0].appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
|
||||
// Create needs Fills or Strokes, and a stroke width.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for p in [json!({"fills": false, "strokes": false}), json!({"strokes": true, "strokeWidth": 0}), json!({"strokeWidth": -3})] {
|
||||
assert!(s.execute("imageTrace.make", &json!({"id": g.0, "params": p})).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_presets_query_and_errors() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -296,7 +296,8 @@ Method:
|
|||
1. open_file the image: it becomes a document of its own pixel size.
|
||||
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
|
||||
color, then threshold, colors, paths, corners, noise, method abutting or overlapping,
|
||||
ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers.
|
||||
ignoreWhite, snapCurvesToLines; for line art, strokes: true (with strokeWidth, the widest
|
||||
line in pixels) traces lines as stroked centre lines instead of filled outlines. Run imageTrace.presets to see every preset with its numbers.
|
||||
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
|
||||
near 90.
|
||||
3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked
|
||||
//! centre lines instead of filled outlines.
|
||||
//!
|
||||
//! 1. **Distance**: a chamfer (3-4) distance transform gives each pixel its distance to the nearest
|
||||
//! pixel outside the layer.
|
||||
//! 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.
|
||||
//! 3. **Skeleton**: the line is thinned to one pixel (Zhang–Suen, applied pixel by pixel so it
|
||||
//! keeps its topology and two-pixel-wide lines don't vanish), then staircase corners that
|
||||
//! don't connect anything are dropped.
|
||||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||||
//! merged into one point) become polylines through the pixel centres; rings without ends
|
||||
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
|
||||
//! on either side of a junction left with two of them are joined.
|
||||
//! 5. **Curves**: each polyline is fitted like an outline ([`fit::fit_polyline`]); the stroke
|
||||
//! width is the line's mean width (its pixel count over its length).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::fit::{self, FitOptions};
|
||||
|
||||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||||
pub(crate) struct Line {
|
||||
pub path: PathData,
|
||||
/// Stroke width in pixels.
|
||||
pub width: f64,
|
||||
pub pixels: usize,
|
||||
}
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||||
pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f64, o: &FitOptions) -> (Vec<bool>, Vec<Line>) {
|
||||
let mut thin = vec![false; labels.len()];
|
||||
let mut out = vec![];
|
||||
if w == 0 || h == 0 || labels.len() != w * h {
|
||||
return (thin, out);
|
||||
}
|
||||
let mask = |i: usize| labels.get(i) == Some(&label);
|
||||
let dt = distance(&mask, w, h);
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![];
|
||||
for start in 0..w * h {
|
||||
if !mask(start) || seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut comp = vec![];
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
comp.push(i);
|
||||
for j in neighbours(i, w, h) {
|
||||
if mask(j) && !seen[j] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
let widest = 2.0 * f64::from(comp.iter().map(|&i| dt[i]).max().unwrap_or(0)) / 3.0 - 1.0;
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(&comp, w, max_width, o) else { continue };
|
||||
for &i in &comp {
|
||||
thin[i] = true;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
(thin, 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);
|
||||
const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
|
||||
AROUND.into_iter().filter_map(move |(dx, dy)| {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
(nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize)
|
||||
})
|
||||
}
|
||||
|
||||
/// Chamfer 3-4 distance (3 per pixel step, saturating) of each pixel in `mask` to the nearest
|
||||
/// pixel outside it; outside the image counts as outside.
|
||||
fn distance(mask: &impl Fn(usize) -> bool, w: usize, h: usize) -> Vec<u16> {
|
||||
let mut d = vec![0u16; w * h];
|
||||
let at = |d: &[u16], x: i64, y: i64| {
|
||||
if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0 } else { d.get(y as usize * w + x as usize).copied().unwrap_or(0) }
|
||||
};
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if mask(i) {
|
||||
let (x, y) = (x as i64, y as i64);
|
||||
let steps = [(x - 1, y, 3), (x - 1, y - 1, 4), (x, y - 1, 3), (x + 1, y - 1, 4)];
|
||||
d[i] = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in (0..h).rev() {
|
||||
for x in (0..w).rev() {
|
||||
let i = y * w + x;
|
||||
if mask(i) {
|
||||
let (xi, yi) = (x as i64, y as i64);
|
||||
let steps = [(xi + 1, yi, 3), (xi + 1, yi + 1, 4), (xi, yi + 1, 3), (xi - 1, yi + 1, 4)];
|
||||
let v = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||||
d[i] = d[i].min(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A grid around one line: its pixels with a one-pixel empty border.
|
||||
struct Grid {
|
||||
on: Vec<bool>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Image position of grid pixel (0, 0).
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// The 8 neighbours (N, NE, E, SE, S, SW, W, NW) of interior pixel `i`.
|
||||
fn ring(&self, i: usize) -> [bool; 8] {
|
||||
let w = self.w;
|
||||
let g = |j: usize| self.on.get(j).copied().unwrap_or(false);
|
||||
[g(i - w), g(i - w + 1), g(i + 1), g(i + w + 1), g(i + w), g(i + w - 1), g(i - 1), g(i - w - 1)]
|
||||
}
|
||||
fn interior(&self) -> impl Iterator<Item = usize> + use<> {
|
||||
let (w, h) = (self.w, self.h);
|
||||
(1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x))
|
||||
}
|
||||
fn neighbours(&self, i: usize) -> impl Iterator<Item = usize> + '_ {
|
||||
let w = self.w;
|
||||
[i - w, i - w + 1, i + 1, i + w + 1, i + w, i + w - 1, i - 1, i - w - 1].into_iter().filter(|&j| self.on.get(j).copied().unwrap_or(false))
|
||||
}
|
||||
fn degree(&self, i: usize) -> usize {
|
||||
self.neighbours(i).count()
|
||||
}
|
||||
/// The centre of pixel `i` in image coordinates.
|
||||
fn centre(&self, i: usize) -> Point {
|
||||
Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zhang–Suen thinning. Each sub-iteration picks the pixels to remove from the same state, so the
|
||||
/// skeleton stays centred; they are then removed one by one, each only if it still doesn't
|
||||
/// disconnect anything, so a two-pixel-wide line thins to one pixel instead of vanishing.
|
||||
fn thin(g: &mut Grid) {
|
||||
let removable = |p: &[bool; 8]| {
|
||||
let b = p.iter().filter(|v| **v).count();
|
||||
let a = (0..8).filter(|&k| !p[k] && p[(k + 1) % 8]).count();
|
||||
(2..=6).contains(&b) && a == 1
|
||||
};
|
||||
let mut picked = vec![];
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for step in 0..2 {
|
||||
picked.clear();
|
||||
for i in g.interior() {
|
||||
if !g.on[i] {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
let (n, e, s, w) = (p[0], p[2], p[4], p[6]);
|
||||
let side = if step == 0 { !(e && s && (n || w)) } else { !(n && w && (e || s)) };
|
||||
if side && removable(&p) {
|
||||
picked.push(i);
|
||||
}
|
||||
}
|
||||
for &i in &picked {
|
||||
if removable(&g.ring(i)) {
|
||||
g.on[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Staircase corners: a pixel whose neighbours stay connected without it (Yokoi's
|
||||
// 8-connectivity number 1) and that doesn't end the line.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for i in g.interior() {
|
||||
if !g.on[i] || g.degree(i) < 2 {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
// x1 = E, x2 = NE, x3 = N, x4 = NW, x5 = W, x6 = SW, x7 = S, x8 = SE.
|
||||
let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]];
|
||||
let off = |k: usize| !x[k % 8];
|
||||
let c8: usize = [0, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum();
|
||||
if c8 == 1 {
|
||||
g.on[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of skeleton pixels between two nodes (`None`: none, a ring).
|
||||
#[derive(Clone)]
|
||||
struct Run {
|
||||
pts: Vec<Point>,
|
||||
ends: [Option<usize>; 2],
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn length(&self) -> f64 {
|
||||
self.pts.windows(2).map(|p| p[0].distance(p[1])).sum::<f64>()
|
||||
+ if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Option<Line> {
|
||||
let xs = comp.iter().map(|&i| i % w);
|
||||
let ys = comp.iter().map(|&i| i / w);
|
||||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||||
let (gw, gh) = (x1 - x0 + 3, y1 - y0 + 3);
|
||||
let mut g = Grid { on: vec![false; gw * gh], w: gw, h: gh, x0: x0 as i64 - 1, y0: y0 as i64 - 1 };
|
||||
for &i in comp {
|
||||
g.on[(i / w - y0 + 1) * gw + (i % w - x0 + 1)] = true;
|
||||
}
|
||||
thin(&mut g);
|
||||
let skeleton: Vec<usize> = g.interior().filter(|&i| g.on[i]).collect();
|
||||
if skeleton.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// Nodes: line ends, and junctions with the junction pixels touching them.
|
||||
let mut node = vec![usize::MAX; g.on.len()];
|
||||
let mut centres: Vec<Point> = vec![];
|
||||
for &i in &skeleton {
|
||||
let d = g.degree(i);
|
||||
if d == 2 || node[i] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let id = centres.len();
|
||||
let mut members = vec![i];
|
||||
node[i] = id;
|
||||
if d > 2 {
|
||||
let mut k = 0;
|
||||
while let Some(&p) = members.get(k) {
|
||||
k += 1;
|
||||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||||
if node[q] == usize::MAX && g.degree(q) > 2 {
|
||||
node[q] = id;
|
||||
members.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sum = members.iter().fold(Point::ZERO, |s, &m| s + g.centre(m).to_vec2());
|
||||
centres.push(Point::new(sum.x / members.len() as f64, sum.y / members.len() as f64));
|
||||
}
|
||||
// Runs from each node, then the rings left.
|
||||
let mut used: HashSet<(usize, usize)> = HashSet::new();
|
||||
let edge = |a: usize, b: usize| (a.min(b), a.max(b));
|
||||
let mut runs: Vec<Run> = vec![];
|
||||
for &p in skeleton.iter().filter(|&&i| node[i] != usize::MAX) {
|
||||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||||
if node[q] == node[p] || !used.insert(edge(p, q)) {
|
||||
continue;
|
||||
}
|
||||
let mut pts = vec![centres[node[p]], g.centre(q)];
|
||||
let (mut prev, mut cur) = (p, q);
|
||||
while node[cur] == usize::MAX {
|
||||
let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) else { break };
|
||||
used.insert(edge(cur, next));
|
||||
pts.push(g.centre(next));
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
let end = (node[cur] != usize::MAX).then_some(node[cur]);
|
||||
if let (Some(e), Some(last)) = (end, pts.last_mut()) {
|
||||
*last = centres[e];
|
||||
}
|
||||
runs.push(Run { pts, ends: [Some(node[p]), end], closed: false });
|
||||
}
|
||||
}
|
||||
for &s in &skeleton {
|
||||
if node[s] != usize::MAX || g.neighbours(s).any(|n| used.contains(&edge(s, n))) {
|
||||
continue;
|
||||
}
|
||||
let mut pts = vec![g.centre(s)];
|
||||
let (mut prev, mut cur) = (usize::MAX, s);
|
||||
let mut closed = false;
|
||||
while let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) {
|
||||
used.insert(edge(cur, next));
|
||||
if next == s {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
pts.push(g.centre(next));
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
runs.push(Run { pts, ends: [None, None], closed });
|
||||
}
|
||||
// The line's mean width: its pixels over its length (the skeleton stops about half a width
|
||||
// short of each end).
|
||||
let width = |runs: &[Run]| {
|
||||
let length: f64 = runs.iter().map(Run::length).sum();
|
||||
let px = comp.len() as f64;
|
||||
((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0))
|
||||
};
|
||||
let spur = width(&runs);
|
||||
let runs = join(prune(runs, centres.len(), spur), centres.len());
|
||||
let width = width(&runs);
|
||||
let subs: Vec<_> = runs.iter().filter_map(|r| fit::fit_polyline(&r.pts, r.closed, o)).collect();
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
|
||||
}
|
||||
|
||||
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
|
||||
/// to a junction that keeps at least two other runs.
|
||||
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
|
||||
let mut degree = vec![0usize; nodes];
|
||||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||||
degree[*e] += 1;
|
||||
}
|
||||
let mut order: Vec<usize> = (0..runs.len()).collect();
|
||||
order.sort_by(|a, b| runs[*a].length().total_cmp(&runs[*b].length()));
|
||||
let mut keep = vec![true; runs.len()];
|
||||
for i in order {
|
||||
let r = &runs[i];
|
||||
let [Some(a), Some(b)] = r.ends else { continue };
|
||||
let (tip, fork) = if degree[a] == 1 { (a, b) } else { (b, a) };
|
||||
if degree[tip] == 1 && degree[fork] >= 3 && r.length() < width.max(2.0) {
|
||||
keep[i] = false;
|
||||
degree[tip] -= 1;
|
||||
degree[fork] -= 1;
|
||||
}
|
||||
}
|
||||
runs.into_iter().zip(keep).filter_map(|(r, k)| k.then_some(r)).collect()
|
||||
}
|
||||
|
||||
/// The runs joined through every node exactly two of them meet at (a run meeting itself closes).
|
||||
fn join(runs: Vec<Run>, nodes: usize) -> Vec<Run> {
|
||||
let mut runs: Vec<Option<Run>> = runs.into_iter().map(Some).collect();
|
||||
for n in 0..nodes {
|
||||
let at: Vec<(usize, usize)> = runs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, r)| r.iter().flat_map(move |r| (0..2).filter(move |&k| r.ends[k] == Some(n)).map(move |k| (i, k))))
|
||||
.collect();
|
||||
let [(i, ki), (j, kj)] = at[..] else { continue };
|
||||
if i == j {
|
||||
if let Some(r) = runs[i].as_mut() {
|
||||
r.pts.pop();
|
||||
r.ends = [None, None];
|
||||
r.closed = r.pts.len() >= 3;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (Some(mut a), Some(mut b)) = (runs[i].take(), runs[j].take()) else { continue };
|
||||
// a ends at n, b starts at n.
|
||||
if ki == 0 {
|
||||
a.pts.reverse();
|
||||
a.ends.reverse();
|
||||
}
|
||||
if kj == 1 {
|
||||
b.pts.reverse();
|
||||
b.ends.reverse();
|
||||
}
|
||||
a.pts.extend(b.pts.into_iter().skip(1));
|
||||
a.ends[1] = b.ends[1];
|
||||
runs[i] = Some(a);
|
||||
}
|
||||
runs.into_iter().flatten().collect()
|
||||
}
|
||||
|
|
@ -133,6 +133,49 @@ pub(crate) fn fit_loop(l: &Loop, o: &FitOptions) -> Option<SubPath> {
|
|||
Some(sp)
|
||||
}
|
||||
|
||||
/// Fit a centre line through `pts` (pixel-centre points in order) into a Bézier subpath: the
|
||||
/// points within `polygon_tol` of it are dropped (Douglas–Peucker), then curves are fitted. A
|
||||
/// `closed` line runs back to its first point.
|
||||
pub(crate) fn fit_polyline(pts: &[Point], closed: bool, o: &FitOptions) -> Option<SubPath> {
|
||||
let n = pts.len();
|
||||
let first = *pts.first()?;
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut ext = pts.to_vec();
|
||||
let mut keep = vec![false; n + 1];
|
||||
keep[0] = true;
|
||||
if closed {
|
||||
// Split the ring at its first point and the point farthest from it.
|
||||
ext.push(first);
|
||||
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(first).total_cmp(&pts[j].distance_squared(first))).unwrap_or(n / 2);
|
||||
keep[far] = true;
|
||||
rdp(&ext, 0, far, o.polygon_tol, &mut keep);
|
||||
rdp(&ext, far, n, o.polygon_tol, &mut keep);
|
||||
} else {
|
||||
keep[n - 1] = true;
|
||||
rdp(&ext, 0, n - 1, o.polygon_tol, &mut keep);
|
||||
}
|
||||
let mut poly: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
|
||||
if closed && poly.len() < 3 {
|
||||
poly = pts.to_vec();
|
||||
}
|
||||
if poly.len() < if closed { 3 } else { 2 } {
|
||||
return None;
|
||||
}
|
||||
let sp = SubPath::polyline(&poly, closed);
|
||||
let fitted =
|
||||
simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false });
|
||||
let mut sp = fitted.subpaths.into_iter().next()?;
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if o.snap_lines {
|
||||
snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5));
|
||||
}
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// Retract the handles of curves that stay within `tol` of their chord.
|
||||
fn snap_to_lines(sp: &mut SubPath, tol: f64) {
|
||||
let n = sp.anchors.len();
|
||||
|
|
|
|||
|
|
@ -18,10 +18,15 @@
|
|||
//! 5. **Curves**: the polygon is fitted with cubic Béziers (`vectorcraft_pathops::simplify_with`,
|
||||
//! least squares with corner detection); optionally nearly-straight curves snap to lines.
|
||||
//!
|
||||
//! With **Create: Strokes**, the parts of a colour layer no wider than the stroke width are traced
|
||||
//! as stroked centre lines instead (see `centerline`); without Fills, wider parts become stroked
|
||||
//! outlines.
|
||||
//!
|
||||
//! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or
|
||||
//! *overlapping* (stacked: each layer also covers every layer above it, so no hairline gaps).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod centerline;
|
||||
mod contour;
|
||||
mod fit;
|
||||
mod mosaic;
|
||||
|
|
@ -152,6 +157,12 @@ pub struct TraceParams {
|
|||
pub ignore_white: bool,
|
||||
/// Replace nearly-straight curves with straight lines.
|
||||
pub snap_curves_to_lines: bool,
|
||||
/// Create: trace areas as filled shapes.
|
||||
pub fills: bool,
|
||||
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
|
||||
pub strokes: bool,
|
||||
/// Stroke: the widest feature (px) traced as a stroke.
|
||||
pub stroke_width: f64,
|
||||
}
|
||||
|
||||
impl Default for TraceParams {
|
||||
|
|
@ -167,6 +178,9 @@ impl Default for TraceParams {
|
|||
method: Method::Abutting,
|
||||
ignore_white: false,
|
||||
snap_curves_to_lines: false,
|
||||
fills: true,
|
||||
strokes: false,
|
||||
stroke_width: 10.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -250,7 +264,8 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> {
|
|||
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
|
||||
}
|
||||
|
||||
/// One traced shape: an outer contour plus its holes, filled with `color`.
|
||||
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
|
||||
/// lines stroked with it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TracedPath {
|
||||
/// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`).
|
||||
|
|
@ -258,6 +273,9 @@ pub struct TracedPath {
|
|||
pub color: [u8; 3],
|
||||
/// Filled pixel count of the component the path came from.
|
||||
pub pixels: usize,
|
||||
/// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the
|
||||
/// outlines of wider areas without Fills).
|
||||
pub stroke: Option<f64>,
|
||||
}
|
||||
|
||||
/// The traced result, bottom-most path first.
|
||||
|
|
@ -357,15 +375,37 @@ impl Layers<'_> {
|
|||
let (params, opts) = (self.params, &self.opts);
|
||||
let color = *self.q.palette.get(ci)?;
|
||||
let overlapping = params.method == Method::Overlapping;
|
||||
let (w, h) = (self.w, self.h);
|
||||
// Create Strokes: this colour's lines, left out of its areas.
|
||||
let (thin, lines) = if params.strokes {
|
||||
centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, opts)
|
||||
} else {
|
||||
(vec![], vec![])
|
||||
};
|
||||
let mask: Vec<bool> = self
|
||||
.q
|
||||
.labels
|
||||
.iter()
|
||||
.map(|&l| l != TRANSPARENT && if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci })
|
||||
.enumerate()
|
||||
.map(|(i, &l)| {
|
||||
l != TRANSPARENT
|
||||
&& !thin.get(i).copied().unwrap_or(false)
|
||||
&& if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci }
|
||||
})
|
||||
.collect();
|
||||
// Without Fills, areas are outlined with a 1 px stroke.
|
||||
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
|
||||
let min_hole = params.noise.max(1) as i64;
|
||||
let mut paths = vec![];
|
||||
for comp in trace_mask(&mask, self.w, self.h) {
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>| {
|
||||
let n = path.anchor_count();
|
||||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||||
return false;
|
||||
}
|
||||
paths.push(TracedPath { path, color, pixels, stroke });
|
||||
true
|
||||
};
|
||||
for comp in trace_mask(&mask, w, h) {
|
||||
let Some(outer) = fit::fit_loop(&comp.outer, opts) else { continue };
|
||||
let mut subs = vec![outer];
|
||||
for hole in &comp.holes {
|
||||
|
|
@ -374,12 +414,14 @@ impl Layers<'_> {
|
|||
}
|
||||
subs.extend(fit::fit_loop(hole, opts));
|
||||
}
|
||||
let path = PathData::new(subs);
|
||||
let n = path.anchor_count();
|
||||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||||
if !add(PathData::new(subs), comp.pixels, area_stroke) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for line in lines {
|
||||
if !add(line.path, line.pixels, Some(line.width)) {
|
||||
return None;
|
||||
}
|
||||
paths.push(TracedPath { path, color, pixels: comp.pixels });
|
||||
}
|
||||
Some(paths)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use super::*;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
const BLACK: [u8; 4] = [0, 0, 0, 255];
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
|
|
@ -348,3 +349,116 @@ fn an_image_too_large_to_trace_is_refused_before_decoding() {
|
|||
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}");
|
||||
assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding");
|
||||
}
|
||||
|
||||
fn strokes(fills: bool) -> TraceParams {
|
||||
TraceParams { strokes: true, fills, stroke_width: 6.0, noise: 4, ..bw() }
|
||||
}
|
||||
|
||||
/// Endpoints (first and last anchor) of each open subpath of the stroked paths.
|
||||
fn stroked(res: &TraceResult) -> Vec<(&TracedPath, &vectorcraft_geom::SubPath)> {
|
||||
res.paths.iter().filter(|p| p.stroke.is_some()).flat_map(|p| p.path.subpaths.iter().map(move |s| (p, s))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_line_traces_as_one_stroked_centre_line() {
|
||||
// A 3 px wide diagonal line from (20, 20) to (180, 100).
|
||||
let img = Raster::from_fn(200, 120, |x, y| {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let t = (((px - 20.0) * 160.0 + (py - 20.0) * 80.0) / (160.0f64.powi(2) + 80.0f64.powi(2))).clamp(0.0, 1.0);
|
||||
let (cx, cy) = (20.0 + 160.0 * t, 20.0 + 80.0 * t);
|
||||
if (px - cx).hypot(py - cy) <= 1.5 { BLACK } else { WHITE }
|
||||
});
|
||||
let res = trace(&img, &strokes(true));
|
||||
assert_eq!(res.paths.len(), 1, "{:?}", res.paths.iter().map(|p| p.stroke).collect::<Vec<_>>());
|
||||
let p = &res.paths[0];
|
||||
let width = p.stroke.expect("stroked");
|
||||
assert!((2.0..=4.0).contains(&width), "width {width}");
|
||||
assert_eq!(p.path.subpaths.len(), 1);
|
||||
let sp = &p.path.subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
|
||||
let (a, b) = if a.x < b.x { (a, b) } else { (b, a) };
|
||||
assert!(a.distance(Point::new(20.0, 20.0)) < 3.0 && b.distance(Point::new(180.0, 100.0)) < 3.0, "{a:?} … {b:?}");
|
||||
// A straight line: a few anchors, all on it.
|
||||
assert!(sp.anchors.len() <= 4, "{} anchors", sp.anchors.len());
|
||||
// Without Create Strokes, the same line is a filled outline.
|
||||
let filled = trace(&img, &bw());
|
||||
assert!(filled.paths.iter().all(|p| p.stroke.is_none()) && filled.paths[0].path.subpaths[0].closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_ring_traces_as_a_closed_centre_line() {
|
||||
let img = Raster::from_fn(120, 120, |x, y| {
|
||||
let d = (x as f64 + 0.5 - 60.0).hypot(y as f64 + 0.5 - 60.0);
|
||||
if (d - 40.0).abs() <= 1.5 { BLACK } else { WHITE }
|
||||
});
|
||||
let res = trace(&img, &strokes(true));
|
||||
let lines = stroked(&res);
|
||||
assert_eq!(lines.len(), 1, "one ring");
|
||||
let sp = lines[0].1;
|
||||
assert!(sp.closed);
|
||||
let len = PathData::single(sp.clone()).length();
|
||||
let want = std::f64::consts::TAU * 40.0;
|
||||
assert!((len - want).abs() / want < 0.05, "length {len} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_areas_stay_filled_and_lines_cross_at_junctions() {
|
||||
// A disc (r = 20) and, apart from it, a plus sign of 3 px wide bars.
|
||||
let img = Raster::from_fn(200, 100, |x, y| {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let disc = (px - 50.0).hypot(py - 50.0) <= 20.0;
|
||||
let plus = ((px - 150.0).abs() <= 1.5 && (20.0..=80.0).contains(&py)) || ((py - 50.0).abs() <= 1.5 && (120.0..=180.0).contains(&px));
|
||||
if disc || plus { 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() < 60.0, "disc area {a}");
|
||||
let lines = stroked(&res);
|
||||
assert!((2..=4).contains(&lines.len()), "{} centre lines", lines.len());
|
||||
// The centre lines stop about half a width short of each end of the bars (their round caps
|
||||
// cover it).
|
||||
let total: f64 = lines.iter().map(|(_, s)| PathData::single((*s).clone()).length()).sum();
|
||||
assert!((106.0..=120.0).contains(&total), "both bars' length: {total}");
|
||||
let ends: Vec<Point> = lines.iter().flat_map(|(_, s)| [s.anchors[0].p, s.anchors[s.anchors.len() - 1].p]).collect();
|
||||
for want in [Point::new(150.0, 20.0), Point::new(150.0, 80.0), Point::new(120.0, 50.0), Point::new(180.0, 50.0)] {
|
||||
assert!(ends.iter().any(|e| e.distance(want) < 4.0), "a line ends near {want:?}: {ends:?}");
|
||||
}
|
||||
// Strokes only: the disc is outlined.
|
||||
let res = trace(&img, &strokes(false));
|
||||
assert!(res.paths.iter().all(|p| p.stroke.is_some()));
|
||||
assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_settings_parse_with_defaults() {
|
||||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap();
|
||||
assert!(p.fills && p.strokes && p.stroke_width == 4.0);
|
||||
let d = TraceParams::default();
|
||||
assert!(d.fills && !d.strokes && d.stroke_width == 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_strokes_survives_noise_and_tiny_images() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let noise: Vec<u8> = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect();
|
||||
let noise = Raster::new(120, 120, noise);
|
||||
for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] {
|
||||
for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] {
|
||||
for noise in [0, 1, 4] {
|
||||
let p = TraceParams { strokes: true, fills, stroke_width: width, noise, ..TraceParams::default() };
|
||||
for t in trace(&img, &p).paths {
|
||||
assert!(t.stroke.is_none_or(|w| w.is_finite() && w >= 1.0), "{:?}", t.stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1566,6 +1566,9 @@
|
|||
No text selected No hay texto seleccionado
|
||||
No Thumbnails Sin miniaturas
|
||||
Noise Ruido
|
||||
Create: Crear:
|
||||
Fills Rellenos
|
||||
Strokes Trazos
|
||||
Nonbreaking Hyphen Guion de no separación
|
||||
Nonbreaking Space Espacio de no separación
|
||||
None Ninguno
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1563,6 +1563,9 @@
|
|||
No text selected Aucun texte sélectionné
|
||||
No Thumbnails Sans vignettes
|
||||
Noise Bruit
|
||||
Create: Créer :
|
||||
Fills Fonds
|
||||
Strokes Contours
|
||||
Nonbreaking Hyphen Trait d’union insécable
|
||||
Nonbreaking Space Espace insécable
|
||||
None Aucun
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1566,6 +1566,9 @@
|
|||
No text selected Nessun testo selezionato
|
||||
No Thumbnails Nessuna miniatura
|
||||
Noise Disturbo
|
||||
Create: Crea:
|
||||
Fills Riempimenti
|
||||
Strokes Tracce
|
||||
Nonbreaking Hyphen Trattino indivisibile
|
||||
Nonbreaking Space Spazio indivisibile
|
||||
None Nessuno
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1696,6 +1696,9 @@
|
|||
No symbols in this document このドキュメントにシンボルはありません
|
||||
No text selected テキストが選択されていません
|
||||
Noise ノイズ
|
||||
Create: 作成:
|
||||
Fills 塗り
|
||||
Strokes 線
|
||||
None なし
|
||||
None. なし。
|
||||
Normal 通常
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1562,6 +1562,9 @@
|
|||
No text selected Текст не выделен
|
||||
No Thumbnails Без миниатюр
|
||||
Noise Шум
|
||||
Create: Создать:
|
||||
Fills Заливки
|
||||
Strokes Обводки
|
||||
Nonbreaking Hyphen Неразрывный дефис
|
||||
Nonbreaking Space Неразрывный пробел
|
||||
None Нет
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1541,6 +1541,9 @@
|
|||
No text selected 未选择文字
|
||||
No Thumbnails 无缩略图
|
||||
Noise 杂色
|
||||
Create: 创建:
|
||||
Fills 填色
|
||||
Strokes 描边
|
||||
Nonbreaking Hyphen 不断行连字号
|
||||
Nonbreaking Space 不断行空格
|
||||
None 无
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1583,6 +1583,9 @@
|
|||
No text selected 未選取文字
|
||||
No Thumbnails 無縮圖
|
||||
Noise 雜訊
|
||||
Create: 建立:
|
||||
Fills 填色
|
||||
Strokes 筆畫
|
||||
Nonbreaking Hyphen 不斷行連字號
|
||||
Nonbreaking Space 不斷行空格
|
||||
None 無
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -1,4 +1,5 @@
|
|||
//! Image Trace panel: preset, mode, threshold or colour count, and the advanced fidelity options.
|
||||
//! Image Trace panel: preset, mode, threshold or colour count, and the advanced options (fidelity,
|
||||
//! method, Create: Fills / Strokes with the widest line stroked).
|
||||
//! With an Image Trace object selected, changing a setting re-traces it (one undo step per change;
|
||||
//! sliders apply when released). With an image selected, Trace makes a new Image Trace object.
|
||||
|
||||
|
|
@ -207,6 +208,27 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
});
|
||||
// Create: Fills and/or Strokes (one of them stays on), and the widest line stroked.
|
||||
let create = |p: &Value, key: &str| p[key].as_bool().unwrap_or(key == "fills");
|
||||
let strokes = create(&st.params, "strokes");
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Create:"));
|
||||
for (key, other, label) in [("fills", "strokes", tl!("Fills")), ("strokes", "fills", tl!("Strokes"))] {
|
||||
let on = create(&st.params, key);
|
||||
if widgets::check(ui, label, on, !on || create(&st.params, other)) {
|
||||
st.params[key] = json!(!on);
|
||||
st.preset = "Custom".into();
|
||||
retrace = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut v = st.params["strokeWidth"].as_f64().unwrap_or(10.0);
|
||||
let (changed, release) = ui.add_enabled_ui(strokes, |ui| slider(ui, tl!("Stroke"), &mut v, 1.0..=100.0, " px")).inner;
|
||||
if changed {
|
||||
st.params["strokeWidth"] = json!(v.round());
|
||||
st.preset = "Custom".into();
|
||||
}
|
||||
retrace |= release;
|
||||
for (key, label) in [("snapCurvesToLines", tl!("Snap Curves To Lines")), ("ignoreWhite", tl!("Ignore White"))] {
|
||||
let on = st.params[key].as_bool().unwrap_or(false);
|
||||
if widgets::check(ui, label, on, true) {
|
||||
|
|
@ -258,6 +280,57 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, PointerButton, Pos2, Rect, vec2};
|
||||
|
||||
/// One headless frame of the panel (and its menu) with `events`: the texts drawn, with where.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Click the panel's `label` (the first one drawn).
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
|
||||
let texts = frame(app, ctx, vec![]);
|
||||
let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}"));
|
||||
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, vec![Event::PointerMoved(at), press(true)]);
|
||||
frame(app, ctx, vec![press(false)]);
|
||||
}
|
||||
|
||||
fn traced_app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&png);
|
||||
app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap();
|
||||
app.run("imageTrace.make", json!({"preset": "Default"})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_strokes_traces_again_and_keeps_one_kind_on() {
|
||||
let mut app = traced_app();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let create = |app: &VectorcraftApp| target(app).2.map(|t| (t["params"]["fills"].clone(), t["params"]["strokes"].clone()));
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
for s in ["Create:", "Fills", "Strokes", "Stroke", "10"] {
|
||||
assert!(texts.iter().any(|(t, _)| t == s), "{s}: {texts:?}");
|
||||
}
|
||||
// Fills alone can't be turned off.
|
||||
click(&mut app, &ctx, "Fills");
|
||||
assert_eq!(create(&app), Some((json!(true), json!(false))));
|
||||
click(&mut app, &ctx, "Strokes");
|
||||
assert_eq!(create(&app), Some((json!(true), json!(true))), "traced again with strokes");
|
||||
click(&mut app, &ctx, "Fills");
|
||||
assert_eq!(create(&app), Some((json!(false), json!(true))));
|
||||
click(&mut app, &ctx, "Strokes");
|
||||
assert_eq!(create(&app), Some((json!(false), json!(true))), "Strokes alone stays on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_and_menu_draw_headless() {
|
||||
|
|
@ -266,6 +339,7 @@ mod tests {
|
|||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
for _ in 0..2 {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(&mut app, ui);
|
||||
menu(&mut app, ui);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ fn labels(items: &[Item]) -> Vec<&'static str> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> {
|
||||
pub(crate) fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> {
|
||||
fn walk(s: &Shape, v: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
|
|
|
|||
Loading…
Reference in New Issue