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 meaning
diff --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.rs
new 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) |
