feat(mcp): describe complete CAD record schemas

This commit is contained in:
Hakan Seven committed 2026-09-09 01:01:44 +03:00
1 parent 8f574c8388
commit 4ddd19f6e8
10 files changed
+1329 -32

No files matched your search

+1
View File
@@ -367,6 +367,7 @@ impl OpenCADStudio {
"entities" => self.entity_summary(),
"query" => self.entity_query(&req),
"records" => self.record_query(&req),
"record_schema" => self.record_schema(&req),
"capabilities" => self.record_capabilities(),
"layers" => {
let i = self.active_tab;
+2 -1
View File
@@ -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","records","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","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
})
}
@@ -378,6 +378,7 @@ impl OpenCADStudio {
| "measure"
| "query"
| "records"
| "record_schema"
| "capabilities"
| "entities"
| "layers"
+856 -8
View File
@@ -2,6 +2,8 @@ use acadrust::tables::{Table, TableEntry};
use iced::Task;
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Map, Value, json};
use std::collections::VecDeque;
use std::sync::OnceLock;
use super::{Message, OpenCADStudio};
@@ -36,6 +38,135 @@ const COLLECTIONS: &[(&str, bool)] = &[
("document", false),
];
fn collection_root_type(collection: &str) -> Option<&'static str> {
Some(match collection {
"entities" => "EntityType",
"objects" => "ObjectType",
"layers" => "Layer",
"line_types" => "LineType",
"text_styles" => "TextStyle",
"block_records" => "BlockRecord",
"dim_styles" => "DimStyle",
"app_ids" => "AppId",
"views" => "View",
"vports" => "VPort",
"ucss" => "Ucs",
"vx_table" => "VxTableRecord",
"header" => "HeaderVariables",
"summary_info" => "SummaryInfo",
"classes" => "DxfClass",
"block_visibility" => "BlockVisibilityParameter",
"context_scales"
| "block_representations"
| "vx_control_entries"
| "section_view_representations"
| "view_rep_references" => "Handle",
"fields" => "FieldDef",
"dgn_line_style_definitions" => "DgnLsDefinition",
"dgn_line_style_components" => "DgnLsComponent",
"notifications" => "NotificationCollection",
"preview" => "Preview",
"section_view_style" => "EntitySectionViewStyle",
// This aggregate is assembled by OCS and has a live inferred schema.
"document" => "Value",
_ => return None,
})
}
fn collection_is_mutable(collection: &str) -> bool {
COLLECTIONS
.iter()
.find(|(name, _)| *name == collection)
.is_some_and(|(_, mutable)| *mutable)
}
fn collection_record_name(collection: &str) -> Option<&'static str> {
Some(match collection {
"classes" => "Class",
"fields" => "Field",
"dgn_line_style_definitions" => "LineStyleDefinition",
"dgn_line_style_components" => "LineStyleComponent",
"section_view_style" => "SectionViewStyle",
"view_rep_references" => "HandleReferences",
"notifications" => "Notifications",
"document" => "Document",
_ => collection_root_type(collection)?,
})
}
fn identity_paths(collection: &str) -> &'static [&'static str] {
match collection {
"entities" => &["/common/handle", "/common/owner_handle"],
"objects" => &[
"/handle",
"/owner",
"/common/handle",
"/common/owner_handle",
],
"header" => &["/handle_seed"],
"summary_info" => &[],
"layers" | "line_types" | "text_styles" | "block_records" | "dim_styles" | "app_ids"
| "views" | "vports" | "ucss" | "vx_table" => &["/handle", "/name"],
_ => &[],
}
}
fn path_changes_identity(collection: &str, path: &str) -> bool {
path_is_identity_field(collection, path)
|| identity_paths(collection).iter().any(|identity| {
path == *identity
|| identity
.strip_prefix(path)
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
fn path_is_identity_field(collection: &str, path: &str) -> bool {
match collection {
"entities" => path.ends_with("/common/handle") || path.ends_with("/common/owner_handle"),
"objects" => {
matches!(path, "/handle" | "/owner")
|| path.ends_with("/common/handle")
|| path.ends_with("/common/owner_handle")
}
_ => identity_paths(collection).contains(&path),
}
}
fn collect_identity_values(value: &Value, collection: &str, path: &str, output: &mut Vec<Value>) {
if path_is_identity_field(collection, path) {
output.push(json!({"path":path,"value":value}));
}
match value {
Value::Object(object) => {
for (name, value) in object {
collect_identity_values(
value,
collection,
&format!("{path}/{}", json_pointer_name(name)),
output,
);
}
}
Value::Array(values) => {
for (index, value) in values.iter().enumerate() {
collect_identity_values(value, collection, &format!("{path}/{index}"), output);
}
}
_ => {}
}
}
fn registry_types() -> &'static Map<String, Value> {
static REGISTRY: OnceLock<Value> = OnceLock::new();
REGISTRY.get_or_init(|| {
serde_json::from_str(ocs_plugin_api::get_embedded_type_registry_json())
.expect("embedded type registry")
})["types"]
.as_object()
.expect("type registry object")
}
fn failure(code: &str, message: impl Into<String>) -> Value {
json!({"ok":false,"status":"failed","code":code,"error":message.into()})
}
@@ -504,6 +635,467 @@ fn project_paths(mut record: Value, paths: Option<&Vec<Value>>) -> Result<Value,
Ok(record)
}
fn resolved_type_name(requested: &str) -> Option<String> {
let types = registry_types();
types
.get_key_value(requested)
.or_else(|| {
types
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(requested))
})
.map(|(name, _)| name.clone())
}
fn collect_type_references(value: &Value, pending: &mut VecDeque<String>) {
match value {
Value::Object(object) => {
for (key, value) in object {
if key == "type_id" {
if let Some(type_id) = value.as_str() {
for token in type_id.split(|character: char| {
!character.is_ascii_alphanumeric() && character != '_'
}) {
if registry_types().contains_key(token) {
pending.push_back(token.to_string());
}
}
}
}
collect_type_references(value, pending);
}
}
Value::Array(values) => {
for value in values {
collect_type_references(value, pending);
}
}
_ => {}
}
}
fn referenced_definitions(root: &str) -> Map<String, Value> {
let mut definitions = Map::new();
let mut pending = VecDeque::from([root.to_string()]);
while let Some(name) = pending.pop_front() {
if definitions.contains_key(&name) {
continue;
}
let Some(info) = registry_types().get(&name) else {
continue;
};
collect_type_references(info, &mut pending);
definitions.insert(name, info.clone());
}
definitions
}
fn json_pointer_name(name: &str) -> String {
name.replace('~', "~0").replace('/', "~1")
}
fn field_unit(name: &str, type_id: &str) -> Option<&'static str> {
let numeric = matches!(
type_id,
"f32"
| "f64"
| "i8"
| "i16"
| "i32"
| "i64"
| "u8"
| "u16"
| "u32"
| "u64"
| "Vector2"
| "Vector3"
);
if !numeric {
return None;
}
let name = name.to_ascii_lowercase();
if name.contains("angle") || name == "rotation" || name.contains("twist") {
return Some("radians");
}
if name.contains("scale") || name.contains("ratio") || name.contains("factor") {
return Some("unitless");
}
if [
"point",
"position",
"origin",
"center",
"location",
"elevation",
"height",
"width",
"length",
"radius",
"diameter",
"offset",
"thickness",
"distance",
"size",
"spacing",
]
.iter()
.any(|part| name.contains(part))
{
return Some("drawing_units");
}
None
}
fn type_constraints(type_id: &str) -> Option<Value> {
let integer_bounds = match type_id {
"i8" => Some(json!({"minimum":i8::MIN,"maximum":i8::MAX})),
"i16" => Some(json!({"minimum":i16::MIN,"maximum":i16::MAX})),
"i32" => Some(json!({"minimum":i32::MIN,"maximum":i32::MAX})),
"i64" => Some(json!({"minimum":i64::MIN,"maximum":i64::MAX})),
"u8" => Some(json!({"minimum":0,"maximum":u8::MAX})),
"u16" => Some(json!({"minimum":0,"maximum":u16::MAX})),
"u32" => Some(json!({"minimum":0,"maximum":u32::MAX})),
"u64" | "Handle" => Some(json!({"minimum":0,"maximum":u64::MAX})),
_ => None,
};
if integer_bounds.is_some() {
return integer_bounds;
}
let info = registry_types().get(type_id)?;
if info["kind"] != "Enum" {
return None;
}
Some(json!({
"allowed_variants":info["variants"].as_array().into_iter().flatten()
.filter_map(|variant|variant["name"].as_str()).collect::<Vec<_>>()
}))
}
fn append_schema_fields(
owner_type: &str,
type_fields: &[Value],
prefix: &str,
collection: Option<&str>,
depth: usize,
stack: &mut Vec<String>,
fields: &mut Vec<Value>,
) {
for field in type_fields {
let Some(name) = field["name"].as_str() else {
continue;
};
let type_id = field["type_id"].as_str().unwrap_or("Value");
let path = format!("{prefix}/{}", json_pointer_name(name));
let sequence = field["is_sequence"].as_bool().unwrap_or(false);
let writable = collection.is_some_and(collection_is_mutable)
&& collection.is_none_or(|collection| !path_changes_identity(collection, &path));
let mut description = json!({
"path":path,
"name":name,
"description":format!("{} property on {owner_type}", name.replace('_', " ")),
"type":type_id,
"optional":field["optional"].as_bool().unwrap_or(false),
"sequence":sequence,
"writable":writable,
});
let object = description.as_object_mut().expect("field description");
if sequence {
object.insert(
"item_path_template".into(),
json!(format!("{path}/{{index}}")),
);
}
if let Some(unit) = field_unit(name, type_id) {
object.insert("unit".into(), json!(unit));
}
if let Some(constraints) = type_constraints(type_id) {
object.insert("constraints".into(), constraints);
}
fields.push(description);
let nested_prefix = if sequence {
format!("{path}/{{index}}")
} else {
path
};
if registry_types().contains_key(type_id) {
append_field_descriptions(
type_id,
&nested_prefix,
collection,
depth + 1,
stack,
fields,
);
}
}
}
fn append_field_descriptions(
type_name: &str,
prefix: &str,
collection: Option<&str>,
depth: usize,
stack: &mut Vec<String>,
fields: &mut Vec<Value>,
) {
if depth > 12 || stack.iter().any(|name| name == type_name) {
return;
}
let Some(info) = registry_types().get(type_name) else {
return;
};
stack.push(type_name.to_string());
match info["kind"].as_str() {
Some("Struct") => append_schema_fields(
type_name,
info["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]),
prefix,
collection,
depth,
stack,
fields,
),
Some("Enum") => append_enum_descriptions(
type_name,
info["variants"]
.as_array()
.map(Vec::as_slice)
.unwrap_or(&[]),
prefix,
collection,
depth,
stack,
fields,
),
_ => {}
}
stack.pop();
}
fn append_enum_descriptions(
enum_type: &str,
variants: &[Value],
prefix: &str,
collection: Option<&str>,
depth: usize,
stack: &mut Vec<String>,
fields: &mut Vec<Value>,
) {
for variant in variants {
let Some(variant_name) = variant["name"].as_str() else {
continue;
};
let Some(variant_fields) = variant["fields"]
.as_array()
.filter(|fields| !fields.is_empty())
else {
continue;
};
let path = format!("{prefix}/{}", json_pointer_name(variant_name));
let newtype = (variant_fields.len() == 1 && variant_fields[0]["name"] == "0")
.then(|| &variant_fields[0]);
let type_id = newtype
.and_then(|field| field["type_id"].as_str())
.unwrap_or("struct_variant");
let sequence = newtype
.and_then(|field| field["is_sequence"].as_bool())
.unwrap_or(false);
let writable = collection.is_some_and(collection_is_mutable)
&& collection.is_none_or(|collection| !path_changes_identity(collection, &path));
let mut description = json!({
"path":path,
"name":variant_name,
"description":format!("{variant_name} variant on {enum_type}"),
"type":type_id,
"enum":enum_type,
"variant":variant_name,
"optional":false,
"sequence":sequence,
"writable":writable,
});
if sequence {
description
.as_object_mut()
.expect("variant description")
.insert(
"item_path_template".into(),
json!(format!("{path}/{{index}}")),
);
}
if let Some(unit) = field_unit(variant_name, type_id) {
description
.as_object_mut()
.expect("variant description")
.insert("unit".into(), json!(unit));
}
if let Some(constraints) = type_constraints(type_id) {
description
.as_object_mut()
.expect("variant description")
.insert("constraints".into(), constraints);
}
fields.push(description);
if newtype.is_some() {
if registry_types().contains_key(type_id) {
let nested_prefix = if sequence {
format!("{path}/{{index}}")
} else {
path
};
append_field_descriptions(
type_id,
&nested_prefix,
collection,
depth + 1,
stack,
fields,
);
}
} else {
append_schema_fields(
variant_name,
variant_fields,
&path,
collection,
depth + 1,
stack,
fields,
);
}
}
}
fn append_variant_field_descriptions(
enum_type: &str,
variant_name: &str,
collection: Option<&str>,
fields: &mut Vec<Value>,
) {
let Some(variant_fields) = registry_types()
.get(enum_type)
.and_then(|info| info["variants"].as_array())
.and_then(|variants| {
variants.iter().find(|variant| {
variant["name"]
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case(variant_name))
})
})
.and_then(|variant| variant["fields"].as_array())
else {
return;
};
let mut stack = vec![enum_type.to_string()];
append_schema_fields(
variant_name,
variant_fields,
"",
collection,
0,
&mut stack,
fields,
);
}
fn runtime_value_schema(value: &Value, collection: &str, path: &str) -> Value {
let mut schema = match value {
Value::Null => json!({"type":"null"}),
Value::Bool(value) => json!({"type":"boolean","example":value}),
Value::Number(value) if value.is_i64() || value.is_u64() => {
json!({"type":"integer","example":value})
}
Value::Number(value) => json!({"type":"number","example":value}),
Value::String(value) => {
let example: String = value.chars().take(160).collect();
json!({"type":"string","example":example})
}
Value::Array(values) => {
let item = values
.first()
.map(|value| runtime_value_schema(value, collection, &format!("{path}/0")))
.unwrap_or_else(|| json!({}));
json!({"type":"array","items":item})
}
Value::Object(values) => {
let properties = values
.iter()
.map(|(name, value)| {
let child_path = format!("{path}/{}", json_pointer_name(name));
(
name.clone(),
runtime_value_schema(value, collection, &child_path),
)
})
.collect::<Map<_, _>>();
json!({
"type":"object",
"properties":properties,
"required":values.keys().collect::<Vec<_>>(),
"additionalProperties":false,
})
}
};
if !path.is_empty() {
let object = schema.as_object_mut().expect("runtime schema");
object.insert(
"readOnly".into(),
json!(!collection_is_mutable(collection) || path_changes_identity(collection, path)),
);
let name = path.rsplit('/').next().unwrap_or("");
let scalar_type = if value.is_number() { "f64" } else { "Value" };
if let Some(unit) = field_unit(name, scalar_type) {
object.insert("x-unit".into(), json!(unit));
}
}
schema
}
fn editing_description(collection: Option<&str>) -> Value {
let mutable = collection.is_some_and(collection_is_mutable);
json!({
"read_operation":"records",
"write_operation":mutable.then_some("set_properties"),
"selector":match collection {
Some("entities" | "objects") => "handle",
Some("header" | "summary_info") => "singleton",
Some(_) if mutable => "handle or name",
Some(_) => "read-only derived record",
None => "collection plus handle or name",
},
"path":"RFC 6901 JSON Pointer relative to record.properties",
"sequence_path":"Replace {index} in item_path_template with a current zero-based array index",
"identity_paths":collection.map(identity_paths).unwrap_or(&[]),
"atomic":true,
"compare_and_set":"Add expected to an update to reject stale field values",
"validation":["document revision","request id","JSON type","enum variant","integer bounds","record identity","layer lock"],
})
}
fn collection_record_types(collection: &str) -> Vec<Value> {
let root = collection_root_type(collection).unwrap_or("Value");
if matches!(collection, "entities" | "objects") {
return registry_types()[root]["variants"]
.as_array()
.into_iter()
.flatten()
.map(|variant| {
let fields = variant["fields"].as_array();
let schema_type = fields
.filter(|fields| fields.len() == 1 && fields[0]["name"] == "0")
.and_then(|fields| fields[0]["type_id"].as_str())
.unwrap_or(root);
json!({"type":variant["name"],"schema_type":schema_type})
})
.collect();
}
vec![json!({
"type":collection_record_name(collection).unwrap_or(root),
"schema_type":root,
})]
}
fn requested_handle(request: &Value) -> Result<acadrust::Handle, String> {
request["handle"]
.as_str()
@@ -523,6 +1115,8 @@ fn apply_updates(
if updates.is_empty() {
return Err("set_properties requires a non-empty updates array".into());
}
let mut original_identity = Vec::new();
collect_identity_values(properties, collection, "", &mut original_identity);
let mut paths = Vec::with_capacity(updates.len());
for update in updates {
let path = update["path"]
@@ -531,14 +1125,7 @@ fn apply_updates(
if path.is_empty() || !path.starts_with('/') {
return Err("updates require a non-empty JSON Pointer path".into());
}
let identity = match collection {
"entities" => matches!(path, "/common/handle" | "/common/owner_handle"),
"objects" => matches!(path, "/handle" | "/common/handle"),
"header" => path == "/handle_seed",
"summary_info" => false,
_ => matches!(path, "/handle" | "/name"),
};
if identity {
if path_changes_identity(collection, path) {
return Err(format!("{path} is read-only identity data"));
}
let value = update
@@ -556,6 +1143,11 @@ fn apply_updates(
*target = value;
paths.push(path.to_string());
}
let mut edited_identity = Vec::new();
collect_identity_values(properties, collection, "", &mut edited_identity);
if edited_identity != original_identity {
return Err("record identity is read-only".into());
}
Ok(paths)
}
@@ -611,11 +1203,13 @@ impl OpenCADStudio {
"records":{
"read":"records",
"write":"set_properties",
"describe":"record_schema",
"path":"RFC 6901 JSON Pointer relative to properties",
"filters":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],
"collections":COLLECTIONS.iter().map(|(name, mutable)|json!({
"name":name,
"mutable":mutable,
"root_type":collection_root_type(name),
"count":collection_count(document, name),
})).collect::<Vec<_>>()
},
@@ -623,6 +1217,151 @@ impl OpenCADStudio {
})
}
pub(super) fn record_schema(&self, request: &Value) -> Value {
let collection = request["collection"].as_str();
if collection.is_some_and(|name| collection_root_type(name).is_none()) {
return failure(
"unknown_collection",
format!("unknown record collection: {}", collection.unwrap()),
);
}
let collections = COLLECTIONS
.iter()
.map(|(name, mutable)| {
json!({
"name":name,
"mutable":mutable,
"root_type":collection_root_type(name),
"identity_paths":identity_paths(name),
})
})
.collect::<Vec<_>>();
let Some(requested_type) = request["type"].as_str() else {
let mut record_types = if let Some(collection) = collection {
collection_record_types(collection)
} else {
registry_types()
.iter()
.map(|(name, info)| json!({"type":name,"schema_type":name,"kind":info["kind"]}))
.collect()
};
if let Some(search) = request["search"].as_str().filter(|value| !value.is_empty()) {
let search = search.to_ascii_lowercase();
record_types.retain(|entry| {
entry["type"]
.as_str()
.is_some_and(|name| name.to_ascii_lowercase().contains(&search))
});
}
let count = record_types.len();
let offset = request["offset"].as_u64().unwrap_or(0) as usize;
let limit = request["limit"].as_u64().unwrap_or(1000).min(10_000) as usize;
let record_types = record_types
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
return json!({
"ok":true,
"schema_version":1,
"collection":collection,
"root_type":collection.and_then(collection_root_type),
"collections":collections,
"count":count,
"returned":record_types.len(),
"next_offset":(offset + record_types.len() < count).then_some(offset + record_types.len()),
"record_types":record_types,
"editing":editing_description(collection),
"next":"Call record_schema again with type set to a record type or schema_type returned here."
});
};
let root_type = collection
.and_then(|collection| {
collection_record_types(collection)
.into_iter()
.find(|entry| {
["type", "schema_type"].iter().any(|key| {
entry[*key]
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type))
})
})
.and_then(|entry| entry["schema_type"].as_str().map(str::to_string))
})
.or_else(|| resolved_type_name(requested_type))
.or_else(|| {
(requested_type == "Value"
|| collection.and_then(collection_root_type) == Some("Value"))
.then(|| "Value".to_string())
});
let Some(root_type) = root_type else {
return failure(
"unknown_record_type",
format!("unknown record type: {requested_type}"),
);
};
let mut fields = Vec::new();
let direct_collection_variant =
collection
.and_then(collection_root_type)
.is_some_and(|collection_root| {
collection_root == root_type && !requested_type.eq_ignore_ascii_case(&root_type)
})
&& registry_types()
.get(&root_type)
.is_some_and(|info| info["kind"] == "Enum");
if direct_collection_variant {
append_variant_field_descriptions(&root_type, requested_type, collection, &mut fields);
} else {
append_field_descriptions(&root_type, "", collection, 0, &mut Vec::new(), &mut fields);
}
let definitions = referenced_definitions(&root_type);
let runtime_schema = collection.and_then(|collection| {
collection_records(&self.tabs[self.active_tab].scene.document, collection)
.ok()?
.into_iter()
.find(|record| {
let type_matches = record["type"]
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type))
|| collection_root_type(collection)
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type));
type_matches
&& request["handle"].as_str().is_none_or(|handle| {
record["handle"].as_str().is_some_and(|actual| {
actual.eq_ignore_ascii_case(without_hex_prefix(handle))
})
})
&& request["name"].as_str().is_none_or(|name| {
record["name"]
.as_str()
.is_some_and(|actual| actual.eq_ignore_ascii_case(name))
})
})
.map(|record| runtime_value_schema(&record["properties"], collection, ""))
});
json!({
"ok":true,
"schema_version":1,
"collection":collection,
"record_type":requested_type,
"schema_type":root_type,
"schema":registry_types().get(&root_type),
"definitions":definitions,
"fields":fields,
"runtime_schema":runtime_schema,
"editing":editing_description(collection),
"notes":[
"Enum constraints list every accepted serialized variant.",
"Integer constraints reflect the complete serialized range.",
"Unit annotations are supplied only where the serialized field name and type are unambiguous.",
"runtime_schema describes an existing matching record when one is available."
]
})
}
pub(super) fn record_query(&self, request: &Value) -> Value {
let document = &self.tabs[self.active_tab].scene.document;
let Some(collection) = request["collection"].as_str() else {
@@ -990,6 +1729,104 @@ mod tests {
assert!(app.tabs[i].scene.document.summary_info.author.is_empty());
}
#[test]
fn record_schema_describes_absent_types_fields_enums_units_and_write_rules() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let catalog = app.record_schema(&json!({"collection":"entities"}));
assert_eq!(catalog["ok"], true, "{catalog}");
assert_eq!(catalog["root_type"], "EntityType");
assert_eq!(catalog["count"], 48);
assert!(catalog["record_types"]
.as_array()
.unwrap()
.iter()
.any(|record| record["type"] == "Insert" && record["schema_type"] == "Insert"));
let schema = app.record_schema(&json!({
"collection":"entities",
"type":"Insert"
}));
assert_eq!(schema["ok"], true, "{schema}");
assert!(schema["definitions"].get("Insert").is_some());
assert!(schema["definitions"].get("AttributeEntity").is_some());
assert!(schema["runtime_schema"].is_null());
let fields = schema["fields"].as_array().unwrap();
let field = |path: &str| {
fields
.iter()
.find(|field| field["path"] == path)
.unwrap_or_else(|| panic!("missing schema field {path}"))
};
assert_eq!(field("/common/handle")["writable"], false);
assert_eq!(field("/insert_point")["unit"], "drawing_units");
assert_eq!(field("/rotation")["unit"], "radians");
assert_eq!(
field("/attributes")["item_path_template"],
"/attributes/{index}"
);
assert_eq!(field("/attributes/{index}/value")["writable"], true);
let text = app.record_schema(&json!({"collection":"entities","type":"Text"}));
let alignment = text["fields"]
.as_array()
.unwrap()
.iter()
.find(|field| field["path"] == "/horizontal_alignment")
.expect("horizontal alignment schema");
assert!(alignment["constraints"]["allowed_variants"]
.as_array()
.is_some_and(|variants| variants.len() >= 3));
let unknown = app.record_schema(&json!({"collection":"objects","type":"Unknown"}));
assert_eq!(unknown["schema_type"], "ObjectType");
let fields = unknown["fields"].as_array().unwrap();
let field = |path: &str| {
fields
.iter()
.find(|field| field["path"] == path)
.unwrap_or_else(|| panic!("missing enum variant field {path}"))
};
assert_eq!(field("/type_name")["writable"], true);
assert_eq!(field("/handle")["writable"], false);
assert_eq!(field("/owner")["writable"], false);
assert_eq!(field("/raw_dwg_handle_bits")["writable"], true);
let dimension = app.record_schema(&json!({"collection":"entities","type":"Dimension"}));
let fields = dimension["fields"].as_array().unwrap();
assert!(fields.iter().any(|field| field["path"] == "/Linear"));
assert!(fields.iter().any(
|field| field["path"] == "/Linear/base/common/handle" && field["writable"] == false
));
assert!(fields.iter().any(|field| field["path"] == "/Radius"));
let section_style = app
.record_schema(&json!({"collection":"section_view_style","type":"SectionViewStyle"}));
assert_eq!(section_style["schema_type"], "EntitySectionViewStyle");
assert!(section_style["fields"]
.as_array()
.is_some_and(|fields| fields
.iter()
.any(|field| field["path"] == "/arrow_size" && field["unit"] == "drawing_units")));
let class = app.record_schema(&json!({"collection":"classes","type":"Class"}));
assert_eq!(class["schema_type"], "DxfClass");
assert!(class["fields"]
.as_array()
.is_some_and(|fields| fields.iter().any(|field| field["path"] == "/dxf_name")));
for (collection, root) in [("entities", "EntityType"), ("objects", "ObjectType")] {
let complete = app.record_schema(&json!({"collection":collection,"type":root}));
assert_eq!(complete["ok"], true, "{root}: {complete}");
assert!(complete["fields"]
.as_array()
.is_some_and(|fields| !fields.is_empty()));
let bytes = serde_json::to_vec(&complete).unwrap().len();
assert!(bytes < 8 * 1024 * 1024, "{root} schema is {bytes} bytes");
}
}
#[test]
fn record_updates_reject_type_identity_and_compare_failures() {
let mut app = OpenCADStudio::new_for_test();
@@ -1016,6 +1853,17 @@ mod tests {
);
assert_eq!(identity["code"], "invalid_update", "{identity}");
let identity_parent = execute(
&mut app,
json!({"op":"set_properties","collection":"entities","handle":handle,
"updates":[{"path":"/common","value":{}}]}),
"identity-parent",
);
assert_eq!(
identity_parent["code"], "invalid_update",
"{identity_parent}"
);
let compare = execute(
&mut app,
json!({"op":"set_properties","collection":"entities","handle":handle,
+5 -3
View File
@@ -22,12 +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. 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 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. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. 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",
"record_schema",
"capabilities",
"entities",
"layers",
@@ -986,8 +987,8 @@ fn tool_definitions() -> Value {
},
{
"name":"ocs_read",
"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},
"description":"Discover capabilities and record schemas, 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 or record-type search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"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}
},
@@ -1356,6 +1357,7 @@ mod tests {
);
assert!(READ_OPS.contains(&"capabilities"));
assert!(READ_OPS.contains(&"records"));
assert!(READ_OPS.contains(&"record_schema"));
assert!(EXECUTE_OPS.contains(&"set_properties"));
assert_eq!(
tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"]