From cd8e98c6cd85b26427df2ae72cf742fcc2efcc78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:53 +0700 Subject: [PATCH] DXF: a CNC / CAM preset sets the export options for cutting software Squashed from #5500 onto main. --- crates/cad/src/lib.rs | 72 +++++++++++++++ crates/cad/src/tests.rs | 24 +++++ crates/engine/src/cmd/fileio/dxf.rs | 19 +++- crates/engine/src/cmd/fileio/tests_dxf.rs | 26 ++++++ crates/ui-egui/src/dialogs.rs | 7 +- crates/ui-egui/src/dialogs/dxf_options.rs | 106 +++++++++++++++++----- crates/ui-egui/src/dialogs/tests_dxf.rs | 66 ++++++++++++++ crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/control-protocol.md | 8 +- docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 11 files changed, 309 insertions(+), 32 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 3fc4bcb3b..603521160 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -308,6 +308,78 @@ impl Default for DxfOptions { } } +/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of +/// options matches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DxfPreset { + /// The options as chosen (the export's defaults for those left out). + #[default] + Custom, + /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight + /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize + /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), + /// and placed images as PNG. + Cnc, +} + +impl DxfPreset { + pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc]; + + /// The `dxfPreset` param value. + pub fn id(self) -> &'static str { + match self { + Self::Custom => "custom", + Self::Cnc => "cnc", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Cnc => "CNC / CAM", + } + } + + /// A preset by id (`custom`, `cnc`), any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } + + /// The options the preset sets (Custom: the defaults); the region and crop are the defaults. + pub fn options(self) -> DxfOptions { + match self { + Self::Custom => DxfOptions::default(), + Self::Cnc => DxfOptions { + version: DxfVersion::R2000, + unit: Unit::Millimeters, + scale: 1.0, + scale_lineweights: false, + colors: ColorDepth::Aci256, + raster: RasterFormat::Png, + preserve: Preserve::Editability, + alter_paths: false, + outline_text: true, + ..DxfOptions::default() + }, + } + } + + /// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom. + pub fn of(o: &DxfOptions) -> Self { + let c = Self::Cnc.options(); + let same = o.version == c.version + && o.unit == c.unit + && o.scale == c.scale + && o.scale_lineweights == c.scale_lineweights + && o.colors == c.colors + && o.raster == c.raster + && o.preserve == c.preserve + && o.alter_paths == c.alter_paths + && o.outline_text == c.outline_text; + if same { Self::Cnc } else { Self::Custom } + } +} + /// An image file a drawing links to, to be written next to it under `name`. #[derive(Clone, Debug)] pub struct DxfImage { diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d99858511..5c57fa634 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() { assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); } +#[test] +fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() { + let mut d = text_doc("Cut", 20.0); + let layer = d.layers[0].id; + let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0); + d.insert(Some(layer), usize::MAX, frame).unwrap(); + let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() }; + let out = export(&d, &o).unwrap(); + let p = pairs(&out.bytes); + assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4")); + let k = kinds(&out); + assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}"); + assert!(k.len() > 2, "the glyphs' outlines: {k:?}"); + // The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline. + assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert!(out.images.is_empty()); + assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); + assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); + assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 4320cbb9c..32b87d80d 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfOptions, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::{Document, Unit}; use vectorcraft_geom::Rect; @@ -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 (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}", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), 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 )] @@ -33,6 +33,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS, + FormatOption { + name: "dxfPreset", + ty: "string", + default: "\"custom\"", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { name: "unit", @@ -120,6 +126,7 @@ impl Unsupported { struct Params { #[serde(flatten)] boards: ArtboardPick, + dxf_preset: Option, version: Option, unit: Option, scale: Option, @@ -150,7 +157,8 @@ fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, d /// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice. fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() }; - let d = DxfOptions::default(); + // The options a preset sets are the defaults of those `p` leaves out. + let d = choice("dxfPreset", q.dxf_preset, DxfPreset::from_id, DxfPreset::Custom, "custom or cnc")?.options(); let scale = q.scale.unwrap_or(d.scale); if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) { return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end()))); @@ -181,6 +189,11 @@ pub fn check(p: &Value) -> std::result::Result<(), String> { parse(p).map(|_| ()).map_err(|e| e.to_string()) } +/// The DXF options in `p` (the region and crop left at their defaults). +pub fn options(p: &Value) -> std::result::Result { + parse(p).map(|(o, _)| o).map_err(|e| e.to_string()) +} + /// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner /// (default the first), or with `use_artboards` one drawing per chosen artboard (default all) /// holding the art over it. diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 298adeb11..849767683 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -63,6 +63,32 @@ fn export_writes_dxf_of_the_chosen_version() { assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1")); } +#[test] +fn the_cnc_preset_sets_the_options_that_are_not_given() { + let mut s = two_boards(); + let cnc = text(&s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap()); + assert_eq!(header(&cnc, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&cnc, "$INSUNITS").as_deref(), Some("4")); + // document.export takes it too, beside a PDF preset's name. + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc"})).unwrap(); + assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some("AC1015")); + let o = dxf::options(&json!({"dxfPreset": "CNC", "unit": "in"})).unwrap(); + assert_eq!( + (o.version, o.unit, o.colors, o.preserve, o.alter_paths, o.outline_text), + ( + vectorcraft_cad::DxfVersion::R2000, + vectorcraft_doc::Unit::Inches, + vectorcraft_cad::ColorDepth::Aci256, + vectorcraft_cad::Preserve::Editability, + false, + true + ) + ); + assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); + let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); + assert!(e.contains("custom or cnc"), "{e}"); +} + #[test] fn use_artboards_writes_one_drawing_per_artboard() { let mut s = two_boards(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a7766dd8d..d7e65781a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -473,7 +473,8 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { /// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is, /// an Edit Data Set field set this way is no longer the row's empty text value, and a Layer /// Options setting set this way for several rows is sent to every row (Template also sets Lock -/// and Dim Images, as its checkbox does). +/// and Dim Images, as its checkbox does). A DXF Options preset set this way sets its fields, as +/// one picked from its menu does, and the preset it shows follows the DXF options set this way. pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == brush_options::KIND { brush_art_options::note_set(d, &field); @@ -484,7 +485,11 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == layer_options::KIND { layer_options::note_set(d, &field, &value); } + let dxf = (d.kind == dxf_options::KIND).then(|| field.clone()); d.fields.insert(field, value); + if let Some(field) = dxf { + dxf_options::note_set(d, &field); + } } /// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index dc6a54d16..cc5262393 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -1,13 +1,15 @@ -//! DXF Options (Export As → DXF): version, scale (1 unit of the art = N drawing units) and -//! lineweights, number of colours, the format of placed images, Preserve Appearance or Maximize -//! Editability, and Export Selected Art Only, Alter Paths for Appearance and Outline Text. +//! DXF Options (Export As → DXF): a preset (Custom, or CNC / CAM, which sets the options below for +//! cutting software and shows Custom again once one is changed), version, scale (1 unit of the +//! art = N drawing units) and lineweights, number of colours, the format of placed images, +//! Preserve Appearance or Maximize Editability, and Export Selected Art Only, Alter Paths for +//! Appearance and Outline Text. //! //! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF //! options of `document.exportDxf`. OK remembers the options for next time and writes the file //! (asking where when there is no path); bad options keep the dialog open. use serde_json::{Map, Value, json}; -use vectorcraft_cad::{ColorDepth, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::Unit; use vectorcraft_engine::cmd::fileio; @@ -32,40 +34,95 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Fields that are not remembered between exports (they belong to one export). const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; +/// The preset field (the `dxfPreset` param). +const PRESET: &str = "dxfPreset"; +/// The preset the fields were last set from or matched (a change of [`PRESET`] sets its fields). +const SHOWN_PRESET: &str = "__shownPreset"; + /// The options a first DXF Options dialog starts from (the export's defaults). fn defaults() -> Map { + let mut m = preset_fields(DxfPreset::Custom); + m.insert(PRESET.into(), json!(DxfPreset::Custom.id())); + m.insert("selectedOnly".into(), json!(false)); + m +} + +/// The fields `preset` sets (Custom: the export's defaults). +fn preset_fields(preset: DxfPreset) -> Map { + let o = preset.options(); let mut m = Map::new(); for (k, v) in [ - ("version", json!(DxfVersion::default().id())), - ("unit", json!(Unit::Millimeters.label())), - ("scale", json!(1)), - ("scaleLineweights", json!(false)), - ("colors", json!(ColorDepth::default().id())), - ("rasterFormat", json!(RasterFormat::default().id())), - ("preserve", json!(Preserve::default().id())), - ("alterPaths", json!(false)), - ("outlineText", json!(false)), - ("selectedOnly", json!(false)), + ("version", json!(o.version.id())), + ("unit", json!(o.unit.label())), + ("scale", json!(o.scale)), + ("scaleLineweights", json!(o.scale_lineweights)), + ("colors", json!(o.colors.id())), + ("rasterFormat", json!(o.raster.id())), + ("preserve", json!(o.preserve.id())), + ("alterPaths", json!(o.alter_paths)), + ("outlineText", json!(o.outline_text)), ] { m.insert(k.into(), v); } m } +/// Show the preset the fields match: CNC / CAM while every field it sets is as it sets it, else +/// Custom. An unknown preset stays as it is, for OK to refuse. +fn show_matching_preset(d: &mut Dialog) { + if DxfPreset::from_id(&d.str(PRESET)).is_none() { + return; + } + let preset = fileio::dxf::options(&super::form::params(d)).map_or(DxfPreset::Custom, |o| DxfPreset::of(&o)); + d.fields.insert(PRESET.into(), json!(preset.id())); + d.fields.insert(SHOWN_PRESET.into(), json!(preset.id())); +} + +/// A preset picked since the fields were last matched, or any preset when `force` is set, sets +/// its fields; Custom puts back the export's defaults. +fn apply_picked_preset(d: &mut Dialog, force: bool) { + if (force || !d.str(PRESET).eq_ignore_ascii_case(&d.str(SHOWN_PRESET))) + && let Some(p) = DxfPreset::from_id(&d.str(PRESET)) + { + d.fields.extend(preset_fields(p)); + } +} + +/// `field` was set (`ui.dialog.set`): a preset sets its fields now, even the one shown, so fields +/// set after it keep their values, and any other field shows the preset the fields then match, as +/// drawing does. +pub(super) fn note_set(d: &mut Dialog, field: &str) { + if field == PRESET { + apply_picked_preset(d, true); + } + show_matching_preset(d); +} + /// Open DXF Options for an export whose params (path, artboard choice, any DXF option) are -/// `params`, over the options used last. +/// `params`, over the options used last. A `dxfPreset` in `params` sets its fields under the +/// options `params` gives. pub fn open(app: &mut VectorcraftApp, params: &Value) { let mut fields = defaults(); - for given in [&app.ui.dxf_options, params] { - if let Some(o) = given.as_object() { - fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + if let Some(o) = app.ui.dxf_options.as_object() { + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + if let Some(o) = params.as_object() { + if let Some(p) = o.get(PRESET).and_then(Value::as_str).and_then(DxfPreset::from_id) { + fields.extend(preset_fields(p)); } + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); } fields.insert("format".into(), json!("dxf")); - app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields))); + let mut d = Dialog::new(KIND, Value::Object(fields)); + show_matching_preset(&mut d); + app.ui.dialog = Some(d); } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let presets: Vec<(&str, &str)> = DxfPreset::ALL.iter().map(|p| (p.id(), p.label())).collect(); + choice(ui, d, PRESET, tl!("Preset:"), &presets); + apply_picked_preset(d, false); + ui.add_space(4.0); let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect(); choice(ui, d, "version", tl!("Version:"), &versions); ui.add_space(4.0); @@ -113,6 +170,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { Preserve::Editability => tl!("Type stays text, and every stroke is a line with its lineweight and dashes."), }, ); + show_matching_preset(d); false } @@ -146,14 +204,20 @@ fn note(ui: &mut egui::Ui, text: &str) { /// Check the options, remember them, and write the file(s). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // A preset or field set since the dialog last drew (the control channel sets fields without + // drawing) counts as drawn. + let mut d = d.clone(); + apply_picked_preset(&mut d, false); + show_matching_preset(&mut d); + let d = &d; let params = super::form::params(d); fileio::dxf::check(¶ms)?; // Nothing selected keeps the dialog open too. if let Some(st) = app.session.active() { fileio::export_source(st, ¶ms).map_err(|e| e.to_string())?; } - app.ui.dxf_options = - Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); + let remembered = params.as_object().map(|o| o.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone()))); + app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect()); app.ui.dialog = None; let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); io::export(app, Some("dxf"), path, ¶ms) diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs index 252c67f7e..22429b164 100644 --- a/crates/ui-egui/src/dialogs/tests_dxf.rs +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -40,6 +40,66 @@ fn export_as_dxf_shows_dxf_options_then_writes_and_remembers_them() { assert!(app.ui.dxf_options.get("useArtboards").is_none()); } +#[test] +fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { + let (mut app, written) = app(1); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "custom"); + set(&mut app, "dxfPreset", json!("cnc")); + frame(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + let fields = |d: &crate::state::Dialog| { + ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText"].map(|k| d.str(k)).join(" ") + }; + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + // Any change to a field the preset sets shows Custom, and keeps the other fields. + set(&mut app, "colors", json!("true")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true"); + set(&mut app, "colors", json!("256")); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc", "matching every field again"); + confirm(&mut app).unwrap(); + let dxf = text(&written.borrow()[0].1); + let lines: Vec<&str> = dxf.lines().collect(); + let header = |var: &str| lines.iter().position(|l| *l == var).and_then(|i| lines.get(i + 2)).map(|v| v.trim()); + assert_eq!((header("$ACADVER"), header("$INSUNITS")), (Some("AC1015"), Some("4"))); + // The next DXF Options start from the preset; Custom puts back the defaults. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc"); + set(&mut app, "dxfPreset", json!("custom")); + frame(&mut app); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false"); + // A preset the command gives sets the fields it does not give. + app.ui.dialog = None; + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "cnc", "unit": "Inches"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true"); + // A preset set and confirmed without drawing (the control channel) sets its fields too. + set(&mut app, "dxfPreset", json!("cnc")); + confirm(&mut app).unwrap(); + assert_eq!((app.ui.dxf_options["dxfPreset"].as_str(), app.ui.dxf_options["unit"].as_str()), (Some("cnc"), Some("Millimeters"))); + // `ui.dialog.set` sets the preset's fields at once, so a field set after it keeps its value. + app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "custom"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + set_field(d, "unit".into(), json!("Inches")); + assert_eq!(d.str("dxfPreset"), "custom"); + // Setting the preset again sets its fields again. + set_field(d, "dxfPreset".into(), json!("cnc")); + assert_eq!(d.str("unit"), "Millimeters"); + set_field(d, "unit".into(), json!("Inches")); + // Setting Custom while Custom is shown still puts back the defaults. + let mut reset = d.clone(); + set_field(&mut reset, "dxfPreset".into(), json!("custom")); + assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false"); + confirm(&mut app).unwrap(); + let remembered = ["dxfPreset", "version", "unit"].map(|k| app.ui.dxf_options[k].as_str()); + assert_eq!(remembered, [Some("custom"), Some("2000"), Some("Inches")]); +} + #[test] fn bad_dxf_options_keep_the_dialog_open() { let (mut app, written) = app(1); @@ -47,6 +107,12 @@ fn bad_dxf_options_keep_the_dialog_open() { set(&mut app, "scale", json!(-1)); assert!(confirm(&mut app).is_err()); assert_eq!(kind(&app), Some("dxfOptions")); + set(&mut app, "scale", json!(1)); + set(&mut app, "dxfPreset", json!("laser")); + frame(&mut app); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("custom or cnc"), "{e}"); + set(&mut app, "dxfPreset", json!("custom")); set(&mut app, "scale", json!(10)); // Export Selected Art Only with nothing selected. app.run("select.none", json!({})).unwrap(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5813da723..94f375c4f 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1245,7 +1245,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.dxfOptionsDialog", "DXF Options Dialog", "", - "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", + "{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last, a dxfPreset's options under them; the dxfPreset field shows cnc while every field it sets matches, else custom); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too", ), ( "links.editOriginal", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a799f22ff..3fcec09b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -669,9 +669,11 @@ with. DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or `ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are -`document.export` params: `version`, `unit` (a unit name such as `Millimeters`), `scale`, `scaleLineweights`, `colors` -(`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, `editability`), `selectedOnly`, -`alterPaths`, `outlineText`, plus `path` and the artboard choice. `ui.dialog.confirm` checks them (a bad value, or +`document.export` params: `dxfPreset` (`custom`, `cnc`), `version`, `unit` (a unit name such as `Millimeters`), `scale`, +`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, +`editability`), `selectedOnly`, `alterPaths`, `outlineText`, plus `path` and the artboard choice. Setting `dxfPreset` to +`cnc` sets the fields it sets (see `document.exportDxf`), and `dxfPreset` reads `custom` again once one of them is +changed; setting it to `custom` puts back the defaults. `ui.dialog.confirm` checks them (a bad value, or Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to `dwg` shows, and on confirm answers, the hint to export DXF instead. diff --git a/docs/mcp.md b/docs/mcp.md index 0c42c160f..284a5e887 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4649,8 +4649,12 @@ and every stroke a line. `alterPaths` writes as filled outlines those strokes (w 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 +artboard holding the art over it. `dxfPreset: "cnc"` (CNC / CAM; default `"custom"`) sets the options for cutting +software that the call does not give: version `2000` (the oldest version that has both lightweight polylines and lineweights), `unit: +"mm"` and `scale: 1`, `colors: 256` (all version 2000 holds), `preserve: "editability"` with `alterPaths: false` (every +stroke a center line), `outlineText: true` (type as paths), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, +which shows Custom again once a field it sets is changed. 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 no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such images. @@ -4661,6 +4665,7 @@ opened. ```json {"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} {"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}} +{"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/sign.dxf","dxfPreset":"cnc"}}} ``` ## DXF import diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eee501ef9..c388ee2fd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1336,7 +1336,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); CNC preset and VCarve QA remain pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, PNG placed images), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.