feat(mcp): add standardized spatial metadata schema and visual differential streaming

- Standardize spatial metadata under \_spatial\ with \CAD_WCS\ CRS, document units, forward/inverse affine matrices (\pixel_to_world_matrix\, \world_to_pixel_matrix\), camera projection, and world bounding bounds
- Implement visual differential engine (\compute_visual_diff\) detecting pixel-level changes between viewport frames
- Map dirty pixel bounding box directly to CAD world coordinates (\dirty_world_bounds\) using the spatial transform
- Add \diff\, \diff_mode\ ('highlight' | 'crop' | 'metadata_only'), and \
eset_diff_baseline\ parameters to \ocs_capture\
- Support high-efficiency dirty region cropping to drastically minimize vision token and bandwidth consumption
- Add unit tests for spatial projection, diff computation, tool schema, and structured metadata formatting
This commit is contained in:
Karim Jerbi committed 2026-10-01 14:48:31 +01:00
1 parent c8caac2e10
commit 350742f2b0
3 files changed
+467 -6

No files matched your search

+19
View File
@@ -1422,6 +1422,22 @@ impl OpenCADStudio {
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let doc_unit = crate::app::properties::insunits_name(
self.tabs[self.active_tab].scene.document.header.insertion_units,
);
let spatial_obj = json!({
"crs": "CAD_WCS",
"unit": doc_unit,
"pixel_resolution": [image.width(), image.height()],
"world_bounds": grounding.viewport_world_bounds,
"pixel_to_world_matrix": grounding.pixel_to_world_matrix,
"world_to_pixel_matrix": grounding.world_to_pixel_matrix,
"camera": grounding.camera,
"target_plane": grounding.target_plane,
"annotations": grounding.visible_entities.clone(),
});
Ok(json!({
"path": path,
"scope": actual_scope,
@@ -1431,6 +1447,9 @@ impl OpenCADStudio {
"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,
"_spatial": spatial_obj,
"pixel_to_world_matrix": grounding.pixel_to_world_matrix,
"world_to_pixel_matrix": grounding.world_to_pixel_matrix,
"viewport_world_bounds": grounding.viewport_world_bounds,
"camera": grounding.camera,
"annotated": annotate,
+266 -1
View File
@@ -109,6 +109,9 @@ pub(crate) fn draw_som_badge(
pub(crate) struct VisionGrounding {
pub viewport_world_bounds: Value,
pub camera: Value,
pub target_plane: Value,
pub pixel_to_world_matrix: [f64; 6],
pub world_to_pixel_matrix: [f64; 6],
pub visible_entities: Vec<Value>,
}
@@ -145,13 +148,56 @@ pub(crate) fn compute_grounding(
let eye = c.eye();
let target = c.target;
let normal = (c.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::Z);
let target_plane = json!({
"normal": [normal.x, normal.y, normal.z],
"origin": [target.x, target.y, target.z],
});
let proj_name = match c.projection {
crate::scene::view::camera::Projection::Orthographic => "Orthographic",
crate::scene::view::camera::Projection::Perspective => "Perspective",
};
let camera_info = json!({
"eye": [eye.x, eye.y, eye.z],
"target": [target.x, target.y, target.z],
"distance": c.distance,
"ortho_size": c.ortho_size(),
"projection": proj_name,
"fov_y": c.fov_y,
});
let p_origin = p_tl;
let p_right = p_tr;
let p_bottom = p_bl;
let d_x = (p_right - p_origin) / (image_width as f64).max(1.0);
let d_y = (p_bottom - p_origin) / (image_height as f64).max(1.0);
let m00 = d_x.x;
let m01 = d_y.x;
let tx = p_origin.x;
let m10 = d_x.y;
let m11 = d_y.y;
let ty = p_origin.y;
let det = m00 * m11 - m01 * m10;
let (inv_m00, inv_m01, inv_m10, inv_m11, inv_tx, inv_ty) = if det.abs() > 1e-12 {
let inv_m00 = m11 / det;
let inv_m01 = -m01 / det;
let inv_m10 = -m10 / det;
let inv_m11 = m00 / det;
let inv_tx = -(inv_m00 * tx + inv_m01 * ty);
let inv_ty = -(inv_m10 * tx + inv_m11 * ty);
(inv_m00, inv_m01, inv_m10, inv_m11, inv_tx, inv_ty)
} else {
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
};
let pixel_to_world_matrix = [m00, m01, m10, m11, tx, ty];
let world_to_pixel_matrix = [inv_m00, inv_m01, inv_m10, inv_m11, inv_tx, inv_ty];
let img_w = image_width as f32;
let img_h = image_height as f32;
@@ -254,10 +300,152 @@ pub(crate) fn compute_grounding(
VisionGrounding {
viewport_world_bounds,
camera: camera_info,
target_plane,
pixel_to_world_matrix,
world_to_pixel_matrix,
visible_entities,
}
}
#[derive(Clone, Debug)]
pub(crate) struct VisualDiffResult {
pub changed: bool,
pub change_percentage: f64,
pub changed_pixels: usize,
pub dirty_pixel_bounds: Option<[u32; 4]>, // [min_x, min_y, max_x, max_y]
pub dirty_world_bounds: Option<[f64; 4]>, // [min_wx, min_wy, max_wx, max_wy]
pub patch_resolution: Option<[u32; 2]>,
pub cropped_patch: Option<image::RgbaImage>,
pub diff_overlay: image::RgbaImage,
}
pub(crate) fn compute_visual_diff(
baseline: &image::RgbaImage,
current: &image::RgbaImage,
pixel_to_world_matrix: &[f64; 6],
) -> VisualDiffResult {
let (w, h) = (current.width(), current.height());
let mut diff_overlay = image::RgbaImage::new(w, h);
if baseline.width() != w || baseline.height() != h {
return VisualDiffResult {
changed: true,
change_percentage: 100.0,
changed_pixels: (w * h) as usize,
dirty_pixel_bounds: Some([0, 0, w.saturating_sub(1), h.saturating_sub(1)]),
dirty_world_bounds: None,
patch_resolution: Some([w, h]),
cropped_patch: Some(current.clone()),
diff_overlay: current.clone(),
};
}
let mut min_x = u32::MAX;
let mut min_y = u32::MAX;
let mut max_x = 0u32;
let mut max_y = 0u32;
let mut changed_pixels = 0usize;
for y in 0..h {
for x in 0..w {
let p1 = baseline.get_pixel(x, y);
let p2 = current.get_pixel(x, y);
let dr = (p1[0] as i32 - p2[0] as i32).abs();
let dg = (p1[1] as i32 - p2[1] as i32).abs();
let db = (p1[2] as i32 - p2[2] as i32).abs();
let da = (p1[3] as i32 - p2[3] as i32).abs();
let is_diff = (dr + dg + db + da) > 20;
if is_diff {
changed_pixels += 1;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
// Highlight changed pixel in vibrant green
diff_overlay.put_pixel(x, y, image::Rgba([0, 255, 128, 255]));
} else {
// Dim unchanged background: grayscale attenuated to 35%
let gray = ((p2[0] as f32 * 0.299 + p2[1] as f32 * 0.587 + p2[2] as f32 * 0.114) * 0.35) as u8;
diff_overlay.put_pixel(x, y, image::Rgba([gray, gray, gray, 200]));
}
}
}
let total_pixels = (w * h) as f64;
let change_percentage = if total_pixels > 0.0 {
(changed_pixels as f64 / total_pixels) * 100.0
} else {
0.0
};
if changed_pixels == 0 {
return VisualDiffResult {
changed: false,
change_percentage: 0.0,
changed_pixels: 0,
dirty_pixel_bounds: None,
dirty_world_bounds: None,
patch_resolution: None,
cropped_patch: None,
diff_overlay,
};
}
let pad = 16u32;
let b_min_x = min_x.saturating_sub(pad);
let b_min_y = min_y.saturating_sub(pad);
let b_max_x = (max_x + pad).min(w.saturating_sub(1));
let b_max_y = (max_y + pad).min(h.saturating_sub(1));
let patch_w = (b_max_x - b_min_x + 1).max(1);
let patch_h = (b_max_y - b_min_y + 1).max(1);
let m = pixel_to_world_matrix;
let px0 = b_min_x as f64;
let py0 = b_min_y as f64;
let px1 = b_max_x as f64;
let py1 = b_max_y as f64;
let p00_x = m[0] * px0 + m[1] * py0 + m[4];
let p00_y = m[2] * px0 + m[3] * py0 + m[5];
let p10_x = m[0] * px1 + m[1] * py0 + m[4];
let p10_y = m[2] * px1 + m[3] * py0 + m[5];
let p01_x = m[0] * px0 + m[1] * py1 + m[4];
let p01_y = m[2] * px0 + m[3] * py1 + m[5];
let p11_x = m[0] * px1 + m[1] * py1 + m[4];
let p11_y = m[2] * px1 + m[3] * py1 + m[5];
let w_min_x = p00_x.min(p10_x).min(p01_x).min(p11_x);
let w_max_x = p00_x.max(p10_x).max(p01_x).max(p11_x);
let w_min_y = p00_y.min(p10_y).min(p01_y).min(p11_y);
let w_max_y = p00_y.max(p10_y).max(p01_y).max(p11_y);
let cropped = image::imageops::crop_imm(current, b_min_x, b_min_y, patch_w, patch_h).to_image();
VisualDiffResult {
changed: true,
change_percentage,
changed_pixels,
dirty_pixel_bounds: Some([b_min_x, b_min_y, b_max_x, b_max_y]),
dirty_world_bounds: Some([w_min_x, w_min_y, w_max_x, w_max_y]),
patch_resolution: Some([patch_w, patch_h]),
cropped_patch: Some(cropped),
diff_overlay,
}
}
pub(crate) fn encode_image_png(img: &image::RgbaImage) -> Result<Vec<u8>, String> {
use std::io::Cursor;
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode PNG: {e}"))?;
Ok(buf.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -299,9 +487,34 @@ mod tests {
assert!(grounding.viewport_world_bounds["min"].is_array());
assert!(grounding.viewport_world_bounds["max"].is_array());
// Camera info should be populated
// Camera info should be populated with projection
assert!(grounding.camera["eye"].is_array());
assert!(grounding.camera["target"].is_array());
assert_eq!(grounding.camera["projection"], "Orthographic");
// Target plane normal and origin
assert!(grounding.target_plane["normal"].is_array());
assert!(grounding.target_plane["origin"].is_array());
// Affine matrices: test forward and inverse round-trip
let p2w = grounding.pixel_to_world_matrix;
let w2p = grounding.world_to_pixel_matrix;
// Center pixel (400, 300) should project to camera target (within 1e-4)
let cx = 400.0;
let cy = 300.0;
let wx = p2w[0] * cx + p2w[1] * cy + p2w[4];
let wy = p2w[2] * cx + p2w[3] * cy + p2w[5];
let target_x = grounding.camera["target"][0].as_f64().unwrap();
let target_y = grounding.camera["target"][1].as_f64().unwrap();
assert!((wx - target_x).abs() < 1e-3, "wx={wx}, target_x={target_x}");
assert!((wy - target_y).abs() < 1e-3, "wy={wy}, target_y={target_y}");
// Inverse mapping of (wx, wy) should return (400, 300)
let inv_px = w2p[0] * wx + w2p[1] * wy + w2p[4];
let inv_py = w2p[2] * wx + w2p[3] * wy + w2p[5];
assert!((inv_px - cx).abs() < 1e-3, "inv_px={inv_px}, cx={cx}");
assert!((inv_py - cy).abs() < 1e-3, "inv_py={inv_py}, cy={cy}");
// Both entities should be visible and tagged
assert_eq!(grounding.visible_entities.len(), 2);
@@ -317,4 +530,56 @@ mod tests {
let second = &grounding.visible_entities[1];
assert_eq!(second["tag"], 2);
}
#[test]
fn test_compute_visual_diff() {
let w = 200;
let h = 150;
let base = image::RgbaImage::from_pixel(w, h, image::Rgba([30, 30, 30, 255]));
let p2w = [0.1, 0.0, 0.0, -0.1, -10.0, 7.5];
// 1. Identical images -> changed: false
let diff_same = compute_visual_diff(&base, &base, &p2w);
assert_eq!(diff_same.changed, false);
assert_eq!(diff_same.change_percentage, 0.0);
assert_eq!(diff_same.changed_pixels, 0);
assert!(diff_same.dirty_pixel_bounds.is_none());
// 2. Modify a 20x20 block from (50, 40) to (69, 59)
let mut curr = base.clone();
for y in 40..60 {
for x in 50..70 {
curr.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
}
}
let diff_mod = compute_visual_diff(&base, &curr, &p2w);
assert_eq!(diff_mod.changed, true);
assert_eq!(diff_mod.changed_pixels, 400);
assert!(diff_mod.change_percentage > 1.0);
let bounds = diff_mod.dirty_pixel_bounds.unwrap();
// With 16px pad: min_x <= 50, max_x >= 69, min_y <= 40, max_y >= 59
assert!(bounds[0] <= 50);
assert!(bounds[1] <= 40);
assert!(bounds[2] >= 69);
assert!(bounds[3] >= 59);
// World bounds should be populated
assert!(diff_mod.dirty_world_bounds.is_some());
let wb = diff_mod.dirty_world_bounds.unwrap();
assert!(wb[0] < wb[2]); // min_x < max_x
assert!(wb[1] < wb[3]); // min_y < max_y
// Cropped patch and overlay
assert!(diff_mod.cropped_patch.is_some());
let patch = diff_mod.cropped_patch.unwrap();
assert!(patch.width() >= 20);
assert!(patch.height() >= 20);
// PNG encoding roundtrip
let png_bytes = encode_image_png(&patch).expect("png encode succeeds");
assert!(!png_bytes.is_empty());
assert_eq!(&png_bytes[1..4], b"PNG");
}
}
+182 -5
View File
@@ -24,7 +24,7 @@ const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;
const TASK_TTL_MS: u64 = 3_600_000;
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. 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. Verify important results with queries and a viewport capture, and save only to an explicit path. Viewports and captured snapshots are available as MCP resources under cad://session/{session_id}/viewport.png and cad://session/{session_id}/snapshot/{hash}.png; ocs_capture accepts delivery: 'resource' to avoid large inline base64 payloads. Before delivery call audit with the intended target_format and target_version; use save_verified with an explicit absolute path to save, reopen, hash and compare the semantic manifest.";
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. 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. Verify important results with queries and a viewport capture, and save only to an explicit path. Viewports and captured snapshots are available as MCP resources under cad://session/{session_id}/viewport.png and cad://session/{session_id}/snapshot/{hash}.png; ocs_capture accepts delivery: 'resource' to avoid large inline base64 payloads, supplies standardized spatial grounding in _spatial, and supports diff: true for streaming dirty visual regions. Before delivery call audit with the intended target_format and target_version; use save_verified with an explicit absolute path to save, reopen, hash and compare the semantic manifest.";
const READ_OPS: &[&str] = &[
"state",
"hello",
@@ -222,6 +222,7 @@ struct ResourceStore {
snapshots: HashMap<String, CachedSnapshot>,
order: VecDeque<String>,
latest: HashMap<String, String>,
last_captures: HashMap<String, (image::RgbaImage, String)>,
}
const MAX_STORED_SNAPSHOTS: usize = 32;
@@ -1051,6 +1052,14 @@ fn call_tool(
let scope = arguments["scope"].as_str().unwrap_or("viewport");
let max_dimension = arguments["max_dimension"].as_u64().unwrap_or(1600);
let delivery = arguments["delivery"].as_str().unwrap_or("inline");
let diff = arguments["diff"].as_bool().unwrap_or(false);
let diff_mode = arguments["diff_mode"].as_str().unwrap_or("highlight");
let reset_baseline = arguments["reset_diff_baseline"].as_bool().unwrap_or(false);
if reset_baseline {
resources.last_captures.remove(session_id);
}
let mut req = json!({
"op": "capture",
"path": path.to_string_lossy(),
@@ -1086,11 +1095,83 @@ fn call_tool(
obj.remove("path");
}
let mut final_bytes = bytes;
if diff {
let current_img = image::load_from_memory(&final_bytes)
.map_err(|e| format!("Failed to decode capture for visual diff: {e}"))?
.to_rgba8();
let mut p2w = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
if let Some(arr) = meta
.get("_spatial")
.and_then(|s| s.get("pixel_to_world_matrix"))
.and_then(Value::as_array)
{
if arr.len() == 6 {
for (i, v) in arr.iter().enumerate() {
if let Some(num) = v.as_f64() {
p2w[i] = num;
}
}
}
}
if let Some((prev_img, prev_hash)) = resources.last_captures.get(session_id) {
let diff_res = crate::app::control::vision::compute_visual_diff(prev_img, &current_img, &p2w);
let diff_info = json!({
"is_baseline": false,
"baseline_hash": prev_hash,
"changed": diff_res.changed,
"change_percentage": (diff_res.change_percentage * 100.0).round() / 100.0,
"changed_pixels": diff_res.changed_pixels,
"dirty_pixel_bounds": diff_res.dirty_pixel_bounds,
"dirty_world_bounds": diff_res.dirty_world_bounds,
"patch_resolution": diff_res.patch_resolution,
"mode": diff_mode,
});
if let Some(obj) = meta.as_object_mut() {
obj.insert("diff".into(), diff_info);
}
if diff_res.changed {
match diff_mode {
"crop" => {
if let Some(crop) = &diff_res.cropped_patch {
final_bytes = crate::app::control::vision::encode_image_png(crop)?;
}
}
"highlight" => {
final_bytes = crate::app::control::vision::encode_image_png(&diff_res.diff_overlay)?;
}
_ => {}
}
}
} else {
let diff_info = json!({
"is_baseline": true,
"changed": false,
"change_percentage": 0.0,
"changed_pixels": 0,
"message": "Baseline capture established; subsequent captures with diff: true will report dirty regions.",
"mode": diff_mode,
});
if let Some(obj) = meta.as_object_mut() {
obj.insert("diff".into(), diff_info);
}
}
let mut base_hasher = Sha256::new();
let full_png = crate::app::control::vision::encode_image_png(&current_img)?;
base_hasher.update(&full_png);
let current_full_hash = format!("{:x}", base_hasher.finalize());
resources.last_captures.insert(session_id.to_string(), (current_img, current_full_hash));
}
let mut hasher = Sha256::new();
hasher.update(&bytes);
hasher.update(&final_bytes);
let hash = format!("{:x}", hasher.finalize());
let b64 = BASE64.encode(&bytes);
let bytes_len = bytes.len();
let b64 = BASE64.encode(&final_bytes);
let bytes_len = final_bytes.len();
let uri = resources.insert(session_id, &hash, b64.clone(), bytes_len, meta.clone());
if let Some(obj) = meta.as_object_mut() {
@@ -1198,6 +1279,9 @@ fn tool_definitions() -> Value {
"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},
"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."},
"delivery":{"type":"string","enum":["inline","resource","both"],"default":"inline","description":"Image delivery method: 'inline' embeds base64 in tool content, 'resource' returns an MCP cad:// URI reference without inlining image bytes, 'both' returns both inline image and cad:// URI."},
"diff":{"type":"boolean","default":false,"description":"If true, calculates visual differential compared to the previous capture of this session, returning dirty bounds and changed areas."},
"diff_mode":{"type":"string","enum":["highlight","crop","metadata_only"],"default":"highlight","description":"Visual diff representation: 'highlight' returns full image with changed areas highlighted in vibrant green and unchanged background dimmed; 'crop' returns only the cropped dirty bounding box (saving maximum bandwidth/tokens); 'metadata_only' leaves image untouched but populates the diff metadata."},
"reset_diff_baseline":{"type":"boolean","default":false,"description":"If true, discards any previous diff baseline for this session and establishes this capture as the new baseline."},
"view":{"type":"string","enum":["current","extents","selection","region"],"default":"current","description":"Frame the camera before capture: extents fits all entities, selection fits selected entities, region fits explicit world bounds."},
"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"World XY bounding box [min_x, min_y, max_x, max_y] to zoom and fit in view before capturing (used with view: 'region')."},
"focus_handles":{"type":"array","items":{"type":"string"},"description":"Hex handles of entities to zoom and fit in view before capturing."},
@@ -1908,10 +1992,25 @@ mod tests {
"hash": "hash123",
"bytes": 1024
},
"metadata": {"width": 800, "height": 600}
"metadata": {
"width": 800,
"height": 600,
"_spatial": {
"crs": "CAD_WCS",
"unit": "Millimeters",
"pixel_to_world_matrix": [0.05, 0.0, 0.0, -0.05, -20.0, 15.0],
"world_to_pixel_matrix": [20.0, 0.0, 0.0, -20.0, 400.0, 300.0]
}
}
});
let res_res = tool_result(res_value);
assert_eq!(res_res["isError"], false);
assert_eq!(res_res["structuredContent"]["_spatial"]["crs"], "CAD_WCS");
assert_eq!(res_res["structuredContent"]["_spatial"]["unit"], "Millimeters");
assert_eq!(
res_res["structuredContent"]["_spatial"]["pixel_to_world_matrix"],
json!([0.05, 0.0, 0.0, -0.05, -20.0, 15.0])
);
let content = res_res["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert!(content[0]["text"].as_str().unwrap().contains("cad://session/s1/snapshot/hash123.png"));
@@ -1937,6 +2036,84 @@ mod tests {
assert!(both_content.iter().any(|c| c["type"] == "image"));
}
#[test]
fn capture_tool_schema_and_diff_metadata_handling() {
// 1. Verify ocs_capture schema has diff parameters
let tools = tool_definitions();
let capture_tool = tools
.as_array()
.unwrap()
.iter()
.find(|t| t["name"] == "ocs_capture")
.expect("ocs_capture tool exists");
let props = &capture_tool["inputSchema"]["properties"];
assert_eq!(props["diff"]["type"], "boolean");
assert_eq!(props["diff"]["default"], false);
assert_eq!(props["diff_mode"]["type"], "string");
assert_eq!(props["diff_mode"]["default"], "highlight");
let diff_modes = props["diff_mode"]["enum"].as_array().unwrap();
assert!(diff_modes.iter().any(|v| v == "highlight"));
assert!(diff_modes.iter().any(|v| v == "crop"));
assert!(diff_modes.iter().any(|v| v == "metadata_only"));
assert_eq!(props["reset_diff_baseline"]["type"], "boolean");
assert_eq!(props["delivery"]["type"], "string");
// 2. Test tool_result with diff metadata
let diff_capture_res = json!({
"$resource": {
"uri": "cad://session/s1/snapshot/diffhash789.png",
"mimeType": "image/png",
"hash": "diffhash789",
"bytes": 512
},
"metadata": {
"width": 800,
"height": 600,
"_spatial": {
"crs": "CAD_WCS",
"unit": "Millimeters",
"pixel_to_world_matrix": [0.05, 0.0, 0.0, -0.05, -20.0, 15.0],
"world_to_pixel_matrix": [20.0, 0.0, 0.0, -20.0, 400.0, 300.0]
},
"diff": {
"is_baseline": false,
"baseline_hash": "basehash123",
"changed": true,
"change_percentage": 2.5,
"changed_pixels": 12000,
"dirty_pixel_bounds": [100, 150, 300, 350],
"dirty_world_bounds": [-15.0, -2.5, -5.0, 7.5],
"patch_resolution": [201, 201],
"mode": "crop"
}
}
});
let structured = tool_result(diff_capture_res);
assert_eq!(structured["isError"], false);
assert_eq!(structured["structuredContent"]["diff"]["changed"], true);
assert_eq!(structured["structuredContent"]["diff"]["mode"], "crop");
assert_eq!(
structured["structuredContent"]["diff"]["dirty_pixel_bounds"],
json!([100, 150, 300, 350])
);
assert_eq!(
structured["structuredContent"]["diff"]["dirty_world_bounds"],
json!([-15.0, -2.5, -5.0, 7.5])
);
assert_eq!(structured["structuredContent"]["_spatial"]["crs"], "CAD_WCS");
let content = structured["content"].as_array().unwrap();
let text_block = content.iter().find(|c| {
c["type"] == "text"
&& c["text"]
.as_str()
.unwrap_or("")
.contains("\"dirty_pixel_bounds\"")
});
assert!(text_block.is_some(), "Metadata text block must format diff parameters");
}
#[test]
fn validates_batch_steps_and_compacts_state() {
let batch = json!({