Merge main into fix/graph-series-paint-other-commands

This commit is contained in:
dotCooCoo
2026-11-13 08:49:45 -08:00
116 changed files with 6130 additions and 517 deletions
+15
View File
@@ -140,6 +140,21 @@ pub fn tiff_orientation(bytes: &[u8]) -> Option<u16> {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::Orientation).ok().flatten()
}
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with
/// extra samples) has its Orientation tag read from its first image ([`tiff_orientation`]). Reads
/// only the image's header and metadata; `None` for an [`oversized_tiff`], which no decoder opens.
pub fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> {
use image::ImageDecoder as _;
use image::metadata::Orientation;
if oversized_tiff(bytes).is_some() {
return None;
}
let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok();
let tagged = || tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?));
decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms)
}
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
+19
View File
@@ -144,6 +144,25 @@ impl Selection {
self.key = None;
}
}
/// [`Self::remove`] for each of `ids`, going through the selected objects once.
pub fn remove_all(&mut self, ids: impl IntoIterator<Item = NodeId>) {
let ids: HashSet<NodeId> = ids.into_iter().collect();
if ids.is_empty() {
return;
}
self.target = None;
self.slices.clear();
self.symbol_part = None;
self.on_path_member = None;
self.objects.retain(|x| !ids.contains(x));
for id in &ids {
self.anchors.remove(id);
self.segments.remove(id);
}
if self.key.is_some_and(|k| ids.contains(&k)) {
self.key = None;
}
}
/// Select slices `ids` (see [`Selection::slices`]) and nothing else.
pub fn set_slices(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
+29
View File
@@ -16,6 +16,35 @@ fn basic_ops() {
assert!(s.is_empty());
}
#[test]
fn remove_all_does_what_removing_each_does() {
let mut s = Selection::default();
for i in 1..=6 {
s.add(NodeId(i));
}
for i in [2, 4, 5] {
s.anchors.insert(NodeId(i), BTreeSet::from([(0, 1)]));
s.segments.insert(NodeId(i), BTreeSet::from([(0, 0)]));
}
s.key = Some(NodeId(4));
s.target = Some(NodeId(3));
s.slices = vec![NodeId(9)];
s.guides = vec![NodeId(10)];
s.on_path_member = Some(NodeId(11));
let mut one_by_one = s.clone();
for id in [NodeId(4), NodeId(1), NodeId(4), NodeId(7)] {
one_by_one.remove(id);
}
let mut all = s.clone();
all.remove_all([NodeId(4), NodeId(1), NodeId(4), NodeId(7)]);
assert_eq!(all, one_by_one);
assert_eq!(all.objects, [NodeId(2), NodeId(3), NodeId(5), NodeId(6)]);
// Removing nothing leaves the selection as it was.
let mut none = s.clone();
none.remove_all([]);
assert_eq!(none, s);
}
#[test]
fn guides_are_selected_on_their_own() {
let mut s = Selection::default();
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn is_revolve(id: &str) -> bool {
/// Most bytes the copies of the object's paint in the faces may take together. Each face and the
/// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of
/// thousands of faces took gigabytes; past this the source path is drawn.
const MAX_PAINT_COPIES: usize = 256 << 20;
pub(crate) const MAX_PAINT_COPIES: usize = 256 << 20;
/// The most face and box tests a group's transparent members' faces take together to rejoin their
/// members' last runs; past them each starts a run of its own.
+583 -54
View File
@@ -5,8 +5,9 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2};
use vectorcraft_pathops::BoolOp;
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View};
#[cfg(test)]
use vectorcraft_doc::color::Color;
@@ -678,7 +679,8 @@ fn built_solids<'a>(
/// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's
/// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of
/// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its
/// strokes that covers the fill's whole edge.
/// strokes that covers the fill's whole edge. A face that [`convex_face`] takes also keeps its
/// polygon and plane, for [`local_order`]; every other face has an empty `outline` and no `plane`.
struct Placed {
depth: f64,
side: bool,
@@ -686,6 +688,8 @@ struct Placed {
bounds: Rect,
member: usize,
paints: Paints,
outline: Vec<Point>,
plane: Option<DepthPlane>,
}
/// The part of its shape a face's solid is built from ([`Placed`]).
@@ -694,13 +698,22 @@ enum Paints {
Fill,
/// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without
/// dashes, a width profile or a brush, painted with a solid color or a gradient without
/// transparent stops, on a shape whose subpaths are all closed.
/// transparent stops, on a shape whose subpaths are all closed, extruded (an inflated fill can
/// rise in front of its stroke).
Stroke,
Other,
}
/// Can a stroke's solid on a shape with outline `path` hold the fill's edge ([`Paints::Stroke`])
/// under the effect `effect`? Only when the subpaths are all closed and the effect extrudes: an
/// inflated fill can rise in front of its stroke.
fn holds_edge(effect: &str, path: &PathData) -> bool {
effect != INFLATE && path.subpaths.iter().all(|p| p.closed)
}
impl Paints {
/// What `item` of a shape is, the shape's subpaths all closed when `closed`.
/// What `item` of a shape is, its stroke's solid able to hold the fill's edge when `closed`
/// ([`holds_edge`]).
fn of(item: &AppearanceItem, closed: bool) -> Self {
match item {
AppearanceItem::Fill(_) => Paints::Fill,
@@ -729,10 +742,446 @@ impl Placed {
fn of(f: &Facet, member: usize, paints: Paints) -> Self {
let mut points = f.contours.iter().flatten();
let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q)));
Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints }
let local = convex_face(f).is_some();
let outline = f.contours.first().filter(|_| local).cloned().unwrap_or_default();
let plane = f.plane.filter(|_| local);
Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints, outline, plane }
}
}
/// The most pairs of faces [`local_order`] compares, and the most [`cut_stroke_faces`] compares;
/// the pairs they have not reached by then keep the order of their depths.
const MAX_LOCAL_CHECKS: usize = 4_000_000;
/// The polygon of `f` and the sign of its turns, when `f` is a face turned toward the viewer other
/// than a planar end, with a plane and one convex contour: a face [`local_order`] and
/// [`cut_stroke_faces`] work on.
fn convex_face(f: &Facet) -> Option<(&[Point], f64)> {
if f.away || f.end.is_some() || f.plane.is_none() || f.contours.len() != 1 {
return None;
}
let c = f.contours.first()?;
Some((c, convex(c)?))
}
/// The bounds of polygon `p` (`Rect::ZERO` when it has no points).
fn polygon_bounds(p: &[Point]) -> Rect {
let mut points = p.iter();
points.next().map_or(Rect::ZERO, |q| points.fold(Rect::from_points(*q, *q), |r, q| r.union_pt(*q)))
}
/// Do rectangles `a` and `b` overlap (more than along an edge)?
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
}
/// 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.
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));
copies
})
.max()
.unwrap_or(0)
.max(1);
let affordable = crate::revolve::MAX_PAINT_COPIES.saturating_sub(paint_bytes) / per_face;
MAX_OBJECT_FACES.saturating_sub(faces).min(affordable)
}
/// In `solids`, each solid's part ([`Paints`]), its shape's index and its faces, cut each face of a
/// stroke that does not cover the fill's edge ([`Paints::Other`]) that passes through the plane of a
/// face of the same shape's fill, or of a face of another of the shape's strokes, where the two
/// overlap on the drawing ([`cut_through`]). Of two such strokes, the later one's faces are cut
/// against the earlier one's; a covering stroke's faces ([`Paints::Stroke`]) are never cut, and an
/// other stroke's faces are cut against them. At most `room` pieces are added, the object's solids
/// together.
fn cut_stroke_faces(solids: &mut [(Paints, usize, &mut Vec<Facet>)], mut room: usize) {
let mut checks = 0usize;
for k in 0..solids.len() {
let Some((Paints::Other, shape)) = solids.get(k).map(|(p, s, _)| (*p, *s)) else { continue };
let walls: Vec<usize> = (0..solids.len())
.filter(|&j| {
solids.get(j).is_some_and(|(p, s, _)| {
*s == shape
&& match p {
Paints::Fill | Paints::Stroke => true,
Paints::Other => j < k,
}
})
})
.collect();
for j in walls {
let (wall, stroke) = if j < k {
let (before, from) = solids.split_at_mut(k);
(&**before[j].2, &mut *from[0].2)
} else {
let (before, from) = solids.split_at_mut(j);
(&**from[0].2, &mut *before[k].2)
};
cut_through(wall, stroke, &mut room, &mut checks);
}
}
}
/// Cut each face of `stroke` that passes through the plane of a face of `solid` (the fill's or
/// another stroke's) where their polygons overlap on the drawing, along the line where their depths
/// meet, so that each piece is wholly in front of or behind that face there ([`local_order`] can
/// then order them). A dash's end, which crosses the edge of the fill it is drawn on, is cut into its
/// part outside the fill, in front of the fill's wall, and its part inside, behind it. A piece keeps
/// the part of the face's curved outline on its side of the cut. The first piece of a face takes its
/// place, the others go at the end. Each cut adds a face out of `room`, and each pair compared counts
/// in `checks` ([`MAX_LOCAL_CHECKS`]); cutting stops when either runs out.
fn cut_through(solid: &[Facet], stroke: &mut Vec<Facet>, room: &mut usize, checks: &mut usize) {
// The faces from left to right: a face is compared with those that start left of its end.
let mut walls: Vec<(&Facet, &[Point], f64, Rect)> =
solid.iter().filter_map(|f| convex_face(f).map(|(c, turn)| (f, c, turn, polygon_bounds(c)))).collect();
if walls.is_empty() {
return;
}
walls.sort_by(|a, b| a.3.x0.total_cmp(&b.3.x0));
let mut k = 0;
while k < stroke.len() {
let mut extent = stroke.get(k).and_then(convex_face).map(|(c, _)| polygon_bounds(c));
for &(wall, outline, turn, bounds) in &walls {
let Some(at) = extent.filter(|e| bounds.x0 < e.x1) else { break };
if *checks >= MAX_LOCAL_CHECKS || *room == 0 {
return;
}
*checks += 1;
if !rects_overlap(at, bounds) {
continue;
}
let Some(s) = stroke.get(k) else { break };
let Some((c, _)) = convex_face(s) else { break };
if let Some((near, far)) = cut(s, c, wall, outline, turn) {
*room -= 1;
extent = convex_face(&near).map(|(c, _)| polygon_bounds(c));
stroke[k] = near;
stroke.push(far);
}
}
k += 1;
}
}
/// Face `s` (polygon `c`) cut where its depth meets that of face `wall` (convex polygon `outline`,
/// turning with sign `turn`): its piece nearer the viewer than the wall's plane and its piece
/// farther. `None` when the two do not overlap on the drawing or `s` is on one side of the wall's
/// plane wherever they do.
fn cut(s: &Facet, c: &[Point], wall: &Facet, outline: &[Point], turn: f64) -> Option<(Facet, Facet)> {
let ahead = |p: Point| Some(s.depth_at(p)? - wall.depth_at(p)?);
let overlap = clip_convex(c, outline, turn);
centroid(&overlap)?;
let tolerance = 1e-6;
let (mut nearer, mut farther) = (false, false);
for p in &overlap {
let d = ahead(*p)?;
nearer |= d > tolerance;
farther |= d < -tolerance;
}
if !(nearer && farther) {
return None;
}
let (a, b) = (s.plane?, wall.plane?);
let values: Vec<f64> = match (a.eye, b.eye) {
(Some(e), Some(f)) if e == f && a.center == b.center => {
// With perspective the depths themselves are not linear on the drawing, but where they
// meet is a line: (dA - aA e)(nzB - aB) = (dB - aB e)(nzA - aA), with aX = (nX · p) / e,
// whose difference is linear. Its sign is that of the depths' difference over the
// product of the two denominators, taken at the middle of the face.
let at = |p: Point| {
let (x, y) = (p.x - a.center.x, p.y - a.center.y);
let (aa, ab) = ((a.normal[0] * x + a.normal[1] * y) / e, (b.normal[0] * x + b.normal[1] * y) / e);
let (da, db) = (a.normal[2] - aa, b.normal[2] - ab);
((a.offset - aa * e) * db - (b.offset - ab * e) * da, da * db)
};
let sign = at(centroid(c)?).1.signum();
c.iter().map(|p| at(*p).0 * sign).collect()
}
_ => c.iter().map(|p| ahead(*p)).collect::<Option<_>>()?,
};
let piece = |sign: f64| {
let points = half(c, &values, sign);
let mid = centroid(&points)?;
let mut f = s.clone();
f.depth = s.depth_at(mid).unwrap_or(s.depth);
f.contours = vec![points];
f.outline = s.outline.as_ref().and_then(|o| clip_to_side(o, c, &values, sign));
Some(f)
};
Some((piece(1.0)?, piece(-1.0)?))
}
/// Curved outline `outline` of a face with polygon `c`, cut along the line where `values` (one per
/// point of `c`, varying linearly along each edge) is 0, keeping the side where `sign` times them is
/// at least 0. `None` when the line does not cross `c` at two points or nothing is left.
fn clip_to_side(outline: &PathData, c: &[Point], values: &[f64], sign: f64) -> Option<PathData> {
let mut crossings = vec![];
for (j, (&a, &va)) in c.iter().zip(values).enumerate() {
let k = (j + 1) % c.len();
let (Some(&b), Some(&vb)) = (c.get(k), values.get(k)) else { continue };
if (va >= 0.0) != (vb >= 0.0) {
crossings.push(a.lerp(b, va / (va - vb)));
}
}
let &[q0, q1] = crossings.as_slice() else { return None };
let along = q1 - q0;
let length = along.hypot();
if length.is_nan() || length <= 1e-9 {
return None;
}
let along = along / length;
let (inside, _) = c.iter().zip(values).find(|(_, v)| **v * sign > 0.0)?;
let mut across = Vec2::new(-along.y, along.x);
if (*inside - q0).dot(across) < 0.0 {
across = -across;
}
let bounds = outline.bounds()?.union(polygon_bounds(c));
let reach = 2.0 * (bounds.width() + bounds.height() + (q0 - bounds.center()).hypot()) + 1.0;
let mut side = BezPath::new();
side.move_to(q0 - along * reach);
side.line_to(q0 + along * reach);
side.line_to(q0 + along * reach + across * reach);
side.line_to(q0 - along * reach + across * reach);
side.close_path();
let kept = vectorcraft_pathops::boolean(outline, FillRule::NonZero, &PathData::from_bezpath(&side), FillRule::NonZero, BoolOp::Intersect);
kept.bounds().is_some().then_some(kept)
}
/// The part of polygon `p` where `sign` times `values` (one per point, varying linearly along each
/// edge) is at least 0.
fn half(p: &[Point], values: &[f64], sign: f64) -> Vec<Point> {
let mut out = vec![];
for (j, (&a, &va)) in p.iter().zip(values).enumerate() {
let k = (j + 1) % p.len();
let (Some(&b), Some(&vb)) = (p.get(k), values.get(k)) else { continue };
let (sa, sb) = (va * sign, vb * sign);
if sa >= 0.0 {
out.push(a);
}
if (sa >= 0.0) != (sb >= 0.0) {
out.push(a.lerp(b, sa / (sa - sb)));
}
}
out
}
/// `order` (back to front), when it draws a face of one of a shape's strokes and an overlapping face
/// of the same shape's fill or strokes the wrong way round for their depths where they overlap on
/// the drawing, with every pair of overlapping faces that keep a polygon and a plane
/// ([`convex_face`]: turned toward the viewer, not a planar end), of any shape, put in the order of
/// their depths where they overlap: the face nearer the viewer there goes after the other. A fill
/// wall and a face of the same shape's covering stroke ([`Paints::Stroke`]) keep their order. Each
/// face otherwise keeps its place in `order`, so a fill wall in the gap between two dashes stays
/// where its depth puts it. A cycle of such pairs is broken at the face that comes first in `order`.
/// A face drawn last (a planar end turned toward the viewer, at depth `f64::MAX`) keeps its place:
/// no face moves across it. Otherwise `order` as it is.
fn local_order(faces: &[Placed], order: Vec<usize>) -> Vec<usize> {
use std::cmp::Reverse;
// The faces of each shape that take part, from left to right for a sweep along x.
let mut shapes: Vec<Vec<usize>> = vec![];
for (k, f) in faces.iter().enumerate() {
if f.outline.len() < 3 || f.plane.is_none() {
continue;
}
if shapes.len() <= f.member {
shapes.resize_with(f.member + 1, Vec::new);
}
shapes[f.member].push(k);
}
if !shapes.iter().any(|list| list.iter().any(|&k| faces[k].paints != Paints::Fill)) {
return order;
}
for list in &mut shapes {
list.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0));
}
// A fill wall and a face of the same shape's covering stroke keep the order [`draw_order`] gave
// them: the stroke's solid holds the wall, which never shows.
let covered = |f: &Placed, s: &Placed| f.paints == Paints::Fill && f.side && s.paints == Paints::Stroke && f.member == s.member;
// Is there a face of a shape's strokes and a face of the same shape's fill or strokes that the
// order of their depths draws the wrong way round where they overlap?
let mut position = vec![0usize; faces.len()];
for (at, &k) in order.iter().enumerate() {
position[k] = at;
}
let mut checks = 0usize;
let mut misordered = false;
'shapes: for list in &shapes {
for (i, &f) in list.iter().enumerate() {
let a = &faces[f];
for &s in list.iter().skip(i + 1).take_while(|&&s| faces[s].bounds.x0 < a.bounds.x1) {
if checks >= MAX_LOCAL_CHECKS {
break 'shapes;
}
checks += 1;
let b = &faces[s];
if (a.paints == Paints::Fill && b.paints == Paints::Fill) || covered(a, b) || covered(b, a) || !rects_overlap(a.bounds, b.bounds) {
continue;
}
if nearer_where_overlapping(a, b).is_some_and(|a_nearer| a_nearer != (position[f] > position[s])) {
misordered = true;
break 'shapes;
}
}
}
}
if !misordered {
return order;
}
// Moving those faces must not carry them past any other face they overlap, so every pair of
// faces with a polygon and a plane that overlap goes in the order of their depths there.
let mut sweep: Vec<usize> = (0..faces.len()).filter(|&k| faces[k].outline.len() >= 3 && faces[k].plane.is_some()).collect();
sweep.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0));
// For each face, the faces drawn after it, and for each, how many go before it.
let mut after: Vec<Vec<usize>> = vec![vec![]; faces.len()];
let mut waits = vec![0usize; faces.len()];
'pairs: for (i, &k) in sweep.iter().enumerate() {
let a = &faces[k];
for &j in sweep.iter().skip(i + 1).take_while(|&&j| faces[j].bounds.x0 < a.bounds.x1) {
if checks >= MAX_LOCAL_CHECKS {
break 'pairs;
}
checks += 1;
let b = &faces[j];
if !rects_overlap(a.bounds, b.bounds) {
continue;
}
let a_later = if covered(a, b) || covered(b, a) {
position[k] > position[j]
} else {
let Some(a_nearer) = nearer_where_overlapping(a, b) else { continue };
a_nearer
};
let (back, front) = if a_later { (j, k) } else { (k, j) };
after[back].push(front);
waits[front] += 1;
}
}
// Each step draws the face that comes first in `order` of those whose faces before are drawn.
let mut ready: std::collections::BinaryHeap<Reverse<(usize, usize)>> =
(0..faces.len()).filter(|&k| waits[k] == 0).map(|k| Reverse((position[k], k))).collect();
// The faces drawn last, in `order`: each goes only after every face before it in `order`.
let last: Vec<usize> = order.iter().copied().filter(|&k| faces[k].depth >= f64::MAX).collect();
let mut next_last = 0usize;
let mut drawn = vec![false; faces.len()];
let mut out = Vec::with_capacity(order.len());
let mut first = 0usize;
while out.len() < order.len() {
while last.get(next_last).is_some_and(|&k| drawn[k]) {
next_last += 1;
}
let limit = last.get(next_last).map_or(usize::MAX, |&k| position[k]);
let k = match ready.peek() {
Some(&Reverse((at, k))) if at < limit => {
ready.pop();
k
}
_ => {
// A cycle, or the faces left to draw before a face drawn last wait for faces after
// it: the first face in `order` not drawn yet goes next.
while order.get(first).is_some_and(|&k| drawn[k]) {
first += 1;
}
let Some(&k) = order.get(first) else { break };
k
}
};
if drawn[k] {
continue;
}
drawn[k] = true;
out.push(k);
for &j in &after[k] {
waits[j] = waits[j].saturating_sub(1);
if waits[j] == 0 && !drawn[j] {
ready.push(Reverse((position[j], j)));
}
}
}
out
}
/// Whether face `a` is nearer the viewer than face `b` where their polygons overlap on the drawing,
/// measured at the middle of the overlap. `None` when they do not overlap, when either polygon is
/// not convex, or when they are at the same depth there.
fn nearer_where_overlapping(a: &Placed, b: &Placed) -> Option<bool> {
let (pa, pb) = (a.plane?, b.plane?);
let turn = convex(&b.outline)?;
convex(&a.outline)?;
let overlap = clip_convex(&a.outline, &b.outline, turn);
let mid = centroid(&overlap)?;
let (da, db) = (pa.depth_at(mid)?, pb.depth_at(mid)?);
let tolerance = 1e-6 * (1.0 + da.abs().max(db.abs()));
((da - db).abs() > tolerance).then_some(da > db)
}
/// The sign of the turns of convex polygon `p` (`None` when it is not convex or has no area).
fn convex(p: &[Point]) -> Option<f64> {
let n = p.len();
let mut sign = 0.0;
for i in 0..n {
let (a, b, c) = (p[i], p[(i + 1) % n], p[(i + 2) % n]);
let cross = (b - a).cross(c - b);
if cross.abs() <= 1e-12 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return None;
}
}
(sign != 0.0).then_some(sign)
}
/// The part of polygon `subject` inside convex polygon `clip`, whose turns have sign `turn`
/// (Sutherland–Hodgman).
fn clip_convex(subject: &[Point], clip: &[Point], turn: f64) -> Vec<Point> {
let mut out = subject.to_vec();
for i in 0..clip.len() {
if out.is_empty() {
break;
}
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
let side = |p: Point| (e1 - e0).cross(p - e0) * turn;
let input = std::mem::take(&mut out);
for j in 0..input.len() {
let (p, q) = (input[j], input[(j + 1) % input.len()]);
let (sp, sq) = (side(p), side(q));
if sp >= 0.0 {
out.push(p);
}
if (sp >= 0.0) != (sq >= 0.0) {
let t = sp / (sp - sq);
out.push(p.lerp(q, t));
}
}
}
out
}
/// The centroid of polygon `p`, `None` when it has (almost) no area. It is measured from the
/// polygon's first point, so a small polygon far from the origin keeps its area.
fn centroid(p: &[Point]) -> Option<Point> {
let origin = *p.first()?;
let n = p.len();
let (mut area, mut cx, mut cy) = (0.0, 0.0, 0.0);
for i in 0..n {
let (a, b) = (p[i] - origin, p[(i + 1) % n] - origin);
let cross = a.x * b.y - b.x * a.y;
area += cross;
cx += (a.x + b.x) * cross;
cy += (a.y + b.y) * cross;
}
(area.abs() > 1e-9).then(|| origin + Vec2::new(cx / (3.0 * area), cy / (3.0 * area)))
}
/// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep
/// their depths.
const MAX_OVERLAP_CHECKS: usize = 4_000_000;
@@ -742,7 +1191,8 @@ const MAX_OVERLAP_CHECKS: usize = 4_000_000;
/// of a shape's fill turned toward the viewer is drawn before each face of the same shape's
/// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own.
/// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under
/// any other face.
/// any other face. A shape's fill faces and the faces of its other strokes then go in the order of
/// their depths where they overlap ([`local_order`]).
fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
// Each face's depth to sort by, and whether it was moved before a stroke face at that depth.
let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect();
@@ -781,7 +1231,7 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
// A stable sort: faces at the same depth keep the order of the appearance, and a fill's side
// face moved to a stroke face's depth goes before it.
order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1)));
order
local_order(faces, order)
}
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
@@ -806,7 +1256,9 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as
/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's
/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the
/// fill walls before the faces of its covering strokes that overlap them, and its fill's faces and
/// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the
/// order of their depths where they overlap: [`draw_order`]), the
/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask
/// ([`in_runs`]);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
@@ -995,18 +1447,22 @@ type BuiltSurface<'s> = (Vec<Placed>, Node, &'s [Scope], Vec<Option<PlanarFace>>
/// [`with_graphics`] of built surfaces, each drawn whole.
fn whole_surfaces<'s>(surfaces: Vec<BuiltSurface<'s>>, arts: &[(usize, PlanarFace, Arc<Node>)]) -> Vec<(&'s [Scope], Arc<Node>)> {
with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), arts)
with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), 0, arts)
}
/// `surfaces` drawn whole, each with its scopes, and the graphics `arts` ([`placed_graphics`]) each
/// right after its surface, with that surface's scopes and its opacity mask (a gradient's
/// transparency), so a graphic is hidden where its cap is.
fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc<Node>)>, arts: &[(usize, PlanarFace, Arc<Node>)]) -> Vec<(&'s [Scope], Arc<Node>)> {
/// `surfaces` (those from index `first`) drawn whole, each with its scopes, and the graphics `arts`
/// ([`placed_graphics`]) of those surfaces each right after its surface, with that surface's scopes
/// and its opacity mask (a gradient's transparency), so a graphic is hidden where its cap is.
fn with_graphics<'s>(
surfaces: Vec<(&'s [Scope], Arc<Node>)>,
first: usize,
arts: &[(usize, PlanarFace, Arc<Node>)],
) -> Vec<(&'s [Scope], Arc<Node>)> {
let mut out = Vec::with_capacity(surfaces.len() + arts.len());
for (k, (scopes, s)) in surfaces.into_iter().enumerate() {
let mask = s.extras().mask.clone();
out.push((scopes, s));
out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| {
out.extend(arts.iter().filter(|(at, ..)| *at == first + k).map(|(.., art)| {
let mut art = art.clone();
if let Some(mask) = &mask {
Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone());
@@ -1058,7 +1514,7 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
}
}
let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks);
let out = with_graphics(out, &arts);
let out = with_graphics(out, 0, &arts);
return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() });
}
let (parts, bounds) = solid_parts(shapes, b);
@@ -1085,6 +1541,43 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
let (_, opacity, blend) = paint_of(item, *member);
opacity >= 1.0 && blend == BlendMode::Normal
});
let closed = |shape: usize| shapes.get(shape).is_some_and(|s| holds_edge(&fx.id, &s.path));
// A solid whose surface is one composite of all its facets ([`surface`]: a solid color under
// colored lights with smooth shading) has no faces to order.
let composite = |item: &AppearanceItem, f: &[Facet]| colored && paint_ref(item).color().is_some() && f.iter().any(|f| f.rgb_gradient.is_some());
// The faces are drawn in one order (below) unless a composite draws the surfaces whole. Under
// a member's opacity mask or blending, each member's faces go in one order of their own when its
// fills and strokes are opaque, blend normally, carry no opacity mask and are not composites.
let one_order = opaque && !solids.iter().any(|(item, _, _, f, _)| composite(item, f));
let shape_order: Vec<bool> = (0..shapes.len())
.map(|shape| {
solids.iter().filter(|s| s.4 == shape).all(|(item, member, _, f, _)| {
let (paint, opacity, blend) = paint_of(item, *member);
opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && !composite(item, f)
})
})
.collect();
let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order };
if solids.iter().any(|s| ordered(s.4)) {
// A stroke face that passes through a fill wall or another stroke's face is cut where it
// does. The pieces count against the limits as the faces did: cutting stops before the
// faces pass MAX_OBJECT_FACES or their paint copies pass the allowance, so an effect that
// draws uncut still draws.
let counts: Vec<usize> = solids.iter().map(|(.., f, _)| f.len()).collect();
let room = cut_room(solids.iter().map(|(item, ..)| *item), counts.iter().sum(), paint_bytes);
let mut cuts: Vec<(Paints, usize, &mut Vec<Facet>)> = solids
.iter_mut()
.filter(|(.., shape)| ordered(*shape))
.map(|(item, _, _, f, shape)| (Paints::of(item, closed(*shape)), *shape, f))
.collect();
cut_stroke_faces(&mut cuts, room);
for ((item, _, _, f, _), before) in solids.iter().zip(counts) {
let added = f.get(before..).unwrap_or_default();
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(added, paint_ref(item), colored), paint_ref(item)) {
return None;
}
}
}
let arts = if !graphics_on {
vec![]
} else {
@@ -1100,11 +1593,11 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
.collect();
placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks)
};
let members: Vec<usize> = solids.iter().map(|s| s.4).collect();
let surfaces: Vec<BuiltSurface> = solids
.into_iter()
.map(|(item, member, scopes, f, shape)| {
let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed));
let paints = Paints::of(item, closed);
let paints = Paints::of(item, closed(shape));
let depths: Vec<Placed> = f.iter().map(|f| Placed::of(f, shape, paints)).collect();
let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect();
let (paint, opacity, blend) = paint_of(item, member);
@@ -1112,50 +1605,86 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
(depths, art, scopes, ends)
})
.collect();
if masks {
return Some(masked(n.id, whole_surfaces(surfaces, &arts)));
}
// One order takes a plain group of faces from each solid, one face for each facet. With
// colored lights a surface can instead be one clipped composite of all its facets. A composite
// has no faces to order, so when any surface is one, every surface of the solid, masked ones
// included, draws whole in member order.
let plain =
surfaces.iter().all(|(depths, s, _, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
// A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs
// ([`in_runs`]), and each run carries the mask. With graphics as well, the surfaces draw whole,
// each graphic right after its surface.
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect();
let masked_faces = masks.iter().any(Option::is_some);
if !opaque || !plain || (masked_faces && !arts.is_empty()) {
return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect());
}
let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]);
for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() {
if let NodeKind::Group { children, .. } = s.kind {
placed.extend(depths);
faces.extend(children.into_iter().map(|face| Some((surface, face))));
ends.extend(e);
}
}
let sealed = match fx.id.as_str() {
INFLATE => inflate_options(&p).cap,
CLASSIC_EXTRUDE => classic_extrude_options(&p).cap,
_ => extrude_options(&p).cap,
};
// One order takes a plain group of faces from each solid, one face for each facet. With
// colored lights a surface can instead be one clipped composite of all its facets. A composite
// has no faces to order, so it draws whole.
let plain = |(depths, s, _, _): &BuiltSurface| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len());
if masks {
// Each member's surfaces, in the scopes of its opacity mask or blending: its faces in one
// order when it is opaque and plain, otherwise each surface whole.
let mut out = vec![];
let mut surfaces = surfaces.into_iter().zip(members).enumerate().peekable();
while let Some((first, (s, member))) = surfaces.next() {
let mut run = vec![s];
while let Some((_, (s, _))) = surfaces.next_if(|(_, (_, m))| *m == member) {
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());
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)))));
} else {
out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts));
}
}
return Some(masked(n.id, out));
}
if !opaque || !surfaces.iter().all(plain) {
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)))])
}
/// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in
/// one order ([`draw_order`], `sealed` for solids with caps), each of the graphics `arts` of those
/// 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>> {
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![]);
for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() {
if let NodeKind::Group { children, .. } = s.kind {
placed.extend(depths);
faces.extend(children.into_iter().map(|face| Some((surface, face))));
ends.extend(e.into_iter().map(|end| end.map(|end| (surface, end))));
}
}
let order = draw_order(&placed, sealed);
if masked_faces {
let faces: Vec<(usize, Arc<Node>)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect();
return Some(vec![Arc::new(Node::group(n.id, in_runs(n.id, faces, &masks)))]);
}
// Each face's graphics go right after the last of the faces they are drawn on.
let last: Vec<Option<usize>> =
arts.iter().map(|(_, face, _)| order.iter().rposition(|k| ends.get(*k).copied().flatten() == Some(*face))).collect();
let mut children = Vec::with_capacity(order.len() + arts.len());
// Each graphic goes right after the last of the faces it is drawn on, among the surfaces that
// share it ([`placed_graphics`]): a masked surface's own, or else the run of surfaces without a
// mask that ends at its surface.
let last: Vec<Option<usize>> = arts
.iter()
.map(|(at, face, _)| {
let at = at - first;
let from = if masks.get(at).is_some_and(Option::is_some) {
at
} else {
masks.get(..at).and_then(|before| before.iter().rposition(Option::is_some)).map_or(0, |k| k + 1)
};
order.iter().rposition(|k| ends.get(*k).copied().flatten().is_some_and(|(surface, end)| end == *face && (from..=at).contains(&surface)))
})
.collect();
let mut drawn: Vec<(usize, Arc<Node>)> = Vec::with_capacity(order.len() + arts.len());
for (at, k) in order.into_iter().enumerate() {
children.extend(faces.get_mut(k).and_then(Option::take).map(|(_, face)| face));
children.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (.., art))| art.clone()));
drawn.extend(faces.get_mut(k).and_then(Option::take));
drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (surface, .., art))| {
let mut art = art.clone();
if let Some(mask) = masks.get(surface - first).cloned().flatten() {
Arc::make_mut(&mut art).extras_mut().mask = Some(mask);
}
// An index past the surfaces: the graphic goes in a run of its own, with its own mask.
(masks.len(), art)
}));
}
Some(vec![Arc::new(Node::group(n.id, children))])
if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() }
}
/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces
+467
View File
@@ -253,6 +253,468 @@ fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() {
}
}
/// The faces of the solids effect `id` with `params` builds for `n`, in the order they are drawn,
/// as [`surfaces_for`] orders them, each with the index of the part ([`solid_parts`]) it was built
/// from and whether it is a stroke's.
fn drawn_parts(n: &Node, id: &str, params: &Value) -> Vec<(Facet, usize, bool)> {
let (path, rule) = profile(n).unwrap();
let shapes = [Profile { path, rule, items: solid_paints(n), opacity: 1.0, scopes: vec![] }];
let p = crate::merged_params(id, params);
let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap());
let mut solids = vec![];
for (part, ((item, _, _, _, _, member), f)) in parts.iter().zip(built_solids(id, &p, &parts, bounds, MAX_OBJECT_FACES)).enumerate() {
let Some(f) = f.unwrap() else { continue };
let closed = holds_edge(id, &shapes[*member].path);
solids.push((Paints::of(item, closed), *member, f, part, matches!(item, AppearanceItem::Stroke(_))));
}
let mut cuts: Vec<(Paints, usize, &mut Vec<Facet>)> = solids.iter_mut().map(|(paints, member, f, _, _)| (*paints, *member, f)).collect();
cut_stroke_faces(&mut cuts, MAX_OBJECT_FACES);
let (mut placed, mut all) = (vec![], vec![]);
for (paints, member, f, part, stroke) in solids {
for facet in f {
placed.push(Placed::of(&facet, member, paints));
all.push((facet, part, stroke));
}
}
let sealed = match id {
INFLATE => inflate_options(&p).cap,
CLASSIC_EXTRUDE => classic_extrude_options(&p).cap,
_ => extrude_options(&p).cap,
};
draw_order(&placed, sealed).into_iter().map(|k| all[k].clone()).collect()
}
/// Is `p` inside the polygon of `f` (nonzero winding)?
fn facet_contains(f: &Facet, p: Point) -> bool {
let mut winding = 0i32;
for c in &f.contours {
for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) {
let cross = (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y);
if a.y <= p.y && b.y > p.y && cross > 0.0 {
winding += 1;
} else if a.y > p.y && b.y <= p.y && cross < 0.0 {
winding -= 1;
}
}
}
winding != 0
}
/// The points of a 1 pt grid over `faces` (drawn in that order, each with whether it is a stroke's)
/// where the face drawn on top is the fill's while a face of the stroke turned toward the viewer is
/// nearer the viewer there by more than 0.5 pt, or the other way round. Points within 0.3 pt of an
/// edge are left out.
fn wrong_on_top(faces: &[(Facet, bool)]) -> Vec<Point> {
let polygons: Vec<&Facet> = faces.iter().map(|(f, _)| f).collect();
let extent =
polygons.iter().flat_map(|f| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p));
let mut wrong = vec![];
let mut y = extent.y0.floor() + 0.5;
while y < extent.y1 {
let mut x = extent.x0.floor() + 0.5;
while x < extent.x1 {
let p = Point::new(x, y);
let well_inside = |f: &Facet| {
[(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy)))
};
let top = faces.iter().rposition(|(f, _)| facet_contains(f, p));
if let Some((f, stroke)) = top.and_then(|k| faces.get(k)).filter(|(f, _)| !f.away && well_inside(f)) {
let nearest = |of_stroke: bool| {
faces.iter().filter(|(g, s)| *s == of_stroke && !g.away && well_inside(g)).filter_map(|(g, _)| g.depth_at(p)).reduce(f64::max)
};
if let (Some(ds), Some(df)) = (nearest(true), nearest(false))
&& (ds - df).abs() > 0.5
&& (ds > df) != *stroke
&& f.depth_at(p).is_some()
{
wrong.push(p);
}
}
x += 1.0;
}
y += 1.0;
}
wrong
}
/// The points of a 1 pt grid over `faces` (drawn in that order) where the face drawn on top is
/// farther from the viewer than another face turned toward the viewer, of any part, by more than
/// 0.5 pt. Points within 0.3 pt of an edge, and faces without a plane, are left out.
fn misdrawn(faces: &[(Facet, usize, bool)]) -> Vec<Point> {
let extent =
faces.iter().flat_map(|(f, ..)| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p));
let mut wrong = vec![];
let mut y = extent.y0.floor() + 0.5;
while y < extent.y1 {
let mut x = extent.x0.floor() + 0.5;
while x < extent.x1 {
let p = Point::new(x, y);
let well_inside = |f: &Facet| {
[(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy)))
};
let top = faces.iter().rposition(|(f, ..)| facet_contains(f, p)).and_then(|k| faces.get(k)).filter(|(f, ..)| !f.away && well_inside(f));
if let Some(on_top) = top.and_then(|(f, ..)| f.depth_at(p)) {
let nearest = faces.iter().filter(|(g, ..)| !g.away && well_inside(g)).filter_map(|(g, ..)| g.depth_at(p)).reduce(f64::max);
if nearest.is_some_and(|d| d - on_top > 0.5) {
wrong.push(p);
}
}
x += 1.0;
}
y += 1.0;
}
wrong
}
/// The faces of a shape's fill and strokes are drawn in the order of their depths where they
/// overlap: on a turned C, with or without perspective, under a stroke that does not cover the
/// fill's whole edge (dashed, with a width profile, or with a brush), under a plain stroke and
/// under two strokes whose faces pass through each other, no point shows a face farther from the
/// viewer than another, in Extrude & Bevel and Extrude & Bevel (Classic), and only a few in
/// Inflate, where curved puffs cross.
#[test]
fn fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap() {
use vectorcraft_doc::{Dash, WidthProfile};
let views = [
json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}),
json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10}),
json!({"rotationX": -30, "rotationY": 50, "rotationZ": 5, "perspectiveAngle": 40}),
json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}),
];
type Style<'a> = (&'a str, &'a dyn Fn(&mut Node));
let first = |n: &mut Node, set: &dyn Fn(&mut StrokeLayer)| {
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
set(s);
}
};
let dash = |pattern: Vec<f64>, offset: f64| Some(Dash { pattern, offset, align_corners: false });
let styles: [Style; 6] = [
("plain", &|_| {}),
("dashed", &|n| first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0))),
("profiled", &|n| first(n, &|s| s.profile = Some(WidthProfile::lens()))),
("brushed", &|n| first(n, &|s| s.brush = Some("brush".into()))),
("dashed with perspective", &|n| first(n, &|s| s.dash = dash(vec![20.0, 6.0], 3.0))),
("two strokes", &|n| {
first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0));
let mut wide = StrokeLayer::new(Paint::solid(RED), 14.0);
wide.profile = Some(WidthProfile::lens());
n.appearance.items.push(AppearanceItem::Stroke(wide));
}),
];
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
for (v, view) in views.iter().enumerate() {
for (style, set) in &styles {
// The last two views, for the plain and the two strokes only.
if v >= 2 && !["plain", "two strokes"].contains(style) {
continue;
}
let mut params = view.clone();
params["depth"] = json!(60);
if style.contains("perspective") {
params["perspectiveAngle"] = json!(40);
}
let mut n = stroked(&letter_c());
set(&mut n);
let parts = drawn_parts(&n, id, &params);
let faces: Vec<(Facet, bool)> = parts.iter().map(|(f, _, s)| (f.clone(), *s)).collect();
assert!(faces.iter().any(|(_, s)| *s) && faces.iter().any(|(_, s)| !*s), "{id} {view} {style}: faces of both");
// Inflate's curved puffs cross each other along curves its flat facets only follow
// roughly: a few points next to those crossings may still show the farther face.
let allowed = if id == INFLATE { 5 } else { 0 };
let wrong = wrong_on_top(&faces);
assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points drawn in the wrong paint, first {:?}", wrong.len(), wrong.first());
let wrong = misdrawn(&parts);
assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points show a farther face, first {:?}", wrong.len(), wrong.first());
}
}
}
}
/// The paths of `art`'s faces in drawing order, leaving out the graphics (clip groups).
fn face_paths(art: &Node) -> Vec<PathData> {
fn walk(n: &Node, out: &mut Vec<PathData>) {
match &n.kind {
NodeKind::Group { clip: true, .. } => {}
NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)),
_ => out.extend(n.path_data().cloned()),
}
}
let mut out = vec![];
walk(art, &mut out);
out
}
/// A member of a 3D group with an opacity mask or a blending mode of its own draws its fill's and
/// its strokes' faces in one order inside that mask, the order the same member draws them in
/// without it, its dashes cut where they pass through the fill's walls. A member whose stroke is
/// translucent still draws each surface whole.
#[test]
fn a_masked_members_fill_and_stroke_faces_go_in_one_order() {
let mut member = stroked(&letter_c());
if let Some(AppearanceItem::Stroke(s)) = member.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
}
let solid = |member: Node| {
let mut g = Node::group(NodeId(2), vec![Arc::new(member)]);
g.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})));
solid_art(&g).unwrap()
};
let cover = Node::path(
NodeId(900),
vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0),
);
let mut masked = member.clone();
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true)));
let mut multiplied = member.clone();
multiplied.blend = BlendMode::Multiply;
let unmasked = face_paths(&solid(member.clone()));
for (what, scoped) in [("masked", masked.clone()), ("multiplied", multiplied)] {
let art = solid(scoped);
let mut scopes = 0;
art.walk(&mut |n| scopes += usize::from(n.extras().mask.is_some() || n.blend == BlendMode::Multiply));
assert!(scopes > 0, "{what}: drawn in its scope");
assert_eq!(face_paths(&art), unmasked, "{what}: the faces in the member's one order");
}
// A translucent stroke keeps its surface whole: the fill's faces, then the stroke's.
if let Some(AppearanceItem::Stroke(s)) = masked.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.opacity = 0.5;
}
let art = solid(masked);
let mut groups = vec![];
art.walk(&mut |n| {
if n.opacity == 0.5 {
groups.push(n.children().map_or(0, Vec::len));
}
});
assert_eq!(groups.len(), 1, "the stroke's surface is one translucent group");
}
/// Under a light that is not white, Inflate in gradients, whose faces are drawn one by one, draws the
/// fill's and the dashed stroke's faces in one order, as it does under a white light. A solid color,
/// drawn as one composite of each surface's facets, still draws each surface whole.
#[test]
fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() {
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 });
}
}
}
// How many surfaces the art draws, and how many faces the first holds.
let drawn = |n: &Node, lights: Option<Value>| {
let mut n = n.clone();
let mut p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60});
if let Some(lights) = lights {
p["lights"] = lights;
}
n.appearance.effects.push(fx(INFLATE, p));
let art = solid_art(&n).unwrap();
let surfaces = art.children().unwrap();
(surfaces.len(), surfaces[0].children().map_or(0, Vec::len))
};
let colored = json!([{"color": "#ff8080"}]);
let white = drawn(&n, None);
assert_eq!(white.0, 1, "one order");
let cut = drawn_parts(&n, INFLATE, &json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})).len();
assert_eq!(white.1, cut, "the white light's faces, cut");
assert_eq!(drawn(&n, Some(colored.clone())).0, 1, "one order under the colored light");
// A solid color under the colored light: a composite for each of the fill and the stroke.
let solid = stroked(&letter_c());
assert_eq!(drawn(&solid, Some(colored)).0, 2);
}
/// 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
/// and a cut past the room left are left whole.
#[test]
fn a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet() {
let facet = |x: f64, normal: [f64; 3], offset: f64| Facet {
contours: vec![vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]],
depth: 0.0,
brightness: 1.0,
illumination: [1.0; 3],
normal,
rgb_gradient: None,
outline: None,
side: true,
away: false,
end: None,
plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }),
};
let wall = facet(0.0, [0.0, 0.0, 1.0], 0.0);
// z = x - 8: behind the wall left of x = 8, in front of it right of it.
let r = std::f64::consts::FRAC_1_SQRT_2;
let mut through = facet(5.0, [-r, 0.0, r], -8.0 * r);
// Its curved outline: the top edge bows up to y = -2.
let mut curved = BezPath::new();
curved.move_to((5.0, 0.0));
curved.curve_to((8.0, -8.0 / 3.0), (12.0, -8.0 / 3.0), (15.0, 0.0));
curved.line_to((15.0, 10.0));
curved.line_to((5.0, 10.0));
curved.close_path();
through.outline = Some(PathData::from_bezpath(&curved));
let extent = |f: &Facet| polygon_bounds(&f.contours[0]);
let cut_with = |paints: Paints, member: usize, room: usize| {
let (mut fill, mut stroke) = (vec![wall.clone()], vec![through.clone()]);
let mut solids: Vec<(Paints, usize, &mut Vec<Facet>)> = vec![(Paints::Fill, 0, &mut fill), (paints, member, &mut stroke)];
cut_stroke_faces(&mut solids, room);
stroke
};
let pieces = cut_with(Paints::Other, 0, 10);
assert_eq!(pieces.len(), 2);
let (near, far) = (extent(&pieces[0]), extent(&pieces[1]));
assert!((near.x0 - 8.0).abs() < 1e-9 && near.x1 == 15.0 && far.x0 == 5.0 && (far.x1 - 8.0).abs() < 1e-9, "{near:?} {far:?}");
assert!(pieces[0].depth > 0.0 && pieces[1].depth < 0.0);
// Each piece keeps its side of the curved outline, the bowed edge included.
let curve = |f: &Facet| f.outline.as_ref().and_then(PathData::bounds).unwrap();
let (near, far) = (curve(&pieces[0]), curve(&pieces[1]));
assert!((near.x0 - 8.0).abs() < 1e-6 && (near.x1 - 15.0).abs() < 1e-6 && near.y0 < -1.5, "{near:?}");
assert!((far.x0 - 5.0).abs() < 1e-6 && (far.x1 - 8.0).abs() < 1e-6 && far.y0 < -1.5, "{far:?}");
let whole = std::slice::from_ref(&through);
assert_eq!(cut_with(Paints::Other, 1, 10), whole, "another shape's stroke");
assert_eq!(cut_with(Paints::Stroke, 0, 10), whole, "a covering stroke");
assert_eq!(cut_with(Paints::Other, 0, 0), whole, "no room");
let (mut fill, mut stroke) = (vec![wall.clone()], vec![facet(5.0, [0.0, 0.0, 1.0], 1.0)]);
cut_stroke_faces(&mut [(Paints::Fill, 0, &mut fill), (Paints::Other, 0, &mut stroke)], 10);
assert_eq!(stroke.len(), 1, "in front of the wall wherever they overlap");
// Of two of a shape's strokes whose faces pass through each other, the later stroke's face is
// cut against the earlier one's; a covering stroke's face is never cut, and the other stroke's
// face is cut against it. Two shapes' strokes are left whole.
for (case, (first, second, cut_first)) in
[(Paints::Other, Paints::Other, false), (Paints::Stroke, Paints::Other, false), (Paints::Other, Paints::Stroke, true)].into_iter().enumerate()
{
let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]);
cut_stroke_faces(&mut [(first, 0, &mut a), (second, 0, &mut b)], 10);
assert_eq!((a.len(), b.len()), if cut_first { (2, 1) } else { (1, 2) }, "case {case}");
}
let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]);
cut_stroke_faces(&mut [(Paints::Other, 0, &mut a), (Paints::Other, 1, &mut b)], 10);
assert_eq!((a.len(), b.len()), (1, 1), "two shapes");
}
/// A small polygon far from the origin keeps its area and centroid.
#[test]
fn a_small_polygon_far_away_has_a_centroid() {
let far = 100_000_000.0;
let square = [Point::new(far, far), Point::new(far + 1.0, far), Point::new(far + 1.0, far + 1.0), Point::new(far, far + 1.0)];
let c = centroid(&square).unwrap();
assert!((c.x - far - 0.5).abs() < 1e-6 && (c.y - far - 0.5).abs() < 1e-6, "{c:?}");
assert!(centroid(&[]).is_none() && centroid(&square[..2]).is_none());
}
/// Cutting adds no more faces than the object's face limit and the paint-copy allowance leave room
/// for, so an effect that draws with its faces uncut still draws.
#[test]
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));
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);
assert_eq!(cut_room(items.iter(), 10, max - 3 * one), 3);
assert_eq!(cut_room(items.iter(), 10, max + 1), 0);
}
/// Where a shape's fill wall and a face of its stroke that leaves gaps overlap, the one nearer the
/// viewer there is drawn after the other, whichever their depths put first; a stroke face that
/// overlaps no fill wall keeps its place.
#[test]
fn local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
let at = |depth: f64, x: f64, z: f64, paints: Paints| Placed {
depth,
side: true,
away: false,
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member: 0,
paints,
outline: square(x),
plane: Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }),
};
// 0 a fill wall at z 0; 1 a stroke face over half of it, nearer, sorted before it; 2 a stroke
// face elsewhere; 3 a stroke face over it, farther, sorted after it.
let faces =
[at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 1.0, Paints::Other), at(1.0, 20.0, 1.0, Paints::Other), at(6.0, 2.0, -1.0, Paints::Other)];
assert_eq!(draw_order(&faces, true), [2, 3, 0, 1]);
assert_eq!(draw_order(&faces, false), [2, 3, 0, 1]);
// A covering stroke's faces keep the wall's own rule.
let covering: Vec<Placed> = faces
.iter()
.map(|f| Placed { paints: if f.paints == Paints::Other { Paints::Stroke } else { f.paints }, outline: vec![], plane: None, ..*f })
.collect();
assert_eq!(draw_order(&covering, false), [2, 1, 0, 3]);
// Another shape's stroke, and a stroke face at the same depth where they overlap, move nothing.
let other_shape: Vec<Placed> =
faces[..3].iter().map(|f| Placed { member: usize::from(f.paints == Paints::Other), outline: f.outline.clone(), ..*f }).collect();
assert_eq!(draw_order(&other_shape, true), [2, 1, 0]);
let level = [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 0.0, Paints::Other)];
assert_eq!(draw_order(&level, true), [1, 0]);
// A stroke face moved after its fill's wall does not pass a nearer face of another shape that
// it overlaps: 0 a stroke face, 1 another shape's face in front of both, 2 the fill's wall.
let in_front = Placed { member: 1, ..at(2.0, 5.0, 2.0, Paints::Fill) };
let shapes = [at(1.0, 5.0, 1.0, Paints::Other), in_front, at(3.0, 0.0, 0.0, Paints::Fill)];
assert_eq!(draw_order(&shapes, true), [2, 0, 1]);
// A shape with a covering stroke as well keeps its fill wall before the covering stroke's face,
// even where that face is the farther: 0 the fill's wall, 1 the covering stroke's face behind
// it, 2 the other stroke's face in front.
let both = [at(3.0, 0.0, 0.0, Paints::Fill), at(3.0, 0.0, -1.0, Paints::Stroke), at(2.0, 5.0, 1.0, Paints::Other)];
assert_eq!(draw_order(&both, true), [0, 1, 2]);
// Faces of a shape's strokes go in the order of their depths where they overlap too, with no
// fill face among them: 1 is nearer than 0 there, though sorted before it.
for (case, (back, front)) in
[(Paints::Other, Paints::Other), (Paints::Stroke, Paints::Other), (Paints::Stroke, Paints::Stroke)].into_iter().enumerate()
{
let strokes = [at(5.0, 0.0, 0.0, back), at(2.0, 5.0, 1.0, front)];
assert_eq!(draw_order(&strokes, true), [0, 1], "case {case}");
}
}
/// A cycle of overlapping stroke faces is broken at the face that comes first by depth, and a cap
/// drawn last stays after every wall: 0 at x 0 to 10, flat at z 0; 1 at x 4 to 14, z = x - 8; 2 at
/// x 8 to 18, flat at z 1. Where they overlap, 0 is nearer than 1, 1 nearer than 2 and 2 nearer
/// than 0. 3 is the cap.
#[test]
fn a_depth_cycle_keeps_the_cap_last() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
let at = |depth: f64, x: f64, normal: [f64; 3], offset: f64| Placed {
depth,
side: true,
away: false,
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member: 0,
paints: Paints::Other,
outline: square(x),
plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }),
};
let cap = Placed {
depth: f64::MAX,
side: false,
away: false,
bounds: Rect::new(0.0, 0.0, 18.0, 10.0),
member: 0,
paints: Paints::Other,
outline: vec![],
plane: None,
};
let faces = [at(1.0, 0.0, [0.0, 0.0, 1.0], 0.0), at(2.0, 4.0, [-1.0, 0.0, 1.0], -8.0), at(3.0, 8.0, [0.0, 0.0, 1.0], 1.0), cap];
assert_eq!(draw_order(&faces, true), [0, 2, 1, 3]);
}
/// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a
/// face of the same shape's stroke drawn before it: the wall then goes just before that face. A
/// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an
@@ -266,6 +728,8 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() {
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member,
paints,
outline: vec![],
plane: None,
};
// 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke
// wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a
@@ -292,6 +756,9 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() {
};
assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke);
assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape");
let closed = profile(&stroked(&letter_c())).unwrap().0;
assert!(holds_edge(EXTRUDE, &closed) && holds_edge(CLASSIC_EXTRUDE, &closed));
assert!(!holds_edge(INFLATE, &closed), "an inflated fill can rise in front of its stroke");
assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width");
let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false };
assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes");
+104
View File
@@ -620,6 +620,110 @@ fn a_solid_in_a_transparent_gradient_keeps_its_graphic() {
assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]);
}
/// The paths of `art`'s faces in drawing order (a face lit by a colored light is a clip group: its
/// clipping path), leaving out the graphics (clip groups of solid-color paths).
fn face_paths(art: &Node) -> Vec<PathData> {
fn walk(n: &Node, out: &mut Vec<PathData>) {
match &n.kind {
NodeKind::Group { children, clip: true } => {
let graphic = children.get(1).is_some_and(|inner| match &inner.kind {
NodeKind::Group { children: paths, clip: false } => paths.iter().all(|p| {
p.path_data().is_some()
&& p.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint.color().is_some()))
}),
_ => false,
});
if !graphic {
out.extend(children.first().and_then(|c| c.path_data()).cloned());
}
}
NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)),
_ => out.extend(n.path_data().cloned()),
}
}
let mut out = vec![];
walk(art, &mut out);
out
}
/// A turned solid in a gradient with transparent stops under a colored light, with a dashed stroke
/// and a graphic, draws its fill's and its stroke's faces in the one order it draws them in without
/// the graphic, and the graphic keeps the cap's mask and goes after the fill's cap, not the stroke's.
#[test]
fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let d = document();
let mut stop = GradientStop::new(1.0, red());
stop.opacity = 0.25;
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop]));
let solid = |graphics: Value| {
let mut n = object(
EXTRUDE,
json!({"rotationX": -20.0, "rotationY": -40.0, "depth": 60.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": graphics}),
);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.paint = Paint::Gradient(Box::new(gradient.clone()));
}
// A gradient stroke: a solid color under the colored light would be one composite.
let blues = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.5))],
);
let mut stroke = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(blues))), 6.0);
stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
n.appearance.items.push(AppearanceItem::Stroke(stroke));
solid_art_in(Some(&d), &n).unwrap()
};
let with = solid(json!([{"symbol": "Mark"}]));
let without = solid(json!([]));
assert!(face_paths(&without).len() > 20, "the faces are found");
assert_eq!(face_paths(&with), face_paths(&without), "the faces in one order");
// The graphic is drawn on the fill's cap and on the stroke's (the clip groups holding the
// symbol's red square); the fill's, whose surface carries the mask, carries it too.
let mut masked = vec![];
with.walk(&mut |c| {
if let NodeKind::Group { children, clip: true } = &c.kind
&& let [_, inner] = children.as_slice()
&& let NodeKind::Group { children: paths, clip: false } = &inner.kind
&& paths.iter().any(|p| {
p.appearance.items.iter().any(|i| match i {
AppearanceItem::Fill(f) => f.paint.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3),
AppearanceItem::Stroke(_) => false,
})
})
{
masked.push(c.extras().mask.is_some());
}
});
masked.sort_unstable();
assert_eq!(masked, [false, true], "the fill's graphic carries the cap's mask");
// The fill's graphic goes right after the fill's cap, so the dashes on the cap's edge, drawn
// after it, cover it. Drawn in order: the fill's graphic (the masked clip group), and each face,
// the fill's or the stroke's by its gradient's first stop (red or blue), with its bounds.
let mut drawn: Vec<(&str, Rect)> = vec![];
with.walk(&mut |c| {
if let NodeKind::Group { children, clip: true } = &c.kind
&& c.extras().mask.is_some()
&& let Some(bounds) = children.first().and_then(|clip| clip.path_data()).and_then(PathData::bounds)
{
drawn.push(("graphic", bounds));
}
let first_stop = c.appearance.items.iter().find_map(|i| match i {
AppearanceItem::Fill(f) => match &f.paint {
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_rgb()),
_ => None,
},
AppearanceItem::Stroke(_) => None,
});
if let (Some(rgb), Some(bounds)) = (first_stop, c.path_data().and_then(PathData::bounds)) {
drawn.push((if rgb[0] > rgb[2] { "fill" } else { "stroke" }, bounds));
}
});
let graphic = drawn.iter().position(|(kind, _)| *kind == "graphic").unwrap();
let covering = |(kind, b): &(&str, Rect)| *kind == "stroke" && b.intersect(drawn[graphic].1).area() > 0.0;
assert!(drawn[graphic..].iter().any(covering), "a dash on the cap's edge is drawn over the fill's graphic");
}
#[test]
fn a_cap_with_a_hole_keeps_the_hole_out_of_the_graphic() {
let d = document();
+2
View File
@@ -778,6 +778,8 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap<
pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000;
pub(crate) use addons::{DecodedImages, refresh_color_masks};
/// Document Info's category titles and the Objects category's count labels.
pub use docinfo::{CATEGORIES, OBJECT_LABELS};
pub use docmenu::guide_indexes;
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use object::bounds_rewrite;
+17 -13
View File
@@ -527,8 +527,9 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Ve
}
/// What a trace depends on: the object traced (an image, or an Image Trace object traced again),
/// its image, and the settings it is traced with. The tracer makes the same paths of the same
/// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own.
/// its image, the image's encoded bytes, and the settings it is traced with. The tracer makes the
/// same paths of the same inputs, so a trace made away from the command ([`TraceRun`]) stands in
/// for the command's own.
#[derive(Clone, Debug, PartialEq)]
struct TraceInputs {
target: NodeId,
@@ -538,6 +539,9 @@ struct TraceInputs {
/// Document Library: the library's colours, which the JSON leaves out
/// ([`tr::TraceParams::swatches`]).
swatches: Vec<[u8; 3]>,
/// The encoded image the tracer decodes: the blob under the image's key, shared with the
/// document. Two blobs are equal when they are the same allocation or hold the same bytes.
bytes: Arc<Vec<u8>>,
}
/// What `imageTrace.make` and `imageTrace.makeAndExpand` take from the session before they trace.
@@ -549,7 +553,6 @@ struct Prepared {
params_json: Value,
preset_label: String,
library: Vec<([u8; 3], Paint)>,
bytes: Arc<Vec<u8>>,
}
/// What command `c` with params `p` traces, and how, from the session: everything but the trace.
@@ -597,8 +600,8 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
let bytes = Arc::clone(&blob.bytes);
let library = trace_library(c, s, &mut params)?;
let traced_with = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone() };
Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library, bytes })
let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone(), bytes };
Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library })
}
/// What a trace made of an image: the size in pixels it was decoded at, and the paths.
@@ -616,11 +619,11 @@ impl Traced {
}
}
/// Decode the image `bytes` and trace it with `params`, telling `steps` how far it has got
/// ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`.
fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, bytes: &[u8], steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
/// Decode the image bytes of `inputs` and trace them with `params`, telling `steps` how far it has
/// got ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`.
fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
// An image over the tracer's size is traced scaled down (the paths are scaled back).
let raster = tr::Raster::decode_to_fit(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let raster = tr::Raster::decode_to_fit(&inputs.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
if !steps(0, 0) {
return Err(EngineError::Cancelled);
}
@@ -682,7 +685,8 @@ impl TraceRun {
(self.prepared.inputs.image.width, self.prepared.inputs.image.height)
}
/// Does it still apply: would the command trace the same object, image and settings now?
/// Does it still apply: would the command trace the same object, image, pixels and settings
/// now?
pub fn current(&self, s: &Session) -> bool {
prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs)
}
@@ -691,13 +695,13 @@ impl TraceRun {
/// failed or stopped ([`EngineError::Cancelled`] once `steps` answers `false`). It touches no
/// document, so it can run on any thread.
pub fn run(&self, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result<Traced> {
trace_now(&self.prepared.inputs, &self.prepared.trace, &self.prepared.bytes, steps)
trace_now(&self.prepared.inputs, &self.prepared.trace, steps)
}
}
pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?;
let Prepared { inputs, trace, view, params_json, preset_label, library } = prepare(s, c, p)?;
let group_by_color = expand && by_color_param(c, p)?;
// A saved preset's settings join the journal entry, so a replay traces as this run did after
// the preset is changed, renamed or deleted.
@@ -709,7 +713,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
// A trace handed in for these very inputs ([`TraceRun`]), else one made now.
let Traced { raster, res, .. } = match s.trace_hand_off.take() {
Some(traced) if traced.inputs == inputs => traced,
_ => trace_now(&inputs, &trace, &bytes, &|_, _| true)?,
_ => trace_now(&inputs, &trace, &|_, _| true)?,
};
let TraceInputs { target, image: img, .. } = inputs;
let mut warnings = keep_warnings(&trace, &res);
+2 -6
View File
@@ -143,17 +143,13 @@ pub(crate) fn subpath_selected_segments(
let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty());
if has_explicit_segs {
if let Some(segments) = seg_refs {
for &(s, seg) in segments {
if s == si && seg < seg_count {
selected_segs.insert(seg);
}
}
selected_segs.extend(super::path::subpath_refs(segments, si).filter(|&seg| seg < seg_count));
}
return selected_segs;
}
if let Some(anchors) = anchor_refs {
let sub_anchors: BTreeSet<usize> = anchors.iter().filter_map(|&(s, a)| (s == si && a < n).then_some(a)).collect();
let sub_anchors: BTreeSet<usize> = super::path::subpath_refs(anchors, si).filter(|&a| a < n).collect();
if !sub_anchors.is_empty() {
for i in 0..seg_count {
let next = (i + 1) % n;
+1 -1
View File
@@ -52,7 +52,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [
];
/// The object counts of `document.info` and their labels.
const OBJECT_LABELS: [(&str, &str); 19] = [
pub const OBJECT_LABELS: [(&str, &str); 19] = [
("paths", "Paths"),
("compoundPaths", "Compound Paths"),
("groups", "Groups"),
+83 -19
View File
@@ -116,8 +116,11 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("no such anchor".into()));
}
let mut emptied = Beside::replacing(d, &[id]);
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?;
let removed = remove_anchors_of(d, sel, &mut emptied, id, vec![(si, ai)], curvature)?;
emptied.finish(d)?;
if removed {
sel.remove(id);
}
Ok(removed)
})?;
Ok(json!({ "removedObject": removed }))
@@ -136,14 +139,14 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
return Err(bad("path.removeAnchors", "direct-select anchor points of a path"));
}
let removed = s.edit("Remove Anchor Points", |d, sel| {
let mut removed = 0;
let mut gone = Vec::new();
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, anchors) in jobs {
let shape: Vec<(usize, bool)> =
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
let next = following_anchors(&shape, &anchors);
if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? {
removed += 1;
gone.push(id);
} else if !next.is_empty() {
// The next anchor along is selected, so the next Remove Anchor Points goes on from
// there without picking it first (#2241).
@@ -151,7 +154,8 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
}
}
emptied.finish(d)?;
Ok(removed)
sel.remove_all(gone.iter().copied());
Ok(gone.len())
})?;
Ok(json!({ "removedObjects": removed }))
}
@@ -160,19 +164,32 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its
/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's
/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors.
/// `removed` is in ascending order without repeats, as a selection holds it, and each subpath reads
/// only its own pairs.
fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
let mut next = BTreeSet::new();
let mut kept = 0;
let mut rest = removed;
for (si, &(n, closed)) in shape.iter().enumerate() {
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count());
rest = after;
let gone = &mine[..mine.partition_point(|&(_, a)| a < n)];
if n - gone.len() < 2 {
continue;
}
if let Some(&last) = gone.last() {
let wrap = if closed { 0..last } else { 0..0 };
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
next.insert((kept, a - gone.range(..a).count()));
if let Some(&(_, last)) = gone.last() {
// Anchors past `last` are all left. Below it, the first one left (round a closed
// subpath's end) is past the removed run from 0, and the last one left is below the
// removed run down from `last`.
let a = if last + 1 < n {
Some(last + 1)
} else if closed {
Some(gone.iter().zip(0..).take_while(|&(&(_, g), k)| g == k).count())
} else {
last.checked_sub(gone.iter().rev().zip((0..=last).rev()).take_while(|&(&(_, g), k)| g == k).count())
};
if let Some(a) = a {
next.insert((kept, a - gone.partition_point(|&(_, g)| g < a)));
}
}
kept += 1;
@@ -183,34 +200,37 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
/// each one, or with `curvature` re-curving it as the Curvature tool curves its points
/// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed,
/// and a path left with no subpath is removed through `emptied`. True when the path went.
/// and a path left with no subpath is removed through `emptied`; the caller then takes it out of
/// the selection. True when the path went.
fn remove_anchors_of(
d: &mut Document,
sel: &mut Selection,
emptied: &mut Beside,
id: NodeId,
anchors: impl IntoIterator<Item = (usize, usize)>,
mut anchors: Vec<(usize, usize)>,
curvature: bool,
) -> Result<bool> {
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
// The last first, so the indexes still to come stay valid.
anchors.sort_unstable_by(|a, b| b.cmp(a));
anchors.dedup();
let path = path_mut(d, id)?;
for (si, ai) in anchors {
if let Some(sp) = path.subpaths.get_mut(si) {
if curvature {
let mut rest = &anchors[..];
while let Some(&(si, _)) = rest.first() {
let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count());
rest = after;
let Some(sp) = path.subpaths.get_mut(si) else { continue };
if curvature {
for &(_, ai) in mine {
vectorcraft_tools::draw2::curvature_remove(sp, ai);
} else {
po::remove_anchor(sp, ai);
}
} else {
po::remove_anchors(sp, &mine.iter().map(|&(_, ai)| ai).collect::<Vec<_>>());
}
}
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
let gone = path.is_empty();
if gone {
emptied.put(d, id, vec![])?;
sel.remove(id);
} else {
sel.anchors.remove(&id);
sel.segments.remove(&id);
@@ -277,3 +297,47 @@ pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
})?;
ok()
}
#[cfg(test)]
mod tests {
use super::*;
/// [`following_anchors`] by its definition: for each subpath, a search of the anchors after
/// its last removed one, round a closed end, then before it.
fn following_by_search(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
let mut next = BTreeSet::new();
let mut kept = 0;
for (si, &(n, closed)) in shape.iter().enumerate() {
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
if n - gone.len() < 2 {
continue;
}
if let Some(&last) = gone.last() {
let wrap = if closed { 0..last } else { 0..0 };
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
next.insert((kept, a - gone.range(..a).count()));
}
}
kept += 1;
}
next
}
#[test]
fn following_anchors_matches_a_search_along_each_subpath() {
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
let mut rnd = |m: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % m) as usize
};
for case in 0..2000 {
let shape: Vec<(usize, bool)> = (0..rnd(5) + 1).map(|_| (rnd(9), rnd(2) == 0)).collect();
let removed: BTreeSet<(usize, usize)> = (0..rnd(14)).map(|_| (rnd(shape.len() as u64 + 1), rnd(11))).collect();
let removed: Vec<_> = removed.into_iter().collect();
assert_eq!(following_anchors(&shape, &removed), following_by_search(&shape, &removed), "case {case}: {shape:?} removing {removed:?}");
}
}
}
+1 -1
View File
@@ -1096,7 +1096,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
subpaths.extend(super::path::without_segments(sp, &cuts));
} else {
// Picked anchors leave the path with their connections, as Clear removes them.
let gone: BTreeSet<usize> = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect();
let gone: BTreeSet<usize> = anchor_refs.into_iter().flat_map(|refs| super::path::subpath_refs(refs, si)).collect();
subpaths.extend(super::path::without_anchors(sp, &gone));
}
}
+21 -4
View File
@@ -78,8 +78,11 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
/// document's last artboard, the last one; a Paste in Place, in Front or in Back step that names
/// `artboards` keeps them, and one that names neither, while the Artboard tool is chosen with
/// several artboards selected, pastes on those. A step that resizes the active artboard
/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`.
/// several artboards selected, pastes on those. A Fit to Artwork Bounds step that names neither
/// `index` nor `indices`, while the Artboard tool is chosen with several artboards selected when the
/// step runs, gets those as its `indices`, which its journal entry records. Otherwise a step that
/// resizes the active artboard ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` (nor
/// `indices`, for Fit to Artwork Bounds) gets that artboard as its `index`.
/// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them.
fn run_steps(
s: &mut Session,
@@ -110,9 +113,22 @@ fn run_steps(
// Artboard tool is chosen with several artboards selected, pastes on those.
let names_artboards = id.starts_with("edit.pasteIn")
&& (params.get("artboards").is_some() || (params.get("artboard").is_none() && !crate::cmd::edit::artboard_tool_picks(s).is_empty()));
// A Fit to Artwork Bounds step that names neither `index` nor `indices`, while the Artboard
// tool is chosen with several artboards selected, fits those, as the command does on its own.
let fit_picks = if id == "artboard.fitToArt" && params.get("index").is_none() && params.get("indices").is_none() {
crate::cmd::edit::artboard_tool_picks(s)
} else {
Vec::new()
};
if !fit_picks.is_empty()
&& let Some(o) = params.as_object_mut()
{
o.insert("indices".into(), json!(fit_picks));
}
if let Some(a) = artboard
&& let Some(key) = key
&& params.get(key).is_none()
&& !(id == "artboard.fitToArt" && params.get("indices").is_some())
&& !names_artboards
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
&& let Some(o) = params.as_object_mut()
@@ -129,8 +145,9 @@ fn run_steps(
}
grouped.push(step_groups(s, start, &held));
// The step's journal entry records what it took from the preferences or the clock (a
// save's date, a new document's), as a command of its own would.
if noted != params
// save's date, a new document's), as a command of its own would, and the artboards a Fit to
// Artwork Bounds step took from the Artboard tool.
if (noted != params || !fit_picks.is_empty())
&& let Some(c) = c.as_object_mut()
{
c.insert("params".into(), noted);
+1 -1
View File
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch",
[],
None,
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer; an artboard.fitToArt step that names neither index nor indices, while the Artboard tool is chosen with several artboards selected when the step runs, gets those as indices instead, and the journal records them)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
has_doc,
batch::batch
),
+2 -14
View File
@@ -868,7 +868,7 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are
/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in.
/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is
/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`vectorcraft_doc::cmyk::exif_orientation`]) turns or flips it is
/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG
/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]).
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
@@ -909,7 +909,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
// A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is
// seen, so it places the way it was taken (#4284); the others keep their own bytes.
let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten();
let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| vectorcraft_doc::cmyk::exif_orientation(bytes)).flatten();
let turned = orientation.is_some();
// The resolution describes the stored pixels; a quarter turn swaps its axes.
let quarter = orientation.is_some_and(|o| o.to_exif() >= 5);
@@ -957,18 +957,6 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
as_png(img, None, shown_ppi)
}
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with
/// extra samples) has its Orientation tag read from its first image
/// ([`vectorcraft_doc::cmyk::tiff_orientation`]).
pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> {
use image::ImageDecoder as _;
use image::metadata::Orientation;
let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok();
let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?));
decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms)
}
/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its
/// colors are in.
pub(super) fn as_png(img: image::RgbaImage, icc: Option<Vec<u8>>, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
+216 -11
View File
@@ -481,6 +481,12 @@ fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) {
}
}
#[cfg(test)]
thread_local! {
/// Anchors that [`merge_coincident`] moved to a lower index of their vector on this thread.
static ANCHOR_MOVES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which
/// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many
/// anchors went.
@@ -501,23 +507,68 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:
join_point(sp, 0, smooth);
}
}
// Back to front, so the indices `picked` knows stay true for the anchors still to visit.
for i in (1..sp.anchors.len()).rev() {
// Back to front, so the indices `picked` knows stay true for the anchors still to visit. A
// merged anchor is only marked here; the marked anchors are removed in one pass at the end.
let closed = sp.closed;
let anchors = &mut sp.anchors;
let len = anchors.len();
let mut gone = vec![false; len];
// The anchor after `i` that is still on the path, and the last one still on it.
let mut next = None;
let mut last = len.saturating_sub(1);
for i in (1..len).rev() {
if picked(i - 1)
&& picked(i)
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
&& let Some([a, b]) = anchors.get_mut(i - 1..=i)
&& same(a, b)
{
a.h_out = b.h_out;
sp.anchors.remove(i);
if let Some(smooth) = join_kind {
join_point(sp, i - 1, smooth);
if let Some(slot) = gone.get_mut(i) {
*slot = true;
}
if next.is_none() {
last = i - 1;
}
if let Some(smooth) = join_kind {
// The neighbors the anchor has on the path as it now stands, as `join_point` sees them.
let prev = if i > 1 { Some(i - 2) } else { closed.then_some(last) };
let after = next.or(closed.then_some(0));
join_point_between(anchors, i - 1, prev, after, smooth);
}
} else {
next = Some(i);
}
}
#[cfg(test)]
if let Some(first) = gone.iter().position(|&g| g) {
// `retain` moves each anchor it keeps after the first one it drops.
let dropped = gone.iter().filter(|&&g| g).count();
ANCHOR_MOVES.set(ANCHOR_MOVES.get() + (len - first - dropped));
}
if gone.contains(&true) {
let mut k = 0;
anchors.retain(|_| {
let keep = !gone.get(k).copied().unwrap_or(false);
k += 1;
keep
});
}
before - sp.anchors.len()
}
/// [`join_point`] on anchor `ai` of `anchors` with its neighbors given by index.
fn join_point_between(anchors: &mut [Anchor], ai: usize, prev: Option<usize>, next: Option<usize>, smooth: bool) {
let at = |j: Option<usize>| j.and_then(|j| anchors.get(j)).map(|a| a.p);
let (prev, next) = (at(prev), at(next));
if let Some(a) = anchors.get_mut(ai) {
if smooth {
a.smooth_between(prev, next);
} else {
a.retract();
}
}
}
/// Close subpath `si` of path `id`, merging coincident endpoints if present.
fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option<bool>) -> Result<()> {
let path = path_mut(d, id)?;
@@ -751,8 +802,19 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result<Value> {
let mut merged = vec![];
for (id, picked) in &st.selection.anchors {
let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
let n: usize =
path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)), join_kind)).sum();
// One flag per anchor; references past the end of a subpath or to a missing one pick nothing.
let mut masks: Vec<Vec<bool>> = path.subpaths.iter().map(|sp| vec![false; sp.anchors.len()]).collect();
for &(si, ai) in picked {
if let Some(flag) = masks.get_mut(si).and_then(|m| m.get_mut(ai)) {
*flag = true;
}
}
let n: usize = path
.subpaths
.iter_mut()
.zip(&masks)
.map(|(sp, mask)| merge_coincident(sp, |ai| mask.get(ai).copied().unwrap_or(false), join_kind))
.sum();
if n > 0 {
merged.push((*id, path));
}
@@ -1276,7 +1338,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
.into_iter()
.enumerate()
.flat_map(|(si, sp)| {
let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect();
let cuts = subpath_refs(refs, si).collect();
without_segments(sp, &cuts)
})
.filter(|s| s.anchors.len() >= 2)
@@ -1288,7 +1350,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
.into_iter()
.enumerate()
.flat_map(|(si, sp)| {
let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect();
let gone = subpath_refs(set, si).collect();
without_anchors(sp, &gone)
})
.filter(|s| s.anchors.len() >= 2)
@@ -1314,6 +1376,12 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The anchor or segment indexes in `refs` that belong to subpath `si`, in index order.
/// `refs` is ordered by subpath first, so this reads only that subpath's entries.
pub(super) fn subpath_refs(refs: &BTreeSet<(usize, usize)>, si: usize) -> impl Iterator<Item = usize> + '_ {
refs.range((si, 0)..=(si, usize::MAX)).map(|&(_, i)| i)
}
/// Retain path `id` from `d` without copying its geometry before deletion takes its subpaths.
/// The shared snapshot supplies the original path classification and, only if needed, gradient
/// bounds after the working node's geometry has moved. Returns `NoNode` for a missing/non-path ID.
@@ -1722,7 +1790,7 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let (mut subs, mut starts) = (vec![], vec![]);
for (si, sp) in pd.subpaths.iter().enumerate() {
let at: BTreeSet<usize> = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect();
let at: BTreeSet<usize> = subpath_refs(set, si).collect();
let pieces = sp.cut_at(&at);
// The pieces that start at a cut: every piece of an opened closed subpath, all but the
// first of an open one.
@@ -1763,3 +1831,140 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
})?;
Ok(super::draw2::ids_json(&ids))
}
#[cfg(test)]
mod tests {
use super::*;
/// `merge_coincident` removing each merged anchor from the vector straight away, kept as the
/// reference the one-pass version must match.
fn merge_coincident_by_removal(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: Option<bool>) -> usize {
let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6;
let before = sp.anchors.len();
if sp.closed
&& before > 2
&& picked(0)
&& picked(before - 1)
&& let [first, .., last] = sp.anchors.as_mut_slice()
&& same(first, last)
{
first.h_in = last.h_in;
sp.anchors.pop();
if let Some(smooth) = join_kind {
join_point(sp, 0, smooth);
}
}
for i in (1..sp.anchors.len()).rev() {
if picked(i - 1)
&& picked(i)
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
&& same(a, b)
{
a.h_out = b.h_out;
sp.anchors.remove(i);
if let Some(smooth) = join_kind {
join_point(sp, i - 1, smooth);
}
}
}
before - sp.anchors.len()
}
/// Every field of every anchor, bit for bit.
fn bits(sp: &SubPath) -> (Vec<[u64; 6]>, Vec<AnchorKind>, bool) {
let b = |p: Point| [p.x.to_bits(), p.y.to_bits()];
let fields = sp.anchors.iter().map(|a| [b(a.p), b(a.h_in), b(a.h_out)].concat().try_into().unwrap()).collect();
(fields, sp.anchors.iter().map(|a| a.kind).collect(), sp.closed)
}
/// A small linear congruential generator, so the cases are the same on every run.
struct Lcg(u64);
impl Lcg {
fn below(&mut self, n: u64) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 33) % n
}
}
fn same_as_removal(sp: &SubPath, picked: &dyn Fn(usize) -> bool, join_kind: Option<bool>) {
let (mut ours, mut theirs) = (sp.clone(), sp.clone());
let n = merge_coincident(&mut ours, picked, join_kind);
let m = merge_coincident_by_removal(&mut theirs, picked, join_kind);
assert_eq!(n, m, "{sp:?} {join_kind:?}");
assert_eq!(bits(&ours), bits(&theirs), "{sp:?} {join_kind:?}");
}
/// Short paths with points on, near and just past the merge distance, selections with stale
/// indices, every anchor kind and every join kind give the same anchors, bit for bit, as
/// removing each merged anchor straight away.
#[test]
fn merging_coincident_anchors_matches_removing_them_one_by_one() {
let spots = [(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (5e-7, 0.0), (0.0, 1.5e-6), (10.0, 0.0), (5.0, 5.0), (10.0, 0.0)];
let mut rng = Lcg(7);
for _ in 0..30_000 {
let n = rng.below(9) as usize;
let anchors = (0..n)
.map(|_| {
let (x, y) = spots[rng.below(spots.len() as u64) as usize];
let p = Point::new(x, y);
let handle = |rng: &mut Lcg| match rng.below(3) {
0 => p,
_ => p + Vec2::new(rng.below(7) as f64 - 3.0, rng.below(7) as f64 - 3.0),
};
let (h_in, h_out) = (handle(&mut rng), handle(&mut rng));
let kind = if rng.below(2) == 0 { AnchorKind::Corner } else { AnchorKind::Smooth };
Anchor { p, h_in, h_out, kind }
})
.collect();
let sp = SubPath::new(anchors, rng.below(2) == 0);
let picks: Vec<bool> = (0..n + 2).map(|_| rng.below(4) != 0).collect();
let join_kind = [None, Some(false), Some(true)][rng.below(3) as usize];
same_as_removal(&sp, &|i| picks.get(i).copied().unwrap_or(false), join_kind);
}
}
/// A smooth join of three anchors on one another takes the outgoing handle the middle anchor
/// was given when it joined the right one: an already smooth left anchor keeps that handle.
#[test]
fn a_smooth_join_of_three_anchors_keeps_the_handle_the_middle_one_got() {
let o = Point::ZERO;
let left = Anchor { p: o, h_in: Point::new(-2.0, 0.0), h_out: o, kind: AnchorKind::Smooth };
let right = Anchor { p: o, h_in: o, h_out: Point::new(0.0, 5.0), kind: AnchorKind::Corner };
let mut sp = SubPath::new(vec![left, Anchor::corner(o), right, Anchor::corner(Point::new(10.0, 0.0))], false);
assert_eq!(merge_coincident(&mut sp, |i| i < 3, Some(true)), 2);
let a = sp.anchors[0];
assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(-2.0, 0.0), Point::new(10.0 / 3.0, 0.0), AnchorKind::Smooth));
assert_eq!(sp.anchors.len(), 2);
}
/// One long run of selected anchors on one another, open or closed, becomes one anchor.
#[test]
fn a_long_run_of_anchors_on_one_another_becomes_one() {
for closed in [false, true] {
for join_kind in [None, Some(false), Some(true)] {
let anchors = (0..50_000).map(|i| Anchor::smooth(Point::ZERO, Point::new(1.0, i as f64))).collect();
let sp = SubPath::new(anchors, closed);
same_as_removal(&sp, &|_| true, join_kind);
let mut ours = sp.clone();
assert_eq!(merge_coincident(&mut ours, |_| true, join_kind), 49_999);
assert_eq!((ours.anchors.len(), ours.closed), (1, closed));
}
}
}
/// A long selected run at the start of a path followed by as many anchors that stay: each
/// anchor after the run moves once, not once per merged anchor.
#[test]
fn a_long_run_before_many_other_anchors_merges_in_one_pass() {
let run = 300_000;
let anchors = (0..run).map(|_| Anchor::corner(Point::ZERO)).chain((1..=run).map(|i| Anchor::corner(Point::new(i as f64, 1.0)))).collect();
let mut sp = SubPath::new(anchors, false);
ANCHOR_MOVES.set(0);
assert_eq!(merge_coincident(&mut sp, |i| i < run, Some(false)), run - 1);
assert_eq!(ANCHOR_MOVES.get(), run);
assert_eq!(sp.anchors.len(), run + 1);
assert_eq!(sp.anchors[0].p, Point::ZERO);
assert_eq!(sp.anchors[1].p, Point::new(1.0, 1.0));
assert_eq!(sp.anchors[run].p, Point::new(run as f64, 1.0));
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 8] = [
/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the
/// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to
/// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to
/// each of their steps that names none.
/// each of their steps that names none; a Fit to Artwork Bounds step that names neither `index` nor
/// `indices`, while the Artboard tool is chosen with several artboards selected, gets those as its
/// `indices` instead.
pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"];
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
+97
View File
@@ -3788,6 +3788,103 @@ fn a_trace_run_needs_the_library_colours_it_traced_with() {
assert!(!run.current(&s), "{name} changed");
}
/// `image_doc`'s image with its disc 20 px to the left: the same size, other pixels.
fn moved_disc_png() -> Vec<u8> {
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let (dx, dy) = (x as f64 + 0.5 - 30.0, y as f64 + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
})
.encode_png()
}
/// Puts `bytes` under the image key `key`, or takes the key's image data away (`None`).
fn set_image_data(s: &mut Session, key: &str, bytes: Option<Vec<u8>>) {
s.edit("image data", |d, _| {
match bytes {
Some(b) => d.images.insert(key.into(), ImageBlob::new("image/png", b)),
None => d.images.remove(key),
};
Ok(())
})
.unwrap();
}
/// The middle of the first traced path of the Image Trace object the reply `r` names, in x.
fn traced_center_x(s: &Session, r: &Value) -> f64 {
outline(&traced_paths(&node(s, NodeId(r["id"].as_u64().unwrap())))[0]).0.bounds().unwrap().center().x
}
/// A trace made away from its command no longer applies once the pixels under its image's key
/// change, though the object, its image record and the settings stay as they were; both commands
/// then trace the pixels there now. The same bytes in another allocation still apply.
#[test]
fn a_trace_run_needs_the_pixels_it_traced() {
let p = json!({"params": {"ignoreWhite": true}});
for command in ["imageTrace.make", "imageTrace.makeAndExpand"] {
let mut s = session();
image_doc(&mut s);
let run = cmd::TraceRun::prepare(&s, command, &p).unwrap();
let traced = run.run(&|_, _| true).unwrap();
// The same bytes, allocated anew: still current.
let same = s.doc().unwrap().doc.images["img1"].bytes.to_vec();
set_image_data(&mut s, "img1", Some(same));
assert!(run.current(&s), "{command}: equal bytes");
// Other pixels under the same key: no longer current, and the hand-off is dropped.
set_image_data(&mut s, "img1", Some(moved_disc_png()));
assert!(!run.current(&s), "{command}: other pixels");
traced.hand_in(&mut s);
let r = s.execute(command, run.params()).unwrap();
assert!(s.trace_hand_off.is_none(), "{command}: dropped");
// The disc is now at image x 30, which the ×2 placement at 50 puts at 110.
let x = traced_center_x(&s, &r);
assert!((x - 110.0).abs() < 1.5, "{command}: traced the pixels there now, at {x}");
}
}
/// Image data that is missing or empty: a run made before it went no longer applies, the run's
/// command gives the error it gives without a run, and nothing panics.
#[test]
fn a_trace_run_of_missing_or_empty_image_data() {
let p = json!({"params": {"ignoreWhite": true}});
let mut s = session();
image_doc(&mut s);
let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap();
let traced = run.run(&|_, _| true).unwrap();
set_image_data(&mut s, "img1", None);
assert!(!run.current(&s), "missing");
traced.hand_in(&mut s);
let e = s.execute(run.command(), run.params()).unwrap_err().to_string();
assert!(e.contains("image data `img1` is missing"), "{e}");
set_image_data(&mut s, "img1", Some(vec![]));
assert!(!run.current(&s), "empty");
let empty = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap();
assert!(empty.current(&s), "two empty blobs are equal");
assert!(matches!(empty.run(&|_, _| true), Err(EngineError::Other(_))));
assert!(matches!(s.execute(run.command(), run.params()), Err(EngineError::Other(_))));
}
/// Tracing an Image Trace object again follows the same rule: once the pixels of the image it
/// holds change, a run made before no longer applies, and the command traces the pixels there now.
#[test]
fn a_trace_run_of_an_image_trace_object_needs_its_pixels() {
let p = json!({"params": {"ignoreWhite": true}});
let mut s = session();
image_doc(&mut s);
let g = s.execute("imageTrace.make", &p).unwrap()["id"].as_u64().unwrap();
let again = json!({"id": g, "params": {"ignoreWhite": true}});
let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &again).unwrap();
assert_eq!(run.target(), NodeId(g));
let traced = run.run(&|_, _| true).unwrap();
assert!(run.current(&s));
set_image_data(&mut s, "img1", Some(moved_disc_png()));
assert!(!run.current(&s));
traced.hand_in(&mut s);
let r = s.execute(run.command(), run.params()).unwrap();
assert!(s.trace_hand_off.is_none(), "dropped");
let x = traced_center_x(&s, &r);
assert!((x - 110.0).abs() < 1.5, "traced the pixels there now, at {x}");
}
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
/// circle as its traced path; without the option it is a plain path.
#[test]
+74
View File
@@ -1367,6 +1367,37 @@ fn smooth_tool_reduces_jagged_path() {
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
}
/// A drag of the Smooth tool along a line jagged by up to 6 pt leaves no corner between a curve and
/// a line: every anchor between the path's ends turns by at most 1°.
#[test]
fn smooth_tool_leaves_no_corner_where_a_curve_meets_a_line() {
let mut s = session();
let mut seed = 7u64;
let anchors: Vec<_> = (0..=60)
.map(|i| {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let noise = ((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0;
json!({"x": 100.0 + f64::from(i) * 5.0, "y": 200.0 + 6.0 * noise})
})
.collect();
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let drag_points: Vec<(f64, f64)> = (0..=64).map(|i| (90.0 + f64::from(i) * 5.0, 200.0)).collect();
drag(&mut s, "smooth", &drag_points, Mods::default());
let p = path(&s, id);
let sp = &p.subpaths[0];
assert!(sp.anchors.len() < 61, "the path is smoothed: {}", sp.anchors.len());
let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord);
for i in 1..sp.segment_count() {
let (prev, next) = (sp.segment(i - 1), sp.segment(i));
let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0);
let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0);
let turn = arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees();
let kinds = (sp.segment_is_line(i - 1), sp.segment_is_line(i));
assert!(turn <= 1.0, "anchor {i} turns {turn}° (lines before and after: {kinds:?}): {:?}", sp.anchors[i]);
}
}
/// Smoothing a path with direct-selected anchors leaves the path selected whole: the selection no
/// longer names anchors by indices into the anchors the smooth replaced. A selected path the drag
/// does not reach keeps its anchors selected, and undo brings the old selection back.
@@ -2718,6 +2749,49 @@ fn remove_anchor_points_selects_one_next_anchor_per_subpath_and_per_path() {
assert_eq!(selected_anchors(&s, other), [(0, 0)]);
}
#[test]
fn remove_anchor_points_on_many_paths_and_subpaths_matches_removing_one_at_a_time() {
// Many anchors over many subpaths and paths, curved and straight, open and closed: each
// subpath ends as removing its anchors one at a time, the last first, leaves it. Paths left
// with nothing go, the rest stay selected in order, and one undo step brings all back.
let mut s = session();
let curvy = "M0 0 C10 20 30 20 40 0 C50 -20 70 -20 80 0 C90 20 110 20 120 0 C130 -20 150 -20 160 0 Z \
M0 100 L40 120 C60 140 80 80 100 100 L140 90 L180 110 L220 100";
let ids: Vec<NodeId> = (0..4).map(|i| path_from(&mut s, if i == 2 { "M0 0 L10 0 L20 5" } else { curvy })).collect();
let picks: [&[(usize, usize)]; 4] =
[&[(0, 0), (0, 2), (0, 3), (1, 1), (1, 2), (1, 5)], &[(0, 1), (1, 0), (1, 6)], &[(0, 0), (0, 1)], &[(0, 3), (1, 3), (1, 4)]];
for (k, (&id, &anchors)) in ids.iter().zip(&picks).enumerate() {
let mode = if k == 0 { "set" } else { "add" };
s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors, "mode": mode})).unwrap();
}
let before: Vec<PathData> = ids.iter().map(|&id| path(&s, id)).collect();
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 1);
for (k, &id) in ids.iter().enumerate() {
let mut want = before[k].clone();
let mut order = picks[k].to_vec();
order.sort_unstable_by(|a, b| b.cmp(a));
for (si, ai) in order {
vectorcraft_pathops::remove_anchor(&mut want.subpaths[si], ai);
}
want.subpaths.retain(|sp| sp.anchors.len() >= 2);
if want.subpaths.is_empty() {
assert!(s.doc().unwrap().doc.node(id).is_none(), "path {k} goes");
} else {
assert_eq!(path(&s, id).subpaths, want.subpaths, "path {k}");
}
}
let kept = [ids[0], ids[1], ids[3]];
assert_eq!(s.doc().unwrap().selection.objects, kept);
s.execute("edit.undo", &json!({})).unwrap();
for (k, &id) in ids.iter().enumerate() {
assert_eq!(path(&s, id), before[k]);
assert_eq!(selected_anchors(&s, id), picks[k]);
}
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(ids[2]).is_none());
assert_eq!(s.doc().unwrap().selection.objects, kept);
}
#[test]
fn remove_anchor_point_tool_and_delete_still_select_the_remainder_whole() {
// Only Remove Anchor Points moves the selection on. The Delete Anchor Point tool leaves the
+81
View File
@@ -192,6 +192,87 @@ fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() {
assert_eq!(action, [fit("artboard.fitToArt"), fit("artboard.fitToArt"), fit("artboard.fitToSelection")]);
}
/// A document of three artboards with art of its own on each → its artboards before any fit.
fn three_artboards_with_art(s: &mut Session) -> Vec<vectorcraft_geom::Rect> {
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3, "created": null})).unwrap();
for (x, h) in [(10, 30), (130, 20), (250, 40)] {
s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap();
}
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect()
}
/// The artboards of [`three_artboards_with_art`], each fitted to its art.
fn three_fitted_artboards() -> [vectorcraft_geom::Rect; 3] {
let rect = vectorcraft_geom::Rect::new;
[rect(10.0, 10.0, 30.0, 40.0), rect(130.0, 10.0, 150.0, 30.0), rect(250.0, 10.0, 270.0, 50.0)]
}
/// A Fit to Artwork Bounds step that names neither `index` nor `indices`, while the Artboard tool
/// is chosen with several artboards selected, fits each of those (not the batch's artboard), as the
/// command does on its own; a step's own `index` or `indices` wins, and with one artboard or none
/// selected the step takes the batch's artboard.
#[test]
fn a_batch_fit_step_uses_the_artboard_tool_selection() {
let fitted = three_fitted_artboards();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let fit = |params: Value| json!({"artboard": 1, "commands": [{"command": "artboard.fitToArt", "params": params}]});
let session = |picks: &Value| {
let mut s = Session::new();
let was = three_artboards_with_art(&mut s);
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
s.set_tool_option("selected", picks);
(s, was)
};
let (mut s, was) = session(&json!([2, 0]));
s.execute("command.batch", &fit(json!({}))).unwrap();
assert_eq!(boards(&s), [fitted[0], was[1], fitted[2]], "artboards 1 and 3, the selected ones, and not the batch's artboard 2");
let (mut s, was) = session(&json!([2, 0]));
s.execute("command.batch", &fit(json!({"index": 1}))).unwrap();
assert_eq!(boards(&s), [was[0], fitted[1], was[2]], "a step's own index");
let (mut s, was) = session(&json!([2, 0]));
s.execute("command.batch", &fit(json!({"indices": [1, 2]}))).unwrap();
assert_eq!(boards(&s), [was[0], fitted[1], fitted[2]], "a step's own indices");
for picks in [json!([2]), json!([])] {
let (mut s, was) = session(&picks);
s.execute("command.batch", &fit(json!({}))).unwrap();
assert_eq!(boards(&s), [was[0], fitted[1], was[2]], "the batch's artboard with {picks} selected");
}
}
/// The journal records the artboards a batch's Fit to Artwork Bounds step took from the Artboard
/// tool, so a replay in a new session fits the same ones; an action leaves them out and fits the
/// artboards selected when it plays.
#[test]
fn a_batch_fit_selection_replays_but_an_action_uses_the_current_selection() {
let fitted = three_fitted_artboards();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let mut s = Session::new();
let was = three_artboards_with_art(&mut s);
s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
s.set_tool_option("selected", &json!([0, 2]));
let start = s.journal.len();
let steps = json!([{"command": "artboard.fitToArt", "params": {}}]);
s.execute("command.batch", &json!({"artboard": 1, "commands": steps})).unwrap();
assert_eq!(boards(&s), [fitted[0], was[1], fitted[2]]);
let (id, p) = s.journal.last().unwrap().clone();
assert_eq!((id.as_str(), &p["commands"][0]["params"]), ("command.batch", &json!({"indices": [0, 2]})));
let r = replayed(&s);
assert_ne!(r.tool_id(), "artboard", "the new session's tool selects no artboards");
assert_eq!(boards(&r), boards(&s), "the replay fits the recorded artboards");
let action = s.journal_for_action(start);
assert_eq!(action, [("command.batch".to_string(), json!({"commands": steps}))]);
// Played on a fresh copy of the document with other artboards selected.
let mut a = Session::new();
three_artboards_with_art(&mut a);
a.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap();
a.set_tool_option("selected", &json!([1, 2]));
a.execute("command.batch", &json!({"artboard": 0, "commands": action[0].1["commands"]})).unwrap();
assert_eq!(boards(&a), [was[0], fitted[1], fitted[2]], "the action fits the artboards selected when it plays");
}
#[test]
fn a_save_records_its_date_and_replays_it() {
let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
+3
View File
@@ -753,6 +753,9 @@ fn precise_cursors_replace_every_pen_state() {
Cursor::ShapeBuilderErase,
Cursor::AnchorPoint,
Cursor::Curvature,
Cursor::LivePaintBucket,
Cursor::LivePaintBucketFace,
Cursor::LivePaintBucketEdge,
] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
+146 -1
View File
@@ -2,7 +2,7 @@
use serde_json::json;
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, NodeId, NodeKind, selection::Selection};
use vectorcraft_engine::Session;
use vectorcraft_geom::{Affine, Anchor, PathData, Rect, SubPath};
use vectorcraft_testkit::{invariants::check_session, raster::render_artboard};
@@ -226,6 +226,151 @@ fn deletion_split_preserves_gradient_placement() {
}
}
/// Subpath `i` of a multi-subpath test path: four cubic anchors on their own row, so every piece
/// that survives a deletion can be matched exactly.
fn row(i: usize, closed: bool) -> SubPath {
let y = 10.0 + 3.0 * i as f64;
let anchors = (0..4)
.map(|k| {
let mut a = Anchor::corner((10.0 + 20.0 * f64::from(k), y).into());
a.h_in = a.p + (-3.0, -1.0);
a.h_out = a.p + (3.0, 1.0);
a
})
.collect();
SubPath::new(anchors, closed)
}
/// An open piece made of `indexes` of `sp`, its unused end handles retracted as deletion leaves them.
fn piece(sp: &SubPath, indexes: &[usize]) -> SubPath {
let mut run: Vec<Anchor> = indexes.iter().map(|&i| sp.anchors[i]).collect();
if let Some(first) = run.first_mut() {
first.h_in = first.p;
}
if let Some(last) = run.last_mut() {
last.h_out = last.p;
}
SubPath::new(run, false)
}
/// Put `subpaths` into a new path object and return its ID.
fn multi_subpath_path(s: &mut Session, subpaths: Vec<SubPath>) -> NodeId {
let id = rectangle(s);
*std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap() = PathData::new(subpaths);
id
}
/// Run `command` on the current selection, check the path's surviving subpaths and that one Undo
/// restores the document and `before_selection` and one Redo repeats the edit, then check that a
/// second Clear removes the survivors.
fn delete_and_check(s: &mut Session, command: &str, id: NodeId, expected: &[SubPath], before_selection: &Selection) {
let before_doc = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
let history = s.doc().unwrap().history.undo.len();
s.execute(command, &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.node(id).unwrap().path_data().unwrap().subpaths, expected, "{command}");
assert_eq!(st.selection.objects, [id], "{command}: a multi-subpath path keeps its object");
assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty());
assert_eq!(st.history.undo.len(), history + 1);
let after_doc = serde_json::to_value(&st.doc).unwrap();
let after_selection = st.selection.clone();
check_session(s).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before_doc);
assert_eq!(&s.doc().unwrap().selection, before_selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), after_doc);
assert_eq!(s.doc().unwrap().selection, after_selection);
s.execute("edit.clear", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none(), "{command}: the second Clear removes the survivors");
}
/// Selected anchors on many subpaths each remove only their own subpath's anchors; anchors named on
/// a subpath or index the path does not have remove nothing.
#[test]
fn deleting_anchors_on_many_subpaths_removes_each_subpaths_own_anchors() {
for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] {
let mut s = session();
let subpaths: Vec<SubPath> = (0..6).map(|i| row(i, i % 2 == 1)).collect();
let id = multi_subpath_path(&mut s, subpaths.clone());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [1, 2], [2, 0], [2, 9], [4, 3], [5, 1], [7, 1]]})).unwrap();
// Subpath 3 has no selected anchor and stays closed and unchanged.
let expected = [
piece(&subpaths[0], &[2, 3]),
piece(&subpaths[1], &[3, 0, 1]),
piece(&subpaths[2], &[1, 2, 3]),
subpaths[3].clone(),
piece(&subpaths[4], &[0, 1, 2]),
piece(&subpaths[5], &[2, 3, 0]),
];
let before = s.doc().unwrap().selection.clone();
delete_and_check(&mut s, command, id, &expected, &before);
}
}
/// Selected segments on many subpaths each cut only their own subpath; a segment reference to a
/// subpath or segment the path does not have cuts nothing.
#[test]
fn deleting_segments_on_many_subpaths_cuts_each_subpaths_own_segments() {
for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] {
let mut s = session();
let subpaths: Vec<SubPath> = (0..6).map(|i| row(i, i % 2 == 1)).collect();
let id = multi_subpath_path(&mut s, subpaths.clone());
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1], [1, 3], [4, 0], [5, 1]]})).unwrap();
// Undo prunes the restored selection (`Selection::prune`), which drops references to
// segments the path does not have, so it restores the selection as it is here.
let before = s.doc().unwrap().selection.clone();
// `select.segments` refuses references to missing segments, so these are added directly.
let refs = s.doc_mut().unwrap().selection.segments.get_mut(&id).unwrap();
refs.extend([(2, 9), (7, 0)]);
let expected = [
piece(&subpaths[0], &[0, 1]),
piece(&subpaths[0], &[2, 3]),
piece(&subpaths[1], &[0, 1, 2, 3]),
subpaths[2].clone(),
subpaths[3].clone(),
piece(&subpaths[4], &[1, 2, 3]),
piece(&subpaths[5], &[2, 3, 0, 1]),
];
delete_and_check(&mut s, command, id, &expected, &before);
}
}
/// One selected interior anchor on each of many subpaths removes exactly that anchor from each,
/// keeping the subpaths in their original order.
#[test]
fn deleting_one_anchor_on_each_of_many_subpaths_keeps_subpath_order() {
let mut s = session();
let subpaths: Vec<SubPath> = (0..400).map(|i| row(i, false)).collect();
let id = multi_subpath_path(&mut s, subpaths.clone());
let refs: Vec<[usize; 2]> = (0..400).filter(|i| i % 7 != 3).map(|i| [i, 1]).collect();
s.execute("select.anchors", &json!({"id": id.0, "anchors": refs})).unwrap();
let expected: Vec<SubPath> = subpaths.iter().enumerate().map(|(i, sp)| if i % 7 == 3 { sp.clone() } else { piece(sp, &[2, 3]) }).collect();
let before = s.doc().unwrap().selection.clone();
delete_and_check(&mut s, "edit.clear", id, &expected, &before);
}
/// Cut Path cuts each subpath of a multi-subpath path only at that subpath's selected anchors.
#[test]
fn cutting_at_anchors_on_many_subpaths_cuts_each_subpath_at_its_own_anchors() {
let mut s = session();
let subpaths: Vec<SubPath> = (0..6).map(|i| row(i, i % 2 == 1)).collect();
let id = multi_subpath_path(&mut s, subpaths.clone());
let refs = [(0, 1), (1, 2), (2, 9), (4, 2), (5, 0), (5, 2), (7, 1)];
s.execute("select.anchors", &json!({"id": id.0, "anchors": refs.map(|(a, b)| [a, b])})).unwrap();
s.execute("path.cutAtAnchors", &json!({})).unwrap();
let expected: Vec<SubPath> = subpaths
.iter()
.enumerate()
.flat_map(|(si, sp)| sp.cut_at(&refs.iter().filter(|r| r.0 == si).map(|r| r.1).collect::<std::collections::BTreeSet<_>>()))
.collect();
// Subpaths 0 and 4 become two pieces each, closed subpath 1 opens into one, closed subpath 5
// becomes two, and subpaths 2 and 3 stay as they were.
assert_eq!(expected.len(), 9);
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths, expected);
check_session(&s).unwrap();
}
/// A connected remainder keeps automatic fitting; pinning is needed only for separate objects.
#[test]
fn deleting_one_segment_keeps_unplaced_gradients() {
+21 -15
View File
@@ -165,6 +165,26 @@ impl Anchor {
pub fn reversed(&self) -> Self {
Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind }
}
/// Make the anchor smooth between the points `prev` and `next` (its own point for a missing
/// one): handles in line with them, each a third of the way to that side's point. A smooth
/// anchor with both handles keeps them. False, with no change, when the two points give no
/// direction. [`SubPath::smooth_anchor`] calls it with the anchor's neighbors.
pub fn smooth_between(&mut self, prev: Option<Point>, next: Option<Point>) -> bool {
if self.kind == AnchorKind::Smooth && self.has_in() && self.has_out() {
return true;
}
let (pp, nn) = (prev.unwrap_or(self.p), next.unwrap_or(self.p));
let dir = nn - pp;
let l = dir.hypot();
if l <= 1e-9 {
return false;
}
let u = dir / l;
self.h_in = self.p - u * (self.p.distance(pp) / 3.0);
self.h_out = self.p + u * (self.p.distance(nn) / 3.0);
self.kind = AnchorKind::Smooth;
true
}
/// Remove both handles (convert to a sharp corner).
pub fn retract(&mut self) {
self.h_in = self.p;
@@ -398,21 +418,7 @@ impl SubPath {
};
let at = |j: Option<usize>| j.and_then(|j| self.anchors.get(j)).map(|a| a.p);
let (prev, next) = (at(prev), at(next));
let Some(a) = self.anchors.get_mut(i) else { return false };
if a.kind == AnchorKind::Smooth && a.has_in() && a.has_out() {
return true;
}
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l <= 1e-9 {
return false;
}
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
true
self.anchors.get_mut(i).is_some_and(|a| a.smooth_between(prev, next))
}
/// Cut at the anchors `at`: each becomes the end of one open piece and the start of the next,
/// its handle off each piece retracted (the shape doesn't change). A closed subpath opens at
+18 -1
View File
@@ -563,7 +563,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does",
),
("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"),
(
"window.toolbar.dock",
"Dock Toolbar",
"",
"{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}",
),
(
"window.toolbar.float",
"Float Toolbar",
"",
"{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}",
),
(
"window.toolbar.list",
"List Toolbars",
"",
"{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)",
),
("window.taskBar", "Contextual Task Bar", "", "{}"),
(
"window.pathfinder.compoundShapes",
+249 -23
View File
@@ -348,14 +348,39 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends
}
// The fit may be `tol * 0.98` from the lines between the samples, those lines may be
// `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up
// to 1.5% of `tol`: 3/4 of how far its handles may be from the line.
// to 1.5% of `tol`: 3/4 of how far its handles may be from the line. The fitted curves join
// smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. The last
// curve of a loop fitted across its start leaves along the loop's first segment.
let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d));
for c in fit_cubics(&pts, t0, t1, tol * 0.98) {
let line = is_straight(&c, tol * 0.02);
let fitted = fit_cubics(&pts, t0, t1, tol * 0.98);
let from = result.len();
let mut arrive = t0;
for (k, &c) in fitted.iter().enumerate() {
let leave = match fitted.get(k + 1) {
Some(next) => start_tangent(next),
None if ends.is_some() => result.get(from).map_or(t1, |s| start_tangent(&s.c)),
None => t1,
};
let line = is_straight(&c, tol * 0.02) && line_keeps_joins(&c, arrive, leave);
arrive = if line { unit(c.p3 - c.p0).unwrap_or(arrive) } else { end_tangent(&c) };
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
}
}
/// Most degrees between the line from a refitted curve's start to its end and the directions the
/// path arrives and leaves along, for the curve to become that line: so every join next to such a
/// line turns by at most this much. VectorCraft's own value.
const LINE_JOIN_DEG: f64 = 0.5;
/// Whether the line from `c.p0` to `c.p3` is within [`LINE_JOIN_DEG`] of `arrive`, the direction the
/// path arrives at `c.p0` along, and of `leave`, the direction it leaves `c.p3` along. A curve whose
/// ends are at one point, or a direction of no length, gives `false`.
fn line_keeps_joins(c: &CubicBez, arrive: Vec2, leave: Vec2) -> bool {
let (Some(dir), Some(arrive), Some(leave)) = (unit(c.p3 - c.p0), unit(arrive), unit(leave)) else { return false };
let min = LINE_JOIN_DEG.to_radians().cos();
dir.dot(arrive) >= min && dir.dot(leave) >= min
}
/// `pts` (a run's samples, in order) smoothed along the path: each point becomes the Gaussian-weighted
/// mean of the points around it, `sigma` points of arc length wide, the window narrowing towards the
/// ends so that the first and last points stay where they are. Consecutive samples within
@@ -941,27 +966,10 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool {
return false;
}
if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 {
let (pi, ni) = ((index + n - 1) % n, (index + 1) % n);
if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) {
let (left, right) = (sp.segment(pi), sp.segment(index));
const N: usize = 16;
let mut pts = Vec::with_capacity(2 * N + 1);
sample(&left, N, &mut pts, true);
sample(&right, N, &mut pts, false);
let (t0, t1) = (start_tangent(&left), end_tangent(&right));
// The new curve passes the removed point at some parameter k: the left samples sit
// at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit.
let fit = |k: f64| {
let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect();
fit_single_from(&pts, u, t0, t1)
};
let (c, _, _) = golden_min(fit, 0.0, 1.0);
if c.p1.is_finite() && c.p2.is_finite() {
sp.anchors[pi].h_out = c.p1;
sp.anchors[ni].h_in = c.p2;
}
}
refit_across(&mut sp.anchors, (index + n - 1) % n, index, (index + 1) % n);
}
#[cfg(test)]
tests::count_moves(n - index - 1);
sp.anchors.remove(index);
if !sp.closed {
// A new end has nothing beyond it.
@@ -978,6 +986,101 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool {
true
}
/// Refit the facing handles of anchors `pi` and `ni` so one cubic follows the two segments that
/// meet at anchor `index` between them ([`remove_anchor`]). Two straight segments are left as
/// they are, and so are the handles when the fit is not finite.
fn refit_across(anchors: &mut [Anchor], pi: usize, index: usize, ni: usize) {
let (Some(&a), Some(&b), Some(&c)) = (anchors.get(pi), anchors.get(index), anchors.get(ni)) else { return };
let line = |a: &Anchor, b: &Anchor| !a.has_out() && !b.has_in();
if line(&a, &b) && line(&b, &c) {
return;
}
let (left, right) = (CubicBez::new(a.p, a.h_out, b.h_in, b.p), CubicBez::new(b.p, b.h_out, c.h_in, c.p));
const N: usize = 16;
let mut pts = Vec::with_capacity(2 * N + 1);
sample(&left, N, &mut pts, true);
sample(&right, N, &mut pts, false);
let (t0, t1) = (start_tangent(&left), end_tangent(&right));
// The new curve passes the removed point at some parameter k: the left samples sit at k·i/N,
// the right ones at k + (1 − k)·i/N. Search k for the closest fit.
let fit = |k: f64| {
let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect();
fit_single_from(&pts, u, t0, t1)
};
let (curve, _, _) = golden_min(fit, 0.0, 1.0);
if curve.p1.is_finite() && curve.p2.is_finite() {
anchors[pi].h_out = curve.p1;
anchors[ni].h_in = curve.p2;
}
}
/// [`remove_anchor`] for each of `indexes`, the last first, with the same result, moving each
/// anchor that stays at most once: removing many anchors from a long subpath one at a time shifts
/// the rest of it each time. Indexes out of range and repeats are skipped. Returns how many
/// anchors were removed.
pub fn remove_anchors(sp: &mut SubPath, indexes: &[usize]) -> usize {
let total = sp.anchors.len();
let mut targets: Vec<usize> = indexes.iter().copied().filter(|&i| i < total).collect();
targets.sort_unstable_by(|a, b| b.cmp(a));
targets.dedup();
if targets.is_empty() {
return 0;
}
// Every anchor below the one being removed is still there, so it keeps its index, the one
// before it is `index - 1` and the first one left is anchor 0 until anchor 0 itself goes.
let mut gone = vec![false; total];
let mut len = total;
let mut last = total - 1;
// The lowest anchor removed so far and the first one left after it.
let (mut lowest, mut after_lowest) = (total, None);
for &index in &targets {
let next = if index + 1 < lowest { Some(index + 1) } else { after_lowest };
if (sp.closed || (index > 0 && next.is_some())) && len >= 3 {
let pi = if index > 0 { index - 1 } else { last };
refit_across(&mut sp.anchors, pi, index, next.unwrap_or(0));
}
gone[index] = true;
len -= 1;
(lowest, after_lowest) = (index, next);
if next.is_none() {
last = index.saturating_sub(1);
}
if len == 0 {
continue;
}
let first = if index > 0 { 0 } else { next.unwrap_or(0) };
if !sp.closed {
// A new end has nothing beyond it.
if let Some(a) = sp.anchors.get_mut(first) {
a.h_in = a.p;
}
if let Some(a) = sp.anchors.get_mut(last) {
a.h_out = a.p;
}
}
if len < 3 {
let ends = [first, last].map(|i| sp.anchors.get(i).is_some_and(|a| a.has_in() || a.has_out()));
sp.closed = sp.closed && len == 2 && (ends[0] || ends[1]);
}
}
if len == 0 {
sp.closed = false;
}
let mut kept = 0;
for (i, &g) in gone.iter().enumerate() {
if !g {
if kept != i {
sp.anchors[kept] = sp.anchors[i];
#[cfg(test)]
tests::count_moves(1);
}
kept += 1;
}
}
sp.anchors.truncate(kept);
targets.len()
}
/// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a
/// golden-section search (a bounded number of steps).
fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) {
@@ -1188,6 +1291,129 @@ fn end_point(sp: &SubPath, last: bool) -> Option<Point> {
mod tests {
use super::*;
std::thread_local! {
/// Anchors moved within a subpath's anchor list by [`remove_anchor`] and [`remove_anchors`].
static MOVES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub(super) fn count_moves(n: usize) {
MOVES.set(MOVES.get() + n);
}
/// A subpath of `n` anchors from `seed`: corners, smooth points and points with one handle.
fn random_subpath(seed: &mut u64, n: usize, closed: bool) -> SubPath {
let mut rnd = || {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
*seed
};
let anchors = (0..n)
.map(|i| {
let p = Point::new(i as f64 * 20.0 + (rnd() % 100) as f64 / 10.0, (rnd() % 400) as f64 / 4.0);
let off = Vec2::new((rnd() % 300) as f64 / 10.0 - 15.0, (rnd() % 300) as f64 / 10.0 - 15.0);
match rnd() % 4 {
0 => Anchor::corner(p),
1 => Anchor::smooth(p, p + off),
2 => Anchor::with_handles(p, p, p + off),
_ => Anchor::with_handles(p, p - off, p),
}
})
.collect();
SubPath::new(anchors, closed)
}
/// `sp` with [`remove_anchor`] run for each of `indexes`, the last first.
fn one_at_a_time(sp: &SubPath, indexes: &[usize]) -> SubPath {
let mut order = indexes.to_vec();
order.sort_unstable_by(|a, b| b.cmp(a));
order.dedup();
let mut out = sp.clone();
for i in order {
remove_anchor(&mut out, i);
}
out
}
fn assert_same_bits(got: &SubPath, want: &SubPath, what: &str) {
let bits = |sp: &SubPath| -> Vec<[u64; 6]> {
sp.anchors.iter().map(|a| [a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits)).collect()
};
assert_eq!(got.closed, want.closed, "closed: {what}");
assert_eq!(bits(got), bits(want), "{what}");
assert_eq!(got.anchors.iter().map(|a| a.kind).collect::<Vec<_>>(), want.anchors.iter().map(|a| a.kind).collect::<Vec<_>>(), "{what}");
}
#[test]
fn removing_anchors_together_gives_what_removing_them_one_at_a_time_gives() {
let mut seed = 0x9e37_79b9_7f4a_7c15_u64;
for case in 0..600 {
let n = case % 40 + 1;
let closed = case % 2 == 0;
let sp = random_subpath(&mut seed, n, closed);
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
// Up to n + 2 picks over 0..n + 2: runs of neighbors, both ends, everything, repeats
// and indexes past the end.
let picks = (seed % (n as u64 + 3)) as usize;
let mut indexes: Vec<usize> = (0..picks).map(|k| ((seed >> (k % 48)) as usize).wrapping_add(k * 7) % (n + 2)).collect();
if case % 5 == 0 {
indexes = (0..n).collect();
}
let mut got = sp.clone();
let removed = remove_anchors(&mut got, &indexes);
let want = one_at_a_time(&sp, &indexes);
let what = format!("case {case}: {n} anchors, closed {closed}, removing {indexes:?}");
assert_same_bits(&got, &want, &what);
assert_eq!(removed, sp.anchors.len() - want.anchors.len(), "{what}");
}
}
#[test]
fn removing_anchors_together_keeps_each_step_of_one_at_a_time() {
let curve = |p: Point, d: f64| Anchor::smooth(p, p + Vec2::new(d, 25.0));
let pts = [Point::new(0.0, 0.0), Point::new(50.0, 40.0), Point::new(100.0, 0.0), Point::new(150.0, 40.0), Point::new(200.0, 0.0)];
let cases: Vec<(SubPath, Vec<usize>)> = vec![
// A refit at one removal makes the next segment curved, which the next removal refits.
(
SubPath::new(
vec![Anchor::corner(pts[0]), Anchor::corner(pts[1]), curve(pts[2], 20.0), Anchor::corner(pts[3]), Anchor::corner(pts[4])],
false,
),
vec![1, 2, 3],
),
// The last anchor of an open path goes, then the one before it becomes the end.
(SubPath::new(pts.iter().map(|&p| curve(p, 15.0)).collect(), false), vec![2, 3, 4]),
// The first anchor of a closed path refits across the wrap.
(SubPath::new(pts.iter().map(|&p| curve(p, -10.0)).collect(), true), vec![0, 4]),
// A closed path down to two anchors stays closed with a handle, and opens without one.
(SubPath::new(pts.iter().map(|&p| curve(p, 10.0)).collect(), true), vec![1, 2, 3]),
(SubPath::polyline(&pts, true), vec![1, 2, 3]),
(SubPath::polyline(&pts, true), vec![0, 1, 2, 3]),
(SubPath::polyline(&pts[..1], false), vec![0]),
(SubPath::new(vec![], true), vec![0]),
];
for (sp, indexes) in cases {
let mut got = sp.clone();
remove_anchors(&mut got, &indexes);
assert_same_bits(&got, &one_at_a_time(&sp, &indexes), &format!("{sp:?} removing {indexes:?}"));
}
}
#[test]
fn removing_anchors_together_moves_each_anchor_left_at_most_once() {
let n = 2001;
let pts: Vec<Point> = (0..n).map(|i| Point::new(i as f64, (i % 2) as f64)).collect();
let mut sp = SubPath::polyline(&pts, false);
let every_other: Vec<usize> = (1..n).step_by(2).collect();
MOVES.set(0);
assert_eq!(remove_anchors(&mut sp, &every_other), n / 2);
assert_eq!(sp.anchors.len(), n - n / 2);
let moves = MOVES.get();
assert!(moves < n, "{moves} anchors moved to remove {} of {n}", n / 2);
}
/// The samples Object › Path › Smooth at `level` smooths for `sp`, a subpath with no corner,
/// and the spread it smooths them with.
fn smooth_samples(sp: &SubPath, level: f64) -> (Vec<Point>, f64) {
+2 -2
View File
@@ -23,8 +23,8 @@ pub use boolean::{
};
pub use edit::{
AverageAxis, SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join,
remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, smooth_options,
split_into_grid,
remove_anchor, remove_anchors, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level,
smooth_options, split_into_grid,
};
pub use fit::{fit_cubics, fit_cubics_through};
pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path};
+71
View File
@@ -417,6 +417,77 @@ fn simplify_keeps_square_corners() {
assert!(!has_curves(&s));
}
/// The largest turn, in degrees, at an anchor of `sp` that is not an end of an open subpath: the
/// angle between the direction a segment arrives along and the one the next leaves along.
fn largest_join_turn(sp: &SubPath) -> f64 {
let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord);
let n = sp.segment_count();
let first = usize::from(!sp.closed);
(first..n)
.map(|i| {
let (prev, next) = (sp.segment((i + n - 1) % n), sp.segment(i));
let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0);
let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0);
arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees()
})
.fold(0.0, f64::max)
}
/// A refitted curve becomes a line only when the line keeps the smooth joins on both sides of it:
/// with no corners kept, the refit of a jagged line or of an arc running into a line turns by at most
/// 0.5° at every anchor. Points that all lie on one line still become one line.
#[test]
fn simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth() {
let mut seed = 7u64;
let mut noise = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
};
let jagged: Vec<Point> = (0..=60).map(|i| Point::new(100.0 + f64::from(i) * 5.0, 200.0 + 6.0 * noise())).collect();
let mut arc_into_line: Vec<Point> =
(0..=40).map(|i| Point::new(50.0 * (f64::from(i) / 80.0 * PI).cos(), 50.0 * (f64::from(i) / 80.0 * PI).sin())).collect();
arc_into_line.extend((1..=20).map(|i| Point::new(-f64::from(i) * 2.0, 50.0)));
for pts in [&jagged, &arc_into_line] {
let path = PathData::single(SubPath::polyline(pts, false));
for tolerance in [1.0, 2.5, 5.0] {
let s = simplify_with(&path, &SimplifyOptions { tolerance, corner_angle_deg: 179.0, ..Default::default() });
let turn = largest_join_turn(&s.subpaths[0]);
assert!(turn <= 0.5 + 1e-9, "{tolerance} pt: an anchor turns {turn}°: {s:?}");
assert!(max_deviation(&path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(&path, &s));
}
}
let in_line: Vec<Point> = [0.0, 3.0, 10.0, 11.0, 40.0, 41.5, 90.0, 100.0].map(|x| Point::new(x, 0.5 * x)).to_vec();
let s = simplify_with(&PathData::single(SubPath::polyline(&in_line, false)), &SimplifyOptions { tolerance: 1.0, ..Default::default() });
assert_eq!(s.anchor_count(), 2, "{s:?}");
assert!(!has_curves(&s), "{s:?}");
}
/// A smoothed loop with no corner is fitted across its start, and the join there also turns by at
/// most 0.5°. Each loop is a flat side that bends by 0.6° at the loop's start, closed by an arc of
/// `n` lines `height` tall, with `inner` anchors along each half of the side.
#[test]
fn simplify_keeps_the_join_at_the_start_of_a_smoothed_loop_smooth() {
for (half, height, n, inner, tolerance, smooth) in
[(40.0, 20.0, 8, 3, 2.5, 2.0), (80.0, 60.0, 8, 3, 5.0, 0.5), (160.0, 200.0, 24, 3, 10.0, 2.0), (160.0, 20.0, 24, 8, 10.0, 5.0)]
{
let rise = half * 0.3f64.to_radians().tan();
let along = |i: i32| Point::new(half * f64::from(i) / f64::from(inner), -rise * f64::from(i.abs()) / f64::from(inner));
let mut pts: Vec<Point> = (0..=inner).map(along).collect();
pts.extend((1..n).map(|i| {
let a = f64::from(i) / f64::from(n) * PI;
Point::new(half * a.cos(), -rise - height * a.sin())
}));
pts.extend((-inner..0).map(along));
let path = PathData::single(SubPath::polyline(&pts, true));
let s = simplify_with(&path, &SimplifyOptions { tolerance, corner_angle_deg: 179.0, smooth, ..Default::default() });
let sp = &s.subpaths[0];
assert!(sp.closed && sp.anchors.len() < pts.len(), "{tolerance} pt: {s:?}");
assert!(sp.segment_is_line(0), "{tolerance} pt: the first segment is a curve: {s:?}");
let turn = largest_join_turn(sp);
assert!(turn <= 0.5 + 1e-9, "{tolerance} pt: an anchor turns {turn}°: {s:?}");
}
}
#[test]
fn simplify_straight_lines_option() {
let c = dense_circle(50.0, 200);
+128 -21
View File
@@ -3,11 +3,15 @@
//! stays a linked image ([`LinkInfo`]); an SVG file becomes vector art. One that can't be read
//! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it
//! can be relinked, and a warning.
//!
//! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag)
//! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as
//! it is shown.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use usvg::{ImageHrefResolver, ImageKind, roxmltree};
use vectorcraft_doc::LinkInfo;
@@ -44,6 +48,20 @@ pub(in crate::import) struct Files {
svg_images: AtomicUsize,
/// SVG images were left out for exceeding [`MAX_NESTED_SVG_IMAGES`].
too_many_svg_images: AtomicBool,
/// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got
/// (kept here too, so the address stays its own).
cmyk: Mutex<HashMap<usize, (Bytes, Bytes)>>,
}
/// Encoded image bytes, shared.
type Bytes = Arc<Vec<u8>>;
/// Raster bytes as usvg gets them.
struct Shown {
kind: RasterKind,
bytes: Arc<Vec<u8>>,
/// The CMYK TIFF stored in their place: a turned CMYK image's inks, turned.
cmyk: Option<Vec<u8>>,
}
/// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…).
@@ -174,27 +192,37 @@ impl Files {
files
}
/// What usvg gets for a file read (`bytes`, linked by `link`): a raster usvg reads (others are
/// stored as PNG) or an SVG; `None` for anything else.
/// What usvg gets for a file read (`bytes`, linked by `link`): a raster as [`shown`] gives it
/// (formats usvg doesn't read are stored as PNG) or an SVG; `None` for anything else.
fn source(&mut self, bytes: Vec<u8>, link: LinkInfo) -> Option<Source> {
let Ok(format) = image::guess_format(&bytes) else {
// Unpacked once, here, within what the SVGZ images may unpack to.
let text = crate::text_within(&bytes, &self.unpacked).ok()?;
return text.contains("<svg").then(|| Source::Svg(Arc::from(text.as_ref())));
};
let Some(kind) = raster(format) else {
let img = image::load_from_memory_with_format(&bytes, format).ok()?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
let png = Arc::new(png);
self.links.insert(key(&png), Linked { link, missing: false });
return Some(Source::Raster(ImageKind::PNG, png));
};
let bytes = Arc::new(bytes);
let (kind, bytes) = self.keep(shown(Arc::new(bytes), format)?);
self.links.insert(key(&bytes), Linked { link, missing: false });
Some(Source::Raster(kind, bytes))
}
/// What usvg gets for `shown`, with its CMYK TIFF, when it has one, noted to store in its
/// place ([`Files::stored`]).
fn keep(&self, shown: Shown) -> (RasterKind, Arc<Vec<u8>>) {
if let Some(cmyk) = shown.cmyk
&& let Ok(mut stored) = self.cmyk.lock()
{
stored.insert(key(&shown.bytes), (shown.bytes.clone(), Arc::new(cmyk)));
}
(shown.kind, shown.bytes)
}
/// The CMYK TIFF stored in place of the image whose data usvg got as `kind`: a CMYK JPEG or
/// TIFF its orientation turns, which usvg got as PNG.
pub(in crate::import) fn stored(&self, kind: &ImageKind) -> Option<Arc<Vec<u8>>> {
let ImageKind::PNG(b) = kind else { return None };
self.cmyk.lock().ok()?.get(&key(b)).map(|(_, tiff)| tiff.clone())
}
/// Does href `h` show a placeholder?
fn placeholder(&self, h: &str) -> bool {
matches!(self.sources.get(h), Some(Source::Raster(_, b)) if self.links.get(&key(b)).is_some_and(|l| l.missing))
@@ -241,21 +269,24 @@ impl Files {
pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> {
self.depth.store(height, Ordering::Relaxed);
ImageHrefResolver {
// By type as usvg reads them; one without a type (`text/plain`) by its bytes: a
// raster usvg reads, else SVG.
// By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its
// bytes: one of those rasters, else SVG. A raster as [`shown`] gives it.
resolve_data: Box::new(move |mime, bytes, opts| {
let kind = match mime {
"image/jpg" | "image/jpeg" => ImageKind::JPEG,
"image/png" => ImageKind::PNG,
"image/gif" => ImageKind::GIF,
"image/webp" => ImageKind::WEBP,
use image::ImageFormat as F;
let format = match mime {
"image/jpg" | "image/jpeg" => F::Jpeg,
"image/png" => F::Png,
"image/gif" => F::Gif,
"image/webp" => F::WebP,
"image/tiff" => F::Tiff,
"image/svg+xml" => return self.svg_data(&bytes, opts),
"text/plain" => match image::guess_format(&bytes).ok().and_then(raster) {
Some(kind) => kind,
"text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) {
Some(format) => format,
None => return self.svg_data(&bytes, opts),
},
_ => return None,
};
let (kind, bytes) = self.keep(shown(bytes, format)?);
Some(kind(bytes))
}),
resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? {
@@ -312,6 +343,82 @@ fn raster(format: image::ImageFormat) -> Option<RasterKind> {
}
}
/// Encoded raster `bytes`, `format` by their type (or by their bytes when they have none), as usvg
/// gets them, as Open and Place store them: unchanged when usvg reads the format and the image's
/// orientation ([`vectorcraft_doc::cmyk::exif_orientation`], read for PNG, JPEG, GIF, WebP and
/// TIFF) leaves the pixels as stored. Otherwise the pixels are read ([`decoded`],
/// [`tiff_pixels`]), turned or flipped as the orientation says, and written as PNG, so usvg sizes
/// and fits the image as it is shown. A CMYK TIFF also keeps its inks as a CMYK TIFF
/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]), and a CMYK JPEG or TIFF that is turned keeps them
/// turned ([`vectorcraft_doc::cmyk::turned_cmyk`]). `None` when the pixels can't be read, and for
/// a TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]), before any decoder opens it.
fn shown(bytes: Arc<Vec<u8>>, format: image::ImageFormat) -> Option<Shown> {
use image::ImageFormat as F;
if vectorcraft_doc::cmyk::oversized_tiff(&bytes).is_some() {
return None;
}
let orientation =
matches!(format, F::Png | F::Jpeg | F::Gif | F::WebP | F::Tiff).then(|| vectorcraft_doc::cmyk::exif_orientation(&bytes)).flatten();
if let Some(kind) = raster(format)
&& orientation.is_none()
{
return Some(Shown { kind, bytes, cmyk: None });
}
let tiff = image::guess_format(&bytes).ok() == Some(F::Tiff);
let mut img = if tiff { tiff_pixels(&bytes)? } else { decoded(&bytes, format)? };
let cmyk = match orientation {
Some(o) if matches!(format, F::Jpeg | F::Tiff) => vectorcraft_doc::cmyk::turned_cmyk(&bytes, o, None).map(|(tiff, _)| tiff),
None if tiff => vectorcraft_doc::cmyk::stored_cmyk_tiff(&bytes, None),
_ => None,
};
if let Some(orientation) = orientation {
img.apply_orientation(orientation);
}
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?;
Some(Shown { kind: ImageKind::PNG, bytes: Arc::new(png), cmyk })
}
/// The pixels of encoded image `bytes` (`format` when their bytes don't say), as stored, decoded
/// within [`vectorcraft_doc::MAX_RASTER_ALLOC`], counted as decoded and as RGBA, as Open and Place
/// decode them; `None` when they can't be.
fn decoded(bytes: &[u8], format: image::ImageFormat) -> Option<image::DynamicImage> {
use image::ImageDecoder as _;
let max = vectorcraft_doc::MAX_RASTER_ALLOC;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
if reader.format().is_none() {
reader.set_format(format);
}
let mut limits = image::Limits::default();
limits.max_alloc = Some(max);
reader.limits(limits);
let decoder = reader.into_decoder().ok()?;
let (w, h) = decoder.dimensions();
if decoder.total_bytes() > max || u64::from(w) * u64::from(h) * 4 > max {
return None;
}
image::DynamicImage::from_decoder(decoder).ok()
}
/// The pixels of TIFF `bytes`, as stored, read as Open and Place read them: a TIFF with extra
/// samples (CMYK or gray with alpha) as RGBA, CMYK converted with the active color settings
/// ([`vectorcraft_doc::cmyk::extra_samples_tiff_rgba`]); a 1-bit TIFF compressed with CCITT Group
/// 3 as RGBA ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]); others by [`decoded`], a CMYK TIFF
/// that stores one plane per ink with its inks written together first
/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]). `None` when they can't be read.
fn tiff_pixels(bytes: &[u8]) -> Option<image::DynamicImage> {
use image::DynamicImage::ImageRgba8;
let cms = vectorcraft_color::cms::active();
if let Some(img) = vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) {
return Some(ImageRgba8(img));
}
if let Some(read) = vectorcraft_doc::cmyk::group3_tiff_rgba(bytes) {
return read.ok().map(ImageRgba8);
}
decoded(bytes, image::ImageFormat::Tiff).or_else(|| decoded(&vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, None)?, image::ImageFormat::Tiff))
}
/// No image: left out, noted in `why` when `noted`.
fn leave_out(why: &AtomicBool, noted: bool) -> Option<ImageKind> {
if noted {
+6
View File
@@ -771,6 +771,12 @@ impl Importer {
.ok()
.and_then(|r| r.into_dimensions().ok())
.unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32));
// A turned CMYK image is stored as the CMYK TIFF of its inks, the size of the PNG usvg got.
let cmyk = self.files.stored(i.kind());
let (bytes, mime) = match &cmyk {
Some(tiff) => (tiff, "image/tiff"),
None => (bytes, mime),
};
// A linked file keeps a preview; a placeholder is all a missing one has (its preview).
let linked = self.files.linked(i.kind());
let missing = linked.is_some_and(|l| l.missing);
+278
View File
@@ -0,0 +1,278 @@
//! Raster images whose orientation (EXIF, or a TIFF's Orientation tag) turns their pixels import
//! as they are shown, linked or as data URLs: the pixels turned, the width and height swapped, and
//! the turned image fitted to the `<image>` box.
use std::path::PathBuf;
use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_svg::{ImportOptions, base64_encode, import_with, import_with_report};
fn svg(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120">{body}</svg>"#)
}
fn images(d: &Document) -> Vec<(&Node, &ImageObject)> {
let mut v = Vec::new();
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
v.push((n, &**im));
}
});
v
}
/// The one image `body` imports to, with its warnings, its link read from `dir` when given.
fn import_one(body: &str, dir: Option<&PathBuf>) -> (Document, ImageObject) {
let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path)));
let folder = dir.map(|d| d.to_string_lossy().into_owned());
let (d, w) = match &folder {
Some(f) => import_with(&svg(body), &ImportOptions { folder: Some(f), read: Some(&read) }).unwrap(),
None => import_with_report(&svg(body)).unwrap(),
};
assert!(w.is_empty(), "{w:?}");
let im = match &images(&d)[..] {
[(_, im)] => (*im).clone(),
other => panic!("{} images", other.len()),
};
(d, im)
}
/// A fresh folder for one test's files.
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-svg-orientation-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 16 × 8 JPEG, red on the left half, with an EXIF segment giving Orientation `orientation`
/// when there is one.
fn jpeg(orientation: Option<u16>) -> Vec<u8> {
let rgb = image::RgbImage::from_fn(16, 8, |x, _| if x < 8 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) });
let mut jpg = Vec::new();
image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap();
let Some(orientation) = orientation else { return jpg };
let o = orientation.to_le_bytes();
let exif: Vec<u8> =
[b"Exif\0\0".as_slice(), b"II", &[0x2a, 0, 8, 0, 0, 0], &[1, 0], &[0x12, 0x01, 3, 0, 1, 0, 0, 0, o[0], o[1], 0, 0], &[0, 0, 0, 0]].concat();
let mut tagged = jpg[..2].to_vec();
tagged.extend([0xff, 0xe1]);
tagged.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
tagged.extend(&exif);
tagged.extend(&jpg[2..]);
tagged
}
/// A little-endian, uncompressed TIFF of `w` × `h` pixels of `samples` 8-bit samples each (1 for
/// gray, 2 for gray with an unassociated alpha channel, 4 for CMYK inks), `data` row by row, with
/// an Orientation tag (274) of `orientation`. Its RowsPerStrip (278) value is at byte [`ROWS_AT`]
/// and its PlanarConfiguration (284, 1) value at [`PLANAR_AT`].
fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec<u8> {
let entries: u32 = if samples == 2 { 12 } else { 11 };
let ifd_end = 8 + 2 + entries * 12 + 4;
let bits_at = ifd_end;
let data_at = bits_at + if samples > 2 { 2 * u32::from(samples) } else { 0 };
let entry = |tag: u16, kind: u16, count: u32, value: u32| {
[&tag.to_le_bytes()[..], &kind.to_le_bytes(), &count.to_le_bytes(), &value.to_le_bytes()].concat()
};
// Two SHORTs fit in the entry; more are written after the directory.
let bits = match samples {
1 => 8,
2 => 8 | 8 << 16,
_ => bits_at,
};
let photometric = if samples == 4 { 5 } else { 1 };
let mut out = [b"II".as_slice(), &[0x2a, 0], &8u32.to_le_bytes(), &u16::try_from(entries).unwrap().to_le_bytes()].concat();
// ExtraSamples (338): unassociated alpha.
let extra = (samples == 2).then(|| entry(338, 3, 1, 2));
for e in [
entry(256, 4, 1, w),
entry(257, 4, 1, h),
entry(258, 3, u32::from(samples), bits),
entry(259, 3, 1, 1),
entry(262, 3, 1, photometric),
entry(273, 4, 1, data_at),
entry(274, 3, 1, u32::from(orientation)),
entry(277, 3, 1, u32::from(samples)),
entry(278, 4, 1, h),
entry(279, 4, 1, u32::try_from(data.len()).unwrap()),
entry(284, 3, 1, 1),
]
.into_iter()
.chain(extra)
{
out.extend(e);
}
out.extend(0u32.to_le_bytes());
if samples > 2 {
for _ in 0..samples {
out.extend(8u16.to_le_bytes());
}
}
assert_eq!(out.len(), data_at as usize);
out.extend(data);
out
}
/// Where [`tiff`] writes the RowsPerStrip value (the ninth entry) and the PlanarConfiguration value
/// (the eleventh).
const ROWS_AT: usize = 8 + 2 + 8 * 12 + 8;
const PLANAR_AT: usize = 8 + 2 + 10 * 12 + 8;
/// A 4 × 2 gray TIFF, black on the left half, turned a quarter clockwise by Orientation 6.
fn gray_tiff() -> Vec<u8> {
tiff(4, 2, 1, &[0, 0, 255, 255, 0, 0, 255, 255], 6)
}
/// Where the image's own box goes on the page.
fn placed(im: &ImageObject) -> Rect {
im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))
}
fn near(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6)
}
/// The stored pixels of `im`, as RGB.
fn pixels(d: &Document, im: &ImageObject) -> image::RgbImage {
image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgb8()
}
fn red(p: &image::Rgb<u8>) -> bool {
p[0] > 150 && p[1] < 80
}
/// A JPEG whose EXIF Orientation 6 turns it a quarter clockwise shows upright, linked and as a
/// data URL alike: 8 × 16, the left half on top, fitted (centered) in a square box as the turned
/// image is, and stored without an orientation that would turn it again.
#[test]
fn a_turned_jpeg_imports_upright() {
let jpg = jpeg(Some(6));
let dir = folder("jpeg");
std::fs::write(dir.join("photo.jpg"), &jpg).unwrap();
let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg));
let untyped = format!("data:;base64,{}", base64_encode(&jpg));
for href in ["photo.jpg", data.as_str(), untyped.as_str()] {
let (d, im) = import_one(&format!(r#"<image href="{href}" x="10" y="20" width="80" height="80"/>"#), Some(&dir));
let what = &href[..href.len().min(20)];
assert_eq!((im.width, im.height), (8, 16), "{what}: turned a quarter");
assert!(near(placed(&im), Rect::new(30.0, 20.0, 70.0, 100.0)), "{what}: fitted as turned: {:?}", placed(&im));
let px = pixels(&d, &im);
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "{what}: the left half is on top");
assert_eq!(vectorcraft_doc::cmyk::exif_orientation(&d.images[&im.key].bytes), None, "{what}: not turned again");
assert_eq!(im.link.is_some(), href == "photo.jpg", "{what}");
}
}
/// With `preserveAspectRatio="none"` the turned image fills the box.
#[test]
fn a_turned_jpeg_stretches_to_the_box_as_turned() {
let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6))));
let (_, im) = import_one(&format!(r#"<image href="{data}" x="10" y="20" width="80" height="40" preserveAspectRatio="none"/>"#), None);
assert_eq!((im.width, im.height), (8, 16));
assert!(near(placed(&im), Rect::new(10.0, 20.0, 90.0, 60.0)), "{:?}", placed(&im));
}
/// A JPEG without an orientation, or with Orientation 1, keeps its own bytes.
#[test]
fn an_upright_jpeg_keeps_its_bytes() {
for jpg in [jpeg(None), jpeg(Some(1))] {
let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg));
let (d, im) = import_one(&format!(r#"<image href="{data}" width="80" height="80"/>"#), None);
assert_eq!((im.width, im.height), (16, 8));
assert_eq!(*d.images[&im.key].bytes, jpg);
assert_eq!(d.images[&im.key].mime, "image/jpeg");
}
}
/// A TIFF whose Orientation tag is 6 shows upright, linked, as an `image/tiff` data URL and as a
/// data URL without a type.
#[test]
fn a_turned_tiff_imports_upright() {
let tif = gray_tiff();
let dir = folder("tiff");
std::fs::write(dir.join("scan.tif"), &tif).unwrap();
let data = format!("data:image/tiff;base64,{}", base64_encode(&tif));
let untyped = format!("data:;base64,{}", base64_encode(&tif));
for href in ["scan.tif", data.as_str(), untyped.as_str()] {
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), Some(&dir));
let what = &href[..href.len().min(20)];
assert_eq!((im.width, im.height), (2, 4), "{what}: turned a quarter");
assert!(near(placed(&im), Rect::new(0.0, 0.0, 20.0, 40.0)), "{what}: {:?}", placed(&im));
let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8();
assert_eq!((px.get_pixel(0, 0)[0], px.get_pixel(1, 1)[0], px.get_pixel(0, 3)[0]), (0, 0, 255), "{what}: the left half is on top");
}
}
/// A CMYK TIFF keeps its inks as a CMYK TIFF, turned when its orientation turns it.
#[test]
fn a_cmyk_tiff_keeps_its_inks_turned_or_not() {
let (c, none) = ([255u8, 0, 0, 0], [0u8; 4]);
let data: Vec<u8> = [c, c, none, none, c, c, none, none].concat();
for (orientation, (w, h), cyan_at, none_at) in [(1, (4usize, 2usize), (1, 1), (2, 0)), (6, (2, 4), (1, 1), (0, 2))] {
let tif = tiff(4, 2, 4, &data, orientation);
let href = format!("data:image/tiff;base64,{}", base64_encode(&tif));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
let what = format!("orientation {orientation}");
assert_eq!((im.width as usize, im.height as usize), (w, h), "{what}");
let blob = &d.images[&im.key];
assert_eq!(blob.mime, "image/tiff", "{what}");
let inks = blob.cmyk().expect("CMYK inks");
assert_eq!((inks.width as usize, inks.height as usize), (w, h), "{what}");
let ink = |(x, y): (usize, usize)| &inks.data[(y * w + x) * 4..][..4];
assert_eq!((ink((0, 0)), ink(cyan_at), ink(none_at)), (&c[..], &c[..], &none[..]), "{what}: cyan on the left, or on top");
}
}
/// A gray TIFF with an alpha channel, which the image decoder can't read, imports as Open and
/// Place read it, with its transparency: upright, and turned by Orientation 6.
#[test]
fn a_gray_tiff_with_alpha_imports_turned_or_not() {
let (black, clear) = ([0u8, 255], [255u8, 0]);
let data: Vec<u8> = [black, black, clear, clear, black, black, clear, clear].concat();
for (orientation, size, black_at, clear_at) in [(1, (4, 2), (0, 1), (3, 0)), (6, (2, 4), (1, 1), (0, 3))] {
let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff(4, 2, 2, &data, orientation)));
let (d, im) = import_one(&format!(r#"<image href="{href}" width="20" height="40"/>"#), None);
assert_eq!((im.width, im.height), size, "orientation {orientation}");
let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8();
assert_eq!(px.get_pixel(black_at.0, black_at.1).0, [0, 0, 0, 255], "orientation {orientation}: black");
assert_eq!(px.get_pixel(clear_at.0, clear_at.1)[3], 0, "orientation {orientation}: clear");
}
}
/// A turned TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a
/// strip each, times 5 planes) is refused without a panic: linked, it is a placeholder with a
/// warning; as a data URL, it is left out.
#[test]
fn a_tiff_with_more_strips_than_can_be_counted_is_refused() {
let mut tif = tiff(3, 1 << 31 | 1, 5, &[0; 30], 6);
tif[ROWS_AT..ROWS_AT + 4].copy_from_slice(&1u32.to_le_bytes());
tif[PLANAR_AT..PLANAR_AT + 2].copy_from_slice(&2u16.to_le_bytes());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&tif), Some((3, 1 << 31 | 1)));
let dir = folder("oversized");
std::fs::write(dir.join("huge.tif"), &tif).unwrap();
let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path)));
let f = dir.to_string_lossy();
let (d, w) =
import_with(&svg(r#"<image href="huge.tif" width="20" height="40"/>"#), &ImportOptions { folder: Some(&f), read: Some(&read) }).unwrap();
assert!(w.iter().any(|w| w.contains("huge.tif") && w.contains("is not an image VectorCraft reads")), "{w:?}");
let [(_, im)] = images(&d)[..] else { panic!("{} images", images(&d).len()) };
assert!(d.images[&im.key].is_proxy(), "a placeholder");
let data = format!("data:image/tiff;base64,{}", base64_encode(&tif));
let (d, _) = import_with_report(&svg(&format!(r#"<image href="{data}" width="20" height="40"/>"#))).unwrap();
assert!(images(&d).is_empty());
}
/// A turned JPEG inside an SVG shown as an image is turned too.
#[test]
fn a_turned_jpeg_in_an_svg_image_imports_upright() {
let jpg = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6))));
let inner = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><image href="{jpg}" width="80" height="80"/></svg>"#);
let nested = format!("data:image/svg+xml;base64,{}", base64_encode(inner.as_bytes()));
let (d, im) = import_one(&format!(r#"<image href="{nested}" width="80" height="80"/>"#), None);
assert_eq!((im.width, im.height), (8, 16));
assert!(near(placed(&im), Rect::new(20.0, 0.0, 60.0, 80.0)), "{:?}", placed(&im));
let px = pixels(&d, &im);
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)));
}
+1
View File
@@ -20,6 +20,7 @@ mod font_weights;
mod foreign_attributes;
mod gradients;
mod ids;
mod image_orientation;
mod import_details;
mod import_live;
mod import_text;
+2
View File
@@ -105,6 +105,8 @@ pub struct FontDb {
family_cache: Mutex<Option<Arc<[String]>>>,
menu_cache: Mutex<Option<MenuFamilies>>,
generation: AtomicU64,
/// This database's [`FontDb::id`].
id: u64,
/// System fallback state: characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
+10
View File
@@ -21,6 +21,9 @@ use super::{Arc, AtomicU64, FontDb, FontPaths, HashMap, Mutex, Ordering, Path, P
#[cfg(not(target_arch = "wasm32"))]
use super::{ScanStamp, modified};
/// The next [`FontDb::id`].
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
impl FontDb {
/// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders,
/// read with their subfolders, and font files).
@@ -64,6 +67,7 @@ impl FontDb {
family_cache: Mutex::new(None),
menu_cache: Mutex::new(None),
generation: AtomicU64::new(0),
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
#[cfg(not(target_arch = "wasm32"))]
@@ -128,6 +132,12 @@ impl FontDb {
self.generation.load(Ordering::Relaxed)
}
/// A number no other database made in this process has: with [`FontDb::generation`], it tells
/// what a list built from a database was built from.
pub fn id(&self) -> u64 {
self.id
}
/// Family names available (loaded plus installed system fonts), sorted and deduplicated.
pub fn families(&self) -> Vec<String> {
self.family_list().to_vec()
+89 -28
View File
@@ -516,6 +516,9 @@ struct LineJustification {
/// The tracking after the line's last character that its alignment leaves out of its width:
/// [`LineGeometry::trailing`] for a line that isn't justified, 0 for one that is.
trailing: f64,
/// The glyphs justification leaves at their natural advance, by offset from the line's first
/// glyph: on a line with a tab, those drawn up to its last tab ([`tab_fixed_glyphs`]).
fixed: Vec<bool>,
}
fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool {
@@ -818,10 +821,48 @@ fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: u
}
}
// A space that takes a share of a justified line's room: not a zero-width space, which is a line
// break opportunity but not a word separator.
fn is_justified_space(g: &SGlyph) -> bool {
g.is_space() && !is_format_control(g.ch)
// The glyphs of a justified line that keep their natural advance, by offset from the line's first
// glyph: on a line with a tab of its own (not a list marker's), every glyph drawn up to and
// including the last such tab, so the tabs keep their stops and leaders. Justification spreads the
// line's room over the glyphs drawn after that tab. Empty on a line without such a tab.
fn tab_fixed_glyphs(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> Vec<bool> {
let sg = &paragraph.sg;
let Some(last) = line.drawn.iter().rposition(|&j| j >= paragraph.marker && sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0)) else {
return Vec::new();
};
let mut fixed = vec![false; line.body.saturating_sub(line.index)];
for &j in line.drawn.iter().take(last + 1) {
if let Some(slot) = j.checked_sub(line.index).and_then(|k| fixed.get_mut(k)) {
*slot = true;
}
}
fixed
}
// Does justification leave glyph `glyph` of the line starting at `index` at its natural advance
// (see [`tab_fixed_glyphs`])?
fn is_tab_fixed(fixed: &[bool], index: usize, glyph: usize) -> bool {
glyph.checked_sub(index).and_then(|k| fixed.get(k)).copied().unwrap_or(false)
}
// The natural width of justified area type `line` with a tab, as [`emit_flow_line`] draws it from
// the line's start: its glyphs in drawing order, each tab up to its stop from where the glyphs
// drawn before it end, then the line's hyphen.
fn tabbed_natural_width(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, fixed: &[bool]) -> f64 {
let sg = &paragraph.sg;
let start = line.ax0 - line.opt_l;
let mut x = start;
for &j in &line.drawn {
let Some(g) = sg.get(j) else { continue };
x += if g.ch == '\t' && g.len > 0 && is_tab_fixed(fixed, line.index, j) {
tab_advance(&paragraph.para.tabs, line.position.x0, x + line.opt_l, sg.get(j + 1..line.trimmed).unwrap_or_default()).0
} else if j + 1 == line.trimmed {
g.adv - line.end_trim
} else {
g.adv
};
}
x - start + line.hyphen.as_ref().map_or(0.0, |h| h.adv)
}
// May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic,
@@ -867,7 +908,11 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
Justify::JustifyAll => (0, true),
};
justify &= paragraph.regions.is_some();
let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).count();
// A tab keeps its stop: the glyphs drawn up to a line's last tab keep their advances, and the
// ones after it take the line's room.
let fixed = tab_fixed_glyphs(paragraph, line);
let free = |j: usize| !is_tab_fixed(&fixed, line.index, j);
let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j])).count();
// A gap that justification adds between two glyphs goes into the advance of the one
// drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a
// right-to-left line comes before it in the text.
@@ -877,10 +922,10 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
*slot = Some(b);
}
}
let cjk_gaps = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).count();
let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg)).count();
// Single Word Justification: a justified line holding one word (no word space) is
// spread between its letters (Full Justify) or aligned.
let single_word = justify && spaces == 0 && cjk_gaps == 0;
// spread between its letters (Full Justify) or aligned. A tab separates two words.
let single_word = justify && fixed.is_empty() && spaces == 0 && cjk_gaps == 0;
(align, justify) = match (single_word, para.justification.single_word) {
(false, _) | (true, SingleWordJustify::FullJustify) => (align, justify),
(true, SingleWordJustify::AlignLeft) => (0, false),
@@ -889,12 +934,24 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
};
// Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas,
// and the word spaces and gaps take what is left.
let kashidas = if justify && !cx.vertical && room > line.render_width + EPS {
kashida::plan(cx.text, sg, line.index..line.body, para.kashida, room - line.render_width, |byte| cx.style_at(byte).kashidas)
// On a line with a tab, the kashidas go in the text after its last tab, when that text is one
// run of characters.
let kashida_range = if fixed.is_empty() {
Some(line.index..line.body)
} else {
let start = (line.index..line.body).find(|&j| free(j)).unwrap_or(line.body);
let end = (line.index..line.body).rev().find(|&j| free(j)).map_or(start, |j| j + 1);
(start..end).all(free).then_some(start..end)
};
// A justified line with a tab is as wide as it is drawn: in a right-to-left line its tabs are
// drawn in another order than the one the line was measured in.
let natural = if justify && !fixed.is_empty() { tabbed_natural_width(paragraph, line, &fixed) } else { line.render_width };
let kashidas = if let Some(range) = kashida_range.filter(|r| justify && !cx.vertical && !r.is_empty() && room > natural + EPS) {
kashida::plan(cx.text, sg, range, para.kashida, room - natural, |byte| cx.style_at(byte).kashidas)
} else {
Vec::new()
};
let width = line.render_width + kashidas.iter().map(|k| k.1).sum::<f64>();
let width = natural + kashidas.iter().map(|k| k.1).sum::<f64>();
let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0);
let mut fit = LineFit::default();
let mut squeeze = 0.0;
@@ -904,13 +961,17 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS;
if pushed_in {
let ems: f64 = (line.index..line.body)
.filter(|&j| is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j]))
.filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j]))
.map(|j| squeeze_em(&sg[j], cx.vertical))
.sum();
if ems > EPS {
squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE);
}
} else if justify && (room - width).abs() > EPS {
// What justification may change of each glyph: nothing of one drawn up to the last tab.
let line_flex = || -> Vec<Flex> {
(line.index..line.body).map(|j| if free(j) { flow_line_flex(j, &paragraph.flex, held, hung) } else { Flex::default() }).collect()
};
if cjk_gaps > 0 && room > width {
// Spread over the CJK gaps and the word spaces alike.
per_space = (room - width) / (cjk_gaps + spaces) as f64;
@@ -918,18 +979,20 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
} else if spaces > 0 {
// Word spacing, letter spacing and glyph scaling within their ranges, then the
// word spaces alone; composed lines may shrink word spaces (never below zero).
let flex: Vec<Flex> = (line.index..line.body).map(|j| flow_line_flex(j, &paragraph.flex, held, hung)).collect();
let floor =
(line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).map(|j| sg[j].adv).fold(f64::MAX, f64::min);
let flex = line_flex();
let floor = (line.index..line.body)
.filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j]))
.map(|j| sg[j].adv)
.fold(f64::MAX, f64::min);
fit = fit_line(&flex, room - width, spaces, floor);
} else if room > width {
// One word: its letter spacing and glyph scaling, then the gaps between its glyphs.
let flex: Vec<Flex> = (line.index..line.body).map(|j| flow_line_flex(j, &paragraph.flex, held, hung)).collect();
let flex = line_flex();
fit = fit_line(&flex, room - width, 0, 0.0);
let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum();
let rest = room - width - used;
let hyphen = line.hyphen.is_some();
let gaps = (line.index..line.body).filter(|&j| is_letter_gap(j, held, &after, line.index, sg, hyphen)).count();
let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, &after, line.index, sg, hyphen)).count();
if gaps > 0 && rest > EPS {
per_gap = rest / gaps as f64;
}
@@ -961,7 +1024,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
_ => line.ax1 + line.opt_r - width,
}
};
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing }
LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed }
}
/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop
@@ -1039,22 +1102,20 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
let mut leader = "";
if glyph.ch == '\t' {
(advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance);
} else if glyph_index < body {
} else if glyph_index < body && !is_tab_fixed(&justification.fixed, index, glyph_index) {
let flex = flow_line_flex(glyph_index, &paragraph.flex, &line.held, &line.hung);
if is_word_space(glyph) {
advance += justification.fit.space(&flex);
advance += justification.fit.space(&flex) + justification.per_space;
} else {
advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex);
scale = justification.fit.glyph_scale(&flex);
}
if is_justified_space(glyph) {
advance += justification.per_space;
} else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 {
if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) {
advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical);
if justification.per_cjk != 0.0 || justification.squeeze != 0.0 {
if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) {
advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical);
}
} else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) {
advance += justification.per_gap;
}
} else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) {
advance += justification.per_gap;
}
}
// The kashida before a cluster in the text is drawn at the cluster's right, between
+141 -1
View File
@@ -4,7 +4,7 @@
use crate::*;
use kurbo::{Affine, Shape};
use vectorcraft_doc::text::Kashida;
use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind, TextRun};
use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TabAlign, TabStop, TextKind, TextRun};
use vectorcraft_geom::PathData;
fn db() -> &'static FontDb {
@@ -412,6 +412,146 @@ fn single_word_lines_follow_single_word_justification() {
assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}");
}
/// Word spacing at 100% with no range: the line's room goes on its word spaces alone.
fn no_ranges() -> JustificationOptions {
JustificationOptions { word_spacing: [100.0, 100.0, 100.0], ..JustificationOptions::default() }
}
/// `text` in a 300 pt wide frame with left tab stops at `stops` (a leader of `leader` on each).
fn tabbed(text: &str, stops: &[f64], leader: &str, justify: Justify, j: JustificationOptions) -> TextLayout {
let mut t = area(text, 300.0, justify, j);
t.para.tabs = stops.iter().map(|&position| TabStop { position, align: TabAlign::Left, leader: leader.to_string(), align_on: '.' }).collect();
layout(db(), &t)
}
/// Where the glyph of the character at byte `b` ends.
fn end(l: &TextLayout, b: usize) -> f64 {
at(l, b).origin.x + at(l, b).advance
}
/// A justified line with a tab: the text before its last tab keeps its spacing and the tab its
/// stop, and the word spaces after the tab take the line's room, so the line ends on the far edge.
#[test]
fn a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge() {
// A (0), A (1), space (2), B (3), B (4), tab (5), C (6), C (7), space (8), D (9), D (10); a
// soft return ends the line, so Justify Left justifies it.
let text = "AA BB\tCC DD\u{2028}x";
for j in [no_ranges(), JustificationOptions::default()] {
let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone());
let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j);
for b in 0..5 {
assert!((at(&l, b).origin.x - at(&natural, b).origin.x).abs() < 1e-6, "byte {b} stays where it was");
assert!((at(&l, b).advance - at(&natural, b).advance).abs() < 1e-6, "byte {b} keeps its advance");
}
assert!((end(&l, 5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 5));
assert!((end(&l, 10) - 300.0).abs() < 1e-6, "the last word ends on the edge: {}", end(&l, 10));
assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "the line's record ends there too: {}", l.lines[0].x1);
assert!(at(&l, 8).advance > at(&natural, 8).advance + 1.0, "the word space after the tab takes the room");
}
}
/// A leader stays inside its tab's gap on a justified line.
#[test]
fn a_justified_tab_keeps_its_leader_inside_its_gap() {
let l = tabbed("AA BB\tCC DD", &[150.0], ".", Justify::JustifyAll, no_ranges());
let tab = at(&l, 5);
let dots = tab.outline.bounding_box();
assert!(!tab.outline.elements().is_empty(), "the leader is drawn");
assert!(dots.x0 >= tab.origin.x - 1e-6 && dots.x1 <= 150.0 + 1e-6, "{dots:?} inside {}..150", tab.origin.x);
assert!((end(&l, 10) - 300.0).abs() < 1e-6, "{}", end(&l, 10));
}
/// Two words with a tab between them and no word space: the word after the tab is spread
/// between its letters to the edge, whatever Single Word Justification says, since a tab
/// separates two words.
#[test]
fn a_justified_word_tab_word_line_reaches_the_edge() {
// A (0), A (1), tab (2), B (3), B (4).
let text = "AA\tBB\u{2028}x";
for single_word in [SingleWordJustify::FullJustify, SingleWordJustify::AlignLeft, SingleWordJustify::AlignRight] {
let j = JustificationOptions { single_word, ..JustificationOptions::default() };
let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone());
let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j);
assert!(at(&l, 0).origin.x.abs() < 1e-6, "{single_word:?}: starts at the left edge: {}", at(&l, 0).origin.x);
assert!((at(&l, 0).advance - at(&natural, 0).advance).abs() < 1e-6, "{single_word:?}: the word before the tab keeps its width");
assert!((end(&l, 2) - 150.0).abs() < 1e-6, "{single_word:?}: the tab keeps its stop: {}", end(&l, 2));
assert!((end(&l, 4) - 300.0).abs() < 1e-6, "{single_word:?}: ends on the edge: {}", end(&l, 4));
}
}
/// The last line of a paragraph: Justify All spreads the text after its tab to the edge, and
/// Justify Left leaves it aligned left.
#[test]
fn the_last_line_with_a_tab_fills_the_measure_only_with_justify_all() {
let text = "AA BB\tCC DD";
let all = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges());
assert!((end(&all, 5) - 150.0).abs() < 1e-6 && (end(&all, 10) - 300.0).abs() < 1e-6, "{} {}", end(&all, 5), end(&all, 10));
let left = tabbed(text, &[150.0], "", Justify::JustifyLeft, no_ranges());
let natural = tabbed(text, &[150.0], "", Justify::Left, no_ranges());
assert!((end(&left, 10) - end(&natural, 10)).abs() < 1e-6 && end(&left, 10) < 250.0, "{}", end(&left, 10));
}
/// A line with two tabs: both keep their stops, the word space between them keeps its width, and
/// the text after the last tab takes the room.
#[test]
fn a_justified_line_takes_its_room_after_its_last_tab() {
// A (0), tab (1), B (2), space (3), C (4), tab (5), D (6), space (7), E (8).
let text = "A\tB C\tD E";
let natural = tabbed(text, &[80.0, 160.0], "", Justify::Left, no_ranges());
let l = tabbed(text, &[80.0, 160.0], "", Justify::JustifyAll, no_ranges());
assert!((end(&l, 1) - 80.0).abs() < 1e-6 && (end(&l, 5) - 160.0).abs() < 1e-6, "{} {}", end(&l, 1), end(&l, 5));
assert!((at(&l, 3).advance - at(&natural, 3).advance).abs() < 1e-6, "the space between the tabs keeps its width");
assert!((end(&l, 8) - 300.0).abs() < 1e-6, "{}", end(&l, 8));
}
/// Vertical area type: the tab keeps its stop down the column, and the text after it reaches the
/// column's end.
#[test]
fn a_justified_vertical_line_with_a_tab_reaches_the_end_of_its_column() {
let mut t = area("AA BB\tCC DD", 100.0, Justify::JustifyAll, no_ranges());
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 300.0).to_path(0.1)) };
t.vertical = true;
t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let l = layout(db(), &t);
let down = |b: usize| at(&l, b).origin.y + at(&l, b).advance;
assert!(at(&l, 0).origin.y.abs() < 1e-6, "{}", at(&l, 0).origin.y);
assert!((down(5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", down(5));
assert!((down(10) - 300.0).abs() < 1e-6, "the last word ends at the column's end: {}", down(10));
}
/// Right-to-left area type: the glyphs drawn up to the last tab drawn keep their advances, the tab
/// its stop, and the text drawn after it reaches the right edge.
#[test]
fn a_justified_right_to_left_line_with_a_tab_spans_its_measure() {
// א (0), ב (2), space (4), ג (5), ד (7), tab (9), ה (10), ו (12), space (14), ז (15), ח (17).
let text = "אב גד\tהו זח";
let natural = tabbed(text, &[150.0], "", Justify::Right, no_ranges());
let l = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges());
let mut drawn: Vec<&PositionedGlyph> = l.glyphs.iter().collect();
drawn.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
assert!(drawn[0].origin.x.abs() < 1e-6, "starts at the left edge: {:?}", drawn[0].origin);
let tab = drawn.iter().position(|g| g.byte == 9).unwrap();
for g in &drawn[..tab] {
assert!((g.advance - at(&natural, g.byte).advance).abs() < 1e-6, "byte {} keeps its advance", g.byte);
}
assert!((end(&l, 9) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 9));
let last = drawn.last().unwrap();
assert!((last.origin.x + last.advance - 300.0).abs() < 1e-6, "ends on the right edge: {}", last.origin.x + last.advance);
assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "{}", l.lines[0].x1);
}
/// Point type has no measure to fill: a justified line with a tab keeps its natural width.
#[test]
fn justified_point_type_with_a_tab_keeps_its_width() {
let mut t = point("AA BB\tCC DD", no_ranges());
t.para.justify = Justify::JustifyAll;
t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
let l = layout(db(), &t);
t.para.justify = Justify::Left;
let natural = layout(db(), &t);
assert!((end(&l, 10) - end(&natural, 10)).abs() < 1e-6, "{} {}", end(&l, 10), end(&natural, 10));
}
/// An OpenType (GSUB) Arabic face with a tatweel: craft-fonts' Noto Sans Arabic when the build has
/// craft-fonts (`CRAFT_FONTS_DIR`), else an installed one. None skips the test.
fn arabic_face(test: &str) -> Option<std::sync::Arc<FontFace>> {
+242 -24
View File
@@ -5,9 +5,9 @@
//! with [`distribute`].
use vectorcraft_doc::text::ListCounts;
use vectorcraft_doc::{ParaStyle, TextObject, TextRun};
use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun};
use crate::edit::{normalize, runs_len, slice_runs, style_at};
use crate::edit::{floor_char, normalize, runs_len};
use crate::{FontDb, layout};
/// The story: every frame's runs joined in thread order.
@@ -68,21 +68,23 @@ fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec<ParaS
(first..first + n).map(|i| paras.get(i).cloned().unwrap_or_else(|| fallback.clone())).collect()
}
/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph
/// How many bytes of `probe`'s runs fit in it (whole lines; a following break space or paragraph
/// break stays with the line that ends there, but on a path, where a space with no room
/// overflows as any character does, it starts the next frame).
fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
let mut probe = frame.clone();
probe.runs = runs.to_vec();
let lay = layout(db, &probe);
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
/// overflows as any character does, it starts the next frame), and for area type that overflows,
/// where its first line that does not fit starts.
fn fit(db: &FontDb, probe: &TextObject) -> (usize, Option<usize>) {
#[cfg(test)]
FIT_BYTES.set(FIT_BYTES.get() + runs_len(&probe.runs));
let lay = layout(db, probe);
let text: String = probe.runs.iter().map(|r| r.text.as_str()).collect();
if !lay.overflow {
return text.len();
return (text.len(), None);
}
let mut end = lay.lines.last().map_or(0, |l| l.end);
if lay.on_path {
return end;
return (end, None);
}
let unplaced = end;
let bytes = text.as_bytes();
while end < bytes.len() && bytes[end] == b' ' {
end += 1;
@@ -90,7 +92,198 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
if end < bytes.len() && bytes[end] == b'\n' {
end += 1;
}
end
(end, Some(unplaced))
}
#[cfg(test)]
thread_local! {
/// The bytes of text [`fit`] has laid out on this thread ([`fit_bytes`]).
static FIT_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The bytes of text [`fit`] has laid out on this thread.
#[cfg(test)]
fn fit_bytes() -> usize {
FIT_BYTES.get()
}
#[cfg(test)]
thread_local! {
/// The bytes of frames' own text copied on this thread to measure them ([`probe_copied`]).
static PROBE_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The bytes of frames' own text copied on this thread to measure them.
#[cfg(test)]
fn probe_copied() -> usize {
PROBE_COPIED.get()
}
/// A copy of `frame` to measure text in, its own text counted as copied ([`probe_copied`]).
fn copy_to_measure(frame: &TextObject) -> TextObject {
#[cfg(test)]
PROBE_COPIED.set(PROBE_COPIED.get() + runs_len(&frame.runs));
frame.clone()
}
/// The fewest bytes of story a frame is measured with before the measured text grows.
const MIN_MEASURE: usize = 512;
/// What is left of a story while [`distribute_continuing_levels`] hands it out, `len` bytes
/// borrowed from the story's runs: the runs from `run` on, the first of them without its first
/// `skip` bytes. While `raw` they are all the runs given (`run` and `skip` are 0), empty runs
/// included. Once a frame has taken a share they are the story after it as
/// [`crate::edit::slice_runs`] cuts it: no empty runs, and a run cut after its first character
/// without its glyph choice.
#[derive(Clone, Copy)]
struct Rest<'a> {
runs: &'a [TextRun],
run: usize,
skip: usize,
raw: bool,
len: usize,
}
impl<'a> Rest<'a> {
/// The runs' pieces in order: each piece's text, its run and whether the run was cut before it.
fn pieces(self) -> impl Iterator<Item = (&'a str, &'a TextRun, bool)> {
self.runs.get(self.run..).unwrap_or_default().iter().enumerate().filter_map(move |(k, r)| {
let at = if k == 0 { self.skip } else { 0 };
let text = r.text.get(at..)?;
(self.raw || !text.is_empty()).then_some((text, r, at > 0))
})
}
/// A piece's style: its run's, without the glyph choice when the run was cut before it.
fn style(r: &TextRun, cut: bool) -> CharStyle {
let mut style = r.style.clone();
if cut {
style.glyph_alternate = None;
}
style
}
/// [`crate::edit::slice_runs`] of the rest's runs.
fn slice(self, start: usize, end: usize) -> Vec<TextRun> {
let mut out = vec![];
let mut off = 0;
for (text, r, cut) in self.pieces() {
let (rs, re) = (off, off + text.len());
if rs >= end {
break;
}
let (a, b) = (start.max(rs), end.min(re));
if a < b {
let from = floor_char(text, a - rs);
let t = &text[from..floor_char(text, b - rs)];
if !t.is_empty() {
out.push(TextRun { text: t.to_string(), style: Self::style(r, cut || from > 0), inline: r.inline.clone() });
}
}
off = re;
}
out
}
/// The rest's runs before byte `end`, which follows a paragraph break, as a frame is measured
/// with them: every run that starts before it (an empty one too, while the runs are the ones
/// given), the last one cut there.
fn before(self, end: usize) -> Vec<TextRun> {
let mut out = vec![];
let mut off = 0;
for (text, r, cut) in self.pieces() {
if off >= end {
break;
}
let t = text.get(..end - off).unwrap_or(text);
out.push(TextRun { text: t.to_string(), style: Self::style(r, cut), inline: r.inline.clone() });
off += text.len();
}
out
}
/// [`crate::edit::style_at`] of the rest's runs.
fn style_at(self, byte: usize) -> CharStyle {
let mut off = 0;
let mut last = None;
let mut first = None;
for (text, r, cut) in self.pieces() {
first = first.or(Some((r, cut)));
let end = off + text.len();
if byte < end || (byte == end && byte == off) {
return Self::style(r, cut);
}
if !text.is_empty() {
last = Some((r, cut));
if byte == end {
return Self::style(r, cut);
}
}
off = end;
}
last.or(first).map(|(r, cut)| Self::style(r, cut)).unwrap_or_default()
}
/// The end of the first paragraph break at or after byte `from`, or None when the rest has no
/// break there.
fn break_after(self, from: usize) -> Option<usize> {
let mut off = 0;
for (text, _, _) in self.pieces() {
let at = from.saturating_sub(off);
if let Some(i) = text.as_bytes().get(at..).and_then(|b| b.iter().position(|&b| b == b'\n')) {
return Some(off + at + i + 1);
}
off += text.len();
}
None
}
/// All of the rest's runs.
fn all(self) -> Vec<TextRun> {
if self.raw { self.runs.to_vec() } else { self.slice(0, self.len) }
}
/// The rest after its first `n` bytes, as a slice of the story: the run that byte falls in
/// cut there (back to a character boundary, as [`crate::edit::slice_runs`] cuts it).
fn after(self, n: usize) -> Self {
let mut run = self.run;
let mut at = self.skip + n;
while let Some(r) = self.runs.get(run)
&& at >= r.text.len()
&& run + 1 < self.runs.len()
{
at -= r.text.len();
run += 1;
}
let skip = self.runs.get(run).map_or(0, |r| floor_char(&r.text, at));
let cut_back = at - skip;
Self { runs: self.runs, run, skip, raw: false, len: self.len + cut_back - n }
}
}
/// How many bytes of `rest` fit in `frame`, measured as `probe` makes the frame for some runs (with
/// their paragraph styles, the list counts and the continuation mark).
///
/// Area type without Shrink Text to Fit whose lines align to the top is first measured with the
/// whole paragraphs of the rest that end at or after byte `want`, and then with twice as many bytes
/// each time, while the rest goes on past them. The layout places lines in order and stops at the
/// first line that does not fit, and each paragraph is shaped and broken on its own text. So when
/// that line starts before the last paragraph break measured, the frame holds the same bytes with
/// the whole rest. Other type, and a rest that ends first, is measured with the whole rest.
fn fit_rest(db: &FontDb, probe: impl Fn(Vec<TextRun>) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize) -> usize {
let by_paragraph = matches!(frame.kind, TextKind::Area { .. })
&& frame.area.fit.min_scale().is_none()
&& frame.area.vertical_align == vectorcraft_doc::VerticalAlign::Top;
if by_paragraph {
while let Some(end) = rest.break_after(want.saturating_sub(1)).filter(|&end| end < rest.len) {
let (n, unplaced) = fit(db, &probe(rest.before(end)));
if unplaced.is_some_and(|u| u + 1 < end) {
return n;
}
want = end.saturating_mul(2);
}
}
fit(db, &probe(rest.all())).0
}
/// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the
@@ -186,31 +379,56 @@ pub fn distribute_continuing_levels(
continued: bool,
mut counts: ListCounts,
) -> Vec<Slice> {
let mut rest = runs.to_vec();
let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: runs_len(runs) };
// Once the story is used up, what is left is one empty run with the style there.
let mut used_up: Option<TextRun> = None;
let mut out = Vec::with_capacity(frames.len());
let mut next_para = 0;
let mut continues = continued;
let mut want = MIN_MEASURE;
for (i, f) in frames.iter().enumerate() {
let first_para = next_para;
let len = runs_len(&rest);
let empty_story: [TextRun; 1];
let rest = match used_up.clone() {
Some(empty) => {
empty_story = [empty];
Rest { runs: &empty_story, run: 0, skip: 0, raw: true, len: 0 }
}
None => story_rest,
};
let len = rest.len;
let own = [f.para.clone()];
let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) };
let n = if i + 1 == frames.len() {
len
} else {
let mut probe = (*f).clone();
probe.runs = rest.clone();
probe.set_paragraph_styles(slice_paras(paras, first_para, &rest));
probe.set_list_counts(counts);
probe.first_para_continues = continues;
fit(db, &probe, &rest)
// The frame without its own text, copied once and then for each measurement.
let mut base = copy_to_measure(f);
base.runs = Vec::new();
base.paras = Vec::new();
let probe = |runs: Vec<TextRun>| {
let mut probe = copy_to_measure(&base);
let styles = slice_paras(paras, first_para, &runs);
probe.runs = runs;
probe.set_paragraph_styles(styles);
probe.set_list_counts(counts);
probe.first_para_continues = continues;
probe
};
fit_rest(db, probe, f, rest, want)
};
let mut head = slice_runs(&rest, 0, n);
want = n.saturating_mul(2).max(MIN_MEASURE);
let mut head = rest.slice(0, n);
if head.is_empty() {
head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None });
head.push(TextRun { text: String::new(), style: rest.style_at(n), inline: None });
}
if used_up.is_none() {
if n >= len {
used_up = Some(TextRun { text: String::new(), style: rest.style_at(len), inline: None });
} else {
story_rest = story_rest.after(n);
}
}
let tail = slice_runs(&rest, n, len);
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail };
let head_paras = slice_paras(paras, first_para, &head);
next_para += head_paras.len().saturating_sub(1);
let next_counts = count_list_paragraphs(&head, &head_paras, counts);
+294 -1
View File
@@ -1,5 +1,6 @@
use super::*;
use vectorcraft_doc::{CharStyle, Justify, TextKind};
use crate::edit::{slice_runs, style_at};
use vectorcraft_doc::{Justify, TextKind};
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
fn frame(x: f64) -> TextObject {
@@ -448,6 +449,298 @@ fn list_levels_count_on_through_the_frames() {
assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1]));
}
/// The shares the frames would take if each one were measured with the whole rest of the story:
/// what [`distribute_continuing_levels`] gives, measuring each frame with less of it.
fn whole_rest_shares(
db: &FontDb,
frames: &[&TextObject],
runs: &[TextRun],
paras: &[ParaStyle],
continued: bool,
mut counts: ListCounts,
) -> Vec<Slice> {
let fit_whole = |frame: &TextObject, runs: &[TextRun]| {
let lay = layout(db, frame);
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
if !lay.overflow {
return text.len();
}
let mut end = lay.lines.last().map_or(0, |l| l.end);
if lay.on_path {
return end;
}
let bytes = text.as_bytes();
while end < bytes.len() && bytes[end] == b' ' {
end += 1;
}
if end < bytes.len() && bytes[end] == b'\n' {
end += 1;
}
end
};
let mut rest = runs.to_vec();
let mut out = Vec::with_capacity(frames.len());
let mut next_para = 0;
let mut continues = continued;
for (i, f) in frames.iter().enumerate() {
let first_para = next_para;
let len = runs_len(&rest);
let own = [f.para.clone()];
let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) };
let n = if i + 1 == frames.len() {
len
} else {
let mut probe = (*f).clone();
probe.runs = rest.clone();
probe.set_paragraph_styles(slice_paras(paras, first_para, &rest));
probe.set_list_counts(counts);
probe.first_para_continues = continues;
fit_whole(&probe, &rest)
};
let mut head = slice_runs(&rest, 0, n);
if head.is_empty() {
head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None });
}
let tail = slice_runs(&rest, n, len);
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail };
let head_paras = slice_paras(paras, first_para, &head);
next_para += head_paras.len().saturating_sub(1);
let next_counts = count_list_paragraphs(&head, &head_paras, counts);
let starts_continued = continues;
continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues);
let (list_count, list_level_counts) = counts.parts();
out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued });
counts = next_counts;
}
out
}
/// `frames` share `runs` as they would if each frame were measured with the whole rest, from the
/// story's start and continuing from text before it; returns how many frames took text.
fn same_as_whole_rest(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle], case: &str) -> usize {
let got = distribute(db, frames, runs, paras);
assert_eq!(got, whole_rest_shares(db, frames, runs, paras, false, ListCounts::default()), "{case}");
let counts = ListCounts::from_parts(3, &[2, 1]);
let continuing = distribute_continuing_levels(db, frames, runs, paras, true, counts);
assert_eq!(continuing, whole_rest_shares(db, frames, runs, paras, true, counts), "{case}, continuing");
got.iter().filter(|s| runs_len(&s.runs) > 0).count()
}
/// An area frame `w` by `h` at `x`.
fn sized(x: f64, w: f64, h: f64) -> TextObject {
let mut t = frame(x);
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + w, h)) };
t
}
/// Numbered `n` short paragraphs.
fn paragraphs(n: usize) -> String {
(1..=n).map(|i| format!("Item {i}: the quick brown fox jumps.")).collect::<Vec<_>>().join("\n")
}
/// Each frame takes the same share of a long story when it is measured with only the paragraphs
/// it needs as when it is measured with the whole rest of the story, for every kind of frame,
/// text and paragraph style that changes how much a frame holds.
#[test]
fn frames_take_the_shares_the_whole_rest_gives_them() {
use vectorcraft_doc::text::{NumberStyle, ParaList};
let db = FontDb::global();
let plain = CharStyle::default();
let big = CharStyle { size: 20.0, ..Default::default() };
let small = CharStyle { size: 7.0, ..Default::default() };
let story = |text: &str| vec![TextRun::new(text, plain.clone())];
let frames: Vec<TextObject> = (0..6).map(|k| frame(200.0 * k as f64)).collect();
fn refs(v: &[TextObject]) -> Vec<&TextObject> {
v.iter().collect()
}
let mut took = 0;
// Many short paragraphs, every one of its own style.
took += same_as_whole_rest(db, &refs(&frames), &story(&paragraphs(120)), &[], "short paragraphs");
let spaced: Vec<ParaStyle> = (0..120).map(|i| ParaStyle { space_before: (i % 4) as f64 * 3.0, ..Default::default() }).collect();
took += same_as_whole_rest(db, &refs(&frames), &story(&paragraphs(120)), &spaced, "spaced paragraphs");
// Frames of different sizes, one with no height and one too narrow for a word.
let uneven = vec![
sized(0.0, 120.0, 30.0),
sized(200.0, 120.0, 0.0),
sized(400.0, 200.0, 140.0),
sized(700.0, 4.0, 200.0),
sized(800.0, 60.0, 90.0),
frame(1000.0),
];
took += same_as_whole_rest(db, &refs(&uneven), &story(&paragraphs(120)), &[], "uneven frames");
// A word longer than a line, and runs of spaces and empty paragraphs where frames end.
let long_word = format!("{}\n{}\n{}", "x".repeat(700), paragraphs(30), "y".repeat(900));
took += same_as_whole_rest(db, &refs(&frames), &story(&long_word), &[], "long words");
let breaks = (0..200).map(|i| if i % 5 == 0 { String::new() } else { format!("Line {i} ends here ") }).collect::<Vec<_>>().join("\n");
took += same_as_whole_rest(db, &refs(&frames), &story(&breaks), &[], "spaces and empty paragraphs");
for k in [0usize, 1, 2, 3] {
let tall: Vec<TextObject> = (0..4).map(|i| sized(200.0 * i as f64, 120.0, 14.0 * (k + i + 1) as f64)).collect();
took += same_as_whole_rest(db, &refs(&tall), &story(&breaks), &[], &format!("frame heights {k}"));
}
// Style runs and inline graphics cut where frames end, and empty runs as given.
// Some runs start with a glyph choice, which a run cut after its first character loses.
let mut styled = vec![];
let text = paragraphs(90);
let words: Vec<&str> = text.split_inclusive(' ').collect();
for (i, three) in words.chunks(3).enumerate() {
let text = three.concat();
let mut style = [&plain, &big, &small][i % 3].clone();
if i % 4 == 0 {
let ch = text.chars().next().unwrap_or('I');
style.glyph_alternate = Some(vectorcraft_doc::GlyphChoice { ch, gid: 1, font: "Test".into(), version: String::new() });
}
styled.push(TextRun::new(text, style));
if i % 7 == 0 {
styled.push(TextRun::inline(vectorcraft_doc::InlineArt::new("sym"), plain.clone()));
}
if i % 11 == 0 {
styled.push(TextRun::new("", big.clone()));
}
}
took += same_as_whole_rest(db, &refs(&frames), &styled, &[], "style runs");
// Numbered lists at two levels, counted on from text before.
let at = |level| ParaStyle {
list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)),
left_indent: 18.0,
first_line_indent: -18.0,
..Default::default()
};
let listed: Vec<ParaStyle> = (0..120).map(|i| at(1 + i % 3 / 2)).collect();
took += same_as_whole_rest(db, &refs(&frames), &story(&paragraphs(120)), &listed, "lists");
// Justified and hyphenated paragraphs, long enough to run from frame to frame.
let text = "The quick brown fox jumps over the lazy dog, extraordinarily unbelievably. ".repeat(6);
let long_paras = (0..14).map(|_| text.trim_end()).collect::<Vec<_>>().join("\n");
let justified: Vec<ParaStyle> = (0..14)
.map(|i| ParaStyle { justify: [Justify::JustifyAll, Justify::JustifyLeft][i % 2], hyphenate: i % 3 != 0, ..Default::default() })
.collect();
took += same_as_whole_rest(db, &refs(&frames), &story(&long_paras), &justified, "justified and hyphenated");
// Paragraphs longer than a frame holds, each composed as a whole.
let tall: Vec<TextObject> = (0..5).map(|k| sized(200.0 * k as f64, 160.0, 160.0 + 40.0 * k as f64)).collect();
let very_long = (0..6).map(|_| text.repeat(4).trim_end().to_string()).collect::<Vec<_>>().join("\n");
for justify in [Justify::Left, Justify::JustifyLeft] {
let composed = [ParaStyle { justify, ..Default::default() }];
took += same_as_whole_rest(db, &refs(&tall), &story(&very_long), &composed, &format!("long paragraphs, {justify:?}"));
}
// Columns, vertical type, Shrink Text to Fit, lines aligned to the center, type on a path,
// and a last frame the story overflows.
let mut columns = frames.clone();
for f in &mut columns {
f.area.columns = 2;
f.area.gutter = 6.0;
}
took += same_as_whole_rest(db, &refs(&columns), &story(&paragraphs(120)), &[], "columns");
let mut vertical = frames.clone();
for f in &mut vertical {
f.vertical = true;
}
took += same_as_whole_rest(db, &refs(&vertical), &story(&paragraphs(80)), &[], "vertical type");
let mut mixed = frames.clone();
mixed[1].area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 60.0 };
mixed[2].area.vertical_align = vectorcraft_doc::VerticalAlign::Center;
mixed[3] = path_frame(300.0);
took += same_as_whole_rest(db, &refs(&mixed), &story(&paragraphs(120)), &[], "other kinds of frame");
let two = [&frames[0], &frames[1]];
took += same_as_whole_rest(db, &two, &story(&paragraphs(200)), &[], "overflowing last frame");
// Short stories, an empty one and one of empty runs.
took += same_as_whole_rest(db, &refs(&frames), &story("Hi"), &[], "short story");
took += same_as_whole_rest(db, &refs(&frames), &story(""), &[], "empty story");
took += same_as_whole_rest(db, &refs(&frames), &[TextRun::new("", big.clone()), TextRun::new("", small.clone())], &[], "empty runs");
assert!(took > 40, "the frames took text: {took}");
}
/// A seeded mix of run splits, sizes, paragraph styles and frame sizes: each frame takes the share
/// the whole rest gives it.
#[test]
fn seeded_stories_take_the_shares_the_whole_rest_gives_them() {
let db = FontDb::global();
let mut seed: u64 = 0x2545_f491_4f6c_dd1d;
let mut next = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed % n
};
let words = ["fox", "a", "jumps", "extraordinary", "dog,", "the", "lazy", "over", " ", "quick"];
for case in 0..12 {
let mut runs = vec![];
let mut paras = 1;
for _ in 0..(150 + next(250)) {
let mut text = words[next(words.len() as u64) as usize].to_string();
text.push(if next(6) == 0 {
paras += 1;
'\n'
} else {
' '
});
let style = CharStyle { size: [12.0, 9.0, 16.0][next(3) as usize], tracking: [0.0, 50.0][next(2) as usize], ..Default::default() };
runs.push(TextRun::new(text, style));
}
normalize(&mut runs);
let styles: Vec<ParaStyle> = (0..paras)
.map(|_| ParaStyle {
justify: [Justify::Left, Justify::Center, Justify::JustifyLeft][next(3) as usize],
first_line_indent: [0.0, 12.0][next(2) as usize],
space_after: [0.0, 4.0][next(2) as usize],
..Default::default()
})
.collect();
let frames: Vec<TextObject> = (0..2 + next(6)).map(|k| sized(200.0 * k as f64, 40.0 + next(120) as f64, 10.0 + next(120) as f64)).collect();
same_as_whole_rest(db, &frames.iter().collect::<Vec<_>>(), &runs, &styles, &format!("case {case}"));
}
}
/// A long story of short paragraphs through many frames: each frame is measured with about the
/// text it takes and a little more, not with the whole rest of the story.
#[test]
fn many_frames_measure_about_the_text_they_take() {
let db = FontDb::global();
let text = paragraphs(1500);
let story = vec![TextRun::new(text.clone(), CharStyle::default())];
let frames: Vec<TextObject> = (0..40).map(|k| frame(200.0 * k as f64)).collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let before = fit_bytes();
let parts = distribute(db, &refs, &story, &[]);
let measured = fit_bytes() - before;
let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect();
assert_eq!(joined, text, "no character lost or duplicated");
let placed: usize = parts[..parts.len() - 1].iter().map(|p| runs_len(&p.runs)).sum();
let longest = text.split('\n').map(str::len).max().unwrap_or(0);
assert!(placed > 0 && placed < text.len() / 4, "the frames hold part of the story: {placed} of {}", text.len());
let bound = 4 * placed + frames.len() * 2 * (MIN_MEASURE + longest);
assert!(measured <= bound, "measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len());
}
/// Frames that already hold text and take a story over several measurements each: each frame's own
/// text is copied once to measure it, not once for each measurement.
#[test]
fn frames_with_text_copy_it_once_to_measure_it() {
let db = FontDb::global();
let text = paragraphs(600);
let story = vec![TextRun::new(text.clone(), CharStyle::default())];
let mut frames: Vec<TextObject> = (0..4).map(|k| sized(400.0 * k as f64, 300.0, 600.0)).collect();
for f in &mut frames {
f.runs = vec![TextRun::new("Old text. ".repeat(500), CharStyle::default())];
}
let refs: Vec<&TextObject> = frames.iter().collect();
let (fit_before, copied_before) = (fit_bytes(), probe_copied());
let parts = distribute(db, &refs, &story, &[]);
let (measured, copied) = (fit_bytes() - fit_before, probe_copied() - copied_before);
let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect();
assert_eq!(joined, text, "no character lost or duplicated");
let first = runs_len(&parts[0].runs);
assert!(measured > 2 * first, "the first frame is measured more than once: {measured} bytes for {first}");
let own: usize = frames[..frames.len() - 1].iter().map(|f| runs_len(&f.runs)).sum();
assert_eq!(copied, own, "each frame before the last copies its own text once to measure it");
}
#[test]
fn short_story_leaves_later_frames_empty_with_a_style() {
let db = FontDb::global();
+2 -2
View File
@@ -9,8 +9,8 @@
mod solid;
mod visibility;
pub use solid::{
Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, inflate_each,
inflate_together, plane, plane_face_map,
Bevel, BevelShape, DepthPlane, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate,
inflate_each, inflate_together, plane, plane_face_map,
};
pub use visibility::{VisibleFace, visible_faces};
mod shading;
+42
View File
@@ -292,12 +292,53 @@ pub struct Facet {
/// The planar end of the solid the facet is: Extrude & Bevel's front or back cap, or a plane
/// (its front). `None` for every other facet.
pub end: Option<PlanarFace>,
/// The face's plane in view space and the projection it was drawn with, for its depth at a
/// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose
/// corners lie more than 0.001 pt off one plane (a piece of a twisted wall).
pub plane: Option<DepthPlane>,
/// The contours in view space, before projection, for tests that measure depths.
#[cfg(test)]
view: Vec<Vec<V3>>,
}
/// A facet's plane in view space (x right, y down, z toward the viewer): the points `v` with
/// `normal · v = offset`, drawn by a projection about `center` with the eye `eye` in front of the
/// object's center plane (`None` without perspective).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DepthPlane {
pub normal: [f64; 3],
pub offset: f64,
pub center: Point,
pub eye: Option<f64>,
}
impl DepthPlane {
/// The view-space depth (larger is nearer the viewer) of the point of the plane that projects
/// to `p`, a point of the drawing. `None` when the plane is seen edge-on there or the point is
/// not in front of the eye.
pub fn depth_at(&self, p: Point) -> Option<f64> {
let [nx, ny, nz] = self.normal;
let (x, y) = (p.x - self.center.x, p.y - self.center.y);
let z = match self.eye {
None => (nz.abs() > 1e-9).then(|| (self.offset - nx * x - ny * y) / nz)?,
Some(e) => {
// On the ray through `p`, the point at depth z is at (x, y) × (e - z) / e.
let a = (nx * x + ny * y) / e;
let den = nz - a;
let z = (den.abs() > 1e-9).then(|| (self.offset - a * e) / den)?;
(z < e).then_some(z)?
}
};
z.is_finite().then_some(z)
}
}
impl Facet {
/// The view-space depth of the facet at `p`, a point of the drawing ([`DepthPlane::depth_at`]).
pub fn depth_at(&self, p: Point) -> Option<f64> {
self.plane.as_ref()?.depth_at(p)
}
/// About how thick the facet is across: twice its area over its perimeter (a w × l rectangle
/// long against its width gives about w).
pub fn thickness(&self) -> f64 {
@@ -339,6 +380,7 @@ impl Facet {
// A revolved cap is not one of the planar faces graphics map onto.
end: None,
away: false,
plane: None,
#[cfg(test)]
view: vec![],
}
+11 -2
View File
@@ -6,8 +6,8 @@ use super::rings::{
solid_rings_traced,
};
use super::{
Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, eye_distance,
perspective_scale,
Bevel, DepthPlane, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View,
eye_distance, perspective_scale,
};
use crate::shadow::Projection;
@@ -129,6 +129,14 @@ impl Builder<'_> {
} else {
(V3(-n.0, -n.1, -n.2), false)
};
// A face whose corners lie off one plane (a piece of a twisted wall bent into a saddle) has
// no plane to measure its depth on. Nor has a face with a corner at or past the eye: the
// projection holds that corner just in front of the eye, so the drawing no longer matches
// the plane's projection there.
let offset = n.dot(centre);
let held = self.camera.eye.is_some_and(|e| contours.iter().flatten().any(|v| e - v.2 < e * 1e-3));
let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3);
let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye });
#[cfg(test)]
let view = contours.clone();
let contours: Vec<Vec<Point>> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect();
@@ -156,6 +164,7 @@ impl Builder<'_> {
side: false,
away: behind,
end: None,
plane,
#[cfg(test)]
view,
});
+40
View File
@@ -154,6 +154,46 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
assert!(open.len() > 2 * 8, "{}", open.len());
}
/// A flat facet carries its plane: the depth it gives at each corner of its drawing is the corner's
/// view-space depth, with and without perspective. A piece of a twisted wall whose corners lie off
/// one plane has none.
#[test]
fn a_flat_facet_measures_its_depth_on_its_plane() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let o = Extrude { depth: 60.0, cap: true, ..Extrude::default() };
for perspective in [0.0, 50.0] {
let view = View { rotation_x: -20.0, rotation_y: 30.0, perspective, ..front() };
let flat = extrude(&p, FillRule::EvenOdd, b, o, view).unwrap();
assert!(!flat.is_empty());
for f in &flat {
assert!(f.plane.is_some(), "{perspective}");
for (q, v) in f.contours.iter().flatten().zip(f.view.iter().flatten()) {
let d = f.depth_at(*q);
assert!(d.is_some_and(|d| close(d, v.2, 1e-6)), "{perspective}: {d:?} against {}", v.2);
}
}
let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 90.0, ..o }, view).unwrap();
assert!(twisted.iter().any(|f| f.side && f.plane.is_none()), "{perspective}");
}
}
/// A flat face with a corner at or past the eye, which the projection holds just in front of the
/// eye, has no plane: its drawing no longer matches the plane's projection there.
#[test]
fn a_face_reaching_past_the_eye_has_no_plane() {
use super::build::{Builder, Order};
let (_, b) = shape(&square(0.0, 0.0, 100.0));
let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0);
let eye = camera.center_and_eye().1.unwrap();
for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] {
let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None };
let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)];
builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap();
assert_eq!(builder.facets.len(), 1, "{far}");
assert_eq!(builder.facets[0].plane.is_some(), has_plane, "{far}");
}
}
#[test]
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
+112 -20
View File
@@ -49,6 +49,42 @@ fn lp_part_at<'a>(doc: &'a Document, p: Point, tol: f64, edge: bool, takes: &dyn
best.map(|(_, g, e)| (g, Some(e)))
}
/// The face and edge of a Live Paint group at `p`, among the groups `takes` takes, by the rules of
/// [`lp_part_at`], in one walk of the document. Inside a visible face of a group, the face is the
/// front-most such group's visible face under `p`, and with `edge` the edge is that group's visible
/// edge nearest `p` within `tol`. Outside every visible face there is no face, and with `edge` the
/// edge is the visible edge nearest `p` within `tol` among the groups (the front-most group's on a
/// tie).
fn lp_face_and_edge<'a>(
doc: &'a Document,
p: Point,
tol: f64,
edge: bool,
takes: &dyn Fn(NodeId) -> bool,
) -> (Option<&'a Arc<Node>>, Option<&'a Arc<Node>>) {
let mut in_face: Option<(&Arc<Node>, Option<&Arc<Node>>)> = None;
let mut best: Option<(f64, &Arc<Node>)> = None;
doc.walk(|n| {
if !is_live_paint(n) || !takes(n.id) {
return;
}
let near = if edge { visible_edge_near(n, p, tol) } else { None };
if let Some(f) = visible_face_at(n, p) {
in_face = Some((f, near));
}
if let Some(e) = near
&& let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p))
&& best.is_none_or(|b| d <= b.0)
{
best = Some((d, e));
}
});
match in_face {
Some((f, e)) => (Some(f), e),
None => (None, best.map(|b| b.1)),
}
}
/// Whether the Live Paint Bucket takes Live Paint group `id`: it is visible, with every layer and
/// group around it.
fn lp_visible(cx: &ToolContext, id: NodeId) -> bool {
@@ -62,17 +98,25 @@ fn lp_selectable(cx: &ToolContext, id: NodeId) -> bool {
cx.doc.is_editable(id) && crate::direct::in_scope(cx.doc, cx.isolation, id)
}
/// Whether every selected object is a Live Paint group (true with nothing selected). A selection
/// that also holds other paths has regions outside its groups' faces that a click fills, making
/// one Live Paint group of the selection.
fn lp_sel_all_live(cx: &ToolContext) -> bool {
cx.selection.objects.iter().all(|id| cx.doc.node(*id).is_some_and(is_live_paint))
}
/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`], among
/// the groups `takes` takes), else with `cache` the region of the selection under `p`. A large
/// selection's regions are found on another thread ([`RegionCache::ready`]), and until they are, no
/// region is highlighted.
/// the groups `takes` takes), else with `cache` the region of the selection under `p` when the
/// selection is not only Live Paint groups ([`lp_sel_all_live`]). A large selection's regions are
/// found on another thread ([`RegionCache::ready`]), and until they are, no region is highlighted.
fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> LpHover {
if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, takes) {
let path = part.and_then(|n| n.path_data()).map(|pd| pd.to_bezpath());
return path.map(|bp| if edge { LpHover::Edge(bp) } else { LpHover::Part(bp) }).unwrap_or_default();
}
let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint));
if !sel_is_live && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) {
if !lp_sel_all_live(cx)
&& let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p))
{
return LpHover::Region(i);
}
LpHover::None
@@ -125,7 +169,8 @@ enum LpPaint {
/// Shift-click fills the face. With both on, a click paints the edge nearest the pointer when one
/// is within 4 pixels, else it fills the face, and Shift-click fills the face. With both off, a
/// click paints nothing. With Highlight off, the face or edge under the pointer is not
/// highlighted.
/// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints
/// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper.
pub struct LivePaintBucketTool {
pub(super) cache: RegionCache,
pub(super) hover: LpHover,
@@ -202,21 +247,68 @@ impl LivePaintBucketTool {
}
/// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with
/// Highlight off, else the face or edge a click there paints.
/// Highlight off, else the face or edge a click there paints. With Highlight off the selection's
/// map is still made, for the cursor ([`Self::paints_at`]).
fn hover_at(&mut self, cx: &ToolContext, p: Point, shift: bool, alt: bool) -> LpHover {
if alt || !self.highlight {
if alt {
return LpHover::None;
}
let takes = |id| lp_visible(cx, id);
match self.paints(shift) {
LpPaint::Nothing => LpHover::None,
LpPaint::Fills => lp_hover(cx, Some(&mut self.cache), p, false, &takes),
// A click paints no region of the selection, so none is highlighted.
LpPaint::Strokes => lp_hover(cx, None, p, true, &takes),
LpPaint::Both => match lp_part_at(cx.doc, p, cx.tol(4.0), true, &takes) {
Some((_, Some(e))) => e.path_data().map(|pd| LpHover::Edge(pd.to_bezpath())).unwrap_or_default(),
_ => lp_hover(cx, Some(&mut self.cache), p, false, &takes),
},
let target = self.target_at(cx, p, shift);
if self.highlight { target } else { LpHover::None }
}
/// What a click at `p` reaches, Shift held or not: whether it fills a face and paints an edge,
/// with the face and the edge of a Live Paint group there ([`lp_face_and_edge`]). The document
/// is walked once: the highlight and the cursor are asked for on every pointer update.
fn parts_at<'a>(&self, cx: &ToolContext<'a>, p: Point, shift: bool) -> (bool, Option<&'a Arc<Node>>, Option<&'a Arc<Node>>) {
let (wants_face, wants_edge) = match self.paints(shift) {
LpPaint::Nothing => return (false, None, None),
LpPaint::Fills => (true, false),
LpPaint::Strokes => (false, true),
LpPaint::Both => (true, true),
};
let (face, edge) = lp_face_and_edge(cx.doc, p, cx.tol(4.0), wants_edge, &|id| lp_visible(cx, id));
(wants_face, face, edge)
}
/// The face or edge a click at `p` paints, Shift held or not. With Paint Strokes alone, a click
/// paints no region of the selection, so none is highlighted.
fn target_at(&mut self, cx: &ToolContext, p: Point, shift: bool) -> LpHover {
let (wants_face, face, edge) = self.parts_at(cx, p, shift);
let path = |n: &Arc<Node>| n.path_data().map(|pd| pd.to_bezpath());
if let Some(e) = edge {
return path(e).map(LpHover::Edge).unwrap_or_default();
}
if !wants_face {
return LpHover::None;
}
if let Some(f) = face {
return path(f).map(LpHover::Part).unwrap_or_default();
}
if !lp_sel_all_live(cx)
&& let Some(i) = self.cache.ready(cx, true).and_then(|m| m.region_at(p))
{
return LpHover::Region(i);
}
LpHover::None
}
/// The cursor for a click at `p`, Shift held or not: [`Cursor::LivePaintBucketEdge`] where it
/// paints an edge of a Live Paint group, [`Cursor::LivePaintBucketFace`] where it fills a face
/// of one or a region of the selected paths, else [`Cursor::LivePaintBucket`]. A region counts
/// once the selection's map is made (the pointer moving over it makes it), as it is highlighted.
fn paints_at(&self, cx: &ToolContext, p: Point, shift: bool) -> Cursor {
let (wants_face, face, edge) = self.parts_at(cx, p, shift);
let edge = edge.is_some();
let region =
|| !lp_sel_all_live(cx) && self.cache.key.as_ref() == Some(&RegionCache::key_for(cx, true)) && self.cache.map.region_at(p).is_some();
let face = wants_face && !edge && (face.is_some() || region());
if edge {
Cursor::LivePaintBucketEdge
} else if face {
Cursor::LivePaintBucketFace
} else {
Cursor::LivePaintBucket
}
}
}
@@ -315,8 +407,8 @@ impl Tool for LivePaintBucketTool {
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32)
}
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
if m.alt { Cursor::Eyedropper } else { Cursor::LivePaintBucket }
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
if m.alt { Cursor::Eyedropper } else { self.paints_at(cx, p, m.shift) }
}
fn options(&self) -> Value {
+99 -2
View File
@@ -1140,6 +1140,103 @@ fn bucket_options_choose_what_a_click_paints() {
assert_eq!(t.options()["paintFills"], false);
}
/// The cursor says what a click paints: a filled bucket over a face it fills, a brush over an
/// edge it paints, an empty bucket where it paints nothing, under each Paint Fills / Paint Strokes
/// setting and Shift; Alt keeps the Eyedropper, and Use Precise Cursors turns each into the
/// crosshair.
#[test]
fn bucket_cursor_shows_what_a_click_paints() {
let (d, ..) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Default::default() };
let alt = Mods { alt: true, ..Default::default() };
let at = |t: &LivePaintBucketTool, x: f64, m: Mods| t.cursor(&cx, Point::new(x, 25.0), m);
let (face, edge, none) = (Cursor::LivePaintBucketFace, Cursor::LivePaintBucketEdge, Cursor::LivePaintBucket);
let mut t = LivePaintBucketTool::default();
// Paint Fills alone: the face, and with Shift the edge in reach.
assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default()), at(&t, 400.0, Mods::default())], [face, face, none]);
assert_eq!([at(&t, 51.0, shift), at(&t, 75.0, shift)], [edge, none]);
assert_eq!([at(&t, 51.0, alt), at(&t, 400.0, alt)], [Cursor::Eyedropper, Cursor::Eyedropper]);
// Paint Strokes alone: the edge in reach, nothing farther away; Shift fills the face.
t.set_option("paintFills", &json!(false));
t.set_option("paintStrokes", &json!(true));
assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default())], [edge, none]);
assert_eq!([at(&t, 51.0, shift), at(&t, 75.0, shift)], [face, face]);
// Both: the edge in reach, else the face; Shift fills the face.
t.set_option("paintFills", &json!(true));
assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default()), at(&t, 400.0, Mods::default())], [edge, face, none]);
assert_eq!(at(&t, 51.0, shift), face);
// Both off: a click paints nothing.
t.set_option("paintFills", &json!(false));
t.set_option("paintStrokes", &json!(false));
assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, shift)], [none, none]);
// Highlight off: the cursor still says what a click paints.
let mut t = LivePaintBucketTool::default();
t.set_option("highlight", &json!(false));
assert_eq!([at(&t, 75.0, Mods::default()), at(&t, 51.0, shift)], [face, edge]);
for c in [face, edge, none] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
assert_eq!(c.stand_in(), None, "{c:?} is drawn");
}
}
/// Over a region of selected ordinary paths the cursor is the filled bucket once the pointer has
/// moved there (the move makes the selection's map), with Highlight on or off; with Paint Strokes
/// alone, a click there paints nothing and the bucket stays empty.
#[test]
fn bucket_cursor_fills_a_region_of_the_selected_paths() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let cx = cx(&d, &s, &p);
let pos = Point::new(75.0, 50.0);
for highlight in [true, false] {
let mut t = LivePaintBucketTool::default();
t.set_option("highlight", &json!(highlight));
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket, "no map yet, highlight {highlight}");
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
t.poll(&cx, true);
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace, "highlight {highlight}");
assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::LivePaintBucket, "highlight {highlight}");
t.set_option("paintFills", &json!(false));
t.set_option("paintStrokes", &json!(true));
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket, "highlight {highlight}");
assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)).is_empty());
}
}
/// With a Live Paint group and ordinary paths selected, a region of the paths outside the group
/// is highlighted and shows the filled bucket, as a click there fills it; with only the group
/// selected, the same point shows the empty bucket.
#[test]
fn bucket_fills_a_region_of_a_selection_that_mixes_a_group_and_paths() {
let (mut d, a, b) = two_rects_at(200.0);
let (g, _) = live_square(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0));
let p = paint();
let pos = Point::new(275.0, 50.0);
let mut s = Selection::default();
s.set([g, a, b]);
let mixed = cx(&d, &s, &p);
let mut t = LivePaintBucketTool::default();
t.pointer(&mixed, &ev(PointerKind::Move, pos.x, pos.y));
t.poll(&mixed, true);
assert!(!t.overlays(&mixed).is_empty(), "the region is highlighted");
assert_eq!(t.cursor(&mixed, pos, Mods::default()), Cursor::LivePaintBucketFace);
let acts = t.pointer(&mixed, &ev(PointerKind::Down, pos.x, pos.y));
assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, v)] if id == "livePaint.fill" && v.get("ids").is_some()), "{acts:?}");
let mut s = Selection::default();
s.set([g]);
let live = cx(&d, &s, &p);
let mut t = LivePaintBucketTool::default();
t.pointer(&live, &ev(PointerKind::Move, pos.x, pos.y));
t.poll(&live, true);
assert!(t.overlays(&live).is_empty());
assert_eq!(t.cursor(&live, pos, Mods::default()), Cursor::LivePaintBucket);
}
#[test]
fn bucket_strokes_alone_paints_and_highlights_no_region_of_the_selection() {
let (d, a, b) = two_rects();
@@ -1269,7 +1366,7 @@ fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() {
let alt = Mods { alt: true, ..Default::default() };
let both = Mods { alt: true, shift: true, ..Default::default() };
let pos = Point::new(75.0, 25.0);
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket);
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace);
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
assert!(!t.overlays(&cx).is_empty());
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0).with_mods(alt));
@@ -1282,7 +1379,7 @@ fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() {
);
assert!(t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0).with_mods(both)).is_empty());
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket);
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace);
assert!(!t.overlays(&cx).is_empty());
}
+8 -2
View File
@@ -141,8 +141,13 @@ pub enum Cursor {
Knife,
/// The Shaper tool.
Shaper,
/// The Live Paint Bucket.
/// The Live Paint Bucket where a click paints nothing: an empty paint bucket.
LivePaintBucket,
/// The Live Paint Bucket where a click fills a face (of a Live Paint group, or a region of
/// the selected paths): a filled paint bucket.
LivePaintBucketFace,
/// The Live Paint Bucket where a click paints an edge of a Live Paint group: a brush.
LivePaintBucketEdge,
/// The Measure tool.
Measure,
/// The Print Tiling tool.
@@ -280,6 +285,8 @@ impl Cursor {
| Cursor::Knife
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::LivePaintBucketFace
| Cursor::LivePaintBucketEdge
| Cursor::Measure
| Cursor::PrintTiling
| Cursor::SymbolSprayer
@@ -320,7 +327,6 @@ impl Cursor {
| Cursor::Join
| Cursor::Knife
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::Measure
| Cursor::ObjectsOnPath
| Cursor::PrintTiling
+30 -3
View File
@@ -439,7 +439,10 @@ designer = false
[[cursor]]
id = "Eyedropper"
group = "Colour and gradient"
shown_when = ["The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead."]
shown_when = [
"The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead.",
"The Live Paint Bucket with Alt held (a click samples paint).",
]
hotspot = "The pipette's tip."
look = "A pipette."
@@ -475,8 +478,32 @@ hotspot = "Where the mesh point goes."
[[cursor]]
id = "LivePaintBucket"
group = "Colour and gradient"
shown_when = ["The Live Paint Bucket: a click fills a face (or an edge) of a Live Paint group."]
hotspot = "Where the paint pours out."
shown_when = [
"The Live Paint Bucket where a click paints nothing: away from Live Paint groups and the selected paths' regions, or over a part the Paint Fills and Paint Strokes options (and Shift) leave unpainted.",
"With Use Precise Cursors on, the crosshair shows instead.",
]
hotspot = "The drop the paint pours onto."
look = "An empty paint bucket tipped toward the hotspot, a small drop on it. LivePaintBucketFace is the same bucket filled."
[[cursor]]
id = "LivePaintBucketFace"
group = "Colour and gradient"
shown_when = [
"The Live Paint Bucket over a face of a Live Paint group, or a region of the selected paths, that a click fills.",
"With Use Precise Cursors on, the crosshair shows instead.",
]
hotspot = "The drop the paint pours onto."
look = "LivePaintBucket filled, with a larger drop on the hotspot."
[[cursor]]
id = "LivePaintBucketEdge"
group = "Colour and gradient"
shown_when = [
"The Live Paint Bucket over an edge of a Live Paint group that a click paints with the stroke.",
"With Use Precise Cursors on, the crosshair shows instead.",
]
hotspot = "The brush's tip."
look = "A brush pointing down-left: bristles at the hotspot, then a white ferrule and a dark handle."
# ── Width, blend, slice and shape builder ─────────────────────────────────────────────────────────
+3 -2
View File
@@ -641,10 +641,11 @@ mod cursor_tests {
] {
assert_eq!(cursor_at(id, &d, away), c, "{id}");
assert_eq!(c.precise(), Cursor::Crosshair, "{id}");
// The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it.
// The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. The
// Live Paint Bucket's bucket is drawn.
let stand_in = match id {
"mesh" => Some(Cursor::PenAdd),
"blobBrush" | "eraser" => None,
"blobBrush" | "eraser" | "livePaintBucket" => None,
_ => Some(Cursor::Crosshair),
};
assert_eq!(c.stand_in(), stand_in, "{id}");
+6 -6
View File
@@ -42,7 +42,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok();
}
}
ui.separator();
crate::chrome::group_separator(ui);
}
match active_shape.as_str() {
@@ -106,7 +106,7 @@ fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, se
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true }));
}
ui.separator();
crate::chrome::group_separator(ui);
}
fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
@@ -164,7 +164,7 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value,
json!({ "reset": true }),
);
}
ui.separator();
crate::chrome::group_separator(ui);
}
fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option<CustomShape>) {
@@ -225,7 +225,7 @@ fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, ac
json!({ "reset": true }),
);
}
ui.separator();
crate::chrome::group_separator(ui);
}
fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
@@ -255,7 +255,7 @@ fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sel
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true }));
}
ui.separator();
crate::chrome::group_separator(ui);
}
fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
@@ -298,7 +298,7 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele
json!({ "reset": true }),
);
}
ui.separator();
crate::chrome::group_separator(ui);
}
fn mode_str(m: EllipseMode) -> &'static str {
+2
View File
@@ -599,6 +599,8 @@ impl VectorcraftApp {
// Toolbar too.
toolbar::show_custom(self, ui.ctx());
}
// The lanes along the window's edges where toolbars made with New Toolbar dock.
crate::custom_toolbars::lanes(self, ui);
panels::library_panel::tidy(self);
// The canvas and the panels: one docking layout. Tab hides the panels but not the canvas.
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui));
+1
View File
@@ -16,6 +16,7 @@ impl VectorcraftApp {
native_menu::run(self, ctx);
if self.fonts_ready {
tools_float::cancel_move_on_escape(self, ctx);
crate::custom_toolbars::cancel_move_on_escape(self, ctx);
shortcuts::handle(self, ctx);
}
// A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in
+1 -1
View File
@@ -123,6 +123,7 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::TypeArea | Cursor::TypeOnPath => C::Text,
// The system has no tip outline to go round: its crosshair aims as well.
Cursor::BlobBrush | Cursor::Eraser => C::Crosshair,
Cursor::LivePaintBucket | Cursor::LivePaintBucketFace | Cursor::LivePaintBucketEdge => C::Crosshair,
// Not drawn yet: the cursor each borrows.
Cursor::Lasso
| Cursor::MagicWand
@@ -133,7 +134,6 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::Join
| Cursor::Knife
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::Measure
| Cursor::PrintTiling
| Cursor::SymbolSprayer
+3
View File
@@ -184,6 +184,9 @@ fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() {
Cursor::PencilNew,
Cursor::PencilStraight,
Cursor::PencilContinue,
Cursor::LivePaintBucket,
Cursor::LivePaintBucketFace,
Cursor::LivePaintBucketEdge,
] {
assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}");
assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}");
+1
View File
@@ -18,6 +18,7 @@ mod hints;
mod status_bar;
mod tabs;
pub(crate) use control_bar::group_separator;
pub use control_bar::{
AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, control_bar_moving, control_bar_placement,
read_hidden_groups,
+70 -7
View File
@@ -202,7 +202,7 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 {
/// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it.
fn group_end(ui: &mut Ui) {
ui.add_space(GROUP_GAP);
separator(ui);
boundary(ui, separator);
}
fn group_end_width(ui: &Ui) -> f32 {
@@ -218,6 +218,66 @@ fn separator_width(ui: &Ui) -> f32 {
SEPARATOR + ui.spacing().item_spacing.x
}
/// A separator between two groups of the Control bar's controls, drawn in the bar's row `ui`: the
/// pass that drew it, the range of the paint list that holds it (between two empty shapes), the
/// row's next widget id just after it, and whether it reaches the end of the row, leaving no room
/// for a control after it.
#[derive(Clone, Copy)]
struct Boundary {
pass: u64,
shapes: (usize, usize),
after: egui::Id,
at_end: bool,
}
/// The key of the row `ui`'s last [`Boundary`].
fn boundary_key(ui: &Ui) -> egui::Id {
ui.unique_id().with("cb-group-boundary")
}
/// A separator between two groups of the Control bar's controls, drawn with `draw`. It stays only
/// while a control follows it in the row: [`erase_trailing`] erases it at the end of the row when no
/// control does or none has room, and the next separator erases it when no control comes between
/// them. The separator keeps its room either way.
fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) {
erase_trailing(ui);
let start = ui.painter().add(egui::Shape::Noop);
draw(ui);
let end = ui.painter().add(egui::Shape::Noop);
let at_end = ui.cursor().min.x >= ui.max_rect().right();
let b = Boundary { pass: ui.ctx().cumulative_pass_nr(), shapes: (start.0, end.0), after: ui.next_auto_id(), at_end };
let key = boundary_key(ui);
ui.data_mut(|d| d.insert_temp(key, b));
}
/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws
/// one, and left out when no control follows it ([`boundary`]).
pub(crate) fn group_separator(ui: &mut Ui) {
boundary(ui, |ui| {
ui.separator();
});
}
/// Erases the row `ui`'s last group separator when no control was drawn after it in this pass, or
/// none had room in the row. The end of the Control bar's controls row calls it too.
fn erase_trailing(ui: &mut Ui) {
let key = boundary_key(ui);
let Some(b) = ui.data_mut(|d| {
let b = d.get_temp::<Boundary>(key);
d.remove::<Boundary>(key);
b
}) else {
return;
};
if b.pass != ui.ctx().cumulative_pass_nr() || (b.after != ui.next_auto_id() && !b.at_end) {
return;
}
let painter = ui.painter();
for i in b.shapes.0 + 1..b.shapes.1 {
painter.set(egui::layers::ShapeIdx(i), egui::Shape::Noop);
}
}
/// The room the controls at the end of the Control bar need: the Transform link, or Document Setup
/// and Preferences with nothing selected, or none while the panel menu has that group off. The
/// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align buttons,
@@ -276,7 +336,7 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
}
}
if transform {
separator(ui);
boundary(ui, separator);
}
}
transform
@@ -292,7 +352,7 @@ fn artboard_align_group(app: &mut VectorcraftApp, ui: &mut Ui) {
if !group_on(app, "alignDistribute") || app.session.active().is_none() || ui.available_width() < separator_width(ui) + buttons_width(ui, 7) {
return;
}
separator(ui);
boundary(ui, separator);
crate::panels::align::align_buttons(app, ui, BUTTON);
if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x {
crate::panels::align::align_to_menu(app, ui, BUTTON);
@@ -479,7 +539,7 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
}
/// The Control bar's Fill and Stroke group: the fill and stroke chips, the Stroke link, the weight
/// and the width profile, and the separator after them.
/// and the width profile, and the separator after them while a control follows ([`group_separator`]).
fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) {
let shown_stroke = crate::panels::current_stroke(app);
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
@@ -494,12 +554,12 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("stroke.set", json!({"profile": id})).ok();
}
ui.add_space(4.0);
ui.separator();
group_separator(ui);
}
/// The Control bar's Opacity group (its link and field, showing `opacity`) and, with something
/// `selected`, its Graphic Style group (Style, Recolor Artwork and Global Edit Options), each while
/// the panel menu has it on, and the separator after them.
/// the panel menu has it on, and the separator after them while a control follows.
fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool) {
let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected);
if opacity_on {
@@ -529,7 +589,7 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity:
global_edit_options(app, ui);
}
if opacity_on || style_on {
ui.separator();
group_separator(ui);
}
}
@@ -680,6 +740,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform"));
if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) {
artboard_align_group(app, ui);
erase_trailing(ui);
return;
}
if anchors != AnchorControls::None && group_on(app, "anchors") {
@@ -713,6 +774,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
crate::panels::character::control_bar(app, ui);
}
if !groups_before_transform(app, ui, sel.len()) {
erase_trailing(ui);
return;
}
// The Transform link opens the whole Transform panel (reference point, rotate,
@@ -750,6 +812,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click()));
}
}
erase_trailing(ui);
});
// Controls that keep their size can still run into the button's room in a narrow window;
// the button is left out then. A floating bar clips them, so its button always shows.
+90
View File
@@ -2845,6 +2845,96 @@ fn the_artboard_tool_s_control_bar_follows_its_two_groups() {
assert!(has(&bar, "Name:") && has(&bar, "Move Artwork with Artboard"), "{bar:?}");
}
/// The separators the docked Control bar draws in window `screen` (vertical lines most of the bar's
/// height, left of the panel menu button), in order, with whether any other shape is drawn between
/// each one and the next, or after the last one for the last.
fn control_bar_separators(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Vec<(f32, bool)> {
control_bar_frame_in(app, ctx, screen, vec![]);
let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default();
let menu_left =
nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds()).map_or(screen.right(), |b| b.x0 as f32);
let separator = |s: &egui::Shape| match s {
egui::Shape::LineSegment { points: [a, b], .. } if (a.x - b.x).abs() < 0.5 && (a.y - b.y).abs() >= 16.0 => Some(a.x),
_ => None,
};
let (mut lines, mut others) = (vec![], vec![]);
for cs in &out.shapes {
let r = cs.shape.visual_bounding_rect();
if !r.is_finite() || !cs.clip_rect.intersects(r) || r.min.x >= menu_left || r.width() > screen.width() / 2.0 {
continue;
}
match separator(&cs.shape) {
Some(x) => lines.push(x),
None => others.push(r.center().x),
}
}
lines.sort_by(f32::total_cmp);
(0..lines.len())
.map(|i| {
let next = lines.get(i + 1).copied().unwrap_or(f32::INFINITY);
(lines[i], others.iter().any(|&x| x > lines[i] && x < next))
})
.collect()
}
/// A Control bar separator stands only between two groups that draw controls: none is left at the
/// end of the bar when the groups after it are off, do not apply or have no room, and none follows
/// another with nothing between them, in every window width. With every group on, the separators
/// between the groups stay.
#[test]
fn no_separator_is_left_at_the_end_of_the_control_bar() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let set = |app: &mut crate::VectorcraftApp, groups: &[&str], visible: bool| {
for group in groups {
app.run("window.control.options", json!({ "group": group, "visible": visible })).unwrap();
}
};
// The first width in each case where a separator has nothing after it.
let mut stray = vec![];
let mut check = |app: &mut crate::VectorcraftApp, case: &str| {
for w in (300..=1500).step_by(50) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
let lines = control_bar_separators(app, &ctx, screen);
if lines.iter().any(|(_, followed)| !followed) {
stray.push(format!("{case}, {w} wide: {lines:?}"));
return;
}
}
};
app.run("select.set", json!({ "ids": [id] })).unwrap();
let wide = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1500.0, 300.0));
assert!(control_bar_separators(&mut app, &ctx, wide).len() >= 2, "the groups keep their separators");
check(&mut app, "every group on");
set(&mut app, &["transform", "alignDistribute"], false);
assert!(!control_bar_separators(&mut app, &ctx, wide).is_empty(), "Fill and Stroke and Opacity keep theirs");
check(&mut app, "Transform and Align off");
set(&mut app, &["opacity", "graphicStyle"], false);
check(&mut app, "Fill and Stroke last");
app.run("select.set", json!({ "ids": [] })).unwrap();
set(&mut app, &["opacity", "graphicStyle"], true);
set(&mut app, &["documentOptions"], false);
check(&mut app, "nothing selected, Document Options off");
app.select_tool("puppetWarp");
set(&mut app, &["fillStroke", "opacity"], false);
check(&mut app, "Puppet Warp options last");
app.select_tool("ellipse");
check(&mut app, "Ellipse options last");
set(&mut app, &["fillStroke", "opacity", "documentOptions", "transform", "alignDistribute"], true);
app.select_tool("artboard");
set(&mut app, &["artboardOptions"], false);
check(&mut app, "the artboard's Transform link and Align");
set(&mut app, &["alignDistribute"], false);
check(&mut app, "the artboard's Transform link last");
assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}");
}
/// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps
/// its Anchor Point label.
#[test]
+12 -23
View File
@@ -33,11 +33,9 @@ fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding {
/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`.
fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) {
let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let root = crate::tests_folder::Folder::new(&format!("{tag}-root"));
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden));
let _ = std::fs::remove_dir_all(root);
}
/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64`, `thumbnailBase64`
@@ -73,7 +71,7 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() {
/// space do not keep it.
#[test]
fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() {
let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id()));
let base = crate::tests_folder::Folder::new("continuation");
let root = base.join("in");
std::fs::create_dir_all(&root).unwrap();
let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned();
@@ -84,7 +82,6 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() {
let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")});
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
let _ = std::fs::remove_dir_all(base);
assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}");
// A readable library's id with a space after the hidden part stays whole.
// A readable id with a `..` part does not keep the hidden part.
@@ -250,7 +247,7 @@ fn a_reply_hides_a_hidden_relative_path_written_as_absolute() {
/// by their file names; plain text and a path inside the root are not.
#[test]
fn a_request_sends_paths_that_an_error_hides() {
let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id()));
let base = crate::tests_folder::Folder::new("sent-paths");
let root = base.join("in");
std::fs::create_dir_all(&root).unwrap();
let inside = root.join("a.svg").to_string_lossy().into_owned();
@@ -259,7 +256,6 @@ fn a_request_sends_paths_that_an_error_hides() {
let mut sent = Vec::new();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&params, &mut sent));
let _ = std::fs::remove_dir_all(base);
let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}"));
let want = [
pair("outside/h.svg", "h.svg"),
@@ -276,7 +272,7 @@ fn a_request_sends_paths_that_an_error_hides() {
/// relative name and a path inside the root are not.
#[test]
fn an_absolute_path_outside_the_read_root_is_unreadable() {
let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id()));
let base = crate::tests_folder::Folder::new("unreadable-absolute");
std::fs::create_dir_all(base.join("in")).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
let inside = base.join("in").join("a.svg").to_string_lossy().into_owned();
@@ -295,7 +291,6 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() {
assert!(!super::unreadable_absolute("keep"));
assert!(!super::unreadable_absolute(&inside));
});
let _ = std::fs::remove_dir_all(base);
}
/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps
@@ -395,12 +390,10 @@ fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() {
#[cfg(not(windows))]
#[test]
fn a_sent_path_keeps_a_backslash_in_its_file_name() {
let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let root = crate::tests_folder::Folder::new("sent-backslash");
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap();
let mut sent = Vec::new();
vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent));
let _ = std::fs::remove_dir_all(root);
assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]);
}
@@ -412,13 +405,11 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() {
let panel = "library:swatches:loaded//b.svg".to_string();
app.ui.color_guide_limit = "loaded//b.svg".to_string();
app.ui.open_panel = Some(panel);
let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let root = crate::tests_folder::Folder::new("top-level-library");
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap();
let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || {
(input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({})))
});
let _ = std::fs::remove_dir_all(root);
assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}");
assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}");
assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}");
@@ -429,9 +420,8 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() {
#[test]
fn a_sent_path_reads_the_same_whatever_the_outcome() {
let app = VectorcraftApp::new(Session::new(), Services::default());
let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
let root = crate::tests_folder::Folder::new("sent-outcome");
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap();
let sent = json!({"path": "/outside/b.svg"});
let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}});
let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"});
@@ -439,7 +429,6 @@ fn a_sent_path_reads_the_same_whatever_the_outcome() {
super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default());
super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default());
});
let _ = std::fs::remove_dir_all(root);
assert_eq!(done["result"]["path"], "b.svg", "{done}");
assert_eq!(failed["error"], "can't read b.svg", "{failed}");
}
@@ -1157,7 +1146,7 @@ fn panels_that_redraw_apply_a_merge_on_another_thread_first() {
/// applies the merge first: the file holds it, and closing asks to save it.
#[test]
fn save_and_close_apply_a_merge_on_another_thread_first() {
let dir = vectorcraft_testkit::temp_dir("control-merge-save");
let dir = crate::tests_folder::Folder::adopt(vectorcraft_testkit::temp_dir("control-merge-save"));
for then in ["file.saveAs", "file.exportAs", "file.close"] {
let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
+42 -1
View File
@@ -432,6 +432,42 @@ fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) {
}
}
/// The Live Paint Bucket: a paint bucket tipped toward the hotspot at its lower left, its mouth
/// down-left with a rim across it, and a drop on the hotspot. Empty (white) with a small drop where
/// a click paints nothing; filled, with a larger drop, where a click fills a face.
fn paint_bucket(p: &mut Ink, o: Pos2, filled: bool) {
// `u` runs from the hotspot up-right into the bucket, `v` across it.
let (d, n) = (vec2(1.0, -1.0).normalized(), vec2(1.0, 1.0).normalized());
let at = |u: f32, v: f32| o + u * d + v * n;
poly(p, vec![at(5.5, -6.0), at(16.5, -4.5), at(16.5, 4.5), at(5.5, 6.0)], if filled { INK } else { HALO }, INK);
poly(p, vec![at(4.5, -7.0), at(6.5, -7.0), at(6.5, 7.0), at(4.5, 7.0)], INK, INK);
if filled {
p.circle_filled(o, 3.5, HALO);
p.circle_filled(o, 2.2, INK);
} else {
p.circle_filled(o, 2.5, HALO);
p.circle_filled(o, 1.25, INK);
}
}
/// The Live Paint Bucket over an edge it paints: a brush, its bristles' tip on the hotspot, the
/// white ferrule and the black handle running up-right.
fn paint_brush(p: &mut Ink, o: Pos2) {
let (d, n) = (vec2(1.0, -1.0).normalized(), vec2(1.0, 1.0).normalized());
let at = |u: f32, v: f32| o + u * d + v * n;
let bristles = vec![at(0.0, 0.0), at(4.0, -2.2), at(6.0, -2.2), at(6.0, 2.2), at(4.0, 2.2)];
let ferrule = vec![at(6.0, -2.0), at(9.0, -2.0), at(9.0, 2.0), at(6.0, 2.0)];
let handle = vec![at(9.0, -1.5), at(19.0, -1.2), at(19.0, 1.2), at(9.0, 1.5)];
// Every halo first, so no part's halo covers another's outline.
for part in [&bristles, &ferrule, &handle] {
p.add(Shape::closed_line(part.clone(), Stroke::new(3.0, HALO)));
}
for (part, fill) in [(bristles, INK), (ferrule, HALO), (handle, INK)] {
p.add(Shape::convex_polygon(part.clone(), fill, Stroke::NONE));
p.add(Shape::closed_line(part, Stroke::new(1.0, INK)));
}
}
/// The Hand tool and Space: an open hand (palm and four fingers, thumb to the left), or the same
/// hand closed while it drags the view. Windows has no system hand that isn't a pointing finger
/// (winit maps egui's Grab and Grabbing to the four-headed move cursor), so it is drawn like the
@@ -480,7 +516,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
#[cfg(test)]
const GLYPHS: [Cursor; 93] = [
const GLYPHS: [Cursor; 95] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -548,6 +584,8 @@ const GLYPHS: [Cursor; 93] = [
Cursor::Knife,
Cursor::Shaper,
Cursor::LivePaintBucket,
Cursor::LivePaintBucketFace,
Cursor::LivePaintBucketEdge,
Cursor::Measure,
Cursor::PrintTiling,
Cursor::SymbolSprayer,
@@ -669,6 +707,9 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
Cursor::LoadedText => loaded_text(ink, p),
Cursor::ShapeBuilder => shape_builder(ink, p, false),
Cursor::ShapeBuilderErase => shape_builder(ink, p, true),
Cursor::LivePaintBucket => paint_bucket(ink, p, false),
Cursor::LivePaintBucketFace => paint_bucket(ink, p, true),
Cursor::LivePaintBucketEdge => paint_brush(ink, p),
Cursor::Hand => hand(ink, p, false),
Cursor::HandGrab => hand(ink, p, true),
Cursor::ZoomIn => magnifier(ink, p, false),
+39
View File
@@ -292,6 +292,45 @@ fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point(
}
}
/// The Live Paint Bucket's cursors are drawn, not the crosshair: an empty bucket where a click
/// paints nothing, a filled bucket with a larger drop on the hotspot where it fills a face, and a
/// brush with its tip on the hotspot where it paints an edge.
#[test]
fn the_live_paint_bucket_shows_a_bucket_or_a_brush() {
for ppp in [1.0f32, 2.0] {
let img = |c| rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}"));
let (cross, empty, filled, brush) =
(img(Cursor::Crosshair), img(Cursor::LivePaintBucket), img(Cursor::LivePaintBucketFace), img(Cursor::LivePaintBucketEdge));
// The pixel nearest the point `u` points up-right of the hotspot and `v` down-right across.
let at = |img: &CustomCursorImage, u: f32, v: f32| {
let s = std::f32::consts::FRAC_1_SQRT_2;
let [hx, hy] = img.hotspot.map(f32::from);
let (x, y) = ((hx + (u + v) * s * ppp - 0.5).round(), (hy + (v - u) * s * ppp - 0.5).round());
let inside = x >= 0.0 && y >= 0.0 && x < f32::from(img.size[0]) && y < f32::from(img.size[1]);
if inside { pixel(img, x as usize, y as usize) } else { [0; 4] }
};
let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64;
let white = |p: [u8; 4]| p[3] > 200 && p[0] > 192;
assert!(white(at(&empty, 11.0, 2.0)), "the empty bucket's body is white at {ppp}");
assert!(inked(at(&filled, 11.0, 2.0)), "the filled bucket's body is ink at {ppp}");
assert!(at(&empty, 0.0, 0.0)[3] > 0 && inked(at(&filled, 0.0, 0.0)), "a drop on the hotspot at {ppp}");
if ppp == 2.0 {
assert!(inked(at(&filled, 1.4, 0.0)) && !inked(at(&empty, 2.0, 0.0)), "the filled bucket's drop is larger");
}
assert!(inked(at(&brush, 3.0, 0.0)), "the bristles by the hotspot at {ppp}");
assert!(white(at(&brush, 7.5, 0.0)), "the white ferrule at {ppp}");
assert!(inked(at(&brush, 14.0, 0.0)), "the handle at {ppp}");
assert!(!inked(at(&brush, 11.0, 4.0)), "the brush is thin where the bucket is wide at {ppp}");
assert!(white(at(&empty, 14.0, 0.0)), "the empty bucket has no handle there at {ppp}");
for (c, i) in [(Cursor::LivePaintBucket, &empty), (Cursor::LivePaintBucketFace, &filled), (Cursor::LivePaintBucketEdge, &brush)] {
assert!(c.stand_in().is_none(), "{c:?} is drawn");
assert_ne!(i, &cross, "{c:?} is not the crosshair at {ppp}");
assert_eq!(rasterize(c.precise(), ppp).as_ref(), Some(&cross), "{c:?} with Use Precise Cursors at {ppp}");
}
assert!(empty != filled && filled != brush && empty != brush, "three looks at {ppp}");
}
}
#[test]
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
let mut images = Images::default();
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -255,7 +255,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let Some((editing, bar)) = bar_of(app) else { return };
let mut run: Option<(&str, Value)> = None;
ui.separator();
crate::chrome::group_separator(ui);
for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] {
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
@@ -1,21 +1,20 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use std::time::Instant;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::findfiles::Rules;
use super::*;
use crate::tests_folder::Folder;
fn frame(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
/// A fresh temporary folder (canonical, as a search reports it).
fn folder(tag: &str) -> PathBuf {
/// A fresh temporary folder (canonical, as a search reports it), removed when dropped.
fn folder(tag: &str) -> Folder {
let dir = vectorcraft_testkit::temp_dir(&format!("missing-fonts-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap())
Folder::adopt(vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()))
}
/// An app that reads files and whose searches may go anywhere in `root`, on two threads.
@@ -191,7 +190,7 @@ fn closing_the_dialog_stops_its_search() {
app.session.search_threads = Some(1);
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap();
app.run("ui.missingFontsDialog", json!({"folder": root})).unwrap();
app.run("ui.missingFontsDialog", json!({"folder": root.as_path()})).unwrap();
let id = app.ui.dialog_search.expect("the dialog's search");
super::super::cancel(&mut app);
assert!(app.ui.dialog.is_none() && app.ui.dialog_search.is_none());
@@ -275,7 +274,8 @@ fn the_question_comes_back_after_a_dialog_that_took_its_place() {
/// opens without the question.
#[test]
fn generic_families_are_not_asked_about() {
let mut app = app_for(&folder("generic"));
let root = folder("generic");
let mut app = app_for(&root);
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><text x="10" y="40" font-family="sans-serif">A</text></svg>"#;
crate::io::open_bytes(&mut app, "generic.svg", svg.as_bytes(), None).unwrap();
let listed = app.session.execute("text.fonts", &json!({})).unwrap();
@@ -4,6 +4,7 @@ use std::rc::Rc;
use vectorcraft_engine::Session;
use super::*;
use crate::tests_folder::Folder;
use crate::{FilePick, Services, theme};
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
@@ -197,7 +198,7 @@ fn board_files_of(app: &VectorcraftApp, boards: [bool; 4]) -> Vec<Option<String>
#[test]
fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() {
let (mut app, written, _) = desktop("again.ai");
let dir = vectorcraft_testkit::temp_dir("ai-options-enter");
let dir = Folder::adopt(vectorcraft_testkit::temp_dir("ai-options-enter"));
let orig = dir.join("orig.ai");
std::fs::write(&orig, vectorcraft_testkit::ai::ai(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_pdf())).unwrap();
app.run("document.open", json!({"path": orig.to_string_lossy()})).unwrap();
@@ -215,7 +216,7 @@ fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() {
#[test]
fn save_a_copy_and_template_suggest_their_names() {
let (mut app, written, picks) = desktop("copy.vectorcraft");
let folder = vectorcraft_testkit::temp_dir("save-template-dialog");
let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-dialog"));
app.session.prefs.templates_folder = folder.to_string_lossy().into_owned();
app.run("file.saveCopy", json!({})).unwrap();
assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft");
@@ -237,7 +238,8 @@ fn save_a_copy_and_template_suggest_their_names() {
#[test]
fn save_as_template_writes_ait_when_it_is_picked() {
let (mut app, written, _) = desktop("flyer.ait");
app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ait").to_string_lossy().into_owned();
let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-ait"));
app.session.prefs.templates_folder = folder.to_string_lossy().into_owned();
app.run("file.saveAsTemplate", json!({})).unwrap();
assert!(app.ui.dialog.is_none(), "a template asks no options");
let (path, bytes) = written.borrow()[0].clone();
@@ -285,9 +287,10 @@ fn the_native_options_have_no_precision_row() {
/// replaced with the template's, as Save As replaces the extension of a format it doesn't write.
#[test]
fn save_as_template_replaces_a_save_formats_extension() {
let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-ext"));
for (picked, saved) in [("card.pdf", "card.vctemplate"), ("card.vectorcraft", "card.vctemplate"), ("card.svg", "card.vctemplate")] {
let (mut app, written, _) = desktop(picked);
app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ext").to_string_lossy().into_owned();
app.session.prefs.templates_folder = folder.to_string_lossy().into_owned();
app.run("file.saveAsTemplate", json!({})).unwrap();
let (path, bytes) = written.borrow()[0].clone();
assert_eq!(path, saved, "typed {picked}");
+4 -1
View File
@@ -508,7 +508,10 @@ fn the_add_tool_menu_fills_a_new_toolbar_without_a_drag() {
assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched");
assert!(!app.ui.tools_drawer, "no drawer opened");
let list = app.run("window.toolbar.list", json!({})).unwrap();
assert_eq!(list, json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]]}]));
assert_eq!(
list,
json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]], "floating": true, "side": "left", "order": null}])
);
// A second tool goes after the first.
let shown = open(&mut app, 19.0);
click(&mut app, &ctx, 20.0, items(&shown, "Magic Wand Tool")[0].0);
+40 -26
View File
@@ -383,7 +383,7 @@ pub fn font_menu(
|ui| {
// Wider than a narrow field, so the filters keep their rows and long names show.
ui.set_min_width(menu_width(ui, state, look));
header(ui, state_id, state, current, look)
header(ui, vectorcraft_text::FontDb::global(), state_id, state, current, look)
},
look.row,
|h| h.rows.len(),
@@ -970,6 +970,8 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
/// What the menu's header hands its rows.
struct Listed<'s> {
/// The fonts listed: [`vectorcraft_text::FontDb::global`] outside the unit tests.
db: &'s vectorcraft_text::FontDb,
st: &'s mut MenuState,
/// The rows listed.
rows: Arc<[Row]>,
@@ -985,14 +987,28 @@ struct Listed<'s> {
/// [`craft_ui::combo::Menu::show_rows_with_header`] lays them out.
#[cfg(test)]
fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, sample: Option<&str>, look: MenuLook) -> Option<FontPick> {
let h = header(ui, state_id, st, current, look);
list_of(ui, vectorcraft_text::FontDb::global(), state_id, st, current, sample, look).inner
}
/// [`list`] of the families in `db`, with the rows' scroll area as laid out.
#[cfg(test)]
fn list_of(
ui: &mut Ui,
db: &vectorcraft_text::FontDb,
state_id: egui::Id,
st: &mut MenuState,
current: &str,
sample: Option<&str>,
look: MenuLook,
) -> egui::scroll_area::ScrollAreaOutput<Option<FontPick>> {
let h = header(ui, db, state_id, st, current, look);
let total = h.rows.len();
egui::ScrollArea::vertical().max_height(ui.available_height()).show_rows(ui, look.row, total, |ui, range| rows(ui, range, h, sample, look)).inner
egui::ScrollArea::vertical().max_height(ui.available_height()).show_rows(ui, look.row, total, |ui, range| rows(ui, range, h, sample, look))
}
/// The top of the open menu, which stays in place: the search field, the filters, and the rows
/// they list.
fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &str, look: MenuLook) -> Listed<'s> {
/// of `db` they list.
fn header<'s>(ui: &mut Ui, db: &'s vectorcraft_text::FontDb, state_id: egui::Id, st: &'s mut MenuState, current: &str, look: MenuLook) -> Listed<'s> {
let t = Tokens::get(ui.ctx());
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
// Each time the menu opens: no query, the current family highlighted and in view (the
@@ -1025,7 +1041,6 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &
let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase();
let refilter = filters(ui, st, &t);
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let db = vectorcraft_text::FontDb::global();
// The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック),
// as it was before any preview.
let current = db.canonical(&st.current, "").0;
@@ -1092,7 +1107,7 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &
// The scroll bar beside the rows, not over them: a floating one covered the stars at their
// right ends, so a favourite was hard to click (#2277).
ui.spacing_mut().scroll = widgets::list_scroll_style();
Listed { st, rows, current: current.to_string(), enter, keyed, opening }
Listed { db, st, rows, current: current.to_string(), enter, keyed, opening }
}
/// One of the filters' chips.
@@ -1166,28 +1181,29 @@ fn filters(ui: &mut Ui, st: &mut MenuState, t: &Tokens) -> bool {
/// [`WINDOW_SHARE`] of the window. The menu is never narrower than its field.
fn menu_width(ui: &Ui, st: &MenuState, look: MenuLook) -> f32 {
let sample = if look.samples { SAMPLE_W } else { 0.0 };
let names = name_width(ui, look.english_names, &st.rows).min(NAME_MAX);
let names = name_width(ui, vectorcraft_text::FontDb::global(), look.english_names, &st.rows).min(NAME_MAX);
let row = DISCLOSURE + names + NAME_GAP + sample + STAR + 2.0 + widgets::list_scroll_style().allocated_width();
// Rounding in the chips' widths must not wrap the widest row.
let filters = if st.filters > 0.0 { st.filters.ceil() + 1.0 } else { 0.0 };
filters.max(row).min(ui.ctx().content_rect().width() * WINDOW_SHARE)
}
/// The width of the widest name the menu lists after the disclosure triangle: the family names as
/// Show Font Names in English shows them, and the style names among `rows` after their indent.
fn name_width(ui: &Ui, english: bool, rows: &[Row]) -> f32 {
/// The width of the widest name the menu lists after the disclosure triangle: the family names of
/// `db` as Show Font Names in English shows them, and the style names among `rows` after their
/// indent.
fn name_width(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool, rows: &[Row]) -> f32 {
let font = egui::FontId::proportional(NAME_SIZE);
let measure = |name: &str| ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x;
let styles = rows.iter().filter_map(Row::style).map(|s| STYLE_INDENT + measure(s)).fold(0.0, f32::max);
family_name_width(ui, english).max(styles)
family_name_width(ui, db, english).max(styles)
}
/// What the widest family name was measured for: the font list's generation, Show Font Names in
/// English, and the number of fonts the interface draws with, which grows as faces for other
/// scripts are added to it.
fn family_name_key(ui: &Ui, english: bool) -> (u64, bool, usize) {
/// What the widest family name was measured for: the font database and its generation, Show Font
/// Names in English, and the number of fonts the interface draws with, which grows as faces for
/// other scripts are added to it.
fn family_name_key(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool) -> (u64, u64, bool, usize) {
let faces = ui.ctx().fonts(|f| f.definitions().font_data.len());
(vectorcraft_text::FontDb::global().generation(), english, faces)
(db.id(), db.generation(), english, faces)
}
/// The id under which the widest family name's width is kept with its [`family_name_key`].
@@ -1195,16 +1211,15 @@ fn family_name_id() -> egui::Id {
egui::Id::unique("font-menu-name-width")
}
/// The width of the widest family name the menu lists, labeled as Show Font Names in English
/// (`english`) labels it; worked out again only when its [`family_name_key`] changes.
fn family_name_width(ui: &Ui, english: bool) -> f32 {
let key = family_name_key(ui, english);
if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, bool, usize), f32)>(family_name_id()))
/// The width of the widest family name the menu lists from `db`, labeled as Show Font Names in
/// English (`english`) labels it; worked out again only when its [`family_name_key`] changes.
fn family_name_width(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool) -> f32 {
let key = family_name_key(ui, db, english);
if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, u64, bool, usize), f32)>(family_name_id()))
&& k == key
{
return w;
}
let db = vectorcraft_text::FontDb::global();
let w = widest_label(ui, db.menu_family_list().iter().map(|f| db.family_display_name(f, english)).collect());
ui.data_mut(|d| d.insert_temp(family_name_id(), (key, w)));
w
@@ -1269,14 +1284,13 @@ fn heading_row(ui: &mut Ui, row_h: f32, label: &str, t: &Tokens) {
/// The rows of the open menu in `range`, the ones in view: the menu's one scrolling part, under
/// the search field and filters (#555).
fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Option<&str>, look: MenuLook) -> Option<FontPick> {
let Listed { st, rows, current, enter, keyed, opening } = h;
let Listed { db, st, rows, current, enter, keyed, opening } = h;
let current = current.as_str();
let row_h = look.row;
let t = Tokens::get(ui.ctx());
let ink = sample_ink(look.on_white, t.text);
let db = vectorcraft_text::FontDb::global();
let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f));
let widest = name_width(ui, look.english_names, &rows);
let widest = name_width(ui, db, look.english_names, &rows);
if rows.is_empty() {
// A filter or a font rescan can remove the row whose font was previewed.
// Do not leave that font applied while the list has no candidate to highlight.
+49 -25
View File
@@ -21,40 +21,44 @@ fn the_thai_filter_lists_fonts_with_thai() {
/// stay clear of it.
#[test]
fn the_scroll_bar_leaves_the_stars_clear() {
// Every family starred, so each row draws its star. Tests running alongside add fonts and
// composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps
// the rows to the families starred here.
let all = vectorcraft_text::FontDb::global().families();
// A database of the bundled fonts alone, each family starred, so each row draws its star:
// fonts that tests running alongside add to the process-wide database stay out of the rows.
let db = vectorcraft_text::FontDb::with_font_dirs(vec![]);
let favorites: Vec<String> = ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"].map(str::to_string).into();
let ctx = egui::Context::default();
// Under the search field and the filters, room for fewer rows than the four families.
let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0));
let mut st = super::MenuState { favorites_only: true, ..Default::default() };
let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() };
let mut stars = vec![];
for _ in 0..3 {
let look = super::MenuLook { samples: false, favorites: &favorites, ..Default::default() };
let (mut list, mut stars) = (None, vec![]);
// Frames enough for the scroll bar to finish appearing.
for _ in 0..30 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));
super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look);
let shown = super::list_of(&mut child, &db, egui::Id::unique("fonts"), &mut st, "Inter", None, look);
list = Some((shown.inner_rect, shown.content_size));
});
out.textures_delta.clear();
stars = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
// The rows' stars, not the ★ filter button at the top left.
egui::Shape::Text(t) if t.galley.text() == "★" && t.pos.x > area.center().x => Some(t.visual_bounding_rect().right()),
egui::Shape::Text(t) if t.galley.text() == "★" => Some(t.visual_bounding_rect()),
_ => None,
})
.collect();
}
let families = st.rows.len();
assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn");
// The rows don't fill the list without enough families to scroll, and then there is no bar.
if families as f32 * look.row > area.height() {
let bar = super::widgets::list_scroll_style();
let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin;
for right in stars {
assert!(right <= area.right() - column + 0.5, "a star at {right} runs under the scroll bar");
}
let Some((inner, content)) = list else { panic!("the list was not drawn") };
let families: Vec<&str> = st.rows.iter().filter_map(super::Row::family).collect();
assert_eq!(families.len(), favorites.len(), "the starred families are listed: {families:?}");
assert!(content.y > inner.height() + 0.5, "the rows, {} high, fill a list {} high", content.y, inner.height());
// The rows' stars in the list, not the ★ filter above it.
let stars: Vec<_> = stars.into_iter().filter(|s| inner.contains(s.center())).collect();
assert!(!stars.is_empty(), "no row star drawn in the list at {inner:?}");
let bar = super::widgets::list_scroll_style();
let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin;
for star in stars {
assert!(star.right() <= area.right() - column + 0.5, "a star at {} runs under the scroll bar", star.right());
}
}
@@ -104,14 +108,33 @@ fn the_widest_name_is_measured_again_when_the_interface_fonts_change() {
};
// A width no name has, kept as if measured for the fonts as they are.
frame(&mut |ui| {
let key = super::family_name_key(ui, true);
let key = super::family_name_key(ui, vectorcraft_text::FontDb::global(), true);
ui.data_mut(|d| d.insert_temp(super::family_name_id(), (key, -1.0_f32)));
});
frame(&mut |ui| {
let data = ui.ctx().fonts(|f| f.definitions().font_data.values().next().cloned()).expect("an interface font");
ui.ctx().add_font(egui::epaint::text::FontInsert { name: "font-menu-test-face".into(), data: (*data).clone(), families: vec![] });
});
frame(&mut |ui| assert!(super::family_name_width(ui, true) >= 0.0, "measured again"));
frame(&mut |ui| assert!(super::family_name_width(ui, vectorcraft_text::FontDb::global(), true) >= 0.0, "measured again"));
}
/// The widest family name measured for one font database is not taken for another one at the same
/// generation.
#[test]
fn each_font_database_has_its_own_widest_name() {
use vectorcraft_doc::CompositeFont;
let short = vectorcraft_text::FontDb::with_font_dirs(vec![]);
let long = vectorcraft_text::FontDb::with_font_dirs(vec![]);
assert!(short.set_composite_font(&CompositeFont::new("C", "Inter", "Regular")));
assert!(long.set_composite_font(&CompositeFont::new("A composite font with a name longer than any family", "Inter", "Regular")));
assert_eq!(short.generation(), long.generation());
let ctx = egui::Context::default();
let mut widths = (0.0, 0.0);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
widths = (super::family_name_width(ui, &short, true), super::family_name_width(ui, &long, true));
});
out.textures_delta.clear();
assert!(widths.1 > widths.0 + 1.0, "the long name's database measured {} and the other {}", widths.1, widths.0);
}
/// Without a row of filters drawn yet, the menu still makes room for the widest name, its sample,
@@ -125,7 +148,7 @@ fn the_menu_makes_room_for_the_widest_name_and_its_sample() {
let mut out = ctx.run_ui(input, |ui| {
let st = super::MenuState::default();
let look = super::MenuLook::default();
let names = super::name_width(ui, look.english_names, &st.rows).min(NAME_MAX);
let names = super::name_width(ui, vectorcraft_text::FontDb::global(), look.english_names, &st.rows).min(NAME_MAX);
assert!(names > 0.0, "a family is listed");
let width = super::menu_width(ui, &st, look);
assert!(width >= DISCLOSURE + names + NAME_GAP + SAMPLE_W + STAR, "the menu is {width} wide for a name {names} wide");
@@ -143,14 +166,15 @@ fn only_listed_style_names_widen_the_names() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let long = format!("SemiCondensed ExtraLight Italic {}", "W".repeat(80));
let db = vectorcraft_text::FontDb::global();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let families = super::name_width(ui, true, &[]);
let families = super::name_width(ui, db, true, &[]);
let font = egui::FontId::proportional(super::NAME_SIZE);
let style = STYLE_INDENT + ui.painter().layout_no_wrap(long.clone(), font, egui::Color32::WHITE).size().x;
assert!(style > families, "the style name is wider than every family name");
let listed = [Row::Family("A".into()), Row::Style("A".into(), long.clone())];
assert_eq!(super::name_width(ui, true, &listed), style);
assert!(super::name_width(ui, true, &listed[..1]) < style, "a style not listed is not measured");
assert_eq!(super::name_width(ui, db, true, &listed), style);
assert!(super::name_width(ui, db, true, &listed[..1]) < style, "a style not listed is not measured");
});
out.textures_delta.clear();
}
+18
View File
@@ -3372,6 +3372,20 @@ digits Malayalam Малаялам
Spot Color Objects Обекти със спот цветове
Pattern Objects Обекти с мостри
Gradient Objects Обекти с градиенти
Font Details Подробности за шрифтовете
Linked Images Свързани изображения
Embedded Images Вградени изображения
Compound Paths Съставни пътища
Text Objects Текстови обекти
Placed Documents Поставени документи
Symbol Instances Екземпляри на символи
Gradient Meshes Мрежи с градиент
Blends Преливания
Envelopes Пликове
Intertwine Groups Групи с преплитане
Opacity Masks Маски за непрозрачност
Objects with Effects Обекти с ефекти
Path Length Дължина на пътя
Spot Colors Спот цветове
Spot Colors… Спот цветове…
Spread: Разпространение:
@@ -4057,6 +4071,10 @@ axis Both И двете
Dock Panel Закотвен панел
Dock to Top Закотвяне горе
Dock to Bottom Закотвяне долу
Dock to Left Закотвяне вляво
Dock to Right Закотвяне вдясно
Dock Toolbar Закотвяне на лентата с инструменти
Float Toolbar Плаваща лента с инструменти
Control Bar Options Опции на контролната лента
Tool Options Опции за инструмент
Object Options Опции за обект
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -908,6 +908,10 @@
Dock Panel Usidri panel
Dock to Top Usidri na vrh
Dock to Bottom Usidri na dno
Dock to Left Usidri lijevo
Dock to Right Usidri desno
Dock Toolbar Usidri traku s alatima
Float Toolbar Odvoji traku s alatima
Control Bar Options Opcije kontrolne trake
Tool Options Opcije alata
Object Options Opcije objekta
@@ -3959,6 +3963,19 @@ theme Appearance Izgled
Spot Color Objects Objekti sa spot bojama
Pattern Objects Objekti s uzorcima
Gradient Objects Objekti s gradijentima
Font Details Detalji fontova
Linked Images Povezane slike
Embedded Images Ugrađene slike
Compound Paths Složene putanje
Text Objects Tekstualni objekti
Placed Documents Postavljeni dokumenti
Symbol Instances Instance simbola
Gradient Meshes Gradijentne mreže
Blends Stapanja
Envelopes Omotnice
Intertwine Groups Grupe isprepletanja
Opacity Masks Maske neprozirnosti
Objects with Effects Objekti s efektima
Spot Colors Spot boje
Spot Colors (0) Spot boje (0)
Swatch Options Opcije uzorka boje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -1247,6 +1247,10 @@ antiAlias Sharp Nítid
Dock Panel Acobla el panell
Dock to Top Acobla a dalt
Dock to Bottom Acobla a baix
Dock to Left Acobla a l’esquerra
Dock to Right Acobla a la dreta
Dock Toolbar Acobla la barra d’eines
Float Toolbar Flota la barra d’eines
Control Bar Options Opcions de la barra de control
Tool Options Opcions de l’eina
Object Options Opcions de l’objecte
@@ -4385,6 +4389,19 @@ digits Malayalam Malaiàlam
Spot Color Objects Objectes amb colors directes
Pattern Objects Objectes amb motius
Gradient Objects Objectes amb degradats
Font Details Detalls dels tipus de lletra
Linked Images Imatges enllaçades
Embedded Images Imatges incrustades
Compound Paths Traçats compostos
Text Objects Objectes de text
Placed Documents Documents col·locats
Symbol Instances Instàncies de símbol
Gradient Meshes Malles de degradat
Blends Fusions
Envelopes Embolcalls
Intertwine Groups Grups entrellaçats
Opacity Masks Màscares d’opacitat
Objects with Effects Objectes amb efectes
Spot Colors Colors directes
Spot Colors (0) Colors directes (0)
Spot Colors… Colors directes…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -923,6 +923,10 @@
Dock Panel Ukotvit panel
Dock to Top Ukotvit nahoru
Dock to Bottom Ukotvit dolů
Dock to Left Ukotvit vlevo
Dock to Right Ukotvit vpravo
Dock Toolbar Ukotvit panel nástrojů
Float Toolbar Plovoucí panel nástrojů
Control Bar Options Možnosti ovládacího panelu
Tool Options Možnosti nástroje
Object Options Možnosti objektu
@@ -4015,6 +4019,19 @@ theme Appearance Motiv
Spot Color Objects Objekty s přímými barvami
Pattern Objects Objekty se vzorky
Gradient Objects Objekty s přechody
Font Details Podrobnosti písem
Linked Images Propojené obrazy
Embedded Images Vložené obrazy
Compound Paths Složené cesty
Text Objects Textové objekty
Placed Documents Umístěné dokumenty
Symbol Instances Instance symbolů
Gradient Meshes Přechodové sítě
Blends Prolnutí
Envelopes Obálky
Intertwine Groups Skupiny proplétání
Opacity Masks Masky krytí
Objects with Effects Objekty s efekty
Spot Colors Přímé barvy
Spot Colors (0) Přímé barvy (0)
Swatch Options Možnosti položky vzorníku
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -853,6 +853,10 @@
Dock Panel Dok panel
Dock to Top Dok øverst
Dock to Bottom Dok nederst
Dock to Left Dok til venstre
Dock to Right Dok til højre
Dock Toolbar Dok værktøjslinjen
Float Toolbar Flydende værktøjslinje
Control Bar Options Indstillinger for kontrollinjen
Tool Options Værktøjsindstillinger
Object Options Objektindstillinger
@@ -3734,6 +3738,19 @@ theme Appearance Udseende
Spot Color Objects Objekter med spotfarver
Pattern Objects Objekter med mønstre
Gradient Objects Objekter med gradienter
Font Details Skrifttypedetaljer
Linked Images Linkede billeder
Embedded Images Indlejrede billeder
Compound Paths Sammensatte kurver
Text Objects Tekstobjekter
Placed Documents Placerede dokumenter
Symbol Instances Symbolforekomster
Gradient Meshes Gradientnet
Blends Blandinger
Envelopes Konvolutter
Intertwine Groups Sammenfletningsgrupper
Opacity Masks Opacitetsmasker
Objects with Effects Objekter med effekter
Spot Colors Spotfarver
Spot Colors (0) Spotfarver (0)
Swatch Options Indstillinger for farveprøve
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -3435,6 +3435,20 @@ effect Points Punkte
Spot Color Objects Objekte mit Volltonfarben
Pattern Objects Objekte mit Mustern
Gradient Objects Objekte mit Verläufen
Font Details Schriftdetails
Linked Images Verknüpfte Bilder
Embedded Images Eingebettete Bilder
Compound Paths Zusammengesetzte Pfade
Text Objects Textobjekte
Placed Documents Platzierte Dokumente
Symbol Instances Symbolinstanzen
Gradient Meshes Verlaufsgitter
Blends Angleichungen
Envelopes Hüllen
Intertwine Groups Verflechtungsgruppen
Opacity Masks Deckkraftmasken
Objects with Effects Objekte mit Effekten
Pattern Swatches Musterfarbfelder
Spot Colors Volltonfarben
Spot Colors… Volltonfarben…
Spread: Ausbreitung:
@@ -4077,6 +4091,10 @@ theme Appearance Erscheinungsbild
Dock Panel Bedienfeld andocken
Dock to Top Oben andocken
Dock to Bottom Unten andocken
Dock to Left Links andocken
Dock to Right Rechts andocken
Dock Toolbar Werkzeugleiste andocken
Float Toolbar Werkzeugleiste ablösen
Control Bar Options Optionen der Steuerungsleiste
Tool Options Werkzeugoptionen
Object Options Objektoptionen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -2257,6 +2257,10 @@ axis Both Και τα δύο
Dock Panel Αγκύρωση πάνελ
Dock to Top Αγκύρωση επάνω
Dock to Bottom Αγκύρωση κάτω
Dock to Left Αγκύρωση αριστερά
Dock to Right Αγκύρωση δεξιά
Dock Toolbar Αγκύρωση γραμμής εργαλείων
Float Toolbar Αιωρούμενη γραμμή εργαλείων
Control Bar Options Επιλογές γραμμής ελέγχου
Tool Options Επιλογές εργαλείου
Object Options Επιλογές αντικειμένου
@@ -3969,6 +3973,20 @@ digits Malayalam Μαλαγιάλαμ
Spot Color Objects Αντικείμενα με χρώματα spot
Pattern Objects Αντικείμενα με μοτίβα
Gradient Objects Αντικείμενα με ντεγκραντέ
Font Details Λεπτομέρειες γραμματοσειρών
Linked Images Συνδεδεμένες εικόνες
Embedded Images Ενσωματωμένες εικόνες
Compound Paths Σύνθετες διαδρομές
Text Objects Αντικείμενα κειμένου
Placed Documents Τοποθετημένα έγγραφα
Symbol Instances Στιγμιότυπα συμβόλων
Gradient Meshes Πλέγματα ντεγκραντέ
Blends Αναμείξεις
Envelopes Περιβλήματα
Intertwine Groups Ομάδες διαπλοκής
Opacity Masks Μάσκες αδιαφάνειας
Objects with Effects Αντικείμενα με εφέ
Path Length Μήκος διαδρομής
Spot Colors Πλακάτα χρώματα
Spot Colors… Πλακάτα χρώματα…
Stacked Bar Graph Tool Στοιβαγμένο ραβδόγραμμα (Stacked Bar Graph Tool)
Can't render this file because it is too large.
+17
View File
@@ -3465,6 +3465,19 @@ digits Malayalam Malayálam
Spot Color Objects Objetos con tintas planas
Pattern Objects Objetos con motivos
Gradient Objects Objetos con degradados
Font Details Detalles de fuentes
Linked Images Imágenes vinculadas
Embedded Images Imágenes incrustadas
Compound Paths Trazados compuestos
Text Objects Objetos de texto
Placed Documents Documentos colocados
Symbol Instances Instancias de símbolo
Gradient Meshes Mallas de degradado
Blends Fusiones
Envelopes Envolventes
Intertwine Groups Grupos entrelazados
Opacity Masks Máscaras de opacidad
Objects with Effects Objetos con efectos
Spot Colors Tintas planas
Spot Colors… Tintas planas…
Spread: Extensión:
@@ -4155,6 +4168,10 @@ axis Both Ambos
Dock Panel Anclar panel
Dock to Top Anclar arriba
Dock to Bottom Anclar abajo
Dock to Left Anclar a la izquierda
Dock to Right Anclar a la derecha
Dock Toolbar Anclar barra de herramientas
Float Toolbar Hacer flotar la barra de herramientas
Control Bar Options Opciones de la barra de control
Tool Options Opciones de herramienta
Object Options Opciones de objeto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -911,6 +911,10 @@
Dock Panel Telakoi paneeli
Dock to Top Telakoi ylös
Dock to Bottom Telakoi alas
Dock to Left Telakoi vasemmalle
Dock to Right Telakoi oikealle
Dock Toolbar Telakoi työkalurivi
Float Toolbar Kelluta työkalurivi
Control Bar Options Ohjauspalkin asetukset
Tool Options Työkalun asetukset
Object Options Objektin asetukset
@@ -3939,6 +3943,19 @@ theme Appearance Ulkoasu
Spot Color Objects Objektit, joissa on spottivärejä
Pattern Objects Objektit, joissa on kuvioita
Gradient Objects Objektit, joissa on liukuvärejä
Font Details Fonttien tiedot
Linked Images Linkitetyt kuvat
Embedded Images Upotetut kuvat
Compound Paths Yhdistelmäpolut
Text Objects Tekstiobjektit
Placed Documents Sijoitetut asiakirjat
Symbol Instances Symbolien ilmentymät
Gradient Meshes Liukuväriverkot
Blends Sulautukset
Envelopes Vääristyskuoret
Intertwine Groups Lomitusryhmät
Opacity Masks Peittävyysmaskit
Objects with Effects Objektit, joissa on tehosteita
Spot Colors Spottivärit
Spot Colors (0) Spottivärit (0)
Swatch Options Värimallin asetukset
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -3457,6 +3457,19 @@ digits Malayalam Malayalam
Spot Color Objects Objets avec tons directs
Pattern Objects Objets avec motifs
Gradient Objects Objets avec dégradés
Font Details Détails des polices
Linked Images Images liées
Embedded Images Images incorporées
Compound Paths Tracés transparents
Text Objects Objets texte
Placed Documents Documents importés
Symbol Instances Instances de symbole
Gradient Meshes Filets de dégradé
Blends Dégradés de formes
Envelopes Enveloppes
Intertwine Groups Groupes entrelacés
Opacity Masks Masques d’opacité
Objects with Effects Objets avec effets
Spot Colors Tons directs
Spot Colors… Tons directs…
Spread: Étendue :
@@ -4144,6 +4157,10 @@ axis Both Les deux
Dock Panel Ancrer le panneau
Dock to Top Ancrer en haut
Dock to Bottom Ancrer en bas
Dock to Left Ancrer à gauche
Dock to Right Ancrer à droite
Dock Toolbar Ancrer la barre d’outils
Float Toolbar Rendre la barre d’outils flottante
Control Bar Options Options de la barre de contrôle
Tool Options Options de l’outil
Object Options Options de l’objet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -1386,6 +1386,10 @@ antiAlias Sharp शार्प
Dock Panel पैनल डॉक करें
Dock to Top ऊपर डॉक करें
Dock to Bottom नीचे डॉक करें
Dock to Left बाईं ओर डॉक करें
Dock to Right दाईं ओर डॉक करें
Dock Toolbar टूलबार डॉक करें
Float Toolbar टूलबार फ़्लोट करें
Control Bar Options नियंत्रण बार ऑप्शन्स
Tool Options टूल ऑप्शन्स
Object Options ऑब्जेक्ट ऑप्शन्स
@@ -4747,6 +4751,19 @@ antiAlias Sharp शार्प
Spot Color Objects स्पॉट कलर्स के साथ ऑब्जेक्ट्स
Pattern Objects पैटर्न्स के साथ ऑब्जेक्ट्स
Gradient Objects ग्रेडिएंट्स के साथ ऑब्जेक्ट्स
Font Details फ़ॉन्ट विवरण
Linked Images लिंक्ड चित्र
Embedded Images एम्बेडेड चित्र
Compound Paths कंपाउंड पाथ्स
Text Objects टेक्स्ट ऑब्जेक्ट्स
Placed Documents प्लेस्ड डॉक्यूमेंट्स
Symbol Instances सिंबल इंस्टेंस
Gradient Meshes ग्रेडिएंट मेश
Blends ब्लेंड्स
Envelopes एन्वेलप्स
Intertwine Groups इंटरट्वाइन समूह
Opacity Masks ओपेसिटी मास्क
Objects with Effects इफ़ेक्ट्स के साथ ऑब्जेक्ट्स
Spot Colors स्पॉट रंग
Spot Colors (0) स्पॉट कलर्स (0)
Spot Colors… स्पॉट कलर्स…
Can't render this file because it is too large.
+17
View File
@@ -1114,6 +1114,10 @@ font-technology Variable Változó
Dock Panel Panel dokkolása
Dock to Top Dokkolás felülre
Dock to Bottom Dokkolás alulra
Dock to Left Dokkolás balra
Dock to Right Dokkolás jobbra
Dock Toolbar Eszköztár dokkolása
Float Toolbar Eszköztár lebegtetése
Control Bar Options Vezérlősáv beállításai
Tool Options Eszközbeállítások
Object Options Objektumbeállítások
@@ -4119,6 +4123,19 @@ mark-style Japanese Japán
Spot Color Objects Direkt színeket használó objektumok
Pattern Objects Mintázatokat használó objektumok
Gradient Objects Színátmeneteket használó objektumok
Font Details Betűtípus-részletek
Linked Images Csatolt képek
Embedded Images Beágyazott képek
Compound Paths Összetett útvonalak
Text Objects Szövegobjektumok
Placed Documents Elhelyezett dokumentumok
Symbol Instances Szimbólumpéldányok
Gradient Meshes Színátmenetes hálók
Blends Összemosások
Envelopes Burkolók
Intertwine Groups Összefonási csoportok
Opacity Masks Átlátszósági maszkok
Objects with Effects Effektusokat használó objektumok
Spot Colors Direktszínek
Spot Colors (0) Direktszínek (0)
Spot Colors… Direktszínek…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -853,6 +853,10 @@
Dock Panel Tambatkan panel
Dock to Top Tambatkan ke atas
Dock to Bottom Tambatkan ke bawah
Dock to Left Tambatkan ke kiri
Dock to Right Tambatkan ke kanan
Dock Toolbar Tambatkan bilah alat
Float Toolbar Mengambangkan bilah alat
Control Bar Options Opsi bilah kontrol
Tool Options Opsi alat
Object Options Opsi objek
@@ -3734,6 +3738,19 @@ theme Appearance Tampilan
Spot Color Objects Objek dengan Warna Spot
Pattern Objects Objek dengan Pola
Gradient Objects Objek dengan Gradien
Font Details Detail Font
Linked Images Gambar Tertaut
Embedded Images Gambar Tersemat
Compound Paths Jalur Majemuk
Text Objects Objek Teks
Placed Documents Dokumen yang Ditempatkan
Symbol Instances Instans Simbol
Gradient Meshes Mesh Gradien
Blends Blend
Envelopes Amplop
Intertwine Groups Grup Jalinan
Opacity Masks Mask Opasitas
Objects with Effects Objek dengan Efek
Spot Colors Warna spot
Spot Colors (0) Warna spot (0)
Swatch Options Opsi sampel
Can't render this file because it contains an unexpected character in line 2422 and column 5.
+18
View File
@@ -3510,6 +3510,20 @@ digits Malayalam Malayalam
Spot Color Objects Oggetti con colori spot
Pattern Objects Oggetti con motivi
Gradient Objects Oggetti con sfumature
Font Details Dettagli font
Linked Images Immagini collegate
Embedded Images Immagini incorporate
Compound Paths Tracciati composti
Text Objects Oggetti di testo
Placed Documents Documenti inseriti
Symbol Instances Istanze di simbolo
Gradient Meshes Trame sfumatura
Blends Fusioni
Envelopes Involucri
Intertwine Groups Gruppi intrecciati
Opacity Masks Maschere di opacità
Objects with Effects Oggetti con effetti
Pattern Swatches Campioni motivo
Spot Colors Colori spot
Spot Colors… Colori spot…
Spread: Estensione:
@@ -4206,6 +4220,10 @@ axis Both Entrambi
Dock Panel Ancora pannello
Dock to Top Ancora in alto
Dock to Bottom Ancora in basso
Dock to Left Ancora a sinistra
Dock to Right Ancora a destra
Dock Toolbar Ancora barra degli strumenti
Float Toolbar Rendi mobile barra degli strumenti
Control Bar Options Opzioni barra di controllo
Tool Options Opzioni strumento
Object Options Opzioni oggetto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -3287,6 +3287,20 @@ mark-style Japanese 日本式
Spot Color Objects 特色を使用したオブジェクト
Pattern Objects パターンを使用したオブジェクト
Gradient Objects グラデーションを使用したオブジェクト
Font Details フォントの詳細
Linked Images リンク画像
Embedded Images 埋め込み画像
Compound Paths 複合パス
Text Objects テキストオブジェクト
Placed Documents 配置ドキュメント
Symbol Instances シンボルインスタンス
Gradient Meshes グラデーションメッシュ
Blends ブレンド
Envelopes エンベロープ
Intertwine Groups 織り交ぜグループ
Opacity Masks 不透明マスク
Objects with Effects 効果を使用したオブジェクト
Pattern Swatches パターンスウォッチ
Spot Colors 特色
Spot Colors… 特色…
Spread: 拡散:
@@ -4148,6 +4162,10 @@ axis Both 両方
Dock Panel パネルをドッキング
Dock to Top 上部にドッキング
Dock to Bottom 下部にドッキング
Dock to Left 左側にドッキング
Dock to Right 右側にドッキング
Dock Toolbar ツールバーをドッキング
Float Toolbar ツールバーをフローティング
Control Bar Options コントロールバーオプション
Tool Options ツールオプション
Object Options オブジェクトオプション
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -816,6 +816,10 @@
Dock Panel პანელის მიმაგრება
Dock to Top ზემოთ მიმაგრება
Dock to Bottom ქვემოთ მიმაგრება
Dock to Left მარცხნივ მიმაგრება
Dock to Right მარჯვნივ მიმაგრება
Dock Toolbar ხელსაწყოთა ზოლის მიმაგრება
Float Toolbar მცურავი ხელსაწყოთა ზოლი
Control Bar Options მართვის ზოლის პარამეტრები
Tool Options ხელსაწყოს პარამეტრები
Object Options ობიექტის პარამეტრები
@@ -3573,6 +3577,19 @@ theme Appearance იერსახე
Spot Color Objects ობიექტები სპოტ-ფერებით
Pattern Objects ობიექტები პატერნებით
Gradient Objects ობიექტები გრადიენტებით
Font Details შრიფტების დეტალები
Linked Images დაკავშირებული გამოსახულებები
Embedded Images ჩაშენებული გამოსახულებები
Compound Paths შედგენილი ტრაექტორიები
Text Objects ტექსტური ობიექტები
Placed Documents განთავსებული დოკუმენტები
Symbol Instances სიმბოლოს ეგზემპლარები
Gradient Meshes გრადიენტული ბადეები
Blends გადასვლები
Envelopes გარსები
Intertwine Groups გადახლართვის ჯგუფები
Opacity Masks გაუმჭვირვალობის ნიღბები
Objects with Effects ობიექტები ეფექტებით
Spot Colors სპოტ-ფერები
Swatch Options ფერის ნიმუშის პარამეტრები
Ungroup Color Group ფერების ჯგუფის დაშლა
Can't render this file because it is too large.
+18
View File
@@ -3276,6 +3276,20 @@ mark-style Japanese 일본식
Spot Color Objects 별색을 사용한 개체
Pattern Objects 패턴을 사용한 개체
Gradient Objects 그레이디언트를 사용한 개체
Font Details 글꼴 세부 정보
Linked Images 연결된 이미지
Embedded Images 포함된 이미지
Compound Paths 컴파운드 패스
Text Objects 텍스트 개체
Placed Documents 가져온 문서
Symbol Instances 심볼 인스턴스
Gradient Meshes 그레이디언트 메시
Blends 블렌드
Envelopes 엔벨로프
Intertwine Groups 교차 그룹
Opacity Masks 불투명 마스크
Objects with Effects 효과를 사용한 개체
Pattern Swatches 패턴 견본
Spot Colors 별색
Spot Colors… 별색…
Spread: 확산:
@@ -4132,6 +4146,10 @@ axis Both 양쪽
Dock Panel 패널 고정
Dock to Top 위쪽에 고정
Dock to Bottom 아래쪽에 고정
Dock to Left 왼쪽에 고정
Dock to Right 오른쪽에 고정
Dock Toolbar 도구 막대 고정
Float Toolbar 도구 막대 분리
Control Bar Options 컨트롤 막대 옵션
Tool Options 도구 옵션
Object Options 개체 옵션
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -1368,6 +1368,10 @@
Dock Panel Paneel dokken
Dock to Top Bovenaan dokken
Dock to Bottom Onderaan dokken
Dock to Left Links dokken
Dock to Right Rechts dokken
Dock Toolbar Werkbalk dokken
Float Toolbar Werkbalk laten zweven
Control Bar Options Opties voor bedieningsbalk
Tool Options Gereedschapsopties
Object Options Objectopties
@@ -4249,6 +4253,19 @@ theme Appearance Vormgeving
Spot Color Objects Objecten met spotkleuren
Pattern Objects Objecten met patronen
Gradient Objects Objecten met verlopen
Font Details Lettertypedetails
Linked Images Gekoppelde afbeeldingen
Embedded Images Ingesloten afbeeldingen
Compound Paths Samengestelde paden
Text Objects Tekstobjecten
Placed Documents Geplaatste documenten
Symbol Instances Symboolinstanties
Gradient Meshes Verlooprasters
Blends Vermengingen
Envelopes Omvormingen
Intertwine Groups Verstrengelingsgroepen
Opacity Masks Dekkingmaskers
Objects with Effects Objecten met effecten
Spot Colors Spotkleuren
Spot Colors (0) Spotkleuren (0)
Swatch Options Kleurstaalopties
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -868,6 +868,10 @@
Dock Panel Zadokuj panel
Dock to Top Zadokuj u góry
Dock to Bottom Zadokuj u dołu
Dock to Left Zadokuj po lewej
Dock to Right Zadokuj po prawej
Dock Toolbar Zadokuj pasek narzędzi
Float Toolbar Odepnij pasek narzędzi
Control Bar Options Opcje paska sterowania
Tool Options Opcje narzędzia
Object Options Opcje obiektu
@@ -3874,6 +3878,20 @@ theme Appearance Wygląd
Spot Color Objects Obiekty z kolorami dodatkowymi
Pattern Objects Obiekty ze wzorkami
Gradient Objects Obiekty z gradientami
Font Details Szczegóły czcionek
Linked Images Połączone obrazy
Embedded Images Osadzone obrazy
Compound Paths Ścieżki złożone
Text Objects Obiekty tekstowe
Placed Documents Umieszczone dokumenty
Symbol Instances Wystąpienia symboli
Gradient Meshes Siatki gradientowe
Blends Przejścia
Envelopes Koperty
Intertwine Groups Grupy przeplatania
Opacity Masks Maski krycia
Objects with Effects Obiekty z efektami
Pattern Swatches Próbki wzorków
Spot Colors Kolory dodatkowe
Swatch Options Opcje próbki
Ungroup Color Group Rozgrupuj grupę kolorów
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17
View File
@@ -2913,6 +2913,19 @@ digits Malayalam Malaiala
Spot Color Objects Objetos com cores spot
Pattern Objects Objetos com padrões
Gradient Objects Objetos com gradientes
Font Details Detalhes das fontes
Linked Images Imagens vinculadas
Embedded Images Imagens incorporadas
Compound Paths Caminhos compostos
Text Objects Objetos de texto
Placed Documents Documentos colocados
Symbol Instances Instâncias de símbolo
Gradient Meshes Malhas de gradiente
Blends Mesclagens
Envelopes Envelopes
Intertwine Groups Grupos entrelaçados
Opacity Masks Máscaras de opacidade
Objects with Effects Objetos com efeitos
Spot Colors Cores Spot
Spot Colors… Cores Spot…
Spread: Propagação:
@@ -3382,6 +3395,10 @@ imageTrace Outline Contorno
Dock Panel Encaixar Painel
Dock to Top Encaixar na Parte Superior
Dock to Bottom Encaixar na Parte Inferior
Dock to Left Encaixar à Esquerda
Dock to Right Encaixar à Direita
Dock Toolbar Encaixar Barra de Ferramentas
Float Toolbar Flutuar Barra de Ferramentas
Control Bar Options Opções da Barra de Controle
Tool Options Opções de Ferramenta
Object Options Opções de Objeto
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17
View File
@@ -3497,6 +3497,19 @@ digits Malayalam Малаялам
Spot Color Objects Объекты с плашечными цветами
Pattern Objects Объекты с узорами
Gradient Objects Объекты с градиентами
Font Details Сведения о шрифтах
Linked Images Связанные изображения
Embedded Images Внедрённые изображения
Compound Paths Составные контуры
Text Objects Текстовые объекты
Placed Documents Размещённые документы
Symbol Instances Экземпляры символов
Gradient Meshes Градиентные сетки
Blends Переходы
Envelopes Оболочки
Intertwine Groups Группы переплетения
Opacity Masks Непрозрачные маски
Objects with Effects Объекты с эффектами
Spot Colors Плашечные цвета
Spot Colors… Плашечные цвета…
Spread: Разворот:
@@ -4192,6 +4205,10 @@ axis Both Оба
Dock Panel Прикрепить панель
Dock to Top Прикрепить сверху
Dock to Bottom Прикрепить снизу
Dock to Left Прикрепить слева
Dock to Right Прикрепить справа
Dock Toolbar Прикрепить панель инструментов
Float Toolbar Сделать панель инструментов плавающей
Control Bar Options Параметры панели управления
Tool Options Параметры инструмента
Object Options Параметры объекта
Can't render this file because it is too large.
+17
View File
@@ -3387,6 +3387,19 @@ digits Malayalam Malayalam
Spot Color Objects Objekt med dekorfärger
Pattern Objects Objekt med mönster
Gradient Objects Objekt med gradienter
Font Details Typsnittsdetaljer
Linked Images Länkade bilder
Embedded Images Inbäddade bilder
Compound Paths Sammansatta banor
Text Objects Textobjekt
Placed Documents Placerade dokument
Symbol Instances Symbolinstanser
Gradient Meshes Gradientnät
Blends Blandningar
Envelopes Kuvert
Intertwine Groups Sammanflätningsgrupper
Opacity Masks Opacitetsmasker
Objects with Effects Objekt med effekter
Spot Colors Dekorfärger
Spot Colors… Dekorfärger…
Spread: Spridning:
@@ -4071,6 +4084,10 @@ axis Both Båda
Dock Panel Docka panel
Dock to Top Docka överst
Dock to Bottom Docka nederst
Dock to Left Docka till vänster
Dock to Right Docka till höger
Dock Toolbar Docka verktygsfältet
Float Toolbar Gör verktygsfältet flytande
Control Bar Options Alternativ för kontrollfältet
Tool Options Verktygsalternativ
Object Options Objektalternativ
Can't render this file because it is too large.
+22 -13
View File
@@ -292,24 +292,33 @@ fn control_bar_panel_menu_labels_are_in_complete_catalogs() {
}
}
/// The titles of Document Info's Spot Color Objects, Pattern Objects and Gradient Objects
/// categories, which the panel, its menu and the Objects category's counts show.
/// Document Info's labels: every category title, which the panel and its menu show, every count
/// label of the Objects category, and the Document category's fixed row labels (the artboard rows
/// show artboard names and are left out).
#[test]
fn document_info_object_category_titles_are_in_complete_catalogs() {
fn document_info_labels_are_in_complete_catalogs() {
let mut session = vectorcraft_engine::Session::new();
session.execute("file.new", &serde_json::json!({"width": 100, "height": 100})).unwrap();
session.execute("shape.rectangle", &serde_json::json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let info = session.execute("document.info", &serde_json::json!({})).unwrap();
let titles: Vec<&str> = info["sections"]
.as_array()
.unwrap()
.iter()
.filter(|s| matches!(s["id"].as_str(), Some("spotColors" | "patterns" | "gradients")))
.filter_map(|s| s["title"].as_str())
.collect();
assert_eq!(titles, ["Spot Color Objects", "Pattern Objects", "Gradient Objects"]);
let sections = info["sections"].as_array().unwrap();
let titles: Vec<&str> = sections.iter().filter_map(|s| s["title"].as_str()).collect();
let categories: Vec<&str> = vectorcraft_engine::cmd::CATEGORIES.iter().map(|&(_, title)| title).collect();
assert_eq!(titles, categories);
let mut labels = categories;
labels.extend(vectorcraft_engine::cmd::OBJECT_LABELS.iter().map(|&(_, label)| label));
let objects = sections.iter().find(|s| s["id"] == "objects").unwrap()["rows"].as_array().unwrap();
assert!(objects.iter().any(|r| r[0] == "Path Length"), "{objects:?}");
labels.push("Path Length");
let document = sections.iter().find(|s| s["id"] == "document").unwrap()["rows"].as_array().unwrap();
let artboards = document.iter().position(|r| r[0] == "Artboards").unwrap();
let names = artboards + 1..=artboards + document[artboards][1].as_str().unwrap().parse::<usize>().unwrap();
let fixed: Vec<&str> = document.iter().enumerate().filter(|(k, _)| !names.contains(k)).filter_map(|(_, r)| r[0].as_str()).collect();
assert_eq!(fixed.len(), 9, "{fixed:?}");
labels.extend(fixed);
for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) {
for title in &titles {
assert!(lang.catalog().plain(title).is_some(), "{} is missing {title}", lang.code);
for label in &labels {
assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code);
}
}
}
+17
View File
@@ -3286,6 +3286,19 @@ digits Malayalam Malayalam
Spot Color Objects Spot Renkli Nesneler
Pattern Objects Desenli Nesneler
Gradient Objects Degradeli Nesneler
Font Details Yazı Tipi Ayrıntıları
Linked Images Bağlantılı Görseller
Embedded Images Gömülü Görseller
Compound Paths Bileşik Yollar
Text Objects Metin Nesneleri
Placed Documents Yerleştirilen Belgeler
Symbol Instances Sembol Örnekleri
Gradient Meshes Degrade Örgüler
Blends Karışımlar
Envelopes Zarflar
Intertwine Groups İç İçe Geçme Grupları
Opacity Masks Opaklık Maskeleri
Objects with Effects Efektli Nesneler
Spot Colors Nokta Renkleri
Spot Colors… Nokta Renkleri…
Spread: Yayılma:
@@ -3955,6 +3968,10 @@ axis Both Her İkisi
Dock Panel Bağlantı İstasyonu Paneli
Dock to Top Üste yerleştir
Dock to Bottom Alta yerleştir
Dock to Left Sola yerleştir
Dock to Right Sağa yerleştir
Dock Toolbar Araç çubuğunu yerleştir
Float Toolbar Araç Çubuğunu Yüzer Yap
Control Bar Options Kontrol Çubuğu Seçenekleri
Tool Options Araç Seçenekleri
Object Options Nesne Seçenekleri
Can't render this file because it contains an unexpected character in line 5 and column 25.
+18
View File
@@ -3503,6 +3503,20 @@ effect Points Точки
Spot Color Objects Об’єкти з плашковими кольорами
Pattern Objects Об’єкти з візерунками
Gradient Objects Об’єкти з градієнтами
Font Details Відомості про шрифти
Linked Images Пов’язані зображення
Embedded Images Впроваджені зображення
Compound Paths Складені контури
Text Objects Текстові об’єкти
Placed Documents Розміщені документи
Symbol Instances Екземпляри символів
Gradient Meshes Градієнтні сітки
Blends Переходи
Envelopes Оболонки
Intertwine Groups Групи переплетення
Opacity Masks Маски непрозорості
Objects with Effects Об’єкти з ефектами
Pattern Swatches Зразки візерунків
Spot Colors Плашкові кольори
Spot Colors… Плашкові кольори…
Spread: Розворот:
@@ -4195,6 +4209,10 @@ axis Both Обидві
Dock Panel Прикріпити панель
Dock to Top Прикріпити вгорі
Dock to Bottom Прикріпити внизу
Dock to Left Прикріпити ліворуч
Dock to Right Прикріпити праворуч
Dock Toolbar Прикріпити панель інструментів
Float Toolbar Від’єднати панель інструментів
Control Bar Options Параметри панелі керування
Tool Options Параметри інструменту
Object Options Параметри об’єкта
Can't render this file because it is too large.
+18
View File
@@ -1012,6 +1012,10 @@ font-technology Variable Biến thiên
Dock Panel Neo bảng
Dock to Top Neo lên trên
Dock to Bottom Neo xuống dưới
Dock to Left Neo sang trái
Dock to Right Neo sang phải
Dock Toolbar Neo thanh công cụ
Float Toolbar Thả nổi thanh công cụ
Control Bar Options Tùy chọn thanh điều khiển
Tool Options Tùy chọn công cụ
Object Options Tùy chọn đối tượng
@@ -3851,6 +3855,20 @@ mark-style Japanese Kiểu Nhật
Spot Color Objects Đối tượng có màu đặc biệt
Pattern Objects Đối tượng có họa tiết
Gradient Objects Đối tượng có chuyển sắc
Font Details Chi tiết phông chữ
Linked Images Hình ảnh đã liên kết
Embedded Images Hình ảnh đã nhúng
Compound Paths Đường dẫn phức
Text Objects Đối tượng văn bản
Placed Documents Tài liệu đã đặt
Symbol Instances Thực thể ký hiệu
Gradient Meshes Lưới chuyển sắc
Blends Hòa trộn
Envelopes Envelope
Intertwine Groups Nhóm đan xen
Opacity Masks Mặt nạ độ mờ
Objects with Effects Đối tượng có hiệu ứng
Pattern Swatches Mẫu màu họa tiết
Spot Colors Màu đặc biệt
Spot Colors… Màu đặc biệt…
Spray Radius Bán kính phun
Can't render this file because it contains an unexpected character in line 204 and column 5.
+17
View File
@@ -3326,6 +3326,19 @@ digits Malayalam 马拉雅拉姆数字
Spot Color Objects 使用专色的对象
Pattern Objects 使用图案的对象
Gradient Objects 使用渐变的对象
Font Details 字体详细信息
Linked Images 链接的图像
Embedded Images 嵌入的图像
Compound Paths 复合路径
Text Objects 文本对象
Placed Documents 置入的文档
Symbol Instances 符号实例
Gradient Meshes 渐变网格
Blends 混合
Envelopes 封套
Intertwine Groups 交织编组
Opacity Masks 不透明度蒙版
Objects with Effects 使用效果的对象
Spot Colors 专色
Spot Colors… 专色…
Spread: 扩散:
@@ -5443,6 +5456,10 @@ axis Both 两者
Dock Panel 停放面板
Dock to Top 停放到顶部
Dock to Bottom 停放到底部
Dock to Left 停放到左侧
Dock to Right 停放到右侧
Dock Toolbar 停放工具栏
Float Toolbar 浮动工具栏
Control Bar Options 控制栏选项
Tool Options 工具选项
Object Options 对象选项
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -4563,6 +4563,20 @@ digits Malayalam 馬來亞拉姆數字
Spot Color Objects 使用特別色的物件
Pattern Objects 使用圖樣的物件
Gradient Objects 使用漸層的物件
Font Details 字體詳細資訊
Linked Images 已連結的影像
Embedded Images 嵌入的影像
Compound Paths 複合路徑
Text Objects 文字物件
Placed Documents 已置入的文件
Symbol Instances 符號範例
Gradient Meshes 漸層網格
Blends 漸變
Envelopes 封套
Intertwine Groups 交織群組
Opacity Masks 不透明度遮色片
Objects with Effects 使用效果的物件
Pattern Swatches 圖樣色票
Spot Colors 特別色
Spot Colors… 特別色…
Spread: 擴散:
@@ -5272,6 +5286,10 @@ axis Both 兩者
Dock Panel 停駐面板
Dock to Top 停駐到頂部
Dock to Bottom 停駐到底部
Dock to Left 停駐到左側
Dock to Right 停駐到右側
Dock Toolbar 停駐工具列
Float Toolbar 浮動工具列
Control Bar Options 控制列選項
Tool Options 工具選項
Object Options 物件選項
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -91,8 +91,8 @@ pub mod workspaces;
mod document_drag;
#[cfg(test)]
use tests::{
background as tests_background, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, svg as tests_svg,
synthetic as tests_synthetic, sysclip as tests_sysclip,
background as tests_background, folder as tests_folder, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors,
svg as tests_svg, synthetic as tests_synthetic, sysclip as tests_sysclip,
};
#[cfg(test)]
mod tests;
+18 -1
View File
@@ -564,7 +564,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does",
),
("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"),
(
"window.toolbar.dock",
"Dock Toolbar",
"",
"{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}",
),
(
"window.toolbar.float",
"Float Toolbar",
"",
"{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}",
),
(
"window.toolbar.list",
"List Toolbars",
"",
"{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)",
),
("window.taskBar", "Contextual Task Bar", "", "{}"),
(
"window.pathfinder.compoundShapes",
+8 -25
View File
@@ -56,13 +56,9 @@ fn install_importer(app: &mut VectorcraftApp, src: &str) {
}
/// A file for the importers below, in a folder of its own.
fn importer_file(tag: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("vc-ui-opening-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("a.tob");
std::fs::write(&file, "not json").unwrap();
let file = file.to_string_lossy().into_owned();
fn importer_file(tag: &str) -> (crate::tests_folder::Folder, String) {
let dir = crate::tests_folder::Folder::new(&format!("ui-opening-{tag}"));
let file = dir.file("a.tob", b"not json");
(dir, file)
}
@@ -73,7 +69,7 @@ fn importer_file(tag: &str) -> (std::path::PathBuf, String) {
fn open_with_runs_the_importer_off_the_ui_thread() {
use vectorcraft_plugins::wat::{self as tpl, Given};
let rect = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#;
let (dir, file) = importer_file("with");
let (_dir, file) = importer_file("with");
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
@@ -97,14 +93,13 @@ fn open_with_runs_the_importer_off_the_ui_thread() {
assert!(app.ui.dialog.is_some(), "the failure is reported in a dialog");
assert!(app.ui.recent_files.is_empty());
app.run("plugin.remove", json!({"id": "test.opening.with"})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// Cancel stops an importer that never returns, on the thread reading the file.
#[test]
fn cancel_stops_a_running_importer() {
use vectorcraft_plugins::wat as tpl;
let (dir, file) = importer_file("cancel");
let (_dir, file) = importer_file("cancel");
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
install_importer(&mut app, &tpl::module(&tpl::importer("test.opening.spins", r#"["tob"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1));
@@ -115,7 +110,6 @@ fn cancel_stops_a_running_importer() {
assert!(r.as_ref().err().is_some_and(|e| e.contains("cancelled")), "{:?}", r.err());
assert!(app.session.documents().is_empty());
app.run("plugin.remove", json!({"id": "test.opening.spins"})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// #1694: a file that finishes opening while a dialog is up waits for it to close, so the
@@ -289,8 +283,7 @@ fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() {
#[test]
fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() {
use image::ImageFormat::{Bmp, Jpeg, Png, Tiff};
let dir = std::env::temp_dir().join(format!("vc-opening-links-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let dir = crate::tests_folder::Folder::new("opening-links");
let (before, after) = (dir.join("before"), dir.join("after"));
std::fs::create_dir_all(&before).unwrap();
let file = |folder: &std::path::Path, name: &str| folder.join(name).to_string_lossy().into_owned();
@@ -336,7 +329,6 @@ fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() {
assert!(st.history.undo.is_empty() && !st.is_dirty(), "{update}");
assert_eq!(asked(&app), question, "{update}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A file opened under automation roots is installed under them too: the open keeps the scope it
@@ -344,7 +336,7 @@ fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() {
#[test]
fn a_file_opened_under_automation_roots_finishes_under_them() {
use vectorcraft_engine::file_access;
let dir = scope_dir();
let dir = crate::tests_folder::Folder::new("opening-scope");
let roots = file_access::AutomationRoots::new(&[dir.as_path()], &[]).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Services::default());
file_access::confine(roots.as_ref(), || {
@@ -355,12 +347,6 @@ fn a_file_opened_under_automation_roots_finishes_under_them() {
assert!(scope.run(file_access::current).is_some(), "the roots go with the open");
}
fn scope_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-opening-scope-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A file asked for again while it is still being read is not read a second time (#3455).
#[test]
fn a_file_asked_for_again_while_it_is_read_opens_once() {
@@ -378,9 +364,7 @@ fn a_file_asked_for_again_while_it_is_read_opens_once() {
/// the dialog acts on its own document (#5369).
#[test]
fn a_queued_reopen_that_asks_holds_back_files_ready_to_open() {
let dir = std::env::temp_dir().join(format!("vectorcraft-opening-queued-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = crate::tests_folder::Folder::new("opening-queued");
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
let (doc, other) = (dir.join("doc.vectorcraft"), dir.join("other.vectorcraft"));
@@ -407,7 +391,6 @@ fn a_queued_reopen_that_asks_holds_back_files_ready_to_open() {
assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(&*doc), "the dialog's document stays active");
assert_eq!(app.opening.activate.first().map(|(p, _)| p), Some(&other), "the later activation waits");
assert_eq!(app.opening.pending.len(), 1, "the other file waits");
let _ = std::fs::remove_dir_all(dir);
}
/// Of two files read off the UI thread and ready in the same frame, the first that opens and asks
+3 -2
View File
@@ -1435,8 +1435,9 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
}
fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits {
let min =
if id == CANVAS { egui::vec2(200.0 + crate::toolbar::docked_width(&self.app.ui), 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) };
// The canvas keeps room for Tools and the lane of toolbars docked beside it.
let beside = crate::toolbar::docked_width(&self.app.ui) + crate::custom_toolbars::beside_canvas_reserve(&self.app.ui);
let min = if id == CANVAS { egui::vec2(200.0 + beside, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) };
craft_ui::docking::PanelLimits { min, ..Default::default() }
}
+1 -1
View File
@@ -100,7 +100,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool,
}
if transform {
super::transform::link(app, ui);
ui.separator();
crate::chrome::group_separator(ui);
}
if options {
art_option_boxes(app, ui, ui.available_width() < ART_OPTIONS_WIDTH);
@@ -95,6 +95,28 @@ fn fit_to_artwork_bounds_fits_each_selected_artboard_to_its_own_art() {
assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]);
}
/// A batch the app runs (as the Actions panel plays an action) with a Fit to Artwork Bounds step
/// fits artboards 1 and 3, selected with the Artboard tool, and leaves artboard 2, the active one,
/// as it was.
#[test]
fn a_batchs_fit_to_artwork_bounds_step_fits_each_selected_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap();
for (x, h) in [(10, 30), (130, 20), (250, 40)] {
app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap();
}
app.session.execute("select.all", &json!({})).unwrap();
app.session.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let was = boards(&app);
app.select_tool("artboard");
select(&mut app, 1);
app.session.set_tool_option("selected", &json!([0, 2]));
app.run("command.batch", json!({"commands": [{"command": "artboard.fitToArt", "params": {}}]})).unwrap();
let rect = vectorcraft_geom::Rect::new;
assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]);
}
/// With the Artboard tool, Shift-click on a row selects the artboards from the last row clicked
/// without Shift to it and makes it the active one, Ctrl/Cmd-click adds its artboard to the
/// tool's selection or takes it out (#1139), a plain click selects it alone, and every selected
+1 -1
View File
@@ -1073,7 +1073,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0));
size_field(app, ui, "cb-font-size", 96.0);
ui.add_space(4.0);
ui.separator();
crate::chrome::group_separator(ui);
}
/// The panel state key of the panel menu's Show Snap to Glyph Options.

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