feat(fillet): add polyline fillet option

This commit is contained in:
gianlucafiore committed 2026-09-07 19:46:43 -03:00
1 parent cbfdcd9ecd
commit 4e46deee49
1 file changed
+256 -12
+256 -12
View File
@@ -248,10 +248,6 @@ fn compute_fillet(
[-u2[0], -u2[1]]
};
// Angle between the two keep-directions
let cos_a = (dir1[0] * dir2[0] + dir1[1] * dir2[1]).clamp(-1.0, 1.0);
let angle = cos_a.acos();
let z = l1.start.z;
@@ -458,7 +454,194 @@ fn compute_bulge(t1: [f64; 2], t2: [f64; 2], arc_center: [f64; 2]) -> f64 {
-(span_cw / 4.0).tan() // CW arc
}
}
#[derive(Clone, Copy)]
struct PolylineCornerFillet {
incoming: [f64; 2],
outgoing: [f64; 2],
bulge: f64,
}
fn segment_parameter(point: [f64; 2], start: [f64; 2], end: [f64; 2]) -> Option<f64> {
let dx = end[0] - start[0];
let dy = end[1] - start[1];
let len2 = dx * dx + dy * dy;
if len2 <= 1.0e-20 {
return None;
}
Some(
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / len2,
)
}
/// Apply the current FILLET radius to every eligible corner of a 2D polyline.
///
/// Initial scope deliberately handles straight-segment corners only. Corners
/// adjacent to an existing bulge arc are preserved unchanged.
fn fillet_entire_lwpolyline(poly: &LwPolyline, radius: f64) -> Option<LwPolyline> {
let n = poly.vertices.len();
if n < 3 || radius <= 1.0e-9 {
return None;
}
let mut corners: Vec<Option<PolylineCornerFillet>> = vec![None; n];
let first_corner = if poly.is_closed { 0 } else { 1 };
let end_corner = if poly.is_closed {
n
} else {
n.saturating_sub(1)
};
for i in first_corner..end_corner {
let prev = if i == 0 { n - 1 } else { i - 1 };
// A bulge belongs to the segment starting at that vertex.
// For now only fillet corners where both adjacent segments are straight.
if poly.vertices[prev].bulge.abs() >= 1.0e-9
|| poly.vertices[i].bulge.abs() >= 1.0e-9
{
continue;
}
let incoming = lwpoly_seg_as_line(poly, prev);
let outgoing = lwpoly_seg_as_line(poly, i);
// Pick points halfway along each segment, away from the corner.
// This tells the existing line-line FILLET code which side to keep.
let click_in = [
(incoming.start.x + incoming.end.x) * 0.5,
(incoming.start.y + incoming.end.y) * 0.5,
];
let click_out = [
(outgoing.start.x + outgoing.end.x) * 0.5,
(outgoing.start.y + outgoing.end.y) * 0.5,
];
let Some((trimmed_in, trimmed_out, maybe_arc)) =
compute_fillet(&incoming, click_in, &outgoing, click_out, radius)
else {
continue;
};
let (
EntityType::Line(trimmed_in),
EntityType::Line(trimmed_out),
Some(EntityType::Arc(arc)),
) = (trimmed_in, trimmed_out, maybe_arc)
else {
continue;
};
let tangent_in = [trimmed_in.end.x, trimmed_in.end.y];
let tangent_out = [trimmed_out.start.x, trimmed_out.start.y];
let in_start = [incoming.start.x, incoming.start.y];
let in_end = [incoming.end.x, incoming.end.y];
let out_start = [outgoing.start.x, outgoing.start.y];
let out_end = [outgoing.end.x, outgoing.end.y];
let Some(t_in) = segment_parameter(tangent_in, in_start, in_end) else {
continue;
};
let Some(t_out) = segment_parameter(tangent_out, out_start, out_end) else {
continue;
};
// Whole-polyline FILLET should not extend past neighbouring vertices.
// If this radius cannot fit at this corner, leave that corner unchanged.
if !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_in)
|| !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_out)
{
continue;
}
corners[i] = Some(PolylineCornerFillet {
incoming: tangent_in,
outgoing: tangent_out,
bulge: compute_bulge(
tangent_in,
tangent_out,
[arc.center.x, arc.center.y],
),
});
}
if corners.iter().all(Option::is_none) {
return None;
}
// Adjacent fillets must not consume more than the shared segment.
// Reject the operation rather than producing an inverted/overlapping edge.
let segment_count = if poly.is_closed { n } else { n - 1 };
for i in 0..segment_count {
let next = (i + 1) % n;
let start = corners[i]
.map(|corner| corner.outgoing)
.unwrap_or([
poly.vertices[i].location.x,
poly.vertices[i].location.y,
]);
let end = corners[next]
.map(|corner| corner.incoming)
.unwrap_or([
poly.vertices[next].location.x,
poly.vertices[next].location.y,
]);
let original_start = [
poly.vertices[i].location.x,
poly.vertices[i].location.y,
];
let original_end = [
poly.vertices[next].location.x,
poly.vertices[next].location.y,
];
let Some(start_t) = segment_parameter(start, original_start, original_end) else {
continue;
};
let Some(end_t) = segment_parameter(end, original_start, original_end) else {
continue;
};
if start_t > end_t + 1.0e-9 {
return None;
}
}
let mut result = poly.clone();
result.common.handle = Handle::NULL;
result.vertices.clear();
for (i, original) in poly.vertices.iter().enumerate() {
if let Some(corner) = corners[i] {
let mut tangent_in = original.clone();
tangent_in.location.x = corner.incoming[0];
tangent_in.location.y = corner.incoming[1];
tangent_in.bulge = corner.bulge;
let mut tangent_out = original.clone();
tangent_out.location.x = corner.outgoing[0];
tangent_out.location.y = corner.outgoing[1];
// This corner is only eligible when its outgoing segment is straight.
tangent_out.bulge = 0.0;
result.vertices.push(tangent_in);
result.vertices.push(tangent_out);
} else {
result.vertices.push(original.clone());
}
}
Some(result)
}
/// Rebuild a LwPolyline, replacing the corner vertex at `corner_idx` with two
/// new vertices T1 (start of fillet arc) and T2 (end of fillet arc).
/// `bulge` encodes the direction and span of the arc (T1 → T2).
@@ -1112,6 +1295,7 @@ fn compute_chamfer(
enum FilletStep {
First,
Polyline,
WaitingForRadius,
Second {
h1: Handle,
@@ -1193,7 +1377,12 @@ impl CadCommand for FilletCommand {
fn prompt(&self) -> String {
match &self.step {
FilletStep::First => crate::tf!(
"FILLET Select first object (Line/Arc/LwPolyline) [R={:.4}]:",
"FILLET Select first object or [Polyline/Radius] [R={:.4}]:",
self.radius
)
.into_owned(),
FilletStep::Polyline => crate::tf!(
"FILLET Select 2D polyline [R={:.4}]:",
self.radius
)
.into_owned(),
@@ -1212,11 +1401,16 @@ impl CadCommand for FilletCommand {
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
// Offer the radius keyword while selecting objects; the "R" token is
// already handled by on_text_input.
FilletStep::First | FilletStep::Second { .. } => vec![CmdOption::new("Radius", "R")],
FilletStep::WaitingForRadius => vec![],
FilletStep::First => vec![
CmdOption::new("Polyline", "P"),
CmdOption::new("Radius", "R"),
],
FilletStep::Second { .. } => {
vec![CmdOption::new("Radius", "R")]
}
FilletStep::Polyline | FilletStep::WaitingForRadius => vec![],
}
}
@@ -1255,6 +1449,10 @@ impl CadCommand for FilletCommand {
FilletStep::First | FilletStep::Second { .. } => {
let t = text.trim();
let upper = t.to_uppercase();
if matches!(self.step, FilletStep::First) && upper == "P" {
self.step = FilletStep::Polyline;
return Some(CmdResult::NeedPoint);
}
// "R" alone → enter sub-step to collect radius
if upper == "R" {
self.enter_radius_substep();
@@ -1277,6 +1475,7 @@ impl CadCommand for FilletCommand {
}
None
}
FilletStep::Polyline => None,
}
}
@@ -1292,6 +1491,28 @@ impl CadCommand for FilletCommand {
match &self.step {
FilletStep::WaitingForRadius => return CmdResult::NeedPoint,
FilletStep::Polyline => {
let poly = self
.entity_index
.get(&self.all_entities, handle)
.and_then(|entity| match entity {
EntityType::LwPolyline(poly) => Some(poly.clone()),
_ => None,
});
let Some(poly) = poly else {
return CmdResult::NeedPoint;
};
let Some(result) = fillet_entire_lwpolyline(&poly, self.radius) else {
return CmdResult::NeedPoint;
};
self.continue_after_fillet(vec![(
handle,
vec![EntityType::LwPolyline(result)],
)])
}
FilletStep::First => {
let e1 = self
.entity_index
@@ -1366,6 +1587,28 @@ impl CadCommand for FilletCommand {
match &self.step {
FilletStep::WaitingForRadius => vec![],
FilletStep::Polyline => {
let preview = self
.entity_index
.get(&self.all_entities, handle)
.and_then(|entity| match entity {
EntityType::LwPolyline(poly) => {
fillet_entire_lwpolyline(poly, self.radius)
}
_ => None,
});
preview
.map(|poly| {
vec![WireModel::solid(
"fillet_polyline_preview".into(),
lwpoly_pts(&poly),
WireModel::CYAN,
false,
)]
})
.unwrap_or_default()
}
FilletStep::First => {
let pts = self
.entity_index
@@ -1636,12 +1879,13 @@ impl CadCommand for ChamferCommand {
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
// Offer the distance keyword while selecting objects; the "D" token
// is already handled by on_text_input.
ChamferStep::First
| ChamferStep::Second { .. }
| ChamferStep::SecondPoly { .. } => vec![CmdOption::new(t!("Distance").as_ref(), "D")],
| ChamferStep::SecondPoly { .. } => {
vec![CmdOption::new(t!("Distance").as_ref(), "D")]
}
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![],
}
}