forked from 1b-agent/opencadstudio-upstream
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:
1 parent
a5435a6df6
commit
97afb57333
10 files changed
+1118
-488
No files matched your search
+2
-477
@@ -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<_>>()?
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user