Files
figcraft/crates/render/src/width_tests.rs
T
BFlatandClaude Opus 5.5 1184dd4177 M4: variable width strokes (#58)
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>
2026-10-10 10:12:21 +07:00

49 lines
1.9 KiB
Rust

//! Variable width strokes render along the path, past the weight where the profile widens them.
use super::*;
use figcraft_color::{Color, Paint};
use figcraft_doc::{NodeKind, WidthPreset, WidthProfile};
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
let i = ((y * p.width() + x) * 4) as usize;
p.data().get(i).is_some_and(|r| *r < 40)
}
/// A horizontal line from (10, 50) to (90, 50) with a 20 wide black stroke.
fn line_doc(profile: Option<WidthProfile>) -> (figcraft_doc::Document, figcraft_doc::NodeId) {
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let l = d.create(p, NodeKind::Line, "L", 10.0, 50.0, 80.0, 0.0).unwrap();
let n = d.get_mut(l).unwrap();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 20.0;
n.stroke.width_profile = profile;
(d, p)
}
fn render(d: &figcraft_doc::Document, p: figcraft_doc::NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 100, height: 100 }, &Options::default()).unwrap()
}
#[test]
fn wedge_profile_tapers_the_stroke() {
let (d, p) = line_doc(WidthPreset::Wedge.profile());
let pix = render(&d, p);
assert!(dark(&pix, 15, 58) && dark(&pix, 15, 42), "wide at the start");
assert!(dark(&pix, 70, 50), "a quarter of the width three quarters along");
assert!(!dark(&pix, 85, 54) && !dark(&pix, 85, 46), "thin at the end");
// Uniform for comparison: full width at both ends.
let (d, p) = line_doc(None);
let pix = render(&d, p);
assert!(dark(&pix, 85, 58));
}
#[test]
fn widened_profile_paints_past_the_weight() {
// Three times the weight in the middle: 30 either side of the line.
let (d, p) = line_doc(Some(WidthProfile::new([(0.0, 1.0), (0.5, 3.0), (1.0, 1.0)])));
let pix = render(&d, p);
assert!(dark(&pix, 50, 77) && dark(&pix, 50, 23), "not culled at the weight's reach");
assert!(!dark(&pix, 15, 70));
}