use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
use ogeom_geom::{
Circle2d, Curve, Curve2d as _, Curve3d, Ellipse2d, Line2d, PlanarCurve, Surface,
SurfaceGeometry,
};
use ogeom_math::{Circle2, Ellipse2, Frame2, Point, Point2};
use crate::approx::approximate_branch;
use crate::march::{Marching, branches, trace_tangential};
use crate::surface::{Meeting, surface_surface};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IntersectOptions {
pub tolerance: f64,
pub marching: Marching,
}
impl Default for IntersectOptions {
fn default() -> Self {
Self {
tolerance: 1e-6,
marching: Marching::default(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SectionCurve {
pub curve: Curve,
pub on_a: Option<PlanarCurve>,
pub on_b: Option<PlanarCurve>,
pub tolerance: f64,
pub exact: bool,
pub closed: bool,
pub tangential: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SurfaceIntersection {
Apart,
Touching(Vec<Point>),
Along(Vec<SectionCurve>),
Same,
}
pub fn intersect_surfaces(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
options: IntersectOptions,
tol: Tolerances,
) -> OgeomResult<SurfaceIntersection> {
if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
ogeom_bail!(
Construction,
"a tolerance of {} is not a distance",
options.tolerance
);
}
if let Some(sections) = near_parallel_plane_drum(a, b, tol) {
return Ok(if sections.is_empty() {
SurfaceIntersection::Apart
} else {
SurfaceIntersection::Along(sections)
});
}
match surface_surface(a, b, tol) {
Ok(Meeting::Apart) => Ok(SurfaceIntersection::Apart),
Ok(Meeting::Same) => Ok(SurfaceIntersection::Same),
Ok(Meeting::Touching(points)) => Ok(SurfaceIntersection::Touching(points)),
Ok(Meeting::Along(curves)) => {
let sections: Vec<SectionCurve> = curves
.into_iter()
.filter_map(|curve| exact_section(curve, a, b, tol))
.collect();
Ok(if sections.is_empty() {
SurfaceIntersection::Apart
} else {
SurfaceIntersection::Along(sections)
})
}
Err(_) => match near_parallel_drums(a, b, tol).or_else(|| ball_through_drum(a, b, tol)) {
Some(sections) if sections.is_empty() => Ok(SurfaceIntersection::Apart),
Some(sections) => Ok(SurfaceIntersection::Along(sections)),
None => marched(a, b, options, tol),
},
}
}
fn near_parallel_drums(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
tol: Tolerances,
) -> Option<Vec<SectionCurve>> {
const LEAN: f64 = 1e-3;
let (SurfaceGeometry::Cylinder(sa), SurfaceGeometry::Cylinder(sb)) = (a, b) else {
return None;
};
let (ca, cb) = (sa.cylinder(), sb.cylinder());
let (axis_a, axis_b) = (ca.axis(), cb.axis());
let (da, db) = (axis_a.direction.vector(), axis_b.direction.vector());
let (ra, rb) = (ca.radius(), cb.radius());
let cos = da.dot(db);
if da.cross(db).magnitude() > LEAN || cos.abs() < 0.5 {
return None;
}
let (pa, pb) = (axis_a.location, axis_b.location);
let (_, (a0, a1)) = a.domain();
let (_, (b0, b1)) = b.domain();
let along = |v: f64| (pb - pa).dot(da) + v * cos;
let (lo, hi) = (
a0.min(a1).max(along(b0).min(along(b1))),
a0.max(a1).min(along(b0).max(along(b1))),
);
if !(lo.is_finite() && hi.is_finite()) {
return None;
}
if hi - lo <= tol.confusion() {
return Some(Vec::new());
}
let meet = |z: f64| -> Option<[Point; 2]> {
let centre_a = pa + da * z;
let s = (centre_a - pb).dot(da) / cos;
let centre_b = pb + db * s;
let mut between = centre_b - centre_a;
between = between - da * between.dot(da);
let d = between.magnitude();
let margin = tol.confusion() * 1e3;
if d <= margin || d >= ra + rb - margin || d <= (ra - rb).abs() + margin {
return None;
}
let x = (d * d + ra * ra - rb * rb) / (2.0 * d);
let h = (ra * ra - x * x).max(0.0).sqrt();
let ex = between / d;
let ey = da.cross(ex);
Some([centre_a + ex * x + ey * h, centre_a + ex * x - ey * h])
};
lines_through_stations(lo, hi, meet, rb * (1.0 / cos.abs() - 1.0), tol)
}
const NEAR_PARALLEL_STRAY: f64 = 1e-5;
fn lines_through_stations(
lo: f64,
hi: f64,
meet: impl Fn(f64) -> Option<[Point; 2]>,
stated: f64,
tol: Tolerances,
) -> Option<Vec<SectionCurve>> {
const STATIONS: u32 = 32;
const STRAIGHT: f64 = 1e-6;
let at = |k: f64| (hi - lo).mul_add(k / f64::from(STATIONS), lo);
let heights: Vec<f64> = (0..=STATIONS).map(|k| at(f64::from(k))).collect();
let met: Vec<[Point; 2]> = heights.iter().map(|&z| meet(z)).collect::<Option<_>>()?;
let between: Vec<[Point; 2]> = (0..STATIONS)
.map(|k| meet(at(f64::from(k) + 0.5)))
.collect::<Option<_>>()?;
let mut out = Vec::with_capacity(2);
for side in 0..2 {
let (from, to) = (met[0][side], met[met.len() - 1][side]);
let span = to - from;
let length = span.magnitude();
if length <= tol.confusion() {
return None;
}
let off_line = |p: Point| {
let t = (p - from).dot(span) / (length * length);
p.distance(from + span * t)
};
let stray = met
.iter()
.chain(&between)
.map(|pair| off_line(pair[side]))
.fold(0.0_f64, f64::max);
let (curve, stray): (Curve, f64) = if stray <= STRAIGHT {
(
ogeom_geom::LineCurve::segment(from, to, tol).ok()?.into(),
stray,
)
} else {
let points: Vec<Point> = met.iter().map(|pair| pair[side]).collect();
let fitted =
ogeom_geom::fit::fit_points_at(&heights, &points, 3, tol.confusion(), tol).ok()?;
let curve: Curve = fitted.curve.into();
let mut worst = fitted.error;
for (k, pair) in (0..STATIONS).zip(&between) {
let p = curve.point_at(at(f64::from(k) + 0.5), tol).ok()?;
worst = worst.max(p.distance(pair[side]));
}
(curve, worst)
};
let tolerance = stray + stated + tol.confusion();
if tolerance > NEAR_PARALLEL_STRAY {
return None;
}
out.push(SectionCurve {
curve,
on_a: None,
on_b: None,
tolerance,
exact: false,
closed: false,
tangential: false,
});
}
Some(out)
}
fn ball_through_drum(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
tol: Tolerances,
) -> Option<Vec<SectionCurve>> {
const SAMPLES: u32 = 256;
const STRAY: f64 = 1e-5;
let (ball, drum, ball_first) = match (a, b) {
(SurfaceGeometry::Sphere(s), SurfaceGeometry::Cylinder(c)) => (s, c, true),
(SurfaceGeometry::Cylinder(c), SurfaceGeometry::Sphere(s)) => (s, c, false),
_ => return None,
};
let (sphere, cylinder) = (ball.sphere(), drum.cylinder());
let frame = cylinder.frame();
let (x, y, d) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
let (origin, r) = (frame.origin(), cylinder.radius());
let (centre, big) = (sphere.centre(), sphere.radius());
let ball_frame = sphere.frame();
let (_, (h0, h1)) = drum.domain();
let margin = r * 0.1;
let heights = |angle: f64| -> Option<[f64; 2]> {
let foot = origin + (x * angle.cos() + y * angle.sin()) * r;
let w = foot - centre;
let half = d.dot(w);
let disc = half.mul_add(half, -(w.dot(w) - big * big));
if disc <= margin * margin {
return None;
}
let root = disc.sqrt();
let pair = [-half - root, -half + root];
pair.iter().all(|v| *v >= h0 && *v <= h1).then_some(pair)
};
let at = |angle: f64, v: f64| origin + (x * angle.cos() + y * angle.sin()) * r + d * v;
let on_ball = |p: Point, before: Option<Point2>| -> Point2 {
let local = ball_frame.to_local(p);
let lat = local.z.atan2(local.x.hypot(local.y));
let mut lon = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
if let Some(prev) = before {
while lon - prev.x > core::f64::consts::PI {
lon -= core::f64::consts::TAU;
}
while prev.x - lon > core::f64::consts::PI {
lon += core::f64::consts::TAU;
}
}
Point2::new(lon, lat)
};
let angle_of = |k: f64| core::f64::consts::TAU * k / f64::from(SAMPLES);
let params: Vec<f64> = (0..=SAMPLES).map(|k| angle_of(f64::from(k))).collect();
let mut sampled: Vec<[f64; 2]> = Vec::with_capacity(params.len());
for &angle in ¶ms {
sampled.push(heights(angle)?);
}
let mut out = Vec::with_capacity(2);
for side in 0..2 {
let points: Vec<Point> = params
.iter()
.zip(&sampled)
.map(|(&angle, pair)| at(angle, pair[side]))
.collect();
let on_drum: Vec<Point2> = params
.iter()
.zip(&sampled)
.map(|(&angle, pair)| Point2::new(angle, pair[side]))
.collect();
let mut on_sphere: Vec<Point2> = Vec::with_capacity(points.len());
for p in &points {
let q = on_ball(*p, on_sphere.last().copied());
on_sphere.push(q);
}
let target = tol.confusion() * 10.0;
let curve: Curve = ogeom_geom::fit::fit_points_at(¶ms, &points, 3, target, tol)
.ok()?
.curve
.into();
let drum_image: PlanarCurve =
ogeom_geom::fit::fit_points_2d_at(¶ms, &on_drum, 3, target, tol)
.ok()?
.curve
.into();
let ball_image: PlanarCurve =
ogeom_geom::fit::fit_points_2d_at(¶ms, &on_sphere, 3, target, tol)
.ok()?
.curve
.into();
let mut stray = 0.0_f64;
for k in 0..(2 * SAMPLES) {
let angle = angle_of(f64::from(k) / 2.0);
let truth = at(angle, heights(angle)?[side]);
let on_curve = curve.point_at(angle, tol).ok()?;
let uv = drum_image.point_at(angle, tol).ok()?;
let through_drum = drum.point_at(uv.x, uv.y, tol).ok()?;
let uv = ball_image.point_at(angle, tol).ok()?;
let through_ball = ball.point_at(uv.x, uv.y, tol).ok()?;
stray = stray
.max(truth.distance(on_curve))
.max(truth.distance(through_drum))
.max(truth.distance(through_ball));
}
let tolerance = stray.max(tol.confusion());
if tolerance > STRAY {
return None;
}
let (on_a, on_b) = if ball_first {
(ball_image, drum_image)
} else {
(drum_image, ball_image)
};
out.push(SectionCurve {
curve,
on_a: Some(on_a),
on_b: Some(on_b),
tolerance,
exact: false,
closed: true,
tangential: false,
});
}
Some(out)
}
fn near_parallel_plane_drum(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
tol: Tolerances,
) -> Option<Vec<SectionCurve>> {
const LEAN: f64 = 1e-3;
const SPAN: f64 = 3e4;
let (plane, drum, surface) = match (a, b) {
(SurfaceGeometry::Plane(p), SurfaceGeometry::Cylinder(c)) => (p.plane(), c.cylinder(), b),
(SurfaceGeometry::Cylinder(c), SurfaceGeometry::Plane(p)) => (p.plane(), c.cylinder(), a),
_ => return None,
};
let axis = drum.axis();
let (d, r) = (axis.direction.vector(), drum.radius());
let n = plane.normal().vector();
let lean = n.dot(d).abs();
if lean <= tol.angular() || lean > LEAN || r / lean < SPAN {
return None;
}
let across = n - d * n.dot(d);
let k = across.magnitude();
let e1 = across / k;
let e2 = d.cross(e1);
let (_, (lo, hi)) = surface.domain();
if !(lo.is_finite() && hi.is_finite()) || hi - lo <= tol.confusion() {
return None;
}
let meet = |z: f64| -> Option<[Point; 2]> {
let centre = axis.location + d * z;
let u = -plane.signed_distance_to(centre) / k;
let margin = tol.confusion() * 1e3;
if u.abs() >= r - margin {
return None;
}
let w = r.mul_add(r, -(u * u)).sqrt();
Some([centre + e1 * u + e2 * w, centre + e1 * u - e2 * w])
};
lines_through_stations(lo, hi, meet, 0.0, tol)
}
fn exact_section(
curve: Curve,
a: &SurfaceGeometry,
b: &SurfaceGeometry,
tol: Tolerances,
) -> Option<SectionCurve> {
let closed = match &curve {
Curve::Circle(_) | Curve::Ellipse(_) => true,
_ => curve.is_closed(tol),
};
let range = curve.domain();
let on_a = exact_pcurve(&curve, range, a, tol);
let on_b = exact_pcurve(&curve, range, b, tol);
if let Curve::Line(_) = &curve {
let mut interval = curve.domain();
if let Some(p) = &on_a {
interval = intersect_intervals(interval, inside_box(p, a))?;
}
if let Some(p) = &on_b {
interval = intersect_intervals(interval, inside_box(p, b))?;
}
let (lo, hi) = interval;
let Curve::Line(line) = &curve else {
unreachable!()
};
let clipped: Curve = ogeom_geom::LineCurve::over(line.axis(), lo, hi)
.ok()?
.into();
let clip2 = |p: &PlanarCurve| -> Option<PlanarCurve> {
let PlanarCurve::Line(l) = p else {
return Some(p.clone());
};
Some(Line2d::over(l.axis(), lo, hi).ok()?.into())
};
let (ca, cb) = (on_a.as_ref().and_then(clip2), on_b.as_ref().and_then(clip2));
let tangential = touching_along(&clipped, ca.as_ref(), cb.as_ref(), a, b, tol);
return Some(SectionCurve {
on_a: ca,
on_b: cb,
tolerance: 0.0,
exact: true,
closed: false,
tangential,
curve: clipped,
});
}
for (pcurve, surface) in [(&on_a, a), (&on_b, b)] {
if let Some(p) = pcurve
&& !touches_box(p, surface, tol)
{
return None;
}
}
let tangential = touching_along(&curve, on_a.as_ref(), on_b.as_ref(), a, b, tol);
Some(SectionCurve {
on_a,
on_b,
tolerance: 0.0,
exact: true,
closed,
tangential,
curve,
})
}
fn touching_along(
curve: &Curve,
on_a: Option<&PlanarCurve>,
on_b: Option<&PlanarCurve>,
a: &SurfaceGeometry,
b: &SurfaceGeometry,
tol: Tolerances,
) -> bool {
let sample_uv = |pc: Option<&PlanarCurve>,
surface: &SurfaceGeometry,
t: f64|
-> Option<ogeom_math::Point2> {
if let Some(pc) = pc {
return pc.point_at(t, tol).ok();
}
let p = curve.point_at(t, tol).ok()?;
chart_inversion(surface, p, tol)
};
let (lo, hi) = curve.domain();
let mut judged = 0_usize;
for f in [0.07, 0.19, 0.37, 0.53, 0.71, 0.89] {
let t = (hi - lo).mul_add(f, lo);
let (Some(ua), Some(ub)) = (sample_uv(on_a, a, t), sample_uv(on_b, b, t)) else {
continue;
};
let (Ok(na), Ok(nb)) = (a.normal_at(ua.x, ua.y, tol), b.normal_at(ub.x, ub.y, tol)) else {
continue;
};
if na.vector().cross(nb.vector()).magnitude() > 1e-6 {
return false;
}
judged += 1;
}
judged >= 3
}
fn chart_inversion(
surface: &SurfaceGeometry,
p: ogeom_math::Point,
tol: Tolerances,
) -> Option<ogeom_math::Point2> {
use ogeom_math::elementary;
let (u, v) = match surface {
SurfaceGeometry::Plane(s) => elementary::plane_parameters(&s.plane(), p),
SurfaceGeometry::Cylinder(s) => {
elementary::cylinder_parameters(&s.cylinder(), p, tol).ok()?
}
SurfaceGeometry::Cone(s) => elementary::cone_parameters(&s.cone(), p, tol).ok()?,
SurfaceGeometry::Sphere(s) => elementary::sphere_parameters(&s.sphere(), p, tol).ok()?,
SurfaceGeometry::Torus(s) => elementary::torus_parameters(&s.torus(), p, tol).ok()?,
_ => return None,
};
Some(ogeom_math::Point2::new(u, v))
}
fn inside_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry) -> Option<(f64, f64)> {
let PlanarCurve::Line(line) = pcurve else {
return None;
};
let ((ua, ub), (va, vb)) = surface.domain();
let axis = line.axis();
let (o, d) = (axis.location, axis.direction.vector());
let mut lo = f64::NEG_INFINITY;
let mut hi = f64::INFINITY;
for (origin, direction, low, high) in [(o.x, d.x, ua, ub), (o.y, d.y, va, vb)] {
if direction.abs() <= f64::MIN_POSITIVE {
if origin < low || origin > high {
return None;
}
continue;
}
let (a, b) = ((low - origin) / direction, (high - origin) / direction);
let (near, far) = if a < b { (a, b) } else { (b, a) };
lo = lo.max(near);
hi = hi.min(far);
}
if lo >= hi {
return None;
}
Some((lo, hi))
}
fn touches_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry, tol: Tolerances) -> bool {
use ogeom_geom::Curve2d;
let ((ua, ub), (va, vb)) = surface.domain();
let (lo, hi) = pcurve.domain();
const SPANS: u32 = 64;
let points: Vec<Option<ogeom_math::Point2>> = (0..=SPANS)
.map(|i| {
pcurve
.point_at(lo + (hi - lo) * f64::from(i) / f64::from(SPANS), tol)
.ok()
})
.collect();
points.windows(2).any(|pair| {
let (Some(p), Some(q)) = (pair[0], pair[1]) else {
return false;
};
let pad = p.distance(q);
let u_ok =
surface.is_periodic_u() || (p.x.max(q.x) + pad >= ua && p.x.min(q.x) - pad <= ub);
let v_ok =
surface.is_periodic_v() || (p.y.max(q.y) + pad >= va && p.y.min(q.y) - pad <= vb);
u_ok && v_ok
})
}
fn intersect_intervals(a: (f64, f64), b: Option<(f64, f64)>) -> Option<(f64, f64)> {
let b = b?;
let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
if lo >= hi {
return None;
}
Some((lo, hi))
}
fn marched(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
options: IntersectOptions,
tol: Tolerances,
) -> OgeomResult<SurfaceIntersection> {
let traced = branches(a, b, options.marching, tol)?;
if traced.is_empty() {
return Ok(SurfaceIntersection::Apart);
}
let mut out = Vec::with_capacity(traced.len());
let mut contacts: Vec<crate::march::Traced> = Vec::new();
for branch in &traced {
if branch_is_tangential(a, b, branch, tol)? {
if let Some(contact) = walk_contact(a, b, branch, &contacts, options.marching, tol)? {
contacts.push(contact);
}
continue;
}
if branch.stopped == crate::march::Stopped::RanOut {
ogeom_bail!(
NotDone,
"a marched section ran out of its point budget before \
finishing; the seam is longer than the chord affords and \
fitting the truncation would state a curve that is not there"
);
}
for fitted in fitted_in_pieces(a, b, branch, options.tolerance, tol)? {
out.push(SectionCurve {
curve: fitted.curve.into(),
on_a: Some(fitted.on_a.into()),
on_b: Some(fitted.on_b.into()),
tolerance: options.marching.chord + fitted.fit_error,
exact: false,
closed: fitted.closed,
tangential: false,
});
}
}
for contact in &contacts {
let fitted = approximate_branch(a, b, contact, options.tolerance, tol)?;
out.push(SectionCurve {
curve: fitted.curve.into(),
on_a: Some(fitted.on_a.into()),
on_b: Some(fitted.on_b.into()),
tolerance: options.marching.chord + fitted.fit_error,
exact: false,
closed: fitted.closed,
tangential: true,
});
}
if out.is_empty() {
return Ok(SurfaceIntersection::Apart);
}
Ok(SurfaceIntersection::Along(out))
}
fn fitted_in_pieces(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
branch: &crate::march::Traced,
tolerance: f64,
tol: Tolerances,
) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
const DEPTH: u32 = 6;
const FLOOR: usize = 16;
fn go(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
branch: &crate::march::Traced,
tolerance: f64,
depth: u32,
tol: Tolerances,
) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
let whole = approximate_branch(a, b, branch, tolerance, tol)?;
let step = branch
.points
.windows(2)
.map(|w| w[0].distance(w[1]))
.fold(0.0_f64, f64::max);
if whole.met
|| whole.fit_error <= step
|| branch.closed()
|| depth == 0
|| branch.points.len() < 2 * FLOOR
{
return Ok(vec![whole]);
}
let middle = branch.points.len() / 2;
let half = |range: core::ops::RangeInclusive<usize>| crate::march::Traced {
points: branch.points[range.clone()].to_vec(),
on_a: branch.on_a[range.clone()].to_vec(),
on_b: branch.on_b[range].to_vec(),
stopped: branch.stopped,
};
let mut pieces = go(a, b, &half(0..=middle), tolerance, depth - 1, tol)?;
pieces.extend(go(
a,
b,
&half(middle..=branch.points.len() - 1),
tolerance,
depth - 1,
tol,
)?);
let worst = pieces.iter().map(|p| p.fit_error).fold(0.0_f64, f64::max);
Ok(if worst < whole.fit_error {
pieces
} else {
vec![whole]
})
}
go(a, b, branch, tolerance, DEPTH, tol)
}
fn walk_contact(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
fragment: &crate::march::Traced,
already: &[crate::march::Traced],
marching: Marching,
tol: Tolerances,
) -> OgeomResult<Option<crate::march::Traced>> {
let middle = fragment.points.len() / 2;
let Some(point) = fragment.points.get(middle).copied() else {
return Ok(None);
};
for traced in already {
let spacing = traced
.points
.windows(2)
.map(|w| w[0].distance(w[1]))
.fold(0.0f64, f64::max);
let near = traced
.points
.iter()
.map(|p| p.distance(point))
.fold(f64::INFINITY, f64::min);
if near <= spacing.mul_add(0.5, marching.chord.max(tol.confusion())) {
return Ok(None);
}
}
let seed = crate::march::Contact {
point,
on_a: fragment.on_a[middle],
on_b: fragment.on_b[middle],
};
Ok(trace_tangential(a, b, seed, marching, tol)
.ok()
.filter(|traced| traced.points.len() >= 4))
}
fn branch_is_tangential(
a: &SurfaceGeometry,
b: &SurfaceGeometry,
branch: &crate::march::Traced,
tol: Tolerances,
) -> OgeomResult<bool> {
use ogeom_geom::Surface as _;
let count = branch.points.len();
if count == 0 {
return Ok(true);
}
for k in 0..5 {
let i = (k * (count - 1)) / 4;
let (ua, va) = branch.on_a[i.min(count - 1)];
let (ub, vb) = branch.on_b[i.min(count - 1)];
let (dau, dav) = a.d1_at(ua, va, tol)?;
let (dbu, dbv) = b.d1_at(ub, vb, tol)?;
let na = dau.cross(dav);
let nb = dbu.cross(dbv);
let (ma, mb) = (na.magnitude(), nb.magnitude());
if ma <= tol.confusion() || mb <= tol.confusion() {
continue;
}
if na.cross(nb).magnitude() / (ma * mb) > 3e-2 {
return Ok(false);
}
}
Ok(true)
}
#[must_use]
pub fn exact_pcurve_of(
curve: &Curve,
surface: &SurfaceGeometry,
tol: Tolerances,
) -> Option<PlanarCurve> {
exact_pcurve(curve, curve.domain(), surface, tol)
}
#[must_use]
pub fn exact_pcurve_over(
curve: &Curve,
range: (f64, f64),
surface: &SurfaceGeometry,
tol: Tolerances,
) -> Option<PlanarCurve> {
exact_pcurve(curve, range, surface, tol)
}
fn exact_pcurve(
curve: &Curve,
range: (f64, f64),
surface: &SurfaceGeometry,
tol: Tolerances,
) -> Option<PlanarCurve> {
if let Curve::Trimmed(trimmed) = curve
&& !trimmed.is_reversed()
{
let window = ogeom_geom::Curve3d::domain(&**trimmed);
let basis = exact_pcurve(trimmed.basis(), range, surface, tol)?;
return ogeom_geom::Trimmed2d::new(basis, window.0, window.1, tol)
.ok()
.map(Into::into);
}
match surface {
SurfaceGeometry::Plane(p) => on_plane(curve, p.plane(), tol),
SurfaceGeometry::Cylinder(c) => on_cylinder(curve, range, c.cylinder(), tol),
SurfaceGeometry::Sphere(s) => on_sphere(curve, range, s.sphere(), tol),
SurfaceGeometry::Torus(t) => on_torus(curve, t.torus(), tol),
SurfaceGeometry::Cone(c) => on_cone(curve, range, c.cone(), tol),
_ => None,
}
}
fn on_cone(
curve: &Curve,
range: (f64, f64),
cone: ogeom_math::Cone,
tol: Tolerances,
) -> Option<PlanarCurve> {
let frame = cone.frame();
let axis_z = frame.z().vector();
let tau = core::f64::consts::TAU;
match curve {
Curve::Circle(c) => {
let circle = c.circle();
if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
return None;
}
let local = frame.to_local(circle.centre());
if local.x.hypot(local.y) > tol.confusion() {
return None;
}
let expected = cone
.half_angle()
.tan()
.mul_add(local.z, cone.reference_radius());
if (expected - circle.radius()).abs() > tol.confusion() * 10.0 {
return None;
}
let start = circle.centre() + circle.frame().x().vector() * circle.radius();
let at = frame.to_local(start);
let phase = at.y.atan2(at.x);
let winding = circle.frame().z().vector().dot(axis_z).signum();
let towards =
ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
Some(
Line2d::over(
ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
0.0,
tau,
)
.ok()?
.into(),
)
}
Curve::Line(line) => {
let axis = line.axis();
let on = |t: f64| {
let p = axis.location + axis.direction.vector() * t;
cone.distance_to(p) <= tol.confusion() * 10.0
};
if !on(0.0) || !on(1.0) || !on(-1.0) {
return None;
}
let (lo, hi) = if range.0.is_finite() && range.1.is_finite() && range.0 != range.1 {
range
} else {
line.domain()
};
let mut local: Option<ogeom_math::Point> = None;
for t in [lo, hi] {
if !t.is_finite() {
continue;
}
let candidate = frame.to_local(axis.location + axis.direction.vector() * t);
if local.is_none_or(|held| candidate.x.hypot(candidate.y) > held.x.hypot(held.y)) {
local = Some(candidate);
}
}
let local = local?;
if local.x.hypot(local.y) <= tol.confusion() {
return None;
}
let u = local.y.atan2(local.x).rem_euclid(tau);
let v_at = |t: f64| {
frame
.to_local(axis.location + axis.direction.vector() * t)
.z
};
let knots = ogeom_math::KnotVector::new(vec![lo, lo, hi, hi], 1).ok()?;
Some(
ogeom_geom::BSpline2d::new(
knots,
vec![Point2::new(u, v_at(lo)), Point2::new(u, v_at(hi))],
tol,
)
.ok()?
.into(),
)
}
_ => None,
}
}
fn on_torus(curve: &Curve, torus: ogeom_math::Torus, tol: Tolerances) -> Option<PlanarCurve> {
let Curve::Circle(c) = curve else {
return None;
};
let circle = c.circle();
let frame = torus.frame();
let axis_z = frame.z().vector();
let normal = circle.frame().z().vector();
let local = frame.to_local(circle.centre());
let tau = core::f64::consts::TAU;
if normal.cross(axis_z).magnitude() <= tol.angular()
&& local.x.hypot(local.y) <= tol.confusion()
{
let sin_v = local.z / torus.minor_radius();
let cos_v = (circle.radius() - torus.major_radius()) / torus.minor_radius();
if (sin_v.hypot(cos_v) - 1.0).abs() > tol.confusion() {
return None;
}
let v = sin_v.atan2(cos_v);
let start = circle.centre() + circle.frame().x().vector() * circle.radius();
let at = frame.to_local(start);
let phase = at.y.atan2(at.x);
let winding = normal.dot(axis_z).signum();
let towards =
ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
return Some(
Line2d::over(
ogeom_math::Axis2::new(Point2::new(phase, v), towards),
0.0,
tau,
)
.ok()?
.into(),
);
}
if (circle.radius() - torus.minor_radius()).abs() <= tol.confusion()
&& normal.dot(axis_z).abs() <= tol.angular()
&& (local.x.hypot(local.y) - torus.major_radius()).abs() <= tol.confusion()
&& local.z.abs() <= tol.confusion()
{
let u = local.y.atan2(local.x);
let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
let xc = circle.frame().x().vector();
let phase = xc.dot(axis_z).atan2(xc.dot(radial));
let winding = normal.dot(radial.cross(axis_z)).signum();
let towards =
ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, winding), tol).ok()?;
return Some(
Line2d::over(
ogeom_math::Axis2::new(Point2::new(u, phase), towards),
0.0,
tau,
)
.ok()?
.into(),
);
}
None
}
fn on_plane(curve: &Curve, plane: ogeom_math::Plane, tol: Tolerances) -> Option<PlanarCurve> {
let frame = plane.frame();
let flat = |p: Point| {
let local = frame.to_local(p);
Point2::new(local.x, local.y)
};
let flat_direction = |d: ogeom_math::Direction| {
let tip = flat(frame.origin() + d.vector());
ogeom_math::Direction2::new(tip - flat(frame.origin()), tol).ok()
};
match curve {
Curve::Line(line) => {
let axis = line.axis();
let through = flat(axis.location);
let direction = flat_direction(axis.direction)?;
let (lo, hi) = line.domain();
Some(
Line2d::over(ogeom_math::Axis2::new(through, direction), lo, hi)
.ok()?
.into(),
)
}
Curve::Circle(c) => {
let circle = c.circle();
let frame2 = Frame2::from_axes(
flat(circle.centre()),
flat_direction(circle.frame().x())?,
flat_direction(circle.frame().y())?,
tol,
)
.ok()?;
Some(Circle2d::new(Circle2::new(frame2, circle.radius(), tol).ok()?).into())
}
Curve::Ellipse(e) => {
let ellipse = e.ellipse();
let frame2 = Frame2::from_axes(
flat(ellipse.centre()),
flat_direction(ellipse.frame().x())?,
flat_direction(ellipse.frame().y())?,
tol,
)
.ok()?;
Some(
Ellipse2d::new(
Ellipse2::new(frame2, ellipse.major_radius(), ellipse.minor_radius(), tol)
.ok()?,
)
.into(),
)
}
Curve::BSpline(b) => {
let control = b
.control_points()
.iter()
.map(|w| ogeom_math::Weighted::new(flat((*w).point()), w.weight, tol))
.collect::<Result<Vec<_>, _>>()
.ok()?;
Some(
ogeom_geom::BSpline2d::rational(b.knots().clone(), control)
.ok()?
.into(),
)
}
_ => None,
}
}
fn on_cylinder(
curve: &Curve,
range: (f64, f64),
cylinder: ogeom_math::Cylinder,
tol: Tolerances,
) -> Option<PlanarCurve> {
let axis = cylinder.axis();
let frame = cylinder.frame();
match curve {
Curve::Line(line) => {
let direction = line.axis().direction;
let along = direction.dot(axis.direction);
if (along.abs() - 1.0).abs() > tol.angular() {
return None;
}
let through = line.axis().location;
if (axis.distance_to(through) - cylinder.radius()).abs() > tol.confusion() {
return None;
}
let local = frame.to_local(through);
let u = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
let (lo, hi) = line.domain();
let start = Point2::new(u, local.z);
let towards =
ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, along.signum()), tol)
.ok()?;
Some(
Line2d::over(ogeom_math::Axis2::new(start, towards), lo, hi)
.ok()?
.into(),
)
}
Curve::Circle(c) => {
let circle = c.circle();
if circle
.frame()
.z()
.cross_with(axis.direction.vector())
.magnitude()
> tol.angular()
{
return None;
}
if axis.distance_to(circle.centre()) > tol.confusion() {
return None;
}
if (circle.radius() - cylinder.radius()).abs() > tol.confusion() {
return None;
}
let local = frame.to_local(circle.centre());
let start = circle.centre() + circle.frame().x().vector() * circle.radius();
let at = frame.to_local(start);
let phase = at.y.atan2(at.x);
let winding = circle.frame().z().dot(axis.direction).signum();
let towards =
ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
Some(
Line2d::over(
ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
0.0,
core::f64::consts::TAU,
)
.ok()?
.into(),
)
}
Curve::Ellipse(_) => {
use ogeom_geom::Curve3d as _;
let tau = core::f64::consts::TAU;
let local = |t: f64| -> Option<ogeom_math::Point> {
Some(frame.to_local(curve.point_at(t, tol).ok()?))
};
let l0 = local(0.0)?;
let lq = local(tau / 4.0)?;
let lh = local(tau / 2.0)?;
let r = cylinder.radius();
for l in [&l0, &lq, &lh] {
if (l.x.hypot(l.y) - r).abs() > tol.confusion() * 10.0 {
return None;
}
}
let phase = l0.y.atan2(l0.x);
let uq = lq.y.atan2(lq.x);
let step = (uq - phase).rem_euclid(tau);
let winding = if (step - tau / 4.0).abs() < 1e-6 {
1.0
} else if (step - 3.0 * tau / 4.0).abs() < 1e-6 {
-1.0
} else {
return None;
};
let c0 = f64::midpoint(l0.z, lh.z);
let a = (l0.z - lh.z) / 2.0;
let b = lq.z - c0;
let candidate = ogeom_geom::Trig2d::new(
Point2::new(phase, c0),
ogeom_math::Vector2::new(winding, 0.0),
ogeom_math::Vector2::new(0.0, a),
ogeom_math::Vector2::new(0.0, b),
range,
)
.ok()?;
use ogeom_geom::Curve2d as _;
for i in 0..7 {
let t = range.0 + (range.1 - range.0) * (0.09 + 0.13 * f64::from(i)) / 0.91;
let l = local(t)?;
let chart = candidate.point_at(t, tol).ok()?;
let du = (chart.x - l.y.atan2(l.x)).rem_euclid(tau);
if du.min(tau - du) > 1e-9 {
return None;
}
if (chart.y - l.z).abs() > tol.confusion() * 10.0 {
return None;
}
}
Some(PlanarCurve::Trig(candidate))
}
_ => None,
}
}
fn on_meridian(
curve: &ogeom_geom::CircleCurve,
range: (f64, f64),
sphere: ogeom_math::Sphere,
tol: Tolerances,
) -> Option<PlanarCurve> {
let circle = curve.circle();
let sweep = if curve.is_reversed() { -1.0 } else { 1.0 };
let frame = sphere.frame();
let z = frame.z().vector();
if circle.centre().distance(sphere.centre()) > tol.confusion() {
return None;
}
if (circle.radius() - sphere.radius()).abs() > tol.confusion() {
return None;
}
let (cx, cy) = (circle.frame().x().vector(), circle.frame().y().vector());
let (xz, yz) = (cx.dot(z), cy.dot(z));
if xz.hypot(yz) < 1.0 - tol.angular() {
return None;
}
let raw_alpha = yz.atan2(xz);
let w = cx * -raw_alpha.sin() + cy * raw_alpha.cos();
let local = frame.to_local(sphere.centre() + w);
let longitude = local.y.atan2(local.x);
let half = core::f64::consts::PI;
let mid = f64::midpoint(range.0, range.1);
let x_mid = (sweep * mid - raw_alpha).rem_euclid(core::f64::consts::TAU);
let x_mid = if x_mid > half {
x_mid - core::f64::consts::TAU
} else {
x_mid
};
let span = sweep * (range.1 - range.0);
let (mut x0, mut x1) = (x_mid - span / 2.0, x_mid + span / 2.0);
if x0 > x1 {
core::mem::swap(&mut x0, &mut x1);
}
let alpha = sweep.mul_add(mid, -x_mid);
let slack = tol.parametric().max(1e-9);
let (axis_point, towards) = if x0 >= -slack && x1 <= half + slack {
(
Point2::new(longitude, half.mul_add(0.5, alpha)),
ogeom_math::Vector2::new(0.0, -sweep),
)
} else if x0 >= -half - slack && x1 <= slack {
(
Point2::new(longitude + half, half.mul_add(0.5, -alpha)),
ogeom_math::Vector2::new(0.0, sweep),
)
} else {
return None;
};
let towards = ogeom_math::Direction2::new(towards, tol).ok()?;
let margin = (range.1 - range.0) * 0.25;
let line: PlanarCurve = Line2d::over(
ogeom_math::Axis2::new(axis_point, towards),
range.0 - margin,
range.1 + margin,
)
.ok()?
.into();
for k in 0..=4 {
let t = (range.1 - range.0).mul_add(f64::from(k) / 4.0, range.0);
let uv = line.point_at(t, tol).ok()?;
let lifted = ogeom_math::elementary::sphere_at(&sphere, uv.x, uv.y).point;
let want = curve.point_at(t, tol).ok()?;
if lifted.distance(want) > tol.confusion() {
return None;
}
}
Some(line)
}
fn on_sphere(
curve: &Curve,
range: (f64, f64),
sphere: ogeom_math::Sphere,
tol: Tolerances,
) -> Option<PlanarCurve> {
let Curve::Circle(c) = curve else {
return None;
};
let circle = c.circle();
let frame = sphere.frame();
if circle
.frame()
.z()
.cross_with(frame.z().vector())
.magnitude()
> tol.angular()
{
return on_meridian(c, range, sphere, tol);
}
let local = frame.to_local(circle.centre());
if local.x.abs() > tol.confusion() || local.y.abs() > tol.confusion() {
return None;
}
let latitude = (local.z / sphere.radius()).clamp(-1.0, 1.0).asin();
if (circle.radius() - sphere.radius() * latitude.cos()).abs() > tol.confusion() {
return None;
}
let start = circle.centre() + circle.frame().x().vector() * circle.radius();
let at = frame.to_local(start);
let phase = at.y.atan2(at.x);
let winding = circle.frame().z().vector().dot(frame.z().vector()).signum();
let towards = ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
Some(
Line2d::over(
ogeom_math::Axis2::new(Point2::new(phase, latitude), towards),
0.0,
core::f64::consts::TAU,
)
.ok()?
.into(),
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
use ogeom_math::{Cylinder, Direction, Frame, Plane, Sphere, Vector};
const T: Tolerances = Tolerances::millimetres();
fn sphere(centre: Point, radius: f64) -> SurfaceGeometry {
SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
}
fn cylinder(axis: Vector, radius: f64) -> SurfaceGeometry {
let frame = Frame::new(
Point::ORIGIN,
Direction::new(axis, T).unwrap(),
Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
T,
)
.unwrap();
CylinderSurface::new(Cylinder::new(frame, radius, T).unwrap(), (-4.0, 4.0))
.unwrap()
.into()
}
fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
PlaneSurface::over(
Plane::through(origin, Direction::new(normal, T).unwrap()),
(-6.0, 6.0),
(-6.0, 6.0),
)
.unwrap()
.into()
}
fn assert_same_parameter(
section: &SectionCurve,
surface: &SurfaceGeometry,
pcurve: &PlanarCurve,
samples: usize,
) {
let (lo, hi) = section.curve.domain();
let (plo, phi) = pcurve.domain();
assert!(
(lo - plo).abs() < 1e-9 && (hi - phi).abs() < 1e-9,
"domains disagree: [{lo}, {hi}] against [{plo}, {phi}]"
);
for i in 0..=samples {
#[allow(clippy::cast_precision_loss)]
let t = lo + (hi - lo) * i as f64 / samples as f64;
let on_curve = section.curve.point_at(t, T).unwrap();
let at = pcurve.point_at(t, T).unwrap();
let lifted = surface.point_at(at.x, at.y, T).unwrap();
assert!(
on_curve.is_equal(lifted, T),
"at t = {t}: curve {on_curve:?}, lifted {lifted:?}"
);
}
}
#[test]
fn an_analytic_pair_comes_back_exact_with_matching_pcurves() {
let drum = cylinder(Vector::Z, 2.0);
let cut = plane(Point::ORIGIN, Vector::X);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
else {
panic!("a plane through a cylinder meets it along curves");
};
assert_eq!(curves.len(), 2);
for section in &curves {
assert!(section.exact);
assert!((section.tolerance - 0.0).abs() < f64::EPSILON);
let on_a = section.on_a.as_ref().expect("a line has a cylinder pcurve");
let on_b = section.on_b.as_ref().expect("and a plane pcurve");
assert_same_parameter(section, &drum, on_a, 50);
assert_same_parameter(section, &cut, on_b, 50);
}
}
#[test]
fn an_oblique_cut_gives_the_ellipse_a_trig_pcurve_on_the_drum() {
let drum = cylinder(Vector::Z, 2.0);
let angle: f64 = 0.5;
let cut = plane(Point::ORIGIN, Vector::new(0.0, angle.sin(), angle.cos()));
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
else {
panic!("an oblique plane meets the cylinder along its ellipse");
};
assert_eq!(curves.len(), 1);
let section = &curves[0];
assert!(section.exact);
assert!(matches!(section.curve, Curve::Ellipse(_)));
let on_drum = section
.on_a
.as_ref()
.expect("the oblique ellipse now carries its cylinder pcurve");
assert!(
matches!(on_drum, PlanarCurve::Trig(_)),
"the chart trace is trig-affine: {on_drum:?}"
);
assert_same_parameter(section, &drum, on_drum, 60);
let on_plane = section.on_b.as_ref().expect("and its plane pcurve");
assert_same_parameter(section, &cut, on_plane, 60);
}
#[test]
fn a_perpendicular_cut_gives_a_circle_with_a_straight_pcurve() {
let drum = cylinder(Vector::Z, 2.0);
let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
else {
panic!("expected curves");
};
assert_eq!(curves.len(), 1);
let section = &curves[0];
assert!(section.closed);
assert!(matches!(section.curve, Curve::Circle(_)));
assert!(matches!(
section.on_a.as_ref().unwrap(),
PlanarCurve::Line(_)
));
assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 60);
assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 60);
}
#[test]
fn coaxial_cylinder_and_sphere_give_circles_with_pcurves_on_both() {
let drum = cylinder(Vector::Z, 1.5);
let ball = sphere(Point::ORIGIN, 3.0);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&drum, &ball, IntersectOptions::default(), T).unwrap()
else {
panic!("expected curves");
};
assert_eq!(curves.len(), 2);
for section in &curves {
assert!(section.exact);
assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 40);
assert_same_parameter(section, &ball, section.on_b.as_ref().unwrap(), 40);
}
}
fn torus(origin: Point, axis: Vector, major: f64, minor: f64) -> SurfaceGeometry {
let frame = Frame::new(
origin,
Direction::new(axis, T).unwrap(),
Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
T,
)
.unwrap();
ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(frame, major, minor, T).unwrap())
.into()
}
#[test]
fn an_axis_normal_plane_meets_a_torus_in_two_parallels_with_pcurves() {
let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
let cut = plane(Point::new(0.0, 0.0, 0.3), Vector::Z);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
else {
panic!("an axis-normal plane through the tube meets it along curves");
};
assert_eq!(curves.len(), 2);
let spread = 0.5_f64.mul_add(0.5, -(0.3 * 0.3)).sqrt();
let mut radii: Vec<f64> = curves
.iter()
.map(|s| {
let Curve::Circle(c) = &s.curve else {
panic!("a parallel is a circle");
};
c.circle().radius()
})
.collect();
radii.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!((radii[0] - (2.0 - spread)).abs() < 1e-12);
assert!((radii[1] - (2.0 + spread)).abs() < 1e-12);
for section in &curves {
assert!(section.exact);
assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 48);
}
}
#[test]
fn the_plane_a_ball_rolls_on_touches_its_torus_along_the_circle_it_rolled() {
let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
let cut = plane(Point::new(0.0, 0.0, 0.5), Vector::Z);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
else {
panic!("the rolling plane touches along a circle, not at points");
};
assert_eq!(curves.len(), 1);
let Curve::Circle(c) = &curves[0].curve else {
panic!("the tangency is a circle");
};
assert!((c.circle().radius() - 2.0).abs() < 1e-12);
assert_same_parameter(&curves[0], &ring, curves[0].on_a.as_ref().unwrap(), 48);
assert_same_parameter(&curves[0], &cut, curves[0].on_b.as_ref().unwrap(), 48);
}
#[test]
fn a_coaxial_cylinder_meets_a_torus_in_two_parallels_and_touches_in_one() {
let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
let drum = cylinder(Vector::Z, 2.2);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&drum, &ring, IntersectOptions::default(), T).unwrap()
else {
panic!("a coaxial cylinder through the tube meets it along curves");
};
assert_eq!(curves.len(), 2);
for section in &curves {
assert!(section.exact);
let Curve::Circle(c) = §ion.curve else {
panic!("a parallel is a circle");
};
assert!((c.circle().radius() - 2.2).abs() < 1e-12);
assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 48);
assert_same_parameter(section, &ring, section.on_b.as_ref().unwrap(), 48);
}
let grazing = cylinder(Vector::Z, 2.5);
let SurfaceIntersection::Along(touch) =
intersect_surfaces(&grazing, &ring, IntersectOptions::default(), T).unwrap()
else {
panic!("the grazing cylinder touches along the equator");
};
assert_eq!(touch.len(), 1);
assert_same_parameter(&touch[0], &grazing, touch[0].on_a.as_ref().unwrap(), 48);
assert_same_parameter(&touch[0], &ring, touch[0].on_b.as_ref().unwrap(), 48);
}
#[test]
fn coaxial_tori_are_the_same_or_meet_in_parallels() {
let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
assert!(matches!(
intersect_surfaces(&ring, &ring.clone(), IntersectOptions::default(), T).unwrap(),
SurfaceIntersection::Same
));
let lifted = torus(Point::new(0.0, 0.0, 0.5), Vector::Z, 2.0, 0.5);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&ring, &lifted, IntersectOptions::default(), T).unwrap()
else {
panic!("lifted coaxial tori meet along curves");
};
assert_eq!(curves.len(), 2);
for section in &curves {
assert!(section.exact);
assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
assert_same_parameter(section, &lifted, section.on_b.as_ref().unwrap(), 48);
}
}
#[test]
fn a_pair_with_no_closed_form_comes_back_fitted_with_pcurves() {
let a = cylinder(Vector::Z, 1.0);
let b = cylinder(Vector::X, 1.6);
let options = IntersectOptions {
tolerance: 1e-5,
marching: Marching {
chord: 1e-5,
..Marching::default()
},
};
let SurfaceIntersection::Along(curves) = intersect_surfaces(&a, &b, options, T).unwrap()
else {
panic!("crossed cylinders meet along curves");
};
assert_eq!(curves.len(), 2);
for section in &curves {
assert!(!section.exact);
assert!(section.closed);
assert!(
section.tolerance <= 1e-5 + 1e-4,
"got {}",
section.tolerance
);
assert!(section.on_a.is_some() && section.on_b.is_some());
let (lo, hi) = section.curve.domain();
for i in 0..=200 {
#[allow(clippy::cast_precision_loss)]
let t = lo + (hi - lo) * f64::from(i) / 200.0;
let p = section.curve.point_at(t, T).unwrap();
let (SurfaceGeometry::Cylinder(x), SurfaceGeometry::Cylinder(y)) = (&a, &b) else {
unreachable!()
};
let off = x
.cylinder()
.distance_to(p)
.abs()
.max(y.cylinder().distance_to(p).abs());
assert!(
off <= section.tolerance * 2.0,
"at t = {t} the fitted curve is {off:e} off, tolerance {}",
section.tolerance
);
}
}
}
#[test]
fn a_plane_all_but_along_the_axis_still_meets_a_short_drum() {
let drum = cylinder(Vector::Z, 1.0);
let wall: SurfaceGeometry = PlaneSurface::over(
Plane::through(
Point::new(0.0, 0.6, 0.0),
Direction::new(Vector::new(0.0, 1.0, 1e-4), T).unwrap(),
),
(-1e9, 1e9),
(-1e9, 1e9),
)
.unwrap()
.into();
let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
let SurfaceIntersection::Along(sections) = met else {
panic!("the wall crosses the drum: {met:?}");
};
assert_eq!(sections.len(), 1);
let curve = §ions[0].curve;
let (lo, hi) = curve.domain();
let inside = (0..=100_000).any(|k| {
let p = curve
.point_at(lo + (hi - lo) * f64::from(k) / 100_000.0, T)
.unwrap();
p.z.abs() <= 4.0
});
assert!(inside, "and the section runs through the drum's height");
}
fn on_both(section: &SectionCurve, a: &SurfaceGeometry, b: &SurfaceGeometry) {
let (lo, hi) = section.curve.domain();
for k in 0..=64 {
let p = section
.curve
.point_at(lo + (hi - lo) * f64::from(k) / 64.0, T)
.unwrap();
for surface in [a, b] {
let off = match surface {
SurfaceGeometry::Plane(plane) => plane.plane().signed_distance_to(p).abs(),
SurfaceGeometry::Cylinder(drum) => {
let axis = drum.cylinder().axis();
let rel = p - axis.location;
let d = axis.direction.vector();
((rel - d * rel.dot(d)).magnitude() - drum.cylinder().radius()).abs()
}
_ => unreachable!("planes and drums only"),
};
assert!(
off <= section.tolerance + 1e-9,
"{p:?} is {off:e} off, stated {:e}",
section.tolerance
);
}
}
}
#[test]
fn a_plane_all_but_along_a_drums_axis_meets_it_in_two_near_lines() {
let drum = cylinder(Vector::Z, 1.0);
let wall: SurfaceGeometry = PlaneSurface::over(
Plane::through(
Point::new(0.0, 0.99, 0.0),
Direction::new(Vector::new(0.0, 1.0, 2e-5), T).unwrap(),
),
(-1e9, 1e9),
(-1e9, 1e9),
)
.unwrap()
.into();
let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
let SurfaceIntersection::Along(sections) = met else {
panic!("the wall crosses the drum: {met:?}");
};
assert_eq!(sections.len(), 2);
for section in §ions {
assert!(section.tolerance > 0.0 && section.tolerance <= 1e-5);
on_both(section, &wall, &drum);
}
}
#[test]
fn drums_all_but_parallel_meet_in_two_near_lines() {
let drill = cylinder(Vector::Z, 1.0);
let frame = Frame::new(
Point::new(1.5, 0.0, 0.0),
Direction::new(Vector::new(5e-5, 0.0, 1.0), T).unwrap(),
Direction::X,
T,
)
.unwrap();
let bore: SurfaceGeometry =
CylinderSurface::new(Cylinder::new(frame, 1.0, T).unwrap(), (-3.0, 3.0))
.unwrap()
.into();
let met = intersect_surfaces(&drill, &bore, IntersectOptions::default(), T).unwrap();
let SurfaceIntersection::Along(sections) = met else {
panic!("the drums cross: {met:?}");
};
assert_eq!(sections.len(), 2);
for section in §ions {
assert!(!section.exact && section.tolerance <= 1e-5);
let (lo, hi) = section.curve.domain();
let (p, q) = (
section.curve.point_at(lo, T).unwrap(),
section.curve.point_at(hi, T).unwrap(),
);
assert!(
(p.z - q.z).abs() > 5.9,
"over the shared height: {p:?} {q:?}"
);
on_both(section, &drill, &bore);
}
}
#[test]
fn exact_lines_are_clipped_to_the_surfaces_extents() {
let drum = cylinder(Vector::Z, 2.0);
let cut = plane(Point::ORIGIN, Vector::X);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
else {
panic!("expected curves");
};
for section in &curves {
let (lo, hi) = section.curve.domain();
assert!(
hi - lo <= 8.0 + 1e-9,
"the line was not clipped: [{lo}, {hi}]"
);
let start = section.curve.point_at(lo, T).unwrap();
let end = section.curve.point_at(hi, T).unwrap();
assert!(start.z >= -4.0 - 1e-9 && end.z <= 4.0 + 1e-9);
}
let high = plane(Point::new(0.0, 0.0, 10.0), Vector::Z);
assert_eq!(
intersect_surfaces(&drum, &high, IntersectOptions::default(), T).unwrap(),
SurfaceIntersection::Apart
);
}
#[test]
fn the_degenerate_answers_pass_through() {
assert_eq!(
intersect_surfaces(
&sphere(Point::ORIGIN, 1.0),
&sphere(Point::new(5.0, 0.0, 0.0), 1.0),
IntersectOptions::default(),
T
)
.unwrap(),
SurfaceIntersection::Apart
);
assert_eq!(
intersect_surfaces(
&sphere(Point::ORIGIN, 1.0),
&sphere(Point::ORIGIN, 1.0),
IntersectOptions::default(),
T
)
.unwrap(),
SurfaceIntersection::Same
);
assert!(matches!(
intersect_surfaces(
&plane(Point::ORIGIN, Vector::Z),
&sphere(Point::new(0.0, 0.0, 2.0), 2.0),
IntersectOptions::default(),
T
)
.unwrap(),
SurfaceIntersection::Touching(ref p) if p.len() == 1
));
}
#[test]
fn unusable_options_are_refused() {
let a = sphere(Point::ORIGIN, 1.0);
let b = plane(Point::ORIGIN, Vector::Z);
for tolerance in [0.0, -1.0, f64::NAN] {
let options = IntersectOptions {
tolerance,
..IntersectOptions::default()
};
assert!(intersect_surfaces(&a, &b, options, T).is_err());
}
}
#[test]
fn a_circle_wound_against_the_axis_keeps_its_pcurve_same_parameter() {
let drum: SurfaceGeometry = CylinderSurface::new(
Cylinder::new(
Frame::new(Point::new(2.0, 2.0, -1.0), Direction::Z, Direction::X, T).unwrap(),
0.5,
T,
)
.unwrap(),
(0.0, 3.0),
)
.unwrap()
.into();
for normal in [Direction::Z, -Direction::Z] {
let frame = Frame::new(Point::ORIGIN, normal, Direction::X, T).unwrap();
let ground: SurfaceGeometry =
PlaneSurface::over(Plane::new(frame), (-4.0, 4.0), (-4.0, 4.0))
.unwrap()
.into();
let met = intersect_surfaces(&ground, &drum, IntersectOptions::default(), T).unwrap();
let SurfaceIntersection::Along(curves) = met else {
panic!("a plane through a cylinder sections it");
};
for sc in &curves {
let pcurve = sc
.on_b
.as_ref()
.expect("a circle on its cylinder has a pcurve");
let (lo, hi) = sc.curve.domain();
for i in 0..8 {
let t = lo + (hi - lo) * f64::from(i) / 8.0;
let p3 = sc.curve.point_at(t, T).unwrap();
let uv = pcurve.point_at(t, T).unwrap();
let lifted = drum
.point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
.unwrap();
assert!(
p3.distance(lifted) < 1e-9,
"normal {normal:?}, t {t}: pcurve lifts {lifted:?} against {p3:?}"
);
}
}
}
}
#[test]
fn a_meridian_half_has_an_exact_line_for_a_pcurve() {
use ogeom_geom::Surface as _;
let half = core::f64::consts::PI;
for (centre, radius) in [(Point::ORIGIN, 4.0), (Point::new(1.0, -2.0, 0.5), 1.25)] {
let ball = sphere(centre, radius);
let SurfaceGeometry::Sphere(s) = &ball else {
panic!("a sphere surface");
};
for azimuth in [0.0_f64, 0.7, 2.4] {
let normal = Vector::new(-azimuth.sin(), azimuth.cos(), 0.0);
let cut = plane(centre, normal);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&ball, &cut, IntersectOptions::default(), T).unwrap()
else {
panic!("a plane through the centre meets the ball along a circle");
};
assert_eq!(curves.len(), 1, "one great circle");
let circle = &curves[0].curve;
assert!(curves[0].exact);
assert!(
exact_pcurve_over(circle, circle.domain(), &ball, T).is_none(),
"the whole meridian has no single chart image"
);
for (lo, hi) in [(0.0, half), (half, 2.0 * half), (0.3, half - 0.1)] {
let pcurve = exact_pcurve_over(circle, (lo, hi), &ball, T)
.expect("half a meridian has an exact pcurve");
assert!(
matches!(pcurve, PlanarCurve::Line(_)),
"and it is a straight line in the chart"
);
for i in 0..=16 {
let t = (hi - lo).mul_add(f64::from(i) / 16.0, lo);
let want = circle.point_at(t, T).unwrap();
let uv = pcurve.point_at(t, T).unwrap();
assert!(
uv.y >= -half.mul_add(0.5, 1e-12) && uv.y <= half.mul_add(0.5, 1e-12),
"the latitude stays inside the chart: {}",
uv.y
);
let lifted = ball
.point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
.unwrap();
assert!(
want.distance(lifted) < 1e-9,
"azimuth {azimuth}, t {t}: {lifted:?} against {want:?}"
);
}
}
assert!(
exact_pcurve_over(circle, (half - 0.2, half + 0.2), &ball, T).is_none(),
"a range across a pole has no one line"
);
let _ = s;
}
}
}
#[test]
fn a_trimmed_curve_carries_its_basis_pcurve_trimmed_the_same_way() {
use ogeom_geom::TrimmedCurve;
let drum = cylinder(Vector::Z, 2.0);
let ground = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
let SurfaceIntersection::Along(curves) =
intersect_surfaces(&drum, &ground, IntersectOptions::default(), T).unwrap()
else {
panic!("a plane across a cylinder meets it in a circle");
};
let whole = curves[0].curve.clone();
let (lo, hi) = whole.domain();
let quarter: Curve = TrimmedCurve::new(whole.clone(), lo + 0.3, lo + (hi - lo) / 4.0, T)
.unwrap()
.into();
for surface in [&drum, &ground] {
let full = exact_pcurve_of(&whole, surface, T).expect("the whole circle has one");
let part = exact_pcurve_of(&quarter, surface, T).expect("and so does a quarter of it");
let (a, b) = quarter.domain();
for i in 0..=8 {
let t = (b - a).mul_add(f64::from(i) / 8.0, a);
let (whole_at, part_at) =
(full.point_at(t, T).unwrap(), part.point_at(t, T).unwrap());
assert!(
whole_at.distance(part_at) < 1e-12,
"the trim carries the basis: {whole_at:?} against {part_at:?}"
);
let lifted = surface
.point_at(part_at.x.rem_euclid(core::f64::consts::TAU), part_at.y, T)
.or_else(|_| surface.point_at(part_at.x, part_at.y, T))
.unwrap();
assert!(
lifted.distance(quarter.point_at(t, T).unwrap()) < 1e-9,
"same-parameter, still"
);
}
}
}
#[test]
fn a_far_stated_ruling_reads_its_angle_on_the_used_nappe() {
use ogeom_geom::ConeSurface;
let cone =
ogeom_math::Cone::new(Frame::WORLD, 24.0, core::f64::consts::FRAC_PI_4, T).unwrap();
let surface: SurfaceGeometry = ConeSurface::new(cone, (-1e5, 1e5)).unwrap().into();
let u_true = 0.01_f64;
let radial = Vector::new(u_true.cos(), u_true.sin(), 0.0);
let direction =
Direction::new((radial + Vector::new(0.0, 0.0, 1.0)) / 2f64.sqrt(), T).unwrap();
let far = -7.0e5;
let origin = Point::ORIGIN + radial * 24.0 + direction.vector() * far;
let line = ogeom_geom::LineCurve::over(
ogeom_math::Axis::new(origin, direction),
far.abs() - 1.0,
far.abs() + 1.0,
)
.unwrap();
let curve: Curve = line.into();
let range = ogeom_geom::Curve3d::domain(&curve);
let pcurve = exact_pcurve_over(&curve, range, &surface, T).expect("a ruling inverts");
let at = pcurve.point_at(range.0, T).unwrap();
let tau = core::f64::consts::TAU;
let gap = (at.x - u_true)
.rem_euclid(tau)
.min(tau - (at.x - u_true).rem_euclid(tau));
assert!(
gap < 1e-6,
"the ruling's chart angle must be the used side's: got u {} against {u_true}",
at.x
);
}
}