mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
Add-ons: Parametric Shape Tool — re-editable stars, polygons, rings, arrows, gears and more
Path Precision Tools gains the Parametric Shape Tool: ten kinds of shape (rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, speech bubble) that stay editable as numbers. - Geometry in vectorcraft_pathops::addons::pathtools::shapes: each kind's outline, corner rounding limited to the middle of a side, a fingerprint of the outline, recovery of centre, size and rotation after a move, turn or scale, and recognition of plain rectangles, ellipses and regular polygons. - The tool (parametricShape, All Tools drawer and Object › Plug-ins › Path Precision Tools only, no shortcut): drag from the centre (Shift: 45° steps), arrow keys change the count and values while dragging, handles edit a selected shape, double-click places one. - Commands addon.pathtools.shape.create / .set / .convert, one undo step each; params checked (counts 3..1000, sizes up to 16384 pt). - A shape keeps its numbers in one object-data entry (vc-pathtools-shape) that SVG export already leaves out; anchor edits make it a plain path. - Parametric Shape panel, cursor stand-in, catalog rows in all 25 languages, docs and tests.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
//! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke,
|
||||
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
|
||||
//! anchor edits and Split / Add Points at Crossings. Its tools, panel and submenu (Object ›
|
||||
//! Plug-ins › Path Precision Tools) are the tools crate's and the UI's.
|
||||
//! anchor edits, Split / Add Points at Crossings and the Parametric Shape Tool's shapes ([`shape`]).
|
||||
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
|
||||
//! and the UI's.
|
||||
|
||||
mod shape;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -37,7 +39,7 @@ const MAX_EXTEND: f64 = 1e6;
|
||||
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
let mut v = vec![
|
||||
cmd!(
|
||||
"addon.pathtools.erase",
|
||||
"Anchor Eraser Brush",
|
||||
@@ -137,7 +139,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
crossings
|
||||
),
|
||||
]
|
||||
];
|
||||
v.extend(shape::specs());
|
||||
v
|
||||
}
|
||||
|
||||
/// The editable paths (not guides) under `roots`, compound path members included.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The Parametric Shape Tool's commands (`addon.pathtools.shape.*`): draw a parametric shape, change
|
||||
//! one's numbers, and turn plain rectangles, ellipses and regular polygons into them. A parametric
|
||||
//! shape is an ordinary path whose numbers sit in one entry of its object data
|
||||
//! (`vectorcraft_tools::addons::parametric_shape`), which SVG export leaves out.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point};
|
||||
use vectorcraft_pathops::addons::pathtools::shapes::{ParametricShape, ShapeKind, recognise};
|
||||
use vectorcraft_tools::addons::parametric_shape::{CREATE, EDIT, KEY, apply, read, stored, to_json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, has_selection};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The undo step's name and the command's label.
|
||||
const CONVERT: &str = "Convert to Parametric Shape";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.pathtools.shape.create",
|
||||
"Parametric Shape",
|
||||
[],
|
||||
None,
|
||||
"{center: [x, y], kind?: \"rectangle\"|\"ellipse\"|\"polygon\" (default)|\"star\"|\"ring\"|\"arrow\"|\"cross\"|\"heart\"|\"gear\"|\"bubble\" (a speech bubble), width?, height?: 0.01..16384 pt (100), rotation?: degrees clockwise (0: upright), count?: 3..1000 (a polygon's sides, a star's points, a gear's teeth), a?, b?: the kind's own values (star: inner radius 0.01..1 of the outer, 0.5; ring: hole 0.01..0.99 of the size, 0.5; arrow: head length and shaft width 0.05..0.95, 0.4 and 0.4; cross: bar offset −0.9..0.9 (0, − up) and arm thickness 0.05..0.95 (1/3); heart: notch depth and point roundness 0..1 (0.4 and 0); gear: tooth height 0.01..0.9 (0.2); bubble: tail height 0.05..0.9 (0.25) and tail tip −1..1 across (−0.4)), radius?: corner radius 0..16384 pt (0; no corner rounds past the middle of a side)} draw a parametric shape with the current paint, selected, as one undo step; it stays editable with addon.pathtools.shape.set until its anchors are edited (moving, turning or scaling it keeps it) → {id, shape}",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.shape.set",
|
||||
"Edit Parametric Shape",
|
||||
[],
|
||||
None,
|
||||
"{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.shape.convert",
|
||||
"Convert to Parametric Shape",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selection)} make each plain closed path that is a rectangle, an ellipse or a polygon with equal sides and corners (stretched along its own axes at most) a parametric shape of the same outline, as one undo step → {ids (converted)}",
|
||||
has_selection,
|
||||
convert
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Give path node `n` the outline `path` of shape `s` and keep its numbers; a live shape goes.
|
||||
fn shape_node(n: &mut Node, s: &ParametricShape, path: PathData) {
|
||||
let entry = stored(s, &path);
|
||||
if let NodeKind::Path { path: p, live, .. } = &mut n.kind {
|
||||
*p = path;
|
||||
*live = None;
|
||||
}
|
||||
n.edit_attrs(|a| match a.data.iter_mut().find(|(k, _)| k == KEY) {
|
||||
Some(e) => e.1 = entry,
|
||||
None => a.data.push((KEY.into(), entry)),
|
||||
});
|
||||
}
|
||||
|
||||
/// The outline of `s`, checked already.
|
||||
fn outline(s: &ParametricShape, cmd: &str) -> Result<PathData> {
|
||||
s.path().ok_or_else(|| bad(cmd, "the shape's numbers are out of range"))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.shape.create";
|
||||
if p.get("center").is_none_or(Value::is_null) {
|
||||
return Err(bad(C, "`center` must be [x, y]"));
|
||||
}
|
||||
let shape = apply(ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), p).map_err(|e| bad(C, e))?;
|
||||
let path = outline(&shape, C)?;
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
// shortcut: always on top of the insertion layer, as Draw Normal; honour Draw Behind/Inside if asked.
|
||||
let id = s.edit(CREATE, |d, sel| {
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Path { path: PathData::default(), rule: FillRule::NonZero, live: None, clipping: false, guide: false });
|
||||
shape_node(&mut n, &shape, path);
|
||||
look.apply(d, &mut n);
|
||||
d.insert(parent, usize::MAX, n)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "shape": to_json(&shape) }))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.shape.set";
|
||||
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if !d.is_editable(id) {
|
||||
return Err(bad(C, "the path is locked or hidden"));
|
||||
}
|
||||
let old = d.node(id).and_then(read).ok_or_else(|| bad(C, "the object is not an editable parametric shape"))?;
|
||||
let shape = apply(old, p).map_err(|e| bad(C, e))?;
|
||||
if shape != old {
|
||||
let path = outline(&shape, C)?;
|
||||
s.edit(EDIT, |d, sel| {
|
||||
shape_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, &shape, path);
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "id": id.0, "shape": to_json(&shape) }))
|
||||
}
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.shape.convert";
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut found = vec![];
|
||||
for id in roots {
|
||||
let Some(n) = d.node(id).filter(|n| d.is_editable(id) && read(n).is_none()) else { continue };
|
||||
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
|
||||
&& let Some(shape) = recognise(path)
|
||||
{
|
||||
found.push((id, shape, outline(&shape, C)?));
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Err(bad(C, "select plain rectangles, ellipses or regular polygons to convert"));
|
||||
}
|
||||
let ids: Vec<u64> = found.iter().map(|(id, ..)| id.0).collect();
|
||||
s.edit(CONVERT, |d, sel| {
|
||||
for (id, shape, path) in found {
|
||||
shape_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, &shape, path);
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids }))
|
||||
}
|
||||
@@ -273,3 +273,186 @@ fn crossings_add_anchors_or_cut_in_one_step() {
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
|
||||
}
|
||||
|
||||
// ---------- Parametric Shape Tool ----------
|
||||
|
||||
/// The parametric shape object `id` is now, as the tool reads it.
|
||||
fn shape_of(s: &Session, id: NodeId) -> Option<vectorcraft_pathops::addons::pathtools::shapes::ParametricShape> {
|
||||
vectorcraft_tools::addons::parametric_shape::read(s.doc().unwrap().doc.node(id).unwrap())
|
||||
}
|
||||
|
||||
fn star(s: &mut Session) -> NodeId {
|
||||
let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "star", "center": [200, 150], "width": 100, "height": 100})).unwrap();
|
||||
id_of(&r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_draws_a_selected_parametric_shape_in_one_step() {
|
||||
let mut s = session();
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "gear", "center": [200, 150], "count": 10, "a": 0.3, "radius": 2})).unwrap();
|
||||
let id = id_of(&r);
|
||||
assert_eq!(steps(&s), n + 1, "one undo step");
|
||||
assert_eq!((r["shape"]["kind"].clone(), r["shape"]["count"].clone(), r["shape"]["width"].clone()), (json!("gear"), json!(10), json!(100.0)));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [id]);
|
||||
let g = shape_of(&s, id).unwrap();
|
||||
assert_eq!((g.count, g.a, g.radius), (10, 0.3, 2.0));
|
||||
assert!(path(&s, id).anchor_count() > 40, "40 tooth corners, rounded");
|
||||
// Every kind, with its defaults.
|
||||
for kind in ["rectangle", "ellipse", "polygon", "star", "ring", "arrow", "cross", "heart", "gear", "bubble"] {
|
||||
let id = id_of(&s.execute("addon.pathtools.shape.create", &json!({"kind": kind, "center": [50, 50]})).unwrap());
|
||||
assert_eq!(shape_of(&s, id).map(|s| s.kind.id()), Some(kind));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(steps(&s), n + 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_changes_the_numbers_in_one_step_and_nothing_without_a_change() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
assert_eq!(path(&s, id).anchor_count(), 10);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8, "a": 0.3, "rotation": 15})).unwrap();
|
||||
assert_eq!((r["shape"]["count"].clone(), r["shape"]["rotation"].clone()), (json!(8), json!(15.0)));
|
||||
assert_eq!(path(&s, id).anchor_count(), 16);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// The same numbers again, or none: no step.
|
||||
s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8})).unwrap();
|
||||
s.execute("addon.pathtools.shape.set", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// Another kind takes its own defaults.
|
||||
let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "kind": "arrow"})).unwrap();
|
||||
assert_eq!((r["shape"]["a"].clone(), r["shape"]["rotation"].clone()), (json!(0.4), json!(15.0)));
|
||||
assert_eq!(path(&s, id).anchor_count(), 7);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).anchor_count(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_shape_params_are_refused() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let c = json!([10, 10]);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.shape.create",
|
||||
&[
|
||||
json!({}),
|
||||
json!({"center": null}),
|
||||
json!({"center": [1]}),
|
||||
json!({"center": "here"}),
|
||||
json!({"center": [1e300, 0]}),
|
||||
json!({"center": c, "kind": "cloud"}),
|
||||
json!({"center": c, "kind": 3}),
|
||||
json!({"center": c, "count": 2}),
|
||||
json!({"center": c, "count": 1001}),
|
||||
json!({"center": c, "count": 4.5}),
|
||||
json!({"center": c, "width": 0}),
|
||||
json!({"center": c, "height": 16385}),
|
||||
json!({"center": c, "width": "40"}),
|
||||
json!({"center": c, "kind": "star", "a": 2}),
|
||||
json!({"center": c, "kind": "bubble", "b": -1.5}),
|
||||
json!({"center": c, "radius": -1}),
|
||||
json!({"center": c, "rotation": "up"}),
|
||||
],
|
||||
);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.shape.set",
|
||||
&[json!({}), json!({"id": rect.0, "count": 6}), json!({"id": 999_999}), json!({"id": id.0, "count": 0}), json!({"id": id.0, "width": -5})],
|
||||
);
|
||||
s.execute("object.setProps", &json!({"ids": [id.0], "locked": true})).unwrap();
|
||||
refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 6})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
// Moved, turned and scaled with the ordinary commands: still the shape, at its new place.
|
||||
s.execute("object.move", &json!({"dx": 20, "dy": -10})).unwrap();
|
||||
assert!(shape_of(&s, id).is_some_and(|s| s.center.distance(Point::new(220.0, 140.0)) < 1e-6));
|
||||
s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 140]})).unwrap();
|
||||
let r = shape_of(&s, id).unwrap();
|
||||
assert!((r.rotation + 30.0).abs() < 1e-6, "30° counter-clockwise is −30 here: {r:?}");
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [220, 140]})).unwrap();
|
||||
let r = shape_of(&s, id).unwrap();
|
||||
assert!((r.width - 200.0).abs() < 1e-6 && r.center.distance(Point::new(220.0, 140.0)) < 1e-6, "{r:?}");
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap();
|
||||
assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1));
|
||||
assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays");
|
||||
// Stretched across its own axes (it is turned): skewed, so no longer.
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap();
|
||||
assert_eq!(shape_of(&s, id), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(shape_of(&s, id).is_some(), "undo brings it back");
|
||||
// An anchor moved by hand: a plain path, which `set` refuses.
|
||||
select_anchors(&mut s, id, &[2]);
|
||||
s.execute("addon.pathtools.setAnchor", &json!({"x": 0})).unwrap();
|
||||
assert_eq!(shape_of(&s, id), None);
|
||||
refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 7})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_makes_plain_shapes_parametric_in_one_step() {
|
||||
let mut s = session();
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 100, "height": 50})).unwrap());
|
||||
let p = id_of(&s.execute("shape.polygon", &json!({"cx": 200, "cy": 150, "radius": 40, "sides": 7})).unwrap());
|
||||
let st = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 150, "radius1": 40, "radius2": 15})).unwrap());
|
||||
select(&mut s, &[r, p, st]);
|
||||
let n = steps(&s);
|
||||
let out = s.execute("addon.pathtools.shape.convert", &json!({})).unwrap();
|
||||
assert_eq!(out["ids"], json!([r.0, p.0]));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(shape_of(&s, r).map(|s| s.kind.id()), Some("rectangle"));
|
||||
assert_eq!(shape_of(&s, p).map(|s| (s.kind.id(), s.count)), Some(("polygon", 7)));
|
||||
assert_eq!(shape_of(&s, st), None);
|
||||
assert_eq!(path(&s, r).bounds().map(|b| b.center()), Some(Point::new(60.0, 45.0)));
|
||||
// Nothing left to convert.
|
||||
select(&mut s, &[r, st]);
|
||||
refuses(&mut s, "addon.pathtools.shape.convert", &[json!({}), json!({"ids": [st.0]}), json!({"ids": [123456]})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shape_data_stays_out_of_svg_export() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
s.execute("object.setProps", &json!({"ids": [id.0], "data": {"pivot": "1,2"}})).unwrap();
|
||||
let key = vectorcraft_tools::addons::parametric_shape::KEY;
|
||||
let node = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(node.extras().attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, _)| k == key)), "kept with the object");
|
||||
assert!(shape_of(&s, id).is_some(), "setting other data keeps it a shape");
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert!(svg.contains("data-pivot=\"1,2\""), "the object's own data still goes out: {svg}");
|
||||
assert!(!svg.contains(key) && !svg.contains("fp"") && !svg.contains("\"fp\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() {
|
||||
let mut s = session();
|
||||
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("parametricShape", view).unwrap();
|
||||
s.set_tool_option("kind", &json!("star"));
|
||||
let n = steps(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 100.0), view).unwrap();
|
||||
s.tool_key(vectorcraft_tools::ToolKey::Up, vectorcraft_tools::Mods::default(), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 90.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 90.0), view).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
let id = *s.doc().unwrap().selection.objects.first().unwrap();
|
||||
let shape = shape_of(&s, id).unwrap();
|
||||
assert_eq!((shape.kind.id(), shape.count, shape.height, shape.rotation), ("star", 6, 120.0, 0.0));
|
||||
// Its size handle (lower right of its box) drags into one more step.
|
||||
let corner = shape.frame() * Point::new(shape.width / 2.0, shape.height / 2.0);
|
||||
for (k, x) in [(PointerKind::Down, corner.x), (PointerKind::Drag, corner.x + 30.0), (PointerKind::Up, corner.x + 30.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, corner.y), view).unwrap();
|
||||
}
|
||||
assert_eq!(steps(&s), n + 2);
|
||||
assert!((shape_of(&s, id).unwrap().width - 180.0).abs() < 1e-9);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user