mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 06:31:41 +00:00
Merge main into fix/graph-series-paint-other-commands
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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, ¶ms);
|
||||
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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶ms).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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = ¶graph.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 = ¶graph.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(¶graph.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, ¶graph.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, ¶graph.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, ¶graph.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, ¶graph.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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(¶graphs(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(¶graphs(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(¶graphs(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(¶graphs(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(¶graphs(120)), &[], "columns");
|
||||
let mut vertical = frames.clone();
|
||||
for f in &mut vertical {
|
||||
f.vertical = true;
|
||||
}
|
||||
took += same_as_whole_rest(db, &refs(&vertical), &story(¶graphs(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(¶graphs(120)), &[], "other kinds of frame");
|
||||
let two = [&frames[0], &frames[1]];
|
||||
took += same_as_whole_rest(db, &two, &story(¶graphs(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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(¶ms, &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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user