From fda82da3df685b870cee7eff5db699abdd191ced Mon Sep 17 00:00:00 2001 From: Nicholas Gower Date: Fri, 23 Oct 2026 22:51:06 -0700 Subject: [PATCH] RECTANG: add a Center Rectangle construction mode (#1895) * RECTANG: add a Center Rectangle construction mode (#1127) The first point of RECTANG was always a corner. A new Center option makes it the rectangle's centre, so the second point (or Dimensions/Area) builds it outward evenly: centre 10,10 with a corner at 14,16 gives 6,4 to 14,16. - interactive: a Center keyword at the first prompt (Chamfer keeps the c shortcut; Center matches ce or the full word), which also changes the prompt to Specify center point for rectangle; - JSON: center: true on rectang, keeping p1/p2 and the default corner mode unchanged; - rotation turns about the centre; chamfer, fillet, width, elevation and thickness apply as before; the rubber band previews the centred rectangle; - params documentation updated. Tests: centre-point geometry for p2, dimensions and area, rotation, fillet, chamfer, width, thickness, the interactive keyword and preview, and the unchanged default corner mode. Fixes #1127 * docs: document the functions touched by #1127 * RECTANG: the Center option is CEnter, so C stays Chamfer's shortcut (as PLINE) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 4 +- crates/engine/src/cmd/draw2.rs | 48 ++++++++++-- crates/engine/src/cmd/draw2_tests.rs | 106 +++++++++++++++++++++++++++ docs/target-app-parity.md | 5 +- 4 files changed, 154 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 8fa3f6d6..ce8c8f7d 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -11,6 +11,8 @@ use super::pline_opts::{Outcome, PlineAsk}; use super::*; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap}; +/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt +/// machine each command starts. pub fn specs() -> Vec { vec![ CommandSpec::new("line", "Line", run_line) @@ -36,7 +38,7 @@ pub fn specs() -> Vec { CommandSpec::new("rectang", "Rectangle", super::draw2::run_rectang2) .menu(&["Draw", "Rectangle"]) .alias(&["rec", "rectangle"]) - .params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?}") + .params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?, center?: bool (p1 is the centre, not a corner; the size spans it evenly)}") .interactive(|_| Ok(Box::new(super::draw2::RectM2::default()))), CommandSpec::new("polygon", "Polygon", run_polygon) .menu(&["Draw", "Polygon"]) diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 1d3b10c6..68dfd5db 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1581,8 +1581,12 @@ fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64, k } +/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is +/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`. pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { let a = point_req("rectang", p, "p1")?; + // Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly. + let centered = bool_or(p, "center", false); let rot = f64_or(p, "rotation", 0.0).to_radians(); let ch = p .get("chamfer") @@ -1618,12 +1622,14 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { } else { let b = point_req("rectang", p, "p2")?; let loc = (b - a).rotate(-rot); - (loc.x, loc.y) + // In centre mode the picked point is one corner, mirrored across the centre. + if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) } }; if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() { return Err(bad("rectang", "rectangle has no area")); } - let k = rect_kind(a, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); + let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a }; + let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); let hd = s.add_entity(k)?; let th = f64_or(p, "thickness", 0.0); if th != 0.0 { @@ -1654,6 +1660,8 @@ enum RAsk { #[derive(Default)] pub(crate) struct RectM2 { first: Option, + /// The first point is the rectangle's centre (the `Center` option), not a corner. + center: bool, fillet: f64, chamfer: (f64, f64), width: f64, @@ -1666,23 +1674,37 @@ pub(crate) struct RectM2 { } impl RectM2 { + /// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from + /// the centre when centre mode is on, then applies the requested thickness. fn make(&self, s: &mut Session, w: f64, h: f64) -> Result { let Some(a) = self.first else { return Ok(Step::Cancel) }; if w.abs() < 1e-12 || h.abs() < 1e-12 { return Err(other("The rectangle has no area.")); } - let hd = s.add_entity(rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; + let first = self.origin(w, h).unwrap_or(a); + let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?; if self.thickness != 0.0 { let t = self.thickness; s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?; } Ok(Step::Done) } + /// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first + /// point itself as a corner, or the first point less half the size around the centre. + fn origin(&self, w: f64, h: f64) -> Option { + let a = self.first?; + Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a }) + } + /// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only + /// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in + /// centre mode. fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> { let a = self.first?; let loc = (p - a).rotate(-self.rotation); Some(match self.dims { Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }), + // In centre mode the picked point is one corner, mirrored across the centre. + None if self.center => (2.0 * loc.x, 2.0 * loc.y), None => (loc.x, loc.y), }) } @@ -1692,6 +1714,8 @@ impl Interactive for RectM2 { fn name(&self) -> &'static str { "RECTANG" } + /// Asks for the pending option's value, or for the first and second points; in centre mode + /// the first prompt asks for the centre instead of a corner. fn prompt(&self, _s: &Session) -> Prompt { let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}")); if let Some(a) = self.asking { @@ -1717,10 +1741,15 @@ impl Interactive for RectM2 { }; } match self.first { - None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]), + None => { + let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" }; + Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"]) + } Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p), } } + /// Consumes one input: option keywords (`Center` only switches the first point's meaning + /// to the centre and asks nothing), option values, and the points that size the rectangle. fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(a) = self.asking { let val = |i: &Input, default: f64| -> Result { @@ -1794,6 +1823,11 @@ impl Interactive for RectM2 { } match i { Input::Keyword(k) => { + // `Center` only switches the first point's meaning; it asks nothing. + if k == "CEnter" { + self.center = true; + return Ok(Step::Continue); + } self.asking = Some(match k.as_str() { "Chamfer" => RAsk::Chamfer1, "Elevation" => RAsk::Elevation, @@ -1820,12 +1854,14 @@ impl Interactive for RectM2 { _ => Ok(Step::Continue), } } + /// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin + /// rules as the committed entity so centre mode previews where it will land. fn preview(&self, _s: &Session, c: Vec2) -> Vec { if self.asking.is_some() { return Vec::new(); } - match (self.first, self.other_corner(c)) { - (Some(a), Some((w, h))) if w.abs() > 1e-12 && h.abs() > 1e-12 => { + match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) { + Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => { vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)] } _ => Vec::new(), diff --git a/crates/engine/src/cmd/draw2_tests.rs b/crates/engine/src/cmd/draw2_tests.rs index 56be94cc..a28d92b4 100644 --- a/crates/engine/src/cmd/draw2_tests.rs +++ b/crates/engine/src/cmd/draw2_tests.rs @@ -566,6 +566,7 @@ fn xline_options_command_line() { // ---------------- RECTANG ---------------- +/// The corners of the rectangle just added, so a test can assert its bounds. fn rect_pts(s: &Session) -> Vec { match last(s) { EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| v.p).collect(), @@ -622,6 +623,111 @@ fn rectang_options_command_line() { } } +/// Guards the JSON `center: true` form for every sizing route: a picked corner mirrored about +/// the centre, full-size `dimensions`, full `area`, rotation about the centre, that the default +/// corner mode is untouched, and that centre mode without a size is rejected. +#[test] +fn rectang_center_mode_json() { + let mut s = Session::new(); + // The first point is the centre; the second is a corner mirrored about it. + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16]})).unwrap(); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // Dimensions are the rectangle's full size, still centred on the first point. + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12]})).unwrap(); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // A second point only picks the quadrant. + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "p2": [0, 0]})).unwrap(); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // The area is the full area, centred too. + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "area": 96, "length": 8})).unwrap(); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // Rotation is about the centre. + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "rotation": 90})).unwrap(); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 4.0, 6.0) && near(b.max, 16.0, 14.0), "{b:?}"); + // The default corner mode is unchanged. + s.execute("rectang", &json!({"p1": [10, 10], "p2": [14, 16]})).unwrap(); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // Centre mode still needs a size. + assert!(s.execute("rectang", &json!({"center": true, "p1": [10, 10]})).is_err()); +} + +/// Guards that fillet, chamfer, width and thickness still apply in centre mode, and that an +/// oversized fillet falls back to sharp corners without moving the rectangle off centre. +#[test] +fn rectang_center_mode_honours_options() { + let mut s = Session::new(); + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 1, "width": 0.25, "thickness": 2})).unwrap(); + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.25), + k => panic!("{k:?}"), + } + assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 2.0); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // Chamfer, still centred. + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "chamfer": [1, 1]})).unwrap(); + assert_eq!(rect_pts(&s).len(), 8); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // A fillet too large for the rectangle falls back to sharp corners, still centred. + s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 5})).unwrap(); + assert_eq!(rect_pts(&s).len(), 4); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); +} + +/// Guards the `Center` keyword at the command line, before or after other options, with +/// dimensions and area still centred, the prompt wording and centred rubber band, and that the +/// default corner mode is unchanged. +#[test] +fn rectang_center_mode_command_line() { + let mut s = Session::new(); + s.cmdline("rectang ce 10,10 14,16").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // Options before the centre point, and the centre picked after them. + s.cmdline("rectang center f 1 w 0.1 10,10 14,16").unwrap(); + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.1), + k => panic!("{k:?}"), + } + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // Dimensions after the centre: the pick only chooses the quadrant. + s.cmdline("rectang ce 10,10 d 8 12 14,16").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // Area too: the rectangle of that area is centred on the first point. + s.cmdline("rectang ce 10,10 a 96 l 8").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}"); + // The rubber band is centred while the second point is picked. + s.start("rectang").unwrap(); + s.cmdline("ce").unwrap(); + assert!(s.prompt_text().contains("Specify center point for rectangle"), "{}", s.prompt_text()); + s.cmdline("10,10").unwrap(); + let pv = s.preview(Vec2::new(14.0, 16.0)); + let pb = match pv.first().map(|e| &e.kind) { + Some(EntityKind::LwPolyline(p)) => cadcraft_geom::Bounds2::from_points(p.vertices.iter().map(|v| v.p).collect::>()), + k => panic!("{k:?}"), + }; + assert!(near(pb.min, 6.0, 4.0) && near(pb.max, 14.0, 16.0), "{pb:?}"); + s.cancel(); + // The default corner mode is unchanged. + s.cmdline("rectang 10,10 14,16").unwrap(); + let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s)); + assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}"); +} + // ---------------- 3DPOLY / MLINE / HELIX / SPLINE CV ---------------- #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bdf89d03..1e529cda 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: CADCraft vs AutoCAD 2027 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Block Editor for static blocks; blocks evidence updated, numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (RECTANG centre construction mode, #1127; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall, @@ -146,7 +146,7 @@ presence: an option that prints "not available yet" doesn't count. | Area | Weight | Parity | Remaining (Opus 5.5 h) | Evidence and main gaps | Doc | |---|---:|---:|---|---|---| -| Draw (2D) | 13% | 60% | 15–25 | ARC 11/11 methods, CIRCLE 6/6, RECTANG all options, REVCLOUD, WIPEOUT, XLINE; PLINE arc options and SPLINE options done (#397, #408); MLINE, REGION and HELIX are objects (#510); isometric drafting (#556) | [gaps](gaps.md#features) | +| Draw (2D) | 13% | 60% | 15–25 | ARC 11/11 methods, CIRCLE 6/6, RECTANG all options plus a centre construction mode (#1127), REVCLOUD, WIPEOUT, XLINE; PLINE arc options and SPLINE options done (#397, #408); MLINE, REGION and HELIX are objects (#510); isometric drafting (#556) | [gaps](gaps.md#features) | | Modify | 15% | 50% | 40–60 | TRIM/EXTEND, OFFSET, ROTATE, PEDIT and SPLINEDIT options done (#397, #558, #559); associative arrays with ARRAYEDIT (#448), no array grips; MATCHPROP picker and Settings (#439) | [gaps](gaps.md#features) | | Geometry precision | 10% | 40% | 70–110 | exact for lines/arcs/circles; ellipse and spline intersections, offsets, closest points tessellated; absolute tolerances; polyline offset doesn't recompute bulges | [geometry-parity](geometry-parity.md) | | Layers and properties | 7% | 70% | 10–15 | Layer Properties Manager with filters, states, VP overrides; QSELECT; Quick Properties; transparency; linetype manager thin; 19 linetypes of ~45 | [gaps](gaps.md#features) | @@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured | | 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured | | 2026-10-12 | minor | Blocks row evidence: Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); percentages and hours not re-measured | | 2026-10-11 | minor | Evidence text of the feature-area rows refreshed for merged work (#397, #406, #408, #417, #420, #433, #448, #451, #453, #459, #468, #507, #510, #525, #534, #546, #547, #558, #563, #564, #567, #571); parity percentages and hours not re-measured |