Lightningbeam/lightningbeam-ui/lightningbeam-core/src/region_select.rs

1074 lines
36 KiB
Rust

//! Region selection path clipping
//!
//! Clips BezPaths against a closed polygon region (rectangle or lasso),
//! producing separate inside and outside paths.
//!
//! Uses a Weiler-Atherton-style approach: walk the subject path, alternating
//! between following the subject (when inside) and following the clip boundary
//! (when transitioning between crossings).
use vello::kurbo::{
BezPath, CubicBez, Line, ParamCurve, PathEl, Point, Rect, Shape as KurboShape,
};
/// Result of clipping a shape path against a region
#[derive(Debug, Clone)]
pub struct ClipResult {
/// Path segments inside the region
pub inside: BezPath,
/// Path segments outside the region
pub outside: BezPath,
}
/// Convert a Rect to a closed BezPath (4 line segments)
pub fn rect_to_path(rect: Rect) -> BezPath {
let mut path = BezPath::new();
path.move_to(Point::new(rect.x0, rect.y0));
path.line_to(Point::new(rect.x1, rect.y0));
path.line_to(Point::new(rect.x1, rect.y1));
path.line_to(Point::new(rect.x0, rect.y1));
path.close_path();
path
}
/// Convert a list of lasso points to a closed BezPath (polygon)
pub fn lasso_to_path(points: &[Point]) -> BezPath {
let mut path = BezPath::new();
if points.is_empty() {
return path;
}
path.move_to(points[0]);
for &p in &points[1..] {
path.line_to(p);
}
path.close_path();
path
}
/// Test if a point is inside a closed region using winding number
fn point_in_region(point: Point, region: &BezPath) -> bool {
region.winding(point) != 0
}
/// Extract line segments from a region path (which is always a polygon)
fn region_line_segments(region: &BezPath) -> Vec<Line> {
let mut lines = Vec::new();
let mut current = Point::ZERO;
let mut subpath_start = Point::ZERO;
for el in region.elements() {
match *el {
PathEl::MoveTo(p) => {
current = p;
subpath_start = p;
}
PathEl::LineTo(p) => {
lines.push(Line::new(current, p));
current = p;
}
PathEl::ClosePath => {
if dist(current, subpath_start) > 1e-10 {
lines.push(Line::new(current, subpath_start));
}
current = subpath_start;
}
PathEl::QuadTo(_, p) => {
lines.push(Line::new(current, p));
current = p;
}
PathEl::CurveTo(_, _, p) => {
lines.push(Line::new(current, p));
current = p;
}
}
}
lines
}
fn dist(a: Point, b: Point) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
// ── Line-line intersection (exact, no cubic conversion) ──────────────────
/// Find the intersection of two line segments.
/// Returns (t1, t2) parameters on line1 and line2 respectively, or None.
fn line_line_intersection(l1: &Line, l2: &Line) -> Option<(f64, f64)> {
let d1x = l1.p1.x - l1.p0.x;
let d1y = l1.p1.y - l1.p0.y;
let d2x = l2.p1.x - l2.p0.x;
let d2y = l2.p1.y - l2.p0.y;
let denom = d1x * d2y - d1y * d2x;
if denom.abs() < 1e-12 {
return None; // Parallel
}
let dx = l2.p0.x - l1.p0.x;
let dy = l2.p0.y - l1.p0.y;
let t1 = (dx * d2y - dy * d2x) / denom;
let t2 = (dx * d1y - dy * d1x) / denom;
// Both parameters must be in [0, 1] for segments to intersect
// Use a small epsilon to avoid edge-case issues at endpoints
let eps = 1e-9;
if t1 >= -eps && t1 <= 1.0 + eps && t2 >= -eps && t2 <= 1.0 + eps {
Some((t1.clamp(0.0, 1.0), t2.clamp(0.0, 1.0)))
} else {
None
}
}
/// Find intersection of a cubic bezier with a line segment.
/// Returns list of t-parameters on the cubic where it crosses the line.
fn cubic_line_intersections(cubic: &CubicBez, line: &Line) -> Vec<f64> {
// Express the line as ax + by + c = 0
let lx = line.p1.x - line.p0.x;
let ly = line.p1.y - line.p0.y;
let line_len_sq = lx * lx + ly * ly;
if line_len_sq < 1e-20 {
return Vec::new();
}
// Normal to the line
let a = -ly;
let b = lx;
let c = -(a * line.p0.x + b * line.p0.y);
// Evaluate signed distance of each control point to the line
let d0 = a * cubic.p0.x + b * cubic.p0.y + c;
let d1 = a * cubic.p1.x + b * cubic.p1.y + c;
let d2 = a * cubic.p2.x + b * cubic.p2.y + c;
let d3 = a * cubic.p3.x + b * cubic.p3.y + c;
// Cubic polynomial coefficients: d(t) = at^3 + bt^2 + ct + d
// where d(t) is the signed distance at parameter t
let ca = -d0 + 3.0 * d1 - 3.0 * d2 + d3;
let cb = 3.0 * d0 - 6.0 * d1 + 3.0 * d2;
let cc = -3.0 * d0 + 3.0 * d1;
let cd = d0;
let roots = solve_cubic(ca, cb, cc, cd);
// Filter: t must be in [0,1] and the point must lie on the line segment
let eps = 1e-6;
let mut result = Vec::new();
for t in roots {
if t < -eps || t > 1.0 + eps {
continue;
}
let t = t.clamp(0.0, 1.0);
let p = cubic.eval(t);
// Check if point is on the line segment by projecting
let dx = p.x - line.p0.x;
let dy = p.y - line.p0.y;
let s = (dx * lx + dy * ly) / line_len_sq;
if s >= -eps && s <= 1.0 + eps {
// Avoid duplicate t values
if !result.iter().any(|&existing: &f64| (existing - t).abs() < 1e-6) {
result.push(t);
}
}
}
result.sort_by(|a, b| a.partial_cmp(b).unwrap());
result
}
/// Solve cubic equation at^3 + bt^2 + ct + d = 0
/// Returns real roots.
fn solve_cubic(a: f64, b: f64, c: f64, d: f64) -> Vec<f64> {
if a.abs() < 1e-12 {
// Degenerate to quadratic
return solve_quadratic(b, c, d);
}
// Normalize: t^3 + pt^2 + qt + r = 0
let p = b / a;
let q = c / a;
let r = d / a;
// Depressed cubic substitution: t = u - p/3
// u^3 + Au + B = 0
let a2 = q - p * p / 3.0;
let b2 = r - p * q / 3.0 + 2.0 * p * p * p / 27.0;
let discriminant = b2 * b2 / 4.0 + a2 * a2 * a2 / 27.0;
let mut roots = Vec::new();
if discriminant.abs() < 1e-14 {
// Triple or double root
if a2.abs() < 1e-12 {
roots.push(-p / 3.0);
} else {
let u = (b2 / 2.0).cbrt();
roots.push(2.0 * u - p / 3.0); // wait, this is wrong for the double root case
// Actually: u^3 + Au + B = 0 with disc=0
// roots: -2*(B/2)^(1/3) and (B/2)^(1/3) (double)
roots.clear();
let cb = if b2 > 0.0 { -(b2 / 2.0).cbrt() } else { (-b2 / 2.0).cbrt() };
roots.push(2.0 * cb - p / 3.0);
roots.push(-cb - p / 3.0);
}
} else if discriminant > 0.0 {
// One real root
let sq = discriminant.sqrt();
let u = cbrt(-b2 / 2.0 + sq);
let v = cbrt(-b2 / 2.0 - sq);
roots.push(u + v - p / 3.0);
} else {
// Three real roots (casus irreducibilis)
let r_mag = (-a2 * a2 * a2 / 27.0).sqrt();
let theta = (-b2 / (2.0 * r_mag)).acos();
let m = 2.0 * (r_mag).cbrt();
roots.push(m * (theta / 3.0).cos() - p / 3.0);
roots.push(m * ((theta + 2.0 * std::f64::consts::PI) / 3.0).cos() - p / 3.0);
roots.push(m * ((theta + 4.0 * std::f64::consts::PI) / 3.0).cos() - p / 3.0);
}
roots
}
fn cbrt(x: f64) -> f64 {
if x >= 0.0 { x.cbrt() } else { -(-x).cbrt() }
}
fn solve_quadratic(a: f64, b: f64, c: f64) -> Vec<f64> {
if a.abs() < 1e-12 {
// Linear
if b.abs() < 1e-12 {
return Vec::new();
}
return vec![-c / b];
}
let disc = b * b - 4.0 * a * c;
if disc < -1e-12 {
return Vec::new();
}
if disc.abs() < 1e-12 {
return vec![-b / (2.0 * a)];
}
let sq = disc.sqrt();
vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)]
}
// ── Segment representation ───────────────────────────────────────────────
/// A segment from the subject path, possibly split at intersection points.
/// Tracks the cubic curve and which region boundary edge it crosses at each end.
#[derive(Debug, Clone)]
struct SubSegment {
cubic: CubicBez,
inside: bool,
}
/// A crossing point where the subject path crosses the region boundary.
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct Crossing {
/// Point of intersection
point: Point,
/// Index into the region boundary edges
edge_index: usize,
/// Parameter on the region boundary edge
edge_t: f64,
/// True if this crossing goes from outside to inside
entering: bool,
/// Global parameter encoding for ordering crossings on the boundary:
/// edge_index + edge_t (allows sorting crossings around the boundary)
boundary_param: f64,
}
// ── Core clipping ────────────────────────────────────────────────────────
/// Convert a line segment to a CubicBez
pub fn line_to_cubic(line: &Line) -> CubicBez {
let p0 = line.p0;
let p1 = line.p1;
let cp1 = Point::new(
p0.x + (p1.x - p0.x) / 3.0,
p0.y + (p1.y - p0.y) / 3.0,
);
let cp2 = Point::new(
p0.x + 2.0 * (p1.x - p0.x) / 3.0,
p0.y + 2.0 * (p1.y - p0.y) / 3.0,
);
CubicBez::new(p0, cp1, cp2, p1)
}
/// Extract cubic bezier curves from a BezPath (converting lines/quads to cubics)
fn extract_cubics(path: &BezPath) -> Vec<CubicBez> {
let mut cubics = Vec::new();
let mut current = Point::ZERO;
let mut subpath_start = Point::ZERO;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
current = p;
subpath_start = p;
}
PathEl::LineTo(p) => {
if dist(current, p) > 1e-10 {
cubics.push(line_to_cubic(&Line::new(current, p)));
}
current = p;
}
PathEl::QuadTo(cp, p) => {
let cp1 = Point::new(
current.x + 2.0 / 3.0 * (cp.x - current.x),
current.y + 2.0 / 3.0 * (cp.y - current.y),
);
let cp2 = Point::new(
p.x + 2.0 / 3.0 * (cp.x - p.x),
p.y + 2.0 / 3.0 * (cp.y - p.y),
);
cubics.push(CubicBez::new(current, cp1, cp2, p));
current = p;
}
PathEl::CurveTo(cp1, cp2, p) => {
cubics.push(CubicBez::new(current, cp1, cp2, p));
current = p;
}
PathEl::ClosePath => {
if dist(current, subpath_start) > 1e-10 {
cubics.push(line_to_cubic(&Line::new(current, subpath_start)));
}
current = subpath_start;
}
}
}
cubics
}
/// Find all intersection t-values of a cubic with the region boundary lines.
/// Returns (t_on_cubic, edge_index, t_on_edge) sorted by t_on_cubic.
fn find_all_intersections(
cubic: &CubicBez,
region_lines: &[Line],
) -> Vec<(f64, usize, f64)> {
let mut hits = Vec::new();
// Check if this cubic is actually a line (degenerate cubic from line_to_cubic)
let is_line = is_degenerate_line(cubic);
for (edge_idx, line) in region_lines.iter().enumerate() {
let t_values = if is_line {
// Use exact line-line intersection
let subject_line = Line::new(cubic.p0, cubic.p3);
if let Some((t1, t2)) = line_line_intersection(&subject_line, line) {
// Skip intersections at exact endpoints of the region edge to avoid
// double-counting at region vertices
if t2 > 1e-9 && t2 < 1.0 - 1e-9 {
vec![(t1, t2)]
} else if t1 > 1e-9 && t1 < 1.0 - 1e-9 {
// The intersection is at an endpoint of the region edge.
// Only count it for one edge (the one where t2 > 0) to avoid doubles.
vec![(t1, t2)]
} else {
vec![]
}
} else {
vec![]
}
} else {
// Cubic-line intersection
cubic_line_intersections(cubic, line)
.into_iter()
.map(|t| {
let p = cubic.eval(t);
let dx = p.x - line.p0.x;
let dy = p.y - line.p0.y;
let lx = line.p1.x - line.p0.x;
let ly = line.p1.y - line.p0.y;
let s = (dx * lx + dy * ly) / (lx * lx + ly * ly);
(t, s.clamp(0.0, 1.0))
})
.collect()
};
for (t_cubic, t_edge) in t_values {
// Avoid duplicates
if !hits.iter().any(|&(existing_t, _, _): &(f64, usize, f64)| {
(existing_t - t_cubic).abs() < 1e-6
}) {
hits.push((t_cubic, edge_idx, t_edge));
}
}
}
hits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
hits
}
/// Check if a cubic is actually a degenerate line (from line_to_cubic)
fn is_degenerate_line(cubic: &CubicBez) -> bool {
// A cubic from line_to_cubic has control points at 1/3 and 2/3 along the line
let expected_p1 = Point::new(
cubic.p0.x + (cubic.p3.x - cubic.p0.x) / 3.0,
cubic.p0.y + (cubic.p3.y - cubic.p0.y) / 3.0,
);
let expected_p2 = Point::new(
cubic.p0.x + 2.0 * (cubic.p3.x - cubic.p0.x) / 3.0,
cubic.p0.y + 2.0 * (cubic.p3.y - cubic.p0.y) / 3.0,
);
dist(cubic.p1, expected_p1) < 1e-6 && dist(cubic.p2, expected_p2) < 1e-6
}
/// Split cubics at intersections with boundary lines and classify each piece.
/// Returns (sub_segments, crossings).
fn split_and_classify(
cubics: &[CubicBez],
boundary_lines: &[Line],
containment_region: &BezPath,
) -> (Vec<SubSegment>, Vec<Crossing>) {
let mut sub_segments: Vec<SubSegment> = Vec::new();
let mut crossings: Vec<Crossing> = Vec::new();
for cubic in cubics {
let hits = find_all_intersections(cubic, boundary_lines);
if hits.is_empty() {
let mid = cubic.eval(0.5);
let inside = point_in_region(mid, containment_region);
sub_segments.push(SubSegment { cubic: *cubic, inside });
} else {
let mut prev_t = 0.0;
for &(t, edge_idx, edge_t) in &hits {
if t - prev_t > 1e-8 {
let sub = cubic.subsegment(prev_t..t);
let mid = sub.eval(0.5);
let inside = point_in_region(mid, containment_region);
sub_segments.push(SubSegment { cubic: sub, inside });
}
let point = cubic.eval(t);
let before = cubic.eval((t - 0.005).max(0.0));
let after = cubic.eval((t + 0.005).min(1.0));
let entering = !point_in_region(before, containment_region)
&& point_in_region(after, containment_region);
crossings.push(Crossing {
point,
edge_index: edge_idx,
edge_t,
entering,
boundary_param: edge_idx as f64 + edge_t,
});
prev_t = t;
}
if 1.0 - prev_t > 1e-8 {
let sub = cubic.subsegment(prev_t..1.0);
let mid = sub.eval(0.5);
let inside = point_in_region(mid, containment_region);
sub_segments.push(SubSegment { cubic: sub, inside });
}
}
}
(sub_segments, crossings)
}
/// One-sided clip: build the "inside" path of `subject_cubics` clipped against `boundary`.
fn clip_one_side(
subject_cubics: &[CubicBez],
boundary: &BezPath,
want_inside: bool,
) -> BezPath {
let boundary_lines = region_line_segments(boundary);
if boundary_lines.is_empty() {
return BezPath::new();
}
let (sub_segments, crossings) = split_and_classify(subject_cubics, &boundary_lines, boundary);
build_clipped_path(&sub_segments, &crossings, &boundary_lines, want_inside, None)
}
/// Clip a BezPath against a closed polygon region.
///
/// Uses a Weiler-Atherton-inspired approach:
/// 1. Split all subject path segments at region boundary crossings
/// 2. Classify each sub-segment as inside or outside
/// 3. For the "inside" path: chain inside sub-segments together, connecting
/// consecutive runs by walking the region boundary from exit to entry point
/// 4. Same for "outside" but walking the other way
///
/// When the region extends beyond the subject (e.g., a lasso that overshoots),
/// the boundary walk for the inside path may include region boundary segments
/// outside the subject. A second-pass clip against the subject trims these,
/// producing the correct intersection.
pub fn clip_path_to_region(path: &BezPath, region: &BezPath) -> ClipResult {
let region_lines = region_line_segments(region);
if region_lines.is_empty() {
return ClipResult {
inside: BezPath::new(),
outside: path.clone(),
};
}
let cubics = extract_cubics(path);
if cubics.is_empty() {
return ClipResult {
inside: BezPath::new(),
outside: BezPath::new(),
};
}
// Step 1: Split and classify subject against region
let (sub_segments, crossings) = split_and_classify(&cubics, &region_lines, region);
// Step 2: Build raw inside and outside paths
let inside_raw = build_clipped_path(&sub_segments, &crossings, &region_lines, true, None);
let outside_raw = build_clipped_path(&sub_segments, &crossings, &region_lines, false, Some(path));
// Step 3: Check if any region vertex lies outside the subject.
// If so, boundary walks for the inside path may have followed region edges
// outside the subject. Reclip the inside against the subject.
// The outside doesn't need reclipping — it uses subject-aware grouping instead.
let region_extends_beyond = region_lines.iter().any(|line| {
!point_in_region(line.p0, path)
});
let inside = reclip_against_subject(&inside_raw, path, region_extends_beyond);
let outside = outside_raw;
ClipResult { inside, outside }
}
/// Clip `raw_path` against `subject` to ensure it stays within the subject.
/// This trims boundary walks that followed region edges outside the subject.
/// `region_extends_beyond` indicates whether any region vertex lies outside
/// the subject, meaning boundary walks could have escaped.
fn reclip_against_subject(raw_path: &BezPath, subject: &BezPath, region_extends_beyond: bool) -> BezPath {
if raw_path.elements().is_empty() || !region_extends_beyond {
return raw_path.clone();
}
let cubics = extract_cubics(raw_path);
if cubics.is_empty() {
return raw_path.clone();
}
let reclipped = clip_one_side(&cubics, subject, true);
if reclipped.elements().is_empty() {
raw_path.clone()
} else {
reclipped
}
}
/// Build a clipped path for one side (inside=true or outside=false).
///
/// Strategy:
/// - Walk through sub_segments, collecting those matching `want_inside`
/// - When we encounter a gap (transition from wanted to unwanted), we've hit
/// a boundary crossing. Walk the region boundary to connect to the next
/// run of wanted sub-segments.
/// - When multiple disconnected pieces exist (e.g., a lasso splits the
/// remainder into two), emit them as separate sub-paths.
///
/// `subject`: if provided, used to validate boundary walks. Walks whose midpoint
/// falls outside the subject indicate disconnected groups that need separate sub-paths.
fn build_clipped_path(
sub_segments: &[SubSegment],
_crossings: &[Crossing],
region_lines: &[Line],
want_inside: bool,
subject: Option<&BezPath>,
) -> BezPath {
let mut path = BezPath::new();
if sub_segments.is_empty() {
return path;
}
// Collect runs of consecutive sub-segments that are `want_inside`
let mut runs: Vec<(usize, usize)> = Vec::new(); // (start_idx, end_idx exclusive)
let mut i = 0;
while i < sub_segments.len() {
if sub_segments[i].inside == want_inside {
let start = i;
while i < sub_segments.len() && sub_segments[i].inside == want_inside {
i += 1;
}
runs.push((start, i));
} else {
i += 1;
}
}
if runs.is_empty() {
return path;
}
// If there's only one run and it covers the entire path, just output it closed
if runs.len() == 1 && runs[0].0 == 0 && runs[0].1 == sub_segments.len() {
let (start, end) = runs[0];
path.move_to(sub_segments[start].cubic.p0);
for seg in &sub_segments[start..end] {
emit_cubic(&mut path, &seg.cubic);
}
path.close_path();
return path;
}
// Group runs into separate sub-paths. Two consecutive runs belong to the
// same sub-path if they can be connected by a boundary walk that doesn't
// need to traverse the "other side". We detect this by checking if the
// boundary walk midpoint is on the correct side of the region.
//
// Each group will form its own closed sub-path.
let groups = group_runs_into_subpaths(&runs, sub_segments, region_lines, want_inside, subject);
for group in &groups {
let first_run = group[0];
path.move_to(sub_segments[first_run.0].cubic.p0);
for (gi, &(start, end)) in group.iter().enumerate() {
// Emit the subject-path segments for this run
for seg in &sub_segments[start..end] {
emit_cubic(&mut path, &seg.cubic);
}
// Connect to the next run in this group via boundary walk
let next_gi = (gi + 1) % group.len();
let next_run = group[next_gi];
let exit_point = sub_segments[end - 1].cubic.p3;
let entry_point = sub_segments[next_run.0].cubic.p0;
if dist(exit_point, entry_point) > 0.5 {
let boundary_pts = walk_boundary(
exit_point,
entry_point,
region_lines,
want_inside,
);
for &bp in &boundary_pts {
path.line_to(bp);
}
path.line_to(entry_point);
}
}
path.close_path();
}
path
}
/// Group runs into separate sub-paths based on whether boundary walks
/// between them stay within the subject.
///
/// When `subject` is provided, boundary walks whose midpoint falls outside
/// the subject indicate disconnected groups. When not provided, all runs
/// are grouped into a single sub-path.
fn group_runs_into_subpaths(
runs: &[(usize, usize)],
sub_segments: &[SubSegment],
region_lines: &[Line],
want_inside: bool,
subject: Option<&BezPath>,
) -> Vec<Vec<(usize, usize)>> {
if runs.len() <= 1 {
return vec![runs.to_vec()];
}
let subject = match subject {
Some(s) => s,
None => return vec![runs.to_vec()],
};
// For each pair of consecutive runs, check if the boundary walk
// between them stays inside the subject.
let mut break_after: Vec<bool> = vec![false; runs.len()];
for run_idx in 0..runs.len() {
let next_idx = (run_idx + 1) % runs.len();
let (_, end) = runs[run_idx];
let (next_start, _) = runs[next_idx];
let exit_point = sub_segments[end - 1].cubic.p3;
let entry_point = sub_segments[next_start].cubic.p0;
if dist(exit_point, entry_point) <= 0.5 {
continue;
}
// Get the boundary walk
let boundary_pts = walk_boundary(
exit_point,
entry_point,
region_lines,
want_inside,
);
// Check if any walk point or walk midpoint lies outside the subject.
// If so, this walk escapes the subject and the runs should be in
// separate sub-paths.
let mut all_points = vec![exit_point];
all_points.extend_from_slice(&boundary_pts);
all_points.push(entry_point);
for window in all_points.windows(2) {
let mid = Point::new(
(window[0].x + window[1].x) / 2.0,
(window[0].y + window[1].y) / 2.0,
);
if !point_in_region(mid, subject) {
break_after[run_idx] = true;
break;
}
}
}
// Build groups based on break points.
// Walk runs in order, breaking at break points.
// Handle the circular nature: if last→first is NOT a break, merge them.
let mut groups: Vec<Vec<(usize, usize)>> = Vec::new();
let mut current_group: Vec<(usize, usize)> = vec![runs[0]];
for i in 0..runs.len() - 1 {
if break_after[i] {
groups.push(current_group);
current_group = Vec::new();
}
current_group.push(runs[i + 1]);
}
// Handle wrap-around
if !groups.is_empty() && !break_after[runs.len() - 1] {
// Last run connects back to first group — merge
let first_group = groups.remove(0);
current_group.extend(first_group);
}
groups.push(current_group);
groups
}
/// Emit a cubic to a BezPath, using line_to for degenerate (linear) cubics
fn emit_cubic(path: &mut BezPath, cubic: &CubicBez) {
if is_degenerate_line(cubic) {
path.line_to(cubic.p3);
} else {
path.curve_to(cubic.p1, cubic.p2, cubic.p3);
}
}
/// Walk along the region boundary from `from` to `to`.
///
/// For the "inside" clip, we walk the shorter path along the boundary
/// (staying close to the region interior). For the "outside" clip, we walk
/// the longer path (going around the outside of the region).
fn walk_boundary(
from: Point,
to: Point,
region_lines: &[Line],
want_inside: bool,
) -> Vec<Point> {
let n = region_lines.len();
if n == 0 {
return vec![to];
}
// Find boundary position for `from` and `to`
let from_pos = project_onto_boundary(from, region_lines);
let to_pos = project_onto_boundary(to, region_lines);
// Walk clockwise (increasing edge index)
let cw = walk_boundary_direction(from_pos, to_pos, region_lines, true);
// Walk counter-clockwise (decreasing edge index)
let ccw = walk_boundary_direction(from_pos, to_pos, region_lines, false);
let cw_len = chain_length(from, &cw, to);
let ccw_len = chain_length(from, &ccw, to);
// Always take the shorter walk — for inside clips this connects
// inside runs, for outside clips this connects outside runs,
// and in both cases we want the shortest boundary path.
let _ = want_inside;
if cw_len <= ccw_len { cw } else { ccw }
}
/// A position on the boundary: (edge_index, t along that edge)
#[derive(Clone, Copy, Debug)]
struct BoundaryPos {
edge: usize,
t: f64,
}
fn project_onto_boundary(point: Point, lines: &[Line]) -> BoundaryPos {
let mut best_edge = 0;
let mut best_t = 0.0;
let mut best_dist = f64::MAX;
for (i, line) in lines.iter().enumerate() {
let lx = line.p1.x - line.p0.x;
let ly = line.p1.y - line.p0.y;
let len_sq = lx * lx + ly * ly;
if len_sq < 1e-20 {
continue;
}
let t = ((point.x - line.p0.x) * lx + (point.y - line.p0.y) * ly) / len_sq;
let t = t.clamp(0.0, 1.0);
let proj = Point::new(line.p0.x + t * lx, line.p0.y + t * ly);
let d = dist(point, proj);
if d < best_dist {
best_dist = d;
best_edge = i;
best_t = t;
}
}
BoundaryPos { edge: best_edge, t: best_t }
}
/// Walk the boundary from `from_pos` to `to_pos` in a given direction.
/// `clockwise` = true means walk forward (increasing edge index).
/// Returns intermediate points (not including `from`, not including `to`).
fn walk_boundary_direction(
from_pos: BoundaryPos,
to_pos: BoundaryPos,
lines: &[Line],
clockwise: bool,
) -> Vec<Point> {
let n = lines.len();
let mut result = Vec::new();
if from_pos.edge == to_pos.edge {
// Same edge — check if we can go directly
if clockwise && to_pos.t > from_pos.t + 1e-9 {
return result; // Direct, no intermediate vertices needed
}
if !clockwise && to_pos.t < from_pos.t - 1e-9 {
return result; // Direct
}
// Otherwise we need to go all the way around
}
if clockwise {
// Walk forward: from from_pos.edge to to_pos.edge
let mut edge = from_pos.edge;
// First: emit the end vertex of the current edge (if we're not already at it)
if from_pos.t < 1.0 - 1e-9 {
result.push(lines[edge].p1);
}
edge = (edge + 1) % n;
let mut safety = 0;
while edge != to_pos.edge && safety < n + 1 {
result.push(lines[edge].p1);
edge = (edge + 1) % n;
safety += 1;
}
// We're now on the target edge; the caller will add `to` point
} else {
// Walk backward: from from_pos.edge to to_pos.edge
let mut edge = from_pos.edge;
// First: emit the start vertex of the current edge (if we're not already at it)
if from_pos.t > 1e-9 {
result.push(lines[edge].p0);
}
edge = if edge == 0 { n - 1 } else { edge - 1 };
let mut safety = 0;
while edge != to_pos.edge && safety < n + 1 {
result.push(lines[edge].p0);
edge = if edge == 0 { n - 1 } else { edge - 1 };
safety += 1;
}
}
result
}
fn chain_length(start: Point, intermediates: &[Point], end: Point) -> f64 {
let mut len = 0.0;
let mut prev = start;
for &p in intermediates {
len += dist(prev, p);
prev = p;
}
len += dist(prev, end);
len
}
/// Check if a shape path has any segments that cross the region boundary
pub fn path_intersects_region(path: &BezPath, region: &BezPath) -> bool {
let region_lines = region_line_segments(region);
let cubics = extract_cubics(path);
for cubic in &cubics {
let hits = find_all_intersections(&cubic, &region_lines);
if !hits.is_empty() {
return true;
}
}
false
}
/// Check if all points of a path are inside the region
pub fn path_fully_inside_region(path: &BezPath, region: &BezPath) -> bool {
for el in path.elements() {
let p = match *el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => p,
PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => p,
PathEl::ClosePath => continue,
};
if !point_in_region(p, region) {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rect_to_path() {
let rect = Rect::new(10.0, 20.0, 100.0, 200.0);
let path = rect_to_path(rect);
// MoveTo + 3 LineTo + ClosePath = 5 elements
assert!(path.elements().len() >= 5);
}
#[test]
fn test_lasso_to_path() {
let points = vec![
Point::new(0.0, 0.0),
Point::new(100.0, 0.0),
Point::new(100.0, 100.0),
Point::new(0.0, 100.0),
];
let path = lasso_to_path(&points);
assert!(path.elements().len() >= 5);
}
#[test]
fn test_point_in_region() {
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(point_in_region(Point::new(50.0, 50.0), &region));
assert!(!point_in_region(Point::new(150.0, 50.0), &region));
}
#[test]
fn test_line_line_intersection() {
let l1 = Line::new(Point::new(0.0, 5.0), Point::new(10.0, 5.0));
let l2 = Line::new(Point::new(5.0, 0.0), Point::new(5.0, 10.0));
let result = line_line_intersection(&l1, &l2);
assert!(result.is_some());
let (t1, t2) = result.unwrap();
assert!((t1 - 0.5).abs() < 1e-6);
assert!((t2 - 0.5).abs() < 1e-6);
}
#[test]
fn test_clip_rect_corner() {
// Rectangle from (0,0) to (100,100)
let mut subject = BezPath::new();
subject.move_to(Point::new(0.0, 0.0));
subject.line_to(Point::new(100.0, 0.0));
subject.line_to(Point::new(100.0, 100.0));
subject.line_to(Point::new(0.0, 100.0));
subject.close_path();
// Clip to upper-right corner: (50,0) to (100,50)
let region = rect_to_path(Rect::new(50.0, 0.0, 150.0, 50.0));
let result = clip_path_to_region(&subject, &region);
// Inside should have elements (the upper-right portion)
assert!(!result.inside.elements().is_empty(),
"inside path should not be empty");
// Outside should have elements (the rest of the rectangle)
assert!(!result.outside.elements().is_empty(),
"outside path should not be empty");
// The inside portion should be a roughly rectangular region
// Its bounding box should be approximately (50,0)-(100,50)
let inside_bb = result.inside.bounding_box();
assert!((inside_bb.x0 - 50.0).abs() < 2.0,
"inside x0 should be ~50, got {}", inside_bb.x0);
assert!((inside_bb.y0 - 0.0).abs() < 2.0,
"inside y0 should be ~0, got {}", inside_bb.y0);
assert!((inside_bb.x1 - 100.0).abs() < 2.0,
"inside x1 should be ~100, got {}", inside_bb.x1);
assert!((inside_bb.y1 - 50.0).abs() < 2.0,
"inside y1 should be ~50, got {}", inside_bb.y1);
}
#[test]
fn test_clip_fully_inside() {
let mut path = BezPath::new();
path.move_to(Point::new(20.0, 20.0));
path.line_to(Point::new(80.0, 20.0));
path.line_to(Point::new(80.0, 80.0));
path.line_to(Point::new(20.0, 80.0));
path.close_path();
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
let result = clip_path_to_region(&path, &region);
assert!(!result.inside.elements().is_empty());
assert!(result.outside.elements().is_empty());
}
#[test]
fn test_clip_fully_outside() {
let mut path = BezPath::new();
path.move_to(Point::new(200.0, 200.0));
path.line_to(Point::new(300.0, 200.0));
path.line_to(Point::new(300.0, 300.0));
path.close_path();
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
let result = clip_path_to_region(&path, &region);
assert!(result.inside.elements().is_empty());
assert!(!result.outside.elements().is_empty());
}
#[test]
fn test_path_intersects_region() {
let mut path = BezPath::new();
path.move_to(Point::new(-50.0, 50.0));
path.line_to(Point::new(150.0, 50.0));
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(path_intersects_region(&path, &region));
}
#[test]
fn test_path_fully_inside() {
let mut path = BezPath::new();
path.move_to(Point::new(20.0, 20.0));
path.line_to(Point::new(80.0, 20.0));
path.line_to(Point::new(80.0, 80.0));
path.close_path();
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(path_fully_inside_region(&path, &region));
assert!(!path_intersects_region(&path, &region));
}
#[test]
fn test_cubic_line_intersection() {
// Horizontal line as cubic
let cubic = CubicBez::new(
Point::new(0.0, 50.0),
Point::new(33.33, 50.0),
Point::new(66.67, 50.0),
Point::new(100.0, 50.0),
);
// Vertical line segment
let line = Line::new(Point::new(50.0, 0.0), Point::new(50.0, 100.0));
let hits = cubic_line_intersections(&cubic, &line);
assert_eq!(hits.len(), 1, "Expected 1 intersection, got {}", hits.len());
assert!((hits[0] - 0.5).abs() < 0.01, "t should be ~0.5, got {}", hits[0]);
}
}