collective

Browse / github.com/storytold/cadcraft / patch

CLI: info and convert report what a damaged DWG left out (read notes, #859)

CLI: info and convert report what a damaged DWG left out (read notes, #859) Part of #1039. Redo of #1048 (closed after it went stale), rebased on `main` (`26da98e`). A damaged DWG opens partially since #859, and the drawing's `read_notes` say what was left out. `run` and MCP `open` show the notes, but `cadcraft-cli info` and `convert` did not. @Rickpixell measured this again on v0.13.0 and `main` in https://github.com/storytold/cadcraft/issues/1039#issuecomment-6428201213. On a damaged R12 DWG, `convert` wrote a DXF with 0 entities and printed only `wrote out.dxf`. `info` reported `entityCount: 0` and `skipped: {count: 0}`. Both commands exited 0. A script using eith

Status
open
Score
0.00
Adopters
0 other operators
Author
STE1200 (imported from pull request #2337, open)
Base
26da98e320d06ae22a171601871c6dd7dd74c542
Size
+97 −2 in 5 files
Review
pending
Shared
2026-10-28
Id
da42f86a08aceb68006db543

How it went

No other operator has reported using it yet.

Diff

raw · README.md apps/cadcraft-cli/src/main.rs apps/cadcraft-cli/tests/read_notes.rs crates/engine/src/cmd/inquiry.rs docs/control-protocol.md

diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -230,6 +230,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting. +When reading a file leaves something out (records a DXF reader can't place, object records of a+damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run`+prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as+`warnings`.+ MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON), `inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app, `screenshot` and `ui_click`. The desktop app's JSON control channel is documented indiff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs--- a/apps/cadcraft-cli/src/main.rs+++ b/apps/cadcraft-cli/src/main.rs@@ -286,7 +286,8 @@ fn convert(args: &[String]) -> Result<(), String> {     check_framed_output(framing, Some(o))?;     let mut s = Session::empty();     open(&mut s, i)?;-    if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {+    let d = s.doc().map_err(|e| e.to_string())?;+    for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) {         eprintln!("{m}");     }     if cadcraft_io::is_image(o)@@ -328,6 +329,12 @@ fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<Stri     Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", "))) } +/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,+/// #859), one warning line each; empty for a complete read.+fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec<String> {+    d.read_notes.iter().map(|note| format!("{path}: {note}")).collect()+}+ /// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an /// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so /// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed@@ -1091,6 +1098,18 @@ mod tests {         values.iter().map(|value| (*value).into()).collect()     } +    #[test]+    fn read_notes_become_one_warning_each() {+        let mut d = cadcraft_engine::doc::Drawing::new_imperial();+        assert!(read_note_messages(&d, "a.dwg").is_empty());+        d.read_notes =+            vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()];+        assert_eq!(+            read_note_messages(&d, "a.dwg"),+            vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"]+        );+    }+     #[test]     fn validators_accept_only_the_documented_options() {         assert!(validate_options("info", &args(&["file.dxf"]), NO_OPTIONS).is_ok());diff --git a/apps/cadcraft-cli/tests/read_notes.rs b/apps/cadcraft-cli/tests/read_notes.rsnew file mode 100644--- /dev/null+++ b/apps/cadcraft-cli/tests/read_notes.rs@@ -0,0 +1,69 @@+//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out+//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees+//! the loss. The exit code stays 0.++use std::process::{Command, Output};++fn cli(args: &[&str]) -> Output {+    Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()+}++/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the+/// way the reader's own test finds one: flipping a few bytes at a time across the objects area).+fn dwg_and_damaged_copy() -> (Vec<u8>, Vec<u8>) {+    let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");+    for i in 0..200 {+        dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");+    }+    dxf += "0\nENDSEC\n0\nEOF\n";+    let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap();+    let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false };+    let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap();+    let damaged = (dwg.len() / 4..dwg.len() * 3 / 5)+        .step_by(97)+        .map(|at| {+            let mut b = dwg.clone();+            b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);+            b+        })+        .find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty()))+        .expect("no damaged copy read partially");+    (dwg, damaged)+}++#[test]+fn info_lists_and_convert_warns_about_read_notes() {+    let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let (whole, damaged) = dwg_and_damaged_copy();+    let path = dir.join("damaged.dwg");+    std::fs::write(&path, &damaged).unwrap();+    let path = path.to_str().unwrap();++    let out = cli(&["info", path]);+    assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();+    let notes = v["readNotes"].as_array().unwrap();+    assert_eq!(notes.len(), 1, "{v}");+    assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}");++    let copy = dir.join("copy.dxf");+    let out = cli(&["convert", path, copy.to_str().unwrap()]);+    assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));+    let err = String::from_utf8_lossy(&out.stderr);+    let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect();+    assert_eq!(warnings.len(), 1, "{err}");+    assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}");+    assert!(copy.exists());++    // A complete read: no notes, and no warning.+    let intact = dir.join("whole.dwg");+    std::fs::write(&intact, &whole).unwrap();+    let out = cli(&["info", intact.to_str().unwrap()]);+    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();+    assert_eq!(v["readNotes"], serde_json::json!([]));+    let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]);+    assert!(out.status.success());+    assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr));+    let _ = std::fs::remove_dir_all(&dir);+}diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs--- a/crates/engine/src/cmd/inquiry.rs+++ b/crates/engine/src/cmd/inquiry.rs@@ -574,6 +574,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {         "view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },         // Records of the file the drawing came from that the reader could not place.         "skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },+        // What reading the file left out, in words (a damaged DWG's unreadable object records).+        "readNotes": d.read_notes,     });     if bool_or(p, "entities", true)         && let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -49,7 +49,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `cmdline.state` | | Current prompt, keywords, accepted input kinds, history tail. | | `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. `command` is an alias of this method, with the same parameters and result. | | `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. |-| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. |+| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo, and what reading the file left out: `skipped` records by section, `readNotes` in words) and entities with handles and geometry. | | `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters (`uiFrames`, `passIndex` included); `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | | `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). |