use std::f64::consts::PI;
use crate::error::{AlgorithmError, Result};
use crate::vector::simplify_linestring_dp;
use oxigdal_core::vector::{Coordinate, LineString};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JoinStyle {
#[default]
Miter,
Bevel,
Round,
}
#[derive(Debug, Clone)]
pub struct OffsetOptions {
pub miter_limit: f64,
pub join_style: JoinStyle,
pub simplify_tolerance: Option<f64>,
}
impl Default for OffsetOptions {
fn default() -> Self {
Self {
miter_limit: 10.0,
join_style: JoinStyle::Miter,
simplify_tolerance: None,
}
}
}
#[derive(Debug, Clone)]
pub struct OffsetResult {
pub coords: Vec<(f64, f64)>,
pub was_simplified: bool,
}
pub fn offset_linestring(
coords: &[(f64, f64)],
distance: f64,
options: &OffsetOptions,
) -> Result<OffsetResult> {
let n = coords.len();
if n < 2 {
return Err(AlgorithmError::InsufficientData {
operation: "offset_linestring",
message: format!("need at least 2 vertices, got {n}"),
});
}
if distance == 0.0 {
return Ok(OffsetResult {
coords: coords.to_vec(),
was_simplified: false,
});
}
let normals = compute_segment_normals(coords);
let mut out: Vec<(f64, f64)> = Vec::with_capacity(n + 8);
let n0 = first_valid_normal(&normals);
let (px, py) = coords[0];
out.push((px + distance * n0.0, py + distance * n0.1));
for i in 1..n - 1 {
let na = prev_valid_normal(&normals, i);
let nb = next_valid_normal(&normals, i);
let (vx, vy) = coords[i];
emit_join_points(&mut out, (vx, vy), na, nb, distance, options);
}
let n_last = last_valid_normal(&normals);
let (ex, ey) = coords[n - 1];
out.push((ex + distance * n_last.0, ey + distance * n_last.1));
let was_simplified;
if let Some(tol) = options.simplify_tolerance {
let ls_coords: Vec<Coordinate> =
out.iter().map(|&(x, y)| Coordinate::new_2d(x, y)).collect();
match LineString::new(ls_coords) {
Ok(ls) => match simplify_linestring_dp(&ls, tol) {
Ok(simplified) => {
out = simplified.coords.iter().map(|c| (c.x, c.y)).collect();
was_simplified = true;
}
Err(_) => {
was_simplified = false;
}
},
Err(_) => {
was_simplified = false;
}
}
} else {
was_simplified = false;
}
Ok(OffsetResult {
coords: out,
was_simplified,
})
}
pub fn offset_polygon_rings(
rings: &[Vec<(f64, f64)>],
distance: f64,
options: &OffsetOptions,
) -> Result<Vec<Vec<(f64, f64)>>> {
rings
.iter()
.map(|ring| offset_closed_ring(ring, distance, options))
.collect()
}
fn offset_closed_ring(
ring: &[(f64, f64)],
distance: f64,
options: &OffsetOptions,
) -> Result<Vec<(f64, f64)>> {
let effective_ring = strip_closing_vertex(ring);
let n = effective_ring.len();
if n < 3 {
return Err(AlgorithmError::InsufficientData {
operation: "offset_polygon_rings",
message: format!("ring must have at least 3 distinct vertices, got {n}"),
});
}
let signed_area = ring_signed_area(effective_ring);
let effective_distance = if signed_area > 0.0 {
-distance
} else {
distance
};
let normals = compute_cyclic_normals(effective_ring);
let mut out: Vec<(f64, f64)> = Vec::with_capacity(n + 4);
for i in 0..n {
let prev_seg = if i == 0 { n - 1 } else { i - 1 };
let curr_seg = i;
let na = normals[prev_seg];
let nb = normals[curr_seg];
let (vx, vy) = effective_ring[i];
emit_join_points(&mut out, (vx, vy), na, nb, effective_distance, options);
}
if let Some(&first) = out.first() {
out.push(first);
}
Ok(out)
}
fn compute_segment_normals(coords: &[(f64, f64)]) -> Vec<(f64, f64)> {
coords.windows(2).map(|w| left_normal(w[0], w[1])).collect()
}
fn compute_cyclic_normals(ring: &[(f64, f64)]) -> Vec<(f64, f64)> {
let n = ring.len();
(0..n)
.map(|i| left_normal(ring[i], ring[(i + 1) % n]))
.collect()
}
fn left_normal(a: (f64, f64), b: (f64, f64)) -> (f64, f64) {
let dx = b.0 - a.0;
let dy = b.1 - a.1;
let len = dx.hypot(dy);
if len < f64::EPSILON {
return (0.0, 0.0);
}
(-dy / len, dx / len)
}
fn first_valid_normal(normals: &[(f64, f64)]) -> (f64, f64) {
normals
.iter()
.find(|&&n| normal_is_valid(n))
.copied()
.unwrap_or((0.0, 0.0))
}
fn last_valid_normal(normals: &[(f64, f64)]) -> (f64, f64) {
normals
.iter()
.rev()
.find(|&&n| normal_is_valid(n))
.copied()
.unwrap_or((0.0, 0.0))
}
fn prev_valid_normal(normals: &[(f64, f64)], vertex_idx: usize) -> (f64, f64) {
let seg_idx = vertex_idx.saturating_sub(1);
(0..=seg_idx)
.rev()
.map(|j| normals[j])
.find(|&n| normal_is_valid(n))
.unwrap_or((0.0, 0.0))
}
fn next_valid_normal(normals: &[(f64, f64)], vertex_idx: usize) -> (f64, f64) {
(vertex_idx..normals.len())
.map(|j| normals[j])
.find(|&n| normal_is_valid(n))
.unwrap_or((0.0, 0.0))
}
#[inline]
fn normal_is_valid(n: (f64, f64)) -> bool {
n.0 != 0.0 || n.1 != 0.0
}
fn emit_join_points(
out: &mut Vec<(f64, f64)>,
v: (f64, f64),
na: (f64, f64),
nb: (f64, f64),
distance: f64,
options: &OffsetOptions,
) {
let pa = (v.0 + distance * na.0, v.1 + distance * na.1);
let pb = (v.0 + distance * nb.0, v.1 + distance * nb.1);
if !normal_is_valid(na) && !normal_is_valid(nb) {
out.push(pa);
return;
}
if !normal_is_valid(na) {
out.push(pb);
return;
}
if !normal_is_valid(nb) {
out.push(pa);
return;
}
match options.join_style {
JoinStyle::Bevel => {
emit_bevel(out, pa, pb);
}
JoinStyle::Round => {
emit_round(out, v, pa, pb, distance);
}
JoinStyle::Miter => {
emit_miter(out, v, na, nb, pa, pb, distance, options.miter_limit);
}
}
}
#[inline]
fn emit_bevel(out: &mut Vec<(f64, f64)>, pa: (f64, f64), pb: (f64, f64)) {
out.push(pa);
out.push(pb);
}
fn emit_miter(
out: &mut Vec<(f64, f64)>,
v: (f64, f64),
na: (f64, f64),
nb: (f64, f64),
pa: (f64, f64),
pb: (f64, f64),
distance: f64,
miter_limit: f64,
) {
let bx = na.0 + nb.0;
let by = na.1 + nb.1;
let blen = bx.hypot(by);
if blen < f64::EPSILON {
out.push(pa);
return;
}
let (bux, buy) = (bx / blen, by / blen);
let dot = bux * na.0 + buy * na.1;
if dot.abs() < f64::EPSILON {
emit_bevel(out, pa, pb);
return;
}
let miter_length = distance / dot;
let ratio = (miter_length / distance).abs();
if ratio > miter_limit.abs() || !miter_length.is_finite() {
emit_bevel(out, pa, pb);
} else {
out.push((v.0 + miter_length * bux, v.1 + miter_length * buy));
}
}
fn emit_round(
out: &mut Vec<(f64, f64)>,
v: (f64, f64),
pa: (f64, f64),
pb: (f64, f64),
distance: f64,
) {
out.push(pa);
let radius = distance.abs();
if radius < f64::EPSILON {
out.push(pb);
return;
}
let angle_a = (pa.1 - v.1).atan2(pa.0 - v.0);
let angle_b = (pb.1 - v.1).atan2(pb.0 - v.0);
let mut delta = angle_b - angle_a;
while delta > PI {
delta -= 2.0 * PI;
}
while delta < -PI {
delta += 2.0 * PI;
}
let segments = (((delta.abs() / PI) * 8.0).ceil() as usize).max(1);
for k in 1..segments {
let t = (k as f64) / (segments as f64);
let angle = angle_a + t * delta;
out.push((v.0 + radius * angle.cos(), v.1 + radius * angle.sin()));
}
out.push(pb);
}
fn ring_signed_area(ring: &[(f64, f64)]) -> f64 {
let n = ring.len();
let mut sum = 0.0_f64;
for i in 0..n {
let j = (i + 1) % n;
sum += ring[i].0 * ring[j].1;
sum -= ring[j].0 * ring[i].1;
}
sum / 2.0
}
fn strip_closing_vertex(ring: &[(f64, f64)]) -> &[(f64, f64)] {
if ring.len() >= 2 {
let first = ring[0];
let last = ring[ring.len() - 1];
if (first.0 - last.0).abs() < f64::EPSILON && (first.1 - last.1).abs() < f64::EPSILON {
return &ring[..ring.len() - 1];
}
}
ring
}
#[cfg(test)]
mod tests {
use super::*;
fn opts_miter() -> OffsetOptions {
OffsetOptions {
join_style: JoinStyle::Miter,
..Default::default()
}
}
fn opts_bevel() -> OffsetOptions {
OffsetOptions {
join_style: JoinStyle::Bevel,
..Default::default()
}
}
fn opts_round() -> OffsetOptions {
OffsetOptions {
join_style: JoinStyle::Round,
..Default::default()
}
}
#[test]
fn test_left_normal_east() {
let n = left_normal((0.0, 0.0), (1.0, 0.0));
assert!((n.0 - 0.0).abs() < 1e-12, "nx should be 0, got {}", n.0);
assert!((n.1 - 1.0).abs() < 1e-12, "ny should be 1, got {}", n.1);
}
#[test]
fn test_left_normal_north() {
let n = left_normal((0.0, 0.0), (0.0, 1.0));
assert!((n.0 - (-1.0)).abs() < 1e-12);
assert!((n.1 - 0.0).abs() < 1e-12);
}
#[test]
fn test_offset_horizontal_line_left_positive() {
let coords = vec![(0.0, 0.0), (10.0, 0.0)];
let result = offset_linestring(&coords, 1.0, &opts_miter());
assert!(result.is_ok(), "expected Ok, got {result:?}");
let r = result.expect("checked above");
assert_eq!(r.coords.len(), 2);
for &(_, y) in &r.coords {
assert!((y - 1.0).abs() < 1e-10, "expected y=1, got {y}");
}
}
#[test]
fn test_offset_horizontal_line_right_negative() {
let coords = vec![(0.0, 0.0), (10.0, 0.0)];
let result = offset_linestring(&coords, -1.0, &opts_miter());
assert!(result.is_ok());
let r = result.expect("checked above");
assert_eq!(r.coords.len(), 2);
for &(_, y) in &r.coords {
assert!((y - (-1.0)).abs() < 1e-10, "expected y=-1, got {y}");
}
}
#[test]
fn test_offset_zero_distance_returns_input() {
let coords = vec![(0.0, 0.0), (5.0, 5.0), (10.0, 0.0)];
let result = offset_linestring(&coords, 0.0, &opts_miter());
assert!(result.is_ok());
let r = result.expect("checked above");
assert_eq!(r.coords, coords);
}
#[test]
fn test_offset_right_angle_miter_within_limit() {
let coords = vec![(0.0, 10.0), (0.0, 0.0), (10.0, 0.0)];
let opts = opts_miter();
let result = offset_linestring(&coords, 1.0, &opts);
assert!(result.is_ok());
let r = result.expect("checked above");
for &(x, y) in &r.coords {
assert!(x.is_finite(), "x must be finite, got {x}");
assert!(y.is_finite(), "y must be finite, got {y}");
}
assert!(r.coords.len() >= 3, "got {} points", r.coords.len());
}
#[test]
fn test_offset_right_angle_bevel_style() {
let coords = vec![(0.0, 10.0), (0.0, 0.0), (10.0, 0.0)];
let result = offset_linestring(&coords, 1.0, &opts_bevel());
assert!(result.is_ok());
let r = result.expect("checked above");
assert_eq!(
r.coords.len(),
4,
"bevel should produce 4 points, got {}",
r.coords.len()
);
}
#[test]
fn test_offset_miter_limit_clamps_sharp_angle() {
let coords = vec![(0.0, 0.0), (10.0, 0.0), (10.05, 0.01)];
let opts = OffsetOptions {
join_style: JoinStyle::Miter,
miter_limit: 2.0,
..Default::default()
};
let result = offset_linestring(&coords, 1.0, &opts);
assert!(result.is_ok());
let r = result.expect("checked above");
for &(x, y) in &r.coords {
assert!(x.is_finite() && y.is_finite(), "coords must be finite");
}
}
#[test]
fn test_offset_insufficient_vertices_errors() {
let coords = vec![(0.0, 0.0)]; let result = offset_linestring(&coords, 1.0, &opts_miter());
assert!(result.is_err(), "expected Err for 1-point input");
}
#[test]
fn test_offset_round_style_no_nan() {
let coords = vec![(0.0, 10.0), (0.0, 0.0), (10.0, 0.0)];
let result = offset_linestring(&coords, 1.0, &opts_round());
assert!(result.is_ok());
let r = result.expect("checked above");
for &(x, y) in &r.coords {
assert!(
x.is_finite() && y.is_finite(),
"round join produced non-finite coord"
);
}
assert!(
r.coords.len() > 4,
"expected >4 coords for round join, got {}",
r.coords.len()
);
}
#[test]
fn test_offset_polygon_ring_square() {
let ring = vec![
(0.0_f64, 0.0_f64),
(10.0, 0.0),
(10.0, 10.0),
(0.0, 10.0),
(0.0, 0.0), ];
let opts = OffsetOptions {
join_style: JoinStyle::Miter,
..Default::default()
};
let result = offset_polygon_rings(&[ring], 1.0, &opts);
assert!(result.is_ok());
let rings = result.expect("checked above");
assert_eq!(rings.len(), 1);
let out_ring = &rings[0];
for &(x, y) in out_ring {
assert!(x.is_finite() && y.is_finite());
}
let area = shoelace_area(out_ring);
assert!(
area > 100.0,
"expanded area should exceed original; got {area}"
);
}
fn shoelace_area(ring: &[(f64, f64)]) -> f64 {
let n = ring.len();
let mut sum = 0.0;
for i in 0..n - 1 {
sum += ring[i].0 * ring[i + 1].1;
sum -= ring[i + 1].0 * ring[i].1;
}
sum.abs() / 2.0
}
}