Merge main into fix/monitor-profile-after-blending

This commit is contained in:
dotCooCoo
2026-11-14 09:50:27 -08:00
125 changed files with 9906 additions and 542 deletions
+3 -1
View File
@@ -428,7 +428,9 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result<DxfOutput, String> {
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return Err("the export region is not finite".into());
}
let baked = vectorcraft_effects::bake_document(doc);
// Symbols' art is placed without clipping, as the scene places it.
let baked =
vectorcraft_effects::bake_document_placing_symbols(doc, false, vectorcraft_effects::WrittenLayers { hidden: true, non_printing: true });
let doc = baked.as_ref().unwrap_or(doc);
Ok(scene::Scene::new(doc, opts).run())
}
+23
View File
@@ -1410,6 +1410,29 @@ fn a_stained_instance_is_written_in_its_stained_color() {
assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}");
}
#[test]
fn symbol_art_effects_are_written_where_they_draw() {
// A line and the symbol's line below it, each moved 100 pt right by a Transform effect, start
// at the same x.
let moved = |mut n: Node| {
n.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 100.0}),
visible: true,
});
n
};
let mut d = doc_with(|d| {
let line = path(d, shapes::line((10.0, 10.0).into(), (20.0, 10.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0);
vec![moved(line), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]
});
let art = path(&mut d, shapes::line((10.0, 40.0).into(), (20.0, 40.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) });
let starts: Vec<f64> = entities(&export(&d, &opts(&d)).unwrap()).iter().map(|e| all(e, 10).into_iter().fold(f64::INFINITY, f64::min)).collect();
assert_eq!(starts.len(), 2, "{starts:?}");
assert!((starts[0] - starts[1]).abs() < 1e-6, "{starts:?}");
}
#[test]
fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() {
// Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty
+38 -9
View File
@@ -627,9 +627,8 @@ fn reoriented<V: Copy>(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wr
out
}
/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap
/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its
/// arrowheads.
/// A stroke read along its subpath turned as `o` says: its width profile (whose sides swap when
/// reversed), pen pressure, tilt, bearing and rotation. Its arrowheads stay as they are.
fn reorient_stroke(s: &mut StrokeLayer, o: Orient) {
if o.is_identity() {
return;
@@ -669,15 +668,45 @@ fn reorient_stroke(s: &mut StrokeLayer, o: Orient) {
let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same);
p.points = out;
}
if o.reversed && !o.closed {
std::mem::swap(&mut s.start_arrow, &mut s.end_arrow);
s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0);
}
/// `n` with every stroke read along its subpaths turned as `o` says, and on a reversed open path
/// its arrowheads swapped.
fn reorient_strokes(n: &mut Node, o: Orient) {
if o.is_identity() {
return;
}
for it in n.appearance.items.iter_mut() {
if let AppearanceItem::Stroke(s) = it {
reorient_stroke(s, o);
if o.reversed && !o.closed {
std::mem::swap(&mut s.start_arrow, &mut s.end_arrow);
s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0);
}
}
}
}
/// `n` with every stroke read along its subpaths turned as `o` says.
fn reorient_strokes(n: &mut Node, o: Orient) {
if o.is_identity() {
/// Turn the strokes of path `n` for Reverse Path Direction, before its subpaths flagged in
/// `reversed` are reversed with [`SubPath::reverse`] (a closed one then starts at its last
/// anchor): each stroke's width profile (sides swapped), pen pressure, tilt, bearing and rotation
/// then run the new way, so the stroke draws as it did. A stroke reads one profile along every
/// subpath, so it turns only when all of them turn alike. Arrowheads stay at the path's start and
/// end.
pub fn reverse_stroke_records(n: &mut Node, reversed: &[bool]) {
let NodeKind::Path { path, .. } = &n.kind else { return };
let mut turns = path.subpaths.iter().enumerate().map(|(i, sp)| {
let mut o = Orient::of(sp);
if reversed.get(i).copied().unwrap_or(false) {
if sp.closed {
o.start_at(sp, sp.anchors.len().saturating_sub(1));
}
o.reverse();
}
o
});
let Some(o) = turns.next() else { return };
if o.is_identity() || !turns.all(|t| t.same_turn(&o)) {
return;
}
for it in n.appearance.items.iter_mut() {
+58
View File
@@ -251,6 +251,45 @@ fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool {
heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH))
}
/// How deep the art of a document's symbols and patterns draws, to measure objects that use them
/// outside the document's layers ([`Self::deepest`]).
#[derive(Clone, Debug, Default)]
pub struct DrawnHeights {
symbols: HashMap<String, usize>,
patterns: HashMap<String, usize>,
}
impl DrawnHeights {
/// The heights of `doc`'s symbols and patterns, as [`Document::too_deep`] counts them: the art
/// is 1 deep, and what it uses is counted under it. A symbol or pattern that uses itself, or
/// draws deeper than [`MAX_DRAWN_DEPTH`], has none.
pub fn of(doc: &Document) -> Self {
let mut out = Self::default();
for ((pattern, name), h) in heights(doc) {
if let Some(h) = h {
let map = if pattern { &mut out.patterns } else { &mut out.symbols };
map.insert(name.to_string(), h);
}
}
out
}
/// The deepest `n`, placed `depth` deep, draws: its own objects and the art of their opacity
/// masks, and under the objects the art of the symbols and patterns they use. A use of a
/// symbol or pattern without a height ([`Self::of`]) counts as the object alone. `n`'s own
/// nesting may pass [`MAX_DEPTH`]. Without recursion.
pub fn deepest(&self, n: &Node, depth: usize) -> usize {
let mut out = depth;
let mut stack = vec![(n, depth)];
while let Some((n, d)) = stack.pop() {
let used = uses(n).filter_map(|(pattern, name)| if pattern { self.patterns.get(name) } else { self.symbols.get(name) }).max();
out = out.max(d + used.copied().unwrap_or(0));
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1)));
}
out
}
}
/// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object):
/// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees
/// within [`MAX_DEPTH`].
@@ -623,4 +662,23 @@ mod tests {
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")]));
assert!(!d.too_deep(None) && !d.limit_depth());
}
#[test]
fn drawn_heights_measure_objects_outside_the_layers() {
// s0 holds s1 two groups deep, which holds s2, which holds an instance of nothing known:
// s2 draws 3 deep, s1 6 and s0 9.
let d = symbols(3, 2, "none");
let heights = DrawnHeights::of(&d);
let on_layer = instance(1, "s0");
assert_eq!(heights.deepest(&on_layer, 2), 2 + 9);
// An object's own nesting and its mask's art count past MAX_DEPTH.
let mut deep = Node::group(NodeId(5), vec![nested(MAX_DEPTH + 10)]);
deep.extras_mut().mask = Some(Box::new(OpacityMask::new((*instance(6, "s2")).clone(), true)));
assert_eq!(heights.deepest(&deep, 1), 1 + 1 + MAX_DEPTH + 10);
let shallow = Node::group(NodeId(5), vec![instance(7, "s1")]);
assert_eq!(heights.deepest(&shallow, 100), 101 + 6);
// A symbol that holds itself has no height, so its instance counts alone.
let looped = DrawnHeights::of(&symbols(2, 1, "s0"));
assert_eq!(looped.deepest(&instance(1, "s0"), 2), 2);
}
}
+1
View File
@@ -61,6 +61,7 @@ pub mod range;
pub mod rastersettings;
mod reach;
pub mod recolor;
pub mod resample;
pub mod selection;
pub mod separated;
pub mod setup;
+17 -1
View File
@@ -229,7 +229,8 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
let (w, h) = crate::resample::fit_size(img.width(), img.height(), PROXY_SIZE, PROXY_SIZE);
let small = crate::resample::averaged(img, w, h);
let mut png = Vec::new();
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
@@ -732,4 +733,19 @@ mod tests {
image(&mut d, "c", None);
assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]);
}
/// The preview of an image with clear pixels keeps the color of its opaque ones: the clear
/// black beside them does not darken them, whichever way the preview is made.
#[test]
fn a_preview_keeps_the_color_beside_clear_pixels() {
let mut full = vec![];
image::RgbaImage::from_fn(1024, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })
.write_to(&mut Cursor::new(&mut full), image::ImageFormat::Png)
.unwrap();
for blob in [ImageBlob::png(full.clone()).with_proxy(), ImageBlob::png(full.clone()).checked_with_proxy(true).unwrap()] {
let p = image::load_from_memory(blob.proxy.as_ref().unwrap()).unwrap().into_rgba8();
assert_eq!(p.dimensions(), (PROXY_SIZE, 1));
assert!(p.pixels().all(|q| q.0 == [200, 30, 30, 128]), "{:?}", p.get_pixel(0, 0));
}
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Scaling images whose pixels carry straight (not premultiplied) alpha.
//!
//! `image`'s filtered resampling filters each channel on its own, which is correct only for
//! premultiplied pixels, and its thumbnail averages each channel on its own too, so on
//! straight-alpha pixels the color under clear pixels bleeds into the opaque ones next to them
//! (clear black darkens a cut-out's edge). [`resized`], [`fitted`] and [`averaged`] weight each
//! pixel's color by its alpha while scaling, as the streaming shrink in the renderer does.
use image::imageops::FilterType;
use image::{DynamicImage, RgbaImage};
/// How [`resized`] makes each new pixel from the old ones.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Resample {
/// `image`'s filtered resampling with this filter.
Filter(FilterType),
/// The mean of the old pixels each new one covers ([`averaged`]).
Average,
}
/// `img` scaled to `w` × `h` pixels as `how` says, each pixel's color weighted by its alpha.
/// Filtered or averaged, an image with clear or partly clear pixels comes back as 8-bit RGBA
/// whatever its own pixel format, and a pixel that ends up wholly clear is transparent black. An
/// image without clear pixels is scaled as `image` scales it, in its own pixel format.
/// Nearest-neighbor picking leaves the pixels as they are, in their own format, and is not
/// weighted. Filtered, the colors keep the precision of premultiplied 8-bit pixels, and an 8-bit
/// RGBA image is premultiplied in place.
pub fn resized(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage {
if (w, h) == (img.width(), img.height()) {
return img;
}
let f = match how {
Resample::Average => return averaged(&img, w, h),
Resample::Filter(f) => f,
};
if f == FilterType::Nearest || opaque(&img) {
return img.resize_exact(w, h, f);
}
let mut rgba = img.into_rgba8();
for p in rgba.pixels_mut() {
let [r, g, b, a] = &mut p.0;
for c in [r, g, b] {
*c = times_alpha(*c, *a);
}
}
let mut out = image::imageops::resize(&rgba, w, h, f);
for p in out.pixels_mut() {
let [r, g, b, a] = &mut p.0;
for c in [r, g, b] {
*c = over_alpha(*c, *a);
}
}
DynamicImage::ImageRgba8(out)
}
/// [`resized`] to the largest size that fits `w` × `h` pixels and keeps `img`'s proportions, the
/// size `image`'s own `resize` and `thumbnail` pick.
pub fn fitted(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage {
let (nw, nh) = fit_size(img.width(), img.height(), w, h);
resized(img, nw, nh, how)
}
/// `img` scaled to `w` × `h` pixels, each new pixel the mean of the old pixels it covers, each old
/// pixel counted by the area of it covered and its color weighted by its alpha. `img` is read one
/// row at a time, so besides the result this takes one row of 8-bit RGBA at most. An image
/// without clear pixels is scaled as `image`'s thumbnail scales it.
pub fn averaged(img: &DynamicImage, w: u32, h: u32) -> DynamicImage {
let (sw, sh) = (img.width(), img.height());
if opaque(img) || w == 0 || h == 0 || sw == 0 || sh == 0 {
return img.thumbnail_exact(w, h);
}
// Per new pixel of the row being made: red, green and blue times alpha, alpha (all times the
// area covered), and the area covered.
let mut sums = vec![([0f64; 4], 0f64); w as usize];
let mut out = Vec::with_capacity(w as usize * h as usize * 4);
for ty in 0..h {
for (y, wy) in covered(ty, sh, h) {
let row = rgba_row(img, y as u32);
for ((s, area), tx) in sums.iter_mut().zip(0..w) {
for (x, wx) in covered(tx, sw, w) {
let Some(p) = row.get(x * 4..x * 4 + 4) else { continue };
let k = wx * wy;
let a = f64::from(p[3]) * k;
s[0] += f64::from(p[0]) * a;
s[1] += f64::from(p[1]) * a;
s[2] += f64::from(p[2]) * a;
s[3] += a;
*area += k;
}
}
}
let byte = |v: f64| v.round().clamp(0.0, 255.0) as u8;
for (s, area) in &mut sums {
out.extend(if s[3] <= 0.0 { [0; 4] } else { [byte(s[0] / s[3]), byte(s[1] / s[3]), byte(s[2] / s[3]), byte(s[3] / *area)] });
(*s, *area) = ([0.0; 4], 0.0);
}
}
RgbaImage::from_raw(w, h, out).map_or_else(|| img.thumbnail_exact(w, h), DynamicImage::ImageRgba8)
}
/// Row `y` of `img` as 8-bit RGBA: borrowed from an 8-bit RGBA image, converted from others.
fn rgba_row(img: &DynamicImage, y: u32) -> std::borrow::Cow<'_, [u8]> {
let n = img.width() as usize * 4;
match img {
DynamicImage::ImageRgba8(b) => std::borrow::Cow::Borrowed(b.as_raw().get(y as usize * n..(y as usize + 1) * n).unwrap_or_default()),
other => std::borrow::Cow::Owned(other.crop_imm(0, y, other.width(), 1).into_rgba8().into_raw()),
}
}
/// The old pixels that new pixel `t` of `tn` covers along a side of `n` old pixels, each with the
/// length of it covered (in old pixels), worked out as they are read so that nothing is stored.
fn covered(t: u32, n: u32, tn: u32) -> impl Iterator<Item = (usize, f64)> {
let scale = f64::from(n) / f64::from(tn);
let (a, b) = (f64::from(t) * scale, (f64::from(t) + 1.0) * scale);
let (first, end) = (a.floor() as u32, (b.ceil() as u32).min(n));
(first..end.max(first + 1).min(n)).filter_map(move |i| {
let len = b.min(f64::from(i) + 1.0) - a.max(f64::from(i));
(len > 0.0).then_some((i as usize, len))
})
}
/// Each 4-channel pixel of `px` with all four channels multiplied by its value in `alpha` (one
/// sample a pixel, 0 to 255): ink amounts or colors made ready for scaling beside a separate alpha
/// channel. Pixels past the end of `alpha` are left as they are.
pub fn premultiply(px: &mut [u8], alpha: &[u8]) {
for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) {
for c in p {
*c = times_alpha(*c, a);
}
}
}
/// [`premultiply`] undone after scaling: each 4-channel pixel of `px` divided by its value in the
/// scaled `alpha`, and 0 where that is 0.
pub fn unpremultiply(px: &mut [u8], alpha: &[u8]) {
for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) {
for c in p {
*c = over_alpha(*c, a);
}
}
}
/// Does `img` have no alpha channel, or is every pixel of it opaque?
fn opaque(img: &DynamicImage) -> bool {
match img {
DynamicImage::ImageRgba8(b) => b.pixels().all(|p| p[3] == u8::MAX),
DynamicImage::ImageLumaA8(b) => b.pixels().all(|p| p[1] == u8::MAX),
DynamicImage::ImageRgba16(b) => b.pixels().all(|p| p[3] == u16::MAX),
DynamicImage::ImageLumaA16(b) => b.pixels().all(|p| p[1] == u16::MAX),
DynamicImage::ImageRgba32F(b) => b.pixels().all(|p| p[3] >= 1.0),
other => !other.color().has_alpha(),
}
}
/// Channel `c` times alpha `a`, both 0 to 255.
fn times_alpha(c: u8, a: u8) -> u8 {
((u16::from(c) * u16::from(a) + 127) / 255) as u8
}
/// Premultiplied channel `c` divided by alpha `a` (0 when `a` is 0).
fn over_alpha(c: u8, a: u8) -> u8 {
if a == 0 {
return 0;
}
let a = u32::from(a);
((u32::from(c) * 255 + a / 2) / a).min(255) as u8
}
/// The largest size of at most `nw` × `nh` with the proportions of `w` × `h`, at least a pixel a
/// side.
pub fn fit_size(w: u32, h: u32, nw: u32, nh: u32) -> (u32, u32) {
if w == 0 || h == 0 {
return (nw.max(1), nh.max(1));
}
let ratio = (f64::from(nw) / f64::from(w)).min(f64::from(nh) / f64::from(h));
let side = |n: u32| ((f64::from(n) * ratio).round() as u32).max(1);
(side(w), side(h))
}
#[cfg(test)]
mod tests {
use super::*;
/// A 4 × 2 image whose left half is opaque red and whose right half is clear black.
fn half_clear() -> DynamicImage {
DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }))
}
/// Is the color of `p`, premultiplied by its alpha, within a step of `rgb` premultiplied by the
/// same alpha (the precision of premultiplied 8-bit pixels)?
fn close(p: [u8; 4], rgb: [u8; 3]) -> bool {
p[..3].iter().zip(rgb).all(|(&c, want)| times_alpha(c, p[3]).abs_diff(times_alpha(want, p[3])) <= 1)
}
#[test]
fn averaging_keeps_the_color_of_the_opaque_pixels() {
let p = resized(half_clear(), 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0;
assert!(close(p, [200, 30, 30]) && p[3].abs_diff(128) <= 1, "{p:?}");
}
/// Three pixels averaged into two: each new pixel takes one old pixel whole and half of the
/// clear one in the middle.
#[test]
fn averaging_counts_the_part_of_a_pixel_covered() {
let img = RgbaImage::from_fn(3, 1, |x, _| if x == 1 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([200, 30, 30, 255]) });
let out = averaged(&DynamicImage::ImageRgba8(img), 2, 1).into_rgba8();
assert_eq!(out.into_raw(), [[200, 30, 30, 170], [200, 30, 30, 170]].concat());
// Gray with alpha is read the same way.
let gray = image::GrayAlphaImage::from_fn(3, 1, |x, _| image::LumaA(if x == 1 { [0, 0] } else { [200, 255] }));
let out = averaged(&DynamicImage::ImageLumaA8(gray), 2, 1).into_rgba8();
assert_eq!(out.into_raw(), [[200, 200, 200, 170], [200, 200, 200, 170]].concat());
}
#[test]
fn filtering_keeps_the_color_of_the_opaque_pixels() {
for f in [FilterType::Triangle, FilterType::CatmullRom, FilterType::Lanczos3] {
let out = resized(half_clear(), 2, 1, Resample::Filter(f)).into_rgba8();
for x in 0..2 {
let p = out.get_pixel(x, 0).0;
assert!(p[3] == 0 || close(p, [200, 30, 30]), "{f:?} pixel {x}: {p:?}");
}
}
}
#[test]
fn fitted_keeps_proportions() {
let img = DynamicImage::ImageRgba8(RgbaImage::new(400, 100));
let out = fitted(img, 64, 64, Resample::Average);
assert_eq!((out.width(), out.height()), (64, 16));
}
#[test]
fn opaque_and_alpha_free_images_scale_as_image_scales_them() {
let rgb = DynamicImage::ImageRgb8(image::RgbImage::from_fn(5, 3, |x, y| image::Rgb([x as u8 * 40, y as u8 * 60, 7])));
let ours = resized(rgb.clone(), 2, 2, Resample::Filter(FilterType::Triangle));
assert_eq!(ours, rgb.resize_exact(2, 2, FilterType::Triangle));
let opaque = DynamicImage::ImageRgba8(rgb.to_rgba8());
assert_eq!(resized(opaque.clone(), 2, 2, Resample::Average), opaque.thumbnail_exact(2, 2));
}
#[test]
fn a_wholly_clear_result_is_transparent_black() {
let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, image::Rgba([90, 90, 90, 0])));
assert_eq!(resized(img, 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0, [0, 0, 0, 0]);
}
#[test]
fn premultiply_and_unpremultiply_scale_beside_a_separate_alpha() {
// Two pixels of inks, the second clear: averaged, the first one's inks remain.
let mut px = vec![40, 80, 120, 160, 0, 0, 0, 0];
premultiply(&mut px, &[255, 0]);
let img = RgbaImage::from_raw(2, 1, px).unwrap();
let mut out = image::imageops::thumbnail(&img, 1, 1).into_raw();
let alpha = image::imageops::thumbnail(&image::GrayImage::from_raw(2, 1, vec![255, 0]).unwrap(), 1, 1).into_raw();
unpremultiply(&mut out, &alpha);
let want = [40, 80, 120, 160];
assert!(out.iter().zip(want).all(|(&c, w)| times_alpha(c, alpha[0]).abs_diff(times_alpha(w, alpha[0])) <= 1), "{out:?} {alpha:?}");
}
}
+248 -7
View File
@@ -17,7 +17,8 @@
//! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by
//! the caller).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::Arc;
use vectorcraft_doc::color::Paint;
@@ -26,7 +27,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
use crate::distort::{transform_move, transform_offsets};
use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape,
@@ -114,13 +115,181 @@ thread_local! {
/// The objects of symbol art that [`bake_document`] has baked for instances inside the art of
/// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`].
static NESTED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
/// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their
/// placed art ([`placed_symbol_art`]).
static PLACING: std::cell::RefCell<Option<Rc<Placing>>> = const { std::cell::RefCell::new(None) };
/// The objects in the baked art of the instances that [`bake_node`] has replaced by their
/// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`].
static PLACED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
/// The objects in the baked art of the instances that [`bake_node`] baked for one export and
/// then left as they are ([`placed_fits`] refused the art), counted with [`PLACED_NODES`]
/// against [`Placing::most_nodes`] before more art is baked. Never given back.
static DROPPED_NODES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
/// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer
/// is 1 deep.
static LEVEL: std::cell::Cell<usize> = const { std::cell::Cell::new(1) };
/// Whether [`bake_node`] is baking inside an object the export does not write ([`unwritten`]),
/// where no instance is placed.
static UNWRITTEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked.
fn one_deeper<T>(f: impl FnOnce() -> T) -> T {
LEVEL.with(|c| c.set(c.get() + 1));
let out = f();
LEVEL.with(|c| c.set(c.get().saturating_sub(1)));
out
}
/// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and
/// how that art is placed through 9-slice guides.
struct Placing {
/// The names of the symbols whose art draws something that baking changes, directly or through
/// the instances of other such symbols in it ([`drawn_symbols`]).
symbols: HashSet<String>,
/// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not
/// ([`Document::place_symbol_art_unclipped`]).
clip_slices: bool,
/// Most objects the baked art of all placed instances may hold together:
/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] for an export.
most_nodes: u64,
/// How deep the document's symbols and patterns draw, for the instances and pattern paints
/// left in placed art.
heights: vectorcraft_doc::depth::DrawnHeights,
/// The hidden layers and layers that do not print that the export writes.
layers: WrittenLayers,
}
/// The layers an exporter writes besides the shown, printing ones
/// ([`bake_document_placing_symbols`]). Template layers are never written.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct WrittenLayers {
/// Hidden layers (written turned off, as PDF layers and DXF write them).
pub hidden: bool,
/// Layers that do not print.
pub non_printing: bool,
}
/// Does the export leave `n` and everything in it out, while [`bake_document_placing_symbols`]
/// bakes? A hidden object other than a layer, a template layer, and a hidden layer or a layer
/// that does not print when the export leaves those out ([`WrittenLayers`]).
fn unwritten(n: &Node) -> bool {
PLACING.with(|p| {
p.borrow().as_ref().is_some_and(|p| match n.kind {
NodeKind::Layer { template, printable, .. } => template || !(printable || p.layers.non_printing) || !(n.visible || p.layers.hidden),
_ => !n.visible,
})
})
}
/// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep,
/// when the art placed for the export so far holds `spent` objects and the art baked for
/// instances left as they are holds `dropped` ([`DROPPED_NODES`])? `Some` with the
/// objects placed then, when its objects (opacity masks' art included) fit with those within
/// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws
/// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`].
fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option<u64> {
PLACING.with(|p| {
let p = p.borrow();
let p = p.as_ref()?;
let spent = spent.saturating_add(baked.count_with_masks() as u64);
let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get()));
(spent.saturating_add(dropped) <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent)
})
}
/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art
/// holds an instance of one of them at any depth.
fn drawn_symbols(d: &Document) -> HashSet<String> {
let mut out: HashSet<String> = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect();
if out.is_empty() {
return out;
}
let uses: Vec<(&str, Vec<String>)> = d
.symbols
.iter()
.filter(|s| !out.contains(&s.name))
.map(|s| {
let mut names = vec![];
s.art.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
names.push(symbol.clone());
}
});
(s.name.as_str(), names)
})
.collect();
loop {
let before = out.len();
for (name, names) in &uses {
if !out.contains(*name) && names.iter().any(|x| out.contains(x)) {
out.insert((*name).to_string());
}
}
if out.len() == before {
return out;
}
}
}
/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces
/// by its placed art? False outside it, and for objects the export does not write ([`unwritten`]).
fn holds_placed_symbol(n: &Node) -> bool {
fn walk(n: &Node, symbols: &HashSet<String>) -> bool {
!unwritten(n)
&& (matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol))
|| n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols))))
}
!UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols)))
}
/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it
/// places, as the art it draws: the symbol's art with the instance's own appearance changes and
/// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters
/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask,
/// raster effects and attributes (its URL among them). The art is placed before its effects are baked, as the canvas draws it, so an
/// instance that scales its art does not scale what its effects do. `None` for other objects,
/// for instances the export does not write ([`unwritten`], hidden or inside such an object), for
/// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art
/// as placed, before its effects are baked, would already take the art placed for the export,
/// with the art baked for instances left as they are ([`DROPPED_NODES`]), past
/// [`Placing::most_nodes`]; those instances stay as they are. `Some` with the objects in the art
/// as placed, before its effects are baked. [`bake_node`] checks the baked art with
/// [`placed_fits`].
fn placed_symbol_art(d: &Document, n: &Node) -> Option<(Node, u64)> {
let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None };
let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?;
if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) {
return None;
}
let sym = d.symbols.iter().find(|s| s.name == *symbol)?;
let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
let most = match d.symbol_slice_map(symbol, *xf) {
Some(m) if clip => m.most_placed(&art, d),
Some(m) => m.most_placed_unclipped(&art, d),
None => art.count_with_masks() as u64,
};
if PLACED_NODES.with(|c| c.get()).saturating_add(DROPPED_NODES.with(|c| c.get())).saturating_add(most) > most_nodes {
return None;
}
let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?;
let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n);
g.extras_mut().attrs = n.extras().attrs.clone();
Some((g, most))
}
/// `n` with its geometry effects evaluated (`None` = unchanged).
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
// Inside an object the export leaves out, effects are baked but no instance is placed.
if !UNWRITTEN.with(|c| c.get()) && unwritten(n) {
UNWRITTEN.with(|c| c.set(true));
let out = bake_node(d, n);
UNWRITTEN.with(|c| c.set(false));
return out;
}
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
// A ruler guide draws nothing and stays as it is, last in its layer.
if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() {
if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() {
return None;
}
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
@@ -181,6 +350,26 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
}
return Some(baked.unwrap_or(m));
}
// Symbol instances whose art draws live effects, when the caller asks for them: the art
// placed, then baked. When the baked art passes the export's budget, or would draw too deep
// where the instance is, it is dropped with the art placed for the instances inside it, and
// the instance stays as it is. While the art bakes, its objects before baking are held in
// the budget, so the instances inside it are placed only within what is left; art baked and
// then dropped still counts against the budget for the instances after it.
if let Some((m, most)) = placed_symbol_art(d, n) {
let spent = PLACED_NODES.with(|c| c.get());
PLACED_NODES.with(|c| c.set(spent.saturating_add(most)));
INSTANCE_DEPTH.with(|c| c.set(c.get() + 1));
let baked = bake_node(d, &m);
INSTANCE_DEPTH.with(|c| c.set(c.get() - 1));
let baked = baked.unwrap_or(m);
if let Some(now) = placed_fits(&baked, spent, DROPPED_NODES.with(|c| c.get())) {
PLACED_NODES.with(|c| c.set(now));
return Some(baked);
}
PLACED_NODES.with(|c| c.set(spent));
DROPPED_NODES.with(|c| c.set(c.get().saturating_add(baked.count_with_masks() as u64)));
}
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
// patterns: each copy an object of its own, so its gradient fits it rather than spanning
// every copy and its strokes and patterns can differ from the others'.
@@ -214,11 +403,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
.enumerate()
.map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() {
Some(a) => {
let mut a = bake_node(d, &a).unwrap_or(a);
let mut a = one_deeper(|| bake_node(d, &a)).unwrap_or(a);
fresh_ids(d, &mut a, &mut std::collections::HashSet::new());
Arc::new(a)
}
None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()),
None => one_deeper(|| bake_node(d, c)).map(Arc::new).unwrap_or_else(|| c.clone()),
})
.collect();
if let Some(ch) = m.children_mut() {
@@ -295,7 +484,7 @@ fn bake_pieces(d: &mut Document, mut m: Node) -> Node {
.into_iter()
.map(|c| {
let c = Arc::unwrap_or_clone(c);
let mut c = bake_node(d, &c).unwrap_or(c);
let mut c = one_deeper(|| bake_node(d, &c)).unwrap_or(c);
fresh_ids(d, &mut c, &mut seen);
Arc::new(c)
})
@@ -772,8 +961,60 @@ fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes:
/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when
/// there is nothing to do (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
bake(doc, None)
}
/// [`bake_document`], and every symbol instance whose symbol's art draws something baking changes
/// (live geometry effects, color adjustments and the rest [`needs_bake`] counts, in its own art or
/// in the art of the symbols it places) replaced by that art as the instance places it, baked
/// ([`placed_symbol_art`]), so the art draws as on the canvas wherever the instance is. For
/// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than
/// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped
/// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the
/// exporter places other instances. `layers`: the hidden layers and layers that do not print
/// that the exporter writes. Instances the export does not write (hidden ones, those in hidden
/// objects, on template layers, and on the layers `layers` leaves out) stay as they are. The
/// symbols' own art is left as it is.
pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: WrittenLayers) -> Option<Document> {
bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES, layers)))
}
/// Restores what [`bake`] changes in the thread's baking state when it ends.
struct Restore(Option<Rc<Placing>>, u64, u64, usize, bool);
impl Drop for Restore {
fn drop(&mut self) {
PLACING.with(|p| *p.borrow_mut() = self.0.take());
PLACED_NODES.with(|c| c.set(self.1));
DROPPED_NODES.with(|c| c.set(self.2));
LEVEL.with(|c| c.set(self.3));
UNWRITTEN.with(|c| c.set(self.4));
}
}
/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`,
/// [`Placing::most_nodes`] and [`Placing::layers`].
fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option<Document> {
let placed = doc.with_placed_art();
if !placed.layers.iter().any(|l| needs_bake(l)) {
let placing = placing
.map(|(clip_slices, most_nodes, layers)| (drawn_symbols(&placed), clip_slices, most_nodes, layers))
.filter(|p| !p.0.is_empty())
.map(|(symbols, clip_slices, most_nodes, layers)| Placing {
symbols,
clip_slices,
most_nodes,
heights: vectorcraft_doc::depth::DrawnHeights::of(&placed),
layers,
});
// A bake inside another one (of art an effect makes) places symbols only when it asks to.
let _restore = Restore(
PLACING.with(|p| p.replace(placing.map(Rc::new))),
PLACED_NODES.with(|c| c.replace(0)),
DROPPED_NODES.with(|c| c.replace(0)),
LEVEL.with(|c| c.replace(1)),
UNWRITTEN.with(|c| c.replace(false)),
);
if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) {
return match placed {
std::borrow::Cow::Owned(d) => Some(d),
std::borrow::Cow::Borrowed(_) => None,
+342
View File
@@ -1115,3 +1115,345 @@ fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
// Three squares of their own, not one path spanning all three.
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
}
/// A 10 × 10 square at (10, 40), with id `id`, that a Transform effect moves 100 pt right.
fn moved_square(id: u64) -> Node {
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art());
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 100.0})));
n
}
fn instance(id: u64, symbol: &str, xf: vectorcraft_geom::Affine) -> Node {
Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
}
fn add_symbol(d: &mut Document, name: &str, art: Node) {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
}
fn bounds(d: &Document, id: u64) -> Rect {
d.node(NodeId(id)).and_then(|n| n.geometric_bounds()).unwrap_or_else(|| panic!("node {id} has bounds"))
}
fn near_rect(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
}
/// Each instance draws its symbol's art with the art's effects applied where the instance places
/// it: an instance that doubles the art's size moves it the effect's 100 pt, as the canvas draws
/// it, not 200 pt. Plain baking leaves the instances and the symbols' art as they are.
#[test]
fn placing_symbols_bakes_their_art_where_each_instance_places_it() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "S", Affine::IDENTITY));
let layer = d.layers[0].id;
d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap();
add_symbol(&mut d, "S", moved_square(800));
assert!(bake_document(&d).is_none(), "nothing in the layers needs baking");
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. }));
assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900));
assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901));
assert_eq!(out.symbols[0].art.appearance.effects.len(), 1, "the symbol's art keeps its effect");
}
/// An instance of a symbol whose art holds an instance of another symbol with effects draws that
/// art with its effects too.
#[test]
fn placing_symbols_reaches_the_art_of_nested_instances() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY));
add_symbol(&mut d, "Inner", moved_square(800));
add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))]));
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900));
}
/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export
/// ends.
#[test]
fn placing_a_symbol_that_holds_itself_stops() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "Loop", Affine::IDENTITY));
add_symbol(
&mut d,
"Loop",
Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]),
);
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
let mut lefts = vec![];
out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0)));
assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}");
let mut depth = 0;
let mut n = out.node(NodeId(900)).unwrap();
while let Some(next) = n.children().and_then(|ch| ch.last()) {
n = next;
depth += 1;
}
assert!(depth <= 3 * (vectorcraft_doc::depth::MAX_DEPTH + 1), "{depth}");
}
/// A dynamic symbol instance's own paint for an object of the art applies before the object's
/// effects are baked: a fill whose own Transform effect makes the square a group of pieces still
/// paints in the instance's red, and its sibling keeps the symbol's paint.
#[test]
fn placing_symbols_keeps_dynamic_instances_changes() {
use vectorcraft_geom::Affine;
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let mut square = Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art());
if let Some(AppearanceItem::Fill(f)) = square.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.effects.push(fx("distort.transform", json!({"moveH": 100.0})));
}
let sibling = Node::path(NodeId(801), shapes::rectangle(Rect::new(10.0, 60.0, 20.0, 70.0)), Appearance::default_art());
let mut inst = instance(900, "D", Affine::IDENTITY);
inst.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(red.clone()), ..Default::default() }];
let mut d = doc_with(inst);
add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)]));
d.symbols[0].dynamic = true;
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
let mut fills = vec![];
out.node(NodeId(900)).unwrap().walk(&mut |n| {
if n.path_data().is_some()
&& let Some(b) = n.geometric_bounds()
{
fills.push((b.x0.round(), b.y0.round(), n.appearance.fill_paint()));
}
});
assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}");
assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}");
}
/// An instance whose fill stains its symbol's art places the art in its stained colors, with the
/// art's effects applied after the stain: a Transform moves the stained square and an Invert
/// inverts the stained color.
#[test]
fn placing_symbols_keeps_the_instances_stain() {
use vectorcraft_geom::Affine;
let blue = Color::rgb8(30, 90, 220);
let mut inst = instance(900, "Mark", Affine::IDENTITY);
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
inst.appearance.items.retain(|i| i.is_fill());
if let AppearanceItem::Fill(f) = &mut inst.appearance.items[0] {
f.opacity = 0.5;
}
let square =
Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0));
let stained = vectorcraft_doc::symbol_overrides::instance_art(&square, &inst).appearance.fill_paint().color().unwrap();
assert_ne!(stained.to_hex(), blue.to_hex());
let [r, g, b] = stained.to_rgb();
let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b);
for (id, params, want) in [("distort.transform", json!({"moveH": 100.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] {
let mut art = square.clone();
art.appearance.effects.push(fx(id, params));
let mut d = doc_with(inst.clone());
add_symbol(&mut d, "Mark", art);
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
assert!(!is_instance(&out, 900), "{id}: the instance is placed");
let mut fills = vec![];
out.node(NodeId(900)).unwrap().walk(&mut |n| {
if n.path_data().is_some() {
fills.extend(n.appearance.fill_paint().color().map(|c| c.to_hex()));
}
});
assert_eq!(fills, [want.to_hex()], "{id}");
}
}
/// Every layer written, as DXF writes them (template layers are never written).
const ALL: WrittenLayers = WrittenLayers { hidden: true, non_printing: true };
fn is_instance(d: &Document, id: u64) -> bool {
d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. }))
}
/// The art placed for an export is counted as baked: the copies an effect makes count, so an
/// instance whose baked art passes what is left of the budget stays an instance, and the budget
/// holds the baked art of the instances placed, exactly.
#[test]
fn placing_symbols_counts_the_baked_art() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "S", Affine::IDENTITY));
let layer = d.layers[0].id;
d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap();
add_symbol(&mut d, "S", gradient_copies(800));
let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap();
let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64);
assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}");
assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each));
let fits = bake(&d, Some((true, 2 * each, ALL))).unwrap();
assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901));
let short = bake(&d, Some((true, 2 * each - 1, ALL))).unwrap();
assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is");
}
/// The baked art of an instance placed inside another's art is counted once: a budget of exactly
/// the outer instance's baked art places both.
#[test]
fn placing_symbols_counts_nested_art_once() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY));
add_symbol(&mut d, "Inner", gradient_copies(800));
add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))]));
let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap();
let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64);
let exact = bake(&d, Some((true, total, ALL))).unwrap();
assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}");
let short = bake(&d, Some((true, total - 1, ALL))).unwrap();
assert!(is_instance(&short, 900), "the outer instance stays as it is");
}
/// While an instance's art bakes, the art's own objects are held in the budget: an instance
/// nested in it whose art does not fit in what is left stays as it is, and the outer instance is
/// placed around it.
#[test]
fn placing_symbols_holds_the_outer_art_while_it_bakes() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY));
add_symbol(&mut d, "Inner", Node::group(NodeId(803), vec![Arc::new(gradient_copies(800))]));
let mut art: Vec<Arc<Node>> = (0..5)
.map(|i| Arc::new(Node::path(NodeId(810 + i), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())))
.collect();
art.push(Arc::new(instance(802, "Inner", Affine::IDENTITY)));
let outer = Node::group(NodeId(801), art);
// The outer instance's group around its art, with the inner instance left as it is.
let around = 1 + outer.count_with_masks() as u64;
add_symbol(&mut d, "Outer", outer);
let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap();
let inner = all.node(NodeId(802)).map_or(0, |n| n.count_with_masks() as u64);
assert!(inner > 2 && inner <= around, "the inner art alone fits: {inner} <= {around}");
let out = bake(&d, Some((true, around, ALL))).unwrap();
assert!(!is_instance(&out, 900) && is_instance(&out, 802), "the outer instance is placed around the inner one");
}
/// Art baked for an instance and then left out, because it passes the budget, still counts: an
/// instance after it whose art would fit in the budget alone stays as it is, and a budget with
/// room for both places both.
#[test]
fn placing_symbols_counts_the_art_it_drops() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(901, "Small", Affine::IDENTITY));
let layer = d.layers[0].id;
d.insert(Some(layer), 0, instance(900, "Big", Affine::IDENTITY)).unwrap();
add_symbol(&mut d, "Big", gradient_copies(800));
add_symbol(&mut d, "Small", moved_square(810));
let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap();
let count = |id: u64| all.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64);
let (big, small) = (count(900), count(901));
assert!(small < big, "{small} < {big}");
let short = bake(&d, Some((true, big - 1, ALL))).unwrap();
assert!(is_instance(&short, 900), "the big art passes {}", big - 1);
assert!(is_instance(&short, 901), "the big art's {big} objects count, so the small art does not fit");
let both = bake(&d, Some((true, big + small, ALL))).unwrap();
assert!(!is_instance(&both, 900) && !is_instance(&both, 901));
}
/// Instances the export does not write are not placed and do not count against the budget: with
/// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a
/// template layer, one on a layer that does not print and one on a hidden layer (when the export
/// leaves those layers out) come first and stay as they are, and the visible instance after them
/// is placed. An export that writes those layers places their instances first.
#[test]
fn placing_symbols_skips_instances_the_export_leaves_out() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "S", Affine::IDENTITY));
add_symbol(&mut d, "S", gradient_copies(800));
let each = bake(&d, Some((true, u64::MAX, ALL))).unwrap().node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64);
let layer = d.layers[0].id;
let mut hidden = instance(901, "S", Affine::IDENTITY);
hidden.visible = false;
let mut group = Node::group(NodeId(902), vec![Arc::new(instance(903, "S", Affine::IDENTITY))]);
group.visible = false;
for (i, n) in [hidden, group].into_iter().enumerate() {
d.insert(Some(layer), i, n).unwrap();
}
let mut left_out = vec![];
for (id, template, printable, visible) in [(910, true, true, true), (920, false, false, true), (930, false, true, false)] {
let mut l = Node::layer(NodeId(id), &format!("L{id}"), vectorcraft_doc::LayerColor::Preset(0));
if let NodeKind::Layer { template: t, printable: p, .. } = &mut l.kind {
*t = template;
*p = printable;
}
l.visible = visible;
if let Some(ch) = l.children_mut() {
ch.push(Arc::new(instance(id + 1, "S", Affine::IDENTITY)));
}
left_out.push(Arc::new(l));
}
d.layers.splice(0..0, left_out);
let out = bake(&d, Some((true, each, WrittenLayers::default()))).unwrap();
assert!(!is_instance(&out, 900), "the visible instance is placed within {each}");
for id in [901, 903, 911, 921, 931] {
assert!(is_instance(&out, id), "{id} stays as it is");
}
// An export that writes the layers that do not print, or the hidden layers, places that
// layer's instance first; the template layer's instance is never placed.
for (layers, first) in [(WrittenLayers { non_printing: true, hidden: false }, 921), (WrittenLayers { non_printing: false, hidden: true }, 931)] {
let out = bake(&d, Some((true, each, layers))).unwrap();
assert!(!is_instance(&out, first) && is_instance(&out, 900) && is_instance(&out, 911), "{layers:?}");
}
}
/// `leaf` in `levels` groups, with ids from `id` up.
fn in_groups(id: u64, levels: u64, leaf: Node) -> Node {
(0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)]))
}
/// Placed art is drawn no deeper than the document may draw: an instance whose art's effects add
/// a level past it stays an instance, and the instances around it are placed.
#[test]
fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() {
use vectorcraft_doc::depth::{DrawnHeights, MAX_DRAWN_DEPTH};
use vectorcraft_geom::Affine;
// The instance on the layer is 2 deep; A to D each hold the next 31 objects down, and E's
// square is 2 deep in its art: 2 + 4 × 31 + 2 = MAX_DRAWN_DEPTH.
let mut d = doc_with(instance(900, "A", Affine::IDENTITY));
for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() {
let base = 1000 * (i as u64 + 1);
add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY)));
}
add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100)));
assert!(!d.too_deep(None));
assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH);
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
// E's square becomes a group of copies, a level past the cap: E's instance stays.
assert!(is_instance(&out, 4100), "E's instance stays as it is");
assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed");
assert!(DrawnHeights::of(&out).deepest(&out.layers[0], 1) <= MAX_DRAWN_DEPTH);
// Without the chain above it, E's art is placed.
let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY));
add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100)));
assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900));
}
/// Art baked for an instance and then left out because it draws too deep still counts when the
/// baked art of a later instance is checked: that art fits before its effects add copies, but
/// not once they do.
#[test]
fn placing_symbols_counts_art_dropped_for_depth_against_later_baked_art() {
use vectorcraft_geom::Affine;
// The chain of placing_symbols_keeps_the_baked_art_within_the_drawn_depth, then F, whose
// square becomes a group of copies once baked.
let mut d = doc_with(instance(900, "A", Affine::IDENTITY));
for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() {
let base = 1000 * (i as u64 + 1);
add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY)));
}
add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100)));
add_symbol(&mut d, "F", gradient_copies(6000));
let layer = d.layers[0].id;
d.insert(Some(layer), 1, instance(901, "F", Affine::IDENTITY)).unwrap();
let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap();
assert!(is_instance(&all, 4100) && !is_instance(&all, 900) && !is_instance(&all, 901));
let count = |d: &Document, id: u64| d.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64);
let (a, f) = (count(&all, 900), count(&all, 901));
// E's baked art, as placed where nothing above it draws deep.
let mut shallow = doc_with(instance(4100, "E", Affine::IDENTITY));
add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100)));
let e = count(&bake(&shallow, Some((true, u64::MAX, ALL))).unwrap(), 4100);
assert!(f > 2, "F's copies are objects of their own: {f}");
let short = bake(&d, Some((true, a + e + f - 1, ALL))).unwrap();
assert!(!is_instance(&short, 900) && is_instance(&short, 4100), "A to D are placed, E is not");
assert!(is_instance(&short, 901), "E's dropped {e} objects count, so F's baked {f} do not fit");
let room = bake(&d, Some((true, a + e + f, ALL))).unwrap();
assert!(!is_instance(&room, 900) && !is_instance(&room, 901));
}
+4 -1
View File
@@ -68,7 +68,10 @@ pub use adjust::{
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
is_channel,
};
pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use bake::{
StrokeArt, WrittenLayers, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf,
fresh_ids, needs_bake,
};
pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent};
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
+148 -27
View File
@@ -136,8 +136,86 @@ pub(crate) fn facet_art(
composed_art(id, path, paint, Ramp::Rgb(light), seam)
}
/// Composite smooth solid-fill facets with one clip and three channel groups per surface.
/// Black channel backdrops keep shared edges opaque before the final coverage is applied.
/// The width of the seam stroke of a facet's underlay: a round stroke that reaches 1 pt past each
/// edge, over the pixels that the antialiased edges of two neighboring facets each cover only in
/// part at 1:1. A clip to the surface's outline removes what it paints past the surface.
pub(crate) const UNDERLAY_SEAM: f64 = 2.0;
/// The area facet `path` and its round seam stroke `seam` wide paint together: the stroke's outer
/// contours and the facet, all wound one way with a positive area. The stroke's inner contours,
/// wound the other way, are left out: the facet covers them, and contours that cancel each other
/// leave partial coverage where a union of footprints meets at a pixel.
pub(crate) fn footprint(path: &vectorcraft_geom::BezPath, seam: f64) -> vectorcraft_geom::BezPath {
use kurbo::{PathEl, Shape};
let stroke = kurbo::stroke(path.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
let sign = if stroke.area() < 0.0 { -1.0 } else { 1.0 };
let mut footprint = vectorcraft_geom::BezPath::new();
let mut contour = vectorcraft_geom::BezPath::new();
for el in stroke.elements().iter().chain(std::iter::once(&PathEl::MoveTo(kurbo::Point::ZERO))) {
if matches!(el, PathEl::MoveTo(_)) && !contour.elements().is_empty() {
if contour.area() * sign >= 0.0 {
footprint.extend(contour.iter());
}
contour = vectorcraft_geom::BezPath::new();
}
contour.push(*el);
}
let mut boundary = path.clone();
if boundary.area() * sign < 0.0 {
boundary = boundary.reverse_subpaths();
}
footprint.extend(boundary.iter());
if sign < 0.0 { footprint.reverse_subpaths() } else { footprint }
}
/// Colored-light facets `faces` (in drawing order) drawn over an underlay of themselves, the same
/// facets grown to seams [`UNDERLAY_SEAM`] wide, all clipped once to the union of the facets'
/// footprints. Where two facets' antialiased edges meet, the underlay fills what they leave
/// uncovered with the neighbors' own colors, so the background does not show through. `None` unless
/// every face is a facet a surface draws: a path filled and stroked by its seam, or a facet composed
/// in a clip of its footprint, neither with an opacity mask.
pub(crate) fn sealed(id: vectorcraft_doc::NodeId, faces: &[std::sync::Arc<vectorcraft_doc::Node>]) -> Option<vectorcraft_doc::Node> {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, Node, NodeKind};
let mut outline = vectorcraft_geom::BezPath::new();
let mut underlay = Vec::with_capacity(faces.len());
for face in faces {
if face.extras().mask.is_some() {
return None;
}
match &face.kind {
NodeKind::Path { clipping: false, .. } => {
let path = face.path_data()?.to_bezpath();
let mut seam = None;
let mut grown = (**face).clone();
for item in &mut grown.appearance.items {
if let AppearanceItem::Stroke(s) = item {
seam = Some(seam.map_or(s.width, |w: f64| w.max(s.width)));
s.width = UNDERLAY_SEAM;
}
}
outline.extend(footprint(&path, seam?).iter());
underlay.push(Arc::new(grown));
}
NodeKind::Group { children, clip: true } if children.len() == 2 => {
let clip = children.first()?.path_data()?.to_bezpath();
let wider = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&clip, UNDERLAY_SEAM)), Appearance::default());
let colors = children.get(1)?.clone();
outline.extend(clip.iter());
underlay.push(Arc::new(Node::new(id, NodeKind::Group { children: vec![Arc::new(wider), colors], clip: true })));
}
_ => return None,
}
}
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&outline), Appearance::default());
underlay.extend(faces.iter().cloned());
Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, underlay))], clip: true }))
}
/// Composite smooth solid-fill facets with one clip and three channel groups per surface. In each
/// channel, an underlay of the facets with wide seams lies under the facets themselves, so a
/// shared edge is opaque in that channel's own light before the channels are screened together;
/// the black backdrop shows only outside the surface, which the clip removes.
pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node {
use kurbo::Shape;
use std::sync::Arc;
@@ -167,20 +245,36 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t
if let Some(value) = color.get_mut(channel) {
*value = 1.0;
}
let paints: Vec<Paint> = facets
.iter()
.map(|face| {
if let Some(ramp) = face.rgb_gradient {
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
} else {
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
}
})
.collect();
let draw = |seam: fn(&vectorcraft_three_d::Facet) -> f64| -> Vec<Arc<Node>> {
facets
.iter()
.zip(&paints)
.map(|(face, paint)| {
let edge = seam_stroke(paint.clone(), seam(face));
Arc::new(Node::path(
id,
face.path(),
Appearance {
items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(edge)],
..Default::default()
},
))
})
.collect()
};
let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))];
children.extend(facets.iter().map(|face| {
let paint = if let Some(ramp) = face.rgb_gradient {
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
} else {
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
};
let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
Arc::new(Node::path(
id,
face.path(),
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() },
))
}));
children.extend(draw(|_| UNDERLAY_SEAM));
children.extend(draw(|face| (face.thickness() / 2.0).clamp(0.05, 0.75)));
let mut group = Node::group(id, children);
group.isolate = true;
group.blend = BlendMode::Screen;
@@ -228,24 +322,25 @@ pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option<Paint>) {
}
fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node {
use kurbo::Shape;
use std::sync::Arc;
use vectorcraft_doc::color::BlendMode;
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
let (base, mask) = opaque_paint(paint);
// Clip once to the union of this single-contour face and its round seam stroke.
// Align their winding so the nonzero fill adds coverage instead of cancelling it.
let mut boundary = path.to_bezpath();
let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
if boundary.area() * footprint.area() < 0.0 {
boundary = boundary.reverse_subpaths();
// An unplaced gradient (and a freeform gradient without points) stays fitted to the face's
// bounds grown by 1 pt, not to the larger cover below.
let mut paint = paint.clone();
if let Paint::Gradient(g) = &mut paint {
g.pin(path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0));
}
footprint.extend(boundary.iter());
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
let (base, mask) = opaque_paint(&paint);
// Clip once to the union of this single-contour face and its round seam stroke.
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&path.to_bezpath(), seam)), Appearance::default());
// Opaque rectangles cover the complete seam stroke, without separately antialiasing
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks.
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. They
// reach at least half a point past the seam stroke grown for an underlay ([`sealed`]), so
// their own edges stay outside either clip.
let margin = seam.max(1.0) + UNDERLAY_SEAM / 2.0;
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(margin, margin);
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
let mut illumination = match light {
Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)),
@@ -380,4 +475,30 @@ mod tests {
assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]);
assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch");
}
/// Under colored light, a face's unplaced source gradient is fitted to the face's bounds grown by
/// 1 pt, whatever the size of the cover rectangle that draws it; a placed one keeps its placement.
#[test]
fn composed_faces_fit_unplaced_gradients_to_the_face_not_the_cover() {
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_three_d::{RgbGradient, ShadeGradient};
let face = shapes::rectangle(Rect::new(10.0, 20.0, 50.0, 40.0));
let ramp = |b| ShadeGradient { start: Point::new(10.0, 20.0), end: Point::new(50.0, 20.0), brightness: [b, 1.0] };
let light = RgbGradient { channels: [ramp(0.2), ramp(0.5), ramp(0.8)] };
let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)];
let unplaced = GradientPaint { gradient: Gradient::new(GradientKind::Linear, stops), geom: None, angle: 0.0, swatch: None, freeform: None };
let placed = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(80.0, 0.0), aspect: 1.0, focal: None };
let fitted = unplaced.resolve(Rect::new(9.0, 19.0, 51.0, 41.0));
for (geom, expected) in [(None, fitted), (Some(placed), placed)] {
let paint = Paint::Gradient(Box::new(GradientPaint { geom, ..unplaced.clone() }));
for seam in [0.05, 0.75] {
let art = facet_art(vectorcraft_doc::NodeId(1), &face, &paint, light, seam);
let NodeKind::Group { children, clip: true } = &art.kind else { panic!("a clipped composite") };
let base = &children[1].children().unwrap()[0];
let Paint::Gradient(g) = base.appearance.items[0].paint() else { panic!("the source gradient") };
assert_eq!(g.geom, Some(expected), "seam {seam}");
}
}
}
}
+1 -1
View File
@@ -868,7 +868,7 @@ fn as_art_opts(
/// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and
/// remaining (raster) effects.
fn dressed(mut art: Node, n: &Node) -> Node {
pub(crate) fn dressed(mut art: Node, n: &Node) -> Node {
art.name = n.name.clone();
art.visible = n.visible;
art.opacity = n.opacity;
+97 -12
View File
@@ -635,11 +635,26 @@ fn paint_ref(item: &AppearanceItem) -> &Paint {
}
}
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored
/// lights give each facet derived paints (`lighting::solid_surface`, or `lighting::facet_art` for
/// each face).
/// Are `facets` drawn over an underlay of themselves ([`crate::lighting::sealed`],
/// [`crate::lighting::solid_surface`])? Under colored lights, when any facet takes a smooth light
/// ramp.
fn underlaid(facets: &[Facet], colored: bool) -> bool {
colored && facets.iter().any(|f| f.rgb_gradient.is_some())
}
/// The most paint copies `surface` makes for one facet ([`facet_copies`]).
const MAX_FACET_COPIES: usize = 12;
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), twice that when
/// the facets lie over an underlay of themselves, and [`MAX_FACET_COPIES`] when colored lights also
/// give each facet derived paints in three channels (`lighting::solid_surface`).
fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize {
facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 })
let per_facet = match (underlaid(facets, colored), paint.color().is_some()) {
(true, true) => MAX_FACET_COPIES,
(true, false) => 4,
(false, _) => 2,
};
facets.len().saturating_mul(per_facet)
}
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
@@ -832,14 +847,14 @@ fn rects_overlap(a: Rect, b: Rect) -> bool {
/// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces
/// and whose paint copies take `paint_bytes` so far: as many as keep the faces within
/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies
/// of the costliest stroke's paint.
/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as
/// [`MAX_FACET_COPIES`] copies of the costliest stroke's paint.
fn cut_room<'a>(items: impl Iterator<Item = &'a AppearanceItem>, faces: usize, paint_bytes: usize) -> usize {
let per_face = items
.filter(|item| matches!(item, AppearanceItem::Stroke(_)))
.map(|item| {
let mut copies = 0usize;
crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item));
crate::revolve::paint_copies_add(&mut copies, MAX_FACET_COPIES, paint_ref(item));
copies
})
.max()
@@ -1693,6 +1708,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
let split =
split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::<usize>() <= MAX_FACE_COMPOSITES;
let members: Vec<usize> = solids.iter().map(|s| s.4).collect();
// Which surfaces lie over an underlay of their facets, in one order or whole
// ([`in_one_order`], [`underlaid_surface`]).
let seals: Vec<bool> = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect();
let seal = |range: std::ops::Range<usize>| seals.get(range).is_some_and(|s| s.iter().any(|s| *s));
let surfaces: Vec<BuiltSurface> = solids
.into_iter()
.map(|(item, member, scopes, f, shape)| {
@@ -1728,19 +1747,37 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
run.push(s);
}
let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none());
let range = first..first + run.len();
if ordered(member) && flat {
let scopes = run.first().map_or(&[][..], |s| s.2);
out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed)))));
let seals = seals.get(range).unwrap_or_default();
out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed, seals)))));
} else {
out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts));
let run = run.into_iter().zip(range).map(|((_, s, scopes, _), k)| (scopes, Arc::new(underlaid_surface(n.id, s, seal(k..k + 1)))));
out.extend(with_graphics(run.collect(), first, &arts));
}
}
return Some(masked(n.id, out));
}
if !opaque || !surfaces.iter().all(plain) {
let surfaces =
surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect();
return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect());
}
Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))])
Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))])
}
/// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when
/// `seal`, keeping the surface's opacity, blend mode and opacity mask.
fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node {
let NodeKind::Group { children, clip: false } = &surface.kind else { return surface };
let Some(mut out) = seal.then(|| crate::lighting::sealed(id, children)).flatten() else { return surface };
out.opacity = surface.opacity;
out.blend = surface.blend;
if let Some(mask) = surface.extras().mask.clone() {
out.extras_mut().mask = Some(mask);
}
out
}
/// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in
@@ -1748,7 +1785,16 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
/// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops
/// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of
/// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is.
fn in_one_order(id: NodeId, surfaces: Vec<BuiltSurface<'_>>, first: usize, arts: &[(usize, PlanarFace, Arc<Node>)], sealed: bool) -> Vec<Arc<Node>> {
/// The faces of the surfaces that `seals` marks lie over underlays of themselves
/// ([`underlaid_runs`], [`underlaid_surface`]).
fn in_one_order(
id: NodeId,
surfaces: Vec<BuiltSurface<'_>>,
first: usize,
arts: &[(usize, PlanarFace, Arc<Node>)],
sealed: bool,
seals: &[bool],
) -> Vec<Arc<Node>> {
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect();
let arts: Vec<&(usize, PlanarFace, Arc<Node>)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect();
let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]);
@@ -1787,7 +1833,46 @@ fn in_one_order(id: NodeId, surfaces: Vec<BuiltSurface<'_>>, first: usize, arts:
(masks.len(), art)
}));
}
if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() }
let takes_underlay = |surface: usize| seals.get(surface) == Some(&true);
if masks.iter().any(Option::is_some) {
// The faces of a surface without a mask are sealed run by run before the masked surfaces'
// faces go in runs of their own, each holding one surface's faces, sealed with its mask.
let unmasked = underlaid_runs(id, drawn, |s| takes_underlay(s) && masks.get(s).is_some_and(Option::is_none));
return in_runs(id, unmasked, &masks)
.into_iter()
.map(|run| match &run.kind {
NodeKind::Group { clip: false, .. } if seals.iter().any(|s| *s) && run.extras().mask.is_some() => {
Arc::new(underlaid_surface(id, (*run).clone(), true))
}
_ => run,
})
.collect();
}
underlaid_runs(id, drawn, takes_underlay).into_iter().map(|(_, face)| face).collect()
}
/// `faces` in their drawing order, each with the index of its surface. Each run of consecutive
/// faces of one surface that `seal` takes lies over an underlay of its own faces, clipped to their
/// outline ([`crate::lighting::sealed`]), so no underlay reaches under another surface's faces or a
/// graphic drawn between them. A sealed run keeps its surface's index.
fn underlaid_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc<Node>)> {
let mut out = Vec::with_capacity(faces.len());
let mut run: Vec<Arc<Node>> = vec![];
let mut faces = faces.into_iter().peekable();
while let Some((surface, face)) = faces.next() {
run.push(face);
if faces.peek().is_some_and(|(next, _)| *next == surface) {
continue;
}
match seal(surface).then(|| crate::lighting::sealed(id, &run)).flatten() {
Some(sealed) => {
out.push((surface, Arc::new(sealed)));
run.clear();
}
None => out.extend(run.drain(..).map(|face| (surface, face))),
}
}
out
}
/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces
+133 -11
View File
@@ -535,14 +535,53 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() {
assert_eq!(drawn(&solid, Some(colored)).0, 1);
}
/// The faces `run` stands for: a run of faces over an underlay of themselves ([`crate::lighting::sealed`]:
/// a clip and a group of the underlay's copies followed by the faces) gives its faces, any other
/// node gives itself.
fn unsealed(run: &Arc<Node>) -> Vec<Arc<Node>> {
if let NodeKind::Group { clip: true, children } = &run.kind
&& let [clip, inner] = children.as_slice()
&& matches!(clip.kind, NodeKind::Path { .. })
&& let NodeKind::Group { clip: false, children: kids } = &inner.kind
&& !kids.is_empty()
&& kids.len() % 2 == 0
{
let (under, faces) = kids.split_at(kids.len() / 2);
let underlay = |k: &Arc<Node>| match &k.kind {
NodeKind::Group { clip, .. } => *clip,
_ => k.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.width == crate::lighting::UNDERLAY_SEAM)),
};
if under.first().is_some_and(underlay) {
return faces.to_vec();
}
}
vec![run.clone()]
}
/// The bounds of every face under `n`, a sealed run's faces one by one ([`unsealed`]).
fn face_bounds_under(n: &Arc<Node>, out: &mut Vec<Rect>) {
let faces = unsealed(n);
if faces.len() != 1 || !Arc::ptr_eq(&faces[0], n) {
out.extend(faces.iter().map(|f| drawn_face_bounds(f)));
return;
}
if let NodeKind::Group { clip: true, .. } = &n.kind {
out.push(drawn_face_bounds(n));
}
for child in n.children().into_iter().flatten() {
face_bounds_under(child, out);
}
}
/// The bounds of face `f` of 3D art: its path's, or for a clipped colored composite
/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 1 pt.
/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 2 pt (a
/// point past the seam and half the underlay's seam).
fn drawn_face_bounds(f: &Node) -> Rect {
match &f.kind {
NodeKind::Path { .. } => f.path_data().and_then(PathData::bounds).unwrap(),
NodeKind::Group { clip: true, children } => {
let cover = &children[1].children().unwrap()[0];
cover.path_data().and_then(PathData::bounds).unwrap().inflate(-1.0, -1.0)
cover.path_data().and_then(PathData::bounds).unwrap().inflate(-2.0, -2.0)
}
_ => panic!("a face is a path or a clipped composite"),
}
@@ -587,7 +626,7 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() {
let art = solid_art(&with).unwrap();
let surfaces = art.children().unwrap();
assert_eq!(surfaces.len(), 1, "{what}: one order");
let faces = surfaces[0].children().unwrap();
let faces: Vec<Arc<Node>> = surfaces[0].children().unwrap().iter().flat_map(unsealed).collect();
assert_eq!(faces.len(), parts.len(), "{what}: one face per facet");
for (k, (face, (facet, ..))) in faces.iter().zip(&parts).enumerate() {
let (a, b) = (drawn_face_bounds(face), facet.path().bounds().unwrap());
@@ -616,11 +655,9 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() {
g.appearance.effects.push(fx(id, params.clone()));
let bounds = |art: &Node| {
let mut out = vec![];
art.walk(&mut |n| {
if let NodeKind::Group { clip: true, .. } = &n.kind {
out.push(drawn_face_bounds(n));
}
});
for child in art.children().into_iter().flatten() {
face_bounds_under(child, &mut out);
}
out
};
let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap());
@@ -637,6 +674,89 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() {
assert!(whole_misdrawn > 0, "whole surfaces show farther faces");
}
/// Under a colored light, the faces of an Inflate in gradients, in one order, lie over underlays
/// run by run: each run of consecutive faces of one surface (the fill's or the dashed stroke's)
/// over an underlay of its own faces, so no underlay reaches under the other surface's faces.
#[test]
fn a_colored_inflate_in_gradients_lies_over_underlays_of_each_surfaces_runs() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = |c: Color| {
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)],
))))
};
let mut n = stroked(&letter_c());
for item in &mut n.appearance.items {
match item {
AppearanceItem::Fill(f) => f.paint = gradient(RED),
AppearanceItem::Stroke(s) => {
s.paint = gradient(BLUE);
s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
}
}
}
let p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60});
let cut = drawn_parts(&n, INFLATE, &p).len();
let mut lit = p.clone();
lit["lights"] = json!([{"color": "#ff8080"}]);
n.appearance.effects.push(fx(INFLATE, lit));
// Which surface a face belongs to, by the first stop of its paint (a composed face's own paint,
// or a path face's shaded one): red for the fill's, blue for the stroke's.
fn surface_of(face: &Node) -> Option<Color> {
let item = match &face.kind {
NodeKind::Path { .. } => face.appearance.items.first()?,
_ => face.children()?.get(1)?.children()?.first()?.appearance.items.first()?,
};
let Paint::Gradient(g) = item.paint() else { return None };
let [r, _, b] = g.gradient.stops.first()?.color.to_rgb();
Some(if r > b { RED } else { BLUE })
}
// The faces in `order`, and the surface of each run over an underlay, looking into the groups
// that carry a surface's opacity mask.
fn runs(order: &[Arc<Node>], faces: &mut usize, surfaces: &mut Vec<Color>) {
for child in order {
let NodeKind::Group { children, clip } = &child.kind else {
assert!(surface_of(child).is_some(), "a face");
*faces += 1;
continue;
};
if !clip {
assert!(child.extras().mask.is_some(), "a masked run");
runs(children, faces, surfaces);
continue;
}
// A composed face isolates its colors; a run's faces are not isolated together.
if children[1].isolate {
assert!(surface_of(child).is_some(), "a composed face");
*faces += 1;
continue;
}
let stack = children[1].children().unwrap();
let (under, own) = stack.split_at(stack.len() / 2);
assert_eq!(under.len(), own.len(), "one underlay face for each face");
let colors: Vec<_> = own.iter().map(|f| surface_of(f).unwrap()).collect();
assert!(colors.iter().all(|c| *c == colors[0]), "a run holds one surface's faces");
surfaces.push(colors[0]);
*faces += own.len();
}
}
let (mut faces, mut surfaces) = (0, vec![]);
runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces);
assert!(faces >= cut, "{faces} faces, {cut} under a white light, whose cells are not triangulated");
assert!(surfaces.contains(&RED) && surfaces.contains(&BLUE), "{surfaces:?}");
// With transparent stops in the fill's gradient, the fill's faces go in masked runs; the
// stroke's faces, without a mask, still lie over underlays of their own runs.
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_)))
&& let Paint::Gradient(g) = &mut f.paint
{
g.gradient.stops.iter_mut().for_each(|s| s.opacity = 0.5);
}
let (mut faces, mut surfaces) = (0, vec![]);
runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces);
assert!(faces >= cut && surfaces.contains(&RED) && surfaces.contains(&BLUE), "{faces} faces, {surfaces:?}");
}
/// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they
/// overlap is cut along the line where their depths meet, into the piece in front of the wall and
/// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke
@@ -724,7 +844,7 @@ fn cutting_stays_within_the_face_limit_and_the_paint_allowance() {
let items = stroked(&letter_c()).appearance.items.clone();
let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap();
let mut one = 0usize;
crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke));
crate::revolve::paint_copies_add(&mut one, MAX_FACET_COPIES, paint_ref(stroke));
let max = crate::revolve::MAX_PAINT_COPIES;
assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one));
assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0);
@@ -1595,12 +1715,14 @@ fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() {
assert_eq!(one[0], paint, "the paint");
// The composite: its paint cover, then each channel's black backdrop and the face's ramp.
let all_fills = fills(&batch);
let ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect();
let mut ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect();
// Each channel's ramp is drawn twice, in the underlay and in the facet itself.
ramps.dedup();
assert_eq!(all_fills.first(), Some(&paint));
assert_eq!(ramps, one.iter().skip(1).collect::<Vec<_>>(), "the same light ramps");
// The single face's clip holds its seam: the clip reaches past the facet by half the seam.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
let widths: Vec<f64> = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect();
let widths: Vec<f64> = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0 && *w != crate::lighting::UNDERLAY_SEAM).collect();
assert!(widths.len() == 3 && widths.iter().all(|w| (w - seam).abs() < 1e-12), "{widths:?}");
let NodeKind::Group { children, .. } = &single.kind else { panic!("a clip group") };
let (clip, facet) = (children[0].path_data().and_then(PathData::bounds).unwrap(), face.path().bounds().unwrap());
+4
View File
@@ -892,6 +892,10 @@ pub use buildcmds::live_paint_gaps;
/// Control bar enable it when it changes one).
pub use brushsym::reset_transformation_changes;
/// The byte order of UTF-16 text without a byte-order mark, by Import Data Sets' rule (the Graph
/// Data window reads files by it too).
pub use variablecmds::utf16_order_without_mark;
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
/// They run as before but the command palette leaves them out (it lists the command once).
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
+7 -5
View File
@@ -518,16 +518,18 @@ fn sort(s: &mut Session, p: &Value) -> Result<Value> {
}
let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect();
if apply == "opacity" {
if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) {
// The opacity each object is given, as stored; an object whose stored opacity it already is has not changed.
let given: Vec<(NodeId, f32)> = wanted.iter().map(|(o, v)| (o.id, (v / 100.0) as f32)).collect();
let changed: Vec<NodeId> = given.iter().filter(|(id, a)| d.node(*id).is_some_and(|n| n.opacity != *a)).map(|(id, _)| *id).collect();
if changed.is_empty() {
return Ok(count);
}
s.edit("Sort Objects", |d, _| {
for (o, v) in &wanted {
if let Some(n) = d.node_mut(o.id) {
n.opacity = (v / 100.0) as f32;
for (id, a) in &given {
if let Some(n) = d.node_mut(*id) {
n.opacity = *a;
}
}
let changed: Vec<NodeId> = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect();
keep_series_opacity(d, &changed);
Ok(())
})?;
+1 -1
View File
@@ -11,7 +11,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
+22 -5
View File
@@ -217,11 +217,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
// An extending stroke that ends within four Fidelities of the path's other end closes it:
// at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
// `closed`, when given, says whether the stroke closes the path: the Pencil and the
// Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release.
// Left out, an extending stroke that ends within four Fidelities of the path's other end
// closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
let close_tol = (tol * 4.0).clamp(6.0, 400.0);
let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol);
let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first };
let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close;
// A stroke ending near the other end ends on it; one ending farther away closes with a
// straight segment back to it.
let ends_on_first = closing && near_first;
if ends_on_first && let Some(l) = pts.last_mut() {
*l = first;
}
if let Some((_, _, y, _)) = &connect
@@ -238,13 +245,23 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
add.remove(0);
}
sp.anchors.extend(add);
if closing
if ends_on_first
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
&& let Some(a0) = sp.anchors.first_mut()
{
a0.h_in = l.h_in;
sp.closed = true;
} else if closing && !ends_on_first && sp.anchors.len() > 2 {
// Closed from away from the other end: a straight segment back to it, whatever
// handles the open ends carried.
sp.closed = true;
if let Some(l) = sp.anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = sp.anchors.first_mut() {
f.h_in = f.p;
}
}
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
+2 -14
View File
@@ -352,20 +352,8 @@ fn svg_darkness_preserves_source_model_and_gradient_appearance() {
#[test]
fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() {
const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: exercise this profile in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated color profile test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
// Color settings are process-wide: exercise this profile in a separate test process.
if !crate::tests::in_own_process("cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space") {
return;
}
use vectorcraft_color::{Color, Paint, cms};
+18
View File
@@ -840,6 +840,24 @@ fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() {
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6));
}
/// A Sort Objects that gives series marks the opacities they already have changes nothing: the value the graph stores
/// stays and no undo step is made, even when the evenly stepped percentage differs from a mark's in the last digits.
#[test]
fn an_opacity_sort_objects_that_changes_no_mark_keeps_the_stored_opacity() {
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 65})).unwrap();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
// From left to right the columns have 30%, 65% and 100%, the values an even step from 30% to 100% gives them.
let undo = s.doc().unwrap().history.undo.len();
let ids = [marks[0].0, marks[1].0, marks[2].0];
s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap();
let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect();
assert_eq!(opacities, [0.3, 0.65, 1.0]);
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
}
/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark
/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them
/// when the graph is drawn again.
+3 -2
View File
@@ -472,7 +472,7 @@ impl Session {
pub fn transparency_info(&self) -> TransparencyInfo {
match (self.active(), transparency_targets(self, &Value::Null)) {
(Some(st), Ok(ids)) => {
let points = super::paint::picked_mesh_points_of(self, &ids);
let points = self.mesh_points_picked();
TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points)
}
_ => TransparencyInfo::default(),
@@ -483,7 +483,8 @@ impl Session {
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = transparency_targets(s, p)?;
let points = super::paint::picked_mesh_points_of(s, &ids);
// The points `transparency.set` with the same `ids` (or none) fades.
let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) };
let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points);
Ok(json!({
"ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
+74 -10
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency",
["Window", "Transparency"],
None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
has_doc,
transparency
),
@@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Fill and Stroke",
[],
None,
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}",
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}",
always,
proxies
),
@@ -179,9 +179,13 @@ impl Session {
/// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the
/// first selected object's own stack) for the proxy of its kind, else the first selected
/// object's (a group's first painted object's; type: its first run's), else the defaults for
/// new art.
/// new art. When the object whose fill the proxy shows is a gradient mesh with points picked
/// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid
/// fill color changes.
pub fn proxy_paints(&self) -> (Paint, Paint) {
let points = self.fill_mesh_points();
let show = |stroke: bool| match self.proxy_source(stroke) {
Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c),
Some((n, item)) => proxy_paint(&n, stroke, item),
None if stroke => self.paint.stroke.clone(),
None => self.paint.fill.clone(),
@@ -273,6 +277,13 @@ impl DocState {
/// each selected object's own stack is compared. One walk of the document, so callers that ask
/// every frame cache it by revision.
pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) {
self.proxy_mixed_with(item, &[])
}
/// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors
/// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of
/// its picked points instead of its fill.
fn proxy_mixed_with(&self, item: Option<usize>, points: &[(NodeId, Vec<usize>)]) -> (bool, bool) {
if self.selection.is_empty() {
return (false, false);
}
@@ -291,7 +302,10 @@ impl DocState {
let differ = |stroke: bool| {
let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke));
let set = if item.is_some() { &selected } else { &nodes };
let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item));
let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) {
c if c.is_empty() => vec![proxy_paint(n, stroke, item)],
c => c.into_iter().map(Paint::solid).collect(),
});
let Some(p0) = paints.next() else { return false };
paints.any(|p| !same_in_proxy(&p0, &p))
};
@@ -313,8 +327,9 @@ impl DocState {
impl Session {
/// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item.
/// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors.
pub fn proxy_mixed(&self) -> (bool, bool) {
self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item()))
self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points()))
}
}
@@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
let selected = item.targets(s, p)?;
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
// The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill
// recolours just those points of their meshes.
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid
// fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`).
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
// Without `item`, unless the Appearance panel's active fill item takes the paint.
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
@@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option<Vec<(
(!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v)
}
/// The colors of the picked points of `n` (see [`picked_mesh_points`]) when it is a gradient mesh
/// among `points`, in the order they were picked; empty otherwise.
fn picked_colors(n: &Node, points: &[(NodeId, Vec<usize>)]) -> Vec<Color> {
match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) {
(NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(),
_ => vec![],
}
}
impl Session {
/// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or
/// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are
/// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's
/// active item is a fill row or an object of a symbol instance is selected, and a mesh with
/// points picked counts even when other objects are selected with it.
pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec<usize>)> {
let Some(st) = self.active() else { return vec![] };
// Only gradient meshes have points to pick: a selection with no mesh and no container that
// could hold one skips reading the tool's options.
let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container());
if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) {
return vec![];
}
let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] };
if item != ItemTarget::Top {
return vec![];
}
let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] };
picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected))
}
/// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set`
/// without `ids` or `item`) fades instead of their objects: `None` unless every target is a
/// gradient mesh with points picked, and while the Appearance panel's active item takes the
/// opacity (outside opacity mask editing).
pub fn mesh_points_picked(&self) -> Option<Vec<(NodeId, Vec<usize>)>> {
let st = self.active()?;
let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?;
// Only gradient meshes have points to pick: other targets skip reading the tool's options.
if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) {
return None;
}
if st.doc.mask_edit.is_none() && self.appearance_item().is_some() {
return None;
}
picked_mesh_points_of(self, &ids)
}
}
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
if !paint.is_none() {
@@ -1086,8 +1150,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
q.remove("item");
q.remove("id");
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
// The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity
// themselves, so only the mesh around them fades (#4206, #4170, #4208).
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the
// opacity themselves, so only the mesh around them fades (#4206, #4170, #4208).
if matches!(item, ItemTarget::Top)
&& let Some(o) = q.get("opacity").and_then(Value::as_f64)
&& let Some(points) = picked_mesh_points_of(s, &ids)
+16 -14
View File
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reverse Path Direction",
["Object", "Path"],
None,
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}",
has_selected_path,
reverse
),
@@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
let mut changed = 0;
for id in &ids {
let path = path_mut(d, *id)?;
if let Some(si) = subpath {
if let Some(sp) = path.subpaths.get_mut(si) {
let flip: Vec<bool> = match subpath {
Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(),
None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(),
};
if !flip.contains(&true) {
continue;
}
// The stroke's width profile and pen recordings turn with the path, read before it turns.
if let Some(n) = d.node_mut(*id) {
vectorcraft_doc::blend::reverse_stroke_records(n, &flip);
}
let path = path_mut(d, *id)?;
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
changed += 1;
}
} else {
let flip: Vec<bool> = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect();
if flip.contains(&true) {
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
}
}
changed += 1;
}
}
changed += 1;
}
Ok(changed)
})?;
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
has_doc,
place
),
+16 -3
View File
@@ -3,6 +3,7 @@
use serde_json::Value;
use super::super::utf16_order_without_mark;
use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param};
/// Text files as `file.place` reports them (not a format `document.open` reads).
@@ -76,8 +77,10 @@ pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result<String>
Ok(clean(&decode(bytes, o), o))
}
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are
/// read as the platform's 8-bit set), or the platform's 8-bit character set.
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark, or without one when the
/// file has an even length, reads as valid UTF-16 with no NUL character in the byte order
/// [`utf16_order_without_mark`] gives; bytes that aren't UTF-8 are read as the platform's 8-bit
/// set), or the platform's 8-bit character set.
fn decode(bytes: &[u8], o: TextOptions) -> String {
let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() };
if o.ansi {
@@ -91,7 +94,17 @@ fn decode(bytes: &[u8], o: TextOptions) -> String {
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[0xFF, 0xFE, rest @ ..] => utf16(rest, true),
[0xFE, 0xFF, rest @ ..] => utf16(rest, false),
_ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string),
_ => {
let without_mark = (bytes.len().is_multiple_of(2) && bytes.contains(&0))
.then(|| utf16_order_without_mark(bytes))
.flatten()
.and_then(|le| {
let units = bytes.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok()
})
.filter(|text| !text.contains('\0'));
without_mark.unwrap_or_else(|| std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string))
}
}
}
+1
View File
@@ -26,6 +26,7 @@ use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids
mod csv;
mod library;
pub use csv::utf16_order_without_mark;
use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import};
pub(crate) use library::{give_svg_library, svg_library};
use library::{load_library, save_library};
+23 -13
View File
@@ -59,25 +59,35 @@ fn utf16(bytes: &[u8], le: bool) -> Option<String> {
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok().filter(|_| odd.is_empty())
}
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of
/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with
/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is
/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or
/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either
/// order has no data rows, and is read little-endian so that the import reports that. The text
/// must be valid UTF-16 in that order and hold no NUL character.
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
let (units, odd) = bytes.as_chunks::<2>();
if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
/// The byte order of `bytes`, which hold NUL bytes and no byte-order mark, read as UTF-16:
/// `Some(true)` for little-endian, or `None` when they don't read as UTF-16. At least half of the
/// two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with fewer,
/// such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is the one
/// that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or U+0D00,
/// so a file that reads one in both orders is refused. A file that reads none in either order (one
/// line) is big-endian when more of its units start with a NUL byte than end with one, as
/// characters from U+0001 to U+00FF do in that order, and little-endian otherwise. An odd last
/// byte is left out of the count.
pub fn utf16_order_without_mark(bytes: &[u8]) -> Option<bool> {
let units = bytes.as_chunks::<2>().0;
if units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
return None;
}
let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D);
let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c)));
let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c)));
if lines_le && lines_be {
return None;
match (lines_le, lines_be) {
(true, true) => None,
(false, false) => Some(units.iter().filter(|c| c[0] == 0).count() <= units.iter().filter(|c| c[1] == 0).count()),
(_, lines_be) => Some(!lines_be),
}
utf16(bytes, !lines_be).filter(|text| !text.contains('\0'))
}
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16 in the byte order
/// [`utf16_order_without_mark`] gives, or `None`. The file must have an even length, and the text
/// must be valid UTF-16 in that order and hold no NUL character.
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
utf16(bytes, utf16_order_without_mark(bytes)?).filter(|text| !text.contains('\0'))
}
/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted.
+19
View File
@@ -241,6 +241,25 @@ pub(crate) fn key_guide(s: &Session) -> Option<usize> {
st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied())
}
/// Runs the unit test `path` (`tests::newdoc::...`) again in a child process of the test binary
/// → whether this is that child. For a test that changes process-wide state the tests running
/// beside it read, such as the active color settings.
pub(crate) fn in_own_process(path: &str) -> bool {
const CHILD: &str = "VECTORCRAFT_TEST_OWN_PROCESS";
if std::env::var_os(CHILD).is_some() {
return true;
}
let output =
std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", path, "--nocapture"]).env(CHILD, "1").output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"{path} failed in its own process:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
false
}
#[test]
fn create_and_undo_redo() {
let mut s = session();
+55
View File
@@ -163,6 +163,61 @@ fn reverse_path_direction_on_and_off_set_the_winding() {
assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse");
}
#[test]
fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path() {
use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile};
let mut s = session();
let open =
NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 50}, {"x": 40, "y": 50}, {"x": 100, "y": 50}]}))["id"].as_u64().unwrap());
let closed = rect(&mut s, 10.0);
let profile = WidthProfile { points: vec![(0.0, 0.2, 0.6), (0.3, 1.0, 0.4), (1.0, 0.5, 0.9)] };
let pressure = PressureProfile { points: vec![(0.0, 0.1), (0.25, 0.9), (1.0, 0.4)] };
let bearing = AngleProfile { points: vec![(0.0, 10.0), (0.6, 80.0), (1.0, 170.0)] };
s.edit("set", |d, _| {
for id in [open, closed] {
let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap();
st.profile = Some(profile.clone());
st.pressure = Some(pressure.clone());
st.tilt = Some(pressure.clone());
st.bearing = Some(bearing.clone());
st.rotation = Some(bearing.clone());
}
Ok(())
})
.unwrap();
// (anchor point, its fraction of the path's length) for each anchor.
let along = |s: &Session, id: NodeId| {
let sp = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
let pos = sp.anchor_arc_positions();
let total = *pos.last().unwrap();
sp.anchors.iter().zip(pos).map(|(a, at)| (a.p, at / total)).collect::<Vec<_>>()
};
let before = [along(&s, open), along(&s, closed)];
run(&mut s, "select.set", json!({"ids": [open.0, closed.0]}));
assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 2);
for ((id, before), closed) in [open, closed].into_iter().zip(before).zip([false, true]) {
let after = along(&s, id);
let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone();
// Each anchor keeps the widths it had (its sides swapped, as the path runs the other way)
// and what the pen recorded there. (A closed path's start is where its profile steps.) A
// closed path now starts part way along a span of the width profile, which is resampled
// there, so its smooth curve moves a little.
let near = if closed { 0.02 } else { 1e-6 };
for (p, t) in before.iter().filter(|(_, t)| !closed || *t > 0.0) {
let u = after.iter().find(|(q, _)| q.distance(*p) < 1e-9).unwrap().1;
let (l, r) = profile.at(*t);
let (l2, r2) = st.profile.as_ref().unwrap().at(u);
assert!((l - r2).abs() < near && (r - l2).abs() < near, "{id:?} at {p:?}: {:?} then {:?}", (l, r), (l2, r2));
for got in [&st.pressure, &st.tilt] {
assert!((pressure.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} pressure at {p:?}");
}
for got in [&st.bearing, &st.rotation] {
assert!((bearing.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} bearing at {p:?}");
}
}
}
}
#[test]
fn attributes_survive_a_native_round_trip() {
let mut s = session();
+31
View File
@@ -3878,6 +3878,37 @@ fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() {
assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_)));
}
#[test]
fn live_paint_selection_double_click_selects_the_faces_no_stroked_edge_parts() {
let mut s = session();
let g = live(&mut s);
// The edge between the middle and right faces draws no stroke.
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100.0, 50.0], "none": true})).unwrap();
let v = ViewInfo::default();
s.select_tool("livePaintSelection", v).unwrap();
let gn = node(&s, g);
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id;
let before = s.doc().unwrap().doc.clone();
let double_click = |s: &mut Session, x: f64, m: Mods| {
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
s.pointer(&PointerEvent::new(kind, x, 50.0).with_mods(m), v).unwrap();
}
let mut ids = s.doc().unwrap().selection.objects.clone();
ids.sort();
ids
};
let sorted = |mut ids: Vec<NodeId>| {
ids.sort();
ids
};
assert_eq!(double_click(&mut s, 125.0, Mods::default()), sorted(vec![face(75.0), face(125.0)]));
// The stroked edge at x = 50 parts the left face from the others.
assert_eq!(double_click(&mut s, 25.0, Mods::default()), vec![face(25.0)]);
// Shift-double-click adds the faces reached to the selection.
assert_eq!(double_click(&mut s, 75.0, Mods { shift: true, ..Default::default() }), sorted(vec![face(25.0), face(75.0), face(125.0)]));
assert_eq!(s.doc().unwrap().doc, before, "selecting changes no paint");
}
#[test]
fn live_paint_selection_marquee_selects_what_it_touches() {
let mut s = session();
+4 -1
View File
@@ -16,7 +16,10 @@ use crate::*;
/// Every test here reads or changes the process-wide proof view or colour settings: one at a time.
/// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs`
/// (their own process): every colour shown or rendered goes through them, so no lock could cover
/// all the tests that would see the change.
/// all the tests that would see the change. A unit test that assigns its front document a profile
/// other than the working space changes the active settings the same way until another document
/// comes to the front: it starts with `crate::tests::in_own_process`, which runs it again in a
/// child process.
pub(crate) static GLOBAL: Mutex<()> = Mutex::new(());
fn session() -> Session {
+41
View File
@@ -5269,6 +5269,47 @@ fn pencil_extend_closes_within_400_pt_at_any_fidelity() {
}
}
/// `closed`, when given, decides whether a stroke continuing an open path closes it (the tools
/// send it by Close Paths When Ends Are Within): false leaves it open even ending on the other end;
/// true closes one ending 50 pt away with a straight segment back, the stroke's end kept, and one
/// ending 2 pt away on the other end itself.
#[test]
fn pencil_extend_closes_as_closed_says() {
let mut s = session();
for (end, closed, ends_on_start) in [([0i32, 0], false, false), ([0, 50], true, false), ([0, 2], true, true)] {
let l = line(&mut s, 0.0, 0.0, 200.0, 0.0);
let points = json!([[200, 0], [200, 100], [100, 150], end]);
let p = json!({"points": points, "fidelity": 1.5, "closed": closed, "extend": {"id": l.0}, "keepSelected": false});
s.execute("path.freehand", &p).unwrap();
let pd = path(&s, l);
let sp = &pd.subpaths[0];
assert_eq!(sp.closed, closed, "ending at {end:?}");
if closed {
let stroke_end = Point::new(f64::from(end[0]), f64::from(end[1]));
let kept = sp.anchors.iter().any(|a| a.p.distance(stroke_end) < 1e-6);
assert_eq!(kept, !ends_on_start, "ending at {end:?}: {:?}", sp.anchors);
let on_start = sp.anchors.iter().filter(|a| a.p.distance(Point::new(0.0, 0.0)) < 1e-6).count();
assert_eq!((sp.anchors[0].p, on_start), (Point::new(0.0, 0.0), 1), "ending at {end:?}");
}
}
}
/// A continued path closed from away from its other end closes with a straight segment, even when
/// that end carries a handle the open path didn't use.
#[test]
fn pencil_extend_closes_far_away_with_a_straight_segment() {
let mut s = session();
let anchors = json!([{"x": 0, "y": 0, "in": [-40, 30]}, {"x": 200, "y": 0}]);
let l = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
let points = json!([[200, 0], [200, 100], [100, 150], [0, 50]]);
let p = json!({"points": points, "fidelity": 1.5, "closed": true, "extend": {"id": l.0}, "keepSelected": false});
s.execute("path.freehand", &p).unwrap();
let sp = path(&s, l).subpaths[0].clone();
assert!(sp.closed);
let (first, last) = (sp.anchors[0], sp.anchors[sp.anchors.len() - 1]);
assert_eq!((first.h_in, last.h_out), (first.p, last.p), "the closing segment has no handles: {:?}", sp.anchors);
}
/// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never
/// crash, and what they draw has finite anchors.
#[test]
+3
View File
@@ -683,6 +683,9 @@ fn the_new_document_links_from_the_profile_file() {
/// is a warning.
#[test]
fn a_profile_brings_the_color_profile_its_file_carries() {
if !super::in_own_process("tests::newdoc::a_profile_brings_the_color_profile_its_file_carries") {
return;
}
use vectorcraft_color::cms;
let (mut s, dir) = with_profiles("icc");
let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap();
+9 -2
View File
@@ -5,6 +5,7 @@
//! as images of the effected object.
use serde_json::{Value, json};
use vectorcraft_color::cms;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::Rect;
use vectorcraft_render::{Rendered, Renderer};
@@ -173,16 +174,22 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO)
{
let (mut s, g) = striped();
// Every color drawn goes through the process-wide color settings. A test that changes them
// runs in a process of its own (`crate::tests::in_own_process`).
let colors = cms::active();
assert_eq!(cms::active_settings(), cms::ColorSettings::default(), "{id}: another test left the color settings changed");
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
let a = render(&s);
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic");
let again = render(&s);
// The multithreaded pipeline draws the same.
let mut mt = Renderer::new();
mt.threads = 4;
let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
assert!(std::sync::Arc::ptr_eq(&colors, &cms::active()), "{id}: another test changed the color settings while this one drew");
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
assert_eq!(a.pixels, again.pixels, "{id} is deterministic");
assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b));
}
}
+3
View File
@@ -112,6 +112,9 @@ fn a_linked_pngs_bytes_are_embedded() {
#[test]
fn an_embedded_profile_is_restored() {
if !super::in_own_process("tests::saveoptions::an_embedded_profile_is_restored") {
return;
}
let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap();
let icc = cms::icc_bytes(&builtin.name).unwrap();
let name = format!("Studio RGB {}", std::process::id());
+43
View File
@@ -68,6 +68,49 @@ fn type_placed_on_an_artboard_edge_takes_that_artboards_frame() {
}
}
/// `text` as UTF-16 without a byte-order mark, little-endian when `le` is set.
fn utf16(text: &str, le: bool) -> Vec<u8> {
text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect()
}
/// Unicode text in UTF-16 without a byte-order mark is read as UTF-16 when at least half of its
/// two-byte units hold a NUL byte, in the byte order that reads a line break, or for one line, the
/// order its NUL bytes give. Other bytes keep today's reading.
#[test]
fn utf16_without_a_byte_order_mark_places_by_its_nul_bytes_and_line_breaks() {
let mut s = session();
for le in [true, false] {
let r = place_text(&mut s, &utf16("Hello, 世界 café!\r\nSecond line", le), json!({}));
assert_eq!(placed(&s, &r).0, "Hello, 世界 café!\nSecond line", "little-endian: {le}");
let r = place_text(&mut s, &utf16("One line, 世界", le), json!({}));
assert_eq!(placed(&s, &r).0, "One line, 世界", "little-endian: {le}");
}
// One line with NUL bytes in both places (U+4E00 ends in a zero byte) takes the order more of
// its units give, and a tie is little-endian.
let r = place_text(&mut s, &utf16("一AB", false), json!({}));
assert_eq!(placed(&s, &r).0, "一AB");
let r = place_text(&mut s, &utf16("一A", true), json!({}));
assert_eq!(placed(&s, &r).0, "一A");
// Not read as UTF-16, so read as UTF-8 or else Windows-1252 with NUL bytes dropped: an odd
// length, an unpaired surrogate, a NUL character, and line breaks in both byte orders (U+0A00).
let r = place_text(&mut s, b"A\0B", json!({}));
assert_eq!(placed(&s, &r).0, "AB");
let r = place_text(&mut s, b"\0a\0b\xd8\0", json!({}));
assert_eq!(placed(&s, &r).0, "abØ");
let r = place_text(&mut s, b"\0\0\xc3\xa9", json!({}));
assert_eq!(placed(&s, &r).0, "é");
let r = place_text(&mut s, &utf16("A\u{0A00}\nB", true), json!({}));
assert_eq!(placed(&s, &r).0, "A\n\nB");
// Fewer than half of the two-byte units hold a NUL byte: UTF-8, and Windows-1252.
let r = place_text(&mut s, "Fünf\0 Zeilen!".as_bytes(), json!({}));
assert_eq!(placed(&s, &r).0, "Fünf Zeilen!");
let r = place_text(&mut s, b"caf\xe9\0x", json!({}));
assert_eq!(placed(&s, &r).0, "caféx");
// ANSI reads each byte as a character of the 8-bit set; NUL and other control bytes are dropped.
let r = place_text(&mut s, &utf16("Hi, 世界", true), json!({"text": {"characterSet": "ansi"}}));
assert_eq!(placed(&s, &r).0, "Hi, NLu");
}
#[test]
fn text_files_report_and_queue_with_their_options() {
let mut s = session();
+10
View File
@@ -2201,6 +2201,16 @@ fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() {
let e = import(&mut s, &bytes).unwrap_err().to_string();
assert!(e.contains("CSV has a header but no data rows"), "{e}");
}
// With no line break, the side of each unit that holds the NUL byte gives the byte order, and a
// tie is little-endian.
let order = crate::cmd::utf16_order_without_mark;
assert_eq!(order(&utf16("City", false)), Some(false));
assert_eq!(order(&utf16("City", true)), Some(true));
assert_eq!(order(b"\0aa\0"), Some(true));
assert_eq!(order(&[utf16("City\n", false), vec![0x0A]].concat()), Some(false), "an odd last byte is left out");
// Read big-endian, this one-line file ends in an unpaired surrogate.
let e = import(&mut s, b"\0a\0b\xd8\0").unwrap_err().to_string();
assert!(e.contains("CSV holds NUL bytes"), "{e}");
import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap();
assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目"));
// Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks
+86
View File
@@ -1115,6 +1115,92 @@ fn lasso_picks_gradient_mesh_points() {
assert_eq!(red, top, "only the points inside the loop are recoloured");
}
/// With gradient mesh points picked, the Fill proxy (and the Color panel that edits it) shows
/// the color a solid fill gives them, not the mesh object's own fill: "?" when the picked
/// points' colors differ, the object's fill again once no point is picked.
#[test]
fn the_fill_proxy_shows_the_picked_mesh_points_color() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap();
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
let object_fill = s.proxy_paints().0;
// Round the top row of points.
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap();
let shown = s.execute("paint.proxies", &json!({})).unwrap();
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()), "the picked points' color: {shown}");
assert_eq!(shown["fillMixed"], json!(false));
assert_eq!(shown["fill"], json!(s.proxy_paints().0));
// One picked point takes another color: the points differ.
let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") };
let first = m.idx(0, 0);
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": first, "color": "#00ff00"})).unwrap();
assert!(s.proxy_mixed().0, "the picked points' colors differ");
// Picking none (the Selection tool) shows the object's fill again.
s.select_tool("selection", ViewInfo::default()).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
assert_eq!(s.proxy_paints().0, object_fill);
assert!(!s.proxy_mixed().0);
}
/// With the Appearance panel's stroke row active, a solid fill still recolors the picked mesh
/// points, so the Fill proxy shows them; with its fill row active, the fill goes to that row, and
/// the proxy shows the row.
#[test]
fn the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
let items = &s.doc().unwrap().doc.node(a).unwrap().appearance.items;
let fill_row = items.iter().position(|it| it.is_fill()).expect("a fill row");
let stroke_row = items.iter().position(|it| !it.is_fill()).expect("a stroke row");
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
assert_eq!(s.fill_mesh_points().first().map(|(_, v)| v.len()), Some(3), "the top row is picked");
let point_color = |s: &Session| {
let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") };
m.points[m.idx(0, 0)].color.to_hex()
};
s.execute("appearance.setActiveItem", &json!({"index": stroke_row})).unwrap();
s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap();
assert_eq!(point_color(&s), "#3366ff", "the stroke row leaves the fill on the picked points");
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()));
assert!(!s.proxy_mixed().0);
s.execute("appearance.setActiveItem", &json!({"index": fill_row})).unwrap();
assert!(s.fill_mesh_points().is_empty());
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
assert_eq!(point_color(&s), "#3366ff", "the fill row takes the fill");
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#00ff00".into()));
}
/// A gradient mesh with points picked selected with another object: a solid fill recolors the
/// points and fills the other object, and the Fill proxy and its "?" count the points' colors.
#[test]
fn the_fill_proxy_counts_picked_mesh_points_beside_other_objects() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
assert_eq!(s.fill_mesh_points().first().map(|(id, v)| (*id, v.len())), Some((a, 3)), "the top row is still picked");
s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap();
let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") };
assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#3366ff");
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()));
assert!(!s.proxy_mixed().0, "the points and the other object are the same color");
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap();
assert!(s.proxy_mixed().0, "the picked points and the other object's fill differ");
}
/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are
/// deselected and the others selected, the rest of the selection staying as it was.
#[test]
+11 -8
View File
@@ -1771,8 +1771,9 @@ fn both_points_snap_onto_anchors_with_a_visible_indicator() {
}
/// Smart Guides for the Linear picks: hovering away from any line, the first point slides into
/// line with another object's corner and its guides show before the click; the second slides onto
/// the construction guide through the first. The dimension measures between the snapped points.
/// line with another object's corner and its guides show before the click; the second slides into
/// line with the other corner, with no construction guide through the first point pulling it
/// sideways. The dimension measures between the snapped points.
#[test]
fn linear_picks_show_and_snap_to_smart_guides() {
use vectorcraft_engine::tools::Overlay;
@@ -1788,18 +1789,20 @@ fn linear_picks_show_and_snap_to_smart_guides() {
assert!(o.iter().any(|o| matches!(o, Overlay::Line { .. } | Overlay::Label { .. })), "its guides show before the click: {o:?}");
ev(&mut s, PointerKind::Down, 250.0, 202.5);
ev(&mut s, PointerKind::Up, 250.0, 202.5);
// Straight below the first point, 1.5 pt off: the rubber band ends on the vertical through it.
ev(&mut s, PointerKind::Move, 251.5, 260.0);
// Below the first point, 1.5 pt to its right and 2.5 pt off the bottom edge's level: the
// rubber band ends on that level, where the pointer is across it.
ev(&mut s, PointerKind::Move, 251.5, 262.5);
let end = overlays(&mut s).into_iter().find_map(|o| if let Overlay::Line { b, dashed: true, .. } = o { Some(b) } else { None }).unwrap();
assert_eq!((end.x, end.y), (250.0, 260.0), "on the construction guide");
ev(&mut s, PointerKind::Down, 251.5, 260.0);
ev(&mut s, PointerKind::Up, 251.5, 260.0);
assert_eq!((end.x, end.y), (251.5, 260.0), "in line with the bottom edge, not on a vertical through the first point");
ev(&mut s, PointerKind::Down, 251.5, 262.5);
ev(&mut s, PointerKind::Up, 251.5, 262.5);
ev(&mut s, PointerKind::Move, 220.0, 230.0);
assert!(!overlays(&mut s).iter().any(|o| matches!(o, Overlay::Label { .. })), "placing: the guides are gone");
ev(&mut s, PointerKind::Down, 220.0, 230.0);
ev(&mut s, PointerKind::Up, 220.0, 230.0);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "60.00 pt");
// Aligned from (250, 200) to (251.5, 260): √(1.5² + 60²).
assert_eq!(text(&s, id), "60.02 pt");
}
/// The page content streams of exported PDF `v`, inflated.
@@ -481,6 +481,151 @@ fn solid_gradient_opacity_is_applied_once_even_without_shading() {
}
}
/// How `image`, `d` drawn at 1:1 on 400 × 400 pixels, differs from `d` drawn at 3x and averaged
/// down, on the pixels inside the art: those whose 3x average has some alpha, the same as every
/// pixel within 3 of them. Left out too are pixels where the 3x average bends sharply (a channel's
/// second difference across the pixel, along x or y, over 4 levels), such as the rim where a cap
/// meets a body: a gradient sampled at each pixel's center matches an area average only where the
/// light changes close to linearly across the pixel. Returns (pixels measured, premultiplied RGB
/// channels more than 3 levels off, the largest RGB difference, alphas off: below 255 where the 3x
/// average is opaque, otherwise more than 3 levels off, the first few pixels off).
fn seams_against_supersampled(d: &Document, image: &vectorcraft_render::Rendered) -> (usize, usize, u8, usize, Vec<String>) {
let fine = vectorcraft_render::Renderer::new().render(
d,
1200,
1200,
kurbo::Affine::scale(3.0),
&vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() },
);
let average = fine.area_resized(400, 400).unwrap();
let (mut measured, mut color, mut max, mut holes) = (0, 0, 0, 0);
let mut worst = vec![];
for y in 3..397 {
for x in 3..397 {
let q = average.pixel(x, y);
if q[3] == 0 || !(y - 3..=y + 3).all(|y| (x - 3..=x + 3).all(|x| average.pixel(x, y)[3] == q[3])) {
continue;
}
let bend = |a: [u8; 4], b: [u8; 4]| (0..3).any(|c| (i32::from(a[c]) + i32::from(b[c]) - 2 * i32::from(q[c])).abs() > 4);
if bend(average.pixel(x - 1, y), average.pixel(x + 1, y)) || bend(average.pixel(x, y - 1), average.pixel(x, y + 1)) {
continue;
}
measured += 1;
let p = image.pixel(x, y);
for c in 0..3 {
let diff = p[c].abs_diff(q[c]);
max = max.max(diff);
color += usize::from(diff > 3);
}
let hole = if q[3] == 255 { p[3] < 255 } else { p[3].abs_diff(q[3]) > 3 };
holes += usize::from(hole);
if (p.into_iter().zip(q).any(|(a, b)| a.abs_diff(b) > 3) || hole) && worst.len() < 4 {
worst.push(format!("({x},{y}) {p:?} vs {q:?}"));
}
}
}
(measured, color, max, holes, worst)
}
/// Colored lights on smooth 3D surfaces, compared at 1:1 with a 3x render averaged down, on every
/// pixel inside the art ([`seams_against_supersampled`]). A solid fill draws as one batched surface;
/// a source gradient composes each facet on its own, and one with transparent stops applies their
/// opacity once over the surface. Inflate seen straight on, one layer of facets, has no alpha off, and
/// in opaque fills no RGB channel more than 3 levels off. Inflate turned so facets narrow to slivers
/// at the edges, and Revolve with a cap over its body, draw facets over other facets, and have no
/// alpha off.
#[test]
fn colored_light_surfaces_match_supersampling_at_internal_facet_edges() {
let lights = json!([
{"color":"#ff0000","rotation":135,"height":35,"intensity":70},
{"color":"#0000ff","rotation":-35,"height":25,"intensity":65}
]);
let fills = |start: [u32; 2], end: [u32; 2]| {
[
json!({"color":"#ffcc66"}),
json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[
{"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"}
]}}),
json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[
{"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6}
]}}),
]
};
let mut cases = vec![];
for rotation in [(0, 0), (50, 35)] {
let (mut s, _) = setup("threeD.inflate");
let change = json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights,"rotationX":rotation.0,"rotationY":rotation.1}});
s.execute("effect.setParams", &change).unwrap();
cases.push((format!("inflate {rotation:?}"), s, fills([100, 150], [200, 150]), rotation == (0, 0)));
}
let (mut s, _) = capped_profile();
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap();
cases.push(("revolve".into(), s, fills([80, 200], [240, 200]), false));
let mut failures = vec![];
for (name, mut s, fills, one_layer) in cases {
for fill in fills {
s.execute("paint.setFill", &fill).unwrap();
let image = render(doc(&s));
let (measured, color, max, holes, worst) = seams_against_supersampled(doc(&s), &image);
assert!(measured > 2_000, "{name} {fill}: {measured} pixels inside");
let opaque = !fill.to_string().contains("opacity");
if holes > 0 || (one_layer && opaque && color > 0) {
failures.push(format!(
"{name} {fill}: of {measured} pixels, {color} channels off by more than 3 (at most {max}), {holes} alphas off: {worst:?}"
));
}
}
}
assert!(failures.is_empty(), "{failures:#?}");
}
/// The closed facet edges of colored-light surfaces survive SVG and PDF, read back and drawn at
/// 1:1, and Expand Appearance: inside the art, every pixel stays opaque and within 6 levels of the
/// live art.
#[test]
fn colored_light_seams_stay_closed_in_svg_pdf_and_expansion() {
let lights = json!([
{"color":"#ff0000","rotation":135,"height":35,"intensity":70},
{"color":"#0000ff","rotation":-35,"height":25,"intensity":65}
]);
for fill in [
json!({"color":"#ffcc66"}),
json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[
{"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"}
]}}),
] {
let (mut s, _) = setup("threeD.inflate");
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap();
s.execute("paint.setFill", &fill).unwrap();
let live = render(doc(&s));
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
assert!(svg.contains("<clipPath") && svg.contains("linearGradient") && svg.contains("screen") && !svg.contains("<image"), "{fill}");
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|w| w == b"/Subtype /Image"), "{fill}");
let before = doc(&s).clone();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let expanded = render(doc(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc(&s), &before);
for (name, image) in [
("SVG", render(&vectorcraft_svg::import(&svg).unwrap())),
("PDF", render(&vectorcraft_pdf::import(&bytes).unwrap())),
("expanded", expanded),
] {
let mut checked = 0;
for y in 103..197 {
for x in 103..197 {
let (p, q) = (live.pixel(x, y), image.pixel(x, y));
assert!(q[3] == 255 && p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 6), "{name} {fill} ({x},{y}): {p:?} vs {q:?}");
checked += 1;
}
}
assert_eq!(checked, 94 * 94);
}
}
}
#[test]
fn batched_colored_inflate_preserves_compound_holes_and_surface_opacity() {
let mut s = Session::new();
+3 -2
View File
@@ -228,8 +228,9 @@ const GRID_GRAY: f64 = 200.0 / 255.0;
/// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names
/// VectorCraft and the format's app, and holds no version number; the version is written on its
/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]).
const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes
/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). The reader reads the symbols of data with
/// this creator at their own size, as [`export_ai_editing`] writes them on a large canvas.
pub(crate) const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes
/// The first line of a PostScript file (the DSC version comment).
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
+2 -2
View File
@@ -4495,8 +4495,8 @@ fn type_the_data_cant_hold_is_written_as_its_outlines() {
rings.subpaths.extend(shapes::rectangle(Rect::new(120.0, 30.0, 170.0, 70.0)).subpaths);
holed.kind = vectorcraft_doc::TextKind::Area { frame: rings };
holed.xf = Affine::IDENTITY;
// A carriage return in the characters: a story ends its paragraphs with it. Area type that
// flows around a wrap object: the wrap object's settings aren't written.
// A carriage return in the characters: a story ends its paragraphs with it. Area type with
// wrap shapes and no wrap object: the text document doesn't hold the shapes.
let returned = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "A\rB", style(10.0, Color::BLACK));
let mut wrapped = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped round", style(10.0, Color::BLACK));
wrapped.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)) };
+4 -1
View File
@@ -204,7 +204,9 @@ pub(in crate::ai_editing) fn holds(n: &Node, t: &TextObject) -> bool {
shape(t).as_ref().and_then(segments).is_some_and(|s| !(open && closed(&s)))
}
};
// Area type that flows around wrap objects: the data doesn't hold their wrap settings.
// Area type with wrap shapes: the text document doesn't hold them. The reader works them out
// again from the art above the type, which can wrap it differently (a live object written as
// its drawn pieces) or not at all.
shape_ok
&& t.wrap.is_empty()
&& t.ruby.is_empty()
@@ -962,6 +964,7 @@ impl TextDoc {
matches!(t.kind, TextKind::Area { .. })
&& t.vertical == vertical
&& writable(n.id)
// The text document doesn't hold wrap shapes (see `holds`).
&& t.wrap.is_empty()
&& plain(n)
&& t.ruby.is_empty()
+1 -1
View File
@@ -94,7 +94,7 @@ pub(crate) use ai::{
};
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from};
pub(crate) use native::{MAX_TEXT, scale_document};
pub(crate) use native::{MAX_TEXT, scale_document, scale_symbols};
pub use text::family_style;
pub(crate) use text::{document_font, postscript_face};
+6
View File
@@ -215,6 +215,9 @@ pub struct Structure {
pub text_patterns: HashMap<String, Paint>,
/// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not.
pub canvas_scale: f64,
/// The data's symbols are at their own size, whatever its scale: VectorCraft wrote it (its
/// `%%Creator` is [`crate::ai_editing::CREATOR_TEXT`]).
pub symbols_own_size: bool,
/// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an
/// id two objects have.
pub xml_ids: HashMap<String, Option<NodeId>>,
@@ -648,6 +651,8 @@ struct Reader<'a> {
page_art: bool,
/// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not.
canvas_scale: f64,
/// [`Structure::symbols_own_size`], from the header's `%%Creator`.
symbols_own_size: bool,
/// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels).
ruler_units: Option<u8>,
/// This reader reads a pattern's definition (see [`Reader::define_pattern`]).
@@ -823,6 +828,7 @@ impl<'a> Reader<'a> {
large_canvas: None,
page_art: false,
canvas_scale: 1.0,
symbols_own_size: false,
ruler_units: None,
pattern: None,
pattern_tiles: HashMap::new(),
@@ -79,6 +79,10 @@ impl<'a> Reader<'a> {
}
return Ok(());
}
if let Some(v) = bytes.strip_prefix(b"%Creator:") {
self.symbols_own_size = v.trim_ascii() == crate::ai_editing::CREATOR_TEXT.as_bytes();
return Ok(());
}
if bytes == b"%EndComments" {
self.header_done = true;
}
+11 -1
View File
@@ -467,6 +467,10 @@ pub struct Symbols {
/// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds
/// them to a document read from the layers.
pub swatches: Vec<Swatch>,
/// The data's declared large canvas scale (1 when it declares none), not applied to the art.
pub canvas_scale: f64,
/// [`Structure::symbols_own_size`].
pub own_size: bool,
}
/// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library
@@ -529,7 +533,7 @@ pub fn symbols(data: &[u8], names: &mut obj::Names) -> Option<Symbols> {
.chain(art_names(symbols.iter().map(|(s, _)| &*s.art)))
.collect();
names.absorb_among(std::mem::take(&mut r.names), &used);
Some(Symbols { symbols, images, swatches })
Some(Symbols { symbols, images, swatches, canvas_scale: r.canvas_scale, own_size: r.symbols_own_size })
}
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
@@ -1194,6 +1198,11 @@ impl<'a> Reader<'a> {
drop_commented_copies(&mut self.doc.layers, &self.hidden_ids);
super::on_path::apply(&mut self.doc, &self.on_path);
let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0));
// A Large Canvas copy's symbols are at its art's scale too ([`Self::ensure_space`]), except
// in data VectorCraft wrote.
if let Some(s) = self.large_canvas.filter(|s| *s != 1.0 && !self.symbols_own_size) {
crate::import::scale_symbols(&mut doc, s);
}
// The active artboard, when it is not the first (the default).
let active = self.active_board.filter(|i| *i > 0 && *i < self.artboards.len());
let boards: Vec<AiArtboard> = if self.artboards.is_empty() {
@@ -1374,6 +1383,7 @@ impl<'a> Reader<'a> {
styles_read,
text_patterns,
canvas_scale: self.canvas_scale,
symbols_own_size: self.symbols_own_size,
xml_ids: self.xml_ids,
bindings,
bindings_skipped,
+6 -4
View File
@@ -70,6 +70,8 @@ pub(super) struct CidChar {
/// The base-font code or glyph name a `bfchar` or `bfrange` mapping gives it, instead of a
/// CID.
pub base: Option<Base>,
/// The value of its code (`<8001>` is 32769).
pub value: u32,
}
impl CMap {
@@ -146,12 +148,12 @@ impl CMap {
work += at.map_or(self.bases.len(), |k| k + 1) as u64;
let base = at.and_then(|k| self.bases.get(k)).and_then(|m| Some((m.4, base_of(&m.3, c - m.1)?)));
out.push(match (matched.is_some(), find(&self.cids, &mut work), base) {
(true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None },
(true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base) },
(true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None, value: c },
(true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base), value: c },
// No character mapping: the notdef mapping's CID, else font 0's CID 0.
_ => match find(&self.notdefs, &mut work) {
Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None },
_ => CidChar { font: 0, cid: 0, notdef: None, base: None },
Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None, value: c },
_ => CidChar { font: 0, cid: 0, notdef: None, base: None, value: c },
},
});
i += n;
+1 -1
View File
@@ -47,9 +47,9 @@ mod layers;
mod plan;
mod slots;
pub(crate) use build::scale_document;
#[cfg(test)]
use build::{Frame, MADE, prune_unseen};
pub(crate) use build::{scale_document, scale_symbols};
#[cfg(test)]
use compare::{
MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour,
+182 -10
View File
@@ -10,7 +10,7 @@ use super::slots::{
use super::{
Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, VariableKind, Vec2, ai,
};
use vectorcraft_doc::PlacedDocument;
use vectorcraft_doc::{PlacedDocument, Symbol};
/// Which box of the editing copy is the page.
#[derive(Clone, Copy)]
@@ -83,12 +83,11 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64, names: ai:
};
let in_units = |r: Option<Rect>, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0);
let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k)));
let styles = structure.styles_read;
let (styles, own_size) = (structure.styles_read, structure.symbols_own_size);
let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?;
if let Some(k) = scale {
// The graphic styles read from the file are drawn in its units too.
scale_document(&mut doc, k, styles);
relayout_wrapped(&mut doc);
scale_read_document(&mut doc, k, styles, own_size);
}
Ok((doc, notes))
}
@@ -108,12 +107,11 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6
let Some(scale) = alone_scale(&structure, data, pages, unit) else {
return Err(Box::new(structure));
};
let styles = structure.styles_read;
let (styles, own_size) = (structure.styles_read, structure.symbols_own_size);
let built = build_from(structure, data, None, Page::Artboard, false, false, 1.0).map(|(mut doc, notes)| {
if scale != 1.0 {
// The graphic styles read from the file are drawn in its units too.
scale_document(&mut doc, scale, styles);
relayout_wrapped(&mut doc);
scale_read_document(&mut doc, scale, styles, own_size);
}
(doc, notes)
});
@@ -268,6 +266,7 @@ pub(super) fn build_weighing(
styles_read,
text_patterns,
canvas_scale,
symbols_own_size,
xml_ids,
bindings,
bindings_skipped,
@@ -577,8 +576,7 @@ pub(super) fn build_weighing(
bind_variables(&mut doc, &bindings, bindings_skipped, &xml_ids, &filled, &mut notes);
placed_documents(&mut doc, &placed_pages);
if small.is_some() {
scale_document(&mut doc, unit, styles_read);
relayout_wrapped(&mut doc);
scale_read_document(&mut doc, unit, styles_read, symbols_own_size);
}
Ok((doc, notes))
}
@@ -1916,7 +1914,7 @@ fn drop_guides_off_boards(doc: &mut Document) {
/// styles.
pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) {
let a = Affine::scale(k);
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
let scaling = document_scaling();
for layer in &mut doc.layers {
let layer = Arc::make_mut(layer);
layer.transform(a, scaling);
@@ -1935,6 +1933,101 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) {
}
}
/// What [`scale_document`] and [`scale_symbols`] scale with the art: strokes, effects and pattern
/// tiles.
fn document_scaling() -> Scaling {
Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }
}
/// A document read in units of `1/k` points drawn at its size: [`scale_document`], and its wrapped
/// type laid out again. Unless `own_size` (data VectorCraft wrote, whose symbols and opacity masks
/// that don't move with their object are at their own size), also the art of those masks on the
/// layers and its symbols ([`scale_symbols`]).
fn scale_read_document(doc: &mut Document, k: f64, styles: bool, own_size: bool) {
scale_document(doc, k, styles);
if !own_size {
let a = Affine::scale(k);
for layer in &mut doc.layers {
transform_unlinked_masks(Arc::make_mut(layer), a, document_scaling());
}
scale_symbols(doc, k);
}
relayout_wrapped(doc);
}
/// Draw the art of `doc`'s symbols `k` times as large ([`scale_symbol_art`]), with every instance
/// on the layers and in a pattern's tile (opacity masks included) placed by its transform times
/// `1/k`, so it draws where and as large as before, with the symbol's strokes and effects `k`
/// times as wide.
pub(crate) fn scale_symbols(doc: &mut Document, k: f64) {
if !(k.is_finite() && k > 0.0) || k == 1.0 || doc.symbols.is_empty() {
return;
}
scale_symbol_art(&mut doc.symbols, k);
let back = Affine::scale(1.0 / k);
for n in doc.layers.iter_mut().chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) {
place_instances_by(n, back);
}
}
/// Draw the art of `symbols` `k` times as large, from each symbol's origin, with its strokes,
/// effects and pattern tiles, as [`scale_document`] draws the layers. An instance in the art keeps
/// its own scale and turn: it is placed by its transform times `1/k`.
pub(crate) fn scale_symbol_art(symbols: &mut [Symbol], k: f64) {
if !(k.is_finite() && k > 0.0) || k == 1.0 {
return;
}
let (a, back) = (Affine::scale(k), Affine::scale(1.0 / k));
for s in symbols {
let art = Arc::make_mut(&mut s.art);
art.transform(a, document_scaling());
transform_unlinked_masks(art, a, document_scaling());
scale_wrap_offsets(art, k);
place_instances_by(&mut s.art, back);
}
}
/// The art of the opacity masks in `n` that don't move with their object, which
/// [`Node::transform`] leaves where it is, transformed by `a` with `scaling` as `n` was: a change of
/// units moves them too.
fn transform_unlinked_masks(n: &mut Node, a: Affine, scaling: Scaling) {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
let linked = m.linked;
let art = Arc::make_mut(&mut m.art);
if !linked {
art.transform(a, scaling);
}
transform_unlinked_masks(art, a, scaling);
}
if let Some(children) = n.children_mut() {
for c in children {
transform_unlinked_masks(Arc::make_mut(c), a, scaling);
}
}
}
/// Every symbol instance in `n` (opacity masks included) placed by its transform times `m`. Only
/// the objects on the way to an instance are copied.
fn place_instances_by(n: &mut Arc<Node>, m: Affine) {
let mut holds = false;
vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
if !holds {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::SymbolInstance { xf, .. } = &mut n.kind {
*xf *= m;
}
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
place_instances_by(&mut mask.art, m);
}
if let Some(children) = n.children_mut() {
for c in children {
place_instances_by(c, m);
}
}
}
/// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points
/// they may be).
fn scale_wrap_offsets(n: &mut Node, k: f64) {
@@ -2256,6 +2349,85 @@ mod candidate_tests {
assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]);
}
/// Symbols drawn ten times as large keep every instance where and as large as it was (on a layer,
/// in an opacity mask's art, linked or not, and in a pattern's tile), and an instance in a
/// symbol's art keeps its own scale and turn, moved with the art.
#[test]
fn scaling_the_symbols_keeps_their_instances_in_place() {
let mut doc = Document::new(100.0, 100.0);
let boxed = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-1.0, -0.5, 1.0, 0.5)), Default::default());
doc.symbols.push(Symbol { name: "Box".into(), art: Arc::new(boxed), dynamic: false });
let turn = Affine::translate((3.0, 4.0)) * Affine::rotate(0.5) * Affine::scale(2.0);
let nested = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn });
let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(nested)]);
// An opacity mask that doesn't move with its object, holding an instance too.
let masking = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn });
let mut unlinked = vectorcraft_doc::OpacityMask::new(masking, true);
unlinked.linked = false;
pair.extras_mut().mask = Some(Box::new(unlinked));
doc.symbols.push(Symbol { name: "Pair".into(), art: Arc::new(pair), dynamic: false });
let place = Affine::translate((40.0, 30.0)) * Affine::scale(3.0);
let mut on_layer = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Pair".into(), xf: place });
let in_mask = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place });
on_layer.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(in_mask, true)));
doc.insert(Some(doc.layers[0].id), 0, on_layer).unwrap();
let tiled = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place });
doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)]));
// Each instance's box on the page, by the art of Box it places (through Pair's instance).
let placed = |d: &Document| {
let xf = |n: &Node| match &n.kind {
NodeKind::SymbolInstance { xf, .. } => *xf,
other => panic!("an instance: {other:?}"),
};
let art = d.symbols[0].art.geometric_bounds().unwrap();
let layer = &d.layers[0].children().unwrap()[0];
let mask = &layer.extras().mask.as_ref().unwrap().art;
let tile = &d.patterns[0].art[0];
let nested = &d.symbols[1].art.children().unwrap()[0];
let masking = &d.symbols[1].art.extras().mask.as_ref().unwrap().art;
[xf(layer) * xf(nested), xf(layer) * xf(masking), xf(mask), xf(tile)].map(|m| m.transform_rect_bbox(art))
};
let before = placed(&doc);
scale_symbols(&mut doc, 10.0);
let after = placed(&doc);
for (a, b) in after.iter().zip(&before) {
let far = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(far < 1e-9, "{after:?} {before:?}");
}
let pair = &doc.symbols[1].art.children().unwrap()[0];
let NodeKind::SymbolInstance { xf, .. } = &pair.kind else { panic!("an instance") };
let want = Affine::translate((30.0, 40.0)) * Affine::rotate(0.5) * Affine::scale(2.0);
let far = xf.as_coeffs().iter().zip(want.as_coeffs()).fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
assert!(far < 1e-9, "{xf:?}");
let art = doc.symbols[0].art.geometric_bounds().unwrap();
assert_eq!(art, Rect::new(-10.0, -5.0, 10.0, 5.0));
}
/// Layers read in the units of a page scaled up to points take the art of their opacity masks
/// with them, a mask that doesn't move with its object included, except in data VectorCraft
/// wrote, where that mask's art is at its own size.
#[test]
fn scaling_a_read_document_scales_unlinked_opacity_masks() {
for own_size in [false, true] {
let mut doc = Document::new(100.0, 100.0);
let mut boxes = vec![];
for linked in [true, false] {
let mask = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(1.0, 2.0, 3.0, 4.0)), Default::default());
let mut mask = vectorcraft_doc::OpacityMask::new(mask, true);
mask.linked = linked;
let mut n = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Default::default());
n.extras_mut().mask = Some(Box::new(mask));
boxes.push((linked, doc.insert(Some(doc.layers[0].id), 0, n).unwrap()));
}
scale_read_document(&mut doc, 10.0, false, own_size);
for (linked, id) in boxes {
let mask = doc.node(id).and_then(|n| n.extras().mask.as_ref().and_then(|m| m.art.geometric_bounds()));
let want = if linked || !own_size { Rect::new(10.0, 20.0, 30.0, 40.0) } else { Rect::new(1.0, 2.0, 3.0, 4.0) };
assert_eq!(mask, Some(want), "linked {linked}, own size {own_size}");
}
}
}
/// Area type in `doc`'s first layer at `index`, in the frame `r`.
fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId {
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default());
+55 -26
View File
@@ -1,4 +1,6 @@
use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art};
use super::build::{
PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art, scale_symbol_art,
};
use super::slots::LAYERS_UNREAD;
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
@@ -45,7 +47,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus
let own_palette = use_palette(data, &mut v.document, &mut read);
use_brushes(data, &mut v.document, have, &mut v.warnings, &mut read);
use_styles(data, &mut v.document, true, 1.0, None, &mut read);
use_symbols(data, &mut v.document, own_palette, &mut v.warnings, &mut read);
use_symbols(data, &mut v.document, own_palette, None, &mut v.warnings, &mut read);
use_variables(data, &mut v.document, &mut v.warnings, &mut read);
add_notes(&mut v.warnings, read.notes());
}
@@ -153,7 +155,7 @@ pub fn layered_ai_from(
let own_palette = use_palette(&data, &mut visible, &mut read);
use_brushes(&data, &mut visible, have, &mut warnings, &mut read);
use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read);
use_symbols(&data, &mut visible, own_palette, &mut warnings, &mut read);
use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut warnings, &mut read);
use_variables(&data, &mut visible, &mut warnings, &mut read);
add_notes(&mut warnings, read.notes());
return (visible, warnings, false);
@@ -172,7 +174,7 @@ pub fn layered_ai_from(
use_brushes(&data, &mut visible, have, &mut warnings, &mut read);
use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read);
let mut all = unread(warnings, why);
use_symbols(&data, &mut visible, own_palette, &mut all, &mut read);
use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut all, &mut read);
use_variables(&data, &mut visible, &mut all, &mut read);
add_notes(&mut all, read.notes());
(visible, all, false)
@@ -377,26 +379,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo
if styles.is_empty() {
return;
}
if !large_canvas && (unit != 1.0 || canvas != 1.0) {
let read;
let boards = match boards {
Some(b) => b,
None => {
read = ai::artboards(data, &mut ai::Names::default()).0;
&read
}
};
let k = board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas));
if let Some(k) = k.filter(|k| *k != 1.0) {
for g in &mut styles {
g.appearance.scale_strokes(k);
g.appearance.scale_effects(k, vectorcraft_effects::scale_effect);
}
if !large_canvas && let Some(k) = data_scale(data, doc, unit, canvas, boards) {
for g in &mut styles {
g.appearance.scale_strokes(k);
g.appearance.scale_effects(k, vectorcraft_effects::scale_effect);
}
}
doc.graphic_styles = styles.into();
}
/// How many times as large `doc` (a file read as its PDF part) draws the art of its editing data
/// `data`, whose declared large canvas scale is `canvas`, when it isn't 1: `unit` when the editing
/// data's artboards (`boards`, or read from `data`) are the size of `doc`'s pages in units of
/// `unit` points, else `canvas` when they are the pages' size at that scale ([`board_scale`]).
fn data_scale(data: &[u8], doc: &Document, unit: f64, canvas: f64, boards: Option<&[(String, Rect)]>) -> Option<f64> {
if unit == 1.0 && canvas == 1.0 {
return None;
}
let read;
let boards = match boards {
Some(b) => b,
None => {
read = ai::artboards(data, &mut ai::Names::default()).0;
&read
}
};
board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)).filter(|k| *k != 1.0)
}
/// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its
/// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and
/// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name
@@ -405,15 +415,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo
/// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and
/// the note about instances that can't be drawn added to `warnings`. An object of their art linked
/// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The
/// symbols' art keeps the editing data's units, as in a document read from the layers: when
/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art
/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has
/// none this reads. `names` reads the symbols' names and keeps the notes on them.
fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec<String>, names: &mut ai::Names) {
/// symbols' art is drawn as large as a document read from the layers draws it
/// ([`scale_symbol_art`]): with `units` `None` (an EPS's page), as many times as large as the data's
/// declared large canvas scale; with `Some((unit, boards))`, as many times as large as
/// [`use_styles`] draws the styles' strokes and effects ([`data_scale`], `boards` the editing data's
/// artboards); at its own size in data VectorCraft wrote. Left as it is when `doc` has symbols
/// already or the editing data has none this reads. `names` reads the symbols' names and keeps the
/// notes on them.
fn use_symbols(
data: &[u8],
doc: &mut Document,
own_palette: bool,
units: Option<(f64, &[(String, Rect)])>,
warnings: &mut Vec<String>,
names: &mut ai::Names,
) {
if !doc.symbols.is_empty() {
return;
}
let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data, names) else { return };
let Some(ai::Symbols { symbols, images, swatches, canvas_scale, own_size }) = ai::symbols(data, names) else { return };
let (mut symbols, registrations): (Vec<_>, Vec<_>) = symbols.into_iter().unzip();
let k = match units {
_ if own_size => None,
None => Some(canvas_scale),
Some((unit, boards)) => data_scale(data, doc, unit, canvas_scale, Some(boards)),
};
if let Some(k) = k {
scale_symbol_art(&mut symbols, k);
}
for (key, blob) in images {
doc.images.entry(key).or_insert(blob);
}
@@ -444,7 +473,7 @@ fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mu
*link = Some(new.clone());
true
};
for (mut symbol, registration) in symbols {
for (mut symbol, registration) in symbols.into_iter().zip(registrations) {
let mut art = reid_page_art(doc, &symbol.art);
if !renamed.is_empty() {
vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow);
+10
View File
@@ -136,6 +136,16 @@ fn codes_map_by_their_codespace_with_notdef_fallbacks() {
width("<01>", 30.0);
}
/// `kshow` over a CMap runs its procedure between each two codes with their values, whatever
/// their length, and the next character follows where it moves the point (PLRM 3rd ed., `kshow`).
#[test]
fn kshow_runs_between_each_two_cmap_codes() {
let fonts = String::from_utf8(cid_font(true)).unwrap() + CMAP;
let check = |cond: &str| check(&format!("{fonts} /TestFont 50 selectfont {cond}"));
check("gsave nulldevice 10 20 moveto [ {} <418001> kshow ] grestore aload length 2 eq exch 16#8001 eq and exch 65 eq and");
check("gsave nulldevice 10 20 moveto {pop pop 100 0 rmoveto} <418001> kshow currentpoint grestore pop 165 sub abs 0.01 lt");
}
/// `usecmap` brings in another CMap's ranges and mappings; the CMap's own come after them.
#[test]
fn usecmap_takes_the_parents_mappings() {
+29
View File
@@ -119,6 +119,35 @@ fn widthshow_matches_a_composite_character_by_font_number_and_code() {
check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh));
}
/// `kshow` runs its procedure between each two characters of a composite font, with the values of
/// their codes (font number times 256 plus code), and the next character follows where it moves
/// the current point (PLRM 3rd ed., `kshow`).
#[test]
fn kshow_runs_between_each_two_composite_characters() {
let (_, wh) = glyph('H');
let (_, wi) = glyph('I');
let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def");
let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}"));
check("gsave nulldevice 10 20 moveto [ {} (\\000\\001\\001\\002\\000\\001) kshow ] grestore aload length 4 eq exch 1 eq and exch 258 eq and exch 258 eq and exch 1 eq and".into());
check(format!(
"gsave nulldevice 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow currentpoint grestore pop {} sub abs 0.01 lt",
10.0 + 50.0 * (wh + wi) + 100.0
));
// Drawn, the I starts where the procedure moved the point.
let r = read(&format!("{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop 100 0 rmoveto}} (\\000\\001\\000\\002) kshow"));
let paths: Vec<_> = all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| bounds(n)).collect();
assert_eq!(paths.len(), 2, "{paths:?}");
assert!(paths[1].x0 > 10.0 + 50.0 * wh + 100.0 - 1.0, "{paths:?}");
// A procedure that draws: its square goes after the glyphs' group, outside it.
let r = read(&format!(
"{fonts}/Comp findfont 50 scalefont setfont 10 20 moveto {{pop pop currentpoint 2 2 rectfill}} (\\000\\001\\000\\002\\000\\001) kshow"
));
let top = super::tests::objects(&r.document);
assert_eq!(top.len(), 3, "{top:?}");
assert_eq!(top[0].children().map(Vec::len), Some(3));
assert!(top[1..].iter().all(|n| matches!(n.kind, NodeKind::Path { .. }) && (bounds(n).width() - 2.0).abs() < 0.01), "{top:?}");
}
/// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`)
/// counts against the operation budget, as drawing them does.
#[test]
+126
View File
@@ -1809,6 +1809,44 @@ fn an_eps_without_layers_keeps_its_symbols() {
assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings);
}
/// The box of the art of `d`'s first symbol (Bar, 12 × 2 about the origin in the editing data).
fn bar_bounds(d: &Document) -> Option<vectorcraft_geom::Rect> {
d.symbols.first().and_then(|s| s.art.geometric_bounds())
}
/// A symbol library without layers whose editing data is drawn larger than its units comes in with
/// its symbols' art as large as a document read from its layers has it: an EPS of a Large Canvas
/// copy at its declared scale, and a `.ai` whose artboard is its PDF part's page at that scale or
/// in the units of its `UserUnit`. One whose artboard is the page in points keeps its art's size.
#[test]
fn a_symbol_library_in_larger_units_comes_in_at_the_pages_size() {
let board = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let library = |setup: &str| symbol_library().replace("%%BeginSetup\n", &format!("%%BeginSetup\n{setup}"));
let ten = Some(vectorcraft_geom::Rect::new(-60.0, -10.0, 60.0, 10.0));
let r = import(&eps(&page_square(50, 50), &library("%AI24_LargeCanvasScale: 10\n"))).unwrap();
assert_eq!(bar_bounds(&r.document), ten, "{:?}", r.warnings);
let large = format!("%AI24_LargeCanvasScale: 10\n{board}");
let (d, warnings, read) = crate::layered_ai(&private(&library(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0);
assert!(!read, "{warnings:?}");
assert_eq!(bar_bounds(&d), ten, "{warnings:?}");
let (d, warnings, _) = crate::layered_ai(&private(&library(board)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0);
assert_eq!(bar_bounds(&d), ten, "{warnings:?}");
let points = board.replace("0 100 /Real", "0 1000 /Real").replace("100 0 /Real", "1000 0 /Real");
let (d, warnings, _) = crate::layered_ai(&private(&library(&points)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0);
assert_eq!(bar_bounds(&d), Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)), "{warnings:?}");
// A library VectorCraft wrote (its `%%Creator`) has its symbols at their own size, whatever its
// scale.
let own =
|setup: &str| library(setup).replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {}\n", crate::ai_editing::CREATOR_TEXT), 1);
let own_size = Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0));
let r = import(&eps(&page_square(50, 50), &own("%AI24_LargeCanvasScale: 10\n"))).unwrap();
assert_eq!(bar_bounds(&r.document), own_size, "{:?}", r.warnings);
let (d, warnings, _) = crate::layered_ai(&private(&own(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0);
assert_eq!(bar_bounds(&d), own_size, "{warnings:?}");
}
/// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their
/// layers can't be used (their editing data is for another page) get the symbols defined before
/// their layers.
@@ -2769,6 +2807,94 @@ fn a_large_canvas_eps_keeps_its_graphic_styles_at_the_pages_size() {
assert_eq!((object.appearance.stroke_width(), object.extras().graphic_style), (2.0, Some(1)), "{object:#?}");
}
/// The definition of the symbol Box: a rectangle 2 × 1 about the origin, stroked 0.4 wide.
fn stroked_box_symbol() -> String {
let art = "0 0 0 1 K\n0.4 w\n-1 -0.5 m\n-1 0.5 L\n1 0.5 L\n1 -0.5 L\n-1 -0.5 L\nS\n";
format!("%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Box", art, 5))
}
/// The art of `d`'s symbol Box ([`stroked_box_symbol`]) is ten times as large, its stroke 4 wide,
/// and its instance on `d`'s second layer, placed in the file at twice its size about (`x`, `y`),
/// keeps that scale and draws a box of 40 × 20 about `center`.
fn box_symbol_at_ten_times(d: &Document, center: (f64, f64), notes: &[String]) {
let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{notes:?}")).art;
let b = art.geometric_bounds().unwrap();
let close = |r: vectorcraft_geom::Rect, e: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(e).all(|(a, b)| (a - b).abs() < 1e-6);
assert!(close(b, [-10.0, -5.0, 10.0, 5.0]), "the art at the page's size: {b:?} {notes:?}");
let mut widths = vec![];
art.walk(&mut |n| {
if matches!(n.kind, NodeKind::Path { .. }) {
widths.push(n.appearance.stroke_width());
}
});
assert_eq!(widths, [4.0], "{notes:?}");
let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{:#?} {notes:?}", d.layers));
let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {:?}", instance.kind) };
let [a, b0, c, d0, ..] = xf.as_coeffs();
assert!((a.hypot(b0) - 2.0).abs() < 1e-9 && (c.hypot(d0) - 2.0).abs() < 1e-9, "the instance's own scale: {xf:?}");
let (x, y) = center;
assert!(close(xf.transform_rect_bbox(b), [x - 20.0, y - 10.0, x + 20.0, y + 10.0]), "{:?}", xf.transform_rect_bbox(b));
}
/// The symbols of a Large Canvas editing copy come in at the page's size, as its art does: their
/// art and its strokes ten times as large, and each instance with the scale the file gives it,
/// drawn where and as large as before.
#[test]
fn a_large_canvas_eps_keeps_its_symbols_at_the_pages_size() {
let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 5 5 /RTransformMatrix\n;\n9 () XW";
let square = "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf";
let art = native_large_canvas(10.0, 10.0, &format!("{}{}{}", stroked_box_symbol(), layer("Art", true, square), layer("Box", false, instance)));
let r = import(&eps(&page_square(50, 50), &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Box"], "{:?}", r.warnings);
box_symbol_at_ten_times(&r.document, (50.0, 50.0), &r.warnings);
}
/// A large canvas `.ai` 100 units square at a scale of 10, with the symbol Box
/// ([`stroked_box_symbol`]) and an instance of it at twice its size about (50, 50), its header
/// carrying `creator` when given, read with its PDF part, on a page whose PDF part can't be read,
/// and without a page.
fn large_canvas_ai_box_reads(creator: Option<&str>) -> Vec<(&'static str, Document, Vec<String>)> {
let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 50 50 /RTransformMatrix\n;\n9 () XW";
let layers = format!("{}{}{}", stroked_box_symbol(), layer("Art", true, &square(10, 10)), layer("Box", false, instance));
let mut art = native_large_canvas(100.0, 10.0, &layers);
if let Some(creator) = creator {
art = art.replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {creator}\n"), 1);
}
let data = private(&art);
let (d, notes, read) = crate::layered_ai(&data, in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, false, 1.0);
assert!(read, "{notes:?}");
let on_page = crate::ai_alone_on_page(&data, (1000.0, 1000.0), 10.0, Default::default()).unwrap();
let no_page = crate::ai_alone(&data).unwrap();
vec![("PDF part", d, notes), ("on a page", on_page.0, on_page.1), ("no page", no_page.0, no_page.1)]
}
/// A `.ai` whose editing data is read at its declared large canvas scale has its symbols scaled
/// with the art, as an EPS has, however the file opens.
#[test]
fn a_large_canvas_ai_keeps_its_symbols_at_the_pages_size() {
for (how, d, notes) in large_canvas_ai_box_reads(None) {
let notes: Vec<String> = notes.into_iter().chain([how.to_string()]).collect();
box_symbol_at_ten_times(&d, (500.0, 500.0), &notes);
}
}
/// A large canvas `.ai` that VectorCraft wrote (its `%%Creator`) has its symbols defined at their
/// own size, as its `.ai` save writes them: their art keeps that size, and the instance, scaled with
/// the layers, draws the same box, however the file opens.
#[test]
fn a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size() {
for (how, d, notes) in large_canvas_ai_box_reads(Some(crate::ai_editing::CREATOR_TEXT)) {
let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{how}: {notes:?}")).art;
let b = art.geometric_bounds().unwrap();
assert_eq!(b, vectorcraft_geom::Rect::new(-1.0, -0.5, 1.0, 0.5), "{how}: {notes:?}");
let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{how}: {:#?} {notes:?}", d.layers));
let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("{how}: an instance: {:?}", instance.kind) };
let drawn = xf.transform_rect_bbox(b);
let close = [drawn.x0 - 480.0, drawn.y0 - 490.0, drawn.x1 - 520.0, drawn.y1 - 510.0].iter().all(|v| v.abs() < 1e-6);
assert!(close, "{how}: {drawn:?}");
}
}
#[test]
fn type_with_a_listed_art_style_is_linked_to_its_graphic_style() {
let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
+35 -7
View File
@@ -999,6 +999,8 @@ struct Mapped {
cid: Option<(u32, Option<u32>)>,
outer: Affine,
vertical: bool,
/// The value of the character's code in the shown string (what `kshow` gives its procedure).
value: u32,
}
/// Type in a CIDFont only named, over a predefined CMap ([`Interp::named_cid_type`]): its codes
@@ -1097,7 +1099,8 @@ impl Interp<'_> {
if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize))
&& font_type(&d.borrow()) != Some(0.0)
{
out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false });
let value = u32::from(pick) * 256 + u32::from(code);
out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false, value });
}
}
Ok(Some(out))
@@ -1124,21 +1127,28 @@ impl Interp<'_> {
.filter_map(|c| match (fonts.get(c.font), c.base) {
// A base font's code, or its glyph name (at the code its encoding gives it, if any).
(Some(Obj::Dict(d)), Some(Base::Code(code))) => {
Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical })
Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical, value: c.value })
}
(Some(Obj::Dict(d)), Some(Base::Name(name))) => {
let code = encoded(&d.borrow(), &name);
let name = code.is_none().then_some(name);
Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical })
Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical, value: c.value })
}
// A code without a mapping in a base font (not a CIDFont): its code 0, the
// `.notdef` glyph CID 0 stands for in a CIDFont.
(Some(Obj::Dict(d)), None) if !d.borrow().contains_key(&Key::name("CIDFontType")) => {
Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical })
}
(Some(Obj::Dict(d)), None) => {
Some(Mapped { font: d.clone(), pick: 0, code: (c.cid & 0xff) as u8, name: None, cid: Some((c.cid, c.notdef)), outer, vertical })
Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical, value: c.value })
}
(Some(Obj::Dict(d)), None) => Some(Mapped {
font: d.clone(),
pick: 0,
code: (c.cid & 0xff) as u8,
name: None,
cid: Some((c.cid, c.notdef)),
outer,
vertical,
value: c.value,
}),
_ => None,
})
.collect())
@@ -1346,6 +1356,12 @@ impl Interp<'_> {
fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res {
let start = self.out.drawn.len();
let mut text = String::new();
let kern: Vec<u32> = match spacing {
Spacing::Kern(_) => glyphs.iter().map(|g| g.value).collect(),
_ => Vec::new(),
};
// Where in the objects drawn each run of `kshow`'s procedure put its art.
let mut kerned = Vec::new();
for (i, g) in glyphs.iter().enumerate() {
let at = self.user_point()?;
let w = self.composite_glyph(g, at, Draw::Fill)?;
@@ -1353,6 +1369,12 @@ impl Interp<'_> {
// times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8).
let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w);
self.g.cur = Some(self.xf() * (at + d));
// `kshow`'s procedure runs between each two characters with the values of their codes.
let before = self.out.drawn.len();
self.kern(spacing, &kern, i, at + d)?;
if self.out.drawn.len() > before {
kerned.push(before..self.out.drawn.len());
}
// A CID stands for no character (its code is only the CID's low byte).
let c = if g.cid.is_some() { '\u{2022}' } else { g.name.as_deref().map_or_else(|| code_char(&g.font.borrow(), g.code), glyph_char) };
if c != '\u{2022}' {
@@ -1364,7 +1386,13 @@ impl Interp<'_> {
{
text = name.to_string();
}
// What `kshow`'s procedure draws goes after the type, outside its group.
let mut art = Vec::new();
for run in kerned.into_iter().rev() {
art.splice(0..0, self.out.drawn.drain(run));
}
self.group_glyphs(start, &text);
self.out.drawn.extend(art);
Ok(())
}
+2 -1
View File
@@ -230,7 +230,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb
}
// Type in the document's composite fonts is drawn in them, whether or not an app has the document open.
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
let baked = vectorcraft_effects::bake_document(doc);
let baked =
vectorcraft_effects::bake_document_placing_symbols(doc, true, vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true });
let doc = baked.as_ref().unwrap_or(doc);
let page = scene::Scene::new(doc, opts).run();
let mut setting_notes = vec![];
+17
View File
@@ -1654,6 +1654,23 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() {
assert!(page.contains("1 0 0 rg"), "{page}");
}
#[test]
fn symbol_art_effects_are_written_where_they_draw() {
// The symbol's square at (10, 40), which a Transform effect moves 100 pt right, is drawn from
// x = 110 and not from x = 10.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })]);
let mut art = path(&mut d, shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
art.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 100.0}),
visible: true,
});
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) });
let page = page_of(&d, &opts(&d));
let starts: Vec<f64> = page.lines().filter(|l| l.ends_with(" m")).filter_map(|l| l.split_whitespace().next()?.parse().ok()).collect();
assert!(!starts.is_empty() && starts.iter().all(|x| (110.0..=120.0).contains(x)), "{starts:?}\n{page}");
}
#[test]
fn level_preview_and_overprint_ids_read_back() {
for l in Level::ALL {
+37
View File
@@ -490,6 +490,43 @@ fn trace_and_style_prompt_steps_run_in_order() {
assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}");
}
/// A window capture and an artboard preview scaled down keep the color of opaque pixels beside
/// clear ones: the clear black beside them does not darken them.
#[test]
fn scaled_down_pictures_keep_the_color_beside_clear_pixels() {
struct Striped;
impl Backend for Striped {
fn call(&mut self, method: &str, _: Value) -> Result<Value, String> {
let mut png = std::io::Cursor::new(Vec::new());
image::RgbaImage::from_fn(64, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })
.write_to(&mut png, image::ImageFormat::Png)
.unwrap();
let png = vectorcraft_format::base64_encode(png.get_ref());
Ok(match method {
"document.inspect" => json!({"artboards": [{"width": 64, "height": 4}]}),
_ => json!({"pngBase64": png, "width": 64, "height": 4}),
})
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"test".into()
}
}
let mut s = Server::new(Box::new(Striped));
let shots = [("screenshot", json!({"window": true, "max_side": 16})), ("render_preview", json!({"max_side": 16}))];
for (id, (tool, args)) in (1..).zip(shots) {
let r = call(&mut s, id, tool, args);
let data =
r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("{tool}: {r}"))["data"].as_str().unwrap();
let img = image::load_from_memory(&vectorcraft_format::base64_decode(data).unwrap()).unwrap().into_rgba8();
assert_eq!(img.dimensions(), (16, 1), "{tool}");
assert!(img.pixels().all(|p| p.0 == [200, 30, 30, 128]), "{tool}: {:?}", img.get_pixel(0, 0));
}
}
/// `screenshot` and `render_preview` with `traceViews: true` draw an Image Trace object in the
/// view set with imageTrace.setView, as the app's window does; without it they draw the tracing
/// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White.
+2 -1
View File
@@ -4,6 +4,7 @@ use super::handlers::{
select_ids, transform,
};
use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, json, writable_format};
use vectorcraft_doc::resample::{Resample, fitted};
/// The `menu` argument of the command listings.
pub(super) const MENU_FILTER: &str =
@@ -445,7 +446,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
// The render was asked for at most 4096 a side.
let img = super::handlers::decode_png(&png, 8192).map_err(|e| format!("read preview: {e}"))?;
let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img };
let img = if img.width() > max || img.height() > max { fitted(img, max, max, Resample::Average) } else { img };
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?;
Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()})))
+2 -1
View File
@@ -1,5 +1,6 @@
use super::dispatch::trace_views;
use super::{Backend, Map, ToolResult, Value, json};
use vectorcraft_doc::resample::{Resample, fitted};
// ---------- implementation ----------
@@ -521,7 +522,7 @@ fn fit_capture(png: Vec<u8>, max: Option<u32>, info: &mut Value) -> Result<Vec<u
info["height"] = json!(img.height());
return Ok(png);
}
let img = img.thumbnail(max, max);
let img = fitted(img, max, max, Resample::Average);
info["width"] = json!(img.width());
info["height"] = json!(img.height());
let mut out = std::io::Cursor::new(Vec::new());
+1 -1
View File
@@ -260,7 +260,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"type.convertListToText",
"Convert to Text",
"",
"{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type",
"{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs, in every frame of a threaded story (or of those the Type tool's selection touches), become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type",
),
(
"align.horizontalLeft",
+18 -1
View File
@@ -4,6 +4,8 @@
use std::io::Cursor;
use vectorcraft_doc::resample::{Resample, resized};
use crate::bytes::Reader;
/// Most pixels along a side of an image the writers store (larger ones are scaled down).
@@ -42,7 +44,8 @@ impl Rgba {
let img = if k > 1.0 {
let nw = ((f64::from(w) / k).floor() as u32).max(1);
let nh = ((f64::from(h) / k).floor() as u32).max(1);
image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle)
let how = Resample::Filter(image::imageops::FilterType::Triangle);
resized(image::DynamicImage::ImageRgba8(img), nw, nh, how).into_rgba8()
} else {
img
};
@@ -447,4 +450,18 @@ mod tests {
let f = Rgba::fitted(img);
assert_eq!((f.width, f.height), (MAX_SIDE, 2));
}
/// A large image scaled to fit keeps the color of its opaque pixels beside clear ones: the
/// clear black beside them does not darken them.
#[test]
fn large_images_keep_their_color_beside_clear_pixels() {
let img =
image::RgbaImage::from_fn(MAX_SIDE * 2, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) });
let f = Rgba::fitted(img);
assert_eq!((f.width, f.height), (MAX_SIDE, 2));
let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8;
for p in f.pixels.as_chunks::<4>().0 {
assert!(p[3] > 0 && [200, 30, 30].iter().zip(p).all(|(&want, &c)| m(c, p[3]).abs_diff(m(want, p[3])) <= 1), "{p:?}");
}
}
}
+9 -2
View File
@@ -45,8 +45,15 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let set = &opts.settings;
set.check()?;
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
// Live geometry effects export as their result; raster effects are reported below.
let baked = vectorcraft_effects::bake_document(doc);
// Live geometry effects export as their result, in symbols' art too; raster effects are
// reported below.
// Only the layers this export writes place their instances' art (see `ex.non_printing` and
// `ex.layers` below).
let layers = vectorcraft_effects::WrittenLayers {
hidden: set.writes_layers() && !doc.page_knockout,
non_printing: set.include_non_printing || set.create_layers,
};
let baked = vectorcraft_effects::bake_document_placing_symbols(doc, true, layers);
// Baking stops early when cancelled, leaving a half-baked copy that must not be written.
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
let doc = baked.as_ref().unwrap_or(doc);
+78 -17
View File
@@ -23,7 +23,8 @@ use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageRea
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
use vectorcraft_doc::ImageBlob;
use vectorcraft_doc::cmyk::jpeg_components;
use vectorcraft_doc::cmyk::{Inks, jpeg_components};
use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply};
use crate::output::{CmykPixels, Pixels};
use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec};
@@ -277,15 +278,29 @@ fn jpeg_quality(r: &Rule, jpeg: bool) -> Option<u8> {
}
}
/// `img` resampled to `w` × `h` pixels as `r` says.
/// How `r` resamples an image.
fn resampling(r: &Rule) -> Resample {
match r.downsample {
Downsample::Average => Resample::Average,
Downsample::Subsample => Resample::Filter(FilterType::Nearest),
Downsample::Bicubic | Downsample::None => Resample::Filter(FilterType::CatmullRom),
}
}
/// `img` resampled to `w` × `h` pixels as `r` says, the colors of an image with alpha weighted by
/// it ([`resized`]).
fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage {
resized(img, w, h, resampling(r))
}
/// The four channels of `img` resampled to `w` × `h` pixels as `r` says, each on its own.
fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage {
if (w, h) == (img.width(), img.height()) {
return img;
}
match r.downsample {
Downsample::Average => img.thumbnail_exact(w, h),
Downsample::Subsample => img.resize_exact(w, h, FilterType::Nearest),
Downsample::Bicubic | Downsample::None => img.resize_exact(w, h, FilterType::CatmullRom),
match resampling(r) {
Resample::Average => img.thumbnail_exact(w, h),
Resample::Filter(f) => img.resize_exact(w, h, f),
}
}
@@ -403,17 +418,10 @@ pub(crate) fn recode_cmyk(
let Some(inks) = blob.cmyk() else {
return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) };
};
let dims = (inks.width, inks.height);
let (width, height) = sized(&rule, dims, size);
let alpha = match alpha.map(|a| image::GrayImage::from_raw(inks.width, inks.height, a)) {
Some(Some(a)) => Some(resample(DynamicImage::ImageLuma8(a), &rule, (width, height)).into_luma8().into_raw()),
Some(None) => return Recoded { image: None, warning: None },
None => None,
};
// The four inks resample as the four channels of an RGBA image do.
let inks = match image::RgbaImage::from_raw(inks.width, inks.height, inks.data) {
Some(img) => resample(DynamicImage::ImageRgba8(img), &rule, (width, height)).into_rgba8().into_raw(),
None => return Recoded { image: None, warning: Some(CMYK_RGB) },
let (width, height) = sized(&rule, (inks.width, inks.height), size);
let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) {
Ok(r) => r,
Err(warning) => return Recoded { image: None, warning },
};
let (samples, space) = match convert {
Some(p) => p.convert(&inks),
@@ -425,6 +433,36 @@ pub(crate) fn recode_cmyk(
Recoded { image, warning: rule.instead }
}
/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any.
type InksAndAlpha = (Vec<u8>, Option<Vec<u8>>);
/// Ink amounts `inks` and their `alpha` (one sample a pixel) resampled to `to` as `r` says. The
/// inks under clear pixels don't count where neighbors blend: they are multiplied by the alpha
/// before resampling and divided by the resampled alpha after. `Err` with the warning when they
/// can't be read.
fn resampled_inks(inks: Inks, alpha: Option<Vec<u8>>, r: &Rule, to: (u32, u32)) -> Result<InksAndAlpha, Option<&'static str>> {
let dims = (inks.width, inks.height);
let weighted = to != dims && resampling(r) != Resample::Filter(FilterType::Nearest);
let mut data = inks.data;
let alpha = match alpha.map(|a| image::GrayImage::from_raw(dims.0, dims.1, a)) {
Some(Some(a)) => {
if weighted {
premultiply(&mut data, a.as_raw());
}
Some(resample(DynamicImage::ImageLuma8(a), r, to).into_luma8().into_raw())
}
Some(None) => return Err(None),
None => None,
};
// The four inks resample as the four channels of an RGBA image do, each on its own.
let img = image::RgbaImage::from_raw(dims.0, dims.1, data).ok_or(Some(CMYK_RGB))?;
let mut inks = resample_channels(DynamicImage::ImageRgba8(img), r, to).into_rgba8().into_raw();
if let Some(a) = alpha.as_deref().filter(|_| weighted) {
unpremultiply(&mut inks, a);
}
Ok((inks, alpha))
}
/// The most one image read here may allocate (bytes), as the image reader allows by default.
const MAX_DECODE: u64 = 512 << 20;
@@ -482,3 +520,26 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual
}
Image::from_jpeg_with_icc(out.into(), icc.map(|i| i.to_vec().into()), interpolate).ok()
}
#[cfg(test)]
mod tests {
use super::*;
/// The inks of a CMYK image with alpha, resampled, keep the amounts of the opaque pixels: the
/// inks under the clear pixels beside them don't thin them out.
#[test]
fn resampled_inks_keep_the_amounts_beside_clear_pixels() {
let data = [[40, 80, 120, 160], [0, 0, 0, 0]].repeat(8).concat();
let alpha = [255, 0].repeat(8);
for method in ["average", "bicubic"] {
let c: CompressionSettings = serde_json::from_value(serde_json::json!({"color": {"downsample": method}})).unwrap();
let inks = Inks::new(16, 1, data.clone()).unwrap();
let (inks, alpha) = resampled_inks(inks, Some(alpha.clone()), &rule(&c, Kind::Color), (4, 1)).unwrap();
let alpha = alpha.unwrap();
let m = |c: u8, a: u8| (u16::from(c) * u16::from(a) / 255) as u8;
for (p, &a) in inks.as_chunks::<4>().0.iter().zip(&alpha) {
assert!(a > 0 && p.iter().zip([40, 80, 120, 160]).all(|(&c, want)| m(c, a).abs_diff(m(want, a)) <= 1), "{method}: {p:?} at {a}");
}
}
}
}
+8 -5
View File
@@ -183,11 +183,14 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) {
/// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`]
/// says.
pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> {
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
let d = doc.to_mut();
keep_layers(&mut d.layers, set.print_layers);
composite_overprints(d, set);
doc
// The layers that do not print are left out first, so the art placed for symbols is only
// the art that prints; the layers kept are shown.
let mut kept = doc.clone();
keep_layers(&mut kept.layers, set.print_layers);
let layers = vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true };
let mut d = vectorcraft_effects::bake_document_placing_symbols(&kept, true, layers).unwrap_or(kept);
composite_overprints(&mut d, set);
Cow::Owned(d)
}
/// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated
+45
View File
@@ -246,6 +246,26 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() {
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds());
}
/// The live effects in a symbol's art apply where the instance places the art, as on the canvas:
/// the symbol's square at (10, 40), which a Transform effect moves 100 pt right, is written at
/// (110, 40).
#[test]
fn symbol_art_effects_are_written_where_they_draw() {
let mut d = doc(200.0, 100.0);
let mut square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(1.0, 0.0, 0.0));
square.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 100.0}),
visible: true,
});
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(square) });
add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY }));
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 1);
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds());
}
/// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas
/// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20.
#[test]
@@ -336,6 +356,31 @@ fn a_stained_instance_keeps_its_stain() {
}
}
/// A stained instance of a symbol whose art has a live effect is written in its stained colors,
/// where the effect draws the art.
#[test]
fn a_stained_instance_keeps_its_stain_when_its_art_has_effects() {
let blue = Color::rgb8(30, 90, 220);
let square = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), blue);
let half = stained_instance("Moved", Affine::IDENTITY, 0.5, true);
let want = vectorcraft_brush::instance_art(&square, &half).appearance.fill_paint().color().unwrap().to_hex();
assert_ne!(want, blue.to_hex(), "the stain changes the color");
let mut art = square;
art.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 100.0}),
visible: true,
});
let mut d = doc(200.0, 100.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) });
add(&mut d, half);
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 1);
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(110.0, 40.0, 120.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds());
assert_eq!(leaf_fills(&out), [want]);
}
/// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES`
/// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through
/// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy
+32
View File
@@ -73,6 +73,38 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() {
assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi");
}
/// An image with clear pixels, resampled, keeps the color of its opaque pixels: the clear black
/// beside them does not darken them.
#[test]
fn resampled_images_keep_their_color_beside_clear_pixels() {
let striped = image::RgbaImage::from_fn(600, 600, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) });
let d = doc(encoded(striped.into(), ImageFormat::Png), "image/png");
for method in ["average", "bicubic"] {
let mut settings: PdfSettings =
serde_json::from_value(json!({"compression": {"color": {"downsample": method, "ppi": 150, "abovePpi": 225, "compression": "zip"}}}))
.unwrap();
settings.compression.compress_text = false;
let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap();
let rgb = file
.objects()
.into_iter()
.find_map(|o| match o {
Object::Stream(s)
if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") && s.dict().contains_key(b"SMask") =>
{
Some(s.decoded().unwrap().into_owned())
}
_ => None,
})
.unwrap_or_else(|| panic!("{method}: a color image with a soft mask"));
assert_eq!(rgb.len(), 150 * 150 * 3, "{method}");
for p in rgb.as_chunks::<3>().0 {
assert!(p[0].abs_diff(200) <= 3 && p[1].abs_diff(30) <= 3 && p[2].abs_diff(30) <= 3, "{method}: {p:?}");
}
}
}
/// The width and height of each image XObject in `pdf`.
fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> {
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap();
+23 -2
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Dict, Name};
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeId};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::{add, doc, rect_node};
use crate::*;
@@ -89,6 +89,27 @@ fn hidden_and_unprinted_objects_have_no_links_and_clipping_trims_them() {
assert_eq!(links(&export(&d, &PdfOptions::default()).unwrap()), vec![Vec::<(String, [f32; 4])>::new()]);
}
/// A linked symbol instance whose art has a live effect links over the art where the effect draws it.
#[test]
fn a_linked_instance_keeps_its_link_when_its_art_has_effects() {
let mut art = rect_node(Rect::new(10.0, 40.0, 20.0, 50.0), Color::rgb(0.0, 0.0, 1.0));
art.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 100.0}),
visible: true,
});
let mut d = doc(200.0, 100.0);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(art) });
let mut instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY });
instance.edit_attrs(|a| a.url = "https://example.com/symbol".into());
add(&mut d, instance);
let pages = links(&export(&d, &PdfOptions::default()).unwrap());
let [(uri, rect)] = pages[0].as_slice() else { panic!("one link: {pages:?}") };
assert_eq!(uri, "https://example.com/symbol");
// x 110…120 after the 100 pt move; y up: 100 − 50 to 100 − 40.
assert!(near(*rect, [110.0, 50.0, 120.0, 60.0]), "{rect:?}");
}
#[test]
fn each_page_links_its_own_artboard_objects() {
let mut d = doc(200.0, 100.0);
+23
View File
@@ -451,6 +451,29 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() {
assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}");
}
#[test]
fn symbol_art_effects_print_where_they_draw() {
// A red square and the symbol's blue square, each moved 100 pt right by a Transform effect,
// print at the same distance from the page's left edge.
let moved = |mut n: Node| {
n.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"moveH": 100.0}), visible: true });
n
};
let mut d = doc(200.0, 100.0, &[]);
let (layer, id) = (d.default_layer().unwrap(), d.alloc_id());
let red = Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0));
d.insert(Some(layer), 0, moved(red)).unwrap();
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0));
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) });
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Moved".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
let p = pages(&job(&d, json!({})).bytes);
assert_eq!(p.len(), 1);
let left = |c: [u8; 3]| p[0].1.iter().find(|(_, paint)| rgb8(paint) == c).map(|(r, _)| r.x0);
let (red, blue) = (left([255, 0, 0]), left([0, 0, 255]));
assert!(red.zip(blue).is_some_and(|(r, b)| (r - b).abs() < 0.05), "{p:?}");
}
#[test]
fn copies_collate_and_reverse() {
let mut d =
+34 -2
View File
@@ -1,6 +1,7 @@
//! Paint conversion (solid, gradients, patterns) and image decoding.
use vectorcraft_color::{GradientKind, Paint};
use vectorcraft_doc::resample::{Resample, resized};
use vectorcraft_geom::{Rect, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::peniko::{self, ColorStop};
@@ -446,7 +447,7 @@ fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage {
}
let s = max as f64 / w.max(h) as f64;
let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max));
img.resize_exact(nw, nh, image::imageops::FilterType::Triangle)
resized(img, nw, nh, Resample::Filter(image::imageops::FilterType::Triangle))
}
/// [`decode_pixmap_or_why`]'s pixels.
@@ -504,7 +505,7 @@ pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixm
}
};
let img = if img.width() > w as u32 || img.height() > h as u32 {
img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle)
resized(img, w as u32, h as u32, Resample::Filter(image::imageops::FilterType::Triangle))
} else {
img
};
@@ -587,4 +588,35 @@ mod tests {
assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC + 1), Err(Undecoded::NoRoom));
assert_eq!(room_for(MAX_DECODE_ALLOC, MAX_DECODE_ALLOC), Ok(()));
}
/// Opaque red beside clear black, every other column of a `w` × 1 image.
fn striped(w: u32) -> image::RgbaImage {
image::RgbaImage::from_fn(w, 1, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })
}
/// Does each pixel of `pm` hold red at its own coverage (premultiplied, within a step)?
fn red_at_its_coverage(pm: &vello_cpu::Pixmap) -> bool {
let m = |c: u8, a: u8| ((u16::from(c) * u16::from(a) + 127) / 255) as u8;
pm.data().iter().all(|p| p.r.abs_diff(m(200, p.a)) <= 1 && p.g.abs_diff(m(30, p.a)) <= 1 && p.b.abs_diff(m(30, p.a)) <= 1)
}
/// A placed image drawn smaller keeps the color of its opaque pixels at its cut-out edges: the
/// clear pixels beside them do not darken them.
#[test]
fn an_image_drawn_smaller_keeps_its_color_beside_clear_pixels() {
let mut png = Vec::new();
striped(8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let pm = decode_pixmap_fit(&png, 2, 1).unwrap();
assert_eq!((pm.width(), pm.height()), (2, 1));
assert!(pm.data().iter().all(|p| p.a > 0));
assert!(red_at_its_coverage(&pm), "{:?}", pm.data());
}
/// An image wider than a pixmap may be is shrunk to fit with its colors weighted by alpha the same way.
#[test]
fn an_image_shrunk_to_fit_a_pixmap_keeps_its_color_beside_clear_pixels() {
let pm = decoded_pixmap(image::DynamicImage::ImageRgba8(striped(u32::from(u16::MAX) + 4465))).unwrap();
assert_eq!(pm.width(), u16::MAX);
assert!(red_at_its_coverage(&pm));
}
}
+131 -35
View File
@@ -40,7 +40,7 @@ use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
use vectorcraft_text::{FontDb, TextLayout};
use vectorcraft_text::{FontDb, PositionedGlyph, TextLayout, spacing_scale};
use super::css::{Styles, XNode};
use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect};
@@ -1466,7 +1466,12 @@ impl Ctx {
obj.xf = Affine::IDENTITY;
} else {
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks);
let (justify, left, starts, unplaced) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, &pin, origin.x, &mut breaks);
if unplaced > 0 {
warnings.push(format!(
"{label}: {unplaced} absolutely placed character(s) between characters of the other direction keep their place in the line"
));
}
obj.para.justify = justify;
obj.xf = Affine::translate((finite(left), finite(origin.y)));
// Lines anchored differently or at different x (paragraphs aligned or indented
@@ -1599,7 +1604,8 @@ fn leading_tolerance(spacing: f64) -> f64 {
/// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk
/// starting at a character sits on its anchor, and `pin` sets the paragraph directions of the
/// text ([`pin_direction`]). When characters are placed on their own, every line is left-aligned
/// and its anchor x is its left edge.
/// and its anchor x is its left edge. Also returns how many absolutely placed characters kerning
/// cannot put on their x.
#[allow(clippy::too_many_arguments)]
fn lines(
cells: &mut [Cell],
@@ -1611,7 +1617,7 @@ fn lines(
pin: &dyn Fn(&mut TextObject),
x0: f64,
breaks: &mut Vec<Break>,
) -> (Justify, f64, Vec<(usize, f64, f64)>) {
) -> (Justify, f64, Vec<(usize, f64, f64)>, usize) {
let n = cells.len();
let y0 = y[0].unwrap_or(0.0) + dy[0];
let (mut base, mut cur) = (y0, y0);
@@ -1654,7 +1660,7 @@ fn lines(
}
if targets.is_empty() {
let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect();
return (justify_of(anchor_of(0)), x0, starts);
return (justify_of(anchor_of(0)), x0, starts, 0);
}
// Absolutely placed characters: lay the text out left-aligned and kern each one onto its x.
// SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left
@@ -1664,59 +1670,122 @@ fn lines(
// Chunk starts in order: line starts and absolutely placed characters.
let mut starts: Vec<usize> = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect();
starts.sort_unstable();
// The left and right edges of the chunk starting at character `s`, from each character's pen and end.
let edges = |s: usize, pens: &[Option<f64>], ends: &[Option<f64>]| {
// The left and right edges of the chunk starting at character `s`, and the character drawn at its left.
let edges = |s: usize, pens: &[Option<Pen>]| {
let e = starts.get(starts.partition_point(|&c| c <= s)).copied().unwrap_or(n);
let left = (s..e).filter_map(|j| pens[j]).fold(f64::INFINITY, f64::min);
let right = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max);
(left.is_finite() && right.is_finite()).then_some((left, right))
let (mut left, mut right, mut at) = (f64::INFINITY, f64::NEG_INFINITY, s);
for (j, p) in pens.iter().enumerate().take(e).skip(s) {
let Some(p) = p else { continue };
if p.start < left {
(left, at) = (p.start, j);
}
right = right.max(p.end);
}
(left.is_finite() && right.is_finite()).then_some((left, right, at))
};
let mut line_left: Vec<f64> = lines.iter().map(|l| l.1).collect();
// Where each line's first chunk's left edge goes.
let mut first_left: Vec<f64> = vec![];
// Where each absolutely placed character's pen goes, from the left edge of its line's first chunk.
let mut wants: Vec<(usize, f64)> = vec![];
// Absolutely placed characters drawn right next to a placed neighbor with no kerning between
// them (a left-to-right character between right-to-left ones): they stay where the layout puts them.
let mut unplaced = 0;
// Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until
// every character sits on its x.
for round in 0..4 {
let (runs, bytes) = assemble(cells, breaks);
let mut text = with_runs(runs.0);
pin(&mut text);
let (pens, ends) = pens(&measure(&text), &bytes);
let pens = pens(&measure(&text), &bytes, cells);
// The edges of each line's first chunk, found once a round.
let first: Vec<Option<(f64, f64)>> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect();
let first: Vec<Option<(f64, f64, usize)>> = lines.iter().map(|&(s, _)| edges(s, &pens)).collect();
if round == 0 {
// The chunks as they lie before any is moved: each one's left edge on its anchor.
let left_at = |s: usize, x: f64, chunk: Option<(f64, f64)>| chunk.map_or(x, |(left, right)| x - anchor_of(s) * (right - left));
let first_left: Vec<f64> = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect();
let left_at = |s: usize, x: f64, chunk: Option<(f64, f64, usize)>| chunk.map_or(x, |(left, right, _)| x - anchor_of(s) * (right - left));
first_left = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect();
wants = targets
.iter()
.map(|&(i, x)| {
let chunk = edges(i, &pens, &ends);
let chunk = edges(i, &pens);
// The pen of a right-to-left chunk's first character is right of the chunk's left edge.
let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, _))| p - left);
let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, ..))| p.start - left);
(i, left_at(i, x, chunk) + pen - first_left[line_of(i)])
})
.collect();
// A line's layout starts at the line's left edge, and its first chunk's left edge is `left` from there.
line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, _)| left)).collect();
}
let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64);
for &(i, want) in &wants {
let l = line_of(i);
if l != line {
(line, moved) = (l, 0.0);
// A line's layout starts at the line's left edge, and its first chunk's left edge is `left`
// from there (right-to-left kerning can move that chunk).
line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, ..)| left)).collect();
// Kerning a character widens its advance to the right: the glyphs drawn right of it move
// right, and so does a right-to-left glyph's own left edge (its kerning is at its left). In
// drawing order the kerning before an absolutely placed character is a cut after the
// character before it (before that character when it runs right to left), and what is drawn
// after the cut moves. Places in drawing order are doubled (from 2) to put cuts between them.
// Each gap between two placed characters drawn one after the other takes the kerning of one
// cut in it, so placing a character moves none placed before it. Kerning is drawn at the
// character's horizontal scale, so each correction is divided by it.
let mut worst = 0.0f64;
let mut from = 0;
unplaced = 0;
while from < wants.len() {
let l = line_of(wants[from].0);
let to = from + wants[from..].partition_point(|&(i, _)| line_of(i) == l);
let on_line = &wants[from..to];
from = to;
let Some((p0, _, at)) = first[l] else { continue };
let Some(home) = pens[at].map(|p| 2 * p.rank + 2) else { continue };
// A character drawn as part of the glyph before it (a mark) is kerned where that glyph is.
// The kerning stays on the character just before the placed one: a mark whose kerning
// differs from its base's is drawn as a glyph of its own, and kerning the base would put
// the space between the base and its mark.
// Each search stops at the nearest earlier character with a glyph, at the latest the
// placed character before it, so the searches of a line read each character once.
let mut cuts: Vec<(usize, usize)> = on_line
.iter()
.filter(|&&(i, _)| pens[i].is_some())
.filter_map(|&(i, _)| (lines[l].0..i).rev().find_map(|j| pens[j]).map(|k| (2 * k.rank + if k.rtl { 1 } else { 3 }, i - 1)))
.collect();
cuts.sort_unstable();
let mut points: Vec<(usize, f64, f64)> =
on_line.iter().filter_map(|&(i, want)| pens[i].map(|p| (2 * p.rank + 2, p.start, want))).collect();
points.sort_by_key(|p| p.0);
// The character kerned at the first cut between places `a` and `b`.
let cut = |a: usize, b: usize| cuts.get(cuts.partition_point(|c| c.0 <= a)).filter(|c| c.0 < b).map(|c| c.1);
let split = points.partition_point(|p| p.0 < home);
// Drawn right of the line's first chunk: kerning moves the character away from it.
let (mut prev, mut moved) = (home, 0.0);
for &(place, start, want) in &points[split..] {
if let Some(k) = cut(prev, place) {
let d = want - (start - p0 + moved);
cells[k].kern += d / spacing_scale(&cells[k].style, false);
moved += d;
worst = worst.max(d.abs());
} else {
unplaced += 1;
}
prev = place;
}
// Drawn left of it: kerning moves the chunk (and what lies between) away from the character.
(prev, moved) = (home, 0.0);
for &(place, start, want) in points[..split].iter().rev() {
if let Some(k) = cut(place, prev) {
let d = start - p0 - moved - want;
cells[k].kern += d / spacing_scale(&cells[k].style, false);
moved += d;
worst = worst.max(d.abs());
} else {
unplaced += 1;
}
prev = place;
}
let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue };
let d = want - (p - p0 + moved);
cells[i - 1].kern += d;
moved += d;
worst = worst.max(d.abs());
}
if worst < 0.01 {
break;
}
}
let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect();
(Justify::Left, line_left[0], starts)
(Justify::Left, line_left[0], starts, unplaced)
}
/// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor`
@@ -1875,13 +1944,40 @@ fn advance(t: &TextObject) -> f64 {
measure(t).glyphs.iter().map(|g| g.advance).sum()
}
/// Pen x (start) and end x of the glyph that starts at each character's byte offset.
fn pens(lay: &TextLayout, bytes: &[usize]) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
let mut at: HashMap<usize, (f64, f64)> = HashMap::with_capacity(lay.glyphs.len());
for g in &lay.glyphs {
at.entry(g.byte).or_insert((g.origin.x, g.origin.x + g.advance));
/// Where the glyph that starts a character is drawn on a line of point type.
#[derive(Clone, Copy)]
struct Pen {
/// Its left edge. Manual kerning on a right-to-left glyph is at its left, and the glyph is
/// drawn right of it: its left edge is past that kerning.
start: f64,
/// The right edge of its advance.
end: f64,
/// Its place in the order its line is drawn in, from the left.
rank: usize,
rtl: bool,
}
/// The [`Pen`] of the glyph that starts at each character's byte offset (`bytes`, parallel to `cells`).
fn pens(lay: &TextLayout, bytes: &[usize], cells: &[Cell]) -> Vec<Option<Pen>> {
// A line's glyphs are laid out in the order they are drawn.
let mut at: HashMap<usize, (usize, &PositionedGlyph)> = HashMap::with_capacity(lay.glyphs.len());
for (rank, g) in lay.glyphs.iter().enumerate() {
at.entry(g.byte).or_insert((rank, g));
}
bytes.iter().map(|b| at.get(b).map_or((None, None), |&(s, e)| (Some(s), Some(e)))).unzip()
bytes
.iter()
.zip(cells)
.map(|(b, c)| {
let &(rank, g) = at.get(b)?;
let mut start = g.origin.x;
if g.rtl {
let st = c.final_style();
let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(&st, false);
start += finite(kern);
}
Some(Pen { start, end: g.origin.x + g.advance, rank, rtl: g.rtl })
})
.collect()
}
type Key = (Option<usize>, Option<usize>);
+109 -1
View File
@@ -5,7 +5,7 @@ use vectorcraft_color::{GradientKind, Paint};
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_svg::{import, import_with_report};
use vectorcraft_text::{FontDb, layout};
use vectorcraft_text::{FontDb, layout, spacing_scale};
fn svg(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">{body}</svg>"#)
@@ -808,6 +808,114 @@ fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edg
}
}
/// Document-space left and right edges of the natural advance of the characters in `bytes` (the
/// manual kerning that places the next chunk left out: it is at a glyph's right when the glyph runs
/// left to right and at its left when it runs right to left).
fn chunk_edges(t: &TextObject, bytes: std::ops::Range<usize>) -> (f64, f64) {
let lay = layout(FontDb::global(), t);
let mut starts = vec![];
let mut at = 0;
for r in &t.runs {
starts.push((at, &r.style));
at += r.text.len();
}
let style = |byte: usize| starts.iter().rev().find(|(s, _)| *s <= byte).map(|(_, st)| *st).unwrap();
let (mut left, mut right) = (f64::INFINITY, f64::NEG_INFINITY);
for g in lay.glyphs.iter().filter(|g| bytes.contains(&g.byte)) {
let st = style(g.byte);
let kern = st.kerning.unwrap_or(0.0) / 1000.0 * st.size * spacing_scale(st, false);
let x = (t.xf * g.origin).x;
let (l, r) = if g.rtl { (x + kern, x + g.advance) } else { (x, x + g.advance - kern) };
(left, right) = (left.min(l), right.max(r));
}
assert!(left.is_finite(), "no glyph in {bytes:?}");
(left, right)
}
/// SVG 1.1 §10.4 and SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties):
/// every absolutely placed character starts a chunk anchored on its own x, also in right-to-left
/// text whose characters run right to left, on any line.
#[test]
fn absolutely_placed_right_to_left_characters_sit_on_their_own_x() {
// (markup, share of each chunk's advance left of its x, each chunk's bytes and x)
let two_lines = |anchor: &str| {
format!(
r#"<text direction="rtl" text-anchor="{anchor}" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80">אב</tspan></text>"#
)
};
type Chunks = Vec<(std::ops::Range<usize>, f64)>;
let cases: Vec<(String, f64, Chunks)> = vec![
(two_lines("start"), 1.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]),
(two_lines("middle"), 0.5, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]),
(two_lines("end"), 0.0, vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)]),
(r#"<text x="100 160" y="80" direction="rtl" font-size="20">אב</text>"#.into(), 1.0, vec![(0..2, 100.0), (2..4, 160.0)]),
(r#"<text x="100 160" y="80" direction="rtl" text-anchor="end" font-size="20">אב</text>"#.into(), 0.0, vec![(0..2, 100.0), (2..4, 160.0)]),
// Three lines, the characters of the last out of order and overlapping.
(
r#"<text direction="rtl" font-size="20"><tspan x="300" y="40">אב</tspan><tspan x="60 120" y="80">גד</tspan><tspan x="200 90 205" y="120">הוז</tspan></text>"#.into(),
1.0,
vec![(0..4, 300.0), (5..7, 60.0), (7..9, 120.0), (10..12, 200.0), (12..14, 90.0), (14..16, 205.0)],
),
// Left-to-right characters among right-to-left ones on the same line.
(r#"<text x="100 160 220" y="80" direction="rtl" font-size="20">אbc</text>"#.into(), 1.0, vec![(0..2, 100.0), (2..3, 160.0), (3..4, 220.0)]),
// Chunks of several characters.
(
r#"<text direction="rtl" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100" y="80">אב</tspan><tspan x="250">גד</tspan></text>"#.into(),
1.0,
vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)],
),
(
r#"<text direction="rtl" text-anchor="middle" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100" y="80">אב</tspan><tspan x="250">גד</tspan></text>"#.into(),
0.5,
vec![(0..2, 20.0), (3..7, 100.0), (7..11, 250.0)],
),
// A placed character after a letter with a combining mark.
(
r#"<text direction="rtl" font-size="20"><tspan x="100" y="80">&#x5D0;&#x5B8;</tspan><tspan x="160">&#x5D1;</tspan></text>"#.into(),
1.0,
vec![(4..6, 160.0)],
),
(r#"<text font-size="20"><tspan x="100" y="80">e&#x301;</tspan><tspan x="160">b</tspan></text>"#.into(), 0.0, vec![(3..4, 160.0)]),
// Characters stretched by `lengthAdjust`, whose kerning is drawn at their scale.
(
r#"<text direction="rtl" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80" textLength="12" lengthAdjust="spacingAndGlyphs">אב</tspan></text>"#.into(),
1.0,
vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)],
),
// Left to right, the right-to-left characters still each start at their own x.
(
r#"<text direction="ltr" font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80">אב</tspan></text>"#.into(),
0.0,
vec![(0..2, 20.0), (3..5, 100.0), (5..7, 160.0)],
),
(
r#"<text font-size="20"><tspan x="20" y="40">ab</tspan><tspan x="100 160" y="80 80">cd</tspan></text>"#.into(),
0.0,
vec![(0..2, 20.0), (3..4, 100.0), (4..5, 160.0)],
),
];
let unplaced = |w: &[String]| w.iter().any(|w| w.contains("absolutely placed"));
for (body, share, chunks) in cases {
let (d, w) = import_with_report(&svg(&body)).unwrap();
assert!(!unplaced(&w), "{body}: {w:?}");
let t = only_text(&d);
if body.contains("lengthAdjust") {
assert!(t.runs.iter().any(|r| r.style.h_scale < 99.0), "{body}: {:?}", t.runs);
}
for (bytes, x) in chunks {
let (left, right) = chunk_edges(t, bytes.clone());
let want = x - share * (right - left);
assert!(close(left, want, 0.05), "{body}: the chunk {bytes:?} spans {left}..{right}, so it does not start at {want}");
}
}
// A left-to-right character between right-to-left ones is drawn right next to the one after
// it, with no kerning between them: that one keeps its place in the line, and a warning says so.
let (d, w) = import_with_report(&svg(r#"<text x="100 160 220" y="80" direction="rtl" font-size="20">אbג</text>"#)).unwrap();
assert!(unplaced(&w), "{w:?}");
let (left, right) = chunk_edges(only_text(&d), 2..3);
assert!(close(right, 160.0, 0.05), "b spans {left}..{right}");
}
#[test]
fn a_small_line_gap_in_tiny_type_keeps_its_leading() {
// 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it.
+115 -11
View File
@@ -148,6 +148,9 @@ struct Tree<'a> {
painted: Option<Rect>,
/// It paints with the foreground colour (palette index 0xFFFF).
foreground_used: bool,
/// A fill draws nothing (a radial gradient none of whose circles has a positive radius), so a
/// glyph with nothing else is colour art that draws nothing, not its outline.
drew_nothing: bool,
}
impl<'a> Tree<'a> {
@@ -166,6 +169,7 @@ impl<'a> Tree<'a> {
too_large: false,
painted: None,
foreground_used: false,
drew_nothing: false,
}
}
@@ -254,7 +258,7 @@ impl<'a> Tree<'a> {
self.close();
}
let root = self.groups.pop()?;
if root.children.is_empty() {
if root.children.is_empty() && !self.drew_nothing {
return None;
}
Some(Arc::new(Node::group(NO_ID, root.children)))
@@ -308,22 +312,27 @@ impl<'a> Tree<'a> {
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => {
let spread = spread(*extend);
let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1));
let line = stops(color_stops, self);
// Without a stop, the others would repeat over a different interval.
if !spread.is_pad() && line.len() < color_stops.len() {
self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette");
return None;
}
let Some((c0, r0, c1, r1, mut stops)) = non_negative_radii(point(*c0), f64::from(*r0), point(*c1), f64::from(*r1), line, *extend)
else {
self.drew_nothing = true;
return None;
};
let concentric = (c0 - c1).hypot() < 1e-6;
if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 {
self.unsupported.get_or_insert("a radial gradient from a circle");
return None;
}
let mut stops = stops(color_stops, self);
if stops.iter().any(|s| !(0.0..=1.0).contains(&s.offset)) {
self.unsupported.get_or_insert("a radial gradient with stops past its circles");
return None;
}
if !spread.is_pad() {
if stops.len() < color_stops.len() {
self.unsupported.get_or_insert("a repeating or reflecting gradient with a stop missing from the palette");
return None;
}
// A run of the stops is the whole way from the center to the circle: one that
// starts at a circle (a first stop past the center comes as one) repeats over
// less of it. The normalized ends can be a rounding error off 0 and 1.
@@ -383,6 +392,87 @@ fn normalized(mut stops: Vec<GradientStop>) -> Option<(Vec<GradientStop>, f32, f
Some((stops, first, if span > 0.0 { last } else { first + 1.0 }))
}
/// A radial gradient's circles (`c0`, `r0`) and (`c1`, `r1`) and color line `stops` (0 and 1 at
/// the circles) recast so that neither circle has a negative radius, as normalizing a color line
/// to 0..1 can give. Circles of a negative radius are not drawn, only those on the side of the
/// circle of radius 0 (the tip of the cone) where the radius is positive. With pad, the stops on
/// the other side become one stop at the tip with the color there; with repeat and reflect, the
/// circles move along by whole periods of the color line to the drawn side
/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). `None`
/// when no circle is drawn.
fn non_negative_radii(
c0: Point,
r0: f64,
c1: Point,
r1: f64,
mut stops: Vec<GradientStop>,
extend: Extend,
) -> Option<(Point, f64, Point, f64, Vec<GradientStop>)> {
if (r0 >= 0.0 && r1 >= 0.0) || stops.is_empty() {
return Some((c0, r0, c1, r1, stops));
}
if r0 == r1 {
return None;
}
stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
let tip = r0 / (r0 - r1);
let grows = r1 > r0;
let (from, to, stops) = match extend {
Extend::Repeat | Extend::Reflect => {
// A reflected color line repeats every 2: once forward, then back.
let (period, stops) = if extend == Extend::Reflect {
let forward = stops.iter().map(|s| GradientStop { offset: s.offset / 2.0, ..s.clone() });
let back = stops.iter().rev().map(|s| GradientStop { offset: 1.0 - s.offset / 2.0, ..s.clone() });
(2.0, forward.chain(back).collect())
} else {
(1.0, stops)
};
let from = if grows { (tip / period).ceil() * period } else { (tip / period).floor() * period - period };
(from, from + period, stops)
}
_ => {
// The color line runs from 0 to 1, and further where its stops do.
let first = stops.first().map_or(0.0, |s| f64::from(s.offset).min(0.0));
let last = stops.last().map_or(1.0, |s| f64::from(s.offset).max(1.0));
let (from, to) = match (grows, tip) {
(true, t) if t < last => (t, last),
(true, t) => (t, t + 1.0),
(false, t) if t > first => (first, t),
(false, t) => (t - 1.0, t),
};
let line = Gradient { stops, ..Gradient::default() };
// The color at `w`, as a stop at `offset` of the new line.
let at = |w: f64, offset: f32| {
let (color, opacity) = line.sample(w as f32);
GradientStop { opacity, ..GradientStop::new(offset, color) }
};
// Stops at the tip stay on the drawn side of its stop, so a sharp change of color there
// keeps the color on that side.
let mut kept: Vec<GradientStop> = line
.stops
.iter()
.filter(|s| (from - 1e-6..=to + 1e-6).contains(&f64::from(s.offset)))
.map(|s| GradientStop { offset: ((f64::from(s.offset) - from) / (to - from)).clamp(0.0, 1.0) as f32, ..s.clone() })
.collect();
if grows {
kept.insert(0, at(tip, 0.0));
if kept.last().is_none_or(|s| s.offset < 1.0) {
kept.push(at(to, 1.0));
}
} else {
kept.push(at(tip, 1.0));
if kept.first().is_none_or(|s| s.offset > 0.0) {
kept.insert(0, at(from, 0.0));
}
}
(from, to, kept)
}
};
let circle = |w: f64| (c0.lerp(c1, w), (r0 + w * (r1 - r0)).max(0.0));
let ((c0, r0), (c1, r1)) = (circle(from), circle(to));
Some((c0, r0, c1, r1, stops))
}
impl ColorPainter for Tree<'_> {
fn push_transform(&mut self, transform: Transform) {
let t = self.transform() * affine(&transform);
@@ -575,10 +665,24 @@ impl ColorPainter for Pixels<'_, '_> {
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
peniko::Gradient::new_linear(point(p0), point(p1)).with_stops(stops(color_stops).as_slice()).with_extend(extend(e)).into()
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => peniko::Gradient::new_two_point_radial(point(c0), r0, point(c1), r1)
.with_stops(stops(color_stops).as_slice())
.with_extend(extend(e))
.into(),
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
let line = color_stops
.iter()
.map(|s| {
let (c, a) = colour(&self.palette, self.foreground, s.palette_index, s.alpha).unwrap_or((Color::BLACK, 0.0));
GradientStop { opacity: a, ..GradientStop::new(s.offset, c) }
})
.collect();
let Some((c0, r0, c1, r1, line)) = non_negative_radii(point(c0), f64::from(r0), point(c1), f64::from(r1), line, e) else { return };
let stops: Vec<peniko::ColorStop> = line
.iter()
.map(|s| {
let [r, g, b, a] = s.color.to_rgba8(s.opacity);
peniko::ColorStop::from((s.offset, peniko::Color::from_rgba8(r, g, b, a)))
})
.collect();
peniko::Gradient::new_two_point_radial(c0, r0 as f32, c1, r1 as f32).with_stops(stops.as_slice()).with_extend(extend(e)).into()
}
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
peniko::Gradient::new_sweep(point(c0), start_angle.to_radians(), end_angle.to_radians())
.with_stops(stops(color_stops).as_slice())
+23 -14
View File
@@ -256,6 +256,11 @@ pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_do
/// back before bisecting the earlier ones.
const MAX_DRAWN_CHECKS: usize = 256;
/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one when a glyph of
/// the line has a negative advance: a later break can then be drawn narrower than an earlier one,
/// and bisecting can miss the latest break that fits.
const MAX_DRAWN_CHECKS_STEPPING_BACK: usize = 1024;
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
/// millions of outlines.
const MAX_LEADER_COPIES: f64 = 4096.0;
@@ -357,8 +362,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins
/// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn:
/// once the line holds a tab, whose width depends on what is drawn before it, the line reads on
/// while the least it can be drawn at fits `width`, then ends at the latest legal break where it
/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a
/// hyphen are bisected); hyphenation points are measured the same way.
/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, or
/// [`MAX_DRAWN_CHECKS_STEPPING_BACK`] when a glyph of the line has a negative advance, the earlier
/// ones without a hyphen are bisected); hyphenation points are measured the same way.
#[allow(clippy::too_many_arguments)]
pub(super) fn break_line(
text: &str,
@@ -377,24 +383,26 @@ pub(super) fn break_line(
fits: Option<&dyn Fn(usize, bool) -> bool>,
) -> (usize, bool) {
// The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest
// of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of
// them from the latest back and bisecting the earlier ones without a hyphen; the first of them
// when none of those measured fits.
let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) {
// of the legal `breaks` before it that fits as drawn, measuring at most `checks` of them from
// the latest back and bisecting the earlier ones without a hyphen; the first of them when none
// of those measured fits.
let settle = |tabbed: bool, checks: usize, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) {
let Some(fits) = fits.filter(|_| tabbed) else { return chosen };
if fits(chosen.0, chosen.1) {
return chosen;
}
let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default();
if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) {
if let Some(&b) = earlier.iter().rev().take(checks).find(|b| fits(b.0, b.1)) {
return b;
}
// Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect.
let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default();
let rest = earlier.get(..earlier.len().saturating_sub(checks)).unwrap_or_default();
let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect();
let fitting = plain.partition_point(|b| fits(b.0, b.1));
fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen)
};
// How many breaks a line measures as drawn one by one.
let checks = |back: f64| if back > 0.0 { MAX_DRAWN_CHECKS_STEPPING_BACK } else { MAX_DRAWN_CHECKS };
let mut breaks: Vec<(usize, bool)> = Vec::new();
let mut tabbed = false;
if !width.is_finite() {
@@ -462,7 +470,7 @@ pub(super) fn break_line(
let cluster_ink = if is_tab { 0.0 } else { cluster_width };
let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() };
if gl.ch == '\u{2028}' {
return settle(tabbed, (cluster_end, false), &breaks);
return settle(tabbed, checks(back), (cluster_end, false), &breaks);
}
// Measured as the line is set when it ends with this cluster: only the cluster's last glyph
// is narrowed, and only where the line may end.
@@ -509,7 +517,7 @@ pub(super) fn break_line(
j = cluster_end;
}
if j >= g.len() {
return settle(tabbed, (g.len(), false), &breaks);
return settle(tabbed, checks(back), (g.len(), false), &breaks);
}
// Hyphenate the word that overflows. A list marker starting the line is not part of a word.
let word_start = last_break.map_or(first, |b| b.0);
@@ -534,18 +542,19 @@ pub(super) fn break_line(
.filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
.collect();
// The points the line may end at, the latest first. Measured as drawn, at most
// MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected:
// `checks(back)` of them are measured one by one, and the earlier ones are bisected:
// each ends with a hyphen, so a later one is drawn no narrower.
let points: Vec<usize> = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect();
let drawn = fits.filter(|_| tabbed);
for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) {
let one_by_one = checks(back);
for &k in points.iter().take(if drawn.is_some() { one_by_one } else { usize::MAX }) {
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) {
return (k, true);
}
}
if let Some(fits) = drawn {
let earlier: Vec<usize> = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect();
let earlier: Vec<usize> = points.iter().skip(one_by_one).rev().copied().collect();
let fitting = earlier.partition_point(|&k| fits(k, true));
if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) {
return (k, true);
@@ -555,7 +564,7 @@ pub(super) fn break_line(
// Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only
// cluster starts, so the resulting end always keeps each source cluster together.
match last_break {
Some(b) if b.0 > i => settle(tabbed, b, &breaks),
Some(b) if b.0 > i => settle(tabbed, checks(back), b, &breaks),
_ => (j, false),
}
}
+65 -7
View File
@@ -24,6 +24,10 @@ fn pushes_in(para: &ParaStyle) -> bool {
/// Paragraphs longer than this (glyphs) are always broken line by line.
const MAX_COMPOSE_GLYPHS: usize = 200_000;
/// The widths tried, halving the difference each time, for the longest break of a line placed again
/// at its own metrics in more room than it was broken in ([`place_flow_line`]).
const REFIT_STEPS: usize = 24;
/// Whether every line of area type in `regions` is as wide however tall it is and wherever it is set:
/// rectangular cells without text wrap.
fn constant_width(regions: Option<&[Region]>) -> bool {
@@ -415,25 +419,31 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
let word = word_width(sg, *i, line_end, para.kinsoku);
// A composed line in a frame whose lines are not all as wide is placed at its own tallest
// metrics, as the composer placed it (`shaped::Shaped`): its room is the room across its height.
let est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
let composed_line = composed.as_ref().and_then(|lines| lines.get(*para_line)).is_some_and(|&(end, _)| end > *i);
let mut est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
Some(&(end, _)) if end > *i && !constant_width(pen.regions) => sg.get(*i..end).and_then(Metrics::max).unwrap_or(est),
_ => est,
};
// A line broken here in such a frame is placed again when it turns out taller than it was
// placed: its room is the room across its own height.
let refit = !composed_line && pen.regions.is_some() && !constant_width(pen.regions);
// Place, break, then settle the baseline on the line's real metrics (moving on to the
// next row/column if it no longer fits).
loop {
let before = refit.then(|| pen.clone());
let first_in_region = pen.prev.is_none();
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else {
let Some((mut baseline, mut x0, mut x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else {
cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start };
return None;
};
let width = x1 - x0 - ind_l - ind_r;
let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((&para.hyphenation, *zone));
let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
let glyphs: &[SGlyph] = sg;
// The line broken in room `width` wide: its end, whether it ends with a hyphen, and whether
// it pushed characters in.
let break_in = |width: f64| match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
Some(&(end, hyph)) if end > *i => (end, hyph, false),
_ if *i < n => {
let start = *i;
let glyphs: &[SGlyph] = sg;
// A horizontal line with right-to-left text is drawn in another order than its
// text's, and its tabs take their stops in the order they are drawn: whether the line
// ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]).
@@ -478,8 +488,56 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
}
_ => (n, false, false),
};
let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm);
baseline += if same_baseline || (pen.regions.is_none() && first_in_region) {
let (mut end, mut hyph, mut pushed_in) = break_in(x1 - x0 - ind_l - ind_r);
let metrics = |end: usize| glyphs.get(*i..end).and_then(Metrics::max).unwrap_or(*pm);
let m = metrics(end);
// The baseline moves from where `est` placed it to where the line's metrics set it, unless
// the line was placed at metrics of its own (below).
let mut settle = true;
if let Some(before) = before
&& m != est
{
if m.union(est) != est {
// Taller than it was placed: place it again at metrics that hold both, and break it
// again in the room there.
*pen = before;
est = est.union(m);
continue;
}
// Broken again in the room of a taller line, it no longer holds what made that line
// taller. It goes where its own metrics place it when the room there holds the room it
// was broken in, and otherwise stays where it was placed, inside the room measured.
settle = false;
let mut own = before;
if let Some((b, a, z, _)) = own.place(m, ind_l, ind_r, word)
&& a <= x0 + EPS
&& z >= x1 - EPS
{
// The room there may hold more of the line, up to what would make it taller than
// `m`: the longest break that does not is between the two widths (a line breaks no
// earlier in more room).
let (mut lo, mut hi) = (x1 - x0 - ind_l - ind_r, z - a - ind_l - ind_r);
let wider = break_in(hi);
if metrics(wider.0).union(m) == m {
(end, hyph, pushed_in) = wider;
} else {
for _ in 0..REFIT_STEPS {
let mid = (lo + hi) / 2.0;
let line = break_in(mid);
if metrics(line.0).union(m) == m {
(end, hyph, pushed_in) = line;
lo = mid;
} else {
hi = mid;
}
}
}
*pen = own;
(baseline, x0, x1) = (b, a, z);
}
}
let m = metrics(end);
baseline += if !settle || same_baseline || (pen.regions.is_none() && first_in_region) {
0.0
} else if pen.model == LeadingModel::EmBoxTop {
// The em box top is fixed (the frame's top, or the line above's): the baseline
+390 -8
View File
@@ -386,7 +386,7 @@ fn slant_boundaries(slants: &mut [Slant]) -> (Vec<(f64, f64)>, bool, Vec<Line>)
}
/// The most pieces a line's band is cut into to keep it inside an Invert Wrap shape grown by its
/// offset.
/// offset, when [`grown_band`] gives up, or out of a wrap shape shrunk by its offset.
const INVERT_WRAP_PIECES: f64 = 64.0;
/// The edges of `polys` that reach into the band `top..bottom` (`top <= bottom`), leaving out those
@@ -582,6 +582,297 @@ fn eroded_cover(polys: &[Vec<Point>], top: f64, bottom: f64, d: f64) -> Vec<(f64
union_intervals(out)
}
/// The most steps [`grown_band`] takes for one band and one wrap shape: edges, pairs of edges and
/// pairs of grown pieces looked at, and pieces looked at for each x it tests.
const MAX_INVERT_WRAP_WORK: usize = 1 << 20;
#[cfg(test)]
thread_local! {
/// How many steps [`grown_band`] took on this thread.
static GROWN_BAND_WORK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Adds `n` to `work`; false when that passes [`MAX_INVERT_WRAP_WORK`].
fn spend(work: &mut usize, n: usize) -> bool {
*work = work.saturating_add(n);
#[cfg(test)]
GROWN_BAND_WORK.set(GROWN_BAND_WORK.get().saturating_add(n));
*work <= MAX_INVERT_WRAP_WORK
}
/// The x of the edge `p`–`q` (not horizontal) at `y`: the x of its end at that end's height.
fn edge_x(p: Point, q: Point, y: f64) -> f64 {
if y == p.y {
p.x
} else if y == q.y {
q.x
} else {
line_x(p, q, y)
}
}
/// A trapezoid with a horizontal top and bottom: its top and bottom heights, then the x of its left
/// edge at each of them, then the x of its right edge at each of them.
type Trapezoid = (f64, f64, f64, f64, f64, f64);
/// The shape `polys` (even-odd) between the heights `y0..y1` (`y0 < y1`) as trapezoids with
/// horizontal tops and bottoms, cut at each anchor's height and wherever two edges cross, whose union
/// (each trapezoid with its outline) is the shape with its outline there. Edges that lie along one
/// another give trapezoids that touch, and contours that cancel each other give none. `None` when
/// [`MAX_INVERT_WRAP_WORK`] runs out.
fn trapezoids(polys: &[Vec<Point>], y0: f64, y1: f64, work: &mut usize) -> Option<Vec<Trapezoid>> {
let mut edges: Vec<(Point, Point)> = polys
.iter()
.flat_map(|p| (0..p.len()).map(move |i| (p[i], p[(i + 1) % p.len()])))
.filter(|(a, b)| a.y != b.y && a.y.max(b.y) > y0 && a.y.min(b.y) < y1)
.map(|(a, b)| if a.y < b.y { (a, b) } else { (b, a) })
.collect();
if !spend(work, edges.len()) {
return None;
}
edges.sort_by(|u, v| u.0.y.total_cmp(&v.0.y));
let mut ys: Vec<f64> = edges.iter().flat_map(|(p, q)| [p.y, q.y]).filter(|&y| y > y0 && y < y1).chain([y0, y1]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup();
let mut out = Vec::new();
let (mut next, mut active) = (0, Vec::new());
for w in ys.windows(2) {
let (a, b) = (w[0], w[1]);
// The edges that span the slab `a..b`: every anchor's height is a cut, so an edge that
// starts at or above `a` and ends below it reaches `b`.
while let Some(&e) = edges.get(next).filter(|e| e.0.y <= a) {
active.push(e);
next += 1;
}
active.retain(|e: &(Point, Point)| e.1.y >= b);
if !spend(work, active.len()) {
return None;
}
// The heights within the slab at which two edges cross: their order left to right differs
// at its top and bottom.
let mut cuts = vec![a, b];
let ends: Vec<(f64, f64)> = active.iter().map(|&(p, q)| (edge_x(p, q, a), edge_x(p, q, b))).collect();
let mut order: Vec<usize> = (0..ends.len()).collect();
order.sort_by(|&i, &j| ends[i].0.total_cmp(&ends[j].0).then(ends[i].1.total_cmp(&ends[j].1)));
if order.windows(2).any(|w| ends[w[0]].1 > ends[w[1]].1) {
if !spend(work, ends.len() * ends.len()) {
return None;
}
for (i, &(sa, sb)) in ends.iter().enumerate() {
for &(ta, tb) in &ends[i + 1..] {
let (da, db) = (sa - ta, sb - tb);
if da * db < 0.0 {
let y = a + (b - a) * (da / (da - db));
if y > a && y < b {
cuts.push(y);
}
}
}
}
cuts.sort_by(f64::total_cmp);
cuts.dedup();
}
for c in cuts.windows(2) {
let (c, d) = (c[0], c[1]);
// No two edges cross within `c..d`, so their order at its middle holds throughout, and
// the shape (even-odd) lies between the first and second, the third and fourth, and so on.
let mid = (c + d) * 0.5;
let mut xs: Vec<(f64, f64, f64)> = active.iter().map(|&(p, q)| (line_x(p, q, mid), edge_x(p, q, c), edge_x(p, q, d))).collect();
if !spend(work, xs.len()) {
return None;
}
xs.sort_by(|u, v| u.0.total_cmp(&v.0));
for pair in xs.as_chunks::<2>().0 {
let ((_, l0, l1), (_, r0, r1)) = (pair[0], pair[1]);
if r0 > l0 || r1 > l1 {
out.push((c, d, l0, l1, r0, r1));
}
}
}
}
Some(out)
}
/// A convex polygon as its lower and upper outlines (the least and the greatest y at each x), each
/// left to right from its least x to its greatest, with its x range and y range.
struct Convex {
lower: Vec<Point>,
upper: Vec<Point>,
xs: (f64, f64),
ys: (f64, f64),
}
impl Convex {
/// The convex hull of `pts`.
fn hull(mut pts: Vec<Point>) -> Convex {
let cross = |o: Point, a: Point, b: Point| (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
let chain = |pts: &[Point], turns: fn(f64) -> bool| {
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
for &p in pts {
while let [.., o, a] = out[..] {
if turns(cross(o, a, p)) {
break;
}
out.pop();
}
out.push(p);
}
// The points at the greatest x after the first are on the other outline.
while let [.., a, b] = out[..] {
if a.x != b.x {
break;
}
out.pop();
}
out
};
pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y)));
let lower = chain(&pts, |c| c > 0.0);
pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(q.y.total_cmp(&p.y)));
let upper = chain(&pts, |c| c < 0.0);
let xs = (lower.first().map_or(f64::NAN, |p| p.x), lower.last().map_or(f64::NAN, |p| p.x));
let ys = (lower.iter().fold(f64::INFINITY, |m, p| m.min(p.y)), upper.iter().fold(f64::NEG_INFINITY, |m, p| m.max(p.y)));
Convex { lower, upper, xs, ys }
}
/// The y range of the polygon at `x`, within its x range.
fn at(&self, x: f64) -> (f64, f64) {
(chain_y(&self.lower, x), chain_y(&self.upper, x))
}
}
/// The y of the outline `chain` (left to right, no two points at one x) at `x`, within its x range.
fn chain_y(chain: &[Point], x: f64) -> f64 {
let i = chain.partition_point(|p| p.x < x);
match (i.checked_sub(1).and_then(|h| chain.get(h)), chain.get(i)) {
(Some(p), Some(q)) => p.y + (x - p.x) / (q.x - p.x) * (q.y - p.y),
(None, Some(q)) => q.y,
(Some(p), None) => p.y,
(None, None) => f64::NAN,
}
}
/// The x at which some segment of `a` and some segment of `b` (outlines left to right) are at the
/// same y, strictly between the ends of the x range they share.
fn chain_crossings(a: &[Point], b: &[Point], out: &mut Vec<f64>, work: &mut usize) -> bool {
for s in a.windows(2) {
let first = b.partition_point(|q| q.x <= s[0].x).saturating_sub(1);
for t in b[first..].windows(2).take_while(|t| t[0].x < s[1].x) {
if !spend(work, 1) {
return false;
}
let (x0, x1) = (s[0].x.max(t[0].x), s[1].x.min(t[1].x));
if x1 <= x0 {
continue;
}
let (d0, d1) = (chain_y(s, x0) - chain_y(t, x0), chain_y(s, x1) - chain_y(t, x1));
if d0 * d1 < 0.0 {
out.push(x0 + (x1 - x0) * (d0 / (d0 - d1)));
}
}
}
true
}
/// The x within `lo..hi` at which the shape `polys` (even-odd) grown by `off` (> 0) across and
/// along holds every height of the band `top..bottom` (`top <= bottom`): the x whose vertical line
/// across the band has each of its points within `off`, across and along, of the shape or its
/// outline. The shape from `off` above the band to `off` below it, and on to the nearest anchors
/// past those heights, is cut into [`trapezoids`]; each
/// grown by `off` is a convex polygon, and the line at an x lies in their union when the y ranges
/// they give at that x join up across the band. Those ranges change only at the polygons' corners
/// and where their outlines cross each other or the band's top or bottom, so the line is tested
/// once between each two of those x. `None` when that takes more than [`MAX_INVERT_WRAP_WORK`]
/// steps.
fn grown_band(polys: &[Vec<Point>], off: f64, top: f64, bottom: f64, (lo, hi): (f64, f64)) -> Option<Vec<(f64, f64)>> {
let mut work = 0;
// The trapezoids reach on to the nearest anchor past `off` above and below the band, so that
// the shape's outline exactly `off` from the band counts.
let (y0, y1) = (top - off, bottom + off);
let ys = || polys.iter().flatten().map(|p| p.y);
let below = ys().filter(|&y| y < y0).fold(f64::NEG_INFINITY, f64::max);
let above = ys().filter(|&y| y > y1).fold(f64::INFINITY, f64::min);
let traps = trapezoids(polys, if below.is_finite() { below } else { y0 }, if above.is_finite() { above } else { y1 }, &mut work)?;
let mut grown: Vec<Convex> = traps
.into_iter()
.filter(|&(_, _, l0, l1, r0, r1)| l0.min(l1) - off < hi && r0.max(r1) + off > lo)
.map(|(y0, y1, l0, l1, r0, r1)| {
let corners = [(l0, y0), (r0, y0), (l1, y1), (r1, y1)];
let pts = corners
.into_iter()
.flat_map(|(x, y)| [(-off, -off), (off, -off), (-off, off), (off, off)].map(|(dx, dy)| Point::new(x + dx, y + dy)));
Convex::hull(pts.collect())
})
.collect();
grown.sort_by(|p, q| p.xs.0.total_cmp(&q.xs.0));
// The x at which the line's place among the polygons can change.
let mut xs = vec![lo, hi];
for (j, p) in grown.iter().enumerate() {
xs.extend(p.lower.iter().chain(&p.upper).map(|v| v.x));
for chain in [&p.lower, &p.upper] {
for s in chain.windows(2) {
for y in [top, bottom] {
let (d0, d1) = (s[0].y - y, s[1].y - y);
if d0 * d1 < 0.0 {
xs.push(s[0].x + (s[1].x - s[0].x) * (d0 / (d0 - d1)));
}
}
}
}
let ((_, x1), (y0, y1)) = (p.xs, p.ys);
for q in grown[j + 1..].iter().take_while(|q| q.xs.0 < x1) {
if !spend(&mut work, 1) {
return None;
}
let (v0, v1) = q.ys;
if v0 <= y1
&& y0 <= v1
&& !(chain_crossings(&p.upper, &q.lower, &mut xs, &mut work) && chain_crossings(&q.upper, &p.lower, &mut xs, &mut work))
{
return None;
}
}
}
xs.retain(|&x| x >= lo && x <= hi);
xs.sort_by(f64::total_cmp);
xs.dedup();
// Test the line between each two of those x, walking left to right with the polygons whose x
// range holds it.
let mut out: Vec<(f64, f64)> = Vec::new();
let (mut next, mut active, mut ranges): (usize, Vec<&Convex>, Vec<(f64, f64)>) = (0, Vec::new(), Vec::new());
for w in xs.windows(2) {
let x = (w[0] + w[1]) * 0.5;
while let Some(p) = grown.get(next).filter(|p| p.xs.0 <= x) {
active.push(p);
next += 1;
}
active.retain(|p| p.xs.1 >= x);
if !spend(&mut work, active.len()) {
return None;
}
ranges.clear();
ranges.extend(active.iter().map(|p| p.at(x)));
ranges.sort_by(|u, v| u.0.total_cmp(&v.0));
// The line is held from the band's top to `reach`, up to the first range that starts past
// it; a band of no height is held when a range reaches it.
let (mut reach, mut met) = (top, false);
for &(a, b) in &ranges {
if a > reach {
break;
}
reach = reach.max(b);
met |= b >= top;
}
if met && reach >= bottom {
match out.last_mut() {
Some(l) if l.1 >= w[0] => l.1 = w[1],
_ => out.push((w[0], w[1])),
}
}
}
Some(out)
}
impl Region {
pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> {
// A frame out of range has no cells, and a wrap shape out of range is left out.
@@ -654,10 +945,20 @@ impl Region {
} else {
subtract_intervals(&iv, &pad(eroded_cover(polys, top, bottom, -off)))
}
} else if let Some(held) = (*invert && *off > 0.0)
.then(|| iv.first().zip(iv.last()).and_then(|(f, l)| grown_band(polys, *off, top, bottom, (f.0 - inset, l.1 + inset))))
.flatten()
{
// The x at which the shape grown by the offset holds the whole band.
intersect_intervals(&iv, &shrink(held))
} else if *invert {
// The band is cut into pieces. Each height of a piece `y0..y1` is within the offset
// of some height from `y0` plus the offset to `y1` less the offset, so an x the
// shape holds at all of those heights is inside the grown shape over the piece. In
// With no offset, or when measuring the grown shape exactly takes more than
// [`MAX_INVERT_WRAP_WORK`] steps, the band is cut into pieces, which can leave a line
// shorter than the grown shape allows, never longer.
//
// Each height of a piece `y0..y1` is within the offset of some height from `y0`
// plus the offset to `y1` less the offset, so an x the shape holds at all of those
// heights is inside the grown shape over the piece. In
// a piece less than twice the offset tall, each height of the piece is within the
// offset of every height from `y1` less the offset to `y0` plus the offset, so an x
// the shape holds at any of them is. Text within the offset of such an x, for every
@@ -1255,8 +1556,8 @@ fn intersecting_intervals_looks_at_each_interval_once() {
}
/// A line in area type with an Invert Wrap comb of 500 teeth and an offset keeps a span inside
/// each tooth, left to right, through every piece of its band, and intersecting the spans with each
/// piece looks at each span once.
/// each tooth, left to right, and measuring the grown comb takes a few dozen steps for each tooth,
/// not one for each pair of teeth.
#[cfg(test)]
#[test]
fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() {
@@ -1268,11 +1569,92 @@ fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() {
let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.25, invert: true } };
let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]);
INTERSECT_PAIRS.set(0);
GROWN_BAND_WORK.set(0);
let got = cells[0].spans(40.0, 60.0);
let want: Vec<(f64, f64)> = (0..500).map(|k| f64::from(k) * 2.0).map(|x| ((x - 0.25).max(0.0), x + 1.25)).collect();
assert_spans(&got, &want);
// At most 64 pieces, each looking at no more than the spans so far and the tooth spans.
assert!(INTERSECT_PAIRS.get() <= 64 * (500 + 500), "{} pairs", INTERSECT_PAIRS.get());
// The line's one span is intersected once with the 500 spans the grown comb holds.
assert!(INTERSECT_PAIRS.get() <= 1 + 500, "{} pairs", INTERSECT_PAIRS.get());
assert!(GROWN_BAND_WORK.get() <= 40 * 500, "{} steps", GROWN_BAND_WORK.get());
}
/// The spans of a 120 pt wide, 100 pt tall frame over the band `top..bottom` with one Invert Wrap
/// object through `contour` and `offset`.
#[cfg(test)]
fn invert_wrap_spans(contour: &[(f64, f64)], offset: f64, top: f64, bottom: f64) -> Vec<(f64, f64)> {
let frame = Rect::new(0.0, 0.0, 120.0, 100.0).to_path(0.1);
let path = PathData::from_bezpath(&closed(&[contour]));
let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset, invert: true } };
Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(top, bottom)
}
/// The 100 pt square whose right edge steps in to x = 80 from `y0` to `y1`, with 79 more anchors
/// along its left edge, which leave its shape as it is.
#[cfg(test)]
fn notched_square(y0: f64, y1: f64) -> Vec<(f64, f64)> {
let mut contour = vec![(0.0, 0.0), (100.0, 0.0), (100.0, y0), (80.0, y0), (80.0, y1), (100.0, y1), (100.0, 100.0), (0.0, 100.0)];
contour.extend((1..80).rev().map(|i| (0.0, 1.25 * f64::from(i))));
contour
}
/// With Invert Wrap and an offset, a line beside a notch the offset fills reaches as far as the
/// grown shape does at every height of its band, however tall the band and however many anchors
/// the shape has: a notch 0.5625 pt tall with an offset of 1, and one 0.4 pt tall with an offset
/// of 0.25 over a band of 100 pt.
#[cfg(test)]
#[test]
fn invert_wrap_with_an_offset_lets_a_line_beside_a_notch_reach_the_grown_shape() {
assert_spans(&invert_wrap_spans(&notched_square(50.5, 51.0625), 1.0, 0.0, 100.0), &[(0.0, 101.0)]);
assert_spans(&invert_wrap_spans(&notched_square(50.6, 51.0), 0.25, 0.0, 100.0), &[(0.0, 100.25)]);
// A band of no height in the notch, and one in a notch the offset does not fill.
assert_spans(&invert_wrap_spans(&notched_square(50.5, 51.0625), 1.0, 50.7, 50.7), &[(0.0, 101.0)]);
assert_spans(&invert_wrap_spans(&notched_square(50.0, 53.0), 1.0, 0.0, 100.0), &[(0.0, 81.0)]);
assert_spans(&invert_wrap_spans(&notched_square(50.0, 53.0), 1.0, 51.5, 51.5), &[(0.0, 81.0)]);
}
/// With Invert Wrap and an offset, a line beside a notch with sloped sides reaches as far as the
/// grown shape does at the notch's tip: 1 pt above and below the tip, each side is 8 pt from it.
#[cfg(test)]
#[test]
fn invert_wrap_with_an_offset_lets_a_line_beside_a_sloped_notch_reach_the_grown_shape() {
let notch = [(0.0, 0.0), (100.0, 0.0), (100.0, 45.15), (60.0, 50.15), (100.0, 55.15), (100.0, 100.0), (0.0, 100.0)];
assert_spans(&invert_wrap_spans(&notch, 1.0, 40.0, 60.0), &[(0.0, 69.0)]);
assert_spans(&invert_wrap_spans(&notch, 5.0, 40.0, 60.0), &[(0.0, 105.0)]);
}
/// With Invert Wrap and an offset, a band of no height exactly the offset past a wrap shape's
/// bottom edge or its tip still holds a line as wide as the grown edge or tip.
#[cfg(test)]
#[test]
fn invert_wrap_with_an_offset_holds_a_band_exactly_the_offset_past_the_shape() {
let bar = [(20.0, 0.0), (60.0, 0.0), (60.0, 10.0), (20.0, 10.0)];
assert_spans(&invert_wrap_spans(&bar, 1.0, 11.0, 11.0), &[(19.0, 61.0)]);
assert!(invert_wrap_spans(&bar, 1.0, 11.5, 11.5).is_empty());
let tip = [(20.0, 0.0), (80.0, 0.0), (50.0, 10.0)];
assert_spans(&invert_wrap_spans(&tip, 1.0, 11.0, 11.0), &[(49.0, 51.0)]);
let tip = [(20.0, 20.0), (50.0, 10.0), (80.0, 20.0)];
assert_spans(&invert_wrap_spans(&tip, 1.0, 9.0, 9.0), &[(49.0, 51.0)]);
}
/// A wrap shape whose edges cross one another tens of thousands of times takes no more than
/// [`MAX_INVERT_WRAP_WORK`] steps to measure exactly; it is then cut into pieces, and the line
/// still stays inside the shape grown by the offset. An offset that is not a number is 0.
#[cfg(test)]
#[test]
fn invert_wrap_with_an_offset_bounds_its_work_on_a_tangled_shape() {
let star: Vec<Point> = (0..301)
.map(|k| f64::from(k * 150) * std::f64::consts::TAU / 301.0)
.map(|a| Point::new(50.0 + 45.0 * a.cos(), 50.0 + 45.0 * a.sin()))
.collect();
GROWN_BAND_WORK.set(0);
assert!(grown_band(std::slice::from_ref(&star), 1.0, 10.0, 90.0, (0.0, 120.0)).is_none());
assert!(GROWN_BAND_WORK.get() <= 2 * MAX_INVERT_WRAP_WORK, "{} steps", GROWN_BAND_WORK.get());
let contour: Vec<(f64, f64)> = star.iter().map(|p| (p.x, p.y)).collect();
let spans = invert_wrap_spans(&contour, 1.0, 10.0, 90.0);
assert!(spans.iter().all(|&(a, b)| a >= 4.0 && b <= 96.0), "{spans:?}");
let square = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)];
assert_spans(&invert_wrap_spans(&square, f64::NAN, 45.0, 55.0), &invert_wrap_spans(&square, 0.0, 45.0, 55.0));
assert_spans(&invert_wrap_spans(&square, 0.0, 45.0, 55.0), &[(40.0, 60.0)]);
}
/// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap,
+49
View File
@@ -646,6 +646,55 @@ fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() {
assert_eq!(ended, (5, false));
}
/// A long tabbed line with a glyph that steps back ends at the latest break that fits as drawn, even
/// where a later break than one that does not fit fits again.
#[test]
fn a_long_tabbed_line_that_steps_back_ends_at_the_latest_break_that_fits_as_drawn() {
// a (0), tab (1), then b and a space for each u: breaks before 4 + 2u.
let text = format!("a\t{}", "b ".repeat(400));
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -0.5 } else { 1.0 }, ch)).collect();
let glyphs = synthetic_glyphs(&specs);
let drawn_fits = |end: usize, _hyphenated: bool| end <= 10 || end == 200;
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended =
break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits);
assert_eq!(ended, (200, false));
}
/// A tabbed line with a glyph that steps back, ending in a word with more hyphenation points than
/// are measured one by one, ends at the latest point that fits as drawn.
#[test]
fn a_tabbed_line_that_steps_back_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() {
let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() };
// a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ...
let text = format!("a\t{}", "कि".repeat(400));
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if byte == 2 { -1.0 } else { 1.0 }, ch)).collect();
let mut glyphs = synthetic_glyphs(&specs);
for glyph in &mut glyphs {
glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi"));
glyph.sx = 0.001;
}
let drawn_fits = |end: usize, _hyphenated: bool| end <= 20 || end == 50;
let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits);
let ended = break_line(
&text,
&glyphs,
0,
600.0,
&[],
0.0,
Some((&opts, 0.0)),
Burasagari::None,
Kinsoku::None,
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
fits,
);
assert_eq!(ended, (50, true));
}
/// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed
/// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last
/// glyph of the last cluster counts, and only where the line may end.
+6
View File
@@ -1939,6 +1939,12 @@ pub fn colr_variable_font() -> Option<Vec<u8>> {
with_colr(&variable_font()?, family_names(COLR_VARIABLE_FAMILY, &axis_names)?, &colr)
}
/// [`variable_font`] (one weight axis, 400 to 700) as `family` with `colr` and [`colr_font`]'s
/// palette. `None` when it can't be built.
pub fn colr_variable_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> Option<Vec<u8>> {
with_colr(&variable_font()?, family_names(family, &[])?, colr)
}
/// A `name` table for Regular of `family`, with `more` records.
fn family_names(family: &str, more: &[(u16, &str)]) -> Option<Vec<u8>> {
let ps = family.replace(' ', "");
+142 -1
View File
@@ -25,7 +25,7 @@ use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{Node, NodeKind};
use crate::colr::{ColourContent, colour_glyph};
use crate::test_fonts::{COLR_GREEN, COLR_RED};
use crate::test_fonts::{COLR_BLUE, COLR_GREEN, COLR_RED};
const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 };
@@ -358,6 +358,147 @@ fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() {
assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out");
}
/// A padded radial gradient whose color line starts before 0 starts at the circle of radius 0:
/// circles of a negative radius are not drawn, so the stops before that circle become one stop
/// there with the color between them
/// (https://learn.microsoft.com/en-us/typography/opentype/spec/colr, Radial gradients). With its
/// centers apart, the gradient is focused on the tip of its cone; with its centers together, it
/// is centered there with no stop below 0.
#[test]
fn a_radial_gradient_with_a_stop_before_0_starts_at_its_circle_of_radius_0() {
use vectorcraft_doc::color::{Gradient, GradientStop};
use write_fonts::tables::colr::{ColorLine, ColorStop, Extend};
use write_fonts::types::{F2Dot14, FWord, UfWord};
let one = F2Dot14::from_f32(1.0);
let half = F2Dot14::from_f32(0.5);
let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, half), ColorStop::new(one, 1, one)]);
let w = FWord::new;
// Half-opaque red at -0.5 and blue at 1: at 0, a third of the way from red to blue, in color
// and opacity.
let red = GradientStop { opacity: 0.5, ..GradientStop::new(0.0, rgb(COLR_RED)) };
let tip = Gradient { stops: vec![red, GradientStop::new(1.0, rgb(COLR_BLUE))], ..Gradient::default() }.sample(1.0 / 3.0);
assert!((tip.1 - 2.0 / 3.0).abs() < 1e-6, "{tip:?}");
for (family, c1_x, focal) in [("Colrtest Tip", 250, Some(300.0)), ("Colrtest Dot", 300, None)] {
let font = v1_font(family, COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(c1_x), w(300), UfWord::new(400)), o)
});
let root = art(&font, COLOUR_CHAR);
let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("{family}: a gradient") };
let offsets: Vec<f32> = g.gradient.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 1.0], "{family}");
let first = &g.gradient.stops[0];
assert_eq!((first.color, first.opacity), tip, "{family}: the color at the circle of radius 0");
assert_eq!(g.gradient.stops[1].color, rgb(COLR_BLUE), "{family}");
let geom = g.geom.unwrap();
assert!((geom.start.x - f64::from(c1_x)).abs() < 1e-3 && (geom.start.y + 300.0).abs() < 1e-3, "{family}: {geom:?}");
assert!(((geom.end - geom.start).hypot() - 400.0).abs() < 1e-3, "{family}: {geom:?}");
assert_eq!(geom.focal.map(|f| ((f.x * 1e3).round() / 1e3, (f.y * 1e3).round() / 1e3)), focal.map(|x| (x, -300.0)), "{family}");
}
}
/// A sharp change of color at the circle of radius 0 of a gradient whose color line starts
/// before 0 keeps the color beyond it: red up to 0, then green fading to blue.
#[test]
fn a_sharp_change_of_color_at_the_circle_of_radius_0_keeps_the_color_beyond_it() {
use write_fonts::tables::colr::{ColorLine, ColorStop, Extend};
use write_fonts::types::{F2Dot14, FWord, UfWord};
let (one, zero) = (F2Dot14::from_f32(1.0), F2Dot14::from_f32(0.0));
let stops = vec![
ColorStop::new(F2Dot14::from_f32(-1.0), 0, one),
ColorStop::new(zero, 0, one),
ColorStop::new(zero, 2, one),
ColorStop::new(one, 1, one),
];
let line = ColorLine::new(Extend::Pad, 4, stops);
let w = FWord::new;
let font = v1_font("Colrtest Edge", COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(300), w(300), UfWord::new(0), w(250), w(300), UfWord::new(400)), o)
});
let root = art(&font, COLOUR_CHAR);
let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("a gradient") };
let stops = &g.gradient.stops;
let colors: Vec<Color> = stops.iter().map(|s| s.color).collect();
assert_eq!(colors, [rgb(COLR_RED), rgb(COLR_RED), rgb(COLR_GREEN), rgb(COLR_BLUE)], "{stops:?}");
assert!(stops[..3].iter().all(|s| s.offset.abs() < 1e-5) && stops[3].offset == 1.0, "{stops:?}");
}
/// A glyph drawn into an image draws a radial gradient with a circle of a negative radius from the
/// circle of radius 0, as vector art does: with pad, the color at the tip of the cone and the
/// colors on its drawn side, not one solid color. A composite that keeps only its backdrop makes
/// the glyph an image. Red runs to blue; the circles are around the `O`'s middle and larger than it.
#[test]
fn an_image_of_a_radial_gradient_with_a_stop_past_its_circle_of_radius_0_draws_from_that_circle() {
use write_fonts::tables::colr::{ColorLine, ColorStop, CompositeMode, Extend};
use write_fonts::types::{F2Dot14, FWord, UfWord};
let one = F2Dot14::from_f32(1.0);
let w = FWord::new;
type Check = fn([u8; 3]) -> bool;
// Red at -0.5 (a start circle of radius -250 once normalized): the colors run from a third of
// the way to blue at the tip to blue. Blue at 1.5 (an end circle of radius -250): they run
// from red at the outer circle, which lies outside the `O`, to two thirds of the way to blue
// at the tip. Either way every opaque pixel holds some blue.
let cases: [(&str, f32, f32, u16, u16, Check); 2] =
[("Colrtest Tip Image", -0.5, 1.0, 0, 500, |[r, _, b]| b > 20 && r < 240), ("Colrtest Rim Image", 0.0, 1.5, 500, 0, |[_, _, b]| b > 20)];
for (family, first, last, r0, r1, check) in cases {
let line =
ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(first), 0, one), ColorStop::new(F2Dot14::from_f32(last), 1, one)]);
let font = v1_font(family, COLOUR_CHAR, |o| {
let radial = ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(r0), w(290), w(330), UfWord::new(r1));
ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Dest, ColrPaint::glyph(radial, o))
});
let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") };
let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap();
let pixels: Vec<[u8; 3]> = pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect();
assert!(pixels.len() > 100, "{family}: the `O` is drawn");
let wrong: Vec<_> = pixels.iter().filter(|p| !check(**p)).take(5).collect();
assert!(wrong.is_empty(), "{family}: {wrong:?}");
}
// Repeat and reflect draw their bands, not one color.
for (family, extend) in [("Colrtest Repeat Image", Extend::Repeat), ("Colrtest Reflect Image", Extend::Reflect)] {
let line = ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(-0.5), 0, one), ColorStop::new(one, 1, one)]);
let font = v1_font(family, COLOUR_CHAR, |o| {
ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(290), w(330), UfWord::new(0), w(290), w(330), UfWord::new(100)), o)
});
let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("{family}: an image") };
let pixmap = vello_cpu::Pixmap::from_png(std::io::Cursor::new(image.blob.bytes.as_slice())).unwrap();
let colors: std::collections::HashSet<[u8; 3]> =
pixmap.take_unpremultiplied().into_iter().filter(|p| p.a == 255).map(|p| [p.r, p.g, p.b]).collect();
assert!(colors.len() > 10, "{family}: {colors:?}");
}
}
/// A radial gradient whose circles both have the same negative radius at a variable font's
/// instance draws no circle, so a glyph painted with only that gradient is colour art that draws
/// nothing, not its outline. The radii are 10 at the default weight and -10 at 700.
#[test]
fn a_radial_gradient_with_no_circle_drawn_leaves_the_glyph_empty() {
use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Extend, VarColorLine, VarColorStop};
use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList};
use write_fonts::types::{F2Dot14, FWord, GlyphId16, UfWord};
let bytes = colr_font(false).unwrap();
let read = skrifa::FontRef::new(&bytes).unwrap();
let gid = |ch| GlyphId16::new(u16::try_from(read.charmap().map(ch).unwrap().to_u32()).unwrap());
let (one, fixed) = (F2Dot14::from_f32(1.0), 0xFFFF_FFFF);
let line =
VarColorLine::new(Extend::Pad, 2, vec![VarColorStop::new(F2Dot14::from_f32(0.0), 0, one, fixed), VarColorStop::new(one, 1, one, fixed)]);
let w = FWord::new;
let radial = ColrPaint::var_radial_gradient(line, w(290), w(330), UfWord::new(10), w(290), w(330), UfWord::new(10), 0);
let mut colr = Colr::new(0, None, None, 0);
colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), ColrPaint::glyph(radial, gid('O')))])).into();
// One 16-bit delta for each of x0, y0, r0, x1, y1 and r1, at full weight.
let deltas: Vec<u8> = [0i16, 0, -20, 0, 0, -20].iter().flat_map(|d| d.to_be_bytes()).collect();
let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), one, one)]);
let data = ItemVariationData::new(6, 1, vec![0], deltas);
colr.item_variation_store = Some(ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)])).into();
let font = crate::test_fonts::colr_variable_font_with("Colrtest No Circle", &colr).unwrap();
let font = skrifa::FontRef::new(&font).unwrap();
let bold = font.axes().location([("wght", 700.0)]);
let Some(ColourContent::Art(root)) = colour_glyph(&font, (&bold).into(), 1000.0, u32::from(gid(COLOUR_CHAR).to_u16()), FOREGROUND).0 else {
panic!("colour art")
};
assert!(children(&root).is_empty(), "{:?}", children(&root).iter().map(|c| c.kind_label()).collect::<Vec<_>>());
}
/// A COLRv1 color line through `stops` (offset, palette index) that extends by `extend`.
fn color_line(extend: write_fonts::tables::colr::Extend, stops: &[(f32, u16)]) -> write_fonts::tables::colr::ColorLine {
use write_fonts::tables::colr::{ColorLine, ColorStop};
+45 -8
View File
@@ -378,8 +378,8 @@ fn x_width() -> f64 {
fn composer_x_mixed_sizes_in_a_stepped_frame() {
// A frame as wide as the first line down to 40 pt and half as wide below. A 36 pt word makes
// the line it is on reach below the step, where the frame is narrow: the every-line composer
// breaks the paragraph with each line's measure where its height sets it, not as the
// single-line composer does, and every line keeps within its room.
// breaks the paragraph with each line's measure where its height sets it, and every line keeps
// within its room.
let w = x_width();
let set = |x: CharStyle, composer: Composer| {
let mut t = stepped(w, w * 0.5, 40.0, 300.0);
@@ -394,8 +394,10 @@ fn composer_x_mixed_sizes_in_a_stepped_frame() {
// The 36 pt line reaches below the step and is set at the narrow measure.
let tall = &l.lines[1];
assert!(tall.ascent > 30.0 && tall.baseline + tall.descent > 40.0 && tall.avail.1 < w * 0.5 + 0.01, "{:?}", l.lines);
// The single-line composer measures each line across its height too, and breaks this
// paragraph the same way.
let (greedy, _) = set(sans(36.0), Composer::SingleLine);
assert_ne!(every, greedy);
assert_eq!(every, greedy);
// Uniform sizes keep the breaks they had.
let (uniform, _) = set(sans(12.0), Composer::EveryLine);
assert_eq!(uniform, ["Destroy all creatures with toughness X or", "less. Then draw a card for each creature", "destroyed this way."]);
@@ -435,8 +437,10 @@ fn composer_x_mixed_sizes_around_text_wrap() {
across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0
});
assert_eq!(hits.count(), 0, "{:?}", l.lines);
// The single-line composer measures each line across its height too, and breaks this
// paragraph the same way.
let (greedy, _, _) = set(Composer::SingleLine);
assert_ne!(every, greedy);
assert_eq!(every, greedy);
}
#[test]
@@ -463,13 +467,42 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() {
assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]);
}
#[test]
fn single_line_composer_measures_a_line_across_its_tallest_glyph() {
// A frame wide enough for "Destroy all creatures X" down to 20 pt and as wide as "Destroy all"
// below, and a rectangle as wide with an object (6 pt wrap offset) beside that part. The first
// line starts with 12 pt words and takes in a 36 pt word, which sets it lower and reaches below
// 20 pt: the single-line composer measures its room across that height, every line that
// reaches the narrow part is set in it, and the first line keeps the 12 pt words that fit
// above it.
let w = natural_width("Destroy all creatures X", 12.0) + 30.0;
let narrow = natural_width("Destroy all", 12.0) + 2.0;
let runs = vec![run("Destroy all creatures ", sans(12.0)), run("X", sans(36.0)), run(" or less. Then draw a card.", sans(12.0))];
let object = Rect::new(narrow + 6.0, 26.0, w + 10.0, 300.0);
let mut beside = area(runs.clone(), Rect::new(0.0, 0.0, w, 300.0));
beside.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }];
let mut shaped = stepped(w, narrow, 20.0, 300.0);
shaped.runs = runs;
for (name, mut t) in [("stepped frame", shaped), ("text wrap", beside)] {
t.para.composer = Composer::SingleLine;
let l = layout(db(), &t);
assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{name}: {:?}", l.lines);
let low = l.lines.iter().filter(|li| li.baseline + li.descent > 20.01);
assert!(low.clone().all(|li| li.avail.1 <= narrow + 0.01), "{name}: {:?}", l.lines);
// The 36 pt word is set, on a line in the narrow part.
assert!(low.clone().any(|li| li.ascent > 30.0), "{name}: {:?}", l.lines);
assert_eq!(line_texts(&t, &l)[0], "Destroy all creatures", "{name}: {:?}", l.lines);
assert!(l.lines[0].baseline < 20.0 && l.lines[0].avail.1 > w - 0.01, "{name}: {:?}", l.lines);
}
}
#[test]
fn composer_x_mixed_sizes_overflow_a_shaped_frame() {
// A stepped frame too short for the paragraph: the every-line composer still breaks the lines
// that fit as one paragraph, with the lines past the frame's end measured as wide as the last
// line the frame holds. Here that is the narrow part, so the lines that fit break as in a frame
// tall enough for the whole paragraph, and not as the single-line composer breaks them. They
// are set in order, each once and within its room, and the frame overflows.
// tall enough for the whole paragraph. They are set in order, each once and within its room,
// and the frame overflows.
let w = x_width();
let set = |bottom: f64, composer: Composer| {
let mut t = stepped(w, w * 0.5, 40.0, bottom);
@@ -487,8 +520,10 @@ fn composer_x_mixed_sizes_overflow_a_shaped_frame() {
assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]);
let (whole, ..) = set(300.0, Composer::EveryLine);
assert_eq!(every, whole[..every.len()]);
// The single-line composer measures each line across its height too, and breaks the lines
// that fit the same way.
let (greedy, ..) = set(75.0, Composer::SingleLine);
assert_ne!(every, greedy);
assert_eq!(every, greedy);
// A frame with no room for a line sets none.
for composer in [Composer::EveryLine, Composer::SingleLine] {
let mut t = stepped(w, w * 0.5, 4.0, 8.0);
@@ -529,8 +564,10 @@ fn composer_x_mixed_sizes_overflow_around_text_wrap() {
assert_eq!(hits.count(), 0, "{:?}", l.lines);
// The lines that fit break as in `composer_x_mixed_sizes_around_text_wrap`'s taller frame.
assert_eq!(every, ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature"]);
// The single-line composer measures each line across its height too, and breaks the lines
// that fit the same way.
let (greedy, ..) = set(Composer::SingleLine);
assert_ne!(every, greedy);
assert_eq!(every, greedy);
}
#[test]
+4 -3
View File
@@ -415,8 +415,7 @@ fn stepped_frame(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject {
/// A paragraph of mixed type sizes that a stepped frame cannot hold goes on in the next frame. The
/// every-line composer breaks the lines the first frame holds as one paragraph, as in a frame tall
/// enough for all of it and not as the single-line composer breaks them, and the first frame
/// takes the text of those lines. The next frame sets the rest at its own width, without the
/// enough for all of it, and the first frame takes the text of those lines. The next frame sets the rest at its own width, without the
/// first-line indent, and no character is lost or repeated. The frames take the same shares as
/// when each is measured with the whole rest of the story.
#[test]
@@ -460,8 +459,10 @@ fn a_mixed_size_paragraph_continued_from_a_shaped_frame_is_composed_in_both() {
assert_eq!(every, whole[..every.len()], "indent {indent}");
if indent == 0.0 {
assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]);
// The single-line composer measures each line across its height too, and breaks the
// first frame's lines the same way.
let (greedy, ..) = shares(Composer::SingleLine, indent);
assert_eq!(greedy, ["Destroy all creatures with toughness X", "or less. Then draw a", "card for each", "creature destroyed"]);
assert_eq!(greedy, every);
}
// The next frame starts at its left edge, and its lines keep within it.
assert!((line_start(&lays[1], 0) - 200.0).abs() < 1e-6, "{}", line_start(&lays[1], 0));
+11 -7
View File
@@ -172,8 +172,8 @@ impl ShadowCaster {
/// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each
/// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids
/// drawn, but the faces of the others are not kept (their range is empty). A shape that does
/// not inflate is left out, as its solid is; the result says which, in order.
/// drawn, but the solids of the others are not built (their range is empty). A casting shape
/// that does not inflate is left out, as its solid is; the result says which, in order.
pub fn inflate(
&mut self,
shapes: &[(&PathData, FillRule)],
@@ -182,11 +182,15 @@ impl ShadowCaster {
options: Inflate,
view: View,
) -> Vec<Result<Range<usize>, String>> {
inflate_faces(shapes, bounds, options, view, Keep::Faces)
.enumerate()
.map(|(i, r)| {
let (_, faces, projection) = r?;
if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) }
let mut solids = inflate_faces(shapes, bounds, options, view, Keep::Faces);
(0..shapes.len())
.map(|i| {
if !casts.get(i).copied().unwrap_or(true) {
solids.pass();
return Ok(self.faces.len()..self.faces.len());
}
let (_, faces, projection) = solids.next().unwrap_or_else(|| Ok((vec![], Ok(vec![]), None)))?;
self.add(faces?, projection)
})
.collect()
}
+32
View File
@@ -185,3 +185,35 @@ fn every_effect_casts_a_shadow() {
// The surface turns about the square's left edge, reaching 100 to either side of it.
assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}");
}
/// An Inflate shape that casts nothing still places the eye with its puff, but its solid is not
/// built (nor, with no volume, its puff solved); the shapes that cast keep the very faces and
/// projection they have when every shape casts.
#[test]
fn an_inflate_shape_that_casts_nothing_builds_no_solid() {
let (a, b) = (square(100.0, 100.0, 100.0), square(150.0, 150.0, 30.0));
let shapes = [(&a, FillRule::NonZero), (&b, FillRule::NonZero)];
let bounds = Rect::new(100.0, 100.0, 200.0, 200.0);
let view = View { perspective: 60.0, rotation_y: 30.0, ..front() };
let built = || crate::solid::INFLATED.with(std::cell::Cell::get);
let puffed = || crate::solid::PUFFED.with(std::cell::Cell::get);
for o in [Inflate::default(), Inflate { volume: 0.0, depth: 20.0, ..Inflate::default() }] {
let mut all = ShadowCaster::new();
let every = all.inflate(&shapes, &[true, true], bounds, o, view);
let (start, solved) = (built(), puffed());
let mut some = ShadowCaster::new();
let kept = some.inflate(&shapes, &[false, true], bounds, o, view);
assert_eq!(built(), start + 1, "{o:?}: only the casting shape's solid is built");
if o.volume == 0.0 {
assert_eq!(puffed(), solved + 1, "{o:?}: only the casting shape's puff is solved");
}
let (Ok(first), Ok(second)) = (&every[0], &every[1]) else { panic!("{every:?}") };
assert!(!first.is_empty() && !second.is_empty());
assert_eq!(kept, vec![Ok(0..0), Ok(0..second.len())]);
assert_eq!(format!("{:?}", some.faces), format!("{:?}", &all.faces[second.clone()]));
assert_eq!(some.projection, all.projection);
let mut none = ShadowCaster::new();
assert_eq!(none.inflate(&shapes, &[false, false], bounds, o, view), vec![Ok(0..0), Ok(0..0)]);
assert!(none.is_empty() && none.projection.is_none());
}
}
+2 -2
View File
@@ -29,9 +29,9 @@ pub use build::{extrude, plane};
pub use face_map::{FaceMap, extrude_face_map, plane_face_map};
#[cfg(test)]
use inflate::GRID_WORK;
#[cfg(test)]
pub(crate) use inflate::INFLATED;
pub(crate) use inflate::inflate_faces;
#[cfg(test)]
pub(crate) use inflate::{INFLATED, PUFFED, SOLVE_FIRST};
pub use inflate::{inflate, inflate_each, inflate_together};
#[cfg(test)]
use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles};
+111 -18
View File
@@ -21,12 +21,35 @@ struct Puff {
height: f64,
}
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
/// The pieces each curve of `path` is sampled in for Inflate's solid ([`curve_pieces`]); an error
/// when its outline does not fit the solid's limits.
fn outline_pieces(path: &PathData, bounds: Rect, o: &Inflate) -> Result<u32, String> {
// The sides take one face for each edge of the outline in each slice; the grid's faces are
// counted as they are built.
curve_pieces(path, bounds.width().max(bounds.height()), if o.depth > 0.0 { slices(o) } else { 0 }, 0)
}
/// Whether `path` has few enough anchors that [`curve_pieces`] might take it: fewer than
/// [`MAX_SOLID_POINTS`] contours, and at most three times as many anchors (a contour of one anchor
/// adds one, and every other takes at most two anchors for each segment it adds). Counts the
/// contours' anchors without reading them.
pub(super) fn might_fit(path: &PathData) -> bool {
let mut anchors = 0usize;
for (contours, sp) in path.subpaths.iter().enumerate() {
anchors = anchors.saturating_add(sp.anchors.len());
if contours >= MAX_SOLID_POINTS || anchors > MAX_SOLID_POINTS.saturating_mul(3) {
return false;
}
}
true
}
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
#[cfg(test)]
PUFFED.with(|n| n.set(n.get() + 1));
let size = bounds.width().max(bounds.height());
let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?;
let most = outline_pieces(path, bounds, o)?;
let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?;
if rings.is_empty() {
return Ok(None);
@@ -66,7 +89,7 @@ pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options:
}
/// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early
/// builds none of the solids after.
/// builds none of the solids after (and with no volume, solves none of their puffs).
pub fn inflate_each(
shapes: &[(&PathData, FillRule)],
bounds: Rect,
@@ -77,28 +100,98 @@ pub fn inflate_each(
}
/// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as
/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff);
/// each solid is built as the iterator reaches it.
pub(crate) fn inflate_faces(
shapes: &[(&PathData, FillRule)],
bounds: Rect,
options: Inflate,
view: View,
keep: Keep,
) -> impl Iterator<Item = Result<Built, String>> + use<> {
/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff),
/// except with no volume: nothing puffs then, and each shape's membrane is solved when its solid
/// is taken. Each solid is built as the iterator reaches it.
pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, keep: Keep) -> Solids {
let o = options.sanitized();
let later = o.volume == 0.0;
#[cfg(test)]
let later = later && !SOLVE_FIRST.with(std::cell::Cell::get);
if later {
// A shape is copied to be solved later only when its anchors are few enough for the
// solid's point limit; a larger one is refused now, with the error its puff gives.
let shapes: Vec<Result<(PathData, FillRule), String>> = shapes
.iter()
.map(|(path, rule)| {
check_bounds(bounds)?;
if !might_fit(path) {
outline_pieces(path, bounds, &o)?;
}
Ok(((*path).clone(), *rule))
})
.collect();
return Solids { pending: Pending::Later(shapes.into_iter()), bounds, o, view, reach: 0.0, keep };
}
let puffs: Vec<Result<Option<Puff>, String>> =
shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect();
let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max);
puffs.into_iter().map(move |p| match p {
Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep),
Ok(None) => Ok((vec![], Ok(vec![]), None)),
Err(e) => Err(e),
})
Solids { pending: Pending::Solved(puffs.into_iter()), bounds, o, view, reach, keep }
}
/// The shapes' puffs still to take: every one solved when the shapes were laid out, or each
/// shape's path and fill rule (or the error its bounds or outline already gives), its puff solved
/// when its solid is taken.
enum Pending {
Solved(std::vec::IntoIter<Result<Option<Puff>, String>>),
Later(std::vec::IntoIter<Result<(PathData, FillRule), String>>),
}
/// The solids of [`inflate_faces`], one result for each shape in order, each built as it is taken.
pub(crate) struct Solids {
pending: Pending,
bounds: Rect,
o: Inflate,
view: View,
/// The highest puff of the shapes drawn together.
reach: f64,
keep: Keep,
}
impl Solids {
/// Pass over the next shape without building its solid, or solving its puff if that waits.
pub(crate) fn pass(&mut self) {
match &mut self.pending {
Pending::Solved(puffs) => {
puffs.next();
}
Pending::Later(shapes) => {
shapes.next();
}
}
}
}
impl Iterator for Solids {
type Item = Result<Built, String>;
fn next(&mut self) -> Option<Self::Item> {
let p = match &mut self.pending {
Pending::Solved(puffs) => puffs.next()?,
Pending::Later(shapes) => shapes.next()?.and_then(|(path, rule)| puff(&path, rule, self.bounds, &self.o)),
};
Some(match p {
Ok(Some(p)) => inflated(p, self.bounds, self.o, self.view, self.reach, self.keep),
Ok(None) => Ok((vec![], Ok(vec![]), None)),
Err(e) => Err(e),
})
}
fn size_hint(&self) -> (usize, Option<usize>) {
match &self.pending {
Pending::Solved(puffs) => puffs.size_hint(),
Pending::Later(shapes) => shapes.size_hint(),
}
}
}
#[cfg(test)]
thread_local! {
/// How many puffs this thread has solved, for the tests.
pub(crate) static PUFFED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Solve every shape's puff before the first solid is taken even with no volume, as with
/// volume, for the tests that compare the two.
pub(crate) static SOLVE_FIRST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// How many inflated solids this thread has built, for the tests.
pub(crate) static INFLATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// The grid work of the last solid this thread inflated in full, for the tests.
+99
View File
@@ -1102,6 +1102,105 @@ fn a_flat_twisted_inflate_matches_the_extrusion() {
}
}
/// With no volume nothing puffs, so each shape's membrane is solved only when its solid is taken:
/// a caller that stops early (past the object's face limit) solves none after. With volume, the
/// eye waits on the highest puff, so every membrane is solved first.
#[test]
fn a_flat_inflate_solves_each_puff_as_its_solid_is_taken() {
let shapes: Vec<PathData> = (0..3).map(|i| shape(&square(120.0 * i as f64, 0.0, 100.0)).0).collect();
let shapes: Vec<(&PathData, FillRule)> = shapes.iter().map(|p| (p, FillRule::NonZero)).collect();
let b = Rect::new(0.0, 0.0, 340.0, 100.0);
let puffed = || PUFFED.with(std::cell::Cell::get);
let start = puffed();
let mut each = inflate_each(&shapes, b, Inflate { depth: 20.0, volume: 0.0, ..Inflate::default() }, front());
assert_eq!(puffed(), start, "no puff is solved before the first solid is taken");
assert!(each.next().is_some_and(|f| f.is_ok_and(|f| !f.is_empty())));
assert_eq!(puffed(), start + 1, "the later puffs wait until their solids are taken");
drop(each);
assert_eq!(puffed(), start + 1);
let start = puffed();
let each = inflate_each(&shapes, b, Inflate { depth: 20.0, ..Inflate::default() }, front());
assert_eq!(puffed(), start + 3, "with volume, every puff places the eye");
assert_eq!(each.count(), 3);
}
/// With no volume, solving each puff as its solid is taken gives the very solids that solving
/// every puff first gives: their facets, their faces before projection and their projection, bit
/// for bit, with an empty shape and one that cannot be inflated among them.
#[test]
fn a_flat_inflate_solved_as_taken_matches_one_solved_first() {
let mut frame = square(-10.0, -10.0, 260.0);
frame.extend(square(-8.0, -8.0, 256.0));
let mut circle = BezPath::new();
circle.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((180.0, 60.0), 50.0), 0.01));
let mut bad = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true);
bad.anchors[1].h_out = Point::new(f64::NAN, 0.0);
let paths = [shape(&square(0.0, 0.0, 100.0)).0, shape(&frame).0, PathData::new(vec![]), PathData::new(vec![bad]), shape(&circle).0];
let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect();
let b = Rect::new(-10.0, -10.0, 250.0, 250.0);
let turned = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
let options = [
Inflate { volume: 0.0, ..Inflate::default() },
Inflate { volume: 0.0, depth: 40.0, twist: 90.0, taper: 0.5, ..Inflate::default() },
Inflate { volume: 0.0, depth: 10.0, both_sides: true, cap: false, ..Inflate::default() },
];
for view in [front(), turned, View { perspective: 60.0, ..turned }, View { perspective: 100.0, rotation_y: 150.0, ..turned }] {
for o in options {
for (keep, facets, mesh) in [(build::Keep::Facets, true, false), (build::Keep::Faces, false, true), (build::Keep::Both, true, true)] {
let solids = |first: bool| {
SOLVE_FIRST.with(|s| s.set(first));
let out: Vec<Result<build::Built, String>> = inflate_faces(&shapes, b, o, view, keep).collect();
SOLVE_FIRST.with(|s| s.set(false));
out
};
let (taken, first) = (solids(false), solids(true));
assert_eq!(taken.len(), 5);
// The solid keeps its facets, its faces before projection, or both, as asked.
let built = |r: &Result<build::Built, String>| {
r.as_ref().is_ok_and(|(f, m, _)| (!facets || !f.is_empty()) && (!mesh || m.as_ref().is_ok_and(|m| !m.is_empty())))
};
assert!(built(&taken[0]) && built(&taken[1]) && built(&taken[4]), "{o:?} {view:?} facets {facets} mesh {mesh}");
assert!(
taken[2].as_ref().is_ok_and(|(f, m, p)| f.is_empty() && m.as_ref().is_ok_and(Vec::is_empty) && p.is_none()) && taken[3].is_err()
);
assert_eq!(format!("{taken:?}"), format!("{first:?}"), "{o:?} {view:?} facets {facets} mesh {mesh}");
}
}
}
}
/// With no volume, a shape with too many anchors or contours for the solid is refused before it
/// is kept for later, with the error that solving every puff first gives it.
#[test]
fn a_flat_inflate_refuses_a_shape_past_the_point_limit_as_one_solved_first() {
use super::inflate::might_fit;
let many = MAX_SOLID_POINTS * 3 + 10;
let anchors: Vec<Point> = (0..many).map(|i| Point::new((i % 200) as f64, (i / 200) as f64 / 10.0)).collect();
let long = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&anchors, true)]);
let mut nan = anchors.clone();
if let Some(last) = nan.last_mut() {
*last = Point::new(f64::NAN, 0.0);
}
let invalid = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&nan, true)]);
let dots = PathData::new((0..=MAX_SOLID_POINTS).map(|i| vectorcraft_geom::SubPath::polyline(&[Point::new(i as f64, 0.0)], false)).collect());
let square = shape(&square(0.0, 0.0, 100.0)).0;
assert!(might_fit(&square) && !might_fit(&long) && !might_fit(&invalid) && !might_fit(&dots));
let paths = [square, long, invalid, dots];
let shapes: Vec<(&PathData, FillRule)> = paths.iter().map(|p| (p, FillRule::EvenOdd)).collect();
let b = Rect::new(-10.0, -10.0, 250.0, 250.0);
let o = Inflate { volume: 0.0, ..Inflate::default() };
let solids = |first: bool| {
SOLVE_FIRST.with(|s| s.set(first));
let out: Vec<Result<build::Built, String>> = inflate_faces(&shapes, b, o, front(), build::Keep::Facets).collect();
SOLVE_FIRST.with(|s| s.set(false));
out
};
let (taken, first) = (solids(false), solids(true));
assert!(taken[0].as_ref().is_ok_and(|(f, ..)| !f.is_empty()));
assert!(taken[1..].iter().all(Result::is_err), "{:?}", taken[1..].iter().map(|r| r.as_ref().err()).collect::<Vec<_>>());
assert_eq!(format!("{taken:?}"), format!("{first:?}"));
}
/// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole
/// (the middle is filled, as the art is drawn); one running the other way does, and so does
/// either under the even-odd rule.
+3 -3
View File
@@ -41,9 +41,9 @@ pub use live_paint::{
};
use live_paint::{check_live_paint_options, lp_color, lp_rgb_color};
pub use lookup::{
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces,
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
sources, visible_edge_near, visible_face_at,
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, LP_JOIN_TOL, SOURCES_NAME, builder_members, connected_edges, drawn_outlines,
edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap,
path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at,
};
pub use map::{
BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs,
+30 -3
View File
@@ -1,5 +1,7 @@
use super::cache::RegionCache;
use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at};
use super::lookup::{
connected_edges, flood_faces, is_live_paint, live_paint_at, live_paint_at_where, lp_stroke_paint, visible_edge_near, visible_face_at,
};
use super::map::{HIGHLIGHT_RED, sorted_roots};
use super::{
Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent,
@@ -540,7 +542,11 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0);
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws
/// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection;
/// with Shift held at the press, each of them leaves the selection if it is selected and joins it
/// otherwise. A click and a marquee change the selection on release, so Escape between the press
/// otherwise. A double-click on a face selects it and the faces of its group that are not parted
/// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end
/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them
/// leaves the selection if it is selected and joins it otherwise.
/// A click and a marquee change the selection on release, so Escape between the press
/// and the release (which removes the marquee) leaves the selection as it was. The tool does not
/// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer
/// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options
@@ -627,6 +633,18 @@ impl LivePaintSelectionTool {
};
(hit, cursor)
}
/// What a double-click at `p` selects, Alt held or not: on the face a click there picks
/// ([`LivePaintSelectionTool::picks_edge`]), that face and the faces of its group a flood from it
/// reaches across edges that are hidden or draw no stroke ([`flood_faces`]); on the edge a click
/// picks, that edge and the edges of its group joined to it end to end through edges that draw
/// with the stroke paint it draws with ([`connected_edges`], [`lp_stroke_paint`]). Empty where a click selects no face or edge.
fn reach_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec<NodeId> {
let Some(edge) = self.picks_edge(alt) else { return vec![] };
let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] };
let Some(group) = cx.doc.node(gid) else { return vec![] };
if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) }
}
}
/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and
@@ -748,7 +766,16 @@ impl Tool for LivePaintSelectionTool {
(false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))],
}
}
_ => vec![],
PointerKind::DoubleClick => {
// The two clicks before it already selected the part under the pointer (or, with
// Shift, toggled it twice); the double-click selects the part with what it reaches.
let ids = self.reach_at(cx, p, ev.mods.alt);
match (ids.is_empty(), ev.mods.shift) {
(true, _) => vec![],
(false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))],
(false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))],
}
}
}
}
+343
View File
@@ -104,6 +104,349 @@ fn nearest_edge<'a>(edges: impl IntoIterator<Item = &'a Arc<Node>>, p: Point, to
best.map(|b| b.1)
}
/// The greatest distance (pt) between the ends of two Live Paint edges that the Live Paint tools take
/// as one point. Every edge comes from one planar map, so their ends meet this closely.
pub const LP_JOIN_TOL: f64 = 1e-4;
/// The greatest distance (pt) between the middle of a Live Paint face's outline segment and an edge
/// it runs along. The planar map refits outlines to [`vectorcraft_pathops::DEFAULT_PRECISION`],
/// keeping each refit segment's ends, and with Gap Detection on, the faces and the edges come from
/// two maps, so an outline can lie that far off its edges between its ends.
const LP_FACE_EDGE_TOL: f64 = vectorcraft_pathops::DEFAULT_PRECISION;
/// The cell [`LP_JOIN_TOL`] wide that holds `p`, for finding the points near it; two points within
/// [`LP_JOIN_TOL`] of each other lie in the same or neighboring cells. The cast saturates, so a
/// coordinate too large or not finite does not overflow.
fn lp_cell(p: Point) -> (i64, i64) {
((p.x / LP_JOIN_TOL).round() as i64, (p.y / LP_JOIN_TOL).round() as i64)
}
/// The cell that holds `p` ([`lp_cell`]) and the eight cells around it.
fn lp_cells_around(p: Point) -> impl Iterator<Item = (i64, i64)> {
let (x, y) = lp_cell(p);
(-1..=1).flat_map(move |dx: i64| (-1..=1).map(move |dy: i64| (x.saturating_add(dx), y.saturating_add(dy))))
}
/// The stroke paint Live Paint edge `edge` draws with: the paint of its topmost stroke that is shown
/// with a paint, a weight above 0 and an opacity above 0, or [`Paint::None`] when the edge is fully
/// transparent or has no such stroke.
pub(super) fn lp_stroke_paint(edge: &Node) -> Paint {
if edge.opacity <= 0.0 {
return Paint::None;
}
edge.appearance
.items
.iter()
.rev()
.find_map(|i| match i {
vectorcraft_doc::appearance::AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => {
Some(s.paint.clone())
}
_ => None,
})
.unwrap_or(Paint::None)
}
/// Whether Live Paint edge `edge` draws no stroke ([`lp_stroke_paint`] is [`Paint::None`]).
fn unstroked(edge: &Node) -> bool {
lp_stroke_paint(edge).is_none()
}
/// The segments of `pd`.
fn lp_segments(pd: &PathData) -> Vec<kurbo::CubicBez> {
pd.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect()
}
/// The faces of Live Paint group `group` that a flood from face `start` reaches, `start` first:
/// `start`, and each face that shares with a face already reached an edge that is hidden or draws
/// no stroke. Hidden faces are not reached and do not pass the flood on. A face runs along the parts
/// of the edge segments its outline segments lie on ([`lp_lies_on`]) or overlap ([`lp_overlap`]).
/// Two faces share an edge where those parts overlap along more than [`LP_FACE_EDGE_TOL`], or, where
/// both outlines lie within [`LP_JOIN_TOL`] of the edge, along more than half the shorter part. So
/// two faces that a Gap Detection gap parts on the same side of an edge, whose parts only touch, do
/// not share it, and two faces whose shared edge is shorter than [`LP_FACE_EDGE_TOL`] do. Empty when
/// `start` is not a face of `group` that is not hidden.
pub fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
use kurbo::{ParamCurve, ParamCurveArclen};
use std::collections::{HashMap, HashSet};
let shown: Vec<&Arc<Node>> = faces(group).into_iter().filter(|f| f.visible).collect();
if !shown.iter().any(|f| f.id == start) {
return vec![];
}
// Each edge segment, and whether a flood crosses it.
let edge_segs: Vec<(kurbo::CubicBez, bool)> = edges(group)
.into_iter()
.flat_map(|e| {
let cross = !e.visible || unstroked(e);
e.path_data().map(lp_segments).unwrap_or_default().into_iter().map(move |s| (s, cross))
})
.collect();
// The faces along each edge segment a flood crosses, each with the part of the segment it runs
// along (from and to, in the segment's own parameter). A face outline segment usually lies on
// one edge segment, stroked or not ([`lp_lies_on`]); one that does not (a piece of an edge
// segment where a Gap Detection line meets it, the line itself, or an outline segment that spans
// several edge segments) is measured against the edge segments a flood crosses that its bounds
// meet ([`lp_overlap`]). Each part also records whether its outline segment lies within
// [`LP_JOIN_TOL`] of it ([`lp_close`]).
let all: Vec<(kurbo::CubicBez, bool)> = edge_segs.iter().map(|&(s, _)| (s, true)).collect();
let all_grid = LpEdgeGrid::new(&all);
let mut along: HashMap<usize, Vec<(NodeId, f64, f64, bool)>> = HashMap::new();
let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![];
for f in &shown {
for piece in f.path_data().map(lp_segments).unwrap_or_default() {
let m = piece.eval(0.5);
let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL);
// The edge segment the outline segment lies on whose middle is nearest its own.
let on = all_grid
.near_box(bounds)
.into_iter()
.filter_map(|k| edge_segs.get(k).and_then(|(s, cross)| lp_lies_on(s, &piece).map(|part| (k, s, *cross, part))))
.min_by(|x, y| (x.1.eval(0.5) - m).hypot().total_cmp(&(y.1.eval(0.5) - m).hypot()));
match on {
Some((k, s, cross, (from, to))) => {
if cross {
along.entry(k).or_default().push((f.id, from, to, lp_close(s, from, to, &piece)));
}
}
None => pieces.push((piece, f.id)),
}
}
}
if !pieces.is_empty() {
let grid = LpEdgeGrid::new(&edge_segs);
for (piece, id) in pieces {
let bounds = kurbo::ParamCurveExtrema::bounding_box(&piece).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL);
for k in grid.near_box(bounds) {
if let Some((s, true)) = edge_segs.get(k)
&& let Some((a, c)) = lp_overlap(s, &piece)
{
along.entry(k).or_default().push((id, a, c, lp_close(s, a, c, &piece)));
}
}
}
}
let mut next: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
// Sorted by where each part starts, a part is compared only with the parts after it that start
// before it ends. Two parts that overlap along [`LP_FACE_EDGE_TOL`] or less join only when both
// lie within [`LP_JOIN_TOL`] of the edge and the overlap is more than half the shorter part: an
// edge that short is shared, while an outline that only crosses it lies off it.
for (k, on) in &mut along {
let Some((s, _)) = edge_segs.get(*k) else { continue };
on.sort_by(|x, y| x.1.total_cmp(&y.1));
for (i, &(a, a0, a1, a_close)) in on.iter().enumerate() {
for &(c, c0, c1, c_close) in on.iter().skip(i + 1).take_while(|x| x.1 < a1) {
let (from, to) = (a0.max(c0), a1.min(c1));
let len = |x: f64, y: f64| s.subsegment(x..y).arclen(1e-6);
let shared = if a != c && from < to { len(from, to) } else { 0.0 };
let short_shared = a_close && c_close && shared > 0.5 * len(a0, a1).min(len(c0, c1));
if shared > 0.0 && (shared > LP_FACE_EDGE_TOL || short_shared) {
next.entry(a).or_default().push(c);
next.entry(c).or_default().push(a);
}
}
}
}
let mut reached = vec![start];
let mut seen = HashSet::from([start]);
let mut i = 0;
while let Some(&f) = reached.get(i) {
i += 1;
for &n in next.get(&f).map(Vec::as_slice).unwrap_or_default() {
if seen.insert(n) {
reached.push(n);
}
}
}
reached
}
/// The part of edge segment `s` that outline segment `piece` runs along, as (from, to) in `s`'s
/// own parameter, or None when they share no length. The part runs between the ends of `s` that lie
/// within [`LP_FACE_EDGE_TOL`] of `piece` and the ends of `piece` that lie within [`LP_JOIN_TOL`] of
/// `s` (the map keeps the ends of an outline segment it refits), and its middle lies within
/// [`LP_FACE_EDGE_TOL`] of `piece`, where the two run along each other ([`lp_runs_along`]). When no
/// end of `piece` lies on `s`, `piece` must lie within [`LP_JOIN_TOL`] of the part ([`lp_close`]). So
/// two segments that only cross or touch, or that only lie near each other, share nothing.
fn lp_overlap(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> {
use kurbo::{ParamCurve, ParamCurveNearest};
let on_piece = |p: Point| piece.nearest(p, 1e-9).distance_sq.sqrt() <= LP_FACE_EDGE_TOL;
let mut ts: Vec<f64> = [(s.p0, 0.0), (s.p3, 1.0)].into_iter().filter(|(p, _)| on_piece(*p)).map(|(_, t)| t).collect();
let mut piece_end_on = false;
for p in [piece.p0, piece.p3] {
let n = s.nearest(p, 1e-9);
if n.distance_sq.sqrt() <= LP_JOIN_TOL {
ts.push(n.t);
piece_end_on = true;
}
}
let from = ts.iter().copied().fold(f64::INFINITY, f64::min);
let to = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if from >= to {
return None;
}
let mid = (from + to) / 2.0;
let n = piece.nearest(s.eval(mid), 1e-9);
let along = if piece_end_on { lp_runs_along(s, from, to, piece, mid, n.t) } else { lp_close(s, from, to, piece) };
(n.distance_sq.sqrt() <= LP_FACE_EDGE_TOL && along).then_some((from, to))
}
/// The part of edge segment `s` that outline segment `piece` lies on as a whole, as (from, to) in
/// `s`'s own parameter: both ends of `piece` lie within [`LP_JOIN_TOL`] of `s`, its middle lies
/// within [`LP_FACE_EDGE_TOL`] of `s`, and the two run along each other ([`lp_runs_along`]).
fn lp_lies_on(s: &kurbo::CubicBez, piece: &kurbo::CubicBez) -> Option<(f64, f64)> {
use kurbo::{ParamCurve, ParamCurveNearest};
let (a, c, m) = (s.nearest(piece.p0, 1e-9), s.nearest(piece.p3, 1e-9), s.nearest(piece.eval(0.5), 1e-9));
let (from, to) = (a.t.min(c.t), a.t.max(c.t));
let near = a.distance_sq.sqrt() <= LP_JOIN_TOL && c.distance_sq.sqrt() <= LP_JOIN_TOL && m.distance_sq.sqrt() <= LP_FACE_EDGE_TOL;
(near && lp_runs_along(s, from, to, piece, m.t, 0.5)).then_some((from, to))
}
/// Whether outline segment `piece` runs along the part of edge segment `s` from `from` to `to`
/// rather than crossing it: it lies within [`LP_JOIN_TOL`] of that part ([`lp_close`]), or the two
/// run the same way or opposite ways at `ts` on `s` and `tp` on `piece` ([`lp_parallel`]).
fn lp_runs_along(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez, ts: f64, tp: f64) -> bool {
lp_close(s, from, to, piece) || lp_parallel(s, ts, piece, tp)
}
/// Whether outline segment `piece` lies within [`LP_JOIN_TOL`] of edge segment `s` at the ends and
/// the middle of the part of `s` from `from` to `to`.
fn lp_close(s: &kurbo::CubicBez, from: f64, to: f64, piece: &kurbo::CubicBez) -> bool {
use kurbo::{ParamCurve, ParamCurveNearest};
[from, (from + to) / 2.0, to].into_iter().all(|t| piece.nearest(s.eval(t), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL)
}
/// Whether segment `s` at parameter `ts` and segment `piece` at parameter `tp` run the same way or
/// opposite ways: the sine of the angle between their directions there is at most 0.2. False where
/// either has no direction.
fn lp_parallel(s: &kurbo::CubicBez, ts: f64, piece: &kurbo::CubicBez, tp: f64) -> bool {
use kurbo::{ParamCurve, ParamCurveDeriv};
let (u, v) = (s.deriv().eval(ts).to_vec2(), piece.deriv().eval(tp).to_vec2());
let n = u.hypot() * v.hypot();
n > 0.0 && u.cross(v).abs() <= 0.2 * n
}
/// Most grid cells one edge segment is listed in by [`LpEdgeGrid`]; a longer one is tried for
/// every point.
const LP_GRID_SPAN: i64 = 1024;
/// The farthest cell from the grid's origin, on each axis, that [`LpEdgeGrid`] numbers a point in.
const LP_GRID_LIMIT: f64 = 1_048_576.0;
/// The edge segments a flood crosses, by the cells of a square grid (about one cell per segment)
/// that their bounds, widened by [`LP_FACE_EDGE_TOL`], cover.
struct LpEdgeGrid {
origin: Point,
cell: f64,
/// Segment indices by cell.
cells: std::collections::HashMap<(i64, i64), Vec<usize>>,
/// Segments whose bounds cover more than [`LP_GRID_SPAN`] cells, near every point.
wide: Vec<usize>,
}
impl LpEdgeGrid {
/// The grid of the segments of `edges` that a flood crosses (those marked true).
fn new(edges: &[(kurbo::CubicBez, bool)]) -> Self {
let boxes: Vec<(usize, Rect)> = edges
.iter()
.enumerate()
.filter(|(_, e)| e.1)
.map(|(k, (s, _))| (k, kurbo::ParamCurveExtrema::bounding_box(s).inflate(LP_FACE_EDGE_TOL, LP_FACE_EDGE_TOL)))
.collect();
let all = boxes.iter().map(|b| b.1).reduce(|a, c| a.union(c)).unwrap_or_default();
let side = (boxes.len() as f64).sqrt().ceil().max(1.0);
let cell = all.width().max(all.height()) / side;
let cell = if cell.is_finite() && cell > LP_FACE_EDGE_TOL { cell } else { LP_FACE_EDGE_TOL };
let mut grid = Self { origin: all.origin(), cell, cells: std::collections::HashMap::new(), wide: vec![] };
for (k, b) in boxes {
let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1)));
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN {
grid.wide.push(k);
continue;
}
for x in x0..=x1 {
for y in y0..=y1 {
grid.cells.entry((x, y)).or_default().push(k);
}
}
}
grid
}
/// The cell `p` is in, counted from the origin (at most [`LP_GRID_LIMIT`] cells away on each
/// axis).
fn cell_of(&self, p: Point) -> (i64, i64) {
let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-LP_GRID_LIMIT, LP_GRID_LIMIT) as i64;
(at(p.x, self.origin.x), at(p.y, self.origin.y))
}
/// The segments whose widened bounds may meet `r`, each once, in ascending order. When `r`
/// covers more than [`LP_GRID_SPAN`] cells, every listed segment is returned.
fn near_box(&self, r: Rect) -> Vec<usize> {
let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1)));
let mut out: Vec<usize> = self.wide.clone();
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > LP_GRID_SPAN {
out.extend(self.cells.values().flatten());
} else {
for x in x0..=x1 {
for y in y0..=y1 {
out.extend(self.cells.get(&(x, y)).into_iter().flatten());
}
}
}
out.sort_unstable();
out.dedup();
out
}
}
/// The edges of Live Paint group `group` that are not hidden and are joined to edge `start` through
/// edges that draw with stroke paint `paint` ([`lp_stroke_paint`]), `start` first: `start`, and each
/// edge that draws with that stroke paint and has an end (the first or last point of one of its subpaths) within [`LP_JOIN_TOL`] of an
/// end of an edge already reached.
/// Empty when `start` is not an edge of `group` that is not hidden.
pub fn connected_edges(group: &Node, start: NodeId, paint: &Paint) -> Vec<NodeId> {
use std::collections::{HashMap, HashSet};
let es: Vec<&Arc<Node>> = edges(group).into_iter().filter(|e| e.visible && (e.id == start || lp_stroke_paint(e) == *paint)).collect();
// The first and last points of each subpath of each edge.
let ends: Vec<Vec<Point>> = es
.iter()
.map(|e| {
let Some(pd) = e.path_data() else { return vec![] };
pd.subpaths
.iter()
.filter_map(|sp| {
let n = sp.segment_count();
(n > 0).then(|| [sp.segment(0).p0, sp.segment(n - 1).p3])
})
.flatten()
.collect()
})
.collect();
let mut at: HashMap<(i64, i64), Vec<(Point, usize)>> = HashMap::new();
for (i, pts) in ends.iter().enumerate() {
for &p in pts {
at.entry(lp_cell(p)).or_default().push((p, i));
}
}
let Some(first) = es.iter().position(|e| e.id == start) else { return vec![] };
let mut reached = vec![first];
let mut seen = HashSet::from([first]);
let mut k = 0;
while let Some(&i) = reached.get(k) {
k += 1;
for &p in ends.get(i).map(Vec::as_slice).unwrap_or_default() {
for c in lp_cells_around(p) {
for &(q, j) in at.get(&c).map(Vec::as_slice).unwrap_or_default() {
if (q - p).hypot() <= LP_JOIN_TOL && seen.insert(j) {
reached.push(j);
}
}
}
}
}
reached.into_iter().filter_map(|i| es.get(i).map(|e| e.id)).collect()
}
/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order).
pub fn live_paint_at(doc: &Document, p: Point) -> Option<NodeId> {
live_paint_at_where(doc, p, |id| doc.is_visible(id))
+577
View File
@@ -1733,6 +1733,583 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights()
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
/// A Live Paint group of three faces in a row, A (0..50), B (50..100) and C (100..150), 50 pt tall,
/// with these edges: AB between A and B, with no stroke; BC between B and C, stroked red; the tops
/// of A and B stroked black; the top of C stroked red; and one bottom edge under all three,
/// stroked black. Returns the group, the faces [A, B, C] and the edges [AB, BC, top A, top B,
/// top C, bottom].
fn live_strip(d: &mut Document) -> (NodeId, [NodeId; 3], [NodeId; 6]) {
let l = d.layers[0].id;
let black = Paint::solid(vectorcraft_color::Color::BLACK);
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
let mut kids = vec![empty_sources(d)];
let mut fs = [NodeId(0); 3];
for (k, x) in [0.0, 50.0, 100.0].into_iter().enumerate() {
fs[k] = d.alloc_id();
let mut face = Node::path(fs[k], shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default());
face.name = Some(FACE_NAME.into());
kids.push(Arc::new(face));
}
let pt = Point::new;
let lines = [
(pt(50.0, 0.0), pt(50.0, 50.0), None),
(pt(100.0, 0.0), pt(100.0, 50.0), Some(red.clone())),
(pt(0.0, 0.0), pt(50.0, 0.0), Some(black.clone())),
(pt(50.0, 0.0), pt(100.0, 0.0), Some(black.clone())),
(pt(100.0, 0.0), pt(150.0, 0.0), Some(red)),
(pt(0.0, 50.0), pt(150.0, 50.0), Some(black)),
];
let mut es = [NodeId(0); 6];
for (k, (from, to, stroke)) in lines.into_iter().enumerate() {
es[k] = d.alloc_id();
let mut a = Appearance::default();
if let Some(s) = stroke {
a.set_stroke(s);
}
let mut edge = Node::path(es[k], shapes::line(from, to), a);
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
}
let gid = d.alloc_id();
let mut g = Node::group(gid, kids);
g.name = Some(LIVE_PAINT_NAME.into());
let at = d.layers[0].children().map_or(0, |c| c.len());
d.insert(Some(l), at, g).unwrap();
(gid, fs, es)
}
#[test]
fn live_paint_flood_crosses_unstroked_edges_and_stops_at_stroked_ones() {
let mut d = Document::new(500.0, 500.0);
let (g, [a, b, c], [ab, bc, ..]) = live_strip(&mut d);
let group = |d: &Document| d.node(g).unwrap().clone();
assert_eq!(flood_faces(&group(&d), a), vec![a, b]);
assert_eq!(flood_faces(&group(&d), b), vec![b, a]);
assert_eq!(flood_faces(&group(&d), c), vec![c]);
// A hidden edge does not part the faces either; a hidden face is not reached and does not start
// a flood; an id that is not a face of the group reaches nothing.
let mut hidden = d.clone();
hidden.node_mut(bc).unwrap().visible = false;
assert_eq!(flood_faces(&group(&hidden), a), vec![a, b, c]);
hidden.node_mut(b).unwrap().visible = false;
assert_eq!(flood_faces(&group(&hidden), a), vec![a]);
assert!(flood_faces(&group(&hidden), b).is_empty());
assert!(flood_faces(&group(&d), ab).is_empty());
// An edge whose stroke is hidden, 0 pt wide or fully transparent, or that is itself fully
// transparent, draws no stroke, so the flood crosses it.
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
let base = d.node(bc).unwrap().appearance.stroke().unwrap().clone();
let changes: [fn(&mut StrokeLayer); 3] = [|s| s.visible = false, |s| s.width = 0.0, |s| s.opacity = 0.0];
for change in changes {
let mut e = d.clone();
let mut s = base.clone();
change(&mut s);
e.node_mut(bc).unwrap().appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
e.node_mut(bc).unwrap().appearance.items.push(AppearanceItem::Stroke(s));
assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]);
}
let mut e = d.clone();
e.node_mut(bc).unwrap().opacity = 0.0;
assert_eq!(flood_faces(&group(&e), a), vec![a, b, c]);
}
/// Faces P (0..50) and Q (50..100) above one edge from (0, 0) to (100, 0) and face R (0..100) below
/// it, with no edge between P and Q (as where a Gap Detection line parts them): P and Q each run
/// along part of the edge, and R along all of it.
#[test]
fn live_paint_flood_measures_faces_along_part_of_an_edge() {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut kids = vec![empty_sources(&mut d)];
let mut ids = [NodeId(0); 3];
for (k, r) in [Rect::new(0.0, -50.0, 50.0, 0.0), Rect::new(50.0, -50.0, 100.0, 0.0), Rect::new(0.0, 0.0, 100.0, 50.0)].into_iter().enumerate() {
ids[k] = d.alloc_id();
let mut face = Node::path(ids[k], shapes::rectangle(r), Appearance::default());
face.name = Some(FACE_NAME.into());
kids.push(Arc::new(face));
}
let [p, q, r] = ids;
let mut edge = Node::path(d.alloc_id(), shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), Appearance::default());
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
let gid = d.alloc_id();
let mut g = Node::group(gid, kids);
g.name = Some(LIVE_PAINT_NAME.into());
d.insert(Some(l), 0, g).unwrap();
let group = d.node(gid).unwrap().clone();
// P reaches R across the edge, and Q through R; P and Q only touch at (50, 0).
assert_eq!(flood_faces(&group, p), vec![p, r, q]);
let mut without_r = d.clone();
without_r.node_mut(r).unwrap().visible = false;
assert_eq!(flood_faces(without_r.node(gid).unwrap(), p), vec![p]);
}
/// Face P (0..100) above unstroked edges along y = 0 split at `splits`, whose outline runs along
/// all of them with one segment, and below them one face between each two splits, the faces parted
/// from each other by stroked edges. A flood from any face below reaches P and, through it, the
/// others; this holds when the outline's middle is no edge's middle (30), is the end shared by two
/// edges (50), and is the middle of an edge shorter than the outline (25 and 75).
#[test]
fn live_paint_flood_crosses_an_outline_segment_that_spans_several_edges() {
for splits in [vec![30.0], vec![50.0], vec![25.0, 75.0]] {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut kids = vec![empty_sources(&mut d)];
let xs: Vec<f64> = std::iter::once(0.0).chain(splits.iter().copied()).chain(std::iter::once(100.0)).collect();
let add_face = |d: &mut Document, kids: &mut Vec<Arc<Node>>, r: Rect| {
let id = d.alloc_id();
let mut face = Node::path(id, shapes::rectangle(r), Appearance::default());
face.name = Some(FACE_NAME.into());
kids.push(Arc::new(face));
id
};
let p = add_face(&mut d, &mut kids, Rect::new(0.0, -50.0, 100.0, 0.0));
let below: Vec<NodeId> = xs.windows(2).map(|w| add_face(&mut d, &mut kids, Rect::new(w[0], 0.0, w[1], 50.0))).collect();
let pt = Point::new;
let lines = xs
.windows(2)
.map(|w| (pt(w[0], 0.0), pt(w[1], 0.0), false))
.chain(splits.iter().map(|&x| (pt(x, 0.0), pt(x, 50.0), true)))
.collect::<Vec<_>>();
for (from, to, stroked) in lines {
let mut a = Appearance::default();
if stroked {
a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
}
let mut edge = Node::path(d.alloc_id(), shapes::line(from, to), a);
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
}
let gid = d.alloc_id();
let mut g = Node::group(gid, kids);
g.name = Some(LIVE_PAINT_NAME.into());
d.insert(Some(l), 0, g).unwrap();
let group = d.node(gid).unwrap().clone();
let mut all = below.clone();
all.push(p);
all.sort();
for &f in &below {
let mut got = flood_faces(&group, f);
assert_eq!(got.first(), Some(&f), "{splits:?}");
got.sort();
assert_eq!(got, all, "{splits:?} from {f:?}");
}
}
}
/// A Live Paint group built from `paths` as `livePaint.make` builds one, with Gap Detection closing
/// gaps up to `gap` pt when given, and with no stroke on any edge. Returns the group and its faces.
fn live_from_paths(paths: Vec<PathData>, gap: Option<f64>) -> (Node, Vec<NodeId>) {
let mut d = Document::new(500.0, 500.0);
let shapes: Vec<po::Shape> = paths.into_iter().enumerate().map(|(k, p)| po::Shape::new(p, FillRule::NonZero, k as u64)).collect();
let (regions, lines) = match gap {
Some(g) => {
let (r, e, _) = po::live_paint_with_gaps(&shapes, g);
(r, e)
}
None => po::live_paint(&shapes),
};
let mut kids = vec![empty_sources(&mut d)];
let mut ids = vec![];
for r in regions {
let id = d.alloc_id();
let mut face = Node::path(id, r.path, Appearance::default());
face.name = Some(FACE_NAME.into());
kids.push(Arc::new(face));
ids.push(id);
}
for e in lines {
let mut edge = Node::path(d.alloc_id(), e.path, Appearance::default());
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
}
let mut g = Node::group(d.alloc_id(), kids);
g.name = Some(LIVE_PAINT_NAME.into());
(g, ids)
}
/// Live Paint groups built from curves, one with a T-junction where lines end inside an ellipse and
/// one with Gap Detection closing the gap between a curve's end and a circle: with no stroke on any
/// edge, a flood from any face reaches every face.
#[test]
fn live_paint_flood_crosses_unstroked_curves_in_built_groups() {
let pt = Point::new;
let frame = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0));
let t_junction = vec![
frame,
shapes::ellipse(Rect::new(50.0, 60.0, 150.0, 140.0)),
shapes::line(pt(113.0, 0.0), pt(113.0, 80.0)),
shapes::line(pt(113.0, 200.0), pt(113.0, 120.0)),
];
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Smooth };
let (start, end) = (pt(102.7, 20.0), pt(109.0, 100.0 + (2500.0f64 - 81.0).sqrt() - 2.0));
let curve = PathData::single(SubPath::new(
vec![a(start, start, start + kurbo::Vec2::new(40.0, 40.0)), a(end, end + kurbo::Vec2::new(-30.0, -40.0), end)],
false,
));
let gap = vec![shapes::ellipse(Rect::new(50.0, 50.0, 150.0, 150.0)), curve];
for (name, paths, max_gap, count) in [("T-junction", t_junction, None, 3), ("Gap Detection", gap, Some(3.0), 2)] {
let (g, fs) = live_from_paths(paths, max_gap);
assert_eq!(fs.len(), count, "{name}");
for &f in &fs {
let mut got = flood_faces(&g, f);
got.sort();
assert_eq!(got, fs, "{name} from {f:?}");
}
}
}
/// A closed outline: `curve`, then straight lines through `rest` and back to the curve's start.
fn curve_then_lines(curve: kurbo::CubicBez, rest: &[Point]) -> PathData {
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner };
let mut anchors = vec![a(curve.p0, curve.p0, curve.p1), a(curve.p3, curve.p2, curve.p3)];
anchors.extend(rest.iter().map(|&p| a(p, p, p)));
PathData::single(SubPath::new(anchors, true))
}
/// `c` with its two handles moved 0.006 pt along y: its ends stay put and its middle moves 0.0045 pt,
/// as a face outline refit within the planar map's precision can lie off its edge.
fn refit(c: kurbo::CubicBez) -> kurbo::CubicBez {
let up = kurbo::Vec2::new(0.0, 0.006);
kurbo::CubicBez::new(c.p0, c.p1 + up, c.p2 + up, c.p3)
}
/// A Live Paint group of `faces` and of `lines`, each line with or without a stroke. Returns the
/// group and the ids of the faces.
fn live_of(faces: Vec<PathData>, lines: Vec<(PathData, bool)>) -> (Node, Vec<NodeId>) {
let mut d = Document::new(500.0, 500.0);
let mut kids = vec![empty_sources(&mut d)];
let mut ids = vec![];
for pd in faces {
let id = d.alloc_id();
let mut face = Node::path(id, pd, Appearance::default());
face.name = Some(FACE_NAME.into());
kids.push(Arc::new(face));
ids.push(id);
}
for (pd, stroked) in lines {
let mut a = Appearance::default();
if stroked {
a.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
}
let mut edge = Node::path(d.alloc_id(), pd, a);
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
}
let mut g = Node::group(d.alloc_id(), kids);
g.name = Some(LIVE_PAINT_NAME.into());
(g, ids)
}
/// Faces whose outlines along an unstroked curved edge lie up to 0.0045 pt off it, as the planar map
/// can leave them when it refits an outline across a T-junction or, with Gap Detection on, builds the
/// faces and the edges from two maps: the flood still crosses the edge. Two faces that a gap parts
/// on the same side of the edge, which only touch, are still not joined.
#[test]
fn live_paint_flood_crosses_curved_edges_that_outlines_lie_slightly_off() {
use kurbo::ParamCurve;
let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(30.0, 40.0), Point::new(70.0, 40.0), Point::new(100.0, 0.0));
let edge = |s: kurbo::CubicBez| {
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner };
PathData::single(SubPath::new(vec![a(s.p0, s.p0, s.p1), a(s.p3, s.p2, s.p3)], false))
};
// T-junction: a stroked line ends on the curve at t = 0.4 and parts the faces below it (L and R);
// the curve is two edges, and the face above (U) runs along both with one refit segment.
let j = c.eval(0.4);
let (left, right) = (c.subsegment(0.0..0.4), c.subsegment(0.4..1.0));
let (g, ids) = live_of(
vec![
curve_then_lines(left, &[Point::new(j.x, 80.0), Point::new(0.0, 80.0)]),
curve_then_lines(right, &[Point::new(100.0, 80.0), Point::new(j.x, 80.0)]),
curve_then_lines(refit(c), &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]),
],
vec![(edge(left), false), (edge(right), false), (shapes::line(j, Point::new(j.x, 80.0)), true)],
);
let [l, r, u] = [ids[0], ids[1], ids[2]];
assert_eq!(flood_faces(&g, l), vec![l, u, r]);
let mut from_u = flood_faces(&g, u);
assert_eq!(from_u.first(), Some(&u));
from_u.sort();
assert_eq!(from_u, ids);
// Gap Detection: a gap line, which is no edge, parts P and Q above the curve at t = 0.5; the curve
// is one edge, and Q and the face below (B) run along it with refit segments.
let m = c.eval(0.5);
let (g, ids) = live_of(
vec![
curve_then_lines(c.subsegment(0.0..0.5), &[Point::new(m.x, -50.0), Point::new(0.0, -50.0)]),
curve_then_lines(refit(c.subsegment(0.5..1.0)), &[Point::new(100.0, -50.0), Point::new(m.x, -50.0)]),
curve_then_lines(refit(c), &[Point::new(100.0, 80.0), Point::new(0.0, 80.0)]),
],
vec![(edge(c), false)],
);
let [p, q, b] = [ids[0], ids[1], ids[2]];
assert_eq!(flood_faces(&g, p), vec![p, b, q]);
let mut without_b = g.clone();
if let Some(kids) = without_b.children_mut()
&& let Some(n) = kids.iter_mut().find(|n| n.id == b)
{
Arc::make_mut(n).visible = false;
}
assert_eq!(flood_faces(&without_b, p), vec![p]);
}
/// Two large faces whose only shared edge, with no stroke, is 0.005 pt long: the flood crosses it.
#[test]
fn live_paint_flood_crosses_a_shared_edge_shorter_than_the_precision() {
let pt = Point::new;
let (a, b) = (pt(50.0, 0.0), pt(50.0, 0.005));
let (g, ids) = live_of(
vec![
PathData::single(SubPath::polyline(&[pt(0.0, -50.0), a, b, pt(0.0, 50.0)], true)),
PathData::single(SubPath::polyline(&[pt(100.0, -50.0), pt(100.0, 50.0), b, a], true)),
],
vec![(shapes::line(a, b), false)],
);
assert_eq!(flood_faces(&g, ids[0]), ids);
}
/// A stroked edge along y = 0 and an edge with no stroke 0.008 pt below it, with a face above the
/// first whose outline ends on it and bows 0.005 pt off it in the middle, nearer the second, and a
/// face below the second: the stroked edge parts the two faces, so the flood does not join them.
#[test]
fn live_paint_flood_stops_where_a_stroked_edge_runs_along_an_unstroked_one() {
let pt = Point::new;
let bow = kurbo::CubicBez::new(pt(100.0, 0.0), pt(70.0, 0.02 / 3.0), pt(30.0, 0.02 / 3.0), pt(0.0, 0.0));
let (g, ids) = live_of(
vec![
curve_then_lines(bow, &[pt(0.0, -50.0), pt(100.0, -50.0)]),
PathData::single(SubPath::polyline(&[pt(0.0, 0.008), pt(0.0, 50.0), pt(100.0, 50.0), pt(100.0, 0.008)], true)),
],
vec![(shapes::line(pt(0.0, 0.0), pt(100.0, 0.0)), true), (shapes::line(pt(0.0, 0.008), pt(100.0, 0.008)), false)],
);
assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]);
assert_eq!(flood_faces(&g, ids[1]), vec![ids[1]]);
}
/// An edge with no stroke from (10, 0) to (90, 0), and two faces that meet on a line 0.005 pt below
/// it from (0, 0.005) to (100, 0.005) (as a Gap Detection line): neither face runs along the edge, so
/// the flood does not join them.
#[test]
fn live_paint_flood_does_not_cross_an_edge_beside_a_line_two_faces_meet_on() {
let pt = Point::new;
let (a, b) = (pt(0.0, 0.005), pt(100.0, 0.005));
let (g, ids) = live_of(
vec![
PathData::single(SubPath::polyline(&[a, b, pt(100.0, -50.0), pt(0.0, -50.0)], true)),
PathData::single(SubPath::polyline(&[b, a, pt(0.0, 50.0), pt(100.0, 50.0)], true)),
],
vec![(shapes::line(pt(10.0, 0.0), pt(90.0, 0.0)), false)],
);
assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]]);
}
/// A stroked edge and an edge with no stroke that leave one point at a slope of 0.01, so they lie
/// within 0.01 pt of each other for their first point or so: the face between them and the face
/// beyond the edge with no stroke share that edge, so the flood joins them.
#[test]
fn live_paint_flood_crosses_an_unstroked_edge_beside_a_stroked_one() {
let pt = Point::new;
let (o, a, b) = (pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 1.0));
let (g, ids) = live_of(
vec![
PathData::single(SubPath::polyline(&[o, a, b], true)),
PathData::single(SubPath::polyline(&[o, b, pt(100.0, 50.0), pt(0.0, 50.0)], true)),
],
vec![(shapes::line(o, a), true), (shapes::line(o, b), false), (shapes::line(a, b), true)],
);
assert_eq!(flood_faces(&g, ids[0]), ids);
}
/// Two faces on either side of an edge with no stroke whose curve has a cusp at its middle (no
/// direction there): the flood crosses it.
#[test]
fn live_paint_flood_crosses_an_edge_with_a_cusp_at_its_middle() {
let c = kurbo::CubicBez::new(Point::new(0.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0), Point::new(100.0, 0.0));
let a = |p: Point, h_in: Point, h_out: Point| vectorcraft_geom::Anchor { p, h_in, h_out, kind: vectorcraft_geom::AnchorKind::Corner };
let edge = PathData::single(SubPath::new(vec![a(c.p0, c.p0, c.p1), a(c.p3, c.p2, c.p3)], false));
let (g, ids) = live_of(
vec![
curve_then_lines(c, &[Point::new(100.0, -50.0), Point::new(0.0, -50.0)]),
curve_then_lines(c, &[Point::new(100.0, 100.0), Point::new(0.0, 100.0)]),
],
vec![(edge, false)],
);
assert_eq!(flood_faces(&g, ids[0]), ids);
}
/// Two faces parted by a line that is no edge (as a Gap Detection line), which crosses an edge with
/// no stroke: one 0.006 pt long, upright or at a slope of 0.1, and one 1 pt long at a slope of 0.09.
/// The faces only cross the edge and do not run along it, so the flood does not join them.
#[test]
fn live_paint_flood_does_not_cross_an_edge_an_outline_only_crosses() {
let pt = Point::new;
let short = pt(0.006, 0.0);
for (a, b, end) in
[(pt(0.003, -0.004), pt(0.003, 0.02), short), (pt(-0.037, -0.004), pt(0.043, 0.004), short), (pt(0.1, -0.009), pt(0.3, 0.009), pt(1.0, 0.0))]
{
let (g, ids) = live_of(
vec![
PathData::single(SubPath::polyline(&[a, b, pt(-50.0, 50.0), pt(-50.0, -50.0)], true)),
PathData::single(SubPath::polyline(&[b, a, pt(50.0, -50.0), pt(50.0, 50.0)], true)),
],
vec![(shapes::line(pt(0.0, 0.0), end), false)],
);
assert_eq!(flood_faces(&g, ids[0]), vec![ids[0]], "{a:?} to {b:?}");
}
}
#[test]
fn live_paint_connected_edges_follow_the_same_stroke_paint() {
let mut d = Document::new(500.0, 500.0);
let (g, [a, ..], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d);
let group = d.node(g).unwrap().clone();
let black = Paint::solid(vectorcraft_color::Color::BLACK);
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
// Top A and top B join end to end; top C joins them but is red, and the black bottom does not
// join them.
assert_eq!(connected_edges(&group, top_a, &black), vec![top_a, top_b]);
// BC and top C, both red, join at (100, 0).
assert_eq!(connected_edges(&group, bc, &red), vec![bc, top_c]);
assert_eq!(connected_edges(&group, bottom, &black), vec![bottom]);
// The edge started from is taken whatever its paint.
assert_eq!(connected_edges(&group, ab, &Paint::None), vec![ab]);
assert!(connected_edges(&group, a, &black).is_empty());
}
/// An edge from (0, 0) through (50, 0) to (100, 0) and a second edge with the same stroke from
/// (50, 0) to (50, 50): the second one ends at an anchor in the middle of the first, not at one of
/// its ends, so the two are not joined end to end. A third edge from (100, 0) is.
#[test]
fn live_paint_connected_edges_join_only_at_path_ends() {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let black = Paint::solid(vectorcraft_color::Color::BLACK);
let mut kids = vec![empty_sources(&mut d)];
let pt = Point::new;
let paths = [
PathData::single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0)], false)),
shapes::line(pt(50.0, 0.0), pt(50.0, 50.0)),
shapes::line(pt(100.0, 0.0), pt(100.0, 50.0)),
];
let mut ids = vec![];
for pd in paths {
let mut a = Appearance::default();
a.set_stroke(black.clone());
let id = d.alloc_id();
let mut edge = Node::path(id, pd, a);
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
ids.push(id);
}
let gid = d.alloc_id();
let mut g = Node::group(gid, kids);
g.name = Some(LIVE_PAINT_NAME.into());
d.insert(Some(l), 0, g).unwrap();
let group = d.node(gid).unwrap().clone();
assert_eq!(connected_edges(&group, ids[0], &black), vec![ids[0], ids[2]]);
assert_eq!(connected_edges(&group, ids[1], &black), vec![ids[1]]);
}
/// Three edges end to end along (0, 0) to (150, 0) under one face: a red one, one drawn red with a
/// hidden blue stroke over it, and one drawn blue with a hidden red stroke over it. Edges join by the
/// paint they draw with, not by a hidden stroke's paint, for `connected_edges` and a double-click.
#[test]
fn live_paint_connected_edges_follow_the_stroke_paint_they_draw_with() {
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
let mut kids = vec![empty_sources(&mut d)];
let fid = d.alloc_id();
let mut face = Node::path(fid, shapes::rectangle(Rect::new(0.0, 0.0, 150.0, 50.0)), Appearance::default());
face.name = Some(FACE_NAME.into());
kids.push(Arc::new(face));
let pt = Point::new;
let hidden = |p: &Paint| AppearanceItem::Stroke(StrokeLayer { visible: false, ..StrokeLayer::new(p.clone(), 1.0) });
let shown = |p: &Paint| AppearanceItem::Stroke(StrokeLayer::new(p.clone(), 1.0));
let edges = [(0.0, vec![shown(&red)]), (50.0, vec![shown(&red), hidden(&blue)]), (100.0, vec![shown(&blue), hidden(&red)])];
let mut ids = vec![];
for (x, items) in edges {
let id = d.alloc_id();
let mut edge = Node::path(id, shapes::line(pt(x, 0.0), pt(x + 50.0, 0.0)), Appearance { items, ..Appearance::default() });
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
ids.push(id);
}
let gid = d.alloc_id();
let mut g = Node::group(gid, kids);
g.name = Some(LIVE_PAINT_NAME.into());
d.insert(Some(l), 0, g).unwrap();
let group = d.node(gid).unwrap().clone();
assert_eq!(connected_edges(&group, ids[0], &red), vec![ids[0], ids[1]]);
assert_eq!(connected_edges(&group, ids[2], &blue), vec![ids[2]]);
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
let alt = Mods { alt: true, ..Default::default() };
let mut t = LivePaintSelectionTool::default();
let mut dbl = |x: f64| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, 1.0).with_mods(alt));
assert_eq!(dbl(75.0), set(&[ids[1], ids[0]]));
assert_eq!(dbl(125.0), set(&[ids[2]]));
}
#[test]
fn live_paint_selection_double_click_selects_connected_faces_or_edges() {
let mut d = Document::new(500.0, 500.0);
let (_, [a, b, c], [_, _, top_a, top_b, ..]) = live_strip(&mut d);
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
let alt = Mods { alt: true, ..Default::default() };
let shift = Mods { shift: true, ..Default::default() };
let dbl = |t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::DoubleClick, x, y).with_mods(m));
let mut t = LivePaintSelectionTool::default();
// A face: the faces not parted from it by a stroked edge.
assert_eq!(dbl(&mut t, 25.0, 25.0, Mods::default()), set(&[a, b]));
assert_eq!(dbl(&mut t, 125.0, 25.0, Mods::default()), set(&[c]));
// An edge (Alt): the edges joined to it end to end with its stroke paint.
assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[top_a, top_b]));
// With Shift held, the parts reached leave the selection or join it.
assert_eq!(dbl(&mut t, 75.0, 25.0, shift), toggle(&[b, a]));
assert_eq!(dbl(&mut t, 75.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_b, top_a]));
// Away from the group, a double-click adds nothing to what its clicks did.
assert!(dbl(&mut t, 300.0, 300.0, Mods::default()).is_empty());
// Select Fills off: the double-click picks the edge nearest the pointer as a click does.
t.set_option("selectFills", &json!(false));
assert_eq!(dbl(&mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b]));
assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty());
// Select Strokes off: Alt picks the face.
t.set_option("selectFills", &json!(true));
t.set_option("selectStrokes", &json!(false));
assert_eq!(dbl(&mut t, 25.0, 1.0, alt), set(&[a, b]));
// Both off: nothing.
t.set_option("selectFills", &json!(false));
assert!(dbl(&mut t, 25.0, 25.0, Mods::default()).is_empty());
assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty());
}
#[test]
fn live_paint_selection_double_click_skips_locked_groups_and_art_outside_isolation() {
let mut d = Document::new(500.0, 500.0);
let (g, _, _) = live_strip(&mut d);
let (other, _) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0));
let s = Selection::default();
let p = paint();
let dbl = |d: &Document, iso: Option<NodeId>, x: f64| {
let cx = ToolContext { isolation: iso, ..cx(d, &s, &p) };
LivePaintSelectionTool::default().pointer(&cx, &ev(PointerKind::DoubleClick, x, 25.0))
};
assert!(!dbl(&d, None, 25.0).is_empty());
assert!(dbl(&d, Some(other), 25.0).is_empty(), "outside the isolated art");
d.node_mut(g).unwrap().locked = true;
assert!(dbl(&d, None, 25.0).is_empty(), "a locked group");
assert!(!dbl(&d, None, 250.0).is_empty());
}
/// The Live Paint Selection pointer shows what a click at the point selects, from the point and
/// the modifier keys alone (no move first): a face, an edge, or nothing. Groups the tool does not
/// take (locked, hidden, outside isolation) show nothing to select. A press keeps its look until
+7 -5
View File
@@ -110,14 +110,16 @@ pub enum Cursor {
/// The Pencil tool with Alt pressed after the drag starts and Alt key toggles to Smooth Tool off:
/// releasing the mouse button with Alt still held closes the path. A drag started with Shift or
/// Alt held draws an open straight line instead. Also while a freehand stroke is drawn back near
/// its start, where releasing the mouse button closes the path. A crosshair with a ring.
/// its start, or a stroke continuing a selected path near that path's other end, where
/// releasing the mouse button closes the path. A crosshair with a ring.
PencilClose,
/// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a
/// short straight line.
/// short level line.
PencilStraight,
/// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of
/// a selected open path: the line continues the path from that end. A crosshair with a short
/// straight line leaving a square end point.
/// The Pencil tool before a press over an end of a selected open path: the stroke, freehand or a
/// straight line, continues the path from that end. Also while a continuing stroke ends near an
/// open end of another path selected at the press, where releasing the mouse button connects the
/// two. A crosshair with a short line rising to the right.
PencilContinue,
/// The Paintbrush tool.
Paintbrush,
+13 -5
View File
@@ -629,7 +629,9 @@ hotspot = "The lead tip."
[[cursor]]
id = "PencilNew"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star."]
shown_when = [
"The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star.",
]
hotspot = "The lead tip."
look = "The Pencil cursor with a small star badge, where PencilClose has its ring."
@@ -639,6 +641,7 @@ group = "Drawing and editing tools"
shown_when = [
"The Pencil tool with Alt pressed after the drag starts, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path. A drag started with Shift or Alt held draws an open straight line and does not show this cursor.",
"The Pencil tool while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.",
"The Pencil tool while a stroke continuing a selected open path, freehand or a straight line, is drawn to within Close Paths When Ends Are Within of that path's other end: releasing the mouse button there closes the path.",
]
hotspot = "The lead tip."
look = "The Pencil cursor with a ring badge (closing the loop), like PenClose."
@@ -646,16 +649,21 @@ look = "The Pencil cursor with a ring badge (closing the loop), like PenClose."
[[cursor]]
id = "PencilStraight"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone."]
shown_when = [
"The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone.",
]
hotspot = "The lead tip."
look = "The Pencil cursor with a short straight-line badge, where PencilClose has its ring."
look = "The Pencil cursor with a short level-line badge, where PencilClose has its ring."
[[cursor]]
id = "PencilContinue"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool over an end of a selected open path, within Edit Selected Paths, with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line that continues the path from that end."]
shown_when = [
"The Pencil tool over an end of a selected open path, within Edit Selected Paths, before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press continues the path from that end, freehand, or with a straight line when Shift or Alt is held.",
"The Pencil tool while a stroke continuing a selected path ends within Edit Selected Paths of an open end of another path selected at the press: releasing the mouse button connects the two.",
]
hotspot = "The lead tip."
look = "PencilStraight's badge with a small filled square at the line's lower end (the end point the line continues from)."
look = "The Pencil cursor with a short badge line rising to the right, where PencilClose has its ring (the stroke carries on from the path)."
[[cursor]]
id = "Paintbrush"
+102 -14
View File
@@ -240,12 +240,18 @@ fn points_source(cx: &ToolContext, (a, ra): (Point, &Option<PointRef>), (b, rb):
impl DimensionTool {
/// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as
/// a link to its path); else where Smart Guides put it (another object's anchor or centre, in
/// line with one, or for the second point on a construction guide through the first), its
/// guides showing; else on the closest point of the line under the pointer; else `p` itself.
/// line with one, or for the second point square to a segment or grazing a curve from the
/// first with Snap to Perpendicular or Snap to Tangent on, or on a construction guide through
/// a first point picked on an anchor), its guides showing; else on the closest point of the
/// line under the pointer; else `p` itself. A first point not on an anchor has no
/// construction guides through it.
/// A point the guides leave where the pointer is (right on a guide line) stays there, and with
/// Snap to Pixel or Snap to Grid on a point stays where they place it.
fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option<PointRef>) {
let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false));
let leave = self.points.last().map(|(first, anchor)| match anchor {
Some(_) => crate::guides::Leave::segment(cx, *first, false),
None => crate::guides::Leave::contact(*first),
});
let guided = self.snap.hover(cx, p, &[], leave.as_ref());
if let Some((point, reference)) = anchor_under(cx, p) {
self.snap.clear();
@@ -902,10 +908,11 @@ mod tests {
}
}
/// A point that Smart Guides leave where the pointer already is (the pointer right on a
/// construction guide or in line with a target) stays on the guide the overlays show. It
/// does not move to the nearest point of a segment within the pick's reach. Off the guides,
/// a point near a segment still lands on it.
/// A point that Smart Guides leave where the pointer already is (the pointer right in line
/// with a target) stays on the guide the overlays show. It does not move to the nearest point
/// of a segment within the pick's reach. Off the guides, a point near a segment still lands
/// on it, also straight below the first point: the second point has no construction guide
/// through a first point that is not on an anchor.
#[test]
fn a_point_on_a_guide_stays_on_the_guide() {
let (d, _) = doc_with_rect();
@@ -923,17 +930,22 @@ mod tests {
// Off the guides, beside the edge: the closest point of the edge.
let (on_edge, reference) = t.snap_linear(&c, Point::new(206.0, 170.5));
assert!(on_edge.distance(Point::new(200.0, 170.5)) < 1e-4 && reference.is_none(), "{on_edge:?}");
// The first point, above the rectangle, then straight down along the vertical
// construction guide through it, past the right edge.
// The first point, above the rectangle, then straight down past the right edge: no
// vertical guide through the first point holds the second, so beside the edge it lands
// on the edge, and in line with the center it stays in line.
click(&mut t, 206.0, 50.0);
assert_eq!(t.points, vec![(Point::new(206.0, 50.0), None)]);
for y in [150.0, 170.5, 190.0] {
assert_eq!(t.snap_linear(&c, Point::new(206.0, y)), (Point::new(206.0, y), None), "on the construction guide at y = {y}");
assert!(!t.snap.guides().is_empty());
for y in [170.5, 190.0] {
let (q, reference) = t.snap_linear(&c, Point::new(206.0, y));
assert!(q.distance(Point::new(200.0, y)) < 1e-4 && reference.is_none(), "beside the edge at y = {y}: {q:?}");
}
click(&mut t, 206.0, 170.5);
assert_eq!(t.snap_linear(&c, Point::new(207.0, 300.0)), (Point::new(207.0, 300.0), None), "away from the art");
assert!(t.snap.guides().is_empty());
assert_eq!(t.snap_linear(&c, Point::new(206.0, 150.0)), (Point::new(206.0, 150.0), None));
assert!(!t.snap.guides().is_empty());
click(&mut t, 206.0, 150.0);
let dim = t.pending.as_ref().expect("both points were picked");
assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 170.5)));
assert_eq!((dim.a, dim.b), (Point::new(206.0, 50.0), Point::new(206.0, 150.0)));
// The second point, off the edge, is held against the rectangle's anchors.
let Some(DimensionSource::Points { free_b: Some(free), .. }) = &dim.source else { panic!("{:?}", dim.source) };
assert!(matches!(free, FreePoint::Anchors { .. }), "{free:?}");
@@ -950,6 +962,82 @@ mod tests {
assert_eq!(DimensionTool::default().snap_linear(&pixel, Point::new(206.0, 170.0)), (Point::new(206.0, 170.0), None));
}
/// After a first point that is not on an anchor, the second point still lands where the line
/// from the first point meets a slanted segment square or grazes a curve, with the
/// "perpendicular" or "tangent" label, at places no construction guide through the first
/// point reaches. With Snap to Perpendicular and Snap to Tangent off it stays at the pointer.
#[test]
fn the_second_point_lands_square_to_a_segment_or_grazing_a_curve() {
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{Rect, shapes};
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let (line, circle) = (d.alloc_id(), d.alloc_id());
let slanted = shapes::line(Point::new(300.0, 100.0), Point::new(400.0, 150.0));
d.insert(Some(l), 0, Node::path(line, slanted, Appearance::default_art())).unwrap();
let round = shapes::ellipse(Rect::new(420.0, 370.0, 480.0, 430.0));
d.insert(Some(l), 0, Node::path(circle, round, Appearance::default_art())).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let first = Point::new(300.0, 250.0);
let picked = |c: &ToolContext| {
let mut t = DimensionTool::default();
for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] {
t.pointer(c, &PointerEvent::new(kind, first.x, first.y));
}
assert_eq!(t.points, vec![(first, None)]);
t
};
let labeled = |t: &DimensionTool, label: &str| t.snap.guides().iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == label));
// The foot of the perpendicular from (300, 250) to the line is (360, 130), along no
// construction guide angle from the first point.
let mut t = picked(&c);
let (q, reference) = t.snap_linear(&c, Point::new(361.0, 132.0));
assert!(q.distance(Point::new(360.0, 130.0)) < 1e-4 && reference.is_none(), "{q:?}");
assert!(labeled(&t, "perpendicular"), "{:?}", t.snap.guides());
// Where a line from the first point grazes the circle about (450, 400), radius 30.
let (center, radius) = (Point::new(450.0, 400.0), 30.0);
let away = first - center;
let angle = away.y.atan2(away.x) + (radius / away.hypot()).acos();
let touch = center + Vec2::new(angle.cos(), angle.sin()) * radius;
let (q, _) = t.snap_linear(&c, touch + Vec2::new(0.5, 0.5));
assert!((q.distance(center) - radius).abs() < 0.05, "{q:?} is on the circle");
assert!(((q - first).normalize().dot((q - center).normalize())).abs() < 0.01, "{q:?} grazes the circle");
assert!(labeled(&t, "tangent"), "{:?}", t.snap.guides());
// With both options off, the pointer near the foot lands on the closest point of the line
// instead.
let off = ToolContext { snap_perpendicular: false, snap_tangent: false, ..cx(&d, &s, &p) };
let mut t = picked(&off);
let (q, _) = t.snap_linear(&off, Point::new(361.0, 132.0));
assert!(q.distance(Point::new(360.0, 130.0)) > 0.5 && !labeled(&t, "perpendicular"), "{q:?}");
}
/// After a first point picked on an anchor, the second point still lands on the construction
/// guides through it.
#[test]
fn the_second_point_after_an_anchor_lands_on_construction_guides() {
let (d, id) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = DimensionTool::default();
for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] {
t.pointer(&c, &PointerEvent::new(kind, 200.0, 200.0));
}
assert_eq!(t.points, vec![(Point::new(200.0, 200.0), Some(PointRef { node: id, subpath: 0, anchor: 2 }))]);
// 1.5 units right and below the 45° guide up and to the right of the anchor.
let (q, reference) = t.snap_linear(&c, Point::new(331.0, 72.0));
assert!(q.distance(Point::new(329.5, 70.5)) < 1e-4 && reference.is_none(), "{q:?}");
assert!(!t.snap.guides().is_empty());
// The same offset from a first point off the anchors stays at the pointer.
let mut t = DimensionTool::default();
for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] {
t.pointer(&c, &PointerEvent::new(kind, 240.0, 240.0));
}
assert_eq!(t.points, vec![(Point::new(240.0, 240.0), None)]);
assert_eq!(t.snap_linear(&c, Point::new(371.0, 112.0)), (Point::new(371.0, 112.0), None));
assert!(t.snap.guides().is_empty());
}
#[test]
fn placement_press_previews_without_a_prior_move() {
let (d, _) = doc_with_rect();
+72 -15
View File
@@ -98,8 +98,13 @@ pub struct GestureTool {
/// A release where the pointer last dragged to would close the stroke because it ends near
/// its start (Close Paths When Ends Are Within): the cursor shows the closing ring.
near_start: bool,
/// A release where the pointer last dragged to would connect the continued path to another
/// selected path's open end (`finish`): the cursor shows the continuation cursor.
connects: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
extend: Option<(NodeId, bool)>,
/// The other end of the path `extend` continues: a release near it closes the path.
close_to: Option<Point>,
/// A Pencil stroke continuing a selected open path: the other paths selected at the press,
/// whose open ends it connects to.
connect_to: Vec<NodeId>,
@@ -817,7 +822,9 @@ impl GestureTool {
active: false,
began: false,
near_start: false,
connects: false,
extend: None,
close_to: None,
connect_to: vec![],
redraw: None,
fidelity,
@@ -1100,6 +1107,21 @@ impl GestureTool {
self.find_end(cx, p, &cx.selection.objects)
}
/// The other end of the open end `find_end` found: the opposite end of `id`'s last open
/// subpath.
fn other_end(cx: &ToolContext, id: NodeId, at_start: bool) -> Option<Point> {
let pd = cx.doc.node(id)?.path_data()?;
let sp = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty())?;
let a = if at_start { sp.anchors.last() } else { sp.anchors.first() };
a.map(|a| a.p)
}
/// Continues the path at the open end `find_extend` found, if any, noting its other end.
fn set_extend(&mut self, cx: &ToolContext, found: Option<(NodeId, bool, Point)>) {
self.extend = found.map(|(id, at_start, _)| (id, at_start));
self.close_to = found.and_then(|(id, at_start, _)| Self::other_end(cx, id, at_start));
}
/// [`Self::find_extend`] among `ids`.
fn find_end(&self, cx: &ToolContext, p: Point, ids: &[NodeId]) -> Option<(NodeId, bool, Point)> {
if self.edit_within <= 0.0 {
@@ -1144,16 +1166,32 @@ impl GestureTool {
}
/// Does a stroke that ends at `end` end near its start (Close Paths When Ends Are Within, off
/// at 0 pixels)?
/// at 0 pixels)? A stroke continuing a selected path ends near that path's other end, a
/// straight line too; a new straight line never closes, as it would run back over itself.
fn ends_near_start(&self, cx: &ToolContext, end: Point) -> bool {
self.close_within > 0.0 && self.points.len() > 2 && self.points[0].distance(end) <= cx.tol(self.close_within)
if self.close_within <= 0.0 {
return false;
}
let tol = cx.tol(self.close_within);
match (self.extend, self.close_to) {
(Some(_), Some(to)) => self.points.len() >= 2 && to.distance(end) <= tol,
(Some(_), None) => false,
(None, _) => !self.straight && self.points.len() > 2 && self.points.first().is_some_and(|s| s.distance(end) <= tol),
}
}
/// Does a release now, with the stroke ending at `end`, close a freehand stroke because it ends
/// near its start (`finish`)? A stroke that redraws a selected path closes only when the engine
/// finds it does not end on that path, so it counts as not closing.
/// The open end of another path selected at the press that a continuing Pencil stroke ending
/// at `end` connects to (`finish`).
fn connects_at(&self, cx: &ToolContext, end: Point) -> Option<(NodeId, bool, Point)> {
self.extend.and_then(|_| self.find_end(cx, end, &self.connect_to))
}
/// Does a release now, with the stroke ending at `end`, close the stroke because it ends near
/// its start, or near the other end of the path it continues (`finish`)? A stroke that redraws
/// a selected path closes only when the engine finds it does not end on that path, so it counts
/// as not closing, and a stroke that connects to another path doesn't close.
fn release_closes_near_start(&self, cx: &ToolContext, end: Point) -> bool {
self.draws_path() && !self.smoothing && !self.straight && self.extend.is_none() && self.redraw.is_none() && self.ends_near_start(cx, end)
self.draws_path() && !self.smoothing && self.redraw.is_none() && self.ends_near_start(cx, end) && self.connects_at(cx, end).is_none()
}
/// Parameters of `path.freehand` for the current stroke, fitted at Fidelity on screen. Only the
@@ -1194,14 +1232,16 @@ impl GestureTool {
if !self.began {
return vec![];
}
// Close Paths When Ends Are Within is off at 0 pixels.
// Close Paths When Ends Are Within is off at 0 pixels. A stroke continuing a
// selected path closes it near its other end, a straight line too.
let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end));
// A straight line has two points: closing it would draw it back over itself.
let closed = !self.straight && ((m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || (self.extend.is_none() && near));
let mut params = self.freehand_params(cx, closed);
// Alt at the release closes a freehand stroke; a new straight line has two points:
// closing it would draw it back over itself.
let closed = (!self.straight && m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || near;
// A Pencil stroke that continues a selected path and ends on an open end of another
// path selected at the press connects the two.
let connect = pts.last().and_then(|&end| self.find_end(cx, end, &self.connect_to));
// path selected at the press connects the two, and doesn't close.
let connect = pts.last().and_then(|&end| self.connects_at(cx, end));
let mut params = self.freehand_params(cx, closed && connect.is_none());
if let Some((other, at_start, _)) = connect {
params["connect"] = json!({"id": other.0, "end": if at_start { "start" } else { "end" }});
}
@@ -1267,6 +1307,7 @@ impl Tool for GestureTool {
self.active = true;
self.began = false;
self.near_start = false;
self.connects = false;
// Alt switches the Pencil when its toggle is on, the Blob Brush always, and the
// Paintbrush when the press has a selected path.
self.smoothing = ev.mods.alt
@@ -1278,7 +1319,7 @@ impl Tool for GestureTool {
self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt);
self.unconstrained = self.straight && !ev.mods.shift;
let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None };
self.extend = extend.map(|(id, at_start, _)| (id, at_start));
self.set_extend(cx, extend);
// The stroke's previews select the path it continues, so the others are kept now.
self.connect_to = match self.extend {
Some((id, _)) if self.id == "pencil" => cx.selection.objects.iter().copied().filter(|o| *o != id).collect(),
@@ -1336,16 +1377,20 @@ impl Tool for GestureTool {
return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };
}
self.points.truncate(1);
// The line ends at `p`, where its end snaps.
end = p;
} else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES {
// A release here keeps the last point unless it snaps (`Up`), so the ring
// follows the point `finish` will test.
let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.snaps());
let release_end = kept.unwrap_or(end);
self.near_start = self.release_closes_near_start(cx, release_end);
self.connects = self.connects_at(cx, release_end).is_some();
return vec![];
}
self.push(p, ev);
self.near_start = self.release_closes_near_start(cx, end);
self.connects = self.connects_at(cx, end).is_some();
if !self.draws_path() || self.smoothing {
return vec![];
}
@@ -1406,11 +1451,13 @@ impl Tool for GestureTool {
self.active = false;
self.began = false;
self.near_start = false;
self.connects = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
self.clear();
self.extend = None;
self.close_to = None;
self.connect_to.clear();
self.redraw = None;
out
@@ -1440,6 +1487,7 @@ impl Tool for GestureTool {
self.active = false;
self.began = false;
self.near_start = false;
self.connects = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
@@ -1470,6 +1518,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.near_start = false;
self.connects = false;
self.began = false;
self.smoothing = false;
self.straight = false;
@@ -1557,6 +1606,11 @@ impl Tool for GestureTool {
if !self.active && self.straight_at_press(m) {
return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight };
}
// A continuing Pencil stroke that a release would connect to another selected path's open
// end (`finish`) shows that it continues on; `connects` tests the point the release keeps.
if self.id == "pencil" && self.active && self.connects {
return Cursor::PencilContinue;
}
// Alt held at the release closes the path (`finish`).
// A straight line stays open, so its release has nothing to close, and before a press Alt
// gives the Pencil a straight line.
@@ -1576,9 +1630,12 @@ impl Tool for GestureTool {
"smooth" => smooth_cursor(cx),
"pathEraser" => Cursor::PathEraser,
"join" => Cursor::Join,
// Before a press over an open end of a selected path the Pencil shows that the stroke
// continues it (`PointerKind::Down`).
_ if !self.active && self.find_extend(cx, p).is_some() => Cursor::PencilContinue,
// Before a press that draws a new path the Pencil shows its star; over a selected path
// that the press continues from an end or redraws (`PointerKind::Down`), it does not.
_ if !self.active && self.find_extend(cx, p).is_none() && self.find_redraw(cx, p).is_none() => Cursor::PencilNew,
// that the press redraws (`PointerKind::Down`), it does not.
_ if !self.active && self.find_redraw(cx, p).is_none() => Cursor::PencilNew,
_ => Cursor::Pencil,
}
}
+105 -9
View File
@@ -793,7 +793,7 @@ fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() {
}
}
let mut t = GestureTool::new("pencil");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "over an end the stroke continues");
assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws");
assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path");
// While a stroke is drawn, the Pencil's own cursor.
@@ -838,7 +838,7 @@ fn pencil_shift_line_continues_a_selected_path_from_its_end() {
}
/// Before a press over an end of a selected open path, the Pencil shows its continuation cursor
/// exactly when the press would draw a straight line that continues the path; away from the
/// exactly when the press continues the path, freehand or with a straight line; away from the
/// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with
/// the Paintbrush, it does not.
#[test]
@@ -859,12 +859,9 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() {
let shift = Mods { shift: true, ..Default::default() };
let alt = Mods { alt: true, ..Default::default() };
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
// The release's stroke: a straight line that continues the path.
let continues = |acts: &[Vec<Action>]| {
acts.last()
.and_then(|a| a.first())
.is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)))
};
// The release's stroke continues the path.
let continues =
|acts: &[Vec<Action>]| acts.last().and_then(|a| a.first()).is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some()));
for alt_smooth in [false, true] {
for m in [Mods::default(), shift, alt, shift_alt] {
for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] {
@@ -880,7 +877,7 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() {
let end = Point::new(405.0, 303.0);
assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue);
assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "a freehand stroke continues it too");
assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends");
// While the line is drawn, the Pencil's own cursor.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift));
@@ -897,6 +894,105 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() {
assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors");
}
/// A Pencil stroke continuing a selected path shows the continuation cursor while a release would
/// connect it to an open end of another path selected at the press, and the closing ring while a
/// release would close the path because the stroke ends within Close Paths When Ends Are Within
/// of the path's other end, a straight line too; the release does what the cursor showed.
#[test]
fn a_continued_stroke_shows_where_it_connects_or_closes() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let add = |d: &mut vectorcraft_doc::Document, pts: &[(f64, f64)]| {
let id = d.alloc_id();
let mut els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(pts[0].0, pts[0].1))];
els.extend(pts[1..].iter().map(|&(x, y)| vectorcraft_geom::PathEl::LineTo(Point::new(x, y))));
let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap();
id
};
let a = add(&mut d, &[(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)]);
let b = add(&mut d, &[(500.0, 500.0), (600.0, 500.0)]);
let p = paint();
let mut both = Selection::default();
both.set([a, b]);
let mut cx = cx(&d, &both, &p);
cx.smart_guides = false;
cx.snap_to_point = false;
let none = Mods::default();
let shift = Mods { shift: true, ..Default::default() };
// Runs a stroke from `pts[0]`, giving the cursor at its last point before the release and the
// release's `path.freehand` parameters.
let run = |t: &mut GestureTool, pts: &[(f64, f64)], m: Mods| {
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m));
for &(x, y) in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m));
}
let (x, y) = pts[pts.len() - 1];
let shown = t.cursor(&cx, Point::new(x, y), m);
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
(shown, v.clone())
};
let mut t = GestureTool::new("pencil");
// To the other selected path's start: it connects, and doesn't close.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (450.0, 450.0), (498.0, 502.0)], none);
assert_eq!(shown, Cursor::PencilContinue, "near the other path's end");
assert_eq!(
(v["extend"].clone(), v["connect"].clone(), v["closed"].clone()),
(json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}), json!(false))
);
// Back near the path's own start (10 px from it): the ring, and the release closes it.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none);
assert_eq!(shown, Cursor::PencilClose, "near the path's other end");
assert_eq!((v["extend"].clone(), v["closed"].clone()), (json!({"id": a.0, "end": "end"}), json!(true)));
assert!(v.get("connect").is_none(), "{v}");
// Farther than Close Paths When Ends Are Within (15 px): no ring, and it stays open.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (290.0, 330.0)], none);
assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "away from the other end");
// A straight line from the end to near the start: the ring, and it closes the path.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 350.0), (306.0, 306.0)], shift);
assert_eq!(shown, Cursor::PencilClose, "a straight line near the other end");
assert_eq!((v["points"].as_array().map(Vec::len), v["closed"].clone()), (Some(2), json!(true)));
// Close Paths When Ends Are Within off: neither ring nor close.
t.set_option("closeWithin", &json!(0.0));
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none);
assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "at 0 px");
}
/// The continuation cursor tests where the release ends, after snapping, not the pointer: here
/// Snap to Grid moves a stroke's end from 13 pt away from another selected path's end (outside Edit
/// Selected Paths' 12 px) to 9 pt away, so the release connects, and the cursor says so.
#[test]
fn the_continuation_cursor_follows_the_snapped_release() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| {
let id = d.alloc_id();
let els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1))];
let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap();
id
};
// On the 9 pt grid.
let a = add(&mut d, (306.0, 306.0), (405.0, 405.0));
let b = add(&mut d, (504.0, 504.0), (603.0, 504.0));
let p = paint();
let mut both = Selection::default();
both.set([a, b]);
let mut cx = cx(&d, &both, &p);
cx.smart_guides = false;
cx.snap_to_point = false;
cx.snap_to_grid = true;
let mut t = GestureTool::new("pencil");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 405.0, 405.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 460.0, 450.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 517.0, 504.0));
assert_eq!(t.cursor(&cx, Point::new(517.0, 504.0), Mods::default()), Cursor::PencilContinue);
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 517.0, 504.0));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(v["connect"], json!({"id": b.0, "end": "start"}), "{v}");
}
/// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt
/// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag.
#[test]
+5 -4
View File
@@ -257,10 +257,11 @@ impl Leave {
self
}
/// The straight segment from the anchor `at` to a point being dragged (an open path's end
/// moved with Direct Selection): the point may land square to a segment or grazing a curve,
/// but is not held to Shift's angles or the construction guides, which a moved anchor
/// does not follow.
/// The straight segment from the point `at` to a point being placed (an open path's end
/// moved with Direct Selection, or the Dimension tool's second Linear point after a first
/// point that is not on an anchor): the point may
/// land square to a segment or grazing a curve, but is not held to Shift's angles or the
/// construction guides through `at`.
pub fn contact(at: Point) -> Self {
Self { at, shift: None, angles: &[], contact: true }
}
+4 -1
View File
@@ -673,7 +673,10 @@ mod cursor_tests {
// Away from a selected path the Pencil shows its new-path cursor, drawn with a star; its
// own cursor, for drawing and for a selected path under the pointer, borrows the crosshair.
assert_eq!(cursor_at("pencil", &d, away), Cursor::PencilNew);
assert_eq!(Cursor::PencilNew.precise(), Cursor::Crosshair);
// Its badged cursors too: Use Precise Cursors shows the plain crosshair.
for c in [Cursor::PencilNew, Cursor::PencilClose, Cursor::PencilStraight, Cursor::PencilContinue, Cursor::PaintbrushClose] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
assert_eq!(Cursor::PencilNew.stand_in(), None);
assert_eq!(Cursor::Pencil.precise(), Cursor::Crosshair);
assert_eq!(Cursor::Pencil.stand_in(), Some(Cursor::Crosshair));
+54 -4
View File
@@ -48,6 +48,54 @@ pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) {
(nw, nh)
}
/// The `w × h` RGBA8 `rgba` reduced to `nw × nh` (at most `w × h`): each pixel covers a block of
/// whole source pixels, and every source pixel falls in exactly one block. A pixel's alpha is the
/// mean of its block's alphas, and its color is the block's colors weighted by their alpha, so a
/// clear pixel next to an opaque one does not darken or tint it. A wholly clear block (every alpha
/// 0) keeps the plain mean of its colors.
pub(crate) fn box_reduced(rgba: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec<u8> {
let span = |i: u32, n: u32, of: u32| {
let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX);
let start = at(i);
start..at(i + 1).max(start + 1)
};
let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect();
let mut out = Vec::with_capacity(nw as usize * nh as usize * 4);
// Per output column: the alpha-weighted color sums, the alpha sum and the plain color sums.
let mut sums = vec![[0u64; 7]; cols.len()];
let row_len = w as usize * 4;
for ty in 0..nh {
let rows = span(ty, nh, h);
sums.fill([0; 7]);
for y in rows.clone() {
let Some(row) = rgba.get(y * row_len..(y + 1) * row_len) else { break };
for (s, c) in sums.iter_mut().zip(&cols) {
let px = row.get(c.start * 4..c.end * 4).unwrap_or_default();
for &[r, g, b, a] in px.as_chunks::<4>().0 {
let a64 = u64::from(a);
s[0] += u64::from(r) * a64;
s[1] += u64::from(g) * a64;
s[2] += u64::from(b) * a64;
s[3] += a64;
s[4] += u64::from(r);
s[5] += u64::from(g);
s[6] += u64::from(b);
}
}
}
for (s, c) in sums.iter().zip(&cols) {
let count = (rows.len() * c.len()).max(1) as u64;
let mean = |v: u64, n: u64| u8::try_from((v + n / 2) / n).unwrap_or(u8::MAX);
if s[3] == 0 {
out.extend_from_slice(&[mean(s[4], count), mean(s[5], count), mean(s[6], count), 0]);
} else {
out.extend_from_slice(&[mean(s[0], s[3]), mean(s[1], s[3]), mean(s[2], s[3]), mean(s[3], count)]);
}
}
}
out
}
/// An RGBA8 raster (row-major, top row first).
#[derive(Clone, Debug, PartialEq)]
pub struct Raster {
@@ -150,12 +198,14 @@ impl Raster {
let mut limits = image::Limits::default();
limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 });
reader.limits(limits);
let img = reader.decode().map_err(decode_err)?.into_rgba8();
Ok(Self::fitted(&reader.decode().map_err(decode_err)?.into_rgba8(), MAX_PIXELS))
}
/// `img` scaled down, aspect kept, to the largest size of at most `max` pixels.
pub(crate) fn fitted(img: &image::RgbaImage, max: u64) -> Self {
let (w, h) = img.dimensions();
let (nw, nh) = fit_size(w, h, MAX_PIXELS);
let (nw, nh) = fit_size(w, h, max);
// Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive.
let small = image::imageops::thumbnail(&img, nw, nh);
Ok(Self { width: nw, height: nh, rgba: small.into_raw() })
Self { width: nw, height: nh, rgba: box_reduced(img.as_raw(), w, h, nw, nh) }
}
/// Encode as PNG.
pub fn encode_png(&self) -> Vec<u8> {
+24
View File
@@ -1085,6 +1085,30 @@ fn decode_to_fit_scales_an_oversized_image_down() {
assert!(matches!(Raster::decode_to_fit(&png_header(9000, 9000)), Err(TraceError::Decode(_))));
}
/// Scaling an oversized image down weighs each color by its alpha: a clear pixel next to an
/// opaque one leaves the opaque color as it is and only lowers the alpha.
#[test]
fn scaling_down_weighs_color_by_alpha() {
use crate::raster::box_reduced;
const RED: [u8; 4] = [200, 30, 30, 255];
// Opaque red and clear black columns, decoded from a PNG and scaled to fit as `decode_to_fit`
// does: each scaled pixel covers a red and a clear one.
let half_clear = Raster::from_fn(4, 2, |x, _| if x % 2 == 0 { RED } else { [0; 4] });
let decoded = image::load_from_memory(&half_clear.encode_png()).unwrap().into_rgba8();
let fitted = Raster::fitted(&decoded, 2);
assert_eq!((fitted.width, fitted.height), (2, 1));
assert_eq!(fitted.rgba, [[200, 30, 30, 128]; 2].concat());
// A partly clear blue pulls an opaque red toward it by its alpha.
let mixed = Raster::from_fn(2, 1, |x, _| if x == 0 { [255, 0, 0, 255] } else { [0, 0, 255, 85] });
assert_eq!(box_reduced(&mixed.rgba, 2, 1, 1, 1), [191, 0, 64, 170]);
// A wholly clear block stays clear, with the plain mean of its colors.
let clear = Raster::from_fn(2, 1, |x, _| [100 * x as u8, 0, 0, 0]);
assert_eq!(box_reduced(&clear.rgba, 2, 1, 1, 1), [50, 0, 0, 0]);
// A 3 → 2 reduction counts every source pixel once.
let ramp = Raster::from_fn(3, 3, |x, y| [(x * 60 + y * 10) as u8, 0, 0, 255]);
assert_eq!(box_reduced(&ramp.rgba, 3, 3, 2, 2), [0, 0, 0, 255, 90, 0, 0, 255, 15, 0, 0, 255, 105, 0, 0, 255]);
}
/// Four flat colours with anti-aliased (blended) edges between the quadrants.
fn flat_quads() -> Raster {
const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]];
+5 -7
View File
@@ -677,18 +677,16 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(3.0, HALO));
ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(1.2, INK));
}
// The Pencil's crosshair with a short straight line where the closing ring goes.
// The Pencil's crosshair with a short level line where the closing ring goes.
Cursor::PencilStraight => {
crosshair(ink, p);
line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0));
line(ink, p + vec2(3.55, 8.5), p + vec2(13.45, 8.5));
}
// The straight-line cursor's line leaving a filled square: the end point a line continues from.
// The Pencil's crosshair with a short line rising to the right where the closing ring goes:
// the stroke carries on from the path.
Cursor::PencilContinue => {
crosshair(ink, p);
// The square first, so its halo does not cover the line.
let end = p + vec2(5.0, 12.0);
poly(ink, [(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)].map(|(x, y)| end + vec2(x, y)).to_vec(), INK, INK);
line(ink, end, p + vec2(12.0, 5.0));
line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0));
}
Cursor::Eyedropper => eyedropper(ink, p),
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),

Some files were not shown because too many files have changed in this diff Show More