Merge origin/main into plugin/host-model-api

Conflicts were additive: main's AddLayer/ModifyLayer (LayerConfig, OptHandle)
alongside this branch's v7 system-variable, transaction, selection and solid
operations. Main's variants keep their positions in the IPC enums; the v7
additions follow them. src/io/mod.rs takes main's reworded Shape-rotation note.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 15:12:58 +02:00
commit 6b8056cfeb
28 files changed
+1425 -97

No files matched your search

+151 -1
View File
@@ -352,6 +352,14 @@ impl OpenCADStudio {
let i = self.active_tab;
let before = self.tabs[i].scene.document.entities().count();
let error_revision = self.command_line.error_revision;
// Surfaces that are already open belong to an earlier line (or
// another tab, or startup). Only what *this* line left open is
// reported as a blocker, otherwise a finished command would keep
// answering `waiting_input` because of someone else's editor and
// a caller polling for `completed` would never get there.
let editor_before = self.text_inline.is_some();
let mtext_before = self.mtext_editor.is_some();
let modal_before = self.active_modal.is_some();
if let Err(error) = self.run_headless(&cmd) {
return err(error);
}
@@ -359,12 +367,32 @@ impl OpenCADStudio {
return err(self.command_line.last_error.clone().unwrap_or_default());
}
let after = self.tabs[i].scene.document.entities().count();
// A command can finish with an interactive surface still open:
// the in-place text editor (the `TEXT` content step), the MTEXT
// editor, or a modal. Reporting `completed` there hides the fact
// that nothing was committed, so reuse the `waiting_input`
// vocabulary and name the blocker.
let blocked_by: Option<String> = if self.tabs[i].active_cmd.is_some() {
Some("command".to_string())
} else if let (false, Some(modal)) = (modal_before, self.active_modal.as_ref()) {
Some(format!("modal:{modal:?}"))
} else if !editor_before && self.text_inline.is_some() {
Some("text_editor".to_string())
} else if !mtext_before && self.mtext_editor.is_some() {
Some("mtext_editor".to_string())
} else {
None
};
json!({
"ok": true,
"cmd": cmd,
"status": if self.tabs[i].active_cmd.is_some() { "waiting_input" } else { "completed" },
"status": if blocked_by.is_some() { "waiting_input" } else { "completed" },
"blocked_by": blocked_by,
"entities": after,
"added": after as i64 - before as i64,
// Tokens no prompt ever asked for: leftover input is
// reported rather than dropped on the floor.
"unconsumed": self.command_line.unconsumed.clone(),
})
}
"entities" => self.entity_summary(),
@@ -814,6 +842,128 @@ mod tests {
assert!(app.tabs[i].scene.last_tess_wires.get() > before);
}
#[test]
fn batch_text_line_creates_text_and_consumes_every_token() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
// Regression: the content token used to be dropped once the in-place
// editor took over, so this line created nothing while reporting ok.
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 0,0 5 0 hello"}"#);
assert_eq!(r["ok"], true);
assert_eq!(r["status"], "completed", "{r}");
assert_eq!(r["added"], 1, "{r}");
assert_eq!(r["unconsumed"].as_array().map(|v| v.len()), Some(0), "{r}");
let counts = app.automation_op(r#"{"op":"entities"}"#);
assert_eq!(counts["by_type"]["Text"], 1, "{counts}");
assert_eq!(counts["total"], 1, "no phantom entity: {counts}");
assert_eq!(assert_one_text(&app), "hello");
}
#[test]
fn multi_word_text_keeps_the_spaces() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 0,0 5 0 hello world"}"#);
assert_eq!(r["status"], "completed", "{r}");
assert_eq!(r["added"], 1, "{r}");
assert_eq!(r["unconsumed"].as_array().map(|v| v.len()), Some(0), "{r}");
let counts = app.automation_op(r#"{"op":"entities"}"#);
assert_eq!(counts["by_type"]["Text"], 1, "{counts}");
// The spaces between the words have to survive the token split, and the
// whole tail (not just its first token) has to land in the entity — an
// off-by-one in the consumed index would still pass the checks above.
assert_eq!(assert_one_text(&app), "hello world");
}
#[test]
fn a_stray_editor_is_not_hijacked_by_the_next_line() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
// Line 1 stops at the content step and leaves the editor open.
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 30,0 5 0"}"#);
assert_eq!(r["blocked_by"], "text_editor", "{r}");
// Line 2 must not type its leftover token into that unrelated editor,
// and — since line 2 did not open anything itself — it must not inherit
// line 1's blocker either: a caller that polls for `completed` would
// otherwise never get there.
let r = app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 5 9"}"#);
assert_eq!(r["added"], 1, "{r}");
assert_eq!(r["unconsumed"][0], "9", "{r}");
assert_eq!(r["blocked_by"], serde_json::Value::Null, "{r}");
assert_eq!(r["status"], "completed", "{r}");
let counts = app.automation_op(r#"{"op":"entities"}"#);
assert_eq!(counts["by_type"]["Circle"], 1, "{counts}");
assert!(
counts["by_type"].get("Text").is_none(),
"phantom text from stray editor: {counts}"
);
}
#[test]
fn run_reports_leftover_tokens_instead_of_dropping_them() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
// The radius step ends the command, so the extra token is never asked for.
let r = app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 5 9"}"#);
assert_eq!(r["added"], 1, "{r}");
assert_eq!(r["unconsumed"][0], "9", "{r}");
// A fully consumed line reports nothing left over.
let r = app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#);
assert_eq!(r["unconsumed"].as_array().map(|v| v.len()), Some(0), "{r}");
}
#[test]
fn text_without_content_reports_the_open_editor() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let r = app.automation_op(r#"{"op":"run","cmd":"TEXT 0,0 5 0"}"#);
assert_eq!(r["status"], "waiting_input", "{r}");
assert_eq!(r["blocked_by"], "text_editor", "{r}");
let counts = app.automation_op(r#"{"op":"entities"}"#);
assert_eq!(counts["total"], 0, "{counts}");
// The same editor can be finished through the control-surface messages.
let _ = app.update(crate::app::Message::TextInlineInput("hello".into()));
let _ = app.update(crate::app::Message::TextInlineOk);
let counts = app.automation_op(r#"{"op":"entities"}"#);
assert_eq!(counts["by_type"]["Text"], 1, "{counts}");
}
#[test]
fn a_command_still_waiting_is_named_as_the_blocker() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let r = app.automation_op(r#"{"op":"run","cmd":"LINE"}"#);
assert_eq!(r["status"], "waiting_input", "{r}");
assert_eq!(r["blocked_by"], "command", "{r}");
}
/// The text of the single `Text` entity in the drawing (panics otherwise).
fn assert_one_text(app: &OpenCADStudio) -> String {
let i = app.active_tab;
let texts: Vec<String> = app.tabs[i]
.scene
.document
.entities()
.filter_map(|e| match e {
acadrust::EntityType::Text(t) => Some(t.value.clone()),
_ => None,
})
.collect();
assert_eq!(texts.len(), 1, "expected exactly one Text entity, got {texts:?}");
texts.into_iter().next().unwrap()
}
#[test]
fn automation_ops_round_trip() {
let mut app = OpenCADStudio::new_for_test();