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..34e69d6ed 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); @@ -843,7 +872,8 @@ impl<'a> Scene<'a> { let pts: Vec = 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); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 3f5c73024..f02047492 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,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| { + 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 { 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