From a6be897d3141f11f396cdf5a63a797463d69428b Mon Sep 17 00:00:00 2001 From: Giovanni Almeida Date: Sun, 8 Nov 2026 13:53:24 -0300 Subject: [PATCH] M6: a vector point without a corner radius of its own takes the layer's on .fig import (#2114) Figma rounds every point whose style doesn't set a corner radius with the layer's cornerRadius. The import read only the point's style, so those vectors came in sharp. Points now fall back to the layer's radius, at the scale of the layer that carries it (#2091), and the network cache keys on it. The export writes an explicit 0 for a sharp point on a rounded vector so it stays sharp. --- contributors/people.toml | 3 + crates/format/src/fig/export/shape.rs | 4 +- crates/format/src/fig/export_tests.rs | 19 ++++++ crates/format/src/fig/map.rs | 5 +- crates/format/src/fig/map/layer.rs | 7 ++- crates/format/src/fig/tail_tests.rs | 90 +++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index c6924df5f..34f849ed7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -115,3 +115,6 @@ display_name = "Hamza0101" [people.ashokram-si] real_name = "Ashok Ram" + +[people.GiovanniBot] +sync_github_name = true diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index ca5846cde..bfc2b0224 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -207,7 +207,9 @@ impl<'d> Exporter<'d> { .enumerate() .map(|(i, v)| { let mut st: Fields = Vec::new(); - if v.radius > 0.0 { + // A point without a radius of its own is drawn with the layer's, so a sharp + // point on a rounded layer says so. + if v.radius > 0.0 || n.corners.radii[0] > 0.0 { st.push(("cornerRadius", fl(v.radius))); } if let Some(m) = v.mirror { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 9651a1025..4fd533545 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -955,6 +955,25 @@ fn closed_vector_geometry_is_written_with_its_corner_smoothing() { assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius"); } +#[test] +fn sharp_points_on_a_rounded_vector_stay_sharp() { + // A point without a radius takes the layer's when read, so a sharp point on a vector with a + // layer radius is written with its own zero. + let mut doc = Document::new("sharp point"); + let page = doc.first_page().unwrap(); + let mut network = VectorNetwork::from_points(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)), true); + for (v, r) in network.vertices.iter_mut().zip([8.0, 0.0, 8.0, 0.0]) { + v.radius = r; + } + let id = doc.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap(); + doc.node_mut(id).unwrap().corners.radii = [8.0; 4]; + let (back, _) = round_trip(&doc, "sharp point"); + let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap(); + let NodeKind::Vector { network } = &v.kind else { panic!("vector") }; + let radii: Vec = network.vertices.iter().map(|x| x.radius).collect(); + assert_eq!(radii, vec![8.0, 0.0, 8.0, 0.0]); +} + #[test] fn the_written_schema_parses_and_round_trips() { let schema = Schema::parse(export::SCHEMA).unwrap(); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index fd1b47575..aa0db5cf4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -356,8 +356,9 @@ struct Mapper<'a> { /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's /// networks took gigabytes. networks: Vec>, - /// Index in `networks` by its blob, position scale, style table and point-radius scale. - network_ids: HashMap<(usize, u64, u64, usize, u64), usize>, + /// Index in `networks` by its blob, position scale, style table, point-radius scale and the + /// layer's corner radius (which points without one of their own take). + network_ids: HashMap<(usize, u64, u64, usize, u64, u64), usize>, /// Region paint elements (paints and gradient stops) read so far: one budget for the whole /// file, since layers with region paints don't share their network. region_paint_elements: usize, diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index e3de96368..a69bb7d46 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -668,6 +668,8 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); + // A point whose style doesn't set a radius takes the layer's, as the editor draws it. + let layer_radius = v.len("cornerRadius"); let vertices: Vec = net .vertices .iter() @@ -676,7 +678,7 @@ impl<'a> Mapper<'a> { let st = style(p.style); // Point radii are lengths in this row's space, unlike the normalized positions. // An override/derived row already carries the instance's scale. - if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).and_then(|r| scaled(r, radius_scale)).filter(|r| *r > 0.0) { + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).and_then(|r| scaled(r, radius_scale)).or(layer_radius).filter(|r| *r > 0.0) { out["radius"] = json!(r); } match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { @@ -793,7 +795,8 @@ impl<'a> Mapper<'a> { return true; } // A resized instance and a K-scaled one can have the same box but different radii. - let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize, radius_scale.to_bits()); + let key = + (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize, radius_scale.to_bits(), layer_radius.unwrap_or(0.0).to_bits()); let id = match self.network_ids.get(&key) { Some(id) => *id, None => { diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 176c95c9b..663d6002b 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -521,6 +521,96 @@ fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_d } } +#[test] +fn vector_points_without_a_radius_of_their_own_take_the_layers() { + // A vector rounded from the layer's corner radius: the editor rounds every point whose + // style doesn't set a radius (no style, or one without `cornerRadius`) with the layer's, + // while a point's own radius, zero included, wins. + let mut message = tail_message(); + let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() }; + let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap(); + let Kv::Object(fields) = wave else { panic!() }; + fields.push(("cornerRadius", f(6.0))); + let vector_data = field_mut(wave, "vectorData").unwrap(); + let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() }; + let Kv::Object(sharp) = styles.get_mut(2).unwrap() else { panic!() }; + sharp.push(("cornerRadius", f(0.0))); + let (doc, _) = import(&canvas(&schema(), &message), "Rounded").unwrap(); + let radii: Vec = network_of(&doc, "Wave").vertices.iter().map(|v| v.radius).collect(); + assert_eq!(radii, [6.0, 4.0, 6.0, 0.0], "no style, own radius, style without one, own zero"); + // Without a layer radius, the points without one of their own stay sharp. + let (doc, _) = import(&tail_canvas(), "Tail").unwrap(); + let radii: Vec = network_of(&doc, "Wave").vertices.iter().map(|v| v.radius).collect(); + assert_eq!(radii, [0.0, 4.0, 0.0, 0.0]); +} + +#[test] +fn an_instance_that_rounds_a_vector_keeps_its_own_points() { + // An instance's copy shares its main's vector data; overriding the layer's radius must not + // reuse the main's network, nor hand the instance's to the main. + let mut message = tail_message(); + let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() }; + let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap(); + *field_mut(wave, "parentIndex").unwrap() = o(vec![("guid", g(50)), ("position", s("!"))]); + *field_mut(wave, "transform").unwrap() = at(0.0, 0.0); + let rounded = o(vec![("guidPath", o(vec![("guids", arr(vec![g(30)]))])), ("cornerRadius", f(6.0))]); + nodes.push(node(50, Some((1, "~")), "SYMBOL", "Mark", vec![("size", xy(200.0, 100.0)), ("transform", at(700.0, 0.0))])); + nodes.push(node( + 51, + Some((1, "~!")), + "INSTANCE", + "Rounded mark", + vec![ + ("size", xy(200.0, 100.0)), + ("transform", at(700.0, 400.0)), + ("symbolData", o(vec![("symbolID", g(50)), ("symbolOverrides", arr(vec![rounded]))])), + ], + )); + let (doc, _) = import(&canvas(&schema(), &message), "Instance").unwrap(); + let radii_under = |parent: &str| -> Vec { + let p = named(&doc, parent); + let wave = p.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == "Wave").unwrap(); + let NodeKind::Vector { network } = &wave.kind else { panic!() }; + network.vertices.iter().map(|v| v.radius).collect() + }; + assert_eq!(radii_under("Mark"), [0.0, 4.0, 0.0, 0.0]); + assert_eq!(radii_under("Rounded mark"), [6.0, 4.0, 6.0, 6.0]); +} + +#[test] +fn a_scaled_instance_scales_its_vectors_point_radii() { + // Point radii are lengths: an instance scaled with the Scale tool draws its main's, from a + // point's own style or from the layer, times the factor (#2091). + let mut message = tail_message(); + let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() }; + let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap(); + *field_mut(wave, "parentIndex").unwrap() = o(vec![("guid", g(50)), ("position", s("!"))]); + *field_mut(wave, "transform").unwrap() = at(0.0, 0.0); + let Kv::Object(fields) = wave else { panic!() }; + fields.push(("cornerRadius", f(3.0))); + nodes.push(node(50, Some((1, "~")), "SYMBOL", "Mark", vec![("size", xy(200.0, 100.0)), ("transform", at(700.0, 0.0))])); + nodes.push(node( + 51, + Some((1, "~!")), + "INSTANCE", + "Big mark", + vec![ + ("size", xy(400.0, 200.0)), + ("transform", at(700.0, 400.0)), + ("symbolData", o(vec![("symbolID", g(50)), ("uniformScaleFactor", f(2.0))])), + ], + )); + let (doc, _) = import(&canvas(&schema(), &message), "Scaled").unwrap(); + let radii_under = |parent: &str| -> Vec { + let p = named(&doc, parent); + let wave = p.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == "Wave").unwrap(); + let NodeKind::Vector { network } = &wave.kind else { panic!() }; + network.vertices.iter().map(|v| v.radius).collect() + }; + assert_eq!(radii_under("Mark"), [3.0, 4.0, 3.0, 3.0]); + assert_eq!(radii_under("Big mark"), [6.0, 8.0, 6.0, 6.0]); +} + #[test] fn vectors_import_as_editable_networks() { let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();