DXF export: Alter Paths for Appearance outlines only strokes a CAD line can't draw

Alter Paths for Appearance wrote every stroke as its filled outline, so a
1 pt circle became two circles and a hatch, and an open arc became a
closed outline and a hatch: two cut lines where a CAM program expects
one. The option now outlines only the strokes a line can't draw: the ones
Preserve Appearance outlines, strokes wider than the widest lineweight
(2.11 mm, to the hundredth), and every stroke in R13 and R14, which have
no lineweights. Other strokes stay CIRCLE, ARC, SPLINE and LWPOLYLINE
entities with their lineweights; R12 keeps them as POLYLINEs with their
width.

A dashed stroke whose offset starts part way into its pattern is now
outlined with Preserve Appearance and with Alter Paths: a linetype always
starts at the start of its pattern, so the dashes were written in the
wrong place.
This commit is contained in:
dotCooCoo
2026-10-31 19:43:15 +07:00
committed by bflatastic
parent b493e601a9
commit d318a77560
6 changed files with 112 additions and 12 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,
+32 -3
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);
+68 -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,41 @@ 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"));
}
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