Compare commits
1 Commits
main
...
feat/compo
| Author | SHA1 | Date |
|---|---|---|
|
|
33084b93ac |
|
|
@ -0,0 +1,242 @@
|
|||
//! Compound outlines (`a:ln/@cmpd`): double, thick-thin, thin-thick and triple lines.
|
||||
//!
|
||||
//! PowerPoint splits the line's width into bands across the path, measured from its left side
|
||||
//! (looking along the path) to its right, as these shares of the width (read from PowerPoint's
|
||||
//! export of 24 pt lines of each kind):
|
||||
//! - double: 1/3 line, 1/3 gap, 1/3 line;
|
||||
//! - thin-thick: 1/5 line, 1/5 gap, 3/5 line; thick-thin the mirror;
|
||||
//! - triple: 1/6 line, 1/6 gap, 1/3 line, 1/6 gap, 1/6 line.
|
||||
//!
|
||||
//! On a shape drawn clockwise (all presets) the left side is the outside.
|
||||
|
||||
use deckcraft_model::style::Compound;
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, PathSeg, Point, Vec2};
|
||||
|
||||
/// The bands of `c` as (from, to) shares of the width, left side first.
|
||||
pub fn bands(c: Compound) -> &'static [(f64, f64)] {
|
||||
match c {
|
||||
Compound::Single => &[(0.0, 1.0)],
|
||||
Compound::Double => &[(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0)],
|
||||
Compound::ThinThick => &[(0.0, 0.2), (0.4, 1.0)],
|
||||
Compound::ThickThin => &[(0.0, 0.6), (0.8, 1.0)],
|
||||
Compound::Triple => &[(0.0, 1.0 / 6.0), (2.0 / 6.0, 4.0 / 6.0), (5.0 / 6.0, 1.0)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit normal pointing to the right of travel (y down), or None for a zero-length tangent.
|
||||
fn right(t: Vec2) -> Option<Vec2> {
|
||||
let l = t.hypot();
|
||||
(l > 1e-12 && l.is_finite()).then(|| Vec2::new(-t.y, t.x) / l)
|
||||
}
|
||||
|
||||
/// `path` moved `d` to the right of travel (negative: to the left). Line corners meet at their
|
||||
/// miter point (bevelled when sharper than about 11°); curves are offset by kurbo; other joins are
|
||||
/// bridged by a straight line.
|
||||
pub fn offset(path: &BezPath, d: f64, tolerance: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
if d.abs() < 1e-9 {
|
||||
return path.clone();
|
||||
}
|
||||
// Each subpath as segments, offset one by one.
|
||||
let mut segs: Vec<PathSeg> = Vec::new();
|
||||
let mut closed = false;
|
||||
let flush = |segs: &mut Vec<PathSeg>, closed: bool, out: &mut BezPath| {
|
||||
let pieces: Vec<(PathSeg, BezPath)> = segs.iter().filter_map(|s| offset_seg(*s, d, tolerance).map(|p| (*s, p))).collect();
|
||||
let begin = out.elements().len();
|
||||
for (i, (seg, piece)) in pieces.iter().enumerate() {
|
||||
let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() else { continue };
|
||||
match i.checked_sub(1).and_then(|j| pieces.get(j)) {
|
||||
None => out.move_to(p),
|
||||
Some((prev, _)) => join(out, *prev, *seg, p, d),
|
||||
}
|
||||
for e in piece.elements().iter().skip(1) {
|
||||
out.push(*e);
|
||||
}
|
||||
}
|
||||
if closed && let (Some((last, _)), Some((first, piece))) = (pieces.last(), pieces.first()) {
|
||||
if let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() {
|
||||
// Lines meeting at the start: both ends move to their miter point.
|
||||
match miter(*last, *first, d) {
|
||||
Some(m) if is_line(*last) && is_line(*first) => {
|
||||
set_last(out, m);
|
||||
if let Some(PathEl::MoveTo(_)) = out.elements().get(begin) {
|
||||
out.elements_mut()[begin] = PathEl::MoveTo(m);
|
||||
}
|
||||
}
|
||||
_ => out.line_to(p),
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
segs.clear();
|
||||
};
|
||||
let mut start = Point::ZERO;
|
||||
let mut cur = Point::ZERO;
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut segs, closed, &mut out);
|
||||
closed = false;
|
||||
start = p;
|
||||
cur = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
segs.push(PathSeg::Line(kurbo::Line::new(cur, p)));
|
||||
cur = p;
|
||||
}
|
||||
PathEl::QuadTo(a, b) => {
|
||||
segs.push(PathSeg::Quad(kurbo::QuadBez::new(cur, a, b)));
|
||||
cur = b;
|
||||
}
|
||||
PathEl::CurveTo(a, b, c) => {
|
||||
segs.push(PathSeg::Cubic(CubicBez::new(cur, a, b, c)));
|
||||
cur = c;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if (cur - start).hypot() > 1e-9 {
|
||||
segs.push(PathSeg::Line(kurbo::Line::new(cur, start)));
|
||||
}
|
||||
closed = true;
|
||||
flush(&mut segs, closed, &mut out);
|
||||
closed = false;
|
||||
cur = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut segs, closed, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn offset_seg(s: PathSeg, d: f64, tolerance: f64) -> Option<BezPath> {
|
||||
// Far-off or non-finite geometry (hostile files) is left out rather than offset.
|
||||
let sane = |p: Point| p.is_finite() && p.x.abs() < 1e7 && p.y.abs() < 1e7;
|
||||
let ok = match s {
|
||||
PathSeg::Line(l) => sane(l.p0) && sane(l.p1),
|
||||
PathSeg::Quad(q) => sane(q.p0) && sane(q.p1) && sane(q.p2),
|
||||
PathSeg::Cubic(c) => sane(c.p0) && sane(c.p1) && sane(c.p2) && sane(c.p3),
|
||||
};
|
||||
if !ok || !d.is_finite() {
|
||||
return None;
|
||||
}
|
||||
match s {
|
||||
PathSeg::Line(l) => {
|
||||
let n = right(l.p1 - l.p0)?;
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(l.p0 + n * d);
|
||||
p.line_to(l.p1 + n * d);
|
||||
Some(p)
|
||||
}
|
||||
PathSeg::Quad(q) => offset_seg(PathSeg::Cubic(q.raise()), d, tolerance),
|
||||
PathSeg::Cubic(c) => {
|
||||
// Nearly straight cubics (collinear control points) are offset as lines.
|
||||
let chord = c.p3 - c.p0;
|
||||
let flat = |p: Point| chord.cross(p - c.p0).abs() <= 1e-9 * chord.hypot2().max(1e-18);
|
||||
if flat(c.p1) && flat(c.p2) {
|
||||
return offset_seg(PathSeg::Line(kurbo::Line::new(c.p0, c.p3)), d, tolerance);
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
kurbo::offset::offset_cubic(c, d, tolerance, &mut p);
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The segment's tangent at its start and at its end (the first non-zero control vector).
|
||||
fn tangents(s: PathSeg) -> (Vec2, Vec2) {
|
||||
let first = |v: &[Vec2]| v.iter().copied().find(|v| v.hypot2() > 1e-18).unwrap_or(Vec2::ZERO);
|
||||
match s {
|
||||
PathSeg::Line(l) => (l.p1 - l.p0, l.p1 - l.p0),
|
||||
PathSeg::Quad(q) => (first(&[q.p1 - q.p0, q.p2 - q.p0]), first(&[q.p2 - q.p1, q.p2 - q.p0])),
|
||||
PathSeg::Cubic(c) => (first(&[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0]), first(&[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0])),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_line(s: PathSeg) -> bool {
|
||||
matches!(s, PathSeg::Line(_))
|
||||
}
|
||||
|
||||
/// Move the path's last point to `p`.
|
||||
fn set_last(out: &mut BezPath, p: Point) {
|
||||
if let Some(e) = out.elements_mut().last_mut() {
|
||||
match e {
|
||||
PathEl::MoveTo(q) | PathEl::LineTo(q) => *q = p,
|
||||
PathEl::QuadTo(_, q) => *q = p,
|
||||
PathEl::CurveTo(_, _, q) => *q = p,
|
||||
PathEl::ClosePath => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the offsets of `prev` and `next` meet at their shared corner (their miter point), or
|
||||
/// None for a corner sharper than about 11 degrees or a degenerate tangent.
|
||||
fn miter(prev: PathSeg, next: PathSeg, d: f64) -> Option<Point> {
|
||||
let (_, t0) = tangents(prev);
|
||||
let (t1, _) = tangents(next);
|
||||
let (n0, n1) = (right(t0)?, right(t1)?);
|
||||
let denom = 1.0 + n0.dot(n1);
|
||||
(denom > 0.02).then(|| prev.end() + (n0 + n1) * (d / denom)).filter(|m| m.is_finite())
|
||||
}
|
||||
|
||||
/// Join the offset of `prev` (ending at `out`'s current point) to the offset of `next`, which
|
||||
/// starts at `p`: two lines meet at their miter point (both ends moved there, so an inner corner
|
||||
/// doesn't loop); anything else is bridged straight.
|
||||
fn join(out: &mut BezPath, prev: PathSeg, next: PathSeg, p: Point, d: f64) {
|
||||
match miter(prev, next, d) {
|
||||
Some(m) if is_line(prev) && is_line(next) => set_last(out, m),
|
||||
Some(m) => {
|
||||
out.line_to(m);
|
||||
out.line_to(p);
|
||||
}
|
||||
None => out.line_to(p),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
#[test]
|
||||
fn bands_split_the_width_like_powerpoint() {
|
||||
for c in [Compound::Single, Compound::Double, Compound::ThinThick, Compound::ThickThin, Compound::Triple] {
|
||||
let b = bands(c);
|
||||
assert_eq!(b.first().map(|x| x.0), Some(0.0));
|
||||
assert_eq!(b.last().map(|x| x.1), Some(1.0));
|
||||
assert!(b.windows(2).all(|w| w[0].1 < w[1].0), "{c:?}: bands in order with gaps");
|
||||
}
|
||||
// Thin-thick: the thin line on the left, thick-thin its mirror.
|
||||
let (tt, th) = (bands(Compound::ThinThick), bands(Compound::ThickThin));
|
||||
assert!((tt[0].1 - tt[0].0) < (tt[1].1 - tt[1].0));
|
||||
assert!((th[0].1 - th[0].0) > (th[1].1 - th[1].0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsetting_a_rectangle_grows_or_shrinks_it() {
|
||||
// Clockwise in y-down coordinates: the left of travel is the outside.
|
||||
let r = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
|
||||
let out = offset(&r, -5.0, 0.01).bounding_box();
|
||||
assert!(
|
||||
(out.x0 + 5.0).abs() < 1e-6 && (out.y0 + 5.0).abs() < 1e-6 && (out.x1 - 105.0).abs() < 1e-6 && (out.y1 - 55.0).abs() < 1e-6,
|
||||
"{out:?}"
|
||||
);
|
||||
let inn = offset(&r, 5.0, 0.01).bounding_box();
|
||||
assert!((inn.x0 - 5.0).abs() < 1e-6 && (inn.y1 - 45.0).abs() < 1e-6, "{inn:?}");
|
||||
// Corners meet at their miter points: the area is the offset rectangle's.
|
||||
assert!((offset(&r, -5.0, 0.01).area().abs() - 110.0 * 60.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsetting_a_circle_changes_its_radius() {
|
||||
let c = kurbo::Circle::new((50.0, 50.0), 20.0).to_path(0.01);
|
||||
let b = offset(&c, 4.0, 0.01).bounding_box();
|
||||
let w = b.width() / 2.0;
|
||||
assert!((w - 16.0).abs() < 0.2 || (w - 24.0).abs() < 0.2, "radius {w}");
|
||||
// A hostile path never panics and yields finite output.
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((0.0, 0.0));
|
||||
p.curve_to((f64::MAX, 0.0), (0.0, f64::MIN_POSITIVE), (1.0, 1.0));
|
||||
p.close_path();
|
||||
let _ = offset(&p, 3.0, 0.01);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
|
||||
mod chart;
|
||||
mod colr;
|
||||
|
||||
mod compound;
|
||||
mod images;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
|
|
@ -941,6 +943,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
|
|||
}
|
||||
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
|
||||
}
|
||||
// Compound lines: each band stroked along the path moved to its place across the width.
|
||||
let compound = line.compound.unwrap_or_default();
|
||||
if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
|
||||
let tolerance = 0.1 / scale_of(m).max(1e-6);
|
||||
for &(a, b) in compound::bands(compound) {
|
||||
let d = (-0.5 + (a + b) / 2.0) * lw;
|
||||
let mut band = sk.clone();
|
||||
band.width = (b - a) * lw;
|
||||
ctx.set_stroke(band);
|
||||
ctx.stroke_path(&compound::offset(path, d, tolerance));
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctx.set_stroke(sk);
|
||||
ctx.stroke_path(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -776,3 +776,47 @@ fn pattern_fills_draw_powerpoints_tiles() {
|
|||
assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_lines_draw_their_bands() {
|
||||
// A 24 pt outline on a rectangle, as PowerPoint's export draws each kind (top edge, outside
|
||||
// first): double 8/8/8, thin-thick 4.8/4.8/14.4, triple 4/4/8/4/4.
|
||||
use deckcraft_model::style::{Compound, Fill, Line};
|
||||
let runs = |c: Compound| {
|
||||
let sh = Shape {
|
||||
id: ShapeId(760),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
|
||||
line: Some(Line {
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
|
||||
width: Some(24.0),
|
||||
compound: Some(c),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 2.0, ..Default::default() });
|
||||
// Down the middle of the top edge (y 100 pt), from 80 pt to 120 pt: lengths of dark and
|
||||
// light runs in points.
|
||||
let mut out: Vec<(bool, f64)> = vec![];
|
||||
for y in 160..240 {
|
||||
let dark = img.pixel(500, y)[0] < 128;
|
||||
match out.last_mut() {
|
||||
Some((d, n)) if *d == dark => *n += 0.5,
|
||||
_ => out.push((dark, 0.5)),
|
||||
}
|
||||
}
|
||||
out.into_iter().filter(|(d, _)| *d).map(|(_, n)| n).collect::<Vec<f64>>()
|
||||
};
|
||||
let close = |got: Vec<f64>, want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0);
|
||||
assert!(close(runs(Compound::Single), &[24.0]));
|
||||
let d = runs(Compound::Double);
|
||||
assert!(close(d.clone(), &[8.0, 8.0]), "{d:?}");
|
||||
let t = runs(Compound::ThinThick);
|
||||
assert!(close(t.clone(), &[4.8, 14.4]), "{t:?}");
|
||||
let k = runs(Compound::ThickThin);
|
||||
assert!(close(k.clone(), &[14.4, 4.8]), "{k:?}");
|
||||
let tri = runs(Compound::Triple);
|
||||
assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue