diff --git a/Cargo.lock b/Cargo.lock index 8a9a1268..720ac707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/ramox81/cadcodec.git?rev=6dcda1a#6dcda1adc5a99edb1e15fcdeb206c6d65d073828" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=dd4cd16#dd4cd16be0e0aec0e23b66d3d27311316883662a" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=f36bdf6#f36bdf6c967dcdb8d0e740869527090a569dae1d" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=35abd0f#35abd0fbabe224040bbe0e07055d6d172cc61c67" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index a7f4bbf8..1a2af18b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "f36bdf6", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd4cd16", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "35abd0f", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index e367ad7f..eb719dbe 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd4cd16", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -37,7 +37,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd4cd16", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 739bc690..df67d1ec 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd4cd16", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index de6970dd..7c69d400 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -764,7 +764,10 @@ impl OpenCADStudio { // leave the anchor untouched. (#327) if matches!( entity, - acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_) + acadrust::EntityType::Line(_) + | acadrust::EntityType::Arc(_) + | acadrust::EntityType::LwPolyline(_) + | acadrust::EntityType::Polyline2D(_) ) { self.update_cont_anchor(&entity); } diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 4ffcc4d3..6114e3b0 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -300,8 +300,8 @@ impl OpenCADStudio { } "ARC" => { - use crate::modules::draw::draw::arc::ArcCommand; - let new_cmd = ArcCommand::new(); + use crate::modules::draw::draw::arc::Arc3PCommand; + let new_cmd = Arc3PCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -311,6 +311,12 @@ impl OpenCADStudio { self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "ARC_CSE" => { + use crate::modules::draw::draw::arc::ArcCommand; + let new_cmd = ArcCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } "ARC_SCE" => { use crate::modules::draw::draw::arc::ArcSCECommand; let new_cmd = ArcSCECommand::new(); diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 8dffa04b..3f2ffaff 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -22,13 +22,8 @@ fn to_render(arc: &Arc) -> RenderEntity { let ea = arc.end_angle; let normal = (arc.normal.x, arc.normal.y, arc.normal.z); - // Compute OCS basis vectors for this entity's normal. let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); - - // Arc centre in WCS. let (cwx, cwy, cwz) = crate::scene::view::transform::ocs_point_to_wcs((cx, cy, cz), normal); - - // Arc points in WCS: centre_wcs + r*cos(a)*Ax + r*sin(a)*Ay let arc_pt = |a: f64| { let (c, s) = (a.cos(), a.sin()); [ @@ -43,9 +38,13 @@ fn to_render(arc: &Arc) -> RenderEntity { // curves) deliberately emit no midpoint; see #34. let curve = crate::entities::curve::arc_curve(arc); let snap = crate::entities::curve::snap_from(&curve); - let tangent = TangentGeom::Circle { - center: [cwx as f32, cwy as f32, cwz as f32], - radius: r as f32, + let tangent = TangentGeom::Arc { + center: [cwx, cwy, cwz], + axis_x: [ax.0, ax.1, ax.2], + axis_y: [ay.0, ay.1, ay.2], + radius: r, + start_angle: sa, + end_angle: ea, }; if arc.thickness.abs() > 1e-10 { @@ -98,56 +97,14 @@ fn to_render(arc: &Arc) -> RenderEntity { } fn control_points(arc: &Arc) -> [glam::DVec3; 3] { - let center = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z); - let sweep = (arc.end_angle - arc.start_angle).rem_euclid(TAU); - let point = |angle: f64| { - center - + glam::DVec3::new( - arc.radius * angle.cos(), - arc.radius * angle.sin(), - 0.0, - ) - }; + let curve = crate::entities::curve::arc_curve(arc); [ - point(arc.start_angle), - point(arc.start_angle + sweep * 0.5), - point(arc.end_angle), + glam::DVec3::from_array(curve.point_at(0.0)), + glam::DVec3::from_array(curve.point_at(0.5)), + glam::DVec3::from_array(curve.point_at(1.0)), ] } -fn circumcircle( - a: glam::DVec3, - b: glam::DVec3, - c: glam::DVec3, -) -> Option<(glam::DVec3, f64)> { - // Work relative to the first point so large drawing coordinates do not - // lose the small differences that define the circle. - let ab = b - a; - let ac = c - a; - let bc = c - b; - let scale2 = ab - .length_squared() - .max(ac.length_squared()) - .max(bc.length_squared()); - if scale2 < 1.0e-18 { - return None; - } - let det = 2.0 * (ab.x * ac.y - ab.y * ac.x); - if det.abs() <= scale2 * 1.0e-12 { - return None; - } - let ab2 = ab.x * ab.x + ab.y * ab.y; - let ac2 = ac.x * ac.x + ac.y * ac.y; - let center = a - + glam::DVec3::new( - (ab2 * ac.y - ac2 * ab.y) / det, - (ab.x * ac2 - ac.x * ab2) / det, - 0.0, - ); - let radius = center.distance(a); - radius.is_finite().then_some((center, radius)) -} - pub(crate) fn refit_grips( arc: &mut Arc, original: &Arc, @@ -169,36 +126,35 @@ pub(crate) fn refit_grips( return false; } - let Some((center, radius)) = circumcircle(points[0], points[1], points[2]) else { + let plane = crate::entities::curve::arc_curve(original).plane; + let Some(a) = plane.project(points[0].to_array()) else { return false; }; - if radius <= 1.0e-9 { + let Some(b) = plane.project(points[1].to_array()) else { return false; - } - - let start = (points[0].y - center.y).atan2(points[0].x - center.x); - let middle = (points[1].y - center.y).atan2(points[1].x - center.x); - let end = (points[2].y - center.y).atan2(points[2].x - center.x); - let sweep = (end - start).rem_euclid(TAU); - let middle_sweep = (middle - start).rem_euclid(TAU); - // Crossing the two fixed points makes the three-point definition - // degenerate before it reverses. Keep the last valid preview instead of - // swapping the start and end grip identities under the cursor. - if sweep <= 1.0e-9 || middle_sweep > sweep + 1.0e-9 { + }; + let Some(c) = plane.project(points[2].to_array()) else { return false; - } + }; + let Some(fit) = cadkernel::geom2d::arc_through_points(a, b, c) else { + return false; + }; - arc.center.x = center.x; - arc.center.y = center.y; + arc.center.x = fit.centre[0]; + arc.center.y = fit.centre[1]; arc.center.z = original.center.z; - arc.radius = radius; - arc.start_angle = start; - arc.end_angle = end; + arc.radius = fit.radius; + arc.start_angle = fit.start_angle; + arc.end_angle = fit.end_angle; true } fn grips(arc: &Arc) -> Vec { - let ctr = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z); + let (x, y, z) = crate::scene::view::transform::ocs_point_to_wcs( + (arc.center.x, arc.center.y, arc.center.z), + (arc.normal.x, arc.normal.y, arc.normal.z), + ); + let ctr = glam::DVec3::new(x, y, z); let [start, middle, end] = control_points(arc); vec![ center_grip(0, ctr), @@ -215,7 +171,11 @@ fn properties(arc: &Arc) -> Vec { let sweep = (ea - sa).rem_euclid(TAU); let total_angle = sweep.to_degrees(); let arc_length = r * sweep; - let area = 0.5 * r * r * sweep; + let area = crate::entities::curve::arc_curve(arc) + .curve + .chord_closed_area() + .unwrap_or(0.0) + .abs(); let normal = (arc.normal.x, arc.normal.y, arc.normal.z); let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); @@ -240,9 +200,9 @@ fn properties(arc: &Arc) -> Vec { ro(t!("Start X").as_ref(), "start_x", format!("{sx:.4}")), ro(t!("Start Y").as_ref(), "start_y", format!("{sy:.4}")), ro(t!("Start Z").as_ref(), "start_z", format!("{sz:.4}")), - edit(t!("Center X").as_ref(), "center_x", arc.center.x), - edit(t!("Center Y").as_ref(), "center_y", arc.center.y), - edit(t!("Center Z").as_ref(), "center_z", arc.center.z), + edit(t!("Center X").as_ref(), "center_x", cwx), + edit(t!("Center Y").as_ref(), "center_y", cwy), + edit(t!("Center Z").as_ref(), "center_z", cwz), ro(t!("End X").as_ref(), "end_x", format!("{ex:.4}")), ro(t!("End Y").as_ref(), "end_y", format!("{ey:.4}")), ro(t!("End Z").as_ref(), "end_z", format!("{ez:.4}")), @@ -264,9 +224,23 @@ fn apply_geom_prop(arc: &mut Arc, field: &str, value: &str) { return; }; match field { - "center_x" => arc.center.x = v, - "center_y" => arc.center.y = v, - "center_z" => arc.center.z = v, + "center_x" | "center_y" | "center_z" => { + let normal = (arc.normal.x, arc.normal.y, arc.normal.z); + let (mut x, mut y, mut z) = crate::scene::view::transform::ocs_point_to_wcs( + (arc.center.x, arc.center.y, arc.center.z), + normal, + ); + match field { + "center_x" => x = v, + "center_y" => y = v, + "center_z" => z = v, + _ => {} + } + let (ox, oy, oz) = crate::scene::view::transform::wcs_point_to_ocs((x, y, z), normal); + arc.center.x = ox; + arc.center.y = oy; + arc.center.z = oz; + } "radius" if v > 0.0 => arc.radius = v, "start_angle" => arc.start_angle = v.to_radians(), "end_angle" => arc.end_angle = v.to_radians(), @@ -277,14 +251,22 @@ fn apply_geom_prop(arc: &mut Arc, field: &str, value: &str) { fn apply_grip(arc: &mut Arc, grip_id: usize, apply: GripApply) { match (grip_id, apply) { (0, GripApply::Translate(d)) => { - arc.center.x += d.x; - arc.center.y += d.y; - arc.center.z += d.z; + let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs( + (d.x, d.y, d.z), + (arc.normal.x, arc.normal.y, arc.normal.z), + ); + arc.center.x += x; + arc.center.y += y; + arc.center.z += z; } (0, GripApply::Absolute(p)) => { - arc.center.x = p[0]; - arc.center.y = p[1]; - arc.center.z = p[2]; + let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs( + (p[0], p[1], p[2]), + (arc.normal.x, arc.normal.y, arc.normal.z), + ); + arc.center.x = x; + arc.center.y = y; + arc.center.z = z; } (1..=3, GripApply::Absolute(p)) => { let original = arc.clone(); @@ -386,7 +368,11 @@ impl crate::entities::traits::Grippable for Arc { if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 { return None; } - let cursor_angle = (point.y - self.center.y).atan2(point.x - self.center.x); + let (x, y, _) = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + (self.normal.x, self.normal.y, self.normal.z), + ); + let cursor_angle = (y - self.center.y).atan2(x - self.center.x); let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); let desired_sweep = match grip_id { 1 => (self.end_angle - cursor_angle).rem_euclid(TAU), @@ -412,7 +398,9 @@ impl crate::entities::traits::Grippable for Arc { // Hold start_angle, derive new end_angle from arc length // = r * Δθ. let new_span = value / self.radius; - self.end_angle = self.start_angle + new_span; + if new_span < TAU - 1.0e-9 { + self.end_angle = self.start_angle + new_span; + } } A::Lengthen => { // Extend either end by `value` arc-length units along @@ -422,6 +410,11 @@ impl crate::entities::traits::Grippable for Arc { return; } let dtheta = value / self.radius; + let current = (self.end_angle - self.start_angle).rem_euclid(TAU); + let next = current + dtheta; + if next <= 1.0e-9 || next >= TAU - 1.0e-9 { + return; + } match grip_id { 1 => self.start_angle -= dtheta, 2 => self.end_angle += dtheta, @@ -450,26 +443,18 @@ impl crate::entities::traits::Transformable for Arc { impl crate::entities::traits::MassPropsCalc for acadrust::entities::Arc { fn mass_props(&self) -> crate::entities::traits::MassProps { - use std::f64::consts::TAU; - let r = self.radius; - let span = { - let s = (self.end_angle - self.start_angle).rem_euclid(TAU); - if s < 1e-6 { - TAU - } else { - s - } - }; - // Sector area (pie slice) - let area = 0.5 * r * r * span; - let arc_len = r * span; - // Centroid of arc (chord midpoint direction) - let mid_rad = self.start_angle + span / 2.0; + let curve = crate::entities::curve::arc_curve(self); + let area = curve.curve.chord_closed_area().unwrap_or(0.0).abs(); + let centroid = curve + .curve + .chord_closed_centroid() + .map(|point| curve.plane.point_at(point)) + .unwrap_or_else(|| curve.point_at(0.5)); crate::entities::traits::MassProps { area, - perimeter: arc_len, - cx: self.center.x + r * mid_rad.cos(), - cy: self.center.y + r * mid_rad.sin(), + perimeter: curve.length(), + cx: centroid[0], + cy: centroid[1], } } } diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index c2520708..80e8f2da 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -1,21 +1,9 @@ -// Arc tool — ribbon dropdown + all OpenCADStudio arc creation methods. -// -// Methods (dropdown order): -// ARC_3P — 3-Point (default) -// ARC_SCE — Start, Center, End -// ARC_SCA — Start, Center, Angle -// ARC_SCL — Start, Center, Length of chord -// ARC_SEA — Start, End, Angle (sagitta-based pick) -// ARC_SED — Start, End, Direction (tangent at start) -// ARC_SER — Start, End, Radius (radius defined by dist cursor→start) -// ARC — Center, Start, End (CSE) -// ARC_CSA — Center, Start, Angle -// ARC_CSL — Center, Start, Length of chord -// ARC_CONT— Continue (tangent to previous line/arc; pick end only) +// Arc creation commands. use acadrust::types::Vector3; use acadrust::{Arc as CadArc, EntityType}; use crate::t; +use cadkernel::geom2d::{self, Curve as KernelCurve}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::IconKind; @@ -60,7 +48,7 @@ pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[ ("ARC_SEA", "Start, End, Angle", ICON_SEA), ("ARC_SED", "Start, End, Direction", ICON_SED), ("ARC_SER", "Start, End, Radius", ICON_SER), - ("ARC", "Center, Start, End", ICON_CSE), + ("ARC_CSE", "Center, Start, End", ICON_CSE), ("ARC_CSA", "Center, Start, Angle", ICON_CSA), ("ARC_CSL", "Center, Start, Length", ICON_CSL), ("ARC_CONT", "Continue", ICON_CONT), @@ -76,28 +64,31 @@ fn angle_xy(center: DVec3, pt: DVec3, plane: WorkingPlane) -> f64 { plane.angle(center, pt).unwrap_or(0.0) } -/// Build a CCW arc polyline from `start_angle` to `end_angle` (radians). fn arc_preview( center: DVec3, radius: f64, start_angle: f64, end_angle: f64, plane: WorkingPlane, -) -> WireModel { - let mut ea = end_angle; - while ea < start_angle { - ea += TAU; - } - let span = (ea - start_angle).min(TAU); - let segs = ((span / TAU) * 64.0).ceil().max(4.0) as u32; - let pts: Vec<[f64; 3]> = (0..=segs) - .map(|i| { - let a = start_angle + span * (i as f64 / segs as f64); - let point = center + plane.x * (radius * a.cos()) + plane.y * (radius * a.sin()); - [point.x, point.y, point.z] - }) +) -> Option { + let center = plane.to_local(center); + let arc = geom2d::bounded_arc( + [center.x, center.y], + radius, + start_angle, + end_angle, + )?; + let points = KernelCurve::Arc(arc) + .tessellate_angle(TAU / 64.0) + .into_iter() + .map(|point| plane.to_world(DVec3::new(point[0], point[1], center.z)).to_array()) .collect(); - WireModel::solid_f64("rubber_band".into(), pts, WireModel::CYAN, false) + Some(WireModel::solid_f64( + "rubber_band".into(), + points, + WireModel::CYAN, + false, + )) } fn make_arc( @@ -106,30 +97,35 @@ fn make_arc( start_angle: f64, end_angle: f64, plane: WorkingPlane, -) -> EntityType { +) -> Option { let center = plane.to_local(center); - plane.place_entity(EntityType::Arc(CadArc { - center: Vector3::new(center.x, center.y, center.z), + let arc = geom2d::bounded_arc( + [center.x, center.y], radius, start_angle, end_angle, + )?; + Some(plane.place_entity(EntityType::Arc(CadArc { + center: Vector3::new(center.x, center.y, center.z), + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, ..Default::default() - })) + }))) } -/// Signed rotation from `prev` to `curr` around `center`. -/// Positive = CCW, negative = CW. -/// Signed angle (radians) swept from `prev` to `curr` about `center`. -fn rot_delta(center: DVec3, prev: DVec3, curr: DVec3, plane: WorkingPlane) -> f64 { - let p = plane.vector_to_local(prev - center); - let c = plane.vector_to_local(curr - center); - (p.x * c.y - p.y * c.x).atan2(p.x * c.x + p.y * c.y) +fn arc_result( + center: DVec3, + radius: f64, + start_angle: f64, + end_angle: f64, + plane: WorkingPlane, +) -> CmdResult { + make_arc(center, radius, start_angle, end_angle, plane) + .map(CmdResult::CommitAndExit) + .unwrap_or(CmdResult::NeedPoint) } -/// Minimum swept angle before the previewed arc may flip CW/CCW. Filters the -/// per-frame cursor jitter that otherwise reverses the sweep on tiny moves. -const DIR_TOL: f64 = 0.1745; // ~10° - fn line_wire(a: DVec3, b: DVec3) -> WireModel { WireModel::solid_f64( "rubber_band".into(), @@ -139,40 +135,16 @@ fn line_wire(a: DVec3, b: DVec3) -> WireModel { ) } -/// Circumscribed circle through three points. -fn circumcircle( +fn arc_through_points( a: DVec3, b: DVec3, c: DVec3, plane: WorkingPlane, -) -> Option<(DVec3, f64)> { +) -> Option<(DVec3, f64, f64, f64)> { let (a, b, c) = (plane.to_local(a), plane.to_local(b), plane.to_local(c)); - let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); - if d.abs() < 1e-9 { - return None; - } - let ux = ((a.x * a.x + a.y * a.y) * (b.y - c.y) - + (b.x * b.x + b.y * b.y) * (c.y - a.y) - + (c.x * c.x + c.y * c.y) * (a.y - b.y)) - / d; - let uy = ((a.x * a.x + a.y * a.y) * (c.x - b.x) - + (b.x * b.x + b.y * b.y) * (a.x - c.x) - + (c.x * c.x + c.y * c.y) * (b.x - a.x)) - / d; - let center = DVec3::new(ux, uy, a.z); - Some((plane.to_world(center), center.distance(a))) -} - -/// True if `angle` lies inside the CCW arc [start..end] (radians). -fn ccw_contains(angle: f64, start: f64, end: f64) -> bool { - let a = angle.rem_euclid(TAU); - let s = start.rem_euclid(TAU); - let e = end.rem_euclid(TAU); - if s <= e { - a >= s && a <= e - } else { - a >= s || a <= e - } + let arc = geom2d::arc_through_points([a.x, a.y], [b.x, b.y], [c.x, c.y])?; + let center = plane.to_world(DVec3::new(arc.centre[0], arc.centre[1], a.z)); + Some((center, arc.radius, arc.start_angle, arc.end_angle)) } /// Arc center+radius from two endpoints and a cursor (sagitta / bow-toward-cursor). @@ -180,8 +152,9 @@ fn arc_from_sagitta( s: DVec3, e: DVec3, cursor: DVec3, + flip_direction: bool, plane: WorkingPlane, -) -> Option<(DVec3, f64)> { +) -> Option<(DVec3, f64, f64, f64)> { let (s, e, cursor) = (plane.to_local(s), plane.to_local(e), plane.to_local(cursor)); let chord_vec = e - s; let chord_len = (chord_vec.x * chord_vec.x + chord_vec.y * chord_vec.y).sqrt(); @@ -195,11 +168,23 @@ fn arc_from_sagitta( if h.abs() < 1e-3 { return None; } - let r = (chord_len * chord_len + 4.0 * h * h) / (8.0 * h.abs()); - let d = (r * r - (chord_len * 0.5) * (chord_len * 0.5)) - .max(0.0) - .sqrt(); - Some((plane.to_world(mid - perp * h.signum() * d), r)) + let sagitta = if flip_direction { -h } else { h }; + let arc = geom2d::arc_from_sagitta([s.x, s.y], [e.x, e.y], sagitta)?; + let center = plane.to_world(DVec3::new(arc.centre[0], arc.centre[1], s.z)); + Some((center, arc.radius, arc.start_angle, arc.end_angle)) +} + +/// Builds an arc from endpoints and a signed angle. +fn arc_from_endpoints_angle( + s: DVec3, + e: DVec3, + included: f64, + plane: WorkingPlane, +) -> Option<(DVec3, f64, f64, f64)> { + let (s, e) = (plane.to_local(s), plane.to_local(e)); + let arc = geom2d::arc_from_endpoints_angle([s.x, s.y], [e.x, e.y], included)?; + let center = plane.to_world(DVec3::new(arc.centre[0], arc.centre[1], s.z)); + Some((center, arc.radius, arc.start_angle, arc.end_angle)) } /// Arc center+radius from start, end, and a radius-magnitude point (dist = dist(pt, start)). @@ -207,66 +192,28 @@ fn arc_from_se_radius( s: DVec3, e: DVec3, radius_pt: DVec3, + clockwise: bool, plane: WorkingPlane, -) -> Option<(DVec3, f64)> { - let (s, e, radius_pt) = ( - plane.to_local(s), - plane.to_local(e), - plane.to_local(radius_pt), - ); - let r = s.distance(radius_pt).max(1e-3); - let chord_len = s.distance(e); - if r < chord_len * 0.5 { - return None; - } - let unit_chord = (e - s) / chord_len; - let perp = DVec3::new(-unit_chord.y, unit_chord.x, 0.0); - let mid = (s + e) * 0.5; - let h_sign = (radius_pt - mid).dot(perp).signum(); - let d = (r * r - (chord_len * 0.5) * (chord_len * 0.5)) - .max(0.0) - .sqrt(); - Some((plane.to_world(mid - perp * h_sign * d), r)) +) -> Option<(DVec3, f64, f64, f64)> { + let radius = plane.to_local(s).distance(plane.to_local(radius_pt)); + arc_from_endpoints_radius(s, e, radius, clockwise, plane) } -/// Arc center+radius from start, end, and a tangent-direction point at start. -fn arc_from_direction( +fn arc_from_endpoints_radius( s: DVec3, e: DVec3, - dir_pt: DVec3, + signed_radius: f64, + clockwise: bool, plane: WorkingPlane, -) -> Option<(DVec3, f64)> { - let (s, e, dir_pt) = ( - plane.to_local(s), - plane.to_local(e), - plane.to_local(dir_pt), - ); - let t = (dir_pt - s).normalize_or_zero(); - if t.length_squared() < 1e-12 { - return None; - } - let perp_t = DVec3::new(-t.y, t.x, 0.0); // rotate tangent 90° CCW - let chord = e - s; - let denom = perp_t.x * chord.x + perp_t.y * chord.y; - if denom.abs() < 1e-9 { - return None; - } - let lambda = chord.length_squared() * 0.5 / denom; - let center = s + perp_t * lambda; - Some((plane.to_world(center), center.distance(s))) +) -> Option<(DVec3, f64, f64, f64)> { + let (s, e) = if clockwise { (e, s) } else { (s, e) }; + let (s, e) = (plane.to_local(s), plane.to_local(e)); + let arc = geom2d::arc_from_endpoints_radius([s.x, s.y], [e.x, e.y], signed_radius)?; + let center = plane.to_world(DVec3::new(arc.centre[0], arc.centre[1], s.z)); + Some((center, arc.radius, arc.start_angle, arc.end_angle)) } -/// Tangent-continuing arc: starts at `s` leaving in unit direction `t` (forward, -/// G1-continuous with the previous entity) and passes through `e`. Returns -/// `(center, radius, start_angle, end_angle)` with the CCW sweep oriented so the -/// drawn arc actually leaves `s` along `+t` (not the reflex complement). -/// -/// `arc_from_direction` treats the tangent as an undirected line (±t give the -/// same circle), so on its own the CCW-only renderer would draw the wrong arc -/// whenever `e` lies to the right of `+t`. We fix the sweep direction here by -/// checking the circle's CCW travel direction at `s` and swapping the angles -/// when it opposes `+t`. `flip` (Ctrl) selects the complementary arc — the other -/// way around the same circle. +/// Builds an arc tangent to the preceding entity. fn arc_continue( s: DVec3, t: DVec3, @@ -274,82 +221,55 @@ fn arc_continue( flip: bool, plane: WorkingPlane, ) -> Option<(DVec3, f64, f64, f64)> { - let (center, radius) = arc_from_direction(s, e, s + t, plane)?; - // CCW travel direction on this circle at s = (s - center) rotated +90°. - let rs = plane.vector_to_local(s - center); - let t = plane.vector_to_local(t); - let travel_s = DVec3::new(-rs.y, rs.x, 0.0); - let forward = travel_s.dot(t) >= 0.0; - // Draw CCW s→e when the CCW-from-s arc leaves along +t; else CCW e→s. `flip` - // inverts the choice to draw the complementary arc. - let (sa, ea) = if forward ^ flip { - (angle_xy(center, s, plane), angle_xy(center, e, plane)) - } else { - (angle_xy(center, e, plane), angle_xy(center, s, plane)) - }; - Some((center, radius, sa, ea)) + let (s, e) = (plane.to_local(s), plane.to_local(e)); + let tangent = plane.vector_to_local(t); + let arc = geom2d::arc_from_start_tangent( + [s.x, s.y], + [tangent.x, tangent.y], + [e.x, e.y], + flip, + )?; + let center = plane.to_world(DVec3::new(arc.centre[0], arc.centre[1], s.z)); + Some((center, arc.radius, arc.start_angle, arc.end_angle)) } -/// Continuation anchor for `ARC_CONT`: the point where drawing a line/arc ended -/// plus the unit tangent leaving it, used to start a tangent-continuing arc. -/// `last` (the final pick point) disambiguates which endpoint the drawing ended -/// on — essential because a committed arc stores geometric CCW start/end angles, -/// not the direction the pen travelled. Returns `None` for other entity kinds. +/// Returns the last curve endpoint and outgoing tangent. pub fn continue_anchor(entity: &EntityType, last: Option) -> Option<(DVec3, DVec3)> { - let nearer_first = |a: DVec3, b: DVec3| match last { - Some(p) => p.distance(a) <= p.distance(b), - None => true, // default to the entity's stored end - }; - match entity { - EntityType::Line(l) => { - let a = DVec3::new(l.start.x, l.start.y, l.start.z); - let b = DVec3::new(l.end.x, l.end.y, l.end.z); - let (p_end, p_start) = if nearer_first(b, a) { (b, a) } else { (a, b) }; - let t = (p_end - p_start).normalize_or_zero(); - (t.length_squared() > 1e-12).then_some((p_end, t)) - } - EntityType::Arc(a) => { - let sp_v = a.start_point(); - let ep_v = a.end_point(); - let sp = DVec3::new(sp_v.x, sp_v.y, sp_v.z); - let ep = DVec3::new(ep_v.x, ep_v.y, ep_v.z); - let normal = (a.normal.x, a.normal.y, a.normal.z); - let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); - let tangent = |angle: f64, ccw: bool| { - let sign = if ccw { 1.0 } else { -1.0 }; - DVec3::new( - sign * (-angle.sin() * ax.0 + angle.cos() * ay.0), - sign * (-angle.sin() * ax.1 + angle.cos() * ay.1), - sign * (-angle.sin() * ax.2 + angle.cos() * ay.2), - ) - }; - if nearer_first(ep, sp) { - // Ended at end_angle: outward tangent = CCW travel there. - Some((ep, tangent(a.end_angle, true))) - } else { - // Ended at start_angle: outward tangent = CW travel there. - Some((sp, tangent(a.start_angle, false))) - } - } - _ => None, + if !matches!( + entity, + EntityType::Line(_) + | EntityType::Arc(_) + | EntityType::LwPolyline(_) + | EntityType::Polyline2D(_) + ) { + return None; } + let curve = crate::entities::curve::entity_curve(entity)?; + let start = DVec3::from_array(curve.point_at(0.0)); + let end = DVec3::from_array(curve.point_at(1.0)); + let use_end = last.map_or(true, |point| point.distance(end) <= point.distance(start)); + let (point, tangent) = if use_end { + (end, DVec3::from_array(curve.tangent_at(1.0))) + } else { + (start, -DVec3::from_array(curve.tangent_at(0.0))) + }; + let tangent = tangent.normalize_or_zero(); + (tangent.length_squared() > 1.0e-12).then_some((point, tangent)) } /// Compute end_angle from a chord-length pick (SCL / CSL semantics). -/// `chord_len` is clamped to [0, 2r]. -fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> f64 { - let half = (chord.min(2.0 * r) / (2.0 * r)).asin(); - start_angle + 2.0 * half +/// Positive length selects the minor arc; negative length selects the major. +fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> Option { + geom2d::arc_sweep_from_chord(r, chord).map(|sweep| start_angle + sweep) } -// ── Command 1: Center, Start, End (ARC = CSE) ──────────────────────────── +// ── Command 1: Center, Start, End ───────────────────────────────────────── pub struct ArcCommand { step: u8, c: DVec3, r: f64, sa: f64, - prev_pt: Option, cw: bool, plane: WorkingPlane, } @@ -361,7 +281,6 @@ impl ArcCommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -372,9 +291,12 @@ impl CadCommand for ArcCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } fn name(&self) -> &'static str { - "ARC" + "ARC_CSE" } fn prompt(&self) -> String { match self.step { @@ -431,12 +353,11 @@ impl CadCommand for ArcCommand { } _ => { let ea = angle_xy(self.c, pt, self.plane); - let e = if self.cw { - make_arc(self.c, self.r, ea, self.sa, self.plane) + if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) } else { - make_arc(self.c, self.r, self.sa, ea, self.plane) - }; - CmdResult::CommitAndExit(e) + arc_result(self.c, self.r, self.sa, ea, self.plane) + } } } } @@ -444,7 +365,7 @@ impl CadCommand for ArcCommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -469,24 +390,12 @@ impl CadCommand for ArcCommand { match self.step { 1 => Some(line_wire(self.c, pt)), 2 => { - if let Some(prev) = self.prev_pt { - // Only flip the sweep once the cursor has moved a clear - // angular step; keep the reference point until then so slow - // moves accumulate and jitter is ignored. - let d = rot_delta(self.c, prev, pt, self.plane); - if d.abs() > DIR_TOL { - self.cw = d < 0.0; - self.prev_pt = Some(pt); - } - } else { - self.prev_pt = Some(pt); - } let ea = angle_xy(self.c, pt, self.plane); - Some(if self.cw { + if self.cw { arc_preview(self.c, self.r, ea, self.sa, self.plane) } else { arc_preview(self.c, self.r, self.sa, ea, self.plane) - }) + } } _ => None, } @@ -530,30 +439,25 @@ impl CadCommand for Arc3PCommand { return CmdResult::NeedPoint; } let (p1, p2, p3) = (self.pts[0], self.pts[1], self.pts[2]); - match circumcircle(p1, p2, p3, self.plane) { + match arc_through_points(p1, p2, p3, self.plane) { None => { self.pts.pop(); CmdResult::NeedPoint } // collinear — retry - Some((center, radius)) => { - let a1 = angle_xy(center, p1, self.plane); - let a2 = angle_xy(center, p2, self.plane); - let a3 = angle_xy(center, p3, self.plane); - // Choose arc direction so that p2 lies on the arc from p1 to p3. - let (sa, ea) = if ccw_contains(a2, a1, a3) { - (a1, a3) - } else { - (a3, a1) - }; - CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane)) + Some((center, radius, start, end)) => { + arc_result(center, radius, start, end, self.plane) } } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + if self.pts.is_empty() { + CmdResult::Dispatch("ARC_CONT".into()) + } else { + CmdResult::Cancel + } } fn enter_accepts_default_start(&self) -> bool { - self.pts.is_empty() + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -564,16 +468,10 @@ impl CadCommand for Arc3PCommand { 1 => Some(line_wire(self.pts[0], pt)), _ => { let (p1, p2) = (self.pts[0], self.pts[1]); - if let Some((center, radius)) = circumcircle(p1, p2, pt, self.plane) { - let a1 = angle_xy(center, p1, self.plane); - let a2 = angle_xy(center, p2, self.plane); - let a3 = angle_xy(center, pt, self.plane); - let (sa, ea) = if ccw_contains(a2, a1, a3) { - (a1, a3) - } else { - (a3, a1) - }; - Some(arc_preview(center, radius, sa, ea, self.plane)) + if let Some((center, radius, start, end)) = + arc_through_points(p1, p2, pt, self.plane) + { + arc_preview(center, radius, start, end, self.plane) } else { Some(WireModel::solid_f64( "rubber_band".into(), @@ -595,7 +493,6 @@ pub struct ArcSCECommand { c: DVec3, r: f64, sa: f64, - prev_pt: Option, cw: bool, plane: WorkingPlane, } @@ -608,7 +505,6 @@ impl ArcSCECommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -619,6 +515,9 @@ impl CadCommand for ArcSCECommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } fn name(&self) -> &'static str { "ARC_SCE" @@ -649,12 +548,11 @@ impl CadCommand for ArcSCECommand { } _ => { let ea = angle_xy(self.c, pt, self.plane); - let e = if self.cw { - make_arc(self.c, self.r, ea, self.sa, self.plane) + if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) } else { - make_arc(self.c, self.r, self.sa, ea, self.plane) - }; - CmdResult::CommitAndExit(e) + arc_result(self.c, self.r, self.sa, ea, self.plane) + } } } } @@ -662,7 +560,7 @@ impl CadCommand for ArcSCECommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -671,24 +569,12 @@ impl CadCommand for ArcSCECommand { match self.step { 1 => Some(line_wire(self.s, pt)), 2 => { - if let Some(prev) = self.prev_pt { - // Only flip the sweep once the cursor has moved a clear - // angular step; keep the reference point until then so slow - // moves accumulate and jitter is ignored. - let d = rot_delta(self.c, prev, pt, self.plane); - if d.abs() > DIR_TOL { - self.cw = d < 0.0; - self.prev_pt = Some(pt); - } - } else { - self.prev_pt = Some(pt); - } let ea = angle_xy(self.c, pt, self.plane); - Some(if self.cw { + if self.cw { arc_preview(self.c, self.r, ea, self.sa, self.plane) } else { arc_preview(self.c, self.r, self.sa, ea, self.plane) - }) + } } _ => None, } @@ -704,7 +590,6 @@ pub struct ArcSCACommand { c: DVec3, r: f64, sa: f64, - prev_pt: Option, cw: bool, plane: WorkingPlane, } @@ -717,7 +602,6 @@ impl ArcSCACommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -728,6 +612,9 @@ impl CadCommand for ArcSCACommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } fn name(&self) -> &'static str { "ARC_SCA" @@ -762,12 +649,11 @@ impl CadCommand for ArcSCACommand { } _ => { let ea = angle_xy(self.c, pt, self.plane); - let e = if self.cw { - make_arc(self.c, self.r, ea, self.sa, self.plane) + if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) } else { - make_arc(self.c, self.r, self.sa, ea, self.plane) - }; - CmdResult::CommitAndExit(e) + arc_result(self.c, self.r, self.sa, ea, self.plane) + } } } } @@ -775,19 +661,23 @@ impl CadCommand for ArcSCACommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } fn on_text_input(&mut self, text: &str) -> Option { if self.step == 2 { - let span: f64 = text.trim().replace(',', ".").parse().ok()?; - // Negative span = CW; positive = CCW. + let mut span: f64 = text.trim().replace(',', ".").parse().ok()?; + if self.cw { + span = -span; + } let ea = self.sa + span.to_radians(); - return Some(CmdResult::CommitAndExit(make_arc( - self.c, self.r, self.sa, ea, self.plane, - ))); + return Some(if span < 0.0 { + arc_result(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_result(self.c, self.r, self.sa, ea, self.plane) + }); } None } @@ -818,24 +708,12 @@ impl CadCommand for ArcSCACommand { match self.step { 1 => Some(line_wire(self.s, pt)), 2 => { - if let Some(prev) = self.prev_pt { - // Only flip the sweep once the cursor has moved a clear - // angular step; keep the reference point until then so slow - // moves accumulate and jitter is ignored. - let d = rot_delta(self.c, prev, pt, self.plane); - if d.abs() > DIR_TOL { - self.cw = d < 0.0; - self.prev_pt = Some(pt); - } - } else { - self.prev_pt = Some(pt); - } let ea = angle_xy(self.c, pt, self.plane); - Some(if self.cw { + if self.cw { arc_preview(self.c, self.r, ea, self.sa, self.plane) } else { arc_preview(self.c, self.r, self.sa, ea, self.plane) - }) + } } _ => None, } @@ -852,6 +730,7 @@ pub struct ArcSCLCommand { c: DVec3, r: f64, sa: f64, + cw: bool, plane: WorkingPlane, } @@ -863,6 +742,7 @@ impl ArcSCLCommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, + cw: false, plane: WorkingPlane::default(), } } @@ -872,6 +752,9 @@ impl CadCommand for ArcSCLCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } fn name(&self) -> &'static str { "ARC_SCL" @@ -906,14 +789,14 @@ impl CadCommand for ArcSCLCommand { } _ => { let chord = self.s.distance(pt); - let ea = end_angle_from_chord_len(self.sa, chord, self.r); - CmdResult::CommitAndExit(make_arc( - self.c, - self.r, - self.sa, - ea, - self.plane, - )) + let Some(ea) = end_angle_from_chord_len(self.sa, chord, self.r) else { + return CmdResult::NeedPoint; + }; + if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_result(self.c, self.r, self.sa, ea, self.plane) + } } } } @@ -921,7 +804,7 @@ impl CadCommand for ArcSCLCommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -929,11 +812,13 @@ impl CadCommand for ArcSCLCommand { fn on_text_input(&mut self, text: &str) -> Option { if self.step == 2 { let chord: f64 = text.trim().replace(',', ".").parse().ok()?; - if chord > 0.0 { - let ea = end_angle_from_chord_len(self.sa, chord, self.r); - return Some(CmdResult::CommitAndExit(make_arc( - self.c, self.r, self.sa, ea, self.plane, - ))); + if chord != 0.0 { + let ea = end_angle_from_chord_len(self.sa, chord, self.r)?; + return Some(if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_result(self.c, self.r, self.sa, ea, self.plane) + }); } } None @@ -959,8 +844,12 @@ impl CadCommand for ArcSCLCommand { 1 => Some(line_wire(self.s, pt)), 2 => { let chord = self.s.distance(pt); - let ea = end_angle_from_chord_len(self.sa, chord, self.r); - Some(arc_preview(self.c, self.r, self.sa, ea, self.plane)) + let ea = end_angle_from_chord_len(self.sa, chord, self.r)?; + if self.cw { + arc_preview(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_preview(self.c, self.r, self.sa, ea, self.plane) + } } _ => None, } @@ -974,6 +863,7 @@ pub struct ArcSEACommand { step: u8, s: DVec3, e: DVec3, + ctrl: bool, plane: WorkingPlane, } @@ -983,6 +873,7 @@ impl ArcSEACommand { step: 0, s: DVec3::ZERO, e: DVec3::ZERO, + ctrl: false, plane: WorkingPlane::default(), } } @@ -992,6 +883,9 @@ impl CadCommand for ArcSEACommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.ctrl = ctrl; + } fn name(&self) -> &'static str { "ARC_SEA" @@ -1015,13 +909,11 @@ impl CadCommand for ArcSEACommand { self.step = 2; CmdResult::NeedPoint } - _ => match arc_from_sagitta(self.s, self.e, pt, self.plane) { - Some((center, radius)) => { - let sa = angle_xy(center, self.s, self.plane); - let ea = angle_xy(center, self.e, self.plane); - CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane)) + _ => match arc_from_sagitta(self.s, self.e, pt, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { + arc_result(center, radius, sa, ea, self.plane) } - None => CmdResult::Cancel, + None => CmdResult::NeedPoint, }, } } @@ -1029,21 +921,43 @@ impl CadCommand for ArcSEACommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_text_input(&mut self, text: &str) -> Option { + if self.step != 2 { + return None; + } + let mut included = text.trim().replace(',', ".").parse::().ok()?.to_radians(); + if self.ctrl { + included = -included; + } + let (center, radius, sa, ea) = + arc_from_endpoints_angle(self.s, self.e, included, self.plane)?; + Some(arc_result(center, radius, sa, ea, self.plane)) + } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point((self.s + self.e) * 0.5), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::None, + ref_point: Some(self.s), + }) + } + fn dyn_commit_as_text(&self) -> bool { + self.step == 2 + } fn on_mouse_move(&mut self, pt: DVec3) -> Option { match self.step { 1 => Some(line_wire(self.s, pt)), 2 => { - if let Some((center, radius)) = - arc_from_sagitta(self.s, self.e, pt, self.plane) + if let Some((center, radius, sa, ea)) = + arc_from_sagitta(self.s, self.e, pt, self.ctrl, self.plane) { - let sa = angle_xy(center, self.s, self.plane); - let ea = angle_xy(center, self.e, self.plane); - Some(arc_preview(center, radius, sa, ea, self.plane)) + arc_preview(center, radius, sa, ea, self.plane) } else { Some(line_wire(self.s, self.e)) } @@ -1060,6 +974,7 @@ pub struct ArcSERCommand { step: u8, s: DVec3, e: DVec3, + ctrl: bool, plane: WorkingPlane, } @@ -1069,6 +984,7 @@ impl ArcSERCommand { step: 0, s: DVec3::ZERO, e: DVec3::ZERO, + ctrl: false, plane: WorkingPlane::default(), } } @@ -1078,6 +994,9 @@ impl CadCommand for ArcSERCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.ctrl = ctrl; + } fn name(&self) -> &'static str { "ARC_SER" @@ -1101,13 +1020,11 @@ impl CadCommand for ArcSERCommand { self.step = 2; CmdResult::NeedPoint } - _ => match arc_from_se_radius(self.s, self.e, pt, self.plane) { - Some((center, radius)) => { - let sa = angle_xy(center, self.s, self.plane); - let ea = angle_xy(center, self.e, self.plane); - CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane)) + _ => match arc_from_se_radius(self.s, self.e, pt, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { + arc_result(center, radius, sa, ea, self.plane) } - None => CmdResult::Cancel, + None => CmdResult::NeedPoint, }, } } @@ -1115,7 +1032,7 @@ impl CadCommand for ArcSERCommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -1123,23 +1040,9 @@ impl CadCommand for ArcSERCommand { fn on_text_input(&mut self, text: &str) -> Option { if self.step == 2 { let r: f64 = text.trim().replace(',', ".").parse().ok()?; - let s = self.plane.to_local(self.s); - let e = self.plane.to_local(self.e); - let chord = s.distance(e); - if r > 0.0 && r >= chord / 2.0 { - let mid = (s + e) * 0.5; - let perp = { - let cv = (e - s) / chord; - DVec3::new(-cv.y, cv.x, 0.0) - }; - let d = (r * r - (chord / 2.0) * (chord / 2.0)).max(0.0).sqrt(); - let center = self.plane.to_world(mid - perp * d); - let sa = angle_xy(center, self.s, self.plane); - let ea = angle_xy(center, self.e, self.plane); - return Some(CmdResult::CommitAndExit(make_arc( - center, r, sa, ea, self.plane, - ))); - } + let (center, radius, sa, ea) = + arc_from_endpoints_radius(self.s, self.e, r, self.ctrl, self.plane)?; + return Some(arc_result(center, radius, sa, ea, self.plane)); } None } @@ -1160,18 +1063,17 @@ impl CadCommand for ArcSERCommand { if self.step != 2 { return None; } - arc_from_se_radius(self.s, self.e, cursor, self.plane).map(|(_, r)| r) + arc_from_se_radius(self.s, self.e, cursor, self.ctrl, self.plane) + .map(|(_, r, _, _)| r) } fn on_mouse_move(&mut self, pt: DVec3) -> Option { match self.step { 1 => Some(line_wire(self.s, pt)), 2 => { - if let Some((center, radius)) = - arc_from_se_radius(self.s, self.e, pt, self.plane) + if let Some((center, radius, sa, ea)) = + arc_from_se_radius(self.s, self.e, pt, self.ctrl, self.plane) { - let sa = angle_xy(center, self.s, self.plane); - let ea = angle_xy(center, self.e, self.plane); - Some(arc_preview(center, radius, sa, ea, self.plane)) + arc_preview(center, radius, sa, ea, self.plane) } else { Some(line_wire(self.s, self.e)) } @@ -1188,6 +1090,7 @@ pub struct ArcSEDCommand { step: u8, s: DVec3, e: DVec3, + ctrl: bool, plane: WorkingPlane, } @@ -1197,6 +1100,7 @@ impl ArcSEDCommand { step: 0, s: DVec3::ZERO, e: DVec3::ZERO, + ctrl: false, plane: WorkingPlane::default(), } } @@ -1206,6 +1110,9 @@ impl CadCommand for ArcSEDCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.ctrl = ctrl; + } fn name(&self) -> &'static str { "ARC_SED" @@ -1229,13 +1136,11 @@ impl CadCommand for ArcSEDCommand { self.step = 2; CmdResult::NeedPoint } - _ => match arc_from_direction(self.s, self.e, pt, self.plane) { - Some((center, radius)) => { - let sa = angle_xy(center, self.s, self.plane); - let ea = angle_xy(center, self.e, self.plane); - CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane)) + _ => match arc_continue(self.s, pt - self.s, self.e, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { + arc_result(center, radius, sa, ea, self.plane) } - None => CmdResult::Cancel, + None => CmdResult::NeedPoint, }, } } @@ -1243,21 +1148,41 @@ impl CadCommand for ArcSEDCommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_text_input(&mut self, text: &str) -> Option { + if self.step != 2 { + return None; + } + let angle = text.trim().replace(',', ".").parse::().ok()?.to_radians(); + let tangent = self.plane.x * angle.cos() + self.plane.y * angle.sin(); + let (center, radius, sa, ea) = + arc_continue(self.s, tangent, self.e, self.ctrl, self.plane)?; + Some(arc_result(center, radius, sa, ea, self.plane)) + } + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + (self.step == 2).then(|| DynSpec { + anchor: DynAnchor::Point(self.s), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: Some(self.s + self.plane.x), + }) + } + fn dyn_commit_as_text(&self) -> bool { + self.step == 2 + } fn on_mouse_move(&mut self, pt: DVec3) -> Option { match self.step { 1 => Some(line_wire(self.s, pt)), 2 => { - if let Some((center, radius)) = - arc_from_direction(self.s, self.e, pt, self.plane) + if let Some((center, radius, sa, ea)) = + arc_continue(self.s, pt - self.s, self.e, self.ctrl, self.plane) { - let sa = angle_xy(center, self.s, self.plane); - let ea = angle_xy(center, self.e, self.plane); - Some(arc_preview(center, radius, sa, ea, self.plane)) + arc_preview(center, radius, sa, ea, self.plane) } else { Some(line_wire(self.s, self.e)) } @@ -1275,7 +1200,6 @@ pub struct ArcCSACommand { c: DVec3, r: f64, sa: f64, - prev_pt: Option, cw: bool, plane: WorkingPlane, } @@ -1287,7 +1211,6 @@ impl ArcCSACommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -1298,6 +1221,9 @@ impl CadCommand for ArcCSACommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } fn name(&self) -> &'static str { "ARC_CSA" @@ -1331,12 +1257,11 @@ impl CadCommand for ArcCSACommand { } _ => { let ea = angle_xy(self.c, pt, self.plane); - let e = if self.cw { - make_arc(self.c, self.r, ea, self.sa, self.plane) + if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) } else { - make_arc(self.c, self.r, self.sa, ea, self.plane) - }; - CmdResult::CommitAndExit(e) + arc_result(self.c, self.r, self.sa, ea, self.plane) + } } } } @@ -1344,18 +1269,23 @@ impl CadCommand for ArcCSACommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } fn on_text_input(&mut self, text: &str) -> Option { if self.step == 2 { - let span: f64 = text.trim().replace(',', ".").parse().ok()?; + let mut span: f64 = text.trim().replace(',', ".").parse().ok()?; + if self.cw { + span = -span; + } let ea = self.sa + span.to_radians(); - return Some(CmdResult::CommitAndExit(make_arc( - self.c, self.r, self.sa, ea, self.plane, - ))); + return Some(if span < 0.0 { + arc_result(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_result(self.c, self.r, self.sa, ea, self.plane) + }); } None } @@ -1384,24 +1314,12 @@ impl CadCommand for ArcCSACommand { match self.step { 1 => Some(line_wire(self.c, pt)), 2 => { - if let Some(prev) = self.prev_pt { - // Only flip the sweep once the cursor has moved a clear - // angular step; keep the reference point until then so slow - // moves accumulate and jitter is ignored. - let d = rot_delta(self.c, prev, pt, self.plane); - if d.abs() > DIR_TOL { - self.cw = d < 0.0; - self.prev_pt = Some(pt); - } - } else { - self.prev_pt = Some(pt); - } let ea = angle_xy(self.c, pt, self.plane); - Some(if self.cw { + if self.cw { arc_preview(self.c, self.r, ea, self.sa, self.plane) } else { arc_preview(self.c, self.r, self.sa, ea, self.plane) - }) + } } _ => None, } @@ -1417,6 +1335,7 @@ pub struct ArcCSLCommand { s: DVec3, r: f64, sa: f64, + cw: bool, plane: WorkingPlane, } @@ -1428,6 +1347,7 @@ impl ArcCSLCommand { s: DVec3::ZERO, r: 0.0, sa: 0.0, + cw: false, plane: WorkingPlane::default(), } } @@ -1437,6 +1357,9 @@ impl CadCommand for ArcCSLCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } fn name(&self) -> &'static str { "ARC_CSL" @@ -1471,14 +1394,14 @@ impl CadCommand for ArcCSLCommand { } _ => { let chord = self.s.distance(pt); - let ea = end_angle_from_chord_len(self.sa, chord, self.r); - CmdResult::CommitAndExit(make_arc( - self.c, - self.r, - self.sa, - ea, - self.plane, - )) + let Some(ea) = end_angle_from_chord_len(self.sa, chord, self.r) else { + return CmdResult::NeedPoint; + }; + if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_result(self.c, self.r, self.sa, ea, self.plane) + } } } } @@ -1486,7 +1409,7 @@ impl CadCommand for ArcCSLCommand { CmdResult::Cancel } fn enter_accepts_default_start(&self) -> bool { - self.step == 0 + false } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -1494,11 +1417,13 @@ impl CadCommand for ArcCSLCommand { fn on_text_input(&mut self, text: &str) -> Option { if self.step == 2 { let chord: f64 = text.trim().replace(',', ".").parse().ok()?; - if chord > 0.0 { - let ea = end_angle_from_chord_len(self.sa, chord, self.r); - return Some(CmdResult::CommitAndExit(make_arc( - self.c, self.r, self.sa, ea, self.plane, - ))); + if chord != 0.0 { + let ea = end_angle_from_chord_len(self.sa, chord, self.r)?; + return Some(if self.cw { + arc_result(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_result(self.c, self.r, self.sa, ea, self.plane) + }); } } None @@ -1524,8 +1449,12 @@ impl CadCommand for ArcCSLCommand { 1 => Some(line_wire(self.c, pt)), 2 => { let chord = self.s.distance(pt); - let ea = end_angle_from_chord_len(self.sa, chord, self.r); - Some(arc_preview(self.c, self.r, self.sa, ea, self.plane)) + let ea = end_angle_from_chord_len(self.sa, chord, self.r)?; + if self.cw { + arc_preview(self.c, self.r, ea, self.sa, self.plane) + } else { + arc_preview(self.c, self.r, self.sa, ea, self.plane) + } } _ => None, } @@ -1534,19 +1463,11 @@ impl CadCommand for ArcCSLCommand { // ── Command 11: Continue (ARC_CONT) ────────────────────────────────────── -// Tangent-continuing arc from the previous line/arc endpoint. The start point -// and start tangent are seeded at dispatch (from `continue_anchor`); the user -// picks only the end point, so the arc joins the previous entity smoothly. - pub struct ArcContCommand { s: DVec3, tangent: DVec3, /// Live Ctrl state (set via `set_ctrl`): flips the arc to the other way. ctrl: bool, - /// Anchor history for mid-command Ctrl+Z: the (start, tangent) each - /// committed arc was drawn from, so undoing a segment resumes the run - /// from the prior anchor instead of ending it. - history: Vec<(DVec3, DVec3)>, plane: WorkingPlane, } @@ -1557,7 +1478,6 @@ impl ArcContCommand { tangent, ctrl: false, plane: WorkingPlane::default(), - history: Vec::new(), } } } @@ -1579,33 +1499,11 @@ impl CadCommand for ArcContCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { Some((center, radius, sa, ea)) => { - let arc = make_arc(center, radius, sa, ea, self.plane); - // Remember the anchor this arc was drawn from — mid-command - // Ctrl+Z reverts the commit and resumes from here. - self.history.push((self.s, self.tangent)); - // Keep drawing: re-anchor at the arc just committed so the next - // click starts another tangent-continuing arc from its end, - // until the user ends the run with Enter/Esc (#327). - if let Some((end, tangent)) = continue_anchor(&arc, Some(pt)) { - self.s = end; - self.tangent = tangent; - } - CmdResult::CommitEntity(arc) + arc_result(center, radius, sa, ea, self.plane) } - // A degenerate pick (no arc through these points) shouldn't end the - // whole run — stay active and wait for the next end point. None => CmdResult::NeedPoint, } } - fn on_undo_step(&mut self) -> Option { - // Ctrl+Z mid-run: revert the last committed arc and resume from the - // anchor it was drawn from. With none committed this run, the - // document undo takes over. - let (s, tangent) = self.history.pop()?; - self.s = s; - self.tangent = tangent; - Some(CmdResult::UndoDocument) - } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } @@ -1614,19 +1512,17 @@ impl CadCommand for ArcContCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { - Some((center, radius, sa, ea)) => { - Some(arc_preview(center, radius, sa, ea, self.plane)) - } + Some((center, radius, sa, ea)) => arc_preview(center, radius, sa, ea, self.plane), None => Some(line_wire(self.s, pt)), } } } // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["ARC_3P"] }); // Arc3PCommand +inventory::submit!(crate::command::CommandRegistration { names: &["ARC", "ARC_3P"] }); // Arc3PCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSA"] }); // ArcCSACommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSL"] }); // ArcCSLCommand -inventory::submit!(crate::command::CommandRegistration { names: &["ARC"] }); // ArcCommand +inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSE"] }); // ArcCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCA"] }); // ArcSCACommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCE"] }); // ArcSCECommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCL"] }); // ArcSCLCommand diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index f9f34aca..5a260824 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -79,7 +79,7 @@ impl AreaCommand { let curve = entity_curve(entity)?; let perimeter = curve.length(); Some(AreaMeasurement { - area: curve.curve.enclosed_area().abs(), + area: curve.curve.chord_closed_area()?.abs(), perimeter: perimeter.is_finite().then_some(perimeter), }) } diff --git a/src/modules/draw/modify/break_cmd.rs b/src/modules/draw/modify/break_cmd.rs index da7edb5c..9309908f 100644 --- a/src/modules/draw/modify/break_cmd.rs +++ b/src/modules/draw/modify/break_cmd.rs @@ -96,9 +96,15 @@ fn break_arc(arc: &ArcEnt, p1: DVec3, p2: DVec3) -> Vec { let cy = arc.center.y; let r = arc.radius; - // Project p1 and p2 onto the arc (world XY plane) - let a1 = angle_on_arc(cx, cy, p1); - let a2 = angle_on_arc(cx, cy, p2); + let plane = crate::entities::curve::arc_curve(arc).plane; + let Some(q1) = plane.project(p1.to_array()) else { + return vec![EntityType::Arc(arc.clone())]; + }; + let Some(q2) = plane.project(p2.to_array()) else { + return vec![EntityType::Arc(arc.clone())]; + }; + let a1 = angle_on_arc(cx, cy, DVec3::new(q1[0], q1[1], 0.0)); + let a2 = angle_on_arc(cx, cy, DVec3::new(q2[0], q2[1], 0.0)); let start = arc.start_angle; let end = arc.end_angle; @@ -107,8 +113,7 @@ fn break_arc(arc: &ArcEnt, p1: DVec3, p2: DVec3) -> Vec { let a1_on = clamp_to_arc(a1, start, end); let a2_on = clamp_to_arc(a2, start, end); - // Resulting arc: from a2_on to a1_on (CCW, skipping the removed segment) - // This matches AutoCAD's break behavior: removes CCW from first to second point. + // Keep the span outside the two break points. if (a1_on - a2_on).abs() < 0.01 { // Single-point break: return original unchanged (no gap) return vec![EntityType::Arc(arc.clone())]; diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 113ecbd6..6c241cf6 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -213,38 +213,23 @@ fn lengthen_line(line: &LineEnt, pick_pt: Vec3, mode: &LenMode) -> Option Option { - let cx = arc.center.x as f32; - let cy = arc.center.y as f32; - // Current arc span let span = arc_span_rad(arc.start_angle, arc.end_angle); let current_arc_len = arc.radius * span; let new_arc_len = apply_mode(current_arc_len, mode)?; - if new_arc_len < 1e-10 { + if new_arc_len < 1e-10 || new_arc_len >= std::f64::consts::TAU * arc.radius - 1.0e-9 { return None; } let new_span = new_arc_len / arc.radius; // Which end (start or end angle) is closer to pick? - let start_rad = arc.start_angle; - let end_rad = arc.end_angle; - - let start_pt = Vec3::new( - cx + arc.radius as f32 * start_rad.cos() as f32, - pick_pt.y, - cy + arc.radius as f32 * start_rad.sin() as f32, - ); - let end_pt = Vec3::new( - cx + arc.radius as f32 * end_rad.cos() as f32, - pick_pt.y, - cy + arc.radius as f32 * end_rad.sin() as f32, - ); + let curve = crate::entities::curve::arc_curve(arc); + let start_pt = Vec3::from_array(curve.point_at(0.0).map(|value| value as f32)); + let end_pt = Vec3::from_array(curve.point_at(1.0).map(|value| value as f32)); let dist_start = (pick_pt - start_pt).length(); let dist_end = (pick_pt - end_pt).length(); - let delta_span = new_span - span; - let mut result = arc.clone(); result.common.handle = Handle::NULL; @@ -255,7 +240,6 @@ fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option Option // ── Arc offset ───────────────────────────────────────────────────────────── fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option { - let px = side_pt.x as f64; - let py = side_pt.y as f64; + let plane = crate::entities::curve::arc_curve(a).plane; + let [px, py] = plane.project(side_pt.as_dvec3().to_array())?; let dc = ((px - a.center.x).powi(2) + (py - a.center.y).powi(2)).sqrt(); let new_r = if dc < a.radius { diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index b3d5cab7..bab8229d 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1268,6 +1268,11 @@ fn translated_prototype_wire( center[axis] += delta_f32[axis]; } } + TangentGeom::Arc { center, .. } => { + for axis in 0..3 { + center[axis] += delta[axis]; + } + } } } if !wire.text_verts.is_empty() { @@ -2194,9 +2199,9 @@ fn emit_wire( )); } for tg in &lw.tangent_geoms { - entry - .tangent_geoms - .push(transform_tangent(tg, accum_xform)); + if let Some(tangent) = transform_tangent(tg, accum_xform) { + entry.tangent_geoms.push(tangent); + } } // Per the WireModel contract an empty `fill_tris_low` means "all-zero low // half" (e.g. a Leader / dimension arrowhead fill, which the tessellator @@ -2299,7 +2304,7 @@ fn emit_wire( fn transform_tangent( tg: &TangentGeom, t: &Transform, -) -> TangentGeom { +) -> Option { match tg { TangentGeom::Line { p1, p2 } => { let q1 = t.apply(Vector3::new( @@ -2312,10 +2317,10 @@ fn transform_tangent( p2[1] as f64, p2[2] as f64, )); - TangentGeom::Line { + Some(TangentGeom::Line { p1: [(q1.x) as f32, (q1.y) as f32, (q1.z) as f32], p2: [(q2.x) as f32, (q2.y) as f32, (q2.z) as f32], - } + }) } TangentGeom::Circle { center, radius } => { let c = t.apply(Vector3::new( @@ -2327,10 +2332,44 @@ fn transform_tangent( let sx = ((m[0][0] * m[0][0] + m[0][1] * m[0][1] + m[0][2] * m[0][2]) as f64).sqrt(); let sy = ((m[1][0] * m[1][0] + m[1][1] * m[1][1] + m[1][2] * m[1][2]) as f64).sqrt(); let s = ((sx + sy) * 0.5) as f32; - TangentGeom::Circle { + Some(TangentGeom::Circle { center: [(c.x) as f32, (c.y) as f32, (c.z) as f32], radius: radius * s, + }) + } + TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } => { + let c = t.apply(Vector3::new(center[0], center[1], center[2])); + let x = t.apply_rotation(Vector3::new(axis_x[0], axis_x[1], axis_x[2])); + let y = t.apply_rotation(Vector3::new(axis_y[0], axis_y[1], axis_y[2])); + let sx = x.length(); + let sy = y.length(); + let scale = sx.max(sy); + if !scale.is_finite() + || scale <= 1.0e-12 + || (sx - sy).abs() > scale * 1.0e-9 + { + return None; } + let x = x / sx; + let y = y / sy; + if x.dot(&y).abs() > 1.0e-9 { + return None; + } + Some(TangentGeom::Arc { + center: [c.x, c.y, c.z], + axis_x: [x.x, x.y, x.z], + axis_y: [y.x, y.y, y.z], + radius: radius * ((sx + sy) * 0.5), + start_angle: *start_angle, + end_angle: *end_angle, + }) } } } diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 0133640a..703db12a 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -29,8 +29,17 @@ pub enum SnapHint { pub enum TangentGeom { /// Infinite line through these two world-space points. Line { p1: [f32; 3], p2: [f32; 3] }, - /// Circle/arc. + /// Complete circle. Circle { center: [f32; 3], radius: f32 }, + /// Bounded circular arc in its world-space plane. + Arc { + center: [f64; 3], + axis_x: [f64; 3], + axis_y: [f64; 3], + radius: f64, + start_angle: f64, + end_angle: f64, + }, } /// A 1-D entity (line, arc, polyline) represented as an ordered set of diff --git a/src/snap.rs b/src/snap.rs index b34859f7..43da8b47 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1607,7 +1607,7 @@ impl Snapper { let d2 = dist2_to_segment(cursor_screen, sp0, sp1); let t = t_on_segment(cursor_screen, sp0, sp1); let w = Vec3::from(*p1) + t * (Vec3::from(*p2) - Vec3::from(*p1)); - (w, d2) + (w.as_dvec3(), d2) } TangentGeom::Circle { center, radius } => { let cv = Vec3::from(*center); @@ -1621,52 +1621,83 @@ impl Snapper { ); let sr = dist2(sc, rim).sqrt(); let dc = dist2(cursor_screen, sc).sqrt(); - // Trigger by proximity to the circle EDGE (hovering - // the circle), independent of where the tangent point - // lands. let edge_d = (dc - sr).abs(); - // A TRUE tangent: from the point the command draws - // from (P), the tangent point T on the circle has the - // radius CT perpendicular to PT, so T lies at the - // half-angle acos(r/|CP|) either side of the C→P - // direction. Two solutions — take the one nearer the - // cursor. Without P (a deferred tangent picked first) - // or with P inside the circle, fall back to the circle - // point facing the cursor. Previously this always used - // the facing point, so Tangent behaved like Nearest - // (#274). - let w = self - .from_point - .and_then(|p| circle_tangent_points(p, cv, r)) - .map(|(t0, t1)| { - // Two tangents — take the one nearer the cursor. - let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds); - let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds); - if dist2(s0, cursor_screen) <= dist2(s1, cursor_screen) { - t0 - } else { - t1 - } + let w = if let Some(from) = self.from_point { + let Some((t0, t1)) = circle_tangent_points(from, cv, r) else { + continue; + }; + let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds); + let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds); + if dist2(s0, cursor_screen) <= dist2(s1, cursor_screen) { + t0 + } else { + t1 + } + } else { + let dx = cursor_screen.x - sc.x; + let dy = cursor_screen.y - sc.y; + let dl = (dx * dx + dy * dy).sqrt(); + let (nx, ny) = if dl > 1e-6 { + (dx / dl, -dy / dl) + } else { + (1.0, 0.0) + }; + Vec3::new(cv.x + r * nx, cv.y + r * ny, cv.z) + }; + (w.as_dvec3(), edge_d * edge_d) + } + TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } => { + let Some(from) = self.from_point else { + continue; + }; + let Some(world) = arc_tangent_points( + from.as_dvec3(), + *center, + *axis_x, + *axis_y, + *radius, + *start_angle, + *end_angle, + ) + .into_iter() + .min_by(|a, b| { + let sa = world_to_screen(*a, view_rot, eye, bounds); + let sb = world_to_screen(*b, view_rot, eye, bounds); + dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen)) + }) else { + continue; + }; + let edge_d2 = wire + .points + .windows(2) + .enumerate() + .filter_map(|(index, _)| { + let a = wp_f64(wire, index); + let b = wp_f64(wire, index + 1); + (a.is_finite() && b.is_finite()).then(|| { + dist2_to_segment( + cursor_screen, + world_to_screen(a, view_rot, eye, bounds), + world_to_screen(b, view_rot, eye, bounds), + ) + }) }) - .unwrap_or_else(|| { - let dx = cursor_screen.x - sc.x; - let dy = cursor_screen.y - sc.y; - let dl = (dx * dx + dy * dy).sqrt(); - let (nx, ny) = if dl > 1e-6 { - (dx / dl, -dy / dl) - } else { - (1.0, 0.0) - }; - Vec3::new(cv.x + r * nx, cv.y + r * ny, cv.z) - }); - (w, edge_d * edge_d) + .fold(f32::INFINITY, f32::min); + (world, edge_d2) } }; let (tier, sub) = ( snap_tier(SnapType::Tangent), snap_priority(SnapType::Tangent), ); - let screen_pt = world_to_screen(world_pt.as_dvec3(), view_rot, eye, bounds); + let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds); if d2 < radius2 && in_bounds(screen_pt) && snap_better(tier, d2, sub, (best_rank, best_d2, best_sub)) @@ -1687,9 +1718,13 @@ impl Snapper { ), radius: *radius as f64, }, + TangentGeom::Arc { center, radius, .. } => TangentObject::Circle { + center: glam::DVec3::from_array(*center), + radius: *radius, + }, }; best = Some(SnapResult { - world: world_pt.as_dvec3(), + world: world_pt, screen: screen_pt, snap_type: SnapType::Tangent, tangent_obj: Some(tangent_obj), @@ -2249,30 +2284,45 @@ fn seg_intersect_2d(a0: Point, a1: Point, b0: Point, b1: Point) -> Option<(f32, Some((t, s)) } -/// The two points on a circle (`center`, `radius`, in its XY plane) where a -/// line drawn from `p` touches tangentially, or `None` when `p` is inside or on -/// the circle (no external tangent). Each tangent point's radius is -/// perpendicular to the line from `p`, so it sits at the half-angle -/// `acos(radius / |center→p|)` on either side of the center→p direction. This -/// is the real Tangent osnap; snapping to the circle point merely facing the -/// cursor made Tangent behave like Nearest (#274). +/// Returns the two external tangent points on an XY circle. fn circle_tangent_points(p: Vec3, center: Vec3, radius: f32) -> Option<(Vec3, Vec3)> { - let vx = p.x - center.x; - let vy = p.y - center.y; - let d = (vx * vx + vy * vy).sqrt(); - if d <= radius + 1e-6 { + let curve = cadkernel::geom2d::Curve::Circle(cadkernel::geom2d::Circle { + centre: [center.x as f64, center.y as f64], + radius: radius as f64, + }); + let points = cadkernel::geom2d::tangent_from(&curve, [p.x as f64, p.y as f64]); + let [first, second] = points.as_slice() else { return None; - } - let base = vy.atan2(vx); - let off = (radius / d).acos(); - let at = |a: f32| { - Vec3::new( - center.x + radius * a.cos(), - center.y + radius * a.sin(), - center.z, - ) }; - Some((at(base + off), at(base - off))) + Some(( + Vec3::new(first.point[0] as f32, first.point[1] as f32, center.z), + Vec3::new(second.point[0] as f32, second.point[1] as f32, center.z), + )) +} + +fn arc_tangent_points( + from: DVec3, + center: [f64; 3], + axis_x: [f64; 3], + axis_y: [f64; 3], + radius: f64, + start_angle: f64, + end_angle: f64, +) -> Vec { + let plane = cadkernel::space::Plane::from_axes(center, axis_x, axis_y); + let Some(from) = plane.project(from.to_array()) else { + return Vec::new(); + }; + let curve = cadkernel::geom2d::Curve::Arc(cadkernel::geom2d::Arc { + centre: [0.0, 0.0], + radius, + start_angle, + end_angle, + }); + cadkernel::geom2d::tangent_from(&curve, from) + .into_iter() + .map(|point| DVec3::from_array(plane.point_at(point.point))) + .collect() } /// Snap to the extension of a ray beyond `origin` in `dir` direction.