From 4ecf6e2f399071ec850957895a1b7b31783a94d8 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:37:30 +0200 Subject: [PATCH 1/6] 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 2/6] 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 219a9ea0b1428f2b1afbe23aaaed6d10761c195c Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Wed, 28 Oct 2026 09:29:22 +0100 Subject: [PATCH 3/6] 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 4/6] 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 2a63418d5744f3bf66c22ab092a8580dda1f4f80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 29 Oct 2026 12:28:47 +0700 Subject: [PATCH 5/6] #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 6/6] #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 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية"