mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
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:
@@ -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
@@ -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(®ion.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
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user