mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
With the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour (1.4 px), clipped by the area's own fill rule so holes stay clear. The hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); between those clearances and the 6 px hit radius the area wins. Bezier handles, the transform box's handles and other edited layers' parts come first. Line spacing follows octave folding: 8 px at every power-of-two zoom, growing with the zoom and stepping back at sqrt(2) * 2^n. The pattern is anchored to the vector's origin, so it stays on the shape when panning, and several selected areas share one pattern. The last area listed wins where areas overlap, and only vectors with a visible fill have areas. A click selects an area with its points; they move with it (drags, nudges, the Design panel) but keep the unselected look and show no handles. Shift adds an area or takes a selected one out (its own points go, points selected on their own or shared with another selected area stay). A press on a selected area drags everything selected, as one undo step; Escape puts it back. Drags show no hover hatch. Any other press without Shift drops the areas and the points selected only through them, and a click on an empty spot such as a hole clears them and stays in edit mode. Inside the transform box around points selected on their own, the box keeps its drag unless the area is already selected. Area indexes are dropped when the network's topology changes; moving points keeps them. The network gets region walking, an inside test per region by its own fill rule, the region under a point, a region's points and a topology fingerprint (vector_regions.rs). The engine does hit testing and lays out and clips the hatch in screen space (vectoredit/regions.rs); the canvas only draws the lines. vector.edit.select takes regions, and vector.edit.state reports regions, regionVertices and regionHover. Tests: vector_regions_tests (doc) and regions_tests (engine), covering fill rules, overlap, curves, reversed segments, clearances at 100% and 400%, selection, drags, undo and cancel, and the hatch spacing at seven zoom levels, its direction, anchoring, coverage and clipping. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
8.8 KiB
Rust
206 lines
8.8 KiB
Rust
//! Regions as areas (`vector_regions.rs`): the inside test by each region's own fill rule, the last
|
|
//! region winning where regions overlap, curved edges, loops with segments stored backwards, a
|
|
//! region's points and empty or damaged networks.
|
|
|
|
use figcraft_geom::Point;
|
|
|
|
use crate::vector::{Region, Segment, VectorNetwork, Vertex, WindingRule};
|
|
|
|
/// Four points and four straight segments of a square at (`x0`, `y0`), clockwise on screen (y
|
|
/// down), appended to `n`. Returns the segment indexes in ring order.
|
|
fn square(n: &mut VectorNetwork, x0: f64, y0: f64, size: f64) -> Vec<usize> {
|
|
let v0 = n.vertices.len();
|
|
for (x, y) in [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)] {
|
|
n.vertices.push(Vertex::new(x, y));
|
|
}
|
|
let s0 = n.segments.len();
|
|
for k in 0..4 {
|
|
n.segments.push(Segment::line(v0 + k, v0 + (k + 1) % 4));
|
|
}
|
|
(s0..s0 + 4).collect()
|
|
}
|
|
|
|
/// The 7.1 network: a 200 square (points 0..3, segments 0..3) with a 80 square inside it (points
|
|
/// 4..7, segments 4..7), both wound the same way, and no regions yet.
|
|
fn nested() -> VectorNetwork {
|
|
let mut n = VectorNetwork::default();
|
|
square(&mut n, 0.0, 0.0, 200.0);
|
|
square(&mut n, 60.0, 60.0, 80.0);
|
|
n
|
|
}
|
|
|
|
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
|
Region { loops, winding }
|
|
}
|
|
|
|
fn p(x: f64, y: f64) -> Point {
|
|
Point::new(x, y)
|
|
}
|
|
|
|
#[test]
|
|
fn the_inside_test_follows_each_regions_own_fill_rule() {
|
|
let mut n = nested();
|
|
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
|
// 1. Non-zero: the centre counts (both rings wound the same way).
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
|
// 4. Outside.
|
|
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
|
n.regions[0].winding = WindingRule::EvenOdd;
|
|
// 2. Even-odd: the centre is a hole; 3. the ring is inside.
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), None);
|
|
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
|
|
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
|
// 5. The inner ring wound the other way, non-zero: a hole.
|
|
let mut r = nested();
|
|
for s in &mut r.segments[4..8] {
|
|
std::mem::swap(&mut s.a, &mut s.b);
|
|
}
|
|
r.regions = vec![region(vec![vec![0, 1, 2, 3], vec![7, 6, 5, 4]], WindingRule::NonZero)];
|
|
assert_eq!(r.region_at(p(100.0, 100.0)), None);
|
|
assert_eq!(r.region_at(p(20.0, 100.0)), Some(0));
|
|
// Regions of one path may use different rules.
|
|
let mut m = nested();
|
|
m.regions = vec![
|
|
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero),
|
|
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::EvenOdd),
|
|
];
|
|
assert_eq!(m.region_at(p(100.0, 100.0)), Some(0), "the even-odd region has a hole there, the non-zero one doesn't");
|
|
assert_eq!(m.region_at(p(20.0, 100.0)), Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn the_last_overlapping_region_wins() {
|
|
let mut n = nested();
|
|
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
|
// 6, 7.
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
|
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
|
|
// 8. Swapped: the outer square (region 0 before, listed last now) wins in the middle.
|
|
n.regions.swap(0, 1);
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
|
assert_eq!(n.region_points(1), vec![0, 1, 2, 3], "index 1 is the outer square now");
|
|
assert_eq!(n.region_at(p(20.0, 100.0)), Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn curved_edges_count_where_the_curve_runs() {
|
|
let mut n = VectorNetwork {
|
|
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(120.0, 0.0)],
|
|
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -80.0), tb: (0.0, -80.0) }, Segment::line(1, 0)],
|
|
regions: vec![region(vec![vec![0, 1]], WindingRule::NonZero)],
|
|
};
|
|
// 9. Inside the bulge, above the chord; 10. below the chord; 11. above the curve's top (-60).
|
|
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
|
|
assert_eq!(n.region_at(p(60.0, 20.0)), None);
|
|
assert_eq!(n.region_at(p(60.0, -70.0)), None);
|
|
assert_eq!(n.region_at(p(60.0, -58.0)), Some(0), "just under the top of the curve");
|
|
// The lens walks toward the point it shares with the second segment, whichever way that is.
|
|
n.segments.swap(0, 1);
|
|
n.regions = vec![region(vec![vec![0, 1]], WindingRule::NonZero)];
|
|
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
|
|
assert_eq!(n.region_at(p(60.0, -70.0)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_single_curved_segment_loop_encloses_its_area() {
|
|
let n = VectorNetwork {
|
|
vertices: vec![Vertex::new(0.0, 0.0)],
|
|
segments: vec![Segment { a: 0, b: 0, ta: (100.0, -100.0), tb: (-100.0, -100.0) }],
|
|
regions: vec![region(vec![vec![0]], WindingRule::NonZero)],
|
|
};
|
|
assert_eq!(n.region_at(p(0.0, -40.0)), Some(0));
|
|
assert_eq!(n.region_at(p(0.0, 40.0)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn segments_stored_backwards_are_walked_the_right_way() {
|
|
let mut n = VectorNetwork::default();
|
|
square(&mut n, 0.0, 0.0, 200.0);
|
|
// Segment 1 stored as 2 → 1 and segment 3 as 0 → 3.
|
|
n.segments[1] = Segment::line(2, 1);
|
|
n.segments[3] = Segment::line(0, 3);
|
|
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
|
// 12, 13.
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
|
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
|
let walked = n.loop_walk(&[0, 1, 2, 3]);
|
|
assert_eq!(walked, vec![(0, false), (1, true), (2, false), (3, true)]);
|
|
// 14. The first segment reversed too: unchanged.
|
|
n.segments[0] = Segment::line(1, 0);
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
|
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
|
// The walk equals the same loop stored consistently: one closed contour of the same area.
|
|
use figcraft_geom::Shape;
|
|
let mut consistent = VectorNetwork::default();
|
|
square(&mut consistent, 0.0, 0.0, 200.0);
|
|
consistent.regions = n.regions.clone();
|
|
let (a, b) = (n.region_path(0).unwrap().area().abs(), consistent.region_path(0).unwrap().area().abs());
|
|
assert!((a - 40_000.0).abs() < 1e-6 && (b - 40_000.0).abs() < 1e-6, "{a} {b}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_loop_that_does_not_close_is_closed_implicitly() {
|
|
let mut n = VectorNetwork::default();
|
|
square(&mut n, 0.0, 0.0, 200.0);
|
|
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
|
|
// The walk ends at point 3 and closes straight back to point 0.
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
|
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_regions_points_are_listed_once() {
|
|
let mut n = nested();
|
|
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
|
// 15.
|
|
assert_eq!(n.region_points(0), vec![0, 1, 2, 3, 4, 5, 6, 7]);
|
|
// 16.
|
|
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
|
assert_eq!(n.region_points(1), vec![4, 5, 6, 7]);
|
|
assert!(n.region_points(9).is_empty());
|
|
// 17. A missing segment is ignored.
|
|
n.regions = vec![region(vec![vec![0, 1, 99, 2, 3]], WindingRule::NonZero)];
|
|
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
|
|
// 18.
|
|
let mut n = nested();
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), None);
|
|
let empty = VectorNetwork::default();
|
|
assert_eq!(empty.region_at(p(0.0, 0.0)), None);
|
|
assert!(empty.region_path(0).is_none());
|
|
assert!(empty.region_points(0).is_empty());
|
|
assert_eq!(empty.region_winding(0, p(0.0, 0.0)), None);
|
|
// Loops naming missing segments, segments naming missing points, empty loops, junk points.
|
|
n.regions = vec![region(vec![vec![], vec![42, 43]], WindingRule::EvenOdd), region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
|
n.segments.push(Segment::line(1, 500));
|
|
n.regions.push(region(vec![vec![8, 8, 8]], WindingRule::NonZero));
|
|
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
|
assert!(n.region_path(0).is_none());
|
|
assert!(n.region_path(2).is_none());
|
|
for q in [p(f64::NAN, 0.0), p(f64::INFINITY, 1.0), p(1e300, -1e300)] {
|
|
let _ = n.region_at(q);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
|
|
let mut n = nested();
|
|
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
|
let k = n.topology_key();
|
|
n.translate(15.0, 25.0);
|
|
n.segments[0].ta = (5.0, 5.0);
|
|
assert_eq!(n.topology_key(), k, "moving points and handles keeps it");
|
|
n.regions.push(region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero));
|
|
assert_ne!(n.topology_key(), k);
|
|
let k = n.topology_key();
|
|
n.regions.swap(0, 1);
|
|
assert_ne!(n.topology_key(), k, "reordered regions");
|
|
let k = n.topology_key();
|
|
n.segments.push(Segment::line(0, 2));
|
|
assert_ne!(n.topology_key(), k);
|
|
}
|