diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index a4de16e..65b997d 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use super::helpers::*; use super::machines::number; +use super::pline_opts::{Outcome, PlineAsk}; use super::*; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap}; @@ -64,7 +65,7 @@ pub fn specs() -> Vec { CommandSpec::new("ray", "Ray", run_ray) .menu(&["Draw", "Ray"]) .params("{base, through}") - .interactive(|_| Ok(Box::new(XlineM { ray: true, base: None, fixed_angle: None }))), + .interactive(|_| Ok(Box::new(RayM::default()))), CommandSpec::new("spline", "Spline", run_spline) .menu(&["Draw", "Spline", "Fit Points"]) .alias(&["spl"]) @@ -477,6 +478,8 @@ struct PlineM { asking_width: u8, /// The widths are being asked as half-widths (centre line to edge). half: bool, + /// An option asking further questions (arc mode's Angle, CEnter…, line mode's Length). + ask: Option, } impl PlineM { @@ -519,11 +522,11 @@ impl PlineM { } Ok(()) } - /// Bulge for a tangent-continuing arc from the last vertex to `p`. - fn tangent_bulge(&self, p: Vec2) -> f64 { + /// The direction the polyline runs in at its last vertex (+X before the second vertex). + fn tangent(&self) -> Vec2 { let n = self.verts.len(); - let Some(last) = self.verts.last() else { return 0.0 }; - let dir = if n >= 2 { + let Some(last) = self.verts.last() else { return Vec2::X }; + if n >= 2 { let prev = self.verts.get(n - 2).copied().unwrap_or_default(); match bulge_to_arc(prev.p, last.p, prev.bulge) { Some((a, ccw)) => { @@ -534,7 +537,12 @@ impl PlineM { } } else { Vec2::X - }; + } + } + /// Bulge for a tangent-continuing arc from the last vertex to `p`. + fn tangent_bulge(&self, p: Vec2) -> f64 { + let Some(last) = self.verts.last() else { return 0.0 }; + let dir = self.tangent(); let chord = p - last.p; // Included angle = 2 × angle between tangent and chord. let ang = dir.cross(chord).atan2(dir.dot(chord)); @@ -546,7 +554,7 @@ impl Interactive for PlineM { fn name(&self) -> &'static str { "PLINE" } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { if self.asking_width > 0 { let unit = if self.half { "half-width" } else { "width" }; let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) }; @@ -554,6 +562,9 @@ impl Interactive for PlineM { return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}")); } let base = self.verts.last().map(|v| v.p); + if let (Some(a), Some(st)) = (self.ask, base) { + return a.prompt(st, self.tangent(), s); + } match (self.verts.len(), self.arc_mode) { (0, _) => Prompt::new("Specify start point", Accept::POINT), (n, false) => { @@ -591,6 +602,22 @@ impl Interactive for PlineM { self.asking_width = if self.asking_width == 1 { 2 } else { 0 }; return Ok(Step::Continue); } + if let (Some(a), Some(st)) = (self.ask, self.verts.last().map(|v| v.p)) { + match a.input(s, i, st, self.tangent())? { + Outcome::Ask(next) => self.ask = Some(next), + Outcome::Back => self.ask = None, + Outcome::Segment(end, bulge) => { + self.ask = None; + self.begin_segment(); + if let Some(last) = self.verts.last_mut() { + last.bulge = bulge; + } + self.verts.push(PolyVertex::new(end)); + self.sync(s, false)?; + } + } + return Ok(Step::Continue); + } match i { Input::Point(p) => { if self.verts.is_empty() { @@ -644,8 +671,11 @@ impl Interactive for PlineM { self.asking_width = 1; Ok(Step::Continue) } - _ => { - s.echo(format!("{k}: not available yet")); + k => { + match PlineAsk::from_keyword(k, self.arc_mode) { + Some(a) if !self.verts.is_empty() => self.ask = Some(a), + _ => return Err(crate::EngineError::Other("Point or option keyword required.".into())), + } Ok(Step::Continue) } }, @@ -655,6 +685,12 @@ impl Interactive for PlineM { } fn preview(&self, _s: &Session, c: Vec2) -> Vec { let Some(last) = self.verts.last() else { return Vec::new() }; + if let Some(a) = self.ask { + return a + .preview(last.p, self.tangent(), c) + .map(|(e, b)| vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(e)], false)]) + .unwrap_or_default(); + } if self.arc_mode { let b = self.tangent_bulge(c); vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(c)], false)] @@ -1098,45 +1134,31 @@ impl Interactive for PointM { } } -struct XlineM { - ray: bool, +/// RAY: a start point, then through points until Enter (XLINE is `draw2::XlineM2`). +#[derive(Default)] +struct RayM { base: Option, - fixed_angle: Option, } -impl Interactive for XlineM { +impl Interactive for RayM { fn name(&self) -> &'static str { - if self.ray { "RAY" } else { "XLINE" } + "RAY" } fn prompt(&self, _s: &Session) -> Prompt { - match (self.base, self.ray) { - (None, false) => Prompt::new("Specify a point", Accept::POINT).kw(&["Hor", "Ver", "Ang", "Bisect", "Offset"]), - (None, true) => Prompt::new("Specify start point", Accept::POINT), - (Some(b), _) => Prompt::new("Specify through point", Accept::POINT).base(b), + match self.base { + None => Prompt::new("Specify start point", Accept::POINT), + Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { match i { - Input::Keyword(k) => { - match k.as_str() { - "Hor" => self.fixed_angle = Some(0.0), - "Ver" => self.fixed_angle = Some(std::f64::consts::FRAC_PI_2), - _ => s.echo(format!("{k}: not available yet")), - } - Ok(Step::Continue) - } Input::Point(p) => { - if let Some(a) = self.fixed_angle { - s.add_entity(EntityKind::XLine(RayLine { base: v3(p), dir: v3(Vec2::from_angle(a)) }))?; - return Ok(Step::Continue); - } match self.base { None => self.base = Some(p), Some(b) => { let d = (p - b).normalized(); if d != Vec2::ZERO { - let rl = RayLine { base: v3(b), dir: v3(d) }; - s.add_entity(if self.ray { EntityKind::Ray(rl) } else { EntityKind::XLine(rl) })?; + s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(d) }))?; } } } @@ -1147,14 +1169,8 @@ impl Interactive for XlineM { } } fn preview(&self, _s: &Session, c: Vec2) -> Vec { - if let Some(a) = self.fixed_angle { - return vec![EntityKind::XLine(RayLine { base: v3(c), dir: v3(Vec2::from_angle(a)) })]; - } match self.base { - Some(b) if !b.near(c, 1e-12) => { - let rl = RayLine { base: v3(b), dir: v3((c - b).normalized()) }; - vec![if self.ray { EntityKind::Ray(rl) } else { EntityKind::XLine(rl) }] - } + Some(b) if !b.near(c, 1e-12) => vec![EntityKind::Ray(RayLine { base: v3(b), dir: v3((c - b).normalized()) })], _ => Vec::new(), } } diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 9f062ba..5d03c74 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1294,15 +1294,20 @@ fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result { s.add_entity(EntityKind::XLine(RayLine { base: v3(base), dir: v3(d) })) } -/// A line-like object for XLINE Offset/Reference: (base, direction). -fn linear_of(s: &Session, h: Handle) -> Result<(Vec2, Vec2)> { +/// A line-like object for XLINE Offset/Reference: (base, direction). For a polyline, the +/// segment nearest `pick` (the first without one), which must be straight. +fn linear_of(s: &Session, h: Handle, pick: Option) -> Result<(Vec2, Vec2)> { let e = curves::entity(s, h)?; match &e.kind { EntityKind::Line(l) => Ok((l.a.xy(), (l.b.xy() - l.a.xy()).normalized())), EntityKind::XLine(r) | EntityKind::Ray(r) => Ok((r.base.xy(), r.dir.xy().normalized())), EntityKind::LwPolyline(p) => { let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments(); - match segs.first() { + let seg = match pick { + Some(q) => segs.iter().min_by(|a, b| a.closest(q).dist(q).total_cmp(&b.closest(q).dist(q))), + None => segs.first(), + }; + match seg { Some(cadcraft_geom::Segment::Line(l)) => Ok((l.a, l.dir())), _ => Err(other("Select a line object.")), } @@ -1333,7 +1338,7 @@ pub(crate) fn run_xline2(s: &mut Session, p: &Value) -> Result { return added(add_xline(s, v, d)?); } if let Some(h) = curves::handle_param(p, "handle") { - let (base, dir) = linear_of(s, h)?; + let (base, dir) = linear_of(s, h, None)?; let (nb, nd) = if let Some(t) = point_param(p, "through") { (t, dir) } else { @@ -1458,7 +1463,7 @@ impl Interactive for XlineM2 { } (XMode::RefSelect, Input::Point(p)) => { let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - let (_, d) = linear_of(s, h)?; + let (_, d) = linear_of(s, h, Some(p))?; self.mode = XMode::RefAngle(d.angle()); Ok(Step::Continue) } @@ -1491,7 +1496,7 @@ impl Interactive for XlineM2 { } (XMode::OffSelect, Input::Point(p)) => { let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; - self.line = Some(linear_of(s, h)?); + self.line = Some(linear_of(s, h, Some(p))?); self.mode = XMode::OffSide; Ok(Step::Continue) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 136a803..91df76e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod layer; mod layout; mod modify; mod modify2; +mod pline_opts; mod props; mod qselect; mod settings; diff --git a/crates/engine/src/cmd/pline_opts.rs b/crates/engine/src/cmd/pline_opts.rs new file mode 100644 index 0000000..b96cf33 --- /dev/null +++ b/crates/engine/src/cmd/pline_opts.rs @@ -0,0 +1,237 @@ +//! PLINE options that ask further questions before a segment is drawn: arc mode's Angle, +//! CEnter, Direction, Radius and Second pt, and line mode's Length. Each ends in the next +//! segment from the last vertex `st`: its end point and bulge. + +use cadcraft_geom::{Arc, TAU, Vec2, arc_to_bulge}; + +use super::curves::{arc_sca, arc_scl, arc_sea, arc_sed, arc_ser}; +use super::machines::number; +use crate::{Accept, EngineError, Input, Prompt, Result, Session}; + +/// A PLINE option waiting for its answers. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum PlineAsk { + /// Angle: the included angle. + Angle, + /// Angle given: the end point, or [CEnter/Radius]. + AngleEnd(f64), + AngleCenter(f64), + AngleRadius(f64), + /// Included angle and radius given: the direction of the chord. + Chord(f64, f64), + /// CEnter: the center point. + Center, + /// Center given: the end point, or [Angle/Length]. + CenterEnd(Vec2), + CenterAngle(Vec2), + CenterLength(Vec2), + /// Direction: the tangent direction at the start of the arc. + Direction, + DirectionEnd(Vec2), + /// Radius: the radius. + Radius, + /// Radius given: the end point, or [Angle]. + RadiusEnd(f64), + RadiusAngle(f64), + /// Second pt: the second point of a three-point arc. + Second, + SecondEnd(Vec2), + /// Line mode's Length: a line segment this long, continuing the last segment. + Length, +} + +/// What an answer leads to. +pub(crate) enum Outcome { + Ask(PlineAsk), + /// The next segment: end point and bulge. + Segment(Vec2, f64), + /// Back to the arc (or line) prompt without a segment. + Back, +} + +fn other(m: &str) -> EngineError { + EngineError::Other(m.into()) +} + +/// The polyline segment an arc starting at `st` makes: its end point and bulge. A whole circle +/// can't be one segment. +fn arc_segment(st: Vec2, a: &Arc) -> Option<(Vec2, f64)> { + let sweep = a.sweep(); + if !(1e-9..TAU - 1e-9).contains(&sweep) { + return None; + } + let tol = 1e-6 * a.radius.max(1.0); + if a.start_point().near(st, tol) { + Some((a.end_point(), arc_to_bulge(sweep))) + } else if a.end_point().near(st, tol) { + Some((a.start_point(), arc_to_bulge(-sweep))) + } else { + None + } +} + +impl PlineAsk { + /// The option an arc-mode (or line-mode) keyword opens. + pub(crate) fn from_keyword(k: &str, arc_mode: bool) -> Option { + Some(match (k, arc_mode) { + ("Angle", true) => PlineAsk::Angle, + ("CEnter", true) => PlineAsk::Center, + ("Direction", true) => PlineAsk::Direction, + ("Radius", true) => PlineAsk::Radius, + ("Second pt", true) => PlineAsk::Second, + ("Length", false) => PlineAsk::Length, + _ => return None, + }) + } + + pub(crate) fn prompt(self, st: Vec2, tangent: Vec2, s: &Session) -> Prompt { + let end = |kw: &[&str]| Prompt::new("Specify endpoint of arc", Accept::POINT).kw(kw).base(st); + match self { + PlineAsk::Angle | PlineAsk::CenterAngle(_) | PlineAsk::RadiusAngle(_) => Prompt::new("Specify included angle", Accept::NUMBER), + PlineAsk::AngleEnd(_) => end(&["CEnter", "Radius"]), + PlineAsk::AngleCenter(_) | PlineAsk::Center => Prompt::new("Specify center point of arc", Accept::POINT).base(st), + PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st), + PlineAsk::Chord(a, _) => { + let d = shown_direction(s, chord_default(tangent, a)); + Prompt::new("Specify direction of chord for arc", Accept::POINT_OR_NUMBER).base(st).default(d) + } + PlineAsk::CenterEnd(_) => end(&["Angle", "Length"]), + PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st), + PlineAsk::Direction => Prompt::new("Specify the tangent direction for the start point of arc", Accept::POINT_OR_NUMBER).base(st), + PlineAsk::DirectionEnd(_) => Prompt::new("Specify endpoint of the arc", Accept::POINT).base(st), + PlineAsk::RadiusEnd(_) => end(&["Angle"]), + PlineAsk::Second => Prompt::new("Specify second point on arc", Accept::POINT).base(st), + PlineAsk::SecondEnd(_) => Prompt::new("Specify end point of arc", Accept::POINT).base(st), + PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st), + } + } + + /// The arc (or line) the prompt would give for the point `p`. + fn segment_at(self, st: Vec2, tangent: Vec2, p: Vec2) -> Option<(Vec2, f64)> { + let a = match self { + PlineAsk::AngleEnd(a) => arc_sea(st, p, a), + PlineAsk::AngleCenter(a) => arc_sca(st, p, a), + PlineAsk::CenterEnd(c) => (!p.near(c, 1e-12) && st.dist(c) > 1e-12).then(|| Arc::from_start_center_end(st, c, p)), + PlineAsk::DirectionEnd(d) => arc_sed(st, p, d), + PlineAsk::RadiusEnd(r) => arc_ser(st, p, r), + PlineAsk::SecondEnd(m) => Arc::from_3_points(st, m, p), + PlineAsk::Chord(a, r) => return self.chord_segment(st, a, r, st.angle_to(p)), + PlineAsk::Length => return Some((st + tangent * st.dist(p), 0.0)), + _ => None, + }?; + arc_segment(st, &a) + } + + /// The arc with included angle `a` and radius `r` whose chord runs in direction `dir`. + fn chord_segment(self, st: Vec2, a: f64, r: f64, dir: f64) -> Option<(Vec2, f64)> { + let chord = 2.0 * r * (a.abs() / 2.0).sin(); + let en = st + Vec2::from_angle(dir) * chord; + arc_sea(st, en, a).and_then(|arc| arc_segment(st, &arc)) + } + + /// Rubber-band segment for the cursor `c`. + pub(crate) fn preview(self, st: Vec2, tangent: Vec2, c: Vec2) -> Option<(Vec2, f64)> { + self.segment_at(st, tangent, c) + } + + pub(crate) fn input(self, s: &Session, i: Input, st: Vec2, tangent: Vec2) -> Result { + let angles = s.angle_settings(); + let invalid = || other("Invalid arc for these values."); + // A typed or picked size (distance from the last vertex). + let size = |i: &Input| -> Option { + match i { + Input::Point(p) => Some(st.dist(*p)), + Input::Text(t) => number(t), + _ => None, + } + }; + let segment = |r: Option<(Vec2, f64)>| r.map(|(e, b)| Outcome::Segment(e, b)).ok_or_else(invalid); + if matches!(i, Input::Enter) && !matches!(self, PlineAsk::Chord(..)) { + return Ok(Outcome::Back); + } + Ok(match (self, i) { + (PlineAsk::Angle | PlineAsk::CenterAngle(_) | PlineAsk::RadiusAngle(_), Input::Text(t)) => { + let a = angles.rotation(&t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle."))?; + if a.abs() < 1e-9 || a.abs() >= TAU - 1e-9 { + return Err(other("The included angle must be between 0 and 360 degrees.")); + } + match self { + PlineAsk::CenterAngle(c) => segment(arc_sca(st, c, a).and_then(|arc| arc_segment(st, &arc)))?, + PlineAsk::RadiusAngle(r) => Outcome::Ask(PlineAsk::Chord(a, r)), + _ => Outcome::Ask(PlineAsk::AngleEnd(a)), + } + } + (PlineAsk::AngleEnd(a), Input::Keyword(k)) if k == "CEnter" => Outcome::Ask(PlineAsk::AngleCenter(a)), + (PlineAsk::AngleEnd(a), Input::Keyword(k)) if k == "Radius" => Outcome::Ask(PlineAsk::AngleRadius(a)), + (PlineAsk::CenterEnd(c), Input::Keyword(k)) if k == "Angle" => Outcome::Ask(PlineAsk::CenterAngle(c)), + (PlineAsk::CenterEnd(c), Input::Keyword(k)) if k == "Length" => Outcome::Ask(PlineAsk::CenterLength(c)), + (PlineAsk::RadiusEnd(r), Input::Keyword(k)) if k == "Angle" => Outcome::Ask(PlineAsk::RadiusAngle(r)), + (PlineAsk::Center, Input::Point(c)) => { + if c.near(st, 1e-12) { + return Err(invalid()); + } + Outcome::Ask(PlineAsk::CenterEnd(c)) + } + (PlineAsk::Second, Input::Point(m)) => { + if m.near(st, 1e-12) { + return Err(invalid()); + } + Outcome::Ask(PlineAsk::SecondEnd(m)) + } + (PlineAsk::Direction, i) => { + let d = match i { + Input::Point(p) => p - st, + Input::Text(t) => Vec2::from_angle(angles.direction(&t).ok_or_else(|| other("Requires an angle."))?), + _ => return Err(other("Requires a direction.")), + }; + if d.normalized() == Vec2::ZERO { + return Err(other("Requires a direction.")); + } + Outcome::Ask(PlineAsk::DirectionEnd(d.normalized())) + } + (PlineAsk::AngleRadius(a), i) => { + let r = size(&i).filter(|r| r.is_finite() && *r > 1e-12).ok_or_else(|| other("Requires a positive radius."))?; + Outcome::Ask(PlineAsk::Chord(a, r)) + } + // A negative radius gives the major arc. + (PlineAsk::Radius, i) => { + let r = size(&i).filter(|r| r.is_finite() && r.abs() > 1e-12).ok_or_else(|| other("Requires a nonzero radius."))?; + Outcome::Ask(PlineAsk::RadiusEnd(r)) + } + (PlineAsk::CenterLength(c), i) => { + let l = size(&i).filter(|l| l.is_finite()).ok_or_else(|| other("Requires a distance."))?; + segment(arc_scl(st, c, l).and_then(|arc| arc_segment(st, &arc)))? + } + (PlineAsk::Chord(a, r), i) => { + let dir = match i { + Input::Point(p) if !p.near(st, 1e-12) => st.angle_to(p), + Input::Text(t) => angles.direction(&t).ok_or_else(|| other("Requires an angle."))?, + Input::Enter => chord_default(tangent, a), + _ => return Err(other("Requires a direction.")), + }; + segment(self.chord_segment(st, a, r, dir))? + } + (PlineAsk::Length, i) => { + let l = size(&i).filter(|l| l.is_finite() && *l > 1e-12).ok_or_else(|| other("Requires a positive length."))?; + Outcome::Segment(st + tangent * l, 0.0) + } + (_, Input::Point(p)) => segment(self.segment_at(st, tangent, p))?, + _ => return Err(other("Point or option keyword required.")), + }) + } +} + +/// Default chord direction for an arc with included angle `a` that continues the tangent: the +/// tangent turned by half the angle. +fn chord_default(tangent: Vec2, a: f64) -> f64 { + tangent.angle() + a / 2.0 +} + +/// `dir` (radians counterclockwise from +X) as the user reads directions: from ANGBASE in the +/// ANGDIR sense, in AUNITS. +fn shown_direction(s: &Session, dir: f64) -> String { + let au = s.angle_settings(); + let a = (dir - au.angbase) * if au.clockwise { -1.0 } else { 1.0 }; + let prec = s.doc().map(|d| d.header.i64("AUPREC", 0)).unwrap_or(0); + crate::units::format_angle(a, au.aunits, prec) +} diff --git a/crates/engine/tests/pline_arc_xline_options.rs b/crates/engine/tests/pline_arc_xline_options.rs new file mode 100644 index 0000000..7cf611a --- /dev/null +++ b/crates/engine/tests/pline_arc_xline_options.rs @@ -0,0 +1,137 @@ +//! PLINE arc-mode options (Angle, CEnter, Direction, Radius, Second pt, Line), line mode's +//! Length, and the XLINE options (Hor, Ang with Reference, Bisect, Offset with Through) (#397). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +/// tan(22.5°): the bulge of a quarter circle. +const QUARTER: f64 = 0.414_213_562_373_095_1; + +/// The vertices (x, y, bulge) of the last polyline. +fn pline(s: &Session) -> Vec<(f64, f64, f64)> { + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect(), + k => panic!("expected a polyline, got {k:?}"), + } +} + +fn run(script: &str) -> Session { + let mut s = Session::new(); + s.script(script).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + s +} + +#[track_caller] +fn assert_pline(s: &Session, want: &[(f64, f64, f64)]) { + let got = pline(s); + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + assert!((g.0 - w.0).abs() < 1e-9 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9, "{got:?} != {want:?}"); + } +} + +#[test] +fn angle_then_endpoint_center_or_radius() { + assert_pline(&run("PLINE 0,0 A A 90 10,0\n\n"), &[(0.0, 0.0, QUARTER), (10.0, 0.0, 0.0)]); + // Center (5,0): a quarter turn counterclockwise from (0,0) ends at (5,-5). + assert_pline(&run("PLINE 0,0 A A 90 CE 5,0\n\n"), &[(0.0, 0.0, QUARTER), (5.0, -5.0, 0.0)]); + // Radius 5 and a half circle: the chord is 10 long, here pointing up. + assert_pline(&run("PLINE 0,0 A A 180 R 5 90\n\n"), &[(0.0, 0.0, 1.0), (0.0, 10.0, 0.0)]); + // Enter takes the default chord direction: the tangent (+X at the start) turned by half the angle. + assert_pline(&run("PLINE 0,0 A A 180 R 5\n\n\n"), &[(0.0, 0.0, 1.0), (0.0, 10.0, 0.0)]); +} + +#[test] +fn included_angle_follows_angdir() { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "ANGDIR", "value": 1 })).unwrap(); + s.script("PLINE 0,0 A A 90 10,0\n\n").unwrap(); + assert_pline(&s, &[(0.0, 0.0, -QUARTER), (10.0, 0.0, 0.0)]); +} + +#[test] +fn center_then_endpoint_angle_or_length() { + assert_pline(&run("PLINE 0,0 A CE 5,0 10,0\n\n"), &[(0.0, 0.0, 1.0), (10.0, 0.0, 0.0)]); + assert_pline(&run("PLINE 0,0 A CE 5,0 A -90\n\n"), &[(0.0, 0.0, -QUARTER), (5.0, 5.0, 0.0)]); + assert_pline(&run("PLINE 0,0 A CE 5,0 L 7.0710678118654755\n\n"), &[(0.0, 0.0, QUARTER), (5.0, -5.0, 0.0)]); +} + +#[test] +fn direction_radius_and_second_point() { + // Leaving upwards to end at (10,0): a half circle turning clockwise. + assert_pline(&run("PLINE 0,0 A D 90 10,0\n\n"), &[(0.0, 0.0, -1.0), (10.0, 0.0, 0.0)]); + // Radius 10 over a chord of 10: a 60° counterclockwise arc. + let tan15 = (15f64).to_radians().tan(); + assert_pline(&run("PLINE 0,0 A R 10 10,0\n\n"), &[(0.0, 0.0, tan15), (10.0, 0.0, 0.0)]); + assert_pline(&run("PLINE 0,0 A R 5 A 180 0\n\n"), &[(0.0, 0.0, 1.0), (10.0, 0.0, 0.0)]); + // A negative radius: the 300° major arc. + let tan75 = (75f64).to_radians().tan(); + assert_pline(&run("PLINE 0,0 A R -10 10,0\n\n"), &[(0.0, 0.0, tan75), (10.0, 0.0, 0.0)]); + // Through (5,5) on the way to (10,0): over the top, clockwise. + assert_pline(&run("PLINE 0,0 A S 5,5 10,0\n\n"), &[(0.0, 0.0, -1.0), (10.0, 0.0, 0.0)]); +} + +#[test] +fn line_back_and_length() { + assert_pline(&run("PLINE 0,0 A A 90 10,0 L 10,-5\n\n"), &[(0.0, 0.0, QUARTER), (10.0, 0.0, 0.0), (10.0, -5.0, 0.0)]); + // Length continues the last segment's direction. + assert_pline(&run("PLINE 0,0 3,4 L 5\n\n"), &[(0.0, 0.0, 0.0), (3.0, 4.0, 0.0), (6.0, 8.0, 0.0)]); + // After a half circle from (0,-5) to (10,-5) round the bottom, the tangent points up. + assert_pline(&run("PLINE 0,0 0,-5 A 10,-5 L L 4\n\n"), &[(0.0, 0.0, 0.0), (0.0, -5.0, 1.0), (10.0, -5.0, 0.0), (10.0, -1.0, 0.0)]); +} + +#[test] +fn bad_arc_answers_reprompt() { + // A radius too small for the chord is refused and the radius kept for another endpoint. + let mut s = Session::new(); + s.script("PLINE 0,0 A R 2 10,0").unwrap(); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + s.script("4,0\n\n").unwrap(); + assert_pline(&s, &[(0.0, 0.0, 1.0), (4.0, 0.0, 0.0)]); + // Enter at a sub-prompt goes back to the arc prompt. + let mut s = Session::new(); + s.script("PLINE 0,0 A CE\n\n").unwrap(); + assert!(s.prompt_text().contains("Specify endpoint of arc or [Angle/CEnter"), "{}", s.prompt_text()); +} + +/// Base and unit direction of the last construction line or ray. +fn xline(s: &Session) -> (Vec2, Vec2) { + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::XLine(r) | EntityKind::Ray(r) => (r.base.xy(), r.dir.xy()), + k => panic!("expected an xline, got {k:?}"), + } +} + +#[track_caller] +fn assert_xline(s: &Session, base: (f64, f64), angle_deg: f64) { + let (b, d) = xline(s); + let want = Vec2::from_angle(angle_deg.to_radians()); + assert!(b.near(Vec2::new(base.0, base.1), 1e-9), "base {b:?}"); + // An xline runs both ways. + assert!(d.cross(want).abs() < 1e-9, "direction {d:?}"); +} + +#[test] +fn xline_options() { + assert_xline(&run("XLINE H 0,5\n\n"), (0.0, 5.0), 0.0); + assert_xline(&run("XLINE A 30 0,5\n\n"), (0.0, 5.0), 30.0); + assert_xline(&run("XLINE B 0,0 10,0 0,10\n\n"), (0.0, 0.0), 45.0); + // Reference: 30° from a line at 45°. + let mut s = Session::new(); + s.script("LINE 0,0 10,10\n\nXLINE A R 5,5 30 0,5\n\n").unwrap(); + assert_xline(&s, (0.0, 5.0), 75.0); + // Offset through a point, and by a distance from the polyline segment picked (not the first). + let mut s = Session::new(); + s.script("PLINE 0,0 10,0 10,10\n\nXLINE O T 5,0 3,7\n\n").unwrap(); + assert_xline(&s, (3.0, 7.0), 0.0); + s.script("XLINE O 2 10,5 12,5\n\n").unwrap(); + let (b, _) = xline(&s); + assert!((b.x - 12.0).abs() < 1e-9, "{b:?}"); + assert_xline(&s, (b.x, b.y), 90.0); + // RAY: start point, then through points. + let s = run("RAY 0,0 0,10\n\n"); + assert_xline(&s, (0.0, 0.0), 90.0); +} diff --git a/docs/gaps.md b/docs/gaps.md index a5731d0..be293b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -17,7 +17,7 @@ do them first. | 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | | 3 | **Tracking leftovers** (object snap tracking, Extension, Parallel and Apparent Intersection landed, #379): polar angles relative to the last segment, PolarSnap, temporary tracking point | Small now; ranks lower at the next review | 3–5 | [ui](ui-parity.md#tracking-polar-ortho-grid-and-snap--70) | | 4 | **[alpha blocker]** **DWG always saves as R2000; DXF only R2000 ASCII; no code pages** | Can't exchange at 2018 format; pre-2007 CJK/Cyrillic files are garbled | 10–15 | [file formats](file-format-parity.md) | -| 5 | **[alpha blocker]** **Stubbed prompt options** in OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER, FILLET | Scripts and muscle memory hit "not available yet" in the most-used commands | 15–25 | [features](#features) | +| 5 | **[alpha blocker]** **Stubbed prompt options** in SPLINE, MTEXT, MLEADER | Scripts and muscle memory hit "not available yet" in the most-used commands | 15–25 | [features](#features) | | 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | | 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) | | 8 | **No autosave, crash recovery, AUDIT or RECOVER** | Work is lost if anything goes wrong; damaged files can't be repaired | 10–15 | [features](#features) | @@ -28,7 +28,7 @@ do them first. | # | Gap | Evidence | User impact | Est. | |---|---|---|---|---| -| F1 | **[alpha blocker]** Prompt options print "not available yet": OFFSET Erase, Layer, Multiple, Undo; ROTATE Reference; PLINE Angle, CEnter, Direction, Radius, Second pt; SPLINE Method, Knots, Object, Tangency, toLerance; MTEXT Height/Justify/Line spacing/Rotation/Style/Width/Columns; MLEADER options | `cmd/modify.rs:1537,1697-1730,1807-1843`, `cmd/draw.rs:542,623,1106-1128,1297-1320`, `modify2.rs:594,786,809` | High: the commands people use most | 15–25 | +| F1 | **[alpha blocker]** Prompt options print "not available yet": SPLINE Method, Knots, Object, Tangency, toLerance; MTEXT Height/Justify/Line spacing/Rotation/Style/Width/Columns; MLEADER options (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/draw.rs` (`SplineM`), `modify2.rs:594,783,806` | High: the commands people use most | 15–25 | | F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 | | F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 | | F4 | No autosave (SAVETIME), crash recovery files, AUDIT, RECOVER, drawing recovery manager | no SAVETIME anywhere | High: lost work | 10–15 |