From d318a775607dceb0ad5e8ed7c7d11fa5cf029996 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 23:32:34 -0700 Subject: [PATCH] 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. --- crates/cad/src/lib.rs | 7 ++- crates/cad/src/scene.rs | 35 ++++++++++++-- crates/cad/src/tests.rs | 71 +++++++++++++++++++++++++++-- crates/engine/src/cmd/fileio/dxf.rs | 4 +- docs/gaps.md | 1 + docs/mcp.md | 6 ++- 6 files changed, 112 insertions(+), 12 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 4afe84e33..3fc4bcb3b 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -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, diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 58e035059..a292d5a7f 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -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::() * 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> { // 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 = 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); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 3f5c73024..aff8489c4 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -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 { diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index a569f1b4c..4320cbb9c 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "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" }, diff --git a/docs/gaps.md b/docs/gaps.md index 0b31e2a48..1fb18246a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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). diff --git a/docs/mcp.md b/docs/mcp.md index 76f8648ac..4a37804b6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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