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
@@ -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 in@@ -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());new file mode 100644@@ -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);+}@@ -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))@@ -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). |