PLINE arc options and Length, XLINE polyline segments (fixes #397) (#407)

PLINE arc mode answered "not available yet" for Angle, CEnter, Direction,
Radius and Second pt, and line mode for Length. Each now asks its questions
(included angles and directions read the drawing's angle settings) and adds
the segment. XLINE's options were already in draw2's XlineM2; the dead
keyword stub in draw.rs's RAY machine goes, and Offset/Reference use the
polyline segment picked rather than the first one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 00:44:08 +02:00 committed by GitHub
parent 7965f8728b
commit 2f9ebc6f63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 443 additions and 47 deletions

View File

@ -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> {
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<PlineAsk>,
}
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<EntityKind> {
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<Vec2>,
fixed_angle: Option<f64>,
}
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<Step> {
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<EntityKind> {
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(),
}
}

View File

@ -1294,15 +1294,20 @@ fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result<Handle> {
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<Vec2>) -> 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<Value> {
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)
}

View File

@ -17,6 +17,7 @@ mod layer;
mod layout;
mod modify;
mod modify2;
mod pline_opts;
mod props;
mod qselect;
mod settings;

View File

@ -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<PlineAsk> {
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<Outcome> {
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<f64> {
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)
}

View File

@ -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);
}

View File

@ -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 |