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
@@ -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> {@@ -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);@@ -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 |