forked from 1b-agent/opencadstudio-upstream
Accelerate native MCP workflows
This commit is contained in:
1 parent
0e4f755e6f
commit
cb88f2ded6
6 files changed
+1291
-98
No files matched your search
+180
-27
@@ -138,9 +138,9 @@ fn v3(v: acadrust::types::Vector3) -> Value {
|
||||
json!([v.x, v.y, v.z])
|
||||
}
|
||||
|
||||
/// One entity as JSON: handle, type, layer, plus basic geometry for the common
|
||||
/// types (others carry only the common fields).
|
||||
fn entity_json(e: &acadrust::EntityType) -> Value {
|
||||
/// One entity as JSON. Summary mode carries identity only, geometry adds the
|
||||
/// entity's defining values, and full also includes its world bounds.
|
||||
fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value {
|
||||
use acadrust::EntityType as E;
|
||||
let c = e.common();
|
||||
let mut obj = json!({
|
||||
@@ -148,6 +148,9 @@ fn entity_json(e: &acadrust::EntityType) -> Value {
|
||||
"type": crate::entities::names::ui_name(e),
|
||||
"layer": c.layer,
|
||||
});
|
||||
if detail == "summary" {
|
||||
return obj;
|
||||
}
|
||||
let map = obj.as_object_mut().expect("json object");
|
||||
match e {
|
||||
E::Line(l) => {
|
||||
@@ -194,9 +197,41 @@ fn entity_json(e: &acadrust::EntityType) -> Value {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if detail == "full" {
|
||||
let bounds = e.as_entity().bounding_box();
|
||||
map.insert("bounds".into(), json!({
|
||||
"min":[bounds.min.x,bounds.min.y,bounds.min.z],
|
||||
"max":[bounds.max.x,bounds.max.y,bounds.max.z]
|
||||
}));
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> {
|
||||
let values = req[key].as_array()?;
|
||||
if !(2..=3).contains(&values.len()) {
|
||||
return None;
|
||||
}
|
||||
let x = values[0].as_f64()?;
|
||||
let y = values[1].as_f64()?;
|
||||
(x.is_finite() && y.is_finite()).then_some([x, y])
|
||||
}
|
||||
|
||||
fn request_handle(value: &Value) -> Option<acadrust::Handle> {
|
||||
value.as_str()
|
||||
.and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok())
|
||||
.map(acadrust::Handle::new)
|
||||
}
|
||||
|
||||
fn projected_fields(mut entity: Value, fields: Option<&Vec<Value>>) -> Value {
|
||||
let Some(fields) = fields else { return entity };
|
||||
let Some(source) = entity.as_object_mut() else { return entity };
|
||||
let keep: std::collections::HashSet<&str> =
|
||||
fields.iter().filter_map(Value::as_str).collect();
|
||||
source.retain(|key, _| key == "handle" || keep.contains(key.as_str()));
|
||||
entity
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Handle one JSON request line and return the JSON response.
|
||||
#[cfg(any(test, not(target_arch = "wasm32")))]
|
||||
@@ -445,38 +480,156 @@ impl OpenCADStudio {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List entities (handle, type, layer, basic geometry), optionally filtered
|
||||
/// by `type` and/or `layer`, capped by `limit` (default 1000).
|
||||
/// Query entities by identity, metadata and exact plane-curve relationships.
|
||||
fn entity_query(&self, req: &Value) -> Value {
|
||||
let i = self.active_tab;
|
||||
let tab = &self.tabs[i];
|
||||
if let Some(pair) = req["intersections"].as_array() {
|
||||
if pair.len() != 2 {
|
||||
return err("query intersections expects exactly two handles");
|
||||
}
|
||||
let Some(first) = request_handle(&pair[0]) else {
|
||||
return err("query intersections contains an invalid first handle");
|
||||
};
|
||||
let Some(second) = request_handle(&pair[1]) else {
|
||||
return err("query intersections contains an invalid second handle");
|
||||
};
|
||||
let Some(first_entity) = tab.scene.document.get_entity(first) else {
|
||||
return err("query intersections first entity does not exist");
|
||||
};
|
||||
let Some(second_entity) = tab.scene.document.get_entity(second) else {
|
||||
return err("query intersections second entity does not exist");
|
||||
};
|
||||
let Some(first_curve) = crate::entities::curve::entity_curve_xy(first_entity) else {
|
||||
return err("query intersections first entity is not a planar curve");
|
||||
};
|
||||
let Some(second_curve) = crate::entities::curve::entity_curve_xy(second_entity) else {
|
||||
return err("query intersections second entity is not a planar curve");
|
||||
};
|
||||
let crossings = cadkernel::geom2d::intersect(
|
||||
&first_curve,
|
||||
&second_curve,
|
||||
cadkernel::geom2d::Tolerance::default(),
|
||||
);
|
||||
return json!({
|
||||
"ok":true,
|
||||
"document_id":tab.id,
|
||||
"geometry_revision":tab.scene.geometry_epoch,
|
||||
"handles":[format!("{:X}",first.value()),format!("{:X}",second.value())],
|
||||
"count":crossings.len(),
|
||||
"intersections":crossings.into_iter().map(|crossing|json!({
|
||||
"point":[crossing.point[0],crossing.point[1]],
|
||||
"parameter_first":crossing.t_a,
|
||||
"parameter_second":crossing.t_b
|
||||
})).collect::<Vec<_>>()
|
||||
});
|
||||
}
|
||||
|
||||
let type_filter = req["type"].as_str();
|
||||
let layer_filter = req["layer"].as_str();
|
||||
let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize;
|
||||
let offset = req["offset"].as_u64().unwrap_or(0);
|
||||
|
||||
let mut entities = Vec::new();
|
||||
let mut matched = 0u64;
|
||||
for e in self.tabs[i].scene.document.entities() {
|
||||
if let Some(tf) = type_filter {
|
||||
if !crate::entities::names::ui_name(e).eq_ignore_ascii_case(tf) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(lf) = layer_filter {
|
||||
if e.common().layer != lf {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
matched += 1;
|
||||
if matched > offset && entities.len() < limit {
|
||||
entities.push(entity_json(e));
|
||||
}
|
||||
let handles: Option<std::collections::HashSet<u64>> = req["handles"]
|
||||
.as_array()
|
||||
.map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect())
|
||||
.or_else(|| request_handle(&req["handle"])
|
||||
.map(|handle| std::iter::once(handle.value()).collect()));
|
||||
let near = request_point(req, "near");
|
||||
let contains = request_point(req, "contains_point");
|
||||
let bounds = req["bounds"].as_array().and_then(|values| {
|
||||
(values.len() == 4).then(|| Some([
|
||||
values[0].as_f64()?, values[1].as_f64()?,
|
||||
values[2].as_f64()?, values[3].as_f64()?,
|
||||
])).flatten()
|
||||
});
|
||||
if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| {
|
||||
parsed.len() != req["handles"].as_array().map_or(0, Vec::len)
|
||||
}) {
|
||||
return err("query handles contains an invalid hexadecimal handle");
|
||||
}
|
||||
if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() {
|
||||
return err("query handle must be hexadecimal");
|
||||
}
|
||||
if req.get("near").is_some() && near.is_none() {
|
||||
return err("query near expects two or three finite coordinates");
|
||||
}
|
||||
if req.get("contains_point").is_some() && contains.is_none() {
|
||||
return err("query contains_point expects two or three finite coordinates");
|
||||
}
|
||||
if req.get("bounds").is_some() && bounds.is_none_or(|bounds| {
|
||||
!bounds.iter().all(|value| value.is_finite())
|
||||
|| bounds[0] > bounds[2] || bounds[1] > bounds[3]
|
||||
}) {
|
||||
return err("query bounds expects finite [min_x,min_y,max_x,max_y]");
|
||||
}
|
||||
let detail = req["detail"].as_str().unwrap_or("geometry");
|
||||
if !matches!(detail, "summary" | "geometry" | "full") {
|
||||
return err("query detail must be summary, geometry or full");
|
||||
}
|
||||
let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize;
|
||||
let offset = req["offset"].as_u64().unwrap_or(0) as usize;
|
||||
|
||||
let mut matched = Vec::new();
|
||||
for e in tab.scene.document.entities() {
|
||||
if handles.as_ref().is_some_and(|handles| {
|
||||
!handles.contains(&e.common().handle.value())
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if type_filter.is_some_and(|value| {
|
||||
!crate::entities::names::ui_name(e).eq_ignore_ascii_case(value)
|
||||
}) || layer_filter.is_some_and(|value| e.common().layer != value) {
|
||||
continue;
|
||||
}
|
||||
if let Some(bounds) = bounds {
|
||||
let entity_bounds = e.as_entity().bounding_box();
|
||||
if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1]
|
||||
|| entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let curve = (near.is_some() || contains.is_some())
|
||||
.then(|| crate::entities::curve::entity_curve_xy(e)).flatten();
|
||||
if let Some(point) = contains {
|
||||
let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else {
|
||||
continue;
|
||||
};
|
||||
if !cadkernel::geom2d::contains(
|
||||
std::slice::from_ref(curve), point,
|
||||
cadkernel::geom2d::Tolerance::default(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let nearest = near.and_then(|point| curve.as_ref()
|
||||
.map(|curve| cadkernel::geom2d::closest_point(curve, point)));
|
||||
if near.is_some() && nearest.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut entity = entity_json(e, detail);
|
||||
if let Some(nearest) = nearest {
|
||||
let object = entity.as_object_mut().expect("entity JSON object");
|
||||
object.insert("distance".into(), json!(nearest.distance));
|
||||
object.insert("closest_point".into(),
|
||||
json!([nearest.point[0], nearest.point[1]]));
|
||||
object.insert("parameter".into(), json!(nearest.t));
|
||||
}
|
||||
matched.push((nearest.map(|nearest| nearest.distance), entity));
|
||||
}
|
||||
if near.is_some() {
|
||||
matched.sort_by(|left, right| left.0.partial_cmp(&right.0)
|
||||
.unwrap_or(std::cmp::Ordering::Equal));
|
||||
}
|
||||
let count = matched.len();
|
||||
let fields = req["fields"].as_array();
|
||||
let entities: Vec<Value> = matched.into_iter().skip(offset).take(limit)
|
||||
.map(|(_, entity)| projected_fields(entity, fields)).collect();
|
||||
json!({
|
||||
"ok": true,
|
||||
"count": matched,
|
||||
"document_id":tab.id,
|
||||
"geometry_revision":tab.scene.geometry_epoch,
|
||||
"count": count,
|
||||
"returned": entities.len(),
|
||||
"next_offset": if offset + (entities.len() as u64) < matched { Some(offset + entities.len() as u64) } else { None },
|
||||
"next_offset": (offset + entities.len() < count).then_some(offset + entities.len()),
|
||||
"entities": entities,
|
||||
})
|
||||
}
|
||||
|
||||
+241
-15
@@ -172,10 +172,110 @@ fn command_examples(name: &str) -> &'static [&'static str] {
|
||||
"CIRCLE" => &["CIRCLE 5,5 3"],
|
||||
"PLINE" => &["PLINE 0,0 10,0 10,10 C"],
|
||||
"ZOOM" => &["ZOOM EXTENTS"],
|
||||
"MOVE" => &["MOVE 0,0 100,0"],
|
||||
"ROTATE" => &["ROTATE 0,0 90"],
|
||||
"UCS" => &["UCS Z 90"],
|
||||
"PDMODE" => &["PDMODE 3"],
|
||||
"LTSCALE" => &["LTSCALE 2.5"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn command_category(name: &str) -> &'static str {
|
||||
if [
|
||||
"LINE", "CIRCLE", "PLINE", "ARC", "ELLIPSE", "POINT", "SPLINE", "RECT", "RECTANG",
|
||||
"POLYGON", "HATCH", "RAY", "XLINE", "3DPOLY", "MLINE", "TEXT", "MTEXT", "TABLE",
|
||||
]
|
||||
.contains(&name)
|
||||
{
|
||||
"create"
|
||||
} else if [
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"ROTATE",
|
||||
"SCALE",
|
||||
"MIRROR",
|
||||
"ERASE",
|
||||
"TRIM",
|
||||
"EXTEND",
|
||||
"OFFSET",
|
||||
"FILLET",
|
||||
"CHAMFER",
|
||||
"BREAK",
|
||||
"JOIN",
|
||||
"EXPLODE",
|
||||
"STRETCH",
|
||||
"ARRAY",
|
||||
"PEDIT",
|
||||
"SPLINEDIT",
|
||||
"REVERSE",
|
||||
"ALIGN",
|
||||
]
|
||||
.contains(&name)
|
||||
{
|
||||
"modify"
|
||||
} else if ["DIST", "ID", "AREA", "MEASURE", "MEASUREGEOM"].contains(&name) {
|
||||
"inspect"
|
||||
} else if ["ZOOM", "PAN", "UCS", "PLAN", "VPORTS", "3DORBIT"].contains(&name) {
|
||||
"view"
|
||||
} else if name.starts_with("DIM") || ["LEADER", "MLEADER", "TOLERANCE"].contains(&name) {
|
||||
"annotate"
|
||||
} else if [
|
||||
"BOX",
|
||||
"CYLINDER",
|
||||
"CONE",
|
||||
"SPHERE",
|
||||
"TORUS",
|
||||
"WEDGE",
|
||||
"EXTRUDE",
|
||||
"REVOLVE",
|
||||
"SWEEP",
|
||||
"LOFT",
|
||||
"UNION",
|
||||
"SUBTRACT",
|
||||
"INTERSECT",
|
||||
"PRESSPULL",
|
||||
]
|
||||
.contains(&name)
|
||||
{
|
||||
"model"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
fn command_selection_policy(name: &str) -> &'static str {
|
||||
if [
|
||||
"ARRAY",
|
||||
"ARRAYPATH",
|
||||
"ARRAYPOLAR",
|
||||
"ARRAYRECT",
|
||||
"BLOCK",
|
||||
"COPY",
|
||||
"COPYCLIP",
|
||||
"CUTCLIP",
|
||||
"ERASE",
|
||||
"EXPLODE",
|
||||
"GROUP",
|
||||
"LAYFRZ",
|
||||
"LAYLCK",
|
||||
"LAYMCUR",
|
||||
"LAYOFF",
|
||||
"LAYULK",
|
||||
"MIRROR",
|
||||
"MOVE",
|
||||
"ROTATE",
|
||||
"SCALE",
|
||||
"STRETCH",
|
||||
]
|
||||
.contains(&name)
|
||||
{
|
||||
"current_selection_or_prompt"
|
||||
} else {
|
||||
"command_defined"
|
||||
}
|
||||
}
|
||||
|
||||
fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
|
||||
let options = command.options();
|
||||
let mut accepts = Vec::new();
|
||||
@@ -261,7 +361,7 @@ impl OpenCADStudio {
|
||||
"mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})),
|
||||
"text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial,
|
||||
"operation":self.control.pending.as_ref().map(|p| &p.id),
|
||||
"capabilities":["commands","step_input","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","measure"]
|
||||
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,6 +459,11 @@ impl OpenCADStudio {
|
||||
"ok":true,
|
||||
"command":{
|
||||
"name":name,
|
||||
"category":command_category(name),
|
||||
"preconditions":{
|
||||
"document":true,
|
||||
"selection":command_selection_policy(name)
|
||||
},
|
||||
"batch":{
|
||||
"op":"run",
|
||||
"syntax":"Command name followed by prompt answers separated by spaces. Points use x,y or x,y,z; options use their token.",
|
||||
@@ -368,16 +473,27 @@ impl OpenCADStudio {
|
||||
"op":"start",
|
||||
"request":{"op":"start","cmd":name},
|
||||
"next":"Follow state.command.accepts, options and input_example after every step."
|
||||
}
|
||||
},
|
||||
"completion":"completed is final; waiting_input requires another input step; failed includes the reason."
|
||||
}
|
||||
}),
|
||||
Task::none(),
|
||||
);
|
||||
}
|
||||
if let Some(search) = req["search"].as_str().filter(|search| !search.is_empty()) {
|
||||
let search = search.to_ascii_uppercase();
|
||||
names.retain(|name| name.contains(&search));
|
||||
}
|
||||
let count = names.len();
|
||||
let offset = req["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = req["limit"].as_u64().unwrap_or(200).min(1000) as usize;
|
||||
let commands: Vec<String> = names.into_iter().skip(offset).take(limit).collect();
|
||||
return (
|
||||
json!({
|
||||
"ok":true,"commands":names,"actions":actions::NAMES,
|
||||
"detail_parameters":{"name":"LINE"},
|
||||
"ok":true,"count":count,"returned":commands.len(),
|
||||
"next_offset":(offset + commands.len() < count).then_some(offset + commands.len()),
|
||||
"commands":commands,"actions":actions::NAMES,
|
||||
"detail_parameters":{"name":"LINE"},"search_parameter":{"search":"LINE"},
|
||||
"guidance":"Request one command by name for batch examples and interactive input guidance."
|
||||
}),
|
||||
Task::none(),
|
||||
@@ -857,7 +973,16 @@ impl OpenCADStudio {
|
||||
h.isolines.max(0) as usize,
|
||||
)
|
||||
});
|
||||
out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))}));
|
||||
let curve = crate::entities::curve::entity_curve(entity).map(|planar| {
|
||||
let length = planar.curve.length();
|
||||
json!({
|
||||
"bounded":length.is_finite(),
|
||||
"closed":planar.curve.is_closed(),
|
||||
"length":length.is_finite().then_some(length),
|
||||
"area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs())
|
||||
})
|
||||
});
|
||||
out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))}));
|
||||
}
|
||||
json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out})
|
||||
}
|
||||
@@ -869,17 +994,66 @@ impl OpenCADStudio {
|
||||
) {
|
||||
let result = (|| -> Result<Value, String> {
|
||||
let s = screenshot.ok_or("Renderer did not return an image")?;
|
||||
image::save_buffer_with_format(
|
||||
&path,
|
||||
&s.rgba,
|
||||
s.size.width,
|
||||
s.size.height,
|
||||
image::ColorType::Rgba8,
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let requested_scope = self
|
||||
.control
|
||||
.pending
|
||||
.as_ref()
|
||||
.and_then(|pending| pending.request["scope"].as_str())
|
||||
.unwrap_or("window");
|
||||
let max_dimension = self
|
||||
.control
|
||||
.pending
|
||||
.as_ref()
|
||||
.and_then(|pending| pending.request["max_dimension"].as_u64())
|
||||
.unwrap_or(1600)
|
||||
.clamp(256, 4096) as u32;
|
||||
let mut image =
|
||||
image::RgbaImage::from_raw(s.size.width, s.size.height, s.rgba.to_vec())
|
||||
.ok_or("Renderer returned malformed image data")?;
|
||||
let mut actual_scope = "window";
|
||||
if requested_scope == "viewport" {
|
||||
if let Some(bounds) = crate::ui::wrap_bar::dropdown_bounds(
|
||||
crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID,
|
||||
) {
|
||||
let scale = s.scale_factor;
|
||||
let left = (bounds.x * scale).floor().clamp(0.0, image.width() as f32) as u32;
|
||||
let top = (bounds.y * scale).floor().clamp(0.0, image.height() as f32) as u32;
|
||||
let right = ((bounds.x + bounds.width) * scale)
|
||||
.ceil()
|
||||
.clamp(0.0, image.width() as f32) as u32;
|
||||
let bottom = ((bounds.y + bounds.height) * scale)
|
||||
.ceil()
|
||||
.clamp(0.0, image.height() as f32) as u32;
|
||||
if right > left && bottom > top {
|
||||
image = image::imageops::crop_imm(
|
||||
&image,
|
||||
left,
|
||||
top,
|
||||
right - left,
|
||||
bottom - top,
|
||||
)
|
||||
.to_image();
|
||||
actual_scope = "viewport";
|
||||
}
|
||||
}
|
||||
}
|
||||
let longest = image.width().max(image.height());
|
||||
if longest > max_dimension {
|
||||
let scale = max_dimension as f64 / longest as f64;
|
||||
let width = ((image.width() as f64 * scale).round() as u32).max(1);
|
||||
let height = ((image.height() as f64 * scale).round() as u32).max(1);
|
||||
image = image::imageops::resize(
|
||||
&image,
|
||||
width,
|
||||
height,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
}
|
||||
image
|
||||
.save_with_format(&path, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(
|
||||
json!({"path":path,"width":s.size.width,"height":s.size.height,"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}),
|
||||
json!({"path":path,"scope":actual_scope,"width":image.width(),"height":image.height(),"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}),
|
||||
)
|
||||
})();
|
||||
match result {
|
||||
@@ -990,6 +1164,8 @@ mod tests {
|
||||
"PLINE 0,0 10,0 10,10 C"
|
||||
);
|
||||
assert_eq!(detail["command"]["interactive"]["op"], "start");
|
||||
assert_eq!(detail["command"]["category"], "create");
|
||||
assert_eq!(detail["command"]["preconditions"]["document"], true);
|
||||
assert_eq!(
|
||||
app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"}))
|
||||
.0["code"],
|
||||
@@ -997,6 +1173,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn control_queries_exact_curve_relationships_and_metrics() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
request(&mut app, json!({"op":"new"}));
|
||||
request(&mut app, json!({"op":"run","cmd":"LINE -5,0 5,0"}));
|
||||
request(&mut app, json!({"op":"run","cmd":"LINE 0,-5 0,5"}));
|
||||
request(&mut app, json!({"op":"run","cmd":"CIRCLE 20,0 2"}));
|
||||
|
||||
let lines = app.control_request(json!({"op":"query","type":"Line"})).0;
|
||||
let first = lines["entities"][0]["handle"].as_str().unwrap();
|
||||
let second = lines["entities"][1]["handle"].as_str().unwrap();
|
||||
let intersections = app
|
||||
.control_request(json!({"op":"query","intersections":[first,second]}))
|
||||
.0;
|
||||
assert_eq!(intersections["count"], 1, "{intersections}");
|
||||
assert_eq!(
|
||||
intersections["intersections"][0]["point"],
|
||||
json!([0.0, 0.0])
|
||||
);
|
||||
|
||||
let nearest = app
|
||||
.control_request(json!({"op":"query","near":[20.0,0.0],"limit":1}))
|
||||
.0;
|
||||
assert_eq!(nearest["entities"][0]["type"], "Circle");
|
||||
assert_eq!(nearest["entities"][0]["distance"], 2.0);
|
||||
let circle = nearest["entities"][0]["handle"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let contains = app
|
||||
.control_request(json!({"op":"query","contains_point":[20.0,0.0]}))
|
||||
.0;
|
||||
assert_eq!(contains["count"], 1, "{contains}");
|
||||
assert_eq!(contains["entities"][0]["handle"], circle);
|
||||
|
||||
let projected = app
|
||||
.control_request(
|
||||
json!({"op":"query","handle":circle,"detail":"full","fields":["type"]}),
|
||||
)
|
||||
.0;
|
||||
assert_eq!(projected["entities"][0].as_object().unwrap().len(), 2);
|
||||
|
||||
let measured = app
|
||||
.control_request(json!({"op":"measure","handles":[circle]}))
|
||||
.0;
|
||||
let curve = &measured["measurements"][0]["curve"];
|
||||
assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9);
|
||||
assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9);
|
||||
}
|
||||
#[test]
|
||||
fn control_retry_is_idempotent_and_stale_state_rejected() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
request(&mut app, json!({"op":"new"}));
|
||||
|
||||
Reference in new issue
Block a user