Align arc creation and editing behavior

This commit is contained in:
ramox81 2026-08-21 14:03:58 +03:00
commit 5f9d8a9e0e
15 changed files with 537 additions and 337 deletions

4
Cargo.lock generated
View file

@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]] [[package]]
name = "acadrust" name = "acadrust"
version = "0.4.1" 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 = [ dependencies = [
"ahash 0.8.12", "ahash 0.8.12",
"anyhow", "anyhow",
@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cadkernel" name = "cadkernel"
version = "0.1.0" 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 = [ dependencies = [
"acadrust", "acadrust",
"cavalier_contours", "cavalier_contours",

View file

@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17" rfd = "0.17"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
env_logger = "0.11" env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0908da7", features = ["serde"] } acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "662a569", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ebeb2ec", features = ["acis", "offset"] } cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "384cc63", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1" flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }

View file

@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
# Pulled in only by the `host` feature, which adds the `acadrust`-typed # Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine # `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply. # 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). # Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true } interprocess = { version = "2", optional = true }
@ -37,7 +37,7 @@ serde_json = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
cargo-lock = "11" cargo-lock = "11"
# acadrust is scanned at build time to generate the embedded type registry. # 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] [dev-dependencies]
serde_json = "1" serde_json = "1"

View file

@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [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" bincode = "1.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"

View file

@ -764,7 +764,10 @@ impl OpenCADStudio {
// leave the anchor untouched. (#327) // leave the anchor untouched. (#327)
if matches!( if matches!(
entity, entity,
acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_) acadrust::EntityType::Line(_)
| acadrust::EntityType::Arc(_)
| acadrust::EntityType::LwPolyline(_)
| acadrust::EntityType::Polyline2D(_)
) { ) {
self.update_cont_anchor(&entity); self.update_cont_anchor(&entity);
} }

View file

@ -300,8 +300,8 @@ impl OpenCADStudio {
} }
"ARC" => { "ARC" => {
use crate::modules::draw::draw::arc::ArcCommand; use crate::modules::draw::draw::arc::Arc3PCommand;
let new_cmd = ArcCommand::new(); let new_cmd = Arc3PCommand::new();
self.command_line.push_info(&new_cmd.prompt()); self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); 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.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd)); 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" => { "ARC_SCE" => {
use crate::modules::draw::draw::arc::ArcSCECommand; use crate::modules::draw::draw::arc::ArcSCECommand;
let new_cmd = ArcSCECommand::new(); let new_cmd = ArcSCECommand::new();

View file

@ -43,9 +43,13 @@ fn to_render(arc: &Arc) -> RenderEntity {
// curves) deliberately emit no midpoint; see #34. // curves) deliberately emit no midpoint; see #34.
let curve = crate::entities::curve::arc_curve(arc); let curve = crate::entities::curve::arc_curve(arc);
let snap = crate::entities::curve::snap_from(&curve); 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], 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, radius: r as f32,
start_angle: sa as f32,
end_angle: ea as f32,
}; };
if arc.thickness.abs() > 1e-10 { if arc.thickness.abs() > 1e-10 {
@ -98,20 +102,11 @@ fn to_render(arc: &Arc) -> RenderEntity {
} }
fn control_points(arc: &Arc) -> [glam::DVec3; 3] { fn control_points(arc: &Arc) -> [glam::DVec3; 3] {
let center = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z); let curve = crate::entities::curve::arc_curve(arc);
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,
)
};
[ [
point(arc.start_angle), glam::DVec3::from_array(curve.point_at(0.0)),
point(arc.start_angle + sweep * 0.5), glam::DVec3::from_array(curve.point_at(0.5)),
point(arc.end_angle), glam::DVec3::from_array(curve.point_at(1.0)),
] ]
} }
@ -169,6 +164,21 @@ pub(crate) fn refit_grips(
return false; 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 { let Some((center, radius)) = circumcircle(points[0], points[1], points[2]) else {
return false; return false;
}; };
@ -198,7 +208,11 @@ pub(crate) fn refit_grips(
} }
fn grips(arc: &Arc) -> Vec<GripDef> { fn grips(arc: &Arc) -> Vec<GripDef> {
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); let [start, middle, end] = control_points(arc);
vec![ vec![
center_grip(0, ctr), center_grip(0, ctr),
@ -215,7 +229,11 @@ fn properties(arc: &Arc) -> Vec<PropSection> {
let sweep = (ea - sa).rem_euclid(TAU); let sweep = (ea - sa).rem_euclid(TAU);
let total_angle = sweep.to_degrees(); let total_angle = sweep.to_degrees();
let arc_length = r * sweep; 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 normal = (arc.normal.x, arc.normal.y, arc.normal.z);
let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); let (ax, ay) = crate::scene::view::transform::ocs_axes(normal);
@ -240,9 +258,9 @@ fn properties(arc: &Arc) -> Vec<PropSection> {
ro(t!("Start X").as_ref(), "start_x", format!("{sx:.4}")), 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 Y").as_ref(), "start_y", format!("{sy:.4}")),
ro(t!("Start Z").as_ref(), "start_z", format!("{sz:.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 X").as_ref(), "center_x", cwx),
edit(t!("Center Y").as_ref(), "center_y", arc.center.y), edit(t!("Center Y").as_ref(), "center_y", cwy),
edit(t!("Center Z").as_ref(), "center_z", arc.center.z), edit(t!("Center Z").as_ref(), "center_z", cwz),
ro(t!("End X").as_ref(), "end_x", format!("{ex:.4}")), 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 Y").as_ref(), "end_y", format!("{ey:.4}")),
ro(t!("End Z").as_ref(), "end_z", format!("{ez:.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; return;
}; };
match field { match field {
"center_x" => arc.center.x = v, "center_x" | "center_y" | "center_z" => {
"center_y" => arc.center.y = v, let normal = (arc.normal.x, arc.normal.y, arc.normal.z);
"center_z" => arc.center.z = v, 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, "radius" if v > 0.0 => arc.radius = v,
"start_angle" => arc.start_angle = v.to_radians(), "start_angle" => arc.start_angle = v.to_radians(),
"end_angle" => arc.end_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) { fn apply_grip(arc: &mut Arc, grip_id: usize, apply: GripApply) {
match (grip_id, apply) { match (grip_id, apply) {
(0, GripApply::Translate(d)) => { (0, GripApply::Translate(d)) => {
arc.center.x += d.x; let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
arc.center.y += d.y; (d.x, d.y, d.z),
arc.center.z += 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)) => { (0, GripApply::Absolute(p)) => {
arc.center.x = p[0]; let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
arc.center.y = p[1]; (p[0], p[1], p[2]),
arc.center.z = 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)) => { (1..=3, GripApply::Absolute(p)) => {
let original = arc.clone(); 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 { if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 {
return None; 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 current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU);
let desired_sweep = match grip_id { let desired_sweep = match grip_id {
1 => (self.end_angle - cursor_angle).rem_euclid(TAU), 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 // Hold start_angle, derive new end_angle from arc length
// = r * Δθ. // = r * Δθ.
let new_span = value / self.radius; 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 => { A::Lengthen => {
// Extend either end by `value` arc-length units along // Extend either end by `value` arc-length units along
@ -422,6 +468,11 @@ impl crate::entities::traits::Grippable for Arc {
return; return;
} }
let dtheta = value / self.radius; 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 { match grip_id {
1 => self.start_angle -= dtheta, 1 => self.start_angle -= dtheta,
2 => self.end_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 { impl crate::entities::traits::MassPropsCalc for acadrust::entities::Arc {
fn mass_props(&self) -> crate::entities::traits::MassProps { fn mass_props(&self) -> crate::entities::traits::MassProps {
use std::f64::consts::TAU; let curve = crate::entities::curve::arc_curve(self);
let r = self.radius; let area = curve.curve.chord_closed_area().unwrap_or(0.0).abs();
let span = { let centroid = curve
let s = (self.end_angle - self.start_angle).rem_euclid(TAU); .curve
if s < 1e-6 { .chord_closed_centroid()
TAU .map(|point| curve.plane.point_at(point))
} else { .unwrap_or_else(|| curve.point_at(0.5));
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;
crate::entities::traits::MassProps { crate::entities::traits::MassProps {
area, area,
perimeter: arc_len, perimeter: curve.length(),
cx: self.center.x + r * mid_rad.cos(), cx: centroid[0],
cy: self.center.y + r * mid_rad.sin(), cy: centroid[1],
} }
} }
} }

View file

@ -8,7 +8,7 @@
// ARC_SEA — Start, End, Angle (sagitta-based pick) // ARC_SEA — Start, End, Angle (sagitta-based pick)
// ARC_SED — Start, End, Direction (tangent at start) // ARC_SED — Start, End, Direction (tangent at start)
// ARC_SER — Start, End, Radius (radius defined by dist cursor→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_CSA — Center, Start, Angle
// ARC_CSL — Center, Start, Length of chord // ARC_CSL — Center, Start, Length of chord
// ARC_CONT— Continue (tangent to previous line/arc; pick end only) // 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_SEA", "Start, End, Angle", ICON_SEA),
("ARC_SED", "Start, End, Direction", ICON_SED), ("ARC_SED", "Start, End, Direction", ICON_SED),
("ARC_SER", "Start, End, Radius", ICON_SER), ("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_CSA", "Center, Start, Angle", ICON_CSA),
("ARC_CSL", "Center, Start, Length", ICON_CSL), ("ARC_CSL", "Center, Start, Length", ICON_CSL),
("ARC_CONT", "Continue", ICON_CONT), ("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 { fn line_wire(a: DVec3, b: DVec3) -> WireModel {
WireModel::solid_f64( WireModel::solid_f64(
"rubber_band".into(), "rubber_band".into(),
@ -180,8 +167,9 @@ fn arc_from_sagitta(
s: DVec3, s: DVec3,
e: DVec3, e: DVec3,
cursor: DVec3, cursor: DVec3,
flip_direction: bool,
plane: WorkingPlane, 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 (s, e, cursor) = (plane.to_local(s), plane.to_local(e), plane.to_local(cursor));
let chord_vec = e - s; let chord_vec = e - s;
let chord_len = (chord_vec.x * chord_vec.x + chord_vec.y * chord_vec.y).sqrt(); 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 { if h.abs() < 1e-3 {
return None; return None;
} }
let r = (chord_len * chord_len + 4.0 * h * h) / (8.0 * h.abs()); let sweep = 4.0 * (2.0 * h.abs() / chord_len).atan();
let d = (r * r - (chord_len * 0.5) * (chord_len * 0.5)) let included = -h.signum() * sweep * if flip_direction { -1.0 } else { 1.0 };
.max(0.0) arc_from_endpoints_angle(
.sqrt(); plane.to_world(s),
Some((plane.to_world(mid - perp * h.signum() * d), r)) 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)). /// 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, s: DVec3,
e: DVec3, e: DVec3,
radius_pt: DVec3, radius_pt: DVec3,
clockwise: bool,
plane: WorkingPlane, plane: WorkingPlane,
) -> Option<(DVec3, f64)> { ) -> Option<(DVec3, f64, f64, f64)> {
let (s, e, radius_pt) = ( let radius = plane.to_local(s).distance(plane.to_local(radius_pt));
plane.to_local(s), arc_from_endpoints_radius(s, e, radius, clockwise, plane)
plane.to_local(e), }
plane.to_local(radius_pt),
); fn arc_from_endpoints_radius(
let r = s.distance(radius_pt).max(1e-3); s: DVec3,
let chord_len = s.distance(e); e: DVec3,
if r < chord_len * 0.5 { 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; return None;
} }
let unit_chord = (e - s) / chord_len; let minor = 2.0 * (chord_len / (2.0 * radius)).asin();
let perp = DVec3::new(-unit_chord.y, unit_chord.x, 0.0); let mut included = if signed_radius >= 0.0 {
let mid = (s + e) * 0.5; minor
let h_sign = (radius_pt - mid).dot(perp).signum(); } else {
let d = (r * r - (chord_len * 0.5) * (chord_len * 0.5)) TAU - minor
.max(0.0) };
.sqrt(); if clockwise {
Some((plane.to_world(mid - perp * h_sign * d), r)) included = -included;
}
arc_from_endpoints_angle(s, e, included, plane)
} }
/// Arc center+radius from start, end, and a tangent-direction point at start. /// 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)) Some((center, radius, sa, ea))
} }
/// Continuation anchor for `ARC_CONT`: the point where drawing a line/arc ended /// Continuation anchor for `ARC_CONT`: the point where drawing a line, arc or
/// plus the unit tangent leaving it, used to start a tangent-continuing arc. /// planar polyline ended plus the unit tangent leaving it.
/// `last` (the final pick point) disambiguates which endpoint the drawing ended /// `last` (the final pick point) disambiguates which endpoint the drawing ended
/// on — essential because a committed arc stores geometric CCW start/end angles, /// on — essential because a committed arc stores geometric CCW start/end angles,
/// not the direction the pen travelled. Returns `None` for other entity kinds. /// not the direction the pen travelled. Returns `None` for other entity kinds.
pub fn continue_anchor(entity: &EntityType, last: Option<DVec3>) -> Option<(DVec3, DVec3)> { pub fn continue_anchor(entity: &EntityType, last: Option<DVec3>) -> Option<(DVec3, DVec3)> {
let nearer_first = |a: DVec3, b: DVec3| match last { if !matches!(
Some(p) => p.distance(a) <= p.distance(b), entity,
None => true, // default to the entity's stored end EntityType::Line(_)
}; | EntityType::Arc(_)
match entity { | EntityType::LwPolyline(_)
EntityType::Line(l) => { | EntityType::Polyline2D(_)
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); return None;
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,
} }
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). /// Compute end_angle from a chord-length pick (SCL / CSL semantics).
/// `chord_len` is clamped to [0, 2r]. /// Positive length selects the minor arc; negative length selects the major.
fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> f64 { fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> Option<f64> {
let half = (chord.min(2.0 * r) / (2.0 * r)).asin(); if r <= 0.0 || chord == 0.0 || chord.abs() > 2.0 * r {
start_angle + 2.0 * half 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 { pub struct ArcCommand {
step: u8, step: u8,
c: DVec3, c: DVec3,
r: f64, r: f64,
sa: f64, sa: f64,
prev_pt: Option<DVec3>,
cw: bool, cw: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -361,7 +382,6 @@ impl ArcCommand {
c: DVec3::ZERO, c: DVec3::ZERO,
r: 0.0, r: 0.0,
sa: 0.0, sa: 0.0,
prev_pt: None,
cw: false, cw: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
@ -372,9 +392,12 @@ impl CadCommand for ArcCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.cw = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC" "ARC_CSE"
} }
fn prompt(&self) -> String { fn prompt(&self) -> String {
match self.step { match self.step {
@ -444,7 +467,7 @@ impl CadCommand for ArcCommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -469,18 +492,6 @@ impl CadCommand for ArcCommand {
match self.step { match self.step {
1 => Some(line_wire(self.c, pt)), 1 => Some(line_wire(self.c, pt)),
2 => { 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); let ea = angle_xy(self.c, pt, self.plane);
Some(if self.cw { Some(if self.cw {
arc_preview(self.c, self.r, ea, self.sa, self.plane) 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 { 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 { fn enter_accepts_default_start(&self) -> bool {
self.pts.is_empty() false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -595,7 +610,6 @@ pub struct ArcSCECommand {
c: DVec3, c: DVec3,
r: f64, r: f64,
sa: f64, sa: f64,
prev_pt: Option<DVec3>,
cw: bool, cw: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -608,7 +622,6 @@ impl ArcSCECommand {
c: DVec3::ZERO, c: DVec3::ZERO,
r: 0.0, r: 0.0,
sa: 0.0, sa: 0.0,
prev_pt: None,
cw: false, cw: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
@ -619,6 +632,9 @@ impl CadCommand for ArcSCECommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.cw = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_SCE" "ARC_SCE"
@ -662,7 +678,7 @@ impl CadCommand for ArcSCECommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -671,18 +687,6 @@ impl CadCommand for ArcSCECommand {
match self.step { match self.step {
1 => Some(line_wire(self.s, pt)), 1 => Some(line_wire(self.s, pt)),
2 => { 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); let ea = angle_xy(self.c, pt, self.plane);
Some(if self.cw { Some(if self.cw {
arc_preview(self.c, self.r, ea, self.sa, self.plane) arc_preview(self.c, self.r, ea, self.sa, self.plane)
@ -704,7 +708,6 @@ pub struct ArcSCACommand {
c: DVec3, c: DVec3,
r: f64, r: f64,
sa: f64, sa: f64,
prev_pt: Option<DVec3>,
cw: bool, cw: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -717,7 +720,6 @@ impl ArcSCACommand {
c: DVec3::ZERO, c: DVec3::ZERO,
r: 0.0, r: 0.0,
sa: 0.0, sa: 0.0,
prev_pt: None,
cw: false, cw: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
@ -728,6 +730,9 @@ impl CadCommand for ArcSCACommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.cw = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_SCA" "ARC_SCA"
@ -775,7 +780,7 @@ impl CadCommand for ArcSCACommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -783,11 +788,13 @@ impl CadCommand for ArcSCACommand {
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.step == 2 { if self.step == 2 {
let span: f64 = text.trim().replace(',', ".").parse().ok()?; let span: f64 = text.trim().replace(',', ".").parse().ok()?;
// Negative span = CW; positive = CCW.
let ea = self.sa + span.to_radians(); let ea = self.sa + span.to_radians();
return Some(CmdResult::CommitAndExit(make_arc( let entity = if span < 0.0 {
self.c, self.r, self.sa, ea, self.plane, 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 None
} }
@ -818,18 +825,6 @@ impl CadCommand for ArcSCACommand {
match self.step { match self.step {
1 => Some(line_wire(self.s, pt)), 1 => Some(line_wire(self.s, pt)),
2 => { 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); let ea = angle_xy(self.c, pt, self.plane);
Some(if self.cw { Some(if self.cw {
arc_preview(self.c, self.r, ea, self.sa, self.plane) arc_preview(self.c, self.r, ea, self.sa, self.plane)
@ -852,6 +847,7 @@ pub struct ArcSCLCommand {
c: DVec3, c: DVec3,
r: f64, r: f64,
sa: f64, sa: f64,
cw: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -863,6 +859,7 @@ impl ArcSCLCommand {
c: DVec3::ZERO, c: DVec3::ZERO,
r: 0.0, r: 0.0,
sa: 0.0, sa: 0.0,
cw: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
} }
@ -872,6 +869,9 @@ impl CadCommand for ArcSCLCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.cw = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_SCL" "ARC_SCL"
@ -906,14 +906,15 @@ impl CadCommand for ArcSCLCommand {
} }
_ => { _ => {
let chord = self.s.distance(pt); let chord = self.s.distance(pt);
let ea = end_angle_from_chord_len(self.sa, chord, self.r); let Some(ea) = end_angle_from_chord_len(self.sa, chord, self.r) else {
CmdResult::CommitAndExit(make_arc( return CmdResult::NeedPoint;
self.c, };
self.r, let entity = if self.cw {
self.sa, make_arc(self.c, self.r, ea, self.sa, self.plane)
ea, } else {
self.plane, make_arc(self.c, self.r, self.sa, ea, self.plane)
)) };
CmdResult::CommitAndExit(entity)
} }
} }
} }
@ -921,7 +922,7 @@ impl CadCommand for ArcSCLCommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -929,8 +930,8 @@ impl CadCommand for ArcSCLCommand {
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.step == 2 { if self.step == 2 {
let chord: f64 = text.trim().replace(',', ".").parse().ok()?; let chord: f64 = text.trim().replace(',', ".").parse().ok()?;
if chord > 0.0 { if chord != 0.0 {
let ea = end_angle_from_chord_len(self.sa, chord, self.r); let ea = end_angle_from_chord_len(self.sa, chord, self.r)?;
return Some(CmdResult::CommitAndExit(make_arc( return Some(CmdResult::CommitAndExit(make_arc(
self.c, self.r, self.sa, ea, self.plane, self.c, self.r, self.sa, ea, self.plane,
))); )));
@ -959,8 +960,12 @@ impl CadCommand for ArcSCLCommand {
1 => Some(line_wire(self.s, pt)), 1 => Some(line_wire(self.s, pt)),
2 => { 2 => {
let chord = self.s.distance(pt); let chord = self.s.distance(pt);
let ea = end_angle_from_chord_len(self.sa, chord, self.r); let ea = end_angle_from_chord_len(self.sa, chord, self.r)?;
Some(arc_preview(self.c, self.r, self.sa, ea, self.plane)) 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, _ => None,
} }
@ -974,6 +979,7 @@ pub struct ArcSEACommand {
step: u8, step: u8,
s: DVec3, s: DVec3,
e: DVec3, e: DVec3,
ctrl: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -983,6 +989,7 @@ impl ArcSEACommand {
step: 0, step: 0,
s: DVec3::ZERO, s: DVec3::ZERO,
e: DVec3::ZERO, e: DVec3::ZERO,
ctrl: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
} }
@ -992,6 +999,9 @@ impl CadCommand for ArcSEACommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.ctrl = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_SEA" "ARC_SEA"
@ -1015,10 +1025,8 @@ impl CadCommand for ArcSEACommand {
self.step = 2; self.step = 2;
CmdResult::NeedPoint CmdResult::NeedPoint
} }
_ => match arc_from_sagitta(self.s, self.e, pt, self.plane) { _ => match arc_from_sagitta(self.s, self.e, pt, self.ctrl, self.plane) {
Some((center, radius)) => { Some((center, radius, sa, ea)) => {
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)) CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane))
} }
None => CmdResult::Cancel, None => CmdResult::Cancel,
@ -1029,20 +1037,41 @@ impl CadCommand for ArcSEACommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
} }
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.step != 2 {
return None;
}
let included = text.trim().replace(',', ".").parse::<f64>().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<crate::command::DynSpec> {
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<WireModel> { fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
match self.step { match self.step {
1 => Some(line_wire(self.s, pt)), 1 => Some(line_wire(self.s, pt)),
2 => { 2 => {
if let Some((center, radius)) = if let Some((center, radius, sa, ea)) =
arc_from_sagitta(self.s, self.e, pt, self.plane) 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)) Some(arc_preview(center, radius, sa, ea, self.plane))
} else { } else {
Some(line_wire(self.s, self.e)) Some(line_wire(self.s, self.e))
@ -1060,6 +1089,7 @@ pub struct ArcSERCommand {
step: u8, step: u8,
s: DVec3, s: DVec3,
e: DVec3, e: DVec3,
ctrl: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -1069,6 +1099,7 @@ impl ArcSERCommand {
step: 0, step: 0,
s: DVec3::ZERO, s: DVec3::ZERO,
e: DVec3::ZERO, e: DVec3::ZERO,
ctrl: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
} }
@ -1078,6 +1109,9 @@ impl CadCommand for ArcSERCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.ctrl = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_SER" "ARC_SER"
@ -1101,10 +1135,8 @@ impl CadCommand for ArcSERCommand {
self.step = 2; self.step = 2;
CmdResult::NeedPoint CmdResult::NeedPoint
} }
_ => match arc_from_se_radius(self.s, self.e, pt, self.plane) { _ => match arc_from_se_radius(self.s, self.e, pt, self.ctrl, self.plane) {
Some((center, radius)) => { Some((center, radius, sa, ea)) => {
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)) CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane))
} }
None => CmdResult::Cancel, None => CmdResult::Cancel,
@ -1115,7 +1147,7 @@ impl CadCommand for ArcSERCommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -1123,23 +1155,11 @@ impl CadCommand for ArcSERCommand {
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.step == 2 { if self.step == 2 {
let r: f64 = text.trim().replace(',', ".").parse().ok()?; let r: f64 = text.trim().replace(',', ".").parse().ok()?;
let s = self.plane.to_local(self.s); let (center, radius, sa, ea) =
let e = self.plane.to_local(self.e); arc_from_endpoints_radius(self.s, self.e, r, false, self.plane)?;
let chord = s.distance(e); return Some(CmdResult::CommitAndExit(make_arc(
if r > 0.0 && r >= chord / 2.0 { center, radius, sa, ea, self.plane,
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,
)));
}
} }
None None
} }
@ -1160,17 +1180,16 @@ impl CadCommand for ArcSERCommand {
if self.step != 2 { if self.step != 2 {
return None; 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<WireModel> { fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
match self.step { match self.step {
1 => Some(line_wire(self.s, pt)), 1 => Some(line_wire(self.s, pt)),
2 => { 2 => {
if let Some((center, radius)) = if let Some((center, radius, sa, ea)) =
arc_from_se_radius(self.s, self.e, pt, self.plane) 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)) Some(arc_preview(center, radius, sa, ea, self.plane))
} else { } else {
Some(line_wire(self.s, self.e)) Some(line_wire(self.s, self.e))
@ -1188,6 +1207,7 @@ pub struct ArcSEDCommand {
step: u8, step: u8,
s: DVec3, s: DVec3,
e: DVec3, e: DVec3,
ctrl: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -1197,6 +1217,7 @@ impl ArcSEDCommand {
step: 0, step: 0,
s: DVec3::ZERO, s: DVec3::ZERO,
e: DVec3::ZERO, e: DVec3::ZERO,
ctrl: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
} }
@ -1206,6 +1227,9 @@ impl CadCommand for ArcSEDCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.ctrl = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_SED" "ARC_SED"
@ -1229,10 +1253,8 @@ impl CadCommand for ArcSEDCommand {
self.step = 2; self.step = 2;
CmdResult::NeedPoint CmdResult::NeedPoint
} }
_ => match arc_from_direction(self.s, self.e, pt, self.plane) { _ => match arc_continue(self.s, pt - self.s, self.e, self.ctrl, self.plane) {
Some((center, radius)) => { Some((center, radius, sa, ea)) => {
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)) CmdResult::CommitAndExit(make_arc(center, radius, sa, ea, self.plane))
} }
None => CmdResult::Cancel, None => CmdResult::Cancel,
@ -1243,20 +1265,42 @@ impl CadCommand for ArcSEDCommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
} }
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.step != 2 {
return None;
}
let angle = text.trim().replace(',', ".").parse::<f64>().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<crate::command::DynSpec> {
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<WireModel> { fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
match self.step { match self.step {
1 => Some(line_wire(self.s, pt)), 1 => Some(line_wire(self.s, pt)),
2 => { 2 => {
if let Some((center, radius)) = if let Some((center, radius, sa, ea)) =
arc_from_direction(self.s, self.e, pt, self.plane) 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)) Some(arc_preview(center, radius, sa, ea, self.plane))
} else { } else {
Some(line_wire(self.s, self.e)) Some(line_wire(self.s, self.e))
@ -1275,7 +1319,6 @@ pub struct ArcCSACommand {
c: DVec3, c: DVec3,
r: f64, r: f64,
sa: f64, sa: f64,
prev_pt: Option<DVec3>,
cw: bool, cw: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -1287,7 +1330,6 @@ impl ArcCSACommand {
c: DVec3::ZERO, c: DVec3::ZERO,
r: 0.0, r: 0.0,
sa: 0.0, sa: 0.0,
prev_pt: None,
cw: false, cw: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
@ -1298,6 +1340,9 @@ impl CadCommand for ArcCSACommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.cw = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_CSA" "ARC_CSA"
@ -1344,7 +1389,7 @@ impl CadCommand for ArcCSACommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -1353,9 +1398,12 @@ impl CadCommand for ArcCSACommand {
if self.step == 2 { if self.step == 2 {
let span: f64 = text.trim().replace(',', ".").parse().ok()?; let span: f64 = text.trim().replace(',', ".").parse().ok()?;
let ea = self.sa + span.to_radians(); let ea = self.sa + span.to_radians();
return Some(CmdResult::CommitAndExit(make_arc( let entity = if span < 0.0 {
self.c, self.r, self.sa, ea, self.plane, 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 None
} }
@ -1384,18 +1432,6 @@ impl CadCommand for ArcCSACommand {
match self.step { match self.step {
1 => Some(line_wire(self.c, pt)), 1 => Some(line_wire(self.c, pt)),
2 => { 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); let ea = angle_xy(self.c, pt, self.plane);
Some(if self.cw { Some(if self.cw {
arc_preview(self.c, self.r, ea, self.sa, self.plane) arc_preview(self.c, self.r, ea, self.sa, self.plane)
@ -1417,6 +1453,7 @@ pub struct ArcCSLCommand {
s: DVec3, s: DVec3,
r: f64, r: f64,
sa: f64, sa: f64,
cw: bool,
plane: WorkingPlane, plane: WorkingPlane,
} }
@ -1428,6 +1465,7 @@ impl ArcCSLCommand {
s: DVec3::ZERO, s: DVec3::ZERO,
r: 0.0, r: 0.0,
sa: 0.0, sa: 0.0,
cw: false,
plane: WorkingPlane::default(), plane: WorkingPlane::default(),
} }
} }
@ -1437,6 +1475,9 @@ impl CadCommand for ArcCSLCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) { fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane; self.plane = plane;
} }
fn set_ctrl(&mut self, ctrl: bool) {
self.cw = ctrl;
}
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"ARC_CSL" "ARC_CSL"
@ -1471,14 +1512,15 @@ impl CadCommand for ArcCSLCommand {
} }
_ => { _ => {
let chord = self.s.distance(pt); let chord = self.s.distance(pt);
let ea = end_angle_from_chord_len(self.sa, chord, self.r); let Some(ea) = end_angle_from_chord_len(self.sa, chord, self.r) else {
CmdResult::CommitAndExit(make_arc( return CmdResult::NeedPoint;
self.c, };
self.r, let entity = if self.cw {
self.sa, make_arc(self.c, self.r, ea, self.sa, self.plane)
ea, } else {
self.plane, make_arc(self.c, self.r, self.sa, ea, self.plane)
)) };
CmdResult::CommitAndExit(entity)
} }
} }
} }
@ -1486,7 +1528,7 @@ impl CadCommand for ArcCSLCommand {
CmdResult::Cancel CmdResult::Cancel
} }
fn enter_accepts_default_start(&self) -> bool { fn enter_accepts_default_start(&self) -> bool {
self.step == 0 false
} }
fn on_escape(&mut self) -> CmdResult { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
@ -1494,8 +1536,8 @@ impl CadCommand for ArcCSLCommand {
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> { fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.step == 2 { if self.step == 2 {
let chord: f64 = text.trim().replace(',', ".").parse().ok()?; let chord: f64 = text.trim().replace(',', ".").parse().ok()?;
if chord > 0.0 { if chord != 0.0 {
let ea = end_angle_from_chord_len(self.sa, chord, self.r); let ea = end_angle_from_chord_len(self.sa, chord, self.r)?;
return Some(CmdResult::CommitAndExit(make_arc( return Some(CmdResult::CommitAndExit(make_arc(
self.c, self.r, self.sa, ea, self.plane, self.c, self.r, self.sa, ea, self.plane,
))); )));
@ -1524,8 +1566,12 @@ impl CadCommand for ArcCSLCommand {
1 => Some(line_wire(self.c, pt)), 1 => Some(line_wire(self.c, pt)),
2 => { 2 => {
let chord = self.s.distance(pt); let chord = self.s.distance(pt);
let ea = end_angle_from_chord_len(self.sa, chord, self.r); let ea = end_angle_from_chord_len(self.sa, chord, self.r)?;
Some(arc_preview(self.c, self.r, self.sa, ea, self.plane)) 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, _ => None,
} }
@ -1534,19 +1580,16 @@ impl CadCommand for ArcCSLCommand {
// ── Command 11: Continue (ARC_CONT) ────────────────────────────────────── // ── Command 11: Continue (ARC_CONT) ──────────────────────────────────────
// Tangent-continuing arc from the previous line/arc endpoint. The start point // Tangent-continuing arc from the previous line, arc or planar polyline
// and start tangent are seeded at dispatch (from `continue_anchor`); the user // endpoint. The start point and tangent are seeded at dispatch (from
// picks only the end point, so the arc joins the previous entity smoothly. // `continue_anchor`); the user picks only the end point, so the arc joins the
// previous entity smoothly.
pub struct ArcContCommand { pub struct ArcContCommand {
s: DVec3, s: DVec3,
tangent: DVec3, tangent: DVec3,
/// Live Ctrl state (set via `set_ctrl`): flips the arc to the other way. /// Live Ctrl state (set via `set_ctrl`): flips the arc to the other way.
ctrl: bool, 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, plane: WorkingPlane,
} }
@ -1557,7 +1600,6 @@ impl ArcContCommand {
tangent, tangent,
ctrl: false, ctrl: false,
plane: WorkingPlane::default(), 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) { match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) {
Some((center, radius, sa, ea)) => { Some((center, radius, sa, ea)) => {
let arc = make_arc(center, radius, sa, ea, self.plane); let arc = make_arc(center, radius, sa, ea, self.plane);
// Remember the anchor this arc was drawn from — mid-command CmdResult::CommitAndExit(arc)
// 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)
} }
// A degenerate pick (no arc through these points) shouldn't end the // A degenerate pick should keep the one-shot command active so the
// whole run — stay active and wait for the next end point. // user can choose a valid end point.
None => CmdResult::NeedPoint, None => CmdResult::NeedPoint,
} }
} }
fn on_undo_step(&mut self) -> Option<CmdResult> {
// 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 { fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
} }
@ -1623,10 +1646,10 @@ impl CadCommand for ArcContCommand {
} }
// ── Autocomplete registry ───────────────────────────────── // ── 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_CSA"] }); // ArcCSACommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSL"] }); // ArcCSLCommand 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_SCA"] }); // ArcSCACommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCE"] }); // ArcSCECommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCE"] }); // ArcSCECommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCL"] }); // ArcSCLCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCL"] }); // ArcSCLCommand

View file

@ -79,7 +79,7 @@ impl AreaCommand {
let curve = entity_curve(entity)?; let curve = entity_curve(entity)?;
let perimeter = curve.length(); let perimeter = curve.length();
Some(AreaMeasurement { Some(AreaMeasurement {
area: curve.curve.enclosed_area().abs(), area: curve.curve.chord_closed_area()?.abs(),
perimeter: perimeter.is_finite().then_some(perimeter), perimeter: perimeter.is_finite().then_some(perimeter),
}) })
} }

View file

@ -96,9 +96,15 @@ fn break_arc(arc: &ArcEnt, p1: DVec3, p2: DVec3) -> Vec<EntityType> {
let cy = arc.center.y; let cy = arc.center.y;
let r = arc.radius; let r = arc.radius;
// Project p1 and p2 onto the arc (world XY plane) let plane = crate::entities::curve::arc_curve(arc).plane;
let a1 = angle_on_arc(cx, cy, p1); let Some(q1) = plane.project(p1.to_array()) else {
let a2 = angle_on_arc(cx, cy, p2); 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 start = arc.start_angle;
let end = arc.end_angle; let end = arc.end_angle;

View file

@ -213,33 +213,20 @@ fn lengthen_line(line: &LineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<Entity
} }
fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> { fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let cx = arc.center.x as f32;
let cy = arc.center.y as f32;
// Current arc span // Current arc span
let span = arc_span_rad(arc.start_angle, arc.end_angle); let span = arc_span_rad(arc.start_angle, arc.end_angle);
let current_arc_len = arc.radius * span; let current_arc_len = arc.radius * span;
let new_arc_len = apply_mode(current_arc_len, mode)?; 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; return None;
} }
let new_span = new_arc_len / arc.radius; let new_span = new_arc_len / arc.radius;
// Which end (start or end angle) is closer to pick? // Which end (start or end angle) is closer to pick?
let start_rad = arc.start_angle; let curve = crate::entities::curve::arc_curve(arc);
let end_rad = arc.end_angle; 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 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 dist_start = (pick_pt - start_pt).length(); let dist_start = (pick_pt - start_pt).length();
let dist_end = (pick_pt - end_pt).length(); let dist_end = (pick_pt - end_pt).length();

View file

@ -127,8 +127,8 @@ fn offset_circle(c: &CircleEnt, dist: f64, side_pt: Vec3) -> Option<EntityType>
// ── Arc offset ───────────────────────────────────────────────────────────── // ── Arc offset ─────────────────────────────────────────────────────────────
fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> { fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let px = side_pt.x as f64; let plane = crate::entities::curve::arc_curve(a).plane;
let py = side_pt.y as f64; 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 dc = ((px - a.center.x).powi(2) + (py - a.center.y).powi(2)).sqrt();
let new_r = if dc < a.radius { let new_r = if dc < a.radius {

View file

@ -1227,7 +1227,7 @@ fn translated_prototype_wire(
p2[axis] += delta_f32[axis]; p2[axis] += delta_f32[axis];
} }
} }
TangentGeom::Circle { center, .. } => { TangentGeom::Circle { center, .. } | TangentGeom::Arc { center, .. } => {
for axis in 0..3 { for axis in 0..3 {
center[axis] += delta_f32[axis]; center[axis] += delta_f32[axis];
} }
@ -2282,6 +2282,39 @@ fn transform_tangent(
radius: radius * s, 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,
}
}
} }
} }

View file

@ -29,8 +29,17 @@ pub enum SnapHint {
pub enum TangentGeom { pub enum TangentGeom {
/// Infinite line through these two world-space points. /// Infinite line through these two world-space points.
Line { p1: [f32; 3], p2: [f32; 3] }, Line { p1: [f32; 3], p2: [f32; 3] },
/// Circle/arc. /// Complete circle.
Circle { center: [f32; 3], radius: f32 }, 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 /// A 1-D entity (line, arc, polyline) represented as an ordered set of

View file

@ -1661,6 +1661,88 @@ impl Snapper {
}); });
(w, edge_d * edge_d) (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) = ( let (tier, sub) = (
snap_tier(SnapType::Tangent), snap_tier(SnapType::Tangent),
@ -1687,6 +1769,14 @@ impl Snapper {
), ),
radius: *radius as f64, 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 { best = Some(SnapResult {
world: world_pt.as_dvec3(), world: world_pt.as_dvec3(),