feat(mcp): expose complete drawing records

This commit is contained in:
Hakan Seven committed 2026-09-08 23:59:13 +03:00
1 parent 89572e5ca9
commit 8f574c8388
14 files changed
+1492 -187

No files matched your search

Generated
+2 -2
View File
@@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.5.4"
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=1e7808d719c387a9cf26850683991980bf82667f#1e7808d719c387a9cf26850683991980bf82667f"
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7b4c112bd7895fe9039514c21c5b5aa59eaf2525#7b4c112bd7895fe9039514c21c5b5aa59eaf2525"
dependencies = [
"ahash 0.8.12",
"anyhow",
@@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=304a375be1f73945af24e6e0f5b4664417a9d9e0#304a375be1f73945af24e6e0f5b4664417a9d9e0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8efd43cb2f04577d20c3ee33ce5273bb49affa3d#8efd43cb2f04577d20c3ee33ce5273bb49affa3d"
dependencies = [
"acadrust",
"cavalier_contours",
+2 -2
View File
@@ -31,8 +31,8 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "304a375be1f73945af24e6e0f5b4664417a9d9e0", features = ["acis", "offset"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
+2 -2
View File
@@ -14,8 +14,8 @@ bincode = "1.3"
thiserror = "1"
# Host adapters convert ObjectId to acadrust handles; kernel handles stay local.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"], optional = true }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "304a375be1f73945af24e6e0f5b4664417a9d9e0", features = ["acis", "offset"], optional = true }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"], optional = true }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", features = ["acis", "offset"], optional = true }
ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] }
[build-dependencies]
+2 -2
View File
@@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", optional = true, features = ["serde"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", optional = true, features = ["serde"] }
# Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true }
@@ -38,7 +38,7 @@ serde_json = "1"
serde = { version = "1", features = ["derive"] }
cargo-lock = "11"
# acadrust is scanned at build time to generate the embedded type registry.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] }
[dev-dependencies]
serde_json = "1"
+1 -1
View File
@@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"]
[dependencies]
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] }
bincode = "1.3"
serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1"
+1 -1
View File
@@ -10,4 +10,4 @@ crate-type = ["cdylib"]
[dependencies]
ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "1e7808d719c387a9cf26850683991980bf82667f", features = ["serde"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] }
+47 -3
View File
@@ -13,8 +13,8 @@ The client must start that command over standard input/output. If it asks for th
The server provides four tools:
- `ocs_sessions` finds running editor sessions and opens OpenCADStudio when none exists.
- `ocs_read` reads document state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events, and operation status.
- `ocs_execute` performs one operation or a sequential batch against the real editor.
- `ocs_read` discovers capabilities and reads document state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events, and operation status.
- `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor.
- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window.
The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `ocs_session_id` to the other tools, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape:
@@ -31,6 +31,50 @@ The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `oc
}
```
Call `ocs_read` with `op: "capabilities"` before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The `records.collections` list gives every available database collection, its record count, and whether it can be edited.
`op: "records"` returns every serializable property of entities, non-graphical objects, symbol-table records, the complete header, document metadata, and derived database views. Omitting `collection` returns the same collection manifest; use `collection: "all"` for a paged query across the complete database. Records have a stable collection plus a handle or name, a type, and `properties`. Entity records also include their display and file record type names.
Property filters and projections use RFC 6901 JSON Pointer paths relative to `properties`. Multiple `where` filters are combined with AND. Supported operators are `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `starts_with`, `ends_with`, `in`, `exists`, and `not_exists`:
```json
{
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
"op": "records",
"parameters": {
"collection": "entities",
"type": "Insert",
"where": [
{"path": "/common/layer", "value": "Equipment"},
{"path": "/attributes/0/tag", "value": "COMPANY"}
],
"paths": ["/block_name", "/insert_point", "/attributes"]
}
}
```
Use `set_properties` to replace one or more fields atomically. OCS verifies the record identity, JSON types, optional compare-and-set values, layer locks, document revision, and request ID before committing one undoable edit. Handles, ownership slots, table names, and other database identity fields are read-only; their dedicated editor commands preserve cross-record references.
```json
{
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
"request": {
"op": "set_properties",
"request_id": "attributes-1",
"document_id": 1,
"revision": 12,
"collection": "entities",
"handle": "2A",
"updates": [
{"path": "/attributes/0/value", "expected": "BPH", "value": "Updated"},
{"path": "/insert_point/z", "value": 3.0}
]
}
}
```
The same operation edits entries in `objects`, every symbol table, `header`, and `summary_info`. Collections marked `mutable: false` are derived or storage metadata and remain queryable. Their source records must be changed so OCS can rebuild those views consistently.
To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance.
A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token:
@@ -67,7 +111,7 @@ Use `batch` when the steps are already known. OCS supplies each step with the st
Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed.
`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel:
`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. `detail: "full"` adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel:
```json
{
+4 -1
View File
@@ -52,9 +52,12 @@ def legacy(server: Path) -> None:
names = set(definitions)
assert names == TOOLS, names
execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"]
assert len(execute_request["oneOf"]) == 15
assert len(execute_request["oneOf"]) == 16
assert execute_request["properties"]["steps"]["maxItems"] == 64
assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10"
assert "set_properties" in execute_request["properties"]["op"]["enum"]
read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"]
assert {"collection", "where", "paths"} <= set(read_parameters)
assert execute_request["properties"]["kind"]["enum"] == [
"text", "token", "point", "entity", "structure", "selection", "enter"
]
+215 -81
View File
@@ -18,7 +18,7 @@
use std::io::{BufRead, Write};
use std::path::PathBuf;
use serde_json::{json, Value};
use serde_json::{Value, json};
use super::OpenCADStudio;
@@ -138,6 +138,14 @@ fn v3(v: acadrust::types::Vector3) -> Value {
json!([v.x, v.y, v.z])
}
fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool {
if crate::entities::names::ui_name(entity).eq_ignore_ascii_case(requested) {
return true;
}
let record_name = crate::entities::names::dxf_name(entity);
record_name != "ENTITY" && record_name.eq_ignore_ascii_case(requested)
}
/// One entity as JSON. Summary mode carries identity only, geometry adds the
/// entity's defining values, and full also includes its world bounds.
fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value {
@@ -194,15 +202,30 @@ fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value {
}
E::Insert(ins) => {
map.insert("block".into(), json!(ins.block_name));
map.insert("position".into(), v3(ins.insert_point));
let attributes: serde_json::Map<String, Value> = ins
.attributes
.iter()
.map(|attribute| (attribute.tag.clone(), json!(attribute.value)))
.collect();
map.insert("attributes".into(), Value::Object(attributes));
}
_ => {}
}
if detail == "full" {
let bounds = e.as_entity().bounding_box();
map.insert("bounds".into(), json!({
"min":[bounds.min.x,bounds.min.y,bounds.min.z],
"max":[bounds.max.x,bounds.max.y,bounds.max.z]
}));
map.insert(
"bounds".into(),
json!({
"min":[bounds.min.x,bounds.min.y,bounds.min.z],
"max":[bounds.max.x,bounds.max.y,bounds.max.z]
}),
);
if let Ok(Value::Object(wrapper)) = serde_json::to_value(e) {
if let Some((_, properties)) = wrapper.into_iter().next() {
map.insert("properties".into(), properties);
}
}
}
obj
}
@@ -218,16 +241,24 @@ fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> {
}
fn request_handle(value: &Value) -> Option<acadrust::Handle> {
value.as_str()
.and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok())
value
.as_str()
.and_then(|value| {
let value = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
.unwrap_or(value);
u64::from_str_radix(value, 16).ok()
})
.map(acadrust::Handle::new)
}
fn projected_fields(mut entity: Value, fields: Option<&Vec<Value>>) -> Value {
let Some(fields) = fields else { return entity };
let Some(source) = entity.as_object_mut() else { return entity };
let keep: std::collections::HashSet<&str> =
fields.iter().filter_map(Value::as_str).collect();
let Some(source) = entity.as_object_mut() else {
return entity;
};
let keep: std::collections::HashSet<&str> = fields.iter().filter_map(Value::as_str).collect();
source.retain(|key, _| key == "handle" || keep.contains(key.as_str()));
entity
}
@@ -244,7 +275,9 @@ impl OpenCADStudio {
return err(error);
}
if matches!(response["status"].as_str(), Some("accepted" | "running")) {
return self.control_request(json!({"op":"operation","request_id":id})).0;
return self
.control_request(json!({"op":"operation","request_id":id}))
.0;
}
return response;
}
@@ -316,7 +349,9 @@ impl OpenCADStudio {
let i = self.active_tab;
let before = self.tabs[i].scene.document.entities().count();
let error_revision = self.command_line.error_revision;
if let Err(error) = self.run_headless(&cmd) { return err(error); }
if let Err(error) = self.run_headless(&cmd) {
return err(error);
}
if self.command_line.error_revision != error_revision {
return err(self.command_line.last_error.clone().unwrap_or_default());
}
@@ -331,13 +366,14 @@ impl OpenCADStudio {
}
"entities" => self.entity_summary(),
"query" => self.entity_query(&req),
"records" => self.record_query(&req),
"capabilities" => self.record_capabilities(),
"layers" => {
let i = self.active_tab;
let offset = req["offset"].as_u64().unwrap_or(0) as usize;
let limit = req["limit"].as_u64().unwrap_or(1000).min(10_000) as usize;
let count = self.tabs[i].scene.document.layers.iter().count();
let layers: Vec<Value> = self
.tabs[i]
let layers: Vec<Value> = self.tabs[i]
.scene
.document
.layers
@@ -397,8 +433,14 @@ impl OpenCADStudio {
// By explicit handles (hex, as returned by `query`).
if let Some(arr) = req["handles"].as_array() {
for h in arr.iter().filter_map(|h| h.as_str()) {
if let Ok(v) = u64::from_str_radix(h.trim_start_matches("0x"), 16) {
self.tabs[i].scene.select_entity(acadrust::Handle::new(v), false);
let h = h
.strip_prefix("0x")
.or_else(|| h.strip_prefix("0X"))
.unwrap_or(h);
if let Ok(v) = u64::from_str_radix(h, 16) {
self.tabs[i]
.scene
.select_entity(acadrust::Handle::new(v), false);
}
}
}
@@ -410,11 +452,7 @@ impl OpenCADStudio {
.scene
.document
.entities()
.filter(|e| {
type_filter.is_none_or(|t| {
crate::entities::names::ui_name(e).eq_ignore_ascii_case(t)
})
})
.filter(|e| type_filter.is_none_or(|t| entity_type_matches(e, t)))
.filter(|e| layer_filter.is_none_or(|l| e.common().layer == l))
.map(|e| e.common().handle)
.collect();
@@ -435,8 +473,7 @@ impl OpenCADStudio {
return err("save: no \"path\" and the document has none");
};
#[cfg(not(target_arch = "wasm32"))]
let result =
self.save_tab_synchronously_protected(i, path.clone(), true);
let result = self.save_tab_synchronously_protected(i, path.clone(), true);
#[cfg(target_arch = "wasm32")]
let result = crate::io::save(&self.tabs[i].scene.document, &path)
.map_err(crate::io::SaveFailure::other);
@@ -461,19 +498,30 @@ impl OpenCADStudio {
self.drive_headless_task(task)
}
pub(super) fn drive_headless_task(&mut self, task: iced::Task<super::Message>) -> Result<(), String> {
pub(super) fn drive_headless_task(
&mut self,
task: iced::Task<super::Message>,
) -> Result<(), String> {
use iced::futures::StreamExt;
let mut streams = Vec::new();
if let Some(stream) = iced_runtime::task::into_stream(task) { streams.push(stream); }
if let Some(stream) = iced_runtime::task::into_stream(task) {
streams.push(stream);
}
while let Some(stream) = streams.last_mut() {
match iced::futures::executor::block_on(stream.next()) {
Some(iced_runtime::Action::Output(message)) => {
let next = self.update(message);
if let Some(stream) = iced_runtime::task::into_stream(next) { streams.push(stream); }
if let Some(stream) = iced_runtime::task::into_stream(next) {
streams.push(stream);
}
}
Some(iced_runtime::Action::Widget(_)) | Some(iced_runtime::Action::Tick) | Some(iced_runtime::Action::Reload) => {},
Some(iced_runtime::Action::Widget(_))
| Some(iced_runtime::Action::Tick)
| Some(iced_runtime::Action::Reload) => {}
Some(action) => return Err(format!("GUI runtime required for {action:?}")),
None => { streams.pop(); }
None => {
streams.pop();
}
}
}
self.finish_all_pending_history();
@@ -529,20 +577,36 @@ impl OpenCADStudio {
let layer_filter = req["layer"].as_str();
let handles: Option<std::collections::HashSet<u64>> = req["handles"]
.as_array()
.map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect())
.or_else(|| request_handle(&req["handle"])
.map(|handle| std::iter::once(handle.value()).collect()));
.map(|values| {
values
.iter()
.filter_map(request_handle)
.map(|h| h.value())
.collect()
})
.or_else(|| {
request_handle(&req["handle"])
.map(|handle| std::iter::once(handle.value()).collect())
});
let near = request_point(req, "near");
let contains = request_point(req, "contains_point");
let bounds = req["bounds"].as_array().and_then(|values| {
(values.len() == 4).then(|| Some([
values[0].as_f64()?, values[1].as_f64()?,
values[2].as_f64()?, values[3].as_f64()?,
])).flatten()
(values.len() == 4)
.then(|| {
Some([
values[0].as_f64()?,
values[1].as_f64()?,
values[2].as_f64()?,
values[3].as_f64()?,
])
})
.flatten()
});
if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| {
parsed.len() != req["handles"].as_array().map_or(0, Vec::len)
}) {
if req.get("handles").is_some()
&& handles
.as_ref()
.is_some_and(|parsed| parsed.len() != req["handles"].as_array().map_or(0, Vec::len))
{
return err("query handles contains an invalid hexadecimal handle");
}
if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() {
@@ -554,10 +618,13 @@ impl OpenCADStudio {
if req.get("contains_point").is_some() && contains.is_none() {
return err("query contains_point expects two or three finite coordinates");
}
if req.get("bounds").is_some() && bounds.is_none_or(|bounds| {
!bounds.iter().all(|value| value.is_finite())
|| bounds[0] > bounds[2] || bounds[1] > bounds[3]
}) {
if req.get("bounds").is_some()
&& bounds.is_none_or(|bounds| {
!bounds.iter().all(|value| value.is_finite())
|| bounds[0] > bounds[2]
|| bounds[1] > bounds[3]
})
{
return err("query bounds expects finite [min_x,min_y,max_x,max_y]");
}
let detail = req["detail"].as_str().unwrap_or("geometry");
@@ -569,39 +636,47 @@ impl OpenCADStudio {
let mut matched = Vec::new();
for e in tab.scene.document.entities() {
if handles.as_ref().is_some_and(|handles| {
!handles.contains(&e.common().handle.value())
}) {
if handles
.as_ref()
.is_some_and(|handles| !handles.contains(&e.common().handle.value()))
{
continue;
}
if type_filter.is_some_and(|value| {
!crate::entities::names::ui_name(e).eq_ignore_ascii_case(value)
}) || layer_filter.is_some_and(|value| e.common().layer != value) {
if type_filter.is_some_and(|value| !entity_type_matches(e, value))
|| layer_filter.is_some_and(|value| e.common().layer != value)
{
continue;
}
if let Some(bounds) = bounds {
let entity_bounds = e.as_entity().bounding_box();
if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1]
|| entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3]
if entity_bounds.max.x < bounds[0]
|| entity_bounds.max.y < bounds[1]
|| entity_bounds.min.x > bounds[2]
|| entity_bounds.min.y > bounds[3]
{
continue;
}
}
let curve = (near.is_some() || contains.is_some())
.then(|| crate::entities::curve::entity_curve_xy(e)).flatten();
.then(|| crate::entities::curve::entity_curve_xy(e))
.flatten();
if let Some(point) = contains {
let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else {
continue;
};
if !cadkernel::geom2d::contains(
std::slice::from_ref(curve), point,
std::slice::from_ref(curve),
point,
cadkernel::geom2d::Tolerance::default(),
) {
continue;
}
}
let nearest = near.and_then(|point| curve.as_ref()
.map(|curve| cadkernel::geom2d::closest_point(curve, point)));
let nearest = near.and_then(|point| {
curve
.as_ref()
.map(|curve| cadkernel::geom2d::closest_point(curve, point))
});
if near.is_some() && nearest.is_none() {
continue;
}
@@ -609,20 +684,29 @@ impl OpenCADStudio {
if let Some(nearest) = nearest {
let object = entity.as_object_mut().expect("entity JSON object");
object.insert("distance".into(), json!(nearest.distance));
object.insert("closest_point".into(),
json!([nearest.point[0], nearest.point[1]]));
object.insert(
"closest_point".into(),
json!([nearest.point[0], nearest.point[1]]),
);
object.insert("parameter".into(), json!(nearest.t));
}
matched.push((nearest.map(|nearest| nearest.distance), entity));
}
if near.is_some() {
matched.sort_by(|left, right| left.0.partial_cmp(&right.0)
.unwrap_or(std::cmp::Ordering::Equal));
matched.sort_by(|left, right| {
left.0
.partial_cmp(&right.0)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
let count = matched.len();
let fields = req["fields"].as_array();
let entities: Vec<Value> = matched.into_iter().skip(offset).take(limit)
.map(|(_, entity)| projected_fields(entity, fields)).collect();
let entities: Vec<Value> = matched
.into_iter()
.skip(offset)
.take(limit)
.map(|(_, entity)| projected_fields(entity, fields))
.collect();
json!({
"ok": true,
"document_id":tab.id,
@@ -657,7 +741,10 @@ mod tests {
fn layout_notice_skips_grid_camera_and_scene_builds() {
let mut app = OpenCADStudio::new_for_test();
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true);
assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"],
true
);
let i = app.active_tab;
let scene = &mut app.tabs[i].scene;
scene.document.add_layout("Review").unwrap();
@@ -738,6 +825,44 @@ mod tests {
assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false);
}
#[test]
fn block_reference_query_exposes_instance_attributes() {
use acadrust::entities::{AttributeEntity, EntityType, Insert};
use acadrust::types::Vector3;
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0));
insert.common.layer = "Equipment".to_string();
insert
.attributes
.push(AttributeEntity::simple("COMPANY", "BPH"));
insert
.attributes
.push(AttributeEntity::simple("STATUS", "ACTIVE"));
app.tabs[i].scene.add_entity(EntityType::Insert(insert));
let result = app
.automation_op(r#"{"op":"query","type":"Insert","layer":"Equipment","detail":"full"}"#);
assert_eq!(result["count"], 1);
assert_eq!(result["entities"][0]["type"], "Block Reference");
assert_eq!(result["entities"][0]["block"], "A_CPT");
assert_eq!(
result["entities"][0]["position"],
serde_json::json!([12.0, 34.0, 5.0])
);
assert_eq!(result["entities"][0]["attributes"]["COMPANY"], "BPH");
assert_eq!(result["entities"][0]["attributes"]["STATUS"], "ACTIVE");
assert_eq!(
result["entities"][0]["properties"]["attributes"][0]["value"],
"BPH"
);
let selected = app.automation_op(r#"{"op":"select","type":"Insert"}"#);
assert_eq!(selected["selected"], 1);
}
#[test]
fn ucs_interactive_inline_args() {
// `UCS Z 90` must drive the interactive UCS command step-by-step (option
@@ -789,9 +914,7 @@ mod tests {
fn tilted_ucs_places_planar_entities_with_the_plane_normal() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(
r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#,
);
app.automation_op(r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 2,3 1"}"#);
let circle = app.tabs[app.active_tab]
@@ -831,7 +954,10 @@ mod tests {
h.linetype_scale
);
// No command should be left dangling.
assert!(app.tabs[i].active_cmd.is_none(), "command must have finished");
assert!(
app.tabs[i].active_cmd.is_none(),
"command must have finished"
);
}
#[test]
@@ -993,7 +1119,9 @@ mod tests {
(20.0, 300.0),
]
};
let _ = app.update(Message::ViewportMove(iced::Point::new(path[0].0, path[0].1)));
let _ = app.update(Message::ViewportMove(iced::Point::new(
path[0].0, path[0].1,
)));
let _ = app.update(Message::ViewportLeftPress);
std::thread::sleep(std::time::Duration::from_millis(180));
for &(x, y) in &path {
@@ -1036,7 +1164,10 @@ mod tests {
// Two-step: bare command then the value, like typing 1 + Enter
// (feed_active_cmd is the same path the GUI submit offers first).
let _ = app.run_command_line("PICKDRAG");
assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open");
assert!(
app.tabs[app.active_tab].active_cmd.is_some(),
"prompt must open"
);
let _ = app.feed_active_cmd("1");
assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch");
}
@@ -1074,10 +1205,7 @@ mod tests {
let _ = app.run_command_line("MATCHPROP");
assert!(app.tabs[i].active_cmd.is_some(), "MATCHPROP must start");
let _ = app.feed_command(StepInput::EntityPick(src_h, glam::DVec3::ZERO));
let _ = app.feed_command(StepInput::SelectionComplete(vec![
dst_text_h,
dst_mtext_h,
]));
let _ = app.feed_command(StepInput::SelectionComplete(vec![dst_text_h, dst_mtext_h]));
let doc = &app.tabs[i].scene.document;
match doc.get_entity(dst_text_h) {
@@ -1129,7 +1257,10 @@ mod tests {
app.opening.is_none(),
"an already-open drawing must not start a load"
);
assert!(app.pending_opens.is_empty(), "and must not queue one either");
assert!(
app.pending_opens.is_empty(),
"and must not queue one either"
);
// The same file spelled differently (a `..` hop) is still the same file.
let indirect = canon.parent().unwrap().join("..").join(
@@ -1223,8 +1354,7 @@ mod tests {
assert_eq!(saved["ok"], true, "{label}: {}", saved["error"]);
let saved_again = app.automation_op(r#"{"op":"save"}"#);
assert_eq!(
saved_again["ok"],
true,
saved_again["ok"], true,
"normal save: {}",
saved_again["error"]
);
@@ -1242,10 +1372,8 @@ mod tests {
#[test]
fn save_then_open_round_trips() {
let mut app = OpenCADStudio::new_for_test();
let path = std::env::temp_dir().join(format!(
"ocs_automation_test_{}.dxf",
std::process::id()
));
let path =
std::env::temp_dir().join(format!("ocs_automation_test_{}.dxf", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().replace('\\', "\\\\");
app.automation_op(r#"{"op":"new"}"#);
@@ -1275,7 +1403,7 @@ mod tests {
app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0);
app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0);
}
// Start PLINE
let _ = app.update(Message::CommandInput("PLINE".to_string()));
let _ = app.update(Message::CommandSubmit);
@@ -1295,8 +1423,14 @@ mod tests {
// Switch to arc: Option 1 - CommandOptionPick("A")
let _ = app.update(Message::CommandOptionPick("A".to_string()));
let _ = app.view(wid);
println!("ENTITIES: {}", app.tabs[0].scene.document.entities().count());
println!("CMD: {:?}", app.tabs[0].active_cmd.as_ref().map(|c| c.name()));
println!(
"ENTITIES: {}",
app.tabs[0].scene.document.entities().count()
);
println!(
"CMD: {:?}",
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
);
// Move mouse!
let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0)));
+27 -12
View File
@@ -2,7 +2,7 @@
use super::{Message, OpenCADStudio};
use crate::command::{InputKind, StepInput};
use iced::Task;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::{collections::VecDeque, sync::OnceLock};
#[cfg(not(target_arch = "wasm32"))]
mod transport;
@@ -361,7 +361,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","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","records","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
})
}
@@ -377,6 +377,8 @@ impl OpenCADStudio {
| "properties"
| "measure"
| "query"
| "records"
| "capabilities"
| "entities"
| "layers"
| "header"
@@ -449,7 +451,9 @@ impl OpenCADStudio {
return (
failure(
"unknown_command",
format!("Unknown command {requested}; call commands without name to list commands"),
format!(
"Unknown command {requested}; call commands without name to list commands"
),
),
Task::none(),
);
@@ -511,7 +515,7 @@ impl OpenCADStudio {
"Supply a unique request_id (maximum 128 bytes)",
),
Task::none(),
)
);
}
};
if let Some((_, old, result)) = self.control.completed.iter().find(|(i, _, _)| i == &id)
@@ -773,6 +777,7 @@ impl OpenCADStudio {
Task::none()
}
"property" => self.control_set_property(req)?,
"set_properties" => self.control_set_record_properties(req)?,
"action" => self.control_ui_action(req)?,
#[cfg(not(target_arch = "wasm32"))]
"save" => {
@@ -814,6 +819,12 @@ impl OpenCADStudio {
})
}
pub(super) fn set_control_result(&mut self, value: Value) {
if let Some(operation) = self.control.pending.as_mut() {
operation.result = value;
}
}
pub(super) fn control_track(&mut self, task: Task<Message>) -> Task<Message> {
if task.units() == 0 {
return task;
@@ -1101,10 +1112,12 @@ mod tests {
);
let started = request(&mut app, json!({"op":"start","cmd":"LINE"}));
assert_eq!(started["status"], "waiting_input");
assert!(started["state"]["command"]["accepts"]
.as_array()
.unwrap()
.contains(&json!("point")));
assert!(
started["state"]["command"]["accepts"]
.as_array()
.unwrap()
.contains(&json!("point"))
);
assert_eq!(
started["state"]["command"]["input_example"]["kind"],
"point"
@@ -1129,10 +1142,12 @@ mod tests {
);
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1);
request(&mut app, json!({"op":"select","type":"LINE"}));
assert!(!app.control_properties()["sections"]
.as_array()
.unwrap()
.is_empty());
assert!(
!app.control_properties()["sections"]
.as_array()
.unwrap()
.is_empty()
);
let changed = request(&mut app, json!({"op":"property","field":"color","value":1}));
assert_eq!(changed["ok"], true, "{changed}");
assert_eq!(
+67 -58
View File
@@ -9,21 +9,22 @@ pub(crate) mod config;
pub use automation::{export_headless, serve};
mod command_driver;
pub(crate) mod commands;
#[cfg(not(target_arch = "wasm32"))]
mod doc_api;
mod document;
pub(crate) mod expr_eval;
mod find_replace;
pub(crate) mod helpers;
mod history;
#[cfg(not(target_arch = "wasm32"))]
mod doc_api;
mod layers;
mod model_ops;
mod mtext_editor;
#[cfg(not(target_arch = "wasm32"))]
pub mod plugin_host;
mod properties;
mod presspull_ops;
mod properties;
mod recent;
mod record_api;
pub(crate) mod settings;
mod shortcuts;
mod startup;
@@ -225,7 +226,10 @@ impl From<&QSelectState> for QSelectSettings {
Self {
scope: state.scope,
type_filter: state.type_filter.clone(),
property_field: state.property.as_ref().map(|property| property.field.clone()),
property_field: state
.property
.as_ref()
.map(|property| property.field.clone()),
operator: state.operator,
value: state.value.clone(),
mode: state.mode,
@@ -256,12 +260,12 @@ pub(crate) enum FindMatchKey {
}
use crate::snap::Snapper;
use crate::ui::{CommandLine, Ribbon, StatusBar};
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::CadDocument;
use acadrust::types::{Color as AcadColor, LineWeight};
use iced::time::Instant;
use iced::window;
use iced::{mouse, Point, Task, Theme};
use iced::{Point, Task, Theme, mouse};
use std::sync::Arc;
pub(super) const POLY_START_DELAY_MS: u128 = 150;
@@ -318,9 +322,7 @@ struct AddSelectedRestore {
}
/// Which Start-page section a narrow (tabbed) Start page is showing.
#[derive(
Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize,
)]
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum StartSection {
Recent,
Videos,
@@ -780,15 +782,13 @@ pub(super) struct OpenCADStudio {
/// Live filter for installed and available plugin cards.
plugin_search_input: String,
/// Installable release tags fetched per linked repo (for the dropdown).
repo_release_tags:
rustc_hash::FxHashMap<String, Vec<crate::plugin::external::ReleaseInfo>>,
repo_release_tags: rustc_hash::FxHashMap<String, Vec<crate::plugin::external::ReleaseInfo>>,
/// The release tag currently selected per linked repo.
repo_selected_tag: rustc_hash::FxHashMap<String, String>,
/// Repository currently shown in the Plugin Manager detail pane.
selected_plugin_repo: Option<String>,
/// Parsed GitHub README content or the last fetch error, cached per repo.
plugin_readmes:
rustc_hash::FxHashMap<String, Result<iced::widget::markdown::Content, String>>,
plugin_readmes: rustc_hash::FxHashMap<String, Result<iced::widget::markdown::Content, String>>,
/// README requests in flight, used to render a deterministic loading state.
plugin_readme_loading: rustc_hash::FxHashSet<String>,
/// Last marketplace status / error line shown in the Plugin Manager.
@@ -1098,8 +1098,7 @@ pub(super) struct OpenCADStudio {
save_job_serial: u64,
/// Destination leases held while Save As workers are active.
#[cfg(not(target_arch = "wasm32"))]
pending_save_leases:
std::collections::HashMap<u64, crate::io::edit_lock::EditLease>,
pending_save_leases: std::collections::HashMap<u64, crate::io::edit_lock::EditLease>,
/// Locked-file failure currently shown in the recovery dialog.
#[cfg(not(target_arch = "wasm32"))]
pending_save_failure: Option<PendingSaveFailure>,
@@ -1448,7 +1447,8 @@ impl ClipboardDeps {
if !objects.is_empty() {
let mut annotation_scales = Vec::new();
for (_, object) in &objects {
let acadrust::objects::ObjectType::ObjectContextData(context) = object else {
let acadrust::objects::ObjectType::ObjectContextData(context) = object
else {
continue;
};
if annotation_scales
@@ -1618,9 +1618,7 @@ impl ClipboardDeps {
fn snapshot_block(doc: &acadrust::CadDocument, name: &str) -> Option<BlockDef> {
use acadrust::EntityType;
let br = doc.block_records.get(name)?;
if name.starts_with("*Model_Space")
|| name.starts_with("*Paper_Space")
|| br.flags.is_xref
if name.starts_with("*Model_Space") || name.starts_with("*Paper_Space") || br.flags.is_xref
{
return None;
}
@@ -1835,10 +1833,7 @@ pub enum Message {
/// Register a font already held by the shared web store with the UI renderer.
ApplyWebFont(crate::scene::text::web_font::Script),
/// Completion of the UI renderer's runtime font registration.
WebUiFontLoaded(
crate::scene::text::web_font::Script,
Result<(), String>,
),
WebUiFontLoaded(crate::scene::text::web_font::Script, Result<(), String>),
/// Ctrl+V. Routed by `update` into an open text editor, the drawing-object
/// clipboard, or the system text clipboard.
PasteShortcut,
@@ -1906,10 +1901,13 @@ pub enum Message {
WebFileOpened(u64, crate::io::WebOpenOutcome),
#[cfg(target_arch = "wasm32")]
WebFileCached(u64, crate::io::WebOpenOutcome, Result<(), String>),
FileOpened(u64, Result<
(String, PathBuf, CadDocument, crate::scene::DerivedCaches),
crate::io::OpenLoadError,
>),
FileOpened(
u64,
Result<
(String, PathBuf, CadDocument, crate::scene::DerivedCaches),
crate::io::OpenLoadError,
>,
),
RecoveryClose,
RecoveryAttempt,
RecoveryDecline,
@@ -2150,9 +2148,16 @@ pub enum Message {
},
/// A widget captured Up/Down; resolve it only if the command input owns
/// keyboard focus.
CommandLineArrowProbe { direction: ArrowKey, extend_selection: bool },
CommandLineArrowProbe {
direction: ArrowKey,
extend_selection: bool,
},
/// Result of the command-input focus query for a captured Up/Down key.
CommandLineArrowResolved { direction: ArrowKey, focused: bool, extend_selection: bool },
CommandLineArrowResolved {
direction: ArrowKey,
focused: bool,
extend_selection: bool,
},
/// Toggle the dropdown listing the full command-line history.
CommandHistoryToggle,
/// Grab/move/release the expanded history panel's top resize edge.
@@ -2303,9 +2308,7 @@ pub enum Message {
epoch: u64,
source: usize,
wires: std::sync::Weak<Vec<crate::scene::WireModel>>,
index: std::sync::Arc<
crate::scene::pick::interaction_index::InteractionIndex,
>,
index: std::sync::Arc<crate::scene::pick::interaction_index::InteractionIndex>,
build_ms: f64,
},
WindowResized(f32, f32),
@@ -2559,7 +2562,10 @@ pub enum Message {
PropEditChoiceToggle,
/// User is typing in a block-attribute value field (live buffer update),
/// keyed by the attribute tag.
PropAttrInput { tag: String, value: String },
PropAttrInput {
tag: String,
value: String,
},
/// User committed a block-attribute value edit (Enter pressed).
PropAttrCommit(String),
/// Reports the currently keyboard-focused widget (if any) after a
@@ -2585,7 +2591,10 @@ pub enum Message {
PropColorFieldToggle(String),
/// User picked a colour for a generic per-field colour row (hatch gradient
/// `Color 1` / `Color 2`), routed by the field name.
PropColorFieldChanged { field: String, color: AcadColor },
PropColorFieldChanged {
field: String,
color: AcadColor,
},
/// Collapse the inline color picker dropdown. Fired when another
/// properties-panel dropdown (a combo_box) opens, so at most one panel
/// dropdown is open at a time and they can't overlap. (#235)
@@ -2697,7 +2706,10 @@ pub enum Message {
/// Select the row the Text Options / Properties tabs act on.
AttrEditorSelect(usize),
/// Live edit of the attribute value at row `idx` (Attribute tab).
AttrEditorInput { idx: usize, value: String },
AttrEditorInput {
idx: usize,
value: String,
},
// Text Options — all act on the selected row:
AttrEditorTextStyle(String),
AttrEditorJustify(String),
@@ -2752,9 +2764,7 @@ pub enum Message {
/// GitHub Discussions fetched at boot for the Start page.
DiscussionsFetched(Result<Vec<crate::discussions::DiscussionEntry>, String>),
/// Recent-file DWG preview thumbnails decoded on a background thread.
RecentThumbsLoaded(
Vec<(std::path::PathBuf, Option<iced::widget::image::Handle>)>,
),
RecentThumbsLoaded(Vec<(std::path::PathBuf, Option<iced::widget::image::Handle>)>),
/// Installable releases and manifest API versions fetched for `owner/repo`.
PluginReleasesFetched(
String,
@@ -2795,7 +2805,10 @@ pub enum Message {
InvertSelection,
/// Keyboard modifier state changed — tracks whether Shift is held so the
/// pick path can do subtractive (Shift+click) selection.
SetModifiers { shift: bool, ctrl: bool },
SetModifiers {
shift: bool,
ctrl: bool,
},
// ── In-place MText editor ───────────────────────────────────────────
/// Text-area edit action from the multi-line editor widget.
MTextEdit(iced::widget::text_editor::Action),
@@ -3265,10 +3278,7 @@ impl OpenCADStudio {
.iter()
.filter_map(|v| {
let bytes = v.thumb.clone()?;
Some((
v.id.clone(),
iced::widget::image::Handle::from_bytes(bytes),
))
Some((v.id.clone(), iced::widget::image::Handle::from_bytes(bytes)))
})
.collect();
self.videos = videos;
@@ -3782,8 +3792,7 @@ impl OpenCADStudio {
let state = Self::new();
let (id, open_task) = window::open(window::Settings {
maximized: true,
icon: build_window_icon()
.and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()),
icon: build_window_icon().and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()),
exit_on_close_request: false,
// A Wayland compositor has no StartupWMClass to go on: it resolves a
// window's dock icon by matching the window's app_id against the
@@ -3919,24 +3928,17 @@ impl OpenCADStudio {
let mut s = Self::new();
s.queue_startup_prompts();
let focus = s.focus_cmd_input();
let primary_font = crate::scene::text::web_font::preload_language(
&crate::i18n::active_language_tag(),
);
let primary_font =
crate::scene::text::web_font::preload_language(&crate::i18n::active_language_tag());
let fonts = Task::batch([
Task::done(Message::PollWebFonts),
Task::done(Message::ApplyWebFont(primary_font)),
]);
// Web can't reach the Patreon API directly (CORS); fetch the CI-built
// supporters.json served on the same origin instead.
let patrons = Task::perform(
crate::patreon::fetch_patrons_web(),
Message::PatronsFetched,
);
let patrons = Task::perform(crate::patreon::fetch_patrons_web(), Message::PatronsFetched);
s.videos_loading = true;
let videos = Task::perform(
crate::videos::fetch_playlist_web(),
Message::VideosFetched,
);
let videos = Task::perform(crate::videos::fetch_playlist_web(), Message::VideosFetched);
s.discussions_loading = true;
let discussions = Task::perform(
crate::discussions::fetch_discussions_web(),
@@ -3969,7 +3971,12 @@ pub fn run() -> iced::Result {
if let Some(tab) = state.tabs.get(state.active_tab) {
let dot = if tab.dirty { "● " } else { "" };
let name = tab.tab_display_name();
format!("{}Open CAD Studio {} - {}", dot, env!("OCS_APP_VERSION"), name)
format!(
"{}Open CAD Studio {} - {}",
dot,
env!("OCS_APP_VERSION"),
name
)
} else {
concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()
}
@@ -4003,7 +4010,9 @@ pub fn run_web() -> iced::Result {
OpenCADStudio::view_main,
)
.subscription(OpenCADStudio::subscription)
.title(|_state: &OpenCADStudio| concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string())
.title(|_state: &OpenCADStudio| {
concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()
})
.theme(|state: &OpenCADStudio| state.active_theme.clone())
.backend(iced::Backend::Hardware(iced::backend::Api::OpenGL))
.font(iced_aw::ICED_AW_FONT_BYTES)
File diff suppressed because it is too large. Load diff
+9 -8
View File
@@ -1,25 +1,26 @@
#![allow(non_snake_case)]
#![recursion_limit = "256"]
pub mod app;
pub mod config;
#[cfg(not(target_arch = "wasm32"))]
pub mod cli;
pub mod command;
pub mod config;
pub mod discussions;
pub mod entities;
pub mod i18n;
pub mod io;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod network;
#[cfg(not(target_arch = "wasm32"))]
pub mod mcp;
pub mod modules;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod network;
pub mod par;
pub mod patreon;
pub mod discussions;
pub mod videos;
pub mod plugin;
pub mod perf;
pub mod plugin;
pub mod scene;
pub mod snap;
pub mod ui;
pub mod par;
pub mod sys;
pub mod ui;
pub mod videos;
+86 -14
View File
@@ -22,11 +22,13 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Preserve document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path.";
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path.";
const READ_OPS: &[&str] = &[
"state",
"hello",
"query",
"records",
"capabilities",
"entities",
"layers",
"header",
@@ -38,12 +40,39 @@ const READ_OPS: &[&str] = &[
"operation",
];
const EXECUTE_OPS: &[&str] = &[
"new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select",
"property", "action", "save", "stop", "batch",
"new",
"open",
"activate",
"run",
"start",
"input",
"cancel",
"undo",
"redo",
"select",
"property",
"set_properties",
"action",
"save",
"stop",
"batch",
];
const BATCH_STEP_OPS: &[&str] = &[
"new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select",
"property", "action", "save", "stop",
"new",
"open",
"activate",
"run",
"start",
"input",
"cancel",
"undo",
"redo",
"select",
"property",
"set_properties",
"action",
"save",
"stop",
];
const MAX_BATCH_STEPS: usize = 64;
@@ -671,6 +700,15 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> {
r#"{"op":"property","field":"color","value":1}"#,
)
}
"set_properties"
if request["collection"].as_str().is_none_or(str::is_empty)
|| request["updates"].as_array().is_none_or(Vec::is_empty) =>
{
missing(
"collection or updates",
r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#,
)
}
"action" => match request["name"].as_str() {
Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()),
Some(name) => Err(format!(
@@ -833,6 +871,8 @@ fn batch_step_schema() -> Value {
"clear":{"type":"boolean"},
"field":{"type":"string"},
"value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]},
"collection":{"type":"string"},
"updates":{"type":"array","minItems":1,"items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{}},"required":["path","value"],"additionalProperties":false}},
"name":{"type":"string","enum":crate::app::automation_action_names()}
},
"required":["op"],
@@ -873,6 +913,8 @@ fn execute_request_schema() -> Value {
"clear":{"type":"boolean","description":"Clear the current selection before applying select filters."},
"field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."},
"value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]},
"collection":{"type":"string","description":"Record collection returned by ocs_read records."},
"updates":{"type":"array","minItems":1,"description":"Atomic, type-checked property replacements. Paths are RFC 6901 JSON Pointers relative to record.properties.","items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{"description":"Optional compare-and-set value."}},"required":["path","value"],"additionalProperties":false}},
"name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."},
"steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()}
},
@@ -898,6 +940,7 @@ fn execute_request_schema() -> Value {
{"properties":{"op":{"const":"redo"}}},
{"properties":{"op":{"const":"select"}}},
{"properties":{"op":{"const":"property"}},"required":["field","value"]},
{"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]},
{"properties":{"op":{"const":"action"}},"required":["name"]},
{"properties":{"op":{"const":"save"}}},
{"properties":{"op":{"const":"stop"}}},
@@ -943,8 +986,8 @@ fn tool_definitions() -> Value {
},
{
"name":"ocs_read",
"description":"Read state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"description":"Discover capabilities or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all, or omit to discover collections."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"outputSchema":read_output_schema(),
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
@@ -1272,13 +1315,17 @@ mod tests {
);
assert_eq!(tools[0]["annotations"]["readOnlyHint"], false);
for tool in [&tools[1], &tools[2], &tools[3]] {
assert!(tool["inputSchema"]["properties"]
.get("session_id")
.is_none());
assert!(tool["inputSchema"]["required"]
.as_array()
.unwrap()
.contains(&json!("ocs_session_id")));
assert!(
tool["inputSchema"]["properties"]
.get("session_id")
.is_none()
);
assert!(
tool["inputSchema"]["required"]
.as_array()
.unwrap()
.contains(&json!("ocs_session_id"))
);
}
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["required"],
@@ -1307,6 +1354,31 @@ mod tests {
tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0],
"LINE 0,0 10,10"
);
assert!(READ_OPS.contains(&"capabilities"));
assert!(READ_OPS.contains(&"records"));
assert!(EXECUTE_OPS.contains(&"set_properties"));
assert_eq!(
tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"]
["op"]["enum"],
json!([
"eq",
"ne",
"lt",
"lte",
"gt",
"gte",
"contains",
"starts_with",
"ends_with",
"in",
"exists",
"not_exists"
])
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["properties"]["updates"]["items"]["required"],
json!(["path", "value"])
);
assert!(tools[0].get("outputSchema").is_some());
assert!(tools[1].get("outputSchema").is_some());
assert!(tools[2].get("outputSchema").is_some());