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.
This commit is contained in:
Giovanni Almeida committed 2026-11-08 13:55:56 -03:00
1 parent 8a7ebc2484
commit a6be897d31
6 files changed
+123 -5

No files matched your search

+3
View File
@@ -115,3 +115,6 @@ display_name = "Hamza0101"
[people.ashokram-si]
real_name = "Ashok Ram"
[people.GiovanniBot]
sync_github_name = true
+3 -1
View File
@@ -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 {
+19
View File
@@ -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<f64> = 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();
+3 -2
View File
@@ -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<Option<VectorNetwork>>,
/// 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,
+5 -2
View File
@@ -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<Value> = 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 => {
+90
View File
@@ -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<f64> = 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<f64> = 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<f64> {
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<f64> {
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();