diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs index 6ecbd899a..5a8a01128 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 19c11d815..0e06d2941 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 a1e847bc2..2c68f03af 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 f556648c7..785a03d0a 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 75a0817a0..f4c7c84f9 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 a582d7c93..2bf443e78 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 fde19db1c..790f4b3ed 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 adf2c8bd2..82dc3dd2a 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 5fb52f0fa..ed228881c 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 be6ca9ac9..f53f132e1 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);