From 4ecf6e2f399071ec850957895a1b7b31783a94d8 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:37:30 +0200 Subject: [PATCH 01/26] M4: preserve vector region fills across .fig import and native rendering A filled area of a vector network can now carry its own paint stack. A region without paints of its own keeps drawing with the layer's fills, and a region with an explicitly empty list draws nothing, so the two cases stay distinguishable. The canvas, SVG and PDF output, boolean and hit-test geometry, vector editing and region operations all honour the per-region paints and keep them when regions are split, merged or reordered. The .fig importer resolves the individual paint stacks of a layer's filled areas through its normal paint mapping and hands them to the document builder as private mapper data. Public REST vectorNetwork JSON never consumes that data and its region paints are dropped, so only validated .fig input can set them. Invalid entries are skipped with an import note. Live style and variable links on individual regions and writing region paints in the experimental .fig exporter are not part of this change. --- crates/doc/src/boolean_hit_tests.rs | 25 +++- crates/doc/src/colors.rs | 3 + crates/doc/src/geometry.rs | 15 ++ crates/doc/src/lib.rs | 9 ++ crates/doc/src/library.rs | 5 + crates/doc/src/tests.rs | 30 +++- crates/doc/src/vector.rs | 7 +- crates/doc/src/vector_faces.rs | 2 +- crates/doc/src/vector_regions_tests.rs | 13 +- crates/engine/src/cmd/structure.rs | 22 ++- crates/engine/src/shapebuilder.rs | 4 + crates/engine/src/vector_behaviour_tests.rs | 63 ++++++++ crates/engine/src/vectoredit.rs | 3 + crates/engine/src/vectoredit/ops.rs | 14 +- crates/engine/src/vectoredit/regions.rs | 11 +- crates/engine/src/vectoredit/regions_tests.rs | 13 +- crates/format/src/fig/export.rs | 3 + crates/format/src/fig/export_tests.rs | 18 +++ crates/format/src/fig/map.rs | 57 ++++++- crates/format/src/fig/tail_tests.rs | 140 ++++++++++++++++++ crates/format/src/fig/tests.rs | 5 +- .../src/rest/import_regression_tests.rs | 16 ++ crates/format/src/rest/mod.rs | 25 +++- crates/pathops/src/lib.rs | 15 +- crates/pathops/src/ops_tests.rs | 21 ++- crates/pdf/src/draw.rs | 21 ++- crates/pdf/src/tests.rs | 31 +++- crates/render/src/draw.rs | 52 ++++++- crates/render/src/lod.rs | 3 + crates/render/src/lod_tests.rs | 15 +- crates/render/src/tests.rs | 77 +++++++++- crates/svg/src/lib.rs | 29 +++- crates/svg/src/tests.rs | 32 +++- docs/gaps.md | 2 +- 34 files changed, 753 insertions(+), 48 deletions(-) diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs index b826f202..9107a149 100644 --- a/crates/doc/src/boolean_hit_tests.rs +++ b/crates/doc/src/boolean_hit_tests.rs @@ -2,7 +2,7 @@ //! picks the group, and a double click goes one level deeper than the selection. use crate::geometry::{enter_target, select_target}; -use crate::{BoolOp, Document, NodeId, NodeKind}; +use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::Point; fn page(d: &Document) -> NodeId { @@ -40,6 +40,29 @@ fn hits_reach_operands_but_clicks_pick_the_group() { assert_eq!(listed, vec![f, g, b]); } +#[test] +fn ordinary_hit_uses_region_paints_without_layer_fill() { + let mut d = Document::new("region hit"); + let p = page(&d); + let network = VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)], + segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(), + regions: vec![ + Region { + loops: vec![vec![0, 1, 2, 3]], + winding: WindingRule::NonZero, + fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()) }, + ], + }; + let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap(); + d.get_mut(id).unwrap().fills.clear(); + assert!(d.node(id).unwrap().fills.is_empty()); + assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id)); + assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None); +} + #[test] fn double_clicks_go_one_level_deeper() { let (d, f, g, b, r1, _) = setup(); diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index d480f4ea..411470ef 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -57,6 +57,9 @@ impl Node { f(&mut prototype.background); } NodeKind::Text(t) => t.for_each_color_mut(f), + NodeKind::Vector { network } => { + network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f)) + } _ => {} } for it in &mut self.interactions { diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 06596ddc..85924e4e 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -68,6 +68,21 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { n.local_rect().inflate(tol_l, tol_l).contains(lp) } + NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()) => { + let rounded = network.rounded(); + let net = rounded.as_ref().unwrap_or(network); + if !outline { + for (i, region) in net.regions.iter().enumerate().rev() { + let paints = region.fills.as_deref().unwrap_or(&n.fills); + if paints.iter().any(|p| p.visible) && net.region_contains(i, lp) { + return true; + } + } + } + let path = net.stroke_path(); + let reach = (n.stroke.max_weight() / 2.0).max(tol_l); + path.bounding_box().inflate(reach, reach).contains(lp) && near_path(&path, lp, reach) + } _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)), } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 71c6da39..3b90cf29 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -836,6 +836,15 @@ impl Document { pt.source = m.0; } } + if let NodeKind::Vector { network } = &mut c.kind { + for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() { + if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind + && let Some(m) = map.get(&NodeId(pt.source)) + { + pt.source = m.0; + } + } + } self.reserve_ids(*new_id); self.nodes.insert(*new_id, Arc::new(c)); self.dirty.touch(*new_id); diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 35994bb0..842ee0c8 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator, out: &mut B /// Image hashes a node paints (fills, strokes, text and text run fills). pub fn node_images(n: &Node, out: &mut BTreeSet) { paint_images(n.fills.iter().chain(&n.strokes), out); + if let NodeKind::Vector { network } = &n.kind { + for r in &network.regions { + paint_images(r.fills.iter().flatten(), out); + } + } if let NodeKind::Text(t) = &n.kind { paint_images(t.style.fills.iter().flatten(), out); for r in &t.runs { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..056e4dc9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() { assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f)); } +#[test] +fn region_paints_participate_in_colors_images_and_pattern_remapping() { + let mut d = Document::new("region resources"); + let p = page(&d); + let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap(); + let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap(); + let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true); + network.regions[0].fills = Some(vec![ + figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()), + figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill), + figcraft_color::Paint::pattern(source.0), + ]); + let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap(); + assert!(used_images(&d).contains("region-image")); + let dup = d.duplicate_into(group, p, None).unwrap(); + let copied_children = d.node(dup).unwrap().children.clone(); + let copied_source = copied_children[0]; + let copied_vector = d.node(copied_children[1]).unwrap(); + let NodeKind::Vector { network } = &copied_vector.kind else { panic!() }; + let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() }; + assert_eq!(pattern.source, copied_source.0); + + let red = figcraft_color::Color::from_hex("FF0000").unwrap(); + d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c }); + let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() }; + assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK)); +} + #[test] fn click_target_rules() { let mut d = Document::new("x"); @@ -212,7 +240,7 @@ fn vector_network_paths() { let mut bad = VectorNetwork { vertices: vec![Vertex::new(0.0, 0.0)], segments: vec![Segment::line(0, 5)], - regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }], + regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None }], }; bad.sanitize(); assert!(bad.segments.is_empty()); diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7e..88c296f5 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -1,6 +1,7 @@ //! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus //! filled regions made of closed loops. +use figcraft_color::Paint; use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use figcraft_geom::{BezPath, Point, Vec2}; use serde::{Deserialize, Serialize}; @@ -94,6 +95,8 @@ pub struct Region { pub loops: Vec>, #[serde(default)] pub winding: WindingRule, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fills: Option>, } /// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again @@ -143,7 +146,7 @@ impl VectorNetwork { let mut regions = Vec::new(); if closed && n > 2 { segments.push(Segment::line(n - 1, 0)); - regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero }); + regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero, fills: None }); } Self { vertices, segments, regions } } @@ -240,7 +243,7 @@ impl VectorNetwork { } close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v); if !all_loops.is_empty() { - net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero }); + net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero, fills: None }); } net } diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs index 050ab821..f79049fc 100644 --- a/crates/doc/src/vector_faces.rs +++ b/crates/doc/src/vector_faces.rs @@ -30,7 +30,7 @@ impl Face { /// The fill region for this face. pub fn region(&self) -> Region { - Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } } + Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero }, fills: None } } /// Some point strictly inside the face (not in a hole). diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs index c09664b4..cbea9e2d 100644 --- a/crates/doc/src/vector_regions_tests.rs +++ b/crates/doc/src/vector_regions_tests.rs @@ -30,7 +30,18 @@ fn nested() -> VectorNetwork { } fn region(loops: Vec>, winding: WindingRule) -> Region { - Region { loops, winding } + Region { loops, winding, fills: None } +} + +#[test] +fn region_fills_default_to_inheritance_and_keep_explicit_empty() { + let legacy: Region = serde_json::from_value(serde_json::json!({"loops": []})).unwrap(); + assert_eq!(legacy.fills, None); + let empty = Region { fills: Some(Vec::new()), ..legacy }; + let value = serde_json::to_value(empty).unwrap(); + assert!(value["fills"].as_array().is_some_and(Vec::is_empty)); + let inherited = serde_json::to_value(region(Vec::new(), WindingRule::NonZero)).unwrap(); + assert!(inherited.get("fills").is_none()); } fn p(x: f64, y: f64) -> Point { diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..548aa4bb 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -305,6 +305,9 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let ids = req_targets("vector.flatten", s, p)?; let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); + if ids.iter().any(|i| subtree_has_region_paints(doc, *i, 0)) { + return Err(EngineError::Disabled("vector.flatten".into(), "cannot flatten a vector with region paints".into())); + } let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?; let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; let index = doc.index_in_parent(top).map(|i| i + 1); @@ -360,6 +363,14 @@ fn flatten(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": vid})) } +pub(crate) fn subtree_has_region_paints(doc: &Document, id: NodeId, depth: usize) -> bool { + let Some(n) = doc.node(id) else { return false }; + if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some())) { + return true; + } + depth < 128 && n.children.iter().any(|child| subtree_has_region_paints(doc, *child, depth + 1)) +} + fn outline_stroke(s: &mut Session, p: &Value) -> Result { let ids = req_targets("vector.outlineStroke", s, p)?; let mut out = Vec::new(); @@ -397,7 +408,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { v.fills = n.strokes.clone(); v.strokes.clear(); v.styles.fill = n.styles.stroke; - if n.fills.iter().any(|f| f.visible) && closed { + let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible)))); + if (n.fills.iter().any(|f| f.visible) || region_fill) && closed { // Keep the fill as its own layer under the outline, grouped. doc.insert(v, parent, idx.map(|x| x + 1))?; let fill_id = i; @@ -534,6 +546,9 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { fn simplify(s: &mut Session, p: &Value) -> Result { let tol = num_or(p, "tolerance", 0.5).max(0.01); for i in req_targets("vector.simplify", s, p)? { + if s.doc()?.node(i).is_some_and(|n| matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()))) { + return Err(EngineError::Disabled("vector.simplify".into(), "cannot simplify a vector with region paints".into())); + } let doc = s.doc_mut()?; let n = doc.get_mut(i)?; if let NodeKind::Vector { network } = &mut n.kind { @@ -554,6 +569,9 @@ fn simplify(s: &mut Session, p: &Value) -> Result { fn offset_path(s: &mut Session, p: &Value) -> Result { let d = req_num("vector.offset", p, "offset")?; for i in req_targets("vector.offset", s, p)? { + if s.doc()?.node(i).is_some_and(|n| matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()))) { + return Err(EngineError::Disabled("vector.offset".into(), "cannot offset a vector with region paints".into())); + } let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; @@ -594,7 +612,7 @@ fn join(s: &mut Session, p: &Value) -> Result { let last = network.vertices.len() - 1; network.segments.push(Segment::line(last, 0)); let all: Vec = (0..network.segments.len()).collect(); - network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero }); + network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None }); } } return ok(); diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs index 99d59b45..7738d8d6 100644 --- a/crates/engine/src/shapebuilder.rs +++ b/crates/engine/src/shapebuilder.rs @@ -324,6 +324,10 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { if merge && whole_shape { return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick})); } + // Rebuilding a shape from an area rebuilds its network, which would drop individual region paints. + if touched.iter().any(|i| s.doc().is_ok_and(|doc| crate::cmd::structure::subtree_has_region_paints(doc, *i, 0))) { + return Err(EngineError::Disabled(cmd.into(), "the shape builder cannot rebuild a vector with region paints".into())); + } let doc = s.doc_mut()?; let created = if merge { let area = figcraft_pathops::union_faces(regions.iter().map(|r| &r.path)).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 0c472ecc..33c01a5a 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -17,6 +17,69 @@ use crate::params::id_of; use crate::tools::{PointerEvent, PointerKind}; use crate::vectoredit::{VectorEdit, network}; +#[test] +fn region_paints_block_destructive_rebuilds_before_mutation() { + let mut s = Session::new(); + let id = square(&mut s, true); + { + let doc = s.doc_mut().unwrap(); + let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") }; + network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + } + let before = net(&s, id); + for (cmd, params) in + [("vector.simplify", json!({"ids": [id]})), ("vector.offset", json!({"ids": [id], "offset": 2})), ("vector.flatten", json!({"ids": [id]}))] + { + assert!( + matches!(s.execute(cmd, ¶ms), Err(crate::EngineError::Disabled(actual, reason)) if actual == cmd && reason.contains("region paints")), + "{cmd} must report the region-paint-loss guard" + ); + assert_eq!(net(&s, id), before, "{cmd} must leave network untouched"); + } + let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap(); + assert!( + matches!(s.execute("vector.flatten", &json!({"ids": [group]})), Err(crate::EngineError::Disabled(actual, reason)) if actual == "vector.flatten" && reason.contains("region paints")), + "flatten must report its descendant region-paint-loss guard" + ); + assert_eq!(net(&s, id), before, "descendant guard must leave network untouched"); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + assert!( + matches!(s.execute("vector.edit.paint", &json!({"x": 50, "y": 50})), Err(crate::EngineError::Disabled(actual, reason)) if actual == "vector.edit.paint" && reason.contains("region paints")), + "bucket must report its region-paint-loss guard" + ); + assert_eq!(net(&s, id), before, "bucket normalization must leave network untouched"); +} + +#[test] +fn region_paints_block_shape_builder_before_mutation() { + let mut s = Session::new(); + let a = square(&mut s, false); + let b = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"]).unwrap(); + { + let doc = s.doc_mut().unwrap(); + ex_to_vector(doc, a); + } + s.set_selection(vec![a, b]); + let before = net(&s, a); + for (cmd, params) in [("shapeBuilder.merge", json!({"points": [[75, 75]]})), ("shapeBuilder.remove", json!({"points": [[75, 75]]}))] { + assert!( + matches!(s.execute(cmd, ¶ms), Err(crate::EngineError::Disabled(actual, reason)) if actual == cmd && reason.contains("region paints")), + "{cmd} must report the region-paint-loss guard" + ); + assert_eq!(net(&s, a), before, "{cmd} must leave network untouched"); + assert!(s.doc().unwrap().node(b).is_some(), "{cmd} must not remove the other shape"); + } +} + +/// Give vector `id` a painted region, converting a plain shape to a vector first. +fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) { + let n = doc.get(id).unwrap(); + let (path, _) = figcraft_render::node_outline(doc, n).unwrap(); + let mut network = VectorNetwork::from_path(&path); + network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + doc.get_mut(id).unwrap().kind = NodeKind::Vector { network }; +} + fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index f923de80..852ad57f 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -1201,6 +1201,9 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result Result { + if doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()))) { + return Err(EngineError::Disabled("vector.edit.paint".into(), "paint bucket cannot normalize a vector with region paints".into())); + } let filled = edit(doc, id, |net| { let mut want = None; for p in pts { diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 935f033a..4793b9f4 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -586,7 +586,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork { let loops: Vec> = r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::>()).filter(|l| !l.is_empty()).collect(); if !loops.is_empty() { - out.regions.push(Region { loops, winding: r.winding }); + out.regions.push(Region { loops, ..r.clone() }); } } reopen_loops(&mut out); @@ -648,7 +648,7 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb: let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s))); if unfilled { lp.insert(0, si); - net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero }); + net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None }); } } Some(si) @@ -862,6 +862,14 @@ mod tests { VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false) } + #[test] + fn split_part_keeps_region_paint_metadata() { + let mut n = square(); + n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + let part = part(&n, &[0, 1, 2, 3]); + assert_eq!(part.regions[0].fills, n.regions[0].fills); + } + #[test] fn handles_follow_mirroring() { let mut n = open_line(); @@ -899,7 +907,7 @@ mod tests { // Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards. let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false); n.segments.push(Segment::line(0, 2)); - n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero }); + n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero, fills: None }); let p = Point::new(25.0, 10.0); assert!(n.fill_contains(p)); for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] { diff --git a/crates/engine/src/vectoredit/regions.rs b/crates/engine/src/vectoredit/regions.rs index 111c8e11..91763d3a 100644 --- a/crates/engine/src/vectoredit/regions.rs +++ b/crates/engine/src/vectoredit/regions.rs @@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option { (s.is_finite() && s > 0.0).then_some(s) } -/// Whether vector `n` shows a fill (only filled areas are regions). -fn filled(n: &figcraft_doc::Node) -> bool { - n.fills.iter().any(|f| f.visible) +fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool { + net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible)) } /// The network as its fill draws it: rounded points drawn in (region indexes are kept). @@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio return None; } let net = network(doc, ve.id)?; - if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) { + let node = doc.node(ve.id)?; + if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) { return None; } let w = doc.world_transform(ve.id); @@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) { return None; } - drawn(net).region_at(lp) + let drawn = drawn(net); + (0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp)) } /// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once. diff --git a/crates/engine/src/vectoredit/regions_tests.rs b/crates/engine/src/vectoredit/regions_tests.rs index f0e5768e..9ca57bd9 100644 --- a/crates/engine/src/vectoredit/regions_tests.rs +++ b/crates/engine/src/vectoredit/regions_tests.rs @@ -31,7 +31,7 @@ fn nested(regions: Vec) -> VectorNetwork { } fn region(loops: Vec>, winding: WindingRule) -> Region { - Region { loops, winding } + Region { loops, winding, fills: None } } /// The 7.1 region: both squares as one region. @@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() { assert!(ve(&s).regions.is_empty()); } +#[test] +fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() { + let mut n = overlapping(); + n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + n.regions[1].fills = Some(Vec::new()); + let mut s = Session::new(); + let id = editing_vector(&mut s, n); + s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear(); + assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0)); +} + #[test] fn hover_and_press_select_a_region_with_all_its_points() { // 20. diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..fc431229 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1603,6 +1603,9 @@ impl<'d> Exporter<'d> { /// The editable network (with per-point corner radii, handle mirroring and end caps) and, /// when it has filled areas, their outline. fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) { + if net.regions.iter().any(|region| region.fills.is_some()) { + self.warn("vector region paints were left out of the .fig export"); + } let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK { self.warn("vector networks too large for the format were written as outlines"); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 67215b0c..7d721594 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -810,6 +810,24 @@ fn round_trip_keeps_the_details() { assert_eq!(label.characters, "Hi"); } +#[test] +fn fig_export_warns_when_region_paints_are_left_out() { + let mut doc = generate(3); + let Some(id) = doc.nodes().find_map(|node| match &node.kind { + NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id), + _ => None, + }) else { + panic!("generated document has no filled vector"); + }; + let node = doc.node_mut(id).unwrap(); + let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") }; + let region = network.regions.first_mut().unwrap(); + region.fills = Some(Vec::new()); + + let (_, warnings) = export(&doc, None).unwrap(); + assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}"); +} + #[test] fn written_files_decode_and_list_parents_first() { for seed in 0..20 { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..065c933c 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1234,11 +1234,50 @@ impl<'a> Mapper<'a> { .collect(); let segments: Vec = net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); - if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { - self.warn("filled areas of a vector with their own fill use the layer's fill"); - } + let mut region_paint_elements = 0usize; + let regions: Vec = net + .regions + .iter() + .map(|r| { + let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }}); + if r.style != 0 { + match style(r.style) { + Some(s) => { + if let Some(fills) = s.get("fillPaints") { + if let Kv::Array(source_paints) = fills { + let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| { + cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len()) + }); + let oversized_stops = + source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256); + if source_paints.len() > 256 || oversized_stops || region_paint_elements.saturating_add(paint_cost) > 200_000 { + self.warn("vector region paint data exceeded import limits and was skipped"); + region_paint_elements = 200_000; + return region; + } + region_paint_elements += paint_cost; + let paints: Vec = source_paints.iter().filter_map(paint).collect(); + if paints.len() != source_paints.len() { + self.warn("invalid vector region paints were skipped"); + } + if paints.iter().any(has_bound_variables) { + self.warn("variable-bound vector region paints keep resolved appearance; live bindings were left out"); + } + if s.get("styleIdForFill").is_some() { + self.warn("linked vector region fill styles keep resolved appearance; live style links were left out"); + } + region["_figRegionFills"] = Value::Array(paints); + } else { + self.warn("invalid vector region fill paint list; layer fill used"); + } + } + } + None => self.warn("vector region references a missing fill style; layer fill used"), + } + } + region + }) + .collect(); let join = v.str("strokeJoin"); if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) { self.warn("points with their own stroke join use the layer's"); @@ -1995,6 +2034,14 @@ fn paint(p: &Kv) -> Option { Some(Value::Object(o)) } +fn has_bound_variables(value: &Value) -> bool { + match value { + Value::Object(object) => object.contains_key("boundVariables") || object.values().any(has_bound_variables), + Value::Array(values) => values.iter().any(has_bound_variables), + _ => false, + } +} + /// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST /// `boundVariables`: `fields` pairs the format's field with the REST name. fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option { diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..7f16ad1d 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -231,6 +231,146 @@ fn tail_canvas() -> Vec { canvas(&schema(), &tail_message()) } +fn region_canvas(fill_paints: Option>>, style_id: u32) -> Vec { + 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 vector_data = field_mut(wave, "vectorData").unwrap(); + let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() }; + let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() }; + if let Some(fill_paints) = fill_paints { + fields.push(("fillPaints", arr(fill_paints))); + } + let (mut network, _) = network::decode_styled(&square_blob()).unwrap(); + network.regions[0].style = style_id; + let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() }; + let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap(); + *bytes = Kv::Bytes(network::encode(&network)); + canvas(&schema(), &message) +} + +fn field_mut<'a, 's>(value: &'a mut Kv<'s>, name: &str) -> Option<&'a mut Kv<'s>> { + if let Kv::Object(fields) = value { + return fields.iter_mut().find(|(key, _)| *key == name).map(|(_, field)| field); + } + None +} + +#[test] +fn vector_region_fills_import_from_style_table() { + let solid = o(vec![ + ("type", e("SOLID")), + ("color", o(vec![("r", f(0.2)), ("g", f(0.4)), ("b", f(0.8)), ("a", f(0.5))])), + ("opacity", f(0.5)), + ("visible", Kv::Bool(false)), + ]); + let gradient = o(vec![ + ("type", e("GRADIENT_LINEAR")), + ( + "stops", + arr(vec![ + o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]), + o(vec![("position", f(1.0)), ("color", o(vec![("r", f(0.0)), ("g", f(0.0)), ("b", f(1.0)), ("a", f(0.25))]))]), + ]), + ), + ]); + let image_hash = vec![0x12, 0x34, 0x56]; + let image = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(image_hash.clone()))])), ("imageScaleMode", e("FILL"))]); + let wire = region_canvas(Some(vec![solid, gradient, image]), 1); + let (doc, warnings) = import(&wire, "Region fills").unwrap(); + let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap(); + let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") }; + assert_eq!((color.r, color.g, color.b, fills[0].opacity, fills[0].visible), (0.2, 0.4, 0.8, 0.25, false)); + assert!(matches!(&fills[1].kind, figcraft_doc::color::PaintKind::Gradient(g) if g.stops.len() == 2 && g.stops[1].color.a == 0.25)); + let figcraft_doc::color::PaintKind::Image(imported_image) = &fills[2].kind else { panic!("{fills:?}") }; + let image_key = imported_image.image.clone(); + assert!(doc.images.contains_key(&image_key), "image reference must retain its image placeholder"); + assert!(!warnings.iter().any(|warning| warning.contains("their own fill use the layer's fill")), "{warnings:?}"); + + let saved = crate::save_bytes(&doc, None).unwrap(); + let (reopened, _) = crate::load_bytes(&saved).unwrap(); + assert_eq!(network_of(&reopened, "Wave").regions[0].fills, Some(fills.clone())); + assert!(reopened.images.contains_key(&image_key), "image reference must survive native save/reopen"); +} + +#[test] +fn vector_region_missing_and_empty_paints_remain_distinct() { + let (inherited, _) = import(®ion_canvas(None, 1), "Inherited region").unwrap(); + assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None); + let (empty, _) = import(®ion_canvas(Some(Vec::new()), 1), "Empty region").unwrap(); + assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new())); +} + +#[test] +fn vector_regions_keep_distinct_solid_paints() { + 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 vector_data = field_mut(wave, "vectorData").unwrap(); + let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() }; + let Kv::Object(first_style) = styles.first_mut().unwrap() else { panic!() }; + first_style.push(("fillPaints", arr(vec![solid(0.1, 0.2, 0.9)]))); + let Kv::Object(second_style) = o(vec![("styleID", u(2)), ("fillPaints", arr(vec![solid(0.9, 0.1, 0.2)]))]) else { panic!() }; + styles.push(Kv::Object(second_style)); + let (mut network, _) = network::decode_styled(&square_blob()).unwrap(); + network.regions[0].style = 1; + network.regions.push(network::Region { style: 2, non_zero: network.regions[0].non_zero, loops: network.regions[0].loops.clone() }); + let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() }; + let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap(); + *bytes = Kv::Bytes(network::encode(&network)); + + let (doc, _) = import(&canvas(&schema(), &message), "Two region fills").unwrap(); + let regions = &network_of(&doc, "Wave").regions; + assert_eq!(regions.len(), 2); + let color = |index: usize| match ®ions[index].fills.as_ref().unwrap()[0].kind { + figcraft_doc::color::PaintKind::Solid { color } => (color.r, color.g, color.b), + paint => panic!("{paint:?}"), + }; + assert_eq!(color(0), (0.1, 0.2, 0.9)); + assert_eq!(color(1), (0.9, 0.1, 0.2)); +} + +#[test] +fn vector_region_gradient_limit_warns_instead_of_silently_truncating() { + for count in [256, 257] { + let stops = (0..count) + .map(|i| { + let value = if i + 1 == count { 1.0 } else { 0.0 }; + o(vec![ + ("position", f(i as f64 / (count - 1) as f64)), + ("color", o(vec![("r", f(value)), ("g", f(value)), ("b", f(value)), ("a", f(1.0))])), + ]) + }) + .collect(); + let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(stops))]); + let (doc, warnings) = import(®ion_canvas(Some(vec![gradient]), 1), "Bounded region gradient").unwrap(); + let fills = &network_of(&doc, "Wave").regions[0].fills; + if count == 256 { + let figcraft_doc::color::PaintKind::Gradient(g) = &fills.as_ref().unwrap()[0].kind else { panic!() }; + assert_eq!(g.stops.len(), 256); + assert_eq!(g.stops.last().unwrap().color.r, 1.0); + assert!(!warnings.iter().any(|w| w.contains("vector region paint data exceeded"))); + } else { + assert!(fills.is_none()); + assert!(warnings.iter().any(|w| w.contains("vector region paint data exceeded import limits")), "{warnings:?}"); + } + } +} + +#[test] +fn vector_region_invalid_paints_and_style_refs_warn() { + let malformed = o(vec![("opacity", f(0.5))]); + let unsupported = o(vec![("type", e("EMOJI"))]); + let (doc, warnings) = import(®ion_canvas(Some(vec![malformed, unsupported]), 1), "Invalid region paints").unwrap(); + assert_eq!(network_of(&doc, "Wave").regions[0].fills, Some(Vec::new())); + assert!(warnings.iter().any(|warning| warning.contains("invalid vector region paints were skipped")), "{warnings:?}"); + assert!(warnings.iter().any(|warning| warning.contains("EMOJI paints are not supported")), "{warnings:?}"); + + let (doc, warnings) = import(®ion_canvas(None, 9), "Missing region style").unwrap(); + assert_eq!(network_of(&doc, "Wave").regions[0].fills, None); + assert!(warnings.iter().any(|warning| warning.contains("vector region references a missing fill style")), "{warnings:?}"); +} + fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::VectorNetwork { match &named(doc, name).kind { NodeKind::Vector { network } => network, diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..015e6908 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -13,7 +13,8 @@ use super::*; /// Message field ids count from 1; enum values from 0. const SCHEMA: &str = " enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE -enum PaintType SOLID GRADIENT_LINEAR IMAGE +enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI +enum ImageScaleMode STRETCH FIT FILL TILE enum WindingRule NONZERO ODD enum StyleType NONE FILL TEXT enum StackMode NONE HORIZONTAL VERTICAL @@ -50,7 +51,7 @@ message ConditionalActions Action[] actions, VariableData condition message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition message VariableData AnyValue value, DataType dataType -message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar +message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode message ParentIndex GUID guid, string position message FontName string family, string style, string postscript message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs index 2d6364be..73b1b3bb 100644 --- a/crates/format/src/rest/import_regression_tests.rs +++ b/crates/format/src/rest/import_regression_tests.rs @@ -42,6 +42,22 @@ fn stroke_only_vector_shared_stroke_style_keeps_paints() { } } +#[test] +fn public_rest_ignores_region_fills_and_private_fig_handoff() { + let network = json!({ + "vertices": [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 10, "y": 10}], + "segments": [{"a": 0, "b": 1}, {"a": 1, "b": 2}, {"a": 2, "b": 0}], + "regions": [{"loops": [[0, 1, 2]], "winding": "nonZero", "fills": [{"type": "solid", "color": {"r": 0, "g": 1, "b": 0}}], + "_figRegionFills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0}}]}] + }); + let node = json!({"id": "1:1", "name": "Vector", "type": "VECTOR", "relativeTransform": at(0.0, 0.0), + "size": {"x": 10, "y": 10}, "vectorNetwork": network}); + let (doc, _) = import(&file(json!([node])), &no_images).unwrap(); + let vector = named(&doc, "Vector"); + let NodeKind::Vector { network } = &vector.kind else { panic!("{:#?}", vector.kind) }; + assert!(network.regions[0].fills.is_none()); +} + #[test] fn stroke_colour_variable_on_outlined_vector_is_kept() { let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}}); diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..99409ceb 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -645,7 +645,26 @@ impl<'a> Ctx<'a> { fn vector(&mut self, n: &mut Node, j: &Value) -> bool { if let Some(net) = j.get("vectorNetwork") { match network(net) { - Some(network) => { + Some(mut network) => { + // Private `.fig` mapper handoff only. Public REST `vectorNetwork` remains + // native network JSON; it never consumes the private mapper paint fields. + if self.fig_sources.is_some() + && let Some(regions) = net.get("regions").and_then(Value::as_array) + { + for (region, source) in network.regions.iter_mut().zip(regions) { + let Some(fills) = source.get("_figRegionFills") else { continue }; + let Some(list) = fills.as_array() else { + self.warn("invalid vector region paints were skipped"); + continue; + }; + let before = list.len(); + let wrapper = serde_json::json!({"fills": fills}); + region.fills = props::paints(self, &wrapper, "fills"); + if region.fills.as_ref().is_some_and(|parsed| parsed.len() < before) { + self.warn("invalid vector region paints were skipped"); + } + } + } n.kind = NodeKind::Vector { network }; return false; } @@ -1178,6 +1197,10 @@ fn network(v: &Value) -> Option { if !fits { return None; } + // Region paints come only from the validated `.fig` mapper handoff, never from public JSON. + for r in &mut net.regions { + r.fills = None; + } for p in &mut net.vertices { p.radius = p.radius.clamp(0.0, MAX_SIZE); } diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..02a523b1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -59,14 +59,14 @@ pub fn closed(path: &BezPath) -> BezPath { /// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input). pub use figcraft_geom::measure::finite_path as finite; -fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result { +fn binary_with_rule(a: &BezPath, b: &BezPath, rule: linesweeper::FillRule, op: linesweeper::BinaryOp) -> Result { if a.elements().is_empty() && b.elements().is_empty() { return Ok(BezPath::new()); } if !finite(a) || !finite(b) { return Err(PathOpsError::BadInput); } - let contours = linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput)?; + let contours = linesweeper::binary_op(&closed(a), &closed(b), rule, op).map_err(|_| PathOpsError::BadInput)?; let mut out = BezPath::new(); for c in contours.contours() { out.extend(c.path.elements().iter().copied()); @@ -74,11 +74,22 @@ fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result Result { + binary_with_rule(a, b, linesweeper::FillRule::NonZero, op) +} + /// Normalise a single path's winding (non-zero) into simple even-odd contours. pub fn simplify(path: &BezPath) -> Result { binary(path, &BezPath::new(), linesweeper::BinaryOp::Union) } +/// Normalize one path using its fill rule. Unlike [`faces`], this emits every contour without a +/// face-count cap, so render silhouettes preserve large imported networks. +pub fn simplify_with_rule(path: &BezPath, even_odd: bool) -> Result { + let rule = if even_odd { linesweeper::FillRule::EvenOdd } else { linesweeper::FillRule::NonZero }; + binary_with_rule(path, &BezPath::new(), rule, linesweeper::BinaryOp::Union) +} + /// Combine `acc` (the operands so far) with the next operand `p` by `op`. pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result { let op = match op { diff --git a/crates/pathops/src/ops_tests.rs b/crates/pathops/src/ops_tests.rs index f3b474f2..c982fbf3 100644 --- a/crates/pathops/src/ops_tests.rs +++ b/crates/pathops/src/ops_tests.rs @@ -2,10 +2,10 @@ //! agreement with a uniform operation, and single steps. use figcraft_doc::BoolOp; -use figcraft_geom::Rect; use figcraft_geom::kurbo::{BezPath, Shape}; +use figcraft_geom::{Point, Rect}; -use crate::{boolean, boolean_ops, boolean_step}; +use crate::{boolean, boolean_ops, boolean_step, simplify_with_rule}; fn sq(x: f64, y: f64, s: f64) -> BezPath { Rect::new(x, y, x + s, y + s).to_path(0.1) @@ -81,3 +81,20 @@ fn single_steps_and_edge_cases() { bad.line_to((1.0, 1.0)); assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err()); } + +#[test] +fn fill_rule_normalization_keeps_even_odd_holes_and_more_than_4096_contours() { + let mut ring = sq(0.0, 0.0, 20.0); + ring.extend(sq(5.0, 5.0, 10.0).elements().iter().copied()); + let ring = simplify_with_rule(&ring, true).unwrap(); + assert!(ring.winding(Point::new(2.0, 2.0)) != 0); + assert_eq!(ring.winding(Point::new(10.0, 10.0)), 0, "same-direction inner contour remains a hole"); + + let mut many = BezPath::new(); + for i in 0..4097 { + many.extend(sq(i as f64 * 2.0, 0.0, 1.0).elements().iter().copied()); + } + let all = simplify_with_rule(&many, false).unwrap(); + assert!(all.winding(Point::new(8192.5, 0.5)) != 0, "last contour survives beyond the old 4096-face cap"); + assert!((area(&all) - 4097.0).abs() < 1e-6); +} diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index 99f4e390..c702aa25 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -317,8 +317,25 @@ impl<'a> Exporter<'a> { if let Some((path, r)) = &outline && closed { - for p in &n.fills { - self.fill(s, path, rule(*r), p, w, h, xf); + if let NodeKind::Vector { network } = &n.kind + && network.regions.iter().any(|r| r.fills.is_some()) + { + let rounded = network.rounded(); + let net = rounded.as_ref().unwrap_or(network); + for (i, region) in net.regions.iter().enumerate() { + let paints = region.fills.as_deref().unwrap_or(&n.fills); + if let Some(region_path) = net.region_path(i) { + let fill_rule = + if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::NonZero }; + for p in paints { + self.fill(s, ®ion_path, fill_rule, p, w, h, xf); + } + } + } + } else { + for p in &n.fills { + self.fill(s, path, rule(*r), p, w, h, xf); + } } } self.place_inner(s, inner); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 3656cf1e..6ab29b1e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::*; use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImageScaleMode, Paint}; -use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, StrokeAlign, TextData}; +use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, Region, Segment, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule}; pub(crate) fn page_count(pdf: &[u8]) -> usize { let s = String::from_utf8_lossy(pdf); @@ -51,6 +51,35 @@ pub(crate) fn has_inter() -> bool { figcraft_text::find_face("Inter", "Regular").is_some() } +#[test] +fn pdf_vector_region_paints_emit_independent_colors() { + let mut d = Document::new("regions"); + let p = d.first_page().unwrap(); + let network = VectorNetwork { + vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)] + .into_iter() + .map(|(x, y)| Vertex::new(x, y)) + .collect(), + segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(), + regions: vec![ + Region { + loops: vec![vec![0, 1, 2, 3]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]), + }, + Region { + loops: vec![vec![4, 5, 6, 7]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()) }, + ], + }; + let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap(); + let content = inflated(&export_pdf(&d, &[id], &PdfOptions::default()).unwrap()); + assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}"); +} + /// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text. pub(crate) fn sample_doc() -> (Document, Vec) { let mut d = Document::new("sample"); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..f003a198 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -230,6 +230,27 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F Some((p, FillRule::Winding)) } NodeKind::Vector { network } => { + if network.regions.iter().any(|r| r.fills.is_some()) { + let rounded = network.rounded(); + let net = rounded.as_ref().unwrap_or(network); + let mut areas = BezPath::new(); + for (i, region) in net.regions.iter().enumerate() { + let paints = region.fills.as_deref().unwrap_or(&n.fills); + if !paints.iter().any(|p| p.visible) { + continue; + } + let Some(path) = net.region_path(i) else { continue }; + let even_odd = region.winding == figcraft_doc::WindingRule::EvenOdd; + if let Ok(normalized) = figcraft_pathops::simplify_with_rule(&path, even_odd) { + areas.extend(normalized.elements().iter().copied()); + } else { + // Invalid geometry still gets the renderer's best-effort path. + areas.extend(path.elements().iter().copied()); + } + } + let silhouette = figcraft_pathops::simplify(&areas).unwrap_or(areas); + return Some((silhouette, FillRule::Winding)); + } let rule = if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding }; Some((network.fill_path(), rule)) @@ -635,14 +656,31 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf); let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty(); + let styled_network = match &n.kind { + NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()) => Some(network), + _ => None, + }; // The outline fills, clips children to rounded corners and strokes booleans. - let outline = if splatted && !(clips && n.corners.uniform() != Some(0.0)) { None } else { node_outline(doc, n) }; - if !splatted - && let Some((path, rule)) = &outline - && closed - && let Some(tp) = ts_path(path) - { - fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None); + let outline = + if styled_network.is_some() || (splatted && !(clips && n.corners.uniform() != Some(0.0))) { None } else { node_outline(doc, n) }; + if !splatted && closed { + if let Some(network) = styled_network { + let rounded = network.rounded(); + let net = rounded.as_ref().unwrap_or(network); + for (i, region) in net.regions.iter().enumerate() { + let paints = region.fills.as_deref().unwrap_or(&n.fills); + if let Some(path) = net.region_path(i) + && let Some(tp) = ts_path(&path) + { + let rule = if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::Winding }; + fill_with(pix, &tp, paints, w, h, xf, rule, ctx, None); + } + } + } else if let Some((path, rule)) = &outline + && let Some(tp) = ts_path(path) + { + fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None); + } } if let Some(f) = over_fills { f(pix); diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index 6d742585..6cbfea24 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -63,6 +63,9 @@ pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool { if depth > MAX_DEPTH { return false; } + if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some())) { + return false; + } let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_)); let own = match &n.kind { NodeKind::Text(t) => text_paints(n, t).any(image), diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index de8b52b4..9ed6c65e 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Once}; use figcraft_color::{Color, ImageScaleMode, Paint}; -use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData}; +use figcraft_doc::{Document, ImageData, NodeId, NodeKind, Region, TextData, VectorNetwork}; use figcraft_geom::{Affine, Rect}; use crate::retained_tests::assert_same; @@ -110,6 +110,19 @@ fn lod_is_off_unless_asked_for() { assert!(!Options::default().lod); } +#[test] +fn region_paints_disable_vector_and_ancestor_splatting() { + let mut d = Document::new("region LOD"); + let page = d.first_page().unwrap(); + let parent = d.create(page, NodeKind::Frame, "Parent", 0.0, 0.0, 4.0, 4.0).unwrap(); + let mut network = VectorNetwork::default(); + network.regions.push(Region { loops: Vec::new(), winding: Default::default(), fills: Some(Vec::new()) }); + let vector = d.create(parent, NodeKind::Vector { network }, "Styled", 0.0, 0.0, 2.0, 2.0).unwrap(); + let node = d.node(vector).unwrap(); + assert!(!lod::splattable(&d, node, 0)); + assert!(!lod::splattable(&d, d.node(parent).unwrap(), 0)); +} + #[test] fn zoomed_out_previews_are_close_to_exact() { let (d, p) = cards(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55488153..ae0925df 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,6 +1,6 @@ use super::*; use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint}; -use figcraft_doc::{Effect, EffectKind, NodeKind, Shadow, StrokeAlign, TextData}; +use figcraft_doc::{Effect, EffectKind, NodeKind, Region, Segment, Shadow, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule}; fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { let i = ((y * p.width() + x) * 4) as usize; @@ -28,6 +28,81 @@ fn solid_rect_and_page_background() { assert_eq!(px(&pix, 5, 5), [0xF5, 0xF5, 0xF5, 255]); } +#[test] +fn vector_regions_use_their_own_paints_and_empty_regions_do_not_erase() { + let mut d = figcraft_doc::Document::new("regions"); + let page = d.first_page().unwrap(); + let network = VectorNetwork { + vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)] + .into_iter() + .map(|(x, y)| Vertex::new(x, y)) + .collect(), + segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(), + regions: vec![ + Region { + loops: vec![vec![0, 1, 2, 3]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]), + }, + Region { + loops: vec![vec![4, 5, 6, 7]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(vec![]) }, + ], + }; + let id = d.create(page, NodeKind::Vector { network }, "V", 10.0, 10.0, 20.0, 10.0).unwrap(); + d.get_mut(id).unwrap().fills.clear(); + let pix = render_view(&d, page, &view(40, 30), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap(); + assert_eq!(px(&pix, 15, 15), [255, 0, 0, 255]); + assert_eq!(px(&pix, 25, 15), [0, 0, 255, 255]); + assert_eq!(d.get(id).unwrap().fills.len(), 0); +} + +#[test] +fn styled_vector_mask_unions_region_windings_and_excludes_unpainted_regions() { + let mut d = figcraft_doc::Document::new("region mask"); + let page = d.first_page().unwrap(); + let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 60.0, 20.0).unwrap(); + let mut network = VectorNetwork::default(); + let mut regions = Vec::new(); + for x in [0.0, 10.0, 30.0, 40.0, 50.0] { + let v = network.vertices.len(); + network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y))); + let s = network.segments.len(); + network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4))); + let fills = match regions.len() { + 0 => Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]), + 1 => Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]), + 2 => Some(vec![Paint { visible: false, ..Paint::solid(Color::from_hex("00FF00").unwrap()) }]), + 3 => None, + _ => Some(Vec::new()), + }; + regions.push(Region { + loops: vec![(s..s + 4).collect()], + winding: if regions.len() == 1 { WindingRule::EvenOdd } else { WindingRule::NonZero }, + fills, + }); + } + network.regions = regions; + let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 60.0, 20.0).unwrap(); + { + let n = d.get_mut(mask).unwrap(); + n.fills.clear(); + n.is_mask = true; + n.mask_type = figcraft_doc::MaskType::Vector; + } + let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 60.0, 20.0).unwrap(); + d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let pix = render_view(&d, page, &view(60, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap(); + assert_eq!(px(&pix, 5, 10), [0, 0, 0, 255], "visible left region masks"); + assert_eq!(px(&pix, 15, 10), [0, 0, 0, 255], "overlap remains in union despite mixed winding"); + assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255], "hidden region excluded from styled silhouette"); + assert_eq!(px(&pix, 45, 10), [255, 255, 255, 255], "unpainted inherited region excluded"); + assert_eq!(px(&pix, 55, 10), [255, 255, 255, 255], "explicit-empty region excluded"); +} + #[test] fn zoom_scales() { let (d, p, _) = doc_with_rect(Color::BLACK); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a6813017..8f853b92 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -447,11 +447,30 @@ impl W<'_> { } else if let Some((path, rule)) = &outline && closed { - let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; - for p in &n.fills { - if let Some(a) = self.paint_attr(p, w, h) { - let el = format!("", matrix(xf), path_d(path)); - self.body.push_str(&clipped(&paint_clip, el)); + if let NodeKind::Vector { network } = &n.kind + && network.regions.iter().any(|r| r.fills.is_some()) + { + let rounded = network.rounded(); + let net = rounded.as_ref().unwrap_or(network); + for (i, region) in net.regions.iter().enumerate() { + let paints = region.fills.as_deref().unwrap_or(&n.fills); + let fr = if region.winding == figcraft_doc::WindingRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + if let Some(region_path) = net.region_path(i) { + for p in paints { + if let Some(a) = self.paint_attr(p, w, h) { + let el = format!("", matrix(xf), path_d(®ion_path)); + self.body.push_str(&clipped(&paint_clip, el)); + } + } + } + } + } else { + let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + for p in &n.fills { + if let Some(a) = self.paint_attr(p, w, h) { + let el = format!("", matrix(xf), path_d(path)); + self.body.push_str(&clipped(&paint_clip, el)); + } } } } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index b15cd999..866e4cb0 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -1,6 +1,6 @@ use super::*; use figcraft_color::{Color, Paint}; -use figcraft_doc::{Document, Effect, NodeKind}; +use figcraft_doc::{Document, Effect, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; #[test] fn export_basic_shapes() { @@ -23,6 +23,36 @@ fn export_basic_shapes() { assert!(d2.validate().is_empty()); } +#[test] +fn export_vector_region_paints_independently() { + let mut d = Document::new("regions"); + let p = d.first_page().unwrap(); + let network = VectorNetwork { + vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)] + .into_iter() + .map(|(x, y)| Vertex::new(x, y)) + .collect(), + segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(), + regions: vec![ + Region { + loops: vec![vec![0, 1, 2, 3]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]), + }, + Region { + loops: vec![vec![4, 5, 6, 7]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()) }, + ], + }; + let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap(); + let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap(); + assert!(svg.contains("#FF0000") && svg.contains("#0000FF"), "{svg}"); + assert_eq!(svg.matches(" Date: Mon, 26 Oct 2026 21:33:56 +0100 Subject: [PATCH 02/26] M4: preserve inherited region paints and linked fill styles --- crates/doc/src/boolean_hit_tests.rs | 16 +++ crates/doc/src/geometry.rs | 2 +- crates/engine/src/cmd/structure.rs | 76 ++++++++----- crates/engine/src/vector_behaviour_tests.rs | 112 ++++++++++++++++++-- crates/format/src/fig/export.rs | 2 +- crates/format/src/fig/export_tests.rs | 27 +++++ crates/format/src/fig/map.rs | 14 ++- crates/format/src/fig/tail_tests.rs | 60 +++++++++++ crates/render/src/draw.rs | 2 +- crates/render/src/tests.rs | 37 +++++++ 10 files changed, 306 insertions(+), 42 deletions(-) diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs index 6ecbd899..5a8a0112 100644 --- a/crates/doc/src/boolean_hit_tests.rs +++ b/crates/doc/src/boolean_hit_tests.rs @@ -64,6 +64,22 @@ fn ordinary_hit_uses_region_paints_without_layer_fill() { assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None); } +#[test] +fn inherited_paint_frames_use_per_region_hit_testing() { + let mut d = Document::new("inherited region hit"); + let p = page(&d); + let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true); + network.regions[0].winding = WindingRule::EvenOdd; + network.regions.push(network.regions[0].clone()); + let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel"); + let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") }; + network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY }); + assert!(network.regions.iter().all(|region| region.fills.is_none())); + assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently"); + assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None); +} + #[test] fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() { let mut d = Document::new("stroke selection"); diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 19c11d81..0e06d294 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -74,7 +74,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { n.local_rect().inflate(tol_l, tol_l).contains(lp) } - NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()) => { + NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => { let rounded = network.rounded(); let net = rounded.as_ref().unwrap_or(network); if !outline { diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index a1e847bc..2c68f03a 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -387,9 +387,19 @@ fn flatten(s: &mut Session, p: &Value) -> Result { .as_ref() .filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty()) .map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]); + let output_fills = first + .as_ref() + .map(|node| { + if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() { + fallback_fills.clone().unwrap_or_default() + } else { + node.fills.clone() + } + }) + .unwrap_or_default(); let mut net = VectorNetwork::default(); for id in &ids { - collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &mut net, 0)?; + collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?; } let Some(bb) = net.bounds() else { return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); @@ -400,11 +410,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); v.transform = Affine::translate((bb.x0, bb.y0)); if let Some(f) = first { - v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { - vec![Paint::solid(Color::DEFAULT_SHAPE)] - } else { - f.fills.clone() - }; + v.fills = output_fills; v.strokes = f.strokes.clone(); v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() }; v.effects = f.effects.clone(); @@ -424,6 +430,7 @@ fn collect_flatten_parts( id: NodeId, parent_world: Affine, fallback_fills: Option<&[Paint]>, + output_fills: &[Paint], net: &mut VectorNetwork, depth: usize, ) -> Result<()> { @@ -438,17 +445,15 @@ fn collect_flatten_parts( && node.fills.iter().any(|paint| paint.visible) && let Some(path) = figcraft_doc::geometry::shape_path(node) { - let mut frame = VectorNetwork::from_path(&path); - for region in &mut frame.regions { - region.fills = Some(node.fills.clone()); - } - append_flatten_part(doc, id, node, parent_world, fallback_fills, frame, net); + let frame = VectorNetwork::from_path(&path); + let into_parent = parent_world.inverse() * doc.world_transform(id); + append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net); } for child in &node.children { if !doc.node(*child).is_some_and(|child| child.visible) { continue; } - collect_flatten_parts(doc, *child, parent_world, fallback_fills, net, depth + 1)?; + collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?; } return Ok(()); } @@ -473,29 +478,30 @@ fn collect_flatten_parts( if rule == figcraft_render::FillRule::EvenOdd { region.winding = WindingRule::EvenOdd; } - region.fills = Some(if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills).to_vec() } else { node.fills.clone() }); } part } }; - append_flatten_part(doc, id, node, parent_world, fallback_fills, part, net); + let into_parent = parent_world.inverse() * doc.world_transform(id); + append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net); Ok(()) } fn append_flatten_part( - doc: &Document, - id: NodeId, node: &Node, - parent_world: Affine, fallback_fills: Option<&[Paint]>, + output_fills: &[Paint], + into_parent: Affine, mut part: VectorNetwork, net: &mut VectorNetwork, ) { - let into_parent = parent_world.inverse() * doc.world_transform(id); for region in &mut part.regions { let (width, height, paint_transform) = region.paint_frame(node.width, node.height); if region.fills.is_none() { - region.fills = Some(if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills).to_vec() } else { node.fills.clone() }); + let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills }; + if effective != output_fills { + region.fills = Some(effective.to_vec()); + } } region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform }); } @@ -700,11 +706,28 @@ fn simplify(s: &mut Session, p: &Value) -> Result { if let NodeKind::Vector { network } = &mut n.kind { let source = network.clone(); let mut net = VectorNetwork::default(); - for (index, region) in source.regions.iter().enumerate() { - let Some(path) = source.region_path(index) else { continue }; + let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()); + if legacy && !source.regions.is_empty() { + let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) { + WindingRule::EvenOdd + } else { + WindingRule::NonZero + }; + let path = source.fill_path(); let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default()); - let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path); - append_region_path(&mut net, &path, region, region.winding); + let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) { + Ok(path) => (path, WindingRule::EvenOdd), + Err(_) => (path, winding), + }; + let combined = Region { winding: output_winding, ..Default::default() }; + append_region_path(&mut net, &path, &combined, output_winding); + } else { + for (index, region) in source.regions.iter().enumerate() { + let Some(path) = source.region_path(index) else { continue }; + let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default()); + let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path); + append_region_path(&mut net, &path, region, region.winding); + } } if !source.regions.is_empty() { let mut used = vec![false; source.segments.len()]; @@ -803,18 +826,15 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height); for region in &mut part.regions { region.winding = WindingRule::EvenOdd; - region.fills = Some(n.fills.clone()); + region.fills = source.regions[0].fills.clone(); region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform }); } append_network(&mut net, part); } else { for (index, region) in source.regions.iter().enumerate() { let Some(path) = source.region_path(index) else { continue }; - let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height); let mut paint_region = region.clone(); - if paint_region.fills.is_none() { - paint_region.fills = Some(n.fills.clone()); - } + let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height); paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform }); let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd) .map_err(|error| bad("vector.offset", error.to_string()))?; diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index f556648c..785a03d0 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -7,9 +7,9 @@ // The tables read best as tuples. #![allow(clippy::type_complexity)] -use figcraft_doc::{HandleMirroring, NodeId, NodeKind, VectorNetwork}; +use figcraft_doc::{HandleMirroring, NodeId, NodeKind, Region, Segment, VectorNetwork, WindingRule}; use figcraft_geom::kurbo::ParamCurve; -use figcraft_geom::{Point, Shape}; +use figcraft_geom::{BezPath, Point, Shape}; use serde_json::{Value, json}; use crate::Session; @@ -100,17 +100,70 @@ fn region_paints_survive_simplify_offset_and_bucket_normalization() { network.regions[0].fills = None; } } - let inherited_fills = s.doc().unwrap().get(inherited_id).unwrap().fills.clone(); - let (old_width, old_height) = { - let node = s.doc().unwrap().get(inherited_id).unwrap(); - (node.width, node.height) - }; ex(&mut s, "vector.offset", json!({"ids": [inherited_id], "offset": 2})); let inherited_net = net(&s, inherited_id); let inherited = &inherited_net.regions[0]; - assert_eq!(inherited.fills, Some(inherited_fills), "inherited paints become explicit when the node box refits"); - let frame = inherited.paint_space.expect("refitted inherited paint has a frame"); - assert_eq!((frame.width, frame.height), (old_width, old_height), "paint coordinates retain the original node box"); + assert_eq!(inherited.fills, None, "inherited paints remain inherited after offset"); + let inherited_space = inherited.paint_space.expect("offset binds inherited paints to the original frame"); + assert_eq!((inherited_space.width, inherited_space.height), (100.0, 100.0)); + + let inherited_flatten = square(&mut s, false); + let inherited_fills = s.doc().unwrap().get(inherited_flatten).unwrap().fills.clone(); + ex_to_vector(s.doc_mut().unwrap(), inherited_flatten); + if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(inherited_flatten).unwrap().kind { + network.regions[0].fills = None; + } + let group = id_of(&ex(&mut s, "object.group", json!({"ids": [inherited_flatten]}))["id"]).unwrap(); + let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap(); + assert!( + net(&s, flattened).regions.iter().all(|region| region.fills.is_none()), + "flatten keeps inherited paints inherited when output node has same fills" + ); + assert_eq!(s.doc().unwrap().get(flattened).unwrap().fills, inherited_fills); + + let plain = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 60}))["id"]).unwrap(); + let plain_fills = s.doc().unwrap().get(plain).unwrap().fills.clone(); + let group = id_of(&ex(&mut s, "object.group", json!({"ids": [plain]}))["id"]).unwrap(); + let flattened_plain = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap(); + assert!(net(&s, flattened_plain).regions.iter().all(|region| region.fills.is_none()), "plain shapes inherit the flattened node's fill"); + assert_eq!(s.doc().unwrap().get(flattened_plain).unwrap().fills, plain_fills); + let edited_fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; + s.doc_mut().unwrap().get_mut(flattened_plain).unwrap().fills = edited_fills.clone(); + assert_eq!(net(&s, flattened_plain).regions[0].fills, None, "changing the flattened node fill updates inherited geometry"); + + let different_source = square(&mut s, false); + s.doc_mut().unwrap().get_mut(different_source).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; + ex_to_vector(s.doc_mut().unwrap(), different_source); + let source_fills = s.doc().unwrap().get(different_source).unwrap().fills.clone(); + if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(different_source).unwrap().kind { + network.regions[0].fills = None; + } + let group = id_of(&ex(&mut s, "object.group", json!({"ids": [different_source]}))["id"]).unwrap(); + s.doc_mut().unwrap().get_mut(group).unwrap().fills.clear(); + let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap(); + assert_eq!(net(&s, flattened).regions[0].fills, Some(source_fills), "flatten retains paints that differ from output node fills"); + + let legacy_gradient = square(&mut s, false); + let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK); + gradient.from = (0.2, 0.1); + gradient.to = (0.8, 0.9); + s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().fills = vec![figcraft_color::Paint::gradient(gradient)]; + ex_to_vector(s.doc_mut().unwrap(), legacy_gradient); + if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().kind { + network.regions[0].fills = None; + } + let before = net(&s, legacy_gradient).regions[0].paint_frame(100.0, 100.0).2; + let before_world = s.doc().unwrap().world_transform(legacy_gradient) * before; + ex(&mut s, "vector.offset", json!({"ids": [legacy_gradient], "offset": 2})); + let after_net = net(&s, legacy_gradient); + let after = after_net.regions[0] + .paint_frame(s.doc().unwrap().get(legacy_gradient).unwrap().width, s.doc().unwrap().get(legacy_gradient).unwrap().height) + .2; + let after_world = s.doc().unwrap().world_transform(legacy_gradient) * after; + assert!( + before_world.as_coeffs().iter().zip(after_world.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8), + "legacy offset preserves the gradient's world paint frame" + ); let empty_faces = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M200 0 L300 0 L300 100 L200 100 Z"}))["id"]).unwrap(); @@ -133,6 +186,45 @@ fn region_paints_survive_simplify_offset_and_bucket_normalization() { assert!(!s.doc().unwrap().get(empty_faces).unwrap().fills.is_empty(), "the painted target gets the layer default fill"); } +#[test] +fn legacy_simplify_combines_mixed_winding_regions() { + let mut s = Session::new(); + let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M25 25 L75 25 L75 75 L25 75 Z"}))["id"]).unwrap(); + let mut second_path = BezPath::new(); + second_path.move_to((50.0, 0.0)); + second_path.line_to((150.0, 0.0)); + second_path.line_to((150.0, 100.0)); + second_path.line_to((50.0, 100.0)); + second_path.close_path(); + let second = VectorNetwork::from_path(&second_path); + { + let doc = s.doc_mut().unwrap(); + let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") }; + network.regions[0].winding = WindingRule::EvenOdd; + let vertex_offset = network.vertices.len(); + let segment_offset = network.segments.len(); + network.vertices.extend(second.vertices); + network.segments.extend(second.segments.into_iter().map(|segment| Segment { + a: segment.a + vertex_offset, + b: segment.b + vertex_offset, + ..segment + })); + network.regions.extend(second.regions.into_iter().map(|region| Region { + loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + segment_offset).collect()).collect(), + ..region + })); + network.regions[1].winding = WindingRule::NonZero; + } + ex(&mut s, "vector.simplify", json!({"ids": [id]})); + let simplified = net(&s, id); + assert_eq!(simplified.regions.len(), 1, "legacy regions simplify as one combined fill"); + assert!(!simplified.fill_contains(Point::new(60.0, 10.0)), "overlap cancels under combined even-odd winding"); + assert!(!simplified.fill_contains(Point::new(40.0, 40.0)), "nested contour remains a hole"); + assert!(simplified.fill_contains(Point::new(120.0, 50.0)), "non-overlapping area remains filled"); + assert!(simplified.regions[0].fills.is_none()); + assert!(simplified.regions[0].paint_space.is_none()); +} + #[test] fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() { let mut s = Session::new(); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 75a0817a..f4c7c84f 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1776,7 +1776,7 @@ impl<'d> Exporter<'d> { /// The editable network (with per-point corner radii, handle mirroring and end caps) and, /// when it has filled areas, their outline. fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) { - if net.regions.iter().any(|region| region.fills.is_some()) { + if net.regions.iter().any(|region| region.fills.is_some() || region.paint_space.is_some()) { self.warn("vector region paints were left out of the .fig export"); } let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index a582d7c9..2bf443e7 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -842,6 +842,33 @@ fn fig_export_warns_when_region_paints_are_left_out() { assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}"); } +#[test] +fn fig_export_warns_when_inherited_region_paints_have_a_paint_frame() { + let mut doc = generate(3); + let id = doc + .nodes() + .find_map(|node| match &node.kind { + NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id), + _ => None, + }) + .unwrap(); + let node = doc.node_mut(id).unwrap(); + let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") }; + let region = network.regions.first_mut().unwrap(); + region.fills = None; + region.paint_space = Some(RegionPaintSpace { width: 20.0, height: 10.0, transform: Affine::translate((4.0, 8.0)) }); + + let (_, warnings) = export(&doc, None).unwrap(); + assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}"); + + let mut plain = doc; + let node = plain.node_mut(id).unwrap(); + let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") }; + network.regions[0].paint_space = None; + let (_, warnings) = export(&plain, None).unwrap(); + assert!(!warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}"); +} + #[test] fn written_files_decode_and_list_parents_first() { for seed in 0..20 { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index fde19db1..790f4b3e 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1820,7 +1820,19 @@ impl<'a> Mapper<'a> { if r.style != 0 { match style(r.style) { Some(s) => { - if let Some(fills) = s.get("fillPaints") { + let fills = s.get("fillPaints").and_then(|own_fills| { + if own_fills.list().iter().any(paint_bound) { + return Some(own_fills); + } + let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID") + .and_then(|id| self.nodes.get(&id).copied()) + .filter(|style| style.get("styleType").and_then(Kv::str).is_some()); + match linked_style { + Some(style) => style.get("fillPaints"), + None => Some(own_fills), + } + }); + if let Some(fills) = fills { if let Kv::Array(source_paints) = fills { let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| { cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len()) diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index adf2c8bd..82dc3dd2 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -282,6 +282,27 @@ fn region_canvas(fill_paints: Option>>, style_id: u32) -> Vec>, link_target: Option, linked_fill: Kv<'static>) -> Vec { + 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 vector_data = field_mut(wave, "vectorData").unwrap(); + let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() }; + let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() }; + if let Some(own) = own { + fields.push(("fillPaints", arr(vec![own]))); + } + if let Some(target) = link_target { + fields.push(("styleIdForFill", o(vec![("guid", g(target))]))); + } + nodes.push(node(50, None, "RECTANGLE", "Linked Fill", vec![("styleType", e("FILL")), ("fillPaints", arr(vec![linked_fill]))])); + let mut network = network::decode(&square_blob()).unwrap(); + network.regions[0].style = 1; + let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() }; + *field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network)); + canvas(&schema(), &message) +} + fn field_mut<'a, 's>(value: &'a mut Kv<'s>, name: &str) -> Option<&'a mut Kv<'s>> { if let Kv::Object(fields) = value { return fields.iter_mut().find(|(key, _)| *key == name).map(|(_, field)| field); @@ -334,6 +355,45 @@ fn vector_region_missing_and_empty_paints_remain_distinct() { assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new())); } +#[test] +fn vector_region_linked_fill_uses_style_paints_without_recording_definition() { + let linked = solid(0.9, 0.1, 0.2); + let own = solid(0.1, 0.2, 0.9); + let wire = region_canvas_with_link(Some(own), Some(50), linked); + let (doc, _) = import(&wire, "Linked region style").unwrap(); + let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap(); + let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") }; + assert_eq!((color.r, color.g, color.b), (0.9, 0.1, 0.2)); + assert!(doc.nodes().all(|node| node.name != "Linked Fill"), "style lookup must not add a style as a layer"); + let archive = Archive::open(&wire).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let (rest, _, _) = map::to_rest(&message, "Linked region style", crate::rest::MAX_NODES); + assert!(rest.get("styles").is_none(), "regional link must not populate the layer styles map"); +} + +#[test] +fn vector_region_variable_bound_own_fill_wins_over_linked_style() { + let own = + o(vec![("type", e("SOLID")), ("color", o(vec![("r", f(0.1)), ("g", f(0.2)), ("b", f(0.9)), ("a", f(1.0))])), ("colorVar", alias_data(41))]); + let (doc, _) = import(®ion_canvas_with_link(Some(own), Some(50), solid(0.9, 0.1, 0.2)), "Bound region style").unwrap(); + let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap(); + let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") }; + assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9)); +} + +#[test] +fn vector_region_missing_fills_inherit_and_unresolved_link_keeps_own_fill() { + let (inherited, _) = import(®ion_canvas_with_link(None, Some(50), solid(0.9, 0.1, 0.2)), "Inherited linked region").unwrap(); + assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None); + + let own = solid(0.1, 0.2, 0.9); + let (fallback, _) = import(®ion_canvas_with_link(Some(own), Some(999), solid(0.9, 0.1, 0.2)), "Unresolved linked region").unwrap(); + let fills = network_of(&fallback, "Wave").regions[0].fills.as_ref().unwrap(); + let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") }; + assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9)); +} + #[test] fn vector_regions_keep_distinct_solid_paints() { let mut message = tail_message(); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 5fb52f0f..ed228881 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -326,7 +326,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F Some((p, FillRule::Winding)) } NodeKind::Vector { network } => { - if network.regions.iter().any(|r| r.fills.is_some()) { + if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) { let rounded = network.rounded(); let net = rounded.as_ref().unwrap_or(network); let mut areas = BezPath::new(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index be6ca9ac..f53f132e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -162,6 +162,43 @@ fn styled_vector_mask_unions_region_windings_and_excludes_unpainted_regions() { assert_eq!(px(&pix, 55, 10), [255, 255, 255, 255], "explicit-empty region excluded"); } +#[test] +fn inherited_paint_frames_keep_overlapping_regions_in_vector_masks() { + use figcraft_geom::{Affine, Point, Shape}; + + let mut d = Document::new("inherited region mask"); + let page = d.first_page().unwrap(); + let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 40.0, 20.0).unwrap(); + let mut network = VectorNetwork::default(); + for x in [0.0, 10.0] { + let v = network.vertices.len(); + let s = network.segments.len(); + network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y))); + network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4))); + network.regions.push(Region { + loops: vec![(s..s + 4).collect()], + winding: WindingRule::EvenOdd, + fills: None, + paint_space: Some(figcraft_doc::RegionPaintSpace { width: 20.0, height: 20.0, transform: Affine::translate((x, 0.0)) }), + }); + } + let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 30.0, 20.0).unwrap(); + let n = d.get_mut(mask).unwrap(); + n.fills = vec![Paint::solid(Color::BLACK)]; + n.is_mask = true; + n.mask_type = figcraft_doc::MaskType::Vector; + let (outline, rule) = node_outline(&d, d.node(mask).unwrap()).unwrap(); + assert_eq!(rule, FillRule::Winding); + assert_ne!(outline.winding(Point::new(15.0, 10.0)), 0, "independent inherited regions retain their overlap"); + let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 40.0, 20.0).unwrap(); + d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let pix = render_view(&d, page, &view(40, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap(); + for x in [5, 15, 25] { + assert_eq!(px(&pix, x, 10), [0, 0, 0, 255]); + } + assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255]); +} + #[test] fn zoom_scales() { let (d, p, _) = doc_with_rect(Color::BLACK); From 576d8e228ce8efd100cb1a2849c064f13955119f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:46:33 +0100 Subject: [PATCH 03/26] Cap Sketch image members by compression ratio and total size Found while testing crates/format/src/sketch/mod.rs:241-249 (Archive::image, MAX_IMAGE at :69), called once per distinct imageRef from crates/format/src/rest/mod.rs:951 (Ctx::image_key) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/sketch/mod.rs | 28 ++++++++++++++++++++++++-- crates/format/src/sketch/tree_tests.rs | 14 +++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 985541b1..cd0a6830 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -67,6 +67,15 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = 256 * 1024 * 1024; +/// Most bytes an image member may unpack to, per compressed byte (images are already compressed, +/// a zero-filled one packs a thousand times), with a floor for small members. +const MAX_IMAGE_RATIO: u64 = 100; +const MIN_IMAGE_ALLOWANCE: u64 = 16 * 1024 * 1024; +/// Most image bytes read from one file in all. +#[cfg(not(target_pointer_width = "32"))] +const MAX_IMAGE_TOTAL: u64 = 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_IMAGE_TOTAL: u64 = 256 * 1024 * 1024; /// Most pages read from one file. const MAX_PAGES: usize = 1000; @@ -182,6 +191,8 @@ pub(crate) struct Archive<'b> { /// Problems met while reading (pages skipped). pub warnings: Vec, zip: zip::ZipArchive>, + /// Image bytes read so far. + image_total: std::cell::Cell, } impl<'b> Archive<'b> { @@ -234,7 +245,7 @@ impl<'b> Archive<'b> { warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); } let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0); - Ok(Archive { document, version, pages, warnings, zip: z }) + Ok(Archive { document, version, pages, warnings, zip: z, image_total: std::cell::Cell::new(0) }) } /// The bytes of the image a layer references (`images/`). @@ -245,7 +256,20 @@ impl<'b> Archive<'b> { } // A clone shares the parsed directory and the bytes; the resolver only has shared access. let mut z = self.zip.clone(); - crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok() + let member = format!("images/{name}"); + let (size, packed) = { + let f = z.by_name(&member).ok()?; + (f.size(), f.compressed_size()) + }; + let allowed = MAX_IMAGE + .min(packed.saturating_mul(MAX_IMAGE_RATIO).max(MIN_IMAGE_ALLOWANCE)) + .min(MAX_IMAGE_TOTAL.saturating_sub(self.image_total.get())); + if size > allowed { + return None; + } + let data = crate::read_member(&mut z, &member, allowed).ok()?; + self.image_total.set(self.image_total.get() + data.len() as u64); + Some(data) } } diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index 5399a3df..738f6e40 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -158,6 +158,20 @@ fn oval_slice_and_bitmap() { assert_eq!(doc.images.len(), 1); } +#[test] +fn image_bombs_are_refused() { + // A valid 2×2 PNG followed by 40 MB of zeros packs to a few dozen kilobytes. + let mut png = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png.resize(png.len() + 40 * 1024 * 1024, 0); + let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/bomb.png"}); + let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![bitmap])], &[("images/bomb.png", &png)]); + let total: usize = doc.images.values().map(|i| i.bytes.len()).sum(); + assert!(total < 1024 * 1024, "{total} image bytes kept"); +} + #[test] fn unknown_class_is_a_warning() { let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0); From 213cc75ff22e79aa434b57033157851a642e2805 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:13:59 +0100 Subject: [PATCH 04/26] Fix looks_like_svg rejecting DOCTYPE with an internal subset Found while testing crates/svg/src/import/mod.rs:723-740 (looks_like_svg, the `')) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/import.rs | 41 +++++++++++++++++++++++++++- crates/svg/src/import/shape_tests.rs | 11 ++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index dc936260..ea9c321f 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -718,6 +718,45 @@ fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> { o } +/// Length of the `` declaration at the start of `s`, skipping `>` inside quotes and an +/// internal subset (`[ ... ]`). +fn doctype_end(s: &str) -> Option { + let mut quote = None; + let mut depth = 0u32; + let mut skip_to = 0; + for (i, c) in s.char_indices() { + if i < skip_to { + continue; + } + if quote.is_none() && depth > 0 { + // Comments and processing instructions in the subset may hold quotes and brackets. + let rest = &s[i..]; + let close = if rest.starts_with("") + } else if rest.starts_with("") + } else { + None + }; + if let Some(close) = close { + let len = rest.find(close)? + close.len(); + skip_to = i + len; + continue; + } + } + match (quote, c) { + (Some(q), _) if c == q => quote = None, + (Some(_), _) => {} + (None, '"' | '\'') => quote = Some(c), + (None, '[') => depth += 1, + (None, ']') => depth = depth.saturating_sub(1), + (None, '>') if depth == 0 => return Some(i + 1), + _ => {} + } + } + None +} + /// Whether `text` (from the clipboard, say) is an SVG document: ` bool { @@ -731,7 +770,7 @@ pub fn looks_like_svg(text: &str) -> bool { } else if s.starts_with("").map(|i| i + 3) } else if s.starts_with("').map(|i| i + 1) + doctype_end(s) } else { None }; diff --git a/crates/svg/src/import/shape_tests.rs b/crates/svg/src/import/shape_tests.rs index 5bad142f..71d0f508 100644 --- a/crates/svg/src/import/shape_tests.rs +++ b/crates/svg/src/import/shape_tests.rs @@ -107,3 +107,14 @@ fn layer_names_decode_id_escapes() { assert_eq!(layer_name("", "Group"), "Group"); assert_eq!(layer_name("é_x41_", "Group"), "éA"); } + +#[test] +fn doctype_with_internal_subset_is_still_svg() { + let s = "\n\n]>\n"; + assert!(super::looks_like_svg(s)); + let quoted = "y]\"> ]>"; + assert!(super::looks_like_svg(quoted)); + let commented = " --> ]>"; + assert!(super::looks_like_svg(commented)); + assert!(!super::looks_like_svg(" ]>")); +} From aeaca0e1b9bdef028c8a99b2fa1d74b06fcdd6f0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:21:33 +0000 Subject: [PATCH 05/26] Reject cornerRadius/padding/strokeSides arrays with non-numeric entries Found while testing crates/engine/src/cmd/props.rs:165-175 (four, used at :506, :540 and :613) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/props.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a10393..957aeb32 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -165,7 +165,7 @@ pub(crate) fn letter_spacing(v: &Value) -> Option { fn four(v: &Value) -> Option<[f64; 4]> { match v { Value::Array(a) => { - let f: Vec = a.iter().filter_map(f64_of).collect(); + let f: Vec = a.iter().map(f64_of).collect::>()?; match f.as_slice() { [x] => Some([*x; 4]), [a, b] => Some([*a, *b, *a, *b]), @@ -1291,4 +1291,13 @@ mod tests { } assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0)); } + + #[test] + fn four_rejects_non_numeric_entries() { + assert_eq!(four(&json!([10, "abc"])), None); + assert_eq!(four(&json!([10, "abc", null, 20])), None); + assert_eq!(four(&json!([10, 20, 30])), None); + assert_eq!(four(&json!([1, 2, 3, 4])), Some([1.0, 2.0, 3.0, 4.0])); + assert_eq!(four(&json!([1, 2])), Some([1.0, 2.0, 1.0, 2.0])); + } } From 5f7aea91f92dba56650127c8958b13b6614aa027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:28:49 +0800 Subject: [PATCH 06/26] ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button Brings #1210 onto main as it stands after #1019 and #1209, and keeps both: - #1019's flyout caret keeps its 16 pt target, 2 pt clear of its tool (#988). - #1209's narrow-window handling stays as it landed: the pill is never wider than the canvas less a margin and its buttons scroll inside it under a drag or the wheel, the help button lifts above it when they'd overlap, and the pill still clips to itself rather than floating over the panels. The design itself, as #1210 proposed it: - One grid for every state of the pill: 32 pt cells, 8 pt padding, 8 pt between top-level items (the divider included), a 1 pt divider as tall as the content, 16 pt carets, 14 pt mode marks, and a 32 pt mode strip whose current mode is a plain inset with no outline. - The Resources button goes; the left rail's Assets tab, Shift+I, Alt+2 and View > Panels > Assets remain the ways into the Assets panel (and the locale key, its test and the parity notes go with it). - The vector edit and shape builder pills move onto the same grid, so a pill never changes its spacing as the tool changes. main_width is now the same walk the pill draws, and a test walks it for the design and board toolbars: the last item ends exactly PAD from the right edge. That caught the board pill asking for 8 pt more than it drew. --- crates/ui-egui/locales/bn.toml | 1 - crates/ui-egui/locales/cs.toml | 1 - crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 1 - crates/ui-egui/locales/es.toml | 1 - crates/ui-egui/locales/fi.toml | 1 - crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/id.toml | 1 - crates/ui-egui/locales/it.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ka.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/nl.toml | 1 - crates/ui-egui/locales/pl.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/ru.toml | 1 - crates/ui-egui/locales/sv.toml | 1 - crates/ui-egui/locales/th.toml | 1 - crates/ui-egui/locales/tr.toml | 1 - crates/ui-egui/locales/vi.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - crates/ui-egui/src/board_picker_tests.rs | 2 +- crates/ui-egui/src/board_ui_tests.rs | 4 +- crates/ui-egui/src/resources_tests.rs | 59 ++++---- crates/ui-egui/src/shortcuts.rs | 5 - crates/ui-egui/src/toolbar.rs | 168 ++++++++++++--------- crates/ui-egui/src/widgets/board_picker.rs | 5 +- docs/boards-parity.md | 2 +- docs/ui-parity.md | 2 +- 29 files changed, 132 insertions(+), 136 deletions(-) diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index bfdcd9dd..69ccff8a 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1163,7 +1163,6 @@ status = "draft — needs native review" "Resolve" = "সমাধান করুন" "Resolve change" = "পরিবর্তন সমাধান করুন" "Resolved" = "সমাধান করা হয়েছে" -"Resources" = "রিসোর্সসমূহ" "Responsive" = "রেসপনসিভ" "Restart" = "পুনরায় শুরু করুন" "Restore" = "পুনরুদ্ধার করুন" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index c9fdc70f..3e329bcd 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1141,7 +1141,6 @@ status = "draft — needs native review" "Resolve" = "Vyřešit" "Resolve change" = "Vyřešit změnu" "Resolved" = "Vyřešené" -"Resources" = "Zdroje" "Responsive" = "Responzivní" "Restart" = "Znovu spustit" "Restore" = "Obnovit" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index aca9973e..e001a101 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1111,7 +1111,6 @@ status = "draft — needs native review" "Resolve" = "Erledigen" "Resolve change" = "Änderung erledigen" "Resolved" = "Erledigt" -"Resources" = "Ressourcen" "Responsive" = "Responsiv" "Restart" = "Neu starten" "Restore" = "Wiederherstellen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b0afcdf7..a67546f9 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1198,7 +1198,6 @@ name = "English" "Resolve" = "Resolve" "Resolve change" = "Resolve change" "Resolved" = "Resolved" -"Resources" = "Resources" "Responsive" = "Responsive" "Restart" = "Restart" "Restore" = "Restore" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b8f21763..8fb00f52 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1194,7 +1194,6 @@ status = "draft — needs native review" "Resolve" = "Resolver" "Resolve change" = "Resolver cambio" "Resolved" = "Resuelto" -"Resources" = "Recursos" "Responsive" = "Adaptable" "Restart" = "Reiniciar" "Restore" = "Restaurar" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 0e6975cc..50793985 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1167,7 +1167,6 @@ status = "draft — needs native review" "Resolve" = "Ratkaise" "Resolve change" = "Ratkaise muutos" "Resolved" = "Ratkaistu" -"Resources" = "Resurssit" "Responsive" = "Mukautuva" "Restart" = "Aloita uudelleen" "Restore" = "Palauta" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 64a4b208..7936d3c9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1168,7 +1168,6 @@ status = "draft — needs native review" "Resolve" = "Résoudre" "Resolve change" = "Résoudre la modification" "Resolved" = "Résolu" -"Resources" = "Ressources" "Responsive" = "Adaptatif" "Restart" = "Recommencer" "Restore" = "Restaurer" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index b7d15479..603141e5 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1147,7 +1147,6 @@ status = "draft — needs native review" "Resolve" = "Selesaikan" "Resolve change" = "Selesaikan perubahan" "Resolved" = "Terselesaikan" -"Resources" = "Sumber daya" "Responsive" = "Responsif" "Restart" = "Mulai ulang" "Restore" = "Pulihkan" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index d31ab7b6..5af1304a 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -1128,7 +1128,6 @@ status = "draft — needs native review" "Resolve" = "Risolvi" "Resolve change" = "Risolvi la modifica" "Resolved" = "Risolto" -"Resources" = "Risorse" "Responsive" = "Responsive" "Restart" = "Riavvia" "Restore" = "Ripristina" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 29f16e11..a1496312 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1155,7 +1155,6 @@ status = "draft — needs native review" "Resolve" = "解決" "Resolve change" = "変更を解決" "Resolved" = "解決済み" -"Resources" = "リソース" "Responsive" = "レスポンシブ" "Restart" = "再起動" "Restore" = "復元" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index bf4d51c4..c4a46ac1 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1165,7 +1165,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Resolve" = "გადაწყვეტა" "Resolve change" = "ცვლილების გადაწყვეტა" "Resolved" = "გადაწყვეტილია" -"Resources" = "რესურსები" "Responsive" = "ადაპტური" "Restart" = "გადატვირთვა" "Restore" = "აღდგენა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b55ff444..b1304fc6 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1105,7 +1105,6 @@ status = "draft — needs native review" "Resolve" = "해결" "Resolve change" = "변경 사항 해결" "Resolved" = "해결됨" -"Resources" = "리소스" "Responsive" = "반응형" "Restart" = "다시 시작" "Restore" = "복원" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 4bcc9705..c1298aa3 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1167,7 +1167,6 @@ status = "draft — needs native review" "Resolve" = "Oplossen" "Resolve change" = "Wijziging oplossen" "Resolved" = "Opgelost" -"Resources" = "Bronnen" "Responsive" = "Responsive" "Restart" = "Opnieuw starten" "Restore" = "Terugzetten" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index ac7ccfe2..0803fb55 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1171,7 +1171,6 @@ name = "Polski" "Resolve" = "Rozwiąż" "Resolve change" = "Rozwiąż zmianę" "Resolved" = "Rozwiązany" -"Resources" = "Zasoby" "Responsive" = "Responsywny" "Restart" = "Uruchom ponownie" "Restore" = "Przywróć" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 17ee60d9..17008fa1 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1104,7 +1104,6 @@ status = "draft — needs native review" "Resolve" = "Resolver" "Resolve change" = "Resolver alteração" "Resolved" = "Resolvido" -"Resources" = "Recursos" "Responsive" = "Responsivo" "Restart" = "Reiniciar" "Restore" = "Restaurar" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 2d7a9cf0..55775972 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1171,7 +1171,6 @@ name = "Русский" "Resolve" = "Решить" "Resolve change" = "Разрешить изменение" "Resolved" = "Решённые" -"Resources" = "Ресурсы" "Responsive" = "Адаптивно" "Restart" = "Начать заново" "Restore" = "Восстановить" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index aeac2327..404b0cec 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1173,7 +1173,6 @@ name = "Svenska" "Resolve" = "Lös" "Resolve change" = "Lös ändring" "Resolved" = "Löst" -"Resources" = "Resurser" "Responsive" = "Responsiv" "Restart" = "Starta om" "Restore" = "Återställ" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 560975a1..60b2c6eb 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -1161,7 +1161,6 @@ status = "draft — needs native review" "Resolve" = "แก้ไขแล้ว" "Resolve change" = "แก้ไขการเปลี่ยนแปลง" "Resolved" = "แก้ไขแล้ว" -"Resources" = "แหล่งข้อมูล" "Responsive" = "รีสปอนซีฟ" "Restart" = "เริ่มใหม่" "Restore" = "กู้คืน" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 3401b020..b7b8d05b 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1145,7 +1145,6 @@ name = "Türkçe" "Resolve" = "Çöz" "Resolve change" = "Değişikliği çöz" "Resolved" = "Çözüldü" -"Resources" = "Kaynaklar" "Responsive" = "Duyarlı" "Restart" = "Yeniden başlat" "Restore" = "Geri yükle" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index e130afbf..0b2b2280 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -1129,7 +1129,6 @@ status = "draft — needs native review" "Resolve" = "Giải quyết" "Resolve change" = "Giải quyết thay đổi" "Resolved" = "Đã giải quyết" -"Resources" = "Tài nguyên" "Responsive" = "Đáp ứng" "Restart" = "Bắt đầu lại" "Restore" = "Khôi phục" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a4a9085f..f7ffa57f 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1113,7 +1113,6 @@ status = "draft — needs native review" "Resolve" = "解决" "Resolve change" = "解决更改" "Resolved" = "已解决" -"Resources" = "资源" "Responsive" = "响应式" "Restart" = "重新开始" "Restore" = "恢复" diff --git a/crates/ui-egui/src/board_picker_tests.rs b/crates/ui-egui/src/board_picker_tests.rs index c5860525..e5c1666b 100644 --- a/crates/ui-egui/src/board_picker_tests.rs +++ b/crates/ui-egui/src/board_picker_tests.rs @@ -27,7 +27,7 @@ fn board() -> Ui { /// The templates button: left of the Design panel's button at the board toolbar's right end. fn templates_button(u: &Ui) -> Pos2 { let p = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app)); - pos2(p.max.x - 8.0 - 32.0 - 4.0 - 16.0, p.center().y) + pos2(p.max.x - 8.0 - 32.0 - 8.0 - 16.0, p.center().y) } fn count(u: &Ui, f: impl Fn(&BoardItem) -> bool) -> usize { diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index e8592467..e4758ca7 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -39,10 +39,10 @@ fn pill(u: &Ui) -> Rect { } /// The left edge of board toolbar group `i` (button 32 wide, a 16-wide caret 2 after it on the -/// groups with flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp, +/// groups with flyouts, 8 between): move, ink, sticky, shapes, connector, text, section, stamp, /// comment. fn group_x(u: &Ui, i: usize) -> f32 { - const WIDTHS: [f32; 9] = [54.0, 54.0, 54.0, 54.0, 36.0, 36.0, 36.0, 54.0, 36.0]; + const WIDTHS: [f32; 9] = [58.0, 58.0, 58.0, 58.0, 40.0, 40.0, 40.0, 58.0, 40.0]; pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::() } diff --git a/crates/ui-egui/src/resources_tests.rs b/crates/ui-egui/src/resources_tests.rs index 42869098..d105f24d 100644 --- a/crates/ui-egui/src/resources_tests.rs +++ b/crates/ui-egui/src/resources_tests.rs @@ -1,19 +1,14 @@ -//! The toolbar's Resources button and Shift+I open the Assets panel (#710), from a collapsed -//! sidebar and from the version history too. +//! Shift+I opens the Assets panel (#710), from a collapsed sidebar and from the version history +//! too — and the toolbar has no Resources button for it any more (the left rail's Assets tab, +//! Shift+I, Alt+2 and View ▸ Panels ▸ Assets are the ways in). use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui::{Event, Key, Modifiers}; use figcraft_engine::Session; use crate::LeftTab; use crate::layers_drag_tests::Ui; -fn click(u: &mut Ui, p: Pos2) { - u.button(p, true); - u.button(p, false); - u.frame(vec![]); -} - fn shift_i(u: &mut Ui) { let modifiers = Modifiers::SHIFT; u.frame(vec![ @@ -23,22 +18,11 @@ fn shift_i(u: &mut Ui) { u.frame(vec![]); } -/// The middle of the toolbar's Resources button, found in the AccessKit tree. -fn resources_button(u: &mut Ui) -> Pos2 { - u.ctx.enable_accesskit(); - u.frame(vec![]); - let out = u.frame_output(vec![]); - let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; - let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Resources")).expect("Resources"); - let b = n.bounds().expect("bounds"); - pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) -} - fn assets_shown(u: &Ui) -> bool { u.app.ui.left_tab == LeftTab::Assets && !u.app.ui.left_collapsed && !u.app.ui.versions.open } -/// The three states the sidebar can be in when Resources is asked for. +/// The three states the sidebar can be in when Shift+I is pressed. fn starts() -> Vec<(&'static str, Ui)> { let shown = Ui::new(Session::new()); let mut collapsed = Ui::new(Session::new()); @@ -48,15 +32,6 @@ fn starts() -> Vec<(&'static str, Ui)> { vec![("layers", shown), ("collapsed", collapsed), ("version history", versions)] } -#[test] -fn the_resources_button_opens_the_assets_panel() { - for (name, mut u) in starts() { - let at = resources_button(&mut u); - click(&mut u, at); - assert!(assets_shown(&u), "{name}: the Assets panel is open"); - } -} - #[test] fn shift_i_opens_the_assets_panel() { for (name, mut u) in starts() { @@ -65,3 +40,27 @@ fn shift_i_opens_the_assets_panel() { assert!(assets_shown(&u), "{name}: the Assets panel is open"); } } + +/// The toolbar's buttons, as screen readers get them. +fn toolbar_labels(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + out.platform_output + .accesskit_update + .expect("an AccessKit update") + .nodes + .iter() + .filter(|(_, n)| n.role() == Role::Button) + .filter_map(|(_, n)| n.label().map(str::to_string)) + .collect() +} + +#[test] +fn the_toolbar_has_no_resources_button() { + let mut u = Ui::new(Session::new()); + let labels = toolbar_labels(&mut u); + // Actions stays (the palette's button); Resources went (#1210). + assert!(labels.iter().any(|l| l == "Actions"), "Actions is in the toolbar: {labels:?}"); + assert!(!labels.iter().any(|l| l == "Resources"), "no Resources button: {labels:?}"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 94285cb5..6589e10d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -73,11 +73,6 @@ pub fn tip(label: &'static str, id: &str) -> &'static str { cached_tip(label, shortcut(id)) } -/// A tooltip naming a key ("Resources ⇧I"). -pub fn key_tip(label: &'static str, key: &'static str) -> &'static str { - cached_tip(label, Some(key)) -} - /// Panels ask for their tooltips every frame: each (label, key, platform) is built once. fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { type Entry = (Platform, &'static str, &'static str, &'static str); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a5795a8c..dcb0ccf0 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -21,6 +21,19 @@ const CARET: f32 = 14.0; const FLYOUT_W: f32 = 16.0; const FLYOUT_GAP: f32 = 2.0; +// The pill's grid: every state of it (the tools, the vector and shape builder ones) is laid out on +// one 32 pt cell, PAD in from its ends and GAP between its top-level items, the divider included. +/// A toolbar button's size: the cell everything in the pill is placed on. +const TOOL_CELL: f32 = 32.0; +/// The pill's left and right padding. +const PAD: f32 = 8.0; +/// Between the pill's top-level items — the divider included. +const GAP: f32 = 8.0; +/// The divider's width. +const DIVIDER: f32 = 1.0; +/// The mode switcher's marks: smaller than the tools' (13–14 pt). +const MODE_GLYPH: f32 = 14.0; + /// `GLYPH`'s box centred in `r`. fn glyph_rect(r: Rect) -> Rect { Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH)) @@ -129,17 +142,18 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 { main_width(if board { BOARD_GROUPS } else { GROUPS }, board) } -/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and -/// Actions, and the mode switcher (boards: the Design panel's button instead). +/// The main toolbar pill's width: the tool groups with their flyout carets, Actions, the divider +/// and the mode switcher (boards: the templates and the Design panel's buttons instead). fn main_width(groups: &[Group], board: bool) -> f32 { - let (cell, gap) = (TOOL_CELL, 4.0); - let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W); - let end = if board { 2.0 * cell + gap + 16.0 } else { 4.0 * 34.0 + 24.0 }; - groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end + let tools: f32 = groups.iter().map(|g| TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 }).sum(); + // The end, and the gaps that aren't a group's own: after Actions and after the divider (and + // between a board's two buttons, which the end counts as one item). + let (end, gaps) = if board { (2.0 * TOOL_CELL, 3.0) } else { (4.0 * TOOL_CELL, 2.0) }; + PAD * 2.0 + tools + (groups.len() as f32 + gaps) * GAP + TOOL_CELL + DIVIDER + end } -/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the -/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button). +/// The main toolbar pill: the tool groups with their flyouts, Actions, then the +/// Draw · Design · Prototype · Inspect mode switcher (on a board, its two buttons). fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) { let groups = if board { BOARD_GROUPS } else { GROUPS }; let width = main_width(groups, board); @@ -148,16 +162,16 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: let scroll = pill_scroll(ui, rect, width); let outer_clip = ui.clip_rect(); ui.set_clip_rect(rect.intersect(outer_clip)); - let mut x = rect.min.x + 8.0 - scroll; + let mut x = rect.min.x + PAD - scroll; for g in groups { x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t); } - x = side_buttons(app, ui, x, cy, t); + x = actions_button(app, ui, x, cy, t); divider(ui, t, x, cy); - x += 8.0; + x += DIVIDER + GAP; if board { // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for. - x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t); + x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t) + GAP; let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let tip = crate::shortcuts::tip(tr("Design panel"), "view.design"); if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() { @@ -185,9 +199,6 @@ fn pill_scroll(ui: &Ui, rect: Rect, width: f32) -> f32 { scroll } -/// A toolbar button's size. -const TOOL_CELL: f32 = 32.0; - /// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout /// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts. fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 { @@ -223,7 +234,7 @@ fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32 options_flyout(app, ui, g, pos2(r.min.x, top - 8.0)); } } - x + 4.0 + x + GAP } /// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its @@ -245,36 +256,23 @@ fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &To } } -/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`. -/// Where the next part starts. -fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { - for (icon, tip, id) in [ - (Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"), - (Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"), - ] { - let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); - let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()).tip(tip); - if id == "actions" { - app.ui.actions_rect = Some(r); - } - // The Actions button reads on while its panel is open; clicking it again closes it. - let actions_open = id == "actions" && app.ui.palette.is_some(); - if actions_open { - ui.painter().rect_filled(r, CornerRadius::same(5), t.accent); - } else if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon }); - if resp.clicked() { - if id == "actions" { - crate::menus::toggle_palette(&mut app.ui); - } else { - app.run("view.assets", json!({})); - } - } - x += TOOL_CELL + 4.0; +/// The Actions palette's button, from `x`. Where the next part starts. +fn actions_button(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) -> f32 { + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); + let resp = ui.interact(r, ui.scope_id().with("actions"), Sense::click()).tip(crate::shortcuts::tip(tr("Actions"), "app.actions")); + app.ui.actions_rect = Some(r); + // The Actions button reads on while its panel is open; clicking it again closes it. + let open = app.ui.palette.is_some(); + if open { + ui.painter().rect_filled(r, CornerRadius::same(5), t.accent); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - x + icons::paint(ui.painter(), glyph_rect(r), Icon::Actions, if open { t.accent_on } else { t.icon }); + if resp.clicked() { + crate::menus::toggle_palette(&mut app.ui); + } + x + TOOL_CELL + GAP } /// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code). @@ -286,19 +284,24 @@ fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) { crate::RightTab::Prototype => 2, crate::RightTab::Inspect => 3, }; - let seg = Rect::from_min_size(pos2(x, cy - 18.0), vec2(4.0 * 34.0 + 4.0, 36.0)); - ui.painter().rect_filled(seg, CornerRadius::same(8), t.field); + let seg = Rect::from_min_size(pos2(x, cy - TOOL_CELL / 2.0), vec2(4.0 * TOOL_CELL, TOOL_CELL)); + ui.painter().rect_filled(seg, CornerRadius::same(5), t.field); for (i, (icon, tip)) in modes.iter().enumerate() { - let r = Rect::from_min_size(pos2(seg.min.x + 2.0 + i as f32 * 34.0, seg.min.y + 2.0), vec2(34.0, 32.0)); + let r = Rect::from_min_size(pos2(seg.min.x + i as f32 * TOOL_CELL, seg.min.y), vec2(TOOL_CELL, TOOL_CELL)); let resp = ui.interact(r, ui.scope_id().with(("mode", i)), Sense::click()).on_hover_text(*tip); a11y::toggle(&resp, tip, i == active); if i == active { - ui.painter().rect_filled(r, CornerRadius::same(6), t.panel); - ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside); + // The current mode lifts with a plain inset, no outline. + ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(3), t.panel); } else if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary }); + icons::paint( + ui.painter(), + Rect::from_center_size(r.center(), vec2(MODE_GLYPH, MODE_GLYPH)), + *icon, + if i == active { t.accent_text } else { t.icon_secondary }, + ); if resp.clicked() { pick_mode(app, i); } @@ -359,15 +362,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) { app.run("tool.set", json!({"tool": "move"})); } let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") }; - let cell = 32.0; let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text); let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0; - let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0; + let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + DIVIDER + GAP + badge_w; let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t); let cy = rect.center().y; - let mut x = rect.min.x + 8.0; + let mut x = rect.min.x + PAD; for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] { - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let active = app.session.tool.tool == tool; let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut()))); if active { @@ -376,10 +378,10 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) { if resp.clicked() { app.run("tool.set", json!({"tool": tool.id()})); } - x += cell + 4.0; + x += TOOL_CELL + GAP; } divider(ui, &t, x, cy); - let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0)); + let badge = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 13.0), vec2(badge_w, 26.0)); let resp = ui.interact(badge, ui.scope_id().with("view_only"), Sense::hover()).on_hover_text(why); a11y::status(&resp, label, why); ui.painter().rect_filled(badge, CornerRadius::same(13), t.field); @@ -391,15 +393,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) { fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) { let t = Tokens::get(ui.ctx()); let canvas = app.canvas.rect; - let cell = 32.0; - let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0; + let width = VTool::ALL.len() as f32 * (TOOL_CELL + GAP) + PAD * 2.0 + DIVIDER + GAP + DONE_W; let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); let cy = rect.center().y; - let mut x = rect.min.x + 8.0; + let mut x = rect.min.x + PAD; // Holding Cmd with the Move tool bends: show it. let held = current == VTool::Move && ui.input(|i| i.modifiers.command); for tool in VTool::ALL { - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let tip = if tool == VTool::Bend { trf("{tool} hold {key}", &[("tool", &tr(tool.label())), ("key", &pretty_shortcut(tool.shortcut()))]) } else { @@ -423,7 +424,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) { if resp.clicked() { app.run("vector.edit.tool", json!({"tool": tool.id()})); } - x += cell + 4.0; + x += TOOL_CELL + GAP; } if done_button(ui, &t, x, cy, &format!("{} Esc", tr("Done editing"))) { app.run("vector.edit.end", json!({})); @@ -433,7 +434,6 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) { /// The pill shown while the shape builder is active: Merge and Remove modes, a hint and Done. fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let cell = 32.0; let remove = app.session.tool.builder_remove; let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty()); let alt = pretty_shortcut("Alt"); @@ -445,12 +445,12 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { trf("Click or drag to merge · {alt} removes", &[("alt", &alt)]) }; let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary); - let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0; + let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + 2.0 * (DIVIDER + GAP) + galley.size().x + GAP + DONE_W; let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t); let cy = rect.center().y; - let mut x = rect.min.x + 8.0; + let mut x = rect.min.x + PAD; for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, tr("Merge regions")), ("remove", Icon::ShapeBuilderRemove, tr("Remove regions"))] { - let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let active = (mode == "remove") == remove; let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip); if active { @@ -459,13 +459,13 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { if resp.clicked() { app.run("shapeBuilder.mode", json!({"mode": mode})); } - x += cell + 4.0; + x += TOOL_CELL + GAP; } divider(ui, &t, x, cy); - x += 12.0; + x += DIVIDER + GAP; let w = galley.size().x; ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary); - x += w + 12.0; + x += w + GAP; if done_button(ui, &t, x, cy, &format!("{} {}", tr("Done"), pretty_shortcut("Enter"))) { app.run("tool.set", json!({"tool": "move"})); } @@ -515,9 +515,10 @@ pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSal resp } -/// A vertical separator just after `x`. +/// A vertical separator just after `x`: DIVIDER wide, as tall as the pill's content. fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) { - ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border)); + let half = TOOL_CELL / 2.0; + ui.painter().line_segment([pos2(x + DIVIDER / 2.0, cy - half), pos2(x + DIVIDER / 2.0, cy + half)], Stroke::new(DIVIDER, t.border)); } const DONE_W: f32 = 56.0; @@ -525,7 +526,7 @@ const DONE_W: f32 = 56.0; /// A divider at `x` and the accent Done button after it; true when clicked. fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { divider(ui, t, x, cy); - let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0)); + let done = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 14.0), vec2(DONE_W, 28.0)); let resp = ui.interact(done, ui.scope_id().with("tool_done"), Sense::click()).tip(tip); ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on); @@ -642,12 +643,33 @@ mod tests { let phone = Rect::from_min_size(pos2(0.0, 0.0), vec2(375.0, 700.0)); let pill = pill_rect(phone, full); assert!(pill.width() <= phone.width() - 16.0 && pill.min.x >= 8.0 && pill.max.x <= phone.max.x - 8.0, "{pill:?}"); + // Narrower than its items, so it scrolls under a drag or the wheel (#1209). + assert!(full > pill.width(), "{full} in {}", pill.width()); // A desktop canvas keeps the full width, and a degenerate one doesn't go negative. let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0)); assert_eq!(pill_rect(wide, full).width(), full); assert_eq!(pill_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(4.0, 4.0)), full).width(), 0.0); } + /// The width the pill asks for is the width its items take — the groups, Actions, the divider + /// and the end (the mode switcher, or a board's two buttons), PAD in from either end and one + /// GAP apart — so its last item ends exactly PAD from its right edge. + #[test] + fn the_pill_is_as_wide_as_its_items() { + let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0)); + for (groups, board) in [(GROUPS, false), (BOARD_GROUPS, true)] { + let pill = pill_rect(canvas, main_width(groups, board)); + let mut x = pill.min.x + PAD; + for g in groups { + x += TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 } + GAP; + } + x += TOOL_CELL + GAP; // Actions + x += DIVIDER + GAP; // the divider + let end = if board { 2.0 * TOOL_CELL + GAP } else { 4.0 * TOOL_CELL }; + assert!((x + end - (pill.max.x - PAD)).abs() < 0.01, "board {board}: {} vs {}", x + end, pill.max.x - PAD); + } + } + /// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point. fn widths(ppp: f32, icon: Icon, size: f32) -> Vec { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/widgets/board_picker.rs b/crates/ui-egui/src/widgets/board_picker.rs index 6f7e509a..766bfece 100644 --- a/crates/ui-egui/src/widgets/board_picker.rs +++ b/crates/ui-egui/src/widgets/board_picker.rs @@ -25,7 +25,8 @@ const GAP: f32 = 8.0; const TITLE_H: f32 = 26.0; /// The templates button at `at` (its left edge, the toolbar's middle) and, while open, its picker -/// above the toolbar (whose top is `top`). Where the next button starts. +/// above the toolbar (whose top is `top`). The button's right edge: the toolbar puts the next +/// button a GAP after it. pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Tokens) -> f32 { let r = Rect::from_min_size(pos2(at.x, at.y - 16.0), vec2(32.0, 32.0)); let open = app.ui.tool_flyout == Some(KEY); @@ -39,7 +40,7 @@ pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Toke if app.ui.tool_flyout == Some(KEY) { picker(app, ui, r, top - 8.0, t); } - r.max.x + 4.0 + r.max.x } fn area_id() -> egui::Id { diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 69526a18..116295b4 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -27,7 +27,7 @@ all end with the same geometry. | Feature | Status | Notes | |---|---|---| | Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) | -| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button | +| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) | | Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel | | Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why | | Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2604cfc9..a12e3433 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -43,7 +43,7 @@ interaction feel not yet compared. | Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned | | Find layers (filter by name) | done | not covered by the documentation | | Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences; a long list scrolls in its own area and a page dropped outside the visible list stays put (#272) (`layers_behaviour_tests`) | -| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert) and the toolbar's Resources open it (they didn't, #710) | +| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert), Alt+2 and View ▸ Panels ▸ Assets open it; the toolbar's Resources button was removed (the left rail's Assets tab and those shortcuts are the ways in) | | Hide when publishing from the Assets panel's context menu | done | a right-click on one of this file's components (also Libraries ▸ Hide when publishing for the selection, `component.hideWhenPublishing {ids?, on?}`): the model's sign of it is the name, so hiding puts a `.` before the name and showing takes the leading `.` and `_` away (one undo step, saved and synced like a rename; a variant or a layer inside hides its whole component or set); a hidden component shows a crossed eye on its tile and a dimmer name, the menu item its check mark; the next publish leaves it out and brings it back when shown again (`hide_publishing_tests`). Library components (read-only here) have no such menu | ## Selection and transform From 8ae76d9b2a8a822934b707c418afb32a7e2601c5 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Tue, 27 Oct 2026 07:57:53 +0300 Subject: [PATCH 07/26] M8: bound attached MCP replies to 512 MiB (#1113) The attached backend read each reply from the app's control port with read_line, which has no limit. It now reads up to 512 MiB before the newline (an app window capture is at most 256 MiB as base64) and stops at the first byte past that. An oversized or non-UTF-8 reply drops the connection and is reported like a lost reply; the next call reconnects. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/mcp/src/backend.rs | 28 +++++++++--- crates/mcp/src/backend_tests.rs | 77 +++++++++++++++++++++++++++++++++ docs/control-protocol.md | 2 + docs/mcp.md | 4 ++ docs/roadmap.md | 3 +- 5 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 crates/mcp/src/backend_tests.rs diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index f2021cc6..1c2ab141 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,7 +1,7 @@ //! Where MCP tool calls end up: a control-channel method call, answered either by an in-process //! engine session ([`crate::Headless`]) or by a running FigCraft app ([`Attached`]). -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; @@ -76,17 +76,23 @@ pub fn decode_data(v: &Value) -> Result, String> { base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| format!("bad base64 from the app: {e}")) } +/// The longest reply line we read from the app (512 MiB). The app's window captures alone reach +/// 256 MiB as base64 (192 MiB PNGs); anything longer is refused without reading the rest. +const MAX_REPLY: usize = 512 * 1024 * 1024; + /// A running FigCraft app, reached through its loopback control port (`figcraft --control PORT`). pub struct Attached { addr: String, conn: Option<(BufReader, TcpStream)>, next_id: u64, + /// [`MAX_REPLY`]; smaller in tests. + max_reply: usize, } impl Attached { /// Connect to `addr` (e.g. `127.0.0.1:7979`), failing fast when nothing is listening. pub fn connect(addr: &str) -> std::io::Result { - let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, max_reply: MAX_REPLY }; r.reconnect()?; Ok(r) } @@ -133,17 +139,25 @@ impl Attached { } fn roundtrip(&mut self, line: &str) -> std::io::Result { + let max_reply = self.max_reply; let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app")); }; writer.write_all(line.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; - let mut reply = String::new(); - if reader.read_line(&mut reply)? == 0 { + let mut reply = Vec::new(); + if reader.take((max_reply + 1) as u64).read_until(b'\n', &mut reply)? == 0 { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); } - Ok(reply) + if reply.last() == Some(&b'\n') { + reply.pop(); + } + if reply.len() > max_reply { + let mib = max_reply / (1024 * 1024); + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("reply larger than {mib} MiB"))); + } + String::from_utf8(reply).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } } @@ -181,3 +195,7 @@ impl Backend for Attached { format!("attached to the FigCraft app at {}", self.addr) } } + +#[cfg(test)] +#[path = "backend_tests.rs"] +mod tests; diff --git a/crates/mcp/src/backend_tests.rs b/crates/mcp/src/backend_tests.rs new file mode 100644 index 00000000..ec4fc473 --- /dev/null +++ b/crates/mcp/src/backend_tests.rs @@ -0,0 +1,77 @@ +//! The attached backend reads the app's replies with a bound (#1113). + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::sync::mpsc; +use std::time::Duration; + +use serde_json::json; + +use super::{Attached, Backend}; + +/// A fake app that takes one connection per reply, reads one request on it and answers with the +/// reply. It keeps the connections open until [`finish`] (so an unterminated reply never reaches +/// EOF), then returns how many requests it read. +fn fake_app(replies: Vec>) -> (u16, mpsc::Sender<()>, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (done, wait) = mpsc::channel::<()>(); + let app = std::thread::spawn(move || { + let mut open = Vec::new(); + for reply in &replies { + let (stream, _) = listener.accept().unwrap(); + let mut out = stream.try_clone().unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).unwrap(); + out.write_all(reply).unwrap(); + out.flush().unwrap(); + open.push(out); + } + wait.recv_timeout(Duration::from_secs(30)).ok(); + open.len() + }); + (port, done, app) +} + +/// Let the fake app close its connections, and check it read `requests` requests. +fn finish(done: mpsc::Sender<()>, app: std::thread::JoinHandle, requests: usize) { + drop(done); + assert_eq!(app.join().unwrap(), requests); +} + +fn ok_reply(text: &str) -> Vec { + format!("{}\n", json!({"id": 1, "ok": true, "result": text})).into_bytes() +} + +#[test] +fn an_oversized_reply_is_refused_and_the_next_call_reconnects() { + let (port, done, fake) = fake_app(vec![vec![b'x'; 200], ok_reply("fine")]); + let mut app = Attached::port(port).unwrap(); + app.max_reply = 64; + let err = app.call("engine.commands", json!({})).unwrap_err(); + assert!(err.contains("reply larger than"), "{err}"); + assert!(err.contains("may have completed"), "{err}"); + assert!(app.conn.is_none()); + assert_eq!(app.call("engine.commands", json!({})).unwrap(), json!("fine")); + finish(done, fake, 2); +} + +#[test] +fn a_reply_at_the_limit_is_read() { + let reply = ok_reply("at the limit"); + let (port, done, fake) = fake_app(vec![reply.clone()]); + let mut app = Attached::port(port).unwrap(); + app.max_reply = reply.len() - 1; // the newline doesn't count + assert_eq!(app.call("engine.commands", json!({})).unwrap(), json!("at the limit")); + finish(done, fake, 1); +} + +#[test] +fn a_reply_that_is_not_utf8_is_an_error() { + let (port, done, fake) = fake_app(vec![b"{\"ok\": true, \"result\": \"\xff\"}\n".to_vec()]); + let mut app = Attached::port(port).unwrap(); + let err = app.call("engine.commands", json!({})).unwrap_err(); + assert!(err.contains("failed"), "{err}"); + assert!(app.conn.is_none()); + finish(done, fake, 1); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 448a9979..2f211950 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -29,6 +29,8 @@ active connections**; additional connections are closed immediately. Socket read have a **60 s inactivity timeout**, so idle clients and clients that stop reading release their worker. This bounds blocked socket operations, not the total duration of a frame arriving slowly. Clients can reconnect after an idle timeout (the attached MCP backend already does). +Replies have no size limit on the app's side. The attached MCP backend reads them up to **512 MiB +before the newline** and drops the connection on a longer one (see [MCP server](mcp.md)). Both the socket queue and the UI queue hold at most **8 requests** each, including requests waiting for another window's frame. A full socket queue replies with `"control request queue is full"` diff --git a/docs/mcp.md b/docs/mcp.md index 05dfd1e6..cbf989cf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -23,6 +23,10 @@ Attached calls are never automatically replayed after a transport failure: the a applied the request before its reply was lost. The error reports that the operation may have completed; inspect the document before retrying an edit. The next explicit call reconnects. If that connection cannot be established, the error says the request was not sent. +A reply from the app is read up to **512 MiB before the newline** (the largest window capture is +256 MiB as base64). The backend stops reading a longer reply at the first byte past the limit and +treats it like a lost reply: the connection is closed, the operation may have completed, and the +next call reconnects. For larger exports, give `export` a `path` or lower the scale. A connection the app closed while it sat idle is noticed before the next request is written and replaced then, so a pause between calls is not an error. diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba..d9df3e02 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (the attached MCP server reads the app's replies with a 512 MiB bound (#1113); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Bounded attached MCP replies (M8, #1113): `figcraft-cli mcp --attach` read each reply from the app's control port with no size limit, so a process listening on that port could make it buffer until memory ran out. Replies are now read up to 512 MiB (an app window capture is at most 256 MiB as base64); reading stops at the first byte past that, the connection is closed and the call is reported like any other lost reply, and the next call reconnects. Regression tests in the MCP backend tests. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | From d3bde76bafd4f06b7afac68b17291961effb73a2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:04:47 +0000 Subject: [PATCH 08/26] Clamp Vector Drawable numbers to the f32 range so they stay finite Found while testing crates/vectordrawable/src/xml.rs:22 (num), reached via stroke_attrs at :189 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/vectordrawable/src/tests/strokes.rs | 9 +++++++++ crates/vectordrawable/src/xml.rs | 3 +++ 2 files changed, 12 insertions(+) diff --git a/crates/vectordrawable/src/tests/strokes.rs b/crates/vectordrawable/src/tests/strokes.rs index b29b2715..1d179824 100644 --- a/crates/vectordrawable/src/tests/strokes.rs +++ b/crates/vectordrawable/src/tests/strokes.rs @@ -42,6 +42,15 @@ fn a_miter_limit_other_than_four_is_written() { assert_eq!(paths_of(&out.xml)[0].get("strokeMiterLimit"), Some("10")); } +#[test] +fn a_huge_miter_limit_is_written_as_a_finite_number() { + let (mut d, r) = stroked(StrokeAlign::Center); + d.get_mut(r).unwrap().stroke.miter_limit = 1e306; + let out = export(&d, &[r], Compat::BestEffort); + let v = paths_of(&out.xml)[0].get("strokeMiterLimit").unwrap().to_string(); + assert!(v.parse::().unwrap().is_finite(), "{v}"); +} + #[test] fn a_uniform_scale_scales_the_stroke_width() { let (mut d, r) = stroked(StrokeAlign::Center); diff --git a/crates/vectordrawable/src/xml.rs b/crates/vectordrawable/src/xml.rs index 48d7dee2..62b6d5b6 100644 --- a/crates/vectordrawable/src/xml.rs +++ b/crates/vectordrawable/src/xml.rs @@ -19,6 +19,9 @@ pub(crate) fn num(v: f64) -> String { if !v.is_finite() { return "0".into(); } + // Android reads these as 32-bit floats, so clamp to that range; this also keeps the scaling + // below from overflowing to infinity. + let v = v.clamp(-f64::from(f32::MAX), f64::from(f32::MAX)); let r = (v * 1000.0).round() / 1000.0; let s = format!("{r}"); if s == "-0" { "0".into() } else { s } From f1264cf13e62fc44e84d929b397464955dd8e8da Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:35:02 +0000 Subject: [PATCH 09/26] Reject '+' sign in NodeId::from_str Found while testing crates/doc/src/lib.rs:64-71 (impl FromStr for NodeId), reached through crates/engine/src/automation/params.rs:78 (id_of) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/ids_tests.rs | 8 ++++++++ crates/doc/src/lib.rs | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/crates/doc/src/ids_tests.rs b/crates/doc/src/ids_tests.rs index 4f489886..350d198c 100644 --- a/crates/doc/src/ids_tests.rs +++ b/crates/doc/src/ids_tests.rs @@ -110,3 +110,11 @@ fn the_highest_space_counts_every_kind_of_id() { assert_eq!(id_space("S:99999999999999999999999", "S:"), 0, "too big for a number"); assert_eq!(mode_number("1:0"), None); } + +#[test] +fn nodeid_rejects_plus_sign() { + assert!("+12".parse::().is_err()); + assert!("0:+12".parse::().is_err()); + assert_eq!("12".parse::(), Ok(crate::NodeId(12))); + assert_eq!("0:12".parse::(), Ok(crate::NodeId(12))); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index caf0c861..8307296d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -67,6 +67,10 @@ impl std::str::FromStr for NodeId { // Accept "12" and Figma-style "0:12". let t = s.trim(); let t = t.rsplit(':').next().unwrap_or(t); + if t.starts_with('+') { + // `u64::parse` accepts a leading '+'; ids are digits only. + return "-".parse::().map(NodeId); + } t.parse::().map(NodeId) } } From d21c9d4292e5223b5b52d6fe5b8d920c83546c3e Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Tue, 27 Oct 2026 12:55:48 +0300 Subject: [PATCH 10/26] M8: keep undo entries after a failed batch undoes an earlier edit (#1410) Session::grouped saved only the undo stack's length, so a failed group could drop the entries it added but not bring back the ones an edit.undo inside it popped. It now keeps each document's undo entries on entry, as it already did for redo, and restores them when the group fails. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/engine/src/batch_rollback_tests.rs | 199 ++++++++++++++++++++++ crates/engine/src/session/undo.rs | 6 +- docs/roadmap.md | 3 +- 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/batch_rollback_tests.rs b/crates/engine/src/batch_rollback_tests.rs index 298ca097..9d38ce11 100644 --- a/crates/engine/src/batch_rollback_tests.rs +++ b/crates/engine/src/batch_rollback_tests.rs @@ -59,6 +59,90 @@ fn failed_group_reopens_documents_closed_inside_it() { assert!(s.closed_in_group.is_empty()); } +#[test] +fn failed_group_restores_each_tabs_history_after_undo_close_and_open() { + let mut s = Session::new(); + let mut originals = Vec::new(); + for (index, name) in ["First", "Second"].into_iter().enumerate() { + if index > 0 { + s.execute("file.new", &json!({"name": name})).unwrap(); + } + let created = s + .execute( + "command.batch", + &json!({ + "label": format!("{name} shape"), + "commands": [{"command": "shape.rectangle", "params": { + "x": index * 20, "y": 0, "width": 10, "height": 10, "props": {"name": name} + }}] + }), + ) + .unwrap(); + let original = id(&created[0]); + s.execute( + "command.batch", + &json!({ + "label": format!("{name} name"), + "commands": [{"command": "node.set", "params": {"id": original, "props": {"name": format!("{name} renamed")}}}] + }), + ) + .unwrap(); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert_eq!(s.undo_depth(), (1, 1)); + originals.push(original); + } + let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect(); + let before_docs: Vec<_> = s.docs.iter().map(|ds| ds.doc.clone()).collect(); + let before_labels: Vec<_> = s + .docs + .iter() + .map(|ds| { + (ds.history.undo.iter().map(|h| h.label.clone()).collect::>(), ds.history.redo.iter().map(|h| h.label.clone()).collect::>()) + }) + .collect(); + assert_ne!(before_labels[0], before_labels[1]); + let active = s.active; + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "doc.nextTab"}, + {"command": "edit.undo"}, + {"command": "file.close", "params": {"index": 0, "force": true}}, + {"command": "file.new", "params": {"name": "Temporary"}}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::>(), uids); + assert_eq!(s.active, active); + assert!(s.closed_in_group.is_empty()); + for (index, ds) in s.docs.iter().enumerate() { + assert_eq!(ds.doc, before_docs[index]); + assert_eq!(ds.history.undo.iter().map(|h| h.label.clone()).collect::>(), before_labels[index].0); + assert_eq!(ds.history.redo.iter().map(|h| h.label.clone()).collect::>(), before_labels[index].1); + } + // Travel through each restored tab's real history, including the pre-existing redo step. + for _ in 0..2 { + let index = s.active; + let original = originals[index]; + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert!(!s.doc().unwrap().contains(original)); + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true})); + assert_eq!(s.doc().unwrap(), before_docs[index].as_ref()); + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true})); + let name = &before_docs[index].node(original).unwrap().name; + assert_eq!(s.doc().unwrap().node(original).unwrap().name, format!("{name} renamed")); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert_eq!(s.doc().unwrap(), before_docs[index].as_ref()); + s.execute("doc.nextTab", &json!({})).unwrap(); + } +} + #[test] fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() { let mut s = Session::new(); @@ -87,6 +171,121 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() assert_eq!(s.message, before_message); } +#[test] +fn failed_group_keeps_the_undo_steps_it_undid() { + let mut s = Session::new(); + let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let before_doc = s.doc().unwrap().clone(); + assert_eq!(s.undo_depth(), (1, 0)); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 0), "the undo step survives the failed batch"); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert!(!s.doc().unwrap().contains(original)); + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true})); + assert!(s.doc().unwrap().contains(original)); +} + +#[test] +fn failed_group_keeps_history_after_undo_and_redo() { + let mut s = Session::new(); + let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let second = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()); + assert!(s.undo().unwrap()); + let before_doc = s.doc().unwrap().clone(); + let before_labels = s.undo_labels(10); + assert_eq!(s.undo_depth(), (1, 1)); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "edit.redo"}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 1)); + assert_eq!(s.undo_labels(10), before_labels); + assert!(s.undo().unwrap()); + assert!(!s.doc().unwrap().contains(first)); + assert!(s.redo().unwrap()); + assert!(s.doc().unwrap().contains(first)); + assert!(!s.doc().unwrap().contains(second)); + assert!(s.redo().unwrap()); + assert!(s.doc().unwrap().contains(second)); +} + +#[test] +fn failed_outer_group_keeps_the_undo_steps_a_nested_batch_undid() { + let mut s = Session::new(); + let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let before_doc = s.doc().unwrap().clone(); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "command.batch", "params": {"commands": [{"command": "edit.undo"}]}}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 0)); + assert!(s.undo().unwrap()); + assert!(!s.doc().unwrap().contains(original)); + assert!(s.redo().unwrap()); + assert!(s.doc().unwrap().contains(original)); +} + +#[test] +fn failed_group_restores_the_small_history_limit() { + let mut s = Session::new(); + s.ds_mut().unwrap().history.limit = 1; + let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let before_doc = s.doc().unwrap().clone(); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "shape.rectangle", "params": {"x": 20, "y": 0, "width": 10, "height": 10}}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 0)); + assert_eq!(s.ds().unwrap().history.limit, 1); + let second = id(&s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()); + assert_eq!(s.undo_depth(), (1, 0), "new edits still respect the limit"); + assert!(s.undo().unwrap()); + assert!(s.doc().unwrap().contains(first)); + assert!(!s.doc().unwrap().contains(second)); + assert!(!s.undo().unwrap(), "the oldest step was dropped"); +} + #[test] fn failed_group_keeps_the_redo_steps() { let mut s = Session::new(); diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index a354ea27..abbce5bb 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -204,6 +204,8 @@ impl Session { self.ds()?; let before: Vec<_> = self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect(); + // An undo inside pops the undo steps; a failed group has to bring them back. + let undo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.undo.clone()).collect(); // An edit inside clears the redo steps; a failed group has to bring them back. let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect(); let active = self.active; @@ -233,13 +235,13 @@ impl Session { }; self.docs.extend(tab); } - for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) { + for (((uid, doc, selection, page, _, limit), undo), redo) in before.iter().zip(undo_before).zip(redo_before) { if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) { ds.history.redo = redo; ds.doc = doc.clone(); ds.selection = selection.clone(); ds.page = *page; - ds.history.undo.truncate(*undo_len); + ds.history.undo = undo; ds.history.limit = *limit; } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba..ed2ad933 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (a failed batch keeps the undo steps it undid (#1410); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Undo kept after a failed batch (M8, #1410): a batch that undid an earlier edit and then failed restored the document but lost that edit's undo step; a failed group now restores the undo entries as well as the redo entries, so the earlier edit can still be undone and redone. Regression tests in the batch rollback suite. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | From 3b78ca9558278261cee2370cc399e99237c2b217 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:57:38 +0100 Subject: [PATCH 11/26] Treat a % suffix on node.set opacity as a percentage Found while testing crates/engine/src/cmd/props.rs:476-479 (the "opacity" arm), with crates/engine/src/automation/params.rs:14-20 (f64_of) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/props.rs | 3 ++- crates/engine/src/panels_behaviour_tests.rs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a10393..723dd717 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -475,7 +475,8 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?, "opacity" => { let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; - let o = if o > 1.0 { o / 100.0 } else { o }; + let percent = v.as_str().is_some_and(|s| s.trim().ends_with('%')); + let o = if percent || o > 1.0 { o / 100.0 } else { o }; doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32; } "blend" | "blendMode" => { diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index 9870aa31..202fc4f2 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -475,3 +475,14 @@ fn page_navigation_skips_dividers() { ex(&mut s, "view.nextPage", json!({})); assert_eq!(s.page().unwrap(), later, "Next page stops on the last page"); } + +#[test] +fn opacity_percent_strings_always_divide_by_100() { + let mut s = Session::new(); + let r = rect(&mut s, "R", 0.0, 0.0, None); + for (v, want) in [(json!("1%"), 0.01), (json!("0.5%"), 0.005), (json!("50%"), 0.5), (json!(1), 1.0), (json!(50), 0.5), (json!(0.25), 0.25)] { + ex(&mut s, "node.set", json!({"id": r, "props": {"opacity": v.clone()}})); + let o = s.doc().unwrap().node(r).unwrap().opacity; + assert!((o - want).abs() < 1e-6, "opacity {v} gave {o}, want {want}"); + } +} From 9d4a3c6d8674e3916c401c26da9b178c68ba81b6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:15:17 +0000 Subject: [PATCH 12/26] Reject colour arrays containing non-numeric entries in color_of Found while testing crates/engine/src/automation/params.rs:170 (color_of, Value::Array arm) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/automation/params.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/automation/params.rs b/crates/engine/src/automation/params.rs index 35a74e0e..222348a4 100644 --- a/crates/engine/src/automation/params.rs +++ b/crates/engine/src/automation/params.rs @@ -167,7 +167,7 @@ pub fn color_of(v: &Value) -> Option { Value::String(s) => Color::parse_css(s), Value::Object(_) => serde_json::from_value::(v.clone()).ok().map(Color::sanitized), Value::Array(a) => { - let c: Vec = a.iter().filter_map(|x| x.as_f64()).map(|f| f as f32).collect(); + let c = a.iter().map(|x| x.as_f64().map(|f| f as f32)).collect::>>()?; match c.as_slice() { [r, g, b] => Some(Color::new(*r, *g, *b, 1.0)), [r, g, b, a] => Some(Color::new(*r, *g, *b, *a)), @@ -278,3 +278,16 @@ pub fn parse(cmd: &str, p: &Value, key: &str) -> Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))), } } + +#[cfg(test)] +mod color_of_tests { + use super::*; + use serde_json::json; + + #[test] + fn color_of_rejects_non_numeric_array_entries() { + assert_eq!(color_of(&json!([1, "x", 0, 0])), None); + assert_eq!(color_of(&json!([1, 0, 0, "x"])), None); + assert_eq!(color_of(&json!([1, 0, 0, 0.5])), Some(Color::new(1.0, 0.0, 0.0, 0.5))); + } +} From d4471376caf73b5a35f9afb3ce11fa70fa63dc82 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Tue, 27 Oct 2026 21:50:46 +0800 Subject: [PATCH 13/26] fix(engine): honor selected frame for file thumbnails (#1428) --- crates/engine/src/cmd/file/save.rs | 198 +++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs index 593c4c21..e2716bc5 100644 --- a/crates/engine/src/cmd/file/save.rs +++ b/crates/engine/src/cmd/file/save.rs @@ -3,19 +3,38 @@ use super::load::{ext, file_stem, write}; use super::open::remember; use super::*; -/// Thumbnail PNG of the first page's content (for file browsers / the home screen). +/// Thumbnail PNG of the chosen frame, or the first page's content (file browsers / home screen). pub fn thumbnail(doc: &Document) -> Option> { - let page = doc.first_page()?; - let b = doc.page_content_bounds(page)?; - let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); - let view = figcraft_render::View { - origin: (b.x0, b.y0), - scale, - width: (b.width() * scale).ceil().max(1.0) as u32, - height: (b.height() * scale).ceil().max(1.0) as u32, + let render = |page, b: figcraft_geom::Rect, solo| { + let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; + let opts = figcraft_render::Options { solo, ..Default::default() }; + let pix = figcraft_render::render_view(doc, page, &view, &opts)?; + figcraft_render::encode_png(&pix).ok() }; - let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; - figcraft_render::encode_png(&pix).ok() + doc.ids() + .find_map(|id| { + let n = doc.node(id)?; + if !matches!(n.kind, NodeKind::Frame) || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") { + return None; + } + // Hidden library-store pages are not document pages. + let page = doc.page_of(id).filter(|p| doc.pages.contains(p))?; + let b = figcraft_render::export_bounds(doc, id)?; + if ![b.x0, b.y0, b.x1, b.y1, b.width(), b.height()].into_iter().all(f64::is_finite) || b.width() <= 0.0 || b.height() <= 0.0 { + return None; + } + render(page, b, Some(id)) + }) + .or_else(|| { + let page = doc.first_page()?; + render(page, doc.page_content_bounds(page)?, None) + }) } /// The `.figcraft` file for a document: content, thumbnail and version history. @@ -223,3 +242,160 @@ pub(super) fn save_as(s: &mut Session, p: &Value) -> Result { } } } + +#[cfg(test)] +mod thumbnail_tests { + use super::*; + use figcraft_color::{Color, Paint}; + use figcraft_geom::Affine; + + const RED: Color = Color::from_rgb8_const(255, 0, 0); + const GREEN: Color = Color::from_rgb8_const(0, 255, 0); + const BLUE: Color = Color::from_rgb8_const(0, 0, 255); + + fn scene() -> (Session, NodeId, NodeId) { + let mut doc = Document::new("Thumbnail"); + let page = doc.first_page().unwrap(); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap(); + let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap(); + doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)]; + doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)]; + (Session::with_document(doc), cover, wide) + } + + fn choose(s: &mut Session, id: NodeId) { + s.execute("object.setThumbnail", &json!({"id": id})).unwrap(); + } + + fn picture(doc: &Document) -> image::RgbaImage { + image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8() + } + + #[test] + fn chosen_frame_draws_its_children_without_siblings_or_document_changes() { + let (mut s, cover, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.first_page().unwrap(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)]; + // Cropping the whole page to Cover's bounds would still draw this overlapping sibling. + let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap(); + doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)]; + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (200, 200)); + assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]); + assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn"); + assert_eq!(s.doc().unwrap(), &before); + s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap(); + assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents"); + } + + #[test] + fn no_thumbnail_choice_keeps_the_first_page_content_bounds() { + let (s, _, _) = scene(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 31)); + assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]); + } + + #[test] + fn invalid_flags_and_non_frames_keep_the_default_thumbnail() { + let (mut s, cover, wide) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + for value in ["false", "TRUE", ""] { + s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into()); + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}"); + } + choose(&mut s, wide); + s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails"); + } + + #[test] + fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + s.doc_mut().unwrap().remove(cover).unwrap(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 67)); + assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]); + } + + #[test] + fn invalid_thumbnail_ranges_and_pages_fall_back() { + let (mut s, _, _) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Invalid cover"); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap(); + choose(&mut s, cover); + for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}"); + } + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(cover).unwrap(); + n.width = 100.0; + n.height = 100.0; + n.parent = Some(NodeId(u64::MAX)); + assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page"); + doc.get_mut(cover).unwrap().parent = Some(page); + doc.pages.retain(|p| *p != page); + assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list"); + } + + #[test] + fn a_nested_frame_on_another_page_uses_its_page_background() { + let (mut s, _, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Cover page"); + if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind { + *background = GREEN; + } + let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap(); + let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap(); + doc.get_mut(cover).unwrap().fills.clear(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)]; + choose(&mut s, cover); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (160, 80)); + assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]); + assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]); + } + + #[test] + fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(picture(s.doc().unwrap()).dimensions(), want); + } + } + + #[test] + fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let expected = picture(s.doc().unwrap()); + let native = file_bytes(s.ds().unwrap()).unwrap(); + let (fig, _) = super::super::export::fig_bytes(s.doc().unwrap()).unwrap(); + for bytes in [native, fig] { + let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap(); + let actual = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(actual.dimensions(), (200, 200)); + assert_eq!(actual, expected); + } + assert_eq!(s.doc().unwrap(), &before); + } +} From 2d02f289ec849d26c1ad4a461c3a3a956e70fcb6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:16:00 +0100 Subject: [PATCH 14/26] Keep session joins working when a document has out-of-range ids Fixes #1067 Co-Authored-By: Claude Opus 5.5 --- crates/collab/src/entry_ids_tests.rs | 31 ++++++++++++++++++++++++++++ crates/collab/src/server.rs | 16 +++++++++----- crates/doc/src/ids.rs | 5 +++-- crates/doc/src/ids_tests.rs | 4 ++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/collab/src/entry_ids_tests.rs b/crates/collab/src/entry_ids_tests.rs index db58bb2f..976d2cb6 100644 --- a/crates/collab/src/entry_ids_tests.rs +++ b/crates/collab/src/entry_ids_tests.rs @@ -92,6 +92,37 @@ fn client_ids_skip_spaces_used_by_styles_and_comments() { assert_eq!(join(&mut server).id(), 10); } +#[test] +fn an_id_in_the_top_space_does_not_block_joins() { + let mut d = Document::new("a"); + d.styles.push(style(format!("{STYLE_ID_PREFIX}{}", u64::MAX), "Out of range")); + let c = comment(u64::from(u32::MAX) << 32, &d); + d.comments.push(c); + let mut server = Server::new(d, "", Limits::default()); + let a = join(&mut server); + assert!(a.id() > 0 && a.id() < u32::MAX, "{}", a.id()); + // After a reload the session still takes newcomers and keeps the document as it was. + let state = server.state().unwrap(); + let mut restored = Server::restore(state.clone(), "", Limits::default()).unwrap(); + let b = join(&mut restored); + assert!(b.id() > a.id() && b.id() < u32::MAX, "{}", b.id()); + assert_eq!(restored.document().styles.len(), 1); + assert_eq!(restored.document().comments.len(), 1); + // A state saved while the counter was stuck at the top recovers too. + let stuck = crate::SessionState { next_client: u32::MAX, ..state }; + let mut restored = Server::restore(stuck, "", Limits::default()).unwrap(); + let c = join(&mut restored); + assert!(c.id() > a.id() && c.id() < u32::MAX, "{}", c.id()); + // A new entry may not take an id from a space nobody was given yet. + for (k, space) in [c.id() + 1, c.id() + 1000, u32::MAX].into_iter().enumerate() { + let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1); + let ServerMsg::Reject { reason, .. } = send(&mut restored, c.id(), 1 + k as u64, vec![put_style(id.clone())]) else { + panic!("accepted {id}") + }; + assert!(reason.contains("id space"), "{reason}"); + } +} + /// Each client makes `rounds` styles, collections (with an extra mode), variables and comments /// before seeing anyone else's. fn create_everything(doc: &mut Document, who: usize, round: usize) { diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 52f112ad..54089af6 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -153,7 +153,13 @@ impl Server { server.state = replica; server.session = state.session; server.seq = state.seq; - server.next_client = server.next_client.max(state.next_client); + // A counter stuck at the top (saved while an out-of-range id in the document blocked + // every join) carries on above the ids actually handed out. + let saved = match state.next_client { + u32::MAX => state.issued.keys().chain(state.log.iter().map(|a| &a.author)).max().map_or(0, |c| c.saturating_add(1)), + n => n, + }; + server.next_client = server.next_client.max(saved); server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect(); server.deleted = state.deleted; server.removed = state.removed; @@ -481,9 +487,9 @@ impl Server { /// The document-level entries (styles, variables, collections, comments) a batch adds or /// removes, each with whether it is there afterwards. A new one is refused when its id lies in - /// the space of another client this session gave an id to (unless it was removed before: undo - /// puts entries back); ids from before the session (space 0, earlier sessions, other files) - /// are fine. + /// the space of another client this session gave an id to, or of one it hasn't given out yet + /// (unless it was removed before: undo puts entries back); ids from before the session (space + /// 0, earlier sessions, other files) are fine. fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result, Error> { let before = self.state.document(); let mut touched: BTreeSet = BTreeSet::new(); @@ -510,7 +516,7 @@ impl Server { } if now && !self.removed.contains(&entry) - && let Some(space) = entry.id_space().filter(|s| *s != from && self.issued.contains_key(s)) + && let Some(space) = entry.id_space().filter(|s| *s != from && (self.issued.contains_key(s) || *s >= self.next_client)) { return Err(Error::Invalid(format!("new {} isn't in client {from}'s id space (it is client {space}'s)", entry.describe()))); } diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs index 024946bb..7e244fbb 100644 --- a/crates/doc/src/ids.rs +++ b/crates/doc/src/ids.rs @@ -128,7 +128,8 @@ impl Document { /// The highest client id space any id in the document lies in (layers, comments, styles, /// variables, collections, modes): a session hands out client ids above it, so ids made in - /// earlier sessions are never made again. + /// earlier sessions are never made again. Space `u32::MAX` doesn't count: numbers too big for + /// any client's space land there ([`space_of`]) and no session hands that client id out. pub fn highest_id_space(&self) -> u32 { let nodes = self.ids().map(|id| id.0); let comments = self.comments.iter().map(|c| c.id); @@ -136,6 +137,6 @@ impl Document { let vars = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)); let colls = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)); let modes = self.variables.collections.iter().flat_map(|c| c.modes.iter().filter_map(|m| mode_number(&m.id))); - nodes.chain(comments).chain(styles).chain(vars).chain(colls).chain(modes).map(space_of).max().unwrap_or(0) + nodes.chain(comments).chain(styles).chain(vars).chain(colls).chain(modes).map(space_of).filter(|s| *s != u32::MAX).max().unwrap_or(0) } } diff --git a/crates/doc/src/ids_tests.rs b/crates/doc/src/ids_tests.rs index 4f489886..7c6a50e0 100644 --- a/crates/doc/src/ids_tests.rs +++ b/crates/doc/src/ids_tests.rs @@ -103,6 +103,10 @@ fn the_highest_space_counts_every_kind_of_id() { assert_eq!(d.highest_id_space(), 9); d.comments.push(comment(11u64 << 32, d.first_page().unwrap())); assert_eq!(d.highest_id_space(), 11); + // Numbers past every client's space (they saturate to `u32::MAX`) are no session's. + d.styles.push(style(&format!("S:{}", u64::MAX))); + d.comments.push(comment(u64::from(u32::MAX) << 32, d.first_page().unwrap())); + assert_eq!(d.highest_id_space(), 11); // Other forms are space 0. assert_eq!(id_space("lib:S:99999999999", "S:"), 0); assert_eq!(id_space("S:+5", "S:"), 0); From 219a9ea0b1428f2b1afbe23aaaed6d10761c195c Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Wed, 28 Oct 2026 09:29:22 +0100 Subject: [PATCH 15/26] M4: require FILL styles for vector region fill links --- crates/format/src/fig/map.rs | 2 +- crates/format/src/fig/tail_tests.rs | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 790f4b3e..c015b3f0 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1826,7 +1826,7 @@ impl<'a> Mapper<'a> { } let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID") .and_then(|id| self.nodes.get(&id).copied()) - .filter(|style| style.get("styleType").and_then(Kv::str).is_some()); + .filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL")); match linked_style { Some(style) => style.get("fillPaints"), None => Some(own_fills), diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 82dc3dd2..0f869593 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -283,6 +283,10 @@ fn region_canvas(fill_paints: Option>>, style_id: u32) -> Vec>, link_target: Option, linked_fill: Kv<'static>) -> Vec { + region_canvas_with_typed_link(own, link_target, "FILL", linked_fill) +} + +fn region_canvas_with_typed_link(own: Option>, link_target: Option, style_type: &'static str, linked_fill: Kv<'static>) -> Vec { 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(); @@ -295,7 +299,7 @@ fn region_canvas_with_link(own: Option>, link_target: Option, l if let Some(target) = link_target { fields.push(("styleIdForFill", o(vec![("guid", g(target))]))); } - nodes.push(node(50, None, "RECTANGLE", "Linked Fill", vec![("styleType", e("FILL")), ("fillPaints", arr(vec![linked_fill]))])); + nodes.push(node(50, None, "RECTANGLE", "Linked Fill", vec![("styleType", e(style_type)), ("fillPaints", arr(vec![linked_fill]))])); let mut network = network::decode(&square_blob()).unwrap(); network.regions[0].style = 1; let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() }; @@ -394,6 +398,15 @@ fn vector_region_missing_fills_inherit_and_unresolved_link_keeps_own_fill() { assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9)); } +#[test] +fn vector_region_link_to_non_fill_style_keeps_own_fill() { + let own = solid(0.1, 0.2, 0.9); + let (doc, _) = import(®ion_canvas_with_typed_link(Some(own), Some(50), "EFFECT", solid(0.9, 0.1, 0.2)), "Effect-linked region").unwrap(); + let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap(); + let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") }; + assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9)); +} + #[test] fn vector_regions_keep_distinct_solid_paints() { let mut message = tail_message(); From faa96926f4bf396795b79c375ae9271f461c436d Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Wed, 28 Oct 2026 11:59:53 +0100 Subject: [PATCH 16/26] M4: keep stroked whole-shape merges and bound region paints per file Merging every region of one stroked shape is a no-op again: only fill parts count, so a vector with several painted regions still merges. The region paint import budget now covers the whole file instead of resetting for each layer, so instances of a main with large region paint stacks cannot multiply the work. --- crates/engine/src/canvas/shapebuilder.rs | 5 ++- .../shapebuilder_region_paints_tests.rs | 20 ++++++++++ .../engine/src/shapebuilder_strokes_tests.rs | 14 +++++++ crates/format/src/fig/map.rs | 4 ++ crates/format/src/fig/map/layer.rs | 7 ++-- crates/format/src/fig/tail_tests.rs | 37 +++++++++++++++++++ 6 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs index ea02ccc8..19e2ec59 100644 --- a/crates/engine/src/canvas/shapebuilder.rs +++ b/crates/engine/src/canvas/shapebuilder.rs @@ -415,8 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { // Merging every region of a single shape would only replace it with a copy of itself. let whole_shape = touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x))); - let single_fill_part = touched_parts.len() == 1 && touched_parts.iter().all(|part| !part.stroke); - if merge && whole_shape && single_fill_part { + // A vector's distinct region paints are separate fill parts that merging does unify. + let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count(); + if merge && whole_shape && fill_parts <= 1 { return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick})); } let doc = s.doc_mut()?; diff --git a/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs b/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs index d1b747c3..74e45093 100644 --- a/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs +++ b/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs @@ -285,3 +285,23 @@ fn removal_preserves_holes_and_transformed_gradient_frames() { assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled"); assert_same_transform(before, normalized_paint_world(&s, vector, 0)); } + +#[test] +fn removing_the_left_region_keeps_the_right_region_paint_frame() { + let mut s = Session::new(); + let vector = split_vector(&mut s); + { + let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") }; + network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]); + } + let before = normalized_paint_world(&s, vector, 1); + s.set_selection(vec![vector]); + ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]})); + let node = s.doc().unwrap().node(vector).unwrap(); + assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width); + let net = network(&s, vector); + assert_eq!(net.regions.len(), 1); + assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_))); + assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6); + assert_same_transform(before, normalized_paint_world(&s, vector, 0)); +} diff --git a/crates/engine/src/shapebuilder_strokes_tests.rs b/crates/engine/src/shapebuilder_strokes_tests.rs index 19aa2c9c..8bebfb0f 100644 --- a/crates/engine/src/shapebuilder_strokes_tests.rs +++ b/crates/engine/src/shapebuilder_strokes_tests.rs @@ -166,6 +166,20 @@ fn a_centred_stroke_on_a_sharp_star_makes_three_regions() { assert_eq!(covers, vec![(false, true), (true, false), (true, true)], "fill only, stroke only, both"); } +#[test] +fn merging_every_region_of_one_stroked_shape_is_a_no_op() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}}))); + ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}})); + s.set_selection(vec![r]); + let count = ex(&mut s, "shapeBuilder.regions", json!({}))["regions"].as_array().unwrap().len(); + assert!(count > 1, "fill and stroke make several regions"); + let before = s.doc().unwrap().clone(); + let out = ex(&mut s, "shapeBuilder.merge", json!({"regions": (0..count).collect::>()})); + assert!(out["id"].is_null(), "{out}"); + assert_eq!(*s.doc().unwrap(), before); +} + #[test] fn stroke_regions_survive_junk() { for j in [ diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 06cfdb8b..546f6eb3 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -248,6 +248,9 @@ struct Mapper<'a> { networks: Vec>, /// 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>, + /// 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, } impl<'a> Mapper<'a> { @@ -322,6 +325,7 @@ impl<'a> Mapper<'a> { reserved: 0, networks: Vec::new(), network_ids: HashMap::new(), + region_paint_elements: 0, } } diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index 555907aa..6178a238 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -526,7 +526,6 @@ impl<'a> Mapper<'a> { .collect(); let segments: Vec = net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let mut region_paint_elements = 0usize; let regions: Vec = net .regions .iter() @@ -554,12 +553,12 @@ impl<'a> Mapper<'a> { }); let oversized_stops = source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256); - if source_paints.len() > 256 || oversized_stops || region_paint_elements.saturating_add(paint_cost) > 200_000 { + if source_paints.len() > 256 || oversized_stops || self.region_paint_elements.saturating_add(paint_cost) > 200_000 + { self.warn("vector region paint data exceeded import limits and was skipped"); - region_paint_elements = 200_000; return region; } - region_paint_elements += paint_cost; + self.region_paint_elements += paint_cost; let paints: Vec = source_paints.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect(); if paints.len() != source_paints.len() { self.warn("invalid vector region paints were skipped"); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 19b0b871..0e6d32bc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -407,6 +407,43 @@ fn vector_region_link_to_non_fill_style_keeps_own_fill() { assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9)); } +#[test] +fn vector_region_paint_budget_covers_the_whole_file() { + // 190 gradients of 255 stops: 48,640 elements per layer, so four layers fit and a fifth doesn't. + let stop = o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]); + let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(vec![stop; 255]))]); + 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 vector_data = field_mut(wave, "vectorData").unwrap(); + let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() }; + let Kv::Object(style_fields) = styles.first_mut().unwrap() else { panic!() }; + style_fields.push(("fillPaints", arr(vec![gradient; 190]))); + let wave = wave.clone(); + for (index, position) in ["w", "x", "y", "z"].into_iter().enumerate() { + let mut copy = wave.clone(); + let Kv::Object(fields) = &mut copy else { panic!() }; + for (key, value) in fields.iter_mut() { + match *key { + "guid" => *value = g(60 + index as u64), + "name" => *value = Kv::Str(format!("Wave {index}")), + "parentIndex" => *value = o(vec![("guid", g(1)), ("position", s(position))]), + _ => {} + } + } + nodes.push(copy); + } + let mut network = network::decode(&square_blob()).unwrap(); + network.regions[0].style = 1; + let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() }; + *field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network)); + + let (doc, warnings) = import(&canvas(&schema(), &message), "Region paint budget").unwrap(); + let painted = ["Wave", "Wave 0", "Wave 1", "Wave 2", "Wave 3"].iter().filter(|name| network_of(&doc, name).regions[0].fills.is_some()).count(); + assert_eq!(painted, 4); + assert!(warnings.iter().any(|warning| warning.contains("region paint data exceeded import limits")), "{warnings:?}"); +} + #[test] fn vector_regions_keep_distinct_solid_paints() { let mut message = tail_message(); From 2199dcb2da7116fa1b7dacd6ace836929191abeb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:04:10 +0000 Subject: [PATCH 17/26] Reject non-numeric paddingHorizontal/paddingVertical in node.set Found while testing crates/engine/src/cmd/props.rs:623-634 (the "paddingHorizontal" and "paddingVertical" arms) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/layout.rs | 13 +++++++++++++ crates/engine/src/cmd/props.rs | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5a0a4000..22f50b0f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -181,6 +181,19 @@ mod tests { assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err()); } + #[test] + fn junk_axis_padding_is_refused_and_keeps_padding() { + let mut s = Session::new(); + let (f, _, _) = al_frame(&mut s); + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 16}})); + for key in ["paddingHorizontal", "paddingVertical"] { + for junk in [json!("abc"), json!({"a": 1}), json!([1, 2])] { + assert!(s.execute("node.set", &json!({"id": f, "props": {key: junk}})).is_err(), "{key}"); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [16.0; 4], "{key}"); + } + } + } + #[test] fn baseline_alignment_lines_up_text() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a10393..8c09a9b6 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -621,13 +621,13 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { - let val = f64_of(v).unwrap_or(0.0).max(0.0); + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); let l = layout_mut(cmd, k, doc.get_mut(id)?)?; l.padding[1] = val; l.padding[3] = val; } "paddingVertical" => { - let val = f64_of(v).unwrap_or(0.0).max(0.0); + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); let l = layout_mut(cmd, k, doc.get_mut(id)?)?; l.padding[0] = val; l.padding[2] = val; From 877ac1fd5eed0f4cf7bfc97358f423bffe0e7604 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:38:32 +0100 Subject: [PATCH 18/26] Reject non-page ids in page.duplicate Found while testing crates/engine/src/cmd/page.rs:96 (page.duplicate, `let src = id(p, "id").unwrap_or(s.page()?)`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/page.rs | 2 +- crates/engine/src/page_target_tests.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 08e65b0b..dab21122 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { ok() }), spec!("page.duplicate", "Duplicate page", "{id?}", true, |s, p| { - let src = id(p, "id").unwrap_or(s.page()?); + let src = page_target(s, "page.duplicate", p)?; let doc = s.doc_mut()?; let name = format!("Copy of {}", doc.get(src)?.name); let np = doc.add_page(&name); diff --git a/crates/engine/src/page_target_tests.rs b/crates/engine/src/page_target_tests.rs index fd88685d..348ffdf3 100644 --- a/crates/engine/src/page_target_tests.rs +++ b/crates/engine/src/page_target_tests.rs @@ -61,3 +61,13 @@ fn page_commands_still_work_on_pages() { assert!(!s.doc().unwrap().contains(second)); assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays"); } + +#[test] +fn page_duplicate_rejects_a_frame() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let pages = s.doc().unwrap().pages.clone(); + let err = s.execute("page.duplicate", &json!({"id": f})).expect_err("duplicate refuses a layer"); + assert!(err.to_string().contains("is not a page"), "{err}"); + assert_eq!(s.doc().unwrap().pages, pages, "no page was added"); +} From 71571cccad17c95d8b87ffe5c460493ff2d7b272 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:34:52 +0100 Subject: [PATCH 19/26] Keep the right edge of right-aligned auto-width text as it grows Fixes #1477 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/derived.rs | 14 ++ crates/engine/src/text_behaviour_tests.rs | 165 ++++++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs index b6a001b8..362ef79f 100644 --- a/crates/engine/src/derived.rs +++ b/crates/engine/src/derived.rs @@ -54,7 +54,12 @@ pub fn normalize(doc: &mut Document, before: &Document) { if let Some((w, h)) = figcraft_text::auto_size(n) && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) { + let dw = w - n.width; + let anchor_right = keeps_right_edge(doc, before, n); figcraft_layout::set_size(doc, id, w, h); + if anchor_right && let Some(n) = doc.node_mut(id) { + n.transform *= figcraft_geom::kurbo::Affine::translate((-dw, 0.0)); + } } } touched.extend(doc.take_dirty()); @@ -139,6 +144,15 @@ pub fn normalize(doc: &mut Document, before: &Document) { cmd::comment::follow_anchors(doc, before); } +/// Whether auto-width text `n` keeps its right edge when its width changes: it was already +/// right-aligned auto-width text before the edit and no auto layout flow places it. +fn keeps_right_edge(doc: &Document, before: &Document, n: &Node) -> bool { + use figcraft_doc::{TextAlignH, TextAutoResize}; + let right_auto = |n: &Node| n.kind.text().is_some_and(|t| t.align_h == TextAlignH::Right && t.auto_resize == TextAutoResize::WidthAndHeight); + let in_flow = !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && p.kind.is_frame_like()); + right_auto(n) && before.node(n.id).is_some_and(right_auto) && !in_flow +} + /// Whether layer `id` changed (since `before`) in a way that can move or resize layers in an auto /// layout frame around it: new, resized, moved, shown or hidden, restructured, other text or /// layout settings. diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 8b33e303..eac41f51 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -370,3 +370,168 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() { s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap(); assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate); } + +/// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of +/// 1000 units wide), loaded into the font database so text measures the same on every machine. +fn test_mono_font() -> &'static str { + const FAMILY: &str = "FigCraft Test Mono"; + static LOADED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + LOADED.get_or_init(|| { + let families = figcraft_text::load_font_bytes(synthetic_mono_font(FAMILY)); + assert_eq!(families, [FAMILY], "the synthetic font loads"); + }); + FAMILY +} + +fn synthetic_mono_font(family: &str) -> Vec { + let be16 = |v: &mut Vec, x: u16| v.extend_from_slice(&x.to_be_bytes()); + let glyphs: u16 = 1 + (0x7E - 0x20 + 1); + let mut head = Vec::new(); + for x in [0x0001_0000u32, 0x0001_0000, 0, 0x5F0F_3CF5] { + head.extend_from_slice(&x.to_be_bytes()); + } + be16(&mut head, 0); // flags + be16(&mut head, 1000); // unitsPerEm + head.extend_from_slice(&[0; 16]); // created, modified + for x in [0u16, (-200i16) as u16, 500, 800, 0, 8, 2, 0, 0] { + be16(&mut head, x); // bbox, macStyle, lowestRecPPEM, direction hint, short loca, glyph format + } + let mut hhea = 0x0001_0000u32.to_be_bytes().to_vec(); + for x in [800u16, (-200i16) as u16, 0, 500, 0, 0, 500, 1, 0, 0, 0, 0, 0, 0, 0, glyphs] { + be16(&mut hhea, x); + } + let mut maxp = 0x0001_0000u32.to_be_bytes().to_vec(); + be16(&mut maxp, glyphs); + for x in [0u16, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] { + be16(&mut maxp, x); + } + let mut hmtx = Vec::new(); + for _ in 0..glyphs { + be16(&mut hmtx, 500); + be16(&mut hmtx, 0); + } + let loca = vec![0; 2 * (usize::from(glyphs) + 1)]; + let glyf = vec![0; 4]; + // cmap: one Windows Unicode BMP subtable, format 4, 0x20..=0x7E → glyphs 1.. plus the end segment. + let mut cmap = Vec::new(); + for x in [0u16, 1, 3, 1, 0, 12] { + be16(&mut cmap, x); + } + for x in [4u16, 32, 0, 4, 4, 1, 0, 0x7E, 0xFFFF, 0, 0x20, 0xFFFF, (1i16 - 0x20) as u16, 1, 0, 0] { + be16(&mut cmap, x); + } + let strings = [(1u16, family.to_string()), (2, "Regular".into()), (6, family.replace(' ', "") + "-Regular")]; + let mut name = Vec::new(); + be16(&mut name, 0); + be16(&mut name, strings.len() as u16); + be16(&mut name, 6 + 12 * strings.len() as u16); + let mut storage = Vec::new(); + for (id, s) in &strings { + let utf16: Vec = s.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for x in [3u16, 1, 0x409, *id, utf16.len() as u16, storage.len() as u16] { + be16(&mut name, x); + } + storage.extend(utf16); + } + name.extend(storage); + let mut post = 0x0003_0000u32.to_be_bytes().to_vec(); + post.extend_from_slice(&[0; 28]); + let tables: [(&[u8; 4], Vec); 9] = [ + (b"cmap", cmap), + (b"glyf", glyf), + (b"head", head), + (b"hhea", hhea), + (b"hmtx", hmtx), + (b"loca", loca), + (b"maxp", maxp), + (b"name", name), + (b"post", post), + ]; + let mut font = 0x0001_0000u32.to_be_bytes().to_vec(); + for x in [tables.len() as u16, 128, 3, 16] { + be16(&mut font, x); + } + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in &tables { + font.extend_from_slice(*tag); + font.extend_from_slice(&0u32.to_be_bytes()); + font.extend_from_slice(&(offset as u32).to_be_bytes()); + font.extend_from_slice(&(data.len() as u32).to_be_bytes()); + offset += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + font.extend_from_slice(data); + font.resize(font.len().next_multiple_of(4), 0); + } + font +} + +/// The world position of a text's top-right corner (where right-aligned text is anchored). +fn top_right(s: &Session, t: NodeId) -> (f64, f64) { + let doc = s.doc().unwrap(); + let p = doc.world_transform(t) * figcraft_geom::kurbo::Point::new(doc.node(t).unwrap().width, 0.0); + (p.x, p.y) +} + +fn assert_near(a: (f64, f64), b: (f64, f64), why: &str) { + assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{why}: {a:?} != {b:?}"); +} + +#[test] +fn right_aligned_auto_width_text_grows_and_shrinks_to_the_left() { + let font = test_mono_font(); + for (src, rotation) in [("ANCHOR TEST\nabc", 0.0), ("abc", 0.0), ("ANCHOR TEST\nabc", 90.0)] { + let mut s = Session::new(); + let t = text(&mut s, src, json!({"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"})); + s.execute("node.set", &json!({"id": t, "props": {"x": 200, "y": 50, "rotation": rotation}})).unwrap(); + let anchor = top_right(&s, t); + let w0 = size(&s, t).0; + let widest = src.lines().map(|l| l.chars().count()).max().unwrap_or(0); + assert!((w0 - 20.0 * widest as f64).abs() < 1e-6, "{src:?}: the test font measures 20 px a character, not {w0}"); + begin(&mut s, t, Some(src.chars().count()), false).unwrap(); + for c in "defghijklmnopqrstuvwxyz".chars() { + crate::textedit::insert(&mut s, &c.to_string()).unwrap(); + assert_near(top_right(&s, t), anchor, &format!("{src:?} at {rotation}°: typing {c}")); + } + assert!(size(&s, t).0 > w0 + 1e-6, "{src:?} at {rotation}°: typing grows the text"); + for _ in 0..23 { + key(&mut s, "Backspace", false, false, false).unwrap(); + assert_near(top_right(&s, t), anchor, &format!("{src:?} at {rotation}°: deleting")); + } + assert!((size(&s, t).0 - w0).abs() < 1e-6, "back to the starting width"); + } +} + +#[test] +fn right_aligned_auto_width_growth_undoes_to_its_old_place() { + let font = test_mono_font(); + let mut s = Session::new(); + let t = text(&mut s, "abc", json!({"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"})); + let before = (s.doc().unwrap().node(t).unwrap().transform, size(&s, t)); + let anchor = top_right(&s, t); + s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghijklmnop"}})).unwrap(); + assert_near(top_right(&s, t), anchor, "set characters"); + let after = (s.doc().unwrap().node(t).unwrap().transform, size(&s, t)); + assert!(after.1.0 > before.1.0 + 1e-6, "the text grew"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((s.doc().unwrap().node(t).unwrap().transform, size(&s, t)), before, "undo"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((s.doc().unwrap().node(t).unwrap().transform, size(&s, t)), after, "redo"); +} + +#[test] +fn auto_layout_places_right_aligned_auto_width_text() { + let font = test_mono_font(); + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"layout": "horizontal", "padding": 10, "gap": 0}})).unwrap(); + let t = id(s + .execute( + "text.create", + &json!({"text": "abc", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 40, "fontFamily": font, "textAlign": "right", "textAutoResize": "widthAndHeight"}}), + ) + .unwrap()); + s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghijklmnop"}})).unwrap(); + let c = s.doc().unwrap().node(t).unwrap().transform.as_coeffs(); + assert_eq!((c[4], c[5]), (10.0, 10.0), "the auto layout keeps it at its padding"); +} From d3551f3c6c37350ff9509dbc46a893581a049bb4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 09:26:09 +0700 Subject: [PATCH 20/26] #1432: accept components and instances as thumbnails; move the tests to thumbnail_tests.rs --- crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/file/save.rs | 159 +----------------- crates/engine/src/cmd/file/thumbnail_tests.rs | 155 +++++++++++++++++ 3 files changed, 159 insertions(+), 158 deletions(-) create mode 100644 crates/engine/src/cmd/file/thumbnail_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7c901293..576452c5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -432,5 +432,8 @@ mod rest_import_tests; #[cfg(test)] mod staging_write_tests; +#[cfg(test)] +mod thumbnail_tests; + #[cfg(test)] mod zip_cap_tests; diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs index e2716bc5..4b8939c7 100644 --- a/crates/engine/src/cmd/file/save.rs +++ b/crates/engine/src/cmd/file/save.rs @@ -20,7 +20,7 @@ pub fn thumbnail(doc: &Document) -> Option> { doc.ids() .find_map(|id| { let n = doc.node(id)?; - if !matches!(n.kind, NodeKind::Frame) || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") { + if !n.kind.is_frame_like() || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") { return None; } // Hidden library-store pages are not document pages. @@ -242,160 +242,3 @@ pub(super) fn save_as(s: &mut Session, p: &Value) -> Result { } } } - -#[cfg(test)] -mod thumbnail_tests { - use super::*; - use figcraft_color::{Color, Paint}; - use figcraft_geom::Affine; - - const RED: Color = Color::from_rgb8_const(255, 0, 0); - const GREEN: Color = Color::from_rgb8_const(0, 255, 0); - const BLUE: Color = Color::from_rgb8_const(0, 0, 255); - - fn scene() -> (Session, NodeId, NodeId) { - let mut doc = Document::new("Thumbnail"); - let page = doc.first_page().unwrap(); - let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap(); - let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap(); - doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)]; - doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)]; - (Session::with_document(doc), cover, wide) - } - - fn choose(s: &mut Session, id: NodeId) { - s.execute("object.setThumbnail", &json!({"id": id})).unwrap(); - } - - fn picture(doc: &Document) -> image::RgbaImage { - image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8() - } - - #[test] - fn chosen_frame_draws_its_children_without_siblings_or_document_changes() { - let (mut s, cover, _) = scene(); - let doc = s.doc_mut().unwrap(); - let page = doc.first_page().unwrap(); - let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap(); - doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)]; - // Cropping the whole page to Cover's bounds would still draw this overlapping sibling. - let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap(); - doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)]; - choose(&mut s, cover); - let before = s.doc().unwrap().clone(); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (200, 200)); - assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]); - assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]); - assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn"); - assert_eq!(s.doc().unwrap(), &before); - s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap(); - assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents"); - } - - #[test] - fn no_thumbnail_choice_keeps_the_first_page_content_bounds() { - let (s, _, _) = scene(); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (400, 31)); - assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]); - assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]); - } - - #[test] - fn invalid_flags_and_non_frames_keep_the_default_thumbnail() { - let (mut s, cover, wide) = scene(); - let default = thumbnail(s.doc().unwrap()).unwrap(); - for value in ["false", "TRUE", ""] { - s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into()); - assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}"); - } - choose(&mut s, wide); - s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle; - assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails"); - } - - #[test] - fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() { - let (mut s, cover, _) = scene(); - choose(&mut s, cover); - s.doc_mut().unwrap().remove(cover).unwrap(); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (400, 67)); - assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]); - } - - #[test] - fn invalid_thumbnail_ranges_and_pages_fall_back() { - let (mut s, _, _) = scene(); - let default = thumbnail(s.doc().unwrap()).unwrap(); - let doc = s.doc_mut().unwrap(); - let page = doc.add_page("Invalid cover"); - let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap(); - choose(&mut s, cover); - for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] { - let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); - n.width = width; - n.height = height; - assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}"); - } - let doc = s.doc_mut().unwrap(); - let n = doc.get_mut(cover).unwrap(); - n.width = 100.0; - n.height = 100.0; - n.parent = Some(NodeId(u64::MAX)); - assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page"); - doc.get_mut(cover).unwrap().parent = Some(page); - doc.pages.retain(|p| *p != page); - assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list"); - } - - #[test] - fn a_nested_frame_on_another_page_uses_its_page_background() { - let (mut s, _, _) = scene(); - let doc = s.doc_mut().unwrap(); - let page = doc.add_page("Cover page"); - if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind { - *background = GREEN; - } - let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap(); - let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap(); - doc.get_mut(cover).unwrap().fills.clear(); - let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap(); - doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)]; - choose(&mut s, cover); - let png = picture(s.doc().unwrap()); - assert_eq!(png.dimensions(), (160, 80)); - assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]); - assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]); - } - - #[test] - fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() { - let (mut s, cover, _) = scene(); - choose(&mut s, cover); - for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] { - let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); - n.width = width; - n.height = height; - assert_eq!(picture(s.doc().unwrap()).dimensions(), want); - } - } - - #[test] - fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() { - let (mut s, cover, _) = scene(); - choose(&mut s, cover); - let before = s.doc().unwrap().clone(); - let expected = picture(s.doc().unwrap()); - let native = file_bytes(s.ds().unwrap()).unwrap(); - let (fig, _) = super::super::export::fig_bytes(s.doc().unwrap()).unwrap(); - for bytes in [native, fig] { - let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap(); - let actual = image::load_from_memory(&png).unwrap().to_rgba8(); - assert_eq!(actual.dimensions(), (200, 200)); - assert_eq!(actual, expected); - } - assert_eq!(s.doc().unwrap(), &before); - } -} diff --git a/crates/engine/src/cmd/file/thumbnail_tests.rs b/crates/engine/src/cmd/file/thumbnail_tests.rs new file mode 100644 index 00000000..139fdc5a --- /dev/null +++ b/crates/engine/src/cmd/file/thumbnail_tests.rs @@ -0,0 +1,155 @@ +use super::*; +use figcraft_color::{Color, Paint}; +use figcraft_geom::Affine; + +const RED: Color = Color::from_rgb8_const(255, 0, 0); +const GREEN: Color = Color::from_rgb8_const(0, 255, 0); +const BLUE: Color = Color::from_rgb8_const(0, 0, 255); + +fn scene() -> (Session, NodeId, NodeId) { + let mut doc = Document::new("Thumbnail"); + let page = doc.first_page().unwrap(); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap(); + let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap(); + doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)]; + doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)]; + (Session::with_document(doc), cover, wide) +} + +fn choose(s: &mut Session, id: NodeId) { + s.execute("object.setThumbnail", &json!({"id": id})).unwrap(); +} + +fn picture(doc: &Document) -> image::RgbaImage { + image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8() +} + +#[test] +fn chosen_frame_draws_its_children_without_siblings_or_document_changes() { + let (mut s, cover, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.first_page().unwrap(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)]; + // Cropping the whole page to Cover's bounds would still draw this overlapping sibling. + let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap(); + doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)]; + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (200, 200)); + assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]); + assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn"); + assert_eq!(s.doc().unwrap(), &before); + s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap(); + assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents"); +} + +#[test] +fn no_thumbnail_choice_keeps_the_first_page_content_bounds() { + let (s, _, _) = scene(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 31)); + assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]); +} + +#[test] +fn invalid_flags_and_non_frames_keep_the_default_thumbnail() { + let (mut s, cover, wide) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + for value in ["false", "TRUE", ""] { + s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into()); + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}"); + } + choose(&mut s, wide); + s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails"); + s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Component { description: String::new(), props: Default::default() }; + assert_ne!(thumbnail(s.doc().unwrap()).unwrap(), default, "a component counts as a frame"); +} + +#[test] +fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + s.doc_mut().unwrap().remove(cover).unwrap(); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (400, 67)); + assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]); +} + +#[test] +fn invalid_thumbnail_ranges_and_pages_fall_back() { + let (mut s, _, _) = scene(); + let default = thumbnail(s.doc().unwrap()).unwrap(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Invalid cover"); + let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap(); + choose(&mut s, cover); + for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}"); + } + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(cover).unwrap(); + n.width = 100.0; + n.height = 100.0; + n.parent = Some(NodeId(u64::MAX)); + assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page"); + doc.get_mut(cover).unwrap().parent = Some(page); + doc.pages.retain(|p| *p != page); + assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list"); +} + +#[test] +fn a_nested_frame_on_another_page_uses_its_page_background() { + let (mut s, _, _) = scene(); + let doc = s.doc_mut().unwrap(); + let page = doc.add_page("Cover page"); + if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind { + *background = GREEN; + } + let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap(); + let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap(); + doc.get_mut(cover).unwrap().fills.clear(); + let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap(); + doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)]; + choose(&mut s, cover); + let png = picture(s.doc().unwrap()); + assert_eq!(png.dimensions(), (160, 80)); + assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]); + assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]); +} + +#[test] +fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] { + let n = s.doc_mut().unwrap().get_mut(cover).unwrap(); + n.width = width; + n.height = height; + assert_eq!(picture(s.doc().unwrap()).dimensions(), want); + } +} + +#[test] +fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() { + let (mut s, cover, _) = scene(); + choose(&mut s, cover); + let before = s.doc().unwrap().clone(); + let expected = picture(s.doc().unwrap()); + let native = file_bytes(s.ds().unwrap()).unwrap(); + let (fig, _) = super::export::fig_bytes(s.doc().unwrap()).unwrap(); + for bytes in [native, fig] { + let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap(); + let actual = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(actual.dimensions(), (200, 200)); + assert_eq!(actual, expected); + } + assert_eq!(s.doc().unwrap(), &before); +} From 08a36418a65bfa3ebad5a9d571ef6e5bf2bd2fa7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 09:31:37 +0700 Subject: [PATCH 21/26] #1444: accept imported entries from unissued spaces; reserve only the top half (NO_SESSION_SPACE) --- crates/collab/src/entry_ids_tests.rs | 21 +++++++++++++++++++-- crates/collab/src/server.rs | 14 ++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/collab/src/entry_ids_tests.rs b/crates/collab/src/entry_ids_tests.rs index 976d2cb6..72a147ae 100644 --- a/crates/collab/src/entry_ids_tests.rs +++ b/crates/collab/src/entry_ids_tests.rs @@ -6,6 +6,7 @@ use figcraft_doc::{Collection, Comment, Document, Message, Mode, Style, StyleKin use crate::change::{Change, Entry, Json}; use crate::message::{ClientMsg, ServerMsg}; +use crate::server::NO_SESSION_SPACE; use crate::sim::Sim; use crate::{Client, Limits, NoRecompute, Outgoing, Server}; @@ -113,8 +114,8 @@ fn an_id_in_the_top_space_does_not_block_joins() { let mut restored = Server::restore(stuck, "", Limits::default()).unwrap(); let c = join(&mut restored); assert!(c.id() > a.id() && c.id() < u32::MAX, "{}", c.id()); - // A new entry may not take an id from a space nobody was given yet. - for (k, space) in [c.id() + 1, c.id() + 1000, u32::MAX].into_iter().enumerate() { + // A new entry may not take an id from the reserved top half of the spaces. + for (k, space) in [NO_SESSION_SPACE, u32::MAX].into_iter().enumerate() { let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1); let ServerMsg::Reject { reason, .. } = send(&mut restored, c.id(), 1 + k as u64, vec![put_style(id.clone())]) else { panic!("accepted {id}") @@ -123,6 +124,22 @@ fn an_id_in_the_top_space_does_not_block_joins() { } } +#[test] +fn styles_imported_from_another_session_are_accepted_and_joins_go_on() { + let mut server = Server::new(Document::new("a"), "", Limits::default()); + let a = join(&mut server); + // `file.import` copies styles with their ids, made by clients this session hasn't given out + // (up to the highest space a member may use). + for (k, space) in [a.id() + 1, a.id() + 1000, NO_SESSION_SPACE - 1].into_iter().enumerate() { + let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1); + assert!(matches!(send(&mut server, a.id(), 1 + k as u64, vec![put_style(id.clone())]), ServerMsg::Changes(_)), "refused {id}"); + } + // After a restart client ids start above them, and there are plenty left. + let mut restored = Server::restore(server.state().unwrap(), "", Limits::default()).unwrap(); + assert_eq!(join(&mut restored).id(), NO_SESSION_SPACE); + assert_eq!(join(&mut Server::new(restored.document().clone(), "", Limits::default())).id(), NO_SESSION_SPACE); +} + /// Each client makes `rounds` styles, collections (with an extra mode), variables and comments /// before seeing anyone else's. fn create_everything(doc: &mut Document, who: usize, round: usize) { diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index c45b0ba6..d8aab594 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -25,6 +25,12 @@ pub enum Outgoing { /// Why a viewer's batch is refused. pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)"; +/// A new style, variable, collection or comment may not take an id in a space at or above this +/// that isn't its creator's. Client ids start above every space in the document after a restart, +/// so no member can use them up: at least this many always remain. Lower spaces stay open, for +/// entries imported from other files' sessions. +pub const NO_SESSION_SPACE: u32 = 1 << 31; + /// What changed a server's lasting state, in order: what a store appends to its log between /// snapshots ([`Server::journaling`]) and replays over the last snapshot after a restart /// ([`Server::replay`]). Presence and who is connected aren't in it. @@ -506,9 +512,9 @@ impl Server { /// The document-level entries (styles, variables, collections, comments) a batch adds or /// removes, each with whether it is there afterwards. A new one is refused when its id lies in - /// the space of another client this session gave an id to, or of one it hasn't given out yet - /// (unless it was removed before: undo puts entries back); ids from before the session (space - /// 0, earlier sessions, other files) are fine. + /// the space of another client this session gave an id to, or in a space at or above + /// [`NO_SESSION_SPACE`] (unless it was removed before: undo puts entries back); ids from + /// before the session (space 0, earlier sessions, other files) are fine. fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result, Error> { let before = self.state.document(); let mut touched: BTreeSet = BTreeSet::new(); @@ -535,7 +541,7 @@ impl Server { } if now && !self.removed.contains(&entry) - && let Some(space) = entry.id_space().filter(|s| *s != from && (self.issued.contains_key(s) || *s >= self.next_client)) + && let Some(space) = entry.id_space().filter(|s| *s != from && (self.issued.contains_key(s) || *s >= NO_SESSION_SPACE)) { return Err(Error::Invalid(format!("new {} isn't in client {from}'s id space (it is client {space}'s)", entry.describe()))); } From 069449e33c3164026b8637ddd68a0f70c79f4090 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 09:18:57 +0700 Subject: [PATCH 22/26] #1425: share fraction_of for opacity, fill opacity, innerRadius and arcInner --- crates/engine/src/automation/params.rs | 7 +++++++ crates/engine/src/cmd/edit.rs | 4 ++-- crates/engine/src/cmd/edit/props.rs | 8 ++++---- crates/engine/src/panels_behaviour_tests.rs | 15 +++++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/automation/params.rs b/crates/engine/src/automation/params.rs index 35a74e0e..f07e4d86 100644 --- a/crates/engine/src/automation/params.rs +++ b/crates/engine/src/automation/params.rs @@ -20,6 +20,13 @@ pub fn f64_of(v: &Value) -> Option { } } +/// A 0-1 fraction: a `%` suffix or a number above 1 is a percentage ("1%" is 0.01, 50 is 0.5). +pub fn fraction_of(v: &Value) -> Option { + let f = f64_of(v)?; + let percent = v.as_str().is_some_and(|s| s.trim().ends_with('%')); + Some(if percent || f > 1.0 { f / 100.0 } else { f }) +} + pub fn num(p: &Value, key: &str) -> Option { get(p, key).and_then(f64_of) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2d51b958..d24600c9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,8 +8,8 @@ use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; use crate::params::{ - blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string, - targets, + blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, + string, targets, }; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index 91855c2e..6d0deb29 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -1,6 +1,6 @@ use super::{ - BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index, - paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted, + BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, fraction_of, get, json, num, ok, + opt_index, paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted, }; pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result { @@ -312,8 +312,8 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + if let Some(o) = get(p, "opacity").and_then(fraction_of) { + slot.opacity = o.clamp(0.0, 1.0) as f32; slot.color_alpha = 1.0; } if let Some(v) = boolean(p, "visible") { diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index 202fc4f2..d97d2507 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -486,3 +486,18 @@ fn opacity_percent_strings_always_divide_by_100() { assert!((o - want).abs() < 1e-6, "opacity {v} gave {o}, want {want}"); } } + +#[test] +fn fill_opacity_star_ratio_and_arc_ratio_read_a_percent_suffix() { + let mut s = Session::new(); + let r = rect(&mut s, "R", 0.0, 0.0, None); + ex(&mut s, "fill.set", json!({"ids": [r], "color": "#ff0000", "opacity": "1%"})); + let fill = s.doc().unwrap().node(r).unwrap().fills[0].opacity; + assert!((fill - 0.01).abs() < 1e-6, "fill opacity \"1%\" gave {fill}"); + let star = id(&ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"innerRadius": "1%"}}))); + let figcraft_doc::NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") }; + assert!((inner_ratio - 0.01).abs() < 1e-9, "innerRadius \"1%\" gave {inner_ratio}"); + let arc = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"arcInner": "1%"}}))); + let figcraft_doc::NodeKind::Ellipse { inner, .. } = s.doc().unwrap().node(arc).unwrap().kind else { panic!("ellipse") }; + assert!((inner - 0.01).abs() < 1e-9, "arcInner \"1%\" gave {inner}"); +} From 23e16be7f5de8f2f295ed0f0d64ec32dc98b6317 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 09:36:10 +0700 Subject: [PATCH 23/26] #1398: share the member size check between JSON and images (capped_member) # Conflicts: # crates/format/src/sketch.rs --- crates/format/src/sketch.rs | 52 ++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/crates/format/src/sketch.rs b/crates/format/src/sketch.rs index 21cb7d2b..8186ce26 100644 --- a/crates/format/src/sketch.rs +++ b/crates/format/src/sketch.rs @@ -56,10 +56,10 @@ pub const NEWEST_TESTED_VERSION: u64 = 139; pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again"; /// Largest JSON member read (a real page of a few thousand layers takes 33 MB). const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024; -/// Most bytes JSON members may unpack to, per compressed byte (real files pack 5–20 times; a -/// zero-filled array a thousand times), with a floor for small members. -const MAX_JSON_RATIO: u64 = 100; -const MIN_JSON_ALLOWANCE: u64 = 16 * 1024 * 1024; +/// Most bytes a member may unpack to, per compressed byte (real JSON packs 5–20 times and images +/// barely; a zero-filled member a thousand times), with a floor for small members. +const MAX_RATIO: u64 = 100; +const MIN_ALLOWANCE: u64 = 16 * 1024 * 1024; /// Most JSON read from one file in all. #[cfg(not(target_pointer_width = "32"))] const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; @@ -67,10 +67,6 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; -/// Most bytes an image member may unpack to, per compressed byte (images are already compressed, -/// a zero-filled one packs a thousand times), with a floor for small members. -const MAX_IMAGE_RATIO: u64 = 100; -const MIN_IMAGE_ALLOWANCE: u64 = 16 * 1024 * 1024; /// Most image bytes read from one file in all. #[cfg(not(target_pointer_width = "32"))] const MAX_IMAGE_TOTAL: u64 = 1024 * 1024 * 1024; @@ -256,37 +252,39 @@ impl<'b> Archive<'b> { } // A clone shares the parsed directory and the bytes; the resolver only has shared access. let mut z = self.zip.clone(); - let member = format!("images/{name}"); - let (size, packed) = { - let f = z.by_name(&member).ok()?; - (f.size(), f.compressed_size()) - }; - let allowed = MAX_IMAGE - .min(packed.saturating_mul(MAX_IMAGE_RATIO).max(MIN_IMAGE_ALLOWANCE)) - .min(MAX_IMAGE_TOTAL.saturating_sub(self.image_total.get())); - if size > allowed { - return None; - } - let data = crate::read_member(&mut z, &member, allowed).ok()?; - self.image_total.set(self.image_total.get() + data.len() as u64); - Some(data) + let mut total = self.image_total.get(); + let data = capped_member(&mut z, &format!("images/{name}"), MAX_IMAGE, &mut total, MAX_IMAGE_TOTAL).ok(); + self.image_total.set(total); + data } } -/// JSON member `name`, refused before unpacking when it would be larger than `limit`, than -/// [`MAX_JSON_RATIO`] times what it takes in the archive (past a floor), or than what is left of -/// the file's [`MAX_JSON_TOTAL`] (`total`: read so far). -fn json_member(z: &mut zip::ZipArchive>, name: &str, limit: u64, total: &mut u64) -> Result { +/// Member `name`, refused before unpacking when it would be larger than `limit`, than +/// [`MAX_RATIO`] times what it takes in the archive (past a floor), or than what is left of +/// `max_total` (`total`: read so far, counted up). +fn capped_member( + z: &mut zip::ZipArchive>, + name: &str, + limit: u64, + total: &mut u64, + max_total: u64, +) -> Result, FormatError> { let (size, packed) = { let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?; (f.size(), f.compressed_size()) }; - let allowed = limit.min(packed.saturating_mul(MAX_JSON_RATIO).max(MIN_JSON_ALLOWANCE)).min(MAX_JSON_TOTAL.saturating_sub(*total)); + let allowed = limit.min(packed.saturating_mul(MAX_RATIO).max(MIN_ALLOWANCE)).min(max_total.saturating_sub(*total)); if size > allowed { return Err(corrupt(format!("{name} is too large"))); } let data = crate::read_member(z, name, allowed)?; *total += data.len() as u64; + Ok(data) +} + +/// JSON member `name`, read by [`capped_member`] within the file's [`MAX_JSON_TOTAL`]. +fn json_member(z: &mut zip::ZipArchive>, name: &str, limit: u64, total: &mut u64) -> Result { + let data = capped_member(z, name, limit, total, MAX_JSON_TOTAL)?; serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}"))) } From 8d69b3ce8fcc9cbeb63af57136c37c8c26eef8b0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 11:44:05 +0700 Subject: [PATCH 24/26] #1210: revision rows, and drop the Arabic catalog's Resources key --- ROADMAP.md | 3 ++- crates/ui-egui/locales/ar.toml | 1 - docs/ui-parity.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9604db66..8510a394 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -352,6 +352,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | | 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. | | 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. | diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index f252e114..88489ccf 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -1204,7 +1204,6 @@ status = "draft — needs native review" "Resolve" = "حل" "Resolve change" = "حل التغيير" "Resolved" = "محلول" -"Resources" = "الموارد" "Responsive" = "متجاوب" "Restart" = "إعادة البدء" "Restore" = "استعادة" diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a12e3433..02bf1859 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -173,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | | 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | From 2a63418d5744f3bf66c22ab092a8580dda1f4f80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 12:28:47 +0700 Subject: [PATCH 25/26] #634: region fills drawn by their own helper, and the long region-paint test split by case, for cognitive_complexity --- crates/engine/src/vector_behaviour_tests.rs | 16 ++++++++ crates/render/src/draw.rs | 43 +++++++++++++-------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 785a03d0..9d24bc39 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -79,7 +79,11 @@ fn region_paints_survive_simplify_offset_and_bucket_normalization() { prior_world_frame.as_coeffs().iter().zip(after_world_frame.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8), "flattening an already framed gradient preserves its source coordinates" ); +} +#[test] +fn explicit_and_inherited_region_paints_survive_refit() { + let mut s = Session::new(); let empty_id = square(&mut s, false); ex_to_vector(s.doc_mut().unwrap(), empty_id); { @@ -106,7 +110,11 @@ fn region_paints_survive_simplify_offset_and_bucket_normalization() { assert_eq!(inherited.fills, None, "inherited paints remain inherited after offset"); let inherited_space = inherited.paint_space.expect("offset binds inherited paints to the original frame"); assert_eq!((inherited_space.width, inherited_space.height), (100.0, 100.0)); +} +#[test] +fn flatten_keeps_inherited_region_paints_inherited() { + let mut s = Session::new(); let inherited_flatten = square(&mut s, false); let inherited_fills = s.doc().unwrap().get(inherited_flatten).unwrap().fills.clone(); ex_to_vector(s.doc_mut().unwrap(), inherited_flatten); @@ -142,7 +150,11 @@ fn region_paints_survive_simplify_offset_and_bucket_normalization() { s.doc_mut().unwrap().get_mut(group).unwrap().fills.clear(); let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap(); assert_eq!(net(&s, flattened).regions[0].fills, Some(source_fills), "flatten retains paints that differ from output node fills"); +} +#[test] +fn legacy_offset_preserves_the_gradients_world_paint_frame() { + let mut s = Session::new(); let legacy_gradient = square(&mut s, false); let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK); gradient.from = (0.2, 0.1); @@ -164,7 +176,11 @@ fn region_paints_survive_simplify_offset_and_bucket_normalization() { before_world.as_coeffs().iter().zip(after_world.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8), "legacy offset preserves the gradient's world paint frame" ); +} +#[test] +fn bucket_fills_an_explicitly_empty_face_and_leaves_the_other_empty() { + let mut s = Session::new(); let empty_faces = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M200 0 L300 0 L300 100 L200 100 Z"}))["id"]).unwrap(); { diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index a5af3059..3ff68026 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -723,6 +723,31 @@ fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) /// What paints over a node's fills, under its children and strokes (its inner shadows). type OverFills<'a> = Option<&'a dyn Fn(&mut Pixmap)>; +/// A vector's network when any of its regions has paints or a paint frame of its own. +fn region_painted(n: &Node) -> Option<&figcraft_doc::VectorNetwork> { + match &n.kind { + NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => Some(network), + _ => None, + } +} + +/// Each region of a vector with region paints, filled with its own paints (or the layer's) in its +/// own paint frame. +fn fill_vector_regions(ctx: &Ctx, pix: &mut Pixmap, n: &Node, network: &figcraft_doc::VectorNetwork, xf: Affine) { + let rounded = network.rounded(); + let net = rounded.as_ref().unwrap_or(network); + for (i, region) in net.regions.iter().enumerate() { + let paints = region.fills.as_deref().unwrap_or(&n.fills); + if let Some(mut path) = net.region_path(i) { + let (pw, ph, frame) = region.paint_frame(n.width, n.height); + path.apply_affine(frame.inverse()); + let Some(tp) = ts_path(&path) else { continue }; + let rule = if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::Winding }; + fill_with(pix, &tp, paints, pw, ph, xf * frame, rule, ctx, None); + } + } +} + /// Fills, `over_fills`, children (clipped), strokes and text of a node, without opacity/effects. fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, over_fills: OverFills) { let doc = ctx.doc; @@ -804,27 +829,13 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf); let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty(); - let styled_network = match &n.kind { - NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => Some(network), - _ => None, - }; + let styled_network = region_painted(n); // The outline fills, clips children to rounded corners and strokes booleans. let outline = if styled_network.is_some() || (splatted && !(clips && n.corners.uniform() != Some(0.0))) { None } else { node_outline(doc, n) }; if !splatted && closed { if let Some(network) = styled_network { - let rounded = network.rounded(); - let net = rounded.as_ref().unwrap_or(network); - for (i, region) in net.regions.iter().enumerate() { - let paints = region.fills.as_deref().unwrap_or(&n.fills); - if let Some(mut path) = net.region_path(i) { - let (pw, ph, frame) = region.paint_frame(w, h); - path.apply_affine(frame.inverse()); - let Some(tp) = ts_path(&path) else { continue }; - let rule = if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::Winding }; - fill_with(pix, &tp, paints, pw, ph, xf * frame, rule, ctx, None); - } - } + fill_vector_regions(ctx, pix, n, network, xf); } else if let Some((path, rule)) = &outline && let Some(tp) = ts_path(path) { From 67c69f86b6c31cdb913b56050a8c9259c91a8bc9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 12:40:47 +0700 Subject: [PATCH 26/26] #634: drop the removed import warning from the Arabic catalog --- crates/ui-egui/locales/ar.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index f252e114..a23c08a2 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -1694,7 +1694,6 @@ status = "draft — needs native review" "elbowed and curved connectors without a saved path import as straight lines" = "تُستورد الموصلات الزاوية والمنحنية التي ليس لها مسار محفوظ كخطوط مستقيمة" "entries without a node type were skipped" = "تم تخطي الإدخالات التي ليس لها نوع عقدة" "figcraft:// links from a browser, chat or email open this app and join the session." = "تفتح روابط figcraft:// من المتصفح أو الدردشة أو البريد هذا التطبيق وتنضم إلى الجلسة." -"filled areas of a vector with their own fill use the layer's fill" = "تستخدم مناطق المتجه المعبأة ذات التعبئة الخاصة تعبئة الطبقة" "from {server}" = "من {server}" "gamepad triggers without exactly one known controller button were skipped" = "تم تخطي محفزات ذراع الألعاب التي لا تحدد زرًا معروفًا واحدًا بالضبط" "grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "تتجاوز قائمة مسارات الشبكة 1000 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية"