mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 12:21:54 +00:00
Merge pull request #6380 from dotCooCoo/fix/preview-bounds-butt-line
Transform: butt-capped lines measure to their end points with Use Preview Bounds
This commit is contained in:
+325
-39
@@ -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::HEAD_REACH;
|
||||
@@ -101,6 +101,58 @@ 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 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 grown by `side` on every side.
|
||||
fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect {
|
||||
if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) {
|
||||
if depth == 0 {
|
||||
return 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();
|
||||
@@ -115,39 +167,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);
|
||||
@@ -158,7 +252,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
|
||||
@@ -176,30 +278,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 +393,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 +404,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 +435,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 +506,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 +533,164 @@ 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_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();
|
||||
|
||||
@@ -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)] })];
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+5
-1
@@ -3226,7 +3226,11 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou
|
||||
Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take
|
||||
in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an
|
||||
inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter
|
||||
limit. A rectangle's right-angle miters stay within half the weight of its edges.
|
||||
limit. A rectangle's right-angle miters stay within half the weight of its edges. A butt-capped stroke on an open
|
||||
path does not reach past the path's ends: a straight segment's box comes from the four corners of its stroked
|
||||
rectangle. Past an end, only a round or projecting cap or an arrowhead adds to the box. A curve that bends more
|
||||
tightly than half the stroke weight can measure a little larger than it paints, and a butt-dashed path whose first or
|
||||
last dash is a gap is measured to the path's ends.
|
||||
|
||||
A symbol instance is measured by its symbol's art, placed by the instance's transform. The bounds in
|
||||
`document.inspect` and `document.node`, the Align commands, `object.setBounds`, the bounding box, clicks, marquees,
|
||||
|
||||
@@ -1049,6 +1049,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`.
|
||||
- Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members).
|
||||
- Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy).
|
||||
- A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html).
|
||||
- With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy).
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)).
|
||||
- Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy).
|
||||
|
||||
Reference in New Issue
Block a user