Merge branch 'main' of github.com:storytold/vectorcraft into 5421-line-arrowhead

# Conflicts:
#	docs/target-app-parity.md
This commit is contained in:
Julius van der Vaart
2026-11-10 06:17:42 +01:00
119 changed files with 5326 additions and 773 deletions
+388 -39
View File
@@ -1,10 +1,10 @@
//! Where a path's strokes paint, from the path's geometry without building their outlines: the
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
//! up to the miter limit, projecting caps and arrowheads.
//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points.
use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
@@ -100,6 +100,82 @@ fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize,
w
}
/// Does the curvature of `seg` stay below one over `side` all along it? A curve's first derivative
/// is a mix of its three control differences, and its second a mix of their two differences, which
/// bound the curvature from above. Then the edges of a stroke reaching `side` from it do not fold
/// back, and those edges reach farthest at the ends of `seg` or where its tangent is square to an
/// axis.
fn gentle(seg: &PathSeg, side: f64) -> bool {
let c = seg.to_cubic();
let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2].map(|v| v * 3.0);
let along = unit(d[0] + d[1] + d[2]);
let slowest = d.iter().map(|v| v.dot(along)).fold(f64::INFINITY, f64::min);
let bend = 2.0 * (d[1] - d[0]).hypot().max((d[2] - d[1]).hypot());
slowest > 0.0 && side * bend < slowest * slowest
}
/// The box a stroke reaching `side` from `seg` paints across it, from the directions its tangent
/// can take: the tangent is a mix of the nonzero control differences, so when their directions all
/// lie within a quarter turn of the directions' sum, every point across the stroke is in `seg`'s
/// box moved `side` along a normal of one of those directions. `None` when they do not. A handle on
/// its end point adds no direction, so a straight curve with one measures as a line.
fn turning_box(seg: &PathSeg, side: f64) -> Option<Rect> {
let c = seg.to_cubic();
let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2];
let moving = || d.iter().map(|v| (*v, v.hypot())).filter(|(_, h)| *h > 0.0).map(|(v, h)| v / h);
let along = unit(moving().fold(Vec2::ZERO, |s, v| s + v));
if moving().any(|v| v.dot(along) <= 0.0) {
return None;
}
let angle = |v: &Vec2| along.cross(*v).atan2(along.dot(*v));
let lo = moving().min_by(|a, b| angle(a).total_cmp(&angle(b)))?;
let hi = moving().max_by(|a, b| angle(a).total_cmp(&angle(b)))?;
let n = |t: Vec2| (Vec2::new(t.y, -t.x) * side).to_point();
let one_side = round_join_box(Point::ZERO, lo, hi, side).unwrap_or_else(|| Rect::from_points(n(lo), n(hi)));
let reach = one_side.union(Rect::new(-one_side.x1, -one_side.y1, -one_side.x0, -one_side.y0));
let b = ParamCurveExtrema::bounding_box(seg);
Some(Rect::new(b.x0 + reach.x0, b.y0 + reach.y0, b.x1 + reach.x1, b.y1 + reach.y1))
}
/// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`:
/// the corners at the ends and the points across the stroke where the tangent is square to an axis.
/// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending
/// that much is measured by [`turning_box`], or grown by `side` on every side when its tangent
/// turns too far.
fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect {
if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) {
if depth == 0 {
return turning_box(seg, side).unwrap_or_else(|| ParamCurveExtrema::bounding_box(seg).inflate(side, side));
}
return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1));
}
let across = |t: f64| {
let (p, tn) = (seg.eval(t), tangent(seg, t));
let n = Vec2::new(-tn.y, tn.x) * side;
[p + n, p - n]
};
let [a, b] = across(0.0);
seg.extrema().into_iter().chain([1.0]).flat_map(across).fold(Rect::from_points(a, b), |r, p| r.union_pt(p))
}
/// The box of the arc a round join paints `side` from `at`, where the path turns from tangent
/// `t_in` to `t_out`: the arc runs on the outer side of the turn, and its box comes from its ends
/// and the points on it square to an axis. A joint where the path turns back on itself is given
/// the whole disc's box, and one where it does not turn paints no arc (`None`).
fn round_join_box(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<Rect> {
let (t_in, t_out) = (unit(t_in), unit(t_out));
let turn = t_in.cross(t_out);
if turn.abs() < 1e-12 {
return (t_in.dot(t_out) < 0.0).then(|| Rect::from_center_size(at, (2.0 * side, 2.0 * side)));
}
let s = turn.signum();
let outer = |t: Vec2| Vec2::new(t.y, -t.x) * s;
let (a, b) = (outer(t_in), outer(t_out));
let axes = [Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(0.0, -1.0)];
let arc = Rect::from_points(at + a * side, at + b * side);
Some(axes.into_iter().filter(|d| a.cross(*d) * s >= 0.0 && d.cross(b) * s >= 0.0).fold(arc, |r, d| r.union_pt(at + d * side)))
}
/// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it?
fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
let n = pts.len();
@@ -114,39 +190,81 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
impl StrokeLayer {
/// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads,
/// or an alignment that depends on whether the path is closed? Else it reaches evenly round it.
/// an alignment that depends on whether the path is closed, or butt caps, which stop at the
/// ends of an open path? Else it reaches evenly round it.
fn needs_shape(&self) -> bool {
self.join == LineJoin::Miter
|| self.cap == LineCap::Square
|| self.cap != LineCap::Round
|| self.start_arrow.is_some()
|| self.end_arrow.is_some()
|| self.align != StrokeAlign::Center
}
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
/// The box this stroke paints along `segs`, the segments with length of an open subpath, when
/// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each
/// joint between segments paints ([`round_join_box`]), a shallow one included.
/// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips
/// are measured apart. A segment in the middle is measured only when its own box grown by
/// `side`, which holds everything it paints, is not inside the box so far.
fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option<Rect> {
let (first, last) = (segs.first()?, segs.last()?);
let ends = swept_box(first, side, 3).union(swept_box(last, side, 3));
let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| {
if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) }
});
if self.join != LineJoin::Round {
return Some(body);
}
Some(joints(segs, false).fold(body, |b, (at, t_in, t_out)| round_join_box(at, t_in, t_out, side).map_or(b, |j| b.union(j))))
}
/// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt
/// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its
/// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of
/// projecting caps and the reach of the arrowheads round their end points.
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>, measure_tips: bool) -> Rect {
let els = bp.elements();
let mut out = bounds;
let subs = subpaths(bp);
let mut out = tips.map_or(bounds, |t| bounds.union(t));
let mut side_max: f64 = 0.0;
// Every subpath reaches evenly unless one is open and butt-capped; only then is each
// subpath's own box needed.
let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1);
let mut even = even_only.then_some(bounds);
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
for (r, closed) in subpaths(bp) {
for (r, closed) in subs {
let side = self.side_reach(closed);
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
let butt = self.cap == LineCap::Butt && !closed;
if !butt {
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
}
if side <= 0.0 {
continue;
}
let square = self.cap == LineCap::Square && !closed;
let miters = measure_tips && self.join == LineJoin::Miter;
if !(butt || square || miters || !even_only) {
continue;
}
let segs = drawn(&els[r]);
if self.join == LineJoin::Miter {
if miters {
for (at, t_in, t_out) in joints(&segs, closed) {
if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) {
out = out.union_pt(tip);
}
}
}
if self.cap == LineCap::Square && !closed {
if butt {
if let Some(body) = self.butt_body(&segs, side) {
out = out.union(body);
}
continue;
}
if !even_only {
even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)));
}
if square {
for (at, dir) in ends(&segs).into_iter().flatten() {
let n = Vec2::new(-dir.y, dir.x) * side;
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
@@ -157,7 +275,15 @@ impl StrokeLayer {
let r = self.head_reach(end);
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
}
out.union(bounds.inflate(side_max, side_max))
even.map_or(out, |e| out.union(e.inflate(side_max, side_max)))
}
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
/// projecting caps and the reach of the arrowheads round their end points. An open subpath
/// with butt caps does not reach past its ends ([`Self::butt_body`]).
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
self.reach_along(bp, bounds, None, true)
}
/// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a
@@ -175,30 +301,7 @@ impl StrokeLayer {
/// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured
/// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does.
pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>) -> Rect {
let els = bp.elements();
let mut out = tips.map_or(bounds, |t| bounds.union(t));
let mut side_max: f64 = 0.0;
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
for (r, closed) in subpaths(bp) {
let side = self.side_reach(closed);
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
if side <= 0.0 {
continue;
}
if self.cap == LineCap::Square && !closed {
let segs = drawn(&els[r]);
for (at, dir) in ends(&segs).into_iter().flatten() {
let n = Vec2::new(-dir.y, dir.x) * side;
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
}
}
}
for (at, _, end) in head_ends(self, bp) {
let r = self.head_reach(end);
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
}
out.union(bounds.inflate(side_max, side_max))
self.reach_along(bp, bounds, tips, false)
}
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
@@ -314,7 +417,7 @@ impl Appearance {
}
/// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape
/// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`.
/// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`.
pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect {
self.painted_strokes().fold(bounds, |acc, s| {
if !s.needs_shape() {
@@ -325,6 +428,30 @@ impl Appearance {
})
}
/// Does this appearance have a visible butt-capped stroke?
pub fn has_butt_stroke(&self) -> bool {
self.painted_strokes().any(|s| s.cap == LineCap::Butt)
}
/// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with
/// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The
/// visual box's margin at the stroke's corners there depends on their direction, which a scale
/// out of proportion changes.
pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool {
if !self.has_butt_stroke() {
return false;
}
let slanted = |v: Vec2| {
let d = unit(a * v.to_point() - a * Point::ZERO);
(d.x * d.y).abs() > 1e-9
};
let els = bp.elements();
subpaths(bp)
.into_iter()
.filter(|(_, closed)| !closed)
.any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0))))
}
/// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within
/// `tol` of `p` ([`StrokeLayer::hits`])?
pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
@@ -332,6 +459,31 @@ impl Appearance {
}
}
/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object
/// before it answers yes without looking further.
pub const SLANT_INSTANCES: usize = 4096;
impl crate::Node {
/// Does a visible butt-capped stroke of this object, of an object in it or of an object in a
/// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment
/// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it
/// answers yes.
pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool {
self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES))
}
/// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into.
fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool {
if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind {
let Some(l) = left.get().checked_sub(1) else { return true };
left.set(l);
return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false);
}
(self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a)))
|| self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left)))
}
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
@@ -378,7 +530,7 @@ mod tests {
let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b));
assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}");
// A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside.
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}");
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == 0.0, "{big:?}");
// The start has no head.
assert_eq!(at(300.0).head_reach(false), 0.0);
}
@@ -405,6 +557,203 @@ mod tests {
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0));
}
/// An open path through `points`.
fn polyline(points: &[(f64, f64)]) -> BezPath {
let mut bp = BezPath::new();
for (i, p) in points.iter().enumerate() {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp
}
#[test]
fn butt_caps_stop_at_the_ends_of_an_open_path() {
let butt = |f: fn(&mut StrokeLayer)| {
stroke(2.0, |s| {
s.cap = LineCap::Butt;
f(s);
})
};
// A vertical line reaches sideways only.
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
assert_eq!(butt(|_| {}).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
// A 3-4-5 diagonal: its corners reach 0.8 pt across x and 0.6 pt across y.
let d = polyline(&[(0.0, 0.0), (30.0, 40.0)]);
let b = butt(|_| {}).bounds_on(&d, d.bounding_box());
for (got, want) in [(b.x0, -0.8), (b.y0, -0.6), (b.x1, 30.8), (b.y1, 40.6)] {
assert!((got - want).abs() < 1e-9, "{b:?}");
}
// The dashes of a butt stroke stop inside the line as well, and an open path ignores the
// alignment.
let dashed = butt(|s| s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }));
assert_eq!(dashed.bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
assert_eq!(butt(|s| s.align = StrokeAlign::Inside).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
// A round cap reaches half the weight past each end, a square one the corners.
let round = butt(|s| s.cap = LineCap::Round).bounds_on(&v, v.bounding_box());
assert_eq!(round, Rect::new(49.0, 9.0, 51.0, 61.0));
let square = butt(|s| s.cap = LineCap::Square).bounds_on(&v, v.bounding_box());
assert_eq!(square, Rect::new(49.0, 9.0, 51.0, 61.0));
// Two strokes union.
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let wide = stroke(10.0, |s| s.cap = LineCap::Round);
ap.items.push(crate::AppearanceItem::Stroke(wide));
let vb = v.bounding_box();
assert_eq!(ap.stroked_bounds(vb, || v.clone()), Rect::new(45.0, 5.0, 55.0, 65.0));
}
#[test]
fn a_butt_paths_joins_and_curves_stay_inside_the_box() {
// A corner in the middle: the round join reaches a full half weight straight down, outside
// the turn, the ends do not, and a miter reaches its tip.
let zig = polyline(&[(0.0, 0.0), (20.0, 20.0), (40.0, 0.0)]);
let round = stroke(4.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Round;
})
.bounds_on(&zig, zig.bounding_box());
assert!((round.y1 - 22.0).abs() < 1e-9, "the round join's reach: {round:?}");
assert!((round.x0 + 2.0_f64.sqrt()).abs() < 1e-9 && (round.x1 - 40.0 - 2.0_f64.sqrt()).abs() < 1e-9, "the ends' corners: {round:?}");
// A round join paints its arc on the outer side of a turn too shallow to count as a
// corner, and nothing where the path goes straight on.
let round_butt = stroke(4.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Round;
});
let shallow = polyline(&[(0.0, 0.25), (50.0, 0.0), (100.0, 0.25)]);
let b = round_butt.bounds_on(&shallow, shallow.bounding_box());
assert!((b.y0 + 2.0).abs() < 1e-12, "{b:?}");
let straight = polyline(&[(0.0, 0.0), (0.1, 0.0), (100.0, 0.0)]);
assert_eq!(round_butt.bounds_on(&straight, straight.bounding_box()), Rect::new(0.0, -2.0, 100.0, 2.0));
let miter = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&zig, zig.bounding_box());
assert!((miter.y1 - (20.0 + 2.0 * 2.0_f64.sqrt())).abs() < 1e-9, "the miter tip: {miter:?}");
// An arc over the top whose ends leave the page straight down: the top reaches half the
// weight past the path, and the bottom stops at the ends.
let mut arc = BezPath::new();
arc.move_to((0.0, 50.0));
arc.curve_to((0.0, 22.4), (22.4, 0.0), (50.0, 0.0));
arc.curve_to((77.6, 0.0), (100.0, 22.4), (100.0, 50.0));
let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&arc, arc.bounding_box());
assert!((b.y0 + 2.0).abs() < 1e-9 && (b.x0 + 2.0).abs() < 1e-9 && (b.x1 - 102.0).abs() < 1e-9, "{b:?}");
assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end() {
let butt = stroke(2.0, |s| s.cap = LineCap::Butt);
// A straight curve whose first or last handle sits on its end point measures like a line.
let mut first = BezPath::new();
first.move_to((50.0, 10.0));
first.curve_to((50.0, 10.0), (50.0, 40.0), (50.0, 60.0));
let mut last = BezPath::new();
last.move_to((50.0, 10.0));
last.curve_to((50.0, 20.0), (50.0, 60.0), (50.0, 60.0));
for bp in [first, last] {
assert_eq!(butt.bounds_on(&bp, bp.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
}
// A bend that leaves its start straight up from a handle on the start point: the stroke
// reaches only a little below the start, not half the weight.
let mut bend = BezPath::new();
bend.move_to((0.0, 50.0));
bend.curve_to((0.0, 50.0), (0.0, 0.0), (50.0, 0.0));
let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&bend, bend.bounding_box());
assert!(b.y1 >= 50.0 && b.y1 < 50.2, "{b:?}");
assert!((b.x0 + 2.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.x1 - 50.0).abs() < 1e-9, "{b:?}");
// Nearly a cusp: the curve turns back at its lowest point, where its tangent is level and
// the stroke reaches the full half weight below it.
let (a0, a1, a2) = (1e5 * 81.0 / 256.0, -1e5 * 9.0 / 8.0, 1e5);
let x = |i: f64| 2e-9 * i / 3.0;
let mut cusp = BezPath::new();
cusp.move_to((x(0.0), a0));
cusp.curve_to((x(1.0), a0 + a1 / 3.0), (x(2.0), a0 + 2.0 * a1 / 3.0 + a2 / 3.0), (x(3.0), a0 + a1 + a2));
let b = butt.bounds_on(&cusp, cusp.bounding_box());
assert!(b.y0 <= -1.0 + 1e-6, "{b:?}");
// A curve far shorter than the weight still reaches half the weight above where its
// tangent is level.
let mut tiny = BezPath::new();
tiny.move_to((0.0, 0.9e-12));
tiny.curve_to((0.3e-12, -0.5e-12), (1.7e-12, -0.2e-12), (2e-12, 1e-12));
let b = butt.bounds_on(&tiny, tiny.bounding_box());
assert!(b.y0 <= -1.0 + 1e-9, "{b:?}");
}
#[test]
fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let id = vectorcraft_geom::Affine::IDENTITY;
assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id));
// Square ends, slanted segments in the middle.
let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]);
assert!(ap.has_slanted_butt_stroke(&apex, id));
// A rotation slants a vertical line; a round cap is never slanted.
let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]);
assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3)));
ap.stroke_mut().unwrap().cap = LineCap::Round;
assert!(!ap.has_slanted_butt_stroke(&apex, id));
}
#[test]
fn the_slant_check_looks_into_at_most_4096_symbol_instances() {
// S0 is a vertical butt line. Each of S1 to S12 holds two instances of the symbol before
// it, so an instance of S12 holds 8,191 instances and none of them is slanted. Past 4,096
// the check answers yes.
let mut d = Document::new(400.0, 400.0);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let line = Node::path(d.alloc_id(), PathData::from_bezpath(&polyline(&[(0.0, 0.0), (0.0, 10.0)])), ap);
d.symbols.push(crate::Symbol { dynamic: false, name: "S0".into(), art: std::sync::Arc::new(line) });
for k in 1..=12 {
let mut instance = |dx: f64| {
let xf = vectorcraft_geom::Affine::translate((dx, 0.0));
std::sync::Arc::new(Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf }))
};
let pair = vec![instance(0.0), instance(20.0)];
let art = Node::group(d.alloc_id(), pair);
d.symbols.push(crate::Symbol { dynamic: false, name: format!("S{k}"), art: std::sync::Arc::new(art) });
}
let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let symbols = d.symbol_boxes();
assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY));
assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured());
// One instance of S1 holds three instances, all looked into.
let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY });
assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY));
}
#[test]
fn a_compound_path_measures_each_of_its_subpaths_by_its_own_ends() {
// A closed square at the top and an open line below it: only the square reaches past its
// own edges.
let mut bp = BezPath::new();
bp.extend(square().elements().iter().copied());
bp.move_to((50.0, 150.0));
bp.line_to((50.0, 200.0));
let st = stroke(4.0, |s| s.cap = LineCap::Butt);
assert_eq!(st.bounds_on(&bp, bp.bounding_box()), Rect::new(-2.0, -2.0, 102.0, 200.0));
}
#[test]
fn a_middle_segment_can_set_the_box_of_a_butt_path() {
// The flat middle segment reaches a full half weight above the path, and the ends' corners
// reach 10/51 of it below.
let hat = polyline(&[(0.0, 50.0), (10.0, 0.0), (20.0, 0.0), (30.0, 50.0)]);
let b = stroke(2.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Bevel;
})
.bounds_on(&hat, hat.bounding_box());
let corner = 10.0 / (10.0_f64 * 10.0 + 50.0 * 50.0).sqrt();
assert!((b.y0 + 1.0).abs() < 1e-9 && (b.y1 - 50.0 - corner).abs() < 1e-9, "{b:?}");
}
#[test]
fn bounds_with_tips_measures_a_butt_line_as_bounds_on_does() {
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
let st = stroke(2.0, |s| s.cap = LineCap::Butt);
assert_eq!(st.bounds_with_tips(&v, v.bounding_box(), None), Rect::new(49.0, 10.0, 51.0, 60.0));
}
#[test]
fn clicks_hit_outside_strokes_on_their_side_only() {
let sq = square();
+2 -2
View File
@@ -603,7 +603,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() {
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 2, "{w:?}");
assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}");
assert!((w[1] - 102.0).abs() < 0.5 && (w[0] - 204.0).abs() < 0.5, "the original on top: {w:?}");
assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined");
}
@@ -619,7 +619,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() {
p => panic!("not a pattern: {p:?}"),
})
.collect();
assert_eq!(xs, vec![0.0, 30.0]);
assert_eq!(xs, vec![30.0, 0.0], "the original on top");
}
#[test]
+1 -1
View File
@@ -247,7 +247,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
),
g(
+3 -2
View File
@@ -594,7 +594,7 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
};
for kind in [image as fn() -> NodeKind, instance] {
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind());
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [0.0, 30.0, 60.0], "{:?}", kind());
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [60.0, 30.0, 0.0], "{:?}", kind());
}
}
@@ -677,7 +677,8 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
let art = evaluate_container(None, &g).unwrap();
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
assert_eq!(copies.len(), 3);
for (i, c) in copies.iter().enumerate() {
// The original is on top, after the copies below it.
for (i, c) in copies.iter().rev().enumerate() {
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
match c.appearance.fill_paint() {
+1 -1
View File
@@ -87,7 +87,7 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_r
pub use solids::{
CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as,
validate_solid, validate_three_d, validate_three_d_params, view_options,
validate_solid, validate_three_d, validate_three_d_at, validate_three_d_params, view_options,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
+183 -29
View File
@@ -46,8 +46,8 @@ pub fn has_gradient_copies(n: &Node) -> bool {
/// Is `n` a path or compound path with a visible Transform effect that makes copies and fills and
/// strokes that paint the copies differently as one path than as separate objects
/// ([`paints_copies_apart`])? Such an object is drawn through its art ([`outline_art`]), each copy
/// an object of its own above the one before ([`copy_leaf`]), as Expand Appearance makes it
/// (#5920).
/// an object of its own below the one before ([`copy_leaf`]), as Expand Appearance makes it
/// (#5920, #6316).
pub(crate) fn has_layered_copies(n: &Node) -> bool {
leaf_geometry(n).is_some_and(|(_, rule)| paints_copies_apart(&n.appearance, rule)) && has_transform_copies(n)
}
@@ -698,6 +698,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
let seed = art.id.0;
let mut used = art_cost(art);
let max = budget.at_least(used);
// Read before the effects run: a path's own copies don't make it art of several objects.
let copy_whole = copies_whole(art);
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
@@ -738,6 +740,14 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
let options = crate::distort::TransformOptions::of(&params);
(m, copies, options, crate::distort::transform_affine(bounds, &params, seed))
});
// Art of several objects is copied whole, so the copies don't interleave member by member.
if let Some((m, copies, options, _)) = transform
&& copies > 0
&& copy_whole
{
used = copy_art(art, m, copies, copy_scaling(options), max);
continue;
}
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
@@ -747,7 +757,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
// Transform Patterns and Scale Strokes & Effects change each copy's fills and
// strokes; a gradient paints each copy by itself, not the one piece the copies
// make together; and Expand Appearance (`apart`) makes each copy an object.
// Fills and strokes that stack paint each copy above the one before (#5920).
// Fills and strokes that stack paint each copy below the one before (#5920, #6316).
let separate = copies > 0
&& (apart
|| leaf.appearance.has_unplaced_gradient()
@@ -813,6 +823,19 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
/// doesn't grow with them (a horizontal line stretched vertically).
const MAX_STROKE_SCALE: f64 = 1.0e6;
/// How a Transform effect with `options` scales what it maps: strokes, effects and patterns as
/// its options say.
fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::Scaling {
vectorcraft_doc::Scaling {
strokes: options.strokes,
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
patterns: options.patterns,
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
keep_fit_when_flat: true,
..Default::default()
}
}
/// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with
/// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object
/// › Transform with those options transforms an object (#885): the strokes' weights and dashes
@@ -829,14 +852,7 @@ fn transform_leaf(
options: crate::distort::TransformOptions,
budget: Budget,
) -> (Budget, bool) {
let scaling = vectorcraft_doc::Scaling {
strokes: options.strokes,
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
patterns: options.patterns,
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
keep_fit_when_flat: true,
..Default::default()
};
let scaling = copy_scaling(options);
if !options.objects {
if options.strokes
&& let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0)
@@ -861,36 +877,172 @@ fn transform_leaf(
(copy_leaf(leaf, path, m, copies, scaling, budget), true)
}
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
/// more, each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies
/// an object. Each copy is an object of its own, so its gradients fit it (a placed gradient moves
/// with it) rather than spanning every copy, and its strokes and patterns can differ from the
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
/// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the
/// copies cost, the original included.
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
let mut made = Budget::of(path);
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
/// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a
/// group or layer of several objects, at least one of which paints, with no type left in it and no
/// fill or stroke of its own that paints. Type that stays type is copied in its place by the baking
/// that keeps it, a clipping group's first member is its clip, and the art's own fills and strokes
/// paint the shape of everything under it, copies included. A member that [`makes_art`] is copied
/// in its place as well: each copy would make that art again, past what [`copy_art`] counts.
fn copies_whole(art: &Node) -> bool {
fn parts(n: &Node, count: &mut usize, painted: &mut bool, text: &mut bool) {
match &n.kind {
NodeKind::Text(_) => *text = true,
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => {
*count += 1;
*painted |= n.appearance.items.iter().any(crate::paints);
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
*count += 1;
*painted = true;
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
*painted |= n.appearance.items.iter().any(crate::paints);
children.iter().for_each(|c| parts(c, count, painted, text));
}
_ => {}
}
}
if art.clips() || !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || art.appearance.items.iter().any(crate::paints) {
return false;
}
let (mut count, mut painted, mut text) = (0, false, false);
parts(art, &mut count, &mut painted, &mut text);
count > 1 && painted && !text && !art.children().into_iter().flatten().any(|c| makes_art(c))
}
/// Does `n` or anything visible under it, the art of its opacity masks included, carry a visible
/// effect that makes art when it is drawn (its own or that of a fill or stroke that paints), or is
/// any of them a compound shape? Raster effects, color adjustments and unknown effects make none.
fn makes_art(n: &Node) -> bool {
let makes = |e: &Effect| {
e.visible
&& (is_geometry(&e.id)
|| crate::is_pathfinder(&e.id)
|| crate::is_three_d(&e.id)
|| e.id == crate::CROP_MARKS
|| e.id == crate::addons::halftone::HALFTONE)
};
n.visible
&& (n.is_compound_shape()
|| n.appearance.effects.iter().any(makes)
|| n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(makes))
|| n.extras().mask.as_deref().is_some_and(|m| makes_art(&m.art))
|| n.children().into_iter().flatten().any(|c| makes_art(c)))
}
/// What the paths under `n` cost together, those of its opacity masks included.
fn copy_cost(n: &Node) -> Budget {
let mut cost = Budget { anchors: 0, lines: 0 };
vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| {
if let NodeKind::Path { guide: false, path, .. } = &c.kind {
cost = cost.plus(Budget::of(path));
}
});
cost
}
/// What the geometry effects of `leaf` (with geometry `path`) and of its fills and strokes add to
/// what `path` costs when it is drawn.
fn effects_cost(leaf: &Node, path: &PathData) -> Budget {
let none = Budget { anchors: 0, lines: 0 };
let items: Vec<&AppearanceItem> = leaf.appearance.items.iter().filter(|i| crate::paints(i) && crate::has_geometry(i.effects())).collect();
if items.is_empty() && !crate::has_geometry(&leaf.appearance.effects) {
return none;
}
let Some(bounds) = path.bounds() else { return none };
let ctx = GeomContext { seed: leaf.id.0, ..GeomContext::of(leaf) };
let own = crate::apply_geometry_with(&leaf.appearance.effects, path, bounds, &ctx);
let item_cost = |i: &&AppearanceItem| {
let b = own.bounds()?;
Some(Budget::of(&crate::apply_geometry_with(i.effects(), &own, b, &ctx.item(i))))
};
// The object's geometry is drawn as it is only by what has no geometry effect of its own.
let plain = items.is_empty() || leaf.appearance.items.iter().any(|i| crate::paints(i) && !crate::has_geometry(i.effects()));
let base = if plain { Budget::of(&own) } else { none };
base.plus(items.iter().filter_map(item_cost).fold(none, Budget::plus)).minus(Budget::of(path))
}
/// Remove the guides anywhere under `n`.
fn without_guides(n: &mut Node) {
let Some(children) = n.children_mut() else { return };
children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. }));
children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c)));
}
/// Replace the members of `art` by groups of them: the original on top and `copies` more below it,
/// each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies a
/// group, without its guides. The copies stop where [`copy_leaf`]'s would, before they pass `budget` or
/// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what
/// the art costs after.
fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
let Some(members) = art.children().cloned() else { return art_cost(art) };
let original = Node::group(art.id, members);
let mut made = copy_cost(&original);
let mut drawn = original.clone();
without_guides(&mut drawn);
let mut nodes = original.count_with_masks();
let one = drawn.count_with_masks();
let mut placements: Vec<Arc<Node>> = vec![];
let mut xf = Affine::IDENTITY;
for _ in 0..copies {
xf *= m;
// A copy scaled up is drawn with more lines.
let cost = Budget::of(&path.transformed(xf));
if !budget.fits(made.plus(cost)) {
break;
}
if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) {
break;
}
let mut copy = leaf.clone();
nodes = nodes.saturating_add(one);
if nodes > crate::distort::MAX_COPY_OBJECTS {
break;
}
let mut copy = drawn.clone();
copy.transform(xf, scaling);
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
let cost = copy_cost(&copy);
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
made = made.plus(cost);
}
// The copies become nodes of their own under a group with the object's id and name.
// The last copy is the lowest.
placements.reverse();
placements.push(Arc::new(original));
if let Some(children) = art.children_mut() {
*children = placements;
}
made
}
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
/// more below it, each mapped once more by `m` than the last (with `scaling`), as a Transform effect
/// copies an object. Each copy is an object of its own, so its gradients fit it (a placed gradient
/// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
/// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's
/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original
/// included.
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
let mut made = Budget::of(path).plus(effects_cost(leaf, path));
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
let mut xf = Affine::IDENTITY;
for _ in 0..copies {
xf *= m;
// A copy scaled up is drawn with more lines.
let moved = path.transformed(xf);
if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) {
break;
}
let mut copy = leaf.clone();
copy.transform(xf, scaling);
// Each copy's own geometry effects make a different amount, as the copy and its effects scale.
let cost = Budget::of(&moved).plus(effects_cost(&copy, &moved));
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
made = made.plus(cost);
}
// The copies become nodes of their own under a group with the object's id and name, the object
// on top.
placements.reverse();
let mut g = Node::group(leaf.id, placements);
g.name = leaf.name.clone();
*leaf = g;
@@ -1463,6 +1615,8 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
placements.push(Arc::new(copy));
}
*room -= placements.len() - 1;
// The object stays on top of its copies, as a path does.
placements.reverse();
let mut g = Node::group(src.id, placements);
g.name = src.name.clone();
*c = Arc::new(g);
+453 -45
View File
@@ -3,10 +3,11 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{PathData, Rect};
use vectorcraft_three_d::{Edge, Revolve};
use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, solid_paints, union_bounds};
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
@@ -60,6 +61,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
shade: flag(p, "shade", true),
smooth_shading: true,
cap_ends: flag(p, "cap", true),
curve_samples: None,
}
}
@@ -67,12 +69,94 @@ pub fn revolve_options(p: &Value) -> Revolve {
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
validate_revolve_params(p)?;
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
return Err("Revolve requires at least two anchors".into());
let effects = &n.appearance.effects;
// The profile is measured before the object's one Revolve, hidden or shown.
let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects);
if is_container(n) {
const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors";
let mut shapes = members(n, "Revolve")?;
if !shapes.iter().any(|s| has_two_anchors(&s.path)) {
return Err(EMPTY.into());
}
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or(EMPTY)?;
let options = shared_options(&shapes, revolve_options(p))?;
let mut copies = 0;
return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies));
}
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
return Err("Revolve requires a shape with at least two anchors".into());
}
let path = geometry(&path, before, &GeomContext::of(n));
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let paints = solid_paints(n);
check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)
}
/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of
/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve,
/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level
/// Revolve draws in place of those surfaces.
pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> {
if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() {
return validate_revolve(n, p);
}
validate_revolve_params(p)?;
const EMPTY: &str = "Revolve requires a shape with at least two anchors";
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
return Err(EMPTY.into());
}
let ctx = GeomContext::of(n);
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut copies = 0;
for (i, item) in n.appearance.items.iter().enumerate() {
let effects = item.effects();
let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) };
let options = match at.and_then(|at| effects.get(at)) {
_ if i == target => revolve_options(p),
Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)),
_ => continue,
};
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
let b = path.bounds().ok_or(EMPTY)?;
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
}
Ok(())
}
fn has_two_anchors(path: &PathData) -> bool {
path.subpaths.iter().any(|sp| sp.anchors.len() >= 2)
}
/// The bytes the copies of `paint` take on `faces` faces: a face's fill, its shadow and their seam
/// strokes make up to four copies.
fn paint_copies(faces: usize, paint: &Paint) -> usize {
faces.saturating_mul(4).saturating_mul(paint_bytes(paint))
}
/// An error when `path` does not revolve or when its surface in `item`'s paint takes `copies`, the
/// bytes of an object's paint copies so far, past [`MAX_PAINT_COPIES`].
fn check_surface(path: &PathData, b: Rect, options: Revolve, item: Option<&AppearanceItem>, copies: &mut usize) -> Result<(), String> {
let faces = vectorcraft_three_d::revolve(path, b, options)?;
*copies = copies.saturating_add(item.map_or(0, |i| paint_copies(faces.len(), &paint_of(i).0)));
if *copies > MAX_PAINT_COPIES {
return Err("Revolve paint is too complex for this surface; reduce Segments or simplify the gradient".into());
}
Ok(())
}
/// `options` with one number of samples for every curve of `shapes`, the members of one group: the
/// members share Revolve's limits.
fn shared_options(shapes: &[Profile], options: Revolve) -> Result<Revolve, String> {
let samples = vectorcraft_three_d::revolve_curve_samples(shapes.iter().map(|s| &s.path), options)?;
Ok(Revolve { curve_samples: Some(samples), ..options })
}
/// The item whose paint an object's surface takes: its top fill, else its top stroke.
fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> {
items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())
}
/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`.
@@ -87,38 +171,31 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
Ok(())
}
fn profile(n: &Node) -> Option<PathData> {
match &n.kind {
NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()),
NodeKind::Compound { children, .. } => {
Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()))
}
_ => None,
}
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// the bytes of the paint copies of every surface of the object.
#[allow(clippy::too_many_arguments)]
fn surface(
n: &Node,
path: &PathData,
b: Rect,
options: Revolve,
fx: &Effect,
paint: Paint,
opacity: f32,
blend: vectorcraft_doc::color::BlendMode,
expand: bool,
) -> Option<Node> {
let b = path.bounds()?;
copies: &mut usize,
) -> Option<(Vec<f64>, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
// A face's fill, its shadow and their seam strokes: up to four copies of the paint.
if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES {
*copies = copies.saturating_add(paint_copies(faces.len(), &paint));
if *copies > MAX_PAINT_COPIES {
return None;
}
// Visibility is an expansion option, never a change to live geometry or export baking.
@@ -146,6 +223,7 @@ fn surface(
})
.collect()
});
let depths: Vec<f64> = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect();
let children = faces
.into_iter()
.map(|face| {
@@ -202,7 +280,16 @@ fn surface(
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
Some(group)
Some((depths, group))
}
/// A surface of one object about the bounds of `path`, built with options derived from `fx`.
/// `copies` is the count [`surface`] adds to.
fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option<Node> {
let b = path.bounds()?;
let (paint, opacity, blend) = paint_of(item);
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
}
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
@@ -237,26 +324,23 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
/// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes
/// `total` past `limit` stops with an error before the next is built.
pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
let path = profile(n).ok_or("Revolve currently requires a path or compound path")?;
if is_container(n) {
return group_meshes(n, total, limit);
}
let (path, rule) = object_profile(n, "Revolve")?;
let ctx = GeomContext::of(n);
let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> {
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let (paint, opacity, _) = paint_of(item);
let mesh = vectorcraft_three_d::revolve_mesh(path, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) })
};
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
let before = n.appearance.effects.get(..index).unwrap_or_default();
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let path = geometry(&path, before, &ctx);
let item = n
.appearance
.items
.iter()
.rev()
.find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i))
.or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))
.ok_or("the revolved object has no fill or stroke")?;
let paints = solid_paints(n);
let item = surface_item(&paints).ok_or("the revolved object has no fill or stroke")?;
let mut out = vec![];
crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?;
return Ok(out);
@@ -274,28 +358,49 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
Ok(out)
}
/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis.
fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
let index = n
.appearance
.effects
.iter()
.position(|e| e.visible && e.id == REVOLVE)
.ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?;
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let mut shapes = members(n, "Revolve")?;
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let mut out = vec![];
for s in &shapes {
let Some(item) = surface_item(&s.items) else { continue };
let (paint, opacity, _) = paint_of(item);
let mesh = vectorcraft_three_d::revolve_mesh(&s.path, s.rule, b, options)?;
crate::solids::keep_mesh(&mut out, total, crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity * s.opacity) }, limit)?;
}
Ok(out)
}
fn art(n: &Node, expand: bool) -> Option<Node> {
if !has_revolve(n) {
return None;
}
let path = profile(n)?;
if is_container(n) {
return group_art(n, expand);
}
let (path, _) = profile(n)?;
let ctx = GeomContext::of(n);
let mut children = vec![];
let mut copies = 0;
let mut remaining = n.appearance.effects.clone();
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
let fx = n.appearance.effects.get(index)?;
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
// An object's surface uses its top visible fill, falling back to its stroke.
let item = n
.appearance
.items
.iter()
.rev()
.find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i))
.or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))?;
let (paint, opacity, blend) = paint_of(item);
let paints = solid_paints(n);
let item = surface_item(&paints)?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?));
children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?));
// Later geometry acts on the projected art as a whole through container evaluation.
remaining = n
.appearance
@@ -312,9 +417,8 @@ fn art(n: &Node, expand: bool) -> Option<Node> {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
let fx = item.effects().get(index)?;
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
let (paint, opacity, blend) = paint_of(item);
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?;
let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?;
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
children.push(Arc::new(art));
} else {
@@ -328,6 +432,86 @@ fn art(n: &Node, expand: bool) -> Option<Node> {
Some(art)
}
/// The members' `surfaces` in order, from the [`Scope`] at `level` of each one's `Profile::scopes`
/// inward: the surfaces of consecutive members that share a scope go in one group with its opacity
/// and blending mode, so each applies once, to the members it holds together.
fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Node)>, level: usize) -> Vec<Arc<Node>> {
fn close(id: NodeId, run: &mut Vec<(&[Scope], Node)>, scope: Option<Scope>, level: usize, out: &mut Vec<Arc<Node>>) {
if let Some(scope) = scope {
let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1));
group.opacity = scope.opacity;
group.blend = scope.blend;
out.push(Arc::new(group));
}
}
let mut out = vec![];
let mut run = vec![];
let mut scope = None;
for (scopes, s) in surfaces {
let at = scopes.get(level).copied();
if at.map(|a| a.key) != scope.map(|s: Scope| s.key) {
close(id, &mut run, scope, level, &mut out);
scope = at;
}
if scope.is_some() {
run.push((scopes, s));
} else {
out.push(Arc::new(s));
}
}
close(id, &mut run, scope, level, &mut out);
out
}
/// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them,
/// each in its own paint. Opaque members' faces are drawn in one back-to-front order. `None` when
/// the effect is only on the group's own fills or strokes or when its members can't be revolved.
fn group_art(n: &Node, expand: bool) -> Option<Node> {
let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?;
let fx = n.appearance.effects.get(index)?;
let mut shapes = members(n, "").ok()?;
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
let b = union_bounds(&shapes)?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut copies = 0;
let mut surfaces = vec![];
let mut opaque = true;
for s in &shapes {
let Some(item) = surface_item(&s.items) else { continue };
// The opacity and blending of the member and the groups it is in apply through its scopes.
let (paint, opacity, blend) = paint_of(item);
opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty();
// A member with transparency of its own keeps every face, as a transparent object does.
let expand = expand && s.scopes.is_empty();
let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?;
surfaces.push((depths, art, s.scopes.as_slice()));
}
if surfaces.is_empty() {
return None;
}
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
let children = if opaque && plain {
let mut faces = vec![];
for (depths, s, _) in surfaces {
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children));
}
}
// A stable sort: faces at the same depth keep the members' order.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))]
} else {
scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, s)).collect(), 0)
};
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -495,6 +679,230 @@ mod tests {
assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]);
}
/// A closed rectangle `x` to `x + 10` across, filled solid.
fn member(id: u64, x: f64) -> Arc<Node> {
let mut b = BezPath::new();
b.move_to((x, 0.0));
b.line_to((x + 10.0, 0.0));
b.line_to((x + 10.0, 40.0));
b.line_to((x, 40.0));
b.close_path();
Arc::new(Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0)))
}
fn group_with(params: Value, children: Vec<Arc<Node>>) -> Node {
let mut g = Node::group(NodeId(9), children);
g.appearance.effects.push(crate::new_effect(REVOLVE, &params).unwrap());
g
}
#[test]
fn a_groups_members_share_the_axis_on_the_edge_of_them_all() {
let mut hidden = (*member(3, -500.0)).clone();
hidden.visible = false;
let nested = Arc::new(Node::group(NodeId(5), vec![member(4, 90.0)]));
let children = vec![member(1, 0.0), nested, Arc::new(hidden)];
// Left edge, 5 pt out: the axis at -5, the far wall (100) 105 from it.
let g = group_with(json!({"segments":32,"offset":5,"rotationX":0}), children.clone());
let b = revolve_art(&g).unwrap().visual_bounds().unwrap();
assert!((b.x0 + 110.0).abs() < 1.5 && (b.x1 - 100.0).abs() < 1.5, "{b:?}");
// Right edge, 5 pt out: the axis at 105, the far wall (0) 105 from it.
let g = group_with(json!({"segments":32,"offset":5,"edge":"right","rotationX":0}), children);
let b = revolve_art(&g).unwrap().visual_bounds().unwrap();
assert!((b.x1 - 210.0).abs() < 1.5 && (b.x0 - 0.0).abs() < 1.5, "{b:?}");
}
#[test]
fn opaque_members_share_one_back_to_front_order_and_others_keep_their_own_surfaces() {
let g = group_with(json!({"segments":16}), vec![member(1, 0.0), member(2, 50.0)]);
let art = revolve_art(&g).unwrap();
assert_eq!(art.children().unwrap().len(), 1, "one order for every face");
assert!(art.children().unwrap()[0].children().unwrap().len() > 16);
let mut faded = (*member(2, 50.0)).clone();
faded.opacity = 0.5;
let g = group_with(json!({"segments":16}), vec![member(1, 0.0), Arc::new(faded)]);
let art = revolve_art(&g).unwrap();
let surfaces = art.children().unwrap();
assert_eq!(surfaces.len(), 2, "a transparent member stays a surface of its own");
assert_eq!(surfaces[1].opacity, 0.5);
}
fn star(id: u64) -> Arc<Node> {
let anchors = (0..1200)
.map(|i| {
let (s, c) = (i as f64 * std::f64::consts::PI / 600.0).sin_cos();
let r = if i % 2 == 0 { 100.0 } else { 60.0 };
vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(200.0 + r * c, 200.0 + r * s))
})
.collect();
Arc::new(Node::path(
NodeId(id),
PathData::new(vec![vectorcraft_geom::SubPath::new(anchors, true)]),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
))
}
#[test]
fn a_groups_members_share_revolves_limits() {
let alone = group_with(json!({}), vec![star(1)]);
assert!(validate_revolve(&alone, &json!({})).is_ok());
let both = group_with(json!({}), vec![star(1), star(2)]);
let refused = validate_revolve(&both, &json!({})).unwrap_err();
assert!(refused.contains("reduce Segments"), "{refused}");
assert_eq!(revolve_art(&both), None, "past the limits the source draws");
}
#[test]
fn blending_modes_apply_once_to_the_member_or_group_that_has_them() {
use vectorcraft_doc::color::BlendMode;
let blend = |n: Arc<Node>, mode: BlendMode| {
let mut n = (*n).clone();
n.blend = mode;
Arc::new(n)
};
let inner = blend(member(4, 60.0), BlendMode::Multiply);
let screened = blend(Arc::new(Node::group(NodeId(5), vec![member(3, 40.0), inner])), BlendMode::Screen);
let group = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply), member(2, 20.0), screened]);
let art = revolve_art(&group).expect("revolved");
let modes = |n: &Node| match &n.kind {
NodeKind::Group { children, .. } => children.iter().map(|c| c.blend).collect::<Vec<_>>(),
_ => panic!("a group"),
};
assert_eq!(modes(&art), [BlendMode::Multiply, BlendMode::Normal, BlendMode::Screen], "a surface or group for each member");
let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") };
let screened = &children[2];
assert_eq!(modes(screened), [BlendMode::Normal, BlendMode::Multiply], "the screened group's members, together, in their own modes");
let NodeKind::Group { children: inner, .. } = &screened.kind else { panic!("a group") };
assert_eq!(modes(&inner[1]).len(), 1, "the multiplied member's surface");
let plain = group_with(json!({}), vec![member(1, 0.0)]);
assert_ne!(expand_revolve_art(&plain), revolve_art(&plain), "an opaque member's hidden faces are removed");
let multiplied = group_with(json!({}), vec![blend(member(1, 0.0), BlendMode::Multiply)]);
assert_eq!(expand_revolve_art(&multiplied), revolve_art(&multiplied), "a blended member keeps every face");
// A nested group's opacity applies once, to its members' surfaces together.
let mut faded = Node::group(NodeId(6), vec![member(3, 40.0), member(4, 45.0)]);
faded.opacity = 0.5;
let art = revolve_art(&group_with(json!({}), vec![member(1, 0.0), Arc::new(faded)])).expect("revolved");
let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") };
let opacities = |n: &Node| n.children().unwrap().iter().map(|c| c.opacity).collect::<Vec<_>>();
assert_eq!(children.iter().map(|c| c.opacity).collect::<Vec<_>>(), [1.0, 0.5]);
assert_eq!(opacities(&children[1]), [1.0, 1.0], "the faded group's members, together");
}
#[test]
fn a_3d_effect_on_a_groups_fill_is_refused() {
let mut group = Node::group(NodeId(9), vec![member(1, 0.0)]);
group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
for id in [REVOLVE, crate::EXTRUDE] {
assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself");
let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err();
assert!(refused.contains("on the group itself"), "{id}: {refused}");
}
}
#[test]
fn validation_measures_the_profile_after_the_geometry_effects_before_revolve() {
let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap();
let mut group = group_with(json!({}), vec![star(1)]);
assert!(validate_revolve(&group, &json!({})).is_ok());
group.appearance.effects.insert(0, roughen.clone());
assert!(validate_revolve(&group, &json!({})).is_err(), "the group's reshaped member");
assert_eq!(revolve_art(&group), None);
let mut object = (*star(1)).clone();
assert!(validate_revolve(&object, &json!({})).is_ok());
object.appearance.effects.insert(0, roughen);
assert!(validate_revolve(&object, &json!({})).is_err(), "a reshaped object not yet revolved");
}
#[test]
fn validation_of_a_hidden_revolve_leaves_out_the_geometry_effects_after_it() {
let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap();
let mut hidden = crate::new_effect(REVOLVE, &json!({})).unwrap();
hidden.visible = false;
let mut object = (*star(1)).clone();
object.appearance.effects = vec![hidden.clone(), roughen.clone()];
assert_eq!(validate_revolve(&object, &json!({})), Ok(()), "an object whose Revolve is shown again");
let mut group = group_with(json!({}), vec![star(1)]);
group.appearance.effects = vec![hidden, roughen];
assert_eq!(validate_revolve(&group, &json!({})), Ok(()), "a group whose Revolve is shown again");
for n in [&mut object, &mut group] {
n.appearance.effects[0].visible = true;
assert!(revolve_art(n).is_some(), "shown, the Revolve draws");
}
}
#[test]
fn paint_copies_past_the_budget_are_refused_for_the_whole_group() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let painted = |id: u64, x: f64, stops: usize| {
let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect();
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut node = (*member(id, x)).clone();
node.appearance = Appearance::basic(paint, Paint::None, 0.0).into();
Arc::new(node)
};
let plain = member(1, 0.0);
let NodeKind::Path { path: profile, .. } = &plain.kind else { panic!("a path") };
let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len();
let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::<GradientStop>();
let one = group_with(json!({}), vec![painted(1, 0.0, stops)]);
assert_eq!(validate_revolve(&one, &json!({})), Ok(()));
let two = group_with(json!({}), vec![painted(1, 0.0, stops), painted(2, 20.0, stops)]);
let refused = validate_revolve(&two, &json!({})).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
assert_eq!(revolve_art(&two), None, "past the budget the source draws");
let object = painted(3, 0.0, stops * 2);
assert!(validate_revolve(&object, &json!({})).is_err(), "one object past the budget");
}
#[test]
fn the_fills_of_one_object_share_the_budget_of_paint_copies() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let source = profile_node();
let NodeKind::Path { path: profile, .. } = &source.kind else { panic!("a path") };
let faces = vectorcraft_three_d::revolve(profile, profile.bounds().unwrap(), revolve_options(&json!({}))).unwrap().len();
let stops = MAX_PAINT_COPIES * 6 / 10 / (faces * 4) / std::mem::size_of::<GradientStop>();
let fill = |stops: usize, revolved: bool| {
let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect();
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut item = AppearanceItem::Fill(FillLayer::new(paint));
if revolved {
item.effects_mut().push(fx());
}
item
};
let with = |items: Vec<AppearanceItem>| {
let mut n = source.clone();
n.appearance = Appearance { items, ..Default::default() }.into();
n
};
let one = with(vec![fill(stops, true)]);
assert!(revolve_art(&one).is_some());
assert_eq!(validate_item_revolve(&one, &json!({}), 0, 0), Ok(()));
let two = with(vec![fill(stops, true), fill(stops, true)]);
assert_eq!(revolve_art(&two), None, "past the budget the source draws");
let refused = validate_item_revolve(&two, &json!({}), 1, 0).unwrap_err();
assert!(refused.contains("simplify the gradient"), "{refused}");
let second = with(vec![fill(stops, true), fill(stops, false)]);
assert!(validate_item_revolve(&second, &json!({}), 1, 0).is_err(), "the item the effect goes on counts");
assert_eq!(validate_item_revolve(&second, &json!({}), 0, 0), Ok(()), "an item without a Revolve does not");
let below = with(vec![fill(stops * 2, false), fill(1, false)]);
assert!(validate_item_revolve(&below, &json!({}), 0, 0).is_err(), "the item's own paint, not the top fill");
assert_eq!(validate_item_revolve(&below, &json!({}), 1, 0), Ok(()));
}
#[test]
fn an_items_revolve_is_validated_after_only_the_geometry_effects_before_it() {
let roughen = crate::new_effect("distort.roughen", &json!({"size": 1, "detail": 100})).unwrap();
let mut object = (*star(1)).clone();
let mut hidden = fx();
hidden.visible = false;
let item = object.appearance.items.first_mut().unwrap();
item.effects_mut().push(hidden);
item.effects_mut().push(roughen);
assert_eq!(validate_item_revolve(&object, &json!({}), 0, 0), Ok(()), "the roughen after it is not swept");
assert!(validate_item_revolve(&object, &json!({}), 0, 2).is_err(), "a Revolve added after the roughen");
}
#[test]
fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() {
let mut n = profile_node();
+73 -26
View File
@@ -138,7 +138,7 @@ pub fn inflate_options(p: &Value) -> Inflate {
}
/// The shape a solid is made from: a path's or a compound path's outline.
fn profile(n: &Node) -> Option<(PathData, FillRule)> {
pub(crate) fn profile(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
@@ -156,7 +156,7 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
/// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the
/// fill and stroke of its first painted run (its characters' colours).
fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
pub(crate) fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
let own: Vec<AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) {
return own;
@@ -207,29 +207,52 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()));
}
let (path, rule) = profile(n).ok_or_else(|| {
format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.")
})?;
let (path, rule) = object_profile(n, label)?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
facets(id, &path, rule, b, p).map(|_| ())
}
/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from
/// (empty, or only spaces) is a shape without anchors, and any other object is the wrong kind.
pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> {
profile(n).ok_or_else(|| {
if matches!(n.kind, NodeKind::Text(_)) {
format!("{label} requires a shape with at least two anchors")
} else {
format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.")
}
})
}
/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending
/// mode is not Normal. The members of one group share its `key`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Scope {
pub(crate) key: usize,
pub(crate) opacity: f32,
pub(crate) blend: BlendMode,
}
/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint
/// it, and the opacity of the objects it is in (1 for the object with the effect itself).
struct Profile {
path: PathData,
rule: FillRule,
items: Vec<AppearanceItem>,
opacity: f32,
/// it, and the opacity of the objects it is in multiplied together (1 for the object with the
/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from
/// the outermost group down to the member itself (none for the object itself). Group Revolve
/// applies each once, to the surfaces of the members it holds together.
pub(crate) struct Profile {
pub(crate) path: PathData,
pub(crate) rule: FillRule,
pub(crate) items: Vec<AppearanceItem>,
pub(crate) opacity: f32,
pub(crate) scopes: Vec<Scope>,
}
/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)?
fn is_container(n: &Node) -> bool {
pub(crate) fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
}
/// The bounds of every shape together: the centre a group's members turn about.
fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
pub(crate) fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c))
}
@@ -245,15 +268,26 @@ const MAX_MESH_TRIANGLES: usize = 2_000_000;
/// effects and painted with its own fills and strokes. Like the effect on a group in the
/// reference app, they turn about one shared axis (the centre of their bounds together) rather
/// than each about its own. An error names what the group holds that can't become a solid.
fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec<Profile>) -> Result<(), String> {
pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
struct Walk<'a> {
label: &'a str,
out: Vec<Profile>,
keys: usize,
}
fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> {
let label = w.label;
for c in n.children().into_iter().flatten() {
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
continue;
}
let opacity = opacity * c.opacity;
let mut scopes = scopes.to_vec();
if c.blend != BlendMode::Normal || c.opacity < 1.0 {
w.keys += 1;
scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend });
}
if is_container(c) {
walk(c, opacity, label, out)?;
walk(c, opacity, &scopes, w)?;
continue;
}
let Some((path, rule)) = profile(c) else {
@@ -266,19 +300,19 @@ fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
};
return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first."));
};
if out.len() >= MAX_MEMBERS {
if w.out.len() >= MAX_MEMBERS {
return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects"));
}
let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c));
if path.bounds().is_some() {
out.push(Profile { path, rule, items: solid_paints(c), opacity });
w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes });
}
}
Ok(())
}
let mut out = vec![];
walk(n, 1.0, label, &mut out)?;
Ok(out)
let mut w = Walk { label, out: vec![], keys: 0 };
walk(n, 1.0, &[], &mut w)?;
Ok(w.out)
}
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
@@ -288,6 +322,19 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
}
/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of
/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself
/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the
/// item's geometry effects before it, together with the object's other items that carry a Revolve.
/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws.
pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> {
match at {
Some(_) if is_container(n) => Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()),
Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index),
_ => validate_three_d(n, id, p),
}
}
/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not
/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
/// not need to fit.
@@ -385,7 +432,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
/// its 3D effect, about the bounds of all of them.
fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
if before.iter().any(|e| e.visible && is_geometry(&e.id))
&& let Some(b) = union_bounds(shapes)
{
@@ -501,7 +548,7 @@ pub fn solid_art(n: &Node) -> Option<Node> {
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?);
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?);
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
@@ -517,7 +564,7 @@ pub fn solid_art(n: &Node) -> Option<Node> {
for item in &paints {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? {
for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? {
Arc::make_mut(&mut art).appearance.effects =
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
children.push(art);
@@ -680,14 +727,14 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidM
let paints = solid_paints(n);
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)?
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)?
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in &paints {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?);
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?);
}
}
out
+1 -1
View File
@@ -51,7 +51,7 @@ fn the_meshes_of_one_object_share_one_triangle_budget() {
assert!(keep_mesh(&mut out, &mut total, meshes[1].clone(), first).is_err());
assert_eq!(out.len(), 1);
// Building past the budget stops with an error instead of holding every mesh.
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }];
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0, scopes: vec![] }];
let Some(effect) = n.appearance.effects.first() else { panic!("no effect") };
let mut total = 0;
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok());
+22
View File
@@ -56,6 +56,28 @@ fn check(bp: &BezPath, st: &StrokeLayer, what: &str) {
assert!(checked > 0, "{what}: no samples");
}
#[test]
fn a_butt_capped_open_path_is_measured_where_it_paints() {
let mut vertical = BezPath::new();
vertical.move_to((50.0, 10.0));
vertical.line_to((50.0, 90.0));
let mut diagonal = BezPath::new();
diagonal.move_to((10.0, 10.0));
diagonal.line_to((40.0, 50.0));
let mut arc = BezPath::new();
arc.move_to((10.0, 50.0));
arc.curve_to((10.0, 22.4), (32.4, 0.0), (60.0, 0.0));
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
stroke.cap = LineCap::Butt;
for (name, bp) in [("vertical", vertical), ("diagonal", diagonal), ("arc", arc)] {
let painted = outline_region(&PathData::from_bezpath(&bp), FillRule::NonZero, &stroke).to_bezpath().bounding_box();
let bounds = stroke.bounds_on(&bp, bp.bounding_box());
for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] {
assert!((got - want).abs() < 0.05, "{name}: measured {bounds:?}, paints {painted:?}");
}
}
}
#[test]
fn bounds_and_clicks_cover_what_every_stroke_paints() {
let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })];
+261 -12
View File
@@ -105,7 +105,7 @@ fn each_copy_scales_its_stroke_once_more() {
let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
let art = reshape(None, &n, None).unwrap();
let widths: Vec<f64> = leaves(&art).iter().map(|p| stroke_of(p).0).collect();
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top");
// Without the option the copies keep the original's weight.
let plain = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2}));
assert!(!has_transform_art(&plain));
@@ -127,7 +127,7 @@ fn transform_patterns_moves_each_copys_pattern() {
assert!(has_transform_options(&n));
let art = reshape(None, &n, None).unwrap();
let xs: Vec<f64> = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top");
// Without it, every copy's pattern stays put (the tiles don't follow the copies).
let n = patterned(json!({"moveH": 30, "patterns": false, "strokes": false, "objects": true}));
assert!(!has_transform_art(&n));
@@ -218,7 +218,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() {
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
let art = reshape(None, &n, None).unwrap();
let widths: Vec<f64> = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect();
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top");
}
#[test]
@@ -268,7 +268,7 @@ fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() {
assert!(has_transform_options(&n));
let art = reshape(None, &n, None).unwrap();
let xs: Vec<f64> = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top");
// Off, the pattern stays put.
assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2}))));
}
@@ -294,7 +294,7 @@ fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() {
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
let drawn = reshape(None, &n, None).unwrap();
let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::<Vec<f64>>();
assert_eq!(widths(&drawn), vec![2.0, 4.0]);
assert_eq!(widths(&drawn), vec![4.0, 2.0], "the original on top");
// Expand Appearance.
assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn));
// Export.
@@ -394,9 +394,9 @@ fn filled_and_stroked(fill: Paint, params: Value) -> Node {
}
#[test]
fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
// Black fill, white stroke, each copy scaled and turned (#5920): each copy is an object of its
// own above the one before, so its fill covers the strokes of the copies below.
fn copies_with_a_fill_and_a_stroke_are_drawn_one_below_another() {
// Black fill, white stroke, each copy scaled and turned (#5920, #6316): each copy is an object
// of its own below the one before, so the fills above cover the strokes of the copies below.
let params = json!({"scaleH": 97, "scaleV": 97, "rotate": 10, "copies": 25});
let n = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
assert!(has_transform_art(&n));
@@ -407,10 +407,10 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
assert!(matches!(p.kind, NodeKind::Path { .. }), "one shape each, not one path of every copy");
assert!(!p.appearance.fill_paint().is_none() && p.appearance.stroke().is_some());
}
// The original is at the bottom, the smallest copy on top.
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
let top = paths[25].geometric_bounds().unwrap();
assert!(top.width() < 100.0 && top.height() < 100.0, "{top:?}");
// The smallest copy is at the bottom, the original on top.
assert_eq!(paths[25].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
let bottom = paths[0].geometric_bounds().unwrap();
assert!(bottom.width() < 100.0 && bottom.height() < 100.0, "{bottom:?}");
// Drawn as Expand Appearance makes it.
assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26);
@@ -427,3 +427,252 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
// No copies: nothing to stack.
assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10}))));
}
/// A group of two squares side by side, each with a black fill and a white stroke, with the
/// Transform effect `params`.
fn two_squares(params: Value) -> Node {
let square = |id, x| {
let a = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0);
std::sync::Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), a))
};
let mut g = Node::group(NodeId(20), vec![square(21, 0.0), square(22, 120.0)]);
g.appearance.effects.push(transform(params));
g
}
#[test]
fn a_groups_copies_are_whole_groups_below_the_original() {
let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 95, "scaleV": 95, "copies": 10}))).unwrap();
let copies = art.children().unwrap();
assert_eq!(copies.len(), 11, "the original and 10 copies");
for c in copies {
assert_eq!(c.children().map(Vec::len), Some(2), "each copy holds both squares");
}
// The smallest copy is at the bottom, the original on top with its members in their order.
let width = |i: usize| copies[i].geometric_bounds().unwrap().width();
assert!((0..10).all(|i| width(i) < width(i + 1)), "{:?}", (0..11).map(width).collect::<Vec<_>>());
assert_eq!(copies[10].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 220.0, 100.0));
let members = copies[10].children().unwrap();
assert_eq!(members[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
assert_eq!(members[1].geometric_bounds().unwrap(), Rect::new(120.0, 0.0, 220.0, 100.0));
// Without copies the members stay members of the art.
let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 50, "scaleV": 50}))).unwrap();
assert_eq!(art.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [NodeId(21), NodeId(22)]);
// A group's own fill paints the shape of all its copies, so each member keeps its place.
let mut own_fill = two_squares(json!({}));
own_fill.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
let copies = transform(json!({"moveH": 300, "copies": 2}));
crate::reshape::reshape_leaves_within(&mut own_fill, &[&copies], crate::geometry::Budget::EFFECTS, None);
assert_eq!(own_fill.children().map(Vec::len), Some(2), "a stack of copies per member");
// A guide in the group stays one guide.
let mut guided = two_squares(json!({}));
let mut guide = Node::path(NodeId(23), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default());
if let NodeKind::Path { guide: on, .. } = &mut guide.kind {
*on = true;
}
if let Some(members) = guided.children_mut() {
members.push(std::sync::Arc::new(guide));
}
guided.appearance.effects.clear();
crate::reshape::reshape_leaves_within(&mut guided, &[&copies], crate::geometry::Budget::EFFECTS, None);
assert_eq!(guided.children().map(Vec::len), Some(3), "the original and 2 copies");
let mut guides = 0;
guided.walk(&mut |n| guides += usize::from(matches!(n.kind, NodeKind::Path { guide: true, .. })));
assert_eq!(guides, 1);
// A clipping group's first member stays its clip.
let mut clipping = two_squares(json!({}));
if let NodeKind::Group { clip, .. } = &mut clipping.kind {
*clip = true;
}
clipping.appearance.effects.clear();
crate::reshape::reshape_leaves_within(&mut clipping, &[&copies], crate::geometry::Budget::EFFECTS, None);
assert_eq!(clipping.children().map(Vec::len), Some(2), "the clip and the clipped member");
}
#[test]
fn a_groups_copies_stop_at_the_node_limit() {
// Empty groups cost no anchors, so only the count of nodes stops their copies.
let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![])));
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
members.extend(empties);
}
g.appearance.effects.clear();
let copies = transform(json!({"moveH": 1, "copies": 1000}));
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
let mut nodes = 0usize;
g.walk(&mut |_| nodes += 1);
assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}");
assert!(g.children().unwrap().len() < 1001, "the limit stopped the copies");
}
#[test]
fn a_groups_copies_count_the_art_of_opacity_masks() {
let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![]))).collect();
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
let mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(99), empties), true);
std::sync::Arc::make_mut(&mut members[0]).extras_mut().mask = Some(Box::new(mask));
}
g.appearance.effects.clear();
let copies = transform(json!({"moveH": 1, "copies": 1000}));
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
let nodes = g.count_with_masks();
assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}");
}
/// The anchors `n` draws with every effect inside it evaluated, group by group and member by
/// member as drawing and baking evaluate them. The count stops once it passes `limit`.
fn drawn_anchors(n: &Node, limit: usize) -> usize {
fn count(n: &Node, limit: usize, total: &mut usize) {
if *total > limit {
return;
}
if n.children().is_some() && !matches!(n.kind, NodeKind::Compound { .. }) {
let art = crate::evaluate_container(None, n).unwrap_or_else(|| n.clone());
art.children().into_iter().flatten().for_each(|c| count(c, limit, total));
return;
}
if let Some(art) = reshape(None, n, None) {
count(&art, limit, total);
return;
}
let Some((path, _)) = crate::reshape::leaf_geometry(n) else { return };
let Some(bounds) = path.bounds() else { return };
let own = crate::apply_geometry(&n.appearance.effects, &path, bounds);
let items: Vec<usize> = n
.appearance
.items
.iter()
.filter(|i| crate::has_geometry(i.effects()))
.filter_map(|i| Some(crate::apply_geometry(i.effects(), &own, own.bounds()?).anchor_count()))
.collect();
*total += if items.is_empty() { own.anchor_count() } else { items.iter().sum() };
}
let mut total = 0;
count(n, limit, &mut total);
total
}
#[test]
fn a_groups_copies_keep_what_the_effects_inside_them_make_to_the_budget() {
let most = crate::geometry::MAX_EFFECT_ANCHORS;
let copies = |key: &str| transform(json!({key: 1, "copies": 1000}));
// Copies of a group whose member group makes copies of its own.
let inner = two_squares(json!({"moveH": 1, "copies": 1000}));
let mut outer = Node::group(NodeId(30), vec![std::sync::Arc::new(inner)]);
outer.appearance.effects.push(copies("moveV"));
let drawn = drawn_anchors(&outer, 2 * most);
assert!(drawn <= most, "a group inside: {drawn}");
// Copies of a group whose member path makes copies of its own.
let mut own = two_squares(json!({"moveH": 1, "copies": 1000}));
if let Some(members) = own.children_mut() {
std::sync::Arc::make_mut(&mut members[0]).appearance.effects.push(copies("moveV"));
}
let drawn = drawn_anchors(&own, 2 * most);
assert!(drawn <= most, "a path's own effect: {drawn}");
// Copies of a group whose member's fill makes copies of its own.
let mut item = two_squares(json!({"moveH": 1, "copies": 1000}));
if let Some(members) = item.children_mut() {
std::sync::Arc::make_mut(&mut members[0]).appearance.items[0].effects_mut().push(copies("moveV"));
}
let drawn = drawn_anchors(&item, 2 * most);
assert!(drawn <= most, "a fill's own effect: {drawn}");
}
#[test]
fn copies_with_scaled_effects_keep_what_each_one_makes_to_the_budget() {
// Each copy's Roughen is twice as large as the last's, and the rough edges take more lines.
let roughen = Effect { id: "distort.roughen".into(), params: json!({"size": 40, "relative": false, "detail": 20}), visible: true };
let lines = |budget: crate::geometry::Budget| {
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
let m = std::sync::Arc::make_mut(&mut members[0]);
m.appearance.items.retain(AppearanceItem::is_fill);
m.appearance.effects.push(roughen.clone());
members.truncate(1);
}
g.appearance.effects.clear();
let copies = transform(json!({"scaleH": 200, "scaleV": 200, "copies": 5, "strokes": true}));
crate::reshape::reshape_leaves_within(&mut g, &[&copies], budget, None);
let (mut count, mut lines) = (0, 0);
for leaf in leaves(&g) {
count += 1;
let Some((path, _)) = crate::reshape::leaf_geometry(leaf) else { continue };
let drawn = crate::apply_geometry(&leaf.appearance.effects, &path, path.bounds().unwrap());
lines += crate::geometry::Budget::of(&drawn).lines;
}
(count, lines)
};
let (all, most) = lines(crate::geometry::Budget::EFFECTS);
assert_eq!(all, 6, "the original and 5 copies");
let cap = most / 2;
let budget = crate::geometry::Budget { anchors: crate::geometry::MAX_EFFECT_ANCHORS, lines: cap };
let (count, drawn) = lines(budget);
assert!(count < all, "the budget stopped the copies");
assert!(drawn <= cap, "{drawn} > {cap}");
}
#[test]
fn a_groups_copies_are_whole_only_when_nothing_inside_makes_more_art() {
let copies = transform(json!({"moveH": 300, "copies": 2}));
let effect = |id: &str| Effect { id: id.into(), params: json!({}), visible: true };
let roughen = effect("distort.roughen");
// Two squares under a group with `change` made to the first member.
let whole = |change: &dyn Fn(&mut Node)| {
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
change(std::sync::Arc::make_mut(&mut members[0]));
}
g.appearance.effects.clear();
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
g.children().unwrap().len() == 3
};
// A group of one square with effect `e`, in place of the member.
let in_group = |m: &mut Node, e: &Effect| {
let square = Node::path(NodeId(40), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default());
let mut g = Node::group(NodeId(41), vec![std::sync::Arc::new(square), std::sync::Arc::new(m.clone())]);
g.appearance.effects.push(e.clone());
*m = g;
};
assert!(whole(&|_| {}));
// Effects that only change pixels, and effects turned off, are copied with the art.
assert!(whole(&|m| m.appearance.effects.push(effect("stylize.dropShadow"))));
assert!(whole(&|m| m.appearance.effects.push(Effect { visible: false, ..roughen.clone() })));
// Effects that draw nothing are copied with the art as well.
assert!(
whole(&|m| {
m.visible = false;
m.appearance.effects.push(roughen.clone());
}),
"a hidden member's effect"
);
assert!(
whole(&|m| {
*m.appearance.items[0].paint_mut() = Paint::None;
m.appearance.items[0].effects_mut().push(roughen.clone());
}),
"the effect of a fill that paints nothing"
);
assert!(whole(&|m| m.appearance.effects.push(effect("no.such.effect"))), "an unknown effect");
// Effects that make more art than a copy holds keep each member's copies in its place.
assert!(!whole(&|m| m.appearance.effects.push(roughen.clone())), "a member's own effect");
assert!(!whole(&|m| m.appearance.items[0].effects_mut().push(roughen.clone())), "a fill's own effect");
assert!(!whole(&|m| in_group(m, &roughen)), "a group's own effect");
assert!(!whole(&|m| in_group(m, &effect("pathfinder.add"))), "a Pathfinder effect");
assert!(
!whole(&|m| {
let mut art = Node::group(NodeId(43), vec![]);
in_group(&mut art, &roughen);
m.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
}),
"an effect in an opacity mask"
);
}
+17 -1
View File
@@ -149,11 +149,26 @@ pub struct CommandInfo {
/// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one.
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
/// The same schema in the form an MCP tool's `inputSchema` takes ([`input_schema`]).
#[serde(rename = "inputSchema", skip_serializing_if = "Option::is_none")]
pub input_schema: Option<Value>,
}
/// `schema` as an MCP tool input schema: the root `type` is `"object"`, since the arguments are
/// always an object, and every other keyword is kept. The root of [`CommandInfo::schema`] also
/// takes null when no parameter is required.
pub(crate) fn input_schema(schema: &Value) -> Value {
let mut o = schema.clone();
if let Some(map) = o.as_object_mut() {
map.insert("type".into(), "object".into());
}
o
}
impl CommandSpec {
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let schema = self.schema.map(vectorcraft_apischema::ParamSchema::to_json);
CommandInfo {
id: self.id,
label: self.label,
@@ -162,7 +177,8 @@ impl CommandSpec {
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
schema: self.schema.map(vectorcraft_apischema::ParamSchema::to_json),
input_schema: schema.as_ref().map(input_schema),
schema,
}
}
}
+2 -1
View File
@@ -115,7 +115,8 @@ pub(crate) fn subpath_selected_segments(
let n = sp.anchors.len();
let mut selected_segs = BTreeSet::new();
let has_explicit_segs = seg_refs.is_some_and(|segments| segments.iter().any(|&(s, _)| s == si));
// Segments picked anywhere on the path leave anchors out of it, as Clear does.
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 {
+23 -16
View File
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Lock Guides",
["View", "Guides"],
Some("Cmd+Alt+;"),
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
"{locked?: bool} toggle (or set) the session's guide lock, leaving the lockGuidesByDefault preference as it is; locking deselects the ruler guides and guide paths in every open document; guide.add and a guide dragged out of a ruler still make new guides while locked → {locked}",
has_doc,
lock_guides
),
@@ -277,6 +277,27 @@ impl Session {
pub fn guides_locked(&self) -> bool {
self.menu.guides_locked
}
/// Set View → Guides → Lock Guides. Locked guides can't stay selected, ruler guides or guide
/// paths, in any open document ([`Session::select`] lets those go in the active one).
pub(crate) fn set_guides_locked(&mut self, locked: bool) {
self.menu.guides_locked = locked;
if !locked {
return;
}
let guides_selected =
self.doc().is_ok_and(|st| !st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id)));
// It fails only without a document, and then no guide is selected.
if guides_selected {
let _ = self.select(|_, sel| sel.guides.clear());
}
let active = self.active;
for (i, st) in self.docs.iter_mut().enumerate() {
if Some(i) != active && crate::deselect_hidden(&self.menu, &st.doc, &mut st.selection) {
st.revision += 1;
}
}
}
}
// ---------- Select ----------
@@ -717,21 +738,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
let v = switch_or(p, "locked", !s.menu.guides_locked, "view.guides.lock")?;
s.menu.guides_locked = v;
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
// ([`Session::select`] lets those go in the active one).
let st = s.doc()?;
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
s.select(|_, sel| sel.guides.clear())?;
}
if v {
let active = s.active;
for (i, st) in s.docs.iter_mut().enumerate() {
if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) {
st.revision += 1;
}
}
}
s.set_guides_locked(v);
Ok(json!({ "locked": v }))
}
+5 -1
View File
@@ -1063,8 +1063,12 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs);
if cuts.is_empty() {
subpaths.push(sp);
} else {
} else if seg_refs.is_some_and(|segments| !segments.is_empty()) {
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();
subpaths.extend(super::path::without_anchors(sp, &gone));
}
}
subpaths.retain(|sp| sp.anchors.len() >= 2);
+9 -5
View File
@@ -280,9 +280,13 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let item = item_target(s, p, C)?;
if effects::is_three_d(id) {
for target in appearance_targets(s, p)? {
let targets = appearance_targets(s, p)?;
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?;
// A new effect goes after the item's others.
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len())));
effects::validate_three_d_at(n, id, &effect.params, on).map_err(|e| bad(C, e))?;
if id == effects::REVOLVE && effects::has_revolve(n) {
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
}
@@ -589,7 +593,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
cur.extend(new);
}
if visible != Some(false) {
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
effects::validate_three_d_at(n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?;
} else {
// Hidden, the effect draws nothing, so its object need not fit. The values given are
// checked, and after a change of 3D type so is every value it keeps.
@@ -865,8 +869,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
// keeps it as its only member).
Some((image, false)) => Some(rasterfx::replace_with_image(v, image)),
image => {
let m = if container && effects::has_solid(&v) {
// A group's or layer's 3D effect: the shaded faces of its members' solids.
let m = if (container || matches!(n.kind, NodeKind::Text(_))) && (effects::has_solid(&v) || effects::has_revolve(&v)) {
// A 3D effect on a group or layer, or on type: the shaded faces of its solids.
effects::expand_leaf(d, &v, &mut stroke_art)
} else if container {
// A group's or layer's own fills and strokes become art among its members.
+166 -3
View File
@@ -193,6 +193,169 @@ fn type_extrudes_as_its_glyphs_and_stays_editable() {
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text");
}
/// #3617: Revolve takes live type and stays type, updating when the text changes, and saves,
/// exports, expands and undoes as one step.
#[test]
fn type_revolves_as_its_glyphs_and_stays_editable() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "O", "size": 72})).unwrap();
let id = NodeId(t["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap();
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null, "params": {"segments": 16}})).unwrap();
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Text(_)), "still type");
let art = effects::revolve_art(&n).expect("a surface of the glyphs");
let mut tinted = false;
art.walk(&mut |c| {
if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
tinted |= r > g && r > b;
}
});
assert!(tinted, "shaded from the characters' red");
let doc = &s.doc().unwrap().doc;
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), art, "the same art after a save");
let meshes = effects::three_d_meshes(&n).unwrap();
assert_eq!(meshes.len(), 1, "one mesh of every contour of the type");
s.execute("text.setText", &json!({"ids": [id.0], "text": "OO"})).unwrap();
assert_ne!(effects::revolve_art(&node(&s, id)).unwrap(), art, "the surface follows the edited text");
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
assert!(!effects::has_revolve(&node(&s, id)), "expanded");
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(node(&s, id).kind, NodeKind::Text(_)));
assert!(effects::has_revolve(&node(&s, id)));
// Extrude & Bevel on type expands the same way.
s.execute("effect.remove", &json!({"index": 0, "ids": [id.0], "item": null})).unwrap();
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
assert!(!effects::has_solid(&node(&s, id)), "expanded");
}
/// Revolve refuses type with no outlines, and a group with no visible shape, without adding an
/// effect. Extrude & Bevel gives the same reason for empty type.
#[test]
fn revolve_refuses_type_and_groups_without_a_shape() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
for text in ["", " "] {
let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 72})).unwrap();
let id = NodeId(t["id"].as_u64().unwrap());
for effect in ["threeD.revolve", "threeD.extrude"] {
let err = s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("at least two anchors"), "{effect}, {text:?}: {err}");
assert!(node(&s, id).appearance.effects.is_empty());
}
}
let (mut s, a) = session_with_rect();
let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
for member in [a.0, b] {
s.execute("select.set", &json!({"ids": [member]})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
}
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("group holding a shape"), "{err}");
assert!(node(&s, g).appearance.effects.is_empty());
}
/// A Revolve on one fill is checked in that fill's paint, and `effect.setParams` on it as well.
#[test]
fn revolve_on_a_fill_is_checked_in_that_fills_paint() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{AppearanceItem, FillLayer};
let (mut s, id) = session_with_rect();
// The paint copies of 100 faces reach the 256 MiB budget: 8 segments stay under, 64 pass it.
let count = (256 << 20) / (4 * 100 * std::mem::size_of::<GradientStop>());
let stops = (0..count).map(|i| GradientStop::new(i as f32 / count as f32, Color::rgb(1.0, 0.0, 0.0))).collect();
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
s.edit("fills", |d, _| {
let n = d.node_mut(id).unwrap();
n.appearance.items =
vec![AppearanceItem::Fill(FillLayer::new(ramp)), AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))];
Ok(())
})
.unwrap();
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0})).unwrap_err().to_string();
assert!(err.contains("simplify the gradient"), "{err}");
assert!(node(&s, id).appearance.items[0].effects().is_empty());
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0, "params": {"segments": 8}})).unwrap();
let err = s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": 0, "params": {"segments": 128}})).unwrap_err().to_string();
assert!(err.contains("simplify the gradient"), "{err}");
}
/// A hidden Revolve still counts as the object's one Revolve: a second one is refused, and the
/// hidden one can be shown again over the geometry effects after it.
#[test]
fn a_second_revolve_is_refused_over_a_hidden_one() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap();
s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": false})).unwrap();
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [id.0], "item": null})).unwrap();
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("one Revolve effect"), "{err}");
let ids: Vec<_> = node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect();
assert_eq!(ids, ["threeD.revolve", "distort.roughen"]);
s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": true})).unwrap();
assert!(node(&s, id).appearance.effects[0].visible);
}
/// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps
/// each member's paint, follows an edited member, expands and undoes as one step.
#[test]
fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() {
let (mut s, a) = session_with_rect();
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#cc2200"})).unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [b], "color": "#0022cc"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null, "params": {"segments": 16, "rotationX": 90}})).unwrap();
assert_eq!(node(&s, g).children().unwrap().len(), 2, "the members stay as they were");
let art = effects::revolve_art(&node(&s, g)).expect("a surface of the members");
// The axis is the left edge of both members together (x = 100), so the right member's far
// wall sweeps out to 200 pt from it on either side: x = 300 and x = -100.
let bounds = art.visual_bounds().unwrap();
assert!((bounds.x1 - 300.0).abs() < 2.0 && (bounds.x0 + 100.0).abs() < 2.0, "{bounds:?}");
let (mut red, mut blue) = (false, false);
art.walk(&mut |c| {
if let Some([r, _, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
red |= r > b;
blue |= b > r;
}
});
assert!(red && blue, "each member keeps its own paint");
assert_eq!(effects::three_d_meshes(&node(&s, g)).unwrap().len(), 2, "a mesh for each member");
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "both members exported as vector faces");
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap();
assert_ne!(effects::revolve_art(&node(&s, g)).unwrap(), art, "a moved member reshapes the surface");
s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap();
assert!(!effects::has_revolve(&node(&s, g)), "expanded");
s.execute("edit.undo", &json!({})).unwrap();
assert!(effects::has_revolve(&node(&s, g)));
}
/// A group holding what can't be swept is refused as a whole, and a clipping group is named.
#[test]
fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() {
let (mut s, a) = session_with_rect();
let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone();
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [clip.as_u64().unwrap()], "item": null})).unwrap_err().to_string();
assert!(err.contains("path, compound path, type or group"), "{err}");
let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [clip, c]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string();
assert!(err.contains("Revolve on a group") && err.contains("clipping groups"), "{err}");
assert!(node(&s, g).appearance.effects.is_empty());
}
/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is
/// stored as the angle that shows the same view, and a later angle replaces an earlier strength.
#[test]
@@ -581,7 +744,7 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
let copies = n.children().unwrap();
assert_eq!(copies.len(), 3, "the path and its two copies");
let xs: Vec<f64> = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect();
assert_eq!(xs, vec![100.0, 250.0, 400.0]);
assert_eq!(xs, vec![400.0, 250.0, 100.0], "the original on top");
for c in copies {
assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label());
assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0)));
@@ -600,10 +763,10 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
let columns = node(&s, id).children().unwrap().to_vec();
assert_eq!(columns.len(), 3);
for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) {
for (col, x) in columns.iter().zip([400.0, 250.0, 100.0]) {
let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label()));
let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect();
assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]);
assert_eq!(ys, vec![(x, 250.0), (x, 100.0)]);
assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. })));
}
}
+9 -4
View File
@@ -125,15 +125,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor
// The scales and the point above can miss the size or the reference point when the margins of
// the objects differ, the members of groups and the objects in symbols' art included, and with
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are
// measured as drawn after each scale tried instead ([`refit_drawn`]).
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and
// objects with a butt-capped stroke along a segment at a slant, whose margins change with the
// segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]).
let symbols = d.symbol_boxes();
let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n));
let to_local = frame.to_doc().inverse();
let drawn = || {
ids.iter()
.filter_map(|id| d.node(*id))
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local))
};
let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) {
None
} else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() {
let options = scaling_options(s, p);
let to_local = frame.to_doc().inverse();
let measure = |sx: f64, sy: f64| {
let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy)));
let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() };
+1 -1
View File
@@ -1329,7 +1329,7 @@ pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, orig
/// Remove anchors and their incident segments, keeping each surviving stretch as an open path.
/// Only a closed path can have a surviving stretch that wraps past the last anchor.
fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
pub(super) fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
let n = sp.anchors.len();
let gone: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < n).collect();
if gone.is_empty() {
+7
View File
@@ -227,6 +227,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
// Guides & Grid
p!("guideColor", "Guides & Grid", "Guides", "Color", color),
p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)),
p!("lockGuidesByDefault", "Guides & Grid", "Guides", "Lock Guides by Default", bool),
p!("gridColor", "Guides & Grid", "Grid", "Color", color),
p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)),
p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", len(1.0, 16383.0, General)),
@@ -690,10 +691,16 @@ impl Session {
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
let corner_radius_changed = p.corner_radius != self.prefs.corner_radius;
let lock_guides_changed = p.lock_guides_by_default != self.prefs.lock_guides_by_default;
if p.trace_presets != self.prefs.trace_presets {
self.trace_preset_changes.replaced();
}
self.prefs = p;
// Guides & Grid › Lock Guides by Default sets the lock when it changes, the saved value at
// start included. Otherwise the lock stays as View › Guides › Lock Guides left it.
if lock_guides_changed {
self.set_guides_locked(self.prefs.lock_guides_by_default);
}
if corner_radius_changed {
// General › Corner Radius is the Rounded Rectangle tool's radius (#394): a tool already
// made (active, or lent a selection tool for a drag) takes the new value.
+3
View File
@@ -114,6 +114,8 @@ pub struct Prefs {
// Guides & Grid
pub guide_color: String,
pub guide_style: String,
/// Lock Guides by Default: the app starts with View › Guides › Lock Guides on.
pub lock_guides_by_default: bool,
pub grid_color: String,
pub grid_style: String,
pub gridline_every: f64,
@@ -421,6 +423,7 @@ impl Default for Prefs {
identify_objects_by: s("objectName"),
guide_color: s("#4affff"),
guide_style: s("lines"),
lock_guides_by_default: false,
grid_color: s("#c8c8c8"),
grid_style: s("lines"),
gridline_every: 72.0,
+60
View File
@@ -323,6 +323,66 @@ mod tests {
assert!(without.get("schema").is_none());
}
/// The listing gives each command with a schema an `inputSchema`, the form an MCP tool's input
/// schema takes: the same schema with the root `type` `"object"`, and every other keyword kept,
/// nested parameters, required names, defaults and VectorCraft's own fields included. A command
/// with no schema lists neither.
#[test]
fn engine_commands_list_an_input_schema_with_an_object_root() {
let s = Session::new();
let listed: Vec<Value> = s.commands().iter().map(|c| serde_json::to_value(c).unwrap()).collect();
let mut with_schema = 0;
for c in &listed {
let id = c["id"].as_str().unwrap();
let Some(schema) = c.get("schema") else {
assert!(c.get("inputSchema").is_none(), "{id}");
continue;
};
with_schema += 1;
let input = c.get("inputSchema").unwrap_or_else(|| panic!("{id} has no inputSchema"));
assert_eq!(input["type"], "object", "{id}");
assert_eq!(input["$schema"], vectorcraft_apischema::DIALECT, "{id}");
check_schema(input, id).unwrap_or_else(|e| panic!("{e}\n{input:#}"));
assert!(!instance_valid(input, &Value::Null), "{id}: the arguments are an object");
let without_type = |v: &Value| {
let mut v = v.clone();
v.as_object_mut().map(|o| o.remove("type"));
v
};
assert_eq!(without_type(schema), without_type(input), "{id}");
}
assert_eq!(with_schema, schemas().len());
let get = |id: &str| listed.iter().find(|c| c["id"] == id).unwrap();
// No required parameter: the schema also takes null, the input schema does not.
let list = get("view.saved.list");
assert_eq!(list["schema"]["type"], json!(["object", "null"]));
assert_eq!(list["inputSchema"]["type"], "object");
// Required parameters and their constraints are kept.
let new_view = get("view.saved.new");
assert_eq!(new_view["inputSchema"]["required"], json!(["center", "zoom"]));
assert_eq!(new_view["inputSchema"]["properties"], new_view["schema"]["properties"]);
// Nested objects in an array keep their keys.
let edit = &get("view.saved.edit")["inputSchema"]["properties"]["edits"];
assert!(edit.to_string().contains("newName"), "{edit}");
assert!(get("file.new").get("inputSchema").is_none());
}
/// A deprecated command and a deprecated parameter keep their deprecation in the input schema.
#[test]
fn an_input_schema_keeps_deprecation() {
static PARAMS: [Param; 2] = [Param::new("old", Type::Boolean).optional().deprecated(1), Param::new("on", Type::Boolean).optional()];
let schema = ParamSchema::new(&PARAMS, "Old command.").deprecated(1).to_json();
let input = crate::cmd::input_schema(&schema);
check_schema(&input, "synthetic").unwrap_or_else(|e| panic!("{e}\n{input:#}"));
assert_eq!(schema["type"], json!(["object", "null"]));
assert_eq!(input["type"], "object");
assert_eq!(input["deprecated"], true);
assert_eq!(input["x-deprecatedSince"], 1);
assert_eq!(input["properties"]["old"], schema["properties"]["old"]);
assert_eq!(input["properties"]["old"]["deprecated"], true);
assert_eq!(input["description"], "Old command.");
}
/// The listing says what each View command changes when it runs. Only the commands that make an
/// undo step list `document`.
#[test]
+4 -8
View File
@@ -2698,19 +2698,15 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [2]);
// Cut takes the segment between them. The two surviving runs of an ordinary path
// become independent objects, and this path keeps the first run (#3380).
// Cut takes the two anchors out of the path, as Clear does (#6361): each side keeps one
// anchor, which makes no path, so nothing is left.
s.execute("edit.undo", &json!({})).unwrap();
let original = path(&s, id);
let picked = s.doc().unwrap().selection.clone();
s.execute("edit.cut", &json!({})).unwrap();
let left = paths(&s);
assert_eq!(left.len(), 2);
assert_eq!(left[0].0, id);
assert!(left.iter().all(|(_, p)| p.subpaths.len() == 1 && !p.subpaths[0].closed));
assert_eq!(left[0].1.subpaths[0].anchors, original.subpaths[0].anchors[..2]);
assert_eq!(left[1].1.subpaths[0].anchors, original.subpaths[0].anchors[2..]);
assert_eq!(s.doc().unwrap().selection.objects, left.iter().map(|(id, _)| *id).collect::<Vec<_>>());
assert!(left.is_empty());
assert!(s.doc().unwrap().selection.objects.is_empty());
assert_eq!(s.clipboard.nodes.len(), 1);
assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]);
s.execute("edit.undo", &json!({})).unwrap();
+34
View File
@@ -3687,6 +3687,40 @@ fn ruler_guides_and_lock() {
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
}
/// While guides are locked, a guide dragged out of a ruler and `guide.add` still make new guides,
/// each in one undo step and not selected. The ruler guides and guide paths in the document, the
/// new guides included, can't be selected, moved or deleted.
#[test]
fn new_guides_can_be_made_while_guides_are_locked() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let path = guide_path(&mut s, 300.0);
s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
let v = ViewInfo { smart_guides: false, ..Default::default() };
let n = undo_len(&s);
s.ruler_guide(true, &PointerEvent::new(PointerKind::Drag, 153.0, 30.0), true, v).unwrap();
s.ruler_guide(true, &PointerEvent::new(PointerKind::Up, 153.0, 30.0), true, v).unwrap();
assert_eq!(guides(&s), [(false, 50.0), (true, 153.0)]);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "New Guide");
assert_eq!(s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap()["index"], 2);
assert_eq!(guides(&s).len(), 3);
assert_eq!(selected_guides(&s), Vec::<usize>::new());
for (id, params) in [
("guide.select", json!({"indexes": [1]})),
("guide.move", json!({"index": 1, "pos": 170})),
("guide.remove", json!({"index": 1})),
("guide.setLayer", json!({"index": 1, "layer": s.doc().unwrap().current_layer().unwrap().0})),
] {
assert!(matches!(s.execute(id, &params), Err(EngineError::Disabled(..))), "{id}");
}
assert_eq!(guides(&s).len(), 3);
sel(&mut s, &[path]);
assert!(selected(&s).is_empty(), "a guide path can't be selected");
}
#[test]
fn invalid_guide_lock_parameters_keep_guides_selected() {
let mut s = session();
+104
View File
@@ -199,6 +199,110 @@ fn reset_category_and_all() {
assert!(s.execute("prefs.reset", &json!({"category": "Bogus"})).is_err());
}
/// A rectangle made into a guide path; nothing is selected after.
fn guide_path(s: &mut Session, x: f64) -> NodeId {
let id = square(s, x, 0.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
id
}
/// Lock Guides by Default is off, and preferences saved before it existed leave guides unlocked.
#[test]
fn lock_guides_by_default_is_off_and_older_preferences_leave_guides_unlocked() {
assert!(!Prefs::default().lock_guides_by_default);
let older: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap();
assert!(!older.lock_guides_by_default);
let mut s = Session::new();
s.apply_prefs(older);
assert!(!s.guides_locked());
assert!(s.execute("prefs.set", &json!({"key": "lockGuidesByDefault", "value": "yes"})).is_err());
assert!(!s.guides_locked() && !s.prefs.lock_guides_by_default);
}
/// Preferences saved with the option on start a session with guides locked. A document opened in
/// it has its ruler guides and guide paths locked, and new guides can still be made. View › Guides
/// › Lock Guides then unlocks them for the session and leaves the preference on.
#[test]
fn saved_lock_guides_by_default_locks_a_new_session_and_the_documents_it_opens() {
let file = std::env::temp_dir().join(format!("vc-lock-guides-{}.vectorcraft", std::process::id()));
let file = file.to_string_lossy().to_string();
let mut unlocked = new_doc();
assert!(!unlocked.guides_locked(), "a session without the preference");
let path = guide_path(&mut unlocked, 100.0);
unlocked.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap();
unlocked.execute("document.save", &json!({"path": file})).unwrap();
// The preference is set with no document open, as a headless session can.
let mut first = Session::new();
first.execute("prefs.set", &json!({"key": "lockGuidesByDefault", "value": true})).unwrap();
let saved = first.prefs.to_json();
assert_eq!(saved["lockGuidesByDefault"], json!(true));
let mut s = Session::new();
s.apply_prefs(serde_json::from_value(saved).unwrap());
assert!(s.guides_locked());
s.execute("document.open", &json!({"path": file})).unwrap();
let _ = std::fs::remove_file(&file);
assert!(s.guides_locked(), "opening a document keeps the lock");
assert!(matches!(s.execute("guide.select", &json!({"indexes": [0]})), Err(EngineError::Disabled(..))));
s.execute("select.set", &json!({"ids": [path.0]})).unwrap();
assert!(s.doc().unwrap().selection.objects.is_empty(), "a guide path can't be selected");
assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 20})).unwrap()["index"], 1);
assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap(), json!({"locked": false}));
assert!(s.prefs.lock_guides_by_default, "the menu leaves the preference as it is");
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
s.execute("select.set", &json!({"ids": [path.0]})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, [path]);
}
/// Changing the preference sets the lock at once and deselects guides in every open document.
/// A change to any other preference, or setting it to the value it has, keeps the session's lock.
#[test]
fn changing_lock_guides_by_default_sets_the_lock_and_other_changes_keep_it() {
let mut s = new_doc();
s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap();
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let path = guide_path(&mut s, 100.0);
s.execute("select.set", &json!({"ids": [path.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
set_pref(&mut s, "lockGuidesByDefault", json!(true));
assert!(s.guides_locked());
assert!(s.doc().unwrap().selection.objects.is_empty(), "the guide path in the active document");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert!(s.doc().unwrap().selection.guides.is_empty(), "the ruler guide in the other document");
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
set_pref(&mut s, "keyboardIncrement", json!(4));
set_pref(&mut s, "lockGuidesByDefault", json!(true));
assert!(!s.guides_locked(), "the session's unlock stays");
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
set_pref(&mut s, "lockGuidesByDefault", json!(false));
assert!(!s.guides_locked(), "turning the preference off unlocks guides");
}
/// Reset turns the option off and unlocks guides. With the option already off, a reset keeps a
/// lock set from the View menu.
#[test]
fn resetting_lock_guides_by_default_unlocks_guides_only_when_it_was_on() {
let mut s = new_doc();
for params in [json!({"category": "Guides & Grid"}), json!({})] {
set_pref(&mut s, "lockGuidesByDefault", json!(true));
assert!(s.guides_locked());
s.execute("prefs.reset", &params).unwrap();
assert!(!s.prefs.lock_guides_by_default, "{params}");
assert!(!s.guides_locked(), "{params}");
}
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
s.execute("prefs.reset", &json!({})).unwrap();
assert!(s.guides_locked());
}
/// `prefs.set` refuses a recovery or plug-ins folder that is missing or is a file. Nothing changes,
/// other values in the same call included, and the document gets no undo step.
#[test]
+71
View File
@@ -618,6 +618,77 @@ fn set_bounds_leaves_the_height_of_lines_with_no_height() {
assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 100.0)), "{:?}", geometric(&s, ids[1]));
}
/// A line with a 2 pt stroke and butt caps, selected, with Use Preview Bounds on.
fn butt_line(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId {
let id = NodeId(run(s, "shape.line", json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1}))["id"].as_u64().unwrap());
run(s, "stroke.set", json!({"weight": 2, "cap": "butt"}));
preview(s, true);
run(s, "select.set", json!({"ids": [id.0]}));
id
}
#[test]
fn set_bounds_gives_a_butt_line_the_whole_height() {
// A butt cap ends at the anchor, so 10 mm (28.3465 pt) is the length of the line itself.
let mut s = session();
let id = butt_line(&mut s, (100.0, 100.0), (100.0, 140.0));
assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 140.0)), "{:?}", s.transform_bounds(&[id]));
run(&mut s, "object.setBounds", json!({"height": 28.3465, "reference": 0, "strokes": false}));
assert!(close(geometric(&s, id), Rect::new(100.0, 100.0, 100.0, 128.3465)), "{:?}", geometric(&s, id));
assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 128.3465)), "{:?}", s.transform_bounds(&[id]));
}
#[test]
fn set_bounds_gives_a_diagonal_butt_line_the_size_out_of_proportion() {
// The stroke's corners follow the line's slope, which a scale out of proportion changes.
let mut s = session();
let id = butt_line(&mut s, (100.0, 100.0), (130.0, 140.0));
let before = s.transform_bounds(&[id]).unwrap();
// The stroke's corners reach 0.8 pt across x and 0.6 pt across y at this 3-4-5 slope.
assert!(close(before, Rect::new(99.2, 99.4, 130.8, 140.6)), "{before:?}");
run(&mut s, "object.setBounds", json!({"height": 100, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[id]).unwrap();
assert!((v.height() - 100.0).abs() < 1e-6, "{v:?}");
}
#[test]
fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion() {
// Square ends, but the bevel join at the apex sets the top edge by the slanted segments'
// corners, which a scale out of proportion moves.
let mut s = session();
let anchors = json!([{"x": 100, "y": 150}, {"x": 100, "y": 140}, {"x": 110, "y": 100}, {"x": 120, "y": 140}, {"x": 120, "y": 150}]);
let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors}))["id"].as_u64().unwrap());
run(&mut s, "stroke.set", json!({"weight": 2, "cap": "butt", "join": "bevel"}));
preview(&mut s, true);
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[id]).unwrap();
assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}");
}
#[test]
fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() {
let mut s = session();
butt_line(&mut s, (100.0, 100.0), (130.0, 140.0));
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Line"}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.setBounds", json!({"width": 20, "height": 100, "reference": 0, "strokes": false}));
let v = s.transform_bounds(&[id]).unwrap();
assert!((v.width() - 20.0).abs() < 1e-6 && (v.height() - 100.0).abs() < 1e-6, "{v:?}");
}
#[test]
fn align_places_a_vertical_butt_line_by_its_end_points() {
// With Use Preview Bounds on, a butt line's ends line up with the top of a rectangle without
// a stroke.
let mut s = session();
rect(&mut s, 20.0, 50.0, 0.0);
let id = butt_line(&mut s, (200.0, 160.0), (200.0, 190.0));
run(&mut s, "select.all", json!({}));
run(&mut s, "object.align", json!({"vertical": "top"}));
assert!(close(geometric(&s, id), Rect::new(200.0, 100.0, 200.0, 130.0)), "{:?}", geometric(&s, id));
}
/// The rectangles of [`thin_and_thick`] in a group, the thick one in a group of its own inside it
/// when `nested`, selected with an unstroked rectangle from x 100 to 110 when `plain`. Returns the
/// selected objects.
@@ -192,7 +192,7 @@ fn pen_tool_drawing_copies_whole_path_not_single_anchor() {
}
#[test]
fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() {
fn cut_adjacent_anchors_takes_them_out_of_the_source() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
@@ -207,15 +207,93 @@ fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() {
assert_eq!(clip_path.subpaths[0].segment_count(), 1);
assert_eq!(clip_path.subpaths[0].anchors.len(), 2);
// Remaining source path must have the remaining 3 segments (segments 0, 2, 3), not 1.
// The source keeps anchors 3 and 0 and the one segment between them.
let src_node = s.doc().unwrap().doc.node(r).unwrap();
let src_path = src_node.path_data().unwrap();
assert_eq!(src_path.subpaths[0].segment_count(), 3);
assert_eq!(src_path.subpaths[0].anchors.len(), 4);
assert_eq!(src_path.subpaths[0].segment_count(), 1);
assert_eq!(src_path.subpaths[0].anchors.len(), 2);
assert!(!src_path.subpaths[0].closed);
}
// Sum of segments in clipboard + remaining = 1 + 3 = 4 (the original 4 segments).
assert_eq!(clip_path.subpaths[0].segment_count() + src_path.subpaths[0].segment_count(), 4);
/// Every contour left in the document after `command` runs on anchors `picked` of a four-anchor
/// open path, as `[x, y]` lists. With `compound`, the path is a member of a compound path.
fn contours_after(command: &str, picked: Value, compound: bool) -> Vec<Vec<[f64; 2]>> {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap());
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20}));
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80}));
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 150, "y": 80}));
if compound {
let other = rect(&mut s, 10.0, 100.0, 40.0, 40.0);
run(&mut s, "select.set", json!({"ids": [id.0, other.0]}));
run(&mut s, "object.compoundPath.make", json!({}));
}
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": picked, "mode": "set"}));
run(&mut s, command, json!({}));
check_session(&s).unwrap();
contours(&s)
}
/// Every contour in the document, as `[x, y]` lists.
fn contours(s: &Session) -> Vec<Vec<[f64; 2]>> {
let mut left = vec![];
for layer in &s.doc().unwrap().doc.layers {
layer.walk(&mut |n| {
if let Some(path) = n.path_data() {
left.extend(path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| [a.p.x, a.p.y]).collect::<Vec<_>>()));
}
});
}
left
}
#[test]
fn cut_end_anchors_leaves_the_source_as_clear_does() {
for compound in [false, true] {
let picked = || json!([[0, 0], [0, 1]]);
let left = contours_after("edit.cut", picked(), compound);
assert_eq!(left, contours_after("edit.clear", picked(), compound), "compound: {compound}");
assert_eq!(left[0], [[100.0, 80.0], [150.0, 80.0]], "compound: {compound}");
}
}
#[test]
fn cut_interior_anchors_leaves_the_source_as_clear_does() {
let picked = || json!([[0, 1], [0, 2]]);
let left = contours_after("edit.cut", picked(), true);
assert_eq!(left, contours_after("edit.clear", picked(), true));
assert!(left.iter().flatten().all(|p| p[0] != 60.0 && p[0] != 100.0), "the picked anchors are gone: {left:?}");
}
#[test]
fn cut_one_end_anchor_of_a_compound_member_shortens_it() {
let left = contours_after("edit.cut", json!([[0, 3]]), true);
assert_eq!(left[0], [[10.0, 20.0], [60.0, 20.0], [100.0, 80.0]]);
}
#[test]
fn cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does() {
let after = |command: &str| {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 400}));
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap());
let contour = |y: i32| json!({"anchors": [{"x": 10, "y": y}, {"x": 60, "y": y}, {"x": 110, "y": y}], "closed": false});
run(&mut s, "path.setAnchors", json!({"id": id.0, "subpaths": [contour(20), contour(120)]}));
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}));
run(&mut s, "select.segments", json!({"id": id.0, "segments": [[1, 1]], "mode": "add"}));
run(&mut s, command, json!({}));
check_session(&s).unwrap();
s
};
let cut = after("edit.cut");
assert_eq!(contours(&cut), contours(&after("edit.clear")));
assert!(contours(&cut).contains(&vec![[10.0, 20.0], [60.0, 20.0], [110.0, 20.0]]), "the picked anchor stays: {:?}", contours(&cut));
// The clipboard holds what left the source: the picked segment alone.
assert_eq!(cut.clipboard.nodes.len(), 1);
let clip = cut.clipboard.nodes[0].path_data().unwrap();
assert_eq!(clip.subpaths.len(), 1);
assert_eq!(clip.subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect::<Vec<_>>(), [[60.0, 120.0], [110.0, 120.0]]);
}
#[test]
+1 -1
View File
@@ -145,7 +145,7 @@ fn a_file_an_importer_reads_opens_as_editable_art() {
let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#;
open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap();
let (x0, y0, x1, y1) = artboard(&s);
assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s));
assert!(x0 == 0.0 && x1 == 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s));
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap();
+1 -1
View File
@@ -133,7 +133,7 @@ impl Space {
}
/// A named colour or ink's full colour: `[c m y k /DeviceCMYK (name) /kind]` or `[r g b /DeviceRGB …]`.
fn ink(a: &[V]) -> Option<Color> {
pub(super) fn ink(a: &[V]) -> Option<Color> {
let at = a.iter().position(|v| matches!(v, V::Name(_)))?;
let nums: Vec<f32> = a.get(..at)?.iter().map(|v| v.num().filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32)).collect::<Option<_>>()?;
match (a.get(at).and_then(V::text).as_deref(), nums.as_slice()) {
+31
View File
@@ -72,6 +72,37 @@ pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Pa
}
}
/// `n [c m y k /DeviceCMYK (name) /Separation] XC` (or `[r g b /DeviceRGB …]`) before an image: its
/// samples are one ink's tints. The ink at 100 % as a paint, with the named colour that makes its
/// spot swatch (none for Registration). `None` for a device space (`/DeviceGray XC`), the ink
/// `None`, which paints nothing, or another form.
pub(super) fn separation_ink(vals: &[V], names: &mut Names) -> Option<(Paint, Option<Named>)> {
let a = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(a) } else { None })?;
let [.., name @ V::Str(_), V::Name(kind)] = a.as_slice() else { return None };
if kind.trim_start_matches('/') != "Separation" {
return None;
}
let color = super::mesh::ink(a)?;
let name = names.text(name)?;
if name == "None" {
return None;
}
if is_registration(&name) {
let paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 };
return Some((paint, None));
}
// A process plate is its CMYK colour, not a spot ink: no swatch of its name.
if let Some(plate) = ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == name) {
let mut cmyk = [0.0; 4];
if let Some(v) = cmyk.get_mut(plate) {
*v = 1.0;
}
return Some((Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])), None));
}
let paint = Paint::Solid { color, swatch: Some(name.clone()), tint: 1.0 };
Some((paint, Some(Named { name, color, tint: 1.0, spot: true })))
}
fn is_registration(name: &str) -> bool {
matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]")
}
+50 -11
View File
@@ -472,10 +472,34 @@ impl<'a> Reader<'a> {
return Ok(());
};
let opacity = opacity.filter(|_| alpha_len > 0);
// An image in one ink's Separation space (`XC`): its samples are the ink's tints, the most
// at 100 % (255, or a set bit). It comes in as a grayscale image of them, black at 100 %,
// that the ink colorizes, as a Separation image opens from a PDF; its alpha channel
// becomes its opacity mask.
let ink = if channels == 1 && !image_mask { self.raster.as_mut().and_then(|r| r.ink.take()) } else { None };
let inverted;
let samples = if ink.is_some() {
inverted = samples.iter().map(|b| !b).collect::<Vec<u8>>();
inverted.as_slice()
} else {
samples
};
let ink = ink.map(|(paint, named)| {
// Its colour counts toward the document's colour model, as a fill's does (Registration
// says nothing of it).
let registration = matches!(&paint, Paint::Solid { swatch: Some(s), .. } if s == vectorcraft_color::swatch::REGISTRATION);
match paint.color().filter(|_| !registration) {
Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1,
Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1,
_ => {}
}
self.spots.extend(named);
paint
});
let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none();
if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) {
// An image mask keeps the color that colorizes it.
let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint());
// An image mask keeps the color that colorizes it, an ink image its ink.
let colorize = if image_mask && channels == 1 && *bits == 1.0 { Some(self.mask_paint()) } else { ink };
return self.large_image(samples, opacity, shape, k, &m, colorize);
}
// CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK
@@ -487,7 +511,11 @@ impl<'a> Reader<'a> {
return self.add_raster(blob, opacity, &m, (w as u32, h as u32), None);
}
if image_mask && channels == 1 && *bits == 1.0 {
return self.colorized_raster(samples, row, opacity, &m, (w as u32, h as u32));
let paint = self.mask_paint();
return self.colorized_raster(samples, (row, 1), opacity, &m, (w as u32, h as u32), paint);
}
if let Some(paint) = ink {
return self.colorized_raster(samples, (row, *bits as u8), opacity, &m, (w as u32, h as u32), paint);
}
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h as usize {
@@ -526,15 +554,24 @@ impl<'a> Reader<'a> {
self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None)
}
/// A 1-bit gray image whose ImageMask is 1 (`samples`, `row` bytes a row): a grayscale image
/// colorized with the fill color. Its 0 bits, which open black with ImageMask 0, take that color,
/// and its 1 bits are transparent. A fill that isn't a solid color colorizes it black.
fn colorized_raster(&mut self, samples: &[u8], row: usize, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> {
/// A one-channel image (`samples` of `bits` bits, `row` bytes a row) as a grayscale image
/// colorized with `paint`: a 1-bit gray image whose ImageMask is 1, with the fill color (its 0
/// bits, which open black with ImageMask 0, take that color, and its 1 bits are transparent; a
/// fill that isn't a solid color colorizes it black), or an ink's tints, inverted, with the ink.
fn colorized_raster(
&mut self,
samples: &[u8],
(row, bits): (usize, u8),
alpha: Option<&[u8]>,
m: &[f64],
(w, h): (u32, u32),
paint: Paint,
) -> Result<(), String> {
let mut gray = Vec::with_capacity(w as usize * h as usize);
for y in 0..h as usize {
let line = samples.get(y * row..(y + 1) * row).unwrap_or_default();
for x in 0..w as usize {
let v = line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255);
let v = if bits == 1 { line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255) } else { line.get(x).copied().unwrap_or(0) };
gray.push(v);
}
}
@@ -544,7 +581,6 @@ impl<'a> Reader<'a> {
self.image_left_out("an image VectorCraft can't read was left out");
return Ok(());
}
let paint = self.mask_paint();
self.add_raster(ImageBlob::new("image/png", png), alpha, m, (w, h), Some(paint))
}
@@ -625,12 +661,15 @@ impl<'a> Reader<'a> {
return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize);
}
let cmyk = s.channels == 4;
let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk);
// A colorized image's alpha channel becomes its opacity mask, as at its full size: a
// grayscale image whose pixels have alpha takes only a spot color, black or white.
let inline = !cmyk && colorize.is_none();
let (pixels, mask) = raster::reduce(samples, opacity, s, k, inline);
let blob = if cmyk {
Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i))
} else {
use image::ImageEncoder as _;
let colour = match (s.channels, opacity.is_some()) {
let colour = match (s.channels, inline && opacity.is_some()) {
(1, false) => image::ExtendedColorType::L8,
(1, true) => image::ExtendedColorType::La8,
(_, false) => image::ExtendedColorType::Rgb8,
+6 -2
View File
@@ -181,9 +181,13 @@ impl<'a> Reader<'a> {
} else if line.starts_with("AI5_EndRaster") {
// A file `XG` named in this raster object is no later one's.
self.image_link = None;
if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() {
if let Some(Raster { operands: Some(vals), mut samples, half, max, ink, .. }) = self.raster.take() {
end_hex(&mut samples, half, max);
return self.image_from(vals, &samples, true);
// The image's ink (`XC`) stays for `image_from` to read.
self.raster = Some(Raster { ink, ..Raster::default() });
let read = self.image_from(vals, &samples, true);
self.raster = None;
return read;
}
} else if line.starts_with("AI5_BeginPlace") {
// One that is still open (no `~` ended it) isn't read.
+8
View File
@@ -361,6 +361,14 @@ impl<'a> Reader<'a> {
r.space = vals.iter().rev().find_map(V::text);
}
}
// `n [… (name) /Separation] XC`: the image's samples are that ink's tints. A device
// space (`/DeviceGray XC` after a linked image's file name) changes nothing read here.
"XC" => {
let ink = paint::separation_ink(&vals, &mut self.names);
if let Some(r) = self.raster.as_mut() {
r.ink = ink;
}
}
// Placed files.
// `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in
// newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art,
+3 -2
View File
@@ -279,6 +279,9 @@ impl BrushArtColors {
#[derive(Debug, Default)]
pub(super) struct Raster {
pub(super) space: Option<String>,
/// The ink of an image in a Separation space (`XC`): its paint at 100 % and the named colour
/// for its swatch (`paint::separation_ink`).
pub(super) ink: Option<(Paint, Option<Named>)>,
/// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to
/// `%AI5_EndRaster`, the form the version 7 specification documents (§8).
pub(super) operands: Option<Vec<V>>,
@@ -370,8 +373,6 @@ pub(super) const IGNORED: &[&str] = &[
"As",
"Xd",
"Xr",
// `/DeviceGray XC` after a linked image's file name (`XG`): its colour space.
"XC",
"Xh",
"XH",
"Bc",
+102
View File
@@ -2755,6 +2755,108 @@ fn image_masks_are_colorized_with_the_fill() {
assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]);
}
/// A gray image `w` × 1 (`bits` bits a pixel, `alpha` channels, data `data`) whose colour space is
/// set by `xc` (the operands of `XC`) instead of `/DeviceGray XN`.
fn ink_image(xc: &str, bits: u32, alpha: u32, w: u32, data: &[u8]) -> Vec<u8> {
let mut out = image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, alpha), data, data.len() + 3);
// In place, as bytes: the samples aren't text.
let mut swap = |from: &[u8], to: &[u8]| {
let at = out.windows(from.len()).position(|s| s == from).unwrap();
out.splice(at..at + from.len(), to.iter().copied());
};
swap(b"/DeviceGray XN\n", format!("{xc} XC\n").as_bytes());
swap(format!(" 8 1 {alpha} 0 1 0 ").as_bytes(), format!(" {bits} 1 {alpha} 0 1 0 ").as_bytes());
out
}
/// An image in one ink's Separation space (`n [… (name) /Separation] XC`): its samples are the
/// ink's tints (255 at 100 %), so it opens as a grayscale image of them, black at 100 %, colorized
/// by the ink's spot swatch; its alpha channel becomes its opacity mask.
#[test]
fn separation_images_are_colorized_with_their_ink() {
let black = "1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]";
let r = read(&ink_image(black, 8, 0, 3, &[0, 128, 255])).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let a = art(&r.doc);
let Some(Paint::Solid { color, swatch: Some(swatch), tint }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) };
assert_eq!((swatch.as_str(), *tint), ("Black Ink", 1.0));
assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0));
assert!(r.doc.swatch("Black Ink").is_some_and(|s| s.spot), "a spot swatch for the ink");
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha));
let img = pixels(&r.doc).0;
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [255, 127, 0], "no ink is white, 100 % black");
// An RGB alternate gives the ink's colour; an alpha channel becomes the opacity mask.
let white = "1 [1 1 1 /DeviceRGB (White Ink) /Separation]";
let r = read(&ink_image(white, 8, 1, 2, &[255, 255, 255, 0])).unwrap();
let a = art(&r.doc);
let Some(Paint::Solid { color, swatch: Some(swatch), .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) };
assert_eq!((swatch.as_str(), color.to_rgba8(1.0)), ("White Ink", Color::rgb(1.0, 1.0, 1.0).to_rgba8(1.0)));
assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask");
// 1 bit a pixel: a set bit is the ink at 100 % (101).
let r = read(&ink_image(black, 1, 0, 3, b"\xa0")).unwrap();
let a = art(&r.doc);
assert!(a[0].extras().colorize.is_some());
let img = pixels(&r.doc).0;
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [0, 255, 0]);
// A device space, and the ink None (which paints nothing), leave the image as it was.
for xc in ["/DeviceGray", "1 [0 0 0 1 /DeviceCMYK (None) /Separation]"] {
let doc = read(&ink_image(xc, 8, 0, 2, &[0, 200])).unwrap().doc;
assert!(art(&doc)[0].extras().colorize.is_none(), "{xc}");
assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255], "{xc}");
}
}
/// A process plate's Separation colorizes in the plate's CMYK colour, with no swatch of its name;
/// Registration in the Registration colour.
#[test]
fn separation_images_of_process_plates_and_registration() {
let r = read(&ink_image("1 [0 0 0 1 /DeviceCMYK (Black) /Separation]", 8, 0, 2, &[0, 255])).unwrap();
assert_eq!(art(&r.doc)[0].extras().colorize.as_deref(), Some(&Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0))));
assert!(r.doc.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate");
let r = read(&ink_image("1 [1 1 1 1 /DeviceCMYK (All) /Separation]", 8, 0, 2, &[0, 255])).unwrap();
let swatch = art(&r.doc)[0].extras().colorize.as_deref().and_then(|p| if let Paint::Solid { swatch, .. } = p { swatch.clone() } else { None });
assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION));
}
/// The version 7 form, with samples in hex comment lines after `XI`, keeps the image's ink.
#[test]
fn a_separation_image_with_hex_comment_lines_keeps_its_ink() {
let s = "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n\
1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation] XC\n[ 1 0 0 1 0 100 ] 3 1 0 Xh\n\
[ 1 0 0 1 0 100 ] 0 0 3 1 3 1 8 1 0 0 0 0 XI\n%0080FF\n%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n";
let mut data = stream("").into_bytes();
let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
data.splice(at..at, s.bytes());
let r = read(&data).unwrap();
let a = art(&r.doc);
assert!(
matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), .. }) if s == "Black Ink"),
"{:?}",
a[0].extras().colorize
);
let img = pixels(&r.doc).0;
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [255, 127, 0]);
}
/// A Separation image reduced to fit the limits keeps its ink, and its alpha channel becomes its
/// opacity mask, as at its full size.
#[test]
fn a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask() {
let samples = [255u8; 16].iter().chain([0u8; 16].iter()).copied().collect::<Vec<_>>();
let data = ink_image("1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]", 8, 1, 16, &samples);
let r = super::with_image_limits(4, 1 << 31, 512 << 20, || read(&data).unwrap());
assert!(r.warnings.iter().any(|w| w.contains("was reduced")), "{:?}", r.warnings);
let a = art(&r.doc);
assert!(a[0].extras().colorize.is_some());
assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask");
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha), "no alpha in the pixels");
}
#[test]
fn images_with_alpha_channels() {
// RGB samples, then the alpha channel (not counted in the data's size).
+47 -2
View File
@@ -57,18 +57,63 @@ fn command_listings_take_query_menu_and_brief() {
let menu: Vec<&str> = c["menu"].as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect();
assert_eq!(menu.get(..2), Some(&["Object", "Path"][..]), "{c}");
}
// `brief` drops the params text and nothing else.
// `brief` drops the params text and the schemas, and nothing else.
let full = payload(&call(&mut s, "command_list", json!({})));
let brief = payload(&call(&mut s, "command_list", json!({"brief":true})));
assert_eq!(ids(&full), ids(&brief));
assert!(full.as_array().unwrap().iter().any(|c| c.get("params").is_some()));
assert!(brief.as_array().unwrap().iter().all(|c| c.get("params").is_none() && c.get("label").is_some()));
assert!(full.as_array().unwrap().iter().any(|c| c.get("inputSchema").is_some()));
assert!(
brief
.as_array()
.unwrap()
.iter()
.all(|c| c.get("params").is_none() && c.get("schema").is_none() && c.get("inputSchema").is_none() && c.get("label").is_some())
);
assert!(brief.to_string().len() * 2 < full.to_string().len(), "brief is much shorter");
// The other listing takes them too.
let r = payload(&call(&mut s, "list_commands", json!({"query":"rectangle","brief":true,"menu":""})));
assert_eq!(ids(&r["commands"]), ids(&by_filter));
}
#[path = "../../../apischema/src/conformance.rs"]
mod conformance;
/// Both listings give a command with a parameter schema an `inputSchema` with an object root,
/// which follows the 2020-12 rules, and the filters select the same commands as before.
#[test]
fn command_listings_carry_input_schemas() {
let mut s = Server::new(Box::new(Headless::with_document()));
let list = payload(&call(&mut s, "list_commands", json!({"filter":"view.saved"})));
let bare = payload(&call(&mut s, "command_list", json!({"filter":"view.saved"})));
assert_eq!(list["commands"], bare);
assert_eq!(list["count"], bare.as_array().unwrap().len());
let commands = bare.as_array().unwrap();
let with_input: Vec<&Value> = commands.iter().filter(|c| c.get("inputSchema").is_some()).collect();
assert!(with_input.len() >= 3, "{bare}");
for c in with_input {
let input = &c["inputSchema"];
assert_eq!(input["type"], "object", "{c}");
conformance::check_schema(input, c["id"].as_str().unwrap()).unwrap_or_else(|e| panic!("{e}\n{input:#}"));
assert!(!conformance::instance_valid(input, &Value::Null));
assert_eq!(conformance::instance_valid(&c["schema"], &Value::Null), c["schema"]["type"] != "object", "{c}");
}
let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").unwrap();
assert_eq!(saved_list["schema"]["type"], json!(["object", "null"]));
assert_eq!(saved_list["inputSchema"]["type"], "object");
// A command without a schema lists neither.
let new = payload(&call(&mut s, "command_list", json!({"filter":"file.new","enabled_only":true})));
let new = new.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap().clone();
assert!(new.get("schema").is_none() && new.get("inputSchema").is_none(), "{new}");
// `brief`, `menu` and the enabled filter select the same ids with and without the schemas.
let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].clone()).collect::<Vec<_>>();
let view = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true})));
let view_brief = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true,"brief":true})));
assert_eq!(ids(&view["commands"]), ids(&view_brief["commands"]));
assert_eq!(view["count"], view_brief["count"]);
assert!(view_brief["commands"].as_array().unwrap().iter().all(|c| c.get("inputSchema").is_none() && c.get("schema").is_none()));
}
#[test]
fn conventions_commands_batch_and_bounded_preview() {
let mut s = Server::new(Box::new(Headless::with_document()));
+17
View File
@@ -261,3 +261,20 @@ fn log_records_go_out_as_notifications_once_asked_for() {
assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}");
result(&mut s, 4, "logging/setLevel", json!({"level": null}));
}
/// `list_commands` gives each command's input schema over stdin and stdout, as a client reads it.
#[test]
fn list_commands_over_stdio_carries_input_schemas() {
let mut s = server();
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_commands", "arguments": {"filter": "view.saved"}}});
let mut out = Vec::new();
s.serve((call.to_string() + "\n").as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let reply = lines.iter().find(|l| l["id"] == 1).cloned().unwrap_or(Value::Null);
assert_eq!(reply["result"]["isError"], false, "{reply}");
let listed: Value = serde_json::from_str(reply["result"]["content"][0]["text"].as_str().unwrap_or("null")).unwrap_or(Value::Null);
let commands = listed["commands"].as_array().cloned().unwrap_or_default();
assert!(commands.iter().filter(|c| c.get("inputSchema").is_some()).count() >= 3, "{listed}");
let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").cloned().unwrap_or(Value::Null);
assert_eq!(saved_list["inputSchema"]["type"], "object", "{listed}");
}
+2 -2
View File
@@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_list",
"List commands",
"Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.",
"Discover command ids, labels, parameter descriptions and enabled state. A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.",
obj(
json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}),
&[],
@@ -156,7 +156,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"list_commands",
"List commands",
"List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.",
"List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.",
obj(
json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}),
&[],
+5 -4
View File
@@ -10,8 +10,7 @@ pub(super) const MENU_FILTER: &str =
"Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")";
/// The `brief` argument of the command listings.
pub(super) const BRIEF: &str =
"Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it";
pub(super) const BRIEF: &str = "Leave out each command's params description and its schema and inputSchema (the bulk of the catalogue); run command_list with the id as filter to read them";
/// The `max_side` argument of the window captures.
pub(super) const MAX_SIDE_WINDOW: &str =
@@ -30,7 +29,7 @@ pub(super) fn trace_views(a: &Args) -> Result<bool, String> {
/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the
/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params`
/// text: the full catalogue is too large for many clients, #2173).
/// text, `schema` or `inputSchema`: the full catalogue is too large for many clients, #2173).
fn filter_commands(all: Value, a: &Args) -> Value {
let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty());
let needle = text("filter").or_else(|| text("query"));
@@ -63,7 +62,9 @@ fn filter_commands(all: Value, a: &Args) -> Value {
.filter(hit)
.map(|mut c| {
if brief && let Some(o) = c.as_object_mut() {
o.remove("params");
for key in ["params", "schema", "inputSchema"] {
o.remove(key);
}
}
c
})
+2 -2
View File
@@ -585,13 +585,13 @@ impl<'p> Builder<'p> {
Some(Paint::Pattern { pattern: name, xf })
}
/// Separation image `st` (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a
/// Image `st` of one ink (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a
/// process plate or Registration, found by its colour space) and its tints as gray pixels
/// ([`crate::import_image::tint_gray`]). `None` for another image, an ink whose colour can't
/// be told, or tints that can't be read (with a warning): the image opens in RGB. The ink is
/// only marked used once the tints are read.
pub(super) fn separation_image(&mut self, st: &craft_pdf::syntax::object::Stream<'_>, w: u32, h: u32) -> Option<(Paint, Vec<u8>)> {
let space = self.colors.array_space(&crate::import_image::separation_space(st)?)?;
let space = self.colors.array_space(&crate::import_image::one_ink_space(st)?)?;
let gray = crate::import_image::tint_gray(st, w, h).map_err(|why| self.warn(why)).ok()?;
Some((self.colors.native(space, &[1.0])?.paint(), gray))
}
+1 -1
View File
@@ -250,7 +250,7 @@ impl<'a> Device<'a> for Builder<'_> {
let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None };
return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask, None);
}
// A Separation image keeps its ink: its tints as a grayscale image that the ink at
// An image of one ink keeps it: its tints as a grayscale image that the ink at
// 100 % colorizes, as a mask painted in the ink comes in. Its mask becomes an
// opacity mask, as a CMYK image's does. Read once per image: the decision is kept,
// so one that opens in RGB stays RGB, and the gray pixels have their own key.
+82 -12
View File
@@ -3,9 +3,10 @@
//! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any
//! other is decoded, its Decode array applied, and stored as a CMYK TIFF
//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer
//! carries it as the image's opacity mask ([`has_mask`]). A Separation image keeps its ink: its
//! tints are read as a grayscale image ([`tint_gray`]) that the ink colorizes. Every other image is
//! read as RGB by the interpreter.
//! carries it as the image's opacity mask ([`has_mask`]). A DeviceN image of process plates only
//! keeps them the same way, as a CMYK image ([`process_devicen`]). An image of one ink (a
//! Separation, or a DeviceN of one colorant) keeps it: its tints are read as a grayscale image
//! ([`tint_gray`]) that the ink colorizes. Every other image is read as RGB by the interpreter.
use craft_pdf::syntax::Filter;
use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams};
@@ -13,6 +14,8 @@ use craft_pdf::syntax::object::{Array, Dict, Name, Stream};
use vectorcraft_doc::ImageBlob;
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components};
use crate::import_color::ink_names;
/// A CMYK image read as RGB: why.
pub(crate) const UNREAD: &str = "CMYK images whose samples couldn't be read in CMYK were imported in RGB";
@@ -57,7 +60,7 @@ fn decode_ranges(d: &Dict<'_>) -> [(f32, f32); 4] {
pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<ImageBlob>, &'static str> {
let d = st.dict();
if !cmyk_space(d) {
return Ok(None);
return process_devicen(st, width, height);
}
let ranges = decode_ranges(d);
let filters = st.filters();
@@ -94,19 +97,86 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<Im
/// An image in a spot ink read as RGB: why.
pub(crate) const UNREAD_TINTS: &str = "images in a spot ink whose samples couldn't be read were imported in RGB";
/// The image's colour space when it is a Separation (`[/Separation name alt tint]`): one ink.
/// `None` for the ink `None`, which paints nothing (ISO 32000-1 §8.6.6.4): the interpreter draws it.
pub(crate) fn separation_space<'a>(st: &Stream<'a>) -> Option<Array<'a>> {
/// The image's colour space when it lays one ink: a Separation (`[/Separation name alt tint]`), or
/// a DeviceN of one colorant (`[/DeviceN [name] alt tint …]`). `None` for the ink `None`, which
/// paints nothing (ISO 32000-1 §8.6.6.4): the interpreter draws it.
pub(crate) fn one_ink_space<'a>(st: &Stream<'a>) -> Option<Array<'a>> {
let d = st.dict();
let a = d.get::<Array<'a>>(key(d, b"ColorSpace", b"CS"))?;
let one_ink = {
let mut it = a.flex_iter();
it.next::<Name<'_>>()?.as_ref() == b"Separation" && it.next::<Name<'_>>()?.as_ref() != b"None"
};
let one_ink = matches!(ink_names(&mut a.flex_iter()).as_ref().map(|(_, names)| names.as_slice()), Some([name]) if name != "None");
one_ink.then_some(a)
}
/// The tints of the one-component image `st` (`width` × `height` pixels, a Separation image's)
/// The CMYK channel of each colorant of the image's DeviceN space (`None` for the ink `None`) when
/// it has two or more colorants and all are process plates, or `None`. One colorant is an image of
/// one ink ([`one_ink_space`]).
fn process_plates(d: &Dict<'_>) -> Option<Vec<Option<usize>>> {
let a = d.get::<Array<'_>>(key(d, b"ColorSpace", b"CS"))?;
let (device_n, names) = ink_names(&mut a.flex_iter())?;
// PDF's own limit is 32 colorants.
if !device_n || names.len() < 2 || names.len() > 32 {
return None;
}
let plates = names
.iter()
.map(|n| match n.as_str() {
"None" => Some(None),
n => ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == n).map(Some),
})
.collect::<Option<Vec<_>>>()?;
plates.iter().any(Option::is_some).then_some(plates)
}
/// A DeviceN image of process plates only ([`process_plates`]) in CMYK: each colorant's tint in
/// its plate, its Decode array applied, as a CMYK TIFF. `Ok(None)` for any other image, `Err` (a
/// warning) when its samples can't be read.
fn process_devicen(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<ImageBlob>, &'static str> {
let d = st.dict();
let Some(plates) = process_plates(d) else { return Ok(None) };
let bpc = d.get::<u8>(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8);
if !matches!(bpc, 8 | 16) {
return Err(UNREAD);
}
let n = u64::from(width) * u64::from(height);
if n == 0 || n > MAX_CMYK_PIXELS {
return Err(UNREAD);
}
let comps = plates.len();
let ranges: Vec<(f32, f32)> = match d.get::<Array<'_>>(key(d, b"Decode", b"D")).map(|a| a.iter::<f32>().collect::<Vec<_>>()) {
Some(v) if v.len() == 2 * comps && v.iter().all(|x| x.is_finite()) => v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(),
_ => vec![(0.0, 1.0); comps],
};
let size = usize::from(bpc / 8);
let params =
ImageDecodeParams { is_indexed: false, bpc: Some(bpc), num_components: u8::try_from(comps).ok(), target_dimension: None, width, height };
let decoded = st.decoded_image(&params).map_err(|_| UNREAD)?;
// An image codec lays its samples out its own way.
if decoded.image_data.is_some() {
return Err(UNREAD);
}
// Within `MAX_CMYK_PIXELS` and 32 colorants of 2 bytes this fits a 64-bit `usize`, not always a
// 32-bit one (the web build): checked.
let len = usize::try_from(n).ok().and_then(|n| n.checked_mul(comps)?.checked_mul(size)).ok_or(UNREAD)?;
let raw = decoded.data.get(..len).ok_or(UNREAD)?;
let value = |s: &[u8]| match *s {
[hi, lo] => u16::from_be_bytes([hi, lo]) as f32 / 65535.0,
[v] => v as f32 / 255.0,
_ => 0.0,
};
let mut inks = Vec::with_capacity(n as usize * 4);
for px in raw.chunks_exact(size * comps) {
let mut cmyk = [0.0f32; 4];
for ((s, plate), (lo, hi)) in px.chunks_exact(size).zip(&plates).zip(&ranges) {
if let Some(c) = plate.and_then(|p| cmyk.get_mut(p)) {
*c = c.max((lo + value(s) * (hi - lo)).clamp(0.0, 1.0));
}
}
inks.extend(cmyk.map(|v| (v * 255.0).round() as u8));
}
Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD)
}
/// The tints of the one-component image `st` (`width` × `height` pixels, an image of one ink)
/// as a grayscale image: white where it lays no ink, black at 100 %, its Decode array applied.
/// `Err` (a warning) when its samples can't be read.
pub(crate) fn tint_gray(st: &Stream<'_>, width: u32, height: u32) -> Result<Vec<u8>, &'static str> {
+2 -2
View File
@@ -749,7 +749,7 @@ fn an_image_whose_pixels_cant_be_decoded_is_left_out_with_one_note() {
}
/// #2023: a group's Transform effect copies an image among its members as it does the paths: the
/// PDF draws the image three times, where the effect put it.
/// PDF draws the image three times, where the effect put it, the original on top.
#[test]
fn a_group_transform_effect_exports_its_image_copies() {
let mut d = doc(200.0, 100.0);
@@ -776,7 +776,7 @@ fn a_group_transform_effect_exports_its_image_copies() {
let out = roundtrip(&d);
let lefts: Vec<f64> =
leaves(&out).iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect();
assert_eq!(lefts, [20.0, 70.0, 120.0]);
assert_eq!(lefts, [120.0, 70.0, 20.0]);
}
/// Build a PDF with a correct xref table from object bodies.
+61
View File
@@ -432,3 +432,64 @@ fn a_separation_image_that_cant_be_read_stays_rgb() {
assert!(r.document.swatches.iter().all(|s| !s.spot), "no swatch for an ink nothing uses");
assert_eq!(r.warnings.iter().filter(|w| w.contains("spot ink")).count(), 1, "{:?}", r.warnings);
}
/// A Type 4 tint transform as an object, `code` its program.
fn type4(code: &str, inputs: usize) -> String {
let domain = "0 1 ".repeat(inputs);
format!("<< /FunctionType 4 /Domain [{}] /Range [0 1 0 1 0 1 0 1] /Length {} >>\nstream\n{code}\nendstream", domain.trim_end(), code.len())
}
/// The imported images' blobs, in paint order.
fn image_blobs(d: &Document) -> Vec<(ImageBlob, bool)> {
let mut out = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if let NodeKind::Image(im) = &n.kind {
out.push((d.images[&im.key].clone(), n.extras().colorize.is_some()));
}
});
}
out
}
/// A DeviceN image of one colorant opens as a Separation image does: its tints, colorized by the ink.
#[test]
fn a_devicen_image_of_one_ink_opens_colorized_by_it() {
let space = "[/DeviceN [/SpotRed] /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]";
let d = separation_page(space, 4, 8, "", "0055AAFF", &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1);
assert_eq!(images[0].1, [255, 170, 85, 0]);
assert!(matches!(&images[0].0, Some(Paint::Solid { swatch: Some(s), .. }) if s == "SpotRed"), "{:?}", images[0].0);
}
/// A DeviceN image of process plates only opens in CMYK, each colorant's tint in its plate.
#[test]
fn a_devicen_image_of_process_plates_opens_in_cmyk() {
let space = format!("[/DeviceN [/Cyan /Magenta] /DeviceCMYK {} 0 R]", first_extra(1) + 1);
// Cyan and Magenta → (c, m, 0, 0); pixels: none, cyan, magenta, both.
let d = separation_page(&space, 4, 8, "", "0000FF0000FFFFFF", &[&type4("{ 0 0 }", 2)]);
let blobs = image_blobs(&d);
assert_eq!(blobs.len(), 1);
let (blob, colorized) = &blobs[0];
assert!(blob.is_cmyk() && !colorized, "a CMYK image");
let inks = blob.cmyk().unwrap().data;
assert_eq!(inks, [0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 255, 255, 0, 0]);
// 16 bits a sample, the ink None among the plates, and a Decode array that inverts Cyan.
let space = format!("[/DeviceN [/Cyan /None /Black] /DeviceCMYK {} 0 R]", first_extra(1) + 1);
let d = separation_page(&space, 2, 16, "/Decode [1 0 0 1 0 1]", "FFFF0000000000000000FFFF", &[&type4("{ exch pop 0 0 3 -1 roll }", 3)]);
let blobs = image_blobs(&d);
assert_eq!(blobs[0].0.cmyk().unwrap().data, [0, 0, 0, 0, 255, 0, 0, 255]);
}
/// A DeviceN image of a spot ink and a process plate (a duotone) still opens as RGB: an image can
/// hold one ink or the process plates, not both.
#[test]
fn a_duotone_devicen_image_still_opens_as_rgb() {
let space = format!("[/DeviceN [/Black /SpotRed] /DeviceCMYK {} 0 R]", first_extra(1) + 1);
let d = separation_page(&space, 2, 8, "", "FF0000FF", &[&type4("{ dup 3 -1 roll 0 4 1 roll }", 2)]);
let blobs = image_blobs(&d);
assert_eq!(blobs.len(), 1);
assert!(!blobs[0].0.is_cmyk() && !blobs[0].1, "RGB, not colorized");
}
+29 -8
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use serde_json::json;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node};
use vectorcraft_geom::{Affine, Rect, shapes};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within, pan_margin};
use crate::tests_fontchange::{fonts_steady, with_fonts_unchanged};
@@ -214,6 +214,19 @@ fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() {
fresh.threads = 2;
if pan.0.fract() == 0.0 && pan.1.fract() == 0.0 {
assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}");
} else {
// Right of the rectangle's soft edge the frame holds only the glow, which the kept raster
// places at the whole pixel nearest the pan, where a fresh render draws it too. A pan of
// half a pixel is as near to the pixels on both sides, so either one is accepted.
let right = (xf * Point::new(rect.x1, rect.y0)).x.ceil() as usize + 3;
let row = size.0 as usize * 4;
let glow = |p: &[u8]| p.chunks(row).flat_map(|r| r[right * 4..].to_vec()).collect::<Vec<_>>();
let nearest = |v: f64| if (v - v.trunc()).abs() == 0.5 { vec![v.floor(), v.ceil()] } else { vec![v.round()] };
let found = nearest(pan.0).into_iter().flat_map(|x| nearest(pan.1).into_iter().map(move |y| (x, y))).any(|whole| {
let at = Affine::translate(whole) * base;
glow(&panned.pixels) == glow(&fresh.render(&d, size.0, size.1, at, &opts).pixels)
});
assert!(found, "blur {blur}, pan {pan:?}: the glow is drawn at the whole pixel nearest the pan");
}
}
}
@@ -887,8 +900,8 @@ fn the_brush_library_is_checked_only_after_3d_art_is_measured() {
assert_eq!(r.brush_library, None);
}
/// An object whose 3D effect builds nothing (Revolve on a group) is measured by the renderer as the
/// free measure does it: as the renderer draws it, by its members.
/// An object whose 3D effect builds nothing (Revolve on a group's fill) is measured by the renderer
/// as the free measure does it: as the renderer draws it, by its members.
#[test]
fn a_3d_effect_that_builds_nothing_is_measured_alike() {
let mut d = Document::new(100.0, 100.0);
@@ -904,10 +917,12 @@ fn a_3d_effect_that_builds_nothing_is_measured_alike() {
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut g = Node::group(d.alloc_id(), vec![Arc::new(hidden), Arc::new(shown)]);
g.appearance.effects = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }];
g.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
*g.appearance.items[0].effects_mut() = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }];
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let n = d.layers[0].children().unwrap()[0].clone();
assert!(crate::effects::has_revolve(&n) && crate::effects::revolve_art(&n).is_none(), "builds nothing");
let symbols = d.symbol_boxes();
assert_eq!(Renderer::new().effect_bounds(&symbols, &n), crate::effect_bounds(&symbols, &n));
assert_eq!(Renderer::new().bounds_of(&symbols, &n), crate::brush_fx::cull_bounds(&symbols, &n));
@@ -1748,8 +1763,8 @@ fn an_image_inner_glow_follows_its_visible_pixels() {
}
}
/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen) draws the
/// item as without it: the art made for the object is drawn without it, not as an object with
/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen, in a paint
/// whose copies on the faces would pass the budget) draws the item as without it: the art made for the object is drawn without it, not as an object with
/// effects again, and that art again, without end. A stroked object draws exactly as without the
/// 3D effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too. An object with
/// only a fill and a raster effect draws from that art, where without the 3D effect it draws as a
@@ -1762,6 +1777,12 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() {
let rough =
Effect { id: "distort.roughen".into(), params: json!({"size": 10, "relative": false, "detail": 10, "points": "smooth"}), visible: true };
let revolve = Effect { id: "threeD.revolve".into(), params: json!({}), visible: true };
// A gradient of one color with 1,000 stops.
let ramp = |c: Color| {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let stops = (0..1000).map(|i| GradientStop::new(i as f32 / 1000.0, c)).collect();
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))))
};
let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true };
let blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 2}), visible: true };
let cases = [
@@ -1776,8 +1797,8 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() {
for (object, on_fill, stroked) in cases {
let draw = |object: &[Effect], three: bool| {
let mut d = Document::new(100.0, 100.0);
let stroke = if stroked { Paint::solid(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None };
let ap = Appearance::basic(Paint::solid(Color::BLACK), stroke, 2.0);
let stroke = if stroked { ramp(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None };
let ap = Appearance::basic(ramp(Color::BLACK), stroke, 2.0);
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), ap);
let l = d.layers[0].id;
let id = d.insert(Some(l), 0, n).unwrap();
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> {
pub struct RenderOptions {
/// Outline (wireframe) view: 1 px black paths, no paint.
pub outline: bool,
/// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent.
/// Pasteboard color behind everything (straight-alpha RGBA8); `None` = transparent.
pub background: Option<[u8; 4]>,
/// Draw artboards as white rectangles (screen view). Export sets this to false.
pub artboards: bool,
+1 -1
View File
@@ -375,7 +375,7 @@ impl Renderer {
}
/// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into
/// `size`×`size` pixels, optionally over a solid premultiplied background. A symbol instance
/// `size`×`size` pixels, optionally over a solid straight-alpha background. A symbol instance
/// is fitted by its symbol's art in `doc`.
pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option<Rendered> {
self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default())
+1 -1
View File
@@ -92,5 +92,5 @@ fn a_group_transform_copies_its_images() {
.filter(|l| l.starts_with("<image") || l.starts_with("<use"))
.filter_map(|l| l.split("transform=\"").nth(1)?.split('"').next())
.collect();
assert_eq!(placed, ["matrix(1 0 0 1 20 20)", "matrix(1 0 0 1 60 20)", "matrix(1 0 0 1 100 20)"], "{svg}");
assert_eq!(placed, ["matrix(1 0 0 1 100 20)", "matrix(1 0 0 1 60 20)", "matrix(1 0 0 1 20 20)"], "the original on top: {svg}");
}
+262 -20
View File
@@ -18,11 +18,16 @@ mod caps;
mod mesh;
pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh};
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv};
/// Resource limits shared by desktop, CLI and browser evaluation.
const MAX_PROFILE_POINTS: usize = 1024;
const MAX_PROFILE_POINTS: usize = 16_384;
const MAX_FACES: usize = 32768;
/// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art.
const MAX_REVOLVE_FACES: usize = 163_840;
/// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget.
const CURVE_SAMPLES: usize = 16;
const MIN_CURVE_SAMPLES: usize = 4;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct V3(f64, f64, f64);
@@ -86,6 +91,9 @@ pub struct Revolve {
/// Close circular endpoint rings of open profiles. Partial sweeps get sectors;
/// the angular cut sides are left open. Enabled by default.
pub cap_ends: bool,
/// Samples for each curved profile segment, from [`revolve_curve_samples`] for the surfaces of
/// one object. `None` plans them from the profile alone.
pub curve_samples: Option<usize>,
}
/// The default light's Rotation and Height: the light of the earlier default direction (45° to the
@@ -114,6 +122,7 @@ impl Default for Revolve {
shade: true,
smooth_shading: true,
cap_ends: true,
curve_samples: None,
}
}
}
@@ -307,6 +316,11 @@ impl Revolve {
..self
}
}
/// How many steps a sanitized sweep takes.
fn steps(self) -> usize {
((self.segments as f64 * self.angle / 360.0).ceil() as usize).max(1)
}
fn rotate(self, v: V3) -> V3 {
let (sx, cx) = self.rotation_x.to_radians().sin_cos();
let (sy, cy) = self.rotation_y.to_radians().sin_cos();
@@ -449,16 +463,234 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi
/// Evaluate a profile in document space. All subpaths share one axis from `bounds`.
/// Invalid or excessively complex inputs return an error, never a partial surface.
pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Face>, String> {
revolve_faces(path, bounds, options, None)
revolve_faces(path, FillRule::NonZero, bounds, options, None)
}
/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn.
fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec<solid::Face3>>) -> Result<Vec<Face>, String> {
const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex";
const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile";
/// The samples each curved segment takes when `paths`, the profiles of one object's surfaces, are
/// revolved with `options`: [`CURVE_SAMPLES`] when every surface fits the limits together, else as
/// many as fit, the same number for every curve. An error when [`MIN_CURVE_SAMPLES`] do not fit.
/// The count is made before any face is built.
pub fn revolve_curve_samples<'a>(paths: impl IntoIterator<Item = &'a PathData>, options: Revolve) -> Result<usize, String> {
let o = options.sanitized();
let steps = o.steps();
let per_edge = if o.shade && o.smooth_shading { steps.saturating_mul(2) } else { steps };
let (mut anchors, mut contours, mut lines, mut curves, mut caps) = (0usize, 0usize, 0usize, 0usize, 0usize);
for sp in paths.into_iter().flat_map(|p| &p.subpaths) {
anchors = anchors.saturating_add(sp.anchors.len());
contours += 1;
if anchors > MAX_PROFILE_POINTS || contours > MAX_PROFILE_POINTS {
return Err(TOO_COMPLEX_PROFILE.into());
}
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
for i in 0..segments {
if sp.segment_is_line(i) {
lines += 1;
} else {
curves += 1;
}
}
if o.cap_ends && !sp.closed && segments > 0 {
caps += 2;
}
}
let points = MAX_PROFILE_POINTS.saturating_sub(contours);
if lines.saturating_add(curves.saturating_mul(MIN_CURVE_SAMPLES)) > points {
return Err(TOO_COMPLEX_PROFILE.into());
}
let edges = (MAX_REVOLVE_FACES.saturating_sub(caps.saturating_mul(steps)) / per_edge).min(points);
if curves == 0 {
return if lines > edges { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(CURVE_SAMPLES) };
}
let samples = (edges.saturating_sub(lines) / curves).min(CURVE_SAMPLES);
if samples < MIN_CURVE_SAMPLES { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(samples) }
}
/// Most work the search for holes does: each winding test counts its contour's elements, and each
/// contour placed in a cell of the grid or looked at as a candidate for a point counts one.
const MAX_HOLE_WORK: usize = 16_000_000;
/// Most cells along each side of the grid the search for holes sorts contours into.
const MAX_HOLE_GRID: usize = 256;
/// For each segment of each subpath of `path`, whether it walls a hole under `rule`: the fill is
/// outside its closed contour at the segment's middle and not inside. An error past
/// [`MAX_HOLE_WORK`].
#[cfg(test)]
fn hole_segments(path: &PathData, rule: FillRule) -> Result<Vec<Vec<bool>>, String> {
hole_segments_counted(path, rule).map(|(holes, _)| holes)
}
/// [`hole_segments`] and the work it did.
#[cfg(test)]
fn hole_segments_counted(path: &PathData, rule: FillRule) -> Result<(Vec<Vec<bool>>, usize), String> {
let mut search = HoleSearch::new(path, rule)?;
let mut holes = Vec::with_capacity(path.subpaths.len());
for (i, sp) in path.subpaths.iter().enumerate() {
let segments = if sp.closed { sp.anchors.len() } else { 0 };
holes.push((0..segments).map(|segment| search.is_hole(i, sp.segment(segment).eval(0.5))).collect::<Result<Vec<_>, _>>()?);
}
Ok((holes, search.work))
}
/// The closed contours of a profile sorted into a grid over the bounds of them all, each listed in
/// the cells its bounds reach into, to find which parts of the profile's edges wall a hole under
/// its fill rule. A point is tested only against the contours of its cell. Every test counts
/// toward [`MAX_HOLE_WORK`].
struct HoleSearch {
/// Each subpath's outline, its bounds, and for a closed one which way it winds.
contours: Vec<(BezPath, Rect, Option<i32>)>,
rule: FillRule,
area: Option<Rect>,
side: usize,
grid: Vec<Vec<usize>>,
work: usize,
}
impl HoleSearch {
fn new(path: &PathData, rule: FillRule) -> Result<Self, String> {
// Each contour takes a profile point, as in `revolve_curve_samples`.
if path.subpaths.len() > MAX_PROFILE_POINTS {
return Err(TOO_COMPLEX_PROFILE.into());
}
let contours: Vec<(BezPath, Rect, Option<i32>)> = path
.subpaths
.iter()
.map(|sp| {
let contour = PathData::new(vec![sp.clone()]).to_bezpath();
let bounds = contour.bounding_box();
let direction = sp.closed.then(|| if contour.area() < 0.0 { -1 } else { 1 });
(contour, bounds, direction)
})
.collect();
let closed = || contours.iter().enumerate().filter(|(_, (_, _, d))| d.is_some());
let area = closed().map(|(_, (_, b, _))| *b).reduce(|a, b| a.union(b));
let side = ((closed().count() as f64).sqrt().ceil() as usize).clamp(1, MAX_HOLE_GRID);
let mut search = HoleSearch { contours: vec![], rule, area, side, grid: vec![vec![]; side * side], work: 0 };
for (j, (_, b, _)) in closed() {
let cells = search.cells(*b);
search.spend(cells.len())?;
for c in cells {
if let Some(c) = search.grid.get_mut(c) {
c.push(j);
}
}
}
search.contours = contours;
Ok(search)
}
fn spend(&mut self, n: usize) -> Result<(), String> {
self.work = self.work.saturating_add(n);
if self.work > MAX_HOLE_WORK { Err(TOO_COMPLEX_PROFILE.to_string()) } else { Ok(()) }
}
/// The grid cells `r` reaches into.
fn cells(&self, r: Rect) -> Vec<usize> {
let Some(a) = self.area else { return vec![] };
let side = self.side;
let at = |v: f64, lo: f64, hi: f64| {
let span = hi - lo;
if span > 0.0 { (((v - lo) / span * side as f64).floor().max(0.0) as usize).min(side - 1) } else { 0 }
};
let (x0, x1) = (at(r.x0, a.x0, a.x1), at(r.x1, a.x0, a.x1));
let (y0, y1) = (at(r.y0, a.y0, a.y1), at(r.y1, a.y0, a.y1));
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| y * side + x)).collect()
}
/// The closed contours other than `own` whose bounds may meet `r`, each once.
fn candidates(&mut self, own: usize, r: Rect) -> Result<Vec<usize>, String> {
let mut out = vec![];
for c in self.cells(r) {
let listed = self.grid.get(c).map_or(&[][..], Vec::as_slice);
out.extend(listed.iter().copied().filter(|j| *j != own));
}
self.spend(out.len())?;
out.sort_unstable();
out.dedup();
Ok(out)
}
/// Whether the edge of closed subpath `own` at `p` walls a hole: the fill is outside its
/// contour there and not inside.
fn is_hole(&mut self, own: usize, p: Point) -> Result<bool, String> {
let Some(direction) = self.contours.get(own).and_then(|c| c.2) else { return Ok(false) };
let mut others = 0i32;
for j in self.candidates(own, Rect::from_points(p, p))? {
let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue };
if !bounds.contains(p) {
continue;
}
let (elements, winding) = (contour.elements().len(), contour.winding(p));
self.spend(elements)?;
others += winding;
}
Ok(match self.rule {
FillRule::EvenOdd => others % 2 != 0,
FillRule::NonZero => others != 0 && others + direction == 0,
})
}
/// The line from `a` to `b` on subpath `own`, cut where it crosses another closed contour: each
/// part's end, the last one `b`, and whether that part walls a hole at its middle.
fn parts(&mut self, own: usize, a: Point, b: Point) -> Result<Vec<(Point, bool)>, String> {
let mut out = vec![];
let mut from = 0.0;
for t in self.crossings(own, a, b)?.into_iter().chain([1.0]) {
let hole = self.is_hole(own, a.lerp(b, (from + t) * 0.5))?;
out.push((if t < 1.0 { a.lerp(b, t) } else { b }, hole));
from = t;
}
Ok(out)
}
/// Where the line from `a` to `b` on closed subpath `own` crosses another closed contour, as
/// sorted fractions of the way from `a` to `b`, strictly between them.
fn crossings(&mut self, own: usize, a: Point, b: Point) -> Result<Vec<f64>, String> {
if self.contours.get(own).and_then(|c| c.2).is_none() {
return Ok(vec![]);
}
let line = vectorcraft_geom::kurbo::Line::new(a, b);
let reach = Rect::from_points(a, b);
let mut out = vec![];
for j in self.candidates(own, reach)? {
let Some((contour, bounds, Some(_))) = self.contours.get(j) else { continue };
if bounds.x0 > reach.x1 || bounds.x1 < reach.x0 || bounds.y0 > reach.y1 || bounds.y1 < reach.y0 {
continue;
}
let found: Vec<f64> = contour.segments().flat_map(|s| s.intersect_line(line)).map(|i| i.line_t).collect();
self.spend(contour.elements().len())?;
out.extend(found.into_iter().filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9));
}
out.sort_by(f64::total_cmp);
out.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
Ok(out)
}
}
/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn,
/// each turned out of the region `rule` fills.
fn revolve_faces(
path: &PathData,
rule: FillRule,
bounds: Rect,
options: Revolve,
mut mesh: Option<&mut Vec<solid::Face3>>,
) -> Result<Vec<Face>, String> {
let o = options.sanitized();
let curve_samples = match o.curve_samples {
Some(samples) => samples.clamp(1, CURVE_SAMPLES),
None => revolve_curve_samples([path], o)?,
};
// For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so
// each part's normal turns toward a hole only where it walls one.
let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None };
// A face kept for a mesh: its normal follows from its corners.
let keep = |mesh: &mut Vec<solid::Face3>, face: Vec<V3>, normal: V3| {
mesh.push((vec![face], normal));
if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) }
if mesh.len() > MAX_REVOLVE_FACES { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) }
};
if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
return Err("Revolve requires finite profile coordinates within 100 million points".into());
@@ -470,7 +702,7 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti
let center_y = (bounds.y0 + bounds.y1) * 0.5;
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1);
let steps = o.steps();
let sweep = o.angle.to_radians();
let smooth = o.shade && o.smooth_shading;
let [lx, ly, lz] = o.light_direction();
@@ -489,32 +721,33 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti
if let Some(face) = face {
faces.push(face);
mesh_cost += cost;
if mesh_cost > MAX_FACES {
return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into());
if mesh_cost > MAX_REVOLVE_FACES {
return Err(TOO_COMPLEX_SURFACE.into());
}
}
Ok(())
};
let mut count = 0;
let mut curve_budget = 0;
for sp in &path.subpaths {
for (contour, sp) in path.subpaths.iter().enumerate() {
let mut profile = Vec::new();
let mut tangents = Vec::new();
let mut hole_edges = Vec::new();
if let Some(first) = sp.anchors.first() {
profile.push(first.p);
count += 1;
if count > MAX_PROFILE_POINTS {
return Err("Revolve profile is too complex".into());
return Err(TOO_COMPLEX_PROFILE.into());
}
}
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
// Bound work before sampling any data. Curves get sixteen samples, straight edges one.
// Bound work before sampling any data. Straight edges get one sample.
if segments > MAX_PROFILE_POINTS {
return Err("Revolve profile is too complex".into());
return Err(TOO_COMPLEX_PROFILE.into());
}
for i in 0..segments {
let curve = sp.segment(i);
let samples = if sp.segment_is_line(i) { 1 } else { 16 };
let samples = if sp.segment_is_line(i) { 1 } else { curve_samples };
for k in 1..=samples {
let t0 = (k - 1) as f64 / samples as f64;
let t1 = k as f64 / samples as f64;
@@ -536,11 +769,19 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti
let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2();
if v.hypot2() > 1e-20 { v } else { b - a }
};
tangents.push([tangent(t0), tangent(t1)]);
profile.push(b);
count += 1;
if count > MAX_PROFILE_POINTS {
return Err("Revolve profile is too complex".into());
let a = profile.last().copied().unwrap_or(b);
let parts = match search.as_mut() {
Some(search) => search.parts(contour, a, b)?,
None => vec![(b, false)],
};
for (end, hole) in parts {
tangents.push([tangent(t0), tangent(t1)]);
hole_edges.push(hole);
profile.push(end);
count += 1;
if count > MAX_PROFILE_POINTS {
return Err(TOO_COMPLEX_PROFILE.into());
}
}
}
}
@@ -565,8 +806,9 @@ fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Opti
let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum();
if area < 0.0 { -1.0 } else { 1.0 }
};
for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() {
for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() {
let [a, b] = pair else { continue };
let turn = if hole { -turn } else { turn };
if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
return Err("Revolve profile contains invalid coordinates".into());
}
+4 -3
View File
@@ -76,10 +76,11 @@ pub fn inflate_meshes(
}
/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than
/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]).
pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result<Mesh, String> {
/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). The
/// walls of holes under `rule`, such as the counter of an O, face into the hole.
pub fn revolve_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result<Mesh, String> {
let mut faces = vec![];
crate::revolve_faces(path, bounds, options, Some(&mut faces))?;
crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?;
mesh(&faces)
}
+59 -3
View File
@@ -231,7 +231,7 @@ fn a_revolved_profile_is_a_closed_cylinder() {
let p = PathData::from_bezpath(&b);
let r = p.bounds().unwrap();
let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() };
let m = revolve_mesh(&p, r, o).unwrap();
let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap();
assert!(closed(&m));
let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0;
assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m));
@@ -246,7 +246,7 @@ fn a_partial_revolve_caps_its_ends_with_sectors() {
let p = PathData::from_bezpath(&b);
let r = p.bounds().unwrap();
let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() };
let m = revolve_mesh(&p, r, o).unwrap();
let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap();
let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0);
assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m));
}
@@ -261,7 +261,7 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct
let p = PathData::from_bezpath(&b);
let r = p.bounds().unwrap();
let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() };
let m = revolve_mesh(&p, r, o).unwrap();
let m = revolve_mesh(&p, FillRule::NonZero, r, o).unwrap();
assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles");
assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length");
assert!(closed(&m), "{from} to {to}");
@@ -269,6 +269,62 @@ fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direct
}
}
/// The radial direction of every triangle's normal at the wall `x` pt from the axis, each as the
/// sign of the normal's part along the way from the axis: 1 away from it, -1 toward it.
fn wall_directions(m: &Mesh, x: f64) -> Vec<f64> {
m.triangles
.iter()
.filter_map(|t| {
let [a, b, c] = corners(m, *t);
let centre = [(a[0] + b[0] + c[0]) / 3.0, 0.0, (a[2] + b[2] + c[2]) / 3.0];
let radius = centre[0].hypot(centre[2]);
let n = m.normals[t[0] as usize];
((radius - x).abs() < 1.0 && n[1].abs() < 0.5).then(|| dot(n, centre).signum())
})
.collect()
}
#[test]
fn the_wall_of_a_counter_faces_into_the_hole() {
// A square ring 20 pt thick, its axis at the outer square's left edge minus 10: the outer wall
// reaches 110 pt from the axis, and the hole's far wall 90 pt from it.
let ring = |hole_clockwise: bool, rule: FillRule| {
let mut b = BezPath::new();
square(&mut b, 0.0, 0.0, 100.0, false);
square(&mut b, 20.0, 20.0, 60.0, hole_clockwise);
let (p, r) = shape(&b);
revolve_mesh(&p, rule, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap()
};
for (clockwise, rule) in [(true, FillRule::NonZero), (true, FillRule::EvenOdd), (false, FillRule::EvenOdd)] {
let m = ring(clockwise, rule);
let outer = wall_directions(&m, 110.0);
assert!(!outer.is_empty() && outer.iter().all(|s| *s > 0.0), "outer wall, {clockwise} {rule:?}");
let hole = wall_directions(&m, 90.0);
assert!(!hole.is_empty() && hole.iter().all(|s| *s < 0.0), "the hole's far wall faces the axis, {clockwise} {rule:?}");
}
// Wound the same way under the nonzero rule, the inner square is not a hole.
let m = ring(false, FillRule::NonZero);
let inner = wall_directions(&m, 90.0);
assert!(!inner.is_empty() && inner.iter().all(|s| *s > 0.0));
}
#[test]
fn an_edge_that_crosses_another_contour_faces_the_hole_only_where_it_walls_it() {
// Two squares overlapping under the even-odd rule leave their overlap empty. The first square's
// right edge (110 pt from the axis) and the second's left edge (50 pt from it) each wall the
// overlap along part of their length and the outside along the rest.
let mut b = BezPath::new();
square(&mut b, 0.0, 0.0, 100.0, false);
square(&mut b, 40.0, 40.0, 100.0, false);
let (p, r) = shape(&b);
let m = revolve_mesh(&p, FillRule::EvenOdd, r, Revolve { offset: 10.0, segments: 32, ..Revolve::default() }).unwrap();
for radius in [110.0, 50.0] {
let walls = wall_directions(&m, radius);
assert!(walls.iter().any(|s| *s > 0.0) && walls.iter().any(|s| *s < 0.0), "both ways at {radius}");
}
assert!(wall_directions(&m, 150.0).iter().all(|s| *s > 0.0), "the second square's far edge faces out");
}
#[test]
fn inflate_puffs_the_front_toward_the_viewer() {
let mut b = BezPath::new();
+1
View File
@@ -4,6 +4,7 @@ mod caps;
mod classic;
mod lighting;
mod multiple_lights;
mod profile_limits;
mod smooth;
#[test]
+3 -3
View File
@@ -93,14 +93,14 @@ fn caps_share_surface_limits_and_malformed_inputs_stay_bounded() {
assert!(faces.len() <= 384);
let mut excessive = BezPath::new();
excessive.move_to((40.0, 0.0));
for i in 1..=1024 {
for i in 1..=MAX_PROFILE_POINTS {
excessive.line_to((40.0, i as f64));
}
assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, 1024.0), options()).is_err());
assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, MAX_PROFILE_POINTS as f64), options()).is_err());
assert!(revolve(&PathData::default(), bounds, options()).unwrap().is_empty());
// Viewed along the axis, the side walls disappear but the caps still consume
// the same face budget. Repeated rings cannot bypass it through degenerate walls.
let repeated = PathData::new(vec![path.subpaths[0].clone(); 129]);
let repeated = PathData::new(vec![path.subpaths[0].clone(); 1025]);
let o = Revolve { segments: 128, ..options() };
assert!(revolve(&repeated, bounds, Revolve { cap_ends: false, ..o }).unwrap().is_empty());
assert!(revolve(&repeated, bounds, o).unwrap_err().contains("surface is too complex"));
+150
View File
@@ -0,0 +1,150 @@
use crate::*;
use vectorcraft_geom::{Anchor, SubPath};
/// A closed star of `points` tips: twice as many straight edges.
fn star(points: usize) -> PathData {
let anchors = (0..points * 2)
.map(|i| {
let (s, c) = (i as f64 * std::f64::consts::PI / points as f64).sin_cos();
let r = if i % 2 == 0 { 100.0 } else { 60.0 };
Anchor::corner(Point::new(200.0 + r * c, 200.0 + r * s))
})
.collect();
PathData::new(vec![SubPath::new(anchors, true)])
}
/// An open wave of `anchors` smooth anchors: one curved segment between each two.
fn wave(anchors: usize) -> PathData {
let anchors = (0..anchors)
.map(|i| {
let p = Point::new(100.0 + if i % 2 == 0 { 0.0 } else { 20.0 }, i as f64);
Anchor::smooth(p, p + Vec2::new(6.0, 0.3))
})
.collect();
PathData::new(vec![SubPath::new(anchors, false)])
}
fn revolved(path: &PathData, options: Revolve) -> Result<Vec<Face>, String> {
revolve(path, path.bounds().unwrap(), options)
}
#[test]
fn a_star_of_600_points_revolves_at_the_default_segments() {
let faces = revolved(&star(600), Revolve::default()).unwrap();
// The tip on the axis gives two edges with a side of no length.
assert!((1198 * 64 * 2..=1200 * 64 * 2).contains(&faces.len()), "{}", faces.len());
let refused = revolved(&star(600), Revolve { segments: 128, ..Revolve::default() }).unwrap_err();
assert!(refused.contains("reduce Segments"), "{refused}");
}
#[test]
fn a_curved_profile_past_the_old_limit_takes_fewer_samples_for_each_curve() {
let wave = wave(200);
assert!(revolved(&wave, Revolve::default()).is_ok());
let samples = revolve_curve_samples([&wave], Revolve::default()).unwrap();
assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}");
}
#[test]
fn a_profile_within_the_limits_keeps_every_sample() {
assert_eq!(revolve_curve_samples([&wave(50)], Revolve::default()), Ok(CURVE_SAMPLES));
assert_eq!(revolve_curve_samples([&star(5)], Revolve::default()), Ok(CURVE_SAMPLES));
}
#[test]
fn two_thousand_curves_fit_at_low_segments_and_are_refused_before_any_face_at_high() {
let wave = wave(2001);
let samples = revolve_curve_samples([&wave], Revolve { segments: 8, ..Revolve::default() }).unwrap();
assert!((MIN_CURVE_SAMPLES..CURVE_SAMPLES).contains(&samples), "{samples}");
assert!(revolved(&wave, Revolve { segments: 8, ..Revolve::default() }).is_ok());
let refused = revolve_curve_samples([&wave], Revolve { segments: 128, ..Revolve::default() }).unwrap_err();
assert!(refused.contains("reduce Segments"), "{refused}");
assert_eq!(revolved(&wave, Revolve { segments: 128, ..Revolve::default() }), Err(refused));
}
#[test]
fn too_many_anchors_are_refused_as_a_profile() {
let refused = revolve_curve_samples([&wave(MAX_PROFILE_POINTS + 1)], Revolve { segments: 8, ..Revolve::default() }).unwrap_err();
assert_eq!(refused, TOO_COMPLEX_PROFILE);
}
#[test]
fn every_contour_takes_a_point_even_an_empty_one() {
// Contours of one anchor and of none, with no segment between them.
let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(100.0, i as f64))] }, false);
let path = PathData::new((0..=MAX_PROFILE_POINTS).map(lone).collect());
assert_eq!(revolve_curve_samples([&path], Revolve::default()), Err(TOO_COMPLEX_PROFILE.to_string()));
assert_eq!(hole_segments(&path, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string()), "before any contour is built");
let given = Revolve { curve_samples: Some(CURVE_SAMPLES), ..Revolve::default() };
assert_eq!(revolve_mesh(&path, FillRule::NonZero, path.bounds().unwrap(), given).map(|_| ()), Err(TOO_COMPLEX_PROFILE.to_string()));
}
#[test]
fn a_profile_at_the_point_limit_is_taken_and_one_past_it_is_refused() {
let straight = |anchors: usize| {
let anchors = (0..anchors).map(|i| Anchor::corner(Point::new(100.0 + (i % 2) as f64, i as f64))).collect();
PathData::new(vec![SubPath::new(anchors, false)])
};
let narrow = Revolve { angle: 1.0, cap_ends: false, segments: 8, ..Revolve::default() };
assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS)], narrow), Ok(CURVE_SAMPLES));
assert_eq!(revolve_curve_samples([&straight(MAX_PROFILE_POINTS + 1)], narrow), Err(TOO_COMPLEX_PROFILE.to_string()));
}
#[test]
fn the_profiles_of_one_object_share_one_budget() {
let (one, other) = (star(600), star(600));
let o = Revolve::default();
assert!(revolve_curve_samples([&one], o).is_ok());
assert!(revolve_curve_samples([&other], o).is_ok());
let refused = revolve_curve_samples([&one, &other], o).unwrap_err();
assert!(refused.contains("reduce Segments"), "{refused}");
// Contours of one profile count together as well.
let both = PathData::new(one.subpaths.iter().chain(&other.subpaths).cloned().collect());
assert_eq!(revolve_curve_samples([&both], o), Err(refused));
}
#[test]
fn deeply_nested_contours_stop_the_search_for_holes() {
// Concentric triangles: each tests every contour outside it.
let triangle = |r: f64| {
let anchors = [(0.0, -r), (r, r), (-r, r)].iter().map(|(x, y)| Anchor::corner(Point::new(200_000.0 + x, 200_000.0 + y))).collect();
SubPath::new(anchors, true)
};
let nested = PathData::new((1..=4000).map(|i| triangle(i as f64)).collect());
assert_eq!(hole_segments(&nested, FillRule::NonZero), Err(TOO_COMPLEX_PROFILE.to_string()));
let few = PathData::new((1..=4).map(|i| triangle(i as f64)).collect());
assert_eq!(hole_segments(&few, FillRule::EvenOdd).map(|h| h.len()), Ok(4));
}
#[test]
fn a_point_is_tested_only_against_the_contours_near_it() {
// 3,000 separate squares: each segment's middle lies in the bounds of its own square only.
let squares = (0..3000)
.map(|i| {
let (x, y) = ((i % 60) as f64 * 20.0, (i / 60) as f64 * 20.0);
let corners = [(x, y), (x + 10.0, y), (x + 10.0, y + 10.0), (x, y + 10.0)];
SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true)
})
.collect();
let (holes, work) = hole_segments_counted(&PathData::new(squares), FillRule::NonZero).unwrap();
assert!(holes.iter().flatten().all(|hole| !hole));
assert!(work < 200_000, "{work}");
}
#[test]
fn overlapping_contours_are_holes_only_where_they_overlap() {
let square = |x: f64, y: f64, side: f64| {
let corners = [(x, y), (x + side, y), (x + side, y + side), (x, y + side)];
SubPath::new(corners.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect(), true)
};
let overlapping = PathData::new(vec![square(0.0, 0.0, 100.0), square(40.0, 40.0, 100.0)]);
let expected = vec![vec![false, true, true, false], vec![true, false, false, true]];
assert_eq!(hole_segments(&overlapping, FillRule::EvenOdd), Ok(expected));
}
#[test]
fn given_samples_are_used_for_each_curve() {
let wave = wave(4);
let at = |samples| revolved(&wave, Revolve { curve_samples: Some(samples), cap_ends: false, segments: 8, ..Revolve::default() }).unwrap().len();
assert_eq!(at(4) * 2, at(8));
}
+1 -1
View File
@@ -118,6 +118,6 @@ fn unshaded_legacy_degenerate_and_high_complexity_surfaces_stay_bounded() {
}
assert!(revolve(&p, b, Revolve { smooth_shading: true, angle: 0.0, ..o }).unwrap().is_empty());
let mut p = path("M40 0");
p.subpaths[0].anchors.extend((1..400).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64))));
p.subpaths[0].anchors.extend((1..1100).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64))));
assert!(revolve(&p, p.bounds().unwrap(), Revolve { smooth_shading: true, segments: 128, offset: 30.0, ..o }).is_err());
}
+59 -3
View File
@@ -11,6 +11,7 @@ use vectorcraft_doc::{NodeId, Unit};
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_pathops::addons::pathtools::{Inspection, inspect};
use crate::guides::DrawSnap;
use crate::xform::measure;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -27,6 +28,8 @@ pub struct MeasureInspectTool {
before: Option<(Point, Point)>,
/// The path under the pointer and what it reads there.
hover: Option<(NodeId, Inspection)>,
/// Snapping for both ends of a measurement.
snap: DrawSnap,
}
/// An area of `pt2` square points in `unit` (`12.5 mm²`).
@@ -87,7 +90,7 @@ impl Tool for MeasureInspectTool {
match ev.kind {
PointerKind::Move => self.hover = Self::inspect_at(cx, ev.pos),
PointerKind::Down => {
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
let p = self.snap.measure(cx, ev.pos, None);
self.before = self.line;
self.line = Some((p, p));
self.dragging = true;
@@ -95,7 +98,7 @@ impl Tool for MeasureInspectTool {
}
PointerKind::Drag | PointerKind::Up if self.dragging => {
if let Some((a, _)) = self.line {
self.line = Some((a, crate::xform::measure_end_point(cx, a, ev.pos, ev.mods.shift)));
self.line = Some((a, crate::xform::measure_end_point(cx, &mut self.snap, a, ev.pos, ev.mods.shift)));
}
self.dragging = ev.kind == PointerKind::Drag;
}
@@ -138,6 +141,7 @@ impl Tool for MeasureInspectTool {
out.push(Overlay::Anchor { p: at, color: ORANGE, filled: i.anchor.is_some(), size: 6.0 });
out.push(Overlay::Measure { p: at + off, text: readout(cx, i) });
}
out.extend(self.snap.guides().iter().filter(|_| self.dragging).cloned());
out
}
@@ -161,7 +165,8 @@ impl Tool for MeasureInspectTool {
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, Selection, TextObject};
use vectorcraft_text::{FontDb, layout as layout_text};
#[test]
fn a_drag_measures_and_hovering_a_path_reads_it() {
@@ -227,4 +232,55 @@ mod tests {
assert!((o["distance"].as_f64().unwrap() - 36.0).abs() < 1e-9, "on the 9 pt grid: {o}");
assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "kept horizontal: {o}");
}
/// With Snap to Glyph on, both ends and a Shift-drag's end land on the guides of live type.
#[test]
fn its_ends_land_on_glyph_guides_with_snap_to_glyph() {
let mut d = Document::new(500.0, 500.0);
let (l, id) = (d.layers[0].id, d.alloc_id());
let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() });
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
let lay = layout_text(FontDb::global(), &text);
let base = (text.xf * lay.line_xf * Point::new(lay.lines[0].x0, lay.lines[0].baseline)).y;
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let run = |c: &ToolContext, from: Point, to: Point, shift: bool| {
let mods = Mods { shift, ..Mods::default() };
let mut t = MeasureInspectTool::default();
t.pointer(c, &PointerEvent::new(PointerKind::Down, from.x, from.y));
t.pointer(c, &PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(mods));
let o = t.options();
let a = Point::new(o["x"].as_f64().unwrap(), o["y"].as_f64().unwrap());
(a, a + Vec2::new(o["dx"].as_f64().unwrap(), o["dy"].as_f64().unwrap()))
};
let (from, to) = (Point::new(400.0, base + 2.0), Point::new(450.0, base - 40.0));
assert_eq!(run(&on, from, to, false).0, Point::new(400.0, base));
let (from, to) = (Point::new(300.0, base - 100.0), Point::new(401.0, base + 1.0));
let landed = run(&on, from, to, true).1;
assert!(landed.distance(Point::new(400.0, base)) < 1e-9, "{landed:?}");
let off = ToolContext { snap_to_glyph: false, ..on };
let stayed = run(&off, from, to, true).1;
assert!(stayed.distance(to) < 1e-9, "{stayed:?}");
}
/// The guide an end lands on shows while the drag lasts.
#[test]
fn a_drag_shows_the_glyph_guide_it_lands_on() {
let mut d = Document::new(500.0, 500.0);
let (l, id) = (d.layers[0].id, d.alloc_id());
let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() });
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
let lay = layout_text(FontDb::global(), &text);
let base = (text.xf * lay.line_xf * Point::new(lay.lines[0].x0, lay.lines[0].baseline)).y;
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let mut t = MeasureInspectTool::default();
t.pointer(&on, &PointerEvent::new(PointerKind::Down, 400.0, 50.0));
let (drag, up) = (PointerKind::Drag, PointerKind::Up);
let count = |t: &MeasureInspectTool| t.overlays(&on).iter().filter(|o| !matches!(o, Overlay::Measure { .. })).count();
t.pointer(&on, &PointerEvent::new(drag, 450.0, base + 2.0));
assert!(count(&t) > 1, "the measured line and the guide");
t.pointer(&on, &PointerEvent::new(up, 450.0, base + 2.0));
assert_eq!(count(&t), 1, "the measured line alone");
}
}
+22
View File
@@ -288,6 +288,11 @@ pub fn square(d: Vec2) -> Vec2 {
/// state: hovering gathers them again only after the document, the selection, what is left out,
/// the window or the snapping options changed; a press always gathers them afresh, and the drag
/// it starts keeps them, the document then showing the drag's own previews.
///
/// The Measure and Measure & Inspect tools keep one too, for the ends of a drag only. They do not
/// ask it while hovering. [`Self::measure`] gathers [`Targets::for_measure`] instead, and a press
/// reuses those targets while nothing they depend on changed. It has guides to show only while
/// Snap to Glyph gives guides.
#[derive(Default)]
pub struct DrawSnap {
/// The targets, and what they were gathered for (none: for one drag only).
@@ -348,6 +353,23 @@ impl DrawSnap {
self.snap(cx, p, from)
}
/// Snap an end of a measurement at `p` ([`Targets::for_measure`]), reusing the targets while
/// nothing they depend on changed, so a drag lays out the type once. With Snap to Glyph giving
/// no guides, [`Self::guides`] stays empty.
pub fn measure(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point {
let key = SnapKey::of(cx, &[]);
let glyphs = key.snap_to_glyph.is_some();
if self.targets.as_ref().is_none_or(|(k, _)| k.as_ref() != Some(&key)) {
let snap = PointSnap::new(cx, || Targets::for_measure(cx.doc, visible(cx), key.snap_to_glyph));
self.targets = Some((Some(key), snap));
}
let q = self.snap(cx, p, from);
if !glyphs {
self.guides.clear();
}
q
}
/// Snap the point pressed at `p` onto the art's anchors and paths alone, within the Smart
/// Guides snapping tolerance (the Knife's straight cut). The targets serve this press only.
pub fn press_on_paths(&mut self, cx: &ToolContext, p: Point) -> Point {
+11
View File
@@ -213,6 +213,17 @@ impl Targets {
t.index()
}
/// Targets for the ends of a measurement: [`Self::collect`]'s, and with `glyphs` (Snap to Glyph
/// on) the guides those options ask for from the live text in `visible` (all of it for None),
/// without the outlines' anchor points, which Anchor Point gives the Pen and moved objects.
pub fn for_measure(doc: &Document, visible: Option<Rect>, glyphs: Option<GlyphSnap>) -> Self {
let mut t = Self::collect(doc, &[], None);
if let Some(o) = glyphs {
t.push_glyphs(doc, &[], visible, GlyphSnap { anchors: false, ..o });
}
t.index()
}
/// Targets for a dragged direction handle of path `id`: the other art, and the anchors of `id`
/// (its bounds and the segments through the handle move with it, so they would chase it).
pub fn for_handle(doc: &Document, id: NodeId) -> Self {
+279 -12
View File
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_geom::{Point, Vec2};
use super::CYAN;
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
@@ -15,6 +16,8 @@ pub struct MeasureTool {
dragging: bool,
/// The line measured before the drag under way, which Escape puts back.
before: Option<(Point, Point)>,
/// Snapping for both ends, its targets kept while nothing they depend on changes.
snap: DrawSnap,
}
/// (distance, angle in degrees counter-clockwise, dx, dy)
@@ -29,10 +32,10 @@ pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) {
/// tool snaps a Shift-drawn end. With Snap to Point alone, the point `p` snaps to (an anchor, or
/// in line with a ruler guide) is projected instead. When the snapped point lies nearer another
/// step (the grid rounds it past the halfway angle) or behind the start, it is turned back onto
/// the pointer's step.
pub(crate) fn end_point(cx: &ToolContext, a: Point, p: Point, shift: bool) -> Point {
/// the pointer's step. With View › Snap to Glyph on, the guides of live type are targets too.
pub(crate) fn end_point(cx: &ToolContext, snap: &mut DrawSnap, a: Point, p: Point, shift: bool) -> Point {
if !shift {
return crate::guides::snap_draw(cx, p, &[]).0;
return snap.measure(cx, p, None);
}
let d = vectorcraft_geom::constrain_angle_from(p - a, 45.0, cx.constrain_angle);
let len = d.hypot();
@@ -41,10 +44,10 @@ pub(crate) fn end_point(cx: &ToolContext, a: Point, p: Point, shift: bool) -> Po
}
let u = d / len;
let to_point = cx.snap_to_point && !(cx.smart_guides || cx.snap_to_grid || cx.snap_to_pixel);
let s = if to_point { crate::guides::snap_draw(cx, p, &[]).0 } else { p };
let s = if to_point { snap.measure(cx, p, None) } else { p };
let q = a + u * (s - a).dot(u);
let leave = crate::guides::Leave::segment(cx, a, true);
let r = crate::guides::PointSnap::new(cx, || crate::guides::Targets::collect(cx.doc, &[], None)).snap_from(cx, q, Some(&leave)).0;
let r = snap.measure(cx, q, Some(&leave));
let v = r - a;
if v.cross(u).abs() <= 1e-9 * v.hypot().max(1.0) && v.dot(u) >= 0.0 { r } else { a + u * v.hypot() }
}
@@ -61,14 +64,14 @@ impl Tool for MeasureTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
let p = self.snap.measure(cx, ev.pos, None);
self.before = self.line;
self.line = Some((p, p));
self.dragging = true;
}
PointerKind::Drag | PointerKind::Up if self.dragging => {
if let Some((a, _)) = self.line {
self.line = Some((a, end_point(cx, a, ev.pos, ev.mods.shift)));
self.line = Some((a, end_point(cx, &mut self.snap, a, ev.pos, ev.mods.shift)));
}
self.dragging = ev.kind == PointerKind::Drag;
}
@@ -86,10 +89,8 @@ impl Tool for MeasureTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some((a, b)) = self.line else { return vec![] };
if a == b {
return vec![];
}
let guides = self.snap.guides().iter().filter(|_| self.dragging).cloned();
let Some((a, b)) = self.line.filter(|(a, b)| a != b) else { return guides.collect() };
let mut out = vec![Overlay::Line { a, b, color: CYAN, dashed: false }];
// Measurement Labels decides the readout; the line the tool draws stays.
if cx.measurement_labels {
@@ -99,6 +100,7 @@ impl Tool for MeasureTool {
text: format!("D: {}\nAngle: {ang:.1}°\n{}", cx.len(dist), cx.offset_label(dx, dy)),
});
}
out.extend(guides);
out
}
@@ -121,7 +123,11 @@ impl Tool for MeasureTool {
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
use crate::{GlyphSnap, ScreenFrame};
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, Selection, TextObject};
use vectorcraft_geom::kurbo::{ParamCurve, Shape};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_text::{FontDb, layout as layout_text};
#[test]
fn horizontal_drag_reports_positive_zero_angle() {
@@ -324,4 +330,265 @@ mod tests {
assert_eq!(t.options(), first);
assert!((t.options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
}
/// A document holding point type `text` at (100, 300), 60 pt, turned `angle` radians about
/// where it starts, and the type.
fn type_doc(text: &str, angle: f64, visible: bool, locked: bool) -> (Document, TextObject) {
let mut d = Document::new(500.0, 500.0);
let (l, id) = (d.layers[0].id, d.alloc_id());
let at = Point::new(100.0, 300.0);
let mut t = TextObject::point(at, text, CharStyle { size: 60.0, ..Default::default() });
t.xf = Affine::rotate_about(angle, at) * t.xf;
let mut n = Node::new(id, NodeKind::Text(Box::new(t.clone())));
(n.visible, n.locked) = (visible, locked);
d.insert(Some(l), 0, n).unwrap();
(d, t)
}
/// Where the baseline of `t`'s first line starts on the page, and its unit direction.
fn baseline(t: &TextObject) -> (Point, Vec2) {
let lay = layout_text(FontDb::global(), t);
let (m, line) = (t.xf * lay.line_xf, &lay.lines[0]);
let (at, along) = (m * Point::new(line.x0, line.baseline), m * Point::new(line.x0 + 1.0, line.baseline));
(at, (along - at) / (along - at).hypot())
}
/// The y of the x-height of `t`'s first glyph, in type that isn't turned.
fn x_height(t: &TextObject) -> f64 {
let lay = layout_text(FontDb::global(), t);
let g = &lay.glyphs[0];
(t.xf * g.xf * Point::new(0.0, -FontDb::global().face_by_id(g.font_id).unwrap().x_height())).y
}
/// The bounds on the page of glyph `i` of `t`, which isn't turned.
fn glyph_bounds(t: &TextObject, i: usize) -> Rect {
t.xf.transform_rect_bbox(layout_text(FontDb::global(), t).glyphs[i].outline.bounding_box())
}
/// Where a measurement dragged from `from` to `to` starts and ends.
fn measured(tool: &mut dyn Tool, cx: &ToolContext, from: Point, to: Point, shift: bool) -> (Point, Point) {
let mods = Mods { shift, ..Mods::default() };
tool.pointer(cx, &PointerEvent::new(PointerKind::Down, from.x, from.y));
tool.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(mods));
tool.pointer(cx, &PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(mods));
let o = tool.options();
let a = Point::new(o["x"].as_f64().unwrap(), o["y"].as_f64().unwrap());
(a, a + Vec2::new(o["dx"].as_f64().unwrap(), o["dy"].as_f64().unwrap()))
}
/// Snap to Glyph: both ends land on a line of type's baseline and x-height and on its glyphs'
/// bounds, each only while its option is on. With Snap to Glyph off they stay at the pointer.
#[test]
fn its_ends_land_on_glyph_guides_with_snap_to_glyph() {
let (d, t) = type_doc("Hg", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let (base, x) = (baseline(&t).0.y, x_height(&t));
let (from, to) = (Point::new(400.0, base + 2.0), Point::new(450.0, x + 2.0));
let (a, b) = measured(&mut MeasureTool::default(), &on, from, to, false);
assert_eq!(a, Point::new(400.0, base), "the press lands on the baseline");
assert_eq!(b, Point::new(450.0, x), "the end lands on the x-height");
let off = ToolContext { snap_to_glyph: false, ..on };
assert_eq!(measured(&mut MeasureTool::default(), &off, from, to, false), (from, to));
// The Character panel's options: without Baseline and Glyph Bounds (the bottom of `H` is
// on the baseline) the press stays, and without x-height the end does.
let o = GlyphSnap { baseline: false, bounds: false, x_height: false, ..GlyphSnap::default() };
let without = ToolContext { glyph_snap: o, ..on };
assert_eq!(measured(&mut MeasureTool::default(), &without, from, to, false), (from, to));
// A corner of the box of `H`, which only Glyph Bounds gives there.
let corner = Point::new(glyph_bounds(&t, 0).x1, glyph_bounds(&t, 0).y0);
let near = corner + Vec2::new(1.5, -1.0);
let bounds = ToolContext { glyph_snap: GlyphSnap { anchors: false, ..GlyphSnap::default() }, ..on };
let landed = measured(&mut MeasureTool::default(), &bounds, Point::new(400.0, 50.0), near, false).1;
assert!(landed.distance(corner) < 1e-9, "{landed:?} vs {corner:?}");
let plain = measured(&mut MeasureTool::default(), &off, Point::new(400.0, 50.0), near, false).1;
assert!((plain.x - corner.x).abs() > 1e-3, "{plain:?}");
}
/// A Shift-drag's end stays on its 45° step and slides along it to where it meets a glyph guide.
#[test]
fn a_shift_drag_slides_along_its_step_to_a_glyph_guide() {
let (d, t) = type_doc("Hg", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let x = x_height(&t);
let (from, to) = (Point::new(300.0, x - 100.0), Point::new(401.0, x + 1.0));
let landed = measured(&mut MeasureTool::default(), &on, from, to, true).1;
assert!(landed.distance(Point::new(400.0, x)) < 1e-9, "{landed:?}");
let off = ToolContext { snap_to_glyph: false, ..on };
let stayed = measured(&mut MeasureTool::default(), &off, from, to, true).1;
assert!(stayed.distance(to) < 1e-9, "{stayed:?}");
}
/// Proximity Guides pull an end, with every other option off. Anchor Point does not.
#[test]
fn proximity_guides_pull_an_end_and_a_glyphs_anchor_points_do_not() {
let (d, t) = type_doc("HHHH", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let none = GlyphSnap { baseline: false, x_height: false, bounds: false, proximity: false, angular: false, anchors: false };
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let off = ToolContext { snap_to_glyph: false, ..on };
let start = Point::new(400.0, 50.0);
let end = |c: &ToolContext, to: Point| measured(&mut MeasureTool::default(), c, start, to, false).1;
let lay = layout_text(FontDb::global(), &t);
let edge = (t.xf * lay.glyphs[1].origin).x;
let to = Point::new(edge + 2.0, 100.0);
assert_eq!(end(&ToolContext { glyph_snap: GlyphSnap { proximity: true, ..none }, ..on }, to).x, edge);
assert_eq!(end(&off, to).x, to.x);
let anchor = t.xf * lay.glyphs[1].outline.segments().next().unwrap().start();
let to = anchor + Vec2::new(1.5, -1.0);
assert_eq!(end(&ToolContext { glyph_snap: GlyphSnap { anchors: true, ..none }, ..on }, to), end(&off, to));
}
/// While a drag snaps with Snap to Glyph on, the guide shows; the drag's end hides it, and with
/// Snap to Glyph off there is only the measured line and its readout.
#[test]
fn a_drag_shows_the_glyph_guide_it_lands_on() {
let (d, t) = type_doc("Hg", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let to = Point::new(450.0, baseline(&t).0.y + 2.0);
let guides = |c: &ToolContext, to: Point, up: bool| {
let mut tool = MeasureTool::default();
tool.pointer(c, &PointerEvent::new(PointerKind::Down, 400.0, 50.0));
tool.pointer(c, &PointerEvent::new(if up { PointerKind::Up } else { PointerKind::Drag }, to.x, to.y));
tool.overlays(c).iter().filter(|o| !matches!(o, Overlay::Measure { .. })).count() - 1
};
assert!(guides(&on, to, false) > 0, "the guide shows during the drag");
assert_eq!(guides(&on, to, true), 0, "the guide goes when the drag ends");
// Off, an end still lines up with a rectangle's top edge, as it always has, with no guide.
let (rect, _) = doc_with_rect();
let off = ToolContext { snap_to_glyph: false, ..cx(&rect, &s, &p) };
let near = Point::new(450.0, 101.0);
assert_eq!(measured(&mut MeasureTool::default(), &off, Point::new(400.0, 50.0), near, false).1.y, 100.0);
assert_eq!(guides(&off, near, false), 0, "no guide with Snap to Glyph off");
}
/// In rotated type the guides run at its angle: the end lands on the baseline, only with
/// Angular Guides on.
#[test]
fn its_ends_land_on_the_guides_of_rotated_type() {
let (d, t) = type_doc("H", 30f64.to_radians(), true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let (at, dir) = baseline(&t);
let to = at + dir * 250.0 + Vec2::new(-dir.y, dir.x) * 2.0;
let off_line = |c: &ToolContext| (measured(&mut MeasureTool::default(), c, Point::new(20.0, 20.0), to, false).1 - at).cross(dir).abs();
assert!(off_line(&on) < 1e-6, "{}", off_line(&on));
let flat = ToolContext { glyph_snap: GlyphSnap { angular: false, ..GlyphSnap::default() }, ..on };
assert!(off_line(&flat) > 1.0, "{}", off_line(&flat));
}
/// A paragraph gives glyph bounds only: no x-height, and the box of a glyph on a later line.
#[test]
fn a_paragraph_gives_the_measure_tool_glyph_bounds_only() {
let (d, t) = type_doc("H\nH", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let start = Point::new(400.0, 50.0);
let to = Point::new(450.0, x_height(&t) + 2.0);
assert_eq!(measured(&mut MeasureTool::default(), &on, start, to, false).1, to, "no x-height guide");
let lay = layout_text(FontDb::global(), &t);
assert_eq!(lay.lines.len(), 2);
let top = glyph_bounds(&t, lay.glyphs.len() - 1).y0;
assert_eq!(measured(&mut MeasureTool::default(), &on, start, Point::new(450.0, top - 2.0), false).1, Point::new(450.0, top));
}
/// Hidden type gives no guides. Locked type does, as it does for the drawing tools.
#[test]
fn hidden_type_gives_no_glyph_guides_and_locked_type_does() {
let (s, p) = (Selection::default(), paint());
let drag = |d: &Document, base: f64| {
let c = ToolContext { snap_to_glyph: true, ..cx(d, &s, &p) };
measured(&mut MeasureTool::default(), &c, Point::new(400.0, 50.0), Point::new(450.0, base + 2.0), false).1
};
let (hidden, t) = type_doc("Hg", 0.0, false, false);
let base = baseline(&t).0.y;
assert_eq!(drag(&hidden, base).y, base + 2.0);
let (locked, _) = type_doc("Hg", 0.0, true, true);
assert_eq!(drag(&locked, base).y, base);
}
/// Grid and pixel snapping come first; Snap to Glyph needs Smart Guides and Alignment Guides.
#[test]
fn glyph_guides_keep_their_place_among_the_other_snaps() {
let (d, t) = type_doc("Hg", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let base = baseline(&t).0.y;
let (start, to) = (Point::new(400.0, 50.0), Point::new(450.0, base + 2.0));
let end = |c: &ToolContext| measured(&mut MeasureTool::default(), c, start, to, false).1;
assert_eq!(end(&on), Point::new(to.x, base));
let grid = ToolContext { snap_to_grid: true, ..on };
assert_eq!(end(&grid), vectorcraft_geom::snap::snap_point_to_grid(to, grid.grid_step()));
assert_eq!(end(&ToolContext { snap_to_pixel: true, ..on }), Point::new(to.x.round(), to.y.round()));
assert_eq!(end(&ToolContext { smart_guides: false, snap_to_point: true, ..on }), to);
assert_eq!(end(&ToolContext { alignment_guides: false, ..on }), to);
}
/// The snapping distance is in screen pixels, so a zoomed-out view reaches farther; type
/// outside the window gives no guides.
#[test]
fn glyph_guides_follow_the_view() {
let (d, t) = type_doc("Hg", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let base = baseline(&t).0.y;
let (start, to) = (Point::new(400.0, 50.0), Point::new(450.0, base - 5.0));
let end = |c: &ToolContext| measured(&mut MeasureTool::default(), c, start, to, false).1;
assert_eq!(end(&on), to, "5 pt is out of reach at 100%");
assert_eq!(end(&ToolContext { zoom: 0.25, ..on }), Point::new(to.x, base));
let frame = |origin: Point| ScreenFrame { origin, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) };
let near = Point::new(to.x, base + 2.0);
let end = |c: &ToolContext| measured(&mut MeasureTool::default(), c, start, near, false).1;
assert_eq!(end(&ToolContext { screen: Some(frame(Point::ZERO)), ..on }), Point::new(to.x, base));
assert_eq!(end(&ToolContext { screen: Some(frame(Point::new(0.0, 1000.0))), ..on }), near);
}
/// A drag lays out the type once: it keeps the guides its press gathered until the document
/// or a snapping option changes.
#[test]
fn a_drag_keeps_the_glyph_guides_its_press_gathered() {
let (d, t) = type_doc("Hg", 0.0, true, false);
let (empty, s, p) = (Document::new(500.0, 500.0), Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let base = baseline(&t).0.y;
let to = Point::new(450.0, base + 2.0);
let end = |tool: &MeasureTool| {
let o = tool.options();
o["y"].as_f64().unwrap() + o["dy"].as_f64().unwrap()
};
let mut tool = MeasureTool::default();
tool.pointer(&on, &PointerEvent::new(PointerKind::Down, 400.0, 50.0));
// The same revision: the guides of the press still serve, though this document has no type.
let later = ToolContext { snap_to_glyph: true, ..cx(&empty, &s, &p) };
tool.pointer(&later, &PointerEvent::new(PointerKind::Drag, to.x, to.y));
assert_eq!(end(&tool), base);
// A new revision gathers them again.
let edited = ToolContext { revision: (1, 0), ..later };
tool.pointer(&edited, &PointerEvent::new(PointerKind::Drag, to.x, to.y));
assert_eq!(end(&tool), to.y);
// So does turning Snap to Glyph off during the drag.
let mut tool = MeasureTool::default();
tool.pointer(&on, &PointerEvent::new(PointerKind::Down, 400.0, 50.0));
tool.pointer(&ToolContext { snap_to_glyph: false, ..on }, &PointerEvent::new(PointerKind::Drag, to.x, to.y));
assert_eq!(end(&tool), to.y);
}
/// Escape brings back the last result, and the next drag still lands on the guides.
#[test]
fn escape_leaves_the_glyph_guides_for_the_next_drag() {
let (d, t) = type_doc("Hg", 0.0, true, false);
let (s, p) = (Selection::default(), paint());
let on = ToolContext { snap_to_glyph: true, ..cx(&d, &s, &p) };
let base = baseline(&t).0.y;
let mut tool = MeasureTool::default();
let to = Point::new(450.0, base + 2.0);
let first = measured(&mut tool, &on, Point::new(400.0, 50.0), to, false);
tool.pointer(&on, &PointerEvent::new(PointerKind::Down, 300.0, 60.0));
tool.pointer(&on, &PointerEvent::new(PointerKind::Drag, 320.0, base + 2.0));
tool.key(&on, ToolKey::Escape, Mods::default());
assert!((tool.options()["y"].as_f64().unwrap() - first.0.y).abs() < 1e-9);
assert_eq!(measured(&mut tool, &on, Point::new(300.0, 60.0), to, false).1, Point::new(450.0, base));
}
}
+3
View File
@@ -20,6 +20,8 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf:
.active()
.filter(|_| app.session.tool_id() == "intertwine")
.and_then(|st| vectorcraft_tools::intertwine::menu_target(&st.doc, &st.selection.objects, p).map(|(g, m)| (st.uid, g, p, m)));
// Auto Spell Check: the underlined word right-clicked, for its suggestions.
app.ui.context_spelling = if app.ui.view.auto_spell_check { super::tool_overlays::misspelled_word_at(app, p) } else { None };
let on_artboards = app.session.tool_id() == "artboard";
if on_artboards {
crate::panels::artboards::right_click(app, p);
@@ -39,6 +41,7 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf:
app.ui.context_locked = None;
app.ui.context_intertwine = None;
app.ui.context_artboards = None;
app.ui.context_spelling = None;
}
if let Some((id, p)) = clicked {
crate::menus::invoke(app, &id, p);
+121 -16
View File
@@ -4868,29 +4868,134 @@ fn cmd_with_a_touchpad_zooms_and_a_whole_notch_scrolls_sideways() {
};
use egui::{MouseWheelSource as S, MouseWheelUnit as U};
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.0005 && moved < 1e-6;
let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64)| (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0;
let zooms_by = |(zoom, _, moved): (f64, vectorcraft_geom::Vec2, f64), want: f64| (zoom / want - 1.0).abs() < 1e-6 && moved < 1e-6;
// `lines` wheel lines to the left.
let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed));
let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64), lines: f64| {
(zoom - 1.0).abs() < 1e-9 && (scroll.x + lines * line).abs() < 0.5 && scroll.y == 0.0
};
assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default");
// A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line.
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a line delta smaller than a notch zooms");
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)])), "several small deltas zoom");
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), "a whole notch in the same turn zooms");
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)])), "point deltas of an unnamed source zoom");
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "a trackpad scroll zooms");
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)])), "its momentum zooms");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "a whole notch scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)])), "a turn of two notches scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)])), "a part of a line from a named wheel scrolls sideways");
// A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line. The zoom
// is exponential in the wheel's motion, so more motion in a turn multiplies the quarter's.
let small = turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)]);
assert!(zooms_in(small), "a line delta smaller than a notch zooms");
let quarter = small.0;
assert!(
zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)]), quarter.powi(2)),
"several small deltas zoom"
);
assert!(
zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)]), quarter.powi(5)),
"a whole notch in the same turn adds its zoom"
);
let point = turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)]);
assert!(zooms_in(point), "point deltas of an unnamed source zoom");
assert!(zooms_by(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)]), point.0), "a trackpad scroll zooms as far");
assert!(zooms_by(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)]), point.0), "its momentum zooms as far");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "a whole notch scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)]), 2.0), "a turn of two notches scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)]), 0.25), "a part of a line from a named wheel scrolls sideways");
// A turn ends when the wheel goes idle, so the next whole notch scrolls sideways again.
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a touchpad turn");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "the notch after it scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "the notch after it scrolls sideways");
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "with Zoom with Mouse Wheel: a small line delta zooms");
let small = turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)]);
assert!(zooms_in(small), "with Zoom with Mouse Wheel: a small line delta zooms");
let quarter = small.0;
assert!(
zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])),
"with Zoom with Mouse Wheel: a whole notch in the same turn zooms"
zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)]), quarter.powi(5)),
"with Zoom with Mouse Wheel: a whole notch in the same turn adds its zoom"
);
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "with Zoom with Mouse Wheel: a trackpad scroll zooms");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "with Zoom with Mouse Wheel: a whole notch scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "with Zoom with Mouse Wheel: a whole notch scrolls sideways");
}
/// A wheel turn that a touchpad began ends with an End or Cancel phase, also in the frame the next
/// notch arrives, and after egui's scroll timeout with no frame in between. Under either
/// preference, the whole notch after it scrolls sideways. A notch inside the turn zooms.
#[test]
fn a_touchpad_wheel_turn_ends_with_its_end_phase_or_the_scroll_timeout() {
use egui::{MouseWheelSource as S, MouseWheelUnit as U, TouchPhase as P};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
let mut t = 0.0;
let frame_time = 1.0 / 60.0;
let mut run = |app: &mut VectorcraftApp, dt: f64, events: Vec<egui::Event>| {
t += dt;
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
time: Some(t),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
run(&mut app, frame_time, vec![]);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed));
let ev =
|unit, dy: f32, phase, source| egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase, modifiers: egui::Modifiers::COMMAND, source };
// Point deltas zoom at once, so no glide is left when the next event comes.
let small = || ev(U::Point, 6.0, P::Move, S::Unknown);
let notch = || ev(U::Line, 1.0, P::Move, S::Unknown);
// A frame with a small delta, then `events` in one frame `dt` seconds later, and the wheel
// going idle: → (zoom ratio, scroll in points) since the small delta.
let mut turn = |app: &mut VectorcraftApp, dt: f64, events: Vec<egui::Event>| {
run(app, frame_time, vec![egui::Event::PointerMoved(at)]);
run(app, frame_time, vec![small()]);
let before = *app.view().unwrap();
run(app, dt, events);
for _ in 0..40 {
run(app, frame_time, vec![]);
}
let after = *app.view().unwrap();
(after.zoom / before.zoom, (after.center - before.center) * after.zoom)
};
let sideways = |(zoom, scroll): (f64, vectorcraft_geom::Vec2)| (zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0;
for wheel_zooms in [false, true] {
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap();
assert!(turn(&mut app, frame_time, vec![notch()]).0 > 1.01, "a notch inside the turn zooms ({wheel_zooms})");
assert!(
turn(&mut app, frame_time, vec![ev(U::Point, 0.0, P::Start, S::Trackpad), notch()]).0 > 1.01,
"a Start phase does not end the turn ({wheel_zooms})"
);
for phase in [P::End, P::Cancel] {
let r = turn(&mut app, frame_time, vec![ev(U::Point, 0.0, phase, S::Trackpad), notch()]);
assert!(sideways(r), "the notch after {phase:?} in the same frame scrolls sideways ({wheel_zooms}, {r:?})");
}
let (zoom, scroll) = turn(&mut app, 0.2, vec![notch()]);
assert!(
(zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0,
"the notch after the scroll timeout scrolls sideways ({wheel_zooms}, {zoom}, {scroll:?})"
);
}
}
/// Which wheel events count as a touchpad: a named trackpad or its momentum, never a named wheel,
/// and for an unnamed source, point deltas or line and page deltas more than 0.001 from a whole
/// number on either axis.
#[test]
fn fine_steps_tells_touchpad_input_from_whole_notches() {
use egui::{MouseWheelSource as S, MouseWheelUnit as U};
for unit in [U::Point, U::Line, U::Page] {
for delta in [vec2(0.0, 1.0), vec2(0.0, 0.25), vec2(-1.5, 0.0)] {
assert!(view::fine_steps(unit, delta, S::Trackpad), "{unit:?} {delta:?} from a trackpad");
assert!(view::fine_steps(unit, delta, S::Momentum), "{unit:?} {delta:?} from its momentum");
assert!(!view::fine_steps(unit, delta, S::Wheel), "{unit:?} {delta:?} from a named wheel");
}
}
assert!(view::fine_steps(U::Point, vec2(0.0, 1.0), S::Unknown), "whole points from an unnamed source");
for unit in [U::Line, U::Page] {
for (x, y) in [(0.0, 0.25), (0.0, -0.25), (0.25, 1.0), (1.0, -0.5), (0.0, 0.002), (0.0, 0.998), (0.0, 1.002), (0.0, 1.0 / 120.0)] {
assert!(view::fine_steps(unit, vec2(x, y), S::Unknown), "{unit:?} ({x}, {y}) is finer than a notch");
}
for (x, y) in [(0.0, 0.0), (0.0, 1.0), (0.0, -1.0), (0.0, 2.0), (-3.0, 1.0), (0.0, 0.9995), (0.0, 1.0005), (0.0, 0.0005)] {
assert!(!view::fine_steps(unit, vec2(x, y), S::Unknown), "{unit:?} ({x}, {y}) is a whole notch");
}
}
}
/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms
+66 -7
View File
@@ -144,13 +144,7 @@ pub(super) fn spelling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
}
walk(&st.doc.layers, &mut |n| {
let NodeKind::Text(tx) = &n.kind else { return };
// An effect on the type or on one of its fills or strokes, shown (the Appearance panel's
// eye): the helpx page's "Auto spell check isn't available for text with effects applied".
let shown = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible);
if shown(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| shown(i.effects()))
|| n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip))
{
if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) {
return;
}
let lines = match last.remove(&n.id) {
@@ -164,6 +158,71 @@ pub(super) fn spelling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
});
}
/// Whether Auto Spell Check marks `n`'s words: not with an effect shown (the Appearance panel's
/// eye) on the type or on one of its fills or strokes, as the helpx page says "Auto spell check
/// isn't available for text with effects applied".
fn auto_spell_checks(n: &Node) -> bool {
let shown = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible);
!shown(&n.appearance.effects) && !n.appearance.items.iter().any(|i| shown(i.effects()))
}
/// The word with Auto Spell Check's red line at `p` (document space), on top of the others: in
/// the topmost visible, unlocked type object whose unknown word's glyphs `p` is on, with what its
/// language's dictionary suggests instead. For the context menu's suggestions, Ignore All and Add
/// to Dictionary ([Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)).
pub(crate) fn misspelled_word_at(app: &mut VectorcraftApp, p: Point) -> Option<crate::state::ContextSpelling> {
let st = app.session.active()?;
let mut hit = None;
fn walk(nodes: &[std::sync::Arc<Node>], f: &mut impl FnMut(&Node)) {
for n in nodes.iter().filter(|n| n.visible && !n.locked) {
f(n);
if let Some(children) = n.children() {
walk(children, f);
}
}
}
// Paint order: a later hit is on top.
walk(&st.doc.layers, &mut |n| {
let NodeKind::Text(tx) = &n.kind else { return };
if !auto_spell_checks(n) || n.geometric_bounds().is_none_or(|b| !b.contains(p)) {
return;
}
let (words, _) = vectorcraft_engine::cmd::spelling::misspellings(tx, &st.spelling_ignored, MAX_UNDERLINED);
if words.is_empty() {
return;
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx);
for w in words {
let on = vectorcraft_text::selection_quads(&lay, w.range.start, w.range.end).iter().any(|q| in_quad(q.map(|c| tx.xf * c), p));
if on {
hit = Some((n.id, w));
}
}
});
let (id, w) = hit?;
let Some(NodeKind::Text(tx)) = st.doc.node(id).map(|n| &n.kind) else { return None };
let word = tx.plain_text().get(w.range.clone())?.to_string();
let uid = st.uid;
let suggestions = app
.session
.execute("text.spelling.suggest", &serde_json::json!({ "word": word, "lang": w.lang.tag() }))
.ok()
.and_then(|r| r.get("suggestions").and_then(|s| s.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()))
.unwrap_or_default();
Some(crate::state::ContextSpelling { uid, id, start: w.range.start, end: w.range.end, word, suggestions })
}
/// Whether `p` is inside the convex quad `q` (its corners in order, either way round) or on its
/// edge.
fn in_quad(q: [Point; 4], p: Point) -> bool {
let sides = (0..4).map(|i| {
let (a, b) = (q[i], q[(i + 1) % 4]);
(b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
});
let sides: Vec<f64> = sides.collect();
sides.iter().all(|s| *s >= 0.0) || sides.iter().all(|s| *s <= 0.0)
}
/// The most words underlined in one text object: past it the rest of the object isn't checked,
/// so a long story of unknown words costs one check's worth.
const MAX_UNDERLINED: usize = 1_000;
+5 -2
View File
@@ -358,7 +358,7 @@ struct ThumbDrag {
/// the source on the Mac and reports `Unknown` elsewhere. There, point deltas (Wayland) and line
/// deltas that are not whole notches (Windows and X11) count as a touchpad. A browser reports a
/// mouse wheel in points, so the web build counts only line deltas that are not whole notches.
fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool {
pub(super) fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool {
let notch = |d: f32| (d - d.round()).abs() < 1e-3;
match source {
egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum => true,
@@ -483,7 +483,7 @@ pub(super) fn wheel(
let mut scroll = pan;
for e in &i.events {
match e {
egui::Event::MouseWheel { unit, delta, modifiers, source, .. } => {
egui::Event::MouseWheel { unit, delta, phase, modifiers, source, .. } => {
let d = points(unit, *delta);
// The turn ends after egui's scroll timeout, as above.
if i.time - turn.last > 0.150 {
@@ -502,6 +502,9 @@ pub(super) fn wheel(
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
scroll.x += d.x;
}
if matches!(phase, egui::TouchPhase::End | egui::TouchPhase::Cancel) {
turn.fine = false;
}
}
egui::Event::Zoom(f) => zoom *= f64::from(*f),
_ => {}
+5 -1
View File
@@ -152,8 +152,8 @@ pub(crate) use export_for_screens::{
saved_options as screen_saved_options, saved_rows as screen_saved_rows,
};
pub(crate) use form::{humanize, number_list_text, plugin_color_button, read_number_list, unit_suffix};
pub(crate) use new_document::preset_name;
pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card};
pub(crate) use new_document::{popup_open as new_document_popup_open, preset_name};
pub use png_options::open as open_raster_options;
pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
pub use tools::open_tool_dialog;
@@ -560,6 +560,10 @@ pub(crate) fn field_set(app: &mut VectorcraftApp, field: &str, old: Option<&Valu
/// opened over (`ui.dialog.cancel`).
pub fn cancel(app: &mut VectorcraftApp) {
missing_fonts::requeue_replaced(app);
// More Settings' pop-up and Save Preset end on their own; New Document stays.
if app.ui.dialog.as_mut().is_some_and(new_document::close_popup) {
return;
}
if let Some(d) = app.ui.dialog.take() {
if d.kind == swatch_conflict::KIND {
crate::placing::cancel_swatch_paste(app);
+15
View File
@@ -316,6 +316,21 @@ mod tests {
assert!(!transparent_left(&app));
}
/// Cancel with the Save Preset row showing closes the dialog and rolls the preview back.
#[test]
fn cancel_while_saving_a_preset_closes_the_dialog() {
let mut app = scene();
let before = app.session.doc().unwrap().doc.clone();
app.run("ui.flattenTransparencyDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
frame(&mut app);
app.ui.dialog.as_mut().unwrap().fields.insert(SAVING.into(), json!(true));
frame(&mut app);
super::super::cancel(&mut app);
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert_eq!(app.session.doc().unwrap().doc, before, "Cancel rolls back");
}
#[test]
fn ok_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
const WARNING: &str = "an image couldn't be decoded and is missing from the picture";
File diff suppressed because it is too large Load Diff
@@ -86,9 +86,8 @@ fn document_setup_names_each_bleed_side() {
fn new_document_names_its_fields_by_their_captions() {
let mut app = app();
app.run("file.newDialog", json!({})).unwrap();
app.ui.dialog.as_mut().expect("New Document is open").fields.insert("__saving".into(), json!(true));
let h = strict(app);
for name in ["Document Name", "Preset Name", "Width", "Height", "Artboards"] {
for name in ["Document Name", "Width", "Height", "Artboards"] {
let _ = h.get_by_role_and_label(Role::TextInput, name);
}
for name in ["Units", "Background Contents"] {
@@ -96,7 +95,7 @@ fn new_document_names_its_fields_by_their_captions() {
}
}
/// New Document's More Settings, with Save Preset open, leaves no field unnamed.
/// New Document's More Settings pop-up and Save Preset mode leave no field unnamed.
#[test]
fn new_document_more_settings_names_its_fields() {
let mut app = app();
@@ -104,8 +103,11 @@ fn new_document_more_settings_names_its_fields() {
let d = app.ui.dialog.as_mut().expect("New Document is open");
d.kind = super::new_document::MORE.into();
d.fields.insert("__saving".into(), json!(true));
d.fields.insert("artboards".into(), json!(3));
let h = strict(app);
let _ = h.get_by_role_and_label(Role::TextInput, "Preset Name");
for name in ["Preset Name", "Number of Artboards:", "Spacing:", "Columns:"] {
let _ = h.get_by_role_and_label(Role::TextInput, name);
}
}
/// Split Into Grid keeps translated visible labels while naming row and column gutter and total
+100 -4
View File
@@ -12,6 +12,8 @@
//! the mouse wheel turned over the closed field applies the next family (or previous one) to the
//! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the
//! pointer leaves the field or the text selection changes; steps stop at its first and last family.
//! The field takes the wheel only when the pointer was moved onto it after the field last moved or
//! was covered by a window or overlay, and the wheel stream began there.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -441,6 +443,54 @@ struct WheelField {
layer: egui::LayerId,
}
/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value.
pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5;
/// The field the pointer was last moved onto, while that field stays where it was and uncovered.
#[derive(Clone, Copy)]
struct ArmedWheelField {
id: egui::Id,
rect: egui::Rect,
pass: u64,
}
#[derive(Clone, Copy, PartialEq)]
enum WheelStreamOwner {
Field(egui::Id),
Elsewhere,
}
/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included.
#[derive(Clone, Copy, Default)]
struct WheelStream {
last: Option<f64>,
owner: Option<WheelStreamOwner>,
}
impl WheelStream {
/// Note wheel input at `now`; a gap since the last input starts a new stream with no owner.
fn note_input(&mut self, now: f64) {
if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) {
self.owner = None;
}
self.last = Some(now);
}
/// Leave the rest of the stream to the panel when `field` holds it.
fn release(&mut self, field: egui::Id) {
if self.owner == Some(WheelStreamOwner::Field(field)) {
self.owner = Some(WheelStreamOwner::Elsewhere);
}
}
/// Leave the rest of the stream to the panel when any field holds it.
fn release_any(&mut self) {
if let Some(WheelStreamOwner::Field(field)) = self.owner {
self.release(field);
}
}
}
#[derive(Clone)]
struct QueuedWheel {
#[cfg(not(target_arch = "wasm32"))]
@@ -478,6 +528,8 @@ struct WheelFields {
pending: HashMap<egui::Id, Vec<WheelInput>>,
queued: Vec<QueuedWheel>,
replay: Vec<ReplayWheel>,
armed: Option<ArmedWheelField>,
stream: WheelStream,
}
#[cfg(not(target_arch = "wasm32"))]
@@ -524,6 +576,7 @@ impl egui::Plugin for WheelLayerSnapshot {
let id = event.field.with("wheel");
if !active {
ctx.data_mut(|d| d.remove::<f32>(id));
fields.stream.release(event.field);
if let Some(target) = ctx.layer_id_at(event.position) {
fields.replay.push(ReplayWheel { target, position: event.position, event: event.event });
}
@@ -636,7 +689,8 @@ pub(crate) fn sync_wheel_target(app: &VectorcraftApp, ctx: &egui::Context, viewp
let key = wheel_fields_id(viewport);
let (previous, mut fields) = ctx.data(|d| (d.get_temp::<WheelTarget>(key.with("target")), d.get_temp::<WheelFields>(key)));
if previous.is_some_and(|old| old != target) {
fields = None;
// A new target keeps the armed field and the running wheel stream.
fields = fields.map(|old| WheelFields { pass: old.pass, armed: old.armed, stream: old.stream, ..Default::default() });
}
let mut hidden = Vec::new();
if let Some(fields) = &mut fields {
@@ -678,6 +732,9 @@ fn remember_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool) -> Vec<Wh
fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, menu: egui::Id, owner: Option<WheelOwner>) -> Vec<WheelInput> {
let key = wheel_fields_id(ui.ctx().viewport_id());
let pass = ui.ctx().cumulative_pass_nr();
// A window or overlay over the pointer covers the field even when no input arrives.
let pointer = ui.data_mut(|d| d.get_temp_mut_or_default::<WheelFields>(key).pointer).or_else(|| ui.ctx().input(|i| i.pointer.latest_pos()));
let covered = pointer.is_some_and(|p| resp.interact_rect.contains(p) && ui.ctx().layer_id_at(p) != Some(resp.layer_id));
ui.data_mut(|d| {
let mut fields: WheelFields = d.get_temp(key).unwrap_or_default();
if fields.pass != pass {
@@ -685,6 +742,16 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men
fields.fields.clear();
}
fields.fields.retain(|f| f.id != resp.id);
if let Some(armed) = &mut fields.armed
&& armed.id == resp.id
{
if armed.rect == resp.interact_rect && pass.saturating_sub(armed.pass) <= 1 && !covered {
armed.pass = pass;
} else {
fields.armed = None;
fields.stream.release(resp.id);
}
}
if enabled {
fields.fields.push(WheelField { id: resp.id, menu, owner, rect: resp.interact_rect, layer: resp.layer_id });
}
@@ -695,7 +762,10 @@ fn remember_owned_wheel_field(ui: &Ui, resp: &egui::Response, enabled: bool, men
}
/// Take the closed font fields' wheel events before egui smooths and combines them. Other
/// events stay in the input, so scrolling elsewhere keeps its own delta and event order.
/// events stay in the input, so scrolling elsewhere keeps its own delta and event order. A field
/// takes a wheel stream only when the stream begins with the pointer over it and the pointer was
/// moved onto it after the field last moved or was covered. A stream that began anywhere else
/// stays there until it ends, and a stream that leaves its field does not return to it.
pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
let key = wheel_fields_id(raw.viewport_id);
let Some(mut fields) = ctx.data(|d| d.get_temp::<WheelFields>(key)) else { return };
@@ -712,7 +782,10 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
if pass < fields.pass || pass.saturating_sub(fields.pass) > 1 {
fields.fields.clear();
fields.pointer = None;
fields.armed = None;
fields.stream.release_any();
}
let now = raw.time.unwrap_or_else(|| ctx.input_for(raw.viewport_id, |i| i.time) + f64::from(raw.predicted_dt));
if fields.pending_frame != frame {
fields.pending_frame = frame;
fields.pending.clear();
@@ -766,8 +839,20 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
{
fields.activating.push(id);
}
if matches!(event, egui::Event::MouseWheel { .. }) {
fields.stream.note_input(now);
}
match event {
egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } => {
if pointer != Some(*p) {
let pass = fields.pass;
fields.armed = fields.fields.iter().rev().find(|f| over(f, Some(*p))).map(|f| ArmedWheelField { id: f.id, rect: f.rect, pass });
if let Some(WheelStreamOwner::Field(id)) = fields.stream.owner
&& !fields.fields.iter().any(|f| f.id == id && over(f, Some(*p)))
{
fields.stream.release(id);
}
}
mark_queued_wheel_leaves(&mut fields, Some(*p));
for field in &fields.fields {
if pointer.is_some_and(|point| field.rect.contains(point))
@@ -782,6 +867,8 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
}
egui::Event::PointerGone => {
pointer = None;
fields.armed = None;
fields.stream.release_any();
mark_queued_wheel_leaves(&mut fields, None);
for field in &fields.fields {
if !fields.queued.iter().any(|event| event.field == field.id) {
@@ -790,8 +877,13 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
}
}
}
egui::Event::MouseWheel { unit, delta, modifiers, .. } if modifiers.is_none() && delta.y != 0.0 => {
if let Some(field) = fields.fields.iter().rev().find(|f| !fields.activating.contains(&f.id) && over(f, pointer)).copied() {
egui::Event::MouseWheel { unit, delta, modifiers, source, .. } if modifiers.is_none() && delta.y != 0.0 => {
let under = fields.fields.iter().rev().find(|f| !fields.activating.contains(&f.id) && over(f, pointer)).copied();
let starts_on_field = under
.filter(|f| fields.armed.is_some_and(|armed| armed.id == f.id) && !matches!(source, egui::MouseWheelSource::Momentum))
.map(|f| f.id);
let owner = *fields.stream.owner.get_or_insert(starts_on_field.map_or(WheelStreamOwner::Elsewhere, WheelStreamOwner::Field));
if let Some(field) = under.filter(|f| owner == WheelStreamOwner::Field(f.id)) {
let amount = -match unit {
egui::MouseWheelUnit::Point => delta.y / line,
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y,
@@ -823,6 +915,10 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) {
captured = true;
return false;
}
fields.stream.owner = Some(WheelStreamOwner::Elsewhere);
}
egui::Event::MouseWheel { delta, .. } if *delta != egui::Vec2::ZERO => {
fields.stream.owner.get_or_insert(WheelStreamOwner::Elsewhere);
}
_ => {}
}
+290
View File
@@ -355,6 +355,296 @@ fn activating_the_family_popup_leaves_its_wheel_input_available() {
}
}
/// A family field at `top`, the input's clock at `time`. Returns the wheel events left in the
/// input, the notches the field took and the point in the middle of the field.
fn stream_frame(ctx: &egui::Context, top: Option<f32>, time: f64, events: Vec<egui::Event>) -> (usize, i32, egui::Pos2) {
covered_stream_frame(ctx, top, false, time, events)
}
/// [`stream_frame`], with an overlay over the whole field when `covered` is set.
fn covered_stream_frame(ctx: &egui::Context, top: Option<f32>, covered: bool, time: f64, events: Vec<egui::Event>) -> (usize, i32, egui::Pos2) {
let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() };
super::take_raw_wheel(ctx, &mut raw);
let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count();
let mut notches = 0;
let mut point = egui::Pos2::ZERO;
let mut out = ctx.run_ui(raw, |ui| {
let Some(top) = top else { return };
let rect = egui::Rect::from_min_size(egui::pos2(10.0, top), egui::vec2(120.0, 24.0));
let resp = ui.put(rect, egui::Button::new("Family"));
point = resp.rect.center();
for turn in super::remember_wheel_field(ui, &resp, true) {
if let super::WheelInput::Notches(n) = turn {
notches += n;
}
}
if covered {
let cover = rect.expand(5.0);
egui::Area::new(egui::Id::unique("stream overlay")).order(egui::Order::Foreground).fixed_pos(cover.min).show(ui.ctx(), |ui| {
ui.allocate_exact_size(cover.size(), egui::Sense::click());
});
}
});
out.textures_delta.clear();
(left, notches, point)
}
fn stream_turn(phase: egui::TouchPhase, source: egui::MouseWheelSource, dy: f32) -> egui::Event {
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, dy), phase, modifiers: Default::default(), source }
}
fn plain_turn() -> egui::Event {
stream_turn(egui::TouchPhase::Move, egui::MouseWheelSource::Unknown, -1.0)
}
/// A context with a family field drawn at `top` for three frames, from clock time 100.
fn stream_context(top: f32) -> (egui::Context, egui::Pos2) {
let ctx = egui::Context::default();
let mut point = egui::Pos2::ZERO;
for i in 0..3 {
point = stream_frame(&ctx, Some(top), 100.0 + f64::from(i), vec![]).2;
}
(ctx, point)
}
/// A field that moved under a pointer that stayed put leaves the wheel, including a reported
/// pointer position that did not change, until the pointer really moves.
#[test]
fn a_field_that_slides_under_a_still_pointer_leaves_the_wheel() {
let (ctx, point) = stream_context(40.0);
let below = egui::pos2(point.x, point.y + 200.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(below)]);
stream_frame(&ctx, Some(40.0 + 200.0), 111.0, vec![]);
let (left, notches, moved) = stream_frame(&ctx, Some(240.0), 120.0, vec![plain_turn()]);
assert_eq!(moved, below);
assert_eq!((left, notches), (1, 0), "the pointer did not move");
let (left, notches, _) = stream_frame(&ctx, Some(240.0), 130.0, vec![egui::Event::PointerMoved(below), plain_turn()]);
assert_eq!((left, notches), (1, 0), "a report of the same position is not a move");
let nudged = below + egui::vec2(2.0, 0.0);
let (left, notches, _) = stream_frame(&ctx, Some(240.0), 140.0, vec![egui::Event::PointerMoved(nudged), plain_turn()]);
assert_eq!((left, notches), (0, 1), "the pointer moved onto the field");
}
/// A field the pointer was moved onto loses that when the field moves, even back to the same
/// place, or when it is missing from a pass.
#[test]
fn a_field_that_moves_away_and_back_needs_the_pointer_moved_again() {
let (ctx, point) = stream_context(40.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point)]);
stream_frame(&ctx, Some(44.0), 111.0, vec![]);
stream_frame(&ctx, Some(40.0), 112.0, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field moved after the pointer did");
let (ctx, point) = stream_context(40.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point)]);
stream_frame(&ctx, None, 111.0, vec![]);
stream_frame(&ctx, Some(40.0), 112.0, vec![]);
stream_frame(&ctx, Some(40.0), 113.0, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field was gone for a pass");
let (ctx, point) = stream_context(40.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), egui::Event::PointerGone]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the window");
}
/// An overlay that comes and goes over a still pointer, with no input while it is up, leaves the
/// field needing the pointer moved again; a stream the field held leaves it.
#[test]
fn a_field_covered_by_an_overlay_needs_the_pointer_moved_again() {
let (ctx, point) = stream_context(40.0);
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point)]);
covered_stream_frame(&ctx, Some(40.0), true, 111.0, vec![]);
covered_stream_frame(&ctx, Some(40.0), true, 112.0, vec![]);
stream_frame(&ctx, Some(40.0), 113.0, vec![]);
stream_frame(&ctx, Some(40.0), 114.0, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 120.0, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the overlay covered the field");
let nudged = point + egui::vec2(2.0, 0.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 130.0, vec![egui::Event::PointerMoved(nudged), plain_turn()]);
assert_eq!((left, notches), (0, 1), "the pointer moved onto the field again");
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
covered_stream_frame(&ctx, Some(40.0), true, 110.1, vec![]);
covered_stream_frame(&ctx, Some(40.0), true, 110.2, vec![]);
stream_frame(&ctx, Some(40.0), 110.3, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.4, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the overlay covered the field during the stream");
}
/// A field missing from a pass loses the pointer's placement while another font field keeps
/// drawing in that pass, as when a panel collapses and the Control bar's field stays.
#[test]
fn a_field_gone_for_a_pass_beside_another_field_needs_the_pointer_moved_again() {
let ctx = egui::Context::default();
let field_at = |ui: &mut egui::Ui, top: f32| {
let rect = egui::Rect::from_min_size(egui::pos2(10.0, top), egui::vec2(120.0, 24.0));
let resp = ui.put(rect, egui::Button::new("Family"));
super::remember_wheel_field(ui, &resp, true);
};
let frame = |time: f64, family: bool, events: Vec<egui::Event>| {
let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() };
super::take_raw_wheel(&ctx, &mut raw);
let left = raw.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count();
let mut out = ctx.run_ui(raw, |ui| {
field_at(ui, 200.0);
if family {
field_at(ui, 40.0);
}
});
out.textures_delta.clear();
left
};
for i in 0..3 {
frame(100.0 + f64::from(i), true, vec![]);
}
let point = egui::pos2(70.0, 52.0);
assert_eq!(frame(110.0, true, vec![egui::Event::PointerMoved(point), plain_turn()]), 0, "the pointer moved onto the field");
frame(120.0, false, vec![]);
frame(121.0, true, vec![]);
frame(122.0, true, vec![]);
assert_eq!(frame(130.0, true, vec![plain_turn()]), 1, "the field was gone for a pass");
}
/// A pointer at rest on a field that stayed put keeps stepping it, stream after stream.
#[test]
fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() {
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + super::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]);
assert_eq!((left, notches), (0, 1), "the same stream");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![plain_turn()]);
assert_eq!((left, notches), (0, 1), "a new stream, with the pointer still on the field");
}
/// A stream that began elsewhere stays there when the pointer moves onto a field, until a gap
/// with no wheel input ends it.
#[test]
fn a_wheel_stream_that_began_elsewhere_stays_there_until_a_gap() {
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let step = super::WHEEL_STREAM_GAP * 0.8;
let (left, _, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), plain_turn()]);
assert_eq!(left, 1, "the stream began off the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + step, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "a nudge onto the field does not take a running stream");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "each event renews the stream");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + super::WHEEL_STREAM_GAP, vec![plain_turn()]);
assert_eq!((left, notches), (0, 1), "the stream ended, and the pointer was moved onto the field");
}
/// A stream that leaves its field goes to what is under the pointer and does not come back.
#[test]
fn a_wheel_stream_that_leaves_its_field_does_not_return() {
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerMoved(away), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the stream stays off the field");
}
/// A stream loses its field when the pointer leaves without a wheel turn or when the field moves.
#[test]
fn a_wheel_stream_loses_its_field_when_the_pointer_leaves_or_the_field_moves() {
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerMoved(away)]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the field during the stream");
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, Some(44.0), 110.1, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(44.0), 110.2, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field moved during the stream");
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, Some(40.0), 110.1, vec![egui::Event::PointerGone]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.2, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (1, 0), "the pointer left the window during the stream");
}
/// Trackpad momentum and zero-delta phase events keep their stream going, and momentum alone does
/// not start a stream on a field.
#[test]
fn trackpad_momentum_stays_with_its_stream() {
use egui::{MouseWheelSource::Momentum, MouseWheelSource::Trackpad, TouchPhase};
let (ctx, point) = stream_context(40.0);
let away = point + egui::vec2(0.0, 200.0);
let step = super::WHEEL_STREAM_GAP * 0.8;
stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), stream_turn(TouchPhase::Move, Trackpad, -1.0)]);
// The fingers lift with no delta, the pointer moves onto the field, and momentum follows.
stream_frame(&ctx, Some(40.0), 110.0 + step, vec![stream_turn(TouchPhase::End, Trackpad, 0.0)]);
let (left, notches, _) =
stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![egui::Event::PointerMoved(point), stream_turn(TouchPhase::Start, Momentum, -1.0)]);
assert_eq!((left, notches), (1, 0), "momentum continues the stream that began off the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![stream_turn(TouchPhase::Move, Momentum, -1.0)]);
assert_eq!((left, notches), (1, 0), "momentum does not start a stream on the field");
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 300.0, vec![stream_turn(TouchPhase::Move, Trackpad, -1.0)]);
assert_eq!((left, notches), (0, 1), "fingers on the trackpad do");
}
/// A stream that began as a sideways scroll stays off the field when it turns vertical.
#[test]
fn a_sideways_turn_that_starts_a_stream_keeps_it_off_the_field() {
let (ctx, point) = stream_context(40.0);
let sideways = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(-1.0, 0.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), sideways]);
assert_eq!((left, notches), (1, 0));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the stream began as a sideways scroll");
}
/// A stream loses its field when a layout pass is skipped, even though the field comes back in the
/// same place before the stream ends.
#[test]
fn a_wheel_stream_loses_its_field_when_a_pass_is_skipped() {
let (ctx, point) = stream_context(40.0);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]);
assert_eq!((left, notches), (0, 1));
stream_frame(&ctx, None, 110.1, vec![]);
stream_frame(&ctx, Some(40.0), 110.2, vec![]);
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.3, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the field missed a pass during the stream");
}
/// A modified turn is left in the input and keeps the stream away from the field.
#[test]
fn a_modified_turn_that_starts_a_stream_keeps_it_off_the_field() {
let (ctx, point) = stream_context(40.0);
let zoom = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::COMMAND,
source: egui::MouseWheelSource::Unknown,
};
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), zoom]);
assert_eq!((left, notches), (1, 0));
let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.1, vec![plain_turn()]);
assert_eq!((left, notches), (1, 0), "the stream began as a modified turn");
}
/// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun.
#[test]
fn the_variable_filter_is_named_as_fonts_are() {
+2 -4
View File
@@ -284,7 +284,6 @@ font-technology Variable Променливи
Update While Dragging Обновяване при плъзгане
Hide Video Rulers Скриване на видеолинийките
Background Contents Съдържание на фона
Background Contents: Съдържание на фона:
Background: Фон:
Backspace Backspace
Bar Graph Tool Инструмент „Стълбчеста диаграма“
@@ -654,7 +653,6 @@ font-technology Variable Променливи
Couldn't {} {name}: {e} Неуспешно {name} на {}: {e}
Create Създаване
Create Clipping Mask Създаване на маска за изрязване
Create Document Създаване на документ
Create from Guides Създаване от водачи
Create from Selection Създаване от селекцията
Create From: Създаване от:
@@ -1826,6 +1824,7 @@ font-technology Variable Променливи
Lock (⌘2) Заключване (⌘2)
Lock Grid Заключване на мрежата
Lock Guides Заключване на водачите
Lock Guides by Default Заключване на водачите по подразбиране
Lock Others Заключване на другите
Lock Slices Заключване на фрагментите
Lock Station Point Заключване на базовата точка
@@ -2074,7 +2073,6 @@ font-technology Variable Променливи
New Color Group… Нова цветова група…
New Document Presets Предварителни настройки за нов документ
New file Нов файл
New from Template Нов от шаблон
New from Template… Нов от шаблон…
New Graphic Style Нов графичен стил
New Graphic Style (Alt-click to name it) Нов графичен стил (кликнете с Alt, за да го наименувате)
@@ -2858,6 +2856,7 @@ digits Malayalam Малаялам
Save PDF Preset Запази PDF предварителна настройка
Save Perspective Grid Preset Запази предварителна настройка на перспективната мрежа
Save Preset Запази предварителна настройка
Save Document Preset Запази предварителна настройка на документа
Save Preset… Запази предварителна настройка…
Save Print Preset Запази предварителна настройка за печат
Save Recovery Data Запази данни за възстановяване
@@ -3422,7 +3421,6 @@ digits Malayalam Малаялам
Template Шаблон
Templates Шаблони
Templates Folder Папка с шаблони
Templates… Шаблони…
Text Displayed in Cell: Текст, показван в клетката:
Text Export Options Опции за експортиране на текст
Text Fill & Stroke Color Цвят на запълване и щрих на текста
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -4
View File
@@ -600,6 +600,7 @@
Hide Guides Sakrij vodilice
Show Guides Prikaži vodilice
Lock Guides Zaključaj vodilice
Lock Guides by Default Zaključaj vodilice po zadanom
Unlock Guides Otključaj vodilice
Make Guides Napravi vodilice
Release Guides Oslobodi vodilice
@@ -2531,15 +2532,12 @@ digits Malayalam Malajalam
Selected Swatches Odabrani uzorci boja
Advanced Options Napredne opcije
Background Contents Sadržaj pozadine
Background Contents: Sadržaj pozadine:
BLANK DOCUMENT PRESETS PREDLOŠCI PRAZNIH DOKUMENATA
Bleed Napust
Change to Right-to-Left Layout Prebaci na raspored s desna nalijevo
Columns: Kolone:
Create Document Kreiraj dokument
Documents you create appear here. Dokumenti koje kreirate pojavljuju se ovdje.
More Settings Više postavki
New from Template Novo iz predloška
Number of Artboards: Broj radnih ploča:
Orientation: Orijentacija:
PRESET DETAILS DETALJI PREDLOŠKA
@@ -2550,7 +2548,6 @@ digits Malayalam Malajalam
RECENT NEDAVNO
RECENT (1) NEDAVNO (1)
SAVED SAČUVANO
Templates… Predlošci…
Transparent (Default) Prozirno (zadano)
Save for Office Documents Sačuvaj za Office dokumente
Transparent Background Prozirna pozadina
@@ -3486,6 +3483,7 @@ theme Appearance Izgled
Arrange by Row Rasporedi po redu
Arrange by Column Rasporedi po koloni
Save Preset Sačuvaj predložak
Save Document Preset Sačuvaj predložak dokumenta
Screen (72 ppi) Ekran (72 ppi)
Social Square Post 1080×1080 Kvadratna objava za društvene mreže 1080×1080
Distribute Rasporedi ravnomjerno
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -423,7 +423,6 @@ antiAlias Sharp Nítid
Back Enrere
Background Color: Color de fons:
Background Contents Contingut del fons
Background Contents: Contingut del fons:
Background Level Nivell de fons
Background: Fons:
Backspace Retrocés
@@ -917,7 +916,6 @@ antiAlias Sharp Nítid
Create Clipping Mask Crea una màscara de retall
Create Compound Path From Ellipses Crea un traçat compost a partir d’el·lipses
Create Dimension Crea una dimensió
Create Document Crea un document
Create from Guides Crea a partir de guies
Create from Selection Crea a partir de la selecció
Create From: Crea a partir de:
@@ -2339,6 +2337,7 @@ digits Malayalam Malaiàlam
Lock Artboards Bloca els taulers
Lock Grid Bloca la quadrícula
Lock Guides Bloca les guies
Lock Guides by Default Bloca les guies per defecte
Lock Others Bloca la resta
Lock Selected Bloca la selecció
Lock Slices Bloca els talls
@@ -2597,7 +2596,6 @@ digits Malayalam Malaiàlam
New Design Disseny nou
New Document Presets Preajustos de document nous
New file Fitxer nou
New from Template Nou a partir d’una plantilla
New from Template… Nou a partir d’una plantilla…
New Graphic Style Estil gràfic nou
New Graphic Style (Alt-click to name it) Estil gràfic nou (Alt+clic per posar-hi nom)
@@ -3643,6 +3641,7 @@ digits Malayalam Malaiàlam
Save PDF Preset Desa el preajust de PDF
Save Perspective Grid Preset Desa el preajust de quadrícula en perspectiva
Save Preset Desa el preajust
Save Document Preset Desa el preajust de document
Save Preset… Desa el preajust…
Save Print Preset Desa el preajust d’impressió
Save Recovery Data Desa les dades de recuperació
@@ -4369,7 +4368,6 @@ digits Malayalam Malaiàlam
Template literal not closed El literal de plantilla no està tancat
Templates Plantilles
Templates Folder Carpeta de plantilles
Templates… Plantilles…
Tetrad Tètrada
Tetrad 2 Tètrada 2
Tetrad 3 Tètrada 3
@@ -5917,7 +5915,7 @@ digits Malayalam Malaiàlam
@msg {key} must be a finite coordinate {key} ha de ser una coordenada finita
@msg {key} must be an array of non-negative integer object ids {key} ha de ser una matriu d’identificadors d’objecte enters no negatius
@msg {key} must be Unix seconds (an integer) or null {key} ha de ser segons Unix (un enter) o null
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} actualment requereix un traçat o un traçat compost. Converteix primer els textos, les imatges i els grups en traçats.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} actualment requereix un traçat, un traçat compost, text o un grup. Converteix primer les imatges i altres objectes en traçats.
@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} conserva 1 dels {n} taulers del document: el fitxer es reobre amb un. Activa Conserva les capacitats d’edició per conservar-los tots.
@msg {label} produced an empty result {label} ha produït un resultat buit
@msg {label} requires a shape with at least two anchors {label} requereix una forma amb com a mínim dos punts d’ancoratge
@@ -6648,3 +6646,6 @@ breakText Glyphs Glifs
Align to Pixel Grid Alinea a la quadrícula de píxels
@msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels
@msg side is left or right, not `{s}` side ha de ser left o right, no `{s}`
Ignore All Ignora-ho tot
Add to Dictionary Afegeix al diccionari
No Suggestions No hi ha suggeriments
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -609,6 +609,7 @@
Hide Guides Skrýt vodítka
Show Guides Zobrazit vodítka
Lock Guides Zamknout vodítka
Lock Guides by Default Ve výchozím stavu zamknout vodítka
Unlock Guides Odemknout vodítka
Make Guides Vytvořit vodítka
Release Guides Uvolnit vodítka
@@ -2562,15 +2563,12 @@ digits Malayalam Malajálamské
Selected Swatches Vybrané položky vzorníku
Advanced Options Rozšířené možnosti
Background Contents Obsah pozadí
Background Contents: Obsah pozadí:
BLANK DOCUMENT PRESETS PŘEDNASTAVENÍ PRÁZDNÝCH DOKUMENTŮ
Bleed Spadávka
Change to Right-to-Left Layout Změnit na rozvržení zprava doleva
Columns: Sloupce:
Create Document Vytvořit dokument
Documents you create appear here. Zde se zobrazí dokumenty, které vytvoříte.
More Settings Další nastavení
New from Template Nový ze šablony
Number of Artboards: Počet pracovních ploch:
Orientation: Orientace:
PRESET DETAILS PODROBNOSTI PŘEDNASTAVENÍ
@@ -2581,7 +2579,6 @@ digits Malayalam Malajálamské
RECENT NEDÁVNÉ
RECENT (1) NEDÁVNÉ (1)
SAVED ULOŽENÉ
Templates… Šablony…
Transparent (Default) Průhledné (výchozí)
Save for Office Documents Uložit pro kancelářské dokumenty
Transparent Background Průhledné pozadí
@@ -3536,6 +3533,7 @@ theme Appearance Motiv
Arrange by Row Uspořádat po řádcích
Arrange by Column Uspořádat po sloupcích
Save Preset Uložit přednastavení
Save Document Preset Uložit přednastavení dokumentu
Screen (72 ppi) Obrazovka (72 ppi)
Social Square Post 1080×1080 Čtvercový příspěvek pro sociální sítě 1080×1080
Distribute Rozmístit
@@ -5587,7 +5585,7 @@ mark-style Japanese Japonské
Bevel both sides Zkosení na obou stranách
Presets Předvolby
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objekt může mít jen jeden 3D efekt. Upravte volby stávajícího efektu, nebo jej nejprve odstraňte.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} zatím vyžaduje cestu nebo složenou cestu. Nejprve převeďte text, obrazy a skupiny na cesty.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} zatím vyžaduje cestu, složenou cestu, text nebo skupinu. Obrazy a další objekty nejprve převeďte na cesty.
@msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body
@msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý
@msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar
@@ -6667,3 +6665,6 @@ breakText Glyphs Glyfy
Align to Pixel Grid Zarovnat na mřížku obrazových bodů
@msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty
@msg side is left or right, not `{s}` side je left nebo right, ne `{s}`
Ignore All Ignorovat vše
Add to Dictionary Přidat do slovníku
No Suggestions Žádné návrhy
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -4
View File
@@ -559,6 +559,7 @@
Hide Guides Skjul hjælpelinjer
Show Guides Vis hjælpelinjer
Lock Guides Lås hjælpelinjer
Lock Guides by Default Lås hjælpelinjer som standard
Unlock Guides Lås hjælpelinjer op
Make Guides Opret hjælpelinjer
Release Guides Frigør hjælpelinjer
@@ -2350,15 +2351,12 @@ digits Malayalam Malayalam
Selected Swatches Markerede farveprøver
Advanced Options Avancerede indstillinger
Background Contents Baggrundens indhold
Background Contents: Baggrundens indhold:
BLANK DOCUMENT PRESETS TOMME DOKUMENTFORUDINDSTILLINGER
Bleed Beskæring
Change to Right-to-Left Layout Skift til højre-til-venstre-layout
Columns: Kolonner:
Create Document Opret dokument
Documents you create appear here. Dokumenter, du opretter, vises her.
More Settings Flere indstillinger
New from Template Nyt fra skabelon
Number of Artboards: Antal tegnebrætter:
Orientation: Orientering:
PRESET DETAILS FORUDINDSTILLINGSDETALJER
@@ -2369,7 +2367,6 @@ digits Malayalam Malayalam
RECENT SENESTE
RECENT (1) SENESTE (1)
SAVED GEMT
Templates… Skabeloner…
Transparent (Default) Gennemsigtig (standard)
Save for Office Documents Gem til Office-dokumenter
Transparent Background Gennemsigtig baggrund
@@ -3273,6 +3270,7 @@ theme Appearance Udseende
Arrange by Row Arranger efter række
Arrange by Column Arranger efter kolonne
Save Preset Gem forudindstilling
Save Document Preset Gem dokumentforudindstilling
Screen (72 ppi) Skærm (72 ppi)
Social Square Post 1080×1080 Socialt firkantet opslag 1080×1080
Distribute Fordel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -272,7 +272,6 @@ font-technology Variable Variabel
Update While Dragging Beim Ziehen aktualisieren
Hide Video Rulers Videolineale ausblenden
Background Contents Hintergrundinhalt
Background Contents: Hintergrundinhalt:
Background: Hintergrund:
Backspace Rücktaste
Bar Graph Tool Balkendiagramm-Werkzeug
@@ -647,7 +646,6 @@ font-technology Variable Variabel
Couldn't {} {name}: {e} Fehler bei „{}“ für {name}: {e}
Create Erstellen
Create Clipping Mask Schnittmaske erstellen
Create Document Dokument erstellen
Create from Guides Aus Hilfslinien erstellen
Create from Selection Aus Auswahl erstellen
Create From: Erstellen aus:
@@ -1893,6 +1891,7 @@ file-filter Templates Vorlagen
Lock (⌘2) Sperren (⌘2)
Lock Grid Raster sperren
Lock Guides Hilfslinien sperren
Lock Guides by Default Hilfslinien standardmäßig sperren
Lock Others Andere sperren
Lock Slices Slices sperren
Lock Station Point Standpunkt sperren
@@ -2099,7 +2098,6 @@ file-filter Templates Vorlagen
New Color Group… Neue Farbgruppe…
New Document Presets Vorgaben für neue Dokumente
New file Neue Datei
New from Template Neu aus Vorlage
New from Template… Neu aus Vorlage…
New Graphic Style Neuer Grafikstil
New Graphic Style (Alt-click to name it) Neuer Grafikstil (Alt-Klick, um ihn zu benennen)
@@ -2891,6 +2889,7 @@ digits Malayalam Malayalam
Save PDF Preset PDF-Vorgabe speichern
Save Perspective Grid Preset Vorgabe für Perspektivenraster speichern
Save Preset Vorgabe speichern
Save Document Preset Dokumentvorgabe speichern
Preset {number} Vorgabe {number}
Save Preset… Vorgabe speichern…
Save Print Preset Druckvorgabe speichern
@@ -3454,7 +3453,6 @@ effect Points Punkte
Template Vorlage
Templates Vorlagen
Templates Folder Vorlagenordner
Templates… Vorlagen…
Text Displayed in Cell: In der Zelle angezeigter Text:
Text Export Options Textexportoptionen
Text Fill & Stroke Color Flächen- und Konturfarbe des Texts
@@ -5154,7 +5152,7 @@ lighting Light Licht
Bevel both sides Abgeflachte Kante beidseitig
Presets Vorgaben
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ein Objekt kann nur einen 3D-Effekt haben. Bearbeiten Sie die Optionen des vorhandenen Effekts oder entfernen Sie ihn zuerst.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} erfordert derzeit einen Pfad oder zusammengesetzten Pfad. Wandeln Sie Text, Bilder und Gruppen zuerst in Pfade um.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} erfordert derzeit einen Pfad, zusammengesetzten Pfad, Text oder eine Gruppe. Wandeln Sie Bilder und andere Objekte zuerst in Pfade um.
@msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten
@msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex
@msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form
@@ -6254,3 +6252,6 @@ breakText Glyphs Glyphen
Align to Pixel Grid Am Pixelraster ausrichten
@msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden
@msg side is left or right, not `{s}` side ist left oder right, nicht `{s}`
Ignore All Alle ignorieren
Add to Dictionary Zum Wörterbuch hinzufügen
No Suggestions Keine Vorschläge
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -275,7 +275,6 @@ font-technology Variable Μεταβλητές
Update While Dragging Ενημέρωση κατά τη μεταφορά
Hide Video Rulers Απόκρυψη χαράκων βίντεο
Background Contents Περιεχόμενα φόντου
Background Contents: Περιεχόμενο φόντου:
Background: Φόντο:
Backspace Backspace
Bar Graph Tool Ραβδόγραμμα (Bar Graph Tool)
@@ -628,7 +627,6 @@ font-technology Variable Μεταβλητές
Couldn't {} {name}: {e} Δεν ήταν δυνατή η {} του {name}: {e}
Create Δημιουργία
Create Clipping Mask Δημιουργία μάσκας αποκοπής
Create Document Δημιουργία εγγράφου
Create from Guides Δημιουργία από οδηγούς
Create from Selection Δημιουργία από επιλογή
Create From: Δημιουργία από:
@@ -1725,6 +1723,7 @@ font-technology Variable Μεταβλητές
Location: Θέση:
Lock Κλείδωμα
Lock Guides Κλείδωμα οδηγών
Lock Guides by Default Κλείδωμα οδηγών από προεπιλογή
Lock Slices Κλείδωμα τομών
Low Χαμηλή
Luminosity Φωτεινότητα
@@ -2074,7 +2073,6 @@ font-technology Variable Μεταβλητές
Temperature Θερμοκρασία
Template Πρότυπο
Templates Πρότυπα
Templates… Πρότυπα…
Threshold Κατώφλι
Tile Παράθεση
Tint Χρωματική απόχρωση
@@ -2902,7 +2900,6 @@ theme Appearance Εμφάνιση
New Color Group… Νέα ομάδα χρωμάτων…
New Document Presets Νέες προεπιλογές εγγράφου
New file Νέο αρχείο
New from Template Νέο από πρότυπο
New from Template… Νέο από πρότυπο…
New Graphic Style Νέο στυλ γραφικών
New Graphic Style (Alt-click to name it) Νέο στυλ γραφικών (Alt-κλικ για ονομασία)
@@ -3466,6 +3463,7 @@ digits Malayalam Μαλαγιάλαμ
Save PDF Preset Αποθήκευση προεπιλογής PDF
Save Perspective Grid Preset Αποθήκευση προεπιλογής πλέγματος προοπτικής
Save Preset Αποθήκευση προεπιλογής
Save Document Preset Αποθήκευση προεπιλογής εγγράφου
Save Print Preset Αποθήκευση προεπιλογής εκτύπωσης
Save Recovery Data Αποθήκευση δεδομένων ανάκτησης
Save Save for Web Preset Αποθήκευση προεπιλογής για ιστό
@@ -4948,7 +4946,7 @@ digits Malayalam Μαλαγιάλαμ
Bevel Inside Εσωτερική λοξότμηση
Bevel both sides Λοξότμηση και των δύο πλευρών
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ένα αντικείμενο δέχεται ένα εφέ 3D. Επεξεργαστείτε τις επιλογές του υπάρχοντος εφέ ή αφαιρέστε το πρώτα.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. Το {label} απαιτεί προς το παρόν διαδρομή ή σύνθετη διαδρομή. Μετατρέψτε πρώτα το κείμενο, τις εικόνες και τις ομάδες σε διαδρομές.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. Το {label} απαιτεί προς το παρόν διαδρομή, σύνθετη διαδρομή, κείμενο ή ομάδα. Μετατρέψτε πρώτα τις εικόνες και τα άλλα αντικείμενα σε διαδρομές.
@msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης
@msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D
@msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα
@@ -6619,3 +6617,6 @@ breakText Glyphs Γλύφοι
Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων
@msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων
@msg side is left or right, not `{s}` το side είναι left ή right, όχι `{s}`
Ignore All Παράβλεψη όλων
Add to Dictionary Προσθήκη στο λεξικό
No Suggestions Καμία πρόταση
Can't render this file because it is too large.
+6 -5
View File
@@ -297,7 +297,6 @@ font-technology Variable Variables
Update While Dragging Actualizar al arrastrar
Hide Video Rulers Ocultar reglas de video
Background Contents Contenido de fondo
Background Contents: Contenido de fondo:
Background: Fondo:
Backspace Retroceso
Bar Graph Tool Herramienta Gráfico de barras
@@ -669,7 +668,6 @@ font-technology Variable Variables
Couldn't {} {name}: {e} No se pudo {} {name}: {e}
Create Crear
Create Clipping Mask Crear máscara de recorte
Create Document Crear documento
Create from Guides Crear a partir de guías
Create from Selection Crear a partir de la selección
Create From: Crear a partir de:
@@ -1896,6 +1894,7 @@ font-technology Variable Variables
Lock (⌘2) Bloquear (⌘2)
Lock Grid Bloquear cuadrícula
Lock Guides Bloquear guías
Lock Guides by Default Bloquear guías de forma predeterminada
Lock Others Bloquear otros
Lock Slices Bloquear sectores
Lock Station Point Bloquear punto de estación
@@ -2146,7 +2145,6 @@ font-technology Variable Variables
New Color Group… Nuevo grupo de colores…
New Document Presets Preajustes de documento nuevo
New file Archivo nuevo
New from Template Nuevo a partir de plantilla
New from Template… Nuevo a partir de plantilla…
New Graphic Style Nuevo estilo gráfico
New Graphic Style (Alt-click to name it) Nuevo estilo gráfico (Alt+clic para darle nombre)
@@ -2933,6 +2931,7 @@ digits Malayalam Malayálam
Save PDF Preset Guardar preajuste de PDF
Save Perspective Grid Preset Guardar preajuste de cuadrícula de perspectiva
Save Preset Guardar preajuste
Save Document Preset Guardar preajuste de documento
Save Preset… Guardar preajuste…
Save Print Preset Guardar preajuste de impresión
Save Recovery Data Guardar datos de recuperación
@@ -3510,7 +3509,6 @@ digits Malayalam Malayálam
Template Plantilla
Templates Plantillas
Templates Folder Carpeta de plantillas
Templates… Plantillas…
Text Displayed in Cell: Texto mostrado en la celda:
Text Export Options Opciones de exportación de texto
Text Fill & Stroke Color Color de relleno y trazo del texto
@@ -5528,7 +5526,7 @@ mark-style Japanese Japonesas
Bevel both sides Bisel en ambos lados
Presets Ajustes preestablecidos
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objeto admite un solo efecto 3D. Edite las opciones del efecto existente o quítelo primero.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} requiere por ahora un trazado o un trazado compuesto. Convierta primero el texto, las imágenes y los grupos en trazados.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} requiere por ahora un trazado, un trazado compuesto, texto o un grupo. Convierta primero las imágenes y otros objetos en trazados.
@msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla
@msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D
@msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma
@@ -6610,3 +6608,6 @@ breakText Glyphs Glifos
Align to Pixel Grid Alinear con cuadrícula de píxeles
@msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles
@msg side is left or right, not `{s}` side es left o right, no `{s}`
Ignore All Omitir todo
Add to Dictionary Añadir al diccionario
No Suggestions No hay sugerencias
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -600,6 +600,7 @@
Hide Guides Piilota apuviivat
Show Guides Näytä apuviivat
Lock Guides Lukitse apuviivat
Lock Guides by Default Lukitse apuviivat oletuksena
Unlock Guides Avaa apuviivojen lukitus
Make Guides Luo apuviivat
Release Guides Vapauta apuviivat
@@ -2506,15 +2507,12 @@ digits Malayalam Malajalam
Selected Swatches Valitut värimallit
Advanced Options Lisäasetukset
Background Contents Taustan sisältö
Background Contents: Taustan sisältö:
BLANK DOCUMENT PRESETS TYHJÄN ASIAKIRJAN ESIASETUKSET
Bleed Leikkuuvara
Change to Right-to-Left Layout Vaihda oikealta vasemmalle -asetteluun
Columns: Sarakkeet:
Create Document Luo asiakirja
Documents you create appear here. Luomasi asiakirjat näkyvät tässä.
More Settings Lisää asetuksia
New from Template Uusi mallista
Number of Artboards: Piirtoalustojen määrä:
Orientation: Suunta:
PRESET DETAILS ESIASETUKSEN TIEDOT
@@ -2525,7 +2523,6 @@ digits Malayalam Malajalam
RECENT VIIMEISIMMÄT
RECENT (1) VIIMEISIMMÄT (1)
SAVED TALLENNETUT
Templates… Mallit…
Transparent (Default) Läpinäkyvä (oletus)
Save for Office Documents Tallenna Office-asiakirjoja varten
Transparent Background Läpinäkyvä tausta
@@ -3474,6 +3471,7 @@ theme Appearance Ulkoasu
Arrange by Row Järjestä riveittäin
Arrange by Column Järjestä sarakkeittain
Save Preset Tallenna esiasetus
Save Document Preset Tallenna asiakirjan esiasetus
Screen (72 ppi) Näyttö (72 ppi)
Social Square Post 1080×1080 Neliömäinen somejulkaisu 1080×1080
Distribute Jaa tasaisesti
@@ -5471,7 +5469,7 @@ mark-style Japanese Japanilainen
Bevel both sides Viiste molemmin puolin
Presets Esiasetukset
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Objektilla voi olla yksi 3D-tehoste. Muokkaa olemassa olevan tehosteen asetuksia tai poista se ensin.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} vaatii tällä hetkellä polun tai yhdistelmäpolun. Muunna teksti, kuvat ja ryhmät ensin poluiksi.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} vaatii tällä hetkellä polun, yhdistelmäpolun, tekstin tai ryhmän. Muunna kuvat ja muut objektit ensin poluiksi.
@msg {label} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria
@msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle
@msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa
@@ -6702,3 +6700,6 @@ breakText Glyphs Merkit
Align to Pixel Grid Tasaa pikseliruudukkoon
@msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon
@msg side is left or right, not `{s}` side on left tai right, ei `{s}`
Ignore All Ohita kaikki
Add to Dictionary Lisää sanastoon
No Suggestions Ei ehdotuksia
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -298,7 +298,6 @@ font-technology Variable Variables
Update While Dragging Mettre à jour pendant le déplacement
Hide Video Rulers Masquer les règles vidéo
Background Contents Contenu de l’arrière-plan
Background Contents: Contenu de l’arrière-plan :
Background: Arrière-plan :
Backspace Retour arrière
Bar Graph Tool Outil Graphe à barres
@@ -670,7 +669,6 @@ font-technology Variable Variables
Couldn't {} {name}: {e} Impossible de {} {name} : {e}
Create Créer
Create Clipping Mask Créer un masque d’écrêtage
Create Document Créer un document
Create from Guides Créer à partir des repères
Create from Selection Créer à partir de la sélection
Create From: Créer à partir de :
@@ -1890,6 +1888,7 @@ font-technology Variable Variables
Lock (⌘2) Verrouiller (⌘2)
Lock Grid Verrouiller la grille
Lock Guides Verrouiller les repères
Lock Guides by Default Verrouiller les repères par défaut
Lock Others Verrouiller les autres
Lock Slices Verrouiller les tranches
Lock Station Point Verrouiller le point d’observation
@@ -2138,7 +2137,6 @@ font-technology Variable Variables
New Color Group… Nouveau groupe de couleurs…
New Document Presets Paramètres prédéfinis de nouveau document
New file Nouveau fichier
New from Template Nouveau d’après un modèle
New from Template… Nouveau d’après un modèle…
New Graphic Style Nouveau style graphique
New Graphic Style (Alt-click to name it) Nouveau style graphique (Alt+clic pour le nommer)
@@ -2925,6 +2923,7 @@ digits Malayalam Malayalam
Save PDF Preset Enregistrer le paramètre prédéfini PDF
Save Perspective Grid Preset Enregistrer le paramètre prédéfini de grille de perspective
Save Preset Enregistrer le paramètre prédéfini
Save Document Preset Enregistrer le paramètre prédéfini du document
Save Preset… Enregistrer le paramètre prédéfini…
Save Print Preset Enregistrer le paramètre prédéfini d’impression
Save Recovery Data Enregistrer les données de récupération
@@ -3502,7 +3501,6 @@ digits Malayalam Malayalam
Template Modèle
Templates Modèles
Templates Folder Dossier des modèles
Templates… Modèles…
Text Displayed in Cell: Texte affiché dans la cellule :
Text Export Options Options d’exportation du texte
Text Fill & Stroke Color Couleur de fond et de contour du texte
@@ -5158,7 +5156,7 @@ lighting Light Lumière
Bevel both sides Biseau des deux côtés
Presets Paramètres prédéfinis
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un objet n'accepte qu'un seul effet 3D. Modifiez les options de l'effet existant ou supprimez-le d'abord.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} nécessite pour l'instant un tracé ou un tracé transparent. Convertissez d'abord le texte, les images et les groupes en tracés.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} nécessite pour l'instant un tracé, un tracé transparent, du texte ou un groupe. Convertissez d'abord les images et les autres objets en tracés.
@msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage
@msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D
@msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme
@@ -6662,3 +6660,6 @@ breakText Glyphs Glyphes
Align to Pixel Grid Aligner sur la grille de pixels
@msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels
@msg side is left or right, not `{s}` side vaut left ou right, pas `{s}`
Ignore All Tout ignorer
Add to Dictionary Ajouter au dictionnaire
No Suggestions Aucune suggestion
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -491,7 +491,6 @@ antiAlias Sharp शार्प
Back वापस
Background Color: बैकग्राउंड रंग:
Background Contents बैकग्राउंड सामग्री
Background Contents: बैकग्राउंड सामग्री:
Background Level बैकग्राउंड स्तर
Background: बैकग्राउंड:
Backspace Backspace
@@ -1029,7 +1028,6 @@ antiAlias Sharp शार्प
Create Clipping Mask क्लिपिंग मास्क बनाएँ
Create Compound Path From Ellipses एलिप्स से कंपाउंड पाथ बनाएँ
Create Dimension आयाम बनाएँ
Create Document डॉक्यूमेंट बनाएं
Create from Guides गाइड से बनाएँ
Create from Selection सिलेक्शन से बनाएँ
Create From: से बनाएँ:
@@ -2555,6 +2553,7 @@ antiAlias Sharp शार्प
Lock Artboards लॉक आर्टबोर्ड
Lock Grid लॉक ग्रिड
Lock Guides गाइड्स लॉक करें
Lock Guides by Default गाइड्स डिफ़ॉल्ट रूप से लॉक करें
Lock Others लॉक अन्य
Lock Selected लॉक सिलेक्टेड
Lock Slices स्लाइस लॉक करें
@@ -2836,7 +2835,6 @@ antiAlias Sharp शार्प
New Design नई डिजाइन
New Document Presets नया डॉक्यूमेंट प्रीसेट
New file नई फ़ाइल
New from Template टेम्पलेट से नया
New from Template… टेम्पलेट से नया…
New Graphic Style नई ग्राफिक स्टाइल
New Graphic Style (Alt-click to name it) नया ग्राफ़िक स्टाइल (नाम देने के लिए Alt-क्लिक करें)
@@ -3957,6 +3955,7 @@ antiAlias Sharp शार्प
Save PDF Preset PDF प्रीसेट सेव करें
Save Perspective Grid Preset सेव पर्सपेक्टिव ग्रिड प्रीसेट
Save Preset प्रीसेट सेव करें
Save Document Preset डॉक्यूमेंट प्रीसेट सेव करें
Save Preset… प्रीसेट सेव करें…
Save Print Preset सेव करें प्रिंट प्रीसेट
Save Recovery Data रिकवरी डेटा सेव करें
@@ -4736,7 +4735,6 @@ antiAlias Sharp शार्प
Template literal not closed टेम्पलेट साक्षर नहीं बंद
Templates टेम्पलेट्स
Templates Folder टेम्पलेट्स फ़ोल्डर
Templates… टेम्पलेट्स…
Tetrad टेट्राड
Tetrad 2 टेट्राड 2
Tetrad 3 टेट्राड 3
@@ -5400,7 +5398,7 @@ antiAlias Sharp शार्प
@msg {key} must name a registered panel {key} एक पंजीकृत पैनल का नाम होना चाहिए
@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} फ़ाइलों में पारदर्शिता नहीं होती और इस फ़ाइल में {what} होगा: पहले इसे फ़्लैट करें (object.flattenTransparency)
@msg {kind} images can't be opened (see document.formats) {kind} इमेज ओपन नहीं हो सकतीं (document.formats देखें)
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} वर्तमान में एक पाथ या यौगिक पाथ की आवश्यकता होती है। पहले पाथ के लिए प्रकार, इमेज और समूहों को परिवर्तित करें।
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} के लिए वर्तमान में एक पाथ, यौगिक पाथ, टाइप या समूह आवश्यक है। पहले इमेज और अन्य ऑब्जेक्ट को पाथ में परिवर्तित करें।
@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें।
@msg {label} must be a number {label} एक नंबर होना चाहिए
@msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए
@@ -8109,3 +8107,6 @@ theme Appearance अपीयरेंस
@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` इंपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है
@msg an importer always gets an import's budget: leave `budget` out इंपोर्टर को हमेशा इंपोर्ट का बजट मिलता है: `budget` छोड़ दें
@msg side is left or right, not `{s}` side left या right होना चाहिए, `{s}` नहीं
Ignore All सभी को अनदेखा करें
Add to Dictionary शब्दकोश में जोड़ें
No Suggestions कोई सुझाव नहीं
Can't render this file because it is too large.
+6 -5
View File
@@ -393,7 +393,6 @@ font-technology Variable Változó
Update While Dragging Frissítés húzás közben
Hide Video Rulers Videovonalzók elrejtése
Background Contents Háttértartalom
Background Contents: Háttértartalom:
Background: Háttér:
Backspace Backspace
Balance Egyensúly
@@ -864,7 +863,6 @@ font-technology Variable Változó
Craquelure… Repedezettség…
Create Létrehozás
Create Clipping Mask Vágómaszk létrehozása
Create Document Dokumentum létrehozása
Create from Guides Létrehozás segédvonalakból
Create from Selection Létrehozás a kijelölésből
Create From: Létrehozás ebből:
@@ -2377,6 +2375,7 @@ file-filter Templates Sablonok
Lock (⌘2) Zárolás (⌘2)
Lock Grid Rács zárolása
Lock Guides Segédvonalak zárolása
Lock Guides by Default Segédvonalak zárolása alapértelmezetten
Lock Others Többi zárolása
Lock Slices Szeletek zárolása
Lock Station Point Nézőpont zárolása
@@ -2598,7 +2597,6 @@ file-filter Templates Sablonok
New Data Set… Új adatkészlet…
New Document Presets Új dokumentum előbeállításai
New file Új fájl
New from Template Új sablonból
New from Template… Új sablonból…
New Graphic Style Új grafikai stílus
New Graphic Style (Alt-click to name it) Új grafikai stílus (Alt+kattintás: elnevezéssel)
@@ -3516,6 +3514,7 @@ mark-style Japanese Japán
Save PDF Preset PDF-előbeállítás mentése
Save Perspective Grid Preset Perspektivikus rács előbeállításának mentése
Save Preset Előbeállítás mentése
Save Document Preset Dokumentum-előbeállítás mentése
Preset {number} Előbeállítás {number}
Save Preset… Előbeállítás mentése…
Save Print Preset Nyomtatási előbeállítás mentése
@@ -4164,7 +4163,6 @@ mark-style Japanese Japán
Template Sablon
Templates Sablonok
Templates Folder Sablonok mappája
Templates… Sablonok…
Tetrad Tetrád
Tetrad 2 Tetrád 2
Tetrad 3 Tetrád 3
@@ -5570,7 +5568,7 @@ imageTrace Outline Körvonal
Bevel both sides Élletörés mindkét oldalon
Presets Készletek
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Egy objektumnak csak egy 3D effektusa lehet. Szerkessze a meglévő effektus beállításait, vagy előbb távolítsa el.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} egyelőre görbét vagy összetett görbét igényel. Előbb alakítsa görbévé a szöveget, a képeket és a csoportokat.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} egyelőre görbét, összetett görbét, szöveget vagy csoportot igényel. Előbb alakítsa görbévé a képeket és az egyéb objektumokat.
@msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel
@msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz
@msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot
@@ -6651,3 +6649,6 @@ breakText Glyphs Glifák
Align to Pixel Grid Igazítás a pixelrácshoz
@msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz
@msg side is left or right, not `{s}` a side értéke left vagy right lehet, nem `{s}`
Ignore All Az összes kihagyása
Add to Dictionary Hozzáadás a szótárhoz
No Suggestions Nincs javaslat
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -4
View File
@@ -559,6 +559,7 @@
Hide Guides Sembunyikan panduan
Show Guides Tampilkan panduan
Lock Guides Kunci panduan
Lock Guides by Default Kunci panduan secara default
Unlock Guides Buka kunci panduan
Make Guides Buat panduan
Release Guides Lepaskan panduan
@@ -2350,15 +2351,12 @@ digits Malayalam Malayalam
Selected Swatches Swatch terpilih
Advanced Options Opsi lanjutan
Background Contents Konten latar belakang
Background Contents: Konten latar belakang:
BLANK DOCUMENT PRESETS PRESET DOKUMEN KOSONG
Bleed Bleed
Change to Right-to-Left Layout Ubah ke tata letak kanan ke kiri
Columns: Kolom:
Create Document Buat dokumen
Documents you create appear here. Dokumen yang Anda buat muncul di sini.
More Settings Pengaturan lainnya
New from Template Baru dari templat
Number of Artboards: Jumlah artboard:
Orientation: Orientasi:
PRESET DETAILS DETAIL PRESET
@@ -2369,7 +2367,6 @@ digits Malayalam Malayalam
RECENT TERKINI
RECENT (1) TERKINI (1)
SAVED TERSIMPAN
Templates… Templat…
Transparent (Default) Transparan (default)
Save for Office Documents Simpan untuk dokumen Office
Transparent Background Latar belakang transparan
@@ -3273,6 +3270,7 @@ theme Appearance Tampilan
Arrange by Row Susun per baris
Arrange by Column Susun per kolom
Save Preset Simpan preset
Save Document Preset Simpan preset dokumen
Screen (72 ppi) Layar (72 ppi)
Social Square Post 1080×1080 Postingan persegi media sosial 1080×1080
Distribute Distribusikan
Can't render this file because it contains an unexpected character in line 2307 and column 5.
+6 -5
View File
@@ -297,7 +297,6 @@ font-technology Variable Variabili
Update While Dragging Aggiorna durante il trascinamento
Hide Video Rulers Nascondi righelli video
Background Contents Contenuto sfondo
Background Contents: Contenuto sfondo:
Background: Sfondo:
Backspace Backspace
Bar Graph Tool Strumento Grafico a barre
@@ -669,7 +668,6 @@ font-technology Variable Variabili
Couldn't {} {name}: {e} Impossibile {} {name}: {e}
Create Crea
Create Clipping Mask Crea maschera di ritaglio
Create Document Crea documento
Create from Guides Crea da guide
Create from Selection Crea dalla selezione
Create From: Crea da:
@@ -1925,6 +1923,7 @@ file-filter Templates Modelli
Lock (⌘2) Blocca (⌘2)
Lock Grid Blocca griglia
Lock Guides Blocca guide
Lock Guides by Default Blocca guide per impostazione predefinita
Lock Others Blocca altri
Lock Slices Blocca sezioni
Lock Station Point Blocca punto di osservazione
@@ -2175,7 +2174,6 @@ file-filter Templates Modelli
New Color Group… Nuovo gruppo colori…
New Document Presets Preset nuovo documento
New file Nuovo file
New from Template Nuovo da modello
New from Template… Nuovo da modello…
New Graphic Style Nuovo stile grafico
New Graphic Style (Alt-click to name it) Nuovo stile grafico (Alt+clic per assegnare un nome)
@@ -2967,6 +2965,7 @@ digits Malayalam Malayalam
Save PDF Preset Salva preset PDF
Save Perspective Grid Preset Salva preset griglia prospettica
Save Preset Salva preset
Save Document Preset Salva preset documento
Preset {number} Preset {number}
Save Preset… Salva preset…
Save Print Preset Salva preset di stampa
@@ -3546,7 +3545,6 @@ digits Malayalam Malayalam
Template Modello
Templates Modelli
Templates Folder Cartella modelli
Templates… Modelli…
Text Displayed in Cell: Testo visualizzato nella cella:
Text Export Options Opzioni di esportazione testo
Text Fill & Stroke Color Colore riempimento e traccia testo
@@ -5196,7 +5194,7 @@ lighting Light Luce
Bevel both sides Smusso su entrambi i lati
Presets Predefiniti
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Un oggetto può avere un solo effetto 3D. Modificate le opzioni dell'effetto esistente oppure rimuovetelo prima.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} richiede per ora un tracciato o un tracciato composto. Convertite prima testo, immagini e gruppi in tracciati.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} richiede per ora un tracciato, un tracciato composto, testo o un gruppo. Convertite prima immagini e altri oggetti in tracciati.
@msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio
@msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D
@msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma
@@ -6288,3 +6286,6 @@ breakText Glyphs Glifi
Align to Pixel Grid Allinea alla griglia pixel
@msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel
@msg side is left or right, not `{s}` side è left o right, non `{s}`
Ignore All Ignora tutto
Add to Dictionary Aggiungi al dizionario
No Suggestions Nessun suggerimento
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -627,6 +627,7 @@ digits Malayalam マラヤーラム数字
Guides ガイド
Hide Guides ガイドを隠す
Lock Guides ガイドをロック
Lock Guides by Default ガイドを初期設定でロック
Make Guides ガイドを作成
Release Guides ガイドを解除
Clear Guides ガイドを消去
@@ -1076,7 +1077,6 @@ antiAlias Sharp シャープ
Update While Dragging ドラッグ中に更新
Hide Video Rulers ビデオ定規を隠す
Background Contents 背景のカラー
Background Contents: 背景のカラー:
Background: 背景:
Bar Graph Tool 棒グラフツール
Baseline Shift ベースラインシフト
@@ -1302,7 +1302,6 @@ antiAlias Sharp シャープ
Corners… コーナー…
Chamfer 面取り
Create Clipping Mask クリッピングマスクを作成
Create Document ドキュメントを作成
Create From: 作成元:
Create Graph グラフを作成
Create New Layer 新規レイヤーを作成
@@ -2362,7 +2361,6 @@ antiAlias Sharp シャープ
New Symbol… 新規シンボル…
New Workspace 新規ワークスペース
New file 新規ファイル
New from Template テンプレートから新規作成
New theme from the current color 現在のカラーから新規テーマ
New {kind} Style… 新規{kind}スタイル…
Next 次へ
@@ -2905,6 +2903,7 @@ mark-style Japanese 日本式
Save PDF Preset PDF プリセットを保存
Save Perspective Grid Preset 遠近グリッドプリセットを保存
Save Preset プリセットを保存
Save Document Preset ドキュメントプリセットを保存
Preset {number} プリセット {number}
Save Preset… 保存…
Save Print Preset プリントプリセットを保存
@@ -3253,7 +3252,6 @@ mark-style Japanese 日本式
Target Contents 内容をターゲット
Target: ターゲット:
Templates Folder テンプレートフォルダー
Templates… テンプレート…
Text Displayed in Cell: セルに表示するテキスト:
Text Export Options テキスト書き出しオプション
Text Import Options テキスト読み込みオプション
@@ -5238,7 +5236,7 @@ lighting Light ライト
Bevel both sides 両面にベベル
Presets プリセット
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 1 つのオブジェクトに適用できる 3D 効果は 1 つです。既存の効果のオプションを編集するか、先に削除してください。
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} には現在、パスまたは複合パスが必要です。テキスト、画像、グループは先にパスに変換してください。
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} には現在、パス、複合パス、テキスト、またはグループが必要です。画像やその他のオブジェクトは先にパスに変換してください。
@msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です
@msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません
@msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください
@@ -6353,3 +6351,6 @@ breakText Glyphs 字形
Align to Pixel Grid ピクセルグリッドに整合
@msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです
@msg side is left or right, not `{s}` side は left または right です(`{s}` ではありません)
Ignore All すべて無視
Add to Dictionary 辞書に追加
No Suggestions 候補なし
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -521,6 +521,7 @@
Hide Guides დამხმარე ხაზების დამალვა
Show Guides დამხმარე ხაზების ჩვენება
Lock Guides დამხმარე ხაზების ჩაკეტვა
Lock Guides by Default დამხმარე ხაზების ნაგულისხმევად ჩაკეტვა
Unlock Guides დამხმარე ხაზების განბლოკვა
Make Guides დამხმარე ხაზების შექმნა
Release Guides დამხმარე ხაზების მოხსნა
@@ -2252,15 +2253,12 @@ digits Malayalam მალაიალამური
Selected Swatches მონიშნული ფერის ნიმუშები
Advanced Options დამატებითი პარამეტრები
Background Contents ფონის შიგთავსი
Background Contents: ფონის შიგთავსი:
BLANK DOCUMENT PRESETS ცარიელი დოკუმენტის პრესეტები
Bleed ჭრისპირა არე
Change to Right-to-Left Layout მარჯვნიდან მარცხნივ განლაგებაზე გადასვლა
Columns: სვეტები:
Create Document დოკუმენტის შექმნა
Documents you create appear here. თქვენ მიერ შექმნილი დოკუმენტები აქ გამოჩნდება.
More Settings მეტი პარამეტრი
New from Template შაბლონიდან შექმნა
Number of Artboards: სამუშაო დაფების რაოდენობა:
Orientation: ორიენტაცია:
PRESET DETAILS პრესეტის დეტალები
@@ -2270,7 +2268,6 @@ digits Malayalam მალაიალამური
Preview Mode გადახედვის რეჟიმი
RECENT ბოლოდროინდელი
SAVED შენახული
Templates… შაბლონები…
Transparent (Default) გამჭვირვალე (ნაგულისხმევი)
Save for Office Documents შენახვა Office-ის დოკუმენტებისთვის
Transparent Background გამჭვირვალე ფონი
@@ -3095,6 +3092,7 @@ theme Appearance იერსახე
Arrange by Row სტრიქონების მიხედვით დალაგება
Arrange by Column სვეტების მიხედვით დალაგება
Save Preset პრესეტის შენახვა
Save Document Preset დოკუმენტის პრესეტის შენახვა
Screen (72 ppi) ეკრანი (72 ppi)
Social Square Post 1080×1080 კვადრატული პოსტი 1080×1080
Distribute განაწილება
@@ -4959,7 +4957,7 @@ font-technology Variable ცვლადი
Bevel both sides წაკვეთა ორივე მხარეს
Presets პრესეტები
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. ობიექტს ერთი 3D ეფექტი შეიძლება ჰქონდეს. შეცვალეთ არსებული ეფექტის პარამეტრები ან ჯერ წაშალეთ ის.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: ამჟამად საჭიროა ტრაექტორია ან შედგენილი ტრაექტორია. ჯერ გარდაქმენით ტექსტი, გამოსახულებები და ჯგუფები ტრაექტორიებად.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: ამჟამად საჭიროა ტრაექტორია, შედგენილი ტრაექტორია, ტექსტი ან ჯგუფი. ჯერ გარდაქმენით გამოსახულებები და სხვა ობიექტები ტრაექტორიებად.
@msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით
@msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის
@msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა
@@ -6728,3 +6726,6 @@ font-script Japanese იაპონური
Align to Pixel Grid პიქსელურ ბადეზე გასწორება
@msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი
@msg side is left or right, not `{s}` side არის left ან right და არა `{s}`
Ignore All ყველას იგნორირება
Add to Dictionary ლექსიკონში დამატება
No Suggestions შემოთავაზებები არ არის
Can't render this file because it is too large.
+6 -5
View File
@@ -622,6 +622,7 @@ digits Malayalam 말라얄람어
Guides 안내선
Hide Guides 안내선 숨기기
Lock Guides 안내선 잠그기
Lock Guides by Default 기본적으로 안내선 잠그기
Make Guides 안내선 만들기
Release Guides 안내선 해제
Clear Guides 안내선 지우기
@@ -1066,7 +1067,6 @@ antiAlias Sharp 날카롭게
Update While Dragging 드래그하는 동안 업데이트
Hide Video Rulers 비디오 눈금자 숨기기
Background Contents 배경 내용
Background Contents: 배경 내용:
Background: 배경:
Bar Graph Tool 막대 그래프 도구
Baseline Shift 기준선 이동
@@ -1292,7 +1292,6 @@ antiAlias Sharp 날카롭게
Corners… 모퉁이…
Chamfer 모따기
Create Clipping Mask 클리핑 마스크 만들기
Create Document 문서 만들기
Create From: 만들기 기준:
Create Graph 그래프 만들기
Create New Layer 새 레이어 만들기
@@ -2352,7 +2351,6 @@ antiAlias Sharp 날카롭게
New Symbol… 새 심볼…
New Workspace 새 작업 영역
New file 새 파일
New from Template 템플릿에서 새로 만들기
New theme from the current color 현재 색상으로 새 테마 만들기
New {kind} Style… 새 {kind} 스타일…
Next 다음
@@ -2894,6 +2892,7 @@ mark-style Japanese 일본식
Save PDF Preset PDF 사전 설정 저장
Save Perspective Grid Preset 원근 격자 사전 설정 저장
Save Preset 사전 설정 저장
Save Document Preset 문서 사전 설정 저장
Preset {number} 사전 설정 {number}
Save Preset… 사전 설정 저장…
Save Print Preset 인쇄 사전 설정 저장
@@ -3242,7 +3241,6 @@ mark-style Japanese 일본식
Target Contents 대상 내용
Target: 대상:
Templates Folder 템플릿 폴더
Templates… 템플릿…
Text Displayed in Cell: 셀에 표시할 텍스트:
Text Export Options 텍스트 내보내기 옵션
Text Import Options 텍스트 가져오기 옵션
@@ -5157,7 +5155,7 @@ lighting Light 광원
Bevel both sides 양면 베벨
Presets 사전 설정
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 오브젝트에는 3D 효과를 하나만 적용할 수 있습니다. 기존 효과의 옵션을 편집하거나 먼저 제거하십시오.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}에는 현재 패스 또는 컴파운드 패스가 필요합니다. 문자, 이미지 및 그룹을 먼저 패스로 변환하십시오.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}에는 현재 패스, 컴파운드 패스, 문자 또는 그룹이 필요합니다. 이미지 및 기타 개체를 먼저 패스로 변환하십시오.
@msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다
@msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다
@msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오
@@ -6249,3 +6247,6 @@ breakText Glyphs 글리프
Align to Pixel Grid 픽셀 격자에 정렬
@msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다
@msg side is left or right, not `{s}` side는 left 또는 right입니다(`{s}` 아님)
Ignore All 모두 무시
Add to Dictionary 사전에 추가
No Suggestions 제안 없음
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -4
View File
@@ -573,6 +573,7 @@
Hide Guides Hulplijnen verbergen
Show Guides Hulplijnen tonen
Lock Guides Hulplijnen vergrendelen
Lock Guides by Default Hulplijnen standaard vergrendelen
Unlock Guides Hulplijnen ontgrendelen
Make Guides Hulplijnen maken
Release Guides Hulplijnen vrijgeven
@@ -2364,15 +2365,12 @@ digits Malayalam Malayalam
Selected Swatches Geselecteerde stalen
Advanced Options Geavanceerde opties
Background Contents Achtergrondinhoud
Background Contents: Achtergrondinhoud:
BLANK DOCUMENT PRESETS LEGE DOCUMENTVOORINSTELLINGEN
Bleed Afrulling
Change to Right-to-Left Layout Omschakelen naar lay-out van rechts naar links
Columns: Kolommen:
Create Document Document maken
Documents you create appear here. Documenten die je maakt, verschijnen hier.
More Settings Meer instellingen
New from Template Nieuw uit sjabloon
Number of Artboards: Aantal tekenborden:
Orientation: Oriëntatie:
PRESET DETAILS VOORINSTELLINGSDETAILS
@@ -2383,7 +2381,6 @@ digits Malayalam Malayalam
RECENT RECENT
RECENT (1) RECENT (1)
SAVED OPGESLAGEN
Templates… Sjablonen…
Transparent (Default) Transparant (standaard)
Save for Office Documents Opslaan voor Office-documenten
Transparent Background Transparante achtergrond
@@ -3287,6 +3284,7 @@ theme Appearance Vormgeving
Arrange by Row Ordenen op rij
Arrange by Column Ordenen op kolom
Save Preset Voorinstelling opslaan
Save Document Preset Documentvoorinstelling opslaan
Screen (72 ppi) Scherm (72 ppi)
Social Square Post 1080×1080 Vierkante socialmediapost 1080×1080
Distribute Verdelen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -566,6 +566,7 @@
Hide Guides Ukryj linie pomocnicze
Show Guides Pokaż linie pomocnicze
Lock Guides Zablokuj linie pomocnicze
Lock Guides by Default Domyślnie blokuj linie pomocnicze
Unlock Guides Odblokuj linie pomocnicze
Make Guides Utwórz linie pomocnicze
Release Guides Uwolnij linie pomocnicze
@@ -2476,15 +2477,12 @@ digits Malayalam Malajalam
Selected Swatches Zaznaczone próbki
Advanced Options Opcje zaawansowane
Background Contents Zawartość tła
Background Contents: Zawartość tła:
BLANK DOCUMENT PRESETS USTAWIENIA PUSTEGO DOKUMENTU
Bleed Spad
Change to Right-to-Left Layout Zmień na układ od prawej do lewej
Columns: Kolumny:
Create Document Utwórz dokument
Documents you create appear here. Tu pojawią się utworzone dokumenty.
More Settings Więcej ustawień
New from Template Nowy z szablonu
Number of Artboards: Liczba obszarów kompozycji:
Orientation: Orientacja:
PRESET DETAILS SZCZEGÓŁY USTAWIENIA
@@ -2494,7 +2492,6 @@ digits Malayalam Malajalam
Preview Mode Tryb podglądu
RECENT OSTATNIE
SAVED ZAPISANE
Templates… Szablony…
Transparent (Default) Przezroczyste (domyślnie)
Save for Office Documents Zapisz dla dokumentów biurowych
Transparent Background Przezroczyste tło
@@ -3415,6 +3412,7 @@ theme Appearance Wygląd
Arrange by Row Ułóż według wierszy
Arrange by Column Ułóż według kolumn
Save Preset Zapisz ustawienie
Save Document Preset Zapisz ustawienie dokumentu
Screen (72 ppi) Ekran (72 ppi)
Social Square Post 1080×1080 Kwadratowy post społecznościowy 1080×1080
Distribute Rozmieść
@@ -5417,7 +5415,7 @@ mark-style Japanese Japońskie
Bevel both sides Faza po obu stronach
Presets Ustawienia
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Obiekt może mieć jeden efekt 3D. Edytuj opcje istniejącego efektu lub najpierw go usuń.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} wymaga obecnie ścieżki lub ścieżki złożonej. Najpierw przekonwertuj tekst, obrazy i grupy na ścieżki.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} wymaga obecnie ścieżki, ścieżki złożonej, tekstu lub grupy. Najpierw przekonwertuj obrazy i inne obiekty na ścieżki.
@msg {label} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami
@msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D
@msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt
@@ -6523,3 +6521,6 @@ breakText Glyphs Glify
Align to Pixel Grid Wyrównaj do siatki pikseli
@msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone
@msg side is left or right, not `{s}` side to left lub right, a nie `{s}`
Ignore All Ignoruj wszystko
Add to Dictionary Dodaj do słownika
No Suggestions Brak sugestii
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -283,7 +283,6 @@ antiAlias Sharp Nítido
Update While Dragging Atualizar ao Arrastar
Hide Video Rulers Ocultar Réguas de Vídeo
Background Contents Conteúdo do Plano de Fundo
Background Contents: Conteúdo do Plano de Fundo:
Background: Plano de Fundo:
Bar Graph Tool Ferramenta Gráfico de Barras
Baseline Shift Deslocamento da Linha de Base
@@ -596,7 +595,6 @@ antiAlias Sharp Nítido
Corners… Cantos…
Create Criar
Create Clipping Mask Criar Máscara de Recorte
Create Document Criar Documento
Create From: Criar De:
Create Gradient Mesh… Criar Malha de Gradiente…
Create Graph Criar Gráfico
@@ -1617,6 +1615,7 @@ antiAlias Sharp Nítido
Lock (⌘2) Bloquear (⌘2)
Lock Grid Bloquear Grade
Lock Guides Bloquear Guias
Lock Guides by Default Bloquear Guias por Padrão
Lock Slices Bloquear Fatias
Lock Station Point Bloquear Ponto de Estação
Lossless (lossy WebP isn't available yet) Sem Perdas (WebP com perdas ainda não está disponível)
@@ -1792,7 +1791,6 @@ antiAlias Sharp Nítido
Duplicate Toolbar Duplicar Barra de Ferramentas
Delete Toolbar Excluir Barra de Ferramentas
New file Novo arquivo
New from Template Novo a partir de Modelo
New from Template… Novo a partir de Modelo…
New theme from the current color Novo tema a partir da cor atual
New {kind} Style… Novo Estilo de {kind}…
@@ -2457,6 +2455,7 @@ digits Malayalam Malaiala
Save PDF Preset Salvar Predefinido de PDF
Save Perspective Grid Preset Salvar Predefinido de Grade de Perspectiva
Save Preset Salvar Predefinido
Save Document Preset Salvar Predefinido de Documento
Save Preset… Salvar Predefinido…
Save Print Preset Salvar Predefinido de Impressão
Save Recovery Data Salvar Dados de Recuperação
@@ -2880,7 +2879,6 @@ digits Malayalam Malaiala
Target: Alvo:
Temperature/Tint… Temperatura/Tom…
Templates Folder Pasta de Modelos
Templates… Modelos…
Text Displayed in Cell: Texto Exibido na Célula:
Text Export Options Opções de Exportação de Texto
Text Fill & Stroke Color Cor de Preenchimento e Traçado do Texto
@@ -5532,7 +5530,7 @@ mark-style Japanese Japonesas
Bevel both sides Chanfro nos Dois Lados
Presets Predefinidos
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Um objeto aceita apenas um efeito 3D. Edite as opções do efeito existente ou remova-o primeiro.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} exige, por enquanto, um caminho ou caminho composto. Converta antes texto, imagens e grupos em caminhos.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} exige, por enquanto, um caminho, caminho composto, texto ou grupo. Converta antes imagens e outros objetos em caminhos.
@msg {label} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem
@msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D
@msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma
@@ -6740,3 +6738,6 @@ swatch Yellow Green Verde-amarelado
Align to Pixel Grid Alinhar à Grade de Pixels
@msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels
@msg side is left or right, not `{s}` side é left ou right, não `{s}`
Ignore All Ignorar Tudo
Add to Dictionary Adicionar ao Dicionário
No Suggestions Nenhuma Sugestão
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6 -5
View File
@@ -295,7 +295,6 @@ font-technology Variable Вариативные
Update While Dragging Обновлять при перетаскивании
Hide Video Rulers Скрыть видео-линейки
Background Contents Содержимое фона
Background Contents: Содержимое фона:
Background: Фон:
Backspace Backspace
Bar Graph Tool Инструмент «Гистограмма»
@@ -667,7 +666,6 @@ font-technology Variable Вариативные
Couldn't {} {name}: {e} Не удалось {} {name}: {e}
Create Создать
Create Clipping Mask Создать маску обрезки
Create Document Создать документ
Create from Guides Создать из направляющих
Create from Selection Создать из выделения
Create From: Создать из:
@@ -1922,6 +1920,7 @@ file-filter Templates Шаблоны
Lock (⌘2) Заблокировать (⌘2)
Lock Grid Заблокировать сетку
Lock Guides Заблокировать направляющие
Lock Guides by Default Блокировать направляющие по умолчанию
Lock Others Заблокировать остальные
Lock Slices Заблокировать фрагменты
Lock Station Point Заблокировать точку стояния
@@ -2170,7 +2169,6 @@ file-filter Templates Шаблоны
New Color Group… Новая цветовая группа…
New Document Presets Наборы новых документов
New file Новый файл
New from Template Создать из шаблона
New from Template… Создать из шаблона…
New Graphic Style Новый графический стиль
New Graphic Style (Alt-click to name it) Новый графический стиль (Alt+щелчок, чтобы задать имя)
@@ -2962,6 +2960,7 @@ digits Malayalam Малаялам
Save PDF Preset Сохранить набор PDF
Save Perspective Grid Preset Сохранить набор сетки перспективы
Save Preset Сохранить набор
Save Document Preset Сохранить набор документа
Preset {number} Набор {number}
Save Preset… Сохранить набор…
Save Print Preset Сохранить набор печати
@@ -3541,7 +3540,6 @@ digits Malayalam Малаялам
Template Шаблон
Templates Шаблоны
Templates Folder Папка шаблонов
Templates… Шаблоны…
Text Displayed in Cell: Текст, отображаемый в ячейке:
Text Export Options Параметры экспорта текста
Text Fill & Stroke Color Цвет заливки и обводки текста
@@ -5190,7 +5188,7 @@ lighting Light Свет
Bevel both sides Фаска с обеих сторон
Presets Наборы
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. У объекта может быть только один 3D-эффект. Измените параметры существующего эффекта или сначала удалите его.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: пока требуется контур или составной контур. Сначала преобразуйте текст, изображения и группы в контуры.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: пока требуется контур, составной контур, текст или группа. Сначала преобразуйте изображения и другие объекты в контуры.
@msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками
@msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта
@msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру
@@ -6756,3 +6754,6 @@ shape Open Открытый
Align to Pixel Grid Выровнять по пиксельной сетке
@msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры
@msg side is left or right, not `{s}` side — это left или right, а не `{s}`
Ignore All Пропустить все
Add to Dictionary Добавить в словарь
No Suggestions Нет вариантов
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -286,7 +286,6 @@ font-technology Variable Variabla
Update While Dragging Uppdatera vid dragning
Hide Video Rulers Dölj videolinjaler
Background Contents Bakgrundsinnehåll
Background Contents: Bakgrundsinnehåll:
Background: Bakgrund:
Backspace Backspace
Bar Graph Tool Stapeldiagramverktyg
@@ -656,7 +655,6 @@ font-technology Variable Variabla
Couldn't {} {name}: {e} Det gick inte att {} {name}: {e}
Create Skapa
Create Clipping Mask Skapa klippmask
Create Document Skapa dokument
Create from Guides Skapa från stödlinjer
Create from Selection Skapa från markering
Create From: Skapa från:
@@ -1837,6 +1835,7 @@ font-technology Variable Variabla
Lock (⌘2) Lås (⌘2)
Lock Grid Lås rutnät
Lock Guides Lås stödlinjer
Lock Guides by Default Lås stödlinjer som standard
Lock Others Lås övriga
Lock Slices Lås skivor
Lock Station Point Lås betraktarpunkt
@@ -2085,7 +2084,6 @@ font-technology Variable Variabla
New Color Group… Ny färggrupp…
New Document Presets Förinställningar för nytt dokument
New file Ny fil
New from Template Ny från mall
New from Template… Ny från mall…
New Graphic Style Ny grafikstil
New Graphic Style (Alt-click to name it) Ny grafikstil (Alt-klicka för att namnge den)
@@ -2869,6 +2867,7 @@ digits Malayalam Malayalam
Save PDF Preset Spara PDF-förinställning
Save Perspective Grid Preset Spara förinställning för perspektivrutnät
Save Preset Spara förinställning
Save Document Preset Spara dokumentförinställning
Save Preset… Spara förinställning…
Save Print Preset Spara utskriftsförinställning
Save Recovery Data Spara återställningsdata
@@ -3432,7 +3431,6 @@ digits Malayalam Malayalam
Template Mall
Templates Mallar
Templates Folder Mallmapp
Templates… Mallar…
Text Displayed in Cell: Text som visas i cellen:
Text Export Options Alternativ för textexport
Text Fill & Stroke Color Textens fyllnads- och konturfärg
@@ -5062,7 +5060,7 @@ lighting Light Ljus
Bevel both sides Fasning på båda sidor
Presets Förinställningar
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Ett objekt kan bara ha en 3D-effekt. Redigera alternativen för den befintliga effekten eller ta bort den först.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} kräver för närvarande en bana eller sammansatt bana. Konvertera text, bilder och grupper till banor först.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} kräver för närvarande en bana, sammansatt bana, text eller grupp. Konvertera bilder och andra objekt till banor först.
@msg {label} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter
@msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt
@msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen
@@ -8152,3 +8150,6 @@ digits Hindi Hindi
Align to Pixel Grid Justera mot pixelrutnät
@msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet
@msg side is left or right, not `{s}` side är left eller right, inte `{s}`
Ignore All Ignorera alla
Add to Dictionary Lägg till i ordlistan
No Suggestions Inga förslag
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6 -5
View File
@@ -282,7 +282,6 @@ font-technology Variable Değişken
Update While Dragging Sürüklerken Güncelle
Hide Video Rulers Video Cetvellerini Gizle
Background Contents Arka Plan İçeriği
Background Contents: Arkaplan İçeriği:
Background: Arka plan:
Backspace Geri tuşu
Bar Graph Tool Çubuk Grafik Aracı
@@ -648,7 +647,6 @@ font-technology Variable Değişken
Couldn't {} {name}: {e} {} yapılamadı {name}: {e}
Create Oluştur
Create Clipping Mask Kırpma Maskesi Oluştur
Create Document Belge Oluştur
Create from Guides Rehberlerden Oluştur
Create from Selection Seçimden Oluştur
Create From: Şuradan Oluştur:
@@ -1730,6 +1728,7 @@ font-technology Variable Değişken
Lock (⌘2) Kilitle (⌘2)
Lock Grid Izgarayı Kilitle
Lock Guides Kilit Kılavuzları
Lock Guides by Default Kılavuzları Varsayılan Olarak Kilitle
Lock Others Diğerlerini Kilitle
Lock Slices Dilimleri Kilitle
Lock Station Point Kilit İstasyon Noktası
@@ -1958,7 +1957,6 @@ font-technology Variable Değişken
New Color Group… Yeni Renk Grubu…
New Document Presets Yeni Belge Ön Ayarları
New file Yeni dosya
New from Template Şablondan yeni
New from Template… Şablondan yeni…
New Graphic Style Yeni Grafik Stili
New Graphic Style (Alt-click to name it) Yeni Grafik Stili (Adını vermek için Alt tuşuna basarak tıklayın)
@@ -2734,6 +2732,7 @@ digits Malayalam Malayalam
Save PDF Preset PDF Hazır Ayarını Kaydet
Save Perspective Grid Preset Perspektif Izgarası Ön Ayarını Kaydet
Save Preset Ön Ayarı Kaydet
Save Document Preset Belge Ön Ayarını Kaydet
Save Preset… Ön Ayarı Kaydet…
Save Print Preset Yazdırma Ön Ayarını Kaydet
Save Recovery Data Kurtarma Verilerini Kaydet
@@ -3323,7 +3322,6 @@ digits Malayalam Malayalam
Template Şablon
Templates Şablonlar
Templates Folder Şablonlar Klasörü
Templates… Şablonlar…
Text Displayed in Cell: Hücrede Görüntülenen Metin:
Text Export Options Metin Dışa Aktarma Seçenekleri
Text Fill & Stroke Color Metin Dolgu ve Kontur Rengi
@@ -4858,7 +4856,7 @@ lighting Light Işık
Bevel both sides Her iki tarafa da eğim verin
Presets Ön ayarlar
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Bir nesne bir 3D efekti alır. Mevcut efektin seçeneklerini düzenleyin veya önce onu kaldırın.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} şu anda bir yol veya bileşik yol gerektiriyor. Önce türü, görüntüleri ve grupları yollara dönüştürün.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} şu anda bir yol, bileşik yol, yazı veya grup gerektiriyor. Önce görüntüleri ve diğer nesneleri yollara dönüştürün.
@msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir
@msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık
@msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir
@@ -6623,3 +6621,6 @@ breakText Glyphs Glifler
Align to Pixel Grid Piksel Izgarasına Hizala
@msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir
@msg side is left or right, not `{s}` side left veya right olmalıdır, `{s}` değil
Ignore All Tümünü Yoksay
Add to Dictionary Sözlüğe Ekle
No Suggestions Öneri Yok
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6 -5
View File
@@ -291,7 +291,6 @@ font-technology Variable Варіативні
Update While Dragging Оновлювати під час перетягування
Hide Video Rulers Приховати відео-лінійки
Background Contents Вміст фону
Background Contents: Вміст фону:
Background: Фон:
Backspace Backspace
Bar Graph Tool Інструмент «Гістограма»
@@ -663,7 +662,6 @@ font-technology Variable Варіативні
Couldn't {} {name}: {e} Не вдалося {} {name}: {e}
Create Створити
Create Clipping Mask Створити відсічну маску
Create Document Створити документ
Create from Guides Створити з напрямних
Create from Selection Створити із виділення
Create From: Створити з:
@@ -1919,6 +1917,7 @@ file-filter Templates Шаблони
Lock (⌘2) Заблокувати (⌘2)
Lock Grid Заблокувати сітку
Lock Guides Заблокувати напрямні
Lock Guides by Default Блокувати напрямні за замовчуванням
Lock Others Заблокувати решту
Lock Slices Заблокувати фрагменти
Lock Station Point Заблокувати точку стояння
@@ -2167,7 +2166,6 @@ file-filter Templates Шаблони
New Color Group… Нова колірна група…
New Document Presets Набір нових документів
New file Новий файл
New from Template Створити із шаблону
New from Template… Створити із шаблону…
New Graphic Style Новий графічний стиль
New Graphic Style (Alt-click to name it) Новий графічний стиль (Alt+клацання, щоб задати ім’я)
@@ -2959,6 +2957,7 @@ digits Malayalam Малаялам
Save PDF Preset Зберегти набір PDF
Save Perspective Grid Preset Зберегти набір сітки перспективи
Save Preset Зберегти набір
Save Document Preset Зберегти набір документа
Preset {number} Набір {number}
Save Preset… Зберегти набір…
Save Print Preset Зберегти набір друку
@@ -3539,7 +3538,6 @@ effect Points Точки
Template Шаблон
Templates Шаблони
Templates Folder Папка шаблонів
Templates… Шаблони…
Text Displayed in Cell: Текст, що відображається в комірці:
Text Export Options Параметри експорту тексту
Text Fill & Stroke Color Колір заливки та обведення тексту
@@ -5176,7 +5174,7 @@ lighting Light Світло
Bevel both sides Фаска з обох боків
Presets Набори
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Об'єкт може мати лише один 3D-ефект. Змініть параметри наявного ефекту або спершу вилучіть його.
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}: поки що потрібен контур або складений контур. Спершу перетворіть текст, зображення та групи на контури.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}: поки що потрібен контур, складений контур, текст або група. Спершу перетворіть зображення та інші об'єкти на контури.
@msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками
@msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту
@msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру
@@ -6268,3 +6266,6 @@ breakText Glyphs Гліфи
Align to Pixel Grid Вирівняти за піксельною сіткою
@msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури
@msg side is left or right, not `{s}` side — це left або right, а не `{s}`
Ignore All Пропустити все
Add to Dictionary Додати до словника
No Suggestions Немає варіантів
Can't render this file because it contains an unexpected character in line 182 and column 5.
+6 -5
View File
@@ -320,7 +320,6 @@ font-technology Variable Biến thiên
Update While Dragging Cập nhật khi kéo
Hide Video Rulers Ẩn thước video
Background Contents Nội dung nền
Background Contents: Nội dung nền:
Background: Nền:
Backspace Backspace
Balance Cân bằng
@@ -765,7 +764,6 @@ font-technology Variable Biến thiên
Create Tạo
Create Clipping Mask Tạo mặt nạ cắt
Create Compound Path From Ellipses Tạo đường dẫn phức từ hình elip
Create Document Tạo tài liệu
Create from Guides Tạo từ đường căn
Create from Selection Tạo từ vùng chọn
Create From: Tạo từ:
@@ -2203,6 +2201,7 @@ digits Hindi Hindi
Lock (⌘2) Khóa (⌘2)
Lock Grid Khóa lưới
Lock Guides Khóa đường căn
Lock Guides by Default Khóa đường căn theo mặc định
Lock Others Khóa các mục khác
Lock Slices Khóa lát cắt
Lock Station Point Khóa điểm trạm
@@ -2408,7 +2407,6 @@ digits Hindi Hindi
New Design Thiết kế mới
New Document Presets Thiết lập sẵn tài liệu mới
New file Tệp mới
New from Template Mới từ mẫu
New from Template… Mới từ mẫu…
New Graphic Style Kiểu đồ họa mới
New Graphic Style (Alt-click to name it) Kiểu đồ họa mới (Alt-bấm để đặt tên)
@@ -3272,6 +3270,7 @@ mark-style Japanese Kiểu Nhật
Save PDF Preset Lưu thiết lập sẵn PDF
Save Perspective Grid Preset Lưu thiết lập sẵn lưới phối cảnh
Save Preset Lưu thiết lập sẵn
Save Document Preset Lưu thiết lập sẵn tài liệu
Preset {number} Thiết lập sẵn {number}
Save Preset… Lưu thiết lập sẵn…
Save Print Preset Lưu thiết lập sẵn in
@@ -3873,7 +3872,6 @@ mark-style Japanese Kiểu Nhật
Template Mẫu
Templates Mẫu
Templates Folder Thư mục mẫu
Templates… Mẫu…
Text Văn bản
Text Displayed in Cell: Văn bản hiển thị trong ô:
Text Export Options Tùy chọn xuất văn bản
@@ -5176,7 +5174,7 @@ theme Appearance Giao diện
@msg {C}: no effect at index {from} {C}: không có hiệu ứng tại chỉ mục {from}
@msg {depth}-bit PSD files in this colour mode can't be read tệp PSD {depth}-bit ở chế độ màu này không thể đọc được
@msg {key} must be a finite coordinate {key} phải là tọa độ hữu hạn
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} hiện yêu cầu một đường dẫn hoặc đường dẫn phức. Hãy chuyển văn bản, hình ảnh và nhóm thành đường dẫn trước.
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label} hiện yêu cầu một đường dẫn, đường dẫn phức, văn bản hoặc nhóm. Hãy chuyển hình ảnh và các đối tượng khác thành đường dẫn trước.
@msg {label} produced an empty result {label} tạo ra kết quả trống
@msg {label} requires a shape with at least two anchors {label} yêu cầu một hình dạng có ít nhất hai điểm neo
@msg {label}: lost track of a shape's style {label}: mất dấu kiểu của hình dạng
@@ -6269,3 +6267,6 @@ breakText Glyphs Glyph
Align to Pixel Grid Căn theo lưới pixel
@msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được
@msg side is left or right, not `{s}` side là left hoặc right, không phải `{s}`
Ignore All Bỏ qua tất cả
Add to Dictionary Thêm vào từ điển
No Suggestions Không có gợi ý
Can't render this file because it contains an unexpected character in line 204 and column 5.
+3 -5
View File
@@ -243,7 +243,6 @@ antiAlias Sharp 锐利
Update While Dragging 拖动时更新
Hide Video Rulers 隐藏视频标尺
Background Contents 背景内容
Background Contents: 背景内容:
Background: 背景:
Backspace Backspace
Bar Graph Tool 条形图工具
@@ -614,7 +613,6 @@ antiAlias Sharp 锐利
Couldn't {} {name}: {e} 无法{} {name}:{e}
Create 创建
Create Clipping Mask 创建剪切蒙版
Create Document 创建文档
Create from Guides 从参考线创建
Create from Selection 从选区创建
Create From: 创建自:
@@ -1806,6 +1804,7 @@ file-filter Templates 模板
Lock (⌘2) 锁定 (⌘2)
Lock Grid 锁定网格
Lock Guides 锁定参考线
Lock Guides by Default 默认锁定参考线
Lock Slices 锁定切片
Lock Station Point 锁定基准点
Lossless (lossy WebP isn't available yet) 无失真 (尚未提供有失真的 WebP)
@@ -2044,7 +2043,6 @@ file-filter Templates 模板
New Color Group… 新建颜色组…
New Document Presets 新建文档预设
New file 新建文件
New from Template 从模板新建
New from Template… 从模板新建…
New Graphic Style 新建图形样式
New Graphic Style (Alt-click to name it) 新建图形样式 (Alt+点击可命名)
@@ -2828,6 +2826,7 @@ digits Malayalam 马拉雅拉姆数字
Save PDF Preset 保存 PDF 预设
Save Perspective Grid Preset 存储透视网格预设
Save Preset 保存预设
Save Document Preset 保存文档预设
Preset {number} 预设 {number}
Save Preset… 存储预设…
Save Print Preset 保存打印预设
@@ -3374,7 +3373,6 @@ digits Malayalam 马拉雅拉姆数字
Template 模板
Templates 模板
Templates Folder 模板文件夹
Templates… 模板…
Text Displayed in Cell: 单元格中显示的文字:
Text Export Options 文本导出选项
Text Fill & Stroke Color 文字填色和描边颜色
@@ -5457,7 +5455,7 @@ lighting Light 光源
Bevel both sides 双面斜角
Presets 预设
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一个对象只能应用一种 3D 效果。请编辑现有效果的选项,或先将其移去。
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}目前需要路径或复合路径。请先将文字、图像和编组转换为路径。
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路径、复合路径、文字或编组。请先将图像和其他对象转换为路径。
@msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状
@msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果
@msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -5
View File
@@ -256,7 +256,6 @@ font-technology Variable 可變
Update While Dragging 拖曳時更新
Hide Video Rulers 隱藏視訊尺標
Background Contents 背景內容
Background Contents: 背景內容:
Background: 背景:
Backspace Backspace
Bar Graph Tool 橫條圖工具
@@ -631,7 +630,6 @@ font-technology Variable 可變
Couldn't {} {name}: {e} 無法{} {name}:{e}
Create 建立
Create Clipping Mask 建立剪裁遮色片
Create Document 建立文件
Create from Guides 從參考線建立
Create from Selection 從選取範圍建立
Create From: 建立來源:
@@ -2800,6 +2798,7 @@ file-filter Templates 範本
Lock (⌘2) 鎖定 (⌘2)
Lock Grid 鎖定格點
Lock Guides 鎖定參考線
Lock Guides by Default 預設鎖定參考線
Lock Others 鎖定其他
Lock Slices 鎖定切片
Lock Station Point 鎖定基準點
@@ -3064,7 +3063,6 @@ file-filter Templates 範本
New Color Group… 新增顏色群組…
New Document Presets 新增文件預設集
New file 新增檔案
New from Template 從範本新增
New from Template… 從範本新增…
New Graphic Style 新增繪圖樣式
New Graphic Style (Alt-click to name it) 新增繪圖樣式 (Alt+點擊可命名)
@@ -3861,6 +3859,7 @@ digits Malayalam 馬來亞拉姆數字
Save PDF Preset 儲存 PDF 預設集
Save Perspective Grid Preset 儲存透視格點預設集
Save Preset 儲存預設集
Save Document Preset 儲存文件預設集
Preset {number} 預設集 {number}
Save Preset… 儲存預設集…
Save Print Preset 儲存列印預設集
@@ -4438,7 +4437,6 @@ digits Malayalam 馬來亞拉姆數字
Template 範本
Templates 範本
Templates Folder 範本資料夾
Templates… 範本…
Text Displayed in Cell: 儲存格中顯示的文字:
Text Export Options 文字匯出選項
Text Fill & Stroke Color 文字填色與筆畫顏色
@@ -5283,7 +5281,7 @@ lighting Light 光源
Bevel both sides 雙面斜角
Presets 預設集
@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. 一個物件只能套用一種 3D 效果。請編輯現有效果的選項,或先將其移除。
@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label}目前需要路徑或複合路徑。請先將文字、影像和群組轉換為路徑。
@msg {label} currently requires a path, compound path, type or group. Convert images and other objects to paths first. {label}目前需要路徑、複合路徑、文字或群組。請先將影像和其他物件轉換為路徑。
@msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀
@msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果
@msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀
Can't render this file because it contains an unexpected character in line 6 and column 47.
+35 -1
View File
@@ -14,7 +14,8 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect();
let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind));
let several = roots.len() >= 2;
let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep];
let mut v = spelling_items(app);
v.extend([c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep]);
if st.isolation.is_some() {
v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]);
}
@@ -108,6 +109,39 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
prune(app, v)
}
/// A right-click on a word Auto Spell Check underlined ([`crate::state::UiState::context_spelling`]):
/// the words its dictionary suggests, each replacing it (`text.editRange`, one undo step;
/// `spelling_item_label` shows the word), or No Suggestions; then Ignore All
/// (`text.spelling.ignoreAll`) and Add to Dictionary (`text.spelling.addWord`), as the public
/// [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)
/// page lists them. Nothing for another right-click, or after the document or the word changed.
fn spelling_items(app: &VectorcraftApp) -> Vec<Item> {
let Some(st) = app.session.active() else { return vec![] };
let Some(w) = app.ui.context_spelling.as_ref().filter(|w| w.uid == st.uid) else { return vec![] };
let still = match st.doc.node(w.id).map(|n| &n.kind) {
Some(vectorcraft_doc::NodeKind::Text(t)) => t.plain_text().get(w.start..w.end) == Some(w.word.as_str()),
_ => false,
};
if !still {
return vec![];
}
let mut v: Vec<Item> = w
.suggestions
.iter()
.map(|s| cp("Suggestion", "text.editRange", json!({ "id": w.id.0, "start": w.start, "end": w.end, "insert": s })))
.collect();
if v.is_empty() {
v.push(Item::Header("No Suggestions"));
}
v.extend([
Sep,
cp("Ignore All", "text.spelling.ignoreAll", json!({ "word": w.word })),
cp("Add to Dictionary", "text.spelling.addWord", json!({ "word": w.word })),
Sep,
]);
v
}
/// The locked objects under the pointer at the right-click, each unlocked as the Layers panel's
/// lock column does. `unlock_item_label` shows each object's name.
fn unlock_items(app: &VectorcraftApp) -> Vec<Item> {
+5 -2
View File
@@ -1,6 +1,6 @@
use super::context::context_items;
use super::invoke::click_target;
use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label};
use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, spelling_item_label, unlock_item_label};
use super::state::{shortcut_of, shown_state};
use super::tree::menu_tree_named;
use super::{Entry, Item, Serialize, Value, VectorcraftApp};
@@ -51,6 +51,7 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option<Entry<'a>> {
source: label,
label: effect_item_label(id, p)
.or_else(|| unlock_item_label(app, id, p, crate::i18n::current()))
.or_else(|| spelling_item_label(id, p))
.unwrap_or_else(|| display_label(app, id, label))
.into(),
shortcut: item_shortcut(id, p),
@@ -129,7 +130,9 @@ fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Ve
let Some((enabled, _)) = shown_state(app, id, p) else { continue };
out.push(MenuEntry {
path: path.clone(),
label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)),
label: unlock_item_label(app, id, p, crate::i18n::Lang::EN)
.or_else(|| spelling_item_label(id, p))
.unwrap_or_else(|| dynamic_label(app, id, l)),
command: Some(id.to_string()),
params: p.clone(),
enabled,
+9
View File
@@ -214,6 +214,12 @@ pub(super) fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang:
Some(crate::panels::layers::shown_name(app, n, lang))
}
/// A context menu suggestion's label: the word it puts in (`text.editRange {insert}`), as it is,
/// untranslated ([`super::context`]'s `spelling_items`).
pub(super) fn spelling_item_label(id: &str, p: &Value) -> Option<String> {
(id == "text.editRange").then(|| p.get("insert")?.as_str().map(str::to_string)).flatten()
}
/// The shortcut a menu item shows: its command's (items without params), a panel's for the
/// Window menu's `window.panel {panel}` items, an effect's for the Effect menu's items, or a filter
/// plug-in's for Object › Plug-ins' `plugin.dialog {id}` items.
@@ -235,6 +241,9 @@ pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> {
/// Labels only the canvas context menu ([`context_items`]) shows, so the catalog tests can insist
/// every one of them is translated (a test checks the list against the menu).
pub const CONTEXT_LABELS: &[&str] = &[
"Ignore All",
"Add to Dictionary",
"No Suggestions",
"Isolate Selected Group",
"Exit Isolation Mode",
"Lock Artboards",
@@ -1054,8 +1054,9 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() {
h.host.borrow_mut().child_overlay = None;
h.frame(vec![]);
h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP;
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([
Event::PointerMoved(pointer),
Event::PointerMoved(pointer + vec2(1.0, 0.0)),
Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
@@ -1087,8 +1088,9 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() {
h.host.borrow_mut().child_transparent_overlay = None;
h.host.borrow_mut().child_pass_through_overlay = Some((viewport, field.expand(10.0)));
h.frames(2);
h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP;
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([
Event::PointerMoved(pointer),
Event::PointerMoved(pointer + vec2(2.0, 0.0)),
Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
@@ -1453,6 +1455,75 @@ fn wheel_events_on_both_sides_of_an_overlay_are_checked_separately() {
assert_eq!(h.ctx.input_for(viewport, |input| input.pointer.latest_pos()), Some(clear));
}
#[test]
fn a_wheel_stream_that_began_outside_a_floating_font_field_stays_outside() {
let mut h = Harness::new();
h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap();
let viewport = h.float("character");
let field = h
.host
.borrow()
.windows
.get(&viewport)
.and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect))
.expect("the Character panel's font family field");
let on = pos2(field.min.x + 4.0, field.center().y);
let off = pos2(field.min.x + 4.0, field.max.y + 60.0);
let turn = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
let before = h.app.run("text.fonts", json!({})).unwrap();
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(off), turn.clone(), Event::PointerMoved(on), turn]);
h.frames(3);
assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before);
}
#[test]
fn a_wheel_stream_leaves_a_floating_font_field_that_an_overlay_covered_before_its_queued_turn() {
let mut h = Harness::new();
h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap();
let viewport = h.float("character");
let field = h
.host
.borrow()
.windows
.get(&viewport)
.and_then(|w| w.texts.iter().find(|(text, _)| text == "Source Sans 3").map(|(_, rect)| *rect))
.expect("the Character panel's font family field");
let pointer = pos2(field.min.x + 4.0, field.center().y);
let turn = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), false));
h.frames(2);
let field_layer = crate::font_menu::wheel_field_layer_for_test(&h.ctx, viewport, pointer).expect("the font field layer");
let raise_field = |h: &mut Harness| {
h.host.borrow_mut().child_raise_layer = Some((viewport, field_layer));
h.frame(vec![]);
h.host.borrow_mut().child_raise_layer = None;
h.frame(vec![]);
};
raise_field(&mut h);
let before = h.app.run("text.fonts", json!({})).unwrap();
h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), true));
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(pointer), turn.clone()]);
h.frames(2);
assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before, "the overlay covered the field before its queued turn");
h.host.borrow_mut().child_raise_overlay = Some((viewport, field.expand(10.0), false));
raise_field(&mut h);
h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.push(turn);
h.frames(2);
assert_eq!(h.app.run("text.fonts", json!({})).unwrap(), before, "the stream left the field when the overlay covered it");
}
#[test]
fn leaving_a_floating_font_field_resets_queued_fractional_wheel_input() {
let mut h = Harness::new();

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