DXF export: ellipses and unevenly scaled circles are written as ELLIPSE

A closed path of four cubics that is the four-cubic circle under an
affine map (an ellipse, or a circle scaled out of proportion) is written
as a native ELLIPSE in R13 and later, with its center, major axis and
minor to major ratio. The check maps the path back onto the unit circle
and runs the existing circle recognition on it. R12 keeps the existing
fallback, and ellipses with a ratio under 1e-6 stay splines. The ratio
and end parameter are written in full so that a thin ellipse keeps its
size and a whole turn reads back closed.

Part of #1299.
This commit is contained in:
dotCooCoo
2026-10-31 19:43:15 +07:00
committed by bflatastic
parent 06e8d711ae
commit d0a903d5d7
4 changed files with 215 additions and 7 deletions
+90 -1
View File
@@ -10,7 +10,7 @@ use kurbo::PathEl;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
use crate::aci::nearest_aci;
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
@@ -184,6 +184,46 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
Some(CircularSub { centre, radius, samples })
}
/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle
/// scaled out of proportion. `u` and `v` are the conjugate semi-diameters through its first two
/// anchors.
struct EllipticalSub {
centre: Point,
u: Vec2,
v: Vec2,
}
/// Fit a closed subpath of four cubics to one ellipse. Its anchors give the center and two
/// conjugate semi-diameters; the subpath mapped by their inverse must pass [`circular_subpath`] as
/// the unit circle, so the same tolerance and turn checks apply.
fn elliptical_subpath(sub: &Sub) -> Option<EllipticalSub> {
if !sub.closed || sub.segs.len() != 4 {
return None;
}
let (a0, a1, a2) = (sub.start, sub.segs.first()?.end(), sub.segs.get(1)?.end());
let centre = a0.midpoint(a2);
let (u, v) = (a0 - centre, a1 - centre);
let det = u.cross(v);
let scale2 = u.hypot2().max(v.hypot2());
if !det.is_finite() || !scale2.is_finite() || det.abs() <= scale2 * 1e-9 {
return None;
}
let unit = |p: Point| {
let d = p - centre;
Point::new(d.cross(v) / det, u.cross(d) / det)
};
let mut segs = Vec::with_capacity(4);
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
segs.push(Seg::Cubic(unit(a), unit(b), unit(to)));
}
let circle = circular_subpath(&Sub { start: unit(sub.start), segs, closed: true })?;
if circle.centre.to_vec2().hypot() > 5e-4 || (circle.radius - 1.0).abs() > 5e-4 {
return None;
}
Some(EllipticalSub { centre, u, v })
}
fn angle_deg(p: Point, centre: Point) -> f64 {
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
}
@@ -764,6 +804,8 @@ impl<'a> Scene<'a> {
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
// its width. Non-circular curves continue through the existing fallback.
} else if v.handles() && self.elliptical(&sub, style) {
// R13 and later write an ellipse as a native ELLIPSE.
} else if v.handles() {
self.spline(&sub, style);
} else {
@@ -901,6 +943,53 @@ impl<'a> Scene<'a> {
true
}
/// Write a recognized elliptical subpath as a native DXF ELLIPSE (R13 and later): its center,
/// the end of its major axis relative to the center, and the minor to major ratio. Returns
/// false when the curve is not an ellipse, leaving the caller to write it as a spline.
fn elliptical(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
if !sub.closed || last.distance(sub.start) > 1e-9 {
return false;
}
let Some(ellipse) = elliptical_subpath(sub) else { return false };
let centre = self.to_dxf * ellipse.centre;
let u = self.to_dxf * (ellipse.centre + ellipse.u) - centre;
let v = self.to_dxf * (ellipse.centre + ellipse.v) - centre;
// The semi-axes are the square roots of the eigenvalues of [[a, b], [b, d]] = u u^T + v v^T.
let (a, b, d) = (u.x * u.x + v.x * v.x, u.x * u.y + v.x * v.y, u.y * u.y + v.y * v.y);
let mean = (a + d) / 2.0;
let spread = ((a - d) / 2.0).hypot(b);
let (major, minor) = ((mean + spread).sqrt(), (mean - spread).max(0.0).sqrt());
let angle = 0.5 * (2.0 * b).atan2(a - d);
let axis = Vec2::new(major * angle.cos(), major * angle.sin());
let ratio = (minor / major).min(1.0);
// A thinner ellipse stays a spline: DXF readers such as ezdxf need a ratio of 1e-6 or more.
if !centre.x.is_finite() || !centre.y.is_finite() || !axis.x.is_finite() || !axis.y.is_finite() || !ratio.is_finite() || ratio < 1e-6 {
return false;
}
// The drawing extents take the ellipse's bounding box: half-widths hypot(u.x, v.x) and
// hypot(u.y, v.y) about the center.
let (c, eu, ev) = (ellipse.centre, ellipse.u, ellipse.v);
let (hx, hy) = (eu.x.hypot(ev.x), eu.y.hypot(ev.y));
for p in [Point::new(c.x - hx, c.y), Point::new(c.x + hx, c.y), Point::new(c.x, c.y - hy), Point::new(c.x, c.y + hy)] {
self.pt(p);
}
let layer = self.layer.clone();
let h = self.handle();
self.e.entity("ELLIPSE", h, fixed::MODEL_RECORD, &layer, style, "AcDbEllipse");
self.e.xyz(10, centre.x, centre.y);
self.e.xyz(11, axis.x, axis.y);
self.e.normal();
// The ratio and the end parameter are written in full: rounded to six decimals, a thin
// ellipse's minor axis would change size, and a whole turn would read back as an open curve.
self.e.raw(40, &ratio.to_string());
self.e.num(41, 0.0);
self.e.raw(42, &std::f64::consts::TAU.to_string());
true
}
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
/// passes through every anchor (straight segments become straight cubics).
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
+123 -4
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes};
use crate::*;
@@ -208,10 +208,129 @@ fn circles_and_circular_arcs_are_native_dxf_entities() {
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
}
}
// An actual ellipse is not close enough to a circle and remains a spline.
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
/// The path `p` with `xf` applied.
fn transformed(p: &PathData, xf: Affine) -> PathData {
PathData::from_bezpath(&(xf * p.to_bezpath()))
}
fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() {
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
let d = doc_with(|d| {
let stroke = || Paint::solid(Color::BLACK);
vec![
// 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5.
path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0),
// A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3.
path(
d,
transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))),
Paint::None,
stroke(),
1.0,
),
// The first ellipse turned 30° about its center, filled: the outline is the ellipse too.
path(
d,
transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))),
Paint::solid(Color::BLACK),
Paint::None,
0.0,
),
]
});
for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) {
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}");
let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42));
let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]);
assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}");
assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn");
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]);
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]);
// Turned 30° counterclockwise on screen, so clockwise with DXF's y up.
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]);
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]);
assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}");
}
// R12 has no ELLIPSE: the curves are flattened polylines.
let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap());
assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}");
// A thin ellipse keeps its ratio in full (six decimals would write 0.000002).
let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
let e = entities(&export(&thin, &opts(&thin)).unwrap());
assert_eq!(kind(&e[0]), "ELLIPSE");
assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]);
}
#[test]
fn exported_ellipses_read_back_as_the_same_ellipse() {
let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0));
let d = doc_with(|d| {
vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), ["ELLIPSE"]);
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() };
let back = import(&out.bytes, &o).unwrap().document;
let mut paths = vec![];
back.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
paths.push(path.to_bezpath());
}
});
assert_eq!(paths.len(), 1);
assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed");
// Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered.
let bp = &paths[0];
let found = bp.bounding_box().center();
let inverse = xf.inverse();
let original = Point::new(60.0, 40.0);
for seg in bp.segments() {
for i in 0..=16 {
let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2();
let q = inverse * p;
let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0);
assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})");
}
}
}
#[test]
fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() {
// A rounded square: four cubics through the ellipse's anchors with longer handles.
let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8);
let mut squircle = vectorcraft_geom::BezPath::new();
squircle.move_to(Point::new(c.x + r, c.y));
squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y));
squircle.close_path();
// An egg: one half wider than the other.
let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath();
let mut els: Vec<kurbo::PathEl> = egg.elements().to_vec();
if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) {
a.x -= 10.0;
b.x -= 10.0;
}
let d = doc_with(|d| {
vec![
path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0),
// An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6).
path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]);
}
#[test]
+1 -1
View File
@@ -98,7 +98,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). CNC preset and VCarve QA remain open |
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; closed four-cubic ellipses, and circles scaled out of proportion, export as ELLIPSE in R13–2018; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). CNC preset and VCarve QA remain open |
| DWG | open, export | no | no | no open spec; DXF instead |
| EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) |
| PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) |
+1 -1
View File
@@ -254,7 +254,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open.
- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`.
- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`.
- **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272).
- **Done:** Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection and previews that result, as the [manual-simplify page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) describes; simplifying only a Direct Selection part of a path is done too (G1).