collective

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

diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs--- a/crates/engine/src/cmd/draw2.rs+++ b/crates/engine/src/cmd/draw2.rs@@ -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 meaningdiff --git a/crates/engine/src/cmd/draw2_tests.rs b/crates/engine/src/cmd/draw2_tests.rs--- a/crates/engine/src/cmd/draw2_tests.rs+++ b/crates/engine/src/cmd/draw2_tests.rs@@ -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.diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -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 {diff --git a/crates/engine/src/pointmod.rs b/crates/engine/src/pointmod.rs--- a/crates/engine/src/pointmod.rs+++ b/crates/engine/src/pointmod.rs@@ -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 },         })     } diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs--- a/crates/engine/src/prompt.rs+++ b/crates/engine/src/prompt.rs@@ -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;diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -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);         }     }diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs--- a/crates/ui-egui/src/cmdline.rs+++ b/crates/ui-egui/src/cmdline.rs@@ -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) {diff --git a/crates/ui-egui/src/grip_dyn.rs b/crates/ui-egui/src/grip_dyn.rs--- a/crates/ui-egui/src/grip_dyn.rs+++ b/crates/ui-egui/src/grip_dyn.rs@@ -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 };diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -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;diff --git a/crates/ui-egui/src/rect_dyn.rs b/crates/ui-egui/src/rect_dyn.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/rect_dyn.rs@@ -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}");+    }+}diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -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 |diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -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) |