Browse / github.com/storytold/cadcraft / patch
RECTANG: width and height Dynamic Input fields along the sides, Tab to lock
RECTANG: width and height Dynamic Input fields along the sides, Tab to lock After RECTANG's first corner, the other-corner prompt now shows AutoCAD-style **width and height fields along the rectangle's sides**, instead of the generic distance/angle point fields. ## Behaviour - The width field sits on the horizontal side's dimension line and the height field on the vertical side's. Both follow the rubber-band rectangle and show live values until you type in one. - **Tab** moves between the two fields. Typing a value and pressing Tab **locks** that side to the typed length, so the mouse only drives the other side. Tabbing back to a locked field and typing a new value r
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Elarionus (imported from pull request #2137, open)
- Base
e86d895ba1086a7efc2a83048348f89098282d08- Size
- +514 −17 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d47ec02a33d49bd87984ab6f
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/draw2.rs crates/engine/src/cmd/draw2_tests.rs crates/engine/src/lib.rs crates/engine/src/pointmod.rs crates/engine/src/prompt.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/cmdline.rs crates/ui-egui/src/grip_dyn.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/rect_dyn.rs docs/gaps.md docs/ui-parity.md
@@ -10,7 +10,7 @@ use super::curves::{self, Chain, MAX_GEN, TanObj}; use super::helpers::*; use super::machines::number; use super::*;-use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};+use crate::{Accept, EngineError, Input, Interactive, Prompt, RectSize, Result, Session, Step}; pub(crate) mod breakline; pub(crate) mod mline;@@ -1744,7 +1744,14 @@ impl Interactive for RectM2 { 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),+ Some(p) => {+ let pr = Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p);+ // Typed dimensions leave only the quadrant to pick: no width and height fields.+ match self.dims {+ Some(_) => pr,+ None => pr.rect(RectSize { base: p, rotation: self.rotation, center: self.center }),+ }+ } } } /// Consumes one input: option keywords (`Center` only switches the first point's meaning@@ -623,6 +623,68 @@ fn rectang_options_command_line() { } } +/// RECTANG's other-corner prompt asks Dynamic Input for width and height fields+/// ([`crate::Prompt::rect`]) measured from the first corner in the rectangle's rotation, or from+/// the centre in centre mode; typed Dimensions leave only the quadrant, so no fields; and the+/// corner the fields resolve to draws exactly the typed size on the cursor's side.+#[test]+fn rectang_other_corner_asks_for_width_and_height_fields() {+ use crate::RectSize;+ let mut s = Session::new();+ s.cmdline("rectang").unwrap();+ assert_eq!(s.current_prompt().unwrap().rect, None, "the first corner is a point");+ s.cmdline("2,3").unwrap();+ let r = s.current_prompt().unwrap().rect.expect("width and height fields");+ assert_eq!(r, RectSize { base: Vec2::new(2.0, 3.0), rotation: 0.0, center: false });+ assert_eq!(r.size(Vec2::new(-1.0, 7.0)), (-3.0, 4.0));+ // Width 10 typed, height from the cursor; the cursor's quadrant gives the directions.+ let c = r.corner(Some(10.0), None, Vec2::new(-1.0, 7.0));+ assert!(near(c, -8.0, 7.0), "{c:?}");+ // Both typed: the cursor only picks the quadrant.+ s.input(Input::Point(r.corner(Some(10.0), Some(5.0), Vec2::new(4.0, -1.0)))).unwrap();+ assert!(s.running.is_none());+ let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));+ assert!(near(b.min, 2.0, -2.0) && near(b.max, 12.0, 3.0), "{b:?}");++ // Rotation turns the fields with the rectangle.+ s.cmdline("rectang 0,0 r 90").unwrap();+ let r = s.current_prompt().unwrap().rect.unwrap();+ assert!((r.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-12);+ // The width runs up the Y axis now.+ let (w, h) = r.size(Vec2::new(-2.0, 6.0));+ assert!((w - 6.0).abs() < 1e-9 && (h - 2.0).abs() < 1e-9, "{w} {h}");+ let (o, wend, hend) = r.sides((6.0, 2.0));+ assert!(near(o, 0.0, 0.0) && near(wend, 0.0, 6.0) && near(hend, -2.0, 0.0), "{wend:?} {hend:?}");+ s.cancel();++ // Centre mode: the fields are the full size, half of it each side of the centre.+ s.cmdline("rectang ce 10,10").unwrap();+ let r = s.current_prompt().unwrap().rect.unwrap();+ assert!(r.center);+ assert_eq!(r.size(Vec2::new(14.0, 16.0)), (8.0, 12.0));+ s.input(Input::Point(r.corner(Some(4.0), Some(2.0), Vec2::new(0.0, 0.0)))).unwrap();+ let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));+ assert!(near(b.min, 8.0, 9.0) && near(b.max, 12.0, 11.0), "{b:?}");+ assert_eq!(r.sides((4.0, 2.0)).0, Vec2::new(8.0, 9.0));++ // Typed Dimensions: the next point only picks the quadrant, so no fields.+ s.cmdline("rectang 0,0 d 3 2").unwrap();+ assert_eq!(s.current_prompt().unwrap().rect, None);+ s.cancel();++ // `@dx,dy`, an absolute point and keywords still answer the prompt.+ s.cmdline("rectang 1,1 @4,2").unwrap();+ assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).max, 5.0, 3.0));+ s.cmdline("rectang 1,1 6,6").unwrap();+ assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).max, 6.0, 6.0));+ s.cmdline("rectang 0,0 a 20 l 5").unwrap();+ assert!(near(cadcraft_geom::Bounds2::from_points(rect_pts(&s)).max, 5.0, 4.0));++ // Hostile values never escape as non-finite corners.+ let c = r.corner(Some(f64::NAN), Some(f64::INFINITY), Vec2::new(1.0, 1.0));+ assert!(c.is_finite(), "{c:?}");+}+ /// 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.@@ -47,7 +47,7 @@ pub use cadcraft_geom as geom; pub use cadcraft_render as render; pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};-pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};+pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, RectSize, Step}; #[derive(Debug, thiserror::Error)] pub enum EngineError {@@ -162,9 +162,10 @@ impl Session { Frame::TempTrack => Prompt::new("Specify temporary OTRACK point", point), Frame::Track { last: None } => Prompt::new("First tracking point", point), Frame::Track { last: Some(l) } => Prompt::new("Next point (Press ENTER to end tracking)", point).base(l),- Frame::Angle { .. } | Frame::Snap { mode: 0 } => p,+ // A typed angle or snap holds the point, not a rectangle's sides.+ Frame::Angle { .. } | Frame::Snap { mode: 0 } => Prompt { rect: None, ..p }, // "Endpoint of:", keeping the base and deferral PER/TAN need.- Frame::Snap { mode } => Prompt { message: format!("{} of", snap_name(mode)), keywords: Vec::new(), default: None, ..p },+ Frame::Snap { mode } => Prompt { message: format!("{} of", snap_name(mode)), keywords: Vec::new(), default: None, rect: None, ..p }, }) } @@ -66,6 +66,54 @@ pub struct Prompt { /// the routine writes, so it cannot be read for the word "Select" (#1598). #[serde(skip_serializing_if = "std::ops::Not::not")] pub picks_one: bool,+ /// The prompt sizes a rectangle from its first corner or centre (RECTANG's other corner):+ /// Dynamic Input shows its width and height as fields along its sides instead of the pointer+ /// boxes, and a typed one holds that side ([`RectSize::corner`]).+ #[serde(skip_serializing_if = "Option::is_none")]+ pub rect: Option<RectSize>,+}++/// A rectangle sized from a base point ([`Prompt::rect`]).+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]+#[serde(rename_all = "camelCase")]+pub struct RectSize {+ /// The first corner, or the centre with `center`.+ pub base: Vec2,+ /// The rectangle's rotation (radians): its width runs along this direction, its height+ /// a quarter turn on.+ pub rotation: f64,+ /// `base` is the centre: the picked point is a corner half the size away.+ pub center: bool,+}++impl RectSize {+ /// How much of the size lies between the base and the picked corner.+ fn reach(&self) -> f64 {+ if self.center { 0.5 } else { 1.0 }+ }+ /// The signed width and height with the other corner at `p` (negative to the left of or+ /// below the base, in the rectangle's own directions).+ pub fn size(&self, p: Vec2) -> (f64, f64) {+ let l = (p - self.base).rotate(-self.rotation) / self.reach();+ (l.x, l.y)+ }+ /// The other corner for a typed `width` and `height` (lengths; `None` follows the cursor):+ /// the cursor's side of the base gives each typed length its direction.+ pub fn corner(&self, width: Option<f64>, height: Option<f64>, cursor: Vec2) -> Vec2 {+ let (w, h) = self.size(cursor);+ let pick = |typed: Option<f64>, live: f64| match typed.filter(|v| v.is_finite()) {+ Some(v) if live < 0.0 => -v.abs(),+ Some(v) => v.abs(),+ None => live,+ };+ self.base + (Vec2::new(pick(width, w), pick(height, h)) * self.reach()).rotate(self.rotation)+ }+ /// The rectangle of signed size `(w, h)`: the corner it is drawn from, then the far ends of+ /// its width side and of its height side.+ pub fn sides(&self, (w, h): (f64, f64)) -> (Vec2, Vec2, Vec2) {+ let o = if self.center { self.base - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { self.base };+ (o, o + Vec2::new(w, 0.0).rotate(self.rotation), o + Vec2::new(0.0, h).rotate(self.rotation))+ } } /// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.@@ -118,6 +166,11 @@ impl Prompt { self.corner = true; self }+ /// Sizes a rectangle from a base point (the `rect` field).+ pub fn rect(mut self, r: RectSize) -> Self {+ self.rect = Some(r);+ self+ } /// Asks for a distance from the base (the `distance` field). pub fn distance(mut self) -> Self { self.distance = true;@@ -102,6 +102,8 @@ pub struct CanvasState { pub hot_grips: Vec<(Handle, usize)>, /// Grip dimensional input: the value fields of the grip being stretched. pub grip_dyn: crate::grip_dyn::GripDyn,+ /// A rectangle's width and height fields (RECTANG's other corner).+ pub rect_dyn: crate::rect_dyn::RectDyn, /// A GRIP edit was running last frame (its grips go cold when it ends). grip_editing: bool, /// The grip whose menu (Stretch, Add Vertex, Lengthen…) a right-click opened.@@ -1595,8 +1597,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { app.canvas.snap = None; app.canvas.track = None; }- // Grip dimensional input: typed lengths and angles hold the grip and override snaps.- let grip_held = crate::grip_dyn::constrain(app, eff);+ // Grip dimensional input: typed lengths and angles hold the grip and override snaps; a+ // rectangle's typed width and height hold its corner the same way.+ let grip_held = crate::grip_dyn::constrain(app, eff).or_else(|| crate::rect_dyn::constrain(app, eff)); if let Some(q) = grip_held { eff = q; app.canvas.snap = None;@@ -2045,9 +2048,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { && let Some(hp) = hover_pos { // A stretched grip shows its dimensions instead of the pointer boxes; a prompt asking for- // a distance shows its one field and keeps the prompt and its options.- let fields = crate::grip_dyn::draw(app, &painter);- if !fields || app.session.current_prompt().is_some_and(|p| p.distance) {+ // a distance, or sizing a rectangle, shows its fields and keeps the prompt and its options.+ let fields = crate::grip_dyn::draw(app, &painter) || crate::rect_dyn::draw(app, &painter);+ if !fields || app.session.current_prompt().is_some_and(|p| p.distance || p.rect.is_some()) { dynamic_input(app, &painter, hp, fields); } }@@ -80,6 +80,10 @@ fn tab(app: &mut CadApp) { if crate::grip_dyn::tab(app) { return; }+ // A rectangle's width and height: Tab locks the typed length and moves to the other field.+ if crate::rect_dyn::tab(app) {+ return;+ } // Over a snap marker with nothing typed, Tab cycles the candidate snaps. if app.cmd.buffer.is_empty() && app.canvas.snap.is_some() { app.canvas.snap_tab = true;@@ -361,6 +365,12 @@ pub fn submit(app: &mut CadApp) { if crate::grip_dyn::submit(app) { return; }+ // A rectangle's typed width and height place its other corner.+ if crate::rect_dyn::submit(app) {+ app.cmd.locked_second = None;+ app.cmd.history_pos = None;+ return;+ } // Dynamic Input: a split entry (`40,30`, `40<90`, `40,`) is completed from the cursor and // measured from the last point. if let Some(f) = dyn_frame(app) {@@ -200,7 +200,7 @@ pub fn draw(app: &mut CadApp, p: &egui::Painter) -> bool { } /// One value field centred on `at`.-fn field(p: &egui::Painter, at: Pos2, text: &str, font: &egui::FontId, active: bool, locked: bool, live: bool) {+pub(crate) fn field(p: &egui::Painter, at: Pos2, text: &str, font: &egui::FontId, active: bool, locked: bool, live: bool) { let t = Tokens::get(); let g = p.layout_no_wrap(text.to_owned(), font.clone(), if live { t.text_faint } else { t.text }); let lock_w = if locked { 11.0 } else { 0.0 };@@ -43,6 +43,7 @@ pub mod palettes; pub mod parametric; pub mod plotstyles; pub mod quick;+pub mod rect_dyn; pub mod shortcut_menus; pub mod theme; pub mod viewcube;new file mode 100644@@ -0,0 +1,350 @@+//! A rectangle's width and height fields at the cursor: while a prompt sizes a rectangle from its+//! first corner ([`cadcraft_engine::Prompt::rect`], RECTANG's other corner), Dynamic Input shows+//! the rubber-band rectangle's width and height as value fields on dimension lines along its+//! sides, in place of the pointer boxes. Typing goes into the active field; Tab locks a typed+//! length and moves to the other field (and back); a locked side keeps its length while the other+//! follows the cursor, and the cursor's side of the first corner gives each length its direction.+//! Typing in a locked field and Tab again replaces the lock. Enter places the corner the typed+//! lengths give (the command gets a plain point).+//!+//! The command-line text is the active field's text, so Backspace and Escape behave as usual;+//! text that isn't a length (`@4,2`, `6,6`, a keyword such as `D`) goes to the command as typed.++use cadcraft_engine::{Input, RectSize};+use cadcraft_geom::Vec2;+use egui::{Pos2, Stroke, vec2};++use crate::CadApp;+use crate::theme::Tokens;++/// The width field.+pub const WIDTH: usize = 0;+/// The height field.+pub const HEIGHT: usize = 1;++/// The fields' state for the rectangle being sized.+#[derive(Clone, Debug, Default, PartialEq)]+pub struct RectDyn {+ /// The rectangle the fields belong to; another one (a new first corner, a new rotation)+ /// starts over.+ rect: Option<RectSize>,+ /// The active field ([`WIDTH`] or [`HEIGHT`]).+ pub active: usize,+ /// Locked lengths as typed, by field.+ pub locked: [Option<String>; 2],+}++/// The rectangle on show, after bringing the fields' state up to date with the prompt (`None`+/// when no prompt sizes a rectangle or the value boxes aren't live).+pub fn rect(app: &mut CadApp) -> Option<RectSize> {+ let r = crate::cmdline::dyn_frame(app).and_then(|_| app.session.current_prompt()).and_then(|p| p.rect);+ let st = &mut app.canvas.rect_dyn;+ if st.rect != r {+ *st = RectDyn { rect: r, ..RectDyn::default() };+ }+ r+}++/// A typed length: a distance that isn't zero, as a positive number (the cursor gives the+/// direction).+fn length(text: &str) -> Option<f64> {+ cadcraft_engine::units::parse_distance(text.trim()).filter(|v| v.is_finite() && v.abs() > 1e-12).map(f64::abs)+}++/// The lengths that hold the sides: the active field's typed value, else the locked ones.+fn values(app: &CadApp) -> [Option<f64>; 2] {+ let st = &app.canvas.rect_dyn;+ let field = |i: usize| {+ let typed = if i == st.active { length(&app.cmd.buffer) } else { None };+ typed.or_else(|| st.locked.get(i).and_then(|l| l.as_deref()).and_then(length))+ };+ [field(WIDTH), field(HEIGHT)]+}++/// The cursor held by the typed lengths, or `None` when nothing holds it.+pub fn constrain(app: &mut CadApp, p: Vec2) -> Option<Vec2> {+ let r = rect(app)?;+ let [w, h] = values(app);+ if w.is_none() && h.is_none() {+ return None;+ }+ Some(r.corner(w, h, p)).filter(|q| q.is_finite())+}++/// Tab: lock the active field's typed length and move to the other field. `false` when no+/// rectangle fields are on show or the text isn't a length (Tab does its usual thing).+pub fn tab(app: &mut CadApp) -> bool {+ if rect(app).is_none() {+ return false;+ }+ let typed = app.cmd.buffer.trim().to_owned();+ if !typed.is_empty() && length(&typed).is_none() {+ return false;+ }+ let st = &mut app.canvas.rect_dyn;+ if !typed.is_empty()+ && let Some(slot) = st.locked.get_mut(st.active)+ {+ *slot = Some(typed);+ app.cmd.buffer.clear();+ }+ st.active = if st.active == WIDTH { HEIGHT } else { WIDTH };+ true+}++/// Enter: place the corner the typed lengths give, the cursor sizing any side left open. `false`+/// when nothing is typed or locked, or the text isn't a length (Enter or the text then go to the+/// command as usual).+pub fn submit(app: &mut CadApp) -> bool {+ let Some(r) = rect(app) else { return false };+ if !app.cmd.buffer.trim().is_empty() && length(&app.cmd.buffer).is_none() {+ return false;+ }+ let [w, h] = values(app);+ if w.is_none() && h.is_none() {+ return false;+ }+ let cursor = app.canvas.cursor.unwrap_or(app.session.cursor);+ let p = r.corner(w, h, cursor);+ if !p.is_finite() {+ return false;+ }+ app.cmd.buffer.clear();+ app.canvas.rect_dyn = RectDyn::default();+ if let Err(e) = app.session.input(Input::Point(p)) {+ app.session.echo(e.to_string());+ }+ true+}++/// Draw the width and height fields on dimension lines outside the rubber-band rectangle;+/// `false` when there are none (the pointer boxes show instead).+pub fn draw(app: &mut CadApp, p: &egui::Painter) -> bool {+ let Some(r) = rect(app) else { return false };+ let (Some(xf), Some(c)) = (app.canvas.xf, app.canvas.cursor) else { return false };+ let t = Tokens::get();+ let font = crate::theme::small();+ let (lu, lp) = crate::canvas::length_units(&app.session);+ let st = app.canvas.rect_dyn.clone();+ let (w, h) = r.size(c);+ let (o, w_end, h_end) = r.sides((w, h));+ let (so, sw, sh) = (xf.to_screen(o), xf.to_screen(w_end), xf.to_screen(h_end));+ // The rectangle's middle on screen: each dimension line sits on the far side of its edge.+ let mid = so + ((sw - so) + (sh - so)) / 2.0;+ let line = Stroke::new(1.0, t.text_dim);+ let ext = Stroke::new(1.0, t.text_faint);+ for (i, (a, b), live) in [(WIDTH, (so, sw), w.abs()), (HEIGHT, (so, sh), h.abs())] {+ let along = b - a;+ let len = along.length();+ // A side with no length yet: lay the line out along the rectangle's own direction.+ let dir = if len > 1e-3 {+ along / len+ } else {+ let d = Vec2::from_angle(r.rotation + if i == WIDTH { 0.0 } else { std::f64::consts::FRAC_PI_2 });+ vec2(d.x as f32, -d.y as f32)+ };+ let mut n = vec2(-dir.y, dir.x);+ if n.dot((a + along / 2.0) - mid) < 0.0 {+ n = -n;+ }+ let off = n * 24.0;+ p.line_segment([a + off, b + off], line);+ p.line_segment([a + off * 0.2, a + off * 1.25], ext);+ p.line_segment([b + off * 0.2, b + off * 1.25], ext);+ arrow(p, a + off, dir, t.text_dim);+ arrow(p, b + off, -dir, t.text_dim);+ let active = i == st.active;+ let locked = st.locked.get(i).and_then(|l| l.as_deref());+ let typing = active && !app.cmd.buffer.is_empty();+ let text = if typing {+ app.cmd.buffer.clone()+ } else if let Some(l) = locked {+ l.to_owned()+ } else {+ cadcraft_engine::units::format_distance(live, lu, lp)+ };+ crate::grip_dyn::field(p, a + off + along / 2.0, &text, &font, active, locked.is_some() && !typing, locked.is_none() && !typing);+ }+ true+}++/// A small filled arrowhead with its tip at `tip`, pointing along `dir` backwards (into the line).+fn arrow(p: &egui::Painter, tip: Pos2, dir: egui::Vec2, color: egui::Color32) {+ let n = vec2(-dir.y, dir.x);+ let back = tip + dir * 7.0;+ p.add(egui::Shape::convex_polygon(vec![tip, back + n * 2.5, back - n * 2.5], color, Stroke::NONE));+}++#[cfg(test)]+mod tests {+ use super::*;+ use cadcraft_doc::EntityKind;+ use egui::{Event, Rect, pos2};++ fn run_frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) {+ let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() };+ let mut out = ctx.run_ui(raw, |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ });+ out.textures_delta.clear();+ }++ fn key(key: egui::Key) -> Vec<Event> {+ let m = egui::Modifiers::NONE;+ vec![+ Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m },+ Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m },+ ]+ }++ fn typed(app: &mut CadApp, ctx: &egui::Context, text: &str) {+ run_frame(app, ctx, vec![Event::Text(text.into())]);+ }++ /// Tab reaches the command line as text (`cmdline::capture_tab`).+ fn tab(app: &mut CadApp, ctx: &egui::Context) {+ typed(app, ctx, "\t");+ }++ /// The last rectangle's (min, max) corners.+ fn last_rect(app: &CadApp) -> (Vec2, Vec2) {+ let pts: Vec<Vec2> = app+ .session+ .doc()+ .ok()+ .and_then(|d| match &d.model.iter().last()?.kind {+ EntityKind::LwPolyline(p) => Some(p.vertices.iter().map(|v| v.p).collect()),+ _ => None,+ })+ .unwrap_or_default();+ let b = cadcraft_geom::Bounds2::from_points(pts);+ (b.min, b.max)+ }++ fn close(a: Vec2, x: f64, y: f64) -> bool {+ a.dist(Vec2::new(x, y)) < 1e-9+ }++ /// A RECTANG waiting for its other corner at (0,0), with the pointer at `at` (screen offset+ /// from the corner).+ fn rectang_at(at: egui::Vec2) -> (CadApp, egui::Context, Pos2) {+ let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());+ let ctx = egui::Context::default();+ run_frame(&mut app, &ctx, Vec::new());+ let o = app.canvas.xf.map_or(pos2(700.0, 450.0), |x| x.to_screen(Vec2::ZERO));+ app.cmdline("rectang");+ app.session.input(Input::Point(Vec2::ZERO)).unwrap();+ let pointer = o + at;+ run_frame(&mut app, &ctx, vec![Event::PointerMoved(pointer)]);+ (app, ctx, pointer)+ }++ #[test]+ fn width_tab_height_enter_draws_the_exact_rectangle() {+ let (mut app, ctx, _) = rectang_at(vec2(130.0, -70.0));+ assert!(rect(&mut app).is_some(), "width and height fields");+ typed(&mut app, &ctx, "10");+ tab(&mut app, &ctx);+ assert_eq!(app.canvas.rect_dyn.locked, [Some("10".to_owned()), None]);+ assert_eq!(app.canvas.rect_dyn.active, HEIGHT);+ assert!(app.cmd.buffer.is_empty());+ typed(&mut app, &ctx, "5");+ run_frame(&mut app, &ctx, key(egui::Key::Enter));+ assert!(app.session.running.is_none());+ let (lo, hi) = last_rect(&app);+ assert!(close(lo, 0.0, 0.0) && close(hi, 10.0, 5.0), "{lo:?} {hi:?}");+ }++ #[test]+ fn a_locked_side_holds_and_the_cursor_sizes_the_other() {+ // The first corner in the middle of the view, the pointer below and left of it: the+ // locked width runs to the left.+ let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());+ let ctx = egui::Context::default();+ run_frame(&mut app, &ctx, Vec::new());+ let (xf, mid) = (app.canvas.xf.unwrap(), app.canvas.rect.unwrap().center());+ let a = xf.to_world(mid);+ app.cmdline("rectang");+ app.session.input(Input::Point(a)).unwrap();+ run_frame(&mut app, &ctx, vec![Event::PointerMoved(mid + vec2(-130.0, 70.0))]);+ typed(&mut app, &ctx, "10");+ tab(&mut app, &ctx);+ // Move the pointer: the width stays 10, the height follows.+ let pointer = mid + vec2(-170.0, 100.0);+ run_frame(&mut app, &ctx, vec![Event::PointerMoved(pointer)]);+ let c = app.canvas.cursor.unwrap();+ assert!((c.x - (a.x - 10.0)).abs() < 1e-9, "{c:?}");+ assert!((c.y - xf.to_world(pointer).y).abs() < 1e-9, "{c:?}");+ run_frame(&mut app, &ctx, key(egui::Key::Enter));+ let (lo, hi) = last_rect(&app);+ assert!(close(lo, a.x - 10.0, c.y) && close(hi, a.x, a.y), "{lo:?} {hi:?}");+ }++ #[test]+ fn tab_back_to_a_locked_field_and_retype_changes_its_lock() {+ let (mut app, ctx, _) = rectang_at(vec2(130.0, -70.0));+ typed(&mut app, &ctx, "10");+ tab(&mut app, &ctx);+ typed(&mut app, &ctx, "5");+ tab(&mut app, &ctx);+ assert_eq!(app.canvas.rect_dyn.locked, [Some("10".to_owned()), Some("5".to_owned())]);+ assert_eq!(app.canvas.rect_dyn.active, WIDTH, "Tab goes back to the width");+ // Both locked: the cursor sits on the corner.+ let c = app.canvas.cursor.unwrap();+ assert!(close(c, 10.0, 5.0), "{c:?}");+ // Retype the width: the new value replaces the lock.+ typed(&mut app, &ctx, "7");+ tab(&mut app, &ctx);+ assert_eq!(app.canvas.rect_dyn.locked, [Some("7".to_owned()), Some("5".to_owned())]);+ run_frame(&mut app, &ctx, key(egui::Key::Enter));+ let (lo, hi) = last_rect(&app);+ assert!(close(lo, 0.0, 0.0) && close(hi, 7.0, 5.0), "{lo:?} {hi:?}");+ }++ #[test]+ fn points_and_keywords_still_answer_the_prompt() {+ let (mut app, ctx, _) = rectang_at(vec2(130.0, -70.0));+ typed(&mut app, &ctx, "@4,2");+ run_frame(&mut app, &ctx, key(egui::Key::Enter));+ let (_, hi) = last_rect(&app);+ assert!(close(hi, 4.0, 2.0), "{hi:?}");++ let (mut app, ctx, _) = rectang_at(vec2(130.0, -70.0));+ // A keyword goes to the command, even with a length locked.+ typed(&mut app, &ctx, "10");+ tab(&mut app, &ctx);+ typed(&mut app, &ctx, "r");+ run_frame(&mut app, &ctx, key(egui::Key::Enter));+ assert!(app.session.current_prompt().is_some_and(|p| p.message.starts_with("Specify rotation angle")));+ typed(&mut app, &ctx, "90");+ run_frame(&mut app, &ctx, key(egui::Key::Enter));+ // The rotation turned the fields: a new rectangle, so the locks start over.+ let r = rect(&mut app).unwrap();+ assert!((r.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-12);+ assert_eq!(app.canvas.rect_dyn.locked, [None, None]);+ typed(&mut app, &ctx, "6");+ tab(&mut app, &ctx);+ typed(&mut app, &ctx, "2");+ run_frame(&mut app, &ctx, key(egui::Key::Enter));+ assert!(app.session.running.is_none());+ let (lo, hi) = last_rect(&app);+ // Width 6 up the Y axis, height 2 to the left or right (the pointer's side).+ assert!((hi.y - lo.y - 6.0).abs() < 1e-9 && (hi.x - lo.x - 2.0).abs() < 1e-9, "{lo:?} {hi:?}");+ }++ #[test]+ fn fields_are_drawn_with_the_live_lengths() {+ let (mut app, ctx, pointer) = rectang_at(vec2(130.0, -70.0));+ app.ui.show_ucs_icon = false;+ let text = crate::canvas::tests::painted_text(&mut app, &ctx, pointer);+ let c = app.canvas.cursor.unwrap();+ let (lu, lp) = crate::canvas::length_units(&app.session);+ for v in [c.x.abs(), c.y.abs()] {+ let s = cadcraft_engine::units::format_distance(v, lu, lp);+ assert!(text.contains(&s), "no {s} in:\n{text}");+ }+ assert!(text.contains("Specify other corner point"), "{text}");+ assert!(!text.lines().any(|l| l == "X" || l == "Y"), "no pointer boxes:\n{text}");+ }+}@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (RECTANG width and height Dynamic Input fields: U14 added) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -66,6 +66,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode | U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 | | U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 | | U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 |+| U14 | Dimensional input on command prompts: fields on dimension lines exist for grips, distance prompts (radius) and RECTANG's other corner (width and height with Tab to lock, turned by Rotation); LINE, PLINE, ARC and POLYGON second points still show the pointer boxes (distance and angle) rather than length and angle fields on the segment; no DYNDIM; the RECTANG fields' placement (outside the sides through the first corner) is not yet checked against an AutoCAD screenshot | Low-medium | 3–6 | ## File formats @@ -150,6 +151,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | RECTANG's other corner shows width and height Dynamic Input fields along the rectangle's sides (Tab locks a typed length, the cursor's quadrant gives the directions, Rotation turns them): U14 added for the prompts still on pointer boxes | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (RECTANG width and height fields) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -103,9 +103,16 @@ CADCraft has pointer input boxes since #60 (`crates/ui-egui/src/dyninput.rs`): v beside the cursor while a command asks for a point; second and later points default to polar distance and angle (DYNPIFORMAT = 1 for Cartesian); `,` and `<` lock a value and move to the next box; Tab types the separator; Enter sends a relative entry (DYNPICOORDS = 1 for absolute) and-fills an empty box from the cursor; a plain number stays direct distance entry. Missing:-dimensional input on grips (length/angle fields while stretching), lock icons, the prompt with a-down-arrow options menu (DYNPROMPT), and DYNDIM.+fills an empty box from the cursor; a plain number stays direct distance entry. Grip dimensional+input (`grip_dyn.rs`), lock icons, a distance prompt's one length field and the down-arrow+options menu (DYNPROMPT) landed since. RECTANG's other corner shows the rubber-band rectangle's+width and height as fields on dimension lines along its sides (`rect_dyn.rs`, asked for through+`Prompt::rect`): Tab locks a typed length and moves to the other field and back, a locked side+keeps its length while the other follows the cursor, the cursor's quadrant gives each length its+direction, retyping a locked field replaces its lock, Enter places the corner, and the Rotation+option turns the fields with the rectangle; `@dx,dy`, points and keywords still go through.+Missing: length and angle fields on the segment for LINE, PLINE, ARC and POLYGON (they keep the+pointer boxes), and DYNDIM ([gaps.md](gaps.md) U14). ## Grips (≈ 40%) @@ -117,7 +124,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Hot-grip options: Base point, Copy, Undo, Reference, eXit | yes | **advertised in the prompt but not parsed**; the engine's JSON grip commands accept `copy`, the UI never passes it | | Several hot grips at once (Shift-click) | yes | **no** (one handle) | | Multifunctional grip hover menus (Add Vertex, Remove Vertex, Convert to Arc, Lengthen …) | yes | **no** |-| Grip dimensional input (length/angle fields) | yes | no |+| Grip dimensional input (length/angle fields) | yes | yes (`grip_dyn.rs`) | | Dynamic block grips | yes | no (no dynamic blocks) | | Grip appearance (GRIPSIZE, colours, hover) | yes | GRIPSIZE, GRIPS and GRIPOBJLIMIT honoured (Options ▸ Selection); polyline segment midpoints use rectangles, vertices use squares; no grip colours | @@ -203,6 +210,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | RECTANG's other corner: width and height fields on dimension lines with Tab to lock (`rect_dyn.rs`, `Prompt::rect`); the Dynamic input section's stale "missing" list corrected (grip input, lock icons and DYNPROMPT landed) | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |