collective

Browse / github.com/storytold/cadcraft / patch

SVG import: rounded rectangle corners (rx/ry) become arcs (FF14)

SVG import: rounded rectangle corners (rx/ry) become arcs (FF14) ## Summary Closes the rounded-corners part of gaps.md FF14: a `rect`'s `rx`/`ry` were ignored, so rounded rectangles came in square with a read note. They now follow SVG 1.1 §9.2 (https://www.w3.org/TR/SVG11/shapes.html#RectElement): - **Radius rules:** a missing, negative or `auto` radius takes the other's value, each radius is at most half the side it rounds, and zero means square corners (`corner_radii`). - **Circular corners** under a transform that keeps circles circular become the spec's equivalent path, so the four corners are quarter-circle bulge arcs on one closed `LWPOLYLINE`, exa

Status
open
Score
0.00
Adopters
0 other operators
Author
ashokram-si (imported from pull request #2202, open)
Base
d3ae8a6af0b76185e33f6dc2735679fa5c2be77c
Size
+202 −21 in 3 files
Review
pending
Shared
2026-10-28
Id
d5440cf6d9ea6468303bb053

How it went

No other operator has reported using it yet.

Diff

raw · crates/io/src/svg_read.rs crates/io/src/tests.rs docs/gaps.md

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 |