feat(math): implement search for boundaries of partitions in cyclic partitioned lists.

e.g. find the bounds of false, true in [false, true, true, false, false]
under the condition that the blocks in partitions of a single value are connected,
meaning when considering the list as cyclic, there are at most 2 blocks (for value true, false).
This implements only the binary case.

Exponential search was suggested by Harold Aptroot <@harold@mastodon.gamedev.place>

Example/motivating use case: detecting which parts of the exterior of a Polygon
  face towards a point, and which parts don't, in particular finding the (extremal) points
  contained in the tangents. This use case is implemented in `poly_ext_tangent_points`.

Signed-off-by: Ellen Emilia Anna Zscheile <fogti+devel@ytrizja.de>
This commit is contained in:
Ellen Emilia Anna Zscheile 2025-04-24 16:02:04 +02:00
parent f7cd817457
commit 7799c50829
6 changed files with 432 additions and 3 deletions

View File

@ -70,6 +70,7 @@ features = ["rstar"]
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true
proptest = "1.6"
[package.metadata.docs.rs] [package.metadata.docs.rs]
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]

View File

@ -24,10 +24,8 @@ optional = true
[dev-dependencies] [dev-dependencies]
ron = "0.8" ron = "0.8"
serde.workspace = true
[dev-dependencies.insta] [dev-dependencies.insta]
version = "1.42" version = "1.42"
features = ["json"] features = ["json"]
[dev-dependencies.serde]
workspace = true

View File

@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2025 Topola contributors
#
# SPDX-License-Identifier: MIT
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 10a8063ed53caab61327919fad368bd440c684021e1022082d6bf2098e19aea9 # shrinks to len = 4, offset = 0, amount_true = 238

299
src/math/cyclic_search.rs Normal file
View File

@ -0,0 +1,299 @@
// SPDX-FileCopyrightText: 2025 Topola contributors
//
// SPDX-License-Identifier: MIT
use core::{mem::take, ops::Range};
/// generate a breadth-first search list for given bounds and level
fn breadth4level(bounds: Range<usize>, level: u8, mut callback: impl FnMut(Range<usize>) -> bool) {
// level is the exponent of 2
let block_length = (bounds.end - bounds.start) >> level;
let blocks_count = (bounds.end - bounds.start) / block_length;
if blocks_count == 0 {
return;
}
for i in 0..(blocks_count - 1) {
let block_start = bounds.start + i * block_length;
debug_assert!(block_start + block_length < bounds.end);
if !callback(block_start..(block_start + block_length)) {
return;
}
}
let block_start = bounds.start + (blocks_count - 1) * block_length;
debug_assert!(block_start + block_length <= bounds.end);
callback(block_start..bounds.end);
}
#[derive(Clone, Copy)]
enum TriState<T> {
Nothing,
Got(T),
Fixed(T),
}
impl<T> Default for TriState<T> {
fn default() -> Self {
TriState::Nothing
}
}
impl<T> TriState<T> {
fn update(&mut self, value: T) -> bool {
match self {
TriState::Fixed(_) => false,
_ => {
*self = TriState::Got(value);
true
}
}
}
fn fix(&mut self) {
*self = match take(self) {
TriState::Got(x) => TriState::Fixed(x),
x => x,
};
}
fn is_fixed(&self) -> bool {
matches!(self, TriState::Fixed(_))
}
}
impl<T> From<TriState<T>> for Option<T> {
fn from(x: TriState<T>) -> Self {
match x {
TriState::Nothing => None,
TriState::Got(x) | TriState::Fixed(x) => Some(x),
}
}
}
struct Discover {
pos_false: TriState<usize>,
pos_true: TriState<usize>,
}
impl Discover {
fn new() -> Self {
Self {
pos_false: TriState::Nothing,
pos_true: TriState::Nothing,
}
}
fn update(&mut self, pos: usize, value: bool) {
match value {
false => {
self.pos_false.update(pos);
self.pos_true.fix();
}
true => {
self.pos_true.update(pos);
self.pos_false.fix();
}
}
}
fn is_finished_minimal(&self) -> bool {
self.pos_false.is_fixed() || self.pos_true.is_fixed()
}
fn is_finished(&self) -> bool {
self.pos_false.is_fixed() && self.pos_true.is_fixed()
}
fn results(&self) -> (Option<usize>, Option<usize>) {
(self.pos_false.into(), self.pos_true.into())
}
}
/// A brute-force implementation of [`cyclic_breadth_binary_search`].
fn cbps_brute_force<EF>(
bounds: core::ops::Range<usize>,
eval: &EF,
) -> (Option<usize>, Option<usize>)
where
EF: Fn(usize) -> bool,
{
let mut discover = Discover::new();
for i in bounds {
discover.update(i, eval(i));
if discover.is_finished() {
break;
}
}
discover.results()
}
/// Search for the largest index inside the bounds which still fulfills the condition
fn exponential_search<T, EF>(
eval: &EF,
expected_value: T,
mut bounds: core::ops::Range<usize>,
) -> Option<usize>
where
EF: Fn(usize) -> T,
T: Eq + core::fmt::Debug,
{
assert!(bounds.start <= bounds.end);
if bounds.is_empty() || eval(bounds.start) != expected_value {
return None;
}
let mut largest_checked = bounds.start;
while (bounds.start + 1) < bounds.end {
let len = bounds.end - bounds.start;
for level in 0..64u8 {
let mut index = 1 << level;
if index >= len {
break;
}
index += bounds.start;
if eval(index) != expected_value {
bounds.end = index;
break;
}
largest_checked = index;
}
bounds.start = largest_checked;
// this implies that `bounds.start` doesn't have to get checked again
}
debug_assert_eq!(eval(largest_checked), expected_value);
Some(largest_checked)
}
/// Perform a breadth-first search on an induced binary tree on the list,
/// searching for the bounds of the partition induced by `eval`,
/// returning the last item indices in the `false` and `true` blocks
pub fn cyclic_breadth_partition_search<EF>(
bounds: Range<usize>,
eval: EF,
) -> (Option<usize>, Option<usize>)
where
EF: Fn(usize) -> bool,
{
if bounds.is_empty() {
return (None, None);
}
// discover gaps (true is a gap for false and vice versa)
let mut discover = Discover::new();
for i in 0..((bounds.end - bounds.start).ilog2() as u8) {
breadth4level(bounds.clone(), i, |bounds| {
let middle = bounds.start + (bounds.end - bounds.start) / 2;
discover.update(middle, eval(middle));
!discover.is_finished_minimal()
});
if discover.is_finished_minimal() {
break;
}
}
// brute force on failure
if !discover.is_finished() {
return cbps_brute_force(bounds, &eval);
}
let (pos_false, pos_true) = discover.results();
let (mut pos_false, mut pos_true) = (pos_false.unwrap(), pos_true.unwrap());
// discover bounds
debug_assert_ne!(pos_false, pos_true);
// whatever block is at the beginning has
// its end somewhere strictly before the other block
// either:
// - the later block continues at the beginning
// format: L...!L...L...
// - or the later block doesn't continue at the beginning
// format: !L...L...
let val_start = eval(bounds.start);
{
let (pos_start, pos_next) = match val_start {
false => (&mut pos_false, &mut pos_true),
true => (&mut pos_true, &mut pos_false),
};
*pos_start = exponential_search(&eval, val_start, bounds.start..*pos_next).unwrap();
*pos_next = exponential_search(&eval, !val_start, *pos_start + 1..bounds.end).unwrap();
}
(Some(pos_false), Some(pos_true))
}
#[cfg(test)]
mod tests {
use super::{cbps_brute_force, cyclic_breadth_partition_search as cbps};
use proptest::prelude::*;
fn cbps_assert_eq<T, PF>(list: &[T], partition: PF) -> (Option<usize>, Option<usize>)
where
PF: Fn(&T) -> bool,
T: Eq + core::fmt::Debug,
{
let eval = &|i: usize| partition(&list[i]);
let res_expected = cbps_brute_force(0..list.len(), eval);
assert_eq!(cbps(0..list.len(), eval), res_expected);
res_expected
}
#[test]
fn cbps_bpw3_simple00() {
let list = &[false, false, false, true, true];
assert_eq!(cbps_assert_eq(list, |i| *i), (Some(2), Some(4)));
}
#[test]
fn cbps_bpw3_cont_false() {
let list = &[false, false, false];
assert_eq!(cbps_assert_eq(list, |i| *i), (Some(2), None));
}
#[test]
fn cbps_bpw3_cont_true() {
let list = &[true, true];
assert_eq!(cbps_assert_eq(list, |i| *i), (None, Some(1)));
}
#[test]
fn cbps_bpw3_simple01() {
let list = &[true, false, false, false, true, true];
assert_eq!(cbps_assert_eq(list, |i| *i), (Some(3), Some(0)));
}
#[test]
fn cbps_bpw3_1exception() {
let list = &[true, false, true, true, true, true];
assert_eq!(cbps_assert_eq(list, |i| *i), (Some(1), Some(0)));
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 4096, .. ProptestConfig::default()
})]
#[test]
fn cbps_arbitrary(len in 1..4096usize, offset in 0..4096usize, amount_true in 0..4096usize) {
let offset = offset % len;
let amount_true = amount_true % len;
let mut list = vec![false; len];
for i in offset..(offset + amount_true) {
let i = i % len;
list[i] = true;
}
let eval = &|i: usize| list[i];
prop_assert_eq!(
cbps(0..list.len(), eval),
cbps_brute_force(0..list.len(), eval)
);
}
}
}

View File

@ -6,6 +6,12 @@ use geo::algorithm::line_measures::{Distance, Euclidean};
use geo::{geometry::Point, point, Line}; use geo::{geometry::Point, point, Line};
pub use specctra_core::math::{Circle, PointWithRotation}; pub use specctra_core::math::{Circle, PointWithRotation};
mod cyclic_search;
pub use cyclic_search::*;
mod polygon_tangents;
pub use polygon_tangents::*;
mod tangents; mod tangents;
pub use tangents::*; pub use tangents::*;

View File

@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: 2025 Topola contributors
//
// SPDX-License-Identifier: MIT
use super::{between_vectors, cyclic_breadth_partition_search};
use geo::Point;
#[derive(Clone, Debug, thiserror::Error, PartialEq)]
pub enum PolyTangentException<I> {
#[error("trying to target empty polygon")]
EmptyTargetPolygon { origin: Point },
#[error("invalid polygon tangent arguments")]
InvalidData {
poly_ext: Box<[(Point, I)]>,
origin: Point,
},
}
/// Calculates the tangents to the polygon exterior `poly_ext` oriented `cw?=poly_ext_is_cw`
/// going through point `origin`.
pub fn poly_ext_tangent_points<I: Copy>(
poly_ext: &[(Point, I)],
poly_ext_is_cw: bool,
origin: Point,
) -> Result<(I, I), PolyTangentException<I>> {
if poly_ext.is_empty() {
return Err(PolyTangentException::EmptyTargetPolygon { origin });
}
let (pos_false, pos_true) = cyclic_breadth_partition_search(0..poly_ext.len(), &|i: usize| {
let prev = &poly_ext[(poly_ext.len() + i - 1) % poly_ext.len()];
let cur = &poly_ext[i];
let next = &poly_ext[(i + 1) % poly_ext.len()];
// local coordinate system with origin at `cur.0`.
between_vectors(cur.0 - origin, cur.0 - prev.0, cur.0 - next.0)
});
let (mut pos_false, mut pos_true) =
if let (Some(pos_false), Some(pos_true)) = (pos_false, pos_true) {
(pos_false, pos_true)
} else {
return Err(PolyTangentException::InvalidData {
poly_ext: poly_ext.to_vec().into_boxed_slice(),
origin,
});
};
// * `pos_false` points to the maximum
// * `pos_true` points to the minimum
// NOTE: although pos_{false,true} are vertex indices, they are actually
// referring to the "critical" segment(s) (pos_false, pos_false + 1) (and resp. for pos_true).
// because that is where the `between_vectors` result flips.
// These critical segments are independent of CW/CCW.
// if `poly_ext` is oriented CCW, then
// * `pos_false` will be one too early, and
// * `pos_true` will be correct.
// if `poly_ext` is oriented CW, then
// * `pos_false` will be correct.
// * `pos_true` will be one too early, and
// TODO: can we (without too much overhead) determine if `poly_ext` is CW or CCW?
if poly_ext_is_cw {
pos_true += 1;
pos_true %= poly_ext.len();
} else {
pos_false += 1;
pos_false %= poly_ext.len();
}
Ok((poly_ext[pos_true].1, poly_ext[pos_false].1))
}
#[cfg(test)]
mod tests {
use super::poly_ext_tangent_points as petp;
use crate::drawing::dot::FixedDotIndex;
use geo::point;
#[test]
fn petp00() {
let poly_ext = &[
(point! { x: 0.0, y: 0.0 }, FixedDotIndex::new(0.into())),
(point! { x: 1.0, y: 0.0 }, FixedDotIndex::new(1.into())),
(point! { x: 1.0, y: 1.0 }, FixedDotIndex::new(2.into())),
(point! { x: 0.0, y: 1.0 }, FixedDotIndex::new(3.into())),
];
let origin = point! { x: 0.5, y: -1.0 };
assert_eq!(
petp(poly_ext, false, origin),
Ok((FixedDotIndex::new(1.into()), FixedDotIndex::new(0.into())))
);
}
#[test]
fn petp00cw() {
let poly_ext = &[
(point! { x: 0.0, y: 0.0 }, FixedDotIndex::new(0.into())),
(point! { x: 0.0, y: 1.0 }, FixedDotIndex::new(3.into())),
(point! { x: 1.0, y: 1.0 }, FixedDotIndex::new(2.into())),
(point! { x: 1.0, y: 0.0 }, FixedDotIndex::new(1.into())),
];
let origin = point! { x: 0.5, y: -1.0 };
assert_eq!(
petp(poly_ext, true, origin),
Ok((FixedDotIndex::new(1.into()), FixedDotIndex::new(0.into())))
);
}
}