fix(circle): use kernel tangent solutions

Route tangent-tangent-radius construction through cadkernel so enclosing and external tangency share one geometry implementation.

Refs #814
This commit is contained in:
Hakan Seven 2026-08-20 15:20:07 +03:00
commit 4d2c2dbd0f
3 changed files with 77 additions and 146 deletions

2
Cargo.lock generated
View file

@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=2af87ad#2af87adf3e3234fd9e8df1bfe01faf7e2b6333d1"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=eda1263#eda1263da23c1b18e7593151a277a0593bb317ef"
dependencies = [
"acadrust",
"cavalier_contours",

View file

@ -28,7 +28,7 @@ rfd = "0.17"
clap = { version = "4", features = ["derive"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "2af87ad", features = ["acis", "offset"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "eda1263", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }

View file

@ -10,6 +10,10 @@
use acadrust::types::Vector3;
use acadrust::{Circle, EntityType};
use cadkernel::geom2d::{
fillets_between, Circle as KernelCircle, Curve as KernelCurve, Line as KernelLine,
Tolerance,
};
use crate::t;
use crate::command::{CadCommand, CmdResult, DynField, TangentObject, WorkingPlane};
@ -544,16 +548,6 @@ impl Line2D {
}
}
fn line_line_isect(l1: Line2D, l2: Line2D) -> Option<DVec3> {
let det = l1.a * l2.b - l2.a * l1.b;
if det.abs() < 1e-9 {
return None;
}
let x = (-l1.c * l2.b + l2.c * l1.b) / det;
let y = (-l1.a * l2.c + l2.a * l1.c) / det;
Some(DVec3::new(x, y, 0.0))
}
fn solve_quadratic(a: f64, b: f64, c: f64) -> Vec<f64> {
if a.abs() < 1e-9 {
if b.abs() < 1e-9 {
@ -589,143 +583,26 @@ fn best_circle_of(candidates: &[(DVec3, f64)], hint: DVec3) -> Option<(DVec3, f6
})
}
/// All candidate circle centers tangent to two objects with radius r.
fn ttr_candidates(obj1: TangentObject, obj2: TangentObject, r: f64) -> Vec<DVec3> {
match (obj1, obj2) {
(TangentObject::Line { p1: a, p2: b }, TangentObject::Line { p1: c, p2: d }) => {
let l1 = Line2D::from_obj(a, b);
let l2 = Line2D::from_obj(c, d);
let mut out = Vec::new();
for s1 in [-r, r] {
for s2 in [-r, r] {
let ol1 = Line2D { c: l1.c + s1, ..l1 };
let ol2 = Line2D { c: l2.c + s2, ..l2 };
if let Some(pt) = line_line_isect(ol1, ol2) {
out.push(pt);
}
}
}
out
}
(
TangentObject::Line { p1, p2 },
TangentObject::Circle {
center: cp,
radius: cr,
},
)
| (
TangentObject::Circle {
center: cp,
radius: cr,
},
TangentObject::Line { p1, p2 },
) => {
let l = Line2D::from_obj(p1, p2);
ttr_lc_candidates(l, cp, cr, r)
}
(
TangentObject::Circle {
center: c1,
radius: r1,
},
TangentObject::Circle {
center: c2,
radius: r2,
},
) => ttr_cc_candidates(c1, r1, c2, r2, r),
fn tangent_curve(object: TangentObject) -> KernelCurve {
match object {
TangentObject::Line { p1, p2 } => KernelCurve::Line(KernelLine {
start: [p1.x, p1.y],
end: [p2.x, p2.y],
}),
TangentObject::Circle { center, radius } => KernelCurve::Circle(KernelCircle {
centre: [center.x, center.y],
radius,
}),
}
}
fn ttr_lc_candidates(l: Line2D, cp: DVec3, cr: f64, r: f64) -> Vec<DVec3> {
let mut out = Vec::new();
for s1 in [-1.0f64, 1.0] {
for s2 in [-1.0f64, 1.0] {
let rho = cr + s2 * r;
if rho < 0.0 {
continue;
}
let c_off = l.c + s1 * r;
// The candidate center (cx, cy) satisfies:
// l.a*cx + l.b*cy + c_off = 0
// (cx - cp.x)^2 + (cy - cp.y)^2 = rho^2
// Parameterise cx = -l.b*t - l.a*c_off, cy = l.a*t - l.b*c_off
// where t is the free parameter along the offset line.
// Substituting into the circle equation:
// (-l.b*t - l.a*c_off - cp.x)^2 + (l.a*t - l.b*c_off - cp.y)^2 = rho^2
// Let alpha = l.a*c_off + cp.x, beta = l.b*c_off + cp.y (using sign convention)
// Actually: u = -l.a*c_off - cp.x, v = -l.b*c_off - cp.y
let u = -(l.a * c_off) - cp.x;
let v = -(l.b * c_off) - cp.y;
// (-l.b*t + u)^2 + (l.a*t + v)^2 = rho^2
// (l.b^2 + l.a^2)*t^2 + 2*(-l.b*u + l.a*v)*t + (u^2 + v^2 - rho^2) = 0
// Since a^2 + b^2 = 1:
let qa = 1.0f64;
let qb = 2.0 * (-l.b * u + l.a * v);
let qc = u * u + v * v - rho * rho;
for t in solve_quadratic(qa, qb, qc) {
let cx = -l.b * t - l.a * c_off;
let cy = l.a * t - l.b * c_off;
out.push(DVec3::new(cx, cy, 0.0));
}
}
}
out
}
fn ttr_cc_candidates(c1: DVec3, r1: f64, c2: DVec3, r2: f64, r: f64) -> Vec<DVec3> {
let mut out = Vec::new();
for s1 in [-1.0f64, 1.0] {
for s2 in [-1.0f64, 1.0] {
let rho1 = r1 + s1 * r;
let rho2 = r2 + s2 * r;
if rho1 < 0.0 || rho2 < 0.0 {
continue;
}
let ax = c2.x - c1.x;
let ay = c2.y - c1.y;
let k = 0.5
* (rho1 * rho1 - rho2 * rho2 + c2.x * c2.x + c2.y * c2.y
- c1.x * c1.x
- c1.y * c1.y);
let a2 = ax * ax + ay * ay;
if a2 < 1e-12 {
continue;
}
if ay.abs() >= ax.abs() {
let iy = 1.0 / ay;
let qa = 1.0 + ax * ax * iy * iy;
let p = k * iy - c1.y;
let qb = -2.0 * (c1.x + ax * iy * p);
let qc = c1.x * c1.x + p * p - rho1 * rho1;
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
continue;
}
for sign in [-1.0f64, 1.0] {
let cx = (-qb + sign * disc.sqrt()) / (2.0 * qa);
let cy = (k - ax * cx) * iy;
out.push(DVec3::new(cx, cy, 0.0));
}
} else {
let ix = 1.0 / ax;
let qa = ay * ay * ix * ix + 1.0;
let p = k * ix - c1.x;
let qb = -2.0 * (ay * ix * p + c1.y);
let qc = p * p + c1.y * c1.y - rho1 * rho1;
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
continue;
}
for sign in [-1.0f64, 1.0] {
let cy = (-qb + sign * disc.sqrt()) / (2.0 * qa);
let cx = (k - ay * cy) * ix;
out.push(DVec3::new(cx, cy, 0.0));
}
}
}
}
out
fn ttr_candidates(first: TangentObject, second: TangentObject, radius: f64) -> Vec<DVec3> {
let first = tangent_curve(first);
let second = tangent_curve(second);
fillets_between(&first, &second, radius, Tolerance::default())
.into_iter()
.map(|fillet| DVec3::new(fillet.centre[0], fillet.centre[1], 0.0))
.collect()
}
/// Unified TTT solver: circle tangent to three objects. Returns all (center, radius) candidates.
@ -1133,6 +1010,60 @@ inventory::submit!(crate::command::CommandRegistration { names: &["CIRCLE_TTT"]
mod tests {
use super::*;
fn has_candidate(cands: &[DVec3], want: DVec3) -> bool {
cands.iter().any(|c| c.distance(want) < 1e-6)
}
/// #814: include circles that enclose both tangent objects.
#[test]
fn ttr_two_circles_offers_the_enclosing_solution() {
let c1 = TangentObject::Circle {
center: DVec3::new(-3.0, 0.0, 0.0),
radius: 1.0,
};
let c2 = TangentObject::Circle {
center: DVec3::new(3.0, 0.0, 0.0),
radius: 1.0,
};
let cands = ttr_candidates(c1, c2, 5.0);
// |P - Ci| = 5 - 1 = 4 puts both small circles inside the new one.
let y = 7.0f64.sqrt();
assert!(
has_candidate(&cands, DVec3::new(0.0, y, 0.0))
&& has_candidate(&cands, DVec3::new(0.0, -y, 0.0)),
"no enclosing candidate, got {cands:?}"
);
// The externally tangent family is still produced.
assert!(
cands.iter().any(|c| {
(c.distance(DVec3::new(-3.0, 0.0, 0.0)) - 6.0).abs() < 1e-6
&& (c.distance(DVec3::new(3.0, 0.0, 0.0)) - 6.0).abs() < 1e-6
}),
"external tangency lost, got {cands:?}"
);
}
/// Include line-circle solutions that enclose the circle.
#[test]
fn ttr_line_and_circle_offers_the_enclosing_solution() {
let line = TangentObject::Line {
p1: DVec3::ZERO,
p2: DVec3::new(10.0, 0.0, 0.0),
};
let circle = TangentObject::Circle {
center: DVec3::new(0.0, 2.0, 0.0),
radius: 1.0,
};
let cands = ttr_candidates(line, circle, 5.0);
// Tangent to y = 0 at distance 5, and |P - (0,2)| = 5 - 1 = 4.
let x = 7.0f64.sqrt();
assert!(
has_candidate(&cands, DVec3::new(x, 5.0, 0.0))
&& has_candidate(&cands, DVec3::new(-x, 5.0, 0.0)),
"no enclosing candidate, got {cands:?}"
);
}
/// #318: concentric ring + a line through the center — the degenerate
/// circle-pair equation must still yield the annulus circle.
#[test]