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

+44
View File
@@ -112,6 +112,26 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
return self.open_unsaved_dialog_window();
}
// Pending client interactive requests pinned to this drawing
// can never be answered once the tab is gone — cancel them.
let closing_tab_id = self.tabs.get(idx).map(|t| t.id);
let pinned = self
.control
.get_point
.as_ref()
.map(|s| s.document_id)
.filter(|&id| Some(id) == closing_tab_id)
.or_else(|| {
self.control
.user_select
.as_ref()
.map(|s| s.document_id)
.filter(|&id| Some(id) == closing_tab_id)
});
if pinned.is_some() {
self.resolve_get_point(None);
self.resolve_user_select(false);
}
#[cfg(not(target_arch = "wasm32"))]
let tab_id = self.tabs.get(idx).map(|t| t.id);
// This tab is closing for good — drop its autosave recovery copy.
@@ -860,6 +880,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if let Some(cmd) = self.command_line.submit() {
return self.dispatch_command_or_suggest(&cmd);
}
// Empty Enter with a pending client user_select request is the
// person's answer: hand the picked set back to the caller
// before the repeat-last-command shortcut can fire.
if self.control.user_select.is_some() {
self.resolve_user_select(true);
return Task::none();
}
// Empty Enter / Space with no active command repeats the
// last dispatched command — same shortcut `CommandFinalize`
// already implements, mirrored here so the trailing-space
@@ -1009,6 +1036,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].active_cmd.is_some() {
self.feed_command(crate::command::StepInput::Enter)
} else if self.control.user_select.is_some() {
// Enter with no command active answers a pending client
// user_select request — same contract as the command-line
// empty-submit path above.
self.resolve_user_select(true);
Task::none()
} else if let Some(cmd) = self.tabs[i].last_cmd.clone() {
self.dispatch_command(&cmd)
} else {
@@ -1017,6 +1050,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
// Escape answers a pending client getpoint request first: no point.
if self.control.get_point.is_some() {
self.resolve_get_point(None);
return Task::none();
}
// Escape answers a pending client user_select request before anything
// else — the person on the screen is who the request is waiting for.
if self.control.user_select.is_some() {
self.resolve_user_select(false);
return Task::none();
}
if self.ribbon.escape_extension() {
return Task::none();
}
+46 -10
View File
@@ -358,6 +358,34 @@ fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> {
.then_some(bounds)
}
/// Stamp the owning entity's effective color index onto a plot wire so CTB
/// plot-style lookups (`wire.aci > 0`) apply: an entity's own ACI 1-255 is
/// used as-is, ByLayer resolves through the layer table, and true-color
/// objects keep `aci = 0` ("0 means true-color" — no CTB mapping). Wires
/// whose owner cannot be resolved keep the index they carry.
fn plot_owner_aci(scene: &crate::scene::Scene, wire: &mut crate::scene::WireModel) {
let Some(handle) = crate::scene::Scene::handle_from_wire_name(&wire.name) else {
return;
};
let Some(entity) = scene.document.get_entity(handle) else {
return;
};
let common = entity.common();
wire.aci = match &common.color {
acadrust::types::Color::Index(index) => *index,
acadrust::types::Color::ByLayer => scene
.document
.layers
.get(&common.layer)
.map(|layer| match &layer.color {
acadrust::types::Color::Index(index) => *index,
_ => 0,
})
.unwrap_or(7),
_ => 0,
};
}
fn plot_scene_content(
scene: &crate::scene::Scene,
paper_space_last: bool,
@@ -389,7 +417,10 @@ fn plot_scene_content(
wires
.into_iter()
.zip(depths)
.map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth })
.map(|(mut wire, draw_depth)| {
plot_owner_aci(scene, &mut wire);
crate::io::pdf_export::PlotWire { wire, draw_depth }
})
.collect::<Vec<_>>()
};
let paper_wires = with_depth(paper_wires);
@@ -417,7 +448,10 @@ fn plot_scene_content(
model_pattern_wires.retain(|(wire, _)| wire.plot_visible);
let model_pattern_wires = model_pattern_wires
.into_iter()
.map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth })
.map(|(mut wire, draw_depth)| {
plot_owner_aci(scene, &mut wire);
crate::io::pdf_export::PlotWire { wire, draw_depth }
})
.collect::<Vec<_>>();
let (wires, hatches, wipeouts, images, splits) = if paper_space_last {
@@ -3703,10 +3737,11 @@ impl OpenCADStudio {
}
};
let worker_path = path.clone();
let fallback_style = self.dialog_plot_style(&dialog);
self.save_config();
self.close_active_modal();
let work = move || {
crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, None)
crate::io::pdf_export::export_pdf_pages(&pages, &worker_path, fallback_style.as_ref())
.map(|_| {
crate::tf!(
"Exported {} layouts to {}",
@@ -3745,13 +3780,14 @@ impl OpenCADStudio {
}
};
let options = self.plot_print_options(&dialog);
let fallback_style = self.dialog_plot_style(&dialog);
let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all");
self.save_config();
self.close_active_modal();
self.command_line
.push_info(crate::t!("Sending selected layouts to the system printer…").as_ref());
let work = move || {
crate::io::pdf_export::export_pdf_pages(&pages, &temp_path, None)
crate::io::pdf_export::export_pdf_pages(&pages, &temp_path, fallback_style.as_ref())
.and_then(|_| {
crate::io::print_to_printer::print_existing_pdf(&temp_path, &options)
})
@@ -3796,7 +3832,7 @@ impl OpenCADStudio {
self.layout_plot_page_for(&self.plot_dialog.area)
}
fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput {
pub(in crate::app) fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput {
let i = self.active_tab;
let scene = &self.tabs[i].scene;
let paper_space = scene.current_layout != "Model";
@@ -4945,7 +4981,7 @@ impl OpenCADStudio {
}
/// Load a `PlotSettings` into the dialog editor fields.
fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) {
pub(in crate::app) fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) {
use crate::io::paper_catalog::PaperUnits;
use acadrust::objects::{PlotType, ShadePlotMode, ShadePlotResolutionLevel};
self.plot_setup_template = Some(ps.clone());
@@ -5303,15 +5339,15 @@ impl OpenCADStudio {
.cloned()
}
fn window_plot_job(&self) -> Option<PdfPageInput> {
pub(in crate::app) fn window_plot_job(&self) -> Option<PdfPageInput> {
self.area_plot_job(self.plot_window?)
}
fn display_plot_job(&self) -> Option<PdfPageInput> {
pub(in crate::app) fn display_plot_job(&self) -> Option<PdfPageInput> {
self.area_plot_job(self.display_plot_window()?)
}
fn limits_plot_job(&self) -> Option<PdfPageInput> {
pub(in crate::app) fn limits_plot_job(&self) -> Option<PdfPageInput> {
let (min, max) = self.tabs[self.active_tab].scene.current_drawing_limits()?;
self.area_plot_job((min.x, min.y, max.x, max.y))
}
@@ -5338,7 +5374,7 @@ impl OpenCADStudio {
))
}
fn extents_plot_job(&self) -> Option<PdfPageInput> {
pub(in crate::app) fn extents_plot_job(&self) -> Option<PdfPageInput> {
let scene = &self.tabs[self.active_tab].scene;
if scene.current_layout == "Model" {
let (min, max) = scene.model_space_extents()?;
+34 -2
View File
@@ -97,7 +97,7 @@ mod dynamic;
mod file;
mod page_setup_import;
mod style;
mod util;
pub(in crate::app) mod util;
mod viewport;
mod viewport_snap;
@@ -404,10 +404,13 @@ impl OpenCADStudio {
self.finish_all_pending_history();
// After every message, mirror the active command step's prompt so
// its history line stays pinned (non-fading) until the step changes.
// A pending client pick (`user_select` / `getpoint`) pins its labeled
// request line the same way while no command owns the prompt.
let prompt = self.tabs[self.active_tab]
.active_cmd
.as_ref()
.map(|c| c.prompt());
.map(|c| c.prompt())
.or_else(|| self.pending_pick_label());
self.command_line.set_step_prompt(prompt);
// Mirror the step's clickable options so they render as buttons (#304).
let opts = self.tabs[self.active_tab]
@@ -528,7 +531,36 @@ impl OpenCADStudio {
Task::none()
}
Message::ControlRequest(envelope) => {
// Mirror the automation trail into the message list so the
// person at the screen can review what clients asked and
// where it failed — the JSON reply alone reaches only the
// caller. High-frequency polls are traffic, not activity.
let op = envelope.request["op"].as_str().unwrap_or("").to_owned();
let (response, task) = self.control_request(envelope.request);
if !matches!(
op.as_str(),
"" | "state" | "hello" | "operation" | "events" | "capabilities"
) {
if response["ok"] == false {
let code = response["code"].as_str().unwrap_or("failed");
let detail = response["error"].as_str().unwrap_or("");
self.command_line.push_error(&format!(
"automation {op}: {code} — {detail}"
));
} else if matches!(
response["status"].as_str(),
Some("completed" | "cancelled" | "waiting_input")
) && !matches!(
op.as_str(),
// These already narrate themselves in richer lines.
"user_select" | "getpoint" | "cancel"
) {
self.command_line.push_info(&format!(
"automation {op}: {}",
response["status"].as_str().unwrap_or("")
));
}
}
envelope.reply.send(response);
task
}
+1 -1
View File
@@ -62,7 +62,7 @@ pub(super) fn layout_entry_name(s: &str) -> &str {
s.trim_start_matches('*').trim_end_matches('*')
}
pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) {
pub(in crate::app) fn parse_plot_scale(s: &str) -> (f64, f64) {
if s == "Fit" {
return (1.0, 1.0);
}
+105 -1
View File
@@ -976,6 +976,21 @@ impl OpenCADStudio {
Task::none()
}
/// A client automation pick (`getpoint`/`user_select`) is waiting on tab
/// `i`'s document — the cursor behaves like a command point pick: the snap
/// engine runs on every move and the marker stays until the pick settles.
pub(super) fn client_pick_pending(&self, i: usize) -> bool {
self.control
.get_point
.as_ref()
.is_some_and(|session| session.document_id == self.tabs[i].id)
|| self
.control
.user_select
.as_ref()
.is_some_and(|session| session.document_id == self.tabs[i].id)
}
pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
// A ribbon dropdown is open over the viewport. Its backdrop
// cannot swallow cursor motion — in iced 0.14 mouse_area/opaque
@@ -1900,6 +1915,76 @@ impl OpenCADStudio {
self.tabs[i].last_cursor_world = world;
}
// A pending client pick (`getpoint` or `user_select`) is a command-grade
// pick without a command: run the object-snap engine here too, so the
// snap marker shows while the person aims. `getpoint` answers with the
// snapped point (`on_viewport_left_press` resolves from
// `last_cursor_world`); for `user_select` the marker is aiming feedback
// — the click still selects the entity under the cursor. Inputs mirror
// the command snap path below; OTRACK/polar/axis locks stay command-only.
if self.tabs[i].active_cmd.is_none() && self.client_pick_pending(i)
{
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vp_size.0,
height: vp_size.1,
};
let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let (gx, gy, adaptive) = (self.grid_spacing_x, self.grid_spacing_y, self.grid_adaptive);
let visible_step = |distance: f32, fov_y: f32| {
let (sx, sy) = crate::ui::overlay::compute_grid_steps(
gx, gy, distance, fov_y, bounds, adaptive,
);
sx.max(sy)
};
let (view_rot, eye, grid_spacing) = match &edit_cam {
Some(cam) => (
cam.view_proj_rte(bounds),
cam.eye(),
visible_step(cam.distance, cam.fov_y),
),
None => {
let cam = self.tabs[i].scene.camera.borrow();
(
cam.view_proj_rte(bounds),
cam.eye(),
visible_step(cam.distance, cam.fov_y),
)
}
};
self.snapper.grid_spacing = grid_spacing;
let all_wires =
if let (Some(_), Some(h)) = (&edit_cam, self.tabs[i].scene.active_viewport) {
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
} else {
self.tabs[i].scene.hit_test_wires()
};
let snap_candidates = self.tabs[i].scene.interaction_candidates_near(
all_wires,
cursor_world,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
);
let (go, gr) = self.drafting_grid_basis(i);
let snap_hit = self.snapper.snap(
cursor_world,
p,
&snap_candidates,
view_rot,
eye,
bounds,
go,
gr,
None,
);
self.tabs[i].snap_result = snap_hit;
self.tabs[i].last_cursor_world =
snap_hit.map(|hit| hit.world).unwrap_or(cursor_world);
}
// Rollover highlight: when idle (no active command, no
// drag), defer the pick until the cursor stops. The full
// pick (wires + hatches + block hatches + shaded meshes) is
@@ -2674,7 +2759,10 @@ impl OpenCADStudio {
.scene
.set_command_preview_hidden(&preview_hidden);
self.tabs[i].scene.set_preview_wires(previews);
} else {
} else if !self.client_pick_pending(i) {
// Idle (no command, no pending client pick): the snap marker is
// command-only, so clear it — a pending client pick keeps the
// marker its move-time snap block above computed.
self.tabs[i].snap_result = None;
}
@@ -3123,6 +3211,22 @@ impl OpenCADStudio {
return Task::none();
}
}
// A pending client `getpoint` request turns this click into the
// answer: the snapped world point under the cursor. The pick must
// not change the selection while the person is answering, and only
// the pinned document's viewport may answer.
if self.tabs[i].active_cmd.is_none() {
let answers = self
.control
.get_point
.as_ref()
.is_some_and(|session| session.document_id == self.tabs[i].id);
if answers {
let world = self.tabs[i].last_cursor_world;
self.resolve_get_point(Some([world.x, world.y, world.z]));
return Task::none();
}
}
let (p, vp_size) = {
let sel = self.tabs[i].scene.selection.borrow();
let p = match sel.last_move_pos {