diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index 231225c9..68084767 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -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 = 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 { + with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers)) +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index a46706db..5d703622 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -13,6 +13,9 @@ #[cfg(test)] mod fixture; +mod path; +#[cfg(test)] +mod path_tests; #[cfg(test)] mod tests; mod tree; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs new file mode 100644 index 00000000..a842b295 --- /dev/null +++ b/crates/format/src/sketch/path.rs @@ -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::().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 { + 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 = (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 { + 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| { + 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) { + 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 { + 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 = 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 { + 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", + } +} diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs new file mode 100644 index 00000000..8e1528fb --- /dev/null +++ b/crates/format/src/sketch/path_tests.rs @@ -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) -> 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, 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); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 59e3d637..89a43010 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -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 { + 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;