Merge pull request #4840 from dotCooCoo/fix/dxf-dxf-alter-paths-if-necessary

DXF export: Alter Paths for Appearance outlines only strokes a CAD line can't draw
This commit is contained in:
BFlat
2026-10-31 20:10:21 +07:00
committed by GitHub
6 changed files with 138 additions and 13 deletions
+5 -2
View File
@@ -233,7 +233,8 @@ impl RasterFormat {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Preserve {
/// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or
/// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush
/// outside, width profiles, arrowheads, fitted dashes, dashes offset into their pattern)
/// become their filled outlines; brush
/// strokes become their brush art.
#[default]
Appearance,
@@ -277,7 +278,9 @@ pub struct DxfOptions {
pub colors: ColorDepth,
pub raster: RasterFormat,
pub preserve: Preserve,
/// Alter Paths for Appearance: every stroke is written as the filled area it paints.
/// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`]
/// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have
/// no lineweights) is written as the filled area it paints.
pub alter_paths: bool,
/// Type is written as glyph outlines (always with [`Preserve::Appearance`]).
pub outline_text: bool,
+34 -4
View File
@@ -273,6 +273,17 @@ const MAX_ENTITIES: u32 = 10_000_000;
const MAX_COORD: f64 = 1e9;
const MAX_POINTS: usize = 4_000_000;
/// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of
/// odd length repeats twice in its linetype.
fn starts_with_pattern(d: &Dash) -> bool {
if !d.is_dashed() || !d.offset.is_finite() {
return true;
}
let period = d.pattern.iter().sum::<f64>() * if d.pattern.len() % 2 == 1 { 2.0 } else { 1.0 };
let phase = d.offset.rem_euclid(period);
phase.min(period - phase) <= period * 1e-9
}
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
// Far outside any drawing, a curve flattens to millions of points at this tolerance.
@@ -652,8 +663,7 @@ impl<'a> Scene<'a> {
/// The DXF lineweight nearest to `width` points (scaled with the drawing when asked).
fn lineweight(&mut self, width: f64) -> i32 {
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
let hundredths = width * 2540.0 / 72.0 * scale;
let hundredths = self.hundredths(width);
let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1];
if hundredths > f64::from(max) + 5.0 {
self.warn(WIDE_LINES);
@@ -661,6 +671,21 @@ impl<'a> Scene<'a> {
LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max)
}
/// `width` points in hundredths of a millimetre, the unit of DXF lineweights (scaled with
/// the drawing when asked).
fn hundredths(&self, width: f64) -> f64 {
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
width * 2540.0 / 72.0 * scale
}
/// Can a CAD line draw a plain stroke `width` points wide in this version? R12 polylines
/// carry their width, R13 and R14 have no lineweights, and from 2000 a lineweight holds up
/// to 2.11 mm, to the hundredth.
fn line_keeps_width(&self, width: f64) -> bool {
let v = self.o.version;
!v.handles() || (v.r2000() && self.hundredths(width).round() <= f64::from(LINEWEIGHTS[LINEWEIGHTS.len() - 1]))
}
/// A linetype for dash pattern `d` (one per distinct pattern).
fn linetype(&mut self, d: &Dash) -> String {
let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect();
@@ -713,7 +738,11 @@ impl<'a> Scene<'a> {
}
let Some(rgb) = self.paint_rgb(&st.paint) else { return };
let plain = stroke::is_plain(st);
if self.o.alter_paths || (appearance && !plain) {
// A linetype has no phase: dashes that start part way into their pattern need outlines.
let lined = plain && st.dash.as_ref().is_none_or(starts_with_pattern);
// Alter Paths for Appearance outlines a stroke only when a line can't draw it.
let altered = self.o.alter_paths && !(lined && self.line_keeps_width(st.width));
if altered || (appearance && !lined) {
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st);
let style = self.style(rgb, st.opacity, None, None);
self.area(&region.to_bezpath(), FillRule::NonZero, &style, true);
@@ -843,7 +872,8 @@ impl<'a> Scene<'a> {
let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect();
let layer = self.layer.clone();
let h = self.handle();
let flags = i64::from(closed);
// Bit 128: a linetype runs on through the vertices instead of restarting at each one.
let flags = i64::from(closed) | if style.linetype.is_some() { 128 } else { 0 };
if self.o.version.lwpolyline() {
self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline");
self.e.int(90, pts.len() as i64);
+92 -3
View File
@@ -611,14 +611,44 @@ fn dashes_become_linetypes_and_opacity_transparency() {
#[test]
fn alter_paths_writes_strokes_as_filled_outlines() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
// Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc.
let mut d = shapes_doc();
let c = Point::new(150.0, 95.0);
let k = 20.0 * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + 20.0, c.y));
arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0));
let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0);
let layer = d.layers[0].id;
d.insert(Some(layer), usize::MAX, arc).unwrap();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat());
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
// R12 polylines carry any width, so its strokes stay lines too.
let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(!kinds(&r12).iter().any(|k| k == "HATCH"));
assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2")));
// R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch.
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width.
let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]);
let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap();
assert_eq!(kinds(&out), rect_stroke);
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
let lines = export(&wide, &opts(&wide)).unwrap();
assert_eq!(kinds(&lines), ["LWPOLYLINE"]);
assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm")));
// To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line.
for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] {
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]);
assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt");
}
// Preserve Appearance outlines only the strokes a line can't draw.
let mut d = shapes_doc();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
@@ -629,6 +659,65 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
// With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line.
let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
assert!(altered.warnings.is_empty(), "{:?}", altered.warnings);
}
#[test]
fn dashes_that_start_part_way_into_their_pattern_are_outlined() {
let dashed = |offset: f64| {
doc_with(|d| {
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = n.appearance.stroke_mut() {
st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false });
}
vec![n]
})
};
// A linetype starts at the start of its pattern: an offset of a whole period (the odd
// pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch.
for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] {
let d = dashed(offset);
let k = kinds(&export(&d, &opts(&d)).unwrap());
if outlined {
assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}");
} else {
assert_eq!(k, ["LWPOLYLINE"], "offset {offset}");
}
let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap());
assert_eq!(altered, k, "offset {offset}, Alter Paths");
}
// Maximize Editability keeps the line and its linetype.
let d = dashed(4.0);
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE"]);
assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1"));
}
#[test]
fn dashed_polylines_continue_their_pattern_around_corners() {
let rect = |dash: Option<Dash>| {
doc_with(|d| {
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = n.appearance.stroke_mut() {
st.dash = dash.clone();
}
vec![n]
})
};
let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string));
// Group 70 bit 128 makes the linetype run on through the vertices instead of restarting
// at each one; a solid polyline keeps only its closed bit.
let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }));
for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] {
assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129"));
}
let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap();
assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129")));
let solid = rect(None);
assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1"));
}
fn text_doc(text: &str, size: f64) -> Document {
+2 -2
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export DXF",
[],
None,
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (every stroke as its filled outline), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
has_doc,
export_dxf
)]
@@ -64,7 +64,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
name: "alterPaths",
ty: "boolean",
default: "false",
description: "Alter Paths for Appearance: every stroke as its filled outline",
description: "Alter Paths for Appearance: strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14",
},
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "type as glyph outlines (always with preserve: appearance)" },
FormatOption { name: "selectedOnly", ty: "boolean", default: "false", description: "only the selected objects, in their layers" },
+1
View File
@@ -254,6 +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. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. 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).
+4 -2
View File
@@ -3841,8 +3841,10 @@ from the bottom-left corner of the first artboard (or `artboard`), in drawing un
1 mm = 1 unit, which sets `$INSUNITS`); `scaleLineweights` scales the lineweights with them. `colors` is `8`, `16` or
`256` indexed colours, or `true` (default; true colour with the nearest index, DXF 2004 and later). `preserve:
"appearance"` (default) writes type as glyph outlines and the strokes a CAD line can't draw (inside or outside,
width profiles, arrowheads) as filled outlines; `"editability"` keeps type as text and every stroke a line.
`alterPaths` writes every stroke as its filled outline, `outlineText` outlines type, `selectedOnly` writes only the
width profiles, arrowheads, dashes offset into their pattern) as filled outlines; `"editability"` keeps type as text
and every stroke a line. `alterPaths` writes as filled outlines those strokes (with `"editability"` too), strokes
wider than the widest lineweight (2.11 mm), and every stroke in R13 and R14, which have no lineweights; it keeps other
strokes as lines. `outlineText` outlines type, `selectedOnly` writes only the
selected objects in their layers (every export takes it), and `useArtboards: true` writes one drawing per chosen
artboard holding the art over it. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks,
raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with