feat(automation): entity, block, publishing and REST operations from the fork

The operations docs/automation/API-SPEC.md (PR #1383) describes, brought
over from huaninstratech/OpenCADStudio so the specification no longer runs
ahead of the code:

- entity CRUD: entities_create, entities_delete, entities_transform,
  entities_copy_to, and `where` filters on query
- blocks and export: block_define, block_delete, a normalised wblock
- publishing: layout_create, page_setup_set, plot (per_page included)
- metadata: sysvar, xdata_set / xdata_get, file_identity
- collections: group_create, selection_set_save / load
- control plane: batch, events, view_focus, close
- the REST transport: `--http <port>`, the /api/v1 routes and their
  OpenAPI 3 description
- person-in-the-loop picks: user_select and getpoint, hosted by the GUI
  over the same REST routes, with on-screen pending signals

Taken from the fork's commits b7a7ed80, 69396b42, 64f1cf6a, c3aafc8e,
bb5a7bfa, 52317a5c, 50d293ba, b90e194f, 517c6397, 53a5c4c9, a49ac045,
37de5a46, a78b1822, 6d9aed2a, 33f522da, 5b5cafe2, 02a7e78d, 94df40dc,
fd5bc14d, 485fac61 and 2c5fc994.

Left out on purpose: 37de5a46 also removed the donation surface (the
Patreon fetch, the donation prompt, the Start-page supporters rail, the
DONATE command and its locale strings) and replaced the About dialog's call
to action; that is the fork's own product decision and stays out. The
fork's version bump and its unrelated IFC/BIM work are not part of this.

Adapted while porting: the REST server and the GUI bridge are desktop-only
and a template start is refused in the web build, so the wasm target still
compiles; the two plot tests read the PDF through the shared stream helper,
because exported streams are Flate-compressed; the benches set the new
crosshair field; comments and docs describe each operation in its own
terms rather than by another product's API names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
huanldandClaude Opus 5.5 authored and Hakan Seven committed 2026-09-24 13:41:07 +03:00
1 parent 531ba44faa
commit 279a87418e
37 files changed
+8795 -82

No files matched your search

+408
View File
@@ -97,6 +97,50 @@ impl OpenCADStudio {
let at = embed_point(req)?;
let default_width = (image.pixel_width as f64 / 100.0).max(1.0);
let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width);
if req["linked"].as_bool().unwrap_or(false) {
// Path-linked RasterImage + ImageDefinition: the drawing stores
// only the file path, so the picture file must travel with the
// drawing (unlike the embedded OLE2FRAME default below).
self.push_undo_snapshot(i, "IMAGEATTACH");
let height = width * image.pixel_height as f64 / image.pixel_width as f64;
let handle = {
let document = &mut self.tabs[i].scene.document;
let definition_handle = document.allocate_handle();
let mut definition = acadrust::objects::ImageDefinition::with_dimensions(
path,
image.pixel_width,
image.pixel_height,
);
definition.handle = definition_handle;
document.objects.insert(
definition_handle,
acadrust::objects::ObjectType::ImageDefinition(definition),
);
let mut entity = acadrust::entities::RasterImage::with_size(
path,
at,
image.pixel_width as f64,
image.pixel_height as f64,
width,
height,
);
entity.definition_handle = Some(definition_handle);
document
.add_entity(acadrust::EntityType::RasterImage(entity))
.map_err(|e| failure("embed_failed", e))?
};
self.tabs[i].scene.populate_images_from_document();
self.post_ref_op(i);
self.set_control_result(json!({
"handle": format!("{:X}", handle.value()),
"kind": "RasterImage",
"path": path,
"width": width,
"height": height,
"linked": true,
}));
return Ok(Task::none());
}
self.push_undo_snapshot(i, "IMAGEEMBED");
let handle = crate::io::ole_embed::add_embedded_image(
&mut self.tabs[i].scene.document,
@@ -115,6 +159,370 @@ impl OpenCADStudio {
Ok(Task::none())
}
/// `wblock` — write the named block's entities or the listed entity handles
/// out to a standalone DWG/DXF file. The open document is not modified, so
/// this needs no undo snapshot. Format follows the path extension.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn control_wblock(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let path = string(req, "path")?;
let document = &self.tabs[self.active_tab].scene.document;
// A template base keeps its tables/styles in the exported file
// (Catalog §2.1: clone from a .dwt/.dwg template).
let template_base = match req["template"].as_str().filter(|t| !t.is_empty()) {
Some(template) => Some(
crate::io::load_file(std::path::Path::new(template))
.map_err(|e| failure("template_missing", e))?,
),
None => None,
};
let extracted = if let Some(block) = req["block"].as_str() {
match &template_base {
Some(base) => {
let mut base = base.clone();
crate::modules::insert::wblock::extract_block_into(document, block, &mut base)?;
Ok(base)
}
None => crate::modules::insert::wblock::extract_block_to_doc(document, block),
}
} else if let Some(listed) = req["handles"].as_array() {
let handles: Result<Vec<acadrust::Handle>, Value> = listed
.iter()
.map(|v| {
u64::from_str_radix(
v.as_str()
.unwrap_or("")
.trim_start_matches("0x")
.trim_start_matches("0X"),
16,
)
.map(acadrust::Handle::new)
.map_err(|_| {
failure(
"invalid_handle",
format!("Expected a hexadecimal handle, got {v}"),
)
})
})
.collect();
match &template_base {
Some(base) => {
let mut base = base.clone();
crate::modules::insert::wblock::extract_entities_into(
document,
&handles?,
&mut base,
)?;
Ok(base)
}
None => crate::modules::insert::wblock::extract_entities_to_doc(document, &handles?),
}
} else {
return Err(failure(
"selection_required",
"Supply \"handles\" or \"block\"",
));
};
let mut extracted = extracted.map_err(|e| failure("wblock_failed", e))?;
// CF-01.2: optionally shift the export so its overall bounds minimum
// lands on the origin (SPM.ACAD normalizes cloned drawings to 0,0,0).
let normalized = req["normalize"].as_bool().unwrap_or(false);
if normalized {
crate::modules::insert::wblock::normalize_to_origin(&mut extracted);
}
let entities = extracted.entities().count();
// A .dwt target is written as DWG bytes under a hidden scratch name,
// then renamed (DWT is a DWG-family file).
let mut target = std::path::PathBuf::from(path);
let mut scratch = None;
if target
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("dwt"))
{
scratch = Some(target.clone());
target = target.with_file_name(format!(
".{}.tmp.dwg",
target.file_name().unwrap().to_string_lossy()
));
}
crate::io::save(&extracted, &target).map_err(|e| failure("save_failed", e))?;
if let Some(final_path) = scratch {
std::fs::rename(&target, &final_path)
.map_err(|e| failure("save_failed", format!("rename to .dwt failed: {e}")))?;
}
self.set_control_result(json!({
"path": path,
"entities": entities,
"normalized": normalized,
}));
Ok(Task::none())
}
/// `plot` — render the current drawing to a PDF with every choice taken
/// from the request instead of the Plot dialog. Reuses the GUI pipeline
/// (area jobs over `plot_scene_content`, saved page setups for layouts);
/// dialog/layout/camera state is snapshotted and restored around the
/// build, exactly like the print-all path. Explicitly supplied request
/// fields win over a layout's stored page setup; unspecified fields keep
/// the stored setup (layouts) or the app's plot defaults (Model).
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn control_plot(&mut self, req: &Value) -> Result<Task<Message>, Value> {
use crate::io::pdf_export::PdfPageInput;
let path = string(req, "path")?;
if !path.to_ascii_lowercase().ends_with(".pdf") {
return Err(failure("invalid_path", "plot writes .pdf files"));
}
let i = self.active_tab;
let names = self.tabs[i].scene.layout_names();
let requested = req["layout"].as_str().unwrap_or("Model").to_owned();
let targets: Vec<String> = if requested.eq_ignore_ascii_case("all") {
names
} else {
vec![names
.iter()
.find(|name| name.eq_ignore_ascii_case(&requested))
.cloned()
.ok_or_else(|| {
failure("layout_missing", format!("Layout '{requested}' does not exist"))
})?]
};
// Optional plot style table: a CTB path or a folder-discovered name.
let style = req["plot_style"].as_str().filter(|s| !s.is_empty());
let loaded_style = match style {
Some(name) => Some(
crate::io::plot_style::PlotStyleTable::load(std::path::Path::new(name))
.or_else(|_| crate::io::plot_style::PlotStyleTable::load_named(name))
.map_err(|e| failure("plot_style_missing", e))?,
),
None => None,
};
// Request → dialog vocabulary, before any stored setup is loaded so
// the explicit choices can be re-applied on top of it.
let mut request = self.plot_dialog.clone();
if let Some(paper) = req["paper"].as_str().filter(|p| !p.is_empty()) {
request.paper = crate::io::paper_catalog::resolve(paper)
.ok_or_else(|| failure("invalid_paper", format!("Unknown paper '{paper}'")))?
.canonical
.to_string();
request.paper_width_mm = 0.0;
request.paper_height_mm = 0.0;
}
if let Some(orientation) = req["orientation"].as_str() {
request.orientation = if orientation.eq_ignore_ascii_case("Portrait") {
"Portrait".into()
} else {
"Landscape".into()
};
}
let explicit_scale = req.get("scale").is_some();
if let Some(scale) = req["scale"].as_str().filter(|s| !s.is_empty()) {
request.scale = scale.to_owned();
request.fit_to_paper = false;
} else if req.get("fit").is_some() {
request.fit_to_paper = req["fit"].as_bool().unwrap_or(true);
}
let explicit_center = req.get("center").is_some();
request.center = req["center"].as_bool().unwrap_or(true);
if let Some(offset) = req["offset_x"].as_f64() {
request.offset_x = offset.to_string();
}
if let Some(offset) = req["offset_y"].as_f64() {
request.offset_y = offset.to_string();
}
if req.get("upside_down").is_some() {
request.upside_down = req["upside_down"].as_bool().unwrap_or(false);
}
for (key, field) in [
("transparency", 0),
("lineweights", 1),
("merge_lines", 2),
("stamp", 3),
] {
if let Some(flag) = req[key].as_bool() {
match field {
0 => request.transparency = flag,
1 => request.lineweights = flag,
2 => request.merge_lines = flag,
_ => request.stamp = flag,
}
}
}
if let Some(table) = &loaded_style {
self.active_plot_style = Some(table.clone());
request.style_name = table.name.clone();
request.apply_plot_styles = true;
request.style_missing = false;
}
let area_explicit = req.get("area").is_some();
let area = req["area"].as_str().unwrap_or("").to_ascii_lowercase();
match area.as_str() {
"" => request.area = "Extents".into(),
"extents" => request.area = "Extents".into(),
"display" => request.area = "Display".into(),
"limits" => request.area = "Limits".into(),
"layout" => request.area = "Layout".into(),
"window" => {
let values = req["window"].as_array().filter(|v| v.len() == 4).ok_or_else(|| {
failure("window_required", "area \"window\" needs window:[x0,y0,x1,y1]")
})?;
let coord = |k: usize| {
values[k].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_window", "window must be four finite numbers")
})
};
request.area = "Window".into();
request.window = Some((coord(0)?, coord(1)?, coord(2)?, coord(3)?));
}
other => return Err(failure("invalid_area", format!("Unknown area '{other}'"))),
}
// Snapshot everything the pipeline reads or mutates, like print-all.
let original_layout = self.tabs[i].scene.current_layout.clone();
let original_viewport = self.tabs[i].scene.active_viewport;
let original_psltscale = self.tabs[i].scene.document.header.paper_space_linetype_scaling;
let original_plimcheck = self.tabs[i].scene.document.header.paper_space_limit_check;
let original_dialog = self.plot_dialog.clone();
let original_window = self.plot_window;
let original_style = self.active_plot_style.clone();
let original_camera = self.tabs[i].scene.camera.borrow().clone();
let original_camera_generation = self.tabs[i].scene.camera_generation;
let build = |app: &mut Self| -> Result<Vec<PdfPageInput>, String> {
let mut pages = Vec::with_capacity(targets.len());
for name in &targets {
let is_model = name.eq_ignore_ascii_case("Model");
{
let scene = &mut app.tabs[i].scene;
scene.current_layout = name.clone();
scene.active_viewport = None;
scene.load_current_layout_state();
}
let mut page_dialog = request.clone();
page_dialog.paper_space = !is_model;
if is_model {
if page_dialog.area == "Layout" {
page_dialog.area = "Extents".into();
}
} else if let Some(page_setup) = app.tabs[i].scene.plot_settings_for(name) {
// Stored page setup first, then the explicit request
// fields on top (a layout plot's behaviour).
app.plot_dialog.paper_space = true;
app.plot_dialog.window = None;
app.load_plotsettings_into_dialog(&page_setup);
page_dialog = app.plot_dialog.clone();
page_dialog.paper_space = true;
if area_explicit {
page_dialog.area = request.area.clone();
}
if request.paper_width_mm == 0.0 && !request.paper.is_empty() {
page_dialog.paper = request.paper.clone();
page_dialog.paper_width_mm = 0.0;
page_dialog.paper_height_mm = 0.0;
}
if req.get("orientation").is_some() {
page_dialog.orientation = request.orientation.clone();
}
if explicit_scale {
page_dialog.scale = request.scale.clone();
page_dialog.fit_to_paper = request.fit_to_paper;
}
if explicit_center {
page_dialog.center = request.center;
}
if req.get("offset_x").is_some() {
page_dialog.offset_x = request.offset_x.clone();
}
if req.get("offset_y").is_some() {
page_dialog.offset_y = request.offset_y.clone();
}
if req.get("upside_down").is_some() {
page_dialog.upside_down = request.upside_down;
}
}
app.plot_dialog = page_dialog;
if app.plot_dialog.area == "Display" {
app.tabs[i].scene.restore_saved_camera();
}
if app.plot_dialog.style_missing && app.plot_dialog.apply_plot_styles {
return Err(format!(
"Plot style table '{}' is not loaded.",
app.plot_dialog.style_name
));
}
let page = match app.plot_dialog.area.as_str() {
"Layout" => Some(app.layout_plot_page_for("Layout")),
"Display" => app.display_plot_job(),
"Extents" => app.extents_plot_job(),
"Limits" => app.limits_plot_job(),
"Window" => {
if let Some((x0, y0, x1, y1)) = app.plot_dialog.window {
app.plot_window = Some((x0, y0, x1, y1));
}
app.window_plot_job()
}
_ => None,
};
let page =
page.ok_or_else(|| format!("Layout '{name}' plot area is empty."))?;
pages.push(page);
}
Ok(pages)
};
let built = build(self);
self.tabs[i].scene.current_layout = original_layout;
self.tabs[i].scene.active_viewport = original_viewport;
self.tabs[i].scene.document.header.paper_space_linetype_scaling = original_psltscale;
self.tabs[i].scene.document.header.paper_space_limit_check = original_plimcheck;
self.plot_dialog = original_dialog;
self.plot_window = original_window;
self.active_plot_style = original_style;
*self.tabs[i].scene.camera.borrow_mut() = original_camera;
self.tabs[i].scene.camera_generation = original_camera_generation;
let pages = built.map_err(|e| failure("plot_failed", e))?;
if req["per_page"].as_bool().unwrap_or(false) {
// One PDF per layout: <stem>-<Layout>.pdf beside the requested
// path (sheet-by-sheet delivery).
let stem = path.strip_suffix(".pdf").unwrap_or(path).to_owned();
let mut files = Vec::with_capacity(pages.len());
for (name, page) in targets.iter().zip(&pages) {
let safe: String = name
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let file = format!("{stem}-{safe}.pdf");
crate::io::pdf_export::export_pdf_pages(
std::slice::from_ref(page),
std::path::Path::new(&file),
loaded_style.as_ref(),
)
.map_err(|e| failure("plot_failed", e))?;
files.push(json!({"layout": name, "path": file}));
}
self.set_control_result(json!({ "files": files, "pages": pages.len() }));
return Ok(Task::none());
}
// Pages may drop the style when a stored page setup overwrites the
// dialog's style fields, so the explicit request style rides along as
// the export-level fallback.
crate::io::pdf_export::export_pdf_pages(
&pages,
std::path::Path::new(path),
loaded_style.as_ref(),
)
.map_err(|e| failure("plot_failed", e))?;
self.set_control_result(json!({
"path": path,
"pages": pages.len(),
"page_sizes": pages
.iter()
.map(|page| json!([page.paper_w, page.paper_h]))
.collect::<Vec<_>>(),
}));
Ok(Task::none())
}
pub(super) fn control_properties(&mut self) -> Value {
self.refresh_properties();
json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(|p|{
+796
View File
@@ -0,0 +1,796 @@
//! Entity-level automation operations — create, erase, transform, XData and
//! block-definition operations on the drawing database. All operations are atomic (one undo step), validate
//! every input before touching the document, and address entities by the
//! hexadecimal handles that `query`/`records` return.
use super::*;
use crate::command::EntityTransform;
type Parsed<T> = Result<T, Value>;
/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`.
fn point_field(req: &Value, key: &str) -> Parsed<acadrust::types::Vector3> {
let values = req[key]
.as_array()
.filter(|v| (2..=3).contains(&v.len()))
.ok_or_else(|| failure("invalid_point", format!("{key} must be [x,y] or [x,y,z]")))?;
let coord = |i: usize| {
values[i]
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure("invalid_point", format!("{key} needs finite coordinates")))
};
Ok(acadrust::types::Vector3::new(coord(0)?, coord(1)?, values.get(2).and_then(Value::as_f64).unwrap_or(0.0)))
}
fn deg_field(req: &Value, key: &str) -> f64 {
req[key].as_f64().unwrap_or(0.0).to_radians()
}
pub(super) fn hex_handles(req: &Value, key: &str) -> Parsed<Vec<acadrust::Handle>> {
let list = req[key]
.as_array()
.filter(|v| !v.is_empty())
.ok_or_else(|| failure("handles_required", format!("Supply a non-empty {key}:[…] list")))?;
list.iter()
.map(|v| {
u64::from_str_radix(
v.as_str()
.unwrap_or("")
.trim_start_matches("0x")
.trim_start_matches("0X"),
16,
)
.map(acadrust::Handle::new)
.map_err(|_| {
failure(
"invalid_handle",
format!("Expected a hexadecimal handle, got {v}"),
)
})
})
.collect()
}
/// Every handle must exist in the document; otherwise `entity_absent` naming
/// the missing ones so the caller can refresh instead of guessing.
pub(super) fn require_existing(document: &acadrust::CadDocument, handles: &[acadrust::Handle]) -> Parsed<()> {
let missing: Vec<String> = handles
.iter()
.filter(|h| document.get_entity(**h).is_none())
.map(|h| format!("{:X}", h.value()))
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(failure(
"entity_absent",
format!("Handles not found: {}", missing.join(", ")),
))
}
}
fn apply_common_properties(spec: &Value, entity: &mut acadrust::EntityType) -> Parsed<()> {
if let Some(layer) = spec["layer"].as_str() {
if !layer.is_empty()
&& !matches!(
entity,
acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)
)
{
entity.common_mut().layer = layer.to_owned();
}
}
if let Some(index) = spec["color"].as_i64().and_then(|v| i16::try_from(v).ok()) {
entity.common_mut().color = acadrust::types::Color::from_index(index);
}
Ok(())
}
/// One `entities` array entry → a real entity. Geometry validation happens
/// here so a bad definition aborts the whole batch before anything commits.
fn build_entity(spec: &Value) -> Parsed<acadrust::EntityType> {
use acadrust::entities::*;
use acadrust::types::Vector3;
let kind = spec["type"].as_str().unwrap_or("").to_ascii_uppercase();
let entity = match kind.as_str() {
"LINE" => {
let mut line = Line::from_points(point_field(spec, "start")?, point_field(spec, "end")?);
if let Some(t) = spec["thickness"].as_f64() {
line.thickness = t;
}
EntityType::Line(line)
}
"CIRCLE" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r >= 0.0)
.ok_or_else(|| failure("invalid_radius", "circle needs a finite radius >= 0"))?;
EntityType::Circle(Circle::from_center_radius(point_field(spec, "center")?, radius))
}
"ARC" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r > 0.0)
.ok_or_else(|| failure("invalid_radius", "arc needs a finite radius > 0"))?;
EntityType::Arc(Arc::from_center_radius_angles(
point_field(spec, "center")?,
radius,
deg_field(spec, "start_angle_deg"),
deg_field(spec, "end_angle_deg"),
))
}
"LWPOLYLINE" | "POLYLINE" => {
let vertices = spec["vertices"]
.as_array()
.filter(|v| v.len() >= 2)
.ok_or_else(|| failure("invalid_vertices", "polyline needs vertices:[[x,y],…]"))?;
let mut pline = LwPolyline::new();
for point in vertices {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
pline.add_point(acadrust::types::Vector2::new(x, y));
}
if spec["closed"].as_bool().unwrap_or(false) {
pline.close();
}
if let Some(width) = spec["constant_width"].as_f64() {
pline.constant_width = width;
}
EntityType::LwPolyline(pline)
}
"POINT" => EntityType::Point(Point::at(point_field(spec, "location")?)),
"TEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "text needs a non-empty value"));
}
let mut text = Text::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6))
.with_rotation(deg_field(spec, "rotation_deg"));
if let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) {
text.style = style.to_owned();
}
EntityType::Text(text)
}
"MTEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "mtext needs a non-empty value"));
}
let mut mtext = MText::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6));
if let Some(width) = spec["width"].as_f64().filter(|w| *w > 0.0) {
mtext.rectangle_width = width;
}
mtext.rotation = deg_field(spec, "rotation_deg");
EntityType::MText(mtext)
}
"INSERT" => {
let block = spec["block"].as_str().unwrap_or("");
if block.is_empty() {
return Err(failure("block_required", "insert needs a block name"));
}
let mut insert = Insert::new(block, point_field(spec, "position")?)
.with_rotation(deg_field(spec, "rotation_deg"));
match &spec["scale"] {
Value::Number(factor) => {
let factor = factor.as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale must be nonzero")
})?;
insert = insert.with_uniform_scale(factor);
}
Value::Array(factors) if factors.len() == 3 => {
let axis = |i: usize| {
factors[i].as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale components must be nonzero")
})
};
insert = insert.with_scale(axis(0)?, axis(1)?, axis(2)?);
}
_ => {}
}
EntityType::Insert(insert)
}
"SOLID" => {
let corners = spec["corners"]
.as_array()
.filter(|v| (3..=4).contains(&v.len()))
.ok_or_else(|| failure("invalid_corners", "solid needs 3 or 4 corners"))?;
let corner = |i: usize| -> Parsed<Vector3> {
let raw = &corners[i];
let coord = |k: usize| {
raw.get(k)
.and_then(Value::as_f64)
.filter(|v| v.is_finite())
.unwrap_or(0.0)
};
Ok(Vector3::new(coord(0), coord(1), coord(2)))
};
let fourth = corners
.get(3)
.map(|_| corner(3))
.unwrap_or_else(|| Ok(Vector3::ZERO));
EntityType::Solid(Solid::new(corner(0)?, corner(1)?, corner(2)?, fourth?))
}
"HATCH" => {
let boundary = spec["boundary"]
.as_array()
.filter(|v| v.len() >= 3)
.ok_or_else(|| failure("invalid_boundary", "hatch needs boundary:[[x,y],…] (>= 3 points)"))?;
let mut vertices = Vec::with_capacity(boundary.len());
for point in boundary {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
vertices.push(acadrust::types::Vector2::new(x, y));
}
let mut hatch = Hatch::new();
if spec["solid"].as_bool().unwrap_or(false) {
hatch.is_solid = true;
} else {
hatch.is_solid = false;
hatch.pattern = HatchPattern::new(
spec["pattern"].as_str().unwrap_or("ANSI31"),
);
hatch.pattern_scale = spec["pattern_scale"].as_f64().unwrap_or(1.0).abs().max(1e-6);
hatch.pattern_angle = deg_field(spec, "pattern_angle_deg");
}
let mut path = BoundaryPath::external();
path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(vertices, true)));
hatch.paths.push(path);
EntityType::Hatch(hatch)
}
other => {
return Err(failure(
"unknown_entity_type",
format!(
"Unknown entity type '{other}'. Supported: Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch"
),
))
}
};
let mut entity = entity;
apply_common_properties(spec, &mut entity)?;
Ok(entity)
}
impl OpenCADStudio {
/// `entities_create` — build a batch of typed entities, auto-create the
/// layers they name, and commit everything as one undoable step. All definitions are validated before the first
/// entity is added, so the batch is all-or-nothing.
pub(super) fn control_entities_create(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let list = req["entities"]
.as_array()
.filter(|v| !v.is_empty())
.ok_or_else(|| failure("entities_required", "Supply entities:[{type:…},…]"))?;
let built: Vec<acadrust::EntityType> = list.iter().map(build_entity).collect::<Parsed<_>>()?;
let i = self.active_tab;
self.push_undo_snapshot(i, "ENTITIESCREATE");
// Auto-create missing layers (Catalog §1.4: "create layer if missing")
// after the undo snapshot, so undo removes them together with the
// entities.
let mut layers_created = Vec::new();
{
let document = &mut self.tabs[i].scene.document;
for entity in &built {
let layer = entity.common().layer.clone();
if layer.is_empty() || document.layers.contains(&layer) {
continue;
}
let mut layer_record = acadrust::tables::Layer::new(layer.clone());
layer_record.handle = document.allocate_handle();
document
.layers
.add(layer_record)
.map_err(|e| failure("layer_failed", e))?;
layers_created.push(layer);
}
}
let handles: Vec<String> = {
let scene = &mut self.tabs[i].scene;
built
.into_iter()
.map(|entity| format!("{:X}", scene.add_entity(entity).value()))
.collect()
};
self.post_ref_op(i);
let created = handles.len();
self.set_control_result(json!({
"handles": handles,
"created": created,
"layers_created": layers_created,
}));
Ok(Task::none())
}
/// `entities_delete` — erase by handles. Every handle is
/// checked up front; nothing is erased when one is missing.
pub(super) fn control_entities_delete(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let handles = hex_handles(req, "handles")?;
let i = self.active_tab;
require_existing(&self.tabs[i].scene.document, &handles)?;
self.push_undo_snapshot(i, "ENTITIESDELETE");
self.tabs[i].scene.erase_entities(&handles);
self.post_ref_op(i);
let erased = handles.len();
self.set_control_result(json!({ "erased": erased }));
Ok(Task::none())
}
/// `entities_transform` — move/copy/rotate/scale/mirror/array by handles
/// Angles are degrees. `copy`, mirror with
/// `"copy":true` and `array` return the newly created handles.
pub(super) fn control_entities_transform(
&mut self,
req: &Value,
) -> Result<Task<Message>, Value> {
let handles = hex_handles(req, "handles")?;
let action = req["action"].as_str().unwrap_or("move").to_ascii_lowercase();
let i = self.active_tab;
require_existing(&self.tabs[i].scene.document, &handles)?;
let transform = |req: &Value| -> Parsed<EntityTransform> {
Ok(match action.as_str() {
"move" | "copy" => EntityTransform::Translate(glam::DVec3::new(
req["vector"][0].as_f64().unwrap_or(0.0),
req["vector"][1].as_f64().unwrap_or(0.0),
req["vector"][2].as_f64().unwrap_or(0.0),
)),
"rotate" => EntityTransform::Rotate {
center: glam::DVec3::new(
req["center"][0].as_f64().unwrap_or(0.0),
req["center"][1].as_f64().unwrap_or(0.0),
req["center"][2].as_f64().unwrap_or(0.0),
),
axis: glam::DVec3::Z,
angle_rad: deg_field(req, "angle_deg"),
},
"scale" => {
let factor = req["factor"]
.as_f64()
.filter(|f| f.is_finite() && *f != 0.0)
.ok_or_else(|| {
failure("invalid_factor", "scale needs a nonzero finite factor")
})?;
EntityTransform::Scale {
center: glam::DVec3::new(
req["center"][0].as_f64().unwrap_or(0.0),
req["center"][1].as_f64().unwrap_or(0.0),
req["center"][2].as_f64().unwrap_or(0.0),
),
factor,
}
}
"mirror" => {
let axis = req["axis"]
.as_array()
.filter(|v| v.len() == 2)
.ok_or_else(|| {
failure("invalid_axis", "mirror needs axis:[[x1,y1],[x2,y2]]")
})?;
let end = |k: usize| {
axis[k]
.as_array()
.filter(|v| v.len() >= 2)
.ok_or_else(|| {
failure("invalid_axis", "mirror needs axis:[[x1,y1],[x2,y2]]")
})
};
let (a, b) = (end(0)?, end(1)?);
let coord = |v: &[Value], k: usize| {
v[k].as_f64()
.filter(|f| f.is_finite())
.ok_or_else(|| failure("invalid_axis", "mirror axis points must be finite"))
};
let p1 = glam::DVec3::new(coord(&a, 0)?, coord(&a, 1)?, 0.0);
let p2 = glam::DVec3::new(coord(&b, 0)?, coord(&b, 1)?, 0.0);
if p1 == p2 {
return Err(failure(
"invalid_axis",
"mirror axis points must differ",
));
}
EntityTransform::Mirror {
p1,
p2,
working_normal: glam::DVec3::Z,
}
}
other => {
return Err(failure(
"invalid_action",
format!(
"Unknown transform action '{other}'. Use move, copy, rotate, scale, mirror or array"
),
))
}
})
};
self.push_undo_snapshot(i, "ENTITIESTRANSFORM");
let scene = &mut self.tabs[i].scene;
let mut created: Vec<String> = Vec::new();
let affected;
match action.as_str() {
"move" | "rotate" | "scale" => {
let t = transform(req)?;
scene.transform_entities(&handles, &t);
affected = handles.len();
}
"mirror" => {
let t = transform(req)?;
if req["copy"].as_bool().unwrap_or(false) {
created = scene
.copy_entities(&handles, &t)
.into_iter()
.map(|h| format!("{:X}", h.value()))
.collect();
} else {
scene.transform_entities(&handles, &t);
}
affected = handles.len();
}
"copy" => {
let t = transform(req)?;
created = scene
.copy_entities(&handles, &t)
.into_iter()
.map(|h| format!("{:X}", h.value()))
.collect();
affected = handles.len();
}
"array" => {
let rows = req["rows"].as_u64().unwrap_or(1).clamp(1, 100) as i32;
let columns = req["columns"].as_u64().unwrap_or(1).clamp(1, 100) as i32;
let row_spacing = req["row_spacing"].as_f64().unwrap_or(0.0);
let column_spacing = req["column_spacing"].as_f64().unwrap_or(0.0);
for row in 0..rows {
for column in 0..columns {
if row == 0 && column == 0 {
continue;
}
let t = EntityTransform::Translate(glam::DVec3::new(
column as f64 * column_spacing,
row as f64 * row_spacing,
0.0,
));
created.extend(
scene
.copy_entities(&handles, &t)
.into_iter()
.map(|h| format!("{:X}", h.value())),
);
}
}
affected = handles.len();
}
_ => unreachable!("validated by transform()"),
}
self.post_ref_op(i);
self.set_control_result(json!({
"affected": affected,
"created": created,
}));
Ok(Task::none())
}
/// `xdata_set` — replace one application's extended-data record on every
/// listed handle. The APPID table entry is
/// registered implicitly; an empty (or absent) `data` list removes the
/// application's record.
pub(super) fn control_xdata_set(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let handles = hex_handles(req, "handles")?;
let app = string(req, "app")?.to_owned();
if app.is_empty() {
return Err(failure("app_required", "Supply an application name"));
}
let values = parse_xdata_values(req)?;
let i = self.active_tab;
require_existing(&self.tabs[i].scene.document, &handles)?;
self.push_undo_snapshot(i, "XDATASET");
{
let document = &mut self.tabs[i].scene.document;
for handle in &handles {
crate::scene::view::dispatch::set_entity_xdata(
document,
*handle,
&app,
values.clone(),
);
}
}
let updated = handles.len();
self.set_control_result(json!({ "updated": updated, "app": app }));
Ok(Task::none())
}
/// `xdata_get` — read extended data back, optionally for one application.
/// A read: dispatched through the query path, no request_id needed.
pub(super) fn xdata_read(&self, req: &Value) -> Value {
let handles = match hex_handles(req, "handles") {
Ok(handles) => handles,
Err(error) => return error,
};
let app = req["app"].as_str().unwrap_or("");
let i = self.active_tab;
if let Err(error) = require_existing(&self.tabs[i].scene.document, &handles) {
return error;
}
let document = &self.tabs[i].scene.document;
let items: Vec<Value> = handles
.iter()
.filter_map(|handle| {
let entity = document.get_entity(*handle)?;
let extended = &entity.common().extended_data;
let records: serde_json::Map<String, Value> = extended
.records()
.iter()
.filter(|record| app.is_empty() || record.application_name == app)
.map(|record| {
(
record.application_name.clone(),
json!(record.values.iter().map(xdata_value_json).collect::<Vec<_>>()),
)
})
.collect();
Some(json!({
"handle": format!("{:X}", handle.value()),
"xdata": records,
}))
})
.collect();
json!({ "ok": true, "items": items })
}
/// `block_define` — turn the listed entities into a new block definition
/// (block-definition semantics, as the BLOCK command: the sources become
/// the definition's content and an Insert is placed at `insert_at`,
/// defaulting to `base`, so the drawing looks unchanged).
pub(super) fn control_block_define(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
if name.is_empty() {
return Err(failure("name_required", "Supply a block name"));
}
let handles = hex_handles(req, "handles")?;
let base = point_field(req, "base")?;
let insert_at = if req.get("insert_at").is_some() {
point_field(req, "insert_at")?
} else {
base
};
let i = self.active_tab;
require_existing(&self.tabs[i].scene.document, &handles)?;
// World → block space translates `base` onto the block origin; the
// inverse places the Insert so entities keep their world position.
let world_to_block = acadrust::types::Transform::from_translation(
acadrust::types::Vector3::new(-base.x, -base.y, -base.z),
);
let block_to_world =
acadrust::types::Transform::from_translation(insert_at);
self.push_undo_snapshot(i, "BLOCKDEFINE");
// CF-01.3 (barcode re-import): `replace:true` drops the previous
// definition — its children, markers and inserts — inside the same
// undo step, so the new definition can take the name.
if req["replace"].as_bool().unwrap_or(false) {
erase_block_definition(&mut self.tabs[i].scene, &name);
}
let insert = self.tabs[i]
.scene
.create_block_from_entities(&handles, &name, &world_to_block, &block_to_world)
.map_err(|e| failure("block_failed", e))?;
self.post_ref_op(i);
self.set_control_result(json!({
"block": name,
"insert": format!("{:X}", insert.value()),
}));
Ok(Task::none())
}
/// `block_delete` — remove a block definition together with its child
/// entities, the Block/BlockEnd markers and every Insert referencing it.
pub(super) fn control_block_delete(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
if name.starts_with('*') {
return Err(failure(
"block_protected",
"Layout block records cannot be deleted",
));
}
let i = self.active_tab;
if self.tabs[i].scene.document.block_records.get(&name).is_none() {
return Err(failure(
"block_missing",
format!("Block '{name}' does not exist"),
));
}
self.push_undo_snapshot(i, "BLOCKDELETE");
let erased = erase_block_definition(&mut self.tabs[i].scene, &name);
self.post_ref_op(i);
self.set_control_result(json!({ "block": name, "erased": erased }));
Ok(Task::none())
}
/// `view_focus` — fit the listed entities into the view and highlight
/// them (Catalog §1.3). GUI sessions only; headless servers answer
/// `gui_required`.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn control_view_focus(&mut self, req: &Value) -> Result<Task<Message>, Value> {
if self.main_window.is_none() {
return Err(failure(
"gui_required",
"view_focus needs the editor window",
));
}
let handles = hex_handles(req, "handles")?;
let i = self.active_tab;
require_existing(&self.tabs[i].scene.document, &handles)?;
if req["highlight"].as_bool().unwrap_or(true) {
let scene = &mut self.tabs[i].scene;
scene.deselect_all();
for handle in &handles {
scene.select_entity(*handle, false);
}
}
let zoomed = self.tabs[i].scene.zoom_to_entities(&handles);
let selected = self.tabs[i].scene.selected_entities().len();
self.set_control_result(json!({ "zoomed": zoomed, "selected": selected }));
Ok(Task::none())
}
}
/// Remove one block definition from the scene: every entity owned by the
/// block record, every Insert referencing the name, the Block/BlockEnd
/// markers (which the entity iterator skips) and the table record itself.
/// Silent no-op for an unknown name (callers validate first);
/// `*`-prefixed layout records are rejected by the callers. Returns the
/// number of entities erased.
fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str) -> usize {
let Some(record) = scene.document.block_records.get(name) else {
return 0;
};
let owner = record.handle;
let block_markers = [record.block_entity_handle, record.block_end_handle];
let mut victims: Vec<acadrust::Handle> = scene
.document
.entities()
.filter(|entity| {
entity.common().owner_handle == owner
|| matches!(
entity,
acadrust::EntityType::Insert(insert)
if insert.block_name.eq_ignore_ascii_case(name)
)
})
.map(|entity| entity.common().handle)
.collect();
victims.extend_from_slice(&block_markers);
scene.erase_entities(&victims);
scene.document.block_records.remove(name);
victims.len()
}
/// `data:[{code,value},…]` → typed XData values; an absent or empty list
/// means "remove this application's record".
fn parse_xdata_values(req: &Value) -> Parsed<Option<Vec<acadrust::xdata::XDataValue>>> {
use acadrust::xdata::XDataValue;
let Some(list) = req["data"].as_array() else {
return Ok(None);
};
if list.is_empty() {
return Ok(None);
}
let mut values = Vec::with_capacity(list.len());
for item in list {
let code = item["code"]
.as_i64()
.ok_or_else(|| failure("invalid_xdata", "data entries need an integer code"))?;
let value = &item["value"];
let parsed = match code {
1000 => XDataValue::String(
value.as_str().ok_or_else(|| {
failure("invalid_xdata", "code 1000 needs a string value")
})?
.to_string(),
),
1003 => XDataValue::LayerName(
value.as_str().ok_or_else(|| {
failure("invalid_xdata", "code 1003 needs a layer name")
})?
.to_string(),
),
1004 => {
let hex = value.as_str().ok_or_else(|| {
failure("invalid_xdata", "code 1004 needs a hex byte string")
})?;
((0..hex.len()).step_by(2))
.map(|k| {
u8::from_str_radix(&hex[k..(k + 2).min(hex.len())], 16).map_err(|_| {
failure("invalid_xdata", "code 1004 needs a valid hex byte string")
})
})
.collect::<Result<Vec<u8>, Value>>()
.map(XDataValue::BinaryData)?
}
1005 => {
let raw = value.as_str().ok_or_else(|| {
failure("invalid_xdata", "code 1005 needs a hex handle")
})?;
let raw = raw.trim_start_matches("0x").trim_start_matches("0X");
let handle = u64::from_str_radix(raw, 16)
.map_err(|_| failure("invalid_xdata", "code 1005 needs a valid hex handle"))?;
XDataValue::Handle(acadrust::Handle::new(handle))
}
1010..=1013 => {
let point = value.as_array().filter(|v| !v.is_empty()).ok_or_else(|| {
failure("invalid_xdata", "codes 1010-1013 need [x,y,z]")
})?;
let coord = |k: usize| point.get(k).and_then(Value::as_f64).unwrap_or(0.0);
let point = acadrust::types::Vector3::new(coord(0), coord(1), coord(2));
match code {
1010 => XDataValue::Point3D(point),
1011 => XDataValue::Position3D(point),
1012 => XDataValue::Displacement3D(point),
_ => XDataValue::Direction3D(point),
}
}
1040 => XDataValue::Real(value.as_f64().unwrap_or(0.0)),
1041 => XDataValue::Distance(value.as_f64().unwrap_or(0.0)),
1042 => XDataValue::ScaleFactor(value.as_f64().unwrap_or(0.0)),
1070 => XDataValue::Integer16(
value
.as_i64()
.and_then(|v| i16::try_from(v).ok())
.ok_or_else(|| failure("invalid_xdata", "code 1070 needs a 16-bit integer"))?,
),
1071 => XDataValue::Integer32(
value
.as_i64()
.and_then(|v| i32::try_from(v).ok())
.ok_or_else(|| failure("invalid_xdata", "code 1071 needs a 32-bit integer"))?,
),
other => {
return Err(failure(
"invalid_xdata",
format!(
"Unsupported xdata code {other}. Use 1000, 1003, 1004, 1005, 1010-1013, 1040, 1041, 1042, 1070 or 1071"
),
))
}
};
values.push(parsed);
}
Ok(Some(values))
}
/// JSON shape for one stored XData value.
fn xdata_value_json(value: &acadrust::xdata::XDataValue) -> Value {
use acadrust::xdata::XDataValue;
match value {
XDataValue::String(text)
| XDataValue::ControlString(text)
| XDataValue::LayerName(text) => json!(text),
XDataValue::BinaryData(bytes) => json!({
"binary": bytes.iter().map(|b| format!("{b:02X}")).collect::<String>()
}),
XDataValue::Handle(handle) => json!(format!("{:X}", handle.value())),
XDataValue::Point3D(point)
| XDataValue::Position3D(point)
| XDataValue::Displacement3D(point)
| XDataValue::Direction3D(point) => json!([point.x, point.y, point.z]),
XDataValue::Real(number)
| XDataValue::Distance(number)
| XDataValue::ScaleFactor(number) => json!(number),
XDataValue::Integer16(number) => json!(number),
XDataValue::Integer32(number) => json!(number),
}
}
+773
View File
@@ -0,0 +1,773 @@
//! Loopback REST channel hosted by the GUI process. `OpenCADStudio.exe
//! "<file>" --http <port>` boots the normal editor AND serves this bridge, so
//! a client gets the full REST surface of `--http` — create, move, plot,
//! query, everything the headless server does — aimed at the drawing the
//! person at the screen is working on, plus the two interactive operations
//! only this process can answer (`getpoint`, `user_select`: the person
//! picks, and the HTTP connection is the thing that waits). Routes resolve
//! through the same `rest::plan` table as the headless server; requests are
//! forwarded into the GUI's automation queue (`Envelope`, exactly like the
//! native bridge in `transport`) and the JSON response is written back over
//! HTTP. The channel binds loopback only — anything that can reach the port
//! can drive the session, exactly like the headless server.
use super::{Envelope, Reply};
use crate::rest::{self, HttpRequest, Plan, RETRYABLE};
use iced::futures::{channel::mpsc, Stream};
use serde_json::{json, Value};
use std::io::ErrorKind;
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, AtomicU16, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Port handed over by `main` when the GUI hosts the channel. Set before the
/// iced runtime boots, so the subscription worker sees it on first run.
/// Zero means "no channel" (plain GUI runs).
static GUI_HTTP_PORT: AtomicU16 = AtomicU16::new(0);
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Called from `main` before the GUI boots.
pub fn set_gui_http_port(port: u16) {
GUI_HTTP_PORT.store(port, Ordering::SeqCst);
}
pub(in crate::app) fn subscribe() -> iced::Subscription<Envelope> {
iced::Subscription::run(worker)
}
fn worker() -> impl Stream<Item = Envelope> {
iced::stream::channel(32, |sender| async move {
let port = GUI_HTTP_PORT.load(Ordering::SeqCst);
if port != 0 {
std::thread::spawn(move || {
if let Err(error) = listen(sender, port, Arc::new(AtomicU16::new(0))) {
eprintln!("--http (GUI): cannot serve 127.0.0.1:{port}: {error}");
}
});
}
iced::futures::future::pending::<()>().await;
})
}
fn listen(
sender: mpsc::Sender<Envelope>,
port: u16,
bound_port: Arc<AtomicU16>,
) -> std::io::Result<()> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
bound_port.store(listener.local_addr()?.port(), Ordering::SeqCst);
eprintln!("OpenCADStudio GUI REST listening on http://127.0.0.1:{port}/api/v1");
// One thread per connection: a `getpoint` or `user_select` request stays
// open until the person answers, and must not block the other reads
// (get_selection…).
let clients = Arc::new(AtomicUsize::new(0));
// One document-id cache for the whole listener: a state read primes it,
// later mutations address the live drawing without the caller repeating
// the id — the same ergonomics as the headless server.
let document_cache: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None));
for stream in listener.incoming().flatten() {
if clients.fetch_add(1, Ordering::SeqCst) >= 8 {
clients.fetch_sub(1, Ordering::SeqCst);
continue;
}
let clients = clients.clone();
let sender = sender.clone();
let document_cache = document_cache.clone();
std::thread::spawn(move || {
let mut stream = stream;
let _ = stream.set_read_timeout(Some(Duration::from_secs(15)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(15)));
if let Ok(Some(request)) = rest::read_request(&mut stream) {
if request.method == "OPTIONS" {
let _ = rest::write_response(&mut stream, 204, &Value::Null);
} else {
let (status, body) = forward(&request, &sender, &document_cache, &stream);
let _ = rest::write_response(&mut stream, status, &body);
}
}
clients.fetch_sub(1, Ordering::SeqCst);
});
}
Ok(())
}
/// How long one request may hold its connection. The interactive picks wait
/// for the person; everything else bounds a slow op (plot, open a large
/// drawing) without wedging the connection forever.
const DEFAULT_WAIT_SECS: u64 = 300;
const PICK_WAIT_SECS: u64 = 30 * 60;
fn forward(
request: &HttpRequest,
sender: &mpsc::Sender<Envelope>,
document_cache: &Mutex<Option<u64>>,
stream: &TcpStream,
) -> (u16, Value) {
let path = request.path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
if segments.first() != Some(&"api") || segments.get(1) != Some(&"v1") {
return (404, json!({"ok": false, "code": "unknown_route", "error": "Use /api/v1/…"}));
}
let plan = rest::plan(&request.method, &segments[2..], request);
// The picks park until the person answers; an `operation` call is itself
// a poll and must come straight back.
let wait = match plan.op() {
Some("getpoint" | "user_select") => PICK_WAIT_SECS,
Some("operation") | None => 15,
Some(_) => DEFAULT_WAIT_SECS,
};
match plan {
Plan::Local(status, body) => (status, body),
Plan::Ready => {
let state = transact(sender, json!({"protocol":1,"op":"state"}), wait, stream);
harvest(document_cache, &state);
(200, json!({
"ok": true,
"ready": true,
"version": state["version"],
"session_id": state["session_id"],
"document_id": state["document_id"],
}))
}
Plan::Run { request, created } => {
let response = transact(sender, prepare(request), wait, stream);
harvest(document_cache, &response);
(rest::map_status(response.clone(), created == 201), response)
}
Plan::Mutate { op, fields, created } => {
let cached = *document_cache.lock().unwrap();
let mut response = transact(sender, mutate_request(&op, &fields, cached), wait, stream);
let code = response["code"].as_str().unwrap_or("");
if response["ok"] == false && RETRYABLE.contains(&code) {
// The cache is behind the live GUI; refresh from a state read
// and retry once, exactly like the headless server.
let state = transact(sender, json!({"protocol":1,"op":"state"}), wait, stream);
harvest(document_cache, &state);
let cached = *document_cache.lock().unwrap();
response = transact(sender, mutate_request(&op, &fields, cached), wait, stream);
}
harvest(document_cache, &response);
let status = rest::map_status(response.clone(), created == 201);
(if status == 200 { created } else { status }, compact(response))
}
}
}
/// One envelope round trip: queue the request into the GUI and wait up to
/// `wait_secs` for its reply.
fn call(sender: &mpsc::Sender<Envelope>, request: Value, wait_secs: u64) -> Option<Value> {
let (reply, response) = std::sync::mpsc::channel();
sender
.clone()
.try_send(Envelope {
request,
reply: Reply::Native(reply),
})
.ok()?;
response.recv_timeout(Duration::from_secs(wait_secs)).ok()
}
/// Send one request and hold the connection for the GUI's answer.
/// Interactive requests (`getpoint`, `user_select`) and async ops answer
/// `accepted` first and only complete when the person at the screen acts or
/// the task finishes; poll the operation on the caller's behalf so the HTTP
/// connection is the one thing that waits.
///
/// A parked pick is also watched from the other end: the moment the HTTP
/// client hangs up (its own timeout, a crash, a killed probe), the pending
/// pick is retracted with `cancel` so the next pick starts clean instead of
/// dying on `interactive_pending` until the person happens to press Esc.
fn transact(
sender: &mpsc::Sender<Envelope>,
request: Value,
wait: u64,
stream: &TcpStream,
) -> Value {
let op = request["op"].as_str().unwrap_or("").to_owned();
let request_id = request["request_id"].as_str().unwrap_or("").to_owned();
let interactive = matches!(op.as_str(), "getpoint" | "user_select");
if interactive {
eprintln!(
"[pick] {op} {request_id}: parked -- the connection stays open until the person answers"
);
}
let Some(mut response) = call(sender, request, wait) else {
if interactive {
eprintln!("[pick] {op} {request_id}: the GUI never accepted the request");
}
return json!({"ok":false,"code":"response_timeout","error":"The GUI did not answer in time"});
};
let deadline = std::time::Instant::now() + Duration::from_secs(wait);
while matches!(response["status"].as_str(), Some("accepted" | "running"))
&& std::time::Instant::now() < deadline
{
if interactive && peer_gone(stream) {
eprintln!("[pick] {op} {request_id}: client disconnected -- cancelling the pending pick");
// The cancel reuses the pick's own request id: if the person
// answered in the race window, the settled id makes the retract
// a harmless no-op instead of an Escape into the live session.
let _ = call(
sender,
json!({"protocol":1,"op":"cancel","request_id":request_id}),
15,
);
return json!(
{"ok":false,"code":"client_disconnected","error":"The HTTP client hung up; the pending pick was cancelled"}
);
}
std::thread::sleep(Duration::from_millis(100));
let poll_id = response["request_id"].clone();
let Some(poll) = call(
sender,
json!({"protocol":1,"op":"operation","request_id":poll_id}),
15,
) else {
break;
};
response = poll;
}
if interactive {
if matches!(response["status"].as_str(), Some("accepted" | "running")) {
eprintln!(
"[pick] {op} {request_id}: no answer in {wait}s -- cancelling the pending pick"
);
let _ = call(
sender,
json!({"protocol":1,"op":"cancel","request_id":request_id}),
15,
);
} else {
eprintln!(
"[pick] {op} {request_id}: settled as {}",
response["status"].as_str().unwrap_or("unknown")
);
}
}
response
}
/// True once the HTTP client has hung up: a graceful close surfaces as EOF,
/// an aborted one as a socket error. No bytes waiting (`WouldBlock`) means
/// the client is still there, holding its half of the parked connection.
fn peer_gone(stream: &TcpStream) -> bool {
let _ = stream.set_nonblocking(true);
let gone = match stream.peek(&mut [0u8; 1]) {
Ok(0) => true,
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => false,
Err(_) => true,
Ok(_) => false,
};
let _ = stream.set_nonblocking(false);
gone
}
/// The bridge has no descriptor handshake: drop any guessed `session_id`,
/// stamp protocol 1, and give requests without one a bridge request id.
fn prepare(mut request: Value) -> Value {
if !request.is_object() {
request = json!({});
}
if let Some(object) = request.as_object_mut() {
object.remove("session_id");
}
request["protocol"] = json!(1);
if request["request_id"].as_str().is_none_or(str::is_empty) {
let serial = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst);
request["request_id"] = json!(format!("gui-http-{serial}"));
}
request
}
/// Build the mutation envelope: a request id (the caller's wins), the cached
/// `document_id`, then the body fields on top.
fn mutate_request(op: &str, fields: &Value, document_id: Option<u64>) -> Value {
let mut envelope = json!({"protocol":1,"op":op});
if let Some(id) = document_id {
envelope["document_id"] = json!(id);
}
if let Some(object) = fields.as_object() {
for (key, value) in object {
envelope[key] = value.clone();
}
}
if envelope["request_id"].as_str().is_none_or(str::is_empty) {
let serial = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst);
envelope["request_id"] = json!(format!("gui-http-{serial}"));
}
if let Some(object) = envelope.as_object_mut() {
object.remove("session_id");
}
envelope
}
/// Track the active document from responses (state reads and settle
/// snapshots both carry it) so later mutations address the live drawing.
fn harvest(document_cache: &Mutex<Option<u64>>, response: &Value) {
let id = response
.get("document_id")
.and_then(Value::as_u64)
.or_else(|| response["state"]["document_id"].as_u64());
if let Some(id) = id {
*document_cache.lock().unwrap() = Some(id);
}
}
/// Drop the bulky `state` snapshot from mutation answers, like the headless
/// server; clients read it explicitly from GET /state.
fn compact(response: Value) -> Value {
if let Some(object) = response.as_object() {
let trimmed: serde_json::Map<String, Value> = object
.iter()
.filter(|(key, _)| key.as_str() != "state")
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
return Value::Object(trimmed);
}
response
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::OpenCADStudio;
use iced::futures::StreamExt;
use std::io::{BufRead, BufReader, Read, Write as _};
/// Each test owns its port holder: two bridge tests running in parallel
/// must never read each other's bound port.
fn wait_for_bridge(bound_port: &AtomicU16) -> u16 {
for _ in 0..50 {
let port = bound_port.load(Ordering::SeqCst);
if port != 0 {
return port;
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("GUI http bridge never bound a port");
}
/// Post one request without waiting; the bridge only answers once the
/// GUI side has replied to the forwarded envelope.
fn post(port: u16, target: &str, body: &str) -> std::net::TcpStream {
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
let http = format!(
"POST {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
stream.write_all(http.as_bytes()).unwrap();
stream
}
/// Read one HTTP response (status + JSON body) off the wire.
fn read_response(stream: &mut std::net::TcpStream) -> (u16, Value) {
let mut reader = BufReader::new(stream);
let mut status_line = String::new();
let mut content_length = 0usize;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" {
break;
}
if status_line.is_empty() {
status_line = line.clone();
}
if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap();
}
}
let mut payload = vec![0u8; content_length];
reader.read_exact(&mut payload).unwrap();
let status = status_line
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.unwrap();
(status, serde_json::from_slice(&payload).unwrap())
}
#[test]
fn bridge_serves_get_selection_over_http_and_refuses_unknown_paths() {
let mut app = OpenCADStudio::new_for_test();
app.main_window = Some(iced::window::Id::unique());
let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"}));
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#);
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[app.active_tab]
.scene
.select_entity(acadrust::Handle::new(value), false);
let (sender, mut receiver) = mpsc::channel::<Envelope>(8);
let bound = Arc::new(AtomicU16::new(0));
let listen_bound = bound.clone();
std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap());
let port = wait_for_bridge(&bound);
// Read the selection back over loopback HTTP the way the client does.
let mut stream = post(
port,
"/api/v1/get_selection",
r#"{"session_id":"guessed","request_id":"bridge-1"}"#,
);
// The bridge forwarded the envelope; this test plays the GUI and
// answers it, which is what unblocks the HTTP response.
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
assert_eq!(envelope.request["op"], "get_selection");
assert!(envelope.request["session_id"].is_null());
assert_eq!(envelope.request["request_id"], "bridge-1");
let reply = app.control_request(envelope.request).0;
envelope.reply.send(reply);
let (status, body) = read_response(&mut stream);
assert_eq!(status, 200);
assert_eq!(body["ok"], true, "{body}");
assert_eq!(body["status"], "completed");
assert_eq!(body["result"]["count"], 1);
assert_eq!(body["result"]["entities"][0]["handle"], handle.as_str());
assert!(body["result"]["entities"][0]["bounds"].is_object(), "{body}");
assert_eq!(body["result"]["entities"][0]["type"], "Line");
// Unrouted paths are refused locally; the GUI is never woken for
// them. (Unknown POSTs are op attempts and DO reach the pipeline,
// same as the headless server.)
let mut stream = get(port, "/api/v1/nope");
let (status, body) = read_response(&mut stream);
assert_eq!(status, 404);
assert_eq!(body["code"], "unknown_route", "{body}");
}
/// GET without a body — the read half of the REST surface.
fn get(port: u16, target: &str) -> std::net::TcpStream {
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
let http = format!("GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n");
stream.write_all(http.as_bytes()).unwrap();
stream
}
/// Answer the next `count` envelopes the way the live GUI would.
fn drain(app: &mut OpenCADStudio, receiver: &mut mpsc::Receiver<Envelope>, count: usize) {
for _ in 0..count {
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
let reply = app.control_request(envelope.request.clone()).0;
envelope.reply.send(reply);
}
}
#[test]
fn bridge_parks_getpoint_without_blocking_other_reads() {
let mut app = OpenCADStudio::new_for_test();
app.main_window = Some(iced::window::Id::unique());
let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"}));
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#);
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[app.active_tab]
.scene
.select_entity(acadrust::Handle::new(value), false);
let (sender, mut receiver) = mpsc::channel::<Envelope>(8);
let bound = Arc::new(AtomicU16::new(0));
let listen_bound = bound.clone();
std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap());
let port = wait_for_bridge(&bound);
// The client asks for a point and holds that connection open.
let mut point_conn = post(
port,
"/api/v1/getpoint",
r#"{"request_id":"gp-b","prompt":"Chọn vị trí QR"}"#,
);
// While the pick is pending, a second connection reads the selection
// — thread-per-connection keeps the parked request from blocking it.
let mut sel_conn = post(port, "/api/v1/get_selection", "{}");
let mut polls = 0usize;
let (point_status, point_body, sel_body) = loop {
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
let reply = app.control_request(envelope.request.clone()).0;
if envelope.request["op"] == "getpoint" {
// The GUI accepted the pick; the bridge now polls `operation`
// until the person answers. Simulate the click right away.
assert_eq!(reply["status"], "accepted", "{reply}");
envelope.reply.send(reply);
app.tabs[app.active_tab].last_cursor_world =
glam::DVec3::new(1.5, 2.5, 0.0);
let _ = app.update(crate::app::Message::ViewportLeftPress);
} else if envelope.request["op"] == "get_selection" {
envelope.reply.send(reply);
let (status, body) = read_response(&mut sel_conn);
assert_eq!(status, 200, "{body}");
assert_eq!(body["result"]["count"], 1);
assert_eq!(body["result"]["entities"][0]["handle"], handle.as_str());
} else {
// An `operation` poll from the parked getpoint connection.
polls += 1;
assert!(polls < 100, "getpoint never completed: {reply}");
envelope.reply.send(reply.clone());
if reply["status"] == "completed" {
let point_status = read_response(&mut point_conn);
break (point_status.0, point_status.1, Value::Null);
}
}
};
assert_eq!(point_status, 200, "{point_body}");
assert_eq!(point_body["status"], "completed", "{point_body}");
assert_eq!(point_body["result"]["point"], json!([1.5, 2.5, 0.0]));
assert!(sel_body.is_null());
}
/// The client stops waiting mid-pick (its own timeout, a killed probe):
/// the bridge must notice the hang-up, retract the pending pick with
/// `cancel`, and leave the channel free for the next pick — instead of
/// holding the session until the person happens to press Esc.
#[test]
fn bridge_cancels_a_parked_pick_when_the_client_disconnects() {
let mut app = OpenCADStudio::new_for_test();
app.main_window = Some(iced::window::Id::unique());
let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"}));
let (sender, mut receiver) = mpsc::channel::<Envelope>(8);
let bound = Arc::new(AtomicU16::new(0));
let listen_bound = bound.clone();
std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap());
let port = wait_for_bridge(&bound);
// The client parks a pick…
let point_conn = post(port, "/api/v1/getpoint", r#"{"request_id":"gp-drop"}"#);
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
assert_eq!(envelope.request["op"], "getpoint");
let reply = app.control_request(envelope.request.clone()).0;
assert_eq!(reply["status"], "accepted", "{reply}");
envelope.reply.send(reply);
// …then gives up: the connection closes while the person is
// presumably still deciding.
drop(point_conn);
// The bridge notices and retracts the pick; `operation` polls that
// slip in first are answered as `running` like the GUI would.
let mut polls = 0usize;
loop {
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
if envelope.request["op"] == "cancel" {
assert_eq!(envelope.request["request_id"], "gp-drop");
let reply = app.control_request(envelope.request).0;
assert_eq!(reply["status"], "cancelled", "{reply}");
assert_eq!(reply["result"]["cancelled"], true, "{reply}");
break;
}
assert_eq!(envelope.request["op"], "operation", "unexpected envelope");
polls += 1;
assert!(polls < 100, "the disconnect was never noticed");
let reply = app.control_request(envelope.request.clone()).0;
envelope.reply.send(reply);
}
// The channel is free: the next pick is accepted right away instead
// of dying on `interactive_pending`. Answer it with a click so the
// test leaves no session behind.
let mut next_conn = post(port, "/api/v1/getpoint", r#"{"request_id":"gp-next"}"#);
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
assert_eq!(envelope.request["op"], "getpoint");
let reply = app.control_request(envelope.request).0;
assert_eq!(reply["status"], "accepted", "{reply}");
envelope.reply.send(reply);
app.tabs[app.active_tab].last_cursor_world = glam::DVec3::new(2.0, 3.0, 0.0);
let _ = app.update(crate::app::Message::ViewportLeftPress);
let mut polls = 0usize;
let (_, body) = loop {
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
let reply = app.control_request(envelope.request.clone()).0;
envelope.reply.send(reply.clone());
polls += 1;
assert!(polls < 100, "the retry pick never completed: {reply}");
if reply["status"] == "completed" {
break read_response(&mut next_conn);
}
};
assert_eq!(body["status"], "completed", "{body}");
assert_eq!(body["result"]["point"], json!([2.0, 3.0, 0.0]));
}
#[test]
fn bridge_parks_user_select_until_the_person_answers() {
let mut app = OpenCADStudio::new_for_test();
app.main_window = Some(iced::window::Id::unique());
let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"}));
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#);
let handle = app.automation_op(r#"{"op":"query","type":"LINE","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
let (sender, mut receiver) = mpsc::channel::<Envelope>(8);
let bound = Arc::new(AtomicU16::new(0));
let listen_bound = bound.clone();
std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap());
let port = wait_for_bridge(&bound);
// Park one `user_select` POST; the test plays the person at the
// screen: accept, pick, then press Enter (confirm) or Escape
// (cancel), and read the final HTTP answer the client receives.
fn park_and_answer(
app: &mut OpenCADStudio,
receiver: &mut mpsc::Receiver<Envelope>,
port: u16,
body: &str,
confirm: bool,
handle: &str,
) -> (u16, Value) {
let mut conn = post(port, "/api/v1/user_select", body);
let handle_value = u64::from_str_radix(handle, 16).unwrap();
let mut polls = 0usize;
loop {
let envelope = iced::futures::executor::block_on(receiver.next()).unwrap();
let reply = app.control_request(envelope.request.clone()).0;
if envelope.request["op"] == "user_select" {
// The GUI accepted the pick; the bridge now polls
// `operation` until the person answers.
assert_eq!(reply["status"], "accepted", "{reply}");
envelope.reply.send(reply);
app.tabs[app.active_tab]
.scene
.select_entity(acadrust::Handle::new(handle_value), false);
let _ = app.update(if confirm {
crate::app::Message::CommandFinalize
} else {
crate::app::Message::CommandEscape
});
} else {
// An `operation` poll from the parked user_select
// connection: relay it until it settles.
polls += 1;
assert!(polls < 100, "user_select never settled: {reply}");
envelope.reply.send(reply.clone());
if reply["status"] != "running" {
return read_response(&mut conn);
}
}
}
}
// Enter after picking the line hands the picked set back with full
// entity data, the same contract the MCP/native callers see.
let (status, body) = park_and_answer(
&mut app,
&mut receiver,
port,
r#"{"request_id":"us-http","type":"LINE","prompt":"Chọn đối tượng mẫu","detail":"full","clear":true}"#,
true,
&handle,
);
assert_eq!(status, 200, "{body}");
assert_eq!(body["status"], "completed", "{body}");
assert_eq!(body["result"]["cancelled"], false);
assert_eq!(body["result"]["count"], 1);
assert_eq!(body["result"]["handles"][0], json!(handle));
assert_eq!(body["result"]["entities"][0]["handle"], handle.as_str());
assert_eq!(body["result"]["entities"][0]["type"], "Line");
assert_eq!(body["result"]["entities"][0]["layer"], "0");
// Escape answers with the cancelled contract over the same route.
let (status, body) = park_and_answer(
&mut app,
&mut receiver,
port,
r#"{"request_id":"us-http-esc","prompt":"Chọn lại"}"#,
false,
&handle,
);
assert_eq!(status, 200, "{body}");
assert_eq!(body["status"], "cancelled", "{body}");
assert_eq!(body["result"]["cancelled"], true);
assert_eq!(body["result"]["count"], 0);
}
#[test]
fn bridge_runs_the_full_rest_surface_against_the_live_gui() {
let mut app = OpenCADStudio::new_for_test();
app.main_window = Some(iced::window::Id::unique());
let _ = app.control_request(json!({"protocol":1,"op":"new","request_id":"n1"}));
let (sender, mut receiver) = mpsc::channel::<Envelope>(8);
let bound = Arc::new(AtomicU16::new(0));
let listen_bound = bound.clone();
std::thread::spawn(move || listen(sender, 0, listen_bound).unwrap());
let port = wait_for_bridge(&bound);
// GET /state primes the document-id cache; the mutations below carry
// no document_id of their own.
let mut state_conn = get(port, "/api/v1/state");
drain(&mut app, &mut receiver, 1);
let (status, body) = read_response(&mut state_conn);
assert_eq!(status, 200, "{body}");
assert!(body["document_id"].is_u64(), "{body}");
// CREATE through the headless REST URL: 201 with handles, the cached
// document id addressed for us.
let mut create_conn = post(
port,
"/api/v1/entities",
r#"{"entities":[{"type":"Line","start":[0,0],"end":[10,0]}]}"#,
);
drain(&mut app, &mut receiver, 1);
let (status, body) = read_response(&mut create_conn);
assert_eq!(status, 201, "{body}");
let handle = body["result"]["handles"][0].as_str().unwrap().to_owned();
// MOVE it through the same URL the headless server serves.
let mut move_conn = post(
port,
"/api/v1/entities/transform",
&format!(r#"{{"handles":["{handle}"],"action":"move","vector":[5,0]}}"#),
);
drain(&mut app, &mut receiver, 1);
let (status, body) = read_response(&mut move_conn);
assert_eq!(status, 200, "{body}");
let mut query_conn = get(port, "/api/v1/entities?type=Line&detail=full");
drain(&mut app, &mut receiver, 1);
let (_, body) = read_response(&mut query_conn);
assert_eq!(body["entities"][0]["start"], json!([5.0, 0.0, 0.0]), "{body}");
// A stale cache (the person switched documents) costs one state
// refresh and a retry, not an error.
let fresh = app.push_test_document();
let _ = app.control_request(
json!({"protocol":1,"op":"activate","document_id":fresh,"request_id":"a1"}),
);
let mut stale_conn = post(
port,
"/api/v1/entities",
r#"{"entities":[{"type":"Circle","center":[0,0],"radius":1}]}"#,
);
// mutate refused (stale) → state read → retry succeeds.
drain(&mut app, &mut receiver, 3);
let (status, body) = read_response(&mut stale_conn);
assert_eq!(status, 201, "{body}");
// The plain op passthrough works too — MCP-style, explicit
// document_id, any op the automation surface knows. `select`
// settles inline; `run` would park the connection on `operation`
// polls until the live editor's update loop finishes the command.
let mut select_conn = post(
port,
"/api/v1/select",
&format!(r#"{{"document_id":{fresh},"clear":true}}"#),
);
drain(&mut app, &mut receiver, 1);
let (status, body) = read_response(&mut select_conn);
assert_eq!(status, 200, "{body}");
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Interactive user-request operations — selection and point requests
//! answered by the person at the screen. A client asks the
//! person at the screen to pick entities; the operation stays pending
//! (`running` for pollers) until that person answers with Enter or Escape,
//! then resolves with the picked entities' full data.
use super::*;
use crate::app::automation::{entity_json, entity_type_matches};
impl OpenCADStudio {
/// `user_select` — hand the screen to the person at the desk: they pick
/// entities (optionally filtered by type / layer), Enter confirms, Escape
/// cancels. The operation resolves only after that answer, so callers
/// must treat `running` as "still waiting for the user", not as a hang.
pub(super) fn control_user_select(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let i = self.active_tab;
if self.tabs[i].is_start {
return Err(failure("no_document", "Create or open a drawing first"));
}
// Enter and Escape are the answer keys; a live command or dialog owns
// them, so refuse rather than race the user's keystrokes.
if self.tabs[i].active_cmd.is_some() || self.active_modal.is_some() {
return Err(failure(
"user_input_busy",
"The user is already interacting with a command or dialog",
));
}
if self.main_window.is_none() {
return Err(failure(
"gui_required",
"user_select needs the desktop GUI where a person can answer",
));
}
if self.control.get_point.is_some() || self.control.user_select.is_some() {
return Err(failure(
"interactive_pending",
"Another interactive request is already waiting for the user",
));
}
let detail = req["detail"].as_str().unwrap_or("full");
if !matches!(detail, "summary" | "geometry" | "full") {
return Err(failure(
"invalid_detail",
"detail must be summary, geometry or full",
));
}
let type_filter = req["type"]
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned);
let layer_filter = req["layer"]
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned);
// Selection-set semantics: the request starts a fresh pick unless the
// caller asks to keep what is already selected.
if req["clear"].as_bool().unwrap_or(true) {
self.tabs[i].scene.deselect_all();
}
let prompt = req["prompt"]
.as_str()
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| {
crate::t!("Select objects, then press Enter to finish (Esc cancels).")
.into_owned()
});
// Label the request on screen: the message list must say WHO is
// waiting and for what, and the line stays pinned (non-fading) for
// as long as the request is open — see `pending_pick_label`.
let request_id = req["request_id"]
.as_str()
.unwrap_or_default()
.to_owned();
let label = crate::tf!(
"[user_select · {}] {} ({})",
if request_id.is_empty() { "client" } else { &request_id },
prompt,
crate::t!("Enter confirms, Esc cancels"),
)
.into_owned();
self.command_line.push_info(&label);
eprintln!(
"[pick] user_select {}: start -- waiting for the person (Enter confirms, Esc cancels)",
display_id(&request_id)
);
self.control.user_select = Some(UserSelectSession {
request_id,
document_id: self.tabs[i].id,
type_filter,
layer_filter,
detail: detail.to_owned(),
label,
});
Ok(Task::none())
}
/// Settle the pending request once the user answered. `confirmed` is the
/// Enter branch (selection read + filter validation); Escape resolves
/// immediately with `cancelled`. While the session lives the operation
/// stays off the settle path (its `pending` counter is held at one), so
/// pollers see `running` for as long as the person keeps working.
pub(in crate::app) fn resolve_user_select(&mut self, confirmed: bool) {
let Some(session) = self.control.user_select.take() else {
return;
};
if !confirmed {
self.command_line
.push_info(crate::t!("*Cancel*").as_ref());
self.command_line.push_info(
crate::tf!(
"user_select {}: the request was answered with no objects.",
session.request_id
)
.as_ref(),
);
eprintln!(
"[pick] user_select {}: the person pressed Esc -- cancelled",
display_id(&session.request_id)
);
self.finish_interactive(
"user_select",
&session.request_id,
true,
json!({"cancelled": true, "count": 0, "handles": [], "entities": []}),
);
return;
}
let index = self
.tabs
.iter()
.position(|tab| tab.id == session.document_id);
let Some(i) = index else {
eprintln!(
"[pick] user_select {}: document closed -- cancelled",
display_id(&session.request_id)
);
self.finish_interactive(
"user_select",
&session.request_id,
true,
json!({"cancelled": true, "reason": "document_closed", "count": 0, "handles": [], "entities": []}),
);
return;
};
// The filter only gates the answer, not the user's gestures: anything
// that does not match is deselected (and reported) at confirm time.
let picked = self.tabs[i].scene.selected_handles_in_order();
let mut handles: Vec<acadrust::Handle> = Vec::new();
let mut ignored = 0usize;
for handle in picked {
let matches = self.tabs[i]
.scene
.document
.get_entity(handle)
.is_some_and(|entity| {
session
.type_filter
.as_deref()
.is_none_or(|filter| entity_type_matches(entity, filter))
&& session
.layer_filter
.as_deref()
.is_none_or(|filter| entity.common().layer == filter)
});
if matches {
handles.push(handle);
} else {
ignored += 1;
self.tabs[i].scene.deselect_entity(handle);
}
}
if ignored > 0 {
self.command_line.push_info(
crate::tf!(
"{} object(s) ignored: outside the requested type/layer filter.",
ignored
)
.as_ref(),
);
}
let stored: Vec<String> = handles
.iter()
.map(|handle| format!("{:X}", handle.value()))
.collect();
let entities: Vec<Value> = handles
.iter()
.filter_map(|handle| {
self.tabs[i]
.scene
.document
.get_entity(*handle)
.map(|entity| entity_json(entity, &session.detail))
})
.collect();
let count = entities.len();
self.command_line.push_info(
crate::tf!(
"user_select {}: handed {} object(s) to the client.",
session.request_id,
count
)
.as_ref(),
);
eprintln!(
"[pick] user_select {}: the person confirmed -- {} object(s) handed over",
display_id(&session.request_id),
count
);
self.finish_interactive(
"user_select",
&session.request_id,
false,
json!({
"cancelled": false,
"count": count,
"ignored": ignored,
"handles": stored,
"entities": entities,
}),
);
}
/// `getpoint` — ask the person at the screen to pick one point. The next
/// left-click in the pinned document's viewport becomes the answer (the
/// same snapped world point a command would receive); Escape cancels.
/// The operation stays `running` until then, so callers may hold the
/// HTTP request open for as long as the person takes.
pub(super) fn control_getpoint(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let i = self.active_tab;
if self.tabs[i].is_start {
return Err(failure("no_document", "Create or open a drawing first"));
}
if self.tabs[i].active_cmd.is_some() || self.active_modal.is_some() {
return Err(failure(
"user_input_busy",
"The user is already interacting with a command or dialog",
));
}
if self.control.get_point.is_some() || self.control.user_select.is_some() {
return Err(failure(
"interactive_pending",
"Another interactive request is already waiting for the user",
));
}
if self.main_window.is_none() {
return Err(failure(
"gui_required",
"getpoint needs the desktop GUI where a person can answer",
));
}
let prompt = req["prompt"]
.as_str()
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| crate::t!("Pick a point, then click (Esc cancels).").into_owned());
let request_id = req["request_id"]
.as_str()
.unwrap_or_default()
.to_owned();
let label = crate::tf!(
"[getpoint · {}] {} ({})",
if request_id.is_empty() { "client" } else { &request_id },
prompt,
crate::t!("Click confirms, Esc cancels"),
)
.into_owned();
self.command_line.push_info(&label);
eprintln!(
"[pick] getpoint {}: start -- waiting for the person (Click confirms, Esc cancels)",
display_id(&request_id)
);
self.control.get_point = Some(UserPointSession {
request_id,
document_id: self.tabs[i].id,
label,
});
Ok(Task::none())
}
/// Settle a `getpoint` request: `Some(point)` is the picked answer,
/// `None` the Escape (or tab-close) cancellation.
pub(in crate::app) fn resolve_get_point(&mut self, point: Option<[f64; 3]>) {
let Some(session) = self.control.get_point.take() else {
return;
};
// Drop the snap marker the pending pick was showing — no command is
// running, so nothing else would clear it after the session ends.
if let Some(i) = self.tabs.iter().position(|tab| tab.id == session.document_id) {
self.tabs[i].snap_result = None;
}
let (cancelled, result) = match point {
Some(point) => (false, json!({"point": point})),
None => (true, json!({"cancelled": true})),
};
if cancelled {
self.command_line.push_info(crate::t!("*Cancel*").as_ref());
}
self.command_line.push_info(
crate::tf!(
"getpoint {}: {}",
session.request_id,
match point {
Some(point) => format!("{point:?}"),
None => crate::t!("no point — the request was cancelled.").into_owned(),
}
)
.as_ref(),
);
match point {
Some(point) => eprintln!(
"[pick] getpoint {}: the person picked {:?}",
display_id(&session.request_id),
point
),
None => eprintln!(
"[pick] getpoint {}: the person pressed Esc -- cancelled",
display_id(&session.request_id)
),
}
self.finish_interactive("getpoint", &session.request_id, cancelled, result);
}
/// The pinned command-line label of a pending client pick (`user_select`
/// or `getpoint`). The end-of-update driver feeds this to
/// `CommandLine::set_step_prompt` so the request line stays on screen —
/// pinned, non-fading — until the person answers, then it un-pins.
pub(in crate::app) fn pending_pick_label(&self) -> Option<String> {
self.control
.user_select
.as_ref()
.map(|session| session.label.clone())
.or_else(|| self.control.get_point.as_ref().map(|s| s.label.clone()))
}
/// Stamp an interactive answer into the pending operation and let it
/// settle. `cancelled` flips the final status to "cancelled". The
/// request_id + op check keeps a stale answer from writing into someone
/// else's result slot.
fn finish_interactive(
&mut self,
op: &str,
request_id: &str,
cancelled: bool,
result: Value,
) {
if let Some(pending) = self
.control
.pending
.as_mut()
.filter(|p| p.id == request_id && p.request["op"] == op)
{
pending.result = result;
pending.cancelled = cancelled;
pending.pending = 0;
}
self.control_settle();
}
/// `get_selection` — read-only snapshot of the active tab's selection
/// (the read-back half of the `user_select` sampling flow). `handle`,
/// `type`, `layer` and `bounds` come straight from the `query` output so
/// clients share one parser; `text`/`value` expose the stripped and raw
/// strings for TEXT/MTEXT, and `block`/`position` identify an INSERT's
/// block definition and insertion point (null otherwise). Reads
/// `scene.selection` and never mutates it.
pub(in crate::app) fn control_get_selection(&self) -> Value {
let scene = &self.tabs[self.active_tab].scene;
let entities: Vec<Value> = scene
.selected_handles_in_order()
.iter()
.filter_map(|handle| {
scene.document.get_entity(*handle).map(|entity| {
let full = entity_json(entity, "full");
json!({
"handle": full["handle"],
"type": full["type"],
"layer": full["layer"],
"bounds": full["bounds"],
"text": full.get("text").cloned().unwrap_or(Value::Null),
"value": full.get("value").cloned().unwrap_or(Value::Null),
"block": full.get("block").cloned().unwrap_or(Value::Null),
"position": full.get("position").cloned().unwrap_or(Value::Null),
})
})
})
.collect();
json!({
"ok": true,
"status": "completed",
"result": {"count": entities.len(), "entities": entities},
})
}
}
/// Stable diagnostic name for a request that arrived without an id, shared
/// by the on-screen label and the stderr `[pick]` lifecycle lines so the
/// client's own log can be cross-referenced line for line.
fn display_id(request_id: &str) -> &str {
if request_id.is_empty() {
"client"
} else {
request_id
}
}
+653 -16
View File
@@ -109,6 +109,38 @@ pub(super) struct State {
serial: u64,
events: VecDeque<Value>,
pub(super) routing: bool,
/// Session-scoped named handle sets for selection_set_save/load.
selection_sets: std::collections::BTreeMap<String, Vec<acadrust::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>,
}
/// 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 {
@@ -127,6 +159,9 @@ struct Operation {
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()})
@@ -394,6 +429,7 @@ impl OpenCADStudio {
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,
"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<_>>(),
@@ -406,7 +442,7 @@ impl OpenCADStudio {
"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","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
"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"]
})
}
@@ -429,6 +465,8 @@ impl OpenCADStudio {
| "layers"
| "header"
| "history"
| "xdata_get"
| "get_selection"
);
if !query && !self.control.enabled {
return (
@@ -576,12 +614,38 @@ impl OpenCADStudio {
);
}
if let Some(p) = &self.control.pending {
return (
if p.id == id && p.request == req {
json!({"ok":true,"status":"running","request_id":id})
if p.id == id && p.request == req {
return (
json!({"ok":true,"status":"running","request_id":id}),
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 {
failure("busy", "Wait for the running operation")
},
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(),
);
}
@@ -594,7 +658,9 @@ impl OpenCADStudio {
Task::none(),
);
}
if self.tabs[self.active_tab].id != id && op != "activate" {
if self.tabs[self.active_tab].id != id
&& !matches!(op, "activate" | "close" | "entities_copy_to")
{
return (
failure(
"document_not_active",
@@ -603,12 +669,19 @@ impl OpenCADStudio {
Task::none(),
);
}
} else if !query && !matches!(op, "new" | "open" | "stop") {
return (
failure("document_required", "Read state and supply document_id"),
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()
@@ -629,6 +702,8 @@ impl OpenCADStudio {
let response = match op {
"properties" => self.control_properties(),
"measure" => self.control_measure(&req),
"xdata_get" => self.xdata_read(&req),
"get_selection" => self.control_get_selection(),
"history" => {
json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::<Vec<_>>()})
}
@@ -684,6 +759,7 @@ impl OpenCADStudio {
origin_document: tab.id,
geometry_revision: tab.scene.geometry_epoch,
result: json!({}),
cancelled: false,
});
self.control.routing = true;
let action = self.control_action(&req);
@@ -712,7 +788,12 @@ impl OpenCADStudio {
self.finish_all_pending_history();
let task = self.control_track(task);
if let Some(p) = self.control.pending.as_mut() {
p.pending -= 1;
// 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.
if !matches!(p.request["op"].as_str(), Some("user_select" | "getpoint")) {
p.pending -= 1;
}
}
self.control_settle();
let response = self
@@ -728,7 +809,23 @@ impl OpenCADStudio {
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" => self.update(Message::TabNew),
"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")]
@@ -846,6 +943,29 @@ impl OpenCADStudio {
#[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)?,
"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()
@@ -1000,7 +1120,7 @@ impl OpenCADStudio {
}
let status = if failed {
"failed"
} else if p.request["op"] == "cancel" {
} else if p.cancelled || p.request["op"] == "cancel" {
"cancelled"
} else if waiting {
"waiting_input"
@@ -1154,6 +1274,14 @@ impl OpenCADStudio {
}
}
mod actions;
mod entities;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod http_bridge;
mod interactive;
mod sheets;
#[cfg(test)]
mod p1_tests;
pub(super) fn action_names() -> &'static [&'static str] {
actions::NAMES
}
@@ -1566,4 +1694,513 @@ mod tests {
let _ = std::fs::remove_file(path);
let _ = handle;
}
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(acadrust::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().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().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(acadrust::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", acadrust::types::LineWeight::Value(13)),
("THICK", acadrust::types::LineWeight::Value(50)),
] {
let mut layer = acadrust::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 = acadrust::entities::Line::new();
line.common.layer = name.to_string();
line.start = acadrust::types::Vector3::new(origin, origin, 0.0);
line.end = acadrust::types::Vector3::new(origin + 3000.0, origin + 2000.0, 0.0);
scene.add_entity(acadrust::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 = acadrust::tables::Layer::new("FRAME-CYAN");
frame.color = acadrust::types::Color::Index(4);
let _ = scene.document.layers.add(frame);
let mut frame_line = acadrust::entities::Line::new();
frame_line.common.layer = "FRAME-CYAN".to_string();
frame_line.start = acadrust::types::Vector3::new(0.0, 0.0, 0.0);
frame_line.end = acadrust::types::Vector3::new(3000.0, 0.0, 0.0);
scene.add_entity(acadrust::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 = acadrust::entities::Line::new();
red.common.color = acadrust::types::Color::Index(1);
red.start = acadrust::types::Vector3::new(0.0, 1000.0, 0.0);
red.end = acadrust::types::Vector3::new(3000.0, 1000.0, 0.0);
scene.add_entity(acadrust::EntityType::Line(red));
let mut true_color = acadrust::entities::Line::new();
true_color.common.color = acadrust::types::Color::from_true_color_value(0x0000FF00);
true_color.start = acadrust::types::Vector3::new(0.0, 2000.0, 0.0);
true_color.end = acadrust::types::Vector3::new(3000.0, 2000.0, 0.0);
scene.add_entity(acadrust::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);
}
}
+568
View File
@@ -0,0 +1,568 @@
//! Integration tests for the P1/P2 automation operations — template flows,
//! close, sysvar, layouts/page setups, cross-document copy, groups,
//! selection sets, per-page plotting, where filters and hatch readout.
#![cfg(test)]
use crate::app::OpenCADStudio;
use serde_json::{json, Value};
/// Envelope mutation against the active document: fills `{doc}` with the
/// document_id read from state, like every external client must.
fn mutate(app: &mut OpenCADStudio, request: &str) -> Value {
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
app.automation_op(&request.replacen("{doc}", &doc.to_string(), 1))
}
fn count_type(app: &mut OpenCADStudio, kind: &str) -> u64 {
let q = app.automation_op(&format!(
r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#
));
q["count"].as_u64().unwrap()
}
#[test]
fn query_where_filters_on_entity_properties() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 1"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 20,0 5"}"#);
// radius > 2 keeps only the big circle (the filter runs over
// RFC 6901 property paths, same operators as records).
let q = app.automation_op(
r#"{"op":"query","type":"Circle","detail":"geometry","where":[{"path":"/radius","op":"gt","value":2}]}"#,
);
assert_eq!(q["ok"], true, "{}", q["error"]);
assert_eq!(q["count"], 1);
assert_eq!(q["entities"][0]["radius"], 5.0);
// An invalid pointer is refused up front, as is an unknown operator.
let q = app.automation_op(
r#"{"op":"query","type":"Circle","where":[{"path":"radius","op":"eq","value":1}]}"#,
);
assert_eq!(q["ok"], false);
let q = app.automation_op(
r#"{"op":"query","type":"Circle","where":[{"path":"/radius","op":"between","value":1}]}"#,
);
assert_eq!(q["ok"], false);
}
#[test]
fn close_op_guards_dirty_documents_and_discards() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,1"}"#);
// Dirty without discard: refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"close","request_id":"cl0","document_id":{doc}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "document_dirty");
// Discard closes the tab (single tab → replaced by a fresh drawing).
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"close","request_id":"cl1","document_id":{doc},"discard":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["closed"], true);
assert_eq!(app.tabs[app.active_tab].scene.document.entities().count(), 0);
// Unknown document id: document_closed.
let r = app.automation_op(
r#"{"protocol":1,"op":"close","request_id":"cl2","document_id":9999}"#,
);
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "document_closed");
}
#[test]
fn sysvar_round_trips_and_refuses_unknowns() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv1","document_id":{doc},"set":{{"ltscale":2.5,"mirrtext":0}}}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let header = &app.tabs[app.active_tab].scene.document.header;
assert!((header.linetype_scale - 2.5).abs() < 1e-9);
assert!(!header.mirror_text);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv2","document_id":{doc},"get":["ltscale","mirrtext"]}}"#
));
assert_eq!(r["result"]["values"]["ltscale"], 2.5);
assert_eq!(r["result"]["values"]["mirrtext"], 0);
// clayer only accepts an existing layer.
mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"sv2b","document_id":{doc},"entities":[{"type":"Point","location":[0,0],"layer":"Walls"}]}"#,
);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv3","document_id":{doc},"set":{{"clayer":"Walls"}}}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(
app.tabs[app.active_tab].scene.document.header.current_layer_name,
"Walls"
);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv4","document_id":{doc},"set":{{"clayer":"NOPE"}}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "invalid_sysvar_value");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv5","document_id":{doc},"set":{{"not_a_sysvar":1}}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "unknown_sysvar");
}
#[test]
fn layout_create_and_page_setup_configure_sheets() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"layout_create","request_id":"lc1","document_id":{doc},"name":"PLAN"}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert!(r["result"]["layouts"].as_array().unwrap().iter().any(|n| n == "PLAN"));
// Duplicate refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"layout_create","request_id":"lc2","document_id":{doc},"name":"PLAN"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "layout_exists");
// Page setup on the new layout: A4 landscape, fit, centered.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps1","document_id":{doc},"layout":"PLAN","paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let ps = app.tabs[app.active_tab]
.scene
.plot_settings_for("PLAN")
.expect("stored page setup");
assert!(ps.paper_size.contains("ISO_A4"));
assert!(ps.flags.plot_centered);
assert!(ps.flags.use_standard_scale);
// Bad scale string refused; unknown layout refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps2","document_id":{doc},"layout":"PLAN","scale":"nonsense"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "invalid_scale");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"page_setup_set","request_id":"ps3","document_id":{doc},"layout":"GHOST"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "layout_missing");
}
#[test]
fn new_from_template_and_dwt_save_round_trip() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let dwt = std::env::temp_dir().join(format!("ocs_tpl_{}.dwt", std::process::id()));
let _ = std::fs::remove_file(&dwt);
let p = dwt.to_string_lossy().replace('\\', "\\\\");
let saved = app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#));
assert_eq!(saved["ok"], true, "{}", saved["error"]);
assert!(dwt.exists(), ".dwt file written");
// A fresh template-based drawing carries the template's entities and
// no source path. The read may race the file-system rename on Windows.
let mut r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"new","request_id":"nt1","template":"{p}"}}"#
));
for attempt in 0..5 {
if r["ok"] == true {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"new","request_id":"nt-retry-{attempt}","template":"{p}"}}"#
));
}
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["total"], 1);
assert!(app.tabs[app.active_tab].current_path.is_none());
drop(app);
let sidecar = dwt.with_file_name(format!(
".{}.ocs.lock",
dwt.file_name().unwrap().to_string_lossy()
));
let _ = std::fs::remove_file(sidecar);
let _ = std::fs::remove_file(&dwt);
}
#[test]
fn wblock_with_template_base_keeps_template_tables() {
// The template drawing carries layer TPL; the exported entity set lands
// in a copy of that template so TPL survives (Catalog §2.1).
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"wt0","document_id":{doc},"entities":[{"type":"Line","start":[0,0],"end":[5,5],"layer":"TPL"}]}"#,
);
let tpl = std::env::temp_dir().join(format!("ocs_wbtpl_{}.dwg", std::process::id()));
let _ = std::fs::remove_file(&tpl);
let tp = tpl.to_string_lossy().replace('\\', "\\\\");
assert_eq!(app.automation_op(&format!(r#"{{"op":"save","path":"{tp}"}}"#))["ok"], true);
// A new drawing holds the one entity to export.
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 1,1 2"}"#);
let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let out = std::env::temp_dir().join(format!("ocs_wb_out_{}.dxf", std::process::id()));
let _ = std::fs::remove_file(&out);
let op = out.to_string_lossy().replace('\\', "\\\\");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"wblock","request_id":"wt1","document_id":{doc},"path":"{op}","template":"{tp}","handles":["{handle}"]}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
// The export contains the circle AND the template's TPL layer.
let opened = app.automation_op(&format!(r#"{{"op":"open","path":"{op}"}}"#));
assert_eq!(opened["ok"], true);
let layers = app.automation_op(r#"{"op":"layers"}"#);
assert!(layers["layers"]
.as_array()
.unwrap()
.iter()
.any(|l| l["name"] == "TPL"));
drop(app);
for path in [tpl, out] {
let sidecar = path.with_file_name(format!(
".{}.ocs.lock",
path.file_name().unwrap().to_string_lossy()
));
let _ = std::fs::remove_file(sidecar);
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn entities_copy_to_another_open_document() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let handle = app.automation_op(r#"{"op":"query","type":"Circle","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
// A second document with its own layer table.
app.tabs
.push(crate::app::document::DocumentTab::new_drawing(99));
let target = app.tabs[1].id;
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"entities_copy_to","request_id":"cp1","document_id":{target},"handles":["{handle}"]}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["count"], 1);
// Source keeps the original; the target holds the fresh copy.
assert_eq!(count_type(&mut app, "Circle"), 1);
app.active_tab = 1;
assert_eq!(count_type(&mut app, "Circle"), 1);
app.active_tab = 0;
// A missing target is refused.
let r = app.automation_op(
r#"{"protocol":1,"op":"entities_copy_to","request_id":"cp2","document_id":4242,"handles":["01"]}"#,
);
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "document_closed");
}
#[test]
fn group_create_and_selection_sets() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,0"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let q = app.automation_op(r#"{"op":"query","detail":"summary"}"#);
let handles: Vec<String> = q["entities"]
.as_array()
.unwrap()
.iter()
.map(|e| format!("\"{}\"", e["handle"].as_str().unwrap()))
.collect();
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"group_create","request_id":"g1","document_id":{doc},"name":"FRAME","handles":[{}]}}"#,
handles.join(",")
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let objects = &app.tabs[app.active_tab].scene.document.objects;
assert!(objects.values().any(|o| matches!(
o,
acadrust::objects::ObjectType::Group(group) if group.name == "FRAME"
)));
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"selection_set_save","request_id":"s1","document_id":{doc},"name":"frame-set","handles":[{}]}}"#,
handles.join(",")
));
assert_eq!(r["result"]["count"], 2);
// Load recalls (and selects by default).
app.automation_op(r#"{"op":"select","clear":true}"#);
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"selection_set_load","request_id":"s2","document_id":{doc},"name":"frame-set"}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["selected"], 2);
// Unknown set refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"selection_set_load","request_id":"s3","document_id":{doc},"name":"ghost"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "selection_set_missing");
}
#[test]
fn plot_per_page_writes_one_pdf_per_layout() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 100,80"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
app.automation_op(&format!(
r#"{{"protocol":1,"op":"layout_create","request_id":"pp0","document_id":{doc},"name":"PLAN"}}"#
));
let out = std::env::temp_dir().join(format!("ocs_pp_{}.pdf", std::process::id()));
let _ = std::fs::remove_file(&out);
let op = out.to_string_lossy().replace('\\', "\\\\");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"plot","request_id":"pp1","document_id":{doc},"path":"{op}","layout":"all","per_page":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
let files = r["result"]["files"].as_array().unwrap();
let expected = app.tabs[app.active_tab].scene.layout_names().len();
assert_eq!(files.len(), expected, "one PDF per layout");
for file in files {
let bytes = std::fs::read(file["path"].as_str().unwrap()).expect("per-page pdf");
assert!(bytes.starts_with(b"%PDF"));
let _ = std::fs::remove_file(file["path"].as_str().unwrap());
}
}
#[test]
fn hatch_boundary_is_flattened_in_query() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"h1","document_id":{doc},"entities":[
{"type":"Hatch","boundary":[[0,0],[10,0],[10,10],[0,10]],"solid":true}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
let q = app.automation_op(r#"{"op":"query","type":"Hatch","detail":"full"}"#);
assert_eq!(q["count"], 1);
let boundary = q["entities"][0]["boundary"].as_array().expect("boundary");
assert_eq!(boundary[0].as_array().unwrap().len(), 4);
}
#[test]
fn diagnostic_layer_survives_plain_io_round_trip() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"dg1","document_id":{doc},"entities":[
{"type":"Point","location":[1,1],"layer":"Walls"}
]}"#,
);
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["layers_created"], json!(["Walls"]));
// Direct io round trip on a clone of the document.
let doc = app.tabs[app.active_tab].scene.document.clone();
let bytes = crate::io::save_to_bytes(&doc, "dxf", acadrust::DxfVersion::AC1032)
.expect("save to bytes");
let path = std::env::temp_dir().join(format!("ocs_diag_{}.dxf", std::process::id()));
std::fs::write(&path, &bytes).unwrap();
let loaded = crate::io::load_file(&path).expect("load back");
let _ = std::fs::remove_file(&path);
let names: Vec<String> = loaded.layers.iter().map(|l| l.name.clone()).collect();
assert!(
names.iter().any(|n| n == "Walls"),
"Walls lost in plain io round trip; layers = {names:?}"
);
}
#[test]
fn block_delete_and_define_replace_manage_definitions() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,0"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 2"}"#);
let q = app.automation_op(r#"{"op":"query","detail":"summary"}"#);
let joined = q["entities"]
.as_array()
.unwrap()
.iter()
.map(|e| format!("\"{}\"", e["handle"].as_str().unwrap()))
.collect::<Vec<_>>()
.join(",");
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_define","request_id":"bl0","document_id":{doc},"name":"MARK","base":[0,0,0],"handles":[{joined}]}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(count_type(&mut app, "Insert"), 1);
// Re-import parity (CF-01.3): replace drops the previous definition,
// its children and its insert inside one undoable step.
app.automation_op(r#"{"op":"run","cmd":"POINT 9,9"}"#);
let handle = app.automation_op(r#"{"op":"query","type":"Point","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_define","request_id":"bl1","document_id":{doc},"name":"MARK","base":[0,0,0],"handles":["{handle}"],"replace":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(count_type(&mut app, "Insert"), 1, "one insert after replace");
assert_eq!(count_type(&mut app, "Line"), 0, "old children erased");
assert_eq!(count_type(&mut app, "Circle"), 0, "old children erased");
// Delete removes the insert, the markers, the children and the record.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK"}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert!(r["result"]["erased"].as_u64().unwrap() >= 3, "insert + markers + child");
assert_eq!(count_type(&mut app, "Insert"), 0);
// Unknown names and protected layout records are refused.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl3","document_id":{doc},"name":"GHOST"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "block_missing");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl4","document_id":{doc},"name":"*Model_Space"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "block_protected");
}
#[test]
fn wblock_normalize_shifts_the_export_to_the_origin() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 100,200 110,210"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
let handle = app.automation_op(r#"{"op":"query","type":"Line","detail":"summary"}"#)
["entities"][0]["handle"]
.as_str()
.unwrap()
.to_owned();
let out = std::env::temp_dir().join(format!("ocs_wbnorm_{}.dxf", std::process::id()));
let _ = std::fs::remove_file(&out);
let op = out.to_string_lossy().replace('\\', "\\\\");
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"wblock","request_id":"wn1","document_id":{doc},"path":"{op}","handles":["{handle}"],"normalize":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(r["result"]["normalized"], true);
assert_eq!(app.automation_op(&format!(r#"{{"op":"open","path":"{op}"}}"#))["ok"], true);
let q = app.automation_op(r#"{"op":"query","type":"Line","detail":"full"}"#);
assert_eq!(q["entities"][0]["start"], json!([0.0, 0.0, 0.0]));
assert_eq!(q["entities"][0]["end"], json!([10.0, 10.0, 0.0]));
let _ = std::fs::remove_file(&out);
}
#[test]
fn file_identity_is_stable_and_survives_a_round_trip() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let r1 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi1","document_id":{doc}}"#,
);
assert_eq!(r1["ok"], true, "{}", r1["error"]);
assert_eq!(r1["result"]["created"], true);
let identity = r1["result"]["identity"].as_str().unwrap().to_owned();
assert_eq!(identity.len(), 36, "RFC 4122 shape: {identity}");
assert_eq!(&identity[8..9], "-");
// Idempotent: the second call returns the same identity.
let r2 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi2","document_id":{doc}}"#,
);
assert_eq!(r2["result"]["created"], false);
assert_eq!(r2["result"]["identity"], identity.as_str());
// renew mints a fresh one.
let r3 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi3","document_id":{doc},"renew":true}"#,
);
let renewed = r3["result"]["identity"].as_str().unwrap().to_owned();
assert_ne!(renewed, identity);
// The marker survives a save → open round trip.
let path = std::env::temp_dir().join(format!("ocs_fid_{}.dwg", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().replace('\\', "\\\\");
assert_eq!(app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], true);
assert_eq!(app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"], true);
let r4 = mutate(
&mut app,
r#"{"protocol":1,"op":"file_identity","request_id":"fi4","document_id":{doc}}"#,
);
assert_eq!(r4["result"]["identity"], renewed.as_str());
drop(app);
let sidecar = path.with_file_name(format!(
".{}.ocs.lock",
path.file_name().unwrap().to_string_lossy()
));
let _ = std::fs::remove_file(sidecar);
let _ = std::fs::remove_file(&path);
}
#[test]
fn state_reports_the_hand_seed() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let before = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap();
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 1,1"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap();
assert!(after > before, "issuing a handle advances the seed");
}
+672
View File
@@ -0,0 +1,672 @@
//! Sheet-level and environment automation operations — layouts, page setups,
//! system variables, the document lifecycle and saved selection sets.
use super::*;
impl OpenCADStudio {
/// `close` — close a document tab. A dirty
/// document is refused unless `"discard":true`; discarding clears the
/// dirty flag so the close path skips the unsaved-changes dialog.
pub(super) fn control_close(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let index = if let Some(id) = req["document_id"].as_u64() {
self.tabs
.iter()
.position(|t| t.id == id)
.ok_or_else(|| failure("document_closed", "Document absent"))?
} else {
self.active_tab
};
if self.tabs[index].is_start {
self.set_control_result(json!({ "closed": false, "reason": "start_page" }));
return Ok(Task::none());
}
if self.tabs[index].dirty && !req["discard"].as_bool().unwrap_or(false) {
return Err(failure(
"document_dirty",
"Save the document or pass discard:true",
));
}
self.tabs[index].dirty = false;
let remaining = self.tabs.len();
// The close may queue async work (autosave cleanup) — return the
// task so the runtime drives it.
let close_task = self.update(Message::TabClose(index));
let closed_document = req["document_id"].as_u64();
self.set_control_result(json!({
"closed": true,
"documents_before": remaining,
"documents_after": self.tabs.len(),
"closed_document_id": closed_document,
}));
Ok(close_task)
}
/// `sysvar` — read/write the curated system-variable registry
/// (read and write).
/// `{"op":"sysvar","get":["LTSCALE","EXTMIN"]}` reads; `{"op":"sysvar",
/// "set":{"MIRRTEXT":0}}` writes (one undo step, validated per name).
pub(super) fn control_sysvar(&mut self, req: &Value) -> Result<Task<Message>, Value> {
if let Some(set) = req["set"].as_object() {
self.push_undo_snapshot(self.active_tab, "SYSVAR");
let mut applied = serde_json::Map::new();
for (name, value) in set {
set_sysvar(&mut self.tabs[self.active_tab].scene.document, name, value)
.map_err(|e| {
// Nothing committed yet — the snapshot is empty.
e
})?;
applied.insert(name.to_ascii_lowercase(), value.clone());
}
self.set_control_result(json!({ "set": applied }));
}
if let Some(get) = req["get"].as_array() {
let scene = &self.tabs[self.active_tab].scene;
let mut values = serde_json::Map::new();
for name in get {
let name = name.as_str().unwrap_or("");
values.insert(
name.to_ascii_lowercase(),
read_sysvar(scene, name).ok_or_else(|| {
failure(
"unknown_sysvar",
format!(
"Unknown system variable '{name}'. Readable: {}",
READABLE_SYSVARS.join(", ")
),
)
})?,
);
}
self.set_control_result(json!({ "values": values }));
}
if req.get("get").is_none() && req.get("set").is_none() {
return Err(failure(
"get_or_set_required",
"Supply get:[names] and/or set:{name:value}",
));
}
Ok(Task::none())
}
/// `layout_create` — add a layout with the default A4 page setup.
pub(super) fn control_layout_create(
&mut self,
req: &Value,
) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
if name.is_empty() {
return Err(failure("name_required", "Supply a layout name"));
}
let i = self.active_tab;
if self.tabs[i].scene.layout_names().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
return Err(failure(
"layout_exists",
format!("Layout '{name}' already exists"),
));
}
self.push_undo_snapshot(i, "LAYOUTCREATE");
self.tabs[i]
.scene
.document
.add_layout(&name)
.map_err(|e| failure("layout_failed", e))?;
let plot_style = self
.active_plot_style
.as_ref()
.map(|style| style.name.clone())
.unwrap_or_default();
let layout_flags = i16::from(
self.tabs[i].scene.document.header.paper_space_linetype_scaling,
) | (i16::from(self.tabs[i].scene.document.header.paper_space_limit_check) << 1);
for obj in self.tabs[i].scene.document.objects.values_mut() {
if let acadrust::objects::ObjectType::Layout(layout) = obj {
if layout.name == name {
layout.flags = layout_flags;
crate::scene::apply_default_page_setup(layout, &plot_style);
break;
}
}
}
self.tabs[i].scene.ensure_sheet_viewport(&name);
self.tabs[i].dirty = true;
let layouts = self.tabs[i].scene.layout_names();
self.set_control_result(json!({ "layout": name, "layouts": layouts }));
Ok(Task::none())
}
/// `page_setup_set` — write a layout's plot configuration
/// Explicit fields win; everything else
/// keeps the layout's stored setup. Paper names resolve through the
/// catalog; scales are `"fit"` or `"paper:drawing"` like `"1:100"`.
pub(super) fn control_page_setup_set(
&mut self,
req: &Value,
) -> Result<Task<Message>, Value> {
let layout = string(req, "layout")?.to_owned();
let i = self.active_tab;
if !self.tabs[i].scene.layout_names().iter().any(|n| n.eq_ignore_ascii_case(&layout)) {
return Err(failure(
"layout_missing",
format!("Layout '{layout}' does not exist"),
));
}
let mut ps = self
.tabs[i]
.scene
.plot_settings_for(&layout)
.unwrap_or_else(|| acadrust::objects::PlotSettings::new(String::new()));
if let Some(paper) = req["paper"].as_str().filter(|p| !p.is_empty()) {
let resolved = crate::io::paper_catalog::resolve(paper)
.ok_or_else(|| failure("invalid_paper", format!("Unknown paper '{paper}'")))?;
ps.paper_size = resolved.canonical.to_string();
let (width, height) = resolved.portrait_mm();
ps.paper_width = width;
ps.paper_height = height;
ps.rotation = if req["orientation"].as_str().is_some_and(|o| o.eq_ignore_ascii_case("portrait")) {
acadrust::objects::PlotRotation::None
} else {
acadrust::objects::PlotRotation::Degrees90
};
}
if let Some(fit) = req["fit"].as_bool() {
if fit {
ps.set_scale_to_fit();
ps.flags.use_standard_scale = true;
} else {
ps.flags.use_standard_scale = false;
}
}
if let Some(scale) = req["scale"].as_str().filter(|s| !s.is_empty()) {
let parsed = scale.split_once(':').and_then(|(a, b)| {
let numerator = a.trim().parse::<f64>().ok()?;
let denominator = b.trim().parse::<f64>().ok()?;
(numerator > 0.0 && denominator > 0.0).then_some((numerator, denominator))
});
let Some((numerator, denominator)) = parsed else {
return Err(failure(
"invalid_scale",
"scale must look like \"1:100\" (paper:drawing, both > 0)",
));
};
ps.scale_numerator = numerator;
ps.scale_denominator = denominator;
ps.standard_scale_factor = numerator / denominator;
ps.flags.use_standard_scale = false;
}
if let Some(center) = req["center"].as_bool() {
ps.flags.plot_centered = center;
}
if let Some(style) = req["plot_style"].as_str() {
ps.current_style_sheet = style.to_owned();
ps.flags.plot_plot_styles = !style.is_empty();
}
if let Some(window) = req["window"].as_array().filter(|v| v.len() == 4) {
let coord = |k: usize| window[k].as_f64().unwrap_or(0.0);
ps.set_plot_window(coord(0), coord(1), coord(2), coord(3));
ps.plot_type = acadrust::objects::PlotType::Window;
}
self.push_undo_snapshot(i, "PAGESETUP");
if !self.tabs[i].scene.set_layout_plot_settings(&layout, &ps) {
return Err(failure("layout_missing", format!("Layout '{layout}' vanished")));
}
self.tabs[i].dirty = true;
self.set_control_result(json!({
"layout": layout,
"paper": ps.paper_size,
"scale": json!({
"numerator": ps.scale_numerator,
"denominator": ps.scale_denominator,
"fit": ps.flags.use_standard_scale,
}),
"plot_style": ps.current_style_sheet,
}));
Ok(Task::none())
}
/// `entities_copy_to` — copy entities into another open document
/// Referenced layer definitions travel along;
/// the new handles are returned.
pub(super) fn control_entities_copy_to(
&mut self,
req: &Value,
) -> Result<Task<Message>, Value> {
let handles = crate::app::control::entities::hex_handles(req, "handles")?;
let target = req["document_id"]
.as_u64()
.ok_or_else(|| failure("document_required", "Supply the target document_id"))?;
let source_index = self.active_tab;
let target_index = self
.tabs
.iter()
.position(|t| t.id == target)
.ok_or_else(|| failure("document_closed", "Target document absent"))?;
crate::app::control::entities::require_existing(
&self.tabs[source_index].scene.document,
&handles,
)?;
// Clone out of the source first so the two tabs never borrow together.
let cloned: Vec<acadrust::EntityType> = handles
.iter()
.filter_map(|handle| self.tabs[source_index].scene.document.get_entity(*handle))
.map(|entity| entity.clone())
.collect();
// Copy the referenced layer definitions out of the source before the
// target borrow starts (the two tabs never borrow together).
let mut missing_layers: Vec<acadrust::tables::Layer> = Vec::new();
{
let source_document = &self.tabs[source_index].scene.document;
for entity in &cloned {
let layer = entity.common().layer.clone();
if layer.is_empty()
|| layer == "0"
|| source_document.layers.contains(&layer)
|| missing_layers.iter().any(|l| l.name == layer)
{
continue;
}
if let Some(source_layer) = source_document.layers.get(&layer) {
missing_layers.push(source_layer.clone());
}
}
}
self.push_undo_snapshot(target_index, "COPYTO");
let mut created = Vec::new();
{
let scene = &mut self.tabs[target_index].scene;
crate::io::linetypes::populate_document(&mut scene.document);
for layer_record in &missing_layers {
if !scene.document.layers.contains(&layer_record.name) {
let mut layer_record = layer_record.clone();
layer_record.handle = scene.document.allocate_handle();
let _ = scene.document.layers.add(layer_record);
}
}
for entity in cloned {
created.push(format!("{:X}", scene.add_entity_clone(entity).value()));
}
}
self.tabs[target_index].dirty = true;
self.post_ref_op(target_index);
let created_count = created.len();
self.set_control_result(json!({
"document_id": target,
"created": created,
"count": created_count,
}));
Ok(Task::none())
}
/// `group_create` — attach a named group to the listed entities
/// (stored in the drawing's group dictionary).
pub(super) fn control_group_create(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
if name.is_empty() {
return Err(failure("name_required", "Supply a group name"));
}
let handles = crate::app::control::entities::hex_handles(req, "handles")?;
let i = self.active_tab;
crate::app::control::entities::require_existing(&self.tabs[i].scene.document, &handles)?;
self.push_undo_snapshot(i, "GROUPCREATE");
let handle = self.tabs[i].scene.create_group(name.clone(), handles);
self.tabs[i].dirty = true;
self.set_control_result(json!({
"group": name,
"handle": format!("{:X}", handle.value()),
}));
Ok(Task::none())
}
/// `selection_set_save` — remember a named handle set for this session
/// (session-scoped by design).
pub(super) fn control_selection_set_save(
&mut self,
req: &Value,
) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
if name.is_empty() {
return Err(failure("name_required", "Supply a set name"));
}
let handles = crate::app::control::entities::hex_handles(req, "handles")?;
let stored: Vec<String> = handles
.iter()
.map(|h| format!("{:X}", h.value()))
.collect();
self.control
.selection_sets
.insert(name.clone(), handles);
let count = self
.control
.selection_sets
.get(&name)
.map(|set| set.len())
.unwrap_or(0);
self.set_control_result(json!({ "name": name, "handles": stored, "count": count }));
Ok(Task::none())
}
/// `selection_set_load` — recall a saved set. With `"select":true`
/// (default) the entities become the current selection.
pub(super) fn control_selection_set_load(
&mut self,
req: &Value,
) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
let handles = self
.control
.selection_sets
.get(&name)
.cloned()
.ok_or_else(|| failure("selection_set_missing", format!("No set named '{name}'")))?;
let stored: Vec<String> = handles
.iter()
.map(|h| format!("{:X}", h.value()))
.collect();
let select = req["select"].as_bool().unwrap_or(true);
let mut selected = 0;
if select {
let i = self.active_tab;
let scene = &mut self.tabs[i].scene;
scene.deselect_all();
for handle in &handles {
if scene.document.get_entity(*handle).is_some() {
scene.select_entity(*handle, false);
selected += 1;
}
}
}
self.set_control_result(json!({
"name": name,
"handles": stored,
"selected": selected,
}));
Ok(Task::none())
}
/// `new` with `"template"` — the web build reads no files from a path, so
/// a template is refused there rather than silently ignored.
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn apply_template(&mut self, _template: &str) -> Result<usize, Value> {
Err(failure(
"invalid_request",
"starting from a template needs the desktop build",
))
}
/// `new` with `"template"` — start an untitled drawing from a DWG/DXF/DWT
/// file. Tables and styles come from the template; the document keeps no
/// source path.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn apply_template(&mut self, template: &str) -> Result<usize, Value> {
let path = std::path::PathBuf::from(template);
let bytes = self
.read_drawing(&path)
.map_err(|e| failure("template_missing", e))?;
// DWT is a DWG-family file, but the io dispatcher keys on the file
// extension — hand it a .dwg name and point the document's source
// path back at the real template.
let load_path = if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("dwt"))
{
path.with_extension("dwg")
} else {
path.clone()
};
let (mut document, purged) = crate::io::load_bytes_finalized(&load_path, bytes)
.map_err(|e| failure("template_failed", e))?;
document.source_path = Some(path.to_string_lossy().into_owned());
let i = self.active_tab;
{
let scene = &mut self.tabs[i].scene;
scene.clear();
scene.document = document;
scene.load_named_parameters_from_document();
scene.load_parametric_constraints_from_document();
scene.material_base_dir = None;
scene.rebuild_derived_caches();
}
crate::app::style_ops::ensure_standard_styles(&mut self.tabs[i].scene.document);
self.tabs[i].adopt_active_ucs_from_header();
self.tabs[i].current_path = None;
self.tabs[i].is_start = false;
self.tabs[i].dirty = true;
Ok(purged)
}
/// `file_identity` — stable per-document GUID (the drawing's SPMDOCUNIQUE value,
/// Catalog §1.6). The identity lives on a single invisible marker text
/// at the origin carrying the XData application "SPMDOCUNIQUE" — the
/// same marker SPM.ACAD writes — so it survives save/open round trips.
/// Idempotent get-or-create; `"renew":true` rewrites the marker with a
/// fresh GUID instead of adding a second one.
pub(super) fn control_file_identity(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let renew = req["renew"].as_bool().unwrap_or(false);
let i = self.active_tab;
let marker = find_file_identity_marker(&self.tabs[i].scene.document);
if !renew {
if let Some(handle) = marker {
if let Some(identity) = marker_identity(&self.tabs[i].scene.document, handle) {
self.set_control_result(json!({ "identity": identity, "created": false }));
return Ok(Task::none());
}
}
}
let identity = new_guid_v4();
self.push_undo_snapshot(i, "FILEIDENTITY");
match marker {
Some(handle) => {
crate::scene::view::dispatch::set_entity_xdata(
&mut self.tabs[i].scene.document,
handle,
FILE_IDENTITY_APP,
Some(vec![acadrust::xdata::XDataValue::String(identity.clone())]),
);
}
None => {
let mut text = acadrust::entities::Text::with_value(
FILE_IDENTITY_APP,
acadrust::types::Vector3::ZERO,
)
.with_height(0.0001);
text.common.invisible = true;
let handle = self.tabs[i].scene.add_entity(acadrust::EntityType::Text(text));
crate::scene::view::dispatch::set_entity_xdata(
&mut self.tabs[i].scene.document,
handle,
FILE_IDENTITY_APP,
Some(vec![acadrust::xdata::XDataValue::String(identity.clone())]),
);
}
}
self.tabs[i].dirty = true;
self.post_ref_op(i);
self.set_control_result(json!({ "identity": identity, "created": true }));
Ok(Task::none())
}
}
/// The XData application name that carries a drawing's stable identity.
const FILE_IDENTITY_APP: &str = "SPMDOCUNIQUE";
/// The handle of the entity carrying the file identity, if present.
fn find_file_identity_marker(document: &acadrust::CadDocument) -> Option<acadrust::Handle> {
document
.entities()
.find(|entity| {
entity
.common()
.extended_data
.get_record(FILE_IDENTITY_APP)
.is_some()
})
.map(|entity| entity.common().handle)
}
/// The identity GUID from the marker entity's XData.
fn marker_identity(document: &acadrust::CadDocument, handle: acadrust::Handle) -> Option<String> {
let record = document
.get_entity(handle)?
.common()
.extended_data
.get_record(FILE_IDENTITY_APP)?;
match record.values.first() {
Some(acadrust::xdata::XDataValue::String(text)) => Some(text.clone()),
_ => None,
}
}
/// A random RFC 4122 version-4 GUID string, from the OS entropy pool.
fn new_guid_v4() -> String {
use std::fmt::Write as _;
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS entropy unavailable");
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
let mut out = String::with_capacity(36);
for (index, byte) in bytes.iter().enumerate() {
if matches!(index, 4 | 6 | 8 | 10) {
out.push('-');
}
let _ = write!(out, "{byte:02x}");
}
out
}
/// The readable registry; `WRITABLE_SYSVARS` is the subset `set` accepts.
const READABLE_SYSVARS: &[&str] = &[
"celtscale", "clayer", "ctextstyle", "extmax", "extmin", "filletrad",
"insunits", "ltscale", "mirrtext", "osmode", "pdmode", "pdsize",
"textsize",
];
fn read_sysvar(scene: &crate::scene::Scene, name: &str) -> Option<Value> {
let header = &scene.document.header;
let value = match name.to_ascii_lowercase().as_str() {
"ltscale" => json!(header.linetype_scale),
"pdmode" => json!(header.point_display_mode),
"pdsize" => json!(header.point_display_size),
"celtscale" => json!(header.current_entity_linetype_scale),
"textsize" => json!(header.text_height),
"filletrad" => json!(header.fillet_radius),
"mirrtext" => json!(i16::from(header.mirror_text)),
"insunits" => json!(header.insertion_units),
"osmode" => json!(header.object_snap_mode),
"clayer" => json!(header.current_layer_name),
"ctextstyle" => json!(header.current_text_style_name),
"extmin" => {
let (min, _) = scene.model_space_extents()?;
json!([min.x, min.y, min.z])
}
"extmax" => {
let (_, max) = scene.model_space_extents()?;
json!([max.x, max.y, max.z])
}
_ => return None,
};
Some(value)
}
fn set_sysvar(document: &mut acadrust::CadDocument, name: &str, value: &Value) -> Result<(), Value> {
let failure = |message: &str| crate::app::control::failure("invalid_sysvar_value", message);
let header = &mut document.header;
match name.to_ascii_lowercase().as_str() {
"ltscale" => {
header.linetype_scale = positive(value, "ltscale", failure)?;
}
"pdmode" => {
header.point_display_mode = integer(value, "pdmode", failure)?;
}
"pdsize" => {
header.point_display_size = value.as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("pdsize needs a finite number")
})?;
}
"celtscale" => {
header.current_entity_linetype_scale = positive(value, "celtscale", failure)?;
}
"textsize" => {
header.text_height = positive(value, "textsize", failure)?;
}
"filletrad" => {
header.fillet_radius = value
.as_f64()
.filter(|v| v.is_finite() && *v >= 0.0)
.ok_or_else(|| failure("filletrad needs a finite number >= 0"))?;
}
"mirrtext" => {
header.mirror_text = value.as_i64().unwrap_or(1) != 0;
}
"insunits" => {
header.insertion_units = integer(value, "insunits", failure)?;
}
"osmode" => {
header.object_snap_mode = value
.as_i64()
.and_then(|v| i32::try_from(v).ok())
.ok_or_else(|| failure("osmode needs an integer"))?;
}
"clayer" => {
let name = value.as_str().filter(|s| !s.is_empty()).ok_or_else(|| {
failure("clayer needs a layer name")
})?;
let handle = document
.layers
.get(name)
.map(|layer| layer.handle)
.ok_or_else(|| {
failure(&format!("Layer '{name}' does not exist"))
})?;
header.current_layer_name = name.to_owned();
header.current_layer_handle = handle;
}
"ctextstyle" => {
let name = value.as_str().filter(|s| !s.is_empty()).ok_or_else(|| {
failure("ctextstyle needs a text style name")
})?;
let handle = document
.text_styles
.get(name)
.map(|style| style.handle)
.ok_or_else(|| {
failure(&format!("Text style '{name}' does not exist"))
})?;
header.current_text_style_name = name.to_owned();
header.current_text_style_handle = handle;
}
other => {
return Err(crate::app::control::failure(
"unknown_sysvar",
format!(
"System variable '{other}' is read-only or unknown. Writable: {}",
"celtscale, clayer, ctextstyle, filletrad, insunits, ltscale, mirrtext, osmode, pdmode, pdsize, textsize"
),
))
}
}
Ok(())
}
fn positive(
value: &Value,
name: &str,
failure: impl Fn(&str) -> Value,
) -> Result<f64, Value> {
value
.as_f64()
.filter(|v| v.is_finite() && *v > 0.0)
.ok_or_else(|| failure(&format!("{name} needs a finite number > 0")))
}
fn integer(
value: &Value,
name: &str,
failure: impl Fn(&str) -> Value,
) -> Result<i16, Value> {
value
.as_i64()
.and_then(|v| i16::try_from(v).ok())
.ok_or_else(|| failure(&format!("{name} needs a 16-bit integer")))
}