M6: Sketch import: editable paths, shape groups, booleans, stars, polygons

Paths become editable vector networks (handles, per-point corner radius,
open and closed, winding rule). A shape group with one shape becomes that
shape in the group's place; several combine as boolean operations, mixed
operations nesting from the left as Sketch applies them. Stars and
polygons stay stars and polygons while their points are the generated
ones, edited ones and triangles become vectors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jindřich Šaršon
2026-10-22 00:27:20 +02:00
co-authored by Claude Opus 5.5
parent 5805cea9b4
commit 72c7eae868
5 changed files with 410 additions and 23 deletions
+14
View File
@@ -118,3 +118,17 @@ pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value {
}
l
}
/// A `shapePath` through `points` (`(x, y)` in 0–1 of its frame), straight segments.
pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f64, f64)], closed: bool) -> Value {
let pts: Vec<Value> = points.iter().map(|(px, py)| point(*px, *py)).collect();
let mut l = with(layer("shapePath", id, id, x, y, w, h), "points", Value::Array(pts));
l["isClosed"] = json!(closed);
l["booleanOperation"] = json!(-1);
l
}
/// A `shapeGroup` (Sketch 51 and older wrap every shape in one) holding `layers`.
pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec<Value>) -> Value {
with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers))
}
+3
View File
@@ -13,6 +13,9 @@
#[cfg(test)]
mod fixture;
mod path;
#[cfg(test)]
mod path_tests;
#[cfg(test)]
mod tests;
mod tree;
+193
View File
@@ -0,0 +1,193 @@
//! Sketch curve points → vector networks; shape groups → shapes and boolean operations.
//!
//! A path's `points` are `curvePoint`s whose `point`, `curveFrom` (the handle leaving the point)
//! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame;
//! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup`
//! that carries the style; its children combine left to right, each with its own
//! `booleanOperation` (−1 none and 0 union, 1 subtract, 2 intersect, 3 difference).
use figcraft_doc::geom::{Affine, polygon_points, star_points};
use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule};
use serde_json::{Map as JsonMap, Value, json};
use super::tree::{Cx, base, layer, layers, local, size};
/// Most points read from one path.
const MAX_POINTS: usize = 100_000;
/// What a path layer becomes.
pub(crate) enum Shape {
Vector(VectorNetwork),
Star { count: u32, ratio: f64 },
Polygon { count: u32 },
}
/// `"{x, y}"` → `(x, y)`, both finite.
pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> {
let inner = s.trim().strip_prefix('{')?.strip_suffix('}')?;
let (x, y) = inner.split_once(',')?;
let n = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite());
Some((n(x)?, n(y)?))
}
/// The network of a path layer of `w`×`h` (`None` when it has no usable points).
pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option<VectorNetwork> {
let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if pts.len() > MAX_POINTS {
cx.warn(format!("paths with more than {MAX_POINTS} points were cut"));
}
let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (x * w, y * h));
let mut vertices = Vec::new();
// Per vertex: its handles (relative to it) when it has them.
let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new();
for p in pts.iter().take(MAX_POINTS) {
let Some((x, y)) = at(p, "point") else {
cx.warn("path points that couldn't be read were skipped");
continue;
};
let curved = p.get("curveMode").and_then(Value::as_i64).is_some_and(|m| m != 1);
let has = |flag: &str| p.get(flag).and_then(Value::as_bool).unwrap_or(curved);
let handle = |flag: &str, key: &str| if has(flag) { at(p, key).map_or((0.0, 0.0), |(hx, hy)| (hx - x, hy - y)) } else { (0.0, 0.0) };
handles.push((handle("hasCurveFrom", "curveFrom"), handle("hasCurveTo", "curveTo")));
let mut v = Vertex::new(x, y);
v.radius = p.get("cornerRadius").and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, 1e6));
vertices.push(v);
}
let n = vertices.len();
if n == 0 {
return None;
}
let closed = l.get("isClosed").and_then(Value::as_bool).unwrap_or(false) && n > 1;
let count = if closed { n } else { n - 1 };
let segments: Vec<Segment> = (0..count)
.map(|i| {
let j = (i + 1) % n;
let ta = handles.get(i).map_or((0.0, 0.0), |h| h.0);
let tb = handles.get(j).map_or((0.0, 0.0), |h| h.1);
Segment { a: i, b: j, ta, tb }
})
.collect();
let winding = match l.get("style").and_then(|s| s.get("windingRule")).and_then(Value::as_i64) {
Some(1) => WindingRule::EvenOdd,
_ => WindingRule::NonZero,
};
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() };
Some(VectorNetwork { vertices, segments, regions })
}
/// A path, star, polygon or triangle layer: a star or polygon while its points are still the
/// generated ones (same as FigCraft's, within half a percent), else an editable vector.
pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option<Shape> {
let net = network(cx, l, w, h)?;
let class = l.get("_class").and_then(Value::as_str).unwrap_or("");
let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok());
let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0));
let matches = |pts: Vec<figcraft_doc::geom::Point>| {
let tol = 0.005 * w.max(h) + 1e-6;
pts.len() == net.vertices.len() && pts.iter().zip(&net.vertices).all(|(p, v)| (p.x - v.x).abs() <= tol && (p.y - v.y).abs() <= tol)
};
match (class, count) {
("star", Some(count)) if plain => {
let ratio = l.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0).map_or(0.5, |r| r.min(1.0));
if matches(star_points(w, h, count, ratio)) {
return Some(Shape::Star { count, ratio });
}
}
("polygon", Some(count)) if plain && matches(polygon_points(w, h, count)) => return Some(Shape::Polygon { count }),
_ => {}
}
Some(Shape::Vector(net))
}
/// Write `shape` into `node` (its type and geometry).
pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap<String, Value>) {
match shape {
Shape::Vector(net) => {
node.insert("type".into(), json!("VECTOR"));
node.insert("vectorNetworkRef".into(), json!(cx.networks.len()));
cx.networks.push(Some(net));
}
Shape::Star { count, ratio } => {
node.insert("type".into(), json!("STAR"));
node.insert("pointCount".into(), json!(count));
node.insert("innerRadius".into(), json!(ratio));
}
Shape::Polygon { count } => {
node.insert("type".into(), json!("REGULAR_POLYGON"));
node.insert("pointCount".into(), json!(count));
}
}
}
/// A `shapeGroup`: its only child carrying the group's place, name and style, or its children
/// combined by boolean operations (mixed operations nest from the left).
pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option<Value> {
let (w, h) = size(l);
let world = parent * local(l, w, h);
let kids = layers(l);
if let [only] = kids {
let mut node = layer(cx, only, world, depth + 1, false)?;
let (cw, ch) = size(only);
let child_world = world * local(only, cw, ch);
let group = base(l, "GROUP", parent, child_world, cw, ch, root);
if let Some(m) = node.as_object_mut() {
for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] {
match group.get(key) {
Some(v) => m.insert(key.into(), v.clone()),
None => m.remove(key),
};
}
let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true);
m.insert("visible".into(), json!(visible));
}
return Some(node);
}
let mut chain: Vec<Value> = Vec::new();
let mut op = "UNION";
for (i, c) in kids.iter().enumerate() {
let Some(child) = layer(cx, c, world, depth + 2, false) else { continue };
let this = boolean_op(c);
if i > 0 && this != op && chain.len() > 1 {
let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain));
chain.push(inner);
}
if i > 0 {
op = this;
}
chain.push(child);
}
if chain.is_empty() {
return None;
}
let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root);
node.insert("booleanOperation".into(), json!(op));
node.insert("children".into(), Value::Array(chain));
Some(Value::Object(node))
}
/// A boolean operation the shape group doesn't have as a layer of its own: placed like the group.
fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec<Value>) -> Value {
let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false);
node.remove("id");
let name = match op {
"SUBTRACT" => "Subtract",
"INTERSECT" => "Intersect",
"EXCLUDE" => "Exclude",
_ => "Union",
};
node.insert("name".into(), json!(name));
// Placed where the group is: inside it, that's no move at all.
node.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]));
node.insert("booleanOperation".into(), json!(op));
node.insert("children".into(), Value::Array(children));
Value::Object(node)
}
fn boolean_op(l: &Value) -> &'static str {
match l.get("booleanOperation").and_then(Value::as_i64) {
Some(1) => "SUBTRACT",
Some(2) => "INTERSECT",
Some(3) => "EXCLUDE",
_ => "UNION",
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Sketch paths → editable vector networks; shape groups → shapes and boolean operations; stars
//! and polygons.
use figcraft_doc::{BoolOp, Document, NodeKind};
use serde_json::{Value, json};
use super::fixture::*;
use super::import;
use super::path::parse_point;
fn open(layers: Vec<Value>) -> Document {
let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]);
import(&z, "f").unwrap().0
}
fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node {
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}"))
}
fn with_op(mut l: Value, op: i64) -> Value {
l["booleanOperation"] = json!(op);
l
}
#[test]
fn parse_point_accepts_sketch_strings() {
assert_eq!(parse_point("{0.5, 1}"), Some((0.5, 1.0)));
assert_eq!(parse_point("{-0.25,2e-1}"), Some((-0.25, 0.2)));
assert_eq!(parse_point("{1e400, 0}"), None);
assert_eq!(parse_point("garbage"), None);
assert_eq!(parse_point("{0.1}"), None);
assert_eq!(parse_point(""), None);
}
#[test]
fn triangle_path_is_editable_vector() {
let tri = shape_path("Tri", 10.0, 10.0, 100.0, 100.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true);
let open_line = shape_path("Line", 10.0, 200.0, 100.0, 50.0, &[(0.0, 0.0), (1.0, 1.0)], false);
let doc = open(vec![tri, open_line]);
let NodeKind::Vector { network } = &node(&doc, "Tri").kind else { panic!("not a vector") };
let pts: Vec<(f64, f64)> = network.vertices.iter().map(|v| (v.x, v.y)).collect();
assert_eq!(pts, [(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
assert_eq!(network.segments.len(), 3);
assert_eq!(network.regions.len(), 1);
let NodeKind::Vector { network } = &node(&doc, "Line").kind else { panic!("not a vector") };
assert_eq!(network.segments.len(), 1);
assert!(network.regions.is_empty());
}
#[test]
fn curve_handles_and_corner_radius_are_kept() {
let mut p = shape_path("Curve", 0.0, 0.0, 100.0, 100.0, &[(0.0, 0.0), (1.0, 0.0)], false);
p["points"][0]["curveMode"] = json!(2);
p["points"][0]["hasCurveFrom"] = json!(true);
p["points"][0]["curveFrom"] = json!("{0.25, 0.5}");
p["points"][1]["curveMode"] = json!(2);
p["points"][1]["hasCurveTo"] = json!(true);
p["points"][1]["curveTo"] = json!("{0.75, 0.5}");
p["points"][1]["cornerRadius"] = json!(6);
let doc = open(vec![p]);
let NodeKind::Vector { network } = &node(&doc, "Curve").kind else { panic!("not a vector") };
let s = network.segments[0];
assert_eq!((s.ta, s.tb), ((25.0, 50.0), (-25.0, 50.0)));
assert_eq!(network.vertices[1].radius, 6.0);
}
#[test]
fn single_child_shape_group_collapses() {
let inner = with_op(rect("Inner", 0.0, 0.0, 50.0, 30.0), -1);
let doc = open(vec![shape_group("Button", 20.0, 40.0, 50.0, 30.0, vec![inner])]);
let b = node(&doc, "Button");
assert!(matches!(b.kind, NodeKind::Rectangle));
assert!(doc.nodes().all(|n| n.name != "Inner"));
let bb = doc.world_bounds(b.id).unwrap();
assert_eq!((bb.x0, bb.y0, bb.width(), bb.height()), (20.0, 40.0, 50.0, 30.0));
}
#[test]
fn mixed_boolean_ops_chain_left() {
let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1);
let b = with_op(rect("B", 10.0, 10.0, 20.0, 20.0), 1);
let c = with_op(rect("C", 60.0, 0.0, 20.0, 20.0), 0);
let doc = open(vec![shape_group("Combined", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]);
let outer = node(&doc, "Combined");
assert!(matches!(outer.kind, NodeKind::BooleanOp { op: BoolOp::Union }));
let kids: Vec<&figcraft_doc::Node> = outer.children.iter().map(|c| doc.node(*c).unwrap()).collect();
assert_eq!(kids.len(), 2);
assert!(matches!(kids[0].kind, NodeKind::BooleanOp { op: BoolOp::Subtract }));
assert_eq!(kids[1].name, "C");
let inner: Vec<&str> = kids[0].children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
assert_eq!(inner, ["A", "B"]);
let bb = doc.world_bounds(node(&doc, "B").id).unwrap();
assert_eq!((bb.x0, bb.y0), (10.0, 10.0));
}
#[test]
fn intersect_and_difference_ops() {
let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1);
let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 2);
let doc = open(vec![shape_group("Both", 0.0, 0.0, 60.0, 60.0, vec![a.clone(), b])]);
assert!(matches!(node(&doc, "Both").kind, NodeKind::BooleanOp { op: BoolOp::Intersect }));
let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 3);
let doc = open(vec![shape_group("Either", 0.0, 0.0, 60.0, 60.0, vec![a, b])]);
assert!(matches!(node(&doc, "Either").kind, NodeKind::BooleanOp { op: BoolOp::Exclude }));
}
#[test]
fn standalone_shapes_52_plus() {
let doc = open(vec![shape_path("Free", 5.0, 5.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true)]);
assert!(matches!(node(&doc, "Free").kind, NodeKind::Vector { .. }));
}
#[test]
fn star_and_polygon() {
let unit = |pts: Vec<figcraft_doc::geom::Point>, w: f64, h: f64| -> Vec<(f64, f64)> { pts.iter().map(|p| (p.x / w, p.y / h)).collect() };
let mut star = shape_path("Star", 0.0, 0.0, 100.0, 80.0, &unit(figcraft_doc::geom::star_points(100.0, 80.0, 5, 0.5), 100.0, 80.0), true);
star["_class"] = json!("star");
star["numberOfPoints"] = json!(5);
star["radius"] = json!(0.5);
let mut poly = shape_path("Hexagon", 0.0, 100.0, 60.0, 60.0, &unit(figcraft_doc::geom::polygon_points(60.0, 60.0, 6), 60.0, 60.0), true);
poly["_class"] = json!("polygon");
poly["numberOfPoints"] = json!(6);
let mut edited = star.clone();
edited["do_objectID"] = json!("Edited");
edited["name"] = json!("Edited");
edited["points"][2]["point"] = json!("{0.9, 0.9}");
let mut tri = shape_path("Triangle", 200.0, 0.0, 50.0, 50.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true);
tri["_class"] = json!("triangle");
let doc = open(vec![star, poly, edited, tri]);
assert!(matches!(node(&doc, "Star").kind, NodeKind::Star { count: 5, .. }));
if let NodeKind::Star { inner_ratio, .. } = node(&doc, "Star").kind {
assert!((inner_ratio - 0.5).abs() < 1e-9);
}
assert!(matches!(node(&doc, "Hexagon").kind, NodeKind::Polygon { count: 6 }));
assert!(matches!(node(&doc, "Edited").kind, NodeKind::Vector { .. }));
assert!(matches!(node(&doc, "Triangle").kind, NodeKind::Vector { .. }));
}
#[test]
fn points_outside_unit_are_kept_finite() {
let doc = open(vec![shape_path("Over", 0.0, 0.0, 10.0, 10.0, &[(-0.5, 2.0), (1.0, 0.0), (0.0, 0.0)], true)]);
let NodeKind::Vector { network } = &node(&doc, "Over").kind else { panic!("not a vector") };
assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
assert_eq!((network.vertices[0].x, network.vertices[0].y), (-5.0, 20.0));
}
#[test]
fn even_odd_winding() {
let mut p = shape_path("Holes", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true);
p["style"]["windingRule"] = json!(1);
let doc = open(vec![p]);
let NodeKind::Vector { network } = &node(&doc, "Holes").kind else { panic!("not a vector") };
assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd);
}
+46 -23
View File
@@ -70,43 +70,31 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
return None;
}
let class = str_field(l, "_class")?;
if class == "shapeGroup" {
return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten();
}
let ty = match class {
"artboard" | "frame" => "FRAME",
"group" | "graphic" => "GROUP",
"rectangle" | "bitmap" => "RECTANGLE",
"oval" => "ELLIPSE",
"slice" => "SLICE",
"shapePath" | "star" | "polygon" | "triangle" => "VECTOR",
other => {
cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other)));
return None;
}
};
if cx.nodes >= cx.max_nodes {
cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes));
if !count(cx) {
return None;
}
cx.nodes += 1;
let (w, h) = size(l);
let world = parent * local(l, w, h);
let mut node = JsonMap::new();
node.insert("type".into(), json!(ty));
if let Some(id) = str_field(l, "do_objectID") {
node.insert("id".into(), json!(id));
let mut node = base(l, ty, parent, world, w, h, root);
if ty == "VECTOR" {
let shape = super::path::shape(cx, l, w, h)?;
super::path::put_shape(cx, shape, &mut node);
}
node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class)));
node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true)));
node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false)));
node.insert("size".into(), json!({"x": w, "y": h}));
let rel = parent.inverse() * world;
let [a, b, c, d, e, f] = rel.as_coeffs();
node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]]));
// The page places top-level layers by their transform; deeper ones also say where they end up,
// which tells the importer their transform is relative to their group.
if !root {
let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h));
node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}));
}
node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63)));
match class {
"artboard" | "frame" => {
let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) };
@@ -134,8 +122,43 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root:
Some(Value::Object(node))
}
/// Count one more layer; false (with a warning) once the file's budget is spent.
fn count(cx: &mut Cx) -> bool {
if cx.nodes >= cx.max_nodes {
cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes));
return false;
}
cx.nodes += 1;
true
}
/// The properties every REST node of layer `l` has: its type, id, name, visibility, lock, size,
/// place (`world`, relative to `parent`) and constraints.
pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h: f64, root: bool) -> JsonMap<String, Value> {
let mut node = JsonMap::new();
node.insert("type".into(), json!(ty));
if let Some(id) = str_field(l, "do_objectID") {
node.insert("id".into(), json!(id));
}
let class = str_field(l, "_class").unwrap_or("Layer");
node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class)));
node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true)));
node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false)));
node.insert("size".into(), json!({"x": w, "y": h}));
let [a, b, c, d, e, f] = (parent.inverse() * world).as_coeffs();
node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]]));
// The page places top-level layers by their transform; deeper ones also say where they end up,
// which tells the importer their transform is relative to their group.
if !root {
let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h));
node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}));
}
node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63)));
node
}
/// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]).
fn size(l: &Value) -> (f64, f64) {
pub(crate) fn size(l: &Value) -> (f64, f64) {
let f = l.get("frame");
let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE);
(n("width"), n("height"))
@@ -143,7 +166,7 @@ fn size(l: &Value) -> (f64, f64) {
/// A layer's transform within its parent: its frame's position, then its rotation (degrees,
/// counter-clockwise on screen) and flips about its centre.
fn local(l: &Value, w: f64, h: f64) -> Affine {
pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine {
let f = l.get("frame");
let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD);
let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0;