mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
Merge pull request #634 from Skyfuller1303/feat/fig-vector-region-fills
M4: preserve vector region fills across .fig import and native rendering
This commit is contained in:
@@ -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,61 @@ 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)]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
|
||||
],
|
||||
};
|
||||
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 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");
|
||||
let p = page(&d);
|
||||
let mut network =
|
||||
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
|
||||
n.stroke.weight = 1.0;
|
||||
assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicks_go_one_level_deeper() {
|
||||
let (d, f, g, b, r1, _) = setup();
|
||||
|
||||
@@ -60,6 +60,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 {
|
||||
|
||||
@@ -74,6 +74,23 @@ 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() || r.paint_space.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 has_fill = net.regions.iter().any(|region| region.fills.as_deref().unwrap_or(&n.fills).iter().any(|paint| paint.visible));
|
||||
let stroke_only = n.has_visible_stroke() && !has_fill;
|
||||
let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol_l * STROKE_REACH } else { 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) && fills_point(n, &path, lp))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,6 +915,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);
|
||||
|
||||
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator<Item = &'a Paint>, out: &mut B
|
||||
/// Image hashes a node paints (fills, strokes, text and text run fills).
|
||||
pub fn node_images(n: &Node, out: &mut BTreeSet<String>) {
|
||||
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 {
|
||||
|
||||
+31
-3
@@ -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, ..Default::default() }],
|
||||
};
|
||||
bad.sanitize();
|
||||
assert!(bad.segments.is_empty());
|
||||
@@ -534,7 +562,7 @@ fn hit_test_even_odd_vector_hole_misses() {
|
||||
VectorNetwork {
|
||||
vertices: v1.into_iter().chain(v2).collect(),
|
||||
segments: s1.into_iter().chain(s2).collect(),
|
||||
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }],
|
||||
}
|
||||
};
|
||||
let mut d = Document::new("x");
|
||||
@@ -637,7 +665,7 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
let net = VectorNetwork {
|
||||
vertices: vec![v0, v1],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }],
|
||||
};
|
||||
let r = net.rounded().unwrap();
|
||||
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 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 figcraft_geom::{Affine, BezPath, Point, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{StrokeCap, StrokeJoin};
|
||||
@@ -88,12 +89,44 @@ pub enum WindingRule {
|
||||
EvenOdd,
|
||||
}
|
||||
|
||||
/// The paint coordinate frame retained when a region moves or its vector is refit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RegionPaintSpace {
|
||||
/// Width of the box that defines paint coordinates.
|
||||
pub width: f64,
|
||||
/// Height of the box that defines paint coordinates.
|
||||
pub height: f64,
|
||||
/// Maps paint-local coordinates into this network's local coordinates.
|
||||
pub transform: Affine,
|
||||
}
|
||||
|
||||
/// A filled region: loops of segment indices.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Region {
|
||||
pub loops: Vec<Vec<usize>>,
|
||||
#[serde(default)]
|
||||
pub winding: WindingRule,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fills: Option<Vec<Paint>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub paint_space: Option<RegionPaintSpace>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// Paint box and transform, falling back to the current node box for old or invalid metadata.
|
||||
pub fn paint_frame(&self, width: f64, height: f64) -> (f64, f64, Affine) {
|
||||
let Some(frame) = self.paint_space else { return (width, height, Affine::IDENTITY) };
|
||||
let coefficients = frame.transform.as_coeffs();
|
||||
let valid = frame.width.is_finite()
|
||||
&& frame.height.is_finite()
|
||||
&& frame.width > 0.0
|
||||
&& frame.height > 0.0
|
||||
&& coefficients.iter().all(|value| value.is_finite())
|
||||
&& frame.transform.determinant().is_finite()
|
||||
&& frame.transform.determinant() != 0.0
|
||||
&& frame.transform.inverse().as_coeffs().iter().all(|value| value.is_finite());
|
||||
if valid { (frame.width, frame.height, frame.transform) } else { (width, height, Affine::IDENTITY) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
|
||||
@@ -153,7 +186,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, paint_space: None });
|
||||
}
|
||||
Self { vertices, segments, regions }
|
||||
}
|
||||
@@ -242,7 +275,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, paint_space: None });
|
||||
}
|
||||
net
|
||||
}
|
||||
@@ -637,6 +670,12 @@ impl VectorNetwork {
|
||||
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
|
||||
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
|
||||
}
|
||||
let scale = Affine::scale_non_uniform(sx, sy);
|
||||
for region in &mut self.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = scale * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate(&mut self, dx: f64, dy: f64) {
|
||||
@@ -644,6 +683,12 @@ impl VectorNetwork {
|
||||
v.x += dx;
|
||||
v.y += dy;
|
||||
}
|
||||
let translate = Affine::translate((dx, dy));
|
||||
for region in &mut self.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = translate * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of the curves.
|
||||
|
||||
@@ -30,7 +30,12 @@ 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,
|
||||
paint_space: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Some point strictly inside the face (not in a hole).
|
||||
@@ -115,7 +120,13 @@ impl VectorNetwork {
|
||||
/// Like [`Self::paint`], but with `want` the face ends up filled (`Some(true)`) or unfilled
|
||||
/// (`Some(false)`) whatever it was: a paint bucket drag keeps doing what its first click did.
|
||||
pub fn paint_to(&mut self, p: Point, want: Option<bool>) -> Option<bool> {
|
||||
self.paint_to_with(p, want, None)
|
||||
}
|
||||
|
||||
/// Like [`Self::paint_to`], with the layer fill stack used for inherited regions.
|
||||
pub fn paint_to_with(&mut self, p: Point, want: Option<bool>, inherited: Option<&[figcraft_color::Paint]>) -> Option<bool> {
|
||||
let face = self.face_at(p)?;
|
||||
let legacy = self.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
let key = |loops: &[Vec<usize>]| -> Vec<Vec<usize>> {
|
||||
let mut k: Vec<Vec<usize>> = loops
|
||||
.iter()
|
||||
@@ -131,18 +142,52 @@ impl VectorNetwork {
|
||||
let faces = self.faces();
|
||||
let face_keys: Vec<Vec<Vec<usize>>> = faces.iter().map(|f| key(&f.loops)).collect();
|
||||
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
|
||||
let filled: Vec<Region> = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
|
||||
self.regions = filled;
|
||||
if legacy {
|
||||
// Legacy vectors use one combined fill rule across all regions.
|
||||
self.regions =
|
||||
faces.iter().filter(|face| face.interior_point().is_some_and(|point| self.fill_contains(point))).map(Face::region).collect();
|
||||
} else {
|
||||
// Split each styled source region independently to retain its stack and frame.
|
||||
let source_regions = self.regions.clone();
|
||||
let mut filled = Vec::new();
|
||||
for (source_index, source) in source_regions.into_iter().enumerate() {
|
||||
let source_path = self.region_path(source_index);
|
||||
for face in &faces {
|
||||
if face.interior_point().is_some_and(|q| {
|
||||
source_path.as_ref().is_some_and(|path| match source.winding {
|
||||
WindingRule::NonZero => path.winding(q) != 0,
|
||||
WindingRule::EvenOdd => path.winding(q) % 2 != 0,
|
||||
})
|
||||
}) {
|
||||
let mut region = face.region();
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = source.paint_space;
|
||||
filled.push(region);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.regions = filled;
|
||||
}
|
||||
}
|
||||
let k = key(&face.loops);
|
||||
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
|
||||
(Some(_), None | Some(false)) => {
|
||||
let filled = if legacy {
|
||||
self.fill_contains(p) && inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible))
|
||||
} else {
|
||||
self.regions.iter().filter(|r| key(&r.loops) == k).any(|region| match ®ion.fills {
|
||||
Some(fills) => fills.iter().any(|paint| paint.visible),
|
||||
None => inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible)),
|
||||
})
|
||||
};
|
||||
let want = want.unwrap_or(!filled);
|
||||
match (filled, want) {
|
||||
(true, false) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
Some(false)
|
||||
}
|
||||
(Some(_), Some(true)) => Some(true),
|
||||
(None, Some(false)) => Some(false),
|
||||
(None, _) => {
|
||||
(true, true) => Some(true),
|
||||
(false, false) => Some(false),
|
||||
(false, true) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
self.regions.push(face.region());
|
||||
Some(true)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
Region { loops, winding, fills: None, ..Default::default() }
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_paint_space_roundtrips_and_rejects_nonfinite_inverse() {
|
||||
use crate::RegionPaintSpace;
|
||||
use figcraft_geom::Affine;
|
||||
let mut r = region(Vec::new(), WindingRule::EvenOdd);
|
||||
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
|
||||
r.paint_space = Some(RegionPaintSpace { width: 30.0, height: 20.0, transform: Affine::translate((4.0, 8.0)) });
|
||||
let decoded: Region = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
|
||||
assert_eq!(decoded, r);
|
||||
assert_eq!(decoded.paint_frame(100.0, 40.0), (30.0, 20.0, Affine::translate((4.0, 8.0))));
|
||||
r.paint_space.as_mut().unwrap().transform = Affine::scale_non_uniform(1e-320, 1.0);
|
||||
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
|
||||
}
|
||||
|
||||
fn p(x: f64, y: f64) -> Point {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
|
||||
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
|
||||
//! styled like the top-most shape covering the first region; removing regions deletes them. Either
|
||||
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
|
||||
//! when nothing is left), so the rest of the drawing stays exactly where it was.
|
||||
//! styled like the top-most fill or stroke covering the first selected region (using that vector
|
||||
//! region's paints when applicable); removing regions deletes them. Either way every shape covering
|
||||
//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
|
||||
//! the rest of the drawing stays exactly where it was.
|
||||
//!
|
||||
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
|
||||
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
|
||||
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
|
||||
pub struct Part {
|
||||
pub id: NodeId,
|
||||
pub stroke: bool,
|
||||
/// The source vector region for a fill (`None` for ordinary fills and strokes).
|
||||
#[serde(skip)]
|
||||
pub region: Option<usize>,
|
||||
}
|
||||
|
||||
/// One region of the arrangement, in page coordinates.
|
||||
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
|
||||
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
|
||||
return None;
|
||||
}
|
||||
figcraft_render::node_outline(doc, n)?
|
||||
match (&n.kind, part.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let region = net.regions.get(i)?;
|
||||
let path = net.region_path(i)?;
|
||||
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n)?,
|
||||
}
|
||||
};
|
||||
p.apply_affine(doc.world_transform(part.id));
|
||||
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
|
||||
@@ -155,10 +168,17 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
|
||||
let mut inputs = Vec::new();
|
||||
for id in eligible(&doc, ids) {
|
||||
let before = parts.len();
|
||||
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
|
||||
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
|
||||
&& let Some(o) = world_area(&doc, part)
|
||||
{
|
||||
let fill_parts: Vec<Part> = match &doc.get(id)?.kind {
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
|
||||
}
|
||||
_ => vec![Part { id, stroke: false, region: None }],
|
||||
};
|
||||
for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
|
||||
if let Some(o) = world_area(&doc, part) {
|
||||
if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
|
||||
}
|
||||
parts.push(part);
|
||||
inputs.push(o);
|
||||
}
|
||||
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option<VectorNetwork> {
|
||||
Some(net)
|
||||
}
|
||||
|
||||
fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for loop_segments in &mut region.loops {
|
||||
for segment in loop_segments {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
|
||||
region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
|
||||
}
|
||||
|
||||
/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
|
||||
fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
|
||||
let old = doc.get(id)?.clone();
|
||||
let (world, network) = match &old.kind {
|
||||
NodeKind::Vector { network } => (doc.world_transform(id), network),
|
||||
_ => return Err(EngineError::Other("expected a vector with regions".into())),
|
||||
};
|
||||
if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
|
||||
return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
|
||||
}
|
||||
let mut rebuilt = VectorNetwork::default();
|
||||
for (index, area) in areas {
|
||||
let Some(source) = network.regions.get(*index) else { continue };
|
||||
let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
|
||||
if part.regions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (width, height, frame) = paint_frame(Some(source), &old);
|
||||
for region in &mut part.regions {
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
|
||||
}
|
||||
append_network(&mut rebuilt, part);
|
||||
}
|
||||
let Some(bounds) = rebuilt.bounds() else {
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
return Ok(());
|
||||
};
|
||||
rebuilt.translate(-bounds.x0, -bounds.y0);
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
n.children.clear();
|
||||
n.corners = Corners::default();
|
||||
n.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
n.width = bounds.width();
|
||||
n.height = bounds.height();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
|
||||
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
let w = doc.world_transform(id);
|
||||
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
crate::vectoredit::refit(doc, id)
|
||||
}
|
||||
|
||||
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
|
||||
/// with the stroke's paints), just above its shape.
|
||||
/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
|
||||
/// paints), just above its shape. Vector region paints and their original frame stay attached.
|
||||
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
|
||||
let mut src = doc.get(style.id)?.clone();
|
||||
let source_region = match (&src.kind, style.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
|
||||
_ => None,
|
||||
};
|
||||
let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
|
||||
let region_fills = source_region.and_then(|r| r.fills.clone());
|
||||
if style.stroke {
|
||||
src.fills = std::mem::take(&mut src.strokes);
|
||||
src.stroke = StrokeProps::default();
|
||||
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<Node
|
||||
let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
|
||||
let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let to_world = pw * Affine::translate((bb.x0, bb.y0));
|
||||
let paint_transform = to_world.inverse() * doc.world_transform(style) * source_paint_frame;
|
||||
for region in &mut net.regions {
|
||||
region.fills = region_fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
@@ -321,7 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
// 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)));
|
||||
if merge && whole_shape {
|
||||
// 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()?;
|
||||
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
let mut changed = Vec::new();
|
||||
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
|
||||
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
|
||||
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
|
||||
let stroke = Part { id: src, stroke: true, region: None };
|
||||
let fill_parts: Vec<Part> = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
|
||||
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
|
||||
if a.parts.contains(&stroke) {
|
||||
if let Some(area) = kept(stroke)? {
|
||||
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
n.strokes.clear();
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
let left = if !a.parts.contains(&fill) {
|
||||
None
|
||||
} else if touched_parts.contains(&fill) {
|
||||
kept(fill)?
|
||||
} else {
|
||||
if fill_parts.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
continue;
|
||||
}
|
||||
let touched_fills: Vec<Part> = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
|
||||
if touched_fills.is_empty() {
|
||||
// The fill wasn't picked: the shape stays as it is.
|
||||
changed.push(src);
|
||||
continue;
|
||||
};
|
||||
match left {
|
||||
}
|
||||
if fill_parts.iter().any(|part| part.region.is_some()) {
|
||||
let mut areas = Vec::new();
|
||||
for part in &fill_parts {
|
||||
let Some(region) = part.region else { continue };
|
||||
if let Some(area) = kept(*part)? {
|
||||
areas.push((region, area));
|
||||
}
|
||||
}
|
||||
if areas.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
} else {
|
||||
reshape_vector_regions(doc, src, &areas)?;
|
||||
changed.push(src);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match kept(fill_parts[0])? {
|
||||
Some(area) => {
|
||||
reshape(doc, src, &area)?;
|
||||
changed.push(src);
|
||||
@@ -427,7 +543,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"shapeBuilder.merge",
|
||||
"Merge regions",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
true,
|
||||
|s, p| apply(s, "shapeBuilder.merge", p, true)
|
||||
),
|
||||
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shapebuilder_region_paints_tests.rs"]
|
||||
mod region_paints_tests;
|
||||
|
||||
impl Session {
|
||||
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
|
||||
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
use figcraft_color::{Color, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::{Affine, Point, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::shapebuilder::arrangement;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
|
||||
id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
|
||||
}
|
||||
|
||||
fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for lp in &mut region.loops {
|
||||
for segment in lp {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
|
||||
VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
}
|
||||
|
||||
fn split_vector(s: &mut Session) -> NodeId {
|
||||
let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut network = VectorNetwork::default();
|
||||
append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
|
||||
append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
|
||||
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
|
||||
network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
id
|
||||
}
|
||||
|
||||
fn network(s: &Session, id: NodeId) -> &VectorNetwork {
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
|
||||
// This helper's borrow is tied to the session document.
|
||||
network
|
||||
}
|
||||
|
||||
fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
|
||||
let doc = s.doc().unwrap();
|
||||
let mut path = network(s, id).region_path(index).unwrap();
|
||||
path.apply_affine(doc.world_transform(id));
|
||||
path.area().abs()
|
||||
}
|
||||
|
||||
fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
|
||||
let doc = s.doc().unwrap();
|
||||
let node = doc.node(id).unwrap();
|
||||
let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
|
||||
doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
|
||||
}
|
||||
|
||||
fn assert_same_transform(a: Affine, b: Affine) {
|
||||
for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
|
||||
assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
|
||||
let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
|
||||
assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
|
||||
assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
|
||||
assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut net = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
append(&mut net, inner);
|
||||
for region in &mut net.regions {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
region.fills = None;
|
||||
region.paint_space = None;
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
|
||||
let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
|
||||
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
|
||||
assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
|
||||
assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_painted_regions_uses_the_first_picked_region_style() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector));
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(network(&s, vector).regions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
|
||||
region.fills = None;
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
|
||||
}
|
||||
}
|
||||
let before_a = normalized_paint_world(&s, vector, 0);
|
||||
let before_b = normalized_paint_world(&s, vector, 1);
|
||||
assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
|
||||
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
|
||||
assert_eq!(network(&s, merged).regions.len(), 1);
|
||||
assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
|
||||
assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
|
||||
part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
append(&mut net, part);
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
|
||||
s.set_selection(vec![vector]);
|
||||
let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
|
||||
assert!(error.to_string().contains("too many fill regions"));
|
||||
assert_eq!(network(&s, vector), &net);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_changes_only_the_picked_painted_face() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
network.regions[1].fills = None;
|
||||
}
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
|
||||
assert_eq!(network(&s, vector).regions[1].fills, None);
|
||||
assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
|
||||
let mut s = Session::new();
|
||||
// Create crossing first, then vector above it so vector region supplies merge appearance.
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
|
||||
let merged = id(&result);
|
||||
let merged_net = network(&s, merged);
|
||||
assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
|
||||
assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_region_keeps_a_transformed_source_gradient_frame() {
|
||||
let mut s = Session::new();
|
||||
let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
|
||||
transform *= Affine::scale_non_uniform(1.2, 0.8);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.transform = transform;
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
|
||||
let merged = id(&result);
|
||||
assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(before, normalized_paint_world(&s, merged, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_preserves_holes_and_transformed_gradient_frames() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
let outer_loop = outer.regions[0].loops[0].clone();
|
||||
let inner_loop: Vec<usize> = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
|
||||
append(&mut outer, inner);
|
||||
outer.regions = vec![Region {
|
||||
loops: vec![outer_loop, inner_loop],
|
||||
winding: WindingRule::EvenOdd,
|
||||
fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
|
||||
paint_space: None,
|
||||
}];
|
||||
let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.kind = NodeKind::Vector { network: outer };
|
||||
node.transform = transform;
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
|
||||
let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![overlay, vector]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions.len(), 1);
|
||||
assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
|
||||
assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
|
||||
assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
|
||||
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));
|
||||
}
|
||||
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result<Vec<Node
|
||||
/// under it, the others get the same (points outside closed areas are skipped). A vector without
|
||||
/// fills gets the default fill so the painted area shows.
|
||||
pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result<bool> {
|
||||
let inherited = doc.get(id)?.fills.clone();
|
||||
let filled = edit(doc, id, |net| {
|
||||
let mut want = None;
|
||||
for p in pts {
|
||||
want = net.paint_to(*p, want).or(want);
|
||||
want = net.paint_to_with(*p, want, Some(&inherited)).or(want);
|
||||
}
|
||||
want
|
||||
})?
|
||||
.ok_or_else(|| bad("vector.edit.paint", "click inside a closed area"))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
if filled && n.fills.iter().all(|f| !f.visible) {
|
||||
n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
|
||||
let default = figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE);
|
||||
let targets: std::collections::BTreeSet<usize> = match &n.kind {
|
||||
NodeKind::Vector { network } => pts.iter().filter_map(|point| network.region_at(*point)).collect(),
|
||||
_ => std::collections::BTreeSet::new(),
|
||||
};
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
for (index, region) in network.regions.iter_mut().enumerate() {
|
||||
if region.fills.is_none() && !targets.contains(&index) {
|
||||
// Freeze an untouched inherited region before adding a node-wide default.
|
||||
region.fills = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
n.fills = vec![default];
|
||||
}
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
@@ -19,15 +19,21 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
|
||||
if !finite(d) {
|
||||
return;
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for i in idx {
|
||||
if seen.insert(*i)
|
||||
&& let Some(v) = net.vertices.get_mut(*i)
|
||||
{
|
||||
let seen: std::collections::BTreeSet<usize> = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
|
||||
for i in &seen {
|
||||
if let Some(v) = net.vertices.get_mut(*i) {
|
||||
v.x += d.x;
|
||||
v.y += d.y;
|
||||
}
|
||||
}
|
||||
if seen.len() == net.vertices.len() {
|
||||
let translate = Affine::translate(d);
|
||||
for region in &mut net.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = translate * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
|
||||
@@ -65,6 +71,13 @@ pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if set.len() == net.vertices.len() {
|
||||
for region in &mut net.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = m * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
|
||||
@@ -586,7 +599,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork {
|
||||
let loops: Vec<Vec<usize>> =
|
||||
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::<Vec<_>>()).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 +661,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, ..Default::default() });
|
||||
}
|
||||
}
|
||||
Some(si)
|
||||
@@ -862,6 +875,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 +920,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, ..Default::default() });
|
||||
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])] {
|
||||
|
||||
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option<f64> {
|
||||
(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.
|
||||
|
||||
@@ -31,7 +31,7 @@ fn nested(regions: Vec<Region>) -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
Region { loops, winding, fills: None, ..Default::default() }
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
|
||||
VectorNetwork, WindingRule, proto,
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
|
||||
StrokeProps, VectorNetwork, WindingRule, proto,
|
||||
};
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
let pw = doc.world_transform(parent);
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in &ids {
|
||||
let Some(n) = doc.node(*i) else { continue };
|
||||
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
|
||||
// booleans flatten to their result; strokes don't get outlined.
|
||||
let mut part = match &n.kind {
|
||||
NodeKind::Vector { network } => network.clone(),
|
||||
_ => {
|
||||
let Some((op, rule)) = (match &n.kind {
|
||||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
|
||||
figcraft_render::subtree_outline(doc, n, 0)
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let mut part = VectorNetwork::from_path(&op);
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
for r in &mut part.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
let fallback_fills = first
|
||||
.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()
|
||||
}
|
||||
};
|
||||
// Into the common parent's space.
|
||||
let all: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
|
||||
);
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut net = VectorNetwork::default();
|
||||
for id in &ids {
|
||||
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()));
|
||||
};
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let vid = doc.alloc_id();
|
||||
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
|
||||
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();
|
||||
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": vid}))
|
||||
}
|
||||
|
||||
fn collect_flatten_parts(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
parent_world: Affine,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
net: &mut VectorNetwork,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(bad("vector.flatten", "group nesting exceeds 64"));
|
||||
}
|
||||
let Some(node) = doc.node(id) else { return Ok(()) };
|
||||
if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
|
||||
// Match subtree_outline: a nonempty frame contributes its own visible fill, then
|
||||
// visible descendants in stacking order. Child clipping remains the container's outline rule.
|
||||
if node.kind.is_frame_like()
|
||||
&& node.fills.iter().any(|paint| paint.visible)
|
||||
&& let Some(path) = figcraft_doc::geometry::shape_path(node)
|
||||
{
|
||||
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, output_fills, net, depth + 1)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let part = match &node.kind {
|
||||
NodeKind::Vector { network } => {
|
||||
let mut part = network.clone();
|
||||
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
|
||||
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
|
||||
WindingRule::EvenOdd
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
|
||||
part.regions = vec![Region { loops, winding, ..Default::default() }];
|
||||
}
|
||||
part
|
||||
}
|
||||
_ => {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
}
|
||||
};
|
||||
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(
|
||||
node: &Node,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
into_parent: Affine,
|
||||
mut part: VectorNetwork,
|
||||
net: &mut VectorNetwork,
|
||||
) {
|
||||
for region in &mut part.regions {
|
||||
let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
|
||||
if region.fills.is_none() {
|
||||
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 });
|
||||
}
|
||||
let vertices: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|region| Region {
|
||||
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
|
||||
..region
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.outlineStroke", s, p)?;
|
||||
let mut out = Vec::new();
|
||||
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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;
|
||||
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(i)?;
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
let fill = network.fill_path();
|
||||
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
|
||||
let regions = !network.regions.is_empty();
|
||||
let mut net = VectorNetwork::from_path(&simplified);
|
||||
if !regions {
|
||||
let source = network.clone();
|
||||
let mut net = VectorNetwork::default();
|
||||
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, 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()];
|
||||
for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
|
||||
if let Some(used) = used.get_mut(*segment) {
|
||||
*used = true;
|
||||
}
|
||||
}
|
||||
let mut open = source.clone();
|
||||
open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
|
||||
open.regions.clear();
|
||||
if !open.segments.is_empty() {
|
||||
let path = open.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let mut loose = VectorNetwork::from_path(&simplified);
|
||||
loose.regions.clear();
|
||||
append_network(&mut net, loose);
|
||||
}
|
||||
}
|
||||
if source.regions.is_empty() {
|
||||
let path = source.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
net = VectorNetwork::from_path(&simplified);
|
||||
net.regions.clear();
|
||||
}
|
||||
*network = net;
|
||||
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !n.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
};
|
||||
let Ok(mut res) = res else { continue };
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
let mut net = VectorNetwork::from_path(&res);
|
||||
for r in &mut net.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
if d == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let NodeKind::Vector { network: source } = &n.kind else {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut result = result;
|
||||
let bounds = result.bounding_box();
|
||||
result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
|
||||
let node = doc.get_mut(i)?;
|
||||
node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
|
||||
node.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
node.width = bounds.width();
|
||||
node.height = bounds.height();
|
||||
continue;
|
||||
};
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if source.regions.is_empty() {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let mut res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
nm.height = bb.height();
|
||||
continue;
|
||||
}
|
||||
if legacy {
|
||||
let path = source.fill_path();
|
||||
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut part = VectorNetwork::from_path(&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 = 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 mut paint_region = region.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()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
// A collapsed negative offset removes this region, while other source regions stay.
|
||||
if res.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
|
||||
}
|
||||
}
|
||||
let Some(bb) = net.bounds() else {
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.width = 0.0;
|
||||
nm.height = 0.0;
|
||||
continue;
|
||||
};
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
|
||||
let mut part = VectorNetwork::from_path(path);
|
||||
for region in &mut part.regions {
|
||||
region.winding = winding;
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = source.paint_space;
|
||||
}
|
||||
append_network(net, part);
|
||||
}
|
||||
|
||||
fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Join open vectors whose end points touch into one vector.
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.join", s, p)?;
|
||||
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let last = network.vertices.len() - 1;
|
||||
network.segments.push(Segment::line(last, 0));
|
||||
let all: Vec<usize> = (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, paint_space: None });
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
|
||||
@@ -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::<Vec<_>>()}));
|
||||
assert!(out["id"].is_null(), "{out}");
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_regions_survive_junk() {
|
||||
for j in [
|
||||
|
||||
@@ -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;
|
||||
@@ -17,6 +17,276 @@ use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use crate::vectoredit::{VectorEdit, network};
|
||||
|
||||
#[test]
|
||||
fn region_paints_survive_simplify_offset_and_bucket_normalization() {
|
||||
let mut s = Session::new();
|
||||
let id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), id);
|
||||
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);
|
||||
let fills = vec![
|
||||
figcraft_color::Paint::gradient(gradient),
|
||||
figcraft_color::Paint::image("test-image", figcraft_color::ImageScaleMode::Crop),
|
||||
figcraft_color::Paint::pattern(123),
|
||||
];
|
||||
{
|
||||
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(fills.clone());
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [id]}));
|
||||
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "simplify keeps the full stack");
|
||||
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
|
||||
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "offset keeps the full stack");
|
||||
|
||||
// Add a divider so the legacy single region must split into independently painted faces.
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
let network = net(&s, id);
|
||||
let (topseg, botseg) = (
|
||||
network.segments.iter().position(|sg| network.vertices[sg.a].y == 0.0 && network.vertices[sg.b].y == 0.0).unwrap(),
|
||||
network.segments.iter().position(|sg| network.vertices[sg.a].y == 104.0 && network.vertices[sg.b].y == 104.0).unwrap(),
|
||||
);
|
||||
let a = ex(&mut s, "vector.edit.addVertex", json!({"segment": topseg, "t": 0.5}))["vertex"].as_u64().unwrap();
|
||||
let b = ex(&mut s, "vector.edit.addVertex", json!({"segment": botseg, "t": 0.5}))["vertex"].as_u64().unwrap();
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": a, "b": b}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 25, "y": 52}));
|
||||
let normalized = net(&s, id);
|
||||
assert_eq!(normalized.regions.len(), 1, "unfilled face is removed while the other stays");
|
||||
assert_eq!(normalized.regions[0].fills, Some(fills.clone()), "untouched face keeps the original stack");
|
||||
assert!(!normalized.fill_contains(Point::new(27.0, 54.0)));
|
||||
assert!(normalized.fill_contains(Point::new(77.0, 54.0)));
|
||||
s.undo().unwrap();
|
||||
assert!(net(&s, id).fill_contains(Point::new(25.0, 52.0)), "bucket change is undoable");
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
let flattened_net = net(&s, flattened);
|
||||
assert!(flattened_net.regions.iter().all(|region| region.fills == Some(fills.clone())), "flatten keeps each source region stack");
|
||||
assert!(flattened_net.regions.iter().all(|region| region.paint_space.is_some()), "flatten keeps source paint frames");
|
||||
assert!(flattened_net.fill_contains(Point::new(25.0, 52.0)));
|
||||
let prior_frame =
|
||||
flattened_net.regions[0].paint_frame(s.doc().unwrap().get(flattened).unwrap().width, s.doc().unwrap().get(flattened).unwrap().height);
|
||||
let prior_world_frame = s.doc().unwrap().world_transform(flattened) * prior_frame.2;
|
||||
let regrouped = id_of(&ex(&mut s, "object.group", json!({"ids": [flattened]}))["id"]).unwrap();
|
||||
let flattened_again = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [regrouped]}))["id"]).unwrap();
|
||||
let after = net(&s, flattened_again);
|
||||
let after_frame =
|
||||
after.regions[0].paint_frame(s.doc().unwrap().get(flattened_again).unwrap().width, s.doc().unwrap().get(flattened_again).unwrap().height);
|
||||
let after_world_frame = s.doc().unwrap().world_transform(flattened_again) * after_frame.2;
|
||||
assert!(
|
||||
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);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(empty_id).unwrap().kind {
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [empty_id]}));
|
||||
ex(&mut s, "vector.offset", json!({"ids": [empty_id], "offset": 2}));
|
||||
assert_eq!(net(&s, empty_id).regions[0].fills, Some(Vec::new()), "explicit empty paint stack survives refit");
|
||||
|
||||
let inherited_id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), inherited_id);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(inherited_id).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
}
|
||||
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, 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);
|
||||
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");
|
||||
}
|
||||
|
||||
#[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);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(empty_faces).unwrap();
|
||||
node.fills.clear();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
assert!(network.regions.iter().all(|region| region.fills.is_none()));
|
||||
}
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": empty_faces}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 50, "y": 50}));
|
||||
let split = net(&s, empty_faces);
|
||||
let left =
|
||||
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(50.0, 50.0))).map(|(_, region)| region).unwrap();
|
||||
let right =
|
||||
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(250.0, 50.0))).map(|(_, region)| region).unwrap();
|
||||
assert_eq!(left.fills, None, "clicking an explicitly empty face fills it");
|
||||
assert_eq!(right.fills, Some(Vec::new()), "untouched inherited-empty face stays empty");
|
||||
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();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
|
||||
{
|
||||
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 = figcraft_doc::WindingRule::EvenOdd;
|
||||
}
|
||||
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "center starts as an even-odd hole");
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 100, "y": 100}));
|
||||
assert!(net(&s, id).fill_contains(Point::new(100.0, 100.0)), "bucket fills a legacy even-odd hole");
|
||||
s.undo().unwrap();
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
|
||||
let flatten_source =
|
||||
id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(flatten_source).unwrap().kind {
|
||||
network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [flatten_source]}))["id"]).unwrap();
|
||||
let flattened_legacy = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened_legacy).regions.len(), 1, "flatten combines unstyled legacy loops");
|
||||
assert!(!net(&s, flattened_legacy).fill_contains(Point::new(100.0, 100.0)), "flatten preserves the legacy even-odd hole");
|
||||
|
||||
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
|
||||
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "offset retains the even-odd hole");
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened).regions.len(), 1, "legacy region loops stay grouped");
|
||||
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
|
||||
}
|
||||
|
||||
/// 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}"))
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ fn two_squares() -> VectorNetwork {
|
||||
Segment::line(3, 4),
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,8 @@ fn two_lenses() -> VectorNetwork {
|
||||
Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) },
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,10 @@ fn one_shared_segment() -> VectorNetwork {
|
||||
VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }],
|
||||
regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,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.
|
||||
pub(super) fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) {
|
||||
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();
|
||||
if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK {
|
||||
self.warn("vector networks too large for the format were written as outlines");
|
||||
|
||||
@@ -827,6 +827,51 @@ 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 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 {
|
||||
|
||||
@@ -248,6 +248,9 @@ struct Mapper<'a> {
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
|
||||
network_ids: HashMap<(usize, u64, u64, usize), usize>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::component::{Entry, Inst, OverrideRow, prop_ref};
|
||||
use super::connector::connector_has_saved_label;
|
||||
use super::layout::{layout_grid, tracks};
|
||||
use super::paint::{effect, export_setting, paint, paint_bound, stroke};
|
||||
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link};
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,
|
||||
size,
|
||||
@@ -566,6 +566,84 @@ impl<'a> Mapper<'a> {
|
||||
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
|
||||
};
|
||||
let (sx, sy) = (scale("x"), scale("y"));
|
||||
let vertices: Vec<Value> = net
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
|
||||
let st = style(p.style);
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
Some("ANGLE") => out["mirror"] = json!("angle"),
|
||||
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
|
||||
Some("NONE") => out["mirror"] = json!("none"),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
|
||||
out["join"] = json!(join.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let segments: Vec<Value> =
|
||||
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<Value> = 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) => {
|
||||
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) == Some("FILL"));
|
||||
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())
|
||||
});
|
||||
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 || self.region_paint_elements.saturating_add(paint_cost) > 200_000
|
||||
{
|
||||
self.warn("vector region paint data exceeded import limits and was skipped");
|
||||
return region;
|
||||
}
|
||||
self.region_paint_elements += paint_cost;
|
||||
let paints: Vec<Value> = 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");
|
||||
}
|
||||
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();
|
||||
// An absent layer join is the source default.
|
||||
let layer_join = v.str("strokeJoin").unwrap_or("MITER");
|
||||
let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin"));
|
||||
@@ -573,9 +651,6 @@ impl<'a> Mapper<'a> {
|
||||
let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none());
|
||||
let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known)));
|
||||
let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join));
|
||||
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");
|
||||
}
|
||||
if unresolved_style {
|
||||
self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners");
|
||||
}
|
||||
@@ -604,42 +679,18 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
|
||||
}
|
||||
// Every instance of a main shows the same network: it is written once and shared.
|
||||
let mut entry = json!({});
|
||||
entry["vertices"] = Value::Array(vertices);
|
||||
entry["segments"] = Value::Array(segments);
|
||||
entry["regions"] = Value::Array(regions.clone());
|
||||
if regions.iter().any(|r| r.get("_figRegionFills").is_some()) {
|
||||
o.insert("vectorNetwork".into(), entry);
|
||||
return true;
|
||||
}
|
||||
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
|
||||
let id = match self.network_ids.get(&key) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
let vertices: Vec<Value> = net
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
|
||||
let st = style(p.style);
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
Some("ANGLE") => out["mirror"] = json!("angle"),
|
||||
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
|
||||
Some("NONE") => out["mirror"] = json!("none"),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
|
||||
out["join"] = json!(join.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
let segments: Vec<Value> = 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<Value> =
|
||||
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
|
||||
let mut entry = json!({});
|
||||
entry["vertices"] = Value::Array(vertices);
|
||||
entry["segments"] = Value::Array(segments);
|
||||
entry["regions"] = Value::Array(regions);
|
||||
self.networks.push(crate::rest::network(&entry));
|
||||
self.network_ids.insert(key, self.networks.len() - 1);
|
||||
self.networks.len() - 1
|
||||
|
||||
@@ -217,6 +217,14 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
|
||||
Some(Value::Object(o))
|
||||
}
|
||||
|
||||
pub(super) 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.
|
||||
pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option<Value> {
|
||||
|
||||
@@ -264,6 +264,256 @@ fn tail_canvas() -> Vec<u8> {
|
||||
canvas(&schema(), &tail_message())
|
||||
}
|
||||
|
||||
fn region_canvas(fill_paints: Option<Vec<Kv<'static>>>, style_id: u32) -> Vec<u8> {
|
||||
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(&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 region_canvas_with_link(own: Option<Kv<'static>>, link_target: Option<u64>, linked_fill: Kv<'static>) -> Vec<u8> {
|
||||
region_canvas_with_typed_link(own, link_target, "FILL", linked_fill)
|
||||
}
|
||||
|
||||
fn region_canvas_with_typed_link(own: Option<Kv<'static>>, link_target: Option<u64>, style_type: &'static str, linked_fill: Kv<'static>) -> Vec<u8> {
|
||||
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(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!() };
|
||||
*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);
|
||||
}
|
||||
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_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_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), "TEXT", solid(0.9, 0.1, 0.2)), "Text-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_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();
|
||||
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(&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,
|
||||
|
||||
@@ -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
|
||||
@@ -51,7 +52,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
|
||||
|
||||
@@ -738,9 +738,30 @@ impl<'a> Ctx<'a> {
|
||||
/// (exact look, editable as a shape): returns whether it does, and the caller moves the paints
|
||||
/// with [`outline_stroke`] once the styles and variables of the stroke are read.
|
||||
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
|
||||
let net = match j.get("vectorNetworkRef") {
|
||||
Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
|
||||
None => j.get("vectorNetwork").map(network),
|
||||
let net = match j.get("vectorNetwork") {
|
||||
Some(net) => Some(network(net).map(|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");
|
||||
}
|
||||
}
|
||||
}
|
||||
network
|
||||
})),
|
||||
None => j.get("vectorNetworkRef").map(|r| r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
|
||||
};
|
||||
if let Some(net) = net {
|
||||
match net {
|
||||
@@ -1391,6 +1412,11 @@ pub(crate) fn network(v: &Value) -> Option<VectorNetwork> {
|
||||
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;
|
||||
r.paint_space = None;
|
||||
}
|
||||
for p in &mut net.vertices {
|
||||
p.radius = p.radius.clamp(0.0, MAX_SIZE);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,24 @@ 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}}],
|
||||
"paint_space": {"width": 10, "height": 10, "transform": [1, 0, 0, 1, 5, 5]},
|
||||
"_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());
|
||||
assert!(network.regions[0].paint_space.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)}});
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -
|
||||
})
|
||||
.collect();
|
||||
let winding = winding(l);
|
||||
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() };
|
||||
let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding, ..Default::default() }] } else { Vec::new() };
|
||||
Some(VectorNetwork { vertices, segments, regions })
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option<VectorN
|
||||
return None;
|
||||
}
|
||||
if !loops.is_empty() {
|
||||
out.regions.push(Region { loops, winding });
|
||||
out.regions.push(Region { loops, winding, ..Default::default() });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,13 @@ pub fn simplify(path: &BezPath) -> Result<BezPath, PathOpsError> {
|
||||
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<BezPath, PathOpsError> {
|
||||
let rule = if even_odd { linesweeper::FillRule::EvenOdd } else { linesweeper::FillRule::NonZero };
|
||||
binary_by(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<BezPath, PathOpsError> {
|
||||
let op = match op {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+27
-2
@@ -329,8 +329,33 @@ impl<'a> Exporter<'a> {
|
||||
if let Some((path, r)) = &outline
|
||||
&& closed
|
||||
{
|
||||
for p in &n.fills {
|
||||
self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
|
||||
if let NodeKind::Vector { network } = &n.kind
|
||||
&& 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);
|
||||
for (i, region) in net.regions.iter().enumerate() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
if let Some(mut region_path) = net.region_path(i) {
|
||||
let (pw, ph, frame) = region.paint_frame(w, h);
|
||||
region_path.apply_affine(frame.inverse());
|
||||
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,
|
||||
PaintFrame { size: PaintSize { width: pw, height: ph }, transform: xf * frame },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for p in &n.fills {
|
||||
self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.place_inner(s, inner);
|
||||
|
||||
+32
-1
@@ -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,37 @@ 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())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region {
|
||||
loops: vec![vec![4, 5, 6, 7]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
|
||||
],
|
||||
};
|
||||
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<NodeId>) {
|
||||
let mut d = Document::new("sample");
|
||||
|
||||
@@ -326,6 +326,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() || r.paint_space.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))
|
||||
@@ -702,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;
|
||||
@@ -783,14 +829,18 @@ 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 = region_painted(n);
|
||||
// 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 {
|
||||
fill_vector_regions(ctx, pix, n, network, xf);
|
||||
} 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);
|
||||
|
||||
@@ -77,6 +77,9 @@ pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, m
|
||||
{
|
||||
return (ok, Some(levels));
|
||||
}
|
||||
if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())) {
|
||||
return (false, Some(0));
|
||||
}
|
||||
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),
|
||||
|
||||
@@ -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()), ..Default::default() });
|
||||
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();
|
||||
|
||||
+172
-1
@@ -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,177 @@ 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())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region {
|
||||
loops: vec![vec![4, 5, 6, 7]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(vec![]), ..Default::default() },
|
||||
],
|
||||
};
|
||||
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 region_paint_space_preserves_gradient_image_and_pattern_placement() {
|
||||
use figcraft_color::{ImageScaleMode, PaintKind, PatternPaint};
|
||||
use figcraft_doc::RegionPaintSpace;
|
||||
use figcraft_geom::{Affine, Rect, Shape};
|
||||
|
||||
let mut d = Document::new("paint frames");
|
||||
let page = d.first_page().unwrap();
|
||||
let tile = d.create(page, NodeKind::Rectangle, "Tile", 500.0, 500.0, 8.0, 6.0).unwrap();
|
||||
d.get_mut(tile).unwrap().fills = vec![Paint::solid(Color::from_hex("F05020").unwrap())];
|
||||
let mut image = Pixmap::new(8, 4).unwrap();
|
||||
for (i, pixel) in image.pixels_mut().iter_mut().enumerate() {
|
||||
*pixel = tiny_skia::PremultipliedColorU8::from_rgba((i % 8 * 30) as u8, (i / 8 * 60) as u8, 30, 255).unwrap();
|
||||
}
|
||||
d.images.insert(
|
||||
"region-frame-image".into(),
|
||||
figcraft_doc::ImageData { bytes: std::sync::Arc::new(encode_png(&image).unwrap()), width: 8, height: 4 },
|
||||
);
|
||||
let mut pattern = PatternPaint::new(tile.0);
|
||||
pattern.spacing = (3.0, 4.0);
|
||||
let paints = [
|
||||
Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)),
|
||||
Paint::image("region-frame-image", ImageScaleMode::Fill),
|
||||
Paint::image("region-frame-image", ImageScaleMode::Tile),
|
||||
Paint { kind: PaintKind::Pattern(pattern), ..Paint::solid(Color::WHITE) },
|
||||
];
|
||||
let frame = Affine::translate((35.0, 25.0)) * Affine::rotate(0.3);
|
||||
let id = d.create(page, NodeKind::Rectangle, "Source", 0.0, 0.0, 60.0, 30.0).unwrap();
|
||||
for paint in paints {
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.kind = NodeKind::Rectangle;
|
||||
n.transform = frame;
|
||||
n.width = 60.0;
|
||||
n.height = 30.0;
|
||||
n.fills = vec![paint.clone()];
|
||||
let before = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
|
||||
let mut path = Rect::new(0.0, 0.0, 60.0, 30.0).to_path(0.1);
|
||||
path.apply_affine(frame);
|
||||
let mut network = VectorNetwork::from_path(&path);
|
||||
for region in &mut network.regions {
|
||||
region.fills = Some(vec![paint.clone()]);
|
||||
region.paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: frame });
|
||||
}
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.kind = NodeKind::Vector { network };
|
||||
n.transform = Affine::IDENTITY;
|
||||
n.width = 120.0;
|
||||
n.height = 100.0;
|
||||
n.fills.clear();
|
||||
let after = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
|
||||
for (a, b) in before.data().iter().zip(after.data()) {
|
||||
assert!(a.abs_diff(*b) <= 1, "paint moved after reboxing: {:?}", paint.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
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 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);
|
||||
|
||||
+26
-5
@@ -494,11 +494,32 @@ 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!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", 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() || r.paint_space.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(mut region_path) = net.region_path(i) {
|
||||
let (pw, ph, frame) = region.paint_frame(w, h);
|
||||
region_path.apply_affine(frame.inverse());
|
||||
for p in paints {
|
||||
if let Some(a) = self.paint_attr(p, pw, ph) {
|
||||
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf * frame), 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!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -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,38 @@ 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())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region {
|
||||
loops: vec![vec![4, 5, 6, 7]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
|
||||
],
|
||||
};
|
||||
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("<path ").count(), 2, "empty region emits no path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_garbage() {
|
||||
let mut d = Document::new("t");
|
||||
@@ -30,6 +62,21 @@ fn import_rejects_garbage() {
|
||||
assert!(import_svg(&mut d, b"not svg", p, 0.0, 0.0, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_gradient_export_keeps_the_original_paint_box() {
|
||||
use figcraft_color::{Gradient, GradientKind};
|
||||
use figcraft_doc::RegionPaintSpace;
|
||||
use figcraft_geom::{Affine, Rect, Shape};
|
||||
let mut d = Document::new("region gradient");
|
||||
let page = d.first_page().unwrap();
|
||||
let mut network = VectorNetwork::from_path(&Rect::new(20.0, 10.0, 80.0, 40.0).to_path(0.1));
|
||||
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK))]);
|
||||
network.regions[0].paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: Affine::translate((20.0, 10.0)) });
|
||||
let id = d.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 120.0, 100.0).unwrap();
|
||||
let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("x1=\"30\" y1=\"0\" x2=\"30\" y2=\"30\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_gradient_and_groups() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">
|
||||
|
||||
@@ -1693,7 +1693,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 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية"
|
||||
|
||||
@@ -1637,7 +1637,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" = "গ্রিড ট্র্যাক তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে"
|
||||
|
||||
@@ -1603,7 +1603,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky"
|
||||
"entries without a node type were skipped" = "položky bez typu uzlu byly přeskočeny"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy"
|
||||
"from {server}" = "ze serveru {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost"
|
||||
|
||||
@@ -1563,7 +1563,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert"
|
||||
"entries without a node type were skipped" = "Einträge ohne Knotentyp wurden übersprungen"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-Links aus Browser, Chat oder E-Mail öffnen diese App und treten der Sitzung bei."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "gefüllte Bereiche eines Vektors mit eigener Füllung verwenden die Füllung der Ebene"
|
||||
"from {server}" = "von {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "Gamepad-Auslöser ohne genau eine bekannte Controllertaste wurden übersprungen"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspuren hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe"
|
||||
|
||||
@@ -1692,7 +1692,6 @@ name = "English"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines"
|
||||
"entries without a node type were skipped" = "entries without a node type were skipped"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "filled areas of a vector with their own fill use the layer's fill"
|
||||
"from {server}" = "from {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gamepad triggers without exactly one known controller button were skipped"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track list exceeds 1000 entries; the tracks use the default equal sizing"
|
||||
@@ -1713,12 +1712,15 @@ name = "English"
|
||||
"invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored"
|
||||
"invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines"
|
||||
"invalid or oversized vector path data was skipped" = "invalid or oversized vector path data was skipped"
|
||||
"invalid vector region fill paint list; layer fill used" = "invalid vector region fill paint list; layer fill used"
|
||||
"invalid vector region paints were skipped" = "invalid vector region paints were skipped"
|
||||
"just now" = "just now"
|
||||
"key triggers without a supported key were skipped" = "key triggers without a supported key were skipped"
|
||||
"key up triggers import as key presses" = "key up triggers import as key presses"
|
||||
"layers without a position were placed at their parent's origin" = "layers without a position were placed at their parent's origin"
|
||||
"layers without a type were skipped" = "layers without a type were skipped"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "legacy image filter container is unsupported; filter conversion was skipped"
|
||||
"linked vector region fill styles keep resolved appearance; live style links were left out" = "linked vector region fill styles keep resolved appearance; live style links were left out"
|
||||
"locked" = "locked"
|
||||
"malformed image filters container was skipped" = "malformed image filters container was skipped"
|
||||
"malformed image paintFilter container was skipped" = "malformed image paintFilter container was skipped"
|
||||
@@ -1774,10 +1776,13 @@ name = "English"
|
||||
"variable modes of collections that aren't in the file were skipped" = "variable modes of collections that aren't in the file were skipped"
|
||||
"variable modes of collections that aren't in the variables JSON were skipped" = "variable modes of collections that aren't in the variables JSON were skipped"
|
||||
"variable values that don't match their type (or composed colours) were skipped" = "variable values that don't match their type (or composed colours) were skipped"
|
||||
"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "variable-bound vector region paints keep resolved appearance; live bindings were left out"
|
||||
"variables of unknown types were skipped" = "variables of unknown types were skipped"
|
||||
"variables whose collection isn't in the variables JSON were skipped" = "variables whose collection isn't in the variables JSON were skipped"
|
||||
"variables without readable values were skipped" = "variables without readable values were skipped"
|
||||
"vector points with unknown stroke joins use the layer join" = "vector points with unknown stroke joins use the layer join"
|
||||
"vector region paint data exceeded import limits and was skipped" = "vector region paint data exceeded import limits and was skipped"
|
||||
"vector region references a missing fill style; layer fill used" = "vector region references a missing fill style; layer fill used"
|
||||
"vectors without geometry are empty" = "vectors without geometry are empty"
|
||||
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectors without geometry are empty (export the file JSON with geometry=paths)"
|
||||
"very long text was cut to 1,048,576 characters" = "very long text was cut to 1,048,576 characters"
|
||||
|
||||
@@ -1681,7 +1681,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas"
|
||||
"entries without a node type were skipped" = "se omitieron las entradas sin tipo de nodo"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "las áreas rellenas de un vector con relleno propio usan el relleno de la capa"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de mando sin exactamente un botón de mando conocido"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de pistas de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado"
|
||||
|
||||
@@ -1642,7 +1642,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "mutkaiset ja kaarevat yhdistimet, joilla ei ole tallennettua polkua, tuodaan suorina viivoina"
|
||||
"entries without a node type were skipped" = "kohteet, joilta puuttuu solmutyyppi, ohitettiin"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "selaimen, keskustelun tai sähköpostin figcraft://-linkit avaavat tämän sovelluksen ja liittyvät istuntoon."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "vektorin omalla täytöllä määritetyt alueet käyttävät tason täyttöä"
|
||||
"from {server}" = "palvelimelta {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "peliohjainlaukaisimet, joilla ei ole täsmälleen yhtä tunnettua ohjainpainiketta, ohitettiin"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon osien luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa"
|
||||
|
||||
@@ -1645,7 +1645,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites"
|
||||
"entries without a node type were skipped" = "les entrées sans type de nœud ont été ignorées"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Les liens figcraft:// d’un navigateur, d’une messagerie ou d’un e-mail ouvrent cette application et rejoignent la session."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque"
|
||||
"from {server}" = "depuis {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut"
|
||||
|
||||
@@ -1607,7 +1607,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus"
|
||||
"entries without a node type were skipped" = "entri tanpa tipe node dilewati"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer"
|
||||
"from {server}" = "dari {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan"
|
||||
|
||||
@@ -1580,7 +1580,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "i connettori ad angolo e curvi senza un tracciato salvato vengono importati come linee rette"
|
||||
"entries without a node type were skipped" = "le voci senza tipo di nodo sono state saltate"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "i link figcraft:// da un browser, una chat o un'email aprono questa app e partecipano alla sessione."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "le aree riempite di un vettore con riempimento proprio usano il riempimento del livello"
|
||||
"from {server}" = "da {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "i trigger del gamepad senza esattamente un pulsante noto del controller sono stati saltati"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "l'elenco delle tracce della griglia supera 1000 voci; le tracce usano il dimensionamento uniforme predefinito"
|
||||
|
||||
@@ -1626,7 +1626,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" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "グリッドトラックのリストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用"
|
||||
|
||||
@@ -1663,7 +1663,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"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 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს"
|
||||
|
||||
@@ -1554,7 +1554,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개 항목을 넘어 트랙은 기본 균등 크기를 사용함"
|
||||
|
||||
@@ -1642,7 +1642,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "haakse en gebogen connectors zonder opgeslagen pad worden geïmporteerd als rechte lijnen"
|
||||
"entries without a node type were skipped" = "items zonder node-type zijn overgeslagen"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-links uit een browser, chat of e-mail openen deze app en laten je deelnemen aan de sessie."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "gevulde gebieden van een vector met een eigen vulling gebruiken de vulling van de laag"
|
||||
"from {server}" = "van {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gamepad-triggers zonder precies één bekende controllerknop zijn overgeslagen"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met rastertracks overschrijdt 1000 items; de tracks gebruiken de standaard gelijke grootte"
|
||||
|
||||
@@ -1652,7 +1652,6 @@ name = "Polski"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "złamane i zakrzywione łączniki bez zapisanej ścieżki są importowane jako linie proste"
|
||||
"entries without a node type were skipped" = "wpisy bez typu węzła zostały pominięte"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "linki figcraft:// z przeglądarki, czatu lub poczty e-mail otwierają tę aplikację i dołączają do sesji."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "wypełnione obszary wektora z własnym wypełnieniem używają wypełnienia warstwy"
|
||||
"from {server}" = "z serwera {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "wyzwalacze kontrolera bez dokładnie jednego znanego przycisku zostały pominięte"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "lista ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru"
|
||||
|
||||
@@ -1552,7 +1552,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas"
|
||||
"entries without a node type were skipped" = "entradas sem tipo de nó foram ignoradas"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Links figcraft:// de um navegador, chat ou e-mail abrem este app e entram na sessão."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão"
|
||||
|
||||
@@ -1652,7 +1652,6 @@ name = "Русский"
|
||||
"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 записей; треки получают стандартный равный размер"
|
||||
|
||||
@@ -1688,7 +1688,6 @@ name = "Svenska"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer"
|
||||
"entries without a node type were skipped" = "poster utan nodtyp hoppades över"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning"
|
||||
"from {server}" = "från {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår"
|
||||
|
||||
@@ -1631,7 +1631,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 รายการ: แทร็กใช้ขนาดเท่ากันตามค่าเริ่มต้น"
|
||||
|
||||
@@ -1607,7 +1607,6 @@ name = "Türkçe"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır"
|
||||
"entries without a node type were skipped" = "düğüm türü olmayan girdiler atlandı"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır"
|
||||
"from {server}" = "kaynak: {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır"
|
||||
|
||||
@@ -1581,7 +1581,6 @@ status = "draft — needs native review"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "các đường nối gấp khúc và cong không có đường dẫn đã lưu được nhập thành đường thẳng"
|
||||
"entries without a node type were skipped" = "đã bỏ qua các mục không có loại nút"
|
||||
"figcraft:// links from a browser, chat or email open this app and join the session." = "Liên kết figcraft:// từ trình duyệt, ứng dụng trò chuyện hoặc email sẽ mở ứng dụng này và tham gia phiên."
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "các vùng tô riêng trong một vector sẽ dùng màu tô của lớp"
|
||||
"from {server}" = "từ {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "đã bỏ qua các kích hoạt tay cầm không có đúng một nút điều khiển đã biết"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định"
|
||||
|
||||
@@ -1562,7 +1562,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 个条目;轨道使用默认的等分尺寸"
|
||||
|
||||
@@ -17,7 +17,7 @@ and prototype interactions are still unchecked there.
|
||||
|
||||
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|
||||
|---|---|---|---|---|---|
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) |
|
||||
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
@@ -77,7 +77,8 @@ visible and opaque (a page also its background opacity): a reader takes what is
|
||||
draws nothing below a hidden page. The message
|
||||
lists one node change per layer, parents first, each with its GUID, its parent and a position
|
||||
string that orders it among its siblings; vector networks and outlines go in blobs, written once
|
||||
each.
|
||||
each. `.fig` export warns and drops vector region paint stacks; import and editing preserve their
|
||||
source paint coordinates.
|
||||
|
||||
- **Written:** pages (name, background); frames, groups, sections, rectangles (as rounded
|
||||
rectangles, with their four radii), ellipses (arcs), polygons, stars, lines, vectors (editable
|
||||
|
||||
+5
-2
@@ -79,9 +79,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
`.figcraft` path. Layers copied from another design app paste (`edit.pasteDesignLayers`, and
|
||||
Cmd/Ctrl+V when the clipboard's HTML holds a `.fig` canvas): read like a `.fig` file, with its
|
||||
warnings, size-checked (256 MB) before decoding.
|
||||
- **Landed (2026-10-26):** `.fig` import reads vector-region fill stacks; vector editing and path
|
||||
operations retain them with their source paint coordinates. `.fig` export still warns and drops
|
||||
region fills. Evidence: `crates/format/src/fig/tail_tests.rs`, `crates/engine/src/shapebuilder_region_paints_tests.rs`.
|
||||
- **Missing:** elbowed or curved connectors without a saved path (drawn straight), labels of
|
||||
connectors without a saved snapshot, table cells beyond their own layers, filled areas of a
|
||||
vector with their own fills (#634, held for an owner decision on the document model), variables
|
||||
connectors without a saved snapshot, table cells beyond their own layers, live region
|
||||
style/variable links, variables
|
||||
bound to fields the editor can't bind and video fills aren't read; text lays out again with
|
||||
local fonts. Nothing has been checked against real-world files yet (gap 3): the 2026-10-17
|
||||
changes were tested on files built in code, and paste from another design app on clipboard HTML
|
||||
|
||||
Reference in New Issue
Block a user