diff --git a/Cargo.lock b/Cargo.lock index 395e2d54..e3534bff 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/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb" +source = "git+https://github.com/ramox81/cadcodec.git?rev=662a569#662a569ceec9ec6f26392be019e600e56ed2ac46" 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=ebeb2ec#ebeb2ecc2d17d92c588b0fa8bbe3864b156bc71f" +source = "git+https://github.com/ramox81/cadkernel.git?rev=384cc63#384cc63df48c26a19dc9df310c444972cee676aa" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 8cbeac47..2b1feda3 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/HakanSeven12/cadcodec.git", rev = "0908da7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ebeb2ec", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "662a569", features = ["serde"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "384cc63", 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 3d393aa2..fcad125a 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/HakanSeven12/cadcodec.git", rev = "0908da7", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "662a569", 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/HakanSeven12/cadcodec.git", rev = "0908da7", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "662a569", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 671ef9ea..3353d3df 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/HakanSeven12/cadcodec.git", rev = "0908da7", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "662a569", 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 e0bf9acc..d33cb0b6 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 4f32b015..c9d8ce91 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..e14721eb 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -43,9 +43,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 { + let tangent = TangentGeom::Arc { center: [cwx as f32, cwy as f32, cwz as f32], + axis_x: [ax.0 as f32, ax.1 as f32, ax.2 as f32], + axis_y: [ay.0 as f32, ay.1 as f32, ay.2 as f32], radius: r as f32, + start_angle: sa as f32, + end_angle: ea as f32, }; if arc.thickness.abs() > 1e-10 { @@ -98,20 +102,11 @@ 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)), ] } @@ -169,6 +164,21 @@ pub(crate) fn refit_grips( return false; } + let plane = crate::entities::curve::arc_curve(original).plane; + let Some(a) = plane.project(points[0].to_array()) else { + return false; + }; + let Some(b) = plane.project(points[1].to_array()) else { + return false; + }; + let Some(c) = plane.project(points[2].to_array()) else { + return false; + }; + let points = [ + glam::DVec3::new(a[0], a[1], 0.0), + glam::DVec3::new(b[0], b[1], 0.0), + glam::DVec3::new(c[0], c[1], 0.0), + ]; let Some((center, radius)) = circumcircle(points[0], points[1], points[2]) else { return false; }; @@ -198,7 +208,11 @@ pub(crate) fn refit_grips( } 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 +229,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 +258,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 +282,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 +309,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 +426,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 +456,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 +468,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 +501,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..bbf1e55d 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -8,7 +8,7 @@ // 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_CSE — Center, Start, End // ARC_CSA — Center, Start, Angle // ARC_CSL — Center, Start, Length of chord // ARC_CONT— Continue (tangent to previous line/arc; pick end only) @@ -60,7 +60,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), @@ -117,19 +117,6 @@ fn make_arc( })) } -/// 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) -} - -/// 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(), @@ -180,8 +167,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 +183,49 @@ 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 sweep = 4.0 * (2.0 * h.abs() / chord_len).atan(); + let included = -h.signum() * sweep * if flip_direction { -1.0 } else { 1.0 }; + arc_from_endpoints_angle( + plane.to_world(s), + plane.to_world(e), + included, + plane, + ) +} + +/// Arc through two endpoints with a signed included angle. Positive angles +/// travel counter-clockwise from start to end; negative angles travel +/// clockwise and are stored by swapping the geometric endpoints. +fn arc_from_endpoints_angle( + s: DVec3, + e: DVec3, + included: f64, + plane: WorkingPlane, +) -> Option<(DVec3, f64, f64, f64)> { + let (s_local, e_local) = (plane.to_local(s), plane.to_local(e)); + let chord = e_local - s_local; + let length = chord.length(); + let sweep = included.abs(); + if length <= 1.0e-9 || sweep <= 1.0e-9 || sweep >= TAU - 1.0e-9 { + return None; + } + let half_sin = (sweep * 0.5).sin().abs(); + if half_sin <= 1.0e-12 { + return None; + } + let radius = length / (2.0 * half_sin); + let unit = chord / length; + let left = DVec3::new(-unit.y, unit.x, 0.0); + let offset = length / (2.0 * (sweep * 0.5).tan()); + let center_local = (s_local + e_local) * 0.5 + left * offset * included.signum(); + let center = plane.to_world(center_local); + let start = angle_xy(center, s, plane); + let end = angle_xy(center, e, plane); + if included > 0.0 { + Some((center, radius, start, end)) + } else { + Some((center, radius, end, start)) + } } /// Arc center+radius from start, end, and a radius-magnitude point (dist = dist(pt, start)). @@ -207,26 +233,35 @@ 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 { +) -> 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) +} + +fn arc_from_endpoints_radius( + s: DVec3, + e: DVec3, + signed_radius: f64, + clockwise: bool, + plane: WorkingPlane, +) -> Option<(DVec3, f64, f64, f64)> { + let chord_len = plane.to_local(s).distance(plane.to_local(e)); + let radius = signed_radius.abs(); + if radius <= 1.0e-9 || chord_len <= 1.0e-9 || radius < 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)) + let minor = 2.0 * (chord_len / (2.0 * radius)).asin(); + let mut included = if signed_radius >= 0.0 { + minor + } else { + TAU - minor + }; + if clockwise { + included = -included; + } + arc_from_endpoints_angle(s, e, included, plane) } /// Arc center+radius from start, end, and a tangent-direction point at start. @@ -290,66 +325,52 @@ fn arc_continue( Some((center, radius, sa, ea)) } -/// 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. +/// Continuation anchor for `ARC_CONT`: the point where drawing a line, arc or +/// planar polyline ended plus the unit tangent leaving it. /// `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. 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 { + if r <= 0.0 || chord == 0.0 || chord.abs() > 2.0 * r { + return None; + } + let minor = 2.0 * (chord.abs() / (2.0 * r)).asin(); + let sweep = if chord > 0.0 { minor } else { TAU - minor }; + Some(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 +382,6 @@ impl ArcCommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -372,9 +392,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 { @@ -444,7 +467,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,18 +492,6 @@ 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 { arc_preview(self.c, self.r, ea, self.sa, self.plane) @@ -550,10 +561,14 @@ impl CadCommand for Arc3PCommand { } } 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 @@ -595,7 +610,6 @@ pub struct ArcSCECommand { c: DVec3, r: f64, sa: f64, - prev_pt: Option, cw: bool, plane: WorkingPlane, } @@ -608,7 +622,6 @@ impl ArcSCECommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -619,6 +632,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" @@ -662,7 +678,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,18 +687,6 @@ 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 { arc_preview(self.c, self.r, ea, self.sa, self.plane) @@ -704,7 +708,6 @@ pub struct ArcSCACommand { c: DVec3, r: f64, sa: f64, - prev_pt: Option, cw: bool, plane: WorkingPlane, } @@ -717,7 +720,6 @@ impl ArcSCACommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -728,6 +730,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" @@ -775,7 +780,7 @@ 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 @@ -783,11 +788,13 @@ impl CadCommand for ArcSCACommand { 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 ea = self.sa + span.to_radians(); - return Some(CmdResult::CommitAndExit(make_arc( - self.c, self.r, self.sa, ea, self.plane, - ))); + let entity = if span < 0.0 { + make_arc(self.c, self.r, ea, self.sa, self.plane) + } else { + make_arc(self.c, self.r, self.sa, ea, self.plane) + }; + return Some(CmdResult::CommitAndExit(entity)); } None } @@ -818,18 +825,6 @@ 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 { arc_preview(self.c, self.r, ea, self.sa, self.plane) @@ -852,6 +847,7 @@ pub struct ArcSCLCommand { c: DVec3, r: f64, sa: f64, + cw: bool, plane: WorkingPlane, } @@ -863,6 +859,7 @@ impl ArcSCLCommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, + cw: false, plane: WorkingPlane::default(), } } @@ -872,6 +869,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 +906,15 @@ 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; + }; + let entity = if self.cw { + make_arc(self.c, self.r, ea, self.sa, self.plane) + } else { + make_arc(self.c, self.r, self.sa, ea, self.plane) + }; + CmdResult::CommitAndExit(entity) } } } @@ -921,7 +922,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,8 +930,8 @@ 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); + 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, ))); @@ -959,8 +960,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)?; + Some(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 +979,7 @@ pub struct ArcSEACommand { step: u8, s: DVec3, e: DVec3, + ctrl: bool, plane: WorkingPlane, } @@ -983,6 +989,7 @@ impl ArcSEACommand { step: 0, s: DVec3::ZERO, e: DVec3::ZERO, + ctrl: false, plane: WorkingPlane::default(), } } @@ -992,6 +999,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,10 +1025,8 @@ 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); + _ => match arc_from_sagitta(self.s, self.e, pt, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane)) } None => CmdResult::Cancel, @@ -1029,20 +1037,41 @@ 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 included = text.trim().replace(',', ".").parse::().ok()?.to_radians(); + let (center, radius, sa, ea) = + arc_from_endpoints_angle(self.s, self.e, included, self.plane)?; + Some(CmdResult::CommitAndExit(make_arc( + 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)) } else { Some(line_wire(self.s, self.e)) @@ -1060,6 +1089,7 @@ pub struct ArcSERCommand { step: u8, s: DVec3, e: DVec3, + ctrl: bool, plane: WorkingPlane, } @@ -1069,6 +1099,7 @@ impl ArcSERCommand { step: 0, s: DVec3::ZERO, e: DVec3::ZERO, + ctrl: false, plane: WorkingPlane::default(), } } @@ -1078,6 +1109,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,10 +1135,8 @@ 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); + _ => match arc_from_se_radius(self.s, self.e, pt, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane)) } None => CmdResult::Cancel, @@ -1115,7 +1147,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 +1155,11 @@ 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, false, self.plane)?; + return Some(CmdResult::CommitAndExit(make_arc( + center, radius, sa, ea, self.plane, + ))); } None } @@ -1160,17 +1180,16 @@ 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)) } else { Some(line_wire(self.s, self.e)) @@ -1188,6 +1207,7 @@ pub struct ArcSEDCommand { step: u8, s: DVec3, e: DVec3, + ctrl: bool, plane: WorkingPlane, } @@ -1197,6 +1217,7 @@ impl ArcSEDCommand { step: 0, s: DVec3::ZERO, e: DVec3::ZERO, + ctrl: false, plane: WorkingPlane::default(), } } @@ -1206,6 +1227,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,10 +1253,8 @@ 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); + _ => match arc_continue(self.s, pt - self.s, self.e, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane)) } None => CmdResult::Cancel, @@ -1243,20 +1265,42 @@ 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(CmdResult::CommitAndExit(make_arc( + 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)) } else { Some(line_wire(self.s, self.e)) @@ -1275,7 +1319,6 @@ pub struct ArcCSACommand { c: DVec3, r: f64, sa: f64, - prev_pt: Option, cw: bool, plane: WorkingPlane, } @@ -1287,7 +1330,6 @@ impl ArcCSACommand { c: DVec3::ZERO, r: 0.0, sa: 0.0, - prev_pt: None, cw: false, plane: WorkingPlane::default(), } @@ -1298,6 +1340,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" @@ -1344,7 +1389,7 @@ 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 @@ -1353,9 +1398,12 @@ impl CadCommand for ArcCSACommand { if self.step == 2 { let span: f64 = text.trim().replace(',', ".").parse().ok()?; let ea = self.sa + span.to_radians(); - return Some(CmdResult::CommitAndExit(make_arc( - self.c, self.r, self.sa, ea, self.plane, - ))); + let entity = if span < 0.0 { + make_arc(self.c, self.r, ea, self.sa, self.plane) + } else { + make_arc(self.c, self.r, self.sa, ea, self.plane) + }; + return Some(CmdResult::CommitAndExit(entity)); } None } @@ -1384,18 +1432,6 @@ 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 { arc_preview(self.c, self.r, ea, self.sa, self.plane) @@ -1417,6 +1453,7 @@ pub struct ArcCSLCommand { s: DVec3, r: f64, sa: f64, + cw: bool, plane: WorkingPlane, } @@ -1428,6 +1465,7 @@ impl ArcCSLCommand { s: DVec3::ZERO, r: 0.0, sa: 0.0, + cw: false, plane: WorkingPlane::default(), } } @@ -1437,6 +1475,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 +1512,15 @@ 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; + }; + let entity = if self.cw { + make_arc(self.c, self.r, ea, self.sa, self.plane) + } else { + make_arc(self.c, self.r, self.sa, ea, self.plane) + }; + CmdResult::CommitAndExit(entity) } } } @@ -1486,7 +1528,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,8 +1536,8 @@ 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); + 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, ))); @@ -1524,8 +1566,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)?; + Some(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 +1580,16 @@ 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. +// Tangent-continuing arc from the previous line, arc or planar polyline +// endpoint. The start point and 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 +1600,6 @@ impl ArcContCommand { tangent, ctrl: false, plane: WorkingPlane::default(), - history: Vec::new(), } } } @@ -1580,32 +1622,13 @@ impl CadCommand for ArcContCommand { 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) + CmdResult::CommitAndExit(arc) } - // A degenerate pick (no arc through these points) shouldn't end the - // whole run — stay active and wait for the next end point. + // A degenerate pick should keep the one-shot command active so the + // user can choose a valid 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 } @@ -1623,10 +1646,10 @@ impl CadCommand for ArcContCommand { } // ── 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..8e5e1161 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; diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 113ecbd6..8f6c0b05 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -213,33 +213,20 @@ 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(); diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index 648786a1..76e390da 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -127,8 +127,8 @@ fn offset_circle(c: &CircleEnt, dist: f64, side_pt: Vec3) -> 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 d8f79c6c..53ff5560 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1227,7 +1227,7 @@ fn translated_prototype_wire( p2[axis] += delta_f32[axis]; } } - TangentGeom::Circle { center, .. } => { + TangentGeom::Circle { center, .. } | TangentGeom::Arc { center, .. } => { for axis in 0..3 { center[axis] += delta_f32[axis]; } @@ -2282,6 +2282,39 @@ fn transform_tangent( radius: radius * s, } } + TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } => { + let c = t.apply(Vector3::new(center[0] as f64, center[1] as f64, center[2] as f64)); + let x = t.apply_rotation(Vector3::new( + axis_x[0] as f64, + axis_x[1] as f64, + axis_x[2] as f64, + )); + let y = t.apply_rotation(Vector3::new( + axis_y[0] as f64, + axis_y[1] as f64, + axis_y[2] as f64, + )); + let sx = x.length(); + let sy = y.length(); + let scale = (sx + sy) * 0.5; + let x = x.normalize(); + let y = y.normalize(); + TangentGeom::Arc { + center: [c.x as f32, c.y as f32, c.z as f32], + axis_x: [x.x as f32, x.y as f32, x.z as f32], + axis_y: [y.x as f32, y.y as f32, y.z as f32], + radius: radius * scale as f32, + 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..75ff49d3 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: [f32; 3], + axis_x: [f32; 3], + axis_y: [f32; 3], + radius: f32, + start_angle: f32, + end_angle: f32, + }, } /// 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..0caa352f 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1661,6 +1661,88 @@ impl Snapper { }); (w, edge_d * edge_d) } + TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } => { + let cv = Vec3::from(*center); + let ux = Vec3::from(*axis_x).normalize_or_zero(); + let uy = Vec3::from(*axis_y).normalize_or_zero(); + let r = *radius; + let sweep = (*end_angle - *start_angle) + .rem_euclid(std::f32::consts::TAU); + let contains = |angle: f32| { + (angle - *start_angle).rem_euclid(std::f32::consts::TAU) + <= sweep + 1.0e-5 + }; + let candidate = self.from_point.and_then(|from| { + let delta = from - cv; + let px = delta.dot(ux); + let py = delta.dot(uy); + let d2 = px * px + py * py; + if d2 <= r * r + 1.0e-6 { + return None; + } + let base = r * r / d2; + let side = r * (d2 - r * r).sqrt() / d2; + let local = [ + (base * px - side * py, base * py + side * px), + (base * px + side * py, base * py - side * px), + ]; + local + .into_iter() + .filter(|&(x, y)| contains(y.atan2(x))) + .map(|(x, y)| cv + ux * x + uy * y) + .min_by(|a, b| { + let sa = world_to_screen( + a.as_dvec3(), view_rot, eye, bounds, + ); + let sb = world_to_screen( + b.as_dvec3(), view_rot, eye, bounds, + ); + dist2(sa, cursor_screen) + .total_cmp(&dist2(sb, cursor_screen)) + }) + }); + let fallback = || { + (0..wire.points.len()) + .map(|index| wp_f64(wire, index).as_vec3()) + .filter(|point| point.is_finite()) + .min_by(|a, b| { + let sa = world_to_screen( + a.as_dvec3(), view_rot, eye, bounds, + ); + let sb = world_to_screen( + b.as_dvec3(), view_rot, eye, bounds, + ); + dist2(sa, cursor_screen) + .total_cmp(&dist2(sb, cursor_screen)) + }) + .unwrap_or(cv) + }; + let world = candidate.unwrap_or_else(fallback); + 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), + ) + }) + }) + .fold(f32::INFINITY, f32::min); + (world, edge_d2) + } }; let (tier, sub) = ( snap_tier(SnapType::Tangent), @@ -1687,6 +1769,14 @@ impl Snapper { ), radius: *radius as f64, }, + TangentGeom::Arc { center, radius, .. } => TangentObject::Circle { + center: glam::DVec3::new( + center[0] as f64, + center[1] as f64, + center[2] as f64, + ), + radius: *radius as f64, + }, }; best = Some(SnapResult { world: world_pt.as_dvec3(),