forked from 1b-agent/opencadstudio-upstream
fix(trim): trim circles into arcs, fix arc/circle preview plane (#184)
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 <[email protected]>
This commit is contained in:
1 parent
244b410784
commit
c7ecabffb7
1 file changed
+146
-6
@@ -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<EntityType> {
|
||||
.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<EntityType> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user