mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Outline Stroke (and the stroke part of Expand) now builds each stroke from the shared canvas geometry: dash shapes, arrowheads, width profiles and inside/outside alignment, united into one clean path (pathops::stroke_region). Every stroke item is outlined in paint order (runs of fills stay on copies of the path), stroke opacity, blend mode and effects move to the new fill, and brushed strokes become their brush art. The live Effect > Path > Outline Stroke outlines the stroke it sits on (on a fill or the object: the top stroke) with that stroke's cap, join, dashes, alignment, profile, arrowheads and weight: GeomContext now carries the stroke and the fill rule, built per item by the renderer, export baking and Expand Appearance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
14 KiB
Rust
344 lines
14 KiB
Rust
use serde_json::{Value, json};
|
||
use vectorcraft_doc::Effect;
|
||
use vectorcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes};
|
||
|
||
use super::*;
|
||
|
||
fn fx(id: &str, params: Value) -> Effect {
|
||
Effect { id: id.into(), params, visible: true }
|
||
}
|
||
|
||
fn square() -> PathData {
|
||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))
|
||
}
|
||
|
||
fn run(id: &str, params: Value, p: &PathData) -> PathData {
|
||
apply_geometry(&[fx(id, params)], p, p.bounds().unwrap())
|
||
}
|
||
|
||
fn close(a: f64, b: f64, tol: f64) -> bool {
|
||
(a - b).abs() <= tol
|
||
}
|
||
|
||
fn all_finite(p: &PathData) -> bool {
|
||
p.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite() && a.h_in.x.is_finite() && a.h_out.y.is_finite())
|
||
}
|
||
|
||
/// Every geometry effect with non-trivial parameters.
|
||
fn geometry_cases() -> Vec<(&'static str, Value)> {
|
||
let mut v = vec![
|
||
("distort.freeDistort", json!({"corners": [[0.1, 0.0], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})),
|
||
("distort.puckerBloat", json!({"amount": 40})),
|
||
("distort.puckerBloat", json!({"amount": -40})),
|
||
("distort.roughen", json!({})),
|
||
("distort.roughen", json!({"points": "corner", "size": 3, "relative": false})),
|
||
("distort.transform", json!({"scaleH": 50, "rotate": 30, "moveH": 10})),
|
||
("distort.tweak", json!({})),
|
||
("distort.twist", json!({"angle": 90})),
|
||
("distort.zigZag", json!({})),
|
||
("distort.zigZag", json!({"points": "corner", "ridges": 3})),
|
||
("path.offsetPath", json!({"offset": 5})),
|
||
("path.outlineStroke", json!({"width": 4})),
|
||
("convertToShape.rectangle", json!({})),
|
||
("convertToShape.roundedRectangle", json!({})),
|
||
("convertToShape.ellipse", json!({})),
|
||
("stylize.roundCorners", json!({"radius": 10})),
|
||
];
|
||
for (s, _) in WARP_STYLES {
|
||
let id: &'static str = Box::leak(format!("warp.{s}").into_boxed_str());
|
||
v.push((id, json!({"bend": 50})));
|
||
}
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn catalog_complete_and_unique() {
|
||
let cat = effect_catalog();
|
||
let mut ids: Vec<_> = cat.iter().map(|e| e.id).collect();
|
||
ids.sort();
|
||
ids.dedup();
|
||
assert_eq!(ids.len(), cat.len());
|
||
assert!(cat.len() >= 34);
|
||
for e in &cat {
|
||
assert!(e.defaults.is_object(), "{}", e.id);
|
||
assert!(!e.params.is_empty());
|
||
assert_eq!(e.menu[0], "Effect");
|
||
assert_eq!(is_raster(e.id), e.raster);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn defaults_match_dialogs() {
|
||
let d = default_params("stylize.dropShadow").unwrap();
|
||
assert_eq!(d["opacity"], json!(75.0));
|
||
assert_eq!(d["x"], json!(7.0));
|
||
assert_eq!(d["mode"], json!("multiply"));
|
||
let r = default_params("distort.roughen").unwrap();
|
||
assert_eq!(r["size"], json!(5.0));
|
||
assert_eq!(r["detail"], json!(10.0));
|
||
assert!(default_params("nope").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn merged_params_override_defaults() {
|
||
let e = new_effect("distort.twist", &json!({"angle": 45})).unwrap();
|
||
assert_eq!(e.params["angle"], json!(45));
|
||
let e = new_effect("path.offsetPath", &json!({})).unwrap();
|
||
assert_eq!(e.params["joins"], json!("miter"));
|
||
assert!(new_effect("bogus.effect", &json!({})).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn every_geometry_effect_changes_geometry_and_keeps_closedness() {
|
||
let sq = square();
|
||
for (id, params) in geometry_cases() {
|
||
let out = run(id, params.clone(), &sq);
|
||
assert!(!out.is_empty(), "{id} produced nothing");
|
||
assert!(out.is_closed(), "{id} opened the path");
|
||
assert!(all_finite(&out), "{id} produced NaN");
|
||
assert_ne!(out.to_bezpath(), sq.to_bezpath(), "{id} did nothing");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn geometry_effects_are_deterministic() {
|
||
let sq = square();
|
||
for (id, params) in geometry_cases() {
|
||
let a = run(id, params.clone(), &sq);
|
||
let b = run(id, params, &sq);
|
||
assert_eq!(a, b, "{id} is not deterministic");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn open_paths_stay_open() {
|
||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 20.0), Point::new(100.0, 0.0)], false));
|
||
for id in ["distort.roughen", "distort.zigZag", "distort.twist", "distort.tweak", "warp.arc", "warp.flag", "stylize.roundCorners"] {
|
||
let out = run(id, json!({}), &line);
|
||
assert!(!out.is_closed(), "{id}");
|
||
assert_eq!(out.subpaths.len(), 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn warp_keeps_bounds_sane() {
|
||
let sq = square();
|
||
let d = 100.0f64.hypot(100.0);
|
||
for (s, _) in WARP_STYLES {
|
||
for bend in [-100.0, -50.0, 50.0, 100.0] {
|
||
for orient in ["horizontal", "vertical"] {
|
||
let out = run(&format!("warp.{s}"), json!({"bend": bend, "horizontal": 30, "vertical": -30, "orientation": orient}), &sq);
|
||
let b = out.bounds().unwrap();
|
||
assert!(all_finite(&out), "warp.{s}");
|
||
assert!(b.width() > 1.0 && b.height() > 1.0, "warp.{s} collapsed: {b:?}");
|
||
assert!(b.width() < 3.0 * d && b.height() < 3.0 * d, "warp.{s} exploded: {b:?}");
|
||
assert!(b.center().distance(Point::new(50.0, 50.0)) < d, "warp.{s} drifted");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn warp_zero_bend_is_identity_ish() {
|
||
let sq = square();
|
||
for (s, _) in WARP_STYLES {
|
||
let out = run(&format!("warp.{s}"), json!({"bend": 0}), &sq);
|
||
let b = out.bounds().unwrap();
|
||
assert!(close(b.x0, 0.0, 1e-6) && close(b.x1, 100.0, 1e-6) && close(b.y1, 100.0, 1e-6), "warp.{s}: {b:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn warp_arc_bends_the_top_edge() {
|
||
let out = run("warp.arc", json!({"bend": 50}), &square());
|
||
// The ends droop below the original bottom while the middle keeps the top.
|
||
let b = out.bounds().unwrap();
|
||
assert!(b.y1 > 101.0 && close(b.y0, 0.0, 0.5), "{b:?}");
|
||
let out = run("warp.arc", json!({"bend": 50, "orientation": "vertical"}), &square());
|
||
let b = out.bounds().unwrap();
|
||
assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn transform_copies_and_move() {
|
||
let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square());
|
||
assert_eq!(out.subpaths.len(), 4);
|
||
let b = out.bounds().unwrap();
|
||
assert!(close(b.x1, 160.0, 1e-9));
|
||
let r = run("distort.transform", json!({"scaleH": 50, "scaleV": 50}), &square()).bounds().unwrap();
|
||
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 50.0, 1e-9));
|
||
let f = run("distort.transform", json!({"reflectX": true, "moveV": 5}), &square()).bounds().unwrap();
|
||
assert!(close(f.y0, 5.0, 1e-9));
|
||
}
|
||
|
||
#[test]
|
||
fn pucker_bloat_moves_anchors_in_opposite_directions() {
|
||
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
|
||
let a = bloat.subpaths[0].anchors[0].p;
|
||
assert!(a.x > 0.0 && a.y > 0.0, "bloat pulls corners in: {a:?}");
|
||
let pucker = run("distort.puckerBloat", json!({"amount": -50}), &square());
|
||
let a = pucker.subpaths[0].anchors[0].p;
|
||
assert!(a.x < 0.0 && a.y < 0.0, "pucker pushes corners out: {a:?}");
|
||
assert_eq!(run("distort.puckerBloat", json!({"amount": 0}), &square()), square());
|
||
}
|
||
|
||
#[test]
|
||
fn roughen_seed_changes_result_and_detail_adds_points() {
|
||
let a = run("distort.roughen", json!({"seed": 1}), &square());
|
||
let b = run("distort.roughen", json!({"seed": 2}), &square());
|
||
assert_ne!(a, b);
|
||
let lo = run("distort.roughen", json!({"detail": 1}), &square());
|
||
let hi = run("distort.roughen", json!({"detail": 50}), &square());
|
||
assert!(hi.anchor_count() > lo.anchor_count());
|
||
// Displacement is bounded by size (5% of 100 = 5 pt).
|
||
let bb = a.bounds().unwrap();
|
||
assert!(bb.x0 > -8.0 && bb.x1 < 108.0);
|
||
}
|
||
|
||
#[test]
|
||
fn zigzag_ridge_count_and_amplitude() {
|
||
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
|
||
assert_eq!(out.anchor_count(), 4 * 5);
|
||
let b = out.bounds().unwrap();
|
||
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn offset_path_grows_and_shrinks() {
|
||
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
|
||
assert!(close(grow.width(), 120.0, 0.1), "{grow:?}");
|
||
let shrink = run("path.offsetPath", json!({"offset": -10}), &square()).bounds().unwrap();
|
||
assert!(close(shrink.width(), 80.0, 0.1), "{shrink:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn outline_stroke_uses_context_stroke() {
|
||
let st = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 10.0);
|
||
let ctx = GeomContext { stroke: Some(&st), ..Default::default() };
|
||
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
|
||
let b = out.bounds().unwrap();
|
||
assert!(close(b.width(), 110.0, 0.1), "{b:?}");
|
||
// An explicit width overrides the weight; without a stroke a plain 1 pt one is outlined.
|
||
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({"width": 4}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
|
||
assert!(close(out.bounds().unwrap().width(), 104.0, 0.1));
|
||
let out = run("path.outlineStroke", json!({}), &square());
|
||
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
|
||
}
|
||
|
||
#[test]
|
||
fn convert_to_shape_sizes() {
|
||
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
|
||
assert!(close(r.width(), 120.0, 1e-9) && close(r.height(), 110.0, 1e-9));
|
||
let e = run("convertToShape.ellipse", json!({"relative": false, "width": 40, "height": 20}), &square());
|
||
let b = e.bounds().unwrap();
|
||
assert!(close(b.width(), 40.0, 1e-6) && close(b.center().x, 50.0, 1e-9));
|
||
assert_eq!(e.anchor_count(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn round_corners_replaces_corners() {
|
||
let out = run("stylize.roundCorners", json!({"radius": 10}), &square());
|
||
assert_eq!(out.anchor_count(), 8);
|
||
// Area shrinks by 4 × (r² − πr²/4) ≈ 85.8 for r = 10.
|
||
let area = out.to_bezpath().area().abs();
|
||
assert!(close(area, 10000.0 - 85.84, 2.0), "{area}");
|
||
// An ellipse has no sharp corners → unchanged.
|
||
let el = shapes::ellipse(Rect::new(0.0, 0.0, 50.0, 30.0));
|
||
assert_eq!(run("stylize.roundCorners", json!({}), &el), el);
|
||
}
|
||
|
||
#[test]
|
||
fn twist_fixes_the_outer_circle_and_rotates_inside() {
|
||
let out = run("distort.twist", json!({"angle": 0}), &square());
|
||
assert_eq!(out, square());
|
||
let small = PathData::single(SubPath::polyline(&[Point::new(45.0, 50.0), Point::new(55.0, 50.0)], false));
|
||
let out = apply_geometry(&[fx("distort.twist", json!({"angle": 90}))], &small, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||
let a = out.subpaths[0].anchors[0].p;
|
||
assert!((a.y - 50.0).abs() > 1.0, "near-centre points rotate: {a:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn scribble_produces_filled_outline_inside_bounds() {
|
||
let out = run("stylize.scribble", json!({}), &square());
|
||
assert!(!out.is_empty());
|
||
let b = out.bounds().unwrap();
|
||
assert!(b.x0 > -10.0 && b.x1 < 110.0 && b.y0 > -10.0 && b.y1 < 110.0, "{b:?}");
|
||
assert_eq!(out, run("stylize.scribble", json!({}), &square()));
|
||
}
|
||
|
||
#[test]
|
||
fn effects_chain_in_order_and_skip_hidden_and_raster() {
|
||
let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))];
|
||
let b = apply_geometry(&chain, &square(), Rect::new(0.0, 0.0, 100.0, 100.0)).bounds().unwrap();
|
||
// Move first (10..110), then scale around the new centre (60): -40..160.
|
||
assert!(close(b.x0, -40.0, 1e-9) && close(b.x1, 160.0, 1e-9), "{b:?}");
|
||
let mut hidden = fx("distort.twist", json!({"angle": 90}));
|
||
hidden.visible = false;
|
||
let raster = fx("stylize.dropShadow", json!({}));
|
||
assert_eq!(apply_geometry(&[hidden, raster.clone()], &square(), Rect::new(0.0, 0.0, 100.0, 100.0)), square());
|
||
assert!(!has_geometry(&[raster]));
|
||
}
|
||
|
||
#[test]
|
||
fn raster_effects_parse_and_outset() {
|
||
let list = [
|
||
fx("stylize.dropShadow", json!({"x": 10, "y": -4, "blur": 6, "color": "#ff0000", "opacity": 50})),
|
||
fx("stylize.outerGlow", json!({})),
|
||
fx("stylize.innerGlow", json!({"source": "center"})),
|
||
fx("stylize.feather", json!({})),
|
||
fx("blur.gaussian", json!({"radius": 2})),
|
||
fx("distort.twist", json!({})),
|
||
];
|
||
let r = raster_effects(&list);
|
||
assert_eq!(r.len(), 5);
|
||
match &r[0] {
|
||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
|
||
assert_eq!(*mode, vectorcraft_doc::color::BlendMode::Multiply);
|
||
assert!(close(*opacity as f64, 0.5, 1e-6));
|
||
assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0));
|
||
assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]);
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. }));
|
||
assert!(close(outset(&list), 10.0 + 9.0, 1e-9));
|
||
assert_eq!(outset(&[]), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_mode_names() {
|
||
use vectorcraft_doc::color::BlendMode;
|
||
assert_eq!(raster::blend_mode("Color-Burn"), BlendMode::ColorBurn);
|
||
assert_eq!(raster::blend_mode("screen"), BlendMode::Screen);
|
||
assert_eq!(raster::blend_mode("???"), BlendMode::Normal);
|
||
}
|
||
|
||
#[test]
|
||
fn free_distort_identity_and_corner_move() {
|
||
let id = run("distort.freeDistort", json!({}), &square()).bounds().unwrap();
|
||
assert!(close(id.x0, 0.0, 1e-9) && close(id.x1, 100.0, 1e-9));
|
||
let out = run("distort.freeDistort", json!({"corners": [[0, 0], [1.5, 0], [1, 1], [0, 1]]}), &square());
|
||
let b = out.bounds().unwrap();
|
||
assert!(close(b.x1, 150.0, 1e-6), "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn bad_params_fall_back_to_defaults() {
|
||
let out = run("distort.roughen", json!({"size": "abc", "detail": null, "points": 7}), &square());
|
||
assert_eq!(out, run("distort.roughen", json!({}), &square()));
|
||
let out = run("path.offsetPath", json!({"offset": "12 pt"}), &square());
|
||
assert!(close(out.bounds().unwrap().width(), 124.0, 0.1));
|
||
}
|
||
|
||
#[test]
|
||
#[ignore]
|
||
fn bench_catalog_lookup() {
|
||
let t = std::time::Instant::now();
|
||
let mut n = 0;
|
||
for _ in 0..10_000 {
|
||
n += crate::is_geometry("distort.roughen") as usize;
|
||
n += crate::merged_params("stylize.dropShadow", &serde_json::Value::Null).as_object().unwrap().len();
|
||
}
|
||
eprintln!("10k lookups: {:?} ({n})", t.elapsed());
|
||
}
|