Merge pull request #2123 from FredApps/fix/scaled-vector-corners-upstream-2097

M6: scale per-point vector radii in scaled instances
This commit is contained in:
FredApps
2026-11-08 16:53:44 +01:00
committed by GitHub
5 changed files with 157 additions and 7 deletions
+2
View File
@@ -58,6 +58,8 @@ mod text_style_tests;
mod text_wrap_tests;
#[cfg(test)]
mod value_budget_tests;
#[cfg(test)]
mod vector_scale_tests;
use std::cell::Cell;
use std::collections::BTreeMap;
+2 -2
View File
@@ -356,8 +356,8 @@ 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 what a network is made from: its blob, its scale and its style table.
network_ids: HashMap<(usize, u64, u64, usize), usize>,
/// Index in `networks` by its blob, position scale, style table and point-radius scale.
network_ids: HashMap<(usize, u64, u64, usize, 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,
+6 -3
View File
@@ -628,7 +628,7 @@ impl<'a> Mapper<'a> {
/// scaled to the layer's size, with per-point corner radii, handle mirroring and end caps.
/// False when there's none or it's damaged (the outlines stand in).
fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool {
let Some(vd) = v.get("vectorData") else { return false };
let Some((vd, radius_scale)) = v.find("vectorData") else { return false };
let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false };
let Some(blob) = self.blobs.get(blob_id).copied() else { return false };
let Some(net) = network::decode(blob) else {
@@ -674,7 +674,9 @@ impl<'a> Mapper<'a> {
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
// 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) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
@@ -790,7 +792,8 @@ impl<'a> Mapper<'a> {
o.insert("vectorNetwork".into(), entry);
return true;
}
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
// 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 id = match self.network_ids.get(&key) {
Some(id) => *id,
None => {
+144
View File
@@ -0,0 +1,144 @@
//! Per-point radii follow the scale of their vector-data row, not the resized layer's box.
use figcraft_doc::{Document, Node, NodeKind, VectorNetwork};
use super::kiwi::Kv;
use super::network::{self, Network, Region, Segment, Vertex};
use super::tests::{arr, at, canvas, e, f, g, node, o, schema, u, xy};
fn vector_data(radius: f64) -> Kv<'static> {
o(vec![
("vectorNetworkBlob", u(0)),
("normalizedSize", xy(100.0, 100.0)),
(
"styleOverrideTable",
arr(vec![o(vec![("styleID", u(1)), ("cornerRadius", f(radius))]), o(vec![("styleID", u(2)), ("cornerRadius", f(0.0))])]),
),
])
}
fn source() -> Vec<Kv<'static>> {
vec![
node(0, None, "DOCUMENT", "D", vec![]),
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(100.0, 100.0))]),
node(
6,
Some((5, "!")),
"VECTOR",
"Triangle",
vec![
("size", xy(100.0, 100.0)),
("vectorData", vector_data(8.0)),
("horizontalConstraint", e("SCALE")),
("verticalConstraint", e("SCALE")),
],
),
]
}
fn instance(id: u64, parent: u64, main: u64, name: &str, size: f64, factor: Option<f64>, rows: Vec<Kv<'static>>) -> Kv<'static> {
let mut symbol = vec![("symbolID", g(main)), ("symbolOverrides", arr(rows))];
if let Some(factor) = factor {
symbol.push(("uniformScaleFactor", f(factor)));
}
node(id, Some((parent, "!")), "INSTANCE", name, vec![("size", xy(size, size)), ("transform", at(200.0, 0.0)), ("symbolData", o(symbol))])
}
fn row(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut values = vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))]))];
values.extend(fields);
o(values)
}
fn import(nodes: Vec<Kv<'static>>) -> Document {
let network = Network {
vertices: vec![Vertex { style: 1, x: 0.0, y: 0.0 }, Vertex { style: 1, x: 100.0, y: 0.0 }, Vertex { style: 2, x: 0.0, y: 100.0 }],
segments: (0..3).map(|a| Segment { style: 0, a, b: (a + 1) % 3, ta: (0.0, 0.0), tb: (0.0, 0.0) }).collect(),
regions: vec![Region { style: 0, non_zero: false, loops: vec![vec![0, 1, 2]] }],
};
let message = o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(network::encode(&network)))])]))]);
super::import(&canvas(&schema(), &message), "Scaled vectors").unwrap().0
}
fn child<'a>(doc: &'a Document, name: &str) -> &'a Node {
let parent = doc.nodes().find(|node| node.name == name).unwrap();
doc.node(parent.children[0]).unwrap()
}
fn network(node: &Node) -> &VectorNetwork {
let NodeKind::Vector { network } = &node.kind else { panic!("not a vector") };
network
}
fn assert_vector(node: &Node, size: f64, radius: f64) {
assert_eq!((node.width, node.height), (size, size));
let vector = network(node);
assert_eq!((vector.vertices[1].x, vector.vertices[2].y), (size, size));
assert_eq!(vector.vertices.iter().map(|point| point.radius).collect::<Vec<_>>(), [radius, radius, 0.0]);
}
#[test]
fn vector_corner_radii_follow_uniform_scale_without_changing_the_main() {
for (factor, size, radius) in [(None, 100.0, 8.0), (Some(1.0), 100.0, 8.0), (Some(0.5), 50.0, 4.0), (Some(2.0), 200.0, 16.0)] {
let mut nodes = source();
nodes.push(instance(9, 1, 5, "Copy", size, factor, vec![]));
let doc = import(nodes);
assert_vector(child(&doc, "Main"), 100.0, 8.0);
assert_vector(child(&doc, "Copy"), size, radius);
}
}
#[test]
fn vector_corner_radii_from_override_and_derived_rows_are_not_scaled_twice() {
for derived in [false, true] {
let mut nodes = source();
let rows = vec![row(vec![("vectorData", vector_data(3.0))])];
let mut copy = instance(9, 1, 5, "Copy", 50.0, Some(0.5), if derived { vec![] } else { rows.clone() });
if derived {
let Kv::Object(fields) = &mut copy else { panic!() };
fields.push(("derivedSymbolData", arr(rows)));
}
nodes.push(copy);
let doc = import(nodes);
assert_vector(child(&doc, "Copy"), 50.0, 3.0);
}
}
#[test]
fn vector_corner_radii_still_scale_when_only_the_size_is_derived() {
let mut nodes = source();
let mut copy = instance(9, 1, 5, "Copy", 50.0, Some(0.5), vec![]);
let Kv::Object(fields) = &mut copy else { panic!() };
fields.push(("derivedSymbolData", arr(vec![row(vec![("size", xy(50.0, 50.0))])])));
nodes.push(copy);
let doc = import(nodes);
assert_vector(child(&doc, "Copy"), 50.0, 4.0);
}
#[test]
fn nested_vector_corner_radii_take_the_scale_of_their_own_row() {
for own_row in [false, true] {
let mut nodes = source();
nodes.push(node(20, Some((1, "!")), "SYMBOL", "Wrapper", vec![("size", xy(50.0, 50.0))]));
let rows = if own_row { vec![row(vec![("vectorData", vector_data(6.0))])] } else { vec![] };
nodes.push(instance(21, 20, 5, "Inner", 50.0, Some(0.5), rows));
nodes.push(instance(30, 1, 20, "Outer", 25.0, Some(0.5), vec![]));
let doc = import(nodes);
let inner = child(&doc, "Outer");
assert_vector(doc.node(inner.children[0]).unwrap(), 25.0, if own_row { 3.0 } else { 2.0 });
}
}
#[test]
fn equally_sized_vectors_with_different_scale_factors_do_not_share_radii() {
for scaled_first in [false, true] {
let mut nodes = source();
// Equal sort positions are ordered by GUID, so vary IDs to exercise both cache orders.
nodes.push(instance(if scaled_first { 8 } else { 9 }, 1, 5, "Scaled", 50.0, Some(0.5), vec![]));
nodes.push(instance(if scaled_first { 9 } else { 8 }, 1, 5, "Resized", 50.0, None, vec![]));
let doc = import(nodes);
assert_vector(child(&doc, "Scaled"), 50.0, 4.0);
assert_vector(child(&doc, "Resized"), 50.0, 8.0);
}
}
File diff suppressed because one or more lines are too long