feat(mcp): expose entity creation schemas on demand

Expose creation fields, constraints and examples for the 11 supported types without expanding startup tool schemas. Share the creation contract and constructor with execution to keep accepted inputs consistent.

Validation: MCP, contract/factory and entity creation regression tests.
This commit is contained in:
Karim Jerbi committed 2026-10-13 09:33:49 +01:00
1 parent a5435a6df6
commit 97afb57333
10 files changed
+1118 -488

No files matched your search

Generated
+227 -2
View File
@@ -34,6 +34,7 @@ dependencies = [
"image",
"inventory",
"js-sys",
"jsonschema",
"kurbo 0.13.1",
"libloading",
"log",
@@ -129,6 +130,7 @@ dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
@@ -805,6 +807,12 @@ dependencies = [
"piper",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "brotli"
version = "8.0.4"
@@ -1363,6 +1371,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -1537,6 +1551,15 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1704,6 +1727,17 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fancy-regex"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fast-float2"
version = "0.2.4"
@@ -1835,6 +1869,17 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "fluent-uri"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1924,6 +1969,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
dependencies = [
"lazy_static",
"num",
]
[[package]]
name = "funty"
version = "2.0.0"
@@ -2296,6 +2351,8 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
@@ -3133,6 +3190,58 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonschema"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ec8a241beed129f06114aa68007e905ca350e7baeb6e17a7631bb7978d91b2"
dependencies = [
"ahash 0.8.12",
"bytecount",
"data-encoding",
"email_address",
"fancy-regex",
"fraction",
"getrandom 0.3.4",
"idna",
"itoa",
"jsonschema-regex",
"jsonschema-value",
"num-cmp",
"num-traits",
"percent-encoding",
"referencing",
"regex",
"serde",
"serde_json",
"strum",
"unicode-general-category",
"uuid-simd",
]
[[package]]
name = "jsonschema-regex"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb"
dependencies = [
"ahash 0.8.12",
"bytecount",
"fraction",
"num-cmp",
"num-traits",
"serde_json",
]
[[package]]
name = "kamadak-exif"
version = "0.6.1"
@@ -3464,6 +3573,12 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mime"
version = "0.3.17"
@@ -3696,6 +3811,20 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -3706,6 +3835,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -3742,6 +3877,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -4306,7 +4451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4333,6 +4478,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -5093,6 +5244,43 @@ dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "referencing"
version = "0.49.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278"
dependencies = [
"ahash 0.8.12",
"fluent-uri",
"getrandom 0.3.4",
"hashbrown 0.17.1",
"itoa",
"micromap",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -5898,6 +6086,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -5926,7 +6135,7 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c"
dependencies = [
"skrifa 0.37.0",
"skrifa 0.42.1",
"yazi",
"zeno",
]
@@ -6509,6 +6718,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "v_frame"
version = "0.3.9"
@@ -6573,6 +6792,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "walkdir"
version = "2.5.0"
+2
View File
@@ -20,6 +20,8 @@ winresource = "0.1"
[dependencies]
serde_json = "1"
# Offline validation of the same contracts published to automation clients.
jsonschema = { version = "0.49", default-features = false }
serde = { version = "1", features = ["derive"] }
calamine = "0.36.1"
base64 = "0.22"
+6
View File
@@ -4,6 +4,12 @@ Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_e
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.
Before creating an unfamiliar entity, use `ocs_read` with `op: entity_schema` and
`parameters.type` (for example, `Circle`) to get its creation fields, required
values, units, defaults and example. Omit type for the catalog. Creation schemas
describe the inputs to `entities_create`; `record_schema` describes stored records.
Schema discovery runs locally and can omit `ocs_session_id`.
For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. 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.
+9
View File
@@ -135,6 +135,15 @@ def legacy(server: Path) -> None:
assert not guide_tool["result"].get("isError"), guide_tool
assert guide_tool["result"]["structuredContent"]["ok"] is True
assert "resource_link" in guide_tool["result"]["structuredContent"]["contents"][0]["text"]
entity_schema = request(process, {
"jsonrpc": "2.0", "id": "entity-schema", "method": "tools/call",
"params": {"name": "ocs_read", "arguments": {
"op": "entity_schema", "parameters": {"type": "Circle"},
}},
})
assert not entity_schema["result"].get("isError"), entity_schema
contract = entity_schema["result"]["structuredContent"]["schema"]
assert contract["required"] == ["type", "center", "radius"]
close(process)
+2 -477
View File
@@ -5,161 +5,10 @@
use super::*;
use crate::command::EntityTransform;
use crate::entity_creation::{build_entity, deg_field, point_field};
type Parsed<T> = Result<T, Value>;
/// Parse `[x,y]` or `[x,y,z]` without silently turning a malformed
/// coordinate into zero.
fn point_value(value: Option<&Value>, label: &str, code: &str) -> Parsed<codec::types::Vector3> {
let values = value
.and_then(Value::as_array)
.filter(|v| (2..=3).contains(&v.len()))
.ok_or_else(|| failure(code, format!("{label} must be [x,y] or [x,y,z]")))?;
let coord = |i: usize| {
values[i]
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
};
let z = values
.get(2)
.map(|value| {
value
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
})
.transpose()?
.unwrap_or(0.0);
Ok(codec::types::Vector3::new(coord(0)?, coord(1)?, z))
}
/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`.
fn point_field(req: &Value, key: &str) -> Parsed<codec::types::Vector3> {
point_value(req.get(key), key, "invalid_point")
}
fn point_list_field(
spec: &Value,
key: &str,
minimum: usize,
) -> Parsed<Vec<codec::types::Vector3>> {
let points = spec[key]
.as_array()
.filter(|points| points.len() >= minimum)
.ok_or_else(|| {
failure(
"invalid_spline_points",
format!("{key} needs at least {minimum} points"),
)
})?;
points
.iter()
.enumerate()
.map(|(index, point)| {
point_value(
Some(point),
&format!("{key}[{index}]"),
"invalid_spline_points",
)
})
.collect()
}
fn finite_number_list_field(spec: &Value, key: &str) -> Parsed<Vec<f64>> {
let values = spec[key]
.as_array()
.filter(|values| !values.is_empty())
.ok_or_else(|| failure("invalid_spline_values", format!("{key} needs a non-empty array")))?;
values
.iter()
.enumerate()
.map(|(index, value)| {
value
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| {
failure(
"invalid_spline_values",
format!("{key}[{index}] must be finite"),
)
})
})
.collect()
}
fn deg_field(req: &Value, key: &str) -> f64 {
req[key].as_f64().unwrap_or(0.0).to_radians()
}
/// Reject fields that the typed builder would otherwise silently ignore.
fn validate_entity_fields(spec: &Value, kind: &str) -> Parsed<()> {
let Some(object) = spec.as_object() else {
return Ok(());
};
for field in object.keys() {
let allowed = match kind {
"LINE" => matches!(
field.as_str(),
"type" | "start" | "end" | "thickness" | "layer" | "color"
),
"CIRCLE" => matches!(field.as_str(), "type" | "center" | "radius" | "layer" | "color"),
"ARC" => matches!(
field.as_str(),
"type" | "center" | "radius" | "start_angle_deg" | "end_angle_deg" | "layer" | "color"
),
"SPLINE" => matches!(
field.as_str(),
"type" | "degree" | "knots" | "control_points" | "fit_points" | "weights"
| "closed" | "periodic" | "rational" | "planar" | "linear" | "layer" | "color"
),
"LWPOLYLINE" | "POLYLINE" => matches!(
field.as_str(),
"type" | "vertices" | "closed" | "constant_width" | "layer" | "color"
),
"POINT" => matches!(field.as_str(), "type" | "location" | "layer" | "color"),
"TEXT" => matches!(
field.as_str(),
"type" | "value" | "position" | "height" | "rotation_deg" | "style" | "layer" | "color"
),
"MTEXT" => matches!(
field.as_str(),
"type" | "value" | "position" | "height" | "width" | "rotation_deg" | "style" | "layer" | "color"
),
"INSERT" => matches!(
field.as_str(),
"type" | "block" | "position" | "scale" | "rotation_deg" | "layer" | "color"
),
"SOLID" => matches!(field.as_str(), "type" | "corners" | "layer" | "color"),
"HATCH" => matches!(
field.as_str(),
"type" | "boundary" | "solid" | "pattern" | "pattern_scale" | "pattern_angle_deg" | "layer" | "color"
),
_ => true,
};
if !allowed {
return Err(failure(
"unknown_entity_field",
format!("Unknown field '{field}' for {kind} entity"),
));
}
}
Ok(())
}
/// Required finite angle in degrees, converted to radians without a default.
fn finite_angle_field(spec: &Value, key: &str) -> Parsed<f64> {
let degrees = spec[key]
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))?;
let radians = degrees.to_radians();
radians
.is_finite()
.then_some(radians)
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))
}
pub(super) fn hex_handles(req: &Value, key: &str) -> Parsed<Vec<codec::Handle>> {
let list = req[key]
.as_array()
@@ -211,330 +60,6 @@ pub(super) fn require_existing(
}
}
fn apply_common_properties(spec: &Value, entity: &mut codec::EntityType) -> Parsed<()> {
if let Some(layer) = spec["layer"].as_str() {
if !layer.is_empty()
&& !matches!(
entity,
codec::EntityType::Block(_) | codec::EntityType::BlockEnd(_)
)
{
entity.common_mut().layer = layer.to_owned();
}
}
if let Some(index) = spec["color"].as_i64().and_then(|v| i16::try_from(v).ok()) {
entity.common_mut().color = codec::types::Color::from_index(index);
}
Ok(())
}
/// An explicit text style must exist in the current document; otherwise a
/// typo would silently fall back to the default style.
fn text_style_field(spec: &Value, document: &codec::CadDocument) -> Parsed<Option<String>> {
let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) else {
return Ok(None);
};
if !document.text_styles.contains(style) {
return Err(failure(
"invalid_text_style",
format!("Text style '{style}' does not exist in the document"),
));
}
Ok(Some(style.to_owned()))
}
/// One `entities` array entry → a real entity. Geometry validation happens
/// here so a bad definition aborts the whole batch before anything commits.
fn build_entity(spec: &Value, document: &codec::CadDocument) -> Parsed<codec::EntityType> {
use codec::entities::*;
use codec::types::Vector3;
let kind = spec["type"].as_str().unwrap_or("").to_ascii_uppercase();
validate_entity_fields(spec, &kind)?;
let entity = match kind.as_str() {
"LINE" => {
let mut line = Line::from_points(point_field(spec, "start")?, point_field(spec, "end")?);
if let Some(t) = spec["thickness"].as_f64() {
line.thickness = t;
}
EntityType::Line(line)
}
"CIRCLE" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r >= 0.0)
.ok_or_else(|| failure("invalid_radius", "circle needs a finite radius >= 0"))?;
EntityType::Circle(Circle::from_center_radius(point_field(spec, "center")?, radius))
}
"ARC" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r > 0.0)
.ok_or_else(|| failure("invalid_radius", "arc needs a finite radius > 0"))?;
let arc = Arc::from_center_radius_angles(
point_field(spec, "center")?,
radius,
finite_angle_field(spec, "start_angle_deg")?,
finite_angle_field(spec, "end_angle_deg")?,
);
if !arc.sweep_angle().is_finite() || arc.sweep_angle() <= 0.0 {
return Err(failure(
"invalid_sweep",
"arc start_angle_deg and end_angle_deg must define a finite non-zero sweep",
));
}
EntityType::Arc(arc)
}
"SPLINE" => {
let control_points = if spec.get("control_points").is_some() {
point_list_field(spec, "control_points", 2)?
} else {
Vec::new()
};
let fit_points = if spec.get("fit_points").is_some() {
point_list_field(spec, "fit_points", 2)?
} else {
Vec::new()
};
if control_points.is_empty() && fit_points.is_empty() {
return Err(failure(
"invalid_spline_points",
"spline needs control_points or fit_points (at least 2 points)",
));
}
let degree = match spec.get("degree") {
None | Some(Value::Null) => 3,
Some(value) => value
.as_i64()
.filter(|degree| (1..=10).contains(degree))
.ok_or_else(|| {
failure("invalid_spline_values", "degree must be an integer 1..10")
})? as i32,
};
let count = control_points.len();
if count > 0 && count <= degree as usize {
return Err(failure(
"invalid_spline_values",
format!("a degree {degree} spline needs more than {degree} control_points"),
));
}
let mut spline = if control_points.is_empty() {
let mut spline = Spline::from_fit_points(fit_points.clone());
spline.degree = degree;
spline
} else {
Spline::from_control_points(degree, control_points)
};
if !fit_points.is_empty() {
spline.fit_points = fit_points;
}
if spec.get("knots").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline knots require control_points",
));
}
let knots = finite_number_list_field(spec, "knots")?;
if knots.len() != count + degree as usize + 1
|| knots.windows(2).any(|pair| pair[1] < pair[0])
{
return Err(failure(
"invalid_spline_values",
format!(
"knots must be {} non-decreasing values (control_points + degree + 1)",
count + degree as usize + 1
),
));
}
spline.knots = knots;
}
if spec.get("weights").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline weights require control_points",
));
}
let weights = finite_number_list_field(spec, "weights")?;
if weights.len() != count || weights.iter().any(|weight| *weight <= 0.0) {
return Err(failure(
"invalid_spline_values",
format!("weights must be {count} positive values, one per control point"),
));
}
spline.weights = weights;
spline.flags.rational = true;
}
if let Some(value) = spec["closed"].as_bool() {
spline.flags.closed = value;
}
if let Some(value) = spec["periodic"].as_bool() {
spline.flags.periodic = value;
}
if let Some(value) = spec["rational"].as_bool() {
spline.flags.rational = value;
}
if let Some(value) = spec["planar"].as_bool() {
spline.flags.planar = value;
}
if let Some(value) = spec["linear"].as_bool() {
spline.flags.linear = value;
}
EntityType::Spline(spline)
}
"LWPOLYLINE" | "POLYLINE" => {
let vertices = spec["vertices"]
.as_array()
.filter(|v| v.len() >= 2)
.ok_or_else(|| failure("invalid_vertices", "polyline needs vertices:[[x,y],…]"))?;
let mut pline = LwPolyline::new();
for point in vertices {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
pline.add_point(codec::types::Vector2::new(x, y));
}
if spec["closed"].as_bool().unwrap_or(false) {
pline.close();
}
if let Some(width) = spec["constant_width"].as_f64() {
pline.constant_width = width;
}
EntityType::LwPolyline(pline)
}
"POINT" => EntityType::Point(Point::at(point_field(spec, "location")?)),
"TEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "text needs a non-empty value"));
}
let mut text = Text::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6))
.with_rotation(deg_field(spec, "rotation_deg"));
if let Some(style) = text_style_field(spec, document)? {
text.style = style;
}
EntityType::Text(text)
}
"MTEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "mtext needs a non-empty value"));
}
let mut mtext = MText::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6));
if let Some(width) = spec["width"].as_f64().filter(|w| *w > 0.0) {
mtext.rectangle_width = width;
}
mtext.rotation = deg_field(spec, "rotation_deg");
if let Some(style) = text_style_field(spec, document)? {
mtext.style = style;
}
EntityType::MText(mtext)
}
"INSERT" => {
let block = spec["block"].as_str().unwrap_or("");
if block.is_empty() {
return Err(failure("block_required", "insert needs a block name"));
}
let mut insert = Insert::new(block, point_field(spec, "position")?)
.with_rotation(deg_field(spec, "rotation_deg"));
match &spec["scale"] {
Value::Number(factor) => {
let factor = factor.as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale must be nonzero")
})?;
insert = insert.with_uniform_scale(factor);
}
Value::Array(factors) if factors.len() == 3 => {
let axis = |i: usize| {
factors[i].as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale components must be nonzero")
})
};
insert = insert.with_scale(axis(0)?, axis(1)?, axis(2)?);
}
_ => {}
}
EntityType::Insert(insert)
}
"SOLID" => {
let raw_corners = spec["corners"]
.as_array()
.filter(|v| (3..=4).contains(&v.len()))
.ok_or_else(|| failure("invalid_corners", "solid needs 3 or 4 corners"))?;
let corners: Vec<Vector3> = raw_corners
.iter()
.enumerate()
.map(|(index, value)| {
if value.as_array().is_none_or(|values| values.len() != 3) {
return Err(failure(
"invalid_corners",
format!("corners[{index}] must be [x,y,z]"),
));
}
point_value(
Some(value),
&format!("corners[{index}]"),
"invalid_corners",
)
})
.collect::<Parsed<_>>()?;
let solid = match corners.as_slice() {
[first, second, third] => Solid::triangle(*first, *second, *third),
[first, second, third, fourth] => Solid::new(*first, *second, *third, *fourth),
_ => unreachable!("corner count was validated above"),
};
EntityType::Solid(solid)
}
"HATCH" => {
let boundary = spec["boundary"]
.as_array()
.filter(|v| v.len() >= 3)
.ok_or_else(|| failure("invalid_boundary", "hatch needs boundary:[[x,y],…] (>= 3 points)"))?;
let mut vertices = Vec::with_capacity(boundary.len());
for point in boundary {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
vertices.push(codec::types::Vector2::new(x, y));
}
let mut hatch = Hatch::new();
if spec["solid"].as_bool().unwrap_or(false) {
hatch.is_solid = true;
} else {
hatch.is_solid = false;
hatch.pattern = HatchPattern::new(
spec["pattern"].as_str().unwrap_or("ANSI31"),
);
hatch.pattern_scale = spec["pattern_scale"].as_f64().unwrap_or(1.0).abs().max(1e-6);
hatch.pattern_angle = deg_field(spec, "pattern_angle_deg");
}
let mut path = BoundaryPath::external();
path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(vertices, true)));
hatch.paths.push(path);
EntityType::Hatch(hatch)
}
other => {
return Err(failure(
"unknown_entity_type",
format!(
"Unknown entity type '{other}'. Supported: Line, Circle, Arc, Spline, LwPolyline, Point, Text, MText, Insert, Solid, Hatch"
),
))
}
};
let mut entity = entity;
apply_common_properties(spec, &mut entity)?;
Ok(entity)
}
impl OpenCADStudio {
/// `entities_create` — build a batch of typed entities, auto-create the
/// layers they name, and commit everything as one undoable step. All definitions are validated before the first
@@ -547,7 +72,7 @@ impl OpenCADStudio {
let built: Vec<codec::EntityType> = {
let document = &self.tabs[self.active_tab].scene.document;
list.iter()
.map(|spec| build_entity(spec, document))
.map(|spec| build_entity(spec, Some(document)))
.collect::<Parsed<_>>()?
};
+744
View File
@@ -0,0 +1,744 @@
//! Discoverable creation contracts, shared by MCP preflight and the entity builder.
use serde_json::{json, Value};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn published_examples_build_with_the_real_factory() {
let document = codec::CadDocument::new();
for kind in TYPES {
let contract = schema(kind).unwrap();
for example in contract["examples"].as_array().unwrap() {
validate_entity(example, "/entities/0").unwrap();
let built = build_entity(example, Some(&document));
assert!(built.is_ok(), "{kind}: {built:?}");
}
}
for name in ["line", "LINE", "polyline", "Polyline", "LWPOLYLINE"] {
let mut example = schema(name).unwrap()["examples"][0].clone();
example["type"] = json!(name);
validate_entity(&example, "").unwrap();
assert!(build_entity(&example, Some(&document)).is_ok());
}
// JSON Schema integers include numerically integral float notation.
let circle = json!({"type":"Circle","center":[0,0],"radius":2,"color":2.0});
validate_entity(&circle, "").unwrap();
assert_eq!(
build_entity(&circle, Some(&document))
.unwrap()
.common()
.color,
codec::types::Color::from_index(2)
);
let spline = json!({"type":"Spline","degree":2.0,"control_points":[[0,0],[5,10],[10,0]]});
validate_entity(&spline, "").unwrap();
let codec::EntityType::Spline(built) = build_entity(&spline, Some(&document)).unwrap()
else {
panic!("expected spline")
};
assert_eq!(built.degree, 2);
}
#[test]
fn rejects_bad_nested_fields_and_matches_geometry_constraints() {
let cases = [
(
json!({"type":"Line","start":[0,0],"end":[1,"bad"]}),
"/end/1",
),
(json!({"type":"Circle","center":[0,0]}), "/radius"),
(
json!({"type":"Circle","center":[0,0],"radius":-1}),
"/radius",
),
(
json!({"type":"Solid","corners":[[0,0],[1,0],[0,1]]}),
"/corners/0",
),
(
json!({"type":"Point","location":[0,0],"color":32768}),
"/color",
),
(
json!({"type":"Line","start":[0,0],"end":[1,1],"unsupported":true}),
"/unsupported",
),
];
for (spec, path) in cases {
let error = validate_entity(&spec, "/entities/0").unwrap_err();
assert!(error.contains(&format!("/entities/0{path}")), "{error}");
assert!(build_entity(&spec, None).is_err());
}
for spec in [
json!({"type":"Arc","center":[0,0],"radius":5,"start_angle_deg":45,"end_angle_deg":45}),
json!({"type":"Spline","degree":3,"control_points":[[0,0],[1,1]]}),
json!({"type":"Spline","degree":1,"control_points":[[0,0],[1,1]],"weights":[1]}),
json!({"type":"Insert","block":"Example","position":[0,0],"scale":0}),
] {
assert!(validate_entity(&spec, "").is_err());
assert!(build_entity(&spec, None).is_err());
}
// The static path deliberately omits document-dependent style lookup.
let spec = json!({"type":"Text","value":"Label","position":[0,0],"style":"missing_style"});
assert!(validate_entity(&spec, "").is_ok());
assert_eq!(
build_entity(&spec, Some(&codec::CadDocument::new())).unwrap_err()["code"],
"invalid_text_style"
);
}
}
pub const TYPES: &[&str] = &[
"Line",
"Circle",
"Arc",
"Spline",
"LwPolyline",
"Point",
"Text",
"MText",
"Insert",
"Solid",
"Hatch",
];
fn point(min: usize, max: usize) -> Value {
json!({"type":"array","items":{"type":"number"},"minItems":min,"maxItems":max})
}
/// Contracts are built once and shared by discovery, preflight and execution.
pub fn schema(name: &str) -> Result<Value, String> {
use std::sync::OnceLock;
static CONTRACTS: OnceLock<Vec<Value>> = OnceLock::new();
let name = if name.eq_ignore_ascii_case("Polyline") {
"LwPolyline"
} else {
name
};
let index = TYPES.iter().position(|kind| kind.eq_ignore_ascii_case(name))
.ok_or_else(|| format!("/type: unknown entity type '{name}'; request entity_schema without type to list supported types"))?;
let contracts = CONTRACTS.get_or_init(|| {
TYPES
.iter()
.map(|kind| build_schema(kind).expect("known type"))
.collect()
});
Ok(contracts[index].clone())
}
fn build_schema(name: &str) -> Result<Value, String> {
let name = if name.eq_ignore_ascii_case("Polyline") {
"LwPolyline"
} else {
name
};
let canonical = TYPES.iter().find(|t| t.eq_ignore_ascii_case(name))
.ok_or_else(|| format!("/type: unknown entity type '{name}'; request entity_schema without type to list supported types"))?;
let case_pattern = |name: &str| {
name.chars()
.map(|c| format!("[{}{}]", c.to_ascii_uppercase(), c.to_ascii_lowercase()))
.collect::<String>()
};
let pattern = if *canonical == "LwPolyline" {
format!(
"^({}|{})$",
case_pattern(canonical),
case_pattern("Polyline")
)
} else {
format!("^{}$", case_pattern(canonical))
};
let number = json!({"type":"number"});
let string = json!({"type":"string"});
let boolean = json!({"type":"boolean"});
let mut properties = json!({"type":{"type":"string","pattern":pattern},"layer":{"type":"string","description":"Missing layers are created; empty uses the default layer."},"color":{"type":"integer","minimum":-32768,"maximum":32767,"description":"DXF indexed color, including ByLayer/ByBlock sentinels."}});
let (required, example) = match *canonical {
"Line" => {
properties["start"] = point(2, 3);
properties["end"] = point(2, 3);
properties["thickness"] = number.clone();
(
vec!["start", "end"],
json!({"type":"Line","start":[0,0],"end":[10,10]}),
)
}
"Circle" | "Arc" => {
properties["center"] = point(2, 3);
properties["radius"] = if *canonical == "Arc" {
json!({"type":"number","exclusiveMinimum":0})
} else {
json!({"type":"number","minimum":0})
};
if *canonical == "Arc" {
properties["start_angle_deg"] = number.clone();
properties["end_angle_deg"] = number.clone();
properties["end_angle_deg"]["description"] = json!("Together with start_angle_deg, must define a finite, nonzero sweep using the CAD arc constructor.");
(
vec!["center", "radius", "start_angle_deg", "end_angle_deg"],
json!({"type":"Arc","center":[0,0],"radius":5,"start_angle_deg":0,"end_angle_deg":90}),
)
} else {
(
vec!["center", "radius"],
json!({"type":"Circle","center":[0,0],"radius":5}),
)
}
}
"Point" => {
properties["location"] = point(2, 3);
(vec!["location"], json!({"type":"Point","location":[0,0]}))
}
"LwPolyline" => {
properties["vertices"] = json!({"type":"array","minItems":2,"items":point(2,3),"description":"XY coordinates; optional Z is ignored."});
properties["closed"] = boolean.clone();
properties["constant_width"] = number.clone();
(
vec!["vertices"],
json!({"type":"LwPolyline","vertices":[[0,0],[10,0]],"closed":false}),
)
}
"Text" | "MText" => {
properties["value"] = json!({"type":"string","minLength":1});
properties["position"] = point(2, 3);
properties["height"] = json!({"type":"number","default":2.5,"description":"Absolute value, clamped to at least 0.000001."});
properties["rotation_deg"] = number.clone();
properties["style"] = json!({"type":"string","description":"Nonempty style names must exist in the target document."});
if *canonical == "MText" {
properties["width"] = json!({"type":"number","description":"Positive values set rectangle width; otherwise default."});
}
(
vec!["value", "position"],
json!({"type":canonical,"value":"Label","position":[0,0]}),
)
}
"Insert" => {
properties["block"] = json!({"type":"string","minLength":1});
properties["position"] = point(2, 3);
properties["rotation_deg"] = number.clone();
properties["scale"] = json!({"anyOf":[{"type":"number","not":{"const":0}},{"type":"array","minItems":3,"maxItems":3,"items":{"type":"number","not":{"const":0}}}],"default":1,"description":"Nonzero uniform factor or three nonzero axis factors."});
(
vec!["block", "position"],
json!({"type":"Insert","block":"DOOR_90","position":[0,0],"scale":1}),
)
}
"Solid" => {
properties["corners"] =
json!({"type":"array","minItems":3,"maxItems":4,"items":point(3,3)});
(
vec!["corners"],
json!({"type":"Solid","corners":[[0,0,0],[10,0,0],[0,10,0]]}),
)
}
"Hatch" => {
properties["boundary"] = json!({"type":"array","minItems":3,"items":point(2,3),"description":"Closed XY boundary; optional Z is ignored."});
properties["solid"] = boolean.clone();
properties["pattern"] = string;
properties["pattern_scale"] = json!({"type":"number","default":1,"description":"Absolute value, clamped to at least 0.000001."});
properties["pattern_angle_deg"] = number.clone();
(
vec!["boundary"],
json!({"type":"Hatch","boundary":[[0,0],[10,0],[0,10]],"solid":true}),
)
}
"Spline" => {
properties["degree"] = json!({"type":"integer","minimum":1,"maximum":10,"default":3});
for key in ["control_points", "fit_points"] {
properties[key] = json!({"type":"array","minItems":2,"items":point(2,3)});
}
for key in ["knots", "weights"] {
properties[key] = json!({"type":"array","minItems":1,"items":{"type":"number"}});
}
properties["control_points"]["description"] =
json!("Requires more points than degree (default degree 3).");
properties["knots"]["description"] = json!("Requires control_points; exactly control_points count + degree + 1 non-decreasing values.");
properties["weights"]["description"] = json!("Requires control_points; one positive weight per control point. Sets rational=true unless overridden explicitly.");
properties["weights"]["items"]["exclusiveMinimum"] = json!(0);
for key in ["closed", "periodic", "rational", "planar", "linear"] {
properties[key] = boolean.clone();
}
(
vec![],
json!({"type":"Spline","degree":2,"control_points":[[0,0],[5,10],[10,0]]}),
)
}
_ => unreachable!(),
};
let mut required = required;
required.insert(0, "type");
let mut result = json!({"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":properties,"required":required,"additionalProperties":false,"examples":[example],"description":"Creation input, not serialized record properties. Type names are case-insensitive; Polyline aliases LwPolyline. Angles are degrees; coordinates and lengths use drawing units. Additional geometric constraints are checked by preflight and the builder."});
if *canonical == "Spline" {
result["anyOf"] = json!([{"required":["control_points"]},{"required":["fit_points"]}]);
}
Ok(result)
}
pub fn validate_entity(spec: &Value, path: &str) -> Result<(), String> {
let kind = spec["type"]
.as_str()
.ok_or_else(|| format!("{path}/type: expected entity type string"))?;
let contract = schema(kind).map_err(|e| format!("{path}{e}"))?;
crate::schema_validation::validate_shape(&contract, spec, path)?;
build_entity(spec, None).map(|_| ()).map_err(|error| {
format!(
"{path}{}: {}",
error["path"].as_str().unwrap_or(""),
error["error"].as_str().unwrap_or("invalid entity")
)
})
}
type Parsed<T> = Result<T, Value>;
fn failure_at(code: &str, path: &str, error: impl ToString) -> Value {
let mut result = failure(code, error);
result["path"] = json!(path);
result
}
fn failure(code: &str, error: impl ToString) -> Value {
json!({"ok":false,"status":"failed","code":code,"error":error.to_string()})
}
/// Parse `[x,y]` or `[x,y,z]` without silently turning a malformed
/// coordinate into zero.
fn point_value(value: Option<&Value>, label: &str, code: &str) -> Parsed<codec::types::Vector3> {
let values = value
.and_then(Value::as_array)
.filter(|v| (2..=3).contains(&v.len()))
.ok_or_else(|| failure(code, format!("{label} must be [x,y] or [x,y,z]")))?;
let coord = |i: usize| {
values[i]
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
};
let z = values
.get(2)
.map(|value| {
value
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
})
.transpose()?
.unwrap_or(0.0);
Ok(codec::types::Vector3::new(coord(0)?, coord(1)?, z))
}
/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`.
pub(crate) fn point_field(req: &Value, key: &str) -> Parsed<codec::types::Vector3> {
point_value(req.get(key), key, "invalid_point")
}
fn point_list_field(spec: &Value, key: &str, minimum: usize) -> Parsed<Vec<codec::types::Vector3>> {
let points = spec[key]
.as_array()
.filter(|points| points.len() >= minimum)
.ok_or_else(|| {
failure(
"invalid_spline_points",
format!("{key} needs at least {minimum} points"),
)
})?;
points
.iter()
.enumerate()
.map(|(index, point)| {
point_value(
Some(point),
&format!("{key}[{index}]"),
"invalid_spline_points",
)
})
.collect()
}
fn finite_number_list_field(spec: &Value, key: &str) -> Parsed<Vec<f64>> {
let values = spec[key]
.as_array()
.filter(|values| !values.is_empty())
.ok_or_else(|| {
failure(
"invalid_spline_values",
format!("{key} needs a non-empty array"),
)
})?;
values
.iter()
.enumerate()
.map(|(index, value)| {
value
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| {
failure(
"invalid_spline_values",
format!("{key}[{index}] must be finite"),
)
})
})
.collect()
}
pub(crate) fn deg_field(req: &Value, key: &str) -> f64 {
req[key].as_f64().unwrap_or(0.0).to_radians()
}
/// Required finite angle in degrees, converted to radians without a default.
fn finite_angle_field(spec: &Value, key: &str) -> Parsed<f64> {
let degrees = spec[key]
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))?;
let radians = degrees.to_radians();
radians
.is_finite()
.then_some(radians)
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))
}
fn apply_common_properties(spec: &Value, entity: &mut codec::EntityType) -> Parsed<()> {
if let Some(layer) = spec["layer"].as_str() {
if !layer.is_empty()
&& !matches!(
entity,
codec::EntityType::Block(_) | codec::EntityType::BlockEnd(_)
)
{
entity.common_mut().layer = layer.to_owned();
}
}
if let Some(index) = spec["color"]
.as_f64()
.filter(|n| n.fract() == 0.0 && (-32768.0..=32767.0).contains(n))
{
entity.common_mut().color = codec::types::Color::from_index(index as i16);
}
Ok(())
}
/// An explicit text style must exist in the current document; otherwise a
/// typo would silently fall back to the default style.
fn text_style_field(spec: &Value, document: Option<&codec::CadDocument>) -> Parsed<Option<String>> {
let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) else {
return Ok(None);
};
if document.is_some_and(|document| !document.text_styles.contains(style)) {
return Err(failure(
"invalid_text_style",
format!("Text style '{style}' does not exist in the document"),
));
}
Ok(Some(style.to_owned()))
}
/// One `entities` array entry → a real entity. Geometry validation happens
/// here so a bad definition aborts the whole batch before anything commits.
pub(crate) fn build_entity(
spec: &Value,
document: Option<&codec::CadDocument>,
) -> Parsed<codec::EntityType> {
use codec::entities::*;
use codec::types::Vector3;
let kind = spec["type"].as_str().unwrap_or("").to_ascii_uppercase();
// Preserve the public unknown-field error while deriving allowed keys from
// the published contract, rather than maintaining another field list.
if let (Some(object), Ok(schema)) = (spec.as_object(), schema(&kind)) {
for field in object.keys() {
if schema["properties"].get(field).is_none() {
return Err(failure(
"unknown_entity_field",
format!("Unknown field '{field}' for {kind} entity"),
));
}
}
}
let entity = match kind.as_str() {
"LINE" => {
let mut line = Line::from_points(point_field(spec, "start")?, point_field(spec, "end")?);
if let Some(t) = spec["thickness"].as_f64() {
line.thickness = t;
}
EntityType::Line(line)
}
"CIRCLE" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r >= 0.0)
.ok_or_else(|| failure("invalid_radius", "circle needs a finite radius >= 0"))?;
EntityType::Circle(Circle::from_center_radius(point_field(spec, "center")?, radius))
}
"ARC" => {
let radius = spec["radius"]
.as_f64()
.filter(|r| r.is_finite() && *r > 0.0)
.ok_or_else(|| failure("invalid_radius", "arc needs a finite radius > 0"))?;
let arc = Arc::from_center_radius_angles(
point_field(spec, "center")?,
radius,
finite_angle_field(spec, "start_angle_deg")?,
finite_angle_field(spec, "end_angle_deg")?,
);
if !arc.sweep_angle().is_finite() || arc.sweep_angle() <= 0.0 {
return Err(failure_at(
"invalid_sweep", "/end_angle_deg",
"arc start_angle_deg and end_angle_deg must define a finite non-zero sweep",
));
}
EntityType::Arc(arc)
}
"SPLINE" => {
let control_points = if spec.get("control_points").is_some() {
point_list_field(spec, "control_points", 2)?
} else {
Vec::new()
};
let fit_points = if spec.get("fit_points").is_some() {
point_list_field(spec, "fit_points", 2)?
} else {
Vec::new()
};
if control_points.is_empty() && fit_points.is_empty() {
return Err(failure(
"invalid_spline_points",
"spline needs control_points or fit_points (at least 2 points)",
));
}
let degree = match spec.get("degree") {
None | Some(Value::Null) => 3,
Some(value) => value
.as_f64()
.filter(|degree| degree.fract() == 0.0 && (1.0..=10.0).contains(degree))
.ok_or_else(|| {
failure("invalid_spline_values", "degree must be an integer 1..10")
})? as i32,
};
let count = control_points.len();
if count > 0 && count <= degree as usize {
return Err(failure_at(
"invalid_spline_values", "/control_points",
format!("a degree {degree} spline needs more than {degree} control_points"),
));
}
let mut spline = if control_points.is_empty() {
let mut spline = Spline::from_fit_points(fit_points.clone());
spline.degree = degree;
spline
} else {
Spline::from_control_points(degree, control_points)
};
if !fit_points.is_empty() {
spline.fit_points = fit_points;
}
if spec.get("knots").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline knots require control_points",
));
}
let knots = finite_number_list_field(spec, "knots")?;
if knots.len() != count + degree as usize + 1
|| knots.windows(2).any(|pair| pair[1] < pair[0])
{
return Err(failure_at(
"invalid_spline_values", "/knots",
format!(
"knots must be {} non-decreasing values (control_points + degree + 1)",
count + degree as usize + 1
),
));
}
spline.knots = knots;
}
if spec.get("weights").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline weights require control_points",
));
}
let weights = finite_number_list_field(spec, "weights")?;
if weights.len() != count || weights.iter().any(|weight| *weight <= 0.0) {
return Err(failure_at(
"invalid_spline_values", "/weights",
format!("weights must be {count} positive values, one per control point"),
));
}
spline.weights = weights;
spline.flags.rational = true;
}
if let Some(value) = spec["closed"].as_bool() {
spline.flags.closed = value;
}
if let Some(value) = spec["periodic"].as_bool() {
spline.flags.periodic = value;
}
if let Some(value) = spec["rational"].as_bool() {
spline.flags.rational = value;
}
if let Some(value) = spec["planar"].as_bool() {
spline.flags.planar = value;
}
if let Some(value) = spec["linear"].as_bool() {
spline.flags.linear = value;
}
EntityType::Spline(spline)
}
"LWPOLYLINE" | "POLYLINE" => {
let vertices = spec["vertices"]
.as_array()
.filter(|v| v.len() >= 2)
.ok_or_else(|| failure("invalid_vertices", "polyline needs vertices:[[x,y],…]"))?;
let mut pline = LwPolyline::new();
for point in vertices {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_vertices", "polyline vertices must be finite [x,y]")
})?;
pline.add_point(codec::types::Vector2::new(x, y));
}
if spec["closed"].as_bool().unwrap_or(false) {
pline.close();
}
if let Some(width) = spec["constant_width"].as_f64() {
pline.constant_width = width;
}
EntityType::LwPolyline(pline)
}
"POINT" => EntityType::Point(Point::at(point_field(spec, "location")?)),
"TEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "text needs a non-empty value"));
}
let mut text = Text::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6))
.with_rotation(deg_field(spec, "rotation_deg"));
if let Some(style) = text_style_field(spec, document)? {
text.style = style;
}
EntityType::Text(text)
}
"MTEXT" => {
let value = spec["value"].as_str().unwrap_or("");
if value.is_empty() {
return Err(failure("invalid_text", "mtext needs a non-empty value"));
}
let mut mtext = MText::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6));
if let Some(width) = spec["width"].as_f64().filter(|w| *w > 0.0) {
mtext.rectangle_width = width;
}
mtext.rotation = deg_field(spec, "rotation_deg");
if let Some(style) = text_style_field(spec, document)? {
mtext.style = style;
}
EntityType::MText(mtext)
}
"INSERT" => {
let block = spec["block"].as_str().unwrap_or("");
if block.is_empty() {
return Err(failure("block_required", "insert needs a block name"));
}
let mut insert = Insert::new(block, point_field(spec, "position")?)
.with_rotation(deg_field(spec, "rotation_deg"));
match &spec["scale"] {
Value::Number(factor) => {
let factor = factor.as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale must be nonzero")
})?;
insert = insert.with_uniform_scale(factor);
}
Value::Array(factors) if factors.len() == 3 => {
let axis = |i: usize| {
factors[i].as_f64().filter(|f| *f != 0.0).ok_or_else(|| {
failure("invalid_scale", "insert scale components must be nonzero")
})
};
insert = insert.with_scale(axis(0)?, axis(1)?, axis(2)?);
}
_ => {}
}
EntityType::Insert(insert)
}
"SOLID" => {
let raw_corners = spec["corners"]
.as_array()
.filter(|v| (3..=4).contains(&v.len()))
.ok_or_else(|| failure("invalid_corners", "solid needs 3 or 4 corners"))?;
let corners: Vec<Vector3> = raw_corners
.iter()
.enumerate()
.map(|(index, value)| {
if value.as_array().is_none_or(|values| values.len() != 3) {
return Err(failure(
"invalid_corners",
format!("corners[{index}] must be [x,y,z]"),
));
}
point_value(
Some(value),
&format!("corners[{index}]"),
"invalid_corners",
)
})
.collect::<Parsed<_>>()?;
let solid = match corners.as_slice() {
[first, second, third] => Solid::triangle(*first, *second, *third),
[first, second, third, fourth] => Solid::new(*first, *second, *third, *fourth),
_ => unreachable!("corner count was validated above"),
};
EntityType::Solid(solid)
}
"HATCH" => {
let boundary = spec["boundary"]
.as_array()
.filter(|v| v.len() >= 3)
.ok_or_else(|| failure("invalid_boundary", "hatch needs boundary:[[x,y],…] (>= 3 points)"))?;
let mut vertices = Vec::with_capacity(boundary.len());
for point in boundary {
let x = point[0].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
let y = point[1].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
failure("invalid_boundary", "hatch boundary must be finite [x,y]")
})?;
vertices.push(codec::types::Vector2::new(x, y));
}
let mut hatch = Hatch::new();
if spec["solid"].as_bool().unwrap_or(false) {
hatch.is_solid = true;
} else {
hatch.is_solid = false;
hatch.pattern = HatchPattern::new(
spec["pattern"].as_str().unwrap_or("ANSI31"),
);
hatch.pattern_scale = spec["pattern_scale"].as_f64().unwrap_or(1.0).abs().max(1e-6);
hatch.pattern_angle = deg_field(spec, "pattern_angle_deg");
}
let mut path = BoundaryPath::external();
path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(vertices, true)));
hatch.paths.push(path);
EntityType::Hatch(hatch)
}
other => {
return Err(failure(
"unknown_entity_type",
format!(
"Unknown entity type '{other}'. Supported: Line, Circle, Arc, Spline, LwPolyline, Point, Text, MText, Insert, Solid, Hatch"
),
))
}
};
let mut entity = entity;
apply_common_properties(spec, &mut entity)?;
let contract = schema(&kind).expect("supported factory type");
crate::schema_validation::validate_shape(&contract, spec, "")
.map_err(|error| failure("invalid_entity_definition", error))?;
Ok(entity)
}
+2
View File
@@ -8,6 +8,7 @@ pub mod command;
pub mod config;
pub mod discussions;
pub mod entities;
pub(crate) mod entity_creation;
#[cfg(not(target_arch = "wasm32"))]
pub mod gpu_backend;
pub mod i18n;
@@ -27,6 +28,7 @@ pub mod plugin;
#[cfg(not(target_arch = "wasm32"))]
pub mod rest;
pub mod scene;
pub(crate) mod schema_validation;
pub mod snap;
pub mod sys;
pub mod sysinfo;
+68 -5
View File
@@ -31,7 +31,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
///
/// `block_delete` gained its optional `force` parameter.
#[cfg(test)]
const TOOL_SCHEMA_DIGEST: &str = "cf3b464a7664d78dfaa5d68a3c059b78de638884b95d189f955f6a0928909544";
const TOOL_SCHEMA_DIGEST: &str = "8b63b7c6860820cfb05b69d634c7897dec202ad3c7d94af5518f448ff5c8c1b3";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;
@@ -39,7 +39,7 @@ const CACHE_TTL_MS: u64 = 3_600_000;
/// land. Tools and discovery are static by comparison, so they keep the hour.
const RESOURCE_TTL_MS: u64 = 60_000;
const TASK_TTL_MS: u64 = 3_600_000;
pub(crate) const INSTRUCTIONS: &str = "Call ocs_sessions and pass session_id as ocs_session_id. Read capabilities and record_schema before unfamiliar operations. Preserve document_id, revision and request_id. After a timeout query the existing operation; never replay a mutation with a new request_id. running and waiting_input are not completion: follow state.command.accepts, options and input_example. Let the geometry kernel calculate geometry. Before delivery audit the target format/version and use save_verified with an absolute path. Announce the bridge build on first connection and when it changes. Use batch with response_detail: summary for known steps; bindings can reuse earlier results. Read guides on demand via cad://guides/{name} or ocs_read op: guide with parameters.name: workflow, architecture, capture, batch-bindings. Read architecture before floorplan vectorization and capture before advanced visual verification. Omit parameters.name to list guides.";
pub(crate) const INSTRUCTIONS: &str = "Call ocs_sessions and pass session_id as ocs_session_id. Read capabilities and record_schema before unfamiliar operations. Use entity_schema for creation fields. Preserve document_id, revision and request_id. After a timeout query the existing operation; never replay a mutation with a new request_id. running and waiting_input are not completion: follow state.command.accepts, options and input_example. Let the geometry kernel calculate geometry. Before delivery audit the target format/version and use save_verified with an absolute path. Announce the bridge build on first connection and when it changes. Use batch with response_detail: summary for known steps; bindings can reuse earlier results. Read guides on demand via cad://guides/{name} or ocs_read op: guide with parameters.name: workflow, architecture, capture, batch-bindings. Read architecture before floorplan vectorization and capture before advanced visual verification. Omit parameters.name to list guides.";
const READ_OPS: &[&str] = &[
"state",
"hello",
@@ -61,6 +61,7 @@ const READ_OPS: &[&str] = &[
"operation",
"batch_result",
"guide",
"entity_schema",
"xdata_get",
"audit",
"text_search",
@@ -2066,6 +2067,16 @@ fn call_tool(
Ok(Value::Array(sessions(launch).map_err(CallError::Infra)?))
}
"ocs_read" => {
if arguments["op"] == "entity_schema" {
return match arguments["parameters"]["type"].as_str() {
Some(kind) => Ok(
json!({"ok":true,"schema":crate::entity_creation::schema(kind)?,"validation":"Creation inputs are checked by the shared entity constructor; live document checks run on execution."}),
),
None => Ok(
json!({"ok":true,"types":crate::entity_creation::TYPES,"aliases":{"Polyline":"LwPolyline"},"next":"Set parameters.type for one creation schema and example."}),
),
};
}
let session_id = required_string(arguments, "ocs_session_id")?;
let op = arguments["op"].as_str().unwrap_or("state");
if !READ_OPS.contains(&op) {
@@ -2536,8 +2547,8 @@ pub(crate) fn tool_definitions() -> Value {
},
{
"name":"ocs_read",
"description":"Use guide with parameters.name to read workflow, architecture, capture or batch-bindings guidance (omit name to list). 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. Use batch_result with parameters.request_id to retrieve retained batch details without executing steps; available only in the originating MCP connection, for up to 64 retained batches per 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."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"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."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"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":"Use entity_schema with parameters.type for creation fields/examples; omit type to list. Use guide with parameters.name to read workflow, architecture, capture or batch-bindings guidance (omit name to list). 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. Use batch_result with parameters.request_id to retrieve retained batch details without executing steps; available only in the originating MCP connection, for up to 64 retained batches per 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."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"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."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"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}},"anyOf":[{"required":["ocs_session_id"]},{"required":["op"],"properties":{"op":{"enu Line truncated
"outputSchema":read_output_schema(),
"title":"Read OCS state","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
@@ -3245,11 +3256,21 @@ mod tests {
assert!(tool["inputSchema"]["properties"]
.get("session_id")
.is_none());
}
for tool in [&tools[2], &tools[3]] {
assert!(tool["inputSchema"]["required"]
.as_array()
.unwrap()
.contains(&json!("ocs_session_id")));
}
let read_schema = &tools[1]["inputSchema"];
for op in ["entity_schema"] {
crate::schema_validation::validate_shape(read_schema, &json!({"op":op}), "").unwrap();
}
assert!(
crate::schema_validation::validate_shape(read_schema, &json!({"op":"state"}), "")
.is_err()
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["required"],
json!(["op", "request_id"])
@@ -3297,7 +3318,7 @@ mod tests {
assert!(EXECUTE_OPS.contains(&"set_properties"));
assert!(EXECUTE_OPS.contains(&"save_verified"));
assert!(EXECUTE_OPS.contains(&"text_replace"));
assert!(BATCH_STEP_OPS.contains(&"text_replace"));
assert!(crate::mcp_ops::find_op("text_replace").unwrap().batchable);
let save_var = find_variant("save_verified");
assert_eq!(save_var["properties"]["target_version"]["enum"][0], "R14");
assert_eq!(
@@ -4577,6 +4598,48 @@ mod tests {
assert!(after * 100 < before * 70, "expected at least 30% reduction");
}
#[test]
fn entity_creation_schemas_are_local_and_specific() {
let mut clients = HashMap::new();
let mut resources = ResourceStore::default();
let catalog = call_tool(
"ocs_read",
&json!({"op":"entity_schema"}),
&mut clients,
&mut resources,
&mut detached_pump(),
None,
)
.unwrap();
assert_eq!(catalog["types"].as_array().unwrap().len(), 11);
assert!(catalog.get("schema").is_none());
let circle = call_tool(
"ocs_read",
&json!({"op":"entity_schema","parameters":{"type":"Circle"}}),
&mut clients,
&mut resources,
&mut detached_pump(),
None,
)
.unwrap();
assert_eq!(
circle["schema"]["required"],
json!(["type", "center", "radius"])
);
crate::schema_validation::validate_shape(&read_output_schema(), &circle, "").unwrap();
assert!(clients.is_empty());
let rejected = call_tool(
"ocs_execute",
&json!({"ocs_session_id":"offline","request":{"op":"entities_create","request_id":"invalid","entities":[{"type":"Circle","center":[0,0],"radius":-1}]}}),
&mut clients,
&mut resources,
&mut detached_pump(),
None,
);
assert!(rejected.is_err());
assert!(clients.is_empty());
}
#[test]
fn guides_are_discoverable_without_a_gui_session() {
assert!(INSTRUCTIONS.len() < 1100);
+16 -4
View File
@@ -39,6 +39,8 @@ pub enum Ty {
EnumCi(&'static [&'static str]),
ActionName,
ArrayOf(&'static Ty),
/// Entity definitions with on-demand creation schemas.
Entities,
/// Escape hatch: a JSON Schema fragment as text.
Raw(&'static str),
}
@@ -409,10 +411,8 @@ pub static ENTITIES_CREATE: OpDef = OpDef {
batchable: true,
params: &[req(
"entities",
Ty::ArrayOf(&Ty::Raw(
r#"{"type":"object","properties":{"type":{"type":"string"}},"required":["type"]}"#,
)),
"List of entity definitions to create.",
Ty::Entities,
"Entity definitions. Read ocs_read op entity_schema with parameters.type for fields and examples before creating an unfamiliar type.",
)],
rules: &[],
example: r#"{"op":"entities_create","entities":[{"type":"Line","start":[0,0],"end":[10,10]}]}"#,
@@ -930,6 +930,9 @@ fn ty_schema(ty: &Ty) -> Value {
json!({"type": "string", "enum": names})
}
Ty::ArrayOf(inner) => json!({"type": "array", "items": ty_schema(inner)}),
Ty::Entities => {
json!({"type":"array","minItems":1,"items":{"type":"object","required":["type"],"properties":{"type":{"type":"string"}}}})
}
Ty::Raw(s) => serde_json::from_str(s).expect("Raw fragment must be valid JSON"),
}
}
@@ -1147,6 +1150,15 @@ fn validate_type(ty: &Ty, val: &Value) -> Result<(), String> {
validate_type(inner, item).map_err(|e| format!("item {idx}: {e}"))?;
}
}
Ty::Entities => {
let entities = val
.as_array()
.filter(|a| !a.is_empty())
.ok_or("expected nonempty entities array")?;
for (index, entity) in entities.iter().enumerate() {
crate::entity_creation::validate_entity(entity, &format!("/entities/{index}"))?;
}
}
Ty::Raw(_) => {}
}
Ok(())
+42
View File
@@ -0,0 +1,42 @@
//! Offline validation of application-owned JSON Schema contracts.
use serde_json::Value;
/// Only application-owned schemas enter this cache; client payloads are instances,
/// never schemas. Disable JSON Schema HTTP/file resolution at the dependency level.
pub fn validate_shape(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
use std::{
collections::HashMap,
sync::{Arc, Mutex, OnceLock},
};
static VALIDATORS: OnceLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
OnceLock::new();
let key = schema.to_string();
let validator = {
let mut cache = VALIDATORS
.get_or_init(Default::default)
.lock()
.expect("schema cache poisoned");
if let Some(validator) = cache.get(&key) {
Arc::clone(validator)
} else {
let validator = Arc::new(
jsonschema::validator_for(schema).expect("application-owned schema must compile"),
);
cache.insert(key, Arc::clone(&validator));
validator
}
};
validator.validate(value).map_err(|error| {
let field = match error.kind() {
jsonschema::error::ValidationErrorKind::Required { property } => property.as_str(),
jsonschema::error::ValidationErrorKind::AdditionalProperties { unexpected } => {
unexpected.first().map(String::as_str)
}
_ => None,
};
let suffix = field
.map(|key| format!("/{}", key.replace('~', "~0").replace('/', "~1")))
.unwrap_or_default();
format!("{path}{}{suffix}: {error}", error.instance_path())
})
}