From c7ecabffb73742fd15ed2353d710f4b4fe70e9d2 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 26 Jun 2026 07:25:02 +0300 Subject: [PATCH] fix(trim): trim circles into arcs, fix arc/circle preview plane (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRIM ignored circles: the target dispatch handled Line/Arc/Ellipse/ Spline/Ray/XLine/LwPolyline but had no Circle arm, so clicking a circle fell through to a no-op. Add `trim_circle()` — a full circle needs ≥2 boundary crossings; the arc segment under the click is removed and the circle becomes a single Arc (center/radius/thickness/normal preserved). Wire it into both the pick (apply) and hover-preview paths. Also fix `arc_pts`, which emitted points as [x, y, cy+r·sin] — the XZ plane — so arc and circle previews drew in the wrong plane (obvious on a full circle). Match the XY convention used by line_pts and fillet's arc_pts: [cx+r·cos, cy+r·sin, z]; callers now pass center.z. Co-Authored-By: Claude Opus 4.8 --- src/modules/draw/modify/trim.rs | 152 ++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 6 deletions(-) diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index b8eea2754..7005cd70e 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -11,8 +11,8 @@ use std::f64::consts::TAU; use acadrust::entities::{ - Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, LwVertex, Ray as RayEnt, - Spline as SplineEnt, XLine as XLineEnt, + Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, + LwVertex, Ray as RayEnt, Spline as SplineEnt, XLine as XLineEnt, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; @@ -1249,6 +1249,50 @@ fn trim_arc(orig: &ArcEnt, ts: &[f64], t_click: f64) -> Vec { .collect() } +/// Trim a clicked Circle. A full circle has no endpoints, so it needs ≥2 +/// boundary crossings: the arc segment containing the click is removed and the +/// circle becomes a single Arc spanning the rest. With fewer than two crossings +/// there is nothing to cut, so an empty result leaves the circle unchanged. +/// +/// `ts` are the cut parameters in [0,1) around the circle (angle / TAU), sorted. +fn trim_circle(orig: &CircleEnt, ts: &[f64], t_click: f64) -> Vec { + if ts.len() < 2 { + return vec![]; + } + let tc = t_click.rem_euclid(1.0); + // Find the cyclic gap (ta, tb) between adjacent cuts that holds the click; + // the last gap wraps past 1.0 back to the first cut. + let n = ts.len(); + let mut removed: Option<(f64, f64)> = None; + for i in 0..n { + let ta = ts[i]; + let tb = if i + 1 < n { ts[i + 1] } else { ts[0] + 1.0 }; + if (tc >= ta - 1e-9 && tc <= tb + 1e-9) + || (tc + 1.0 >= ta - 1e-9 && tc + 1.0 <= tb + 1e-9) + { + removed = Some((ta, tb)); + break; + } + } + let (ta, tb) = match removed { + Some(g) => g, + None => return vec![], + }; + + // Surviving arc runs CCW from the far edge of the removed gap all the way + // around to its near edge. + let mut arc = ArcEnt::new(); + arc.common = orig.common.clone(); + arc.common.handle = Handle::NULL; + arc.center = orig.center; + arc.radius = orig.radius; + arc.thickness = orig.thickness; + arc.normal = orig.normal; + arc.start_angle = (tb % 1.0) * TAU; + arc.end_angle = (ta % 1.0) * TAU; + vec![EntityType::Arc(arc)] +} + /// Trim a clicked LwPolyline: remove the portion containing the click, bounded /// by the nearest boundary intersections on each side. A closed polyline needs /// ≥2 cuts and becomes an open polyline (the surviving arc); an open one yields @@ -1883,7 +1927,7 @@ fn line_pts(l: &LineEnt) -> Vec<[f32; 3]> { ] } -fn arc_pts(cx: f64, cy: f64, r: f64, a0: f64, a1: f64, y: f64) -> Vec<[f32; 3]> { +fn arc_pts(cx: f64, cy: f64, r: f64, a0: f64, a1: f64, z: f64) -> Vec<[f32; 3]> { let span = { let s = norm(a1) - norm(a0); if s <= 0.0 { @@ -1898,8 +1942,8 @@ fn arc_pts(cx: f64, cy: f64, r: f64, a0: f64, a1: f64, y: f64) -> Vec<[f32; 3]> let ang = norm(a0) + span * (i as f64 / steps as f64); [ (cx + r * ang.cos()) as f32, - y as f32, (cy + r * ang.sin()) as f32, + z as f32, ] }) .collect() @@ -1914,7 +1958,7 @@ fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> { a.radius, a.start_angle, a.end_angle, - a.center.y, + a.center.z, ), EntityType::Ellipse(e) => { let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); @@ -2033,6 +2077,21 @@ impl CadCommand for TrimCommand { let t_click = arc_t(click_angle, a0, a1); Some(trim_arc(a, &ts, t_click)) } + Some(EntityType::Circle(c)) => { + let cx = c.center.x; + let cy = c.center.y; + let ts = arc_seg_ts(cx, cy, c.radius, 0.0, TAU, handle, &self.geos); + if ts.len() < 2 { + return CmdResult::NeedPoint; + } + let click_angle = (pt.y as f64 - cy).atan2(pt.x as f64 - cx); + let t_click = arc_t(click_angle, 0.0, TAU); + let survivors = trim_circle(c, &ts, t_click); + if survivors.is_empty() { + return CmdResult::NeedPoint; + } + Some(survivors) + } Some(EntityType::Ray(r)) => { // Virtual segment: base → base + dir * TRIM_EXTENT (t ∈ [0,1]) let bx = r.base_point.x; @@ -2225,7 +2284,34 @@ impl CadCommand for TrimCommand { let click_angle = (pt.y as f64 - cy).atan2(pt.x as f64 - cx); let t_click = arc_t(click_angle, a0, a1); let survivors = trim_arc(a, &ts, t_click); - let orig_pts = arc_pts(cx, cy, a.radius, a0, a1, a.center.y); + let orig_pts = arc_pts(cx, cy, a.radius, a0, a1, a.center.z); + let removed = WireModel::solid("trim_rm".into(), orig_pts, DIM_RED, false); + let mut out = vec![removed]; + for (i, e) in survivors.iter().enumerate() { + let pts = entity_pts(e); + out.push(WireModel::solid( + format!("trim_keep_{i}"), + pts, + WireModel::CYAN, + false, + )); + } + out + } + Some(EntityType::Circle(c)) => { + let cx = c.center.x; + let cy = c.center.y; + let ts = arc_seg_ts(cx, cy, c.radius, 0.0, TAU, handle, &self.geos); + if ts.len() < 2 { + return vec![]; + } + let click_angle = (pt.y as f64 - cy).atan2(pt.x as f64 - cx); + let t_click = arc_t(click_angle, 0.0, TAU); + let survivors = trim_circle(c, &ts, t_click); + if survivors.is_empty() { + return vec![]; + } + let orig_pts = arc_pts(cx, cy, c.radius, 0.0, TAU, c.center.z); let removed = WireModel::solid("trim_rm".into(), orig_pts, DIM_RED, false); let mut out = vec![removed]; for (i, e) in survivors.iter().enumerate() { @@ -2658,3 +2744,57 @@ impl CadCommand for ExtendCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["EX", "EXTEND"] }); // ExtendCommand inventory::submit!(crate::command::CommandRegistration { names: &["TR", "TRIM"] }); // TrimCommand + +#[cfg(test)] +mod tests { + use super::*; + use std::f64::consts::PI; + + fn circle(r: f64) -> CircleEnt { + let mut c = CircleEnt::new(); + c.center = Vector3::new(0.0, 0.0, 0.0); + c.radius = r; + c + } + + /// A circle crossed by a horizontal cutter (cuts at angle 0 and π, i.e. + /// t = 0.0 and 0.5) becomes a single Arc; clicking the top half removes it + /// and leaves the bottom half (π → 0 CCW), clicking the bottom does the + /// reverse. + #[test] + fn trims_circle_into_arc_on_clicked_half() { + let c = circle(10.0); + let ts = [0.0, 0.5]; + + // Click top (t = 0.25) → removes top, keeps bottom half (start π, end 0). + let top = trim_circle(&c, &ts, 0.25); + assert_eq!(top.len(), 1, "circle should trim to exactly one arc"); + match &top[0] { + EntityType::Arc(a) => { + assert!((norm(a.start_angle) - PI).abs() < 1e-9); + assert!(norm(a.end_angle).abs() < 1e-9); + assert_eq!(a.radius, 10.0); + assert!(a.common.handle.is_null()); + } + _ => panic!("expected an Arc"), + } + + // Click bottom (t = 0.75) → keeps top half (start 0, end π). + let bottom = trim_circle(&c, &ts, 0.75); + match &bottom[0] { + EntityType::Arc(a) => { + assert!(norm(a.start_angle).abs() < 1e-9); + assert!((norm(a.end_angle) - PI).abs() < 1e-9); + } + _ => panic!("expected an Arc"), + } + } + + /// Fewer than two crossings can't cut a closed circle, so it is left as-is. + #[test] + fn circle_with_one_or_zero_cuts_is_left_unchanged() { + let c = circle(5.0); + assert!(trim_circle(&c, &[], 0.3).is_empty()); + assert!(trim_circle(&c, &[0.4], 0.3).is_empty()); + } +}