Files
figcraft/crates/engine/src/vector_fill_rule_tests.rs
T
叶林橦 d489235d2c engine: short loops in vector.reverseLoop really turn round
reverseLoop reversed the list and skipped shared segments, which only turns a
loop of three or more round: a one-segment loop has no order at all, and a
two-segment ring reads the same whichever way its list is turned, so both
answered reversed: true without moving.

Three or more segments: reverse the list and leave every stored end alone (a
shared one belongs to the neighbour too). One or two: store one segment the
other way round, skipping any segment another short loop lists; when none is
left to swap, refuse with a clear error and leave the document as it was.

Tests: two 2-segment areas over one line that share it, the shared segment
listed first in one area and last in the other; each reversal flips that area's
winding sign and leaves the shared segment and the neighbour alone. A shared
one-segment loop is refused. The 4-segment squares stay as the long-loop case.

Drop the two catalog.rs menu lines: they aren't in the reference app's menus,
and the Vector menu runs commands without parameters, so both would fail there.
2026-10-17 22:35:50 +08:00

307 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fill rules on vectors: `vector.fillRule` sets the rule a filled area fills by and
//! `vector.reverseLoop` turns one of its loops round — which is how a hole is made and unmade.
//! The numbers come from the two rules' definitions: non-zero fills where the winding count is not
//! zero (so a hole is a loop running against its outer loop), even-odd where it is odd (so the
//! direction of a loop doesn't matter).
use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::{BezPath, Point};
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn net(s: &Session, id: NodeId) -> VectorNetwork {
match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Vector { network } => network.clone(),
other => panic!("not a vector: {other:?}"),
}
}
/// A 100 × 100 square with a 25…75 hole, both loops in one area: the outer one clockwise, the inner
/// one the same way round (so non-zero fills the hole too until it is turned round).
fn ring() -> VectorNetwork {
let mut path = BezPath::new();
for (x0, y0, x1, y1) in [(0.0, 0.0, 100.0, 100.0), (25.0, 25.0, 75.0, 75.0)] {
path.move_to((x0, y0));
path.line_to((x1, y0));
path.line_to((x1, y1));
path.line_to((x0, y1));
path.close_path();
}
let mut net = VectorNetwork::from_path(&path);
net.regions[0].winding = WindingRule::NonZero;
net
}
/// A selected vector layer holding `network`.
fn vector_layer(s: &mut Session, network: VectorNetwork) -> NodeId {
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let id = NodeId(id);
ex(s, "vector.edit.begin", json!({"id": id}));
ex(s, "vector.edit.end", json!({}));
{
let doc = s.doc_mut().unwrap();
match &mut doc.get_mut(id).unwrap().kind {
NodeKind::Vector { network: net } => *net = network,
other => panic!("not a vector: {other:?}"),
}
}
ex(s, "select.set", json!({"ids": [id]}));
id
}
/// A selected vector layer holding `ring()`.
fn ringed(s: &mut Session) -> NodeId {
vector_layer(s, ring())
}
/// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by
/// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must
/// not flip, or a neighbour's fill or stroke would turn round with it.
fn two_squares() -> VectorNetwork {
// 0:A(0,0) 1:B(50,0) 2:C(100,0) 3:D(100,100) 4:E(50,100) 5:F(0,100)
// s0 A→B (left bottom), s1 B→E (the shared vertical edge), s2 E→F (left top),
// s3 F→A (left left), s4 B→C (right bottom), s5 C→D (right right), s6 D→E (right top);
// the right area's left edge is s1 walked the other way, so the squares share one segment.
// region 0: left square A→B→E→F→A = [s0, s1, s2, s3].
// region 1: right square B→C→D→E→B = [s4, s5, s6, s1] (s1 walked backwards).
VectorNetwork {
vertices: vec![
Vertex::new(0.0, 0.0),
Vertex::new(50.0, 0.0),
Vertex::new(100.0, 0.0),
Vertex::new(100.0, 100.0),
Vertex::new(50.0, 100.0),
Vertex::new(0.0, 100.0),
],
segments: vec![
Segment::line(0, 1),
Segment::line(1, 4),
Segment::line(4, 5),
Segment::line(5, 0),
Segment::line(1, 2),
Segment::line(2, 3),
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 },
],
}
}
/// A selected vector layer holding [`two_squares`].
fn squared(s: &mut Session) -> NodeId {
vector_layer(s, two_squares())
}
/// Two areas of two segments each over the same line: one curve above it and one below. Both areas
/// list the line's segment — the first area first (`[line, above]`), the second area last
/// (`[below, line]`) — and a two-segment ring reads the same whichever way its list is turned, so
/// these only turn round when a segment is stored the other way.
fn two_lenses() -> VectorNetwork {
// 0:A(0,0) 1:B(100,0)
// s0 A→B the straight line both areas use; s1 B→A curving above it; s2 A→B curving below it.
VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
segments: vec![
Segment::line(0, 1),
Segment { a: 1, b: 0, ta: (0.0, -50.0), tb: (0.0, -50.0) },
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 },
],
}
}
/// A selected vector layer holding [`two_lenses`].
fn lensed(s: &mut Session) -> NodeId {
vector_layer(s, two_lenses())
}
/// Two areas whose only loop is the same closed segment (a curve the path closes with a straight
/// line): a one-segment loop's direction is its segment's stored ends, and both areas read them.
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 }],
}
}
/// The point in the middle of the hole, and one on the square's left band.
const HOLE: Point = Point::new(50.0, 50.0);
const BAND: Point = Point::new(10.0, 50.0);
#[test]
fn a_loop_running_with_its_outer_loop_fills_the_hole_until_it_is_turned_round() {
let mut s = Session::new();
let id = ringed(&mut s);
let n = net(&s, id);
assert_eq!(n.regions.len(), 1);
assert_eq!(n.regions[0].loops.len(), 2, "an outer loop and the hole's");
assert!(n.region_contains(0, BAND), "the band fills");
assert!(n.region_contains(0, HOLE), "the hole fills while its loop runs the same way");
// Turning the inner loop round makes it a hole under non-zero.
let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 1}));
assert_eq!(r["reversed"], json!(true));
let n = net(&s, id);
assert!(n.region_contains(0, BAND), "the band still fills");
assert!(!n.region_contains(0, HOLE), "the hole is empty");
assert_eq!(n.region_winding(0, HOLE), Some(0), "its loops cancel out there");
// Undo turns it back.
s.undo().unwrap();
assert!(net(&s, id).region_contains(0, HOLE), "one undo step restores the loop");
}
#[test]
fn the_rule_switches_every_area_or_only_the_one_asked_for() {
let mut s = Session::new();
let id = ringed(&mut s);
// Even-odd leaves the hole empty whatever way its loop runs.
let r = ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"}));
assert_eq!((r["regions"].clone(), r["rule"].clone()), (json!(1), json!("evenOdd")));
let n = net(&s, id);
assert_eq!(n.regions[0].winding, WindingRule::EvenOdd);
assert!(!n.region_contains(0, HOLE), "even-odd counts the hole's loop as a second crossing");
// And back.
ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "nonZero"}));
assert!(net(&s, id).region_contains(0, HOLE), "non-zero fills it again");
// One area of several, by index.
let n = net(&s, id);
let mut two = n.clone();
two.regions.push(n.regions[0].clone());
{
let doc = s.doc_mut().unwrap();
match &mut doc.get_mut(id).unwrap().kind {
NodeKind::Vector { network } => *network = two,
other => panic!("not a vector: {other:?}"),
}
}
ex(&mut s, "vector.fillRule", json!({"id": id, "region": 1, "rule": "evenOdd"}));
let n = net(&s, id);
assert_eq!(n.regions[0].winding, WindingRule::NonZero, "the other area keeps its rule");
assert_eq!(n.regions[1].winding, WindingRule::EvenOdd);
// Every area at once.
ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"}));
assert!(net(&s, id).regions.iter().all(|r| r.winding == WindingRule::EvenOdd));
}
#[test]
fn junk_parameters_are_refused_and_change_nothing() {
let mut s = Session::new();
let id = ringed(&mut s);
let before = net(&s, id);
for p in [
json!({"id": id, "rule": "nope"}),
json!({"id": id}),
json!({"id": id, "rule": 7}),
json!({"id": id, "rule": "evenOdd", "region": -1}),
json!({"id": id, "rule": "evenOdd", "region": 1.5}),
json!({"id": id, "rule": "evenOdd", "region": "x"}),
json!({"id": id, "rule": "evenOdd", "region": 99}),
] {
assert!(s.execute("vector.fillRule", &p).is_err(), "{p} is refused");
}
for p in [
json!({"id": id}),
json!({"id": id, "region": 0}),
json!({"id": id, "region": 0, "loop": 99}),
json!({"id": id, "region": 99, "loop": 0}),
json!({"id": id, "region": -2, "loop": 0}),
] {
assert!(s.execute("vector.reverseLoop", &p).is_err(), "{p} is refused");
}
assert_eq!(net(&s, id), before, "nothing changed");
// A rectangle that was never a vector is refused too.
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
assert!(s.execute("vector.fillRule", &json!({"id": rect, "rule": "evenOdd"})).is_err());
assert!(s.execute("vector.reverseLoop", &json!({"id": rect, "region": 0, "loop": 0})).is_err());
}
#[test]
fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() {
let mut s = Session::new();
let id = squared(&mut s);
let n = net(&s, id);
assert_eq!(n.regions.len(), 2, "two areas, side by side");
// Both areas fill under non-zero: a point in each.
assert!(n.region_contains(0, Point::new(25.0, 50.0)), "the left area fills");
assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the right area fills");
// The vertical edge between them is one segment listed by both areas' loops.
const SHARED: usize = 1;
assert!(n.regions[0].loops[0].contains(&SHARED) && n.regions[1].loops[0].contains(&SHARED), "one segment is shared");
let before = n.segments[SHARED];
let left_winding = n.region_winding(0, Point::new(25.0, 50.0)).unwrap();
assert_ne!(left_winding, 0, "the left area winds once");
// Reverse the left area's only loop.
let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}));
assert_eq!(r["reversed"], json!(true));
let n = net(&s, id);
// The shared segment's stored ends are untouched: reversing the loop must not flip a neighbour's
// segment (reversing the list already turns this loop's traversal of it the other way).
assert_eq!(n.segments[SHARED], before, "the shared segment is left as it was");
// The neighbour's fill is unchanged: it still fills its interior.
assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the neighbour still fills");
// The reversed loop really turned round: its winding at a left-area point flipped sign.
assert_eq!(n.region_winding(0, Point::new(25.0, 50.0)), Some(-left_winding), "the left loop reversed");
// Undo restores the shared segment and the loop.
s.undo().unwrap();
assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too");
}
#[test]
fn a_two_segment_loop_turns_round_with_its_shared_segment_listed_first_or_last() {
let mut s = Session::new();
let id = lensed(&mut s);
const SHARED: usize = 0;
const ABOVE: Point = Point::new(50.0, -25.0);
const BELOW: Point = Point::new(50.0, 25.0);
let n = net(&s, id);
assert_eq!(n.regions.len(), 2, "two areas over the same line");
assert_eq!((n.regions[0].loops[0][0], n.regions[0].loops[0].len()), (SHARED, 2), "the shared segment is listed first");
assert_eq!((n.regions[1].loops[0][1], n.regions[1].loops[0].len()), (SHARED, 2), "and last");
assert!(n.region_contains(0, ABOVE), "the area above the line fills");
assert!(n.region_contains(1, BELOW), "the area below it fills");
let (above, below) = (n.region_winding(0, ABOVE).unwrap(), n.region_winding(1, BELOW).unwrap());
assert_ne!(above, 0, "the area above winds once");
assert_ne!(below, 0, "the area below winds once");
let shared = n.segments[SHARED];
// Turn the area that lists the shared segment first round: a two-segment ring reads the same
// either way, so this only works by storing one of its segments the other way.
assert_eq!(ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}))["reversed"], json!(true));
let n = net(&s, id);
assert_eq!(n.segments[SHARED], shared, "the shared segment keeps its ends");
assert!(n.region_contains(0, ABOVE), "the area still fills");
assert_eq!(n.region_winding(0, ABOVE), Some(-above), "it really turned round");
assert_eq!(n.region_winding(1, BELOW), Some(below), "the neighbour did not");
// And the area that lists it last.
ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 1, "loop": 0}));
let n = net(&s, id);
assert_eq!(n.segments[SHARED], shared, "still untouched");
assert_eq!(n.region_winding(1, BELOW), Some(-below), "the second area really turned round");
assert_eq!(n.region_winding(0, ABOVE), Some(-above), "the first stays as it was");
// Undo turns each back.
s.undo().unwrap();
assert_eq!(net(&s, id).region_winding(1, BELOW), Some(below), "undo turns the second back");
s.undo().unwrap();
assert_eq!(net(&s, id).region_winding(0, ABOVE), Some(above), "and the first");
}
#[test]
fn a_one_segment_loop_shared_with_another_is_refused_instead_of_claiming_a_turn() {
let mut s = Session::new();
let id = vector_layer(&mut s, one_shared_segment());
let before = net(&s, id);
let err = s.execute("vector.reverseLoop", &json!({"id": id, "region": 0, "loop": 0})).unwrap_err();
let message = err.to_string();
assert!(message.contains("shares its segments"), "the error says why: {message}");
assert_eq!(net(&s, id), before, "nothing changed");
}