// Fillet / Chamfer — ribbon definitions + full command implementations. // // FILLET (F): // Pick two lines. Command finds their intersection, computes a tangent arc // of radius R, trims both lines to the tangent points, and inserts the arc. // R=0 just extends/trims both lines to the exact intersection (sharp corner). // // CHAMFER (CHA): // Pick two lines. Command finds their intersection, backs off dist1 along // line 1 and dist2 along line 2, trims, and inserts a straight chamfer line. use acadrust::entities::{Arc as ArcEnt, Line as LineEnt}; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::Vec3; use crate::command::{CadCommand, CmdResult}; use crate::modules::home::defaults; use crate::modules::IconKind; use crate::scene::wire_model::WireModel; // ── 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 // ══════════════════════════════════════════════════════════════════════════ /// Intersect two infinite lines. Returns (t on L1, u on L2). fn ll( ax: f64, ay: f64, dx: f64, dy: f64, cx: f64, cy: f64, ex: f64, ey: f64, ) -> Option<(f64, f64)> { let det = dx * ey - dy * ex; if det.abs() < 1e-10 { return None; } let t = ((cx - ax) * ey - (cy - ay) * ex) / det; let u = ((cx - ax) * dy - (cy - ay) * dx) / det; Some((t, u)) } /// 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 ───────────────────────────────────────────────────────────────── /// 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)> { let (p1, _p2, u1, _len1) = line_geom(l1); let (p3, _p4, u2, _len2) = line_geom(l2); // Intersection of infinite 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 s1 = project_click(click1, [px, py], u1); // positive = along 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]] }; // 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(); // Lines are parallel / anti-parallel if angle < 1e-6 || (angle - std::f64::consts::PI).abs() < 1e-6 { return None; } let half = angle / 2.0; let z = l1.start.z; if radius < 1e-9 { // r = 0: just extend/trim both lines to the intersection let (new_l1, new_l2) = trim_to_point(l1, t_p, p1, u1, l2, u_p, p3, u2)?; return Some((EntityType::Line(new_l1), EntityType::Line(new_l2), None)); } // Distance from P to tangent points let d = radius / half.tan(); // Tangent points let t1 = [px + d * dir1[0], py + d * dir1[1]]; let t2 = [px + d * dir2[0], py + d * dir2[1]]; // Arc center: along bisector of dir1+dir2, distance = r / sin(half) let bx = dir1[0] + dir2[0]; let by = dir1[1] + dir2[1]; let blen = (bx * bx + by * by).sqrt(); if blen < 1e-10 { return None; } let arc_dist = radius / half.sin(); let arc_cx = px + arc_dist * bx / blen; let arc_cy = py + arc_dist * by / blen; // Arc angles in degrees let a_start_deg = (t1[1] - arc_cy).atan2(t1[0] - arc_cx).to_degrees(); let a_end_deg = (t2[1] - arc_cy).atan2(t2[0] - arc_cx).to_degrees(); // Pick CCW direction that fills the concave corner let cross = dir1[0] * dir2[1] - dir1[1] * dir2[0]; let (start_deg, end_deg) = if cross <= 0.0 { (a_start_deg, a_end_deg) } else { (a_end_deg, a_start_deg) }; // Trim l1 to T1 and l2 to T2 let new_l1 = trim_to_xy(l1, t_p, t1, dir1, p1, u1)?; let new_l2 = trim_to_xy(l2, u_p, t2, dir2, p3, u2)?; // Build arc entity let mut arc = ArcEnt::new(); arc.common = l1.common.clone(); arc.common.handle = Handle::NULL; arc.center = Vector3::new(arc_cx, arc_cy, z); arc.radius = radius; arc.start_angle = start_deg; arc.end_angle = end_deg; Some(( EntityType::Line(new_l1), EntityType::Line(new_l2), Some(EntityType::Arc(arc)), )) } /// Trim a line's parameter to an intersection t on the same side as dir (keep side). fn trim_to_xy( orig: &LineEnt, t_isect: f64, tangent: [f64; 2], dir: [f64; 2], p1: [f64; 2], unit: [f64; 2], ) -> Option { let z = orig.start.z; let mut l = orig.clone(); l.common.handle = Handle::NULL; // t_tangent: parameter of the tangent point along the line from start let t_tan = (tangent[0] - p1[0]) * unit[0] + (tangent[1] - p1[1]) * unit[1]; // dir is positive along unit → we keep the portion BEYOND t_tan 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 len = { let dx = orig.end.x - orig.start.x; let dy = orig.end.y - orig.start.y; (dx * dx + dy * dy).sqrt().max(1e-12) }; 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); } let _ = (t_isect, len, t_tan); // used implicitly via `dot` Some(l) } /// Trim both lines exactly to their intersection point (r=0 case). fn trim_to_point( l1: &LineEnt, t_p: f64, p1: [f64; 2], u1: [f64; 2], l2: &LineEnt, u_p: f64, _p3: [f64; 2], _u2: [f64; 2], ) -> Option<(LineEnt, LineEnt)> { let px = p1[0] + t_p * u1[0]; let py = p1[1] + t_p * u1[1]; let z1 = l1.start.z; let z2 = l2.start.z; // For l1: if t_p is past the midpoint, keep start…P; else keep P…end // We use the same "which end is P closer to" logic let mut ll1 = l1.clone(); ll1.common.handle = Handle::NULL; let mut ll2 = l2.clone(); ll2.common.handle = Handle::NULL; if t_p >= 0.0 { ll1.end = Vector3::new(px, py, z1); } else { ll1.start = Vector3::new(px, py, z1); } if u_p >= 0.0 { ll2.end = Vector3::new(px, py, z2); } else { ll2.start = Vector3::new(px, py, z2); } Some((ll1, ll2)) } // ── Point-generation helpers ────────────────────────────────────────────── fn line_pts(l: &LineEnt) -> Vec<[f32; 3]> { vec![ [l.start.x as f32, l.start.y as f32, l.start.z as f32], [l.end.x as f32, l.end.y as f32, l.end.z as f32], ] } fn arc_pts(cx: f64, cy: f64, r: f64, a0_deg: f64, a1_deg: f64, z: f64) -> Vec<[f32; 3]> { use std::f64::consts::TAU; let fn_norm = |a: f64| -> f64 { ((a % TAU) + TAU) % TAU }; let a0 = a0_deg.to_radians(); let a1 = a1_deg.to_radians(); let span = { let s = fn_norm(a1) - fn_norm(a0); if s <= 0.0 { s + TAU } else { s } }; let steps = (span.abs() * 20.0).ceil().max(4.0) as usize; (0..=steps) .map(|i| { let ang = fn_norm(a0) + span * (i as f64 / steps as f64); [ (cx + r * ang.cos()) as f32, (cy + r * ang.sin()) as f32, z as f32, ] }) .collect() } 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, ), _ => vec![], } } // ── 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, t_p, c1, dir1, p1, u1)?; let new_l2 = trim_to_xy(l2, u_p, c2, dir2, p3, 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, WaitingForRadius, Second { h1: Handle, l1: LineEnt, click1: [f64; 2], }, } pub struct FilletCommand { radius: f64, step: FilletStep, all_entities: Vec, } impl FilletCommand { pub fn new(radius: f32, all_entities: Vec) -> Self { Self { radius: radius as f64, step: FilletStep::First, all_entities, } } } impl CadCommand for FilletCommand { fn name(&self) -> &'static str { "FILLET" } fn prompt(&self) -> String { match &self.step { FilletStep::First => format!( "FILLET Select first line [R={:.4} | type R to change]:", self.radius ), FilletStep::WaitingForRadius => format!( "FILLET Enter fillet radius <{:.4}>:", self.radius ), FilletStep::Second { .. } => { format!("FILLET Select second line [R={:.4}]:", self.radius) } } } fn wants_text_input(&self) -> bool { matches!(self.step, FilletStep::WaitingForRadius) } fn on_text_input(&mut self, text: &str) -> Option { match self.step { FilletStep::WaitingForRadius => { let t = text.trim(); if t.is_empty() { // Keep current radius, return to First self.step = FilletStep::First; return Some(CmdResult::NeedPoint); } if let Ok(v) = t.replace(',', ".").parse::() { if v >= 0.0 { self.radius = v; defaults::set_fillet_radius(v as f32); } self.step = FilletStep::First; 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(); // "R" alone → enter sub-step to collect radius if upper == "R" { self.step = FilletStep::WaitingForRadius; return Some(CmdResult::NeedPoint); } // "R 5.0" inline shorthand if upper.starts_with('R') { let body = t[1..].trim(); if let Ok(v) = body.replace(',', ".").parse::() { if v >= 0.0 { self.radius = v; defaults::set_fillet_radius(v as f32); } return Some(CmdResult::NeedPoint); } // "R" + invalid body → enter sub-step self.step = FilletStep::WaitingForRadius; return Some(CmdResult::NeedPoint); } None } } } fn needs_entity_pick(&self) -> bool { !matches!(self.step, FilletStep::WaitingForRadius) } fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } let click = [pt.x as f64, pt.y as f64]; match &self.step { FilletStep::WaitingForRadius => return CmdResult::NeedPoint, FilletStep::First => { // Must be a line let l1 = self .all_entities .iter() .find(|e| e.common().handle == handle) .and_then(|e| { if let EntityType::Line(l) = e { Some(l.clone()) } else { None } }); if let Some(l) = l1 { self.step = FilletStep::Second { h1: handle, l1: l, click1: click, }; CmdResult::NeedPoint } else { CmdResult::NeedPoint // not a line — ignore } } FilletStep::Second { h1, l1, click1 } => { let h1 = *h1; let l1 = l1.clone(); let click1 = *click1; if handle == h1 { return CmdResult::NeedPoint; } let l2 = self .all_entities .iter() .find(|e| e.common().handle == handle) .and_then(|e| { if let EntityType::Line(l) = e { Some(l.clone()) } else { None } }); if let Some(l2) = l2 { match compute_fillet(&l1, click1, &l2, click, self.radius) { Some((new_l1, new_l2, maybe_arc)) => { let mut additions = vec![]; if let Some(arc) = maybe_arc { additions.push(arc); } CmdResult::ReplaceMany( vec![(h1, vec![new_l1]), (handle, vec![new_l2])], additions, ) } None => { // Could not compute fillet (parallel lines, etc.) CmdResult::NeedPoint } } } else { CmdResult::NeedPoint } } } } fn on_hover_entity(&mut self, handle: Handle, pt: Vec3) -> Vec { if handle.is_null() { return vec![]; } let click = [pt.x as f64, pt.y as f64]; match &self.step { FilletStep::WaitingForRadius => vec![], FilletStep::First => { // Highlight hovered line in cyan let pts = self .all_entities .iter() .find(|e| e.common().handle == handle) .and_then(|e| { if let EntityType::Line(l) = e { Some(line_pts(l)) } else { None } }); if let Some(pts) = pts { vec![WireModel::solid( "fillet_hover".into(), pts, WireModel::CYAN, false, )] } else { vec![] } } FilletStep::Second { l1, click1, .. } => { let l1 = l1.clone(); let click1 = *click1; if handle.is_null() { return vec![]; } let l2 = self .all_entities .iter() .find(|e| e.common().handle == 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, maybe_arc)) = compute_fillet(&l1, click1, &l2, click, self.radius) { let mut out = vec![ WireModel::solid( "fillet_l1".into(), entity_pts(&new_l1), WireModel::CYAN, false, ), WireModel::solid( "fillet_l2".into(), entity_pts(&new_l2), 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: Vec3) -> CmdResult { CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } } // ══════════════════════════════════════════════════════════════════════════ // ChamferCommand // ══════════════════════════════════════════════════════════════════════════ enum ChamferStep { First, WaitingForDist1, WaitingForDist2, Second { h1: Handle, l1: LineEnt, click1: [f64; 2], }, } pub struct ChamferCommand { dist1: f64, dist2: f64, step: ChamferStep, all_entities: Vec, } impl ChamferCommand { pub fn new(dist: f32, all_entities: Vec) -> Self { Self { dist1: dist as f64, dist2: defaults::get_chamfer_dist2() as f64, step: ChamferStep::First, all_entities, } } } impl CadCommand for ChamferCommand { fn name(&self) -> &'static str { "CHAMFER" } fn prompt(&self) -> String { match &self.step { ChamferStep::First => format!( "CHAMFER Select first line [D1={:.4} D2={:.4} | type D to change]:", self.dist1, self.dist2 ), ChamferStep::WaitingForDist1 => format!( "CHAMFER Enter first chamfer distance <{:.4}>:", self.dist1 ), ChamferStep::WaitingForDist2 => format!( "CHAMFER Enter second chamfer distance <{:.4}>:", self.dist2 ), ChamferStep::Second { .. } => format!( "CHAMFER Select second line [D1={:.4} D2={:.4}]:", self.dist1, self.dist2 ), } } fn wants_text_input(&self) -> bool { matches!( self.step, ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 ) } fn on_text_input(&mut self, text: &str) -> Option { 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 Ok(v) = t.replace(',', ".").parse::() { self.dist1 = v.max(0.0); defaults::set_chamfer_dist1(self.dist1 as f32); 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, return to First self.step = ChamferStep::First; return Some(CmdResult::NeedPoint); } if let Ok(v) = t.replace(',', ".").parse::() { self.dist2 = v.max(0.0); defaults::set_chamfer_dist2(self.dist2 as f32); self.step = ChamferStep::First; return Some(CmdResult::NeedPoint); } // Invalid — stay and re-prompt Some(CmdResult::NeedPoint) } ChamferStep::First | ChamferStep::Second { .. } => { let t = text.trim(); let upper = t.to_uppercase(); // "D" alone → enter sub-step to collect distances if upper == "D" { self.step = ChamferStep::WaitingForDist1; return Some(CmdResult::NeedPoint); } // "D 5.0" or "D 5.0 3.0" inline shorthand if upper.starts_with('D') { let body = t[1..].trim(); let parts: Vec = body .split_whitespace() .filter_map(|s| s.replace(',', ".").parse::().ok()) .collect(); if !parts.is_empty() { if let Some(&v) = parts.first() { self.dist1 = v.max(0.0); defaults::set_chamfer_dist1(self.dist1 as f32); } if let Some(&v) = parts.get(1) { self.dist2 = v.max(0.0); defaults::set_chamfer_dist2(self.dist2 as f32); } else { self.dist2 = self.dist1; defaults::set_chamfer_dist2(self.dist2 as f32); } return Some(CmdResult::NeedPoint); } // "D" + invalid body → enter sub-step self.step = ChamferStep::WaitingForDist1; return Some(CmdResult::NeedPoint); } None } } } fn needs_entity_pick(&self) -> bool { !matches!( self.step, ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 ) } fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> 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 => { let l1 = self .all_entities .iter() .find(|e| e.common().handle == handle) .and_then(|e| { if let EntityType::Line(l) = e { Some(l.clone()) } else { None } }); if let Some(l) = l1 { self.step = ChamferStep::Second { h1: handle, l1: l, click1: click, }; CmdResult::NeedPoint } else { 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 .all_entities .iter() .find(|e| e.common().handle == 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)) => 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: Vec3) -> Vec { 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 .all_entities .iter() .find(|e| e.common().handle == handle) .and_then(|e| { if let EntityType::Line(l) = e { Some(line_pts(l)) } else { 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 .all_entities .iter() .find(|e| e.common().handle == 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![] } } } fn on_point(&mut self, _pt: Vec3) -> CmdResult { CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } }