mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
Part of #1377: Rectangular Grid and Polar Grid tool options (skew, frame, compound path, fill)
This commit is contained in:
@@ -33,8 +33,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
|
||||
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
|
||||
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
|
||||
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
|
||||
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
|
||||
cmd!(
|
||||
"shape.rectangularGrid",
|
||||
"Rectangular Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
rect_grid
|
||||
),
|
||||
cmd!(
|
||||
"shape.polarGrid",
|
||||
"Polar Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
|
||||
has_doc,
|
||||
polar_grid
|
||||
),
|
||||
cmd!(
|
||||
"path.create",
|
||||
"Create Path",
|
||||
@@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
|
||||
}
|
||||
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
|
||||
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
||||
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
|
||||
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
|
||||
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let children = paths
|
||||
.into_iter()
|
||||
.map(|pd| {
|
||||
let mut n = Node::path(d.alloc_id(), pd, Appearance::default());
|
||||
let mut n = if pd.subpaths.len() > 1 {
|
||||
let members =
|
||||
pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default())));
|
||||
let children = members.collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero })
|
||||
} else {
|
||||
Node::path(d.alloc_id(), pd, Appearance::default())
|
||||
};
|
||||
look.apply(d, &mut n);
|
||||
std::sync::Arc::new(n)
|
||||
})
|
||||
@@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Valu
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
|
||||
/// ±[`shapes::GRID_SKEW_MAX`]).
|
||||
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let skew = match p.get(key) {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
|
||||
};
|
||||
Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX)))
|
||||
}
|
||||
|
||||
/// A grid's on/off option `key` (`default` when absent).
|
||||
fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.rectangularGrid")?;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
|
||||
const CMD: &str = "shape.rectangularGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths =
|
||||
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
|
||||
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_of(p, "shape.polarGrid")?;
|
||||
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
|
||||
const CMD: &str = "shape.polarGrid";
|
||||
let r = rect_of(p, CMD)?;
|
||||
let paths = shapes::polar_grid(
|
||||
r,
|
||||
dividers(p, "concentric", "concentricSkew", CMD)?,
|
||||
dividers(p, "radial", "radialSkew", CMD)?,
|
||||
switch(p, "compound", false, CMD)?,
|
||||
);
|
||||
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
|
||||
}
|
||||
|
||||
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
|
||||
|
||||
@@ -442,6 +442,132 @@ fn line_family_tools_draw_and_click_asks_dialog() {
|
||||
assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]);
|
||||
}
|
||||
|
||||
/// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first.
|
||||
fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec<std::sync::Arc<vectorcraft_doc::Node>> {
|
||||
let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.doc().unwrap().doc.node(id).unwrap().children().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// `base` with the keys of `extra` added.
|
||||
fn with(base: &serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
|
||||
let mut p = base.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
}
|
||||
|
||||
fn is_filled(n: &vectorcraft_doc::Node) -> bool {
|
||||
n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)
|
||||
}
|
||||
|
||||
/// #1377: the Rectangular Grid options. By default the frame is one rectangle under evenly spaced
|
||||
/// dividers and nothing is filled; `frame: false` draws its four sides instead, `fill` paints the
|
||||
/// current fill and the skews crowd the dividers to the top / right (positive) or bottom / left.
|
||||
#[test]
|
||||
fn rectangular_grid_options_frame_fill_and_skew() {
|
||||
const CMD: &str = "shape.rectangularGrid";
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "rows": 3, "columns": 3});
|
||||
// The y of the three horizontal dividers and the x of the three vertical ones (the last six children).
|
||||
let dividers = |g: &[std::sync::Arc<vectorcraft_doc::Node>]| {
|
||||
let starts: Vec<Point> = g[g.len() - 6..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect();
|
||||
starts[..3].iter().map(|p| p.y).chain(starts[3..].iter().map(|p| p.x)).collect::<Vec<_>>()
|
||||
};
|
||||
let plain = grid(&mut s, CMD, base.clone());
|
||||
assert_eq!(plain.len(), 7);
|
||||
assert!(plain[0].path_data().unwrap().is_closed(), "the frame lies under the dividers");
|
||||
assert!(plain.iter().all(|n| !is_filled(n) && n.appearance.stroke().is_some()));
|
||||
assert_eq!(dividers(&plain), [25.0, 50.0, 75.0, 25.0, 50.0, 75.0]);
|
||||
// No frame: its four sides are lines of their own.
|
||||
let sides = grid(&mut s, CMD, with(&base, json!({"frame": false})));
|
||||
assert_eq!(sides.len(), 10);
|
||||
assert!(sides.iter().all(|n| !n.path_data().unwrap().is_closed() && n.path_data().unwrap().anchor_count() == 2));
|
||||
assert_eq!(dividers(&sides), dividers(&plain));
|
||||
let filled = grid(&mut s, CMD, with(&base, json!({"fill": true})));
|
||||
assert!(filled.iter().all(|n| is_filled(n)), "Fill Grid paints the current fill");
|
||||
assert_eq!(filled[0].appearance.fill().unwrap().paint, s.paint.fill);
|
||||
// Skew: toward the top and the right when positive, the bottom and the left when negative;
|
||||
// past ±500 % is ±500 %.
|
||||
let top_right = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 100, "columnSkew": 100}))));
|
||||
for (got, want) in top_right.iter().zip([6.25, 25.0, 56.25, 43.75, 75.0, 93.75]) {
|
||||
assert!((got - want).abs() < 1e-9, "{top_right:?}");
|
||||
}
|
||||
let bottom_left = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": -100, "columnSkew": -100}))));
|
||||
assert!(bottom_left[1] > 50.0 && bottom_left[4] < 50.0, "{bottom_left:?}");
|
||||
let most = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 500, "columnSkew": -500}))));
|
||||
let beyond = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 1e300, "columnSkew": -1e300}))));
|
||||
assert_eq!(most, beyond);
|
||||
assert!(most.iter().all(|v| *v > 0.0 && *v < 100.0), "{most:?}");
|
||||
}
|
||||
|
||||
/// #1377: the Polar Grid options. The ellipses lie under the radial dividers, largest first;
|
||||
/// `compound` pairs them into compound-path rings, `fill` paints the current fill and the skews
|
||||
/// crowd the ellipses outward and the radial dividers clockwise of the top one (positive).
|
||||
#[test]
|
||||
fn polar_grid_options_compound_fill_and_skew() {
|
||||
const CMD: &str = "shape.polarGrid";
|
||||
let mut s = session();
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "concentric": 3, "radial": 4});
|
||||
let width = |n: &vectorcraft_doc::Node| n.geometric_bounds().unwrap().width();
|
||||
let tips =
|
||||
|g: &[std::sync::Arc<vectorcraft_doc::Node>]| g[4..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p).collect::<Vec<_>>();
|
||||
let plain = grid(&mut s, CMD, base.clone());
|
||||
assert_eq!(plain.len(), 8);
|
||||
assert_eq!(plain[..4].iter().map(|n| width(n).round()).collect::<Vec<_>>(), [100.0, 75.0, 50.0, 25.0], "largest at the bottom");
|
||||
assert!(plain[..4].iter().all(|n| n.path_data().unwrap().is_closed()) && plain[4..].iter().all(|n| !n.path_data().unwrap().is_closed()));
|
||||
assert!(plain.iter().all(|n| !is_filled(n)));
|
||||
assert!(grid(&mut s, CMD, with(&base, json!({"fill": true}))).iter().all(|n| is_filled(n)));
|
||||
// Compound: 4 ellipses make 2 rings, each a compound path of an outside and a hole running
|
||||
// against it that carries the grid's paint itself; with 5, the innermost ellipse stays a path.
|
||||
let rings = grid(&mut s, CMD, with(&base, json!({"compound": true, "fill": true})));
|
||||
assert_eq!(rings.len(), 6);
|
||||
for (ring, sizes) in rings[..2].iter().zip([[100.0, 75.0], [50.0, 25.0]]) {
|
||||
let NodeKind::Compound { children, rule } = &ring.kind else { panic!("{:?}", ring.kind) };
|
||||
assert_eq!(*rule, FillRule::NonZero);
|
||||
assert_eq!(children.iter().map(|n| width(n).round()).collect::<Vec<_>>(), sizes);
|
||||
let areas: Vec<f64> = children.iter().map(|n| n.path_data().unwrap().subpaths[0].signed_area()).collect();
|
||||
assert!(areas[0] * areas[1] < 0.0, "the hole runs against the outside: {areas:?}");
|
||||
assert!(is_filled(ring) && ring.appearance.stroke().is_some());
|
||||
}
|
||||
let five = grid(&mut s, CMD, with(&base, json!({"compound": true, "concentric": 4, "radial": 0})));
|
||||
assert_eq!(five.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false]);
|
||||
// Skew.
|
||||
let out = grid(&mut s, CMD, with(&base, json!({"concentricSkew": 100, "radialSkew": 500})));
|
||||
for (n, want) in out[..4].iter().zip([100.0, 93.75, 75.0, 43.75]) {
|
||||
assert!((width(n) - want).abs() < 1e-6, "{}", width(n));
|
||||
}
|
||||
assert!(tips(&out)[0].distance(Point::new(50.0, 0.0)) < 1e-9, "the top divider stays");
|
||||
assert!(tips(&out)[1..].iter().all(|t| t.x > 50.0), "clockwise of the top: {:?}", tips(&out));
|
||||
let inward = grid(&mut s, CMD, with(&base, json!({"concentricSkew": -100, "radialSkew": -500})));
|
||||
assert!((width(&inward[1]) - 56.25).abs() < 1e-6);
|
||||
assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward));
|
||||
}
|
||||
|
||||
/// The grid options are checked: a skew is a number and a switch true or false, or the command
|
||||
/// fails without an undo step.
|
||||
#[test]
|
||||
fn grid_commands_reject_options_of_the_wrong_type() {
|
||||
let mut s = session();
|
||||
let size = json!({"x": 0, "y": 0, "width": 10, "height": 10});
|
||||
for (cmd, key, value) in [
|
||||
("shape.rectangularGrid", "rowSkew", json!("50")),
|
||||
("shape.rectangularGrid", "columnSkew", json!(true)),
|
||||
("shape.rectangularGrid", "frame", json!(1)),
|
||||
("shape.rectangularGrid", "fill", json!("yes")),
|
||||
("shape.polarGrid", "concentricSkew", json!([1])),
|
||||
("shape.polarGrid", "radialSkew", json!({})),
|
||||
("shape.polarGrid", "compound", json!(0)),
|
||||
("shape.polarGrid", "fill", json!("true")),
|
||||
] {
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
let p = with(&size, json!({key: value}));
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
|
||||
assert!(s.execute(cmd, &with(&size, json!({key: null}))).is_ok(), "{cmd}: null is the default");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw2_commands_reject_bad_params() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in New Issue
Block a user