diff --git a/README.md b/README.md index a24b33980..611110c3c 100644 --- 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 in diff --git a/apps/cadcraft-cli/src/convert.rs b/apps/cadcraft-cli/src/convert.rs index 6a9c4edbf..35ffc6aed 100644 --- a/apps/cadcraft-cli/src/convert.rs +++ b/apps/cadcraft-cli/src/convert.rs @@ -1,6 +1,6 @@ use super::args::ConvertArgs; use super::error::Result; -use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message}; +use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message}; use super::{open, unicode}; use cadcraft_engine::Session; @@ -9,7 +9,8 @@ pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result { let framing = a.framing.framing(); let mut s = Session::empty(); open(&mut s, &a.input)?; - if let Some(m) = skipped_message(s.doc()?, i) { + let d = s.doc()?; + for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) { io.errln(format_args!("{m}")); } if cadcraft_io::is_image(&a.output) diff --git a/apps/cadcraft-cli/src/output.rs b/apps/cadcraft-cli/src/output.rs index d783267dc..5716c486c 100644 --- a/apps/cadcraft-cli/src/output.rs +++ b/apps/cadcraft-cli/src/output.rs @@ -165,6 +165,12 @@ pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Some(format!("{path}: {n} {entities} not drawn ({})", 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. +pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec { + d.read_notes.iter().map(|note| format!("{path}: {note}")).collect() +} + /// The warning for the records of `path` the reader could not place, or `None` when there are none. pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option { let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)); @@ -175,3 +181,20 @@ pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> let records = if n == 1 { "record" } else { "records" }; Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", "))) } + +#[cfg(test)] +mod tests { + use super::read_note_messages; + + #[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"] + ); + } +} diff --git a/apps/cadcraft-cli/tests/read_notes.rs b/apps/cadcraft-cli/tests/read_notes.rs new file mode 100644 index 000000000..2f443f03a --- /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, Vec) { + 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 index 364fa6796..283551dfd 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -596,6 +596,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result { "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 index 98db28330..d10d43abb 100644 --- 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). |