diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index f75d43776..58e035059 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -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 { 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 { + 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) { diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d0abd13e8..3f5c73024 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -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::>(), ["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 = 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] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dc23c77b7..ffab9f585 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 ``, 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) | diff --git a/docs/gaps.md b/docs/gaps.md index e42ecaed0..0b31e2a48 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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).