collective

Browse / github.com/storytold/vectorcraft / patch

Native format: document.save can write path coordinates at a fixed precision

Native format: document.save can write path coordinates at a fixed precision ## What and why Native files store every anchor and handle as a full double, such as `3.4876474632797176`, so a file of traced art is larger than it needs to be. `document.save`, `file.saveAs`, `file.saveCopy` and `file.saveAsTemplate` now take `precision` (0 to 9) for native files. The save rounds the anchors and handles of path objects, Live Corners' uncut outlines, blend spines, envelope outlines, Intertwine areas and brush art to that many decimal places, in the layers, symbols, graph designs, patterns and opacity masks, and in the art that placed documents become in files for older vers

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #3990, open)
Base
f792c63597f289d0bfb914fd06255cd1e4c8852d
Size
+509 −28 in 12 files
Review
pending
Shared
2026-10-28
Id
c57e65815a17cf9e46aa9c67

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/lib.rs crates/engine/src/cmd/fileio/native.rs crates/engine/src/cmd/fileio/save.rs crates/engine/src/cmd/fileio/tests.rs crates/engine/src/tests_placed_document.rs crates/engine/src/tests_saveoptions.rs crates/format/src/lib.rs crates/format/tests/it/save_options.rs crates/mcp/src/tests_fileio.rs crates/mcp/src/tools.rs crates/ui-egui/src/dialogs/save_options.rs docs/mcp.md

diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs--- a/crates/doc/src/lib.rs+++ b/crates/doc/src/lib.rs@@ -2118,6 +2118,110 @@ impl Document {         d.drop_edit_modes();         std::borrow::Cow::Owned(d)     }++    /// Round every path's anchors and handles to `decimals` decimal places: path objects (with+    /// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine+    /// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being+    /// edited included), opacity masks and the brushes kept as foreign data. Colors, transforms,+    /// opacity, live shapes' own sizes, meshes and type (its frames and paths included) are left+    /// as they are.+    pub fn round_geometry(&mut self, decimals: u8) {+        let scale = 10f64.powi(i32::from(decimals));+        let round = |v: f64| {+            let r = (v * scale).round() / scale;+            if !r.is_finite() {+                v+            } else if r == 0.0 {+                // `-0.0` is written as `0.0`.+                0.0+            } else {+                r+            }+        };+        let round_path = |path: &mut vectorcraft_geom::PathData| {+            for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) {+                for p in [&mut a.p, &mut a.h_in, &mut a.h_out] {+                    *p = Point::new(round(p.x), round(p.y));+                }+            }+        };+        let mut fix = |n: &mut Node| {+            match &mut n.kind {+                NodeKind::Path { path, live, .. } => {+                    round_path(path);+                    // Live Corners' uncut outline.+                    if let Some(LiveShape::Path { base, .. }) = live.as_deref_mut() {+                        round_path(base);+                    }+                }+                NodeKind::Blend { spec, .. } => {+                    if let Some(spine) = &mut spec.spine {+                        round_path(spine);+                    }+                }+                NodeKind::Envelope { kind, .. } => {+                    if let EnvelopeKind::TopObject { path } = kind.as_mut() {+                        round_path(path);+                    }+                }+                _ => {}+            }+            if let Some(area) = n.extras_if_any().and_then(|e| e.intertwine.as_deref_mut()) {+                round_path(area);+            }+        };+        let roots = self+            .layers+            .iter_mut()+            .chain(self.symbols.iter_mut().map(|s| &mut s.art))+            .chain(self.graph_designs.iter_mut().map(|g| &mut g.art))+            .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))+            .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));+        for root in roots {+            placed_document::each_mut(Arc::make_mut(root), &mut fix);+        }+        if self.unknown.contains_key("brushes")+            && let Some(brushes) = self.unknown.get_mut("brushes")+        {+            round_json_paths(brushes, &round);+        }+    }+}++/// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every+/// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as+/// [`Document::round_geometry`] rounds the nodes it holds.+fn round_json_paths(v: &mut serde_json::Value, round: &impl Fn(f64) -> f64) {+    use serde_json::Value;+    match v {+        Value::Object(o) => {+            if o.get("type").and_then(Value::as_str) == Some("text") {+                return;+            }+            if let Some(subpaths) = o.get_mut("subpaths").and_then(Value::as_array_mut) {+                let anchors = subpaths.iter_mut().filter_map(|s| s.get_mut("anchors").and_then(Value::as_array_mut)).flatten();+                for a in anchors.filter_map(Value::as_object_mut) {+                    let points = a.iter_mut().filter(|(k, _)| matches!(k.as_str(), "p" | "in" | "out")).filter_map(|(_, v)| v.as_array_mut());+                    for xy in points {+                        for n in xy.iter_mut().filter(|n| n.is_f64()) {+                            if let Some(r) = n.as_f64().and_then(|x| serde_json::Number::from_f64(round(x))) {+                                *n = Value::Number(r);+                            }+                        }+                    }+                }+            }+            for c in o.values_mut() {+                round_json_paths(c, round);+            }+        }+        Value::Array(a) => {+            for c in a {+                round_json_paths(c, round);+            }+        }+        _ => {}+    } }  /// Graphic styles.diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs--- a/crates/engine/src/cmd/fileio/native.rs+++ b/crates/engine/src/cmd/fileio/native.rs@@ -35,6 +35,12 @@ pub const OPTIONS: &[FormatOption] = &[         default: "false",         description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",     },+    FormatOption {+        name: "precision",+        ty: "integer",+        default: "null",+        description: "round path anchors and handles to this many decimal places, 0 to 9 (a smaller file; colors, transforms and type are kept exactly); null writes them exactly. Only the save that gives it rounds",+    },     SEPARATE_ARTBOARDS,     SEPARATE_RANGE,     INCLUDE_LINKED,@@ -131,6 +137,7 @@ struct NativeOptions {     compress: Option<bool>,     version: Option<u32>,     preview: bool,+    precision: Option<Value>,     include_linked: bool,     embed_profiles: Option<bool>,     pdf_compatible: bool,@@ -151,6 +158,7 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->         compress: o.compress.unwrap_or(false),         version: o.version.unwrap_or(vectorcraft_format::VERSION),         include_linked: o.include_linked,+        precision: o.precision.map(|v| precision(cmd, &v)).transpose()?,         ..SaveOptions::default()     };     if o.embed_profiles.unwrap_or(true) {@@ -160,6 +168,20 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->     Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible })) } +/// The `precision` option `v`: a whole number of decimal places, 0 to+/// [`vectorcraft_format::MAX_PRECISION`]. Any other value, a string included, gets the range message.+fn precision(cmd: &str, v: &Value) -> Result<u8> {+    let (key, min, max) = ("precision", 0u8, vectorcraft_format::MAX_PRECISION);+    if let Some(x) = v.as_f64()+        && x.fract() == 0.0+        && (f64::from(min)..=f64::from(max)).contains(&x)+    {+        return Ok(x as u8);+    }+    let x = v.as_f64().map_or_else(|| v.to_string(), |x| x.to_string());+    Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})")))+}+ /// The `.vectorcraft` file of `doc` with the options in `p`. pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {     let mut out = Vec::new();@@ -177,13 +199,19 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp     } else {         std::borrow::Cow::Borrowed(doc)     };+    // With a precision, the preview and PDF show the rounded art the file holds.+    let drawn = match so.precision {+        Some(decimals) if extras.preview || extras.pdf => Cow::Owned(rounded(&full, so.version, decimals)),+        _ => Cow::Borrowed(full.as_ref()),+    };     if extras.preview {-        so.preview = preview_png(&full)?;+        so.preview = preview_png(&drawn)?;     }     if extras.pdf {         // Every artboard as PDF pages (no editing data: the file is the native document).-        so.pdf = Some(super::pdf::encode(cmd, &full, &json!({ "range": "all", "preserveEditing": false }))?.0);+        so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0);     }+    drop(drawn);     let native = match full {         Cow::Owned(full) if so.include_linked && so.version >= vectorcraft_format::VERSION => {             // Include available bytes without replacing missing, relinkable placed objects.@@ -197,6 +225,20 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp     vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string())) } +/// `doc` with its path geometry rounded to `decimals` places, as [`vectorcraft_format::save_to`]+/// writes it for `version`: placed documents become groups of their art for older versions.+fn rounded(doc: &Document, version: u32, decimals: u8) -> Document {+    let mut d = if version < vectorcraft_format::VERSION && doc.has_placed() {+        let mut d = doc.with_placed_art().into_owned();+        d.placed_as_groups();+        d+    } else {+        doc.clone()+    };+    d.round_geometry(decimals.min(vectorcraft_format::MAX_PRECISION));+    d+}+ /// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in /// ones are there wherever VectorCraft runs). fn embedded_profiles(doc: &Document) -> BTreeMap<String, Vec<u8>> {@@ -230,8 +272,8 @@ pub(super) fn ai_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Re     let mut q = p.clone();     if let Some(o) = q.as_object_mut() {         // AI carries the current native format without a separate preview. Its compression-        // and PDF-content flags are still validated as booleans.-        o.retain(|k, _| !matches!(k.as_str(), "version" | "preview"));+        // and PDF-content flags are still validated as booleans. Its coordinates are written exactly.+        o.retain(|k, _| !matches!(k.as_str(), "version" | "preview" | "precision"));         if o.get("pdfCompatible").is_some_and(Value::is_null) {             o.remove("pdfCompatible");         }diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs--- a/crates/engine/src/cmd/fileio/save.rs+++ b/crates/engine/src/cmd/fileio/save.rs@@ -257,7 +257,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {         given = svg;     } else {         // So do PDF options (as the Save PDF dialog and document.exportPdf name them) and the-        // native ones (compress, version, preview).+        // native ones (compress, version, preview, precision).         if let Some(o) = p.as_object() {             given.extend(                 o.iter().filter(|(k, v)| !v.is_null() && k.as_str() != "path" && reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())),@@ -288,7 +288,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> { }  /// The native save options, which a save also takes beside the path.-const NATIVE_OPTIONS: [&str; 3] = ["compress", "version", "preview"];+const NATIVE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "precision"];  /// The first option `p` gives both beside the path and in `options` with different values, as /// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on@@ -827,6 +827,8 @@ impl SaveJob {             st.path = Some(path);             st.format = self.plan.format.id;             st.save_options = self.plan.options;+            // A later save rounds only when it gives `precision` too.+            st.save_options.remove("precision");             st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();             st.converted = false;             st.recovered = false;@@ -942,7 +944,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {             "Save Document",             [],             None,-            "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",+            "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",             has_doc,             |s, p| save(s, SaveMode::Save, p)         ),@@ -951,7 +953,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {             "Save As…",             ["File"],             Some("Cmd+Shift+S"),-            "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",+            "{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, precision?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",             has_doc,             |s, p| save(s, SaveMode::SaveAs, p)         ),@@ -960,7 +962,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {             "Save a Copy…",             ["File"],             Some("Cmd+Alt+S"),-            "{…as document.save} {path?, format?, options?} write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> copy.<ext>\", folder?, warnings}",+            "{…as document.save} {path?, format?, options?, precision?} write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> copy.<ext>\", folder?, warnings}",             has_doc,             |s, p| save(s, SaveMode::Copy, p)         ),@@ -969,7 +971,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {             "Save as Template…",             ["File"],             None,-            "{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai; aiVersion 7 is an error); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",+            "{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?, precision?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai; aiVersion 7 is an error); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",             has_doc,             |s, p| save(s, SaveMode::Template, p)         ),diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs--- a/crates/engine/src/cmd/fileio/tests.rs+++ b/crates/engine/src/cmd/fileio/tests.rs@@ -703,8 +703,8 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { }  /// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different-/// values is refused before anything is written; `compress`, `version` or `preview` beside the path of a format that doesn't-/// read it is named in the warnings.+/// values is refused before anything is written; `compress`, `version`, `preview` or `precision` beside the path of a format+/// that doesn't read it is named in the warnings. #[test] fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {     let d = tmp_dir("save-options");@@ -751,6 +751,16 @@ fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {     let r = s.execute("file.saveCopy", &json!({"path": d.join("a.ai").to_string_lossy(), "compress": false, "version": 3})).unwrap();     let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();     assert!(w.iter().any(|w| w.starts_with("`version` was not used")) && !w.iter().any(|w| w.starts_with("`compress`")), "{w:?}");+    // `precision` is read by native files and templates only.+    for name in ["p.svg", "p.pdf", "p.ai"] {+        let r = s.execute("file.saveCopy", &json!({"path": d.join(name).to_string_lossy(), "precision": 2})).unwrap();+        let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();+        assert!(w.iter().any(|w| w.starts_with("`precision` was not used:")), "{name}: {w:?}");+    }+    for name in ["p.vectorcraft", "p.vctemplate"] {+        let r = s.execute("file.saveCopy", &json!({"path": d.join(name).to_string_lossy(), "precision": 2})).unwrap();+        assert_eq!(r["warnings"], json!([]), "{name}: {r}");+    }     let _ = std::fs::remove_dir_all(&d); } diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests_placed_document.rs--- a/crates/engine/src/tests_placed_document.rs+++ b/crates/engine/src/tests_placed_document.rs@@ -691,6 +691,53 @@ fn a_placed_documents_resources_stay_its_own() {     assert!(near(centre_colour(&old), BLUE)); } +#[test]+fn saved_for_older_apps_with_a_precision_the_placed_art_is_rounded_too() {+    let dir = Folder::new("placed-precision");+    let src = dir.file("triangle.vectorcraft");+    let mut d = Document::new(100.0, 100.0);+    let layer = d.layers[0].id;+    let id = d.alloc_id();+    let points = [(3.4876474632797176, 1.0), (90.0, 2.123456789), (40.0, 80.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y));+    let path = vectorcraft_geom::PathData::single(vectorcraft_geom::path::SubPath::polyline(&points, true));+    d.insert(Some(layer), 0, Node::path(id, path, look(BLUE))).unwrap();+    write(&src, &vectorcraft_format::save_file(&d));+    let mut s = session();+    place(&mut s, &src);+    // The coordinates of every anchor and handle in a file's layers.+    fn coords(v: &Value, out: &mut Vec<f64>) {+        match v {+            Value::Object(o) => {+                for a in o.get("anchors").and_then(Value::as_array).into_iter().flatten() {+                    out.extend(["p", "in", "out"].iter().filter_map(|k| a[*k].as_array()).flatten().filter_map(Value::as_f64));+                }+                o.values().for_each(|c| coords(c, out));+            }+            Value::Array(a) => a.iter().for_each(|c| coords(c, out)),+            _ => {}+        }+    }+    let mut saved = |p: Value| {+        let file: Value = serde_json::from_slice(&decode(&s.execute("document.serialize", &p).unwrap())).unwrap();+        assert!(!file.to_string().contains("placeddocument"), "{file}");+        let mut out = vec![];+        coords(&file["document"]["layers"], &mut out);+        out+    };+    let at4 = |x: f64| (x * 1e4).round() / 1e4 == x;+    let exact = saved(json!({"format": "vectorcraft", "version": 3}));+    assert!(exact.len() >= 6 && !exact.iter().all(|x| at4(*x)), "{exact:?}");+    let rounded = saved(json!({"format": "vectorcraft", "version": 3, "precision": 4}));+    assert_eq!(rounded.len(), exact.len());+    assert!(rounded.iter().all(|x| at4(*x)), "{rounded:?}");+    // The preview shows the rounded art the older app opens, not the placed file's exact art.+    let file = decode(&s.execute("file.saveCopy", &json!({"version": 3, "precision": 0, "preview": true})).unwrap());+    let preview = vectorcraft_format::preview(&file);+    assert!(preview.is_some());+    assert_eq!(preview, crate::cmd::fileio::preview_png(&vectorcraft_format::load(&file).unwrap()).unwrap());+    assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap());+}+ #[test] fn break_link_gives_the_editable_copy_placing_without_link_gives() {     let dir = Folder::new("placed-break");diff --git a/crates/engine/src/tests_saveoptions.rs b/crates/engine/src/tests_saveoptions.rs--- a/crates/engine/src/tests_saveoptions.rs+++ b/crates/engine/src/tests_saveoptions.rs@@ -353,3 +353,109 @@ fn format_options_list_the_save_options() {     let v = s.execute("file.formatOptions", &json!({})).unwrap();     assert_eq!(v["options"]["includeLinked"]["value"], true); }++/// The anchors of the first object in the native file at `path`.+fn saved_anchors(path: &str) -> Vec<vectorcraft_geom::Point> {+    let d = vectorcraft_format::load(&std::fs::read(path).unwrap()).unwrap();+    let NodeKind::Path { path, .. } = &d.layers[0].children().unwrap()[0].kind else { panic!("not a path") };+    path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect()+}++#[test]+fn precision_rounds_the_paths_of_that_save_only() {+    let dir = Folder::new("precision");+    let mut s = Session::new();+    s.execute("file.new", &json!({"width": 1000, "height": 1000})).unwrap();+    // 1,000 anchors at coordinates with many decimal places (a fixed pseudo-random sequence).+    let mut seed = 0x2545_f491_4f6c_dd1du64;+    let mut next = || {+        seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);+        (seed >> 11) as f64 / (1u64 << 53) as f64 * 1000.0+    };+    let anchors: Vec<Value> = (0..1000).map(|_| json!({"x": next(), "y": next()})).collect();+    s.execute("path.create", &json!({"anchors": anchors})).unwrap();+    let (exact, rounded) = (dir.file("exact.vectorcraft"), dir.file("rounded.vectorcraft"));+    s.execute("document.save", &json!({"path": exact, "compress": false})).unwrap();+    s.execute("document.save", &json!({"path": rounded, "compress": false, "precision": 3})).unwrap();+    let (e, r) = (std::fs::metadata(&exact).unwrap().len(), std::fs::metadata(&rounded).unwrap().len());+    assert!(r < e, "{r} bytes rounded, {e} exact");+    let (want, got) = (saved_anchors(&exact), saved_anchors(&rounded));+    assert_eq!(got.len(), 1000);+    for (w, g) in want.iter().zip(&got) {+        assert!((w.x - g.x).abs() <= 0.0005 && (w.y - g.y).abs() <= 0.0005, "{w:?} saved as {g:?}");+        assert_eq!(((g.x * 1000.0).round() / 1000.0, (g.y * 1000.0).round() / 1000.0), (g.x, g.y));+    }+    // The document keeps its exact anchors, and Save doesn't remember the precision.+    let v = s.execute("file.formatOptions", &json!({})).unwrap();+    assert!(v["options"]["precision"]["value"].is_null(), "{}", v["options"]["precision"]);+    s.execute("document.save", &json!({})).unwrap();+    assert_eq!(saved_anchors(&rounded), want);+    // A precision that isn't a whole number from 0 to 9 is refused, by the command that was given it.+    for (wrong, got) in [(json!(10), "10"), (json!(2.5), "2.5"), (json!(-1), "-1"), (json!("4"), "\"4\""), (json!(true), "true")] {+        for cmd in ["file.saveCopy", "document.save"] {+            let e = s.execute(cmd, &json!({"precision": wrong})).unwrap_err().to_string();+            assert!(e.contains(&format!("`{cmd}`")) && !e.contains("document.export"), "{cmd} {wrong}: {e}");+            assert!(e.contains(&format!("`precision` must be a whole number between 0 and 9 (got {got})")), "{cmd} {wrong}: {e}");+        }+    }+    // A type error in another native option names that command too.+    let e = s.execute("document.save", &json!({"version": "4"})).unwrap_err().to_string();+    assert!(e.contains("`document.save`") && !e.contains("document.export"), "{e}");+}++#[test]+fn a_ai_file_ignores_precision_as_save_does() {+    let mut s = Session::new();+    s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();+    s.execute("path.create", &json!({"anchors": [{"x": 10.123456789, "y": 20.987654321}, {"x": 60.5, "y": 70.25}]})).unwrap();+    let want: Vec<_> = {+        let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.layers[0].children().unwrap()[0].kind else { panic!("not a path") };+        path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect()+    };+    for cmd in ["document.export", "file.saveCopy"] {+        for precision in [json!(3), json!(10)] {+            let r = s.execute(cmd, &json!({"format": "ai", "precision": precision})).unwrap();+            let mut t = Session::new();+            t.execute("document.open", &json!({"name": "x.ai", "dataBase64": r["dataBase64"]})).unwrap();+            let NodeKind::Path { path, .. } = &t.doc().unwrap().doc.layers[0].children().unwrap()[0].kind else { panic!("not a path") };+            let got: Vec<_> = path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect();+            assert_eq!(got, want, "{cmd} {precision}");+        }+    }+}++/// The anchors of every path in `n` and the art it holds.+fn all_anchors(n: &vectorcraft_doc::Node, out: &mut Vec<vectorcraft_geom::Point>) {+    if let NodeKind::Path { path, .. } = &n.kind {+        out.extend(path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)));+    }+    for c in n.children().into_iter().flatten() {+        all_anchors(c, out);+    }+}++#[test]+fn the_preview_and_pdf_of_a_save_at_a_precision_show_the_rounded_art() {+    let mut s = Session::new();+    s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();+    let anchors = json!([{"x": 10.4, "y": 20.3}, {"x": 80.6, "y": 30.7}, {"x": 45.35, "y": 85.65}]);+    s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap();+    let file = bytes_of(&s.execute("file.saveCopy", &json!({"precision": 0, "pdfCompatible": true, "preview": true})).unwrap()["dataBase64"]);+    // The embedded PDF, opened, has the path on whole points.+    let pdf = vectorcraft_format::pdf_content(&file).unwrap();+    let mut t = Session::new();+    t.execute("document.open", &json!({"name": "x.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap();+    let mut got = vec![];+    for l in &t.doc().unwrap().doc.layers {+        all_anchors(l, &mut got);+    }+    for (x, y) in [(10.0, 20.0), (81.0, 31.0), (45.0, 86.0)] {+        assert!(got.iter().any(|p| (p.x - x).abs() < 1e-3 && (p.y - y).abs() < 1e-3), "({x}, {y}) not in {got:?}");+    }+    assert!(got.iter().all(|p| (p.x - p.x.round()).abs() < 1e-3 && (p.y - p.y.round()).abs() < 1e-3), "{got:?}");+    // The preview is the one the saved document draws, not the exact art's.+    let preview = vectorcraft_format::preview(&file);+    assert!(preview.is_some());+    assert_eq!(preview, crate::cmd::fileio::preview_png(&vectorcraft_format::load(&file).unwrap()).unwrap());+    assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap());+}diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs--- a/crates/format/src/lib.rs+++ b/crates/format/src/lib.rs@@ -229,11 +229,26 @@ pub struct SaveOptions {     pub profiles: BTreeMap<String, Vec<u8>>,     /// Create PDF-Compatible File: a PDF of the document to carry.     pub pdf: Option<Vec<u8>>,+    /// Decimal places to round path anchors and handles to ([`Document::round_geometry`]), at+    /// most [`MAX_PRECISION`] (a larger value rounds to that many); `None` writes them exactly.+    pub precision: Option<u8>, } +/// The most decimal places [`SaveOptions::precision`] rounds path geometry to.+pub const MAX_PRECISION: u8 = 9;+ impl Default for SaveOptions {     fn default() -> Self {-        Self { pretty: false, compress: false, version: VERSION, preview: None, include_linked: false, profiles: BTreeMap::new(), pdf: None }+        Self {+            pretty: false,+            compress: false,+            version: VERSION,+            preview: None,+            include_linked: false,+            profiles: BTreeMap::new(),+            pdf: None,+            precision: None,+        }     } } @@ -299,7 +314,8 @@ struct Body { }  /// `doc` as [`save_to`] writes it with `o`: without editing-mode working copies, with placed-/// documents as their art for older versions, and its images taken out.+/// documents as their art for older versions, path geometry rounded to `o.precision`, and its+/// images taken out. fn body(doc: &Document, o: &SaveOptions) -> Result<Body, FormatError> {     let version = o.version;     let mut d = doc.without_edit_modes().into_owned();@@ -311,6 +327,10 @@ fn body(doc: &Document, o: &SaveOptions) -> Result<Body, FormatError> {     } else {         d.drop_placed_resources();     }+    // The art placed documents became for older versions is rounded too.+    if let Some(decimals) = o.precision {+        d.round_geometry(decimals.min(MAX_PRECISION));+    }     let linked = d.linked_only_images();     // The blobs go in the file's `images` (only those the document uses).     let blobs = std::mem::take(&mut d.images);diff --git a/crates/format/tests/it/save_options.rs b/crates/format/tests/it/save_options.rs--- a/crates/format/tests/it/save_options.rs+++ b/crates/format/tests/it/save_options.rs@@ -5,10 +5,13 @@ use std::sync::Arc;  use serde_json::Value;+use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::links::LinkInfo;-use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind, Symbol};-use vectorcraft_format::{ICC_MIME, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with};-use vectorcraft_geom::path::{PathData, SubPath};+use vectorcraft_doc::{Appearance, BlendSpec, Document, EnvelopeKind, ImageBlob, ImageObject, LiveShape, Node, NodeKind, OpacityMask, Symbol};+use vectorcraft_format::{+    ICC_MIME, MAX_PRECISION, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with,+};+use vectorcraft_geom::path::{Anchor, PathData, SubPath}; use vectorcraft_geom::{Affine, Point}; use vectorcraft_testkit::fixtures::DocBuilder; @@ -206,3 +209,117 @@ fn art_outside_the_layers_counts_too() {     assert!(!is_pretty(&bytes));     assert_eq!(load(&bytes).unwrap().symbols.len(), 1); }++/// One path at `x, y`, its out handle at `x + 6.1234567, y - 0.00001` (`-0.0` at 4 decimals when+/// `y` is 0), filled with a color whose components have many digits.+fn precise_path(d: &mut Document, x: f64, y: f64) -> Node {+    let p = Point::new(x, y);+    let a = Anchor { p, h_in: p, h_out: Point::new(x + 6.1234567, y - 0.00001), kind: Default::default() };+    let sp = SubPath::new(vec![a, Anchor::corner(Point::new(x + 20.0, y))], false);+    let fill = Paint::solid(Color::rgb(0.02745098, 0.33333334, 0.9019608));+    Node::path(d.alloc_id(), PathData::single(sp), Appearance::basic(fill, Paint::None, 0.0))+}++/// A document with a precise path in the layer, in an opacity mask, in a symbol (placed with a+/// precise transform) and in a brush kept as foreign data.+fn precise_doc() -> Document {+    let mut d = Document::new(100.0, 100.0);+    let layer = d.layers[0].id;+    let mut n = precise_path(&mut d, 3.4876474632797176, 3.499999999999999);+    n.extras_mut().mask = Some(Box::new(OpacityMask::new(precise_path(&mut d, 7.77777777, 1.0), false)));+    d.insert(Some(layer), 0, n).unwrap();+    let art = precise_path(&mut d, 1.23456789, 0.0);+    d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) });+    let xf = Affine::new([1.0, 0.0, 0.0, 1.0, 0.123456789, 0.0]);+    let id = d.alloc_id();+    d.insert(Some(layer), 1, Node::new(id, NodeKind::SymbolInstance { symbol: "S".into(), xf })).unwrap();+    let brush = serde_json::to_value(precise_path(&mut d, 5.55555555, 4.0)).unwrap();+    d.unknown.insert("brushes".into(), serde_json::json!([{"name": "B", "type": "art", "art": brush}]));+    d+}++fn first_anchor(n: &Node) -> Anchor {+    let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {n:?}") };+    path.subpaths[0].anchors[0]+}++#[test]+fn precision_rounds_path_anchors_and_handles_only() {+    let d = precise_doc();+    let bytes = save_with(&d, &SaveOptions { precision: Some(4), ..SaveOptions::default() }).unwrap();+    let text = String::from_utf8(bytes.clone()).unwrap();+    assert!(!text.contains("-0.0"), "a handle rounded to zero is written as 0.0");+    let back = load(&bytes).unwrap();+    let path = &back.layers[0].children().unwrap()[0];+    let a = first_anchor(path);+    assert_eq!((a.p, a.h_in, a.h_out), (Point::new(3.4876, 3.5), Point::new(3.4876, 3.5), Point::new(9.6111, 3.5)));+    let mask = path.extras().mask.as_ref().unwrap();+    assert_eq!(first_anchor(&mask.art).p, Point::new(7.7778, 1.0));+    let a = first_anchor(&back.symbols[0].art);+    assert_eq!((a.p, a.h_out), (Point::new(1.2346, 0.0), Point::new(7.358, 0.0)));+    let brush = &back.unknown["brushes"][0]["art"]["kind"]["path"]["subpaths"][0]["anchors"][0];+    assert_eq!((brush["p"][0].as_f64(), brush["out"][0].as_f64()), (Some(5.5556), Some(11.679)));+    // Colors and transforms keep every digit.+    let original = &d.layers[0].children().unwrap()[0];+    assert_eq!(path.appearance, original.appearance);+    assert_eq!(mask.art.appearance, original.extras().mask.as_ref().unwrap().art.appearance);+    let NodeKind::SymbolInstance { xf, .. } = &back.layers[0].children().unwrap()[1].kind else { panic!("not an instance") };+    assert_eq!(xf.as_coeffs()[4], 0.123456789);+    // The document saved is left as it was.+    assert_eq!(first_anchor(&d.layers[0].children().unwrap()[0]).p.x, 3.4876474632797176);+}++#[test]+fn precision_rounds_the_paths_objects_keep_besides_their_own() {+    let mut d = Document::new(100.0, 100.0);+    let layer = d.layers[0].id;+    let path = || PathData::single(SubPath::polyline(&[Point::new(1.23456789, 0.0), Point::new(9.87654321, 5.0)], false));+    // Live Corners' uncut outline, and an Intertwine area.+    let mut n = precise_path(&mut d, 1.0, 1.0);+    if let NodeKind::Path { live, .. } = &mut n.kind {+        *live = Some(Box::new(LiveShape::Path { base: path(), radii: vec![2.0], kinds: vec![], radii_relative: false }));+    }+    n.extras_mut().intertwine = Some(Box::new(path()));+    d.insert(Some(layer), 0, n).unwrap();+    // A blend's replaced spine and an envelope's top-object outline.+    let keys = vec![Arc::new(precise_path(&mut d, 1.0, 1.0)), Arc::new(precise_path(&mut d, 50.0, 1.0))];+    let blend = BlendSpec { spine: Some(path()), ..BlendSpec::default() };+    let id = d.alloc_id();+    d.insert(Some(layer), 1, Node::new(id, NodeKind::Blend { children: keys, spec: Box::new(blend) })).unwrap();+    let envelope = NodeKind::Envelope {+        content: vec![Arc::new(precise_path(&mut d, 1.0, 1.0))],+        kind: Box::new(EnvelopeKind::TopObject { path: path() }),+        fidelity: 50.0,+        editing: false,+        options: Default::default(),+        frame: Default::default(),+    };+    let id = d.alloc_id();+    d.insert(Some(layer), 2, Node::new(id, envelope)).unwrap();+    // The same three objects as brushes' art, and type, whose frame stays exact.+    let mut brushes: Vec<Value> = d.layers[0].children().unwrap().iter().map(|n| serde_json::json!({"art": n})).collect();+    let frame = serde_json::json!({"subpaths": [{"anchors": [{"p": [1.23456789, 0.0]}], "closed": true}]});+    brushes.push(serde_json::json!({"art": {"id": 99, "kind": {"type": "text", "kind": "area", "frame": frame}}}));+    d.unknown.insert("brushes".into(), Value::Array(brushes));+    let text = |precision| String::from_utf8(save_with(&d, &SaveOptions { precision, ..SaveOptions::default() }).unwrap()).unwrap();+    let exact = text(None);+    assert_eq!((exact.matches("1.23456789").count(), exact.matches("9.87654321").count()), (9, 8), "{exact}");+    let rounded = text(Some(4));+    assert_eq!((rounded.matches("1.23456789").count(), rounded.matches("9.87654321").count()), (1, 0), "{rounded}");+    assert_eq!((rounded.matches("1.2346").count(), rounded.matches("9.8765").count()), (8, 8), "{rounded}");+}++#[test]+fn without_precision_coordinates_are_written_exactly() {+    let d = precise_doc();+    let exact = save_with(&d, &SaveOptions::default()).unwrap();+    let text = String::from_utf8(exact.clone()).unwrap();+    for x in ["3.4876474632797176", "3.499999999999999", "7.77777777", "1.23456789", "5.55555555"] {+        assert!(text.contains(x), "{x}");+    }+    assert_eq!(first_anchor(&load(&exact).unwrap().layers[0].children().unwrap()[0]).p.x, 3.4876474632797176);+    // A precision over MAX_PRECISION rounds to MAX_PRECISION decimals.+    let at = |p| save_with(&d, &SaveOptions { precision: Some(p), ..SaveOptions::default() }).unwrap();+    assert_eq!(at(200), at(MAX_PRECISION));+    assert!(at(0).len() < at(MAX_PRECISION).len());+}diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs--- a/crates/mcp/src/tests_fileio.rs+++ b/crates/mcp/src/tests_fileio.rs@@ -195,6 +195,15 @@ fn save_file_takes_the_native_save_options() {     let shown = saved_with(&mut h, "preview.vectorcraft", json!({"preview": true}));     assert!(vectorcraft_format::preview(&shown).is_some(), "a preview");     assert!(vectorcraft_format::preview(&plain).is_none(), "off by default");+    // `precision` rounds the path coordinates of that save only.+    let mut p = Headless::new();+    p.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 100}})).unwrap();+    p.call("engine.execute", json!({"command": "path.create", "params": {"anchors": [{"x": 10.123456, "y": 20.654321}, {"x": 30.5, "y": 40.25}]}}))+        .unwrap();+    let exact = String::from_utf8(saved_with(&mut p, "exact.vectorcraft", json!({"compress": false}))).unwrap();+    assert!(exact.contains("10.123456") && exact.contains("20.654321"), "{exact}");+    let rounded = String::from_utf8(saved_with(&mut p, "rounded.vectorcraft", json!({"compress": false, "precision": 1}))).unwrap();+    assert!(!rounded.contains("10.123456") && !rounded.contains("20.654321") && rounded.contains("20.7"), "{rounded}");     // Version 2 files can't be compressed: the engine's error comes back.     let r = call_tool(&mut h, "save_file", &json!({"path": tmp("v2.vectorcraft"), "version": 2, "compress": true}));     assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("can't be compressed"), "{r:?}");@@ -232,6 +241,9 @@ fn save_file_warns_about_options_it_does_not_use() {     let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp("ignored.ai"), "compress": false, "version": 3})));     let w = warnings(&r);     assert!(ignored(&w, "version") && !ignored(&w, "compress"), "{w:?}");+    // `precision` is read by native files only.+    let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp("ignored-precision.svg"), "precision": 2})));+    assert!(ignored(&warnings(&r), "precision"), "{r}");      // A top-level value that disagrees with `options` is refused, and nothing is written.     let path = tmp("both.vectorcraft");@@ -241,7 +253,9 @@ fn save_file_warns_about_options_it_does_not_use() {     assert!(r.is_error && e.contains("`compress: true` and `options.compress: false` disagree"), "{r:?}");     assert!(!std::path::Path::new(&path).exists(), "nothing is written");     // Nothing to say when they agree or when every option applies.-    for mut args in [json!({"compress": true, "options": {"compress": true}}), json!({"compress": false, "version": 3, "preview": true})] {+    for mut args in+        [json!({"compress": true, "options": {"compress": true}}), json!({"compress": false, "version": 3, "preview": true, "precision": 4})]+    {         args["path"] = json!(tmp("quiet.vectorcraft"));         let r = text(&call_tool(&mut h, "save_file", &args));         assert_eq!(r["warnings"], json!([]), "{args}: {r}");@@ -272,12 +286,12 @@ fn save_file_is_the_same_call_in_the_app_and_headless() {     let path = tmp("app.vectorcraft");     let (addr, app) = recording_app();     let mut remote = Remote::connect(&addr).unwrap();-    let args = json!({"path": path, "compress": true, "version": 3, "preview": null, "options": {"preview": true}});+    let args = json!({"path": path, "compress": true, "version": 3, "preview": null, "precision": 2, "options": {"preview": true}});     assert!(!call_tool(&mut remote, "save_file", &args).is_error);     drop(remote);     let seen = app.join().unwrap();     assert_eq!(seen[0]["method"], "app.save");-    assert_eq!(seen[0]["params"], json!({"path": path, "compress": true, "version": 3, "options": {"preview": true}, "wait": true}));+    assert_eq!(seen[0]["params"], json!({"path": path, "compress": true, "version": 3, "precision": 2, "options": {"preview": true}, "wait": true})); }  /// The app is asked to write the file before it replies (`wait`), even with Background Save on, so adiff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs--- a/crates/mcp/src/tools.rs+++ b/crates/mcp/src/tools.rs@@ -37,7 +37,7 @@ fn string(desc: &str) -> Value {     json!({"type": "string", "description": desc}) } /// The arguments `save_file` passes on to `document.save` beside `path`.-const SAVE_FILE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "options"];+const SAVE_FILE_OPTIONS: [&str; 5] = ["compress", "version", "preview", "precision", "options"]; /// The arguments `doc_inspect` / `inspect_document` pass on to `document.inspect`. const INSPECT_KEYS: [&str; 6] = ["depth", "childLimit", "ids", "kind", "limit", "offset"]; @@ -363,7 +363,7 @@ pub fn tool_definitions() -> Vec<Value> {             "save_file",             "Save file",             &format!(-                "Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). A native file is gzipped when `compress` is true (default: the useCompression preference, Preferences > File Handling > Use Compression); `version` writes it for an older VectorCraft and `preview` embeds a PNG of the first artboard. `options` carries the format's save options (see run_command document.formats); giving `compress`, `version` or `preview` both at the top level and in `options` with different values is an error. The reply's `format` says what was written and `warnings` what that format loses and each option given that the format doesn't read (such as `compress` for .svg). In the running app the call returns once the file is written, or with the error, even with Background Save on. When the save asks first (before replacing a file that opened with losses, or with a file dialog for an untitled document without `path`), the reply is `pending` or \"cancelled\" as before.",+                "Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). A native file is gzipped when `compress` is true (default: the useCompression preference, Preferences > File Handling > Use Compression); `version` writes it for an older VectorCraft, `preview` embeds a PNG of the first artboard, and `precision` rounds path anchors and handles to that many decimal places for that save only. `options` carries the format's save options (see run_command document.formats); giving `compress`, `version`, `preview` or `precision` both at the top level and in `options` with different values is an error. The reply's `format` says what was written and `warnings` what that format loses and each option given that the format doesn't read (such as `compress` for .svg). In the running app the call returns once the file is written, or with the error, even with Background Save on. When the save asks first (before replacing a file that opened with losses, or with a file dialog for an untitled document without `path`), the reply is `pending` or \"cancelled\" as before.",                 save_extensions().join(", .")             ),             obj(@@ -372,6 +372,7 @@ pub fn tool_definitions() -> Vec<Value> {                     "compress": {"type": "boolean", "description": "Native files: gzip the file (default: the useCompression preference)"},                     "version": {"type": "integer", "minimum": 1, "maximum": vectorcraft_format::VERSION, "description": "Native files: the format version to write (default: the current one; 1 and 2 are never compressed)"},                     "preview": {"type": "boolean", "description": "Native files: embed a PNG of the first artboard, at most 256 px (default false)"},+                    "precision": {"type": "integer", "minimum": 0, "maximum": vectorcraft_format::MAX_PRECISION, "description": "Native files: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact (default: exact)"},                     "options": {"type": "object", "description": "The format's save options, passed through (see run_command document.formats); default: as last saved"},                 }),                 &[],@@ -1198,7 +1199,8 @@ fn named_export_params(a: &Args) -> &'static [&'static str] {  /// What `save_file` with arguments `a` adds to the save's `warnings`, for a file written in format /// `written` (none when nothing was written yet): each key in `options` that the format doesn't-/// read. The save itself warns about `compress`, `version` and `preview` given at the top level.+/// read. The save itself warns about `compress`, `version`, `preview` and `precision` given at the+/// top level. fn save_file_warnings(a: &Args, written: Option<&str>) -> Vec<String> {     let Some(f) = written.and_then(format) else { return vec![] };     let nested = a.get("options").and_then(Value::as_object);diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs--- a/crates/ui-egui/src/dialogs/save_options.rs+++ b/crates/ui-egui/src/dialogs/save_options.rs@@ -74,15 +74,16 @@ fn separates(f: &Format) -> bool {     f.options.iter().any(|o| o.name == "separateArtboards") } -/// The options the dialog shows: arrays and objects are for agents, and a page range replaces the-/// single artboard choice; none for a format with its own dialog. A `.ait` template has no-/// Version: Save as Template writes it PDF-compatible only.+/// The options the dialog shows: arrays and objects are for agents, as is a native file's+/// `precision`, and a page range replaces the single artboard choice; none for a format with its+/// own dialog. A `.ait` template has no Version: Save as Template writes it PDF-compatible only. fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> {     let range = f.options.iter().any(|o| o.name == "range");     let separate = separates(f);     let options = if own_dialog(f) { &[][..] } else { f.options };     options.iter().filter(move |o| {         !(matches!(o.ty, "array" | "object")+            || o.name == "precision"             || (range && o.name == "artboard")             || (separate && o.name == "range")             || (matches!(f.id, "ai" | "ait") && o.name == "useArtboards")@@ -740,6 +741,18 @@ mod tests {         assert!(!names("ait").contains(&"aiVersion"), "{:?}", names("ait"));     } +    /// The native options dialog has no row for `precision`, which agents give with the save+    /// command; the rows it has stay.+    #[test]+    fn the_native_options_have_no_precision_row() {+        for id in ["vectorcraft", "template"] {+            let f = fileio::format(id).unwrap();+            assert!(f.options.iter().any(|o| o.name == "precision"), "{id}");+            let names: Vec<&str> = shown(f).map(|o| o.name).collect();+            assert!(!names.contains(&"precision") && names.contains(&"version") && names.contains(&"compress"), "{id}: {names:?}");+        }+    }+     /// A name typed in the Save as Template panel with a Save As format's extension has it     /// replaced with the template's, as Save As replaces the extension of a format it doesn't write.     #[test]diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -420,7 +420,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |-| `save_file` | `{path?, compress?, version?, preview?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), and `preview: true` embeds a PNG of the first artboard. `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version` or `preview` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version` or `preview`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version` or `preview`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |+| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version`, `preview` or `precision`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |@@ -596,7 +596,11 @@ can't be read becomes a placeholder in its box, named in `warnings` and `missing Native files: `run_command document.save {compress: true}` writes a gzip-compressed `.vectorcraft` (the `useCompression` preference makes that the default for saves; compressed files open like any other, told apart by their content). `version: 2` or `1` writes a file older VectorCraft versions open (format name `drawcraft`, never-compressed), and `preview: true` embeds a PNG of the first artboard, at most 256 px on its longer side. A save writes+compressed), and `preview: true` embeds a PNG of the first artboard, at most 256 px on its longer side.+`precision: 4` (0 to 9) rounds the anchors and handles of paths to that many decimal places (in the layers, symbols,+graph designs, patterns, opacity masks and brushes); colors, transforms, opacity and type are written exactly.+The embedded preview and the PDF that `pdfCompatible: true` adds show the rounded paths. Without `precision` a save writes the coordinates exactly, and Save does not remember it. A `.ai` file ignores+`precision`. A save writes a temporary file and renames it over the old one, so a failed save never damages the file; only the images the document uses are written, and document keys written by a newer version are kept. An uncompressed file is indented JSON when the document has at most 2,000 layers and objects and the indented