forked from 1b-agent/opencadstudio-upstream
Route coordinate input, geometry creation, transforms, properties,\nviews, and viewport persistence through a shared working plane.\n\nRefs #484
993 lines
39 KiB
Rust
993 lines
39 KiB
Rust
//! Headless automation server (`OpenCADStudio --serve`).
|
|
//!
|
|
//! Drives the app without a GUI over a line-based JSON protocol: one request
|
|
//! object per line on stdin, one response object per line on stdout. State (the
|
|
//! active document) persists across requests, so an external process — a script
|
|
//! or an AI agent — can act, observe, and act again.
|
|
//!
|
|
//! Operations:
|
|
//! - `{"op":"new"}` — start an empty document
|
|
//! - `{"op":"open","path":"file.dwg"}` — load a drawing
|
|
//! - `{"op":"run","cmd":"LAYER Walls"}` — run a command (the same dispatcher
|
|
//! the GUI command line uses)
|
|
//! - `{"op":"entities"}` — summary count by entity type
|
|
//! - `{"op":"save","path":"out.dwg"}` — write the document (path optional
|
|
//! once opened/saved)
|
|
|
|
use std::io::{BufRead, Write};
|
|
use std::path::PathBuf;
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
use super::OpenCADStudio;
|
|
|
|
/// Run the headless JSON server. Default transport is stdin/stdout; with
|
|
/// `--port <N>` it instead listens on `127.0.0.1:<N>` and serves one client at
|
|
/// a time (the document session persists across reconnects).
|
|
pub fn serve() {
|
|
let mut app = OpenCADStudio::new();
|
|
match port_arg() {
|
|
Some(port) => serve_socket(&mut app, port),
|
|
None => serve_stdio(&mut app),
|
|
}
|
|
}
|
|
|
|
/// Headless one-shot format conversion (`--export IN OUT`). Loads `input`,
|
|
/// writes `output` (format chosen from `output`'s extension), and returns a
|
|
/// process exit code (0 on success). No window is created.
|
|
pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32 {
|
|
let doc = match crate::io::load_file(input) {
|
|
Ok(doc) => doc,
|
|
Err(e) => {
|
|
eprintln!("export: cannot read {}: {e}", input.display());
|
|
return 1;
|
|
}
|
|
};
|
|
match crate::io::save(&doc, output) {
|
|
Ok(()) => {
|
|
println!("Exported {} → {}", input.display(), output.display());
|
|
0
|
|
}
|
|
Err(e) => {
|
|
eprintln!("export: cannot write {}: {e}", output.display());
|
|
1
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `--port <N>` if present on the command line.
|
|
fn port_arg() -> Option<u16> {
|
|
let mut args = std::env::args();
|
|
while let Some(a) = args.next() {
|
|
if a == "--port" {
|
|
return args.next().and_then(|s| s.parse().ok());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn ready() -> Value {
|
|
json!({ "ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION") })
|
|
}
|
|
|
|
fn serve_stdio(app: &mut OpenCADStudio) {
|
|
let stdin = std::io::stdin();
|
|
let stdout = std::io::stdout();
|
|
{
|
|
let mut o = stdout.lock();
|
|
let _ = writeln!(o, "{}", ready());
|
|
let _ = o.flush();
|
|
}
|
|
for line in stdin.lock().lines() {
|
|
let Ok(line) = line else { break };
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let resp = app.automation_op(line);
|
|
let mut o = stdout.lock();
|
|
let _ = writeln!(o, "{resp}");
|
|
let _ = o.flush();
|
|
}
|
|
}
|
|
|
|
fn serve_socket(app: &mut OpenCADStudio, port: u16) {
|
|
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
eprintln!("--serve: cannot bind 127.0.0.1:{port}: {e}");
|
|
return;
|
|
}
|
|
};
|
|
eprintln!("OpenCADStudio --serve listening on 127.0.0.1:{port}");
|
|
for stream in listener.incoming().flatten() {
|
|
let Ok(read_half) = stream.try_clone() else {
|
|
continue;
|
|
};
|
|
let reader = std::io::BufReader::new(read_half);
|
|
let mut writer = stream;
|
|
let _ = writeln!(writer, "{}", ready());
|
|
let _ = writer.flush();
|
|
for line in reader.lines() {
|
|
let Ok(line) = line else { break };
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let resp = app.automation_op(line);
|
|
if writeln!(writer, "{resp}").is_err() {
|
|
break;
|
|
}
|
|
let _ = writer.flush();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn err(msg: impl std::fmt::Display) -> Value {
|
|
json!({ "ok": false, "error": msg.to_string() })
|
|
}
|
|
|
|
fn v3(v: acadrust::types::Vector3) -> Value {
|
|
json!([v.x, v.y, v.z])
|
|
}
|
|
|
|
/// One entity as JSON: handle, type, layer, plus basic geometry for the common
|
|
/// types (others carry only the common fields).
|
|
fn entity_json(e: &acadrust::EntityType) -> Value {
|
|
use acadrust::EntityType as E;
|
|
let c = e.common();
|
|
let mut obj = json!({
|
|
"handle": format!("{:X}", c.handle.value()),
|
|
"type": crate::entities::names::ui_name(e),
|
|
"layer": c.layer,
|
|
});
|
|
let map = obj.as_object_mut().expect("json object");
|
|
match e {
|
|
E::Line(l) => {
|
|
map.insert("start".into(), v3(l.start));
|
|
map.insert("end".into(), v3(l.end));
|
|
}
|
|
E::Circle(cc) => {
|
|
map.insert("center".into(), v3(cc.center));
|
|
map.insert("radius".into(), json!(cc.radius));
|
|
}
|
|
E::Arc(a) => {
|
|
map.insert("center".into(), v3(a.center));
|
|
map.insert("radius".into(), json!(a.radius));
|
|
map.insert("start_angle".into(), json!(a.start_angle));
|
|
map.insert("end_angle".into(), json!(a.end_angle));
|
|
}
|
|
E::Point(p) => {
|
|
map.insert("location".into(), v3(p.location));
|
|
}
|
|
E::Ellipse(el) => {
|
|
map.insert("center".into(), v3(el.center));
|
|
map.insert("major_axis".into(), v3(el.major_axis));
|
|
}
|
|
E::Text(t) => {
|
|
map.insert("value".into(), json!(t.value));
|
|
map.insert("position".into(), v3(t.insertion_point));
|
|
map.insert("height".into(), json!(t.height));
|
|
}
|
|
E::MText(t) => {
|
|
map.insert("value".into(), json!(t.value));
|
|
map.insert("position".into(), v3(t.insertion_point));
|
|
map.insert("height".into(), json!(t.height));
|
|
}
|
|
E::LwPolyline(pl) => {
|
|
let pts: Vec<Value> = pl
|
|
.vertices
|
|
.iter()
|
|
.map(|v| json!([v.location.x, v.location.y]))
|
|
.collect();
|
|
map.insert("vertices".into(), json!(pts));
|
|
}
|
|
E::Insert(ins) => {
|
|
map.insert("block".into(), json!(ins.block_name));
|
|
}
|
|
_ => {}
|
|
}
|
|
obj
|
|
}
|
|
|
|
impl OpenCADStudio {
|
|
/// Handle one JSON request line and return the JSON response.
|
|
pub(crate) fn automation_op(&mut self, line: &str) -> Value {
|
|
let req: Value = match serde_json::from_str(line) {
|
|
Ok(v) => v,
|
|
Err(e) => return err(format!("invalid JSON: {e}")),
|
|
};
|
|
match req["op"].as_str().unwrap_or("") {
|
|
"new" => {
|
|
let i = self.active_tab;
|
|
self.tabs[i].scene.document = acadrust::CadDocument::new();
|
|
self.tabs[i].current_path = None;
|
|
// The headless session starts on the welcome (Start) tab, which
|
|
// blocks drawing commands; turn it into a real drawing.
|
|
self.tabs[i].is_start = false;
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.entity_summary()
|
|
}
|
|
"open" => {
|
|
let Some(path) = req["path"].as_str() else {
|
|
return err("open: missing \"path\"");
|
|
};
|
|
let bytes = match std::fs::read(path) {
|
|
Ok(b) => b,
|
|
Err(e) => return err(format!("open: {e}")),
|
|
};
|
|
let name = PathBuf::from(path)
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| path.to_string());
|
|
match crate::io::load_bytes(&name, bytes) {
|
|
Ok(doc) => {
|
|
let i = self.active_tab;
|
|
self.tabs[i].scene.document = doc;
|
|
crate::app::style_ops::ensure_standard_styles(
|
|
&mut self.tabs[i].scene.document,
|
|
);
|
|
self.tabs[i].adopt_active_ucs_from_header();
|
|
self.tabs[i].current_path = Some(PathBuf::from(path));
|
|
self.tabs[i].is_start = false;
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.entity_summary()
|
|
}
|
|
Err(e) => err(format!("open: {e}")),
|
|
}
|
|
}
|
|
"run" => {
|
|
let cmd = req["cmd"].as_str().unwrap_or("").to_string();
|
|
if cmd.is_empty() {
|
|
return err("run: missing \"cmd\"");
|
|
}
|
|
let i = self.active_tab;
|
|
let before = self.tabs[i].scene.document.entities().count();
|
|
self.run_headless(&cmd);
|
|
let after = self.tabs[i].scene.document.entities().count();
|
|
json!({
|
|
"ok": true,
|
|
"cmd": cmd,
|
|
"entities": after,
|
|
"added": after as i64 - before as i64,
|
|
})
|
|
}
|
|
"entities" => self.entity_summary(),
|
|
"query" => self.entity_query(&req),
|
|
"layers" => {
|
|
let i = self.active_tab;
|
|
let layers: Vec<Value> = self
|
|
.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.iter()
|
|
.map(|l| {
|
|
let mut o = json!({
|
|
"name": l.name,
|
|
"off": l.is_off(),
|
|
"frozen": l.is_frozen(),
|
|
"locked": l.is_locked(),
|
|
});
|
|
let m = o.as_object_mut().expect("json object");
|
|
if let Some(aci) = l.color.index() {
|
|
m.insert("color".into(), json!(aci));
|
|
}
|
|
if let Some((r, g, b)) = l.color.rgb() {
|
|
m.insert("rgb".into(), json!([r, g, b]));
|
|
}
|
|
o
|
|
})
|
|
.collect();
|
|
json!({
|
|
"ok": true,
|
|
"current": self.tabs[i].scene.document.header.current_layer_name,
|
|
"layers": layers,
|
|
})
|
|
}
|
|
"header" => {
|
|
let h = &self.tabs[self.active_tab].scene.document.header;
|
|
json!({
|
|
"ok": true,
|
|
"current_layer": h.current_layer_name,
|
|
"current_text_style": h.current_text_style_name,
|
|
"insertion_units": h.insertion_units,
|
|
"pdmode": h.point_display_mode,
|
|
"pdsize": h.point_display_size,
|
|
"ltscale": h.linetype_scale,
|
|
"annotation_scale_value": h.annotation_scale_value,
|
|
})
|
|
}
|
|
"undo" => {
|
|
let _ = self.update(super::Message::Undo);
|
|
self.entity_summary()
|
|
}
|
|
"redo" => {
|
|
let _ = self.update(super::Message::Redo);
|
|
self.entity_summary()
|
|
}
|
|
"select" => {
|
|
let i = self.active_tab;
|
|
self.tabs[i].scene.deselect_all();
|
|
if req["clear"].as_bool() != Some(true) {
|
|
// By explicit handles (hex, as returned by `query`).
|
|
if let Some(arr) = req["handles"].as_array() {
|
|
for h in arr.iter().filter_map(|h| h.as_str()) {
|
|
if let Ok(v) = u64::from_str_radix(h.trim_start_matches("0x"), 16) {
|
|
self.tabs[i].scene.select_entity(acadrust::Handle::new(v), false);
|
|
}
|
|
}
|
|
}
|
|
// Or by type / layer.
|
|
let type_filter = req["type"].as_str();
|
|
let layer_filter = req["layer"].as_str();
|
|
if type_filter.is_some() || layer_filter.is_some() {
|
|
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.filter(|e| {
|
|
type_filter.is_none_or(|t| {
|
|
crate::entities::names::ui_name(e).eq_ignore_ascii_case(t)
|
|
})
|
|
})
|
|
.filter(|e| layer_filter.is_none_or(|l| e.common().layer == l))
|
|
.map(|e| e.common().handle)
|
|
.collect();
|
|
for h in handles {
|
|
self.tabs[i].scene.select_entity(h, false);
|
|
}
|
|
}
|
|
}
|
|
json!({ "ok": true, "selected": self.tabs[i].scene.selected_entities().len() })
|
|
}
|
|
"save" => {
|
|
let i = self.active_tab;
|
|
let path = req["path"]
|
|
.as_str()
|
|
.map(PathBuf::from)
|
|
.or_else(|| self.tabs[i].current_path.clone());
|
|
let Some(path) = path else {
|
|
return err("save: no \"path\" and the document has none");
|
|
};
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let result =
|
|
self.save_tab_synchronously_protected(i, path.clone(), true);
|
|
#[cfg(target_arch = "wasm32")]
|
|
let result = crate::io::save(&self.tabs[i].scene.document, &path)
|
|
.map_err(crate::io::SaveFailure::other);
|
|
match result {
|
|
Ok(()) => {
|
|
json!({ "ok": true, "saved": path.to_string_lossy() })
|
|
}
|
|
Err(e) => err(format!("save: {e}")),
|
|
}
|
|
}
|
|
"" => err("missing \"op\""),
|
|
other => err(format!("unknown op: {other}")),
|
|
}
|
|
}
|
|
|
|
/// Run a command line headlessly. Thin wrapper over the shared
|
|
/// [`OpenCADStudio::run_command_line`] (see `cmd_result.rs`), which the GUI
|
|
/// command line uses too so both process `UCS Z 90` / `LINE 0,0 10,10` /
|
|
/// `PDMODE 3` identically.
|
|
fn run_headless(&mut self, cmd: &str) {
|
|
let _ = self.run_command_line(cmd);
|
|
}
|
|
|
|
/// List entities (handle, type, layer, basic geometry), optionally filtered
|
|
/// by `type` and/or `layer`, capped by `limit` (default 1000).
|
|
fn entity_query(&self, req: &Value) -> Value {
|
|
let i = self.active_tab;
|
|
let type_filter = req["type"].as_str();
|
|
let layer_filter = req["layer"].as_str();
|
|
let limit = req["limit"].as_u64().unwrap_or(1000) as usize;
|
|
|
|
let mut entities = Vec::new();
|
|
let mut matched = 0u64;
|
|
for e in self.tabs[i].scene.document.entities() {
|
|
if let Some(tf) = type_filter {
|
|
if !crate::entities::names::ui_name(e).eq_ignore_ascii_case(tf) {
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(lf) = layer_filter {
|
|
if e.common().layer != lf {
|
|
continue;
|
|
}
|
|
}
|
|
matched += 1;
|
|
if entities.len() < limit {
|
|
entities.push(entity_json(e));
|
|
}
|
|
}
|
|
json!({
|
|
"ok": true,
|
|
"count": matched,
|
|
"returned": entities.len(),
|
|
"entities": entities,
|
|
})
|
|
}
|
|
|
|
/// Count of entities in the active document, total and by type.
|
|
fn entity_summary(&self) -> Value {
|
|
let i = self.active_tab;
|
|
let mut by_type: std::collections::BTreeMap<String, u64> = Default::default();
|
|
let mut total = 0u64;
|
|
for e in self.tabs[i].scene.document.entities() {
|
|
*by_type
|
|
.entry(crate::entities::names::ui_name(e).to_string())
|
|
.or_default() += 1;
|
|
total += 1;
|
|
}
|
|
json!({ "ok": true, "total": total, "by_type": by_type })
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::app::OpenCADStudio;
|
|
|
|
#[test]
|
|
fn automation_ops_round_trip() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
|
|
let r = app.automation_op(r#"{"op":"new"}"#);
|
|
assert_eq!(r["ok"], true);
|
|
assert_eq!(r["total"], 0);
|
|
|
|
// A synchronous command runs through the real dispatcher.
|
|
let r = app.automation_op(r#"{"op":"run","cmd":"PDMODE 3"}"#);
|
|
assert_eq!(r["ok"], true);
|
|
assert_eq!(r["cmd"], "PDMODE 3");
|
|
|
|
// A draw command with coordinates creates real geometry.
|
|
let r = app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10 10,20"}"#);
|
|
assert_eq!(r["ok"], true);
|
|
assert_eq!(r["added"], 2); // two segments → two Line entities
|
|
let r = app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#);
|
|
assert_eq!(r["added"], 1);
|
|
|
|
let r = app.automation_op(r#"{"op":"entities"}"#);
|
|
assert_eq!(r["ok"], true);
|
|
assert_eq!(r["total"], 3);
|
|
assert_eq!(r["by_type"]["Line"], 2);
|
|
assert_eq!(r["by_type"]["Circle"], 1);
|
|
|
|
// query returns per-entity detail and honours a type filter.
|
|
let r = app.automation_op(r#"{"op":"query","type":"Circle"}"#);
|
|
assert_eq!(r["count"], 1);
|
|
assert_eq!(r["entities"][0]["type"], "Circle");
|
|
assert_eq!(r["entities"][0]["radius"], 3.0);
|
|
|
|
// select by type, then a selection command acts on it.
|
|
let r = app.automation_op(r#"{"op":"select","type":"Line"}"#);
|
|
assert_eq!(r["selected"], 2);
|
|
app.automation_op(r#"{"op":"run","cmd":"ERASE"}"#);
|
|
let r = app.automation_op(r#"{"op":"entities"}"#);
|
|
assert_eq!(r["total"], 1); // only the Circle remains
|
|
|
|
// undo restores the erased lines.
|
|
let r = app.automation_op(r#"{"op":"undo"}"#);
|
|
assert_eq!(r["total"], 3);
|
|
|
|
// move a selected entity by a displacement.
|
|
app.automation_op(r#"{"op":"select","type":"Circle"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"MOVE 0,0 100,0"}"#);
|
|
let r = app.automation_op(r#"{"op":"query","type":"Circle"}"#);
|
|
assert_eq!(r["entities"][0]["center"][0], 105.0); // 5 + 100
|
|
|
|
// Errors are reported, never panics.
|
|
assert_eq!(app.automation_op(r#"{"op":"bogus"}"#)["ok"], false);
|
|
assert_eq!(app.automation_op("not json")["ok"], false);
|
|
assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn ucs_interactive_inline_args() {
|
|
// `UCS Z 90` must drive the interactive UCS command step-by-step (option
|
|
// "Z" then value "90") and rotate the active UCS 90° about Z. (#169)
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#);
|
|
let i = app.active_tab;
|
|
let ucs = app.tabs[i]
|
|
.active_ucs
|
|
.as_ref()
|
|
.expect("UCS Z 90 should set an active UCS");
|
|
// 90° about Z sends the X axis (1,0,0) → (0,1,0).
|
|
assert!(
|
|
ucs.x_axis.x.abs() < 1e-6 && (ucs.x_axis.y - 1.0).abs() < 1e-6,
|
|
"x_axis after UCS Z 90 = ({}, {})",
|
|
ucs.x_axis.x,
|
|
ucs.x_axis.y
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn translated_and_rotated_ucs_resolves_absolute_relative_and_polar_input() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 100,200,300"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"LINE 2,3 @5<0"}"#);
|
|
|
|
let line = app.tabs[app.active_tab]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.find_map(|entity| match entity {
|
|
acadrust::EntityType::Line(line) => Some(line),
|
|
_ => None,
|
|
})
|
|
.expect("LINE should create one segment");
|
|
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
|
assert!(close(line.start.x, 97.0));
|
|
assert!(close(line.start.y, 202.0));
|
|
assert!(close(line.start.z, 300.0));
|
|
assert!(close(line.end.x, 97.0));
|
|
assert!(close(line.end.y, 207.0));
|
|
assert!(close(line.end.z, 300.0));
|
|
}
|
|
|
|
#[test]
|
|
fn tilted_ucs_places_planar_entities_with_the_plane_normal() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.automation_op(
|
|
r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#,
|
|
);
|
|
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 2,3 1"}"#);
|
|
|
|
let circle = app.tabs[app.active_tab]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.find_map(|entity| match entity {
|
|
acadrust::EntityType::Circle(circle) => Some(circle),
|
|
_ => None,
|
|
})
|
|
.expect("CIRCLE should create one entity");
|
|
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
|
assert!(close(circle.center.x, 2.0));
|
|
assert!(close(circle.center.y, 0.0));
|
|
assert!(close(circle.center.z, 3.0));
|
|
assert!(close(circle.normal.x, 0.0));
|
|
assert!(close(circle.normal.y, -1.0));
|
|
assert!(close(circle.normal.z, 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn value_prompt_commands_inline_args() {
|
|
// A single-value setting command entered with its value on one line
|
|
// drives the interactive front-end (start + value step) and applies via
|
|
// the inline handler. (F4)
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"PDMODE 3"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"LTSCALE 2.5"}"#);
|
|
let i = app.active_tab;
|
|
let h = &app.tabs[i].scene.document.header;
|
|
assert_eq!(h.point_display_mode, 3, "PDMODE 3 should set point mode");
|
|
assert!(
|
|
(h.linetype_scale - 2.5).abs() < 1e-9,
|
|
"LTSCALE 2.5 should set scale, got {}",
|
|
h.linetype_scale
|
|
);
|
|
// No command should be left dangling.
|
|
assert!(app.tabs[i].active_cmd.is_none(), "command must have finished");
|
|
}
|
|
|
|
#[test]
|
|
fn rotate_by_typed_angle_after_center() {
|
|
// ROTATE: after picking the centre, typing the angle directly must
|
|
// rotate the selection (the reference point is optional, as the prompt
|
|
// says). Before the fix this did nothing and the command cancelled, so
|
|
// the objects never rotated. Regression for #159.
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
|
|
app.automation_op(r#"{"op":"select","type":"Line"}"#);
|
|
// Centre (0,0) then 90° — no reference point.
|
|
app.automation_op(r#"{"op":"run","cmd":"ROTATE 0,0 90"}"#);
|
|
let q = app.automation_op(r#"{"op":"query","type":"Line"}"#);
|
|
assert_eq!(q["count"], 1, "the line must survive the rotate");
|
|
let ex = q["entities"][0]["end"][0].as_f64().unwrap();
|
|
let ey = q["entities"][0]["end"][1].as_f64().unwrap();
|
|
// (10,0) rotated 90° about the origin → (0,10).
|
|
assert!(
|
|
ex.abs() < 1e-3 && (ey - 10.0).abs() < 1e-3,
|
|
"line end after ROTATE 90 = ({ex}, {ey})"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn start_page_runs_tools_that_need_no_drawing_but_still_refuses_the_rest() {
|
|
// The welcome page's own buttons (Donate / Send Feedback / OCS Web) and
|
|
// Manage > About route through RibbonToolClick, so a blanket is_start
|
|
// refusal killed them outright — by definition they are only ever
|
|
// clickable while is_start holds. `dispatch_command` owns the list of
|
|
// commands that stand alone; this door must not shadow it. (#388, #389)
|
|
use crate::app::Message;
|
|
use crate::modules::ModuleEvent;
|
|
|
|
// Fresh app = welcome tab, no drawing.
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
assert!(
|
|
app.tabs[app.active_tab].is_start,
|
|
"test needs the welcome tab"
|
|
);
|
|
|
|
// ABOUT is safe to drive: it opens a modal. DONATE / WEBVERSION / REPORT
|
|
// take the same path but shell out to a browser, so they are covered by
|
|
// the allowlist assertion below rather than by dispatching them here.
|
|
let start = app.command_line.history.len();
|
|
let _ = app.update(Message::RibbonToolClick {
|
|
tool_id: "ABOUT".to_string(),
|
|
event: ModuleEvent::Command("ABOUT".to_string()),
|
|
});
|
|
let out: String = app.command_line.history[start..]
|
|
.iter()
|
|
.map(|e| e.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
!out.contains("No drawing open"),
|
|
"ABOUT needs no drawing and must not be refused on the welcome page: {out:?}"
|
|
);
|
|
|
|
// …but a tool that does need a drawing is still turned away (#299).
|
|
let start = app.command_line.history.len();
|
|
let _ = app.update(Message::RibbonToolClick {
|
|
tool_id: "LINE".to_string(),
|
|
event: ModuleEvent::Command("LINE".to_string()),
|
|
});
|
|
let out: String = app.command_line.history[start..]
|
|
.iter()
|
|
.map(|e| e.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
out.contains("No drawing open"),
|
|
"LINE must still be refused on the welcome page: {out:?}"
|
|
);
|
|
assert!(
|
|
app.tabs[app.active_tab].active_cmd.is_none(),
|
|
"LINE must not have started"
|
|
);
|
|
|
|
// A non-command tool event touches the scene, so it stays inert too.
|
|
let start = app.command_line.history.len();
|
|
let _ = app.update(Message::RibbonToolClick {
|
|
tool_id: "LAYERS".to_string(),
|
|
event: ModuleEvent::ToggleLayers,
|
|
});
|
|
let out: String = app.command_line.history[start..]
|
|
.iter()
|
|
.map(|e| e.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
out.contains("No drawing open"),
|
|
"a scene-touching event must stay inert on the welcome page: {out:?}"
|
|
);
|
|
|
|
// Every welcome-page button must also clear dispatch's own gate, or the
|
|
// fix above just moves the refusal one door down. Asserted on the source
|
|
// rather than by dispatching: DONATE / REPORT / WEBVERSION shell out to
|
|
// a real browser, which a test must not do.
|
|
let dispatch_src = include_str!("commands/mod.rs");
|
|
// The gate's allow-list lives in the `start_allowed` fn (#388/389);
|
|
// its first `}` closes the `matches!` body, past every name.
|
|
let gate = dispatch_src
|
|
.split("pub fn start_allowed")
|
|
.nth(1)
|
|
.and_then(|s| s.split('}').next())
|
|
.expect("the start_allowed gate moved — re-point this test");
|
|
// DONATE/REPORT/WEBVERSION are the welcome page's own buttons; the rest
|
|
// are ribbon tools that configure the application, not a drawing.
|
|
let standalone = [
|
|
"DONATE",
|
|
"REPORT",
|
|
"WEBVERSION",
|
|
"ABOUT",
|
|
"CHANGELOG",
|
|
"CUI",
|
|
"ALIASEDIT",
|
|
];
|
|
for cmd in standalone {
|
|
assert!(
|
|
gate.contains(&format!("\"{cmd}\"")),
|
|
"{cmd} needs no drawing but is missing from dispatch's standalone \
|
|
list, so it is refused on the welcome page"
|
|
);
|
|
}
|
|
// …and each must actually have somewhere to land.
|
|
let view_src = include_str!("commands/view.rs");
|
|
for cmd in standalone {
|
|
assert!(
|
|
view_src.contains(&format!("\"{cmd}\" =>"))
|
|
|| view_src.contains(&format!("\"{cmd}\" |")),
|
|
"{cmd} has no dispatch arm"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// PICKADD / PICKDRAG (#226): the command flips the live flag both via the
|
|
/// inline form and the two-step ValuePrompt flow.
|
|
#[test]
|
|
fn lasso_press_drag_selects() {
|
|
// Press-drag lasso must select crossed entities — regression probe
|
|
// for the #226 PICKDRAG work (both PICKDRAG modes complete through
|
|
// the poly path).
|
|
use crate::app::Message;
|
|
for (add, rect) in [(true, false), (true, true), (false, false), (false, true)] {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
app.pick_add = add;
|
|
app.pick_drag_rect = rect;
|
|
let _ = app.run_command_line("LINE 0,0 10,10");
|
|
app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
|
|
let _ = app.run_command_line("ZOOM EXTENTS");
|
|
// Both directions. Crossing = a right → left diagonal sweep (the
|
|
// freeform path may be degenerate — crossing counts hits).
|
|
// Window = a left → right perimeter walk so the freeform ring
|
|
// actually ENCLOSES the line (a diagonal has no area).
|
|
let path: Vec<(f32, f32)> = if !add && rect {
|
|
// Rectangle window: a simple left → right diagonal spans it.
|
|
(0..=10)
|
|
.map(|k| {
|
|
let t = k as f32 / 10.0;
|
|
(20.0 + t * 760.0, 20.0 + t * 560.0)
|
|
})
|
|
.collect()
|
|
} else if add {
|
|
let (sx, sy, ex, ey) = (780.0f32, 580.0f32, 20.0f32, 20.0f32);
|
|
(0..=10)
|
|
.map(|k| {
|
|
let t = k as f32 / 10.0;
|
|
(sx + t * (ex - sx), sy + t * (ey - sy))
|
|
})
|
|
.collect()
|
|
} else {
|
|
vec![
|
|
(20.0, 20.0),
|
|
(400.0, 20.0),
|
|
(780.0, 20.0),
|
|
(780.0, 300.0),
|
|
(780.0, 580.0),
|
|
(400.0, 580.0),
|
|
(20.0, 580.0),
|
|
(20.0, 300.0),
|
|
]
|
|
};
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(path[0].0, path[0].1)));
|
|
let _ = app.update(Message::ViewportLeftPress);
|
|
std::thread::sleep(std::time::Duration::from_millis(180));
|
|
for &(x, y) in &path {
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(x, y)));
|
|
}
|
|
{
|
|
let sel = app.tabs[i].scene.selection.borrow();
|
|
assert!(sel.left_dragging, "drag must start (add={add} rect={rect})");
|
|
if rect {
|
|
// Rectangle mode drives the box machinery, not the lasso.
|
|
assert!(
|
|
sel.box_anchor.is_some() && sel.box_current.is_some() && !sel.poly_active,
|
|
"rect marquee must arm the box (add={add})"
|
|
);
|
|
} else {
|
|
assert!(sel.poly_active, "lasso must start (add={add})");
|
|
assert!(sel.poly_points.len() >= 3, "lasso points (add={add})");
|
|
}
|
|
}
|
|
let _ = app.update(Message::ViewportLeftRelease);
|
|
assert!(
|
|
!app.tabs[i].scene.selected.is_empty(),
|
|
"marquee must select the line (add={add} rect={rect})"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pickadd_command_flips_flag() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
// The Start tab blocks drawing commands — open a drawing first.
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
// The boot path may have restored a persisted value — normalize.
|
|
app.pick_add = true;
|
|
app.pick_drag_rect = false;
|
|
let _ = app.run_command_line("PICKADD 0");
|
|
assert!(!app.pick_add, "PICKADD 0 must switch to replace mode");
|
|
let _ = app.run_command_line("PICKADD 1");
|
|
assert!(app.pick_add);
|
|
// Two-step: bare command then the value, like typing 1 + Enter
|
|
// (feed_active_cmd is the same path the GUI submit offers first).
|
|
let _ = app.run_command_line("PICKDRAG");
|
|
assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open");
|
|
app.feed_active_cmd("1");
|
|
assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch");
|
|
}
|
|
|
|
#[test]
|
|
fn matchprop_matches_text_style_and_height() {
|
|
// MATCHPROP between text objects must carry the text-specific
|
|
// properties (style, height) to TEXT and MTEXT destinations, not just
|
|
// the generic layer/color/linetype set. Regression for #361.
|
|
use crate::command::StepInput;
|
|
use acadrust::{EntityType, MText, Text};
|
|
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
|
|
let mut src = Text::new();
|
|
src.value = "SRC".into();
|
|
src.height = 5.0;
|
|
src.style = "BIG".into();
|
|
let src_h = app.tabs[i].scene.add_entity(EntityType::Text(src));
|
|
|
|
let mut dst_text = Text::new();
|
|
dst_text.value = "DST".into();
|
|
dst_text.height = 1.0;
|
|
let dst_text_h = app.tabs[i].scene.add_entity(EntityType::Text(dst_text));
|
|
|
|
let mut dst_mtext = MText::new();
|
|
dst_mtext.value = "DSTM".into();
|
|
dst_mtext.height = 2.0;
|
|
let dst_mtext_h = app.tabs[i].scene.add_entity(EntityType::MText(dst_mtext));
|
|
|
|
// Drive the interactive command exactly as the viewport does:
|
|
// phase 1 source pick, phase 2 destination selection.
|
|
let _ = app.run_command_line("MATCHPROP");
|
|
assert!(app.tabs[i].active_cmd.is_some(), "MATCHPROP must start");
|
|
let _ = app.feed_command(StepInput::EntityPick(src_h, glam::DVec3::ZERO));
|
|
let _ = app.feed_command(StepInput::SelectionComplete(vec![
|
|
dst_text_h,
|
|
dst_mtext_h,
|
|
]));
|
|
|
|
let doc = &app.tabs[i].scene.document;
|
|
match doc.get_entity(dst_text_h) {
|
|
Some(EntityType::Text(t)) => {
|
|
assert_eq!(t.style, "BIG", "TEXT destination must take source style");
|
|
assert!(
|
|
(t.height - 5.0).abs() < 1e-9,
|
|
"TEXT destination must take source height, got {}",
|
|
t.height
|
|
);
|
|
}
|
|
other => panic!("dest TEXT missing: {other:?}"),
|
|
}
|
|
match doc.get_entity(dst_mtext_h) {
|
|
Some(EntityType::MText(m)) => {
|
|
assert_eq!(m.style, "BIG", "MTEXT destination must take source style");
|
|
assert!(
|
|
(m.height - 5.0).abs() < 1e-9,
|
|
"MTEXT destination must take source height, got {}",
|
|
m.height
|
|
);
|
|
}
|
|
other => panic!("dest MTEXT missing: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn handing_over_an_already_open_drawing_switches_to_its_tab() {
|
|
// Double-clicking a drawing that is already open should land on the tab
|
|
// showing it, not load a second copy of the same file.
|
|
use crate::app::Message;
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
|
|
let path = std::env::temp_dir().join("ocs_already_open.dwg");
|
|
std::fs::write(&path, b"x").unwrap();
|
|
let canon = std::fs::canonicalize(&path).unwrap();
|
|
|
|
// Two tabs, the second holding the drawing; leave the first active.
|
|
app.tabs
|
|
.push(crate::app::document::DocumentTab::new_drawing(99));
|
|
let target = app.tabs.len() - 1;
|
|
app.tabs[target].current_path = Some(canon.clone());
|
|
app.active_tab = 0;
|
|
|
|
let _ = app.update(Message::OpenExternal(canon.clone()));
|
|
assert_eq!(app.active_tab, target, "should have switched to the tab");
|
|
assert!(
|
|
app.opening.is_none(),
|
|
"an already-open drawing must not start a load"
|
|
);
|
|
assert!(app.pending_opens.is_empty(), "and must not queue one either");
|
|
|
|
// The same file spelled differently (a `..` hop) is still the same file.
|
|
let indirect = canon.parent().unwrap().join("..").join(
|
|
canon
|
|
.strip_prefix(canon.parent().unwrap().parent().unwrap())
|
|
.unwrap(),
|
|
);
|
|
app.active_tab = 0;
|
|
let _ = app.update(Message::OpenExternal(indirect));
|
|
assert_eq!(
|
|
app.active_tab, target,
|
|
"an unresolved spelling of the same path must still match the tab"
|
|
);
|
|
assert!(app.opening.is_none(), "still no second load");
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_handoff_queues_instead_of_displacing_the_first() {
|
|
// `opening` is one slot, and `on_file_opened` drops any result that
|
|
// arrives once it is clear — so without the queue, two drawings handed
|
|
// over at the same moment (select several files in a file manager: one
|
|
// process each, all arriving together) would leave one tab and silently
|
|
// lose the rest.
|
|
use crate::app::Message;
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
|
|
// Any existing file will do: OpenRecent only stats it, and the actual
|
|
// load is an async Task this test drops.
|
|
let dir = std::env::temp_dir();
|
|
let (a, b) = (dir.join("ocs_si_a.dwg"), dir.join("ocs_si_b.dwg"));
|
|
std::fs::write(&a, b"x").unwrap();
|
|
std::fs::write(&b, b"x").unwrap();
|
|
|
|
let _ = app.update(Message::OpenExternal(a.clone()));
|
|
assert!(
|
|
app.opening.is_some(),
|
|
"first handoff should start an open, not queue"
|
|
);
|
|
assert!(app.pending_opens.is_empty(), "nothing to queue yet");
|
|
|
|
let _ = app.update(Message::OpenExternal(b.clone()));
|
|
assert_eq!(
|
|
app.pending_opens.len(),
|
|
1,
|
|
"second handoff arriving mid-open must queue, not be dropped"
|
|
);
|
|
assert_eq!(app.pending_opens.front(), Some(&b));
|
|
|
|
// A failed drawing pauses the queue while its recovery report is shown.
|
|
let open_id = app.opening.as_ref().map(|opening| opening.id).unwrap();
|
|
let _ = app.update(Message::FileOpened(open_id, Err("boom".into())));
|
|
assert!(
|
|
app.active_modal == Some(crate::app::ModalKind::Recovery),
|
|
"failed open should show its recovery report"
|
|
);
|
|
assert_eq!(
|
|
app.pending_opens.len(),
|
|
1,
|
|
"queued drawing should wait until the report is acknowledged"
|
|
);
|
|
let _ = app.update(Message::RecoveryClose);
|
|
assert!(
|
|
app.pending_opens.is_empty(),
|
|
"closing the report must release the queued drawing"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&a);
|
|
let _ = std::fs::remove_file(&b);
|
|
}
|
|
|
|
#[test]
|
|
fn save_then_open_round_trips() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
let path = std::env::temp_dir().join("ocs_automation_test.dxf");
|
|
let p = path.to_string_lossy().replace('\\', "\\\\");
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
assert_eq!(
|
|
app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"],
|
|
true
|
|
);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
}
|