forked from 1b-agent/opencadstudio-upstream
1626 lines
65 KiB
Rust
1626 lines
65 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)
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use std::io::{BufRead, Write};
|
|
use std::path::PathBuf;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
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).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
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.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
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.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
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
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn ready() -> Value {
|
|
json!({ "ok": true, "ready": true, "version": env!("OCS_APP_VERSION") })
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
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();
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
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])
|
|
}
|
|
|
|
fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool {
|
|
if crate::entities::names::ui_name(entity).eq_ignore_ascii_case(requested) {
|
|
return true;
|
|
}
|
|
let record_name = crate::entities::names::dxf_name(entity);
|
|
record_name != "ENTITY" && record_name.eq_ignore_ascii_case(requested)
|
|
}
|
|
|
|
/// One entity as JSON. Summary mode carries identity only, geometry adds the
|
|
/// entity's defining values, and full also includes its world bounds.
|
|
fn entity_json(e: &acadrust::EntityType, detail: &str) -> 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,
|
|
});
|
|
if detail == "summary" {
|
|
return obj;
|
|
}
|
|
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));
|
|
map.insert("position".into(), v3(ins.insert_point));
|
|
let attributes: serde_json::Map<String, Value> = ins
|
|
.attributes
|
|
.iter()
|
|
.map(|attribute| (attribute.tag.clone(), json!(attribute.value)))
|
|
.collect();
|
|
map.insert("attributes".into(), Value::Object(attributes));
|
|
}
|
|
_ => {}
|
|
}
|
|
if detail == "full" {
|
|
let (min, max) = crate::scene::convert::tess::entity_bounds(e);
|
|
map.insert("bounds".into(), json!({ "min": min, "max": max }));
|
|
if let Ok(Value::Object(wrapper)) = serde_json::to_value(e) {
|
|
if let Some((_, properties)) = wrapper.into_iter().next() {
|
|
map.insert("properties".into(), properties);
|
|
}
|
|
}
|
|
}
|
|
obj
|
|
}
|
|
|
|
fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> {
|
|
let values = req[key].as_array()?;
|
|
if !(2..=3).contains(&values.len()) {
|
|
return None;
|
|
}
|
|
let x = values[0].as_f64()?;
|
|
let y = values[1].as_f64()?;
|
|
(x.is_finite() && y.is_finite()).then_some([x, y])
|
|
}
|
|
|
|
fn request_handle(value: &Value) -> Option<acadrust::Handle> {
|
|
value
|
|
.as_str()
|
|
.and_then(|value| {
|
|
let value = value
|
|
.strip_prefix("0x")
|
|
.or_else(|| value.strip_prefix("0X"))
|
|
.unwrap_or(value);
|
|
u64::from_str_radix(value, 16).ok()
|
|
})
|
|
.map(acadrust::Handle::new)
|
|
}
|
|
|
|
fn projected_fields(mut entity: Value, fields: Option<&Vec<Value>>) -> Value {
|
|
let Some(fields) = fields else { return entity };
|
|
let Some(source) = entity.as_object_mut() else {
|
|
return entity;
|
|
};
|
|
let keep: std::collections::HashSet<&str> = fields.iter().filter_map(Value::as_str).collect();
|
|
source.retain(|key, _| key == "handle" || keep.contains(key.as_str()));
|
|
entity
|
|
}
|
|
|
|
impl OpenCADStudio {
|
|
/// Handle one JSON request line and return the JSON response.
|
|
#[cfg(any(test, not(target_arch = "wasm32")))]
|
|
pub(crate) fn automation_op(&mut self, line: &str) -> Value {
|
|
if let Ok(req) = serde_json::from_str::<Value>(line) {
|
|
if req["protocol"].is_number() {
|
|
let id = req["request_id"].clone();
|
|
let (response, task) = self.control_request(req);
|
|
if let Err(error) = self.drive_headless_task(task) {
|
|
return err(error);
|
|
}
|
|
if matches!(response["status"].as_str(), Some("accepted" | "running")) {
|
|
return self
|
|
.control_request(json!({"op":"operation","request_id":id}))
|
|
.0;
|
|
}
|
|
return response;
|
|
}
|
|
}
|
|
let res = self.automation_op_inner(line);
|
|
// Most automation ops mutate `Scene::selected` directly rather than
|
|
// going through `update()` (`select` calls `deselect_all` /
|
|
// `select_entity`, and `run` can erase the selected entities), so the
|
|
// selection check has to run on this path too.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
self.notify_plugins_selection_changed();
|
|
res
|
|
}
|
|
|
|
pub(super) fn automation_op_inner(&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.clear();
|
|
self.tabs[i].scene.material_base_dir = None;
|
|
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.entity_summary()
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"open" => {
|
|
let Some(path) = req["path"].as_str() else {
|
|
return err("open: missing \"path\"");
|
|
};
|
|
let path_buf = PathBuf::from(path);
|
|
let bytes = match self.read_drawing(&path_buf) {
|
|
Ok(b) => b,
|
|
Err(e) => return err(format!("open: {e}")),
|
|
};
|
|
match crate::io::load_bytes_finalized(&path_buf, bytes) {
|
|
Ok((doc, dropped)) => {
|
|
let i = self.active_tab;
|
|
self.tabs[i].scene.clear();
|
|
self.tabs[i].scene.document = doc;
|
|
self.tabs[i].scene.load_sketch_constraints_from_document();
|
|
self.tabs[i].scene.load_named_parameters_from_document();
|
|
self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from);
|
|
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(path_buf);
|
|
self.tabs[i].is_start = false;
|
|
self.tabs[i].scene.rebuild_derived_caches();
|
|
let mut summary = self.entity_summary();
|
|
if dropped > 0 {
|
|
if let Some(obj) = summary.as_object_mut() {
|
|
obj.insert("purged".to_string(), json!(dropped));
|
|
}
|
|
}
|
|
summary
|
|
}
|
|
Err(e) => err(format!("open: {e}")),
|
|
}
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
"open" => err("open: use the browser file action"),
|
|
"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();
|
|
let error_revision = self.command_line.error_revision;
|
|
if let Err(error) = self.run_headless(&cmd) {
|
|
return err(error);
|
|
}
|
|
if self.command_line.error_revision != error_revision {
|
|
return err(self.command_line.last_error.clone().unwrap_or_default());
|
|
}
|
|
let after = self.tabs[i].scene.document.entities().count();
|
|
json!({
|
|
"ok": true,
|
|
"cmd": cmd,
|
|
"status": if self.tabs[i].active_cmd.is_some() { "waiting_input" } else { "completed" },
|
|
"entities": after,
|
|
"added": after as i64 - before as i64,
|
|
})
|
|
}
|
|
"entities" => self.entity_summary(),
|
|
"query" => self.entity_query(&req),
|
|
"records" => self.record_query(&req),
|
|
"record_schema" => self.record_schema(&req),
|
|
"capabilities" => self.record_capabilities(),
|
|
"layers" => {
|
|
let i = self.active_tab;
|
|
let offset = req["offset"].as_u64().unwrap_or(0) as usize;
|
|
let limit = req["limit"].as_u64().unwrap_or(1000).min(10_000) as usize;
|
|
let count = self.tabs[i].scene.document.layers.iter().count();
|
|
let layers: Vec<Value> = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.iter()
|
|
.skip(offset)
|
|
.take(limit)
|
|
.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,
|
|
"count": count,
|
|
"next_offset": (offset + layers.len() < count).then_some(offset + layers.len()),
|
|
"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()) {
|
|
let h = h
|
|
.strip_prefix("0x")
|
|
.or_else(|| h.strip_prefix("0X"))
|
|
.unwrap_or(h);
|
|
if let Ok(v) = u64::from_str_radix(h, 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| entity_type_matches(e, 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) -> Result<(), String> {
|
|
let task = self.run_command_line(cmd);
|
|
self.drive_headless_task(task)
|
|
}
|
|
|
|
pub(super) fn drive_headless_task(
|
|
&mut self,
|
|
task: iced::Task<super::Message>,
|
|
) -> Result<(), String> {
|
|
use iced::futures::StreamExt;
|
|
let mut streams = Vec::new();
|
|
if let Some(stream) = iced_runtime::task::into_stream(task) {
|
|
streams.push(stream);
|
|
}
|
|
while let Some(stream) = streams.last_mut() {
|
|
match iced::futures::executor::block_on(stream.next()) {
|
|
Some(iced_runtime::Action::Output(message)) => {
|
|
let next = self.update(message);
|
|
if let Some(stream) = iced_runtime::task::into_stream(next) {
|
|
streams.push(stream);
|
|
}
|
|
}
|
|
Some(iced_runtime::Action::Widget(_))
|
|
| Some(iced_runtime::Action::Tick)
|
|
| Some(iced_runtime::Action::Reload) => {}
|
|
Some(action) => return Err(format!("GUI runtime required for {action:?}")),
|
|
None => {
|
|
streams.pop();
|
|
}
|
|
}
|
|
}
|
|
self.finish_all_pending_history();
|
|
Ok(())
|
|
}
|
|
|
|
/// Query entities by identity, metadata and exact plane-curve relationships.
|
|
fn entity_query(&self, req: &Value) -> Value {
|
|
let i = self.active_tab;
|
|
let tab = &self.tabs[i];
|
|
if let Some(pair) = req["intersections"].as_array() {
|
|
if pair.len() != 2 {
|
|
return err("query intersections expects exactly two handles");
|
|
}
|
|
let Some(first) = request_handle(&pair[0]) else {
|
|
return err("query intersections contains an invalid first handle");
|
|
};
|
|
let Some(second) = request_handle(&pair[1]) else {
|
|
return err("query intersections contains an invalid second handle");
|
|
};
|
|
let Some(first_entity) = tab.scene.document.get_entity(first) else {
|
|
return err("query intersections first entity does not exist");
|
|
};
|
|
let Some(second_entity) = tab.scene.document.get_entity(second) else {
|
|
return err("query intersections second entity does not exist");
|
|
};
|
|
let Some(first_curve) = crate::entities::curve::entity_curve_xy(first_entity) else {
|
|
return err("query intersections first entity is not a planar curve");
|
|
};
|
|
let Some(second_curve) = crate::entities::curve::entity_curve_xy(second_entity) else {
|
|
return err("query intersections second entity is not a planar curve");
|
|
};
|
|
let crossings = cadkernel::geom2d::intersect(
|
|
&first_curve,
|
|
&second_curve,
|
|
cadkernel::geom2d::Tolerance::default(),
|
|
);
|
|
return json!({
|
|
"ok":true,
|
|
"document_id":tab.id,
|
|
"geometry_revision":tab.scene.geometry_epoch,
|
|
"handles":[format!("{:X}",first.value()),format!("{:X}",second.value())],
|
|
"count":crossings.len(),
|
|
"intersections":crossings.into_iter().map(|crossing|json!({
|
|
"point":[crossing.point[0],crossing.point[1]],
|
|
"parameter_first":crossing.t_a,
|
|
"parameter_second":crossing.t_b
|
|
})).collect::<Vec<_>>()
|
|
});
|
|
}
|
|
|
|
let type_filter = req["type"].as_str();
|
|
let layer_filter = req["layer"].as_str();
|
|
let handles: Option<std::collections::HashSet<u64>> = req["handles"]
|
|
.as_array()
|
|
.map(|values| {
|
|
values
|
|
.iter()
|
|
.filter_map(request_handle)
|
|
.map(|h| h.value())
|
|
.collect()
|
|
})
|
|
.or_else(|| {
|
|
request_handle(&req["handle"])
|
|
.map(|handle| std::iter::once(handle.value()).collect())
|
|
});
|
|
let near = request_point(req, "near");
|
|
let contains = request_point(req, "contains_point");
|
|
let bounds = req["bounds"].as_array().and_then(|values| {
|
|
(values.len() == 4)
|
|
.then(|| {
|
|
Some([
|
|
values[0].as_f64()?,
|
|
values[1].as_f64()?,
|
|
values[2].as_f64()?,
|
|
values[3].as_f64()?,
|
|
])
|
|
})
|
|
.flatten()
|
|
});
|
|
if req.get("handles").is_some()
|
|
&& handles
|
|
.as_ref()
|
|
.is_some_and(|parsed| parsed.len() != req["handles"].as_array().map_or(0, Vec::len))
|
|
{
|
|
return err("query handles contains an invalid hexadecimal handle");
|
|
}
|
|
if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() {
|
|
return err("query handle must be hexadecimal");
|
|
}
|
|
if req.get("near").is_some() && near.is_none() {
|
|
return err("query near expects two or three finite coordinates");
|
|
}
|
|
if req.get("contains_point").is_some() && contains.is_none() {
|
|
return err("query contains_point expects two or three finite coordinates");
|
|
}
|
|
if req.get("bounds").is_some()
|
|
&& bounds.is_none_or(|bounds| {
|
|
!bounds.iter().all(|value| value.is_finite())
|
|
|| bounds[0] > bounds[2]
|
|
|| bounds[1] > bounds[3]
|
|
})
|
|
{
|
|
return err("query bounds expects finite [min_x,min_y,max_x,max_y]");
|
|
}
|
|
let detail = req["detail"].as_str().unwrap_or("geometry");
|
|
if !matches!(detail, "summary" | "geometry" | "full") {
|
|
return err("query detail must be summary, geometry or full");
|
|
}
|
|
let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize;
|
|
let offset = req["offset"].as_u64().unwrap_or(0) as usize;
|
|
|
|
let mut matched = Vec::new();
|
|
for e in tab.scene.document.entities() {
|
|
if handles
|
|
.as_ref()
|
|
.is_some_and(|handles| !handles.contains(&e.common().handle.value()))
|
|
{
|
|
continue;
|
|
}
|
|
if type_filter.is_some_and(|value| !entity_type_matches(e, value))
|
|
|| layer_filter.is_some_and(|value| e.common().layer != value)
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(bounds) = bounds {
|
|
let (min, max) = crate::scene::convert::tess::entity_bounds(e);
|
|
if max[0] < bounds[0]
|
|
|| max[1] < bounds[1]
|
|
|| min[0] > bounds[2]
|
|
|| min[1] > bounds[3]
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
let curve = (near.is_some() || contains.is_some())
|
|
.then(|| crate::entities::curve::entity_curve_xy(e))
|
|
.flatten();
|
|
if let Some(point) = contains {
|
|
let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else {
|
|
continue;
|
|
};
|
|
if !cadkernel::geom2d::contains(
|
|
std::slice::from_ref(curve),
|
|
point,
|
|
cadkernel::geom2d::Tolerance::default(),
|
|
) {
|
|
continue;
|
|
}
|
|
}
|
|
let nearest = near.and_then(|point| {
|
|
curve
|
|
.as_ref()
|
|
.map(|curve| cadkernel::geom2d::closest_point(curve, point))
|
|
});
|
|
if near.is_some() && nearest.is_none() {
|
|
continue;
|
|
}
|
|
let mut entity = entity_json(e, detail);
|
|
if let Some(nearest) = nearest {
|
|
let object = entity.as_object_mut().expect("entity JSON object");
|
|
object.insert("distance".into(), json!(nearest.distance));
|
|
object.insert(
|
|
"closest_point".into(),
|
|
json!([nearest.point[0], nearest.point[1]]),
|
|
);
|
|
object.insert("parameter".into(), json!(nearest.t));
|
|
}
|
|
matched.push((nearest.map(|nearest| nearest.distance), entity));
|
|
}
|
|
if near.is_some() {
|
|
matched.sort_by(|left, right| {
|
|
left.0
|
|
.partial_cmp(&right.0)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
});
|
|
}
|
|
let count = matched.len();
|
|
let fields = req["fields"].as_array();
|
|
let entities: Vec<Value> = matched
|
|
.into_iter()
|
|
.skip(offset)
|
|
.take(limit)
|
|
.map(|(_, entity)| projected_fields(entity, fields))
|
|
.collect();
|
|
json!({
|
|
"ok": true,
|
|
"document_id":tab.id,
|
|
"geometry_revision":tab.scene.geometry_epoch,
|
|
"count": count,
|
|
"returned": entities.len(),
|
|
"next_offset": (offset + entities.len() < count).then_some(offset + 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 layout_notice_skips_grid_camera_and_scene_builds() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true);
|
|
assert_eq!(
|
|
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"],
|
|
true
|
|
);
|
|
let i = app.active_tab;
|
|
let scene = &mut app.tabs[i].scene;
|
|
scene.document.add_layout("Review").unwrap();
|
|
scene.set_current_layout("Review".to_string());
|
|
let mut viewport = acadrust::entities::Viewport::new();
|
|
viewport.id = 2;
|
|
viewport.width = 100.0;
|
|
viewport.height = 50.0;
|
|
viewport.status.is_on = true;
|
|
scene.add_entity(acadrust::EntityType::Viewport(viewport));
|
|
for entity in scene.document.entities_mut() {
|
|
if let acadrust::EntityType::Viewport(viewport) = entity {
|
|
viewport.status.grid_on = true;
|
|
}
|
|
}
|
|
let before = scene.last_tess_wires.get();
|
|
app.layout_settling = true;
|
|
drop(app.view_main());
|
|
assert_eq!(app.tabs[i].scene.last_tess_wires.get(), before);
|
|
let _ = app.update(crate::app::Message::LayoutSettled);
|
|
assert!(!app.layout_settling);
|
|
drop(app.view_main());
|
|
assert!(app.tabs[i].scene.last_tess_wires.get() > before);
|
|
}
|
|
|
|
#[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 block_reference_query_exposes_instance_attributes() {
|
|
use acadrust::entities::{AttributeEntity, EntityType, Insert};
|
|
use acadrust::types::Vector3;
|
|
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0));
|
|
insert.common.layer = "Equipment".to_string();
|
|
insert
|
|
.attributes
|
|
.push(AttributeEntity::simple("COMPANY", "BPH"));
|
|
insert
|
|
.attributes
|
|
.push(AttributeEntity::simple("STATUS", "ACTIVE"));
|
|
app.tabs[i].scene.add_entity(EntityType::Insert(insert));
|
|
|
|
let result = app
|
|
.automation_op(r#"{"op":"query","type":"Insert","layer":"Equipment","detail":"full"}"#);
|
|
assert_eq!(result["count"], 1);
|
|
assert_eq!(result["entities"][0]["type"], "Block Reference");
|
|
assert_eq!(result["entities"][0]["block"], "A_CPT");
|
|
assert_eq!(
|
|
result["entities"][0]["position"],
|
|
serde_json::json!([12.0, 34.0, 5.0])
|
|
);
|
|
assert_eq!(result["entities"][0]["attributes"]["COMPANY"], "BPH");
|
|
assert_eq!(result["entities"][0]["attributes"]["STATUS"], "ACTIVE");
|
|
assert_eq!(
|
|
result["entities"][0]["properties"]["attributes"][0]["value"],
|
|
"BPH"
|
|
);
|
|
|
|
let selected = app.automation_op(r#"{"op":"select","type":"Insert"}"#);
|
|
assert_eq!(selected["selected"], 1);
|
|
}
|
|
|
|
#[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 center = circle.center_wcs();
|
|
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
|
assert!(close(center.x, 2.0));
|
|
assert!(close(center.y, 0.0));
|
|
assert!(close(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() {
|
|
// App-wide commands remain available on the welcome page; drawing
|
|
// commands and scene tools do not. (#388, #389)
|
|
use crate::app::Message;
|
|
use crate::modules::ModuleEvent;
|
|
use crate::ui::command_line::EntryKind;
|
|
|
|
// 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 schedules its modal; it must pass the welcome-page gate.
|
|
let start = app.command_line.history.len();
|
|
let _ = app.update(Message::RibbonToolClick {
|
|
tool_id: "ABOUT".to_string(),
|
|
event: ModuleEvent::Command("ABOUT".to_string()),
|
|
});
|
|
assert_eq!(
|
|
app.command_line.history.len(),
|
|
start,
|
|
"ABOUT must not be refused on the welcome page"
|
|
);
|
|
|
|
// …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 refusal = &app.command_line.history[start..];
|
|
assert_eq!(refusal.len(), 1, "LINE must emit one refusal");
|
|
assert_eq!(refusal[0].kind, EntryKind::Info);
|
|
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 refusal = &app.command_line.history[start..];
|
|
assert_eq!(refusal.len(), 1, "scene tools must emit one refusal");
|
|
assert_eq!(refusal[0].kind, EntryKind::Info);
|
|
|
|
// Check link commands in source without launching them.
|
|
let dispatch_src = include_str!("commands/mod.rs");
|
|
// Extract the `start_allowed` match body.
|
|
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");
|
|
// Welcome-page links plus app-wide configuration commands.
|
|
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"
|
|
);
|
|
}
|
|
// Every allowed command needs a dispatch arm.
|
|
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"
|
|
);
|
|
let _ = 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 saving_over_an_existing_drawing_succeeds() {
|
|
for (label, pre_existing) in [("new path", false), ("existing drawing", true)] {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"ocs_save_over_{}_{}.dxf",
|
|
std::process::id(),
|
|
pre_existing,
|
|
));
|
|
let _ = std::fs::remove_file(&path);
|
|
if pre_existing {
|
|
std::fs::write(&path, b"a previous drawing").unwrap();
|
|
}
|
|
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let p = path.to_string_lossy().replace('\\', "\\\\");
|
|
let saved = app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#));
|
|
assert_eq!(saved["ok"], true, "{label}: {}", saved["error"]);
|
|
let saved_again = app.automation_op(r#"{"op":"save"}"#);
|
|
assert_eq!(
|
|
saved_again["ok"], true,
|
|
"normal save: {}",
|
|
saved_again["error"]
|
|
);
|
|
|
|
drop(app);
|
|
let sidecar = path.with_file_name(format!(
|
|
".{}.ocs.lock",
|
|
path.file_name().unwrap().to_string_lossy()
|
|
));
|
|
let _ = std::fs::remove_file(sidecar);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn save_then_open_round_trips() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
let path =
|
|
std::env::temp_dir().join(format!("ocs_automation_test_{}.dxf", std::process::id()));
|
|
let _ = std::fs::remove_file(&path);
|
|
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
|
|
);
|
|
drop(app);
|
|
let sidecar = path.with_file_name(format!(
|
|
".{}.ocs.lock",
|
|
path.file_name().unwrap().to_string_lossy()
|
|
));
|
|
let _ = std::fs::remove_file(sidecar);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn open_finalizes_and_purges_like_the_ui_open_path() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
let stale = acadrust::Handle::from(9999);
|
|
app.tabs[app.active_tab].scene.solid_models.insert(
|
|
stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(),
|
|
);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"ocs_automation_finalize_test_{}.dxf",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
let mut doc = acadrust::CadDocument::new();
|
|
let mut good = acadrust::entities::Circle::new();
|
|
good.center = acadrust::types::Vector3::new(5.0, 5.0, 0.0);
|
|
good.radius = 2.0;
|
|
doc.add_entity(acadrust::EntityType::Circle(good)).unwrap();
|
|
let mut corrupt = acadrust::entities::Circle::new();
|
|
corrupt.center = acadrust::types::Vector3::new(1.0, 1.0, 0.0);
|
|
corrupt.radius = 0.0; // io::is_entity_corrupt rejects a zero-radius circle
|
|
doc.add_entity(acadrust::EntityType::Circle(corrupt)).unwrap();
|
|
let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version)
|
|
.expect("save a document containing a corrupt entity");
|
|
std::fs::write(&path, bytes).unwrap();
|
|
|
|
let p = path.to_string_lossy().replace('\\', "\\\\");
|
|
let result = app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#));
|
|
assert_eq!(result["ok"], true, "{}", result["error"]);
|
|
assert_eq!(result["total"], 1, "the corrupt circle must not survive the open");
|
|
assert_eq!(result["purged"], 1, "the purge count must be reported, matching the UI open path's diagnostics");
|
|
|
|
let i = app.active_tab;
|
|
assert!(!app.tabs[i].scene.solid_models.contains_key(&stale));
|
|
assert_eq!(app.tabs[i].scene.material_base_dir.as_deref(), path.parent());
|
|
assert!(
|
|
app.tabs[i].scene.document.source_path.is_some(),
|
|
"automation open must run the same finalization as a path-based open, which sets source_path (load_bytes alone never does)"
|
|
);
|
|
|
|
app.tabs[i].scene.solid_models.insert(
|
|
stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(),
|
|
);
|
|
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true);
|
|
assert!(app.tabs[i].scene.solid_models.is_empty());
|
|
assert!(app.tabs[i].scene.material_base_dir.is_none());
|
|
|
|
drop(app);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pline_line_then_arc() {
|
|
use crate::app::Message;
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
{
|
|
app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0);
|
|
app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0);
|
|
}
|
|
|
|
// Start PLINE
|
|
let _ = app.update(Message::CommandInput("PLINE".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
|
|
// Click first point (100, 100)
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 100.0)));
|
|
let _ = app.update(Message::ViewportLeftPress);
|
|
let _ = app.update(Message::ViewportLeftRelease);
|
|
|
|
// Move to (200, 100) and click second point (draw line)
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0)));
|
|
let _ = app.update(Message::ViewportLeftPress);
|
|
let _ = app.update(Message::ViewportLeftRelease);
|
|
|
|
let wid = app.main_window.unwrap_or_else(iced::window::Id::unique);
|
|
|
|
// Switch to arc: Option 1 - CommandOptionPick("A")
|
|
let _ = app.update(Message::CommandOptionPick("A".to_string()));
|
|
let _ = app.view(wid);
|
|
println!(
|
|
"ENTITIES: {}",
|
|
app.tabs[0].scene.document.entities().count()
|
|
);
|
|
println!(
|
|
"CMD: {:?}",
|
|
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
|
|
);
|
|
|
|
// Move mouse!
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0)));
|
|
let _ = app.view(wid);
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(201.0, 100.0)));
|
|
let _ = app.view(wid);
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 150.0)));
|
|
let _ = app.view(wid);
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 150.0)));
|
|
let _ = app.view(wid);
|
|
|
|
// Click arc point
|
|
let _ = app.update(Message::ViewportLeftPress);
|
|
let _ = app.update(Message::ViewportLeftRelease);
|
|
|
|
// Move mouse again
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 160.0)));
|
|
|
|
// Switch to line
|
|
let _ = app.update(Message::CommandOptionPick("L".to_string()));
|
|
|
|
// Move mouse again
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 150.0)));
|
|
|
|
// Finish
|
|
let _ = app.update(Message::CommandOptionPick(String::new()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_mtp_in_line_command() {
|
|
use crate::app::Message;
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
{
|
|
app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0);
|
|
app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0);
|
|
}
|
|
|
|
// Start LINE
|
|
let _ = app.update(Message::CommandInput("LINE".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE"));
|
|
|
|
// Type M2P
|
|
let _ = app.update(Message::CommandInput("M2P".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
|
|
assert!(app.tabs[0].suspended_cmd.is_some());
|
|
assert_eq!(app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE"));
|
|
|
|
// Point 1: 0,0
|
|
let _ = app.update(Message::CommandInput("0,0".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
|
|
|
|
// Point 2: 10,20
|
|
let _ = app.update(Message::CommandInput("10,20".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
|
|
// MTP should have finished and restored LINE, with midpoint (5, 10, 0)
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE"));
|
|
assert!(app.tabs[0].suspended_cmd.is_none());
|
|
assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0)));
|
|
|
|
// Cancel LINE
|
|
let _ = app.update(Message::CommandEscape);
|
|
assert!(app.tabs[0].active_cmd.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn mtp_snap_override_starts_the_existing_modifier() {
|
|
use crate::app::Message;
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
|
|
let _ = app.update(Message::CommandInput("LINE".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
let _ = app.update(Message::SnapOverrideMtp);
|
|
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
|
|
assert_eq!(
|
|
app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()),
|
|
Some("LINE")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mtp_escape_restores_parent() {
|
|
use crate::app::Message;
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
|
|
// Start LINE
|
|
let _ = app.update(Message::CommandInput("LINE".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE"));
|
|
|
|
// Type MTP
|
|
let _ = app.update(Message::CommandInput("MTP".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
|
|
|
|
// Escape during MTP
|
|
let _ = app.update(Message::CommandEscape);
|
|
// Parent LINE must be restored, not cancelled!
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE"));
|
|
assert!(app.tabs[0].suspended_cmd.is_none());
|
|
|
|
// Escape again cancels LINE
|
|
let _ = app.update(Message::CommandEscape);
|
|
assert!(app.tabs[0].active_cmd.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mtp_typing_routing_with_dyn_input() {
|
|
use crate::app::Message;
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.dyn_input = true;
|
|
|
|
// Start LINE
|
|
let _ = app.update(Message::CommandInput("LINE".to_string()));
|
|
let _ = app.update(Message::CommandSubmit);
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE"));
|
|
|
|
// Simulate typing 'm', '2', 'p' character by character
|
|
let _ = app.update(Message::CommandAppendChar("m".to_string()));
|
|
assert_eq!(app.command_line.input, "M");
|
|
|
|
// The digit '2' must stay in command_line.input instead of routing to dyn fields!
|
|
let _ = app.update(Message::CommandAppendChar("2".to_string()));
|
|
assert_eq!(app.command_line.input, "M2");
|
|
|
|
let _ = app.update(Message::CommandAppendChar("p".to_string()));
|
|
assert_eq!(app.command_line.input, "M2P");
|
|
|
|
// Submit triggers MTP
|
|
let _ = app.update(Message::CommandSubmit);
|
|
assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP"));
|
|
}
|
|
}
|