From 1246c62095ae2139ed8cdbe071722a64348ea67f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 15:35:09 -0800 Subject: [PATCH] DXF: paths of lines and circular arcs export as polylines with bulges A path made of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) was written as one SPLINE, or as a flattened polyline in R12. It is now one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed. Each run of cubics on one circle becomes one arc. A path with any other curve stays a SPLINE, and whole circles, arcs and ellipses keep their CIRCLE, ARC and ELLIPSE entities. - The circle test's tolerance is 5e-4 of the smaller of the radius and the curve's extent, so a shallow curve that is not circular is no longer written as an ARC. - The drawing extents take each ARC's and each bulge's furthest points. Part of #1299 --- crates/cad/src/scene.rs | 121 +++++++++++++++++++--- crates/cad/src/tests.rs | 208 +++++++++++++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 311 insertions(+), 19 deletions(-) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index d7e20654a..72fa4f7b3 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1,7 +1,7 @@ //! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint //! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes -//! polylines and cubic splines with a lineweight and dashes, or filled outlines where the look -//! needs them. +//! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or +//! filled outlines where the look needs them. use std::collections::HashMap; use std::io::Cursor; @@ -66,6 +66,8 @@ struct CircularSub { centre: Point, radius: f64, samples: Vec, + /// The signed angle (radians) the curve turns through about `centre` in document space. + turn: f64, } fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point { @@ -122,7 +124,12 @@ fn circular_subpath(sub: &Sub) -> Option { if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 { return None; } - let tolerance = (radius * 5e-4).max(1e-6); + // The tolerance is a fraction of the smaller of the radius and the curve's extent, so the large + // radius of a shallow curve does not loosen it. + let (lo, hi) = samples + .iter() + .fold((samples[0], samples[0]), |(lo, hi), p| (Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y)))); + let tolerance = (radius.min(lo.distance(hi)) * 5e-4).max(1e-6); if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) { return None; } @@ -181,7 +188,76 @@ fn circular_subpath(sub: &Sub) -> Option { } else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 { return None; } - Some(CircularSub { centre, radius, samples }) + Some(CircularSub { centre, radius, samples, turn: total_turn }) +} + +/// The points of the circle about `centre` of `radius` furthest left, right, up and down that lie on +/// its arc from `from` through `turn` radians, for the drawing extents. +fn arc_extrema(centre: Point, radius: f64, from: Point, turn: f64) -> Vec { + let start = (from.y - centre.y).atan2(from.x - centre.x); + (0..4) + .map(|k| f64::from(k) * std::f64::consts::FRAC_PI_2) + .filter(|a| ((a - start) * turn.signum()).rem_euclid(std::f64::consts::TAU) <= turn.abs()) + .map(|a| Point::new(centre.x + radius * a.cos(), centre.y + radius * a.sin())) + .collect() +} + +/// The most cubics one bulge arc is fitted to; the cubics after them start the next arc. +const MAX_ARC_RUN: usize = 64; + +/// A subpath of straight segments and circular arcs as polyline vertices, in document space. +struct BulgedSub { + /// The anchors, without a closed subpath's repeated start. + pts: Vec, + /// For each anchor, the angle (radians, document space) the segment that starts there turns + /// through: 0 for a straight segment. + turns: Vec, + /// The arcs' extreme points ([`arc_extrema`]), for the drawing extents. + extrema: Vec, +} + +/// Fit a subpath with curves to straight segments and circular arcs: each run of cubics on one +/// circle, turning one way and less than a whole turn, becomes one arc ([`circular_subpath`]'s +/// tolerance). `None` when the subpath has no curves or a curve that is not a circular arc. +fn bulged_subpath(sub: &Sub) -> Option { + if !sub.segs.iter().any(|s| matches!(s, Seg::Cubic(..))) { + return None; + } + let mut pts = vec![sub.start]; + let mut turns = vec![]; + let mut extrema = vec![]; + let mut from = sub.start; + let mut i = 0; + while let Some(seg) = sub.segs.get(i) { + if let Seg::Line(p) = *seg { + turns.push(0.0); + pts.push(p); + from = p; + i += 1; + continue; + } + let mut best = None; + let mut j = i; + while j < sub.segs.len() && j - i < MAX_ARC_RUN && matches!(sub.segs[j], Seg::Cubic(..)) { + let run = Sub { start: from, segs: sub.segs[i..=j].to_vec(), closed: false }; + let Some(arc) = circular_subpath(&run) else { break }; + best = Some((j, arc)); + j += 1; + } + let (j, arc) = best?; + turns.push(arc.turn); + extrema.extend(arc_extrema(arc.centre, arc.radius, from, arc.turn)); + from = sub.segs[j].end(); + pts.push(from); + i = j + 1; + } + if sub.closed && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) { + pts.pop(); + } else { + // The segment from the last anchor: the straight closing edge, or none for an open subpath. + turns.push(0.0); + } + (pts.len() >= 2).then_some(BulgedSub { pts, turns, extrema }) } /// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle @@ -846,7 +922,9 @@ impl<'a> Scene<'a> { } /// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for - /// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones. + /// other straight subpaths, circles, arcs and ellipses where the curves are one, polylines + /// with bulges for subpaths of straight segments and circular arcs, and cubic splines for + /// other curved ones (flattened polylines in R12). /// `width`: the stroke weight, which DXF before 2000 gives polylines as a width. fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) { let v = self.o.version; @@ -863,7 +941,7 @@ impl<'a> Scene<'a> { // A LINE has no width, so a stroke before 2000 keeps its polyline width. match (sub.closed, width, pts.as_slice()) { (false, None, &[a, b]) => self.line(a, b, style), - _ => self.polyline(&pts, sub.closed, style, width), + _ => self.polyline(&pts, &[], sub.closed, style, width), } } else if (v.handles() || width.is_none()) && self.circular(&sub, style) { // Native circles/arcs are preferred by CAD/CAM software. In R12, however, a @@ -871,6 +949,12 @@ impl<'a> Scene<'a> { // 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 let Some(b) = bulged_subpath(&sub) { + // Straight segments and circular arcs: one polyline whose bulges draw the arcs. + b.extrema.iter().for_each(|p| _ = self.pt(*p)); + let flip = if self.to_dxf.determinant() < 0.0 { -1.0 } else { 1.0 }; + let bulges: Vec = b.turns.iter().map(|t| flip * (t / 4.0).tan()).collect(); + self.polyline(&b.pts, &bulges, sub.closed, style, width); } else if v.handles() { self.spline(&sub, style); } else { @@ -883,7 +967,7 @@ impl<'a> Scene<'a> { } } for pts in polygons(&path) { - self.polyline(&pts, sub.closed, style, width); + self.polyline(&pts, &[], sub.closed, style, width); } } } @@ -900,8 +984,9 @@ impl<'a> Scene<'a> { } /// A polyline through `pts` (document space): lightweight from R14, else a polyline with a - /// vertex entity per point. - fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option) { + /// vertex entity per point. `bulges`: the bulge (group 42) of the segment that starts at each + /// point, in drawing space; empty for straight segments only. + fn polyline(&mut self, pts: &[Point], bulges: &[f64], closed: bool, style: &EntityStyle, width: Option) { if pts.len() < 2 { return; } @@ -917,8 +1002,11 @@ impl<'a> Scene<'a> { if let Some(w) = width { self.e.num(43, w); } - for p in &pts { + for (i, p) in pts.iter().enumerate() { self.e.xy(10, p.x, p.y); + if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) { + self.e.num(42, b); + } } return; } @@ -930,13 +1018,16 @@ impl<'a> Scene<'a> { self.e.num(40, w); self.e.num(41, w); } - for p in &pts { + for (i, p) in pts.iter().enumerate() { let vh = self.handle(); self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex"); if self.o.version.handles() { self.e.raw(100, "AcDb2dVertex"); } self.e.xyz(10, p.x, p.y); + if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) { + self.e.num(42, b); + } } let end = self.handle(); self.e.entity("SEQEND", end, h, &layer, style, ""); @@ -960,8 +1051,8 @@ impl<'a> Scene<'a> { return false; } - // Count sampled points in the drawing extents. For a full circle include all cardinal - // points exactly; for an arc, the dense samples conservatively cover its visible span. + // Count the curve's extreme points in the drawing extents: all four cardinal points of a + // full circle; an arc's ends and the cardinal points on it. if sub.closed { let r = circle.radius; for p in [ @@ -973,8 +1064,8 @@ impl<'a> Scene<'a> { self.pt(p); } } else { - for p in &circle.samples { - self.pt(*p); + for p in [sub.start, last].into_iter().chain(arc_extrema(circle.centre, circle.radius, sub.start, circle.turn)) { + self.pt(p); } } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index e4984c333..d99858511 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -275,6 +275,197 @@ fn circles_and_circular_arcs_are_native_dxf_entities() { } } +/// A cubic arc about `c` of radius `r` from `deg0` to `deg1` (degrees, y down), from the current +/// point, which is the arc's start. +fn arc_cubic(b: &mut vectorcraft_geom::BezPath, c: Point, r: f64, deg0: f64, deg1: f64) { + let (a0, a1) = (deg0.to_radians(), deg1.to_radians()); + let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan() * r; + let (p0, p3) = (Point::new(c.x + r * a0.cos(), c.y + r * a0.sin()), Point::new(c.x + r * a1.cos(), c.y + r * a1.sin())); + b.curve_to(Point::new(p0.x - k * a0.sin(), p0.y + k * a0.cos()), Point::new(p3.x + k * a1.sin(), p3.y - k * a1.cos()), p3); +} + +/// A slot (two lines, two half circles) about y = 50, a D shape (a half circle closed by its +/// diameter) and a rounded rectangle with 10 pt corners. +fn lines_and_arcs() -> [PathData; 3] { + let mut slot = vectorcraft_geom::BezPath::new(); + slot.move_to((60.0, 30.0)); + slot.line_to((140.0, 30.0)); + arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, -90.0, 0.0); + arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, 0.0, 90.0); + slot.line_to((60.0, 70.0)); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0); + slot.close_path(); + let mut d = vectorcraft_geom::BezPath::new(); + d.move_to((150.0, 20.0)); + arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, -90.0, 0.0); + arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, 0.0, 90.0); + d.close_path(); + [PathData::from_bezpath(&slot), PathData::from_bezpath(&d), shapes::rounded_rectangle(Rect::new(20.0, 20.0, 100.0, 80.0), 10.0)] +} + +/// A polyline entity's vertices as (x, y, bulge): an LWPOLYLINE's, or those of the VERTEX +/// entities that follow a POLYLINE at `e[i]`. +fn vertices(e: &[Pairs], i: usize) -> Vec<(f64, f64, f64)> { + let mut out = vec![]; + let mut read = |o: &Pairs| { + for (code, value) in o { + let v: f64 = value.trim().parse().unwrap_or(0.0); + match code { + 10 => out.push((v, 0.0, 0.0)), + 20 => out.last_mut().expect("x before y").1 = v, + 42 => out.last_mut().expect("a vertex before its bulge").2 = v, + _ => {} + } + } + }; + if kind(&e[i]) == "LWPOLYLINE" { + read(&e[i]); + } else { + e[i + 1..].iter().take_while(|o| kind(o) == "VERTEX").for_each(&mut read); + } + out +} + +#[test] +fn paths_of_lines_and_circular_arcs_are_polylines_with_bulges() { + let [slot, d_shape, rounded] = lines_and_arcs(); + let d = doc_with(|d| { + let black = || Paint::solid(Color::BLACK); + let mut open = vectorcraft_geom::BezPath::new(); + open.move_to((10.0, 90.0)); + open.line_to((30.0, 90.0)); + arc_cubic(&mut open, Point::new(30.0, 80.0), 10.0, 90.0, 0.0); + vec![ + path(d, slot, Paint::None, black(), 1.0), + path(d, d_shape, Paint::None, black(), 1.0), + path(d, rounded, Paint::None, black(), 1.0), + path(d, PathData::from_bezpath(&open), Paint::None, black(), 1.0), + ] + }); + let b = (std::f64::consts::PI / 8.0).tan(); + for version in DxfVersion::ALL { + let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap(); + let e = entities(&out); + let polys: Vec = (0..e.len()).filter(|i| matches!(kind(&e[*i]), "LWPOLYLINE" | "POLYLINE")).collect(); + let k = kinds(&out); + assert_eq!(polys.len(), 4, "{version:?}: {k:?}"); + assert!(!k.iter().any(|k| matches!(k.as_str(), "SPLINE" | "ARC" | "CIRCLE")), "{version:?}: {k:?}"); + let want = if version.lwpolyline() { "LWPOLYLINE" } else { "POLYLINE" }; + assert!(polys.iter().all(|i| kind(&e[*i]) == want), "{version:?}: {k:?}"); + let flags: Vec = polys.iter().map(|i| get(&e[*i], 70).unwrap().parse::().unwrap() & 1).collect(); + assert_eq!(flags, [1, 1, 1, 0], "{version:?}: closed, closed, closed, open"); + // The slot's half circles turn clockwise with y up: a bulge of -1 after each long side. + assert_eq!(vertices(&e, polys[0]), [(60.0, 70.0, 0.0), (140.0, 70.0, -1.0), (140.0, 30.0, 0.0), (60.0, 30.0, -1.0)], "{version:?}"); + // The D shape: the half circle, then the straight closing edge. + assert_eq!(vertices(&e, polys[1]), [(150.0, 80.0, -1.0), (150.0, 20.0, 0.0)], "{version:?}"); + // The rounded rectangle: eight anchors, a quarter circle (bulge tan 22.5°) at each corner. + let r = vertices(&e, polys[2]); + assert_eq!(r.len(), 8, "{version:?}: {r:?}"); + let bulges: Vec = r.iter().map(|v| v.2).filter(|b| *b != 0.0).collect(); + assert!(close_to(&bulges, &[-b; 4], 1e-6), "{version:?}: {r:?}"); + // An open line then a quarter circle turning counterclockwise with y up. + let o = vertices(&e, polys[3]); + assert!( + close_to(&o.iter().flat_map(|v| [v.0, v.1, v.2]).collect::>(), &[10.0, 10.0, 0.0, 30.0, 10.0, b, 40.0, 20.0, 0.0], 1e-6), + "{version:?}: {o:?}" + ); + } +} + +#[test] +fn polylines_with_bulges_read_back_as_the_same_outline() { + for (i, shape) in lines_and_arcs().into_iter().enumerate() { + let original = shape.to_bezpath(); + let d = doc_with(|d| vec![path(d, shape, Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), ["LWPOLYLINE"], "shape {i}"); + 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, "shape {i}"); + assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "shape {i} reads back closed"); + let (a, b) = (original.bounding_box(), paths[0].bounding_box()); + assert!(close_to(&[a.width(), a.height()], &[b.width(), b.height()], 1e-2), "shape {i}: {a:?} against {b:?}"); + // Every point read back lies on the original outline, once both are centered. + let shift = a.center() - b.center(); + let outline: Vec = original.segments().flat_map(|s| (0..=64).map(move |t| s.eval(f64::from(t) / 64.0))).collect(); + for seg in paths[0].segments() { + for t in 0..=16 { + let p = seg.eval(f64::from(t) / 16.0) + shift; + let near = outline + .windows(2) + .map(|w| { + let (u, v) = (w[1] - w[0], p - w[0]); + let s = if u.hypot2() > 0.0 { (v.dot(u) / u.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + (v - u * s).hypot() + }) + .fold(f64::INFINITY, f64::min); + assert!(near < 1e-2, "shape {i}: {p:?} is {near} pt off the outline"); + } + } + } +} + +#[test] +fn a_path_with_a_curve_that_is_not_a_circular_arc_stays_a_spline() { + // The slot with one end drawn by a longer-handled curve. + let mut slot = vectorcraft_geom::BezPath::new(); + slot.move_to((60.0, 30.0)); + slot.line_to((140.0, 30.0)); + slot.curve_to((165.0, 30.0), (165.0, 70.0), (140.0, 70.0)); + slot.line_to((60.0, 70.0)); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0); + slot.close_path(); + let d = doc_with(|d| { + vec![ + path(d, PathData::from_bezpath(&slot), Paint::None, Paint::solid(Color::BLACK), 1.0), + path(d, shapes::ellipse(Rect::new(150.0, 20.0, 190.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0), + ] + }); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "CIRCLE"]); +} + +#[test] +fn drawing_extents_reach_the_extreme_points_of_arcs() { + // An arc about (100, 50) of radius 40 from -60° to 30° passes x = 140 at 0°, between the points + // along it that recognition samples. + let arc = |closed: bool| { + let mut b = vectorcraft_geom::BezPath::new(); + let start = Point::new(100.0 + 40.0 * 60f64.to_radians().cos(), 50.0 - 40.0 * 60f64.to_radians().sin()); + b.move_to(start); + arc_cubic(&mut b, Point::new(100.0, 50.0), 40.0, -60.0, 30.0); + if closed { + b.close_path(); + } + PathData::from_bezpath(&b) + }; + for (closed, entity) in [(false, "ARC"), (true, "LWPOLYLINE")] { + let d = doc_with(|d| vec![path(d, arc(closed), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), [entity]); + let x: f64 = header(&pairs(&out.bytes), "$EXTMAX").unwrap().trim().parse().unwrap(); + assert!((x - 140.0).abs() < 1e-6, "{entity}: $EXTMAX x {x}"); + } +} + +#[test] +fn a_shallow_curve_that_is_not_circular_is_not_an_arc() { + // 100 pt across and 1.5 pt deep: the circle through its ends and middle has a radius of about + // 834 pt, and the curve is 0.12 pt off it a quarter of the way along. + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to((0.0, 50.0)); + b.curve_to((20.0, 48.0), (80.0, 48.0), (100.0, 50.0)); + let d = doc_with(|d| vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE"]); +} + /// The path `p` with `xf` applied. fn transformed(p: &PathData, xf: Affine) -> PathData { PathData::from_bezpath(&(xf * p.to_bezpath())) @@ -391,11 +582,12 @@ fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() { 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). + // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). Its + // quarters fit shallow circular arcs within the tolerance, so it is a polyline with bulges. 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"]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "LWPOLYLINE"]); } #[test] @@ -462,8 +654,12 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() { vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)] }); + // The three quarters turn counterclockwise with y up (bulge tan 67.5°), then the chord closes it. let modern = export(&d, &opts(&d)).unwrap(); - assert_eq!(kinds(&modern), ["SPLINE"]); + assert_eq!(kinds(&modern), ["LWPOLYLINE"]); + let v = vertices(&entities(&modern), 0); + let flat: Vec = v.iter().flat_map(|v| [v.0, v.1, v.2]).collect(); + assert!(close_to(&flat, &[80.0, 50.0, 67.5f64.to_radians().tan(), 50.0, 20.0, 0.0], 1e-6), "{v:?}"); let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); assert!(kinds(&r12).iter().any(|k| k == "POLYLINE")); assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE")); @@ -497,9 +693,13 @@ fn a_closed_sliver_along_a_circle_is_not_a_circle() { vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)] }); + // Two arcs, out along the circle and back: one polyline with a bulge each way. let modern = export(&d, &opts(&d)).unwrap(); assert_ne!(kinds(&modern), ["CIRCLE"]); - assert_eq!(kinds(&modern), ["SPLINE"]); + assert_eq!(kinds(&modern), ["LWPOLYLINE"]); + let bulges: Vec = vertices(&entities(&modern), 0).iter().map(|v| v.2).collect(); + let b = 10f64.to_radians().tan(); + assert!(close_to(&bulges, &[-b, b], 1e-6) || close_to(&bulges, &[b, -b], 1e-6), "{bulges:?}"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2bbeeb404..20dae8ba9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1168,6 +1168,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); CNC preset and VCarve QA remain pending. + - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - Placed documents' bitmaps on screen: when a bitmap made in the background is ready, the desktop app renders the canvas again only where the objects that show its file are, when the frame shown was rendered on the UI thread and that part is at most half the canvas. Bitmaps drawn or made since a frame began are kept for that frame past the 512 MiB budget, up to 2 GiB, so a view showing more than the budget does not make them again in every frame. A bitmap that would take them past 2 GiB is not kept, and its file draws exactly at that zoom. A bitmap that can't be made (the file can't be read and there is no preview) shows the light placeholder and is not asked for again. Each ready bitmap used to render the whole canvas again, and bitmaps past the budget and bitmaps that could not be made were asked for again in every frame (`crates/render/src/tests/placed_document.rs`).