mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 00:42:46 -06:00
A damaged DWG opens partially since #859 and the drawing's read notes say how many object records were left out. OPEN echoes them and MCP `open` returns them as `warnings`, but `cadcraft-cli info` and `convert` stayed silent: a corpus round trip wrote thousands of skipped records away without a word. - convert: each read note goes to stderr as `IN: <note>`, next to the skipped-records warning (#872). The exit code stays 0. - drawing.inspect (and so info, MCP inspect_drawing and the control channel): new field `readNotes`, next to `skipped`. Existing fields are unchanged. - run already prints OPEN's notes with the session log. - README and docs/control-protocol.md say where the notes appear. Part of #1039. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3f57f8d3d4
commit
5ec0d215d8
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -596,6 +596,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). |
|
||||
|
||||
Reference in New Issue
Block a user