forked from 1b-agent/opencadstudio-upstream
1876 lines
68 KiB
Rust
1876 lines
68 KiB
Rust
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};
|
|
|
|
const COLLECTIONS: &[(&str, bool)] = &[
|
|
("entities", true),
|
|
("objects", true),
|
|
("layers", true),
|
|
("line_types", true),
|
|
("text_styles", true),
|
|
("block_records", true),
|
|
("dim_styles", true),
|
|
("app_ids", true),
|
|
("views", true),
|
|
("vports", true),
|
|
("ucss", true),
|
|
("vx_table", true),
|
|
("header", true),
|
|
("summary_info", true),
|
|
("classes", false),
|
|
("block_visibility", false),
|
|
("context_scales", false),
|
|
("block_representations", false),
|
|
("fields", false),
|
|
("dgn_line_style_definitions", false),
|
|
("dgn_line_style_components", false),
|
|
("vx_control_entries", false),
|
|
("section_view_style", false),
|
|
("view_rep_references", false),
|
|
("section_view_representations", false),
|
|
("notifications", false),
|
|
("preview", false),
|
|
("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()})
|
|
}
|
|
|
|
fn json_value(value: &impl Serialize) -> Result<Value, String> {
|
|
serde_json::to_value(value).map_err(|error| error.to_string())
|
|
}
|
|
|
|
fn enum_parts(value: &impl Serialize) -> Result<(String, Value), String> {
|
|
let Value::Object(wrapper) = json_value(value)? else {
|
|
return Err("record type is not represented by an object".into());
|
|
};
|
|
if wrapper.len() != 1 {
|
|
return Err("record type has an invalid representation".into());
|
|
}
|
|
Ok(wrapper.into_iter().next().expect("one entry"))
|
|
}
|
|
|
|
fn decode_enum<T: DeserializeOwned>(kind: &str, properties: Value) -> Result<T, String> {
|
|
let mut wrapper = Map::new();
|
|
wrapper.insert(kind.to_string(), properties);
|
|
serde_json::from_value(Value::Object(wrapper)).map_err(|error| error.to_string())
|
|
}
|
|
|
|
fn handle_text(handle: acadrust::Handle) -> String {
|
|
format!("{:X}", handle.value())
|
|
}
|
|
|
|
fn without_hex_prefix(value: &str) -> &str {
|
|
value
|
|
.strip_prefix("0x")
|
|
.or_else(|| value.strip_prefix("0X"))
|
|
.unwrap_or(value)
|
|
}
|
|
|
|
fn record(
|
|
collection: &str,
|
|
kind: &str,
|
|
handle: Option<acadrust::Handle>,
|
|
name: Option<&str>,
|
|
mutable: bool,
|
|
properties: Value,
|
|
) -> Value {
|
|
let mut value = json!({
|
|
"collection":collection,
|
|
"type":kind,
|
|
"mutable":mutable,
|
|
"properties":properties,
|
|
});
|
|
let object = value.as_object_mut().expect("record object");
|
|
if let Some(handle) = handle {
|
|
object.insert("handle".into(), json!(handle_text(handle)));
|
|
}
|
|
if let Some(name) = name {
|
|
object.insert("name".into(), json!(name));
|
|
}
|
|
value
|
|
}
|
|
|
|
fn entity_record(entity: &acadrust::EntityType) -> Result<Value, String> {
|
|
let (kind, properties) = enum_parts(entity)?;
|
|
let mut value = record(
|
|
"entities",
|
|
&kind,
|
|
Some(entity.common().handle),
|
|
None,
|
|
true,
|
|
properties,
|
|
);
|
|
let object = value.as_object_mut().expect("record object");
|
|
object.insert(
|
|
"display_type".into(),
|
|
json!(crate::entities::names::ui_name(entity)),
|
|
);
|
|
object.insert(
|
|
"record_type".into(),
|
|
json!(crate::entities::names::dxf_name(entity)),
|
|
);
|
|
Ok(value)
|
|
}
|
|
|
|
fn object_record(
|
|
handle: acadrust::Handle,
|
|
object: &acadrust::objects::ObjectType,
|
|
) -> Result<Value, String> {
|
|
let (kind, properties) = enum_parts(object)?;
|
|
Ok(record(
|
|
"objects",
|
|
&kind,
|
|
Some(handle),
|
|
None,
|
|
true,
|
|
properties,
|
|
))
|
|
}
|
|
|
|
fn table_records<T: Serialize + TableEntry>(
|
|
collection: &str,
|
|
kind: &str,
|
|
table: &Table<T>,
|
|
) -> Result<Vec<Value>, String> {
|
|
table
|
|
.iter()
|
|
.map(|entry| {
|
|
Ok(record(
|
|
collection,
|
|
kind,
|
|
Some(entry.handle()),
|
|
Some(entry.name()),
|
|
true,
|
|
json_value(entry)?,
|
|
))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn map_records<T: Serialize>(
|
|
collection: &str,
|
|
kind: &str,
|
|
values: &std::collections::HashMap<acadrust::Handle, T>,
|
|
) -> Result<Vec<Value>, String> {
|
|
let mut entries: Vec<_> = values.iter().collect();
|
|
entries.sort_by_key(|(handle, _)| handle.value());
|
|
entries
|
|
.into_iter()
|
|
.map(|(handle, value)| {
|
|
Ok(record(
|
|
collection,
|
|
kind,
|
|
Some(*handle),
|
|
None,
|
|
false,
|
|
json_value(value)?,
|
|
))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn collection_records(
|
|
document: &acadrust::CadDocument,
|
|
collection: &str,
|
|
) -> Result<Vec<Value>, String> {
|
|
match collection {
|
|
"entities" => document.entities().map(entity_record).collect(),
|
|
"objects" => {
|
|
let mut objects: Vec<_> = document.objects.iter().collect();
|
|
objects.sort_by_key(|(handle, _)| handle.value());
|
|
objects
|
|
.into_iter()
|
|
.map(|(handle, object)| object_record(*handle, object))
|
|
.collect()
|
|
}
|
|
"layers" => table_records("layers", "Layer", &document.layers),
|
|
"line_types" => table_records("line_types", "LineType", &document.line_types),
|
|
"text_styles" => table_records("text_styles", "TextStyle", &document.text_styles),
|
|
"block_records" => table_records("block_records", "BlockRecord", &document.block_records),
|
|
"dim_styles" => table_records("dim_styles", "DimStyle", &document.dim_styles),
|
|
"app_ids" => table_records("app_ids", "AppId", &document.app_ids),
|
|
"views" => table_records("views", "View", &document.views),
|
|
"vports" => table_records("vports", "VPort", &document.vports),
|
|
"ucss" => table_records("ucss", "Ucs", &document.ucss),
|
|
"vx_table" => table_records("vx_table", "VxTableRecord", &document.vx_table),
|
|
"header" => Ok(vec![record(
|
|
"header",
|
|
"HeaderVariables",
|
|
None,
|
|
Some("header"),
|
|
true,
|
|
json_value(&document.header)?,
|
|
)]),
|
|
"summary_info" => Ok(vec![record(
|
|
"summary_info",
|
|
"SummaryInfo",
|
|
None,
|
|
Some("summary_info"),
|
|
true,
|
|
json_value(&document.summary_info)?,
|
|
)]),
|
|
"classes" => document
|
|
.classes
|
|
.iter()
|
|
.map(|value| {
|
|
Ok(record(
|
|
"classes",
|
|
"Class",
|
|
None,
|
|
Some(&value.dxf_name),
|
|
false,
|
|
json_value(value)?,
|
|
))
|
|
})
|
|
.collect(),
|
|
"block_visibility" => map_records(
|
|
"block_visibility",
|
|
"BlockVisibilityParameter",
|
|
&document.block_visibility_params,
|
|
),
|
|
"context_scales" => map_records("context_scales", "Handle", &document.context_scales),
|
|
"block_representations" => map_records(
|
|
"block_representations",
|
|
"Handle",
|
|
&document.block_representations,
|
|
),
|
|
"fields" => map_records("fields", "Field", &document.fields),
|
|
"dgn_line_style_definitions" => map_records(
|
|
"dgn_line_style_definitions",
|
|
"LineStyleDefinition",
|
|
&document.dgn_ls_definitions,
|
|
),
|
|
"dgn_line_style_components" => map_records(
|
|
"dgn_line_style_components",
|
|
"LineStyleComponent",
|
|
&document.dgn_ls_components,
|
|
),
|
|
"vx_control_entries" => Ok(document
|
|
.vx_control_entries
|
|
.iter()
|
|
.map(|handle| {
|
|
record(
|
|
"vx_control_entries",
|
|
"Handle",
|
|
Some(*handle),
|
|
None,
|
|
false,
|
|
json!({}),
|
|
)
|
|
})
|
|
.collect()),
|
|
"section_view_style" => Ok(document
|
|
.section_view_style
|
|
.as_ref()
|
|
.map(|value| {
|
|
record(
|
|
"section_view_style",
|
|
"SectionViewStyle",
|
|
None,
|
|
Some("section_view_style"),
|
|
false,
|
|
json_value(value).unwrap_or(Value::Null),
|
|
)
|
|
})
|
|
.into_iter()
|
|
.collect()),
|
|
"view_rep_references" => map_records(
|
|
"view_rep_references",
|
|
"HandleReferences",
|
|
&document.view_rep_refs,
|
|
),
|
|
"section_view_representations" => Ok(document
|
|
.section_view_reps
|
|
.iter()
|
|
.map(|handle| {
|
|
record(
|
|
"section_view_representations",
|
|
"Handle",
|
|
Some(*handle),
|
|
None,
|
|
false,
|
|
json!({}),
|
|
)
|
|
})
|
|
.collect()),
|
|
"notifications" => Ok(vec![record(
|
|
"notifications",
|
|
"Notifications",
|
|
None,
|
|
Some("notifications"),
|
|
false,
|
|
json_value(&document.notifications)?,
|
|
)]),
|
|
"preview" => Ok(document
|
|
.preview
|
|
.as_ref()
|
|
.map(|value| {
|
|
record(
|
|
"preview",
|
|
"Preview",
|
|
None,
|
|
Some("preview"),
|
|
false,
|
|
json_value(value).unwrap_or(Value::Null),
|
|
)
|
|
})
|
|
.into_iter()
|
|
.collect()),
|
|
"document" => Ok(vec![record(
|
|
"document",
|
|
"Document",
|
|
None,
|
|
Some("document"),
|
|
false,
|
|
json!({
|
|
"version":document.version,
|
|
"maintenance_version":document.maintenance_version,
|
|
"source_path":document.source_path,
|
|
"source_version":document.dwg_source_version,
|
|
}),
|
|
)]),
|
|
_ => Err(format!("unknown record collection: {collection}")),
|
|
}
|
|
}
|
|
|
|
fn collection_count(document: &acadrust::CadDocument, collection: &str) -> usize {
|
|
match collection {
|
|
"entities" => document.entities().count(),
|
|
"objects" => document.objects.len(),
|
|
"layers" => document.layers.len(),
|
|
"line_types" => document.line_types.len(),
|
|
"text_styles" => document.text_styles.len(),
|
|
"block_records" => document.block_records.len(),
|
|
"dim_styles" => document.dim_styles.len(),
|
|
"app_ids" => document.app_ids.len(),
|
|
"views" => document.views.len(),
|
|
"vports" => document.vports.len(),
|
|
"ucss" => document.ucss.len(),
|
|
"vx_table" => document.vx_table.len(),
|
|
"classes" => document.classes.len(),
|
|
"block_visibility" => document.block_visibility_params.len(),
|
|
"context_scales" => document.context_scales.len(),
|
|
"block_representations" => document.block_representations.len(),
|
|
"fields" => document.fields.len(),
|
|
"dgn_line_style_definitions" => document.dgn_ls_definitions.len(),
|
|
"dgn_line_style_components" => document.dgn_ls_components.len(),
|
|
"vx_control_entries" => document.vx_control_entries.len(),
|
|
"section_view_style" => usize::from(document.section_view_style.is_some()),
|
|
"view_rep_references" => document.view_rep_refs.len(),
|
|
"section_view_representations" => document.section_view_reps.len(),
|
|
"preview" => usize::from(document.preview.is_some()),
|
|
_ => 1,
|
|
}
|
|
}
|
|
|
|
fn compare(
|
|
actual: Option<&Value>,
|
|
operator: &str,
|
|
expected: Option<&Value>,
|
|
) -> Result<bool, String> {
|
|
let exists = actual.is_some();
|
|
if operator == "exists" {
|
|
return Ok(exists);
|
|
}
|
|
if operator == "not_exists" {
|
|
return Ok(!exists);
|
|
}
|
|
let Some(actual) = actual else {
|
|
return Ok(false);
|
|
};
|
|
let expected = expected.ok_or_else(|| format!("filter operator {operator} requires value"))?;
|
|
Ok(match operator {
|
|
"eq" => actual == expected,
|
|
"ne" => actual != expected,
|
|
"lt" | "lte" | "gt" | "gte" => {
|
|
let left = actual
|
|
.as_f64()
|
|
.ok_or_else(|| format!("filter operator {operator} requires numeric values"))?;
|
|
let right = expected
|
|
.as_f64()
|
|
.ok_or_else(|| format!("filter operator {operator} requires numeric values"))?;
|
|
match operator {
|
|
"lt" => left < right,
|
|
"lte" => left <= right,
|
|
"gt" => left > right,
|
|
_ => left >= right,
|
|
}
|
|
}
|
|
"contains" => match (actual, expected) {
|
|
(Value::String(left), Value::String(right)) => left.contains(right),
|
|
(Value::Array(values), expected) => values.contains(expected),
|
|
_ => return Err("contains requires a string/string or array/value pair".into()),
|
|
},
|
|
"starts_with" => actual
|
|
.as_str()
|
|
.zip(expected.as_str())
|
|
.is_some_and(|(left, right)| left.starts_with(right)),
|
|
"ends_with" => actual
|
|
.as_str()
|
|
.zip(expected.as_str())
|
|
.is_some_and(|(left, right)| left.ends_with(right)),
|
|
"in" => expected
|
|
.as_array()
|
|
.is_some_and(|values| values.contains(actual)),
|
|
_ => return Err(format!("unknown filter operator: {operator}")),
|
|
})
|
|
}
|
|
|
|
fn matches_filters(record: &Value, request: &Value) -> Result<bool, String> {
|
|
if request["handle"].as_str().is_some_and(|requested| {
|
|
!record["handle"]
|
|
.as_str()
|
|
.is_some_and(|actual| actual.eq_ignore_ascii_case(without_hex_prefix(requested)))
|
|
}) {
|
|
return Ok(false);
|
|
}
|
|
if let Some(handles) = request["handles"].as_array() {
|
|
let Some(actual) = record["handle"].as_str() else {
|
|
return Ok(false);
|
|
};
|
|
if !handles
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.any(|requested| actual.eq_ignore_ascii_case(without_hex_prefix(requested)))
|
|
{
|
|
return Ok(false);
|
|
}
|
|
}
|
|
if request["type"].as_str().is_some_and(|requested| {
|
|
!["type", "display_type", "record_type"].iter().any(|key| {
|
|
record[*key]
|
|
.as_str()
|
|
.is_some_and(|actual| actual.eq_ignore_ascii_case(requested))
|
|
})
|
|
}) {
|
|
return Ok(false);
|
|
}
|
|
if request["name"].as_str().is_some_and(|requested| {
|
|
!record["name"]
|
|
.as_str()
|
|
.is_some_and(|actual| actual.eq_ignore_ascii_case(requested))
|
|
}) {
|
|
return Ok(false);
|
|
}
|
|
if let Some(filters) = request["where"].as_array() {
|
|
for filter in filters {
|
|
let path = filter["path"]
|
|
.as_str()
|
|
.ok_or_else(|| "each filter requires a JSON Pointer path".to_string())?;
|
|
if !path.is_empty() && !path.starts_with('/') {
|
|
return Err(format!("invalid JSON Pointer: {path}"));
|
|
}
|
|
let actual = if path.is_empty() {
|
|
Some(&record["properties"])
|
|
} else {
|
|
record["properties"].pointer(path)
|
|
};
|
|
let operator = filter["op"].as_str().unwrap_or("eq");
|
|
if !compare(actual, operator, filter.get("value"))? {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
fn project_paths(mut record: Value, paths: Option<&Vec<Value>>) -> Result<Value, String> {
|
|
let Some(paths) = paths else {
|
|
return Ok(record);
|
|
};
|
|
let mut projected = Map::new();
|
|
for path in paths {
|
|
let path = path
|
|
.as_str()
|
|
.ok_or_else(|| "paths must contain JSON Pointer strings".to_string())?;
|
|
if !path.is_empty() && !path.starts_with('/') {
|
|
return Err(format!("invalid JSON Pointer: {path}"));
|
|
}
|
|
let value = if path.is_empty() {
|
|
Some(&record["properties"])
|
|
} else {
|
|
record["properties"].pointer(path)
|
|
};
|
|
projected.insert(path.to_string(), value.cloned().unwrap_or(Value::Null));
|
|
}
|
|
let object = record.as_object_mut().expect("record object");
|
|
object.remove("properties");
|
|
object.insert("values".into(), Value::Object(projected));
|
|
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()
|
|
.and_then(|value| u64::from_str_radix(without_hex_prefix(value), 16).ok())
|
|
.map(acadrust::Handle::new)
|
|
.ok_or_else(|| "set_properties requires a hexadecimal handle".to_string())
|
|
}
|
|
|
|
fn apply_updates(
|
|
properties: &mut Value,
|
|
request: &Value,
|
|
collection: &str,
|
|
) -> Result<Vec<String>, String> {
|
|
let updates = request["updates"]
|
|
.as_array()
|
|
.ok_or_else(|| "set_properties requires a non-empty updates array".to_string())?;
|
|
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"]
|
|
.as_str()
|
|
.ok_or_else(|| "each update requires a JSON Pointer path".to_string())?;
|
|
if path.is_empty() || !path.starts_with('/') {
|
|
return Err("updates require a non-empty JSON Pointer path".into());
|
|
}
|
|
if path_changes_identity(collection, path) {
|
|
return Err(format!("{path} is read-only identity data"));
|
|
}
|
|
let value = update
|
|
.get("value")
|
|
.ok_or_else(|| format!("update {path} requires value"))?
|
|
.clone();
|
|
let target = properties
|
|
.pointer_mut(path)
|
|
.ok_or_else(|| format!("property does not exist: {path}"))?;
|
|
if let Some(expected) = update.get("expected") {
|
|
if target != expected {
|
|
return Err(format!("property changed before update: {path}"));
|
|
}
|
|
}
|
|
*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)
|
|
}
|
|
|
|
fn patched_table_entry<T>(
|
|
table: &Table<T>,
|
|
request: &Value,
|
|
collection: &str,
|
|
) -> Result<(T, T, Vec<String>), String>
|
|
where
|
|
T: Clone + PartialEq + Serialize + DeserializeOwned + TableEntry,
|
|
{
|
|
let requested = request["handle"].as_str().and_then(|value| {
|
|
u64::from_str_radix(without_hex_prefix(value), 16)
|
|
.ok()
|
|
.map(acadrust::Handle::new)
|
|
});
|
|
let name = request["name"].as_str();
|
|
let source = table
|
|
.iter()
|
|
.find(|entry| {
|
|
requested.is_some_and(|handle| entry.handle() == handle)
|
|
|| name.is_some_and(|name| entry.name().eq_ignore_ascii_case(name))
|
|
})
|
|
.cloned()
|
|
.ok_or_else(|| format!("record does not exist in {collection}"))?;
|
|
let mut properties = json_value(&source)?;
|
|
let paths = apply_updates(&mut properties, request, collection)?;
|
|
let edited: T = serde_json::from_value(properties).map_err(|error| error.to_string())?;
|
|
if edited.handle() != source.handle() || edited.name() != source.name() {
|
|
return Err("record identity is read-only".into());
|
|
}
|
|
Ok((source, edited, paths))
|
|
}
|
|
|
|
fn replace_table_entry<T: TableEntry>(table: &mut Table<T>, handle: acadrust::Handle, edited: T) {
|
|
let target = table
|
|
.iter_mut()
|
|
.find(|entry| entry.handle() == handle)
|
|
.expect("validated table entry");
|
|
*target = edited;
|
|
}
|
|
|
|
impl OpenCADStudio {
|
|
pub(super) fn record_capabilities(&self) -> Value {
|
|
let document = &self.tabs[self.active_tab].scene.document;
|
|
json!({
|
|
"ok":true,
|
|
"api":"ocs-cad-automation",
|
|
"version":1,
|
|
"concurrency":{"document_id":true,"revision":true,"request_id":true},
|
|
"transactions":{"undo":true,"redo":true,"atomic_property_updates":true,"batch":true},
|
|
"geometry":{"query":true,"kernel_measurements":true,"spatial_filters":true,"interactive_commands":true},
|
|
"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<_>>()
|
|
},
|
|
"editor":{"selection":true,"properties":true,"commands":true,"files":true,"capture":true,"events":true}
|
|
})
|
|
}
|
|
|
|
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 {
|
|
return self.record_capabilities();
|
|
};
|
|
let mut records = Vec::new();
|
|
if collection == "all" {
|
|
for (name, _) in COLLECTIONS {
|
|
match collection_records(document, name) {
|
|
Ok(mut values) => records.append(&mut values),
|
|
Err(error) => return failure("serialization_failed", error),
|
|
}
|
|
}
|
|
} else {
|
|
match collection_records(document, collection) {
|
|
Ok(values) => records = values,
|
|
Err(error) => return failure("unknown_collection", error),
|
|
}
|
|
}
|
|
let mut matched = Vec::new();
|
|
for record in records {
|
|
match matches_filters(&record, request) {
|
|
Ok(true) => matched.push(record),
|
|
Ok(false) => {}
|
|
Err(error) => return failure("invalid_filter", error),
|
|
}
|
|
}
|
|
let count = matched.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 mut returned = Vec::new();
|
|
for record in matched.into_iter().skip(offset).take(limit) {
|
|
match project_paths(record, request["paths"].as_array()) {
|
|
Ok(record) => returned.push(record),
|
|
Err(error) => return failure("invalid_projection", error),
|
|
}
|
|
}
|
|
json!({
|
|
"ok":true,
|
|
"document_id":self.tabs[self.active_tab].id,
|
|
"revision":self.tabs[self.active_tab].edit_revision,
|
|
"geometry_revision":self.tabs[self.active_tab].scene.geometry_epoch,
|
|
"collection":collection,
|
|
"count":count,
|
|
"returned":returned.len(),
|
|
"next_offset":(offset + returned.len() < count).then_some(offset + returned.len()),
|
|
"records":returned,
|
|
})
|
|
}
|
|
|
|
pub(super) fn control_set_record_properties(
|
|
&mut self,
|
|
request: &Value,
|
|
) -> Result<Task<Message>, Value> {
|
|
let collection = request["collection"]
|
|
.as_str()
|
|
.ok_or_else(|| failure("collection_required", "set_properties requires collection"))?;
|
|
if !COLLECTIONS
|
|
.iter()
|
|
.any(|(name, mutable)| *name == collection && *mutable)
|
|
{
|
|
return Err(failure(
|
|
"read_only_collection",
|
|
format!("collection is absent or read-only: {collection}"),
|
|
));
|
|
}
|
|
let i = self.active_tab;
|
|
let changed;
|
|
let mut result_handle = None;
|
|
let mut result_name = None;
|
|
let paths;
|
|
match collection {
|
|
"entities" => {
|
|
let handle =
|
|
requested_handle(request).map_err(|error| failure("invalid_handle", error))?;
|
|
if self.tabs[i].scene.is_layer_locked(handle) {
|
|
return Err(failure("layer_locked", "entity is on a locked layer"));
|
|
}
|
|
let source = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.get_entity(handle)
|
|
.cloned()
|
|
.ok_or_else(|| failure("record_absent", "entity does not exist"))?;
|
|
let (kind, mut properties) =
|
|
enum_parts(&source).map_err(|error| failure("serialization_failed", error))?;
|
|
paths = apply_updates(&mut properties, request, collection)
|
|
.map_err(|error| failure("invalid_update", error))?;
|
|
let mut edited: acadrust::EntityType = decode_enum(&kind, properties)
|
|
.map_err(|error| failure("invalid_value", error))?;
|
|
if edited.common().handle != handle {
|
|
return Err(failure("identity_changed", "entity identity is read-only"));
|
|
}
|
|
edited.preserve_storage_data_from(&source);
|
|
changed = edited != source;
|
|
if changed {
|
|
self.push_undo_snapshot(i, "MCP SET_PROPERTIES");
|
|
if !self.tabs[i].scene.update_entity(edited) {
|
|
self.discard_last_undo_entry(i);
|
|
return Err(failure("update_failed", "entity could not be updated"));
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
result_handle = Some(handle_text(handle));
|
|
}
|
|
"objects" => {
|
|
let handle =
|
|
requested_handle(request).map_err(|error| failure("invalid_handle", error))?;
|
|
let source = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.objects
|
|
.get(&handle)
|
|
.cloned()
|
|
.ok_or_else(|| failure("record_absent", "object does not exist"))?;
|
|
let (kind, mut properties) =
|
|
enum_parts(&source).map_err(|error| failure("serialization_failed", error))?;
|
|
paths = apply_updates(&mut properties, request, collection)
|
|
.map_err(|error| failure("invalid_update", error))?;
|
|
let mut edited: acadrust::objects::ObjectType = decode_enum(&kind, properties)
|
|
.map_err(|error| failure("invalid_value", error))?;
|
|
edited.preserve_storage_data_from(&source);
|
|
changed = edited != source;
|
|
if changed {
|
|
self.push_undo_snapshot(i, "MCP SET_PROPERTIES");
|
|
self.tabs[i].scene.document.objects.insert(handle, edited);
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
result_handle = Some(handle_text(handle));
|
|
}
|
|
"header" => {
|
|
let source = self.tabs[i].scene.document.header.clone();
|
|
let mut properties =
|
|
json_value(&source).map_err(|error| failure("serialization_failed", error))?;
|
|
paths = apply_updates(&mut properties, request, collection)
|
|
.map_err(|error| failure("invalid_update", error))?;
|
|
let edited =
|
|
serde_json::from_value(properties).map_err(|error: serde_json::Error| {
|
|
failure("invalid_value", error.to_string())
|
|
})?;
|
|
changed = edited != source;
|
|
if changed {
|
|
self.push_undo_snapshot(i, "MCP SET_PROPERTIES");
|
|
self.tabs[i].scene.document.header = edited;
|
|
self.tabs[i].adopt_active_ucs_from_header();
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
result_name = Some("header".to_string());
|
|
}
|
|
"summary_info" => {
|
|
let source = self.tabs[i].scene.document.summary_info.clone();
|
|
let mut properties =
|
|
json_value(&source).map_err(|error| failure("serialization_failed", error))?;
|
|
paths = apply_updates(&mut properties, request, collection)
|
|
.map_err(|error| failure("invalid_update", error))?;
|
|
let edited =
|
|
serde_json::from_value(properties).map_err(|error: serde_json::Error| {
|
|
failure("invalid_value", error.to_string())
|
|
})?;
|
|
changed = edited != source;
|
|
if changed {
|
|
self.push_undo_snapshot(i, "MCP SET_PROPERTIES");
|
|
self.tabs[i].scene.document.summary_info = edited;
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
result_name = Some("summary_info".to_string());
|
|
}
|
|
_ => {
|
|
macro_rules! patch_table {
|
|
($field:ident) => {{
|
|
let (source, edited, changed_paths) = patched_table_entry(
|
|
&self.tabs[i].scene.document.$field,
|
|
request,
|
|
collection,
|
|
)
|
|
.map_err(|error| failure("invalid_update", error))?;
|
|
paths = changed_paths;
|
|
changed = edited != source;
|
|
let handle = source.handle();
|
|
result_handle = Some(handle_text(handle));
|
|
result_name = Some(source.name().to_string());
|
|
if changed {
|
|
self.push_undo_snapshot(i, "MCP SET_PROPERTIES");
|
|
replace_table_entry(
|
|
&mut self.tabs[i].scene.document.$field,
|
|
handle,
|
|
edited,
|
|
);
|
|
self.tabs[i].scene.bump_geometry();
|
|
self.tabs[i].dirty = true;
|
|
}
|
|
}};
|
|
}
|
|
match collection {
|
|
"layers" => patch_table!(layers),
|
|
"line_types" => patch_table!(line_types),
|
|
"text_styles" => patch_table!(text_styles),
|
|
"block_records" => patch_table!(block_records),
|
|
"dim_styles" => patch_table!(dim_styles),
|
|
"app_ids" => patch_table!(app_ids),
|
|
"views" => patch_table!(views),
|
|
"vports" => patch_table!(vports),
|
|
"ucss" => patch_table!(ucss),
|
|
"vx_table" => patch_table!(vx_table),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
if changed {
|
|
self.refresh_properties();
|
|
}
|
|
self.set_control_result(json!({
|
|
"collection":collection,
|
|
"handle":result_handle,
|
|
"name":result_name,
|
|
"paths":paths,
|
|
"changed":changed,
|
|
}));
|
|
Ok(Task::none())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use acadrust::entities::{AttributeEntity, EntityType, Insert};
|
|
use acadrust::objects::{Dictionary, ObjectType};
|
|
use acadrust::types::{Handle, Vector3};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::OpenCADStudio;
|
|
|
|
fn execute(app: &mut OpenCADStudio, mut request: Value, id: &str) -> Value {
|
|
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
|
|
let object = request.as_object_mut().unwrap();
|
|
object.insert("protocol".into(), json!(1));
|
|
object.insert("request_id".into(), json!(id));
|
|
object.insert("document_id".into(), state["document_id"].clone());
|
|
object.insert("revision".into(), state["revision"].clone());
|
|
app.automation_op(&request.to_string())
|
|
}
|
|
|
|
#[test]
|
|
fn records_query_and_edit_every_mutable_record_family() {
|
|
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
|
|
.attributes
|
|
.push(AttributeEntity::simple("COMPANY", "BPH"));
|
|
let entity = app.tabs[i].scene.add_entity(EntityType::Insert(insert));
|
|
|
|
let object_handle = Handle::new(0x500);
|
|
let mut dictionary = Dictionary::new();
|
|
dictionary.handle = object_handle;
|
|
app.tabs[i]
|
|
.scene
|
|
.document
|
|
.objects
|
|
.insert(object_handle, ObjectType::Dictionary(dictionary));
|
|
|
|
let records = app.record_query(&json!({
|
|
"collection":"entities",
|
|
"type":"Insert",
|
|
"where":[{"path":"/attributes/0/tag","value":"COMPANY"}]
|
|
}));
|
|
assert_eq!(records["count"], 1);
|
|
assert_eq!(records["records"][0]["properties"]["block_name"], "A_CPT");
|
|
assert_eq!(
|
|
records["records"][0]["properties"]["attributes"][0]["value"],
|
|
"BPH"
|
|
);
|
|
|
|
let edited = execute(
|
|
&mut app,
|
|
json!({
|
|
"op":"set_properties",
|
|
"collection":"entities",
|
|
"handle":format!("{:X}", entity.value()),
|
|
"updates":[
|
|
{"path":"/attributes/0/value","expected":"BPH","value":"Edited"},
|
|
{"path":"/insert_point/x","value":20.0}
|
|
]
|
|
}),
|
|
"edit-entity",
|
|
);
|
|
assert_eq!(edited["status"], "completed", "{edited}");
|
|
assert_eq!(edited["result"]["changed"], true);
|
|
let EntityType::Insert(insert) = app.tabs[i].scene.document.get_entity(entity).unwrap()
|
|
else {
|
|
panic!("expected insert")
|
|
};
|
|
assert_eq!(insert.attributes[0].value, "Edited");
|
|
assert_eq!(insert.insert_point.x, 20.0);
|
|
|
|
let object_edit = execute(
|
|
&mut app,
|
|
json!({
|
|
"op":"set_properties",
|
|
"collection":"objects",
|
|
"handle":"500",
|
|
"updates":[{"path":"/hard_owner","value":true}]
|
|
}),
|
|
"edit-object",
|
|
);
|
|
assert_eq!(object_edit["result"]["changed"], true, "{object_edit}");
|
|
let Some(ObjectType::Dictionary(dictionary)) =
|
|
app.tabs[i].scene.document.objects.get(&object_handle)
|
|
else {
|
|
panic!("expected dictionary")
|
|
};
|
|
assert!(dictionary.hard_owner);
|
|
|
|
let layer_edit = execute(
|
|
&mut app,
|
|
json!({
|
|
"op":"set_properties",
|
|
"collection":"layers",
|
|
"name":"0",
|
|
"updates":[{"path":"/is_plottable","value":false}]
|
|
}),
|
|
"edit-layer",
|
|
);
|
|
assert_eq!(layer_edit["result"]["changed"], true, "{layer_edit}");
|
|
assert!(
|
|
!app.tabs[i]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.get("0")
|
|
.unwrap()
|
|
.is_plottable
|
|
);
|
|
|
|
let header_edit = execute(
|
|
&mut app,
|
|
json!({
|
|
"op":"set_properties",
|
|
"collection":"header",
|
|
"updates":[{"path":"/point_display_size","value":7.5}]
|
|
}),
|
|
"edit-header",
|
|
);
|
|
assert_eq!(header_edit["result"]["changed"], true, "{header_edit}");
|
|
assert_eq!(app.tabs[i].scene.document.header.point_display_size, 7.5);
|
|
|
|
let summary_edit = execute(
|
|
&mut app,
|
|
json!({
|
|
"op":"set_properties",
|
|
"collection":"summary_info",
|
|
"updates":[{"path":"/author","value":"MCP"}]
|
|
}),
|
|
"edit-summary",
|
|
);
|
|
assert_eq!(summary_edit["result"]["changed"], true, "{summary_edit}");
|
|
assert_eq!(app.tabs[i].scene.document.summary_info.author, "MCP");
|
|
|
|
let undo = execute(&mut app, json!({"op":"undo"}), "undo-summary");
|
|
assert_eq!(undo["status"], "completed", "{undo}");
|
|
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();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
let entity = app.tabs[i]
|
|
.scene
|
|
.add_entity(EntityType::Insert(Insert::new("A", Vector3::ZERO)));
|
|
let handle = format!("{:X}", entity.value());
|
|
|
|
let wrong_type = execute(
|
|
&mut app,
|
|
json!({"op":"set_properties","collection":"entities","handle":handle,
|
|
"updates":[{"path":"/rotation","value":"invalid"}]}),
|
|
"wrong-type",
|
|
);
|
|
assert_eq!(wrong_type["code"], "invalid_value", "{wrong_type}");
|
|
|
|
let identity = execute(
|
|
&mut app,
|
|
json!({"op":"set_properties","collection":"entities","handle":handle,
|
|
"updates":[{"path":"/common/handle","value":99}]}),
|
|
"identity",
|
|
);
|
|
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,
|
|
"updates":[{"path":"/rotation","expected":1.0,"value":2.0}]}),
|
|
"compare",
|
|
);
|
|
assert_eq!(compare["code"], "invalid_update", "{compare}");
|
|
}
|
|
}
|