mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
A stroke can carry a width profile: widths at positions along the path
(each subpath by length), interpolated smoothly (monotone cubic).
- doc: WidthProfile / WidthPoint / WidthPreset (uniform, wedge, taper both
ends, eye, rounded, pinch) on StrokeProps, serde-defaulted so older files
load; paint reach and hit testing follow the widest point.
- pathops: path measuring by length and the variable outline (offset along
the normals, round/miter/bevel joins, caps, dashes keep their width,
Inside/Outside clip as before; bounded samples and dashes, uniform
fallback). Canvas, PNG, SVG (filled outline), PDF and outline stroke use it.
- engine: stroke.widthProfile {preset | points}, node.set widthProfile,
inspect output; the Variable width tool (Shift+W) in vector edit mode:
drag the path to add a width point, slide points, drag their handles,
Delete removes the selected one; vector.edit.widthPoint add/set/remove.
Every gesture is one undo step.
- UI: width profile dropdown with previews drawn in code and a flip button
in the stroke settings; the tool in the edit toolbar (new icon drawn in
code); width handles and a hover preview on the canvas; Position/Width
fields for the selected width point.
- Tests: profile maths, outlines, render regressions, tool gestures,
commands, exports and junk sweeps. ROADMAP, README, docs updated.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
7.4 KiB
Rust
148 lines
7.4 KiB
Rust
//! Variable width strokes: measuring paths and outlining strokes whose width follows a profile.
|
|
|
|
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, WidthPreset, WidthProfile};
|
|
use figcraft_geom::kurbo::{BezPath, Circle, Point, Rect, Shape};
|
|
|
|
use crate::stroke_outline;
|
|
use crate::width::*;
|
|
|
|
fn line(x1: f64) -> BezPath {
|
|
let mut p = BezPath::new();
|
|
p.move_to((0.0, 0.0));
|
|
p.line_to((x1, 0.0));
|
|
p
|
|
}
|
|
|
|
fn props(weight: f64, profile: Option<WidthProfile>) -> StrokeProps {
|
|
StrokeProps { weight, align: StrokeAlign::Center, width_profile: profile, ..Default::default() }
|
|
}
|
|
|
|
/// Whether the non-zero fill of `p` covers `pt`.
|
|
fn covers(p: &BezPath, x: f64, y: f64) -> bool {
|
|
p.winding(Point::new(x, y)) != 0
|
|
}
|
|
|
|
#[test]
|
|
fn measures_paths_by_length() {
|
|
let mut p = line(100.0);
|
|
p.move_to((0.0, 50.0));
|
|
p.line_to((0.0, 150.0));
|
|
let lines = polylines(&p).unwrap();
|
|
assert_eq!(lines.len(), 2);
|
|
assert_eq!(lines[0].length(), 100.0);
|
|
let (pt, n) = lines[0].at(0.25).unwrap();
|
|
assert_eq!(pt, Point::new(25.0, 0.0));
|
|
assert!((n.x).abs() < 1e-12 && (n.y - 1.0).abs() < 1e-12, "{n:?}");
|
|
assert_eq!(lines[0].at(1.0).unwrap().0, Point::new(100.0, 0.0));
|
|
let (i, t, d) = nearest(&lines, Point::new(30.0, 5.0)).unwrap();
|
|
assert_eq!((i, d), (0, 5.0));
|
|
assert!((t - 0.3).abs() < 1e-12, "{t}");
|
|
let (i, t, _) = nearest(&lines, Point::new(-3.0, 100.0)).unwrap();
|
|
assert_eq!((i, t), (1, 0.5));
|
|
// A closed circle ends where it starts.
|
|
let c = Circle::new((0.0, 0.0), 10.0).to_path(0.01);
|
|
let l = &polylines(&c).unwrap()[0];
|
|
assert!(l.closed);
|
|
assert_eq!(l.points.first(), l.points.last());
|
|
assert!((l.length() - std::f64::consts::TAU * 10.0).abs() < 0.1, "{}", l.length());
|
|
}
|
|
|
|
#[test]
|
|
fn width_follows_the_profile() {
|
|
// A wedge from 10 wide to a point: a triangle of area 500.
|
|
let s = stroke_outline(&line(100.0), &props(10.0, WidthPreset::Wedge.profile()), false);
|
|
assert!((s.area().abs() - 500.0).abs() < 2.0, "{}", s.area());
|
|
assert!(covers(&s, 5.0, 4.0) && !covers(&s, 95.0, 4.0), "wide at the start, thin at the end");
|
|
// A uniform profile matches the plain stroke, caps included.
|
|
for cap in [StrokeCap::None, StrokeCap::Round, StrokeCap::Square] {
|
|
let uniform = StrokeProps { cap_start: cap, cap_end: cap, ..props(10.0, Some(WidthProfile::uniform())) };
|
|
let plain = StrokeProps { width_profile: None, ..uniform.clone() };
|
|
let (a, b) = (stroke_outline(&line(100.0), &uniform, false), stroke_outline(&line(100.0), &plain, false));
|
|
assert!((a.area().abs() - b.area().abs()).abs() < 1.0, "{cap:?}: {} vs {}", a.area(), b.area());
|
|
let (ba, bb) = (a.bounding_box(), b.bounding_box());
|
|
assert!((ba.x0 - bb.x0).abs() < 0.1 && (ba.x1 - bb.x1).abs() < 0.1 && (ba.y1 - bb.y1).abs() < 0.1, "{cap:?}: {ba:?} vs {bb:?}");
|
|
}
|
|
// Scales above 1 widen the stroke past its weight.
|
|
let wide = stroke_outline(&line(100.0), &props(10.0, Some(WidthProfile::new([(0.0, 1.0), (0.5, 3.0), (1.0, 1.0)]))), false);
|
|
assert!((wide.bounding_box().y1 - 15.0).abs() < 0.01, "{:?}", wide.bounding_box());
|
|
}
|
|
|
|
#[test]
|
|
fn closed_paths_make_a_ring_per_subpath() {
|
|
let c = Circle::new((50.0, 50.0), 40.0).to_path(0.01);
|
|
let s = stroke_outline(&c, &props(10.0, WidthPreset::Eye.profile()), true);
|
|
assert!(!covers(&s, 50.0, 50.0), "the middle stays open");
|
|
// The eye is widest halfway round (the circle starts at its right-hand point).
|
|
assert!(covers(&s, 14.0, 50.0) && covers(&s, 6.0, 50.0));
|
|
assert!(!covers(&s, 94.0, 50.0) && !covers(&s, 86.0, 50.0), "pointed at the start");
|
|
// Inside alignment stays inside the shape.
|
|
let r = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
|
|
let inside = stroke_outline(&r, &StrokeProps { align: StrokeAlign::Inside, ..props(10.0, WidthPreset::Taper.profile()) }, true);
|
|
let b = inside.bounding_box();
|
|
assert!(b.x0 >= -0.01 && b.y0 >= -0.01 && b.x1 <= 100.01 && b.y1 <= 100.01, "{b:?}");
|
|
assert!(inside.area().abs() > 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn joins_and_dashes() {
|
|
let mut corner = BezPath::new();
|
|
corner.move_to((0.0, 0.0));
|
|
corner.line_to((100.0, 0.0));
|
|
corner.line_to((100.0, 100.0));
|
|
let at = |join| {
|
|
let p = StrokeProps { join, ..props(20.0, Some(WidthProfile::uniform())) };
|
|
stroke_outline(&corner, &p, false)
|
|
};
|
|
let (miter, bevel, round) = (at(StrokeJoin::Miter), at(StrokeJoin::Bevel), at(StrokeJoin::Round));
|
|
// The outer corner is (110, -10): the miter reaches it, the others cut it.
|
|
assert!(covers(&miter, 109.0, -9.0));
|
|
assert!(!covers(&bevel, 109.0, -9.0) && !covers(&round, 109.0, -9.0));
|
|
assert!(covers(&round, 106.0, -6.0) && !covers(&bevel, 106.0, -6.0), "round bulges past the bevel");
|
|
// The inner corner is filled.
|
|
assert!(covers(&bevel, 95.0, 5.0) && covers(&bevel, 91.0, 9.0));
|
|
// Dashes: half of a 100 long, 2 wide line.
|
|
let dashed = StrokeProps { dashes: vec![10.0, 10.0], ..props(2.0, Some(WidthProfile::uniform())) };
|
|
let s = stroke_outline(&line(100.0), &dashed, false);
|
|
assert!((s.area().abs() - 100.0).abs() < 1.0, "{}", s.area());
|
|
assert!(covers(&s, 5.0, 0.0) && !covers(&s, 15.0, 0.0));
|
|
// Dashes keep the width of where they are on the path.
|
|
let tapered = StrokeProps { dashes: vec![10.0, 10.0], ..props(10.0, WidthPreset::Wedge.profile()) };
|
|
let s = stroke_outline(&line(100.0), &tapered, false);
|
|
assert!(covers(&s, 5.0, 4.0) && !covers(&s, 85.0, 4.0));
|
|
// Zero-length dashes with round caps are dots.
|
|
let dots =
|
|
StrokeProps { dashes: vec![0.0, 10.0], cap_start: StrokeCap::Round, cap_end: StrokeCap::Round, ..props(4.0, Some(WidthProfile::uniform())) };
|
|
let s = stroke_outline(&line(100.0), &dots, false);
|
|
assert!(covers(&s, 10.0, 1.0) && !covers(&s, 5.0, 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_and_huge_input_is_bounded() {
|
|
let mut bad = BezPath::new();
|
|
bad.move_to((f64::NAN, 0.0));
|
|
bad.line_to((1.0, 1.0));
|
|
assert!(polylines(&bad).is_none());
|
|
let _ = stroke_outline(&bad, &props(4.0, WidthPreset::Eye.profile()), false);
|
|
// A path too long to sample falls back to the uniform stroke.
|
|
let mut long = BezPath::new();
|
|
long.move_to((0.0, 0.0));
|
|
for i in 0..(MAX_WIDTH_SAMPLES + 10) {
|
|
long.line_to((i as f64, (i % 2) as f64 * 5.0));
|
|
}
|
|
assert!(variable_outline(&long, 2.0, &props(2.0, None), &WidthProfile::uniform()).is_none());
|
|
assert!(!stroke_outline(&long, &props(2.0, WidthPreset::Eye.profile()), false).elements().is_empty());
|
|
// Zero width, empty paths, a single point and a dash pattern of zeros.
|
|
assert!(variable_outline(&line(10.0), 0.0, &props(0.0, None), &WidthProfile::uniform()).unwrap().elements().is_empty());
|
|
assert!(variable_outline(&BezPath::new(), 2.0, &props(2.0, None), &WidthProfile::uniform()).unwrap().elements().is_empty());
|
|
let mut dot = BezPath::new();
|
|
dot.move_to((5.0, 5.0));
|
|
dot.line_to((5.0, 5.0));
|
|
let _ = stroke_outline(&dot, &props(2.0, WidthPreset::Eye.profile()), false);
|
|
let zeros = StrokeProps { dashes: vec![0.0, 0.0], ..props(2.0, Some(WidthProfile::uniform())) };
|
|
assert!(!stroke_outline(&line(10.0), &zeros, false).elements().is_empty(), "solid when the pattern is empty");
|
|
// A tiny dash pattern on a long path doesn't explode.
|
|
let tiny = StrokeProps { dashes: vec![0.01, 0.01], ..props(2.0, Some(WidthProfile::uniform())) };
|
|
let s = stroke_outline(&line(1e6), &tiny, false);
|
|
assert!(s.elements().len() < 1_000_000);
|
|
}
|