forked from 1b-agent/opencadstudio-upstream
Fixes #1422 Co-Authored-By: Claude Opus 5.5 <[email protected]>
2535 lines
91 KiB
Rust
2535 lines
91 KiB
Rust
// Fillet / Chamfer — ribbon definitions + full command implementations.
|
||
//
|
||
// FILLET (F):
|
||
// Supports: Line-Line, Line-Arc, Arc-Line, Arc-Arc.
|
||
// Finds intersection, computes tangent arc of radius R, trims both entities.
|
||
// R=0 just extends/trims to the exact intersection (sharp corner).
|
||
//
|
||
// CHAMFER (CHA):
|
||
// Pick two lines (line-only; arcs are not chamferable).
|
||
// Finds intersection, backs off dist1 along line 1 and dist2 along line 2.
|
||
|
||
use codec::entities::{Arc as ArcEnt, Line as LineEnt, LwPolyline};
|
||
|
||
// Shared plane geometry, from opencadkernel via the local adapters.
|
||
use super::geom;
|
||
use super::geom::{arc_points as arc_pts, line_line as ll, normalize_angle as norm_angle};
|
||
use kernel::geom2d::{
|
||
circle_circle_points as circle_circle_pts, fillet_between_rays, fillets_between, line_circle,
|
||
Arc as KernelArc, Curve as KernelCurve, Fillet as KernelFillet,
|
||
Line as KernelLine, Tolerance,
|
||
};
|
||
use codec::entities::EntityCommon;
|
||
use codec::types::Vector3;
|
||
use codec::{EntityType, Handle};
|
||
use glam::DVec3;
|
||
use crate::t;
|
||
|
||
const TAU: f64 = std::f64::consts::TAU;
|
||
|
||
/// How close two positions must be to count as one while a fillet is being
|
||
/// placed. Loose enough to survive the rounding in a drawing's stored
|
||
/// coordinates, tight enough not to merge two distinct candidate centres.
|
||
const FILLET_TOLERANCE: f64 = 1.0e-9;
|
||
|
||
use crate::command::{CadCommand, CmdResult};
|
||
use crate::modules::draw::defaults;
|
||
use crate::modules::IconKind;
|
||
use crate::scene::model::wire_model::WireModel;
|
||
|
||
use super::entity_index::ModifyEntityIndex;
|
||
|
||
// ── Dropdown constants ─────────────────────────────────────────────────────
|
||
|
||
pub const DROPDOWN_ID: &str = "fillet_chamfer";
|
||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/fillet.svg"));
|
||
|
||
pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[
|
||
(
|
||
"FILLET",
|
||
"Fillet",
|
||
IconKind::Svg(include_bytes!("../../../../assets/icons/fillet.svg")),
|
||
),
|
||
(
|
||
"CHAMFER",
|
||
"Chamfer",
|
||
IconKind::Svg(include_bytes!("../../../../assets/icons/chamfer.svg")),
|
||
),
|
||
];
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// Geometry
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// Extract coords and unit direction for a Line entity.
|
||
fn line_geom(l: &LineEnt) -> ([f64; 2], [f64; 2], [f64; 2], f64) {
|
||
let p1 = [l.start.x, l.start.y];
|
||
let p2 = [l.end.x, l.end.y];
|
||
let dx = p2[0] - p1[0];
|
||
let dy = p2[1] - p1[1];
|
||
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
|
||
(p1, p2, [dx / len, dy / len], len)
|
||
}
|
||
|
||
/// Project click onto line, returning t ∈ ℝ.
|
||
fn project_click(click: [f64; 2], p1: [f64; 2], unit: [f64; 2]) -> f64 {
|
||
(click[0] - p1[0]) * unit[0] + (click[1] - p1[1]) * unit[1]
|
||
}
|
||
|
||
// ── Fillet ─────────────────────────────────────────────────────────────────
|
||
/// Fillet two parallel lines with a semicircle tangent to both.
|
||
///
|
||
/// The current FILLET radius is intentionally ignored for parallel lines:
|
||
/// the effective radius is half the perpendicular distance between them.
|
||
///
|
||
/// The endpoint of the first line nearest its pick determines where the
|
||
/// semicircle is placed. The second line is trimmed or extended to the
|
||
/// corresponding perpendicular tangent point.
|
||
fn fillet_parallel_lines(
|
||
l1: &LineEnt,
|
||
click1: [f64; 2],
|
||
l2: &LineEnt,
|
||
click2: [f64; 2],
|
||
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
|
||
let (p1, p2, u1, len1) = line_geom(l1);
|
||
let (p3, _p4, u2, len2) = line_geom(l2);
|
||
|
||
if len1 <= 1.0e-12 || len2 <= 1.0e-12 {
|
||
return None;
|
||
}
|
||
|
||
// This helper is only for parallel / anti-parallel lines.
|
||
let cross = u1[0] * u2[1] - u1[1] * u2[0];
|
||
if cross.abs() > 1.0e-6 {
|
||
return None;
|
||
}
|
||
|
||
// The pick on the first line determines which end receives the round.
|
||
let dist_sq = |a: [f64; 2], b: [f64; 2]| {
|
||
let dx = a[0] - b[0];
|
||
let dy = a[1] - b[1];
|
||
dx * dx + dy * dy
|
||
};
|
||
|
||
let (tangent1, other1) = if dist_sq(click1, p1) <= dist_sq(click1, p2) {
|
||
(p1, p2)
|
||
} else {
|
||
(p2, p1)
|
||
};
|
||
|
||
// Unit normal to line 1. Project tangent1 perpendicularly onto line 2.
|
||
let normal = [-u1[1], u1[0]];
|
||
let separation =
|
||
(p3[0] - tangent1[0]) * normal[0] + (p3[1] - tangent1[1]) * normal[1];
|
||
|
||
if separation.abs() <= 1.0e-9 {
|
||
// Coincident lines do not define a useful semicircle.
|
||
return None;
|
||
}
|
||
|
||
let tangent2 = [
|
||
tangent1[0] + normal[0] * separation,
|
||
tangent1[1] + normal[1] * separation,
|
||
];
|
||
|
||
let centre = [
|
||
(tangent1[0] + tangent2[0]) * 0.5,
|
||
(tangent1[1] + tangent2[1]) * 0.5,
|
||
];
|
||
|
||
let radius = separation.abs() * 0.5;
|
||
|
||
// Keep the existing part of each selected line and trim/extend the picked
|
||
// end to its tangent point.
|
||
let new_l1 = trim_line_to_point(l1, tangent1, click1)?;
|
||
let new_l2 = trim_line_to_point(l2, tangent2, click2)?;
|
||
|
||
// The semicircle must bulge away from the retained portion of line 1.
|
||
// `other1 - tangent1` points back into the line, so negate it.
|
||
let keep = [
|
||
other1[0] - tangent1[0],
|
||
other1[1] - tangent1[1],
|
||
];
|
||
let keep_len = (keep[0] * keep[0] + keep[1] * keep[1]).sqrt();
|
||
|
||
if keep_len <= 1.0e-12 {
|
||
return None;
|
||
}
|
||
|
||
let bulge_dir = [-keep[0] / keep_len, -keep[1] / keep_len];
|
||
|
||
let a1 = norm_angle(
|
||
(tangent1[1] - centre[1]).atan2(tangent1[0] - centre[0]),
|
||
);
|
||
let a2 = norm_angle(
|
||
(tangent2[1] - centre[1]).atan2(tangent2[0] - centre[0]),
|
||
);
|
||
|
||
// Between two antipodal points there are two possible semicircles.
|
||
// Determine which CCW orientation bulges toward the selected end.
|
||
let midpoint_angle = a1 + std::f64::consts::FRAC_PI_2;
|
||
let midpoint_dir = [midpoint_angle.cos(), midpoint_angle.sin()];
|
||
|
||
let candidate_matches =
|
||
midpoint_dir[0] * bulge_dir[0] + midpoint_dir[1] * bulge_dir[1] >= 0.0;
|
||
|
||
let (start_angle, end_angle) = if candidate_matches {
|
||
(a1, a2)
|
||
} else {
|
||
(a2, a1)
|
||
};
|
||
|
||
let mut arc = ArcEnt::new();
|
||
arc.common = l1.common.clone();
|
||
arc.common.handle = Handle::NULL;
|
||
arc.center = Vector3::new(centre[0], centre[1], l1.start.z);
|
||
arc.radius = radius;
|
||
arc.start_angle = start_angle;
|
||
arc.end_angle = end_angle;
|
||
|
||
Some((
|
||
EntityType::Line(new_l1),
|
||
EntityType::Line(new_l2),
|
||
Some(EntityType::Arc(arc)),
|
||
))
|
||
}
|
||
/// Compute fillet: trim l1/l2 and insert a tangent arc of `radius`.
|
||
/// Returns (trimmed_l1, trimmed_l2, fillet_arc).
|
||
fn compute_fillet(
|
||
l1: &LineEnt,
|
||
click1: [f64; 2],
|
||
l2: &LineEnt,
|
||
click2: [f64; 2],
|
||
radius: f64,
|
||
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
|
||
let (p1, p2, u1, _len1) = line_geom(l1);
|
||
let (p3, p4, u2, _len2) = line_geom(l2);
|
||
|
||
// Parallel lines are a special FILLET case: there is no intersection.
|
||
// Build the tangent semicircle directly, temporarily ignoring the
|
||
// configured radius.
|
||
let cross = u1[0] * u2[1] - u1[1] * u2[0];
|
||
|
||
if cross.abs() <= 1.0e-6 {
|
||
return fillet_parallel_lines(l1, click1, l2, click2);
|
||
}
|
||
|
||
// Intersection of infinite non-parallel lines.
|
||
let (t_p, _u_p) =
|
||
ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?;
|
||
|
||
// Intersection point
|
||
let px = p1[0] + t_p * u1[0];
|
||
let py = p1[1] + t_p * u1[1];
|
||
|
||
// Direction from P toward each click (the "keep" side)
|
||
let picked_side = |click: [f64; 2], a: [f64; 2], b: [f64; 2], unit: [f64; 2]| {
|
||
let picked = project_click(click, [px, py], unit);
|
||
if picked.abs() > 1e-9 {
|
||
picked
|
||
} else {
|
||
// Clicking exactly on the intersection is common when the two
|
||
// entities already meet. Fall back to the existing segment's
|
||
// midpoint so entity start/end storage order cannot flip the side
|
||
// FILLET keeps (#616).
|
||
project_click([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5], [px, py], unit)
|
||
}
|
||
};
|
||
let s1 = picked_side(click1, p1, p2, u1); // positive = along u1
|
||
let s2 = picked_side(click2, p3, p4, u2);
|
||
let dir1 = if s1 >= 0.0 {
|
||
[u1[0], u1[1]]
|
||
} else {
|
||
[-u1[0], -u1[1]]
|
||
};
|
||
let dir2 = if s2 >= 0.0 {
|
||
[u2[0], u2[1]]
|
||
} else {
|
||
[-u2[0], -u2[1]]
|
||
};
|
||
|
||
|
||
let z = l1.start.z;
|
||
|
||
if radius < 1e-9 {
|
||
// r = 0: extend/trim the endpoint opposite each clicked keep-side.
|
||
// The old path chose by the sign of t_p/u_p, so reversing an entity's
|
||
// stored direction changed the result and an intersection at its start
|
||
// could collapse the whole line (#616).
|
||
let intersection = [px, py];
|
||
let new_l1 = trim_to_xy(l1, intersection, dir1, u1)?;
|
||
let new_l2 = trim_to_xy(l2, intersection, dir2, u2)?;
|
||
return Some((EntityType::Line(new_l1), EntityType::Line(new_l2), None));
|
||
}
|
||
|
||
let rounded = fillet_between_rays([px, py], dir1, dir2, radius)?;
|
||
|
||
// Trim each line back to where the arc meets it.
|
||
let new_l1 = trim_to_xy(l1, rounded.tangent1, dir1, u1)?;
|
||
let new_l2 = trim_to_xy(l2, rounded.tangent2, dir2, u2)?;
|
||
|
||
let mut arc = ArcEnt::new();
|
||
arc.common = l1.common.clone();
|
||
arc.common.handle = Handle::NULL;
|
||
arc.center = Vector3::new(rounded.centre[0], rounded.centre[1], z);
|
||
arc.radius = radius;
|
||
arc.start_angle = rounded.start_angle;
|
||
arc.end_angle = rounded.end_angle;
|
||
|
||
Some((
|
||
EntityType::Line(new_l1),
|
||
EntityType::Line(new_l2),
|
||
Some(EntityType::Arc(arc)),
|
||
))
|
||
}
|
||
|
||
/// Move the endpoint opposite the selected keep-side to the tangent point.
|
||
fn trim_to_xy(
|
||
orig: &LineEnt,
|
||
tangent: [f64; 2],
|
||
dir: [f64; 2],
|
||
unit: [f64; 2],
|
||
) -> Option<LineEnt> {
|
||
let z = orig.start.z;
|
||
let mut l = orig.clone();
|
||
l.common.handle = Handle::NULL;
|
||
|
||
// dir is positive along unit → keep the portion beyond the tangent in that direction
|
||
// dir positive: keep from t_tan to +∞ (i.e. set start to tangent point)
|
||
// dir negative: keep from -∞ to t_tan (i.e. set end to tangent point)
|
||
let dot = dir[0] * unit[0] + dir[1] * unit[1]; // +1 or -1
|
||
|
||
if dot > 0.0 {
|
||
// keep from tangent to end → move start to tangent point
|
||
l.start = Vector3::new(tangent[0], tangent[1], z);
|
||
} else {
|
||
// keep from start to tangent → move end to tangent point
|
||
l.end = Vector3::new(tangent[0], tangent[1], z);
|
||
}
|
||
Some(l)
|
||
}
|
||
|
||
// ── Point-generation helpers ──────────────────────────────────────────────
|
||
|
||
fn line_pts(l: &LineEnt) -> Vec<[f32; 3]> {
|
||
geom::line_points(
|
||
[l.start.x, l.start.y, l.start.z],
|
||
[l.end.x, l.end.y, l.end.z],
|
||
)
|
||
}
|
||
|
||
fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> {
|
||
match e {
|
||
EntityType::Line(l) => line_pts(l),
|
||
EntityType::Arc(a) => arc_pts(
|
||
a.center.x,
|
||
a.center.y,
|
||
a.radius,
|
||
a.start_angle,
|
||
a.end_angle,
|
||
a.center.z,
|
||
),
|
||
EntityType::LwPolyline(p) => lwpoly_pts(p),
|
||
_ => vec![],
|
||
}
|
||
}
|
||
|
||
// ── Arc geometry helpers ───────────────────────────────────────────────────
|
||
|
||
/// Extract center, radius, start/end angle (radians), elevation from an arc.
|
||
fn arc_geom(a: &ArcEnt) -> ([f64; 2], f64, f64, f64, f64) {
|
||
(
|
||
[a.center.x, a.center.y],
|
||
a.radius,
|
||
a.start_angle,
|
||
a.end_angle,
|
||
a.center.z,
|
||
)
|
||
}
|
||
|
||
/// Return the CCW angular span from `start` to `end`.
|
||
fn arc_span(start: f64, end: f64) -> f64 {
|
||
let s = (end - start).rem_euclid(TAU);
|
||
if s < 1e-6 {
|
||
TAU
|
||
} else {
|
||
s
|
||
}
|
||
}
|
||
|
||
/// Project a pick point onto an arc: return the angle in radians.
|
||
fn arc_angle_at(center: [f64; 2], pt: [f64; 2]) -> f64 {
|
||
norm_angle((pt[1] - center[1]).atan2(pt[0] - center[0]))
|
||
}
|
||
|
||
/// Clamp angle `a` into the arc range (CCW from `start` to `end`).
|
||
/// Returns the nearer endpoint if `a` is outside.
|
||
fn clamp_angle_to_arc(a: f64, start: f64, end: f64) -> f64 {
|
||
let span = arc_span(start, end);
|
||
let rel = (a - start).rem_euclid(TAU);
|
||
if rel <= span {
|
||
a
|
||
} else if rel < span + (TAU - span) / 2.0 {
|
||
end
|
||
} else {
|
||
start
|
||
}
|
||
}
|
||
|
||
/// Trim an arc so it goes from `new_start` to `new_end` (both in radians).
|
||
fn trim_arc(orig: &ArcEnt, new_start: f64, new_end: f64) -> ArcEnt {
|
||
let mut a = orig.clone();
|
||
a.common.handle = Handle::NULL;
|
||
a.start_angle = norm_angle(new_start);
|
||
a.end_angle = norm_angle(new_end);
|
||
a
|
||
}
|
||
|
||
// ── LwPolyline helpers ────────────────────────────────────────────────────
|
||
|
||
/// Find the index of the LwPolyline segment nearest to `click` (DXF XY).
|
||
fn lwpoly_nearest_seg(poly: &LwPolyline, click: [f64; 2]) -> usize {
|
||
let n = poly.vertices.len();
|
||
let seg_count = if poly.is_closed {
|
||
n
|
||
} else {
|
||
n.saturating_sub(1)
|
||
};
|
||
let mut best_idx = 0;
|
||
let mut best_dist = f64::MAX;
|
||
for i in 0..seg_count {
|
||
let v0 = &poly.vertices[i];
|
||
let v1 = &poly.vertices[(i + 1) % n];
|
||
let px = v0.location.x;
|
||
let py = v0.location.y;
|
||
let dx = v1.location.x - px;
|
||
let dy = v1.location.y - py;
|
||
let len2 = dx * dx + dy * dy;
|
||
let t = if len2 < 1e-24 {
|
||
0.0
|
||
} else {
|
||
((click[0] - px) * dx + (click[1] - py) * dy) / len2
|
||
}
|
||
.clamp(0.0, 1.0);
|
||
let cx = px + t * dx - click[0];
|
||
let cy = py + t * dy - click[1];
|
||
let dist = cx * cx + cy * cy;
|
||
if dist < best_dist {
|
||
best_dist = dist;
|
||
best_idx = i;
|
||
}
|
||
}
|
||
best_idx
|
||
}
|
||
|
||
/// Extract a LwPolyline segment as a virtual `LineEnt` (ignores bulge).
|
||
fn lwpoly_seg_as_line(poly: &LwPolyline, seg_idx: usize) -> LineEnt {
|
||
let n = poly.vertices.len();
|
||
let v0 = &poly.vertices[seg_idx];
|
||
let v1 = &poly.vertices[(seg_idx + 1) % n];
|
||
let mut l = LineEnt::new();
|
||
l.common = poly.common.clone();
|
||
l.common.handle = Handle::NULL;
|
||
l.start = Vector3::new(v0.location.x, v0.location.y, poly.elevation);
|
||
l.end = Vector3::new(v1.location.x, v1.location.y, poly.elevation);
|
||
l
|
||
}
|
||
|
||
/// Compute the LwPolyline bulge for the arc from T1 to T2 whose center is `arc_center`.
|
||
/// Positive = CCW, negative = CW.
|
||
fn compute_bulge(t1: [f64; 2], t2: [f64; 2], arc_center: [f64; 2]) -> f64 {
|
||
let a1 = (t1[1] - arc_center[1]).atan2(t1[0] - arc_center[0]);
|
||
let a2 = (t2[1] - arc_center[1]).atan2(t2[0] - arc_center[0]);
|
||
// CCW angular span from T1 to T2
|
||
let span_ccw = ((a2 - a1) % TAU + TAU) % TAU;
|
||
// Determine whether the fillet arc goes CCW (center to left of T1→T2) or CW.
|
||
let chord = [t2[0] - t1[0], t2[1] - t1[1]];
|
||
let mid = [(t1[0] + t2[0]) * 0.5, (t1[1] + t2[1]) * 0.5];
|
||
let to_c = [arc_center[0] - mid[0], arc_center[1] - mid[1]];
|
||
let cross = chord[0] * to_c[1] - chord[1] * to_c[0];
|
||
if cross >= 0.0 {
|
||
(span_ccw / 4.0).tan() // CCW arc
|
||
} else {
|
||
let span_cw = TAU - span_ccw;
|
||
-(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).
|
||
fn lwpoly_replace_corner(
|
||
poly: &LwPolyline,
|
||
corner_idx: usize,
|
||
t1: [f64; 2],
|
||
t2: [f64; 2],
|
||
bulge: f64,
|
||
) -> LwPolyline {
|
||
let mut new_poly = poly.clone();
|
||
new_poly.common.handle = Handle::NULL;
|
||
let orig = poly.vertices[corner_idx].clone();
|
||
let mut vt1 = orig.clone();
|
||
vt1.location.x = t1[0];
|
||
vt1.location.y = t1[1];
|
||
vt1.bulge = bulge;
|
||
let mut vt2 = orig;
|
||
vt2.location.x = t2[0];
|
||
vt2.location.y = t2[1];
|
||
vt2.bulge = 0.0;
|
||
new_poly.vertices.remove(corner_idx);
|
||
new_poly.vertices.insert(corner_idx, vt2);
|
||
new_poly.vertices.insert(corner_idx, vt1);
|
||
new_poly
|
||
}
|
||
|
||
/// Rebuild a LwPolyline after shortening segment `seg_idx`:
|
||
/// `new_start` / `new_end` replace the segment's start/end vertex if `Some`.
|
||
fn lwpoly_shorten_seg(
|
||
poly: &LwPolyline,
|
||
seg_idx: usize,
|
||
new_start: Option<[f64; 2]>,
|
||
new_end: Option<[f64; 2]>,
|
||
) -> LwPolyline {
|
||
let n = poly.vertices.len();
|
||
let mut new_poly = poly.clone();
|
||
new_poly.common.handle = Handle::NULL;
|
||
if let Some([x, y]) = new_start {
|
||
new_poly.vertices[seg_idx].location.x = x;
|
||
new_poly.vertices[seg_idx].location.y = y;
|
||
new_poly.vertices[seg_idx].bulge = 0.0;
|
||
}
|
||
if let Some([x, y]) = new_end {
|
||
let end_idx = (seg_idx + 1) % n;
|
||
new_poly.vertices[end_idx].location.x = x;
|
||
new_poly.vertices[end_idx].location.y = y;
|
||
}
|
||
new_poly
|
||
}
|
||
|
||
/// Wire points for a LwPolyline (honours bulge → arcs, for preview/highlight).
|
||
fn lwpoly_pts(poly: &LwPolyline) -> Vec<[f32; 3]> {
|
||
let elev = poly.elevation as f32;
|
||
let n = poly.vertices.len();
|
||
let seg_count = if poly.is_closed {
|
||
n
|
||
} else {
|
||
n.saturating_sub(1)
|
||
};
|
||
let mut pts = Vec::with_capacity(seg_count * 2);
|
||
for i in 0..seg_count {
|
||
let v0 = &poly.vertices[i];
|
||
let v1 = &poly.vertices[(i + 1) % n];
|
||
lwpoly_seg_pts(
|
||
[v0.location.x, v0.location.y],
|
||
[v1.location.x, v1.location.y],
|
||
v0.bulge,
|
||
elev,
|
||
&mut pts,
|
||
);
|
||
}
|
||
pts
|
||
}
|
||
|
||
/// Wire points highlighting the polyline segment nearest to `click`.
|
||
fn lwpoly_seg_hover_pts(poly: &LwPolyline, click: [f64; 2]) -> Vec<[f32; 3]> {
|
||
let n = poly.vertices.len();
|
||
if n < 2 {
|
||
return vec![];
|
||
}
|
||
let seg = lwpoly_nearest_seg(poly, click);
|
||
let v0 = &poly.vertices[seg];
|
||
let v1 = &poly.vertices[(seg + 1) % n];
|
||
let mut pts = Vec::new();
|
||
lwpoly_seg_pts(
|
||
[v0.location.x, v0.location.y],
|
||
[v1.location.x, v1.location.y],
|
||
v0.bulge,
|
||
poly.elevation as f32,
|
||
&mut pts,
|
||
);
|
||
pts
|
||
}
|
||
|
||
/// Append the wire points of one polyline segment (straight, or a bulge arc).
|
||
fn lwpoly_seg_pts(p0: [f64; 2], p1: [f64; 2], bulge: f64, elev: f32, out: &mut Vec<[f32; 3]>) {
|
||
if let Some(arc) = (bulge.abs() >= 1e-9)
|
||
.then(|| crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge))
|
||
.flatten()
|
||
{
|
||
const STEPS: usize = 16;
|
||
for j in 0..STEPS {
|
||
let a = arc.sample(j as f64 / STEPS as f64);
|
||
let b = arc.sample((j + 1) as f64 / STEPS as f64);
|
||
out.push([a[0] as f32, a[1] as f32, elev]);
|
||
out.push([b[0] as f32, b[1] as f32, elev]);
|
||
}
|
||
} else {
|
||
out.push([p0[0] as f32, p0[1] as f32, elev]);
|
||
out.push([p1[0] as f32, p1[1] as f32, elev]);
|
||
}
|
||
}
|
||
|
||
// ── Unified fillet entity type ─────────────────────────────────────────────
|
||
|
||
/// A pickable entity for FILLET: Line, Arc, or an LwPolyline segment.
|
||
#[derive(Clone)]
|
||
enum FilletEntity {
|
||
Line(LineEnt),
|
||
Arc(ArcEnt),
|
||
/// A segment of an LwPolyline identified by its entity handle and segment index.
|
||
LwPoly {
|
||
poly: LwPolyline,
|
||
handle: Handle,
|
||
seg_idx: usize,
|
||
},
|
||
}
|
||
|
||
impl FilletEntity {
|
||
fn from_entity(e: &EntityType) -> Option<Self> {
|
||
match e {
|
||
EntityType::Line(l) => Some(Self::Line(l.clone())),
|
||
EntityType::Arc(a) => Some(Self::Arc(a.clone())),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn from_lwpoly(poly: &LwPolyline, handle: Handle, click: [f64; 2]) -> Self {
|
||
let seg_idx = lwpoly_nearest_seg(poly, click);
|
||
Self::LwPoly {
|
||
poly: poly.clone(),
|
||
handle,
|
||
seg_idx,
|
||
}
|
||
}
|
||
|
||
fn to_entity_type(&self) -> EntityType {
|
||
match self {
|
||
Self::Line(l) => EntityType::Line(l.clone()),
|
||
Self::Arc(a) => EntityType::Arc(a.clone()),
|
||
Self::LwPoly { poly, .. } => EntityType::LwPolyline(poly.clone()),
|
||
}
|
||
}
|
||
|
||
fn elevation(&self) -> f64 {
|
||
match self {
|
||
Self::Line(l) => l.start.z,
|
||
Self::Arc(a) => a.center.z,
|
||
Self::LwPoly { poly, .. } => poly.elevation,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Compute FILLET between two entities (Line, Arc, or LwPolyline segment).
|
||
/// Returns (trimmed_e1, trimmed_e2, optional_fillet_arc).
|
||
/// For same-poly corner fillet the two returned entities are identical (the rebuilt poly).
|
||
fn compute_fillet_entities(
|
||
e1: &FilletEntity,
|
||
click1: [f64; 2],
|
||
e2: &FilletEntity,
|
||
click2: [f64; 2],
|
||
radius: f64,
|
||
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
|
||
let z = e1.elevation();
|
||
// A polyline arc segment would be filleted as its straight chord.
|
||
let picks_arc_segment = |entity: &FilletEntity| {
|
||
matches!(entity, FilletEntity::LwPoly { poly, seg_idx, .. }
|
||
if poly.vertices.get(*seg_idx).is_some_and(|vertex| vertex.bulge.abs() > 1e-12))
|
||
};
|
||
if picks_arc_segment(e1) || picks_arc_segment(e2) {
|
||
return None;
|
||
}
|
||
|
||
match (e1, e2) {
|
||
// ── Line × Line ───────────────────────────────────────────────────
|
||
(FilletEntity::Line(l1), FilletEntity::Line(l2)) => {
|
||
compute_fillet(l1, click1, l2, click2, radius)
|
||
}
|
||
// ── Line × Arc ────────────────────────────────────────────────────
|
||
(FilletEntity::Line(l), FilletEntity::Arc(a)) => {
|
||
fillet_line_arc(l, click1, a, click2, radius, z)
|
||
}
|
||
(FilletEntity::Arc(a), FilletEntity::Line(l)) => {
|
||
fillet_line_arc(l, click2, a, click1, radius, z)
|
||
.map(|(new_l, new_a, arc)| (new_a, new_l, arc))
|
||
}
|
||
// ── Arc × Arc ─────────────────────────────────────────────────────
|
||
(FilletEntity::Arc(a1), FilletEntity::Arc(a2)) => {
|
||
fillet_arc_arc(a1, click1, a2, click2, radius, z)
|
||
}
|
||
// ── LwPoly × LwPoly (same entity — corner fillet) ─────────────────
|
||
(
|
||
FilletEntity::LwPoly {
|
||
poly: p1,
|
||
handle: h1,
|
||
seg_idx: s1,
|
||
},
|
||
FilletEntity::LwPoly {
|
||
handle: h2,
|
||
seg_idx: s2,
|
||
..
|
||
},
|
||
) if h1 == h2 => {
|
||
let n = p1.vertices.len();
|
||
let closed = p1.is_closed;
|
||
let seg_count = if closed { n } else { n.saturating_sub(1) };
|
||
let next = |seg: usize| if closed { (seg + 1) % n } else { seg + 1 };
|
||
let straight = |seg: usize| seg < seg_count && p1.vertices[seg].bulge.abs() < 1e-12;
|
||
// `before` runs into the corner and `after` leaves it. Between them
|
||
// sits nothing (a sharp corner), an old fillet arc being replaced,
|
||
// or — on an open polyline — the gap between its two ends, which
|
||
// the fillet closes. Arc segments themselves are never filleted:
|
||
// treating one as its chord bent the result across the shape.
|
||
enum Between {
|
||
Corner,
|
||
Arc(usize),
|
||
OpenEnds,
|
||
}
|
||
let order = |a: usize, b: usize| {
|
||
if next(a) == b {
|
||
Some((a, b, Between::Corner))
|
||
} else if next(a) < seg_count
|
||
&& next(next(a)) == b
|
||
&& !straight(next(a))
|
||
{
|
||
Some((a, b, Between::Arc(next(a))))
|
||
} else if !closed && seg_count >= 2 && a == seg_count - 1 && b == 0 {
|
||
Some((a, b, Between::OpenEnds))
|
||
} else {
|
||
None
|
||
}
|
||
};
|
||
let (before_seg, after_seg, between) = order(*s1, *s2).or_else(|| order(*s2, *s1))?;
|
||
if !straight(before_seg) || !straight(after_seg) {
|
||
return None;
|
||
}
|
||
let l1 = lwpoly_seg_as_line(p1, before_seg);
|
||
let l2 = lwpoly_seg_as_line(p1, after_seg);
|
||
// Re-map click to whichever segment each was picked on.
|
||
let (c1, c2) = if *s1 == before_seg {
|
||
(click1, click2)
|
||
} else {
|
||
(click2, click1)
|
||
};
|
||
let (EntityType::Line(tl1), EntityType::Line(tl2), maybe_arc) =
|
||
compute_fillet(&l1, c1, &l2, c2, radius)?
|
||
else {
|
||
return None;
|
||
};
|
||
let t1 = [tl1.end.x, tl1.end.y]; // trimmed end of seg before corner
|
||
let t2 = [tl2.start.x, tl2.start.y]; // trimmed start of seg after corner
|
||
let bulge = match maybe_arc {
|
||
Some(EntityType::Arc(ref fa)) => compute_bulge(t1, t2, [fa.center.x, fa.center.y]),
|
||
_ => 0.0, // r=0, sharp corner
|
||
};
|
||
let new_poly = match between {
|
||
Between::Corner => lwpoly_replace_corner(p1, after_seg, t1, t2, bulge),
|
||
// The old arc runs from its own vertex to the `after` vertex:
|
||
// move both onto the new tangent points.
|
||
Between::Arc(arc_seg) => {
|
||
let mut poly = p1.clone();
|
||
poly.common.handle = Handle::NULL;
|
||
poly.vertices[arc_seg].location.x = t1[0];
|
||
poly.vertices[arc_seg].location.y = t1[1];
|
||
poly.vertices[arc_seg].bulge = bulge;
|
||
poly.vertices[after_seg].location.x = t2[0];
|
||
poly.vertices[after_seg].location.y = t2[1];
|
||
poly
|
||
}
|
||
// The last vertex becomes the start of the fillet and the
|
||
// closing segment is the fillet itself.
|
||
Between::OpenEnds => {
|
||
let mut poly = p1.clone();
|
||
poly.common.handle = Handle::NULL;
|
||
let last = n - 1;
|
||
poly.vertices[last].location.x = t1[0];
|
||
poly.vertices[last].location.y = t1[1];
|
||
poly.vertices[last].bulge = bulge;
|
||
poly.vertices[0].location.x = t2[0];
|
||
poly.vertices[0].location.y = t2[1];
|
||
poly.is_closed = true;
|
||
poly
|
||
}
|
||
};
|
||
let et = EntityType::LwPolyline(new_poly);
|
||
// Return same rebuilt poly for both slots; caller uses only h1.
|
||
Some((et.clone(), et, None))
|
||
}
|
||
// ── LwPoly × LwPoly (different entities) ──────────────────────────
|
||
(
|
||
FilletEntity::LwPoly {
|
||
poly: p1,
|
||
seg_idx: s1,
|
||
..
|
||
},
|
||
FilletEntity::LwPoly {
|
||
poly: p2,
|
||
seg_idx: s2,
|
||
..
|
||
},
|
||
) => {
|
||
let l1 = lwpoly_seg_as_line(p1, *s1);
|
||
let l2 = lwpoly_seg_as_line(p2, *s2);
|
||
let (tl1_e, tl2_e, maybe_arc) = compute_fillet(&l1, click1, &l2, click2, radius)?;
|
||
if let (EntityType::Line(tl1), EntityType::Line(tl2)) = (&tl1_e, &tl2_e) {
|
||
let np1 = rebuild_poly_from_trimmed_line(p1, *s1, &l1, tl1);
|
||
let np2 = rebuild_poly_from_trimmed_line(p2, *s2, &l2, tl2);
|
||
Some((
|
||
EntityType::LwPolyline(np1),
|
||
EntityType::LwPolyline(np2),
|
||
maybe_arc,
|
||
))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
// ── LwPoly × Line ─────────────────────────────────────────────────
|
||
(FilletEntity::LwPoly { poly, seg_idx, .. }, FilletEntity::Line(l2)) => {
|
||
let l1 = lwpoly_seg_as_line(poly, *seg_idx);
|
||
let (tl1_e, new_l2, maybe_arc) = compute_fillet(&l1, click1, l2, click2, radius)?;
|
||
if let EntityType::Line(tl1) = &tl1_e {
|
||
let np = rebuild_poly_from_trimmed_line(poly, *seg_idx, &l1, tl1);
|
||
Some((EntityType::LwPolyline(np), new_l2, maybe_arc))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
(FilletEntity::Line(l1), FilletEntity::LwPoly { poly, seg_idx, .. }) => {
|
||
let l2 = lwpoly_seg_as_line(poly, *seg_idx);
|
||
let (new_l1, tl2_e, maybe_arc) = compute_fillet(l1, click1, &l2, click2, radius)?;
|
||
if let EntityType::Line(tl2) = &tl2_e {
|
||
let np = rebuild_poly_from_trimmed_line(poly, *seg_idx, &l2, tl2);
|
||
Some((new_l1, EntityType::LwPolyline(np), maybe_arc))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
// ── LwPoly × Arc ──────────────────────────────────────────────────
|
||
(FilletEntity::LwPoly { poly, seg_idx, .. }, FilletEntity::Arc(a)) => {
|
||
let l = lwpoly_seg_as_line(poly, *seg_idx);
|
||
let (tl_e, new_a, maybe_arc) = fillet_line_arc(&l, click1, a, click2, radius, z)?;
|
||
if let EntityType::Line(tl) = &tl_e {
|
||
let np = rebuild_poly_from_trimmed_line(poly, *seg_idx, &l, tl);
|
||
Some((EntityType::LwPolyline(np), new_a, maybe_arc))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
(FilletEntity::Arc(a), FilletEntity::LwPoly { poly, seg_idx, .. }) => {
|
||
let l = lwpoly_seg_as_line(poly, *seg_idx);
|
||
let (tl_e, new_a, maybe_arc) = fillet_line_arc(&l, click2, a, click1, radius, z)?;
|
||
if let EntityType::Line(tl) = &tl_e {
|
||
let np = rebuild_poly_from_trimmed_line(poly, *seg_idx, &l, tl);
|
||
Some((new_a, EntityType::LwPolyline(np), maybe_arc))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Rebuild an LwPolyline after a segment was trimmed.
|
||
/// Detects which endpoint of the original line moved and updates the vertex accordingly.
|
||
fn rebuild_poly_from_trimmed_line(
|
||
poly: &LwPolyline,
|
||
seg_idx: usize,
|
||
orig: &LineEnt,
|
||
trimmed: &LineEnt,
|
||
) -> LwPolyline {
|
||
let start_moved = (trimmed.start.x - orig.start.x).hypot(trimmed.start.y - orig.start.y) > 1e-9;
|
||
let end_moved = (trimmed.end.x - orig.end.x).hypot(trimmed.end.y - orig.end.y) > 1e-9;
|
||
let new_start = if start_moved {
|
||
Some([trimmed.start.x, trimmed.start.y])
|
||
} else {
|
||
None
|
||
};
|
||
let new_end = if end_moved {
|
||
Some([trimmed.end.x, trimmed.end.y])
|
||
} else {
|
||
None
|
||
};
|
||
lwpoly_shorten_seg(poly, seg_idx, new_start, new_end)
|
||
}
|
||
|
||
/// Fillet a Line and an Arc.
|
||
/// Fillets of `radius` tangent to both curves, nearest pick first.
|
||
///
|
||
/// The candidates come from the kernel, which finds them by crossing the two
|
||
/// curves' offsets — the construction that works whether or not the curves
|
||
/// meet. What is left here is the choice between them, which needs the two
|
||
/// pick points and so cannot be made anywhere else.
|
||
fn ranked_fillets(
|
||
a: &KernelCurve,
|
||
click_a: [f64; 2],
|
||
b: &KernelCurve,
|
||
click_b: [f64; 2],
|
||
radius: f64,
|
||
) -> Vec<KernelFillet> {
|
||
let reach = |from: [f64; 2], to: [f64; 2]| (to[0] - from[0]).hypot(to[1] - from[1]);
|
||
let mut found = fillets_between(a, b, radius, Tolerance::new(FILLET_TOLERANCE));
|
||
found.sort_by(|first, second| {
|
||
let one = reach(click_a, first.tangent1) + reach(click_b, first.tangent2);
|
||
let other = reach(click_a, second.tangent1) + reach(click_b, second.tangent2);
|
||
one.total_cmp(&other)
|
||
});
|
||
found
|
||
}
|
||
|
||
/// The entity's shape as a kernel curve.
|
||
fn line_curve(l: &LineEnt) -> KernelCurve {
|
||
KernelCurve::Line(KernelLine {
|
||
start: [l.start.x, l.start.y],
|
||
end: [l.end.x, l.end.y],
|
||
})
|
||
}
|
||
|
||
fn arc_curve(a: &ArcEnt) -> KernelCurve {
|
||
KernelCurve::Arc(KernelArc {
|
||
centre: [a.center.x, a.center.y],
|
||
radius: a.radius,
|
||
start_angle: a.start_angle,
|
||
end_angle: a.end_angle,
|
||
})
|
||
}
|
||
|
||
/// An arc entity built from a kernel fillet.
|
||
fn fillet_entity(fillet: &KernelFillet, radius: f64, z: f64, common: &EntityCommon) -> ArcEnt {
|
||
let mut arc = ArcEnt::new();
|
||
arc.common = common.clone();
|
||
arc.common.handle = Handle::NULL;
|
||
arc.center = Vector3::new(fillet.centre[0], fillet.centre[1], z);
|
||
arc.radius = radius;
|
||
arc.start_angle = fillet.start_angle;
|
||
arc.end_angle = fillet.end_angle;
|
||
arc
|
||
}
|
||
|
||
fn fillet_line_arc(
|
||
line: &LineEnt,
|
||
click_line: [f64; 2],
|
||
arc: &ArcEnt,
|
||
click_arc: [f64; 2],
|
||
radius: f64,
|
||
z: f64,
|
||
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
|
||
let (p1, _, u, _) = line_geom(line);
|
||
let (ac, ar, a_start, a_end, _) = arc_geom(arc);
|
||
|
||
// Intersection of infinite line with the arc's circle
|
||
let ts = line_circle(p1, u, ac, ar);
|
||
|
||
if radius < 1e-9 {
|
||
// r=0: trim to intersection (nearest to each click)
|
||
let t_best = ts.iter().copied().min_by(|a, b| {
|
||
let da = (p1[0] + a * u[0] - click_line[0]).powi(2)
|
||
+ (p1[1] + a * u[1] - click_line[1]).powi(2);
|
||
let db = (p1[0] + b * u[0] - click_line[0]).powi(2)
|
||
+ (p1[1] + b * u[1] - click_line[1]).powi(2);
|
||
da.total_cmp(&db)
|
||
})?;
|
||
let ix = p1[0] + t_best * u[0];
|
||
let iy = p1[1] + t_best * u[1];
|
||
if !(ix.is_finite() && iy.is_finite()) {
|
||
return None;
|
||
}
|
||
|
||
// Trim line to intersection
|
||
let new_line = trim_line_to_point(line, [ix, iy], click_line)?;
|
||
// Trim arc to intersection angle
|
||
let i_angle = arc_angle_at(ac, [ix, iy]);
|
||
let i_clamped = clamp_angle_to_arc(i_angle, a_start, a_end);
|
||
let arc_click_angle = arc_angle_at(ac, click_arc);
|
||
let arc_click_clamped = clamp_angle_to_arc(arc_click_angle, a_start, a_end);
|
||
// Keep the arc side from i_clamped toward the click
|
||
let new_arc = if {
|
||
let sp_to_click = arc_span(i_clamped, arc_click_clamped);
|
||
let sp_click_to_end = arc_span(arc_click_clamped, a_end);
|
||
sp_to_click <= sp_click_to_end
|
||
} {
|
||
trim_arc(arc, i_clamped, a_end)
|
||
} else {
|
||
trim_arc(arc, a_start, i_clamped)
|
||
};
|
||
return Some((EntityType::Line(new_line), EntityType::Arc(new_arc), None));
|
||
}
|
||
|
||
// The candidates come from the kernel: a circle of `radius` tangent to
|
||
// both has its centre where an offset of the line crosses an offset of
|
||
// the arc's circle. What was here enumerated the same four combinations
|
||
// by hand, with its own quadratic and its own sign conventions.
|
||
for fillet in ranked_fillets(
|
||
&line_curve(line),
|
||
click_line,
|
||
&arc_curve(arc),
|
||
click_arc,
|
||
radius,
|
||
) {
|
||
// The touch on the arc has to be on the drawn part of it, not on the
|
||
// rest of the circle it belongs to.
|
||
let tp_arc_angle = arc_angle_at(ac, fillet.tangent2);
|
||
let tp_arc_clamped = clamp_angle_to_arc(tp_arc_angle, a_start, a_end);
|
||
if (norm_angle(tp_arc_angle) - norm_angle(tp_arc_clamped)).abs() > 0.01 {
|
||
continue;
|
||
}
|
||
let Some(new_line) = trim_line_to_point(line, fillet.tangent1, click_line) else {
|
||
continue;
|
||
};
|
||
// Keep the side of the arc the click is on.
|
||
let arc_click_rel = (arc_angle_at(ac, click_arc) - a_start).rem_euclid(TAU);
|
||
let tp_arc_rel = (tp_arc_clamped - a_start).rem_euclid(TAU);
|
||
let new_arc = if tp_arc_rel <= arc_click_rel {
|
||
trim_arc(arc, tp_arc_clamped, a_end)
|
||
} else {
|
||
trim_arc(arc, a_start, tp_arc_clamped)
|
||
};
|
||
return Some((
|
||
EntityType::Line(new_line),
|
||
EntityType::Arc(new_arc),
|
||
Some(EntityType::Arc(fillet_entity(
|
||
&fillet,
|
||
radius,
|
||
z,
|
||
&line.common,
|
||
))),
|
||
));
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Fillet two arcs.
|
||
fn fillet_arc_arc(
|
||
a1: &ArcEnt,
|
||
click1: [f64; 2],
|
||
a2: &ArcEnt,
|
||
click2: [f64; 2],
|
||
radius: f64,
|
||
z: f64,
|
||
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
|
||
let (c1, r1, s1, e1, _) = arc_geom(a1);
|
||
let (c2, r2, s2, e2, _) = arc_geom(a2);
|
||
|
||
if radius < 1e-9 {
|
||
// r=0: trim both arcs to their intersection point
|
||
let pts = circle_circle_pts(c1, r1, c2, r2);
|
||
if pts.is_empty() {
|
||
return None;
|
||
}
|
||
// Pick the intersection point nearest to the average of the two clicks
|
||
let cx = (click1[0] + click2[0]) / 2.0;
|
||
let cy = (click1[1] + click2[1]) / 2.0;
|
||
let ip = *pts.iter().min_by(|a, b| {
|
||
(a[0] - cx)
|
||
.hypot(a[1] - cy)
|
||
.total_cmp(&(b[0] - cx).hypot(b[1] - cy))
|
||
})?;
|
||
if !(ip[0].is_finite() && ip[1].is_finite()) {
|
||
return None;
|
||
}
|
||
|
||
let ia1 = arc_angle_at(c1, ip);
|
||
let ia2 = arc_angle_at(c2, ip);
|
||
let ic1 = clamp_angle_to_arc(ia1, s1, e1);
|
||
let ic2 = clamp_angle_to_arc(ia2, s2, e2);
|
||
|
||
let ca1 = clamp_angle_to_arc(arc_angle_at(c1, click1), s1, e1);
|
||
let ca2 = clamp_angle_to_arc(arc_angle_at(c2, click2), s2, e2);
|
||
|
||
let new_a1 = if (ic1 - s1).rem_euclid(TAU) <= (ca1 - s1).rem_euclid(TAU) {
|
||
trim_arc(a1, ic1, e1)
|
||
} else {
|
||
trim_arc(a1, s1, ic1)
|
||
};
|
||
let new_a2 = if (ic2 - s2).rem_euclid(TAU) <= (ca2 - s2).rem_euclid(TAU) {
|
||
trim_arc(a2, ic2, e2)
|
||
} else {
|
||
trim_arc(a2, s2, ic2)
|
||
};
|
||
return Some((EntityType::Arc(new_a1), EntityType::Arc(new_a2), None));
|
||
}
|
||
|
||
// Same construction as the line-and-arc case, from the same place: the
|
||
// centre is where an offset of one circle crosses an offset of the other.
|
||
for fillet in ranked_fillets(&arc_curve(a1), click1, &arc_curve(a2), click2, radius) {
|
||
// Both touches have to be on the drawn parts of their arcs.
|
||
let tp1_angle = arc_angle_at(c1, fillet.tangent1);
|
||
let tp2_angle = arc_angle_at(c2, fillet.tangent2);
|
||
let tc1 = clamp_angle_to_arc(tp1_angle, s1, e1);
|
||
let tc2 = clamp_angle_to_arc(tp2_angle, s2, e2);
|
||
if (norm_angle(tp1_angle) - norm_angle(tc1)).abs() > 0.01
|
||
|| (norm_angle(tp2_angle) - norm_angle(tc2)).abs() > 0.01
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let ca1 = clamp_angle_to_arc(arc_angle_at(c1, click1), s1, e1);
|
||
let ca2 = clamp_angle_to_arc(arc_angle_at(c2, click2), s2, e2);
|
||
let new_a1 = if (tc1 - s1).rem_euclid(TAU) <= (ca1 - s1).rem_euclid(TAU) {
|
||
trim_arc(a1, tc1, e1)
|
||
} else {
|
||
trim_arc(a1, s1, tc1)
|
||
};
|
||
let new_a2 = if (tc2 - s2).rem_euclid(TAU) <= (ca2 - s2).rem_euclid(TAU) {
|
||
trim_arc(a2, tc2, e2)
|
||
} else {
|
||
trim_arc(a2, s2, tc2)
|
||
};
|
||
return Some((
|
||
EntityType::Arc(new_a1),
|
||
EntityType::Arc(new_a2),
|
||
Some(EntityType::Arc(fillet_entity(&fillet, radius, z, &a1.common))),
|
||
));
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Trim a line endpoint nearest to the intersection point, keeping the click side.
|
||
fn trim_line_to_point(line: &LineEnt, isect: [f64; 2], click: [f64; 2]) -> Option<LineEnt> {
|
||
let mut l = line.clone();
|
||
l.common.handle = Handle::NULL;
|
||
let (p1, _, u, len) = line_geom(line);
|
||
if len < 1e-12 {
|
||
return None;
|
||
}
|
||
// Parameter of intersection
|
||
let t_i = (isect[0] - p1[0]) * u[0] + (isect[1] - p1[1]) * u[1];
|
||
// Parameter of click
|
||
let t_c = (click[0] - p1[0]) * u[0] + (click[1] - p1[1]) * u[1];
|
||
// If click is past intersection on the + side, keep intersection..end
|
||
if t_c >= t_i {
|
||
l.start = Vector3::new(isect[0], isect[1], line.start.z);
|
||
} else {
|
||
l.end = Vector3::new(isect[0], isect[1], line.end.z);
|
||
}
|
||
Some(l)
|
||
}
|
||
|
||
// ── Chamfer ────────────────────────────────────────────────────────────────
|
||
|
||
/// Compute chamfer: trim l1 by dist1 from intersection, l2 by dist2, add chamfer line.
|
||
fn compute_chamfer(
|
||
l1: &LineEnt,
|
||
click1: [f64; 2],
|
||
dist1: f64,
|
||
l2: &LineEnt,
|
||
click2: [f64; 2],
|
||
dist2: f64,
|
||
) -> Option<(EntityType, EntityType, EntityType)> {
|
||
let (p1, _, u1, _) = line_geom(l1);
|
||
let (p3, _, u2, _) = line_geom(l2);
|
||
|
||
let (t_p, _u_p) = ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?;
|
||
|
||
let px = p1[0] + t_p * u1[0];
|
||
let py = p1[1] + t_p * u1[1];
|
||
let z = l1.start.z;
|
||
|
||
let s1 = project_click(click1, [px, py], u1);
|
||
let s2 = project_click(click2, [px, py], u2);
|
||
let dir1 = if s1 >= 0.0 {
|
||
[u1[0], u1[1]]
|
||
} else {
|
||
[-u1[0], -u1[1]]
|
||
};
|
||
let dir2 = if s2 >= 0.0 {
|
||
[u2[0], u2[1]]
|
||
} else {
|
||
[-u2[0], -u2[1]]
|
||
};
|
||
|
||
// Chamfer points: back off dist from P along keep-direction
|
||
let c1 = [px + dist1 * dir1[0], py + dist1 * dir1[1]];
|
||
let c2 = [px + dist2 * dir2[0], py + dist2 * dir2[1]];
|
||
|
||
// Trim l1 to c1 and l2 to c2
|
||
let new_l1 = trim_to_xy(l1, c1, dir1, u1)?;
|
||
let new_l2 = trim_to_xy(l2, c2, dir2, u2)?;
|
||
|
||
// Chamfer line
|
||
let mut cline = l1.clone();
|
||
cline.common.handle = Handle::NULL;
|
||
cline.start = Vector3::new(c1[0], c1[1], z);
|
||
cline.end = Vector3::new(c2[0], c2[1], z);
|
||
|
||
Some((
|
||
EntityType::Line(new_l1),
|
||
EntityType::Line(new_l2),
|
||
EntityType::Line(cline),
|
||
))
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// FilletCommand
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
enum FilletStep {
|
||
First,
|
||
Polyline,
|
||
|
||
// Radius may be typed directly or measured by picking two points.
|
||
WaitingForRadius,
|
||
|
||
RadiusSecondPoint {
|
||
first: DVec3,
|
||
},
|
||
|
||
Second {
|
||
h1: Handle,
|
||
e1: FilletEntity,
|
||
click1: [f64; 2],
|
||
},
|
||
}
|
||
|
||
pub struct FilletCommand {
|
||
radius: f64,
|
||
step: FilletStep,
|
||
all_entities: Vec<EntityType>,
|
||
entity_index: ModifyEntityIndex,
|
||
/// First-object pick to restore after a radius entry made mid-selection
|
||
/// (i.e. "R" pressed after the first object was already picked), so the
|
||
/// command resumes at the second pick instead of restarting selection.
|
||
resume_second: Option<(Handle, FilletEntity, [f64; 2])>,
|
||
/// Fillets applied by this command that Undo can still take back.
|
||
made: usize,
|
||
}
|
||
|
||
impl FilletCommand {
|
||
pub fn new(radius: f64, all_entities: Vec<EntityType>) -> Self {
|
||
let all_entities: Vec<EntityType> = all_entities
|
||
.iter()
|
||
.map(crate::entities::curve::entity_with_lwpolyline_world_xy)
|
||
.collect();
|
||
let entity_index = ModifyEntityIndex::build(&all_entities);
|
||
Self {
|
||
radius: radius as f64,
|
||
step: FilletStep::First,
|
||
all_entities,
|
||
entity_index,
|
||
resume_second: None,
|
||
made: 0,
|
||
}
|
||
}
|
||
|
||
/// Switch to the radius sub-step, remembering the first pick (if any) so
|
||
/// it can be restored afterwards.
|
||
fn enter_radius_substep(&mut self) {
|
||
self.resume_second = if let FilletStep::Second { h1, e1, click1 } = &self.step {
|
||
Some((*h1, e1.clone(), *click1))
|
||
} else {
|
||
None
|
||
};
|
||
self.step = FilletStep::WaitingForRadius;
|
||
}
|
||
|
||
/// Leave the radius sub-step, resuming the second pick when a first object
|
||
/// was already chosen, otherwise restarting at the first pick.
|
||
fn resume_after_radius(&mut self) {
|
||
self.step = match self.resume_second.take() {
|
||
Some((h1, e1, click1)) => FilletStep::Second { h1, e1, click1 },
|
||
None => FilletStep::First,
|
||
};
|
||
}
|
||
|
||
fn continue_after_fillet(&mut self, replacements: Vec<(Handle, Vec<EntityType>)>) -> CmdResult {
|
||
self.all_entities.retain(|entity| {
|
||
!replacements
|
||
.iter()
|
||
.any(|(handle, _)| entity.common().handle == *handle)
|
||
});
|
||
self.all_entities.extend(
|
||
replacements
|
||
.iter()
|
||
.flat_map(|(_, entities)| entities.iter().cloned()),
|
||
);
|
||
self.entity_index = ModifyEntityIndex::build(&self.all_entities);
|
||
self.step = FilletStep::First;
|
||
self.resume_second = None;
|
||
self.made += 1;
|
||
CmdResult::ReplaceManyContinue(replacements)
|
||
}
|
||
|
||
/// Undo option: take back the last fillet of this command. The host
|
||
/// undoes the document step and then hands the restored entities back
|
||
/// through `on_document_undone`.
|
||
fn undo_last(&mut self) -> Option<CmdResult> {
|
||
if self.made == 0 {
|
||
return None;
|
||
}
|
||
self.made -= 1;
|
||
Some(CmdResult::UndoDocument)
|
||
}
|
||
}
|
||
|
||
impl CadCommand for FilletCommand {
|
||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||
let FilletStep::RadiusSecondPoint { first } = self.step else {
|
||
return None;
|
||
};
|
||
|
||
Some(WireModel::solid_f64(
|
||
"fillet_radius_preview".into(),
|
||
vec![
|
||
[first.x, first.y, first.z],
|
||
[pt.x, pt.y, pt.z],
|
||
],
|
||
WireModel::CYAN,
|
||
false,
|
||
))
|
||
}
|
||
fn name(&self) -> &'static str {
|
||
"FILLET"
|
||
}
|
||
|
||
fn prompt(&self) -> String {
|
||
match &self.step {
|
||
FilletStep::First => crate::tf!(
|
||
"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(),
|
||
FilletStep::WaitingForRadius => {
|
||
crate::tf!(
|
||
"FILLET Specify fillet radius or first point <{:.4}>:",
|
||
self.radius
|
||
)
|
||
.into_owned()
|
||
}
|
||
|
||
FilletStep::RadiusSecondPoint { .. } => {
|
||
crate::t!("FILLET Specify second point for radius:").into_owned()
|
||
}
|
||
FilletStep::Second { .. } => {
|
||
crate::tf!(
|
||
"FILLET Select second object (Line/Arc/LwPolyline) [R={:.4}]:",
|
||
self.radius
|
||
)
|
||
.into_owned()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||
use crate::command::CmdOption;
|
||
|
||
match self.step {
|
||
FilletStep::First => {
|
||
let mut opts = Vec::new();
|
||
if self.made > 0 {
|
||
opts.push(CmdOption::new("Undo", "U"));
|
||
}
|
||
opts.push(CmdOption::new("Polyline", "P"));
|
||
opts.push(CmdOption::new("Radius", "R"));
|
||
opts
|
||
}
|
||
FilletStep::Second { .. } => {
|
||
vec![CmdOption::new("Radius", "R")]
|
||
}
|
||
FilletStep::Polyline
|
||
| FilletStep::WaitingForRadius
|
||
| FilletStep::RadiusSecondPoint { .. } => vec![],
|
||
}
|
||
}
|
||
|
||
fn wants_text_input(&self) -> bool {
|
||
matches!(self.step, FilletStep::WaitingForRadius)
|
||
}
|
||
|
||
fn dyn_field(&self) -> crate::command::DynField {
|
||
if matches!(self.step, FilletStep::WaitingForRadius) {
|
||
crate::command::DynField::Scalar
|
||
} else {
|
||
crate::command::DynField::Point
|
||
}
|
||
}
|
||
|
||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||
match self.step {
|
||
FilletStep::WaitingForRadius => {
|
||
let t = text.trim();
|
||
if t.is_empty() {
|
||
// Keep current radius, resume where the radius was requested.
|
||
self.resume_after_radius();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
if let Some(v) = crate::entities::common::parse_typed_length(t) {
|
||
if v >= 0.0 {
|
||
self.radius = v;
|
||
defaults::set_fillet_radius(v);
|
||
}
|
||
self.resume_after_radius();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
// Invalid — stay and re-prompt
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
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);
|
||
}
|
||
if upper == "U" || upper == "UNDO" {
|
||
return self.undo_last();
|
||
}
|
||
// "R" alone → enter sub-step to collect radius
|
||
if upper == "R" {
|
||
self.enter_radius_substep();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
// "R 5.0" inline shorthand
|
||
if t.starts_with(['r', 'R']) {
|
||
let body = t[1..].trim();
|
||
if let Some(v) = crate::entities::common::parse_typed_length(body) {
|
||
if v >= 0.0 {
|
||
self.radius = v;
|
||
defaults::set_fillet_radius(v);
|
||
}
|
||
// Stay in the current step (keeps any first pick).
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
// "R" + invalid body → enter sub-step
|
||
self.enter_radius_substep();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
None
|
||
}
|
||
FilletStep::Polyline
|
||
| FilletStep::RadiusSecondPoint { .. } => None,
|
||
}
|
||
}
|
||
|
||
fn needs_entity_pick(&self) -> bool {
|
||
!matches!(
|
||
self.step,
|
||
FilletStep::WaitingForRadius
|
||
| FilletStep::RadiusSecondPoint { .. }
|
||
)
|
||
}
|
||
|
||
fn on_undo_step(&mut self) -> Option<CmdResult> {
|
||
self.undo_last()
|
||
}
|
||
|
||
fn on_document_undone(&mut self, document: &codec::CadDocument) {
|
||
self.all_entities = document
|
||
.entities()
|
||
.map(crate::entities::curve::entity_with_lwpolyline_world_xy)
|
||
.collect();
|
||
self.entity_index = ModifyEntityIndex::build(&self.all_entities);
|
||
self.step = FilletStep::First;
|
||
self.resume_second = None;
|
||
}
|
||
|
||
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult {
|
||
if handle.is_null() {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
let click = [pt.x as f64, pt.y as f64]; // drawing plane is world XY
|
||
|
||
match &self.step {
|
||
FilletStep::WaitingForRadius
|
||
| FilletStep::RadiusSecondPoint { .. } => {
|
||
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
|
||
.get(&self.all_entities, handle)
|
||
.and_then(|entity| match entity {
|
||
EntityType::LwPolyline(p) => {
|
||
Some(FilletEntity::from_lwpoly(p, handle, click))
|
||
}
|
||
other => FilletEntity::from_entity(other),
|
||
});
|
||
if let Some(e) = e1 {
|
||
self.step = FilletStep::Second {
|
||
h1: handle,
|
||
e1: e,
|
||
click1: click,
|
||
};
|
||
CmdResult::NeedPoint
|
||
} else {
|
||
CmdResult::NeedPoint
|
||
}
|
||
}
|
||
FilletStep::Second { h1, e1, click1 } => {
|
||
let h1 = *h1;
|
||
let e1 = e1.clone();
|
||
let click1 = *click1;
|
||
let same_entity = handle == h1;
|
||
|
||
// For non-LwPoly entities, reject same-entity re-picks.
|
||
if same_entity && !matches!(e1, FilletEntity::LwPoly { .. }) {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
|
||
let e2 = self
|
||
.entity_index
|
||
.get(&self.all_entities, handle)
|
||
.and_then(|entity| match entity {
|
||
EntityType::LwPolyline(p) => {
|
||
Some(FilletEntity::from_lwpoly(p, handle, click))
|
||
}
|
||
other => FilletEntity::from_entity(other),
|
||
});
|
||
|
||
if let Some(e2) = e2 {
|
||
match compute_fillet_entities(&e1, click1, &e2, click, self.radius) {
|
||
Some((new_e1, new_e2, maybe_arc)) => {
|
||
let mut first_replacements = vec![new_e1];
|
||
if let Some(arc) = maybe_arc {
|
||
first_replacements.push(arc);
|
||
}
|
||
let replacements = if same_entity {
|
||
// Corner fillet: both results are the same rebuilt poly.
|
||
vec![(h1, first_replacements)]
|
||
} else {
|
||
vec![(h1, first_replacements), (handle, vec![new_e2])]
|
||
};
|
||
self.continue_after_fillet(replacements)
|
||
}
|
||
None => CmdResult::NeedPoint,
|
||
}
|
||
} else {
|
||
CmdResult::NeedPoint
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_hover_entity(&mut self, handle: Handle, pt: DVec3) -> Vec<WireModel> {
|
||
if handle.is_null() {
|
||
return vec![];
|
||
}
|
||
let click = [pt.x as f64, pt.y as f64];
|
||
|
||
match &self.step {
|
||
FilletStep::WaitingForRadius
|
||
| FilletStep::RadiusSecondPoint { .. } => 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
|
||
.get(&self.all_entities, handle)
|
||
.and_then(|e| match e {
|
||
EntityType::LwPolyline(p) => Some(lwpoly_seg_hover_pts(p, click)),
|
||
_ => FilletEntity::from_entity(e).map(|fe| entity_pts(&fe.to_entity_type())),
|
||
});
|
||
if let Some(pts) = pts {
|
||
vec![WireModel::solid(
|
||
"fillet_hover".into(),
|
||
pts,
|
||
WireModel::CYAN,
|
||
false,
|
||
)]
|
||
} else {
|
||
vec![]
|
||
}
|
||
}
|
||
FilletStep::Second { h1, e1, click1 } => {
|
||
let h1 = *h1;
|
||
let e1 = e1.clone();
|
||
let click1 = *click1;
|
||
let e2 = self
|
||
.entity_index
|
||
.get(&self.all_entities, handle)
|
||
.and_then(|entity| match entity {
|
||
EntityType::LwPolyline(p) => {
|
||
Some(FilletEntity::from_lwpoly(p, handle, click))
|
||
}
|
||
other => FilletEntity::from_entity(other),
|
||
});
|
||
// For non-LwPoly, skip same-entity hover.
|
||
let _ = h1;
|
||
if let Some(e2) = e2 {
|
||
if let Some((new_e1, new_e2, maybe_arc)) =
|
||
compute_fillet_entities(&e1, click1, &e2, click, self.radius)
|
||
{
|
||
let mut out = vec![
|
||
WireModel::solid(
|
||
"fillet_e1".into(),
|
||
entity_pts(&new_e1),
|
||
WireModel::CYAN,
|
||
false,
|
||
),
|
||
WireModel::solid(
|
||
"fillet_e2".into(),
|
||
entity_pts(&new_e2),
|
||
WireModel::CYAN,
|
||
false,
|
||
),
|
||
];
|
||
if let Some(arc) = maybe_arc {
|
||
out.push(WireModel::solid(
|
||
"fillet_arc".into(),
|
||
entity_pts(&arc),
|
||
WireModel::CYAN,
|
||
false,
|
||
));
|
||
}
|
||
return out;
|
||
}
|
||
}
|
||
vec![]
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||
match self.step {
|
||
FilletStep::WaitingForRadius => {
|
||
// First point of the temporary radius measurement.
|
||
self.step = FilletStep::RadiusSecondPoint { first: pt };
|
||
CmdResult::NeedPoint
|
||
}
|
||
|
||
FilletStep::RadiusSecondPoint { first } => {
|
||
let radius = first.distance(pt);
|
||
|
||
// Ignore an accidental zero-length measurement.
|
||
if radius > 1.0e-9 {
|
||
self.radius = radius;
|
||
defaults::set_fillet_radius(radius);
|
||
self.resume_after_radius();
|
||
}
|
||
|
||
CmdResult::NeedPoint
|
||
}
|
||
|
||
_ => CmdResult::NeedPoint,
|
||
}
|
||
}
|
||
fn on_entity_replaced(&mut self, _old: Handle, new_handles: &[Handle]) {
|
||
let mut handles = new_handles.iter().copied();
|
||
for entity in self
|
||
.all_entities
|
||
.iter_mut()
|
||
.filter(|entity| entity.common().handle.is_null())
|
||
{
|
||
let Some(handle) = handles.next() else {
|
||
break;
|
||
};
|
||
entity.common_mut().handle = handle;
|
||
}
|
||
self.entity_index = ModifyEntityIndex::build(&self.all_entities);
|
||
}
|
||
fn on_enter(&mut self) -> CmdResult {
|
||
CmdResult::Cancel
|
||
}
|
||
fn on_escape(&mut self) -> CmdResult {
|
||
CmdResult::Cancel
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// ChamferCommand
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// Chamfer a corner of a single LwPolyline: the two picks select two adjacent
|
||
/// segments; the shared corner vertex is cut back by dist1/dist2 and replaced
|
||
/// with a straight chamfer edge (bulge 0). Returns the rebuilt polyline.
|
||
fn chamfer_lwpoly_corner(
|
||
poly: &LwPolyline,
|
||
click1: [f64; 2],
|
||
dist1: f64,
|
||
click2: [f64; 2],
|
||
dist2: f64,
|
||
) -> Option<EntityType> {
|
||
let n = poly.vertices.len();
|
||
if n < 3 {
|
||
return None;
|
||
}
|
||
let s1 = lwpoly_nearest_seg(poly, click1);
|
||
let s2 = lwpoly_nearest_seg(poly, click2);
|
||
if s1 == s2 {
|
||
return None;
|
||
}
|
||
let (low, high) = if s1 < s2 { (s1, s2) } else { (s2, s1) };
|
||
// The wrap-around corner of a closed polyline joins the last segment
|
||
// (n-1) and the first (0); their shared vertex is v0.
|
||
let wrap = poly.is_closed && low == 0 && high == n.saturating_sub(1);
|
||
if !(high == low + 1 || wrap) {
|
||
return None;
|
||
}
|
||
// `before_seg` ends at the shared corner vertex, `after_seg` starts there.
|
||
let (before_seg, after_seg, corner_idx) = if wrap {
|
||
(high, low, 0)
|
||
} else {
|
||
(low, high, high)
|
||
};
|
||
let l1 = lwpoly_seg_as_line(poly, before_seg);
|
||
let l2 = lwpoly_seg_as_line(poly, after_seg);
|
||
// Map each click + distance to whichever segment it was picked on.
|
||
let (c1, d1, c2, d2) = if s1 == before_seg {
|
||
(click1, dist1, click2, dist2)
|
||
} else {
|
||
(click2, dist2, click1, dist1)
|
||
};
|
||
match compute_chamfer(&l1, c1, d1, &l2, c2, d2)? {
|
||
(EntityType::Line(tl1), EntityType::Line(tl2), _) => {
|
||
let t1 = [tl1.end.x, tl1.end.y];
|
||
let t2 = [tl2.start.x, tl2.start.y];
|
||
let new_poly = lwpoly_replace_corner(poly, corner_idx, t1, t2, 0.0);
|
||
Some(EntityType::LwPolyline(new_poly))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
enum ChamferStep {
|
||
First,
|
||
WaitingForDist1,
|
||
WaitingForDist2,
|
||
Second {
|
||
h1: Handle,
|
||
l1: LineEnt,
|
||
click1: [f64; 2],
|
||
},
|
||
/// First pick was an LwPolyline segment; waiting for the second segment
|
||
/// of the same polyline to chamfer the shared corner.
|
||
SecondPoly {
|
||
h1: Handle,
|
||
poly: LwPolyline,
|
||
click1: [f64; 2],
|
||
},
|
||
}
|
||
|
||
pub struct ChamferCommand {
|
||
dist1: f64,
|
||
dist2: f64,
|
||
step: ChamferStep,
|
||
all_entities: Vec<EntityType>,
|
||
entity_index: ModifyEntityIndex,
|
||
/// First-object pick (line or polyline segment) to restore after a
|
||
/// distance entry made mid-selection, so the command resumes at the
|
||
/// second pick instead of restarting selection.
|
||
resume_pick: Option<ChamferStep>,
|
||
/// Multiple option: keep chamfering until Enter / Esc.
|
||
multiple: bool,
|
||
/// Chamfers applied by this command that Undo can still take back.
|
||
made: usize,
|
||
}
|
||
|
||
impl ChamferCommand {
|
||
pub fn new(dist: f64, all_entities: Vec<EntityType>) -> Self {
|
||
let all_entities: Vec<EntityType> = all_entities
|
||
.iter()
|
||
.map(crate::entities::curve::entity_with_lwpolyline_world_xy)
|
||
.collect();
|
||
let entity_index = ModifyEntityIndex::build(&all_entities);
|
||
Self {
|
||
dist1: dist as f64,
|
||
dist2: defaults::get_chamfer_dist2(),
|
||
step: ChamferStep::First,
|
||
all_entities,
|
||
entity_index,
|
||
resume_pick: None,
|
||
multiple: false,
|
||
made: 0,
|
||
}
|
||
}
|
||
|
||
/// Switch to the distance sub-step, remembering the first pick (if any).
|
||
fn enter_dist_substep(&mut self) {
|
||
self.resume_pick = match &self.step {
|
||
ChamferStep::Second { h1, l1, click1 } => Some(ChamferStep::Second {
|
||
h1: *h1,
|
||
l1: l1.clone(),
|
||
click1: *click1,
|
||
}),
|
||
ChamferStep::SecondPoly { h1, poly, click1 } => Some(ChamferStep::SecondPoly {
|
||
h1: *h1,
|
||
poly: poly.clone(),
|
||
click1: *click1,
|
||
}),
|
||
_ => None,
|
||
};
|
||
self.step = ChamferStep::WaitingForDist1;
|
||
}
|
||
|
||
/// Leave the distance sub-step, resuming the second pick when a first
|
||
/// object was already chosen, otherwise restarting at the first pick.
|
||
fn resume_after_dist(&mut self) {
|
||
self.step = self.resume_pick.take().unwrap_or(ChamferStep::First);
|
||
}
|
||
|
||
/// Publish a chamfer. Single mode replaces and ends; Multiple mode
|
||
/// replaces, refreshes the cached entities and returns to the first pick.
|
||
fn commit(&mut self, replacements: Vec<(Handle, Vec<EntityType>)>) -> CmdResult {
|
||
if !self.multiple {
|
||
return CmdResult::ReplaceMany(replacements, vec![]);
|
||
}
|
||
self.all_entities.retain(|entity| {
|
||
!replacements
|
||
.iter()
|
||
.any(|(handle, _)| entity.common().handle == *handle)
|
||
});
|
||
self.all_entities.extend(
|
||
replacements
|
||
.iter()
|
||
.flat_map(|(_, entities)| entities.iter().cloned()),
|
||
);
|
||
self.entity_index = ModifyEntityIndex::build(&self.all_entities);
|
||
self.step = ChamferStep::First;
|
||
self.resume_pick = None;
|
||
self.made += 1;
|
||
CmdResult::ReplaceManyContinue(replacements)
|
||
}
|
||
|
||
fn undo_last(&mut self) -> Option<CmdResult> {
|
||
if self.made == 0 {
|
||
return None;
|
||
}
|
||
self.made -= 1;
|
||
Some(CmdResult::UndoDocument)
|
||
}
|
||
}
|
||
|
||
impl CadCommand for ChamferCommand {
|
||
fn name(&self) -> &'static str {
|
||
"CHAMFER"
|
||
}
|
||
|
||
fn prompt(&self) -> String {
|
||
match &self.step {
|
||
ChamferStep::First => {
|
||
let d1 = format!("{:.4}", self.dist1);
|
||
let d2 = format!("{:.4}", self.dist2);
|
||
t!(
|
||
"CHAMFER Select first line [D1=%{d1} D2=%{d2}]:",
|
||
d1 = d1,
|
||
d2 = d2
|
||
)
|
||
.into_owned()
|
||
}
|
||
ChamferStep::WaitingForDist1 => {
|
||
let d1 = format!("{:.4}", self.dist1);
|
||
t!("CHAMFER Enter first chamfer distance <%{d1}>:", d1 = d1).into_owned()
|
||
}
|
||
ChamferStep::WaitingForDist2 => {
|
||
let d2 = format!("{:.4}", self.dist2);
|
||
t!("CHAMFER Enter second chamfer distance <%{d2}>:", d2 = d2).into_owned()
|
||
}
|
||
ChamferStep::Second { .. } => {
|
||
let d1 = format!("{:.4}", self.dist1);
|
||
let d2 = format!("{:.4}", self.dist2);
|
||
t!(
|
||
"CHAMFER Select second line [D1=%{d1} D2=%{d2}]:",
|
||
d1 = d1,
|
||
d2 = d2
|
||
)
|
||
.into_owned()
|
||
}
|
||
ChamferStep::SecondPoly { .. } => {
|
||
let d1 = format!("{:.4}", self.dist1);
|
||
let d2 = format!("{:.4}", self.dist2);
|
||
t!(
|
||
"CHAMFER Select the adjacent polyline segment [D1=%{d1} D2=%{d2}]:",
|
||
d1 = d1,
|
||
d2 = d2
|
||
)
|
||
.into_owned()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||
use crate::command::CmdOption;
|
||
|
||
match self.step {
|
||
ChamferStep::First => {
|
||
let mut opts = Vec::new();
|
||
if self.made > 0 {
|
||
opts.push(CmdOption::new("Undo", "U"));
|
||
}
|
||
opts.push(CmdOption::new("Distance", "D"));
|
||
if !self.multiple {
|
||
opts.push(CmdOption::new("Multiple", "M"));
|
||
}
|
||
opts
|
||
}
|
||
ChamferStep::Second { .. } | ChamferStep::SecondPoly { .. } => {
|
||
vec![CmdOption::new("Distance", "D")]
|
||
}
|
||
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![],
|
||
}
|
||
}
|
||
|
||
fn wants_text_input(&self) -> bool {
|
||
matches!(
|
||
self.step,
|
||
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2
|
||
)
|
||
}
|
||
|
||
fn dyn_field(&self) -> crate::command::DynField {
|
||
if matches!(
|
||
self.step,
|
||
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2
|
||
) {
|
||
crate::command::DynField::Scalar
|
||
} else {
|
||
crate::command::DynField::Point
|
||
}
|
||
}
|
||
|
||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||
match self.step {
|
||
ChamferStep::WaitingForDist1 => {
|
||
let t = text.trim();
|
||
if t.is_empty() {
|
||
// Keep current dist1, move on to dist2
|
||
self.step = ChamferStep::WaitingForDist2;
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
if let Some(v) = crate::entities::common::parse_typed_length(t) {
|
||
self.dist1 = v.max(0.0);
|
||
defaults::set_chamfer_dist1(self.dist1);
|
||
self.step = ChamferStep::WaitingForDist2;
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
// Invalid — stay and re-prompt
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
ChamferStep::WaitingForDist2 => {
|
||
let t = text.trim();
|
||
if t.is_empty() {
|
||
// Keep current dist2, resume where the distance was requested.
|
||
self.resume_after_dist();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
if let Some(v) = crate::entities::common::parse_typed_length(t) {
|
||
self.dist2 = v.max(0.0);
|
||
defaults::set_chamfer_dist2(self.dist2);
|
||
self.resume_after_dist();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
// Invalid — stay and re-prompt
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
ChamferStep::First
|
||
| ChamferStep::Second { .. }
|
||
| ChamferStep::SecondPoly { .. } => {
|
||
let t = text.trim();
|
||
let upper = t.to_uppercase();
|
||
if matches!(self.step, ChamferStep::First) {
|
||
match upper.as_str() {
|
||
"M" | "MULTIPLE" => {
|
||
self.multiple = true;
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
"U" | "UNDO" => return self.undo_last(),
|
||
_ => {}
|
||
}
|
||
}
|
||
// "D" alone → enter sub-step to collect distances
|
||
if upper == "D" {
|
||
self.enter_dist_substep();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
// "D 5.0" or "D 5.0 3.0" inline shorthand
|
||
if t.starts_with(['d', 'D']) {
|
||
let body = t[1..].trim();
|
||
let parts: Vec<f64> = body
|
||
.split_whitespace()
|
||
.filter_map(crate::entities::common::parse_typed_length)
|
||
.collect();
|
||
if !parts.is_empty() {
|
||
if let Some(&v) = parts.first() {
|
||
self.dist1 = v.max(0.0);
|
||
defaults::set_chamfer_dist1(self.dist1);
|
||
}
|
||
if let Some(&v) = parts.get(1) {
|
||
self.dist2 = v.max(0.0);
|
||
defaults::set_chamfer_dist2(self.dist2);
|
||
} else {
|
||
self.dist2 = self.dist1;
|
||
defaults::set_chamfer_dist2(self.dist2);
|
||
}
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
// "D" + invalid body → enter sub-step
|
||
self.enter_dist_substep();
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_undo_step(&mut self) -> Option<CmdResult> {
|
||
self.undo_last()
|
||
}
|
||
|
||
fn on_document_undone(&mut self, document: &codec::CadDocument) {
|
||
self.all_entities = document
|
||
.entities()
|
||
.map(crate::entities::curve::entity_with_lwpolyline_world_xy)
|
||
.collect();
|
||
self.entity_index = ModifyEntityIndex::build(&self.all_entities);
|
||
self.step = ChamferStep::First;
|
||
self.resume_pick = None;
|
||
}
|
||
|
||
fn needs_entity_pick(&self) -> bool {
|
||
!matches!(
|
||
self.step,
|
||
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2
|
||
)
|
||
}
|
||
|
||
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult {
|
||
if handle.is_null() {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
let click = [pt.x as f64, pt.y as f64];
|
||
|
||
match &self.step {
|
||
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
ChamferStep::First => {
|
||
match self
|
||
.entity_index.get(&self.all_entities, handle)
|
||
{
|
||
Some(EntityType::Line(l)) => {
|
||
self.step = ChamferStep::Second {
|
||
h1: handle,
|
||
l1: l.clone(),
|
||
click1: click,
|
||
};
|
||
}
|
||
Some(EntityType::LwPolyline(p)) => {
|
||
self.step = ChamferStep::SecondPoly {
|
||
h1: handle,
|
||
poly: p.clone(),
|
||
click1: click,
|
||
};
|
||
}
|
||
_ => {}
|
||
}
|
||
CmdResult::NeedPoint
|
||
}
|
||
ChamferStep::SecondPoly { h1, poly, click1 } => {
|
||
let h1 = *h1;
|
||
let poly = poly.clone();
|
||
let click1 = *click1;
|
||
if handle != h1 {
|
||
// Chamfer corners only within the same polyline.
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
match chamfer_lwpoly_corner(&poly, click1, self.dist1, click, self.dist2) {
|
||
Some(new_poly) => self.commit(vec![(h1, vec![new_poly])]),
|
||
None => CmdResult::NeedPoint,
|
||
}
|
||
}
|
||
ChamferStep::Second { h1, l1, click1 } => {
|
||
let h1 = *h1;
|
||
let l1 = l1.clone();
|
||
let click1 = *click1;
|
||
if handle == h1 {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
|
||
let l2 = self
|
||
.entity_index
|
||
.get(&self.all_entities, handle)
|
||
.and_then(|e| {
|
||
if let EntityType::Line(l) = e {
|
||
Some(l.clone())
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
|
||
if let Some(l2) = l2 {
|
||
match compute_chamfer(&l1, click1, self.dist1, &l2, click, self.dist2) {
|
||
Some((new_l1, new_l2, chamfer_line)) => {
|
||
// Single mode adds the chamfer line as a new entity;
|
||
// Multiple folds it into the first line's replacement
|
||
// so the command can carry on.
|
||
if self.multiple {
|
||
self.commit(vec![
|
||
(h1, vec![new_l1, chamfer_line]),
|
||
(handle, vec![new_l2]),
|
||
])
|
||
} else {
|
||
CmdResult::ReplaceMany(
|
||
vec![(h1, vec![new_l1]), (handle, vec![new_l2])],
|
||
vec![chamfer_line],
|
||
)
|
||
}
|
||
}
|
||
None => CmdResult::NeedPoint,
|
||
}
|
||
} else {
|
||
CmdResult::NeedPoint
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_hover_entity(&mut self, handle: Handle, pt: DVec3) -> Vec<WireModel> {
|
||
if handle.is_null() {
|
||
return vec![];
|
||
}
|
||
let click = [pt.x as f64, pt.y as f64];
|
||
|
||
match &self.step {
|
||
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => return vec![],
|
||
ChamferStep::First => {
|
||
let pts = self
|
||
.entity_index
|
||
.get(&self.all_entities, handle)
|
||
.and_then(|e| match e {
|
||
EntityType::Line(l) => Some(line_pts(l)),
|
||
EntityType::LwPolyline(p) => Some(lwpoly_seg_hover_pts(p, click)),
|
||
_ => None,
|
||
});
|
||
if let Some(pts) = pts {
|
||
vec![WireModel::solid(
|
||
"chamfer_hover".into(),
|
||
pts,
|
||
WireModel::CYAN,
|
||
false,
|
||
)]
|
||
} else {
|
||
vec![]
|
||
}
|
||
}
|
||
ChamferStep::Second { l1, click1, .. } => {
|
||
let l1 = l1.clone();
|
||
let click1 = *click1;
|
||
let l2 = self
|
||
.entity_index
|
||
.get(&self.all_entities, handle)
|
||
.and_then(|e| {
|
||
if let EntityType::Line(l) = e {
|
||
Some(l.clone())
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
if let Some(l2) = l2 {
|
||
if let Some((new_l1, new_l2, cline)) =
|
||
compute_chamfer(&l1, click1, self.dist1, &l2, click, self.dist2)
|
||
{
|
||
return vec![
|
||
WireModel::solid(
|
||
"chamfer_l1".into(),
|
||
entity_pts(&new_l1),
|
||
WireModel::CYAN,
|
||
false,
|
||
),
|
||
WireModel::solid(
|
||
"chamfer_l2".into(),
|
||
entity_pts(&new_l2),
|
||
WireModel::CYAN,
|
||
false,
|
||
),
|
||
WireModel::solid(
|
||
"chamfer_line".into(),
|
||
entity_pts(&cline),
|
||
WireModel::CYAN,
|
||
false,
|
||
),
|
||
];
|
||
}
|
||
}
|
||
vec![]
|
||
}
|
||
ChamferStep::SecondPoly { h1, poly, click1 } => {
|
||
if handle != *h1 {
|
||
return vec![];
|
||
}
|
||
if let Some(new_poly) =
|
||
chamfer_lwpoly_corner(poly, *click1, self.dist1, click, self.dist2)
|
||
{
|
||
return vec![WireModel::solid(
|
||
"chamfer_poly".into(),
|
||
entity_pts(&new_poly),
|
||
WireModel::CYAN,
|
||
false,
|
||
)];
|
||
}
|
||
vec![]
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||
CmdResult::NeedPoint
|
||
}
|
||
fn on_enter(&mut self) -> CmdResult {
|
||
CmdResult::Cancel
|
||
}
|
||
fn on_escape(&mut self) -> CmdResult {
|
||
CmdResult::Cancel
|
||
}
|
||
}
|
||
|
||
|
||
// ── Autocomplete registry ─────────────────────────────────
|
||
inventory::submit!(crate::command::CommandRegistration { names: &["CHAMFER"] }); // ChamferCommand
|
||
inventory::submit!(crate::command::CommandRegistration { names: &["FILLET"] }); // FilletCommand
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn line(x1: f64, y1: f64, x2: f64, y2: f64, handle: u64) -> EntityType {
|
||
let mut line = LineEnt::from_coords(x1, y1, 0.0, x2, y2, 0.0);
|
||
line.common.handle = Handle::new(handle);
|
||
EntityType::Line(line)
|
||
}
|
||
|
||
fn keywords(cmd: &dyn CadCommand) -> Vec<String> {
|
||
cmd.options().into_iter().map(|o| o.keyword).collect()
|
||
}
|
||
|
||
/// The upper half of a radius-5 circle at (5, 0), scaled about the origin
|
||
/// by an infinite factor: the center becomes (inf, NaN).
|
||
fn overflowed_arc(sweep_sign: f64) -> ArcEnt {
|
||
let mut arc = ArcEnt::new();
|
||
arc.center = codec::types::Vector3::new(f64::INFINITY, f64::NAN, 0.0);
|
||
arc.radius = f64::INFINITY;
|
||
arc.start_angle = 0.0;
|
||
arc.end_angle = sweep_sign * std::f64::consts::PI;
|
||
arc
|
||
}
|
||
|
||
#[test]
|
||
fn zero_radius_fillet_rejects_non_finite_geometry() {
|
||
let line = LineEnt::from_coords(f64::NAN, f64::NAN, 0.0, f64::INFINITY, f64::INFINITY, 0.0);
|
||
let arc = overflowed_arc(1.0);
|
||
assert!(fillet_line_arc(&line, [5.0, 5.0], &arc, [5.0, 5.0], 0.0, 0.0).is_none());
|
||
let other = overflowed_arc(-1.0);
|
||
assert!(fillet_arc_arc(&arc, [5.0, 5.0], &other, [5.0, -5.0], 0.0, 0.0).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn fillet_undo_option_appears_after_a_fillet_and_refreshes_from_document() {
|
||
let mut command = FilletCommand::new(
|
||
1.0,
|
||
vec![line(0.0, 0.0, 10.0, 0.0, 1), line(0.0, 0.0, 0.0, 10.0, 2)],
|
||
);
|
||
assert_eq!(keywords(&command), ["P", "R"]);
|
||
assert!(command.on_text_input("U").is_none(), "nothing to undo yet");
|
||
command.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0));
|
||
assert!(matches!(
|
||
command.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)),
|
||
CmdResult::ReplaceManyContinue(_)
|
||
));
|
||
assert_eq!(keywords(&command), ["U", "P", "R"]);
|
||
assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument)));
|
||
assert_eq!(keywords(&command), ["P", "R"]);
|
||
// The host hands the restored document back; the cache follows it.
|
||
let mut doc = codec::CadDocument::new();
|
||
let _ = doc.add_entity(line(0.0, 0.0, 20.0, 0.0, 7));
|
||
command.on_document_undone(&doc);
|
||
assert_eq!(command.all_entities.len(), 1);
|
||
assert_eq!(command.all_entities[0].common().handle, Handle::new(7));
|
||
}
|
||
|
||
#[test]
|
||
fn chamfer_multiple_keeps_going_and_single_ends() {
|
||
let lines = || vec![line(0.0, 0.0, 10.0, 0.0, 1), line(0.0, 0.0, 0.0, 10.0, 2)];
|
||
let mut single = ChamferCommand::new(1.0, lines());
|
||
assert_eq!(keywords(&single), ["D", "M"]);
|
||
single.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0));
|
||
assert!(matches!(
|
||
single.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)),
|
||
CmdResult::ReplaceMany(..)
|
||
));
|
||
|
||
let mut multi = ChamferCommand::new(1.0, lines());
|
||
assert!(matches!(multi.on_text_input("M"), Some(CmdResult::NeedPoint)));
|
||
assert_eq!(keywords(&multi), ["D"], "Multiple is not offered twice");
|
||
multi.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0));
|
||
match multi.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)) {
|
||
CmdResult::ReplaceManyContinue(replacements) => {
|
||
// The chamfer line rides along with the first line's replacement.
|
||
assert_eq!(replacements[0].1.len(), 2);
|
||
assert_eq!(replacements[1].1.len(), 1);
|
||
}
|
||
_ => panic!("multiple mode should keep the command active"),
|
||
}
|
||
assert_eq!(keywords(&multi), ["U", "D"]);
|
||
assert!(matches!(multi.on_text_input("U"), Some(CmdResult::UndoDocument)));
|
||
}
|
||
|
||
#[test]
|
||
fn fillet_continues_with_replacement_entities() {
|
||
let first = Handle::new(1);
|
||
let second = Handle::new(2);
|
||
let mut command = FilletCommand::new(
|
||
1.0,
|
||
vec![line(0.0, 0.0, 10.0, 0.0, 1), line(0.0, 0.0, 0.0, 10.0, 2)],
|
||
);
|
||
|
||
assert!(matches!(
|
||
command.on_entity_pick(first, DVec3::new(5.0, 0.0, 0.0)),
|
||
CmdResult::NeedPoint
|
||
));
|
||
let replacements = match command.on_entity_pick(second, DVec3::new(0.0, 5.0, 0.0)) {
|
||
CmdResult::ReplaceManyContinue(replacements) => replacements,
|
||
_ => panic!("fillet should replace entities and stay active"),
|
||
};
|
||
|
||
let mut next_handle = 10;
|
||
for (old, entities) in replacements {
|
||
let handles: Vec<_> = entities
|
||
.iter()
|
||
.map(|_| {
|
||
let handle = Handle::new(next_handle);
|
||
next_handle += 1;
|
||
handle
|
||
})
|
||
.collect();
|
||
command.on_entity_replaced(old, &handles);
|
||
}
|
||
|
||
assert!(matches!(&command.step, FilletStep::First));
|
||
assert!(matches!(
|
||
command.on_entity_pick(Handle::new(10), DVec3::new(5.0, 0.0, 0.0)),
|
||
CmdResult::NeedPoint
|
||
));
|
||
assert!(matches!(&command.step, FilletStep::Second { .. }));
|
||
}
|
||
}
|