Merge main into #1516

This commit is contained in:
bflatastic
2026-10-29 23:58:44 +07:00
842 changed files with 116826 additions and 41136 deletions
+277 -5
View File
@@ -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}"))
}
@@ -94,7 +364,8 @@ fn entering_and_leaving_vector_edit_mode() {
("Enter", false, "Enter leaves edit mode"),
("Escape", false, "Escape leaves edit mode"),
("Escape with an open pen path", true, "Escape first ends the pen's path and stays in edit mode"),
("click outside", false, "silent: a click away from the shape leaves edit mode (pinned)"),
("click outside", true, "silent: a click away from the shape only clears the points (pinned)"),
("double-click outside", false, "a double-click away from the shape leaves edit mode"),
];
for (how, after, why) in rows {
let mut s = Session::new();
@@ -113,6 +384,7 @@ fn entering_and_leaving_vector_edit_mode() {
key(&mut s, "Escape", false);
assert!(editing(&s).is_some_and(|e| e.anchor.is_none()), "{why}: the path ended");
}
"double-click outside" => s.pointer(&ev(PointerKind::DoubleClick, (600.0, 600.0), NONE)).unwrap(),
_ => click(&mut s, (600.0, 600.0), NONE),
}
assert_eq!(editing(&s).is_some(), after, "{why}");
@@ -236,7 +508,7 @@ fn selecting_points_and_segments() {
match what {
"click tl" => click(&mut s, (0.0, 0.0), m),
"click tr" => click(&mut s, (100.0, 0.0), m),
"click top" => click(&mut s, (50.0, 0.0), m),
"click top" => click(&mut s, (30.0, 0.0), m),
"marquee top" => drag(&mut s, (-20.0, -20.0), (110.0, 10.0), m),
_ => {
ex(&mut s, "vector.edit.tool", json!({"tool": "lasso"}));
@@ -331,7 +603,7 @@ fn deleting_points_vs_delete_and_heal() {
// Deleting a selected segment keeps its points when others use them.
let mut s = Session::new();
let id = square(&mut s, true);
click(&mut s, (50.0, 0.0), NONE);
click(&mut s, (30.0, 0.0), NONE);
key(&mut s, "Delete", false);
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len()), (4, 3), "deleting a segment leaves its points");