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;
|
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})"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|