forked from 1b-agent/opencadstudio-upstream
The web control channel gains a save operation that serializes the active drawing in the requested format and returns it as base64 in the reply, clearing the dirty flag, the counterpart of opening from bytes. A host page can also define window.openCadStudioSaveFile(name, bytes) to receive every saved or exported file in place of a browser download; while it is set no file picker opens. Fixes #1704 Fixes #1705 Co-Authored-By: Claude Opus 5.5 <[email protected]>
2996 lines
123 KiB
Rust
2996 lines
123 KiB
Rust
//! Semantic control shared by the GUI, headless client and web adapter.
|
|
use super::{Message, OpenCADStudio};
|
|
use crate::command::{InputKind, StepInput};
|
|
use iced::Task;
|
|
use serde_json::{json, Value};
|
|
use std::{collections::VecDeque, sync::OnceLock};
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
mod transport;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) use transport::subscribe;
|
|
|
|
pub(super) fn session_id() -> &'static str {
|
|
static ID: OnceLock<String> = OnceLock::new();
|
|
ID.get_or_init(|| {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let mut bytes = [0u8; 16];
|
|
getrandom::fill(&mut bytes).expect("OS random source");
|
|
bytes.iter().map(|v| format!("{v:02x}")).collect()
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
format!("web-{}", js_sys::Date::now())
|
|
}
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Envelope {
|
|
pub request: Value,
|
|
pub reply: Reply,
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum Reply {
|
|
Native(std::sync::mpsc::Sender<Value>),
|
|
Web(String),
|
|
}
|
|
impl Reply {
|
|
pub(super) fn send(self, value: Value) {
|
|
match self {
|
|
Self::Native(sender) => {
|
|
let _ = sender.send(value);
|
|
}
|
|
Self::Web(id) => web_store(id, value),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn web_store(_: String, _: Value) {}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
static WEB_QUEUE: std::sync::Mutex<VecDeque<(String, Value)>> =
|
|
std::sync::Mutex::new(VecDeque::new());
|
|
#[cfg(target_arch = "wasm32")]
|
|
static WEB_RESULTS: std::sync::Mutex<VecDeque<(String, Value)>> =
|
|
std::sync::Mutex::new(VecDeque::new());
|
|
#[cfg(target_arch = "wasm32")]
|
|
static WEB_TICKET: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn web_store(id: String, value: Value) {
|
|
let mut queue = WEB_RESULTS.lock().unwrap();
|
|
queue.push_back((id, value));
|
|
while queue.len() > 64 {
|
|
queue.pop_front();
|
|
}
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
pub fn ocs_control_submit(request: String) -> String {
|
|
let serial = WEB_TICKET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let id = format!("web-{}-{serial}", js_sys::Date::now());
|
|
let value = serde_json::from_str(&request)
|
|
.unwrap_or_else(|error| json!({"op":"invalid","parse_error":error.to_string()}));
|
|
let mut queue = WEB_QUEUE.lock().unwrap();
|
|
if queue.len() >= 64 {
|
|
drop(queue);
|
|
web_store(id.clone(), failure("busy", "Web control queue full"));
|
|
} else {
|
|
queue.push_back((id.clone(), value));
|
|
}
|
|
id
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
pub fn ocs_control_take(id: &str) -> Option<String> {
|
|
let mut queue = WEB_RESULTS.lock().unwrap();
|
|
let position = queue.iter().position(|(candidate, _)| candidate == id)?;
|
|
Some(queue.remove(position).unwrap().1.to_string())
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
pub(super) fn web_request() -> Option<Envelope> {
|
|
let (id, request) = WEB_QUEUE.lock().unwrap().pop_front()?;
|
|
Some(Envelope {
|
|
request,
|
|
reply: Reply::Web(id),
|
|
})
|
|
}
|
|
#[derive(Default)]
|
|
pub(super) struct State {
|
|
pending: Option<Operation>,
|
|
completed: VecDeque<(String, Value, Value)>,
|
|
owner: Option<(u64, String)>,
|
|
pub enabled: bool,
|
|
/// Set by the first control request of the session (or by a web host's
|
|
/// boot-time opt-in): while control is enabled, startup prompts that
|
|
/// would block automation — the donation prompt — are suppressed (#1349).
|
|
pub automation_seen: bool,
|
|
serial: u64,
|
|
events: VecDeque<Value>,
|
|
/// Monotonic counter bumped on every real selection change, so clients
|
|
/// can detect highlight changes without polling the full selection.
|
|
selection_revision: u64,
|
|
pub(super) routing: bool,
|
|
/// Session-scoped named handle sets for selection_set_save/load.
|
|
selection_sets: std::collections::BTreeMap<String, Vec<codec::Handle>>,
|
|
/// Live `user_select` request — the client asked the person at the screen
|
|
/// to pick entities; Some until that person answers with Enter/Escape.
|
|
pub(super) user_select: Option<UserSelectSession>,
|
|
/// Live `getpoint` request — the client asked for one picked point; Some
|
|
/// until the person clicks (answer) or presses Escape (cancel).
|
|
pub(super) get_point: Option<UserPointSession>,
|
|
/// `capture` request parked until the window has drawn another frame:
|
|
/// screenshotting in the same batch as the state change used to return the
|
|
/// previous frame (C11). Cleared by `on_control_capture_frame`.
|
|
pub(super) capture_frame_pending: bool,
|
|
/// Whether the parked capture actually waited for a redraw (vs. the
|
|
/// anti-hang timer). Reported in the capture result as `waited_for_frame`.
|
|
pub(super) capture_after_redraw: bool,
|
|
/// Web: when a parked capture gives up. Only a presented frame can be
|
|
/// read back on wasm, and a hidden or idle tab may never present one, so
|
|
/// the timer tick fails the capture past this instant instead of letting
|
|
/// it hold the control slot forever.
|
|
#[cfg(target_arch = "wasm32")]
|
|
pub(super) capture_deadline: Option<iced::time::Instant>,
|
|
}
|
|
|
|
/// The pending half of an interactive `user_select` operation (`interactive`
|
|
/// module owns the flow): which request it answers, which document's
|
|
/// selection to read, and the filter the answer must satisfy.
|
|
pub(super) struct UserSelectSession {
|
|
pub(super) request_id: String,
|
|
pub(super) document_id: u64,
|
|
pub(super) type_filter: Option<String>,
|
|
pub(super) layer_filter: Option<String>,
|
|
pub(super) detail: String,
|
|
/// The pinned command-line line shown while the person picks — the
|
|
/// prompt plus the request identity, so the screen keeps saying who is
|
|
/// waiting for what until they answer.
|
|
pub(super) label: String,
|
|
}
|
|
|
|
/// The pending half of a `getpoint` operation: which request it answers and
|
|
/// which document's viewport the pick must land in.
|
|
pub(super) struct UserPointSession {
|
|
pub(super) request_id: String,
|
|
pub(super) document_id: u64,
|
|
/// Same pinned line as `UserSelectSession::label`.
|
|
pub(super) label: String,
|
|
}
|
|
impl State {
|
|
pub(super) fn new() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
..Self::default()
|
|
}
|
|
}
|
|
}
|
|
struct Operation {
|
|
id: String,
|
|
request: Value,
|
|
pending: usize,
|
|
error_revision: u64,
|
|
document_id: Option<u64>,
|
|
origin_document: u64,
|
|
geometry_revision: u64,
|
|
result: Value,
|
|
/// Set when an interactive request (`getpoint`/`user_select`) was answered
|
|
/// with a cancellation, so the final status reports "cancelled".
|
|
cancelled: bool,
|
|
}
|
|
fn failure(code: &str, error: impl ToString) -> Value {
|
|
json!({"ok":false,"status":"failed","code":code,"error":error.to_string()})
|
|
}
|
|
/// `{"op":"open","name":"plan.dwg","data_base64":"…"}`: the web build has no
|
|
/// filesystem path to open, so the caller sends the file itself. `name` is
|
|
/// reduced to its file name; it labels the tab and the recent-files entry.
|
|
fn open_bytes_request(req: &Value) -> Result<(String, Vec<u8>), Value> {
|
|
use base64::Engine as _;
|
|
let name = string(req, "name")?;
|
|
let name = name.rsplit(['/', '\\']).next().unwrap_or(name);
|
|
if name.is_empty() {
|
|
return Err(failure("invalid_request", "Missing name"));
|
|
}
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(req["data_base64"].as_str().unwrap_or_default())
|
|
.map_err(|e| failure("invalid_request", format!("data_base64: {e}")))?;
|
|
if bytes.is_empty() {
|
|
return Err(failure("invalid_request", "data_base64 is empty"));
|
|
}
|
|
Ok((name.to_string(), bytes))
|
|
}
|
|
fn string<'a>(req: &'a Value, key: &str) -> Result<&'a str, Value> {
|
|
req[key]
|
|
.as_str()
|
|
.filter(|v| !v.is_empty())
|
|
.ok_or_else(|| failure("invalid_request", format!("Missing {key}")))
|
|
}
|
|
fn handle(req: &Value) -> Result<codec::Handle, Value> {
|
|
u64::from_str_radix(string(req, "handle")?.trim_start_matches("0x"), 16)
|
|
.map(codec::Handle::new)
|
|
.map_err(|_| failure("invalid_handle", "Expected hexadecimal handle"))
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn plugin_ids() -> Vec<String> {
|
|
crate::plugin::external::with_manager(|m| m.ids())
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn plugin_ids() -> Vec<String> {
|
|
Vec::new()
|
|
}
|
|
|
|
fn point(req: &Value) -> Result<glam::DVec3, Value> {
|
|
let values = req["point"]
|
|
.as_array()
|
|
.filter(|v| v.len() == 3)
|
|
.ok_or_else(|| failure("invalid_point", "Expected three finite coordinates"))?;
|
|
let mut p = [0.; 3];
|
|
for (i, v) in values.iter().enumerate() {
|
|
p[i] = v
|
|
.as_f64()
|
|
.filter(|v| v.is_finite())
|
|
.ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?;
|
|
}
|
|
Ok(glam::DVec3::from_array(p))
|
|
}
|
|
|
|
fn command_examples(name: &str) -> &'static [&'static str] {
|
|
match name {
|
|
"LINE" => &["LINE 0,0 10,10"],
|
|
"CIRCLE" => &["CIRCLE 5,5 3"],
|
|
"PLINE" => &["PLINE 0,0 10,0 10,10 C"],
|
|
"ZOOM" => &["ZOOM EXTENTS"],
|
|
"MOVE" => &["MOVE 0,0 100,0"],
|
|
"ROTATE" => &["ROTATE 0,0 90"],
|
|
"UCS" => &["UCS Z 90"],
|
|
"PDMODE" => &["PDMODE 3"],
|
|
"LTSCALE" => &["LTSCALE 2.5"],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
fn command_category(name: &str) -> &'static str {
|
|
if [
|
|
"LINE", "CIRCLE", "PLINE", "ARC", "ELLIPSE", "POINT", "SPLINE", "RECT", "RECTANG",
|
|
"POLYGON", "HATCH", "RAY", "XLINE", "3DPOLY", "MLINE", "TEXT", "MTEXT", "TABLE",
|
|
]
|
|
.contains(&name)
|
|
{
|
|
"create"
|
|
} else if [
|
|
"MOVE",
|
|
"COPY",
|
|
"ROTATE",
|
|
"SCALE",
|
|
"MIRROR",
|
|
"ERASE",
|
|
"TRIM",
|
|
"EXTEND",
|
|
"OFFSET",
|
|
"FILLET",
|
|
"CHAMFER",
|
|
"BREAK",
|
|
"JOIN",
|
|
"EXPLODE",
|
|
"STRETCH",
|
|
"ARRAY",
|
|
"PEDIT",
|
|
"SPLINEDIT",
|
|
"REVERSE",
|
|
"ALIGN",
|
|
]
|
|
.contains(&name)
|
|
{
|
|
"modify"
|
|
} else if ["DIST", "ID", "AREA", "MEASURE", "MEASUREGEOM"].contains(&name) {
|
|
"inspect"
|
|
} else if ["ZOOM", "PAN", "UCS", "PLAN", "VPORTS", "3DORBIT"].contains(&name) {
|
|
"view"
|
|
} else if name.starts_with("DIM") || ["LEADER", "MLEADER", "TOLERANCE"].contains(&name) {
|
|
"annotate"
|
|
} else if [
|
|
"GEOMCONSTRAINT",
|
|
"GCCOINCIDENT",
|
|
"GCCOLLINEAR",
|
|
"GCPERPENDICULAR",
|
|
"QCONSTRAINT",
|
|
"PCONSTRAINT",
|
|
"GCHORIZONTAL",
|
|
"VCONSTRAINT",
|
|
"ECONSTRAINT",
|
|
"GCEQUAL",
|
|
"TCONSTRAINT",
|
|
"GCCONCENTRIC",
|
|
"NRCONSTRAINT",
|
|
"LCONSTRAINT",
|
|
"DELCONSTRAINT",
|
|
]
|
|
.contains(&name)
|
|
{
|
|
"parametric"
|
|
} else if [
|
|
"BOX",
|
|
"CYLINDER",
|
|
"CONE",
|
|
"SPHERE",
|
|
"TORUS",
|
|
"WEDGE",
|
|
"EXTRUDE",
|
|
"REVOLVE",
|
|
"SWEEP",
|
|
"LOFT",
|
|
"UNION",
|
|
"SUBTRACT",
|
|
"INTERSECT",
|
|
"PRESSPULL",
|
|
]
|
|
.contains(&name)
|
|
{
|
|
"model"
|
|
} else {
|
|
"other"
|
|
}
|
|
}
|
|
|
|
fn command_selection_policy(name: &str) -> &'static str {
|
|
if [
|
|
"ARRAY",
|
|
"ARRAYPATH",
|
|
"ARRAYPOLAR",
|
|
"ARRAYRECT",
|
|
"BLOCK",
|
|
"COPY",
|
|
"COPYCLIP",
|
|
"CUTCLIP",
|
|
"ERASE",
|
|
"EXPLODE",
|
|
"GROUP",
|
|
"LAYFRZ",
|
|
"LAYLCK",
|
|
"LAYMCUR",
|
|
"LAYOFF",
|
|
"LAYULK",
|
|
"MIRROR",
|
|
"MOVE",
|
|
"ROTATE",
|
|
"SCALE",
|
|
"STRETCH",
|
|
]
|
|
.contains(&name)
|
|
{
|
|
"current_selection_or_prompt"
|
|
} else {
|
|
"command_defined"
|
|
}
|
|
}
|
|
|
|
pub(super) fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
|
|
let options = command.options();
|
|
let mut accepts = Vec::new();
|
|
if command.is_selection_gathering() {
|
|
accepts.push("selection");
|
|
}
|
|
if command.needs_structure_point_pick() {
|
|
accepts.push("structure");
|
|
}
|
|
if command.needs_entity_pick() {
|
|
accepts.push("entity");
|
|
}
|
|
if accepts.is_empty() && !command.needs_tangent_pick() {
|
|
let kind = match command.input_kind() {
|
|
InputKind::Point => "point",
|
|
InputKind::SingleToken => "token",
|
|
InputKind::FreeText => "text",
|
|
};
|
|
// A point step that also takes keyword letters (MOVE/COPY's base
|
|
// point with [Displacement]) reports SingleToken so the letters reach
|
|
// the command line, but it still takes a point.
|
|
if command.point_step_accepts_keywords() && kind != "point" {
|
|
accepts.push("point");
|
|
}
|
|
accepts.push(kind);
|
|
}
|
|
if options.iter().any(|option| !option.keyword.is_empty()) && !accepts.contains(&"token") {
|
|
accepts.push("token");
|
|
}
|
|
accepts.push("enter");
|
|
|
|
let input_example = if accepts.contains(&"point") {
|
|
json!({"op":"input","kind":"point","point":[0,0,0],"space":"wcs"})
|
|
} else if accepts.contains(&"entity") {
|
|
json!({"op":"input","kind":"entity","handle":"HANDLE","point":[0,0,0]})
|
|
} else if accepts.contains(&"structure") {
|
|
json!({"op":"input","kind":"structure","handle":"HANDLE","point":[0,0,0]})
|
|
} else if accepts.contains(&"selection") {
|
|
json!({"op":"input","kind":"selection"})
|
|
} else if accepts.contains(&"text") {
|
|
json!({"op":"input","kind":"text","text":"value"})
|
|
} else if accepts.contains(&"token") {
|
|
let token = options
|
|
.iter()
|
|
.find(|option| !option.keyword.is_empty())
|
|
.map_or("VALUE", |option| option.keyword.as_str());
|
|
json!({"op":"input","kind":"token","text":token})
|
|
} else {
|
|
json!({"op":"input","kind":"enter"})
|
|
};
|
|
|
|
json!({
|
|
"name":command.name(),
|
|
"prompt":command.prompt(),
|
|
"accepts":accepts,
|
|
"input_example":input_example,
|
|
"options":options.iter().map(|option| json!({
|
|
"label":option.label,
|
|
"keyword":option.keyword,
|
|
"kind":if option.keyword.is_empty(){"enter"}else{"token"}
|
|
})).collect::<Vec<_>>(),
|
|
"entity_pick":command.needs_entity_pick(),
|
|
"structure_pick":command.needs_structure_point_pick(),
|
|
"selection":command.is_selection_gathering(),
|
|
"tangent_pick":command.needs_tangent_pick(),
|
|
"free_text":command.is_free_text_step()
|
|
})
|
|
}
|
|
|
|
impl OpenCADStudio {
|
|
pub(super) fn control_busy(&self) -> bool {
|
|
self.control.pending.is_some()
|
|
}
|
|
|
|
/// Mirror a real selection change into the automation event stream. The
|
|
/// plugin host already publishes the V4 selection event; automation clients
|
|
/// need the same signal instead of polling `state`.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) fn control_record_selection_event(&mut self, handles: &[codec::Handle]) {
|
|
let (document_id, revision) = {
|
|
let tab = &self.tabs[self.active_tab];
|
|
(tab.id, tab.edit_revision)
|
|
};
|
|
let selection: Vec<String> = handles
|
|
.iter()
|
|
.map(|handle| format!("{:X}", handle.value()))
|
|
.collect();
|
|
self.control.selection_revision = self.control.selection_revision.wrapping_add(1);
|
|
let selection_revision = self.control.selection_revision;
|
|
self.control.serial += 1;
|
|
self.control.events.push_back(json!({
|
|
"sequence": self.control.serial,
|
|
"kind": "selection",
|
|
"document_id": document_id,
|
|
"revision": revision,
|
|
"selection_revision": selection_revision,
|
|
"selection": selection,
|
|
}));
|
|
while self.control.events.len() > 128 {
|
|
self.control.events.pop_front();
|
|
}
|
|
}
|
|
|
|
pub(super) fn control_state(&self) -> Value {
|
|
let tab = &self.tabs[self.active_tab];
|
|
let command = tab.active_cmd.as_deref().map(active_command_metadata);
|
|
let missing_fonts = crate::io::font_repo::missing_shx_fonts(&tab.scene.document);
|
|
json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"),
|
|
"mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled,
|
|
"read_only":self.read_only,"missing_fonts":missing_fonts,
|
|
"document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation,
|
|
"hand_seed":format!("{:X}",tab.scene.document.header.handle_seed.max(tab.scene.document.next_handle())),
|
|
"plugins":plugin_ids(),
|
|
"documents":self.tabs.iter().map(|t|json!({"id":t.id,"title":t.tab_title,"path":t.current_path,"dirty":t.dirty,"revision":t.edit_revision,"start":t.is_start})).collect::<Vec<_>>(),
|
|
"selection":tab.scene.selected_handles_in_order().iter().map(|h|format!("{:X}",h.value())).collect::<Vec<_>>(),
|
|
"selection_revision":self.control.selection_revision,
|
|
"command":command,"modal":self.active_modal.as_ref().map(|m|format!("{m:?}")),
|
|
"layout":tab.scene.current_layout,
|
|
"ucs":tab.active_ucs.as_ref().map(|u|json!({"name":u.name,"origin":[u.origin.x,u.origin.y,u.origin.z],"x_axis":[u.x_axis.x,u.x_axis.y,u.x_axis.z],"y_axis":[u.y_axis.x,u.y_axis.y,u.y_axis.z],"elevation":u.elevation})),
|
|
"cursor":{"world":tab.last_cursor_world.to_array(),"screen":[tab.last_cursor_screen.x,tab.last_cursor_screen.y]},
|
|
"viewport_size":({let s=tab.scene.selection.borrow();[s.view.vp_size.0,s.view.vp_size.1]}),
|
|
"camera":({let c=tab.scene.camera.borrow();json!({"target":c.target.to_array(),"rotation":[c.rotation.x,c.rotation.y,c.rotation.z,c.rotation.w],"distance":c.distance,"fov_y":c.fov_y,"projection":format!("{:?}",c.projection),"yaw":c.yaw,"pitch":c.pitch})}),
|
|
"mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})),
|
|
"text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial,
|
|
"operation":self.control.pending.as_ref().map(|p| &p.id),
|
|
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","user_select","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query","audit","verified_save","explicit_save_version"]
|
|
})
|
|
}
|
|
|
|
pub(super) fn control_request(&mut self, req: Value) -> (Value, Task<Message>) {
|
|
// First control contact marks the session automated (#1349): a
|
|
// DonationPrompt that is still queued (not yet shown) is dropped so
|
|
// automation never waits behind it, and `queue_startup_prompts`
|
|
// stops queueing one. A prompt already on screen stays — a human
|
|
// may be reading it; clients close it with `close_modal`.
|
|
if !self.control.automation_seen && self.control.enabled {
|
|
self.control.automation_seen = true;
|
|
if self.active_modal != Some(super::ModalKind::DonationPrompt) {
|
|
self.pending_startup_modals
|
|
.retain(|kind| kind != &super::ModalKind::DonationPrompt);
|
|
}
|
|
}
|
|
let op = req["op"].as_str().unwrap_or("");
|
|
let query = matches!(
|
|
op,
|
|
"state"
|
|
| "hello"
|
|
| "operation"
|
|
| "events"
|
|
| "commands"
|
|
| "properties"
|
|
| "measure"
|
|
| "snap"
|
|
| "grips"
|
|
| "query"
|
|
| "records"
|
|
| "record_schema"
|
|
| "capabilities"
|
|
| "tools"
|
|
| "entities"
|
|
| "layers"
|
|
| "header"
|
|
| "history"
|
|
| "xdata_get"
|
|
| "get_selection"
|
|
| "audit"
|
|
| "text_search"
|
|
| "text_audit"
|
|
);
|
|
if !query && !self.control.enabled {
|
|
return (
|
|
failure("disabled", "Automation is stopped. Enable it in OCS."),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if req["protocol"].as_u64().is_some_and(|v| v != 1) {
|
|
return (
|
|
failure("protocol_mismatch", "Protocol 1 required"),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if req["session_id"]
|
|
.as_str()
|
|
.is_some_and(|v| v != session_id())
|
|
{
|
|
return (
|
|
failure(
|
|
"session_changed",
|
|
"Reconnect; do not replay mutations in a new session",
|
|
),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if op == "hello" || op == "state" {
|
|
return (self.control_state(), Task::none());
|
|
}
|
|
if op == "operation" {
|
|
let id = req["request_id"].as_str().unwrap_or("");
|
|
let response = if let Some(p) = self.control.pending.as_ref().filter(|p| p.id == id) {
|
|
json!({"ok":true,"status":"running","request_id":p.id})
|
|
} else {
|
|
self.control
|
|
.completed
|
|
.iter()
|
|
.find(|(i, _, _)| i == id)
|
|
.map(|(_, _, r)| r.clone())
|
|
.unwrap_or_else(|| {
|
|
failure(
|
|
"unknown_operation",
|
|
"Operation absent or expired; do not automatically replay",
|
|
)
|
|
})
|
|
};
|
|
return (response, Task::none());
|
|
}
|
|
if op == "events" {
|
|
let cursor = req["after"].as_u64().unwrap_or(0);
|
|
return (
|
|
json!({"ok":true,"cursor":self.control.serial,"resync":self.control.events.front().is_some_and(|e|cursor+1<e["sequence"].as_u64().unwrap_or(0)),"events":self.control.events.iter().filter(|e|e["sequence"].as_u64().unwrap_or(0)>cursor).collect::<Vec<_>>()}),
|
|
Task::none(),
|
|
);
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if op == "tools" {
|
|
return (
|
|
json!({
|
|
"ok": true,
|
|
"status": "completed",
|
|
"tools": crate::mcp::tool_definitions(),
|
|
"instructions": crate::mcp::INSTRUCTIONS
|
|
}),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if op == "commands" {
|
|
let mut names = crate::command::all_registered_command_names();
|
|
let mut names: Vec<String> = names.drain(..).map(str::to_owned).collect();
|
|
names.extend(self.command_line.dynamic_commands.iter().cloned());
|
|
names.sort_unstable();
|
|
names.dedup();
|
|
if let Some(requested) = req["name"].as_str() {
|
|
let Some(name) = names
|
|
.iter()
|
|
.find(|name| name.eq_ignore_ascii_case(requested))
|
|
else {
|
|
return (
|
|
failure(
|
|
"unknown_command",
|
|
format!(
|
|
"Unknown command {requested}; call commands without name to list commands"
|
|
),
|
|
),
|
|
Task::none(),
|
|
);
|
|
};
|
|
return (
|
|
json!({
|
|
"ok":true,
|
|
"command":{
|
|
"name":name,
|
|
"category":command_category(name),
|
|
"preconditions":{
|
|
"document":true,
|
|
"selection":command_selection_policy(name)
|
|
},
|
|
"batch":{
|
|
"op":"run",
|
|
"syntax":"Command name followed by prompt answers separated by spaces. Points use x,y or x,y,z; options use their token.",
|
|
"examples":command_examples(name)
|
|
},
|
|
"interactive":{
|
|
"op":"start",
|
|
"request":{"op":"start","cmd":name},
|
|
"next":"Follow state.command.accepts, options and input_example after every step."
|
|
},
|
|
"completion":"completed is final; waiting_input requires another input step; failed includes the reason."
|
|
}
|
|
}),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if let Some(search) = req["search"].as_str().filter(|search| !search.is_empty()) {
|
|
let search = search.to_ascii_uppercase();
|
|
names.retain(|name| name.contains(&search));
|
|
}
|
|
let count = names.len();
|
|
let offset = req["offset"].as_u64().unwrap_or(0) as usize;
|
|
let limit = req["limit"].as_u64().unwrap_or(200).min(1000) as usize;
|
|
let commands: Vec<String> = names.into_iter().skip(offset).take(limit).collect();
|
|
return (
|
|
json!({
|
|
"ok":true,"count":count,"returned":commands.len(),
|
|
"next_offset":(offset + commands.len() < count).then_some(offset + commands.len()),
|
|
"commands":commands,"actions":actions::NAMES,
|
|
"detail_parameters":{"name":"LINE"},"search_parameter":{"search":"LINE"},
|
|
"guidance":"Request one command by name for batch examples and interactive input guidance."
|
|
}),
|
|
Task::none(),
|
|
);
|
|
}
|
|
let id = if query {
|
|
String::new()
|
|
} else {
|
|
let id = match string(&req, "request_id") {
|
|
Ok(v) if v.len() <= 128 => v.to_owned(),
|
|
_ => {
|
|
return (
|
|
failure(
|
|
"request_id_required",
|
|
"Supply a unique request_id (maximum 128 bytes)",
|
|
),
|
|
Task::none(),
|
|
);
|
|
}
|
|
};
|
|
if let Some((_, old, result)) = self.control.completed.iter().find(|(i, _, _)| i == &id)
|
|
{
|
|
return (
|
|
if old == &req {
|
|
result.clone()
|
|
} else {
|
|
failure(
|
|
"request_id_reused",
|
|
format!(
|
|
"Request payload changed for request_id '{id}'; generate a new unique request_id or resend the original payload",
|
|
),
|
|
)
|
|
},
|
|
Task::none(),
|
|
);
|
|
}
|
|
if let Some(p) = &self.control.pending {
|
|
// `cancel` is a reference to the pending request, not a
|
|
// replay of its payload. It must reach the interactive cancel
|
|
// branch below even when it uses the same request_id.
|
|
if p.id == id && op != "cancel" {
|
|
return (
|
|
if p.request == req {
|
|
json!({"ok":true,"status":"running","request_id":id})
|
|
} else {
|
|
failure(
|
|
"request_id_reused",
|
|
format!(
|
|
"Request payload changed for request_id '{id}'; generate a new unique request_id or resend the original payload",
|
|
),
|
|
)
|
|
},
|
|
Task::none(),
|
|
);
|
|
}
|
|
// An interactive request (`user_select`/`getpoint`) outlives
|
|
// normal request handling — it only ends when the person
|
|
// answers. Let a client retract it with `cancel` instead of
|
|
// waiting for their Escape.
|
|
let pending_op = p.request["op"].as_str().unwrap_or("").to_owned();
|
|
let pending_id = p.id.clone();
|
|
if op == "cancel" && matches!(pending_op.as_str(), "user_select" | "getpoint") {
|
|
if pending_op == "getpoint" {
|
|
self.resolve_get_point(None);
|
|
} else {
|
|
self.resolve_user_select(false);
|
|
}
|
|
let response = self
|
|
.control
|
|
.completed
|
|
.iter()
|
|
.find(|(i, _, _)| *i == pending_id)
|
|
.map(|(_, _, r)| r.clone());
|
|
return (
|
|
response
|
|
.unwrap_or_else(|| failure("busy", "Wait for the running operation")),
|
|
Task::none(),
|
|
);
|
|
}
|
|
return (
|
|
failure("busy", "Wait for the running operation"),
|
|
Task::none(),
|
|
);
|
|
}
|
|
id
|
|
};
|
|
if let Some(id) = req["document_id"].as_u64() {
|
|
if !self.tabs.iter().any(|t| t.id == id) {
|
|
return (
|
|
failure("document_closed", "Document no longer exists"),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if self.tabs[self.active_tab].id != id
|
|
&& !matches!(
|
|
op,
|
|
"activate" | "switch_document" | "close" | "entities_copy_to"
|
|
)
|
|
{
|
|
return (
|
|
failure(
|
|
"document_not_active",
|
|
"Activate the requested document first",
|
|
),
|
|
Task::none(),
|
|
);
|
|
}
|
|
// Interactive requests operate on the active tab by definition, so
|
|
// a client may omit document_id (the GUI-hosted REST channel does).
|
|
} else if !query && !matches!(op, "new" | "open" | "stop" | "user_select" | "getpoint") {
|
|
return (
|
|
failure("document_required", "Read state and supply document_id"),
|
|
Task::none(),
|
|
);
|
|
}
|
|
let tab = &self.tabs[self.active_tab];
|
|
if req["revision"]
|
|
.as_u64()
|
|
.is_some_and(|v| v != tab.edit_revision)
|
|
|| req["geometry_revision"]
|
|
.as_u64()
|
|
.is_some_and(|v| v != tab.scene.geometry_epoch)
|
|
|| req["camera_revision"]
|
|
.as_u64()
|
|
.is_some_and(|v| v != tab.scene.camera_generation)
|
|
{
|
|
return (
|
|
json!({"ok":false,"status":"failed","code":"stale_state","error":"Refresh state before editing","state":self.control_state()}),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if query {
|
|
let response = match op {
|
|
"properties" => self.control_properties(),
|
|
"measure" => self.control_measure(&req),
|
|
// The object snap a cursor over `point` (world) would get;
|
|
// `from` is the base for perpendicular and tangent.
|
|
"snap" => 'snap: {
|
|
let p = &req["point"];
|
|
let (Some(x), Some(y)) = (p[0].as_f64(), p[1].as_f64()) else {
|
|
break 'snap failure("invalid_point", "point must be [x, y] or [x, y, z]");
|
|
};
|
|
let world = glam::DVec3::new(x, y, p[2].as_f64().unwrap_or(0.0));
|
|
let from = req["from"].as_array().map(|f| {
|
|
glam::DVec3::new(
|
|
f.first().and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
f.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
f.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
)
|
|
});
|
|
let i = self.active_tab;
|
|
match self.snap_query(i, world, from) {
|
|
Some(hit) => {
|
|
json!({"ok":true,"snap":format!("{:?}", hit.snap_type),"world":[hit.world.x,hit.world.y,hit.world.z]})
|
|
}
|
|
None => json!({"ok":true,"snap":null}),
|
|
}
|
|
}
|
|
"grips" => self.control_grips(),
|
|
"xdata_get" => self.xdata_read(&req),
|
|
"get_selection" => self.control_get_selection(),
|
|
"text_search" => self.control_text_search(&req).unwrap_or_else(|e| e),
|
|
"text_audit" => self.control_text_audit(&req).unwrap_or_else(|e| e),
|
|
"history" => {
|
|
json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::<Vec<_>>()})
|
|
}
|
|
_ => self.automation_op_inner(&req.to_string()),
|
|
};
|
|
return (response, Task::none());
|
|
}
|
|
let client = req["client_id"].as_str().unwrap_or("default").to_owned();
|
|
if tab.active_cmd.is_some()
|
|
&& (self.control.owner.as_ref() != Some(&(tab.id, client.clone()))
|
|
|| matches!(
|
|
op,
|
|
"run" | "start" | "new" | "open" | "activate" | "switch_document"
|
|
))
|
|
&& op != "cancel"
|
|
{
|
|
return (
|
|
failure("command_busy", "An interactive command is already active"),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if matches!(op, "run" | "start" | "input") && tab.is_start {
|
|
return (
|
|
failure("no_document", "Create or open a drawing first"),
|
|
Task::none(),
|
|
);
|
|
}
|
|
if let Some(expected) = req["selection"].as_array() {
|
|
let actual: Vec<Value> = tab
|
|
.scene
|
|
.selected_handles_in_order()
|
|
.iter()
|
|
.map(|h| json!(format!("{:X}", h.value())))
|
|
.collect();
|
|
if &actual != expected {
|
|
return (
|
|
failure(
|
|
"selection_changed",
|
|
"Read state and confirm the current selection",
|
|
),
|
|
Task::none(),
|
|
);
|
|
}
|
|
}
|
|
let doc = if matches!(op, "new" | "open" | "activate" | "switch_document") {
|
|
None
|
|
} else {
|
|
Some(tab.id)
|
|
};
|
|
self.control.pending = Some(Operation {
|
|
id: id.clone(),
|
|
request: req.clone(),
|
|
pending: 1,
|
|
error_revision: self.command_line.error_revision,
|
|
document_id: doc,
|
|
origin_document: tab.id,
|
|
geometry_revision: tab.scene.geometry_epoch,
|
|
result: json!({}),
|
|
cancelled: false,
|
|
});
|
|
self.control.routing = true;
|
|
let action = self.control_action(&req);
|
|
self.control.routing = false;
|
|
let task = match action {
|
|
Ok(t) => t,
|
|
Err(error) => {
|
|
self.control.pending.take();
|
|
return (error, Task::none());
|
|
}
|
|
};
|
|
if self.tabs[self.active_tab].active_cmd.is_some() {
|
|
self.control.owner = Some((self.tabs[self.active_tab].id, client));
|
|
}
|
|
if matches!(op, "new" | "activate" | "switch_document")
|
|
&& self
|
|
.control
|
|
.pending
|
|
.as_ref()
|
|
.is_some_and(|pending| pending.origin_document != self.tabs[self.active_tab].id)
|
|
{
|
|
if let Some(pending) = self.control.pending.as_mut() {
|
|
pending.document_id = Some(self.tabs[self.active_tab].id);
|
|
}
|
|
}
|
|
self.finish_all_pending_history();
|
|
let task = self.control_track(task);
|
|
if let Some(p) = self.control.pending.as_mut() {
|
|
// Interactive requests hold their slot open on purpose: the
|
|
// counter stays at one so settle keeps returning `running` until
|
|
// the person at the screen answers and the resolver zeroes it.
|
|
// `capture` is parked the same way until the next drawn frame.
|
|
if !matches!(
|
|
p.request["op"].as_str(),
|
|
Some("user_select" | "getpoint" | "capture")
|
|
) {
|
|
p.pending -= 1;
|
|
}
|
|
}
|
|
self.control_settle();
|
|
let response = self
|
|
.control
|
|
.completed
|
|
.iter()
|
|
.find(|(i, _, _)| i == &id)
|
|
.map(|(_, _, r)| r.clone())
|
|
.unwrap_or_else(|| json!({"ok":true,"status":"accepted","request_id":id}));
|
|
(response, task)
|
|
}
|
|
|
|
fn control_action(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
let i = self.active_tab;
|
|
Ok(match req["op"].as_str().unwrap_or("") {
|
|
"new" => {
|
|
if let Some(template) = req["template"].as_str() {
|
|
match self.apply_template(template) {
|
|
Ok(purged) => {
|
|
self.set_control_result(json!({
|
|
"template": template,
|
|
"purged": purged,
|
|
"total": self.tabs[i].scene.document.entities().count(),
|
|
}));
|
|
Task::none()
|
|
}
|
|
Err(error) => return Err(error),
|
|
}
|
|
} else {
|
|
self.update(Message::TabNew)
|
|
}
|
|
}
|
|
"open" if req.get("data_base64").is_some() => {
|
|
let (name, bytes) = open_bytes_request(req)?;
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
if self.opening.is_some() {
|
|
return Err(failure("busy", "Another drawing is still opening"));
|
|
}
|
|
self.open_web_bytes(name, bytes)
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let _ = (name, bytes);
|
|
return Err(failure(
|
|
"invalid_request",
|
|
"data_base64 is for the web build; supply path",
|
|
));
|
|
}
|
|
}
|
|
"open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string(
|
|
req, "path",
|
|
)?))),
|
|
"activate" | "switch_document" => {
|
|
let i = self
|
|
.tabs
|
|
.iter()
|
|
.position(|t| Some(t.id) == req["document_id"].as_u64())
|
|
.ok_or_else(|| failure("document_closed", "Document absent"))?;
|
|
self.update(Message::TabSwitch(i))
|
|
}
|
|
"run" => self.run_command_line(string(req, "cmd")?),
|
|
"start" => self.dispatch_command(string(req, "cmd")?),
|
|
"input" => {
|
|
if self.tabs[i].active_cmd.is_none() {
|
|
return Err(failure("no_command", "No command waiting for input"));
|
|
}
|
|
match string(req, "kind")? {
|
|
"text" => self
|
|
.feed_command(StepInput::Text(req["text"].as_str().unwrap_or("").into())),
|
|
"token" => self.feed_active_cmd(string(req, "text")?),
|
|
"point" => {
|
|
let mut p = point(req)?;
|
|
match req["space"].as_str().unwrap_or("wcs") {
|
|
"wcs" => {}
|
|
"ucs" => {
|
|
if let Some(u) = &self.tabs[i].active_ucs {
|
|
p = super::helpers::ucs_to_wcs(p, u);
|
|
}
|
|
}
|
|
"relative" => {
|
|
let base = self.last_point.ok_or_else(|| {
|
|
failure(
|
|
"no_base_point",
|
|
"Relative input needs a previous point",
|
|
)
|
|
})?;
|
|
if let Some(u) = &self.tabs[i].active_ucs {
|
|
p = super::helpers::ucs_rotate_vec(p, u);
|
|
}
|
|
p += base;
|
|
}
|
|
_ => return Err(failure("invalid_space", "Use wcs, ucs or relative")),
|
|
};
|
|
self.feed_command(StepInput::Point(p))
|
|
}
|
|
"entity" | "structure" => {
|
|
let h = handle(req)?;
|
|
if self.tabs[i].scene.document.get_entity(h).is_none() {
|
|
return Err(failure("entity_absent", "Unknown entity"));
|
|
}
|
|
let p = point(req)?;
|
|
self.feed_command(if req["kind"] == "structure" {
|
|
StepInput::StructurePick(h, p)
|
|
} else {
|
|
StepInput::EntityPick(h, p)
|
|
})
|
|
}
|
|
"selection" => {
|
|
let hs = self.tabs[i].scene.selected_handles_in_order();
|
|
self.feed_command(StepInput::SelectionComplete(hs))
|
|
}
|
|
"enter" => self.feed_command(StepInput::Enter),
|
|
_ => return Err(failure("invalid_input", "Unknown input kind")),
|
|
}
|
|
}
|
|
"cancel" => self.update(Message::CommandEscape),
|
|
"undo" => self.update(Message::Undo),
|
|
"redo" => self.update(Message::Redo),
|
|
"select" => {
|
|
if let Some(hs) = req["handles"].as_array() {
|
|
for v in hs {
|
|
let h = u64::from_str_radix(
|
|
v.as_str().unwrap_or("").trim_start_matches("0x"),
|
|
16,
|
|
)
|
|
.map(codec::Handle::new)
|
|
.map_err(|_| failure("invalid_handle", "Expected hexadecimal handle"))?;
|
|
if self.tabs[i].scene.document.get_entity(h).is_none() {
|
|
return Err(failure(
|
|
"entity_absent",
|
|
format!("Entity {} does not exist", v),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
let result = self.automation_op_inner(&req.to_string());
|
|
if result["ok"] != true {
|
|
return Err(result);
|
|
}
|
|
self.refresh_properties();
|
|
Task::none()
|
|
}
|
|
"property" => self.control_set_property(req)?,
|
|
"grip_drag" => self.control_grip_drag(req)?,
|
|
"click" => self.control_click(req)?,
|
|
"set_properties" => self.control_set_record_properties(req)?,
|
|
"action" => self.control_ui_action(req)?,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"embed_image" => self.control_embed_image(req)?,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"wblock" => self.control_wblock(req)?,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"plot" => self.control_plot(req)?,
|
|
"entities_create" => self.control_entities_create(req)?,
|
|
"entities_delete" => self.control_entities_delete(req)?,
|
|
"entities_transform" => self.control_entities_transform(req)?,
|
|
"entities_copy_to" => self.control_entities_copy_to(req)?,
|
|
"text_replace" => self.control_text_replace(req)?,
|
|
"xdata_set" => self.control_xdata_set(req)?,
|
|
"block_define" => self.control_block_define(req)?,
|
|
"block_delete" => self.control_block_delete(req)?,
|
|
"group_create" => self.control_group_create(req)?,
|
|
"selection_set_save" => self.control_selection_set_save(req)?,
|
|
"selection_set_load" => self.control_selection_set_load(req)?,
|
|
"user_select" => self.control_user_select(req)?,
|
|
"getpoint" => self.control_getpoint(req)?,
|
|
"close" => self.control_close(req)?,
|
|
"sysvar" => self.control_sysvar(req)?,
|
|
"layout_create" => self.control_layout_create(req)?,
|
|
"page_setup_set" => self.control_page_setup_set(req)?,
|
|
"file_identity" => self.control_file_identity(req)?,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"view_focus" => self.control_view_focus(req)?,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"save" => {
|
|
let path = req["path"]
|
|
.as_str()
|
|
.map(std::path::PathBuf::from)
|
|
.or_else(|| self.tabs[i].current_path.clone())
|
|
.ok_or_else(|| failure("path_required", "Supply a save path"))?;
|
|
let default_is_dxf = crate::io::source_is_dxf(
|
|
self.tabs[i].current_path.as_deref(),
|
|
&self.tabs[i].scene.document,
|
|
);
|
|
let (version, is_dxf) = super::automation::requested_save_target(
|
|
req,
|
|
self.tabs[i].scene.document.version,
|
|
default_is_dxf,
|
|
Some(&path),
|
|
)
|
|
.map_err(|error| failure("invalid_target", error))?;
|
|
let dropped =
|
|
crate::io::dropped_on_save_count(&self.tabs[i].scene.document, version, is_dxf);
|
|
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
|
|
return Err(failure(
|
|
"lossy_conversion_not_acknowledged",
|
|
format!(
|
|
"Conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"
|
|
),
|
|
));
|
|
}
|
|
self.set_control_result(json!({
|
|
"saved":path,"target_format":if is_dxf { "dxf" } else { "dwg" },
|
|
"target_version":format!("{version:?}"),"dropped_on_save":dropped,
|
|
}));
|
|
if self.main_window.is_none() {
|
|
self.save_tab_synchronously_protected_as(i, path, version, true)
|
|
.map_err(|e| failure("save_failed", e))?;
|
|
Task::none()
|
|
} else {
|
|
self.prepare_native_save(i);
|
|
self.queue_native_save(
|
|
i,
|
|
path,
|
|
version,
|
|
super::SavePurpose::SaveAs,
|
|
super::SaveContinuation::None,
|
|
true,
|
|
true,
|
|
)
|
|
}
|
|
}
|
|
// Web: no filesystem path; the drawing comes back in the reply.
|
|
#[cfg(target_arch = "wasm32")]
|
|
"save" => {
|
|
use base64::Engine as _;
|
|
let default_is_dxf = crate::io::source_is_dxf(None, &self.tabs[i].scene.document);
|
|
let (version, is_dxf) = super::automation::requested_save_target(
|
|
req,
|
|
self.tabs[i].scene.document.version,
|
|
default_is_dxf,
|
|
None,
|
|
)
|
|
.map_err(|error| failure("invalid_target", error))?;
|
|
let dropped =
|
|
crate::io::dropped_on_save_count(&self.tabs[i].scene.document, version, is_dxf);
|
|
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
|
|
return Err(failure(
|
|
"lossy_conversion_not_acknowledged",
|
|
format!(
|
|
"Conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"
|
|
),
|
|
));
|
|
}
|
|
let ext = if is_dxf { "dxf" } else { "dwg" };
|
|
self.sync_view_state_for_save(i);
|
|
super::update::util::sync_annotation_scale_header(&mut self.tabs[i].scene);
|
|
self.stamp_header_sysvars(i);
|
|
self.sync_solid_models_for_save(i);
|
|
self.tabs[i].scene.sync_native_parametric_graph();
|
|
let bytes = crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version)
|
|
.map_err(|error| failure("save_failed", error))?;
|
|
self.tabs[i].dirty = false;
|
|
let name = req["name"].as_str().filter(|name| !name.is_empty());
|
|
let name = name.map_or_else(|| format!("drawing.{ext}"), str::to_string);
|
|
self.set_control_result(json!({
|
|
"name": name,
|
|
"target_format": ext,
|
|
"target_version": format!("{version:?}"),
|
|
"dropped_on_save": dropped,
|
|
"bytes": bytes.len(),
|
|
"data_base64": base64::engine::general_purpose::STANDARD.encode(&bytes),
|
|
}));
|
|
Task::none()
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"save_verified" => {
|
|
let result = self.save_verified_request(req)?;
|
|
self.set_control_result(result);
|
|
Task::none()
|
|
}
|
|
"capture" => {
|
|
self.main_window
|
|
.ok_or_else(|| failure("gui_required", "Capture requires a GUI window"))?;
|
|
string(req, "path")?;
|
|
let was_minimized = crate::sys::restore_window_if_minimized();
|
|
if was_minimized {
|
|
std::thread::sleep(std::time::Duration::from_millis(60));
|
|
}
|
|
|
|
// Optional framing / selection adjustments prior to snapshot:
|
|
if let Some(handles) = req.get("highlight_handles").and_then(Value::as_array) {
|
|
let hex_handles: Vec<codec::Handle> = handles
|
|
.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok())
|
|
.map(codec::Handle::new)
|
|
.collect();
|
|
if !hex_handles.is_empty() {
|
|
let scene = &mut self.tabs[self.active_tab].scene;
|
|
scene.deselect_all();
|
|
for h in hex_handles {
|
|
scene.select_entity(h, false);
|
|
}
|
|
}
|
|
}
|
|
if let Some(focus) = req.get("focus_handles").and_then(Value::as_array) {
|
|
let hex_handles: Vec<codec::Handle> = focus
|
|
.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok())
|
|
.map(codec::Handle::new)
|
|
.collect();
|
|
if !hex_handles.is_empty() {
|
|
self.tabs[self.active_tab]
|
|
.scene
|
|
.zoom_to_entities(&hex_handles);
|
|
}
|
|
} else if let Some(bounds) = req.get("bounds").and_then(Value::as_array) {
|
|
if bounds.len() >= 4 {
|
|
if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (
|
|
bounds[0].as_f64(),
|
|
bounds[1].as_f64(),
|
|
bounds[2].as_f64(),
|
|
bounds[3].as_f64(),
|
|
) {
|
|
let p1 = glam::Vec3::new(x0 as f32, y0 as f32, 0.0);
|
|
let p2 = glam::Vec3::new(x1 as f32, y1 as f32, 0.0);
|
|
self.tabs[self.active_tab].scene.zoom_to_window(p1, p2);
|
|
}
|
|
}
|
|
} else if let Some(view) = req.get("view").and_then(Value::as_str) {
|
|
if view == "extents" {
|
|
self.tabs[self.active_tab].scene.fit_all();
|
|
} else if view == "selection" {
|
|
let selected = self.tabs[self.active_tab].scene.selected_handles_in_order();
|
|
if !selected.is_empty() {
|
|
self.tabs[self.active_tab].scene.zoom_to_entities(&selected);
|
|
}
|
|
}
|
|
}
|
|
|
|
// C11: do not capture in the same batch. Park until the next
|
|
// rendered frame (or the timer fallback) so geometry changes are
|
|
// visible rather than returning a stale frame.
|
|
self.control.capture_frame_pending = true;
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
self.control.capture_deadline =
|
|
Some(iced::time::Instant::now() + std::time::Duration::from_secs(5));
|
|
}
|
|
Task::none()
|
|
}
|
|
"stop" => {
|
|
self.control.enabled = false;
|
|
Task::none()
|
|
}
|
|
_ => return Err(failure("unknown_operation", "Unknown operation")),
|
|
})
|
|
}
|
|
|
|
pub(super) fn set_control_result(&mut self, value: Value) {
|
|
if let Some(operation) = self.control.pending.as_mut() {
|
|
operation.result = value;
|
|
}
|
|
}
|
|
|
|
pub(super) fn control_track(&mut self, task: Task<Message>) -> Task<Message> {
|
|
let track_zero_unit = self.control.pending.as_ref().is_some_and(|p| {
|
|
p.request["op"] == "run"
|
|
&& p.request["cmd"]
|
|
.as_str()
|
|
.is_some_and(|cmd| {
|
|
matches!(
|
|
cmd.trim().to_ascii_uppercase().as_str(),
|
|
"SAVE" | "QSAVE"
|
|
)
|
|
})
|
|
});
|
|
// Most zero-unit tasks are `Task::none()` and have already completed
|
|
// synchronously. SAVE/QSAVE are the deliberate exception: the command
|
|
// dispatcher returns a zero-unit Task::done(Message::SaveFile), and
|
|
// the operation must wait for that message before reporting its state.
|
|
if task.units() == 0 && !track_zero_unit {
|
|
return task;
|
|
}
|
|
let Some(p) = self.control.pending.as_mut() else {
|
|
return task;
|
|
};
|
|
p.pending += 1;
|
|
let id = p.id.clone();
|
|
let end = id.clone();
|
|
task.map(move |m| Message::ControlStep(id.clone(), Box::new(m)))
|
|
.chain(Task::done(Message::ControlTaskDone(end)))
|
|
}
|
|
pub(super) fn control_step(&mut self, id: String, msg: Message) -> Task<Message> {
|
|
if !self.control.pending.as_ref().is_some_and(|p| p.id == id) {
|
|
return Task::none();
|
|
}
|
|
let previous = self.active_tab;
|
|
let target = self.control.pending.as_ref().and_then(|p| p.document_id);
|
|
if let Some(doc) = target {
|
|
let Some(i) = self.tabs.iter().position(|t| t.id == doc) else {
|
|
self.command_line
|
|
.push_error(crate::t!("Automation target document closed").as_ref());
|
|
return Task::none();
|
|
};
|
|
self.active_tab = i;
|
|
}
|
|
self.control.routing = true;
|
|
let task = self.update(msg);
|
|
self.control.routing = false;
|
|
if let Some(pending) = self.control.pending.as_mut() {
|
|
if pending.document_id.is_none()
|
|
&& matches!(
|
|
pending.request["op"].as_str(),
|
|
Some("new" | "open" | "activate" | "switch_document")
|
|
)
|
|
&& pending.origin_document != self.tabs[self.active_tab].id
|
|
{
|
|
pending.document_id = Some(self.tabs[self.active_tab].id);
|
|
}
|
|
}
|
|
if target.is_some() && previous < self.tabs.len() {
|
|
self.active_tab = previous;
|
|
}
|
|
self.control_track(task)
|
|
}
|
|
pub(super) fn control_task_done(&mut self, id: &str) {
|
|
if let Some(p) = self.control.pending.as_mut().filter(|p| p.id == id) {
|
|
p.pending = p.pending.saturating_sub(1);
|
|
}
|
|
self.control_settle();
|
|
}
|
|
pub(super) fn control_observe_user_message(&mut self, message: &Message) {
|
|
if self.control.routing || self.control.owner.is_none() {
|
|
return;
|
|
}
|
|
if matches!(
|
|
message,
|
|
Message::CommandSubmit
|
|
| Message::CommandFinalize
|
|
| Message::CommandOptionPick(_)
|
|
| Message::CommandEscape
|
|
| Message::ViewportLeftPress
|
|
| Message::ViewportLeftRelease
|
|
| Message::PanePress(_)
|
|
| Message::PaneRelease(_)
|
|
| Message::ShortcutPressed(_)
|
|
| Message::MTextOk
|
|
| Message::MTextCancel
|
|
| Message::TextInlineOk
|
|
) {
|
|
self.control.owner = None;
|
|
}
|
|
}
|
|
pub(super) fn control_settle(&mut self) {
|
|
if self.control.pending.as_ref().is_none_or(|p| p.pending != 0) || self.opening.is_some() {
|
|
return;
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if !self.active_save_jobs.is_empty() || self.pending_native_thumbnail_save.is_some() {
|
|
return;
|
|
}
|
|
let p = self.control.pending.take().unwrap();
|
|
let previous = self.active_tab;
|
|
if let Some(index) = p
|
|
.document_id
|
|
.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
|
|
{
|
|
self.active_tab = index;
|
|
}
|
|
let failed = self.command_line.error_revision != p.error_revision;
|
|
let waiting = self.tabs[self.active_tab].active_cmd.is_some()
|
|
|| self.active_modal.is_some()
|
|
|| self.mtext_editor.is_some()
|
|
|| self.text_inline.is_some();
|
|
if !waiting {
|
|
self.control.owner = None;
|
|
}
|
|
let status = if failed {
|
|
"failed"
|
|
} else if p.cancelled || p.request["op"] == "cancel" {
|
|
"cancelled"
|
|
} else if waiting {
|
|
"waiting_input"
|
|
} else {
|
|
"completed"
|
|
};
|
|
let changes = self.tabs[self.active_tab].scene.replay_since(p.geometry_revision)
|
|
.map(|values| values.into_iter().take(1000).map(|(handle,kind)|json!({"handle":format!("{:X}",handle.value()),"kind":format!("{kind:?}")})).collect::<Vec<_>>());
|
|
let response = json!({"ok":!failed,"status":status,"request_id":p.id,"error":if failed{self.command_line.last_error.clone()}else{None},"result":p.result,"changes":changes,"state":self.control_state()});
|
|
self.control.serial += 1;
|
|
self.control.events.push_back(json!({"sequence":self.control.serial,"request_id":p.id,"status":status,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision}));
|
|
while self.control.events.len() > 128 {
|
|
self.control.events.pop_front();
|
|
}
|
|
self.control
|
|
.completed
|
|
.push_back((p.id, p.request, response));
|
|
while self.control.completed.len() > 128 {
|
|
self.control.completed.pop_front();
|
|
}
|
|
if previous < self.tabs.len() {
|
|
self.active_tab = previous;
|
|
}
|
|
}
|
|
pub(super) fn control_measure(&self, req: &Value) -> Value {
|
|
let tab = &self.tabs[self.active_tab];
|
|
let requested: Vec<codec::Handle> = req["handles"]
|
|
.as_array()
|
|
.map(|a| {
|
|
a.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok())
|
|
.map(codec::Handle::new)
|
|
.collect()
|
|
})
|
|
.unwrap_or_else(|| tab.scene.selected_handles_in_order());
|
|
let mut out = Vec::new();
|
|
for handle in requested {
|
|
let Some(entity) = tab.scene.document.get_entity(handle) else {
|
|
continue;
|
|
};
|
|
let (min, max) =
|
|
crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, entity);
|
|
let metrics = tab
|
|
.scene
|
|
.meshes
|
|
.get(&handle)
|
|
.or_else(|| tab.scene.block_meshes.get(&handle))
|
|
.cloned()
|
|
.or_else(|| {
|
|
let h = &tab.scene.document.header;
|
|
crate::entities::solid3d::tessellate_volume(
|
|
entity,
|
|
[1.; 4],
|
|
h.facet_resolution,
|
|
crate::entities::solid3d::display_deflection(h, h.facet_resolution),
|
|
crate::entities::solid3d::clamp_header_isolines(h.isolines),
|
|
)
|
|
});
|
|
let curve = crate::entities::curve::entity_curve(entity).map(|planar| {
|
|
let length = planar.curve.length();
|
|
json!({
|
|
"bounded":length.is_finite(),
|
|
"closed":planar.curve.is_closed(),
|
|
"length":length.is_finite().then_some(length),
|
|
"area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs())
|
|
})
|
|
});
|
|
out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":min,"max":max},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid,"moment_of_inertia":m.metrics.moment_of_inertia,"principal_directions":m.metrics.principal_directions,"principal_moments":m.metrics.principal_moments,"product_of_inertia":m.metrics.product_of_inertia,"radii_of_gyration":m.metrics.radii_of_gyration}))}));
|
|
}
|
|
json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out})
|
|
}
|
|
|
|
pub(super) fn control_screenshot(
|
|
&mut self,
|
|
path: String,
|
|
screenshot: Option<iced::window::Screenshot>,
|
|
) {
|
|
let waited_for_frame = self.control.capture_after_redraw;
|
|
let result = (|| -> Result<Value, String> {
|
|
let s = screenshot
|
|
.ok_or("The window is minimized or has no size; restore it and capture again")?;
|
|
let requested_scope = self
|
|
.control
|
|
.pending
|
|
.as_ref()
|
|
.and_then(|pending| pending.request["scope"].as_str())
|
|
.unwrap_or("viewport");
|
|
let max_dimension = self
|
|
.control
|
|
.pending
|
|
.as_ref()
|
|
.and_then(|pending| pending.request["max_dimension"].as_u64())
|
|
.unwrap_or(1600)
|
|
.clamp(256, 4096) as u32;
|
|
let annotate = self
|
|
.control
|
|
.pending
|
|
.as_ref()
|
|
.and_then(|pending| pending.request["annotate"].as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let mut image =
|
|
image::RgbaImage::from_raw(s.size.width, s.size.height, s.rgba.to_vec())
|
|
.ok_or("Renderer returned malformed image data")?;
|
|
let mut actual_scope = "window";
|
|
|
|
let (vp_w, vp_h) = self.tabs[self.active_tab]
|
|
.scene
|
|
.selection
|
|
.borrow()
|
|
.view
|
|
.vp_size;
|
|
let mut vp_logical_width = if vp_w > 0.0 {
|
|
vp_w
|
|
} else {
|
|
image.width() as f32 / s.scale_factor
|
|
};
|
|
let mut vp_logical_height = if vp_h > 0.0 {
|
|
vp_h
|
|
} else {
|
|
image.height() as f32 / s.scale_factor
|
|
};
|
|
|
|
if requested_scope == "viewport" {
|
|
if let Some(bounds) = crate::ui::wrap_bar::dropdown_bounds(
|
|
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
|
|
) {
|
|
let scale = s.scale_factor;
|
|
let left = (bounds.x * scale).floor().clamp(0.0, image.width() as f32) as u32;
|
|
let top = (bounds.y * scale).floor().clamp(0.0, image.height() as f32) as u32;
|
|
let right = ((bounds.x + bounds.width) * scale)
|
|
.ceil()
|
|
.clamp(0.0, image.width() as f32) as u32;
|
|
let bottom = ((bounds.y + bounds.height) * scale)
|
|
.ceil()
|
|
.clamp(0.0, image.height() as f32) as u32;
|
|
if right > left && bottom > top {
|
|
image = image::imageops::crop_imm(
|
|
&image,
|
|
left,
|
|
top,
|
|
right - left,
|
|
bottom - top,
|
|
)
|
|
.to_image();
|
|
actual_scope = "viewport";
|
|
vp_logical_width = bounds.width;
|
|
vp_logical_height = bounds.height;
|
|
}
|
|
}
|
|
}
|
|
let longest = image.width().max(image.height());
|
|
if longest > max_dimension {
|
|
let scale = max_dimension as f64 / longest as f64;
|
|
let width = ((image.width() as f64 * scale).round() as u32).max(1);
|
|
let height = ((image.height() as f64 * scale).round() as u32).max(1);
|
|
image = image::imageops::resize(
|
|
&image,
|
|
width,
|
|
height,
|
|
image::imageops::FilterType::Triangle,
|
|
);
|
|
}
|
|
|
|
// Spatial grounding: camera, world bounds, and visible entity centroids
|
|
let grounding = vision::compute_grounding(
|
|
self,
|
|
vp_logical_width,
|
|
vp_logical_height,
|
|
image.width(),
|
|
image.height(),
|
|
);
|
|
|
|
// Optional Set-of-Marks visual overlay badges
|
|
if annotate {
|
|
for ent_val in &grounding.visible_entities {
|
|
if let (Some(tag), Some(px_arr), Some(selected)) = (
|
|
ent_val["tag"].as_u64(),
|
|
ent_val["screen_pixel"].as_array(),
|
|
ent_val["selected"].as_bool(),
|
|
) {
|
|
if px_arr.len() == 2 {
|
|
if let (Some(x), Some(y)) = (px_arr[0].as_i64(), px_arr[1].as_i64()) {
|
|
vision::draw_som_badge(
|
|
&mut image,
|
|
x as i32,
|
|
y as i32,
|
|
tag as usize,
|
|
selected,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
image
|
|
.save_with_format(&path, image::ImageFormat::Png)
|
|
.map_err(|e| e.to_string())?;
|
|
// No filesystem behind a web "path": encode the PNG in memory and
|
|
// hand it to the browser as a download named after the request's
|
|
// file name — the delivery every other web output uses.
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let mut png = Vec::new();
|
|
image
|
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
|
.map_err(|e| e.to_string())?;
|
|
let download = path
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.filter(|name| !name.is_empty())
|
|
.unwrap_or("capture.png")
|
|
.to_string();
|
|
crate::sys::download_bytes(&download, &png);
|
|
}
|
|
|
|
let doc_unit = crate::app::properties::insunits_name(
|
|
self.tabs[self.active_tab]
|
|
.scene
|
|
.document
|
|
.header
|
|
.insertion_units,
|
|
);
|
|
|
|
let spatial_obj = json!({
|
|
"crs": "CAD_WCS",
|
|
"unit": doc_unit,
|
|
"pixel_resolution": [image.width(), image.height()],
|
|
"world_bounds": grounding.viewport_world_bounds,
|
|
"pixel_to_world_matrix": grounding.pixel_to_world_matrix,
|
|
"world_to_pixel_matrix": grounding.world_to_pixel_matrix,
|
|
"camera": grounding.camera,
|
|
"target_plane": grounding.target_plane,
|
|
"annotations": grounding.visible_entities.clone(),
|
|
});
|
|
|
|
Ok(json!({
|
|
"path": path,
|
|
"scope": actual_scope,
|
|
"width": image.width(),
|
|
"height": image.height(),
|
|
"scale_factor": s.scale_factor,
|
|
"waited_for_frame": waited_for_frame,
|
|
"document_id": self.tabs[self.active_tab].id,
|
|
"revision": self.tabs[self.active_tab].edit_revision,
|
|
"camera_revision": self.tabs[self.active_tab].scene.camera_generation,
|
|
"_spatial": spatial_obj,
|
|
"pixel_to_world_matrix": grounding.pixel_to_world_matrix,
|
|
"world_to_pixel_matrix": grounding.world_to_pixel_matrix,
|
|
"viewport_world_bounds": grounding.viewport_world_bounds,
|
|
"camera": grounding.camera,
|
|
"annotated": annotate,
|
|
"annotations": grounding.visible_entities.clone(),
|
|
"visible_entities": grounding.visible_entities,
|
|
}))
|
|
})();
|
|
match result {
|
|
Ok(v) => {
|
|
if let Some(p) = self.control.pending.as_mut() {
|
|
p.result = v;
|
|
}
|
|
}
|
|
Err(e) => self.command_line.push_error(&e),
|
|
}
|
|
// `capture` is parked until this result (or error) arrives, rather
|
|
// than being settled by the task counter used for ordinary actions.
|
|
if let Some(p) = self
|
|
.control
|
|
.pending
|
|
.as_mut()
|
|
.filter(|p| p.request["op"] == "capture")
|
|
{
|
|
p.pending = 0;
|
|
}
|
|
self.control.capture_after_redraw = false;
|
|
self.control_settle();
|
|
}
|
|
|
|
/// A frame finished rendering (or the anti-hang timer expired): start the
|
|
/// screenshot only after the capture request has been parked.
|
|
pub(super) fn on_control_capture_frame(&mut self, redrew: bool) -> Task<Message> {
|
|
if !self.control.capture_frame_pending {
|
|
return Task::none();
|
|
}
|
|
let Some(path) = self
|
|
.control
|
|
.pending
|
|
.as_ref()
|
|
.filter(|p| p.request["op"] == "capture")
|
|
.and_then(|p| p.request["path"].as_str())
|
|
.map(str::to_owned)
|
|
else {
|
|
self.control.capture_frame_pending = false;
|
|
return Task::none();
|
|
};
|
|
// Web: iced's window screenshot needs a synchronous GPU map wasm
|
|
// cannot provide; the canvas readback (the save-thumbnail technique)
|
|
// only sees the drawing right after a presented frame, before the
|
|
// browser clears the WebGL buffer — so wait for a real frame.
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let expired = self
|
|
.control
|
|
.capture_deadline
|
|
.is_some_and(|deadline| iced::time::Instant::now() >= deadline);
|
|
if !redrew && !expired && crate::sys::page_visible() {
|
|
return Task::none();
|
|
}
|
|
self.control.capture_frame_pending = false;
|
|
self.control.capture_deadline = None;
|
|
self.control.capture_after_redraw = redrew;
|
|
// No frame to read (hidden tab or nothing redrawing): report the
|
|
// capture as failed rather than hand back a blank canvas.
|
|
let screenshot = if redrew {
|
|
crate::sys::capture_canvas()
|
|
} else {
|
|
None
|
|
};
|
|
Task::done(Message::ControlScreenshot(path, screenshot))
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
self.control.capture_frame_pending = false;
|
|
self.control.capture_after_redraw = redrew;
|
|
let Some(window) = self.main_window else {
|
|
return Task::done(Message::ControlScreenshot(path, None));
|
|
};
|
|
// A minimized window has a 0x0 surface and the renderer panics
|
|
// reading it back, so report instead of capturing.
|
|
iced::window::size(window).then(move |size| {
|
|
let path = path.clone();
|
|
if size.width <= 0.0 || size.height <= 0.0 {
|
|
Task::done(Message::ControlScreenshot(path, None))
|
|
} else {
|
|
iced::window::screenshot(window).map(move |screenshot| {
|
|
Message::ControlScreenshot(path.clone(), Some(screenshot))
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
mod actions;
|
|
pub(crate) mod vision;
|
|
pub(crate) use actions::property_json;
|
|
mod entities;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(crate) mod http_bridge;
|
|
mod interactive;
|
|
mod sheets;
|
|
pub(crate) use sheets::new_guid_v4;
|
|
pub(crate) mod spellcheck;
|
|
pub(crate) mod text_ops;
|
|
|
|
#[cfg(test)]
|
|
mod p1_tests;
|
|
pub(super) fn action_names() -> &'static [&'static str] {
|
|
actions::NAMES
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
fn request(app: &mut OpenCADStudio, mut req: Value) -> Value {
|
|
let state = app.control_state();
|
|
req["protocol"] = json!(1);
|
|
req["document_id"] = state["document_id"].clone();
|
|
req["revision"] = state["revision"].clone();
|
|
req["client_id"] = json!("test");
|
|
if req.get("request_id").is_none() {
|
|
req["request_id"] = json!(format!("req-{}", app.control.serial));
|
|
}
|
|
let (r, t) = app.control_request(req.clone());
|
|
app.drive_headless_task(t).unwrap();
|
|
if matches!(r["status"].as_str(), Some("accepted" | "running")) {
|
|
app.control_request(json!({"op":"operation","request_id":req["request_id"]}))
|
|
.0
|
|
} else {
|
|
r
|
|
}
|
|
}
|
|
#[test]
|
|
fn qsave_completion_reports_clean_document_state() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
assert_eq!(request(&mut app, json!({"op":"new"}))["ok"], true);
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"}))["status"],
|
|
"completed"
|
|
);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"ocs_qsave_state_{}_{}.dwg",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos(),
|
|
));
|
|
let _ = std::fs::remove_file(&path);
|
|
let saved = request(
|
|
&mut app,
|
|
json!({"op":"save","path":path,"overwrite":true}),
|
|
);
|
|
assert_eq!(saved["ok"], true, "{saved}");
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,5 10,5"}))["status"],
|
|
"completed"
|
|
);
|
|
let qsave = request(&mut app, json!({"op":"run","cmd":"QSAVE"}));
|
|
assert_eq!(qsave["status"], "completed", "{qsave}");
|
|
let document_id = qsave["state"]["document_id"].as_u64().unwrap();
|
|
let dirty = qsave["state"]["documents"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|document| document["id"].as_u64() == Some(document_id))
|
|
.and_then(|document| document["dirty"].as_bool());
|
|
assert_eq!(dirty, Some(false), "{qsave}");
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn qsave_save_failure_settles_instead_of_hanging() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
assert_eq!(request(&mut app, json!({"op":"new"}))["ok"], true);
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"}))["status"],
|
|
"completed"
|
|
);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"ocs_qsave_fail_{}_{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos(),
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("blocked.dwg");
|
|
let saved = request(
|
|
&mut app,
|
|
json!({"op":"save","path":path,"overwrite":true}),
|
|
);
|
|
assert_eq!(saved["ok"], true, "{saved}");
|
|
// A directory at the target path makes the next write fail
|
|
// deterministically; the operation must settle as failed instead of
|
|
// waiting forever on its task counter.
|
|
std::fs::remove_file(&path).unwrap();
|
|
std::fs::create_dir(&path).unwrap();
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,5 10,5"}))["status"],
|
|
"completed"
|
|
);
|
|
let qsave = request(&mut app, json!({"op":"run","cmd":"QSAVE"}));
|
|
assert_eq!(qsave["status"], "failed", "{qsave}");
|
|
assert!(
|
|
qsave["error"].as_str().is_some_and(|error| !error.is_empty()),
|
|
"{qsave}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn capture_parks_for_the_next_frame_then_finishes_with_its_result() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
assert_eq!(request(&mut app, json!({"op":"new"}))["ok"], true);
|
|
let path = std::env::temp_dir().join(format!("ocs-capture-frame-{}.png", session_id()));
|
|
let state = app.control_state();
|
|
let parked = app
|
|
.control_request(json!({
|
|
"protocol":1,
|
|
"op":"capture",
|
|
"request_id":"cap-frame",
|
|
"document_id": state["document_id"],
|
|
"revision": state["revision"],
|
|
"path": path.to_string_lossy(),
|
|
}))
|
|
.0;
|
|
assert_eq!(parked["status"], "accepted", "{parked}");
|
|
assert!(app.control.capture_frame_pending, "capture should wait for a frame");
|
|
let running = app
|
|
.control_request(json!({"op":"operation","request_id":"cap-frame"}))
|
|
.0;
|
|
assert_eq!(running["status"], "running", "{running}");
|
|
|
|
let _frame_task = app.on_control_capture_frame(true);
|
|
assert!(!app.control.capture_frame_pending);
|
|
let screenshot = iced::window::Screenshot::new(
|
|
vec![0u8; 4 * 4 * 4],
|
|
iced::Size::new(4, 4),
|
|
1.0,
|
|
);
|
|
app.control_screenshot(path.to_string_lossy().into_owned(), Some(screenshot));
|
|
let done = app
|
|
.control_request(json!({"op":"operation","request_id":"cap-frame"}))
|
|
.0;
|
|
assert_eq!(done["status"], "completed", "{done}");
|
|
assert_eq!(done["result"]["waited_for_frame"], true, "{done}");
|
|
assert_eq!(done["result"]["width"], 4, "{done}");
|
|
assert!(path.exists(), "capture should be written");
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn control_stepwise_drawing_undo_and_properties() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"new"}))["status"],
|
|
"completed"
|
|
);
|
|
let started = request(&mut app, json!({"op":"start","cmd":"LINE"}));
|
|
assert_eq!(started["status"], "waiting_input");
|
|
assert!(started["state"]["command"]["accepts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("point")));
|
|
assert_eq!(
|
|
started["state"]["command"]["input_example"]["kind"],
|
|
"point"
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"point","point":[0.,0.,0.]})
|
|
)["ok"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"point","point":[10.,0.,0.]})
|
|
)["ok"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"input","kind":"enter"}))["status"],
|
|
"completed"
|
|
);
|
|
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1);
|
|
request(&mut app, json!({"op":"select","type":"LINE"}));
|
|
assert!(!app.control_properties()["sections"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty());
|
|
let changed = request(&mut app, json!({"op":"property","field":"color","value":1}));
|
|
assert_eq!(changed["ok"], true, "{changed}");
|
|
assert_eq!(
|
|
app.tabs[app.active_tab]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.next()
|
|
.unwrap()
|
|
.common()
|
|
.color
|
|
.index(),
|
|
Some(1)
|
|
);
|
|
assert_eq!(request(&mut app, json!({"op":"undo"}))["ok"], true);
|
|
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1);
|
|
request(&mut app, json!({"op":"undo"}));
|
|
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0);
|
|
}
|
|
#[test]
|
|
fn selection_changes_are_published_to_the_event_stream() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"}));
|
|
let handle = request(&mut app, json!({"op":"query","type":"Line"}))["entities"][0]["handle"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let before = app.control_state()["selection_revision"].as_u64().unwrap();
|
|
let selected = app.automation_op(&format!(
|
|
r#"{{"op":"select","handles":["{handle}"]}}"#
|
|
));
|
|
assert_eq!(selected["ok"], true, "{selected}");
|
|
let state = app.control_state();
|
|
assert_eq!(
|
|
state["selection_revision"].as_u64().unwrap(),
|
|
before + 1,
|
|
"selection revision should increment once: {state}"
|
|
);
|
|
assert!(
|
|
state["selection"].as_array().unwrap().contains(&json!(handle)),
|
|
"{state}"
|
|
);
|
|
let events = app.control_request(json!({"op":"events"})).0;
|
|
let last = events["events"].as_array().unwrap().last().unwrap();
|
|
assert_eq!(last["kind"], "selection", "{events}");
|
|
assert_eq!(last["document_id"], state["document_id"], "{events}");
|
|
assert!(
|
|
last["selection"].as_array().unwrap().contains(&json!(handle)),
|
|
"{events}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_discovery_explains_batch_and_interactive_use() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
let detail = app
|
|
.control_request(json!({"op":"commands","name":"pline"}))
|
|
.0;
|
|
assert_eq!(detail["command"]["name"], "PLINE");
|
|
assert_eq!(
|
|
detail["command"]["batch"]["examples"][0],
|
|
"PLINE 0,0 10,0 10,10 C"
|
|
);
|
|
assert_eq!(detail["command"]["interactive"]["op"], "start");
|
|
assert_eq!(detail["command"]["category"], "create");
|
|
assert_eq!(detail["command"]["preconditions"]["document"], true);
|
|
assert_eq!(
|
|
app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"}))
|
|
.0["code"],
|
|
"unknown_command"
|
|
);
|
|
}
|
|
#[test]
|
|
fn control_queries_exact_curve_relationships_and_metrics() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE -5,0 5,0"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,-5 0,5"}));
|
|
request(&mut app, json!({"op":"run","cmd":"CIRCLE 20,0 2"}));
|
|
|
|
let lines = app.control_request(json!({"op":"query","type":"Line"})).0;
|
|
let first = lines["entities"][0]["handle"].as_str().unwrap();
|
|
let second = lines["entities"][1]["handle"].as_str().unwrap();
|
|
let intersections = app
|
|
.control_request(json!({"op":"query","intersections":[first,second]}))
|
|
.0;
|
|
assert_eq!(intersections["count"], 1, "{intersections}");
|
|
assert_eq!(
|
|
intersections["intersections"][0]["point"],
|
|
json!([0.0, 0.0])
|
|
);
|
|
|
|
let nearest = app
|
|
.control_request(json!({"op":"query","near":[20.0,0.0],"limit":1}))
|
|
.0;
|
|
assert_eq!(nearest["entities"][0]["type"], "Circle");
|
|
assert_eq!(nearest["entities"][0]["distance"], 2.0);
|
|
let circle = nearest["entities"][0]["handle"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned();
|
|
|
|
let contains = app
|
|
.control_request(json!({"op":"query","contains_point":[20.0,0.0]}))
|
|
.0;
|
|
assert_eq!(contains["count"], 1, "{contains}");
|
|
assert_eq!(contains["entities"][0]["handle"], circle);
|
|
|
|
let projected = app
|
|
.control_request(
|
|
json!({"op":"query","handle":circle,"detail":"full","fields":["type"]}),
|
|
)
|
|
.0;
|
|
assert_eq!(projected["entities"][0].as_object().unwrap().len(), 2);
|
|
|
|
let measured = app
|
|
.control_request(json!({"op":"measure","handles":[circle]}))
|
|
.0;
|
|
let curve = &measured["measurements"][0]["curve"];
|
|
assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9);
|
|
assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn control_shell_roundtrips_with_kernel_mass_properties() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"start","cmd":"BOX"}));
|
|
request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"point","point":[0.0,0.0,0.0]}),
|
|
);
|
|
request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"point","point":[6.0,5.0,0.0]}),
|
|
);
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"input","kind":"token","text":"4"}))["status"],
|
|
"completed"
|
|
);
|
|
let handle = app.tabs[app.active_tab]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.find_map(|entity| {
|
|
matches!(entity, codec::EntityType::Solid3D(_)).then(|| entity.common().handle)
|
|
})
|
|
.unwrap();
|
|
let handle_text = format!("{:X}", handle.value());
|
|
|
|
request(&mut app, json!({"op":"select","handles":[handle_text]}));
|
|
request(&mut app, json!({"op":"start","cmd":"SHELL"}));
|
|
request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"entity","handle":handle_text,"point":[3.0,2.5,4.0]}),
|
|
);
|
|
request(&mut app, json!({"op":"input","kind":"enter"}));
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"input","kind":"token","text":"0.5"}))["status"],
|
|
"completed"
|
|
);
|
|
|
|
let measured = app.control_measure(&json!({"handles":[handle_text]}));
|
|
let mesh = &measured["measurements"][0]["mesh"];
|
|
assert!((mesh["volume"].as_f64().unwrap() - 50.0).abs() < 1e-9);
|
|
assert_eq!(mesh["principal_moments"].as_array().unwrap().len(), 3);
|
|
assert!(matches!(
|
|
app.tabs[app.active_tab]
|
|
.scene
|
|
.document
|
|
.solid_history_operation(handle),
|
|
Some(codec::objects::SolidHistoryOperation::Brep(_))
|
|
));
|
|
|
|
assert_eq!(request(&mut app, json!({"op":"undo"}))["ok"], true);
|
|
let restored = app.control_measure(&json!({"handles":[handle_text]}));
|
|
assert!(
|
|
(restored["measurements"][0]["mesh"]["volume"]
|
|
.as_f64()
|
|
.unwrap()
|
|
- 120.0)
|
|
.abs()
|
|
< 1e-9
|
|
);
|
|
assert_eq!(request(&mut app, json!({"op":"redo"}))["ok"], true);
|
|
|
|
let path = std::env::temp_dir().join(format!("ocs-shell-{}.dwg", session_id()));
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"save","path":path}))["ok"],
|
|
true
|
|
);
|
|
request(&mut app, json!({"op":"new"}));
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"open","path":path}))["status"],
|
|
"completed"
|
|
);
|
|
let reopened = app.control_measure(&json!({"handles":[handle_text]}));
|
|
assert!(
|
|
(reopened["measurements"][0]["mesh"]["volume"]
|
|
.as_f64()
|
|
.unwrap()
|
|
- 50.0)
|
|
.abs()
|
|
< 1e-9
|
|
);
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn control_retry_is_idempotent_and_stale_state_rejected() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
let state = app.control_state();
|
|
let req = json!({"op":"run","cmd":"LINE 0,0 10,0","request_id":"line","document_id":state["document_id"],"revision":state["revision"]});
|
|
let (first, t) = app.control_request(req.clone());
|
|
app.drive_headless_task(t).unwrap();
|
|
let repeat = app.control_request(req.clone()).0;
|
|
assert!(repeat["ok"] == true, "{first} {repeat}");
|
|
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1);
|
|
let mut stale = req;
|
|
stale["request_id"] = json!("stale");
|
|
assert_eq!(app.control_request(stale).0["code"], "stale_state");
|
|
}
|
|
#[test]
|
|
fn external_change_modal_actions_are_automatable() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
let names = crate::app::automation_action_names();
|
|
for name in [
|
|
"external_change_reload",
|
|
"external_change_overwrite",
|
|
"external_change_save_as",
|
|
"external_change_cancel",
|
|
] {
|
|
assert!(names.contains(&name), "{name} should be listed");
|
|
}
|
|
// No conflict pending: the action is a harmless no-op, not an error.
|
|
let reply = request(
|
|
&mut app,
|
|
json!({
|
|
"op":"action",
|
|
"request_id":"external-action-noop",
|
|
"name":"external_change_reload"
|
|
}),
|
|
);
|
|
assert_eq!(reply["status"], "completed", "{reply}");
|
|
}
|
|
|
|
#[test]
|
|
fn pending_request_id_reuse_rejects_changed_payload() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
assert_eq!(request(&mut app, json!({"op":"new"}))["status"], "completed");
|
|
|
|
let pending = request(
|
|
&mut app,
|
|
json!({
|
|
"op":"user_select",
|
|
"request_id":"pending-reuse",
|
|
"prompt":"pick the first object"
|
|
}),
|
|
);
|
|
assert_eq!(pending["status"], "running", "{pending}");
|
|
|
|
let changed = request(
|
|
&mut app,
|
|
json!({
|
|
"op":"user_select",
|
|
"request_id":"pending-reuse",
|
|
"prompt":"pick a different object"
|
|
}),
|
|
);
|
|
assert_eq!(changed["code"], "request_id_reused", "{changed}");
|
|
assert!(
|
|
changed["error"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("pending-reuse"),
|
|
"{changed}"
|
|
);
|
|
|
|
let cancelled = request(
|
|
&mut app,
|
|
json!({"op":"cancel","request_id":"cancel-pending-reuse"}),
|
|
);
|
|
assert_eq!(cancelled["status"], "cancelled", "{cancelled}");
|
|
}
|
|
|
|
#[test]
|
|
fn control_errors_and_missing_entities_do_not_report_success() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
let result = request(&mut app, json!({"op":"run","cmd":"NONEXISTENT_COMMAND"}));
|
|
assert_eq!(result["status"], "failed", "{result}");
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"select","handles":["FFFFFFFF"]}))["code"],
|
|
"entity_absent"
|
|
);
|
|
assert_eq!(
|
|
app.automation_op(r#"{"op":"run","cmd":"NONEXISTENT_COMMAND"}"#)["ok"],
|
|
false
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_user_input_takes_command_ownership() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"start","cmd":"LINE"}));
|
|
let _ = app.update(Message::ViewportLeftPress);
|
|
let result = request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"point","point":[10.,0.,0.]}),
|
|
);
|
|
assert_eq!(result["code"], "command_busy");
|
|
}
|
|
#[test]
|
|
fn control_preserves_other_documents_and_waits_for_async_open() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"CIRCLE 0,0 3"}));
|
|
let id = app.control_state()["document_id"].as_u64().unwrap();
|
|
let path = std::env::temp_dir().join(format!("ocs-control-{}.dxf", session_id()));
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"save","path":path}))["ok"],
|
|
true
|
|
);
|
|
request(&mut app, json!({"op":"new"}));
|
|
let opened = request(&mut app, json!({"op":"open","path":path}));
|
|
assert_eq!(opened["status"], "completed", "{opened}");
|
|
assert!(app.tabs.iter().any(|t| t.id == id));
|
|
assert_eq!(
|
|
app.tabs[app.active_tab].scene.document.entities().count(),
|
|
1
|
|
);
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn open_from_bytes_validates_the_request() {
|
|
let ok = open_bytes_request(&json!({"name":"C:\\plans\\plan.dwg","data_base64":"AAEC"}));
|
|
assert_eq!(ok, Ok(("plan.dwg".to_string(), vec![0, 1, 2])));
|
|
for (req, error) in [
|
|
(json!({"data_base64":"AAEC"}), "Missing name"),
|
|
(json!({"name":"dir/","data_base64":"AAEC"}), "Missing name"),
|
|
(
|
|
json!({"name":"a.dwg","data_base64":""}),
|
|
"data_base64 is empty",
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
open_bytes_request(&req).unwrap_err()["error"],
|
|
error,
|
|
"{req}"
|
|
);
|
|
}
|
|
let bad = open_bytes_request(&json!({"name":"a.dwg","data_base64":"not base64!"}));
|
|
assert_eq!(bad.unwrap_err()["code"], "invalid_request");
|
|
}
|
|
#[test]
|
|
fn open_from_bytes_is_refused_natively_without_touching_documents() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
let tabs = app.tabs.len();
|
|
let reply = request(
|
|
&mut app,
|
|
json!({"op":"open","name":"a.dxf","data_base64":"AAEC"}),
|
|
);
|
|
assert_eq!(reply["code"], "invalid_request", "{reply}");
|
|
assert_eq!(app.tabs.len(), tabs);
|
|
assert!(app.opening.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn point_steps_that_take_keywords_accept_points() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"CIRCLE 0,0 3"}));
|
|
request(&mut app, json!({"op":"select","type":"CIRCLE"}));
|
|
let started = request(&mut app, json!({"op":"start","cmd":"MOVE"}));
|
|
assert_eq!(started["status"], "waiting_input", "{started}");
|
|
let command = &started["state"]["command"];
|
|
let accepts = command["accepts"].as_array().unwrap();
|
|
assert!(accepts.contains(&json!("point")), "{command}");
|
|
assert!(accepts.contains(&json!("token")), "{command}");
|
|
assert_eq!(command["input_example"]["kind"], "point");
|
|
let base = request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"point","point":[0.,0.,0.]}),
|
|
);
|
|
assert_eq!(base["status"], "waiting_input", "{base}");
|
|
let moved = request(
|
|
&mut app,
|
|
json!({"op":"input","kind":"point","point":[0.,-10.,0.]}),
|
|
);
|
|
assert_eq!(moved["status"], "completed", "{moved}");
|
|
let circle = app.tabs[app.active_tab]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.find_map(|e| match e {
|
|
codec::EntityType::Circle(c) => Some(c.center.y),
|
|
_ => None,
|
|
});
|
|
assert_eq!(circle, Some(-10.0));
|
|
}
|
|
|
|
#[test]
|
|
fn embed_image_op_places_an_ole2frame_and_undoes_it() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
let img = image::RgbaImage::from_pixel(40, 30, image::Rgba([9, 9, 9, 255]));
|
|
let path = std::env::temp_dir().join(format!("ocs-embed-{}.png", session_id()));
|
|
img.save(&path).unwrap();
|
|
|
|
let response = request(
|
|
&mut app,
|
|
json!({"op":"embed_image","path":path.to_string_lossy(),"at":[0,0,0],"width":20}),
|
|
);
|
|
assert_eq!(response["status"], "completed", "{response}");
|
|
let handle = response["result"]["handle"].as_str().unwrap().to_string();
|
|
assert_eq!(response["result"]["kind"], "Ole2Frame");
|
|
let ole_count = |app: &OpenCADStudio| {
|
|
app.tabs[app.active_tab]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.filter(|e| matches!(e, codec::EntityType::Ole2Frame(_)))
|
|
.count()
|
|
};
|
|
assert_eq!(ole_count(&app), 1);
|
|
|
|
// The entity must be queryable like any other and undoable as one step.
|
|
let queried = app.automation_op(
|
|
json!({"op":"entities","type":"OLE2FRAME"})
|
|
.to_string()
|
|
.as_str(),
|
|
);
|
|
assert_eq!(queried["total"], 1, "{queried}");
|
|
request(&mut app, json!({"op":"undo"}));
|
|
assert_eq!(ole_count(&app), 0);
|
|
let _ = std::fs::remove_file(path);
|
|
let _ = handle;
|
|
}
|
|
|
|
#[test]
|
|
fn embed_image_calibrates_from_source_and_target_points() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
let img = image::RgbaImage::from_pixel(100, 100, image::Rgba([12, 12, 12, 255]));
|
|
let path = std::env::temp_dir().join(format!("ocs-embed-calib-{}.png", session_id()));
|
|
img.save(&path).unwrap();
|
|
|
|
// 2 points on image: bottom-left (0, 100) and bottom-right (100, 100)
|
|
// Target CAD points: (10.0, 20.0) and (210.0, 20.0) -> scale = 200/100 = 2.0
|
|
let response = request(
|
|
&mut app,
|
|
json!({
|
|
"op": "embed_image",
|
|
"path": path.to_string_lossy(),
|
|
"linked": true,
|
|
"source_points": [[0, 100], [100, 100]],
|
|
"target_points": [[10.0, 20.0], [210.0, 20.0]]
|
|
}),
|
|
);
|
|
assert_eq!(response["status"], "completed", "{response}");
|
|
assert_eq!(response["result"]["kind"], "RasterImage");
|
|
assert_eq!(response["result"]["width"], 200.0);
|
|
assert_eq!(response["result"]["height"], 200.0);
|
|
let at = response["result"]["at"].as_array().unwrap();
|
|
assert_eq!(at[0], 10.0);
|
|
assert_eq!(at[1], 20.0);
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
fn user_select_pick(app: &mut OpenCADStudio, kind: &str) -> String {
|
|
let handle = app.automation_op(
|
|
format!(r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#).as_str(),
|
|
)["entities"][0]["handle"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned();
|
|
let value = u64::from_str_radix(&handle, 16).unwrap();
|
|
app.tabs[app.active_tab]
|
|
.scene
|
|
.select_entity(codec::Handle::new(value), false);
|
|
handle
|
|
}
|
|
|
|
fn user_select_result(app: &mut OpenCADStudio, request_id: &str) -> Value {
|
|
app.control_request(json!({"op":"operation","request_id":request_id}))
|
|
.0
|
|
}
|
|
|
|
#[test]
|
|
fn user_select_stays_running_until_the_user_confirms_then_returns_entities() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
assert_eq!(
|
|
request(&mut app, json!({"op":"new"}))["status"],
|
|
"completed"
|
|
);
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"}));
|
|
let asked = request(
|
|
&mut app,
|
|
json!({"op":"user_select","request_id":"us-1","type":"LINE"}),
|
|
);
|
|
assert_eq!(asked["status"], "running", "{asked}");
|
|
// A second request waits like any other mutation while one is open.
|
|
let busy = request(&mut app, json!({"op":"user_select","request_id":"us-2"}));
|
|
assert_eq!(busy["code"], "busy", "{busy}");
|
|
// The person picks the line in the viewport and presses Enter.
|
|
let handle = user_select_pick(&mut app, "LINE");
|
|
let _ = app.update(Message::CommandFinalize);
|
|
let done = user_select_result(&mut app, "us-1");
|
|
assert_eq!(done["status"], "completed", "{done}");
|
|
assert_eq!(done["result"]["cancelled"], false);
|
|
assert_eq!(done["result"]["count"], 1);
|
|
assert_eq!(done["result"]["handles"][0], json!(handle));
|
|
assert_eq!(done["result"]["entities"][0]["type"], "Line");
|
|
// detail defaults to full, so the whole entity object rides along.
|
|
assert!(
|
|
done["result"]["entities"][0]["properties"].is_object(),
|
|
"{done}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_select_filter_deselects_entities_outside_the_request() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"}));
|
|
request(&mut app, json!({"op":"run","cmd":"CIRCLE 0,0 5"}));
|
|
request(
|
|
&mut app,
|
|
json!({"op":"user_select","request_id":"us-f","type":"CIRCLE"}),
|
|
);
|
|
user_select_pick(&mut app, "LINE");
|
|
user_select_pick(&mut app, "CIRCLE");
|
|
let _ = app.update(Message::CommandFinalize);
|
|
let done = user_select_result(&mut app, "us-f");
|
|
assert_eq!(done["status"], "completed", "{done}");
|
|
assert_eq!(done["result"]["count"], 1);
|
|
assert_eq!(done["result"]["ignored"], 1);
|
|
assert_eq!(done["result"]["entities"][0]["type"], "Circle");
|
|
// The filtered-out pick is deselected in the drawing too.
|
|
assert_eq!(
|
|
app.tabs[app.active_tab]
|
|
.scene
|
|
.selected_handles_in_order()
|
|
.len(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_select_escape_cancels_and_an_empty_enter_confirms_zero() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"}));
|
|
request(&mut app, json!({"op":"user_select","request_id":"us-x"}));
|
|
let _ = app.update(Message::CommandEscape);
|
|
let cancelled = user_select_result(&mut app, "us-x");
|
|
assert_eq!(cancelled["status"], "cancelled", "{cancelled}");
|
|
assert_eq!(cancelled["result"]["cancelled"], true);
|
|
assert_eq!(cancelled["result"]["count"], 0);
|
|
// An immediate Enter with nothing picked answers the
|
|
// request with an empty set rather than hanging forever.
|
|
request(&mut app, json!({"op":"user_select","request_id":"us-e"}));
|
|
let _ = app.update(Message::CommandFinalize);
|
|
let empty = user_select_result(&mut app, "us-e");
|
|
assert_eq!(empty["result"]["cancelled"], false, "{empty}");
|
|
assert_eq!(empty["result"]["count"], 0);
|
|
// A client can also retract its own request programmatically.
|
|
request(&mut app, json!({"op":"user_select","request_id":"us-c"}));
|
|
let retracted = request(&mut app, json!({"op":"cancel","request_id":"us-c-x"}));
|
|
assert_eq!(retracted["status"], "cancelled", "{retracted}");
|
|
assert_eq!(retracted["result"]["cancelled"], true, "{retracted}");
|
|
}
|
|
|
|
#[test]
|
|
fn user_select_request_is_printed_and_stays_visible_until_answered() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"}));
|
|
request(
|
|
&mut app,
|
|
json!({"op":"user_select","request_id":"us-ui","type":"LINE","prompt":"Chọn block"}),
|
|
);
|
|
// The message list names the request — who asked and what for.
|
|
let last = app.command_line.history.last().unwrap();
|
|
assert!(
|
|
last.text.contains("us-ui") && last.text.contains("Chọn block"),
|
|
"{last:?}"
|
|
);
|
|
// The end-of-update driver pins that line so it cannot fade away
|
|
// while the pick is still open.
|
|
let label = app.pending_pick_label().expect("pending label");
|
|
app.command_line.set_step_prompt(Some(label));
|
|
assert!(
|
|
app.command_line.history.last().unwrap().pinned,
|
|
"{:?}",
|
|
app.command_line.history.last().unwrap()
|
|
);
|
|
// The person answers: the line un-pins and the resolution is printed
|
|
// with the same identity.
|
|
user_select_pick(&mut app, "LINE");
|
|
let _ = app.update(Message::CommandFinalize);
|
|
assert!(app.pending_pick_label().is_none());
|
|
let tail: Vec<String> = app
|
|
.command_line
|
|
.history
|
|
.iter()
|
|
.rev()
|
|
.take(3)
|
|
.map(|e| e.text.clone())
|
|
.collect();
|
|
assert!(
|
|
tail.iter()
|
|
.any(|t| t.contains("us-ui") && t.contains("1 object(s)")),
|
|
"{tail:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_select_requires_a_gui_window() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
let reply = request(&mut app, json!({"op":"user_select","request_id":"us-h"}));
|
|
assert_eq!(reply["code"], "gui_required", "{reply}");
|
|
}
|
|
|
|
#[test]
|
|
fn getpoint_resolves_with_the_clicked_point_and_esc_cancels() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
request(&mut app, json!({"op":"new"}));
|
|
let asked = request(
|
|
&mut app,
|
|
json!({"op":"getpoint","request_id":"gp-1","prompt":"Chọn vị trí QR"}),
|
|
);
|
|
assert_eq!(asked["status"], "running", "{asked}");
|
|
// While the pick is pending, everything else waits.
|
|
let busy = request(&mut app, json!({"op":"getpoint","request_id":"gp-2"}));
|
|
assert_eq!(busy["code"], "busy", "{busy}");
|
|
// The person clicks: the snapped world point under the cursor is the
|
|
// answer, and the click leaves the selection untouched.
|
|
app.tabs[app.active_tab].last_cursor_world = glam::DVec3::new(125.5, 64.25, 0.0);
|
|
let _ = app.update(Message::ViewportLeftPress);
|
|
let done = user_select_result(&mut app, "gp-1");
|
|
assert_eq!(done["status"], "completed", "{done}");
|
|
assert_eq!(done["result"]["point"], json!([125.5, 64.25, 0.0]));
|
|
// Escape with no request pending is still just Escape.
|
|
let _ = app.update(Message::CommandEscape);
|
|
// A second pick parks again and cancels on Escape.
|
|
request(&mut app, json!({"op":"getpoint","request_id":"gp-3"}));
|
|
let _ = app.update(Message::CommandEscape);
|
|
let cancelled = user_select_result(&mut app, "gp-3");
|
|
assert_eq!(cancelled["status"], "cancelled", "{cancelled}");
|
|
assert_eq!(cancelled["result"]["cancelled"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn getpoint_snap_marker_and_snapped_answer_while_pending() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 100,0"}));
|
|
let i = app.active_tab;
|
|
{
|
|
let tab = &mut app.tabs[i];
|
|
tab.scene.selection.borrow_mut().view.vp_size = (1920.0, 1080.0);
|
|
tab.scene.sync_tiles_from_panes(1920.0, 1080.0);
|
|
tab.scene.fit_all(); // bring the line into the pane so snaps can hit
|
|
}
|
|
// Park a client pick: with no command running, a cursor move must
|
|
// still run the object-snap engine so the marker shows and the
|
|
// click hands the snapped point back.
|
|
let asked = request(
|
|
&mut app,
|
|
json!({"op":"getpoint","request_id":"gp-snap","prompt":"Chọn góc"}),
|
|
);
|
|
assert_eq!(asked["status"], "running", "{asked}");
|
|
|
|
// Screen position of the line's endpoint through the live camera.
|
|
let endpoint = glam::DVec3::new(100.0, 0.0, 0.0);
|
|
let (sx, sy) = {
|
|
let cam = app.tabs[i].scene.camera.borrow();
|
|
let bounds = iced::Rectangle {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 1920.0,
|
|
height: 1080.0,
|
|
};
|
|
let view = cam.view_proj_rte(bounds);
|
|
let eye = cam.eye();
|
|
let ndc = view.project_point3((endpoint - eye).as_vec3());
|
|
(
|
|
(ndc.x + 1.0) * 0.5 * bounds.width,
|
|
(1.0 - ndc.y) * 0.5 * bounds.height,
|
|
)
|
|
};
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(
|
|
sx as f32, sy as f32,
|
|
)));
|
|
|
|
assert!(
|
|
app.tabs[i].snap_result.is_some(),
|
|
"no object-snap marker while a getpoint is pending"
|
|
);
|
|
let snapped = app.tabs[i].last_cursor_world;
|
|
assert!(
|
|
(snapped - endpoint).length() < 1e-6,
|
|
"cursor point not snapped to the endpoint: {snapped:?}"
|
|
);
|
|
|
|
// The click answers the pick with the snapped endpoint.
|
|
let _ = app.update(Message::ViewportLeftPress);
|
|
let done = user_select_result(&mut app, "gp-snap");
|
|
assert_eq!(done["status"], "completed", "{done}");
|
|
assert_eq!(done["result"]["point"], json!([100.0, 0.0, 0.0]));
|
|
}
|
|
|
|
#[test]
|
|
fn user_select_snap_marker_shows_while_pending() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.main_window = Some(iced::window::Id::unique());
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 100,0"}));
|
|
let i = app.active_tab;
|
|
{
|
|
let tab = &mut app.tabs[i];
|
|
tab.scene.selection.borrow_mut().view.vp_size = (1920.0, 1080.0);
|
|
tab.scene.sync_tiles_from_panes(1920.0, 1080.0);
|
|
tab.scene.fit_all();
|
|
}
|
|
let asked = request(
|
|
&mut app,
|
|
json!({"op":"user_select","request_id":"us-snap","prompt":"Chọn trường","clear":true}),
|
|
);
|
|
assert_eq!(asked["status"], "running", "{asked}");
|
|
|
|
let endpoint = glam::DVec3::new(100.0, 0.0, 0.0);
|
|
let (sx, sy) = {
|
|
let cam = app.tabs[i].scene.camera.borrow();
|
|
let bounds = iced::Rectangle {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 1920.0,
|
|
height: 1080.0,
|
|
};
|
|
let view = cam.view_proj_rte(bounds);
|
|
let eye = cam.eye();
|
|
let ndc = view.project_point3((endpoint - eye).as_vec3());
|
|
(
|
|
(ndc.x + 1.0) * 0.5 * bounds.width,
|
|
(1.0 - ndc.y) * 0.5 * bounds.height,
|
|
)
|
|
};
|
|
let _ = app.update(Message::ViewportMove(iced::Point::new(
|
|
sx as f32, sy as f32,
|
|
)));
|
|
assert!(
|
|
app.tabs[i].snap_result.is_some(),
|
|
"no object-snap marker while a user_select is pending"
|
|
);
|
|
|
|
// The pick itself stays entity-based: selecting the line + Enter still
|
|
// answers the request — the marker must not break selection.
|
|
let handle = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#)
|
|
["entities"][0]["handle"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned();
|
|
let value = u64::from_str_radix(&handle, 16).unwrap();
|
|
app.tabs[i]
|
|
.scene
|
|
.select_entity(codec::Handle::new(value), false);
|
|
let _ = app.update(Message::CommandFinalize);
|
|
let done = user_select_result(&mut app, "us-snap");
|
|
assert_eq!(done["status"], "completed", "{done}");
|
|
assert_eq!(done["result"]["count"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn get_selection_reports_insert_block_and_position() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,10"}));
|
|
let line = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#)
|
|
["entities"][0]["handle"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned();
|
|
// A title-block-style definition with one placed reference.
|
|
request(
|
|
&mut app,
|
|
json!({"op":"block_define","name":"A3","base":[0,0],"handles":[line]}),
|
|
);
|
|
let insert = app.automation_op(r#"{"op":"query","type":"Insert","detail":"summary"}"#)
|
|
["entities"][0]["handle"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_owned();
|
|
app.automation_op(&format!(r#"{{"op":"select","handles":["{insert}"]}}"#));
|
|
let read = request(&mut app, json!({"op":"get_selection"}));
|
|
assert_eq!(read["status"], "completed", "{read}");
|
|
assert_eq!(read["result"]["count"], 1);
|
|
let entity = &read["result"]["entities"][0];
|
|
assert_eq!(entity["type"], "Block Reference");
|
|
assert_eq!(entity["block"], "A3");
|
|
assert!(entity["position"].is_array(), "{entity}");
|
|
// Non-inserts answer null for the block field.
|
|
app.tabs[app.active_tab].scene.deselect_all();
|
|
let circle = request(
|
|
&mut app,
|
|
json!({"op":"entities_create","entities":[{"type":"Circle","center":[0,0],"radius":2}]}),
|
|
);
|
|
let circle_handle = circle["result"]["handles"][0].as_str().unwrap().to_owned();
|
|
app.automation_op(&format!(
|
|
r#"{{"op":"select","handles":["{circle_handle}"]}}"#
|
|
));
|
|
let read = request(&mut app, json!({"op":"get_selection"}));
|
|
assert_eq!(read["result"]["count"], 1);
|
|
assert_eq!(read["result"]["entities"][0]["block"], Value::Null);
|
|
}
|
|
|
|
#[test]
|
|
fn plot_op_applies_per_layer_lineweights() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
{
|
|
let scene = &mut app.tabs[app.active_tab].scene;
|
|
// Two layers set to different pen weights; entities fully ByLayer.
|
|
for (name, weight) in [
|
|
("THIN", codec::types::LineWeight::Value(13)),
|
|
("THICK", codec::types::LineWeight::Value(50)),
|
|
] {
|
|
let mut layer = codec::tables::Layer::new(name);
|
|
layer.line_weight = weight;
|
|
let _ = scene.document.layers.add(layer);
|
|
}
|
|
for (name, origin) in [("THIN", 0.0), ("THICK", 3000.0)] {
|
|
let mut line = codec::entities::Line::new();
|
|
line.common.layer = name.to_string();
|
|
line.start = codec::types::Vector3::new(origin, origin, 0.0);
|
|
line.end = codec::types::Vector3::new(origin + 3000.0, origin + 2000.0, 0.0);
|
|
scene.add_entity(codec::EntityType::Line(line));
|
|
}
|
|
}
|
|
|
|
let pdf = std::env::temp_dir().join(format!("ocs-plot-lw-{}.pdf", std::process::id()));
|
|
let response = request(
|
|
&mut app,
|
|
json!({"op":"plot","path":pdf.to_string_lossy(),"plot_style":"monochrome.ctb"}),
|
|
);
|
|
assert_eq!(response["status"], "completed", "{response}");
|
|
|
|
// The plotted PDF carries two distinct pen widths — the per-layer
|
|
// 0.13 mm / 0.50 mm hierarchy survives the plot style (thin clamps to
|
|
// the 1 px floor, so the ratio is the clamped-pixel ratio 1.889).
|
|
let text =
|
|
crate::io::pdf_export::pdf_stream_text(&std::fs::read(&pdf).expect("pdf written"));
|
|
let tokens: Vec<&str> = text.split_whitespace().collect();
|
|
let mut widths: Vec<f32> = tokens
|
|
.windows(2)
|
|
.filter_map(|pair| {
|
|
if pair[1] == "w" {
|
|
pair[0].parse::<f32>().ok()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
widths.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
widths.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
|
widths.retain(|width| *width > 0.2);
|
|
assert_eq!(
|
|
widths.len(),
|
|
2,
|
|
"two layer weights in the plotted PDF: {widths:?}"
|
|
);
|
|
assert!(
|
|
(widths[1] / widths[0] - 1.889).abs() < 0.15,
|
|
"0.50 mm vs 0.13 mm hierarchy: {widths:?}"
|
|
);
|
|
let _ = std::fs::remove_file(&pdf);
|
|
}
|
|
|
|
#[test]
|
|
fn plot_op_applies_ctb_colors_through_layer_aci() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
{
|
|
let scene = &mut app.tabs[app.active_tab].scene;
|
|
// Frame layer carries the classic cyan ACI 4; its entity is ByLayer.
|
|
let mut frame = codec::tables::Layer::new("FRAME-CYAN");
|
|
frame.color = codec::types::Color::Index(4);
|
|
let _ = scene.document.layers.add(frame);
|
|
let mut frame_line = codec::entities::Line::new();
|
|
frame_line.common.layer = "FRAME-CYAN".to_string();
|
|
frame_line.start = codec::types::Vector3::new(0.0, 0.0, 0.0);
|
|
frame_line.end = codec::types::Vector3::new(3000.0, 0.0, 0.0);
|
|
scene.add_entity(codec::EntityType::Line(frame_line));
|
|
|
|
// An explicit red ACI 1 and a true-color green: index must map
|
|
// through the CTB, the true color must stay RGB (aci 0).
|
|
let mut red = codec::entities::Line::new();
|
|
red.common.color = codec::types::Color::Index(1);
|
|
red.start = codec::types::Vector3::new(0.0, 1000.0, 0.0);
|
|
red.end = codec::types::Vector3::new(3000.0, 1000.0, 0.0);
|
|
scene.add_entity(codec::EntityType::Line(red));
|
|
|
|
let mut true_color = codec::entities::Line::new();
|
|
true_color.common.color = codec::types::Color::from_true_color_value(0x0000FF00);
|
|
true_color.start = codec::types::Vector3::new(0.0, 2000.0, 0.0);
|
|
true_color.end = codec::types::Vector3::new(3000.0, 2000.0, 0.0);
|
|
scene.add_entity(codec::EntityType::Line(true_color));
|
|
}
|
|
|
|
let pdf = std::env::temp_dir().join(format!("ocs-plot-aci-{}.pdf", std::process::id()));
|
|
let response = request(
|
|
&mut app,
|
|
json!({"op":"plot","path":pdf.to_string_lossy(),"plot_style":"monochrome.ctb"}),
|
|
);
|
|
assert_eq!(response["status"], "completed", "{response}");
|
|
|
|
let text =
|
|
crate::io::pdf_export::pdf_stream_text(&std::fs::read(&pdf).expect("pdf written"));
|
|
// Monochrome maps every index colour (ByLayer cyan frame, explicit
|
|
// red) to black; the true-color line keeps its RGB by definition.
|
|
assert!(
|
|
!text.contains("0 1 1 rg") && !text.contains("0 1 1 RG"),
|
|
"cyan frame must be CTB-mapped to monochrome"
|
|
);
|
|
assert!(
|
|
!text.contains("1 0 0 rg") && !text.contains("1 0 0 RG"),
|
|
"explicit red must be CTB-mapped to monochrome"
|
|
);
|
|
assert!(
|
|
text.contains("0 1 0 rg") || text.contains("0 1 0 RG"),
|
|
"true-color line keeps its RGB (aci 0 is not CTB-mapped)"
|
|
);
|
|
assert!(
|
|
text.contains("0 0 0 rg") || text.contains("0 0 0 RG"),
|
|
"monochrome strokes must plot black"
|
|
);
|
|
|
|
// Without a plot style the same drawing keeps its colours: the cyan
|
|
// frame plots as the dark-blue print remap, the red line stays red.
|
|
let plain =
|
|
std::env::temp_dir().join(format!("ocs-plot-aci-plain-{}.pdf", std::process::id()));
|
|
let response = request(
|
|
&mut app,
|
|
json!({"op":"plot","path":plain.to_string_lossy()}),
|
|
);
|
|
assert_eq!(response["status"], "completed", "{response}");
|
|
let text =
|
|
crate::io::pdf_export::pdf_stream_text(&std::fs::read(&plain).expect("pdf written"));
|
|
let color_ops: Vec<[f32; 3]> = {
|
|
let tokens: Vec<&str> = text.split_whitespace().collect();
|
|
tokens
|
|
.windows(4)
|
|
.filter_map(|quad| {
|
|
if quad[3] != "rg" && quad[3] != "RG" {
|
|
return None;
|
|
}
|
|
Some([
|
|
quad[0].parse::<f32>().ok()?,
|
|
quad[1].parse::<f32>().ok()?,
|
|
quad[2].parse::<f32>().ok()?,
|
|
])
|
|
})
|
|
.collect()
|
|
};
|
|
let near = |color: &[f32; 3], target: [f32; 3]| {
|
|
color
|
|
.iter()
|
|
.zip(target)
|
|
.all(|(channel, expected)| (channel - expected).abs() < 0.02)
|
|
};
|
|
assert!(
|
|
color_ops.iter().any(|c| near(c, [0.0, 0.15, 0.5])),
|
|
"unstyled cyan frame plots as the dark-blue print remap: {color_ops:?}"
|
|
);
|
|
assert!(
|
|
color_ops.iter().any(|c| near(c, [1.0, 0.0, 0.0])),
|
|
"unstyled red line keeps its colour: {color_ops:?}"
|
|
);
|
|
assert!(
|
|
color_ops.iter().any(|c| near(c, [0.0, 1.0, 0.0])),
|
|
"unstyled true-color line keeps its colour: {color_ops:?}"
|
|
);
|
|
let _ = std::fs::remove_file(&pdf);
|
|
let _ = std::fs::remove_file(&plain);
|
|
}
|
|
|
|
/// An unknown `entities_transform` action must come back as a clean
|
|
/// validation error. Pre-fix it reached
|
|
/// `unreachable!("validated by transform()")` (entities.rs:476) and
|
|
/// killed the whole process — over REST, MCP and `--serve` alike —
|
|
/// because validation lived inside the `transform` closure, which the
|
|
/// dispatch match only invokes from some of its arms.
|
|
#[test]
|
|
fn entities_transform_rejects_unknown_actions_without_panicking() {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
request(&mut app, json!({"op":"new"}));
|
|
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"}));
|
|
let lines = app.control_request(json!({"op":"query","type":"Line"})).0;
|
|
let handle = lines["entities"][0]["handle"].as_str().unwrap().to_string();
|
|
|
|
// Not one of the six whitelisted actions.
|
|
let rejected = request(
|
|
&mut app,
|
|
json!({
|
|
"op":"entities_transform",
|
|
"handles":[handle],
|
|
"action":"explode"
|
|
}),
|
|
);
|
|
assert_eq!(rejected["code"], "invalid_action", "{rejected}");
|
|
|
|
// Whitespace-only is unknown too — not a panic.
|
|
let blank = request(
|
|
&mut app,
|
|
json!({
|
|
"op":"entities_transform",
|
|
"handles":[handle],
|
|
"action":" "
|
|
}),
|
|
);
|
|
assert_eq!(blank["code"], "invalid_action", "{blank}");
|
|
|
|
// A valid action is matched case- and whitespace-insensitively.
|
|
let moved = request(
|
|
&mut app,
|
|
json!({
|
|
"op":"entities_transform",
|
|
"handles":[handle],
|
|
"action":" move ",
|
|
"vector":[5,0,0]
|
|
}),
|
|
);
|
|
assert_eq!(moved["ok"], true, "{moved}");
|
|
}
|
|
}
|