2026-06-17 21:05:12 +03:00
|
|
|
//! 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;
|
|
|
|
|
|
2026-06-18 01:54:03 +03:00
|
|
|
/// 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).
|
2026-06-17 21:05:12 +03:00
|
|
|
pub fn serve() {
|
|
|
|
|
let mut app = OpenCADStudio::new();
|
2026-06-18 01:54:03 +03:00
|
|
|
match port_arg() {
|
|
|
|
|
Some(port) => serve_socket(&mut app, port),
|
|
|
|
|
None => serve_stdio(&mut app),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 13:59:20 +03:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 01:54:03 +03:00
|
|
|
/// `--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
|
|
|
|
|
}
|
2026-06-17 21:05:12 +03:00
|
|
|
|
2026-06-18 01:54:03 +03:00
|
|
|
fn ready() -> Value {
|
|
|
|
|
json!({ "ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION") })
|
|
|
|
|
}
|
2026-06-17 21:05:12 +03:00
|
|
|
|
2026-06-18 01:54:03 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-06-17 21:05:12 +03:00
|
|
|
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);
|
2026-06-18 01:54:03 +03:00
|
|
|
let mut o = stdout.lock();
|
|
|
|
|
let _ = writeln!(o, "{resp}");
|
|
|
|
|
let _ = o.flush();
|
2026-06-17 21:05:12 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 01:54:03 +03:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-17 21:05:12 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn err(msg: impl std::fmt::Display) -> Value {
|
|
|
|
|
json!({ "ok": false, "error": msg.to_string() })
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 21:22:46 +03:00
|
|
|
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));
|
|
|
|
|
}
|
2026-06-18 01:50:58 +03:00
|
|
|
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));
|
|
|
|
|
}
|
2026-06-17 21:22:46 +03:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
obj
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 21:05:12 +03:00
|
|
|
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;
|
2026-06-17 21:17:17 +03:00
|
|
|
// 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;
|
2026-06-17 21:05:12 +03:00
|
|
|
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;
|
2026-07-21 23:00:36 +03:00
|
|
|
crate::app::style_ops::ensure_standard_styles(
|
|
|
|
|
&mut self.tabs[i].scene.document,
|
|
|
|
|
);
|
feat(ucs): central WCS↔UCS converter; adopt file UCS, drive readout + icon
Introduce one bridge between WCS (how geometry and the file are stored)
and the active UCS (the coordinate system the user works in), so every
UCS-aware system routes through it instead of re-deriving axis math.
- UcsXform (app/helpers.rs): the converter — to_wcs/to_ucs (points),
vec_to_wcs/vec_to_ucs (directions), axes(), is_identity(). Orthonormal
axes, so the inverse is the transpose. The old ucs_to_wcs / ucs_rotate_vec
free fns now delegate to it (one implementation).
- DocumentTab::ucs_xform() builds it from the tab's active_ucs (None = WCS).
- DocumentTab::adopt_active_ucs_from_header() loads the document's saved
current UCS (header model-space UCS) into active_ucs on open, so the file's
coordinate system is live immediately; called from both load paths.
- Status-bar coordinate readout reports the cursor in the active UCS.
- UCS icon tripod projects the active UCS axes, so it rotates to the UCS
instead of always showing world X/Y/Z.
First slice of routing the app's coordinate systems through the converter;
input/picking, grid, ViewCube, and the remaining readouts follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:57:39 +03:00
|
|
|
self.tabs[i].adopt_active_ucs_from_header();
|
2026-06-17 21:05:12 +03:00
|
|
|
self.tabs[i].current_path = Some(PathBuf::from(path));
|
2026-06-17 21:17:17 +03:00
|
|
|
self.tabs[i].is_start = false;
|
2026-06-17 21:05:12 +03:00
|
|
|
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();
|
2026-06-17 21:17:17 +03:00
|
|
|
self.run_headless(&cmd);
|
2026-06-17 21:05:12 +03:00
|
|
|
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(),
|
2026-06-17 21:22:46 +03:00
|
|
|
"query" => self.entity_query(&req),
|
2026-06-18 01:50:58 +03:00
|
|
|
"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,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-06-17 21:31:15 +03:00
|
|
|
"undo" => {
|
|
|
|
|
let _ = self.update(super::Message::Undo);
|
|
|
|
|
self.entity_summary()
|
|
|
|
|
}
|
|
|
|
|
"redo" => {
|
|
|
|
|
let _ = self.update(super::Message::Redo);
|
|
|
|
|
self.entity_summary()
|
|
|
|
|
}
|
2026-06-17 21:27:26 +03:00
|
|
|
"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() })
|
|
|
|
|
}
|
2026-06-17 21:05:12 +03:00
|
|
|
"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");
|
|
|
|
|
};
|
|
|
|
|
match crate::io::save(&self.tabs[i].scene.document, &path) {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
self.tabs[i].current_path = Some(path.clone());
|
|
|
|
|
json!({ "ok": true, "saved": path.to_string_lossy() })
|
|
|
|
|
}
|
|
|
|
|
Err(e) => err(format!("save: {e}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"" => err("missing \"op\""),
|
|
|
|
|
other => err(format!("unknown op: {other}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:16:28 +03:00
|
|
|
/// 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.
|
2026-06-17 21:17:17 +03:00
|
|
|
fn run_headless(&mut self, cmd: &str) {
|
2026-06-25 13:16:28 +03:00
|
|
|
let _ = self.run_command_line(cmd);
|
2026-06-17 21:17:17 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-17 21:22:46 +03:00
|
|
|
/// 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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 21:05:12 +03:00
|
|
|
/// 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");
|
|
|
|
|
|
2026-06-17 21:17:17 +03:00
|
|
|
// 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);
|
|
|
|
|
|
2026-06-17 21:05:12 +03:00
|
|
|
let r = app.automation_op(r#"{"op":"entities"}"#);
|
|
|
|
|
assert_eq!(r["ok"], true);
|
2026-06-17 21:17:17 +03:00
|
|
|
assert_eq!(r["total"], 3);
|
|
|
|
|
assert_eq!(r["by_type"]["Line"], 2);
|
|
|
|
|
assert_eq!(r["by_type"]["Circle"], 1);
|
2026-06-17 21:05:12 +03:00
|
|
|
|
2026-06-17 21:22:46 +03:00
|
|
|
// 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);
|
|
|
|
|
|
2026-06-17 21:27:26 +03:00
|
|
|
// 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
|
|
|
|
|
|
2026-06-17 21:31:15 +03:00
|
|
|
// 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
|
|
|
|
|
|
2026-06-17 21:05:12 +03:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:36:49 +03:00
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:35:25 +03:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 19:58:45 +03:00
|
|
|
#[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})"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
fix(start): let the welcome page's own buttons work (#388, #389)
Donate, Send Feedback, OCS Web and Manage > About all answered "No
drawing open — use New or Open first." and did nothing.
Two doors guarded the welcome tab and the outer one shadowed the inner.
`dispatch_command` already knew which commands stand alone — About,
Donate, Report and the links were all on its list — but
`on_ribbon_tool_click` refused every ribbon event before dispatch was
ever reached.
The welcome page's own buttons emit RibbonToolClick, so they are only
ever clickable while `is_start` holds: the refusal covered exactly the
case they exist in, and they had never worked. New Drawing / Open File /
Plugins send their own messages, which is why the page still looked
alive.
So the ribbon door now defers commands to dispatch and keeps refusing
only what touches the scene. The policy stays in one place instead of
being half-remembered in two. A drawing tool is still turned away on the
welcome tab (#299) — the wording just comes from dispatch now.
The same gap was swallowing tools the issues never mentioned: CUI and
ALIASEDIT configure the application, not a drawing, and were missing
from the list, so fixing only the ribbon door would have moved their
refusal one door down rather than removing it. HELP and CUILOAD /
CUIIMPORT join them.
The new test drives the real message path and fails on the old code with
the reported wording. It reads the allowlist from source rather than
dispatching DONATE / REPORT / WEBVERSION, which shell out to a browser —
that also fails the day someone adds a welcome-page button and forgets
the list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:29:42 +03:00
|
|
|
#[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");
|
|
|
|
|
let gate = dispatch_src
|
|
|
|
|
.split("is_start")
|
|
|
|
|
.nth(1)
|
|
|
|
|
.and_then(|s| s.split('{').next())
|
|
|
|
|
.expect("the is_start 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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 18:22:48 -07:00
|
|
|
#[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:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 13:38:16 -07:00
|
|
|
#[test]
|
|
|
|
|
fn exportpdf_with_explicit_path_writes_pdf_without_dialog() {
|
|
|
|
|
// EXPORTPDF <path> must export straight to the given file — no save
|
|
|
|
|
// dialog — and confirm on the command line. This is the dialog-free
|
|
|
|
|
// export path added for #369 (Export to PDF silently did nothing when
|
|
|
|
|
// the native save dialog could not open).
|
|
|
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
|
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
|
|
|
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 100,50"}"#);
|
|
|
|
|
|
|
|
|
|
let path = std::env::temp_dir().join(format!("ocs_test_369_{}.pdf", std::process::id()));
|
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
|
let start = app.command_line.history.len();
|
|
|
|
|
let _ = app.run_command_line(&format!("EXPORTPDF {}", path.display()));
|
|
|
|
|
let out: String = app.command_line.history[start..]
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|e| e.text.as_str())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
assert!(out.contains("Exported"), "no export confirmation: {out:?}");
|
|
|
|
|
|
|
|
|
|
let bytes = std::fs::read(&path).expect("EXPORTPDF <path> should write the file");
|
|
|
|
|
assert!(bytes.starts_with(b"%PDF"), "output is not a PDF");
|
|
|
|
|
assert!(bytes.len() > 200, "suspiciously small PDF: {}", bytes.len());
|
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cancelled_pdf_save_dialog_reports_on_command_line() {
|
|
|
|
|
// The save dialog resolving to None (cancelled, or it never opened —
|
|
|
|
|
// e.g. a broken XDG portal) must leave a visible message, not silence.
|
|
|
|
|
// Regression for #369.
|
|
|
|
|
use crate::app::Message;
|
|
|
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
|
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
|
|
|
for msg in [Message::PlotExportPath(None), Message::PlotWindowExportPath(None)] {
|
|
|
|
|
let start = app.command_line.history.len();
|
|
|
|
|
let _ = app.update(msg);
|
|
|
|
|
let out: String = app.command_line.history[start..]
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|e| e.text.as_str())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
assert!(
|
|
|
|
|
out.contains("EXPORTPDF"),
|
|
|
|
|
"dialog-None must mention the EXPORTPDF fallback: {out:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(open): open drawings in the running editor
Double-clicking a drawing started a whole second copy of the app. Now it
lands as a tab in the editor already open, and that window comes forward.
The election is the bind itself: every GUI launch tries a deterministic
per-user loopback port, and the OS grants it to exactly one process and
releases it on any death, SIGKILL included. Nothing to reap, no PID to
probe, no stale lock after a crash. The winner serves the port from an
iced subscription; the loser checks it is talking to a matching editor,
hands the paths over and exits. Every surprise — no answer, a stranger on
the port, a timeout — boots a normal window, i.e. the old behaviour: the
feature can degrade, but it cannot lose the file.
The gate in main is positional, after every headless mode has returned.
The plugin runner is this same binary re-spawning itself, so a flag list
would rot the first time a mode is added; a position cannot.
Handing a drawing over that is already open switches to its tab instead
of loading a second copy, comparing resolved paths so a symlink or a
`..` hop is still recognised as the same file.
`opening` is a single slot that a second open would overwrite, and
on_file_opened drops any result arriving once it is clear — so drawings
handed over at once would silently vanish. They queue in pending_opens,
drained from every path that clears the slot, error and cancel included.
Exec becomes %F so one launch takes the whole selection; the CLI takes a
list and boot fans it out through the same door. --new-instance forces a
separate process; --read-only / --script / --new always get their own.
Not reused: the automation server. It dispatches arbitrary commands and
writes arbitrary paths, and its open replaces the active tab's document
in place — publishing that on a port every local process can reach would
be unforced privilege escalation. Only its JSON-lines framing is shared.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:18:16 +03:00
|
|
|
#[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 drawing that fails to parse must still release the queue behind it.
|
|
|
|
|
let _ = app.update(Message::FileOpened(Err("boom".into())));
|
|
|
|
|
assert!(
|
|
|
|
|
app.pending_opens.is_empty(),
|
|
|
|
|
"a failed open must drain the queue, not strand it"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let _ = std::fs::remove_file(&a);
|
|
|
|
|
let _ = std::fs::remove_file(&b);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 21:05:12 +03:00
|
|
|
#[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");
|
2026-06-23 22:54:33 +02:00
|
|
|
let p = path.to_string_lossy().replace('\\', "\\\\");
|
2026-06-17 21:05:12 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|