diff --git a/crates/io/src/svg_read.rs b/crates/io/src/svg_read.rs
--- a/crates/io/src/svg_read.rs
+++ b/crates/io/src/svg_read.rs
@@ -1,17 +1,17 @@
 //! SVG → Drawing: the static W3C SVG subset that drawing tools emit.
 //!
-//! Reads shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`), `path`
-//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping with `transform`
-//! attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers
-//! (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres with
-//! the page's top-left corner at the origin and Y pointing up — where a laser or pen job
+//! Reads shapes (`rect`, rounded corners included, `circle`, `ellipse`, `line`, `polyline`,
+//! `polygon`), `path` outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping
+//! with `transform` attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape
+//! layers (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres
+//! with the page's top-left corner at the origin and Y pointing up — where a laser or pen job
 //! wants it.
 //!
 //! Not read (each is a follow-up): CSS class rules (`<style>`), `use` references, fills,
-//! gradients and hatch patterns, text, embedded raster images, rounded rectangle corners
-//! (`rx`/`ry`), and elliptical arcs under non-uniform transforms (those are flattened into
-//! line segments instead of losing the shape). What the drawing shows of those (all but fills,
-//! gradients and patterns) is named in a read note, so it is never left out silently.
+//! gradients and hatch patterns, text, embedded raster images, and elliptical arcs under
+//! non-uniform transforms (those are flattened into line segments instead of losing the
+//! shape). What the drawing shows of those (all but fills, gradients and patterns) is named in
+//! a read note, so it is never left out silently.
 
 use std::collections::{BTreeMap, HashSet};
 
@@ -146,7 +146,6 @@ enum LeftOut {
     Image,
     Use,
     Style,
-    RoundedCorners,
 }
 
 impl LeftOut {
@@ -165,7 +164,6 @@ impl LeftOut {
             Self::Image => ("raster image", "raster images"),
             Self::Use => ("`<use>` copy", "`<use>` copies"),
             Self::Style => ("`<style>` sheet (its class rules aren't applied)", "`<style>` sheets (their class rules aren't applied)"),
-            Self::RoundedCorners => ("rectangle with rounded corners (drawn square)", "rectangles with rounded corners (drawn square)"),
         };
         format!("{n} {}", if n == 1 { one } else { many })
     }
@@ -363,11 +361,47 @@ impl Parser {
         if w <= 0.0 || h <= 0.0 {
             return Ok(());
         }
-        if num(e, b"rx", 0.0) > 0.0 || num(e, b"ry", 0.0) > 0.0 {
-            self.left_out(LeftOut::RoundedCorners);
+        let (rx, ry) = corner_radii(e, w, h);
+        if rx <= 0.0 || ry <= 0.0 {
+            let pts = [Vec2::new(x, y), Vec2::new(x + w, y), Vec2::new(x + w, y + h), Vec2::new(x, y + h)];
+            return self.add_polyline(&pts.map(|p| self.map(p)), true);
+        }
+        // SVG 1.1 §9.2: a rounded rectangle is exactly this path, from the top edge clockwise.
+        let start = Vec2::new(x + rx, y);
+        let corners = [
+            (Vec2::new(x + w - rx, y), Vec2::new(x + w, y + ry)),
+            (Vec2::new(x + w, y + h - ry), Vec2::new(x + w - rx, y + h)),
+            (Vec2::new(x + rx, y + h), Vec2::new(x, y + h - ry)),
+            (Vec2::new(x, y + ry), Vec2::new(x + rx, y)),
+        ];
+        let degenerate = |a: Vec2, b: Vec2| (b - a).len() <= f64::EPSILON * (w + h);
+        let m = self.cur();
+        if (rx - ry).abs() <= 1e-9 * rx.max(ry) && preserves_circles(&m) {
+            // Circular corners stay circular: quarter-circle bulge arcs, like a path's arcs.
+            let mut sp = Subpath { start, segs: Vec::new(), closed: true };
+            let mut at = start;
+            for (edge_end, arc_end) in corners {
+                if !degenerate(at, edge_end) {
+                    sp.segs.push(Seg::Line(edge_end));
+                }
+                sp.segs.push(Seg::Arc { rx, ry, rot: 0.0, large: false, sweep: true, end: arc_end });
+                at = arc_end;
+            }
+            return self.emit_subpath(&sp);
+        }
+        // Elliptical corners, or circular ones under a non-uniform transform: traced as line
+        // vertices on each corner's ellipse, one closed outline.
+        let mut pts = vec![m.apply(start)];
+        let mut at = start;
+        for (edge_end, arc_end) in corners {
+            if !degenerate(at, edge_end) {
+                pts.push(m.apply(edge_end));
+            }
+            pts.extend(flatten_arc(&m, edge_end, arc_end, (rx, ry), 0.0, false, true)?.into_iter().skip(1));
+            at = arc_end;
         }
-        let pts = [Vec2::new(x, y), Vec2::new(x + w, y), Vec2::new(x + w, y + h), Vec2::new(x, y + h)];
-        self.add_polyline(&pts.map(|p| self.map(p)), true)
+        pts.pop(); // back on the start, which the closed outline already has
+        self.add_polyline(&pts, true)
     }
 
     fn circle(&mut self, e: &BytesStart) -> Result<()> {
@@ -548,6 +582,16 @@ impl Parser {
     }
 
     fn flush_run(&mut self, run: &mut Vec<PolyVertex>, closed: bool) -> Result<()> {
+        // A closed run that ends back on its first vertex (to within rounding): the closing
+        // segment is that last step, so the repeated vertex goes and the step's bulge stays on
+        // the vertex before it.
+        if closed
+            && run.len() > 2
+            && let (Some(first), Some(last)) = (run.first(), run.last())
+            && (last.p - first.p).len() <= 64.0 * f64::EPSILON * (1.0 + first.p.x.abs().max(first.p.y.abs()))
+        {
+            run.pop();
+        }
         if run.len() >= 2 {
             let vertices = std::mem::take(run);
             self.add(EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }))?;
@@ -589,6 +633,28 @@ fn num(e: &BytesStart, name: &[u8], fallback: f64) -> f64 {
     attr(e, name).as_deref().and_then(parse_length_mm).unwrap_or(fallback)
 }
 
+/// Does `m` map circles to circles (a rotation, uniform scale or reflection, plus a shift)?
+fn preserves_circles(m: &Mat3) -> bool {
+    let ex = m.apply_vec(Vec2::new(1.0, 0.0));
+    let ey = m.apply_vec(Vec2::new(0.0, 1.0));
+    // Relative to the basis vectors' size, so a tiny but non-uniform scale isn't taken as uniform.
+    let mag = ex.x * ex.x + ex.y * ex.y + ey.x * ey.x + ey.y * ey.y;
+    mag > 0.0 && (ex.x * ex.x + ex.y * ex.y - (ey.x * ey.x + ey.y * ey.y)).abs() <= 1e-9 * mag && (ex.x * ey.x + ex.y * ey.y).abs() <= 1e-9 * mag
+}
+
+/// A `rect`'s corner radii (SVG 1.1 §9.2): a missing, negative or unreadable (`auto`) radius
+/// takes the other one's value, both missing means square corners, and each is at most half
+/// the side it rounds.
+fn corner_radii(e: &BytesStart, w: f64, h: f64) -> (f64, f64) {
+    let radius = |name: &[u8]| attr(e, name).as_deref().and_then(parse_length_mm).filter(|r| *r >= 0.0);
+    let (rx, ry) = match (radius(b"rx"), radius(b"ry")) {
+        (Some(rx), Some(ry)) => (rx, ry),
+        (Some(r), None) | (None, Some(r)) => (r, r),
+        (None, None) => (0.0, 0.0),
+    };
+    (rx.min(w / 2.0), ry.min(h / 2.0))
+}
+
 /// A bare number, or a number with an absolute CSS/SVG unit: millimetres here, everything
 /// else (px, the default; pt, pc, in, cm) through the CSS reference pixel (96 per inch).
 fn parse_length_mm(s: &str) -> Option<f64> {
diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs
--- a/crates/io/src/tests.rs
+++ b/crates/io/src/tests.rs
@@ -2243,6 +2243,120 @@ fn svg_shapes_become_entities_in_millimetres() {
     assert!((l.a.y - 10.0).abs() < 1e-9 && (l.b.y).abs() < 1e-9, "{l:?}");
 }
 
+/// The vertices of the first polyline and, for each bulge vertex, the point halfway along its arc.
+fn polyline_and_arc_midpoints(d: &Drawing) -> (LwPolyline, Vec<Vec2>) {
+    let p = first(d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
+    let n = p.vertices.len();
+    let mids = (0..n)
+        .filter(|&i| p.vertices[i].bulge != 0.0)
+        .map(|i| {
+            let (a, b) = (p.vertices[i].p, p.vertices[(i + 1) % n].p);
+            let d = b - a;
+            // The sagitta, signed by the bulge, off the chord's midpoint to its right.
+            let s = p.vertices[i].bulge * d.len() / 2.0;
+            (a + b) * 0.5 + Vec2::new(d.y, -d.x) * (s / d.len())
+        })
+        .collect();
+    (p, mids)
+}
+
+/// FF14: `rx`/`ry` round a rectangle's corners as SVG 1.1 §9.2 draws them: one closed polyline
+/// whose four corners are quarter-circle bulge arcs, bulging outward.
+#[test]
+fn svg_rounded_rect_corners_become_arcs() {
+    let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"6\" height=\"4\" rx=\"1\"/></svg>");
+    let d = read(svg.as_bytes(), "rounded.svg").unwrap();
+    assert_eq!(kinds(&d), ["Polyline"]);
+    assert!(d.read_notes.is_empty(), "{:?}", d.read_notes);
+    let (p, mids) = polyline_and_arc_midpoints(&d);
+    assert!(p.closed);
+    let got: Vec<Vec2> = p.vertices.iter().map(|v| v.p).collect();
+    let want = [(2.0, 9.0), (6.0, 9.0), (7.0, 8.0), (7.0, 6.0), (6.0, 5.0), (2.0, 5.0), (1.0, 6.0), (1.0, 8.0)].map(|(x, y)| Vec2::new(x, y));
+    assert_eq!(got.len(), want.len(), "{got:?}");
+    for (g, w) in got.iter().zip(want.iter()) {
+        assert!((*g - *w).len() < 1e-9, "{g:?} vs {w:?}");
+    }
+    // A quarter circle's bulge is tan(90°/4), on the vertex starting each corner.
+    let bulges: Vec<f64> = p.vertices.iter().map(|v| v.bulge).collect();
+    for (i, b) in bulges.iter().enumerate() {
+        let corner = i % 2 == 1;
+        assert!(if corner { (b.abs() - std::f64::consts::FRAC_PI_8.tan()).abs() < 1e-9 } else { *b == 0.0 }, "{bulges:?}");
+    }
+    // Each arc's midpoint is 1 mm from its corner's centre, towards the rectangle's corner.
+    let centres = [(6.0, 8.0), (6.0, 6.0), (2.0, 6.0), (2.0, 8.0)].map(|(x, y)| Vec2::new(x, y));
+    let outer = [(7.0, 9.0), (7.0, 5.0), (1.0, 5.0), (1.0, 9.0)].map(|(x, y)| Vec2::new(x, y));
+    assert_eq!(mids.len(), 4);
+    for ((m, c), o) in mids.iter().zip(centres.iter()).zip(outer.iter()) {
+        assert!(((*m - *c).len() - 1.0).abs() < 1e-9, "{m:?} vs centre {c:?}");
+        assert!((*o - *m).len() < (*o - *c).len(), "{m:?} bulges inward");
+    }
+}
+
+/// SVG 1.1 §9.2's radius rules: a missing, negative or `auto` radius takes the other's value,
+/// each radius is at most half its side, and a zero radius (or none) keeps the corners square.
+#[test]
+fn svg_rounded_rect_radius_rules() {
+    let read_rect = |attrs: &str| {
+        let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"6\" height=\"4\" {attrs}/></svg>");
+        read(svg.as_bytes(), "r.svg").unwrap()
+    };
+    let bulges = |d: &Drawing| polyline_and_arc_midpoints(d).1.len();
+    let vertices = |d: &Drawing| polyline_and_arc_midpoints(d).0.vertices.len();
+    for attrs in ["ry=\"1\"", "rx=\"1\" ry=\"auto\"", "rx=\"-2\" ry=\"1\"", "rx=\"1mm\""] {
+        let d = read_rect(attrs);
+        assert_eq!((vertices(&d), bulges(&d)), (8, 4), "{attrs}");
+    }
+    for attrs in ["", "rx=\"0\"", "rx=\"-1\"", "rx=\"1\" ry=\"0\"", "rx=\"auto\""] {
+        let d = read_rect(attrs);
+        assert_eq!((vertices(&d), bulges(&d)), (4, 0), "{attrs}");
+    }
+    // rx="10" alone: ry is 10 too, then rx is capped at 3 and ry at 2, an elliptical corner.
+    let d = read_rect("rx=\"10\"");
+    let (p, mids) = polyline_and_arc_midpoints(&d);
+    assert!(p.closed && mids.is_empty() && p.vertices.len() > 8, "{p:?}");
+    for v in &p.vertices {
+        assert!(v.p.x > 1.0 - 1e-9 && v.p.x < 7.0 + 1e-9 && v.p.y > 5.0 - 1e-9 && v.p.y < 9.0 + 1e-9, "{:?}", v.p);
+    }
+}
+
+/// Elliptical corners (`rx` ≠ `ry`), and circular ones under a non-uniform transform, aren't
+/// circular arcs, so they are traced as line vertices on the corner's ellipse.
+#[test]
+fn svg_elliptical_rounded_corners_are_traced() {
+    let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"6\" height=\"4\" rx=\"2\" ry=\"1\"/></svg>");
+    let d = read(svg.as_bytes(), "elliptical.svg").unwrap();
+    let (p, mids) = polyline_and_arc_midpoints(&d);
+    assert!(p.closed && mids.is_empty(), "{p:?}");
+    // Every vertex lies on an edge or on its corner's ellipse ((x-cx)/2)² + (y-cy)² = 1.
+    let centres = [(5.0, 8.0), (5.0, 6.0), (3.0, 6.0), (3.0, 8.0)].map(|(x, y)| Vec2::new(x, y));
+    for v in &p.vertices {
+        let (x, y) = (v.p.x, v.p.y);
+        let on_edge = (y - 9.0).abs() < 1e-9 && (3.0..=5.0).contains(&x)
+            || (y - 5.0).abs() < 1e-9 && (3.0..=5.0).contains(&x)
+            || (x - 7.0).abs() < 1e-9 && (6.0..=8.0).contains(&y)
+            || (x - 1.0).abs() < 1e-9 && (6.0..=8.0).contains(&y);
+        let on_ellipse = centres.iter().any(|c| (((x - c.x) / 2.0).powi(2) + (y - c.y).powi(2) - 1.0).abs() < 1e-6);
+        assert!(on_edge || on_ellipse, "{:?} is off the rounded rectangle", v.p);
+    }
+    let svg = format!("{SVG_PAGE}<g transform=\"scale(2,1)\"><rect x=\"1\" y=\"1\" width=\"3\" height=\"4\" rx=\"1\"/></g></svg>");
+    let (p, mids) = polyline_and_arc_midpoints(&read(svg.as_bytes(), "stretched.svg").unwrap());
+    assert!(p.closed && mids.is_empty() && p.vertices.len() > 8, "{p:?}");
+    // A tiny scale is still non-uniform: the test is relative to the scale, not absolute.
+    let svg = format!("{SVG_PAGE}<g transform=\"scale(0.00001,0.00002)\"><rect width=\"200000\" height=\"100000\" rx=\"20000\"/></g></svg>");
+    let (p, mids) = polyline_and_arc_midpoints(&read(svg.as_bytes(), "tiny.svg").unwrap());
+    assert!(p.closed && mids.is_empty() && p.vertices.len() > 8, "{p:?}");
+}
+
+/// A closed path's last point is dropped only when it repeats the first to within rounding,
+/// however far from the origin the drawing is.
+#[test]
+fn svg_closed_path_keeps_a_distinct_last_point_far_from_the_origin() {
+    let svg = format!("{SVG_PAGE}<path d=\"M1000000000 0 L1000000010 0 L1000000010 10 L1000000000.5 0 Z\"/></svg>");
+    let d = read(svg.as_bytes(), "far.svg").unwrap();
+    let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
+    assert!(p.closed && p.vertices.len() == 4, "{p:?}");
+}
+
 #[test]
 fn svg_path_lines_curves_and_arcs() {
     let svg = format!("{SVG_PAGE}<path d=\"M1 1 L5 1 C6 1 7 2 8 3\"/><path d=\"M0 0 A5 5 0 0 1 10 0 Z\"/></svg>");
@@ -2321,9 +2435,9 @@ fn svg_hidden_and_defs_subtrees_are_skipped() {
     assert_eq!(kinds(&d), ["Polyline"]);
 }
 
-/// #627: what the reader doesn't read yet (text, raster images, `<use>` copies, style sheets,
-/// rounded corners) is named in a read note instead of being left out silently; what isn't drawn
-/// (in `<defs>` or hidden) isn't counted.
+/// #627: what the reader doesn't read yet (text, raster images, `<use>` copies, style sheets) is
+/// named in a read note instead of being left out silently; what isn't drawn (in `<defs>` or
+/// hidden) isn't counted. A rectangle's rounded corners are read, so they aren't named.
 #[test]
 fn svg_left_out_content_is_noted() {
     let svg = format!(
@@ -2337,7 +2451,7 @@ fn svg_left_out_content_is_noted() {
     assert_eq!(
         d.read_notes,
         ["SVG: CADCraft doesn't read these yet, so they were left out: 2 texts, 1 raster image, 2 `<use>` copies, \
-          1 `<style>` sheet (its class rules aren't applied), 1 rectangle with rounded corners (drawn square)"]
+          1 `<style>` sheet (its class rules aren't applied)"]
     );
     let plain = read(format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/></svg>").as_bytes(), "a.svg").unwrap();
     assert!(plain.read_notes.is_empty(), "{:?}", plain.read_notes);
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-26 · **Change:** minor (SVG import reads rounded rectangle corners: FF14 narrowed) · **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
@@ -86,7 +86,7 @@ Detail: [file-format-parity.md](file-format-parity.md).
 | FF11 | DWF/DWFx, DGN, WMF, EPS, ACIS/STL and other exchange formats | Low-medium | 50–100 |
 | FF12 | DWG in the web build | Medium | 6–10 |
 | FF13 | R12 binary DXF, pre-R13 DWG | Low | 10–20 |
-| FF14 | SVG import leftovers (shapes, paths, groups and Inkscape layers landed, #636): no CSS classes (`<style>`), `use` references, fills and gradients, text or embedded images; rounded rectangle corners (`rx`/`ry`) are ignored. Opening such a file names what was left out (#627) | Low-medium | 2–4 |
+| FF14 | SVG import leftovers (shapes, paths, groups and Inkscape layers landed, #636; rounded rectangle corners (`rx`/`ry`) are read as arcs): no CSS classes (`<style>`), `use` references, fills and gradients, text or embedded images. Opening such a file names what was left out (#627) | Low-medium | 2–3 |
 
 ## Hardware
 
@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | SVG import reads rounded rectangle corners (`rx`/`ry`, SVG 1.1 §9.2): circular corners become quarter-circle bulge arcs on one closed polyline, elliptical ones (or corners under a non-uniform transform) are traced on their ellipse, and the read note no longer names them: FF14 narrowed (2–4 → 2–3 h) |
 | 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 |
