2026-04-25 14:33:11 -04:00

143 lines
4.4 KiB
Rust

//! Inward offsetting of polygons (spec §2.5).
//!
//! Used to compute the developable polygon `B'` of a block: `B`
//! shrunk by the road setback distance `d_s`. For convex inputs this
//! is exact. For concave inputs the result is the intersection of the
//! inward half-planes of every edge — a conservative approximation
//! that may shave off concave outer-corner detail but never produces
//! an invalid polygon.
use glam::DVec2;
use super::polygon::Polygon;
use super::EPS_GEOM;
/// Compute the inward offset of `poly` by `distance` while preserving
/// a 1-1 correspondence between input edges and output vertices.
///
/// Vertex `i` of the returned ring is the intersection of the inward
/// offset lines of input edges `i-1` and `i`. This is well-defined
/// for convex inputs and modest offsets; for highly non-convex inputs
/// or large offsets the result may self-intersect, in which case
/// the caller should fall back to [`offset_inward`].
#[must_use]
pub fn correlated_offset_inward(poly: &Polygon, distance: f64) -> Option<Vec<DVec2>> {
let n = poly.len();
if n < 3 || distance < 0.0 {
return None;
}
let verts = poly.vertices();
let mut offset_lines: Vec<(DVec2, DVec2)> = Vec::with_capacity(n);
for i in 0..n {
let p = verts[i];
let q = verts[(i + 1) % n];
let edge = q - p;
let len = edge.length();
if len < EPS_GEOM {
return None;
}
let tangent = edge / len;
let inward = DVec2::new(-tangent.y, tangent.x);
let p_off = p + inward * distance;
let q_off = q + inward * distance;
offset_lines.push((p_off, q_off));
}
let mut new_verts = Vec::with_capacity(n);
for i in 0..n {
let prev = offset_lines[(i + n - 1) % n];
let curr = offset_lines[i];
let isect = line_line_intersect(prev.0, prev.1, curr.0, curr.1)?;
new_verts.push(isect);
}
Some(new_verts)
}
fn line_line_intersect(p1: DVec2, p2: DVec2, p3: DVec2, p4: DVec2) -> Option<DVec2> {
let d1 = p2 - p1;
let d2 = p4 - p3;
let denom = d1.x * d2.y - d1.y * d2.x;
if denom.abs() < EPS_GEOM {
return None;
}
let dp = p3 - p1;
let t = (dp.x * d2.y - dp.y * d2.x) / denom;
Some(p1 + d1 * t)
}
/// Compute the inward offset of `poly` by `distance`. Returns `None`
/// when the result is empty (block too narrow to offset) or invalid.
///
/// Implementation: clip `poly` once by the inward-shifted half-plane
/// of each edge and validate the result.
#[must_use]
pub fn offset_inward(poly: &Polygon, distance: f64) -> Option<Polygon> {
if distance.abs() < EPS_GEOM {
return Some(poly.clone());
}
if distance < 0.0 {
return None;
}
let mut working = poly.clone();
for (a, b) in poly.edges().collect::<Vec<_>>() {
let edge = b - a;
let len = edge.length();
if len < EPS_GEOM {
continue;
}
// Inward normal of a CCW polygon: rotate the tangent +90°
// (left-perpendicular).
let tangent = edge / len;
let inward = DVec2::new(-tangent.y, tangent.x);
let shifted = a + inward * distance;
match working.clip_half_plane(shifted, inward) {
Some(p) => working = p,
None => return None,
}
}
Some(working)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rectangle_inward_offset_shrinks() {
let p = Polygon::new(vec![
DVec2::new(0.0, 0.0),
DVec2::new(100.0, 0.0),
DVec2::new(100.0, 50.0),
DVec2::new(0.0, 50.0),
])
.unwrap();
let off = offset_inward(&p, 5.0).unwrap();
// Result should be the rectangle (5,5)-(95,45), area = 90*40.
assert!((off.area() - 90.0 * 40.0).abs() < 1e-6);
}
#[test]
fn too_large_offset_returns_none() {
let p = Polygon::new(vec![
DVec2::new(0.0, 0.0),
DVec2::new(10.0, 0.0),
DVec2::new(10.0, 10.0),
DVec2::new(0.0, 10.0),
])
.unwrap();
assert!(offset_inward(&p, 50.0).is_none());
}
#[test]
fn zero_offset_is_identity() {
let p = Polygon::new(vec![
DVec2::new(0.0, 0.0),
DVec2::new(1.0, 0.0),
DVec2::new(1.0, 1.0),
DVec2::new(0.0, 1.0),
])
.unwrap();
let off = offset_inward(&p, 0.0).unwrap();
assert!((off.area() - p.area()).abs() < 1e-12);
}
}