diff --git a/Cargo.lock b/Cargo.lock index ee73fdd2b..a2ce9f354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "OpenCADStudio" version = "2026.39.0" dependencies = [ + "allsorts", "ashpd", "base64", "bincode", @@ -4205,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=282b130#282b1303e4d096febd37a898850a988595fd8d0e" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d28fd71#d28fd710268e7301798703d3ca9c5698a1af91ed" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4228,7 +4229,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=e204966#e2049666de6acd9681d69f7fb16a9f881b4d8b74" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=79bdc4b#79bdc4b0586b227edad722d220227caa7f06b32f" dependencies = [ "opencadkernel", "serde_json", @@ -4237,7 +4238,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=68eeda1#68eeda1069440f2e990738edb923f37666920dfc" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=a03e4f0#a03e4f094331949c8b27fb659e6891b84eeaa813" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4248,7 +4249,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=68eeda1#68eeda1069440f2e990738edb923f37666920dfc" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=a03e4f0#a03e4f094331949c8b27fb659e6891b84eeaa813" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 96c4aa6a6..5ced7c8f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,10 +34,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "68eeda1", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "68eeda1" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "e204966" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d28fd71", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "a03e4f0", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "a03e4f0" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "79bdc4b" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -94,9 +94,6 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString" iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } -# ATTDEF/ATTRIB DXF output and the R2013+ solid wire cache (cadcodec PR pending). -[patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "282b130" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" @@ -107,6 +104,11 @@ i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" printpdf = { version = "0.9.1", default-features = false, features = ["images"] } +# TrueType subsetting for searchable PDF text (same version printpdf itself +# uses, so no new binary enters the lockfile). Same feature set as printpdf's +# own allsorts use (pure-Rust flate backend) — the defaults would drag in the +# libz-sys C dependency. Native-only like printpdf. +allsorts = { version = "0.16", default-features = false, features = ["flate2_rust"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 09a21c5ea..408368990 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d28fd71", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d28fd71", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 4d0a0f810..b04b14ace 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1958,7 +1958,7 @@ pub fn validate_entity_mutation( if old.is_mpolygon != new.is_mpolygon || old.mpolygon_hatch_color != new.mpolygon_hatch_color || old.mpolygon_x_direction != new.mpolygon_x_direction - || old.mpolygon_boundary_handle_count != new.mpolygon_boundary_handle_count + || old.mpolygon_invalid_loops != new.mpolygon_invalid_loops { return Err("Hatch MPOLYGON fields are unmapped and read-only".into()); } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index eb764d0d1..168bb51ab 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d28fd71", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 44bcdd907..6dacfa72d 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=5ef9376#5ef9376e02996097ff649cd9268c343327413787" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d28fd71#d28fd710268e7301798703d3ca9c5698a1af91ed" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 226e7db29..e5a093028 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -925,7 +925,7 @@ fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { } if manifest.manual_kinds.contains(kind) { arms.push_str(&format!( - " \"{kind}\" => codec::EntityType::{kind}({}_from_dict(dict, vm)?),\n", + " \"{kind}\" => codec::EntityType::{kind}({}_from_dict(dict, vm)?.into()),\n", snake_case(kind) )); continue; @@ -962,7 +962,7 @@ fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { for f in &fields { body.push_str(&format!(" {}\n", f.setter)); } - body.push_str(&format!(" codec::EntityType::{kind}({var})\n")); + body.push_str(&format!(" codec::EntityType::{kind}({var}.into())\n")); arms.push_str(&format!(" \"{kind}\" => {{\n{body} }}\n")); } format!( @@ -990,7 +990,7 @@ fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> Str for kind in &manifest.type_filter { if manifest.manual_kinds.contains(kind) { arms.push_str(&format!( - " codec::EntityType::{kind}(existing_value) => codec::EntityType::{kind}({}_apply(existing_value, dict, vm)?),\n", + " codec::EntityType::{kind}(existing_value) => codec::EntityType::{kind}({}_apply(existing_value, dict, vm)?.into()),\n", snake_case(kind) )); continue; @@ -1032,7 +1032,7 @@ fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> Str for f in &fields { body.push_str(&format!(" {}\n", f.setter)); } - body.push_str(&format!(" codec::EntityType::{kind}({var})\n")); + body.push_str(&format!(" codec::EntityType::{kind}({var}.into())\n")); arms.push_str(&format!( " codec::EntityType::{kind}(existing_value) => {{\n{body} }}\n" )); diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 954cad93c..929268656 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -3699,7 +3699,7 @@ mod ocs { } host_ctx::with_host(|host| { host_ctx::ensure_undo_started(host); - host.add_entity(EntityType::Table(builder.build())).value() + host.add_entity(EntityType::Table(Box::new(builder.build()))).value() }) .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) } diff --git a/src/app/command_driver/tests.rs b/src/app/command_driver/tests.rs index 8cf929474..be67ee374 100644 --- a/src/app/command_driver/tests.rs +++ b/src/app/command_driver/tests.rs @@ -965,16 +965,16 @@ mod thicken_tests { ) .unwrap(); let source = app.add_surface_model( - codec::EntityType::Surface(Surface::new(SurfaceKind::Plane)), + codec::EntityType::Surface(Box::new(Surface::new(SurfaceKind::Plane))), body, ); assert!(!source.is_null()); let i = app.active_tab; let invalid = app.tabs[i] .scene - .add_entity(codec::EntityType::Surface(Surface::new( + .add_entity(codec::EntityType::Surface(Box::new(Surface::new( SurfaceKind::Generic, - ))); + )))); let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); let before = app.tabs[i].history.undo_stack.len(); @@ -1060,7 +1060,7 @@ mod thicken_tests { kernel::brep::extrude_surface(Plane::XY, &[Curve::Nurbs(profile)], [0.0, 0.0, 2.0]) .unwrap(); let source = app.add_surface_model( - codec::EntityType::Surface(Surface::new(SurfaceKind::Generic)), + codec::EntityType::Surface(Box::new(Surface::new(SurfaceKind::Generic))), body, ); let i = app.active_tab; diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index 5d1a98bd6..a45753708 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -322,7 +322,7 @@ impl OpenCADStudio { "_PCCROPCIRC" => "C", _ => "", }; - let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, cloud, option); + let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, *cloud, option); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/commands/point_cloud.rs b/src/app/commands/point_cloud.rs index 6f1ef62a0..0c302da32 100644 --- a/src/app/commands/point_cloud.rs +++ b/src/app/commands/point_cloud.rs @@ -163,10 +163,10 @@ impl OpenCADStudio { elevation_as_gradient: false, croppings: Vec::new(), }; - let entity = codec::EntityType::Extended(codec::entities::ExtendedEntity { + let entity = codec::EntityType::Extended(Box::new(codec::entities::ExtendedEntity { common: codec::entities::EntityCommon::new(), data: codec::entities::ExtendedEntityData::PointCloudEx(data), - }); + })); if let Some(cloud) = self.commit_entity_handle(entity) { // The reactor, owned by the cloud, tells the definition who uses it. let document = &mut self.tabs[i].scene.document; diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 12a544ffb..b037ca839 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -116,20 +116,73 @@ fn embed_point(req: &Value) -> Result { } Ok(codec::types::Vector3::new(p[0], p[1], p[2])) } +fn parse_point_2d(val: &Value) -> Result<[f64; 2], Value> { + let arr = val + .as_array() + .filter(|v| v.len() >= 2) + .ok_or_else(|| failure("invalid_point", "Expected [x, y]"))?; + let x = arr[0] + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?; + let y = arr[1] + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?; + Ok([x, y]) +} + impl OpenCADStudio { /// `embed_image` — pack the picture at `path` into an OLE2FRAME placed /// with its lower-left corner at `at` (default width ≈ pixel_width/100, /// like the interactive command's Enter answer) and commit it as one /// undo step. The drawing stays self-contained: no external file to lose. + /// Supports automatic 2-point calibration via `source_points` and `target_points`. #[cfg(not(target_arch = "wasm32"))] pub(super) fn control_embed_image(&mut self, req: &Value) -> Result, Value> { let i = self.active_tab; let path = string(req, "path")?; let image = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path)) .map_err(|e| failure("embed_failed", e))?; - let at = embed_point(req)?; - let default_width = (image.pixel_width as f64 / 100.0).max(1.0); - let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); + let (at, width) = if let (Some(src_pts), Some(tgt_pts)) = ( + req.get("source_points").and_then(Value::as_array), + req.get("target_points").and_then(Value::as_array), + ) { + if src_pts.len() >= 2 && tgt_pts.len() >= 2 { + let p_s1 = parse_point_2d(&src_pts[0])?; + let p_s2 = parse_point_2d(&src_pts[1])?; + let p_t1 = parse_point_2d(&tgt_pts[0])?; + let p_t2 = parse_point_2d(&tgt_pts[1])?; + let d_px = ((p_s2[0] - p_s1[0]).powi(2) + (p_s2[1] - p_s1[1]).powi(2)).sqrt(); + let d_cad = ((p_t2[0] - p_t1[0]).powi(2) + (p_t2[1] - p_t1[1]).powi(2)).sqrt(); + if d_px <= 1e-6 { + return Err(failure( + "invalid_points", + "Source calibration points must be distinct", + )); + } + let scale = d_cad / d_px; + let w = image.pixel_width as f64 * scale; + // In image pixel coords, y is 0 at top and pixel_height at bottom. + // In CAD coordinates, lower-left is (x_ll, y_ll). + // x_target = x_ll + px * scale => x_ll = x_target - px * scale + // y_target = y_ll + (pixel_height - py) * scale => y_ll = y_target - (pixel_height - py) * scale + let x_ll = p_t1[0] - p_s1[0] * scale; + let y_ll = p_t1[1] - (image.pixel_height as f64 - p_s1[1]) * scale; + let calc_at = codec::types::Vector3::new(x_ll, y_ll, 0.0); + let final_at = embed_point(req).unwrap_or(calc_at); + let final_w = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(w); + (final_at, final_w) + } else { + let default_width = (image.pixel_width as f64 / 100.0).max(1.0); + let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); + (embed_point(req)?, width) + } + } else { + let default_width = (image.pixel_width as f64 / 100.0).max(1.0); + let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); + (embed_point(req)?, width) + }; if req["linked"].as_bool().unwrap_or(false) { // Path-linked RasterImage + ImageDefinition: the drawing stores // only the file path, so the picture file must travel with the @@ -168,6 +221,7 @@ impl OpenCADStudio { "handle": format!("{:X}", handle.value()), "kind": "RasterImage", "path": path, + "at": [at.x, at.y, at.z], "width": width, "height": height, "linked": true, @@ -187,6 +241,7 @@ impl OpenCADStudio { "handle": format!("{:X}", handle.value()), "kind": "Ole2Frame", "path": path, + "at": [at.x, at.y, at.z], "width": width, })); Ok(Task::none()) diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e2e64735a..cab09f40a 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -464,6 +464,7 @@ impl OpenCADStudio { | "records" | "record_schema" | "capabilities" + | "tools" | "entities" | "layers" | "header" @@ -527,6 +528,17 @@ impl OpenCADStudio { Task::none(), ); } + if op == "tools" { + return ( + json!({ + "ok": true, + "status": "completed", + "tools": crate::mcp::tool_definitions(), + "instructions": crate::mcp::INSTRUCTIONS + }), + Task::none(), + ); + } if op == "commands" { let mut names = crate::command::all_registered_command_names(); let mut names: Vec = names.drain(..).map(str::to_owned).collect(); @@ -665,7 +677,7 @@ impl OpenCADStudio { ); } if self.tabs[self.active_tab].id != id - && !matches!(op, "activate" | "close" | "entities_copy_to") + && !matches!(op, "activate" | "switch_document" | "close" | "entities_copy_to") { return ( failure( @@ -743,7 +755,7 @@ impl OpenCADStudio { let client = req["client_id"].as_str().unwrap_or("default").to_owned(); if tab.active_cmd.is_some() && (self.control.owner.as_ref() != Some(&(tab.id, client.clone())) - || matches!(op, "run" | "start" | "new" | "open" | "activate")) + || matches!(op, "run" | "start" | "new" | "open" | "activate" | "switch_document")) && op != "cancel" { return ( @@ -774,7 +786,7 @@ impl OpenCADStudio { ); } } - let doc = if matches!(op, "new" | "open" | "activate") { + let doc = if matches!(op, "new" | "open" | "activate" | "switch_document") { None } else { Some(tab.id) @@ -803,7 +815,7 @@ impl OpenCADStudio { if self.tabs[self.active_tab].active_cmd.is_some() { self.control.owner = Some((self.tabs[self.active_tab].id, client)); } - if matches!(op, "new" | "activate") + if matches!(op, "new" | "activate" | "switch_document") && self .control .pending @@ -876,7 +888,7 @@ impl OpenCADStudio { "open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string( req, "path", )?))), - "activate" => { + "activate" | "switch_document" => { let i = self .tabs .iter() @@ -1058,6 +1070,58 @@ impl OpenCADStudio { .main_window .ok_or_else(|| failure("gui_required", "Capture requires a GUI window"))?; let path = string(req, "path")?.to_owned(); + crate::sys::restore_window_if_minimized(); + + // Optional framing / selection adjustments prior to snapshot: + if let Some(handles) = req.get("highlight_handles").and_then(Value::as_array) { + let hex_handles: Vec = handles + .iter() + .filter_map(|v| v.as_str()) + .filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok()) + .map(codec::Handle::new) + .collect(); + if !hex_handles.is_empty() { + let scene = &mut self.tabs[self.active_tab].scene; + scene.deselect_all(); + for h in hex_handles { + scene.select_entity(h, false); + } + } + } + if let Some(focus) = req.get("focus_handles").and_then(Value::as_array) { + let hex_handles: Vec = focus + .iter() + .filter_map(|v| v.as_str()) + .filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok()) + .map(codec::Handle::new) + .collect(); + if !hex_handles.is_empty() { + self.tabs[self.active_tab].scene.zoom_to_entities(&hex_handles); + } + } else if let Some(bounds) = req.get("bounds").and_then(Value::as_array) { + if bounds.len() >= 4 { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = ( + bounds[0].as_f64(), + bounds[1].as_f64(), + bounds[2].as_f64(), + bounds[3].as_f64(), + ) { + let p1 = glam::Vec3::new(x0 as f32, y0 as f32, 0.0); + let p2 = glam::Vec3::new(x1 as f32, y1 as f32, 0.0); + self.tabs[self.active_tab].scene.zoom_to_window(p1, p2); + } + } + } else if let Some(view) = req.get("view").and_then(Value::as_str) { + if view == "extents" { + self.tabs[self.active_tab].scene.fit_all(); + } else if view == "selection" { + let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); + if !selected.is_empty() { + self.tabs[self.active_tab].scene.zoom_to_entities(&selected); + } + } + } + // A minimized window has a 0x0 surface and the renderer // panics reading it back, so report instead of capturing. iced::window::size(window).then(move |size| { @@ -1118,7 +1182,7 @@ impl OpenCADStudio { if pending.document_id.is_none() && matches!( pending.request["op"].as_str(), - Some("new" | "open" | "activate") + Some("new" | "open" | "activate" | "switch_document") ) && pending.origin_document != self.tabs[self.active_tab].id { @@ -1270,7 +1334,7 @@ impl OpenCADStudio { .pending .as_ref() .and_then(|pending| pending.request["scope"].as_str()) - .unwrap_or("window"); + .unwrap_or("viewport"); let max_dimension = self .control .pending @@ -1278,10 +1342,22 @@ impl OpenCADStudio { .and_then(|pending| pending.request["max_dimension"].as_u64()) .unwrap_or(1600) .clamp(256, 4096) as u32; + let annotate = self + .control + .pending + .as_ref() + .and_then(|pending| pending.request["annotate"].as_bool()) + .unwrap_or(false); + 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"; + + let (vp_w, vp_h) = self.tabs[self.active_tab].scene.selection.borrow().vp_size; + let mut vp_logical_width = if vp_w > 0.0 { vp_w } else { image.width() as f32 / s.scale_factor }; + let mut vp_logical_height = if vp_h > 0.0 { vp_h } else { image.height() as f32 / s.scale_factor }; + if requested_scope == "viewport" { if let Some(bounds) = crate::ui::wrap_bar::dropdown_bounds( crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, @@ -1305,6 +1381,8 @@ impl OpenCADStudio { ) .to_image(); actual_scope = "viewport"; + vp_logical_width = bounds.width; + vp_logical_height = bounds.height; } } } @@ -1320,12 +1398,77 @@ impl OpenCADStudio { image::imageops::FilterType::Triangle, ); } + + // Spatial grounding: camera, world bounds, and visible entity centroids + let grounding = vision::compute_grounding( + self, + vp_logical_width, + vp_logical_height, + image.width(), + image.height(), + ); + + // Optional Set-of-Marks visual overlay badges + if annotate { + for ent_val in &grounding.visible_entities { + if let (Some(tag), Some(px_arr), Some(selected)) = ( + ent_val["tag"].as_u64(), + ent_val["screen_pixel"].as_array(), + ent_val["selected"].as_bool(), + ) { + if px_arr.len() == 2 { + if let (Some(x), Some(y)) = (px_arr[0].as_i64(), px_arr[1].as_i64()) { + vision::draw_som_badge( + &mut image, + x as i32, + y as i32, + tag as usize, + selected, + ); + } + } + } + } + } + image .save_with_format(&path, image::ImageFormat::Png) .map_err(|e| e.to_string())?; - Ok( - 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}), - ) + + 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, + "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, + "_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, + "annotations": grounding.visible_entities.clone(), + "visible_entities": grounding.visible_entities, + })) })(); match result { Ok(v) => { @@ -1337,6 +1480,7 @@ impl OpenCADStudio { } } } +pub(crate) mod vision; mod actions; pub(crate) use actions::property_json; mod entities; @@ -1763,6 +1907,36 @@ mod tests { let _ = handle; } + #[test] + fn embed_image_calibrates_from_source_and_target_points() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let img = image::RgbaImage::from_pixel(100, 100, image::Rgba([12, 12, 12, 255])); + let path = std::env::temp_dir().join(format!("ocs-embed-calib-{}.png", session_id())); + img.save(&path).unwrap(); + + // 2 points on image: bottom-left (0, 100) and bottom-right (100, 100) + // Target CAD points: (10.0, 20.0) and (210.0, 20.0) -> scale = 200/100 = 2.0 + let response = request( + &mut app, + json!({ + "op": "embed_image", + "path": path.to_string_lossy(), + "linked": true, + "source_points": [[0, 100], [100, 100]], + "target_points": [[10.0, 20.0], [210.0, 20.0]] + }), + ); + assert_eq!(response["status"], "completed", "{response}"); + assert_eq!(response["result"]["kind"], "RasterImage"); + assert_eq!(response["result"]["width"], 200.0); + assert_eq!(response["result"]["height"], 200.0); + let at = response["result"]["at"].as_array().unwrap(); + assert_eq!(at[0], 10.0); + assert_eq!(at[1], 20.0); + let _ = std::fs::remove_file(path); + } + fn user_select_pick(app: &mut OpenCADStudio, kind: &str) -> String { let handle = app.automation_op( format!(r#"{{"op":"query","type":"{kind}","detail":"summary"}}"#).as_str(), diff --git a/src/app/control/transport.rs b/src/app/control/transport.rs index adce0eeb7..5827567ef 100644 --- a/src/app/control/transport.rs +++ b/src/app/control/transport.rs @@ -53,6 +53,7 @@ fn listen(sender: mpsc::Sender) -> std::io::Result<()> { let mut file = options.open(&path)?; write!(file, "{descriptor}")?; drop(file); + let _ = crate::mcp::sync_agent_tool_schemas(); let clients = Arc::new(AtomicUsize::new(0)); for stream in listener.incoming().flatten() { if clients.fetch_add(1, Ordering::SeqCst) >= 8 { diff --git a/src/app/control/vision.rs b/src/app/control/vision.rs new file mode 100644 index 000000000..7aa0f3d30 --- /dev/null +++ b/src/app/control/vision.rs @@ -0,0 +1,762 @@ +use crate::app::OpenCADStudio; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// 5x7 bitmapped font glyphs for digits '0' through '9' +const DIGITS_5X7: [[u8; 7]; 10] = [ + [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110], // 0 + [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110], // 1 + [0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111], // 2 + [0b11110, 0b00001, 0b00001, 0b01110, 0b00001, 0b00001, 0b11110], // 3 + [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010], // 4 + [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110], // 5 + [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110], // 6 + [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000], // 7 + [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110], // 8 + [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100], // 9 +]; + +/// Draw a Set-of-Marks badge at `(center_x, center_y)` onto `image`. +pub(crate) fn draw_som_badge( + image: &mut image::RgbaImage, + center_x: i32, + center_y: i32, + tag: usize, + selected: bool, +) { + let tag_str = tag.to_string(); + let num_digits = tag_str.len(); + if num_digits == 0 { + return; + } + let text_w = num_digits * 5 + (num_digits - 1) * 1; + let badge_w = (text_w + 8) as i32; + let badge_h = 13i32; + + let img_w = image.width() as i32; + let img_h = image.height() as i32; + + // Anchor badge slightly above the center anchor point if possible + let x0 = (center_x - badge_w / 2).clamp(1, (img_w - badge_w - 1).max(1)); + let y0 = (center_y - badge_h - 2).clamp(1, (img_h - badge_h - 1).max(1)); + + let (bg, border, text_col) = if selected { + ( + image::Rgba([0, 230, 255, 245]), // Electric cyan + image::Rgba([0, 60, 80, 255]), + image::Rgba([0, 0, 0, 255]), + ) + } else { + ( + image::Rgba([255, 220, 0, 245]), // Vibrant yellow + image::Rgba([40, 30, 0, 255]), + image::Rgba([0, 0, 0, 255]), + ) + }; + + // Fill badge background and border + for y in y0..(y0 + badge_h) { + for x in x0..(x0 + badge_w) { + if x < 0 || x >= img_w || y < 0 || y >= img_h { + continue; + } + let is_border = x == x0 || x == x0 + badge_w - 1 || y == y0 || y == y0 + badge_h - 1; + let col = if is_border { border } else { bg }; + image.put_pixel(x as u32, y as u32, col); + } + } + + // Draw digits + let mut cur_x = x0 + 4; + let cur_y = y0 + 3; + for ch in tag_str.chars() { + if let Some(digit) = ch.to_digit(10) { + let d = digit as usize; + if d < 10 { + for r in 0..7 { + let row_bits = DIGITS_5X7[d][r]; + for c in 0..5 { + if (row_bits & (1 << (4 - c))) != 0 { + let px = cur_x + c as i32; + let py = cur_y + r as i32; + if px >= 0 && px < img_w && py >= 0 && py < img_h { + image.put_pixel(px as u32, py as u32, text_col); + } + } + } + } + } + } + cur_x += 6; + } + + // Small 3x3 indicator dot at the center anchor + let dot_col = if selected { + image::Rgba([0, 230, 255, 255]) + } else { + image::Rgba([255, 60, 0, 255]) + }; + for dy in -1..=1 { + for dx in -1..=1 { + let px = center_x + dx; + let py = center_y + dy; + if px >= 0 && px < img_w && py >= 0 && py < img_h { + image.put_pixel(px as u32, py as u32, dot_col); + } + } + } +} + +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, +} + +pub(crate) fn compute_grounding( + app: &OpenCADStudio, + vp_logical_width: f32, + vp_logical_height: f32, + image_width: u32, + image_height: u32, +) -> VisionGrounding { + let tab = &app.tabs[app.active_tab]; + let c = tab.scene.camera.borrow(); + let vp_rect = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vp_logical_width.max(1.0), + height: vp_logical_height.max(1.0), + }; + + let p_tl = c.pick_on_target_plane(iced::Point::new(0.0, 0.0), vp_rect); + let p_tr = c.pick_on_target_plane(iced::Point::new(vp_rect.width, 0.0), vp_rect); + let p_bl = c.pick_on_target_plane(iced::Point::new(0.0, vp_rect.height), vp_rect); + let p_br = c.pick_on_target_plane(iced::Point::new(vp_rect.width, vp_rect.height), vp_rect); + + let min_x = p_tl.x.min(p_tr.x).min(p_bl.x).min(p_br.x); + let max_x = p_tl.x.max(p_tr.x).max(p_bl.x).max(p_br.x); + let min_y = p_tl.y.min(p_tr.y).min(p_bl.y).min(p_br.y); + let max_y = p_tl.y.max(p_tr.y).max(p_bl.y).max(p_br.y); + + let viewport_world_bounds = json!({ + "min": [min_x, min_y], + "max": [max_x, max_y] + }); + + 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; + + struct Candidate { + handle: codec::Handle, + entity_type: &'static str, + layer: String, + screen_pixel: [i32; 2], + screen_bounds: [i32; 4], + world_center: [f64; 3], + selected: bool, + area: f32, + } + + let selected_set: std::collections::HashSet = + tab.scene.selected_handles_in_order().into_iter().collect(); + + let mut candidates = Vec::new(); + + for entity in tab.scene.document.entities() { + let handle = entity.common().handle; + let (b_min, b_max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, entity); + let min_dvec = glam::DVec3::from(b_min); + let max_dvec = glam::DVec3::from(b_max); + if !min_dvec.is_finite() || !max_dvec.is_finite() { + continue; + } + let center = (min_dvec + max_dvec) * 0.5; + if let Some(screen_pt) = c.project(center, vp_rect) { + if screen_pt.x >= 0.0 + && screen_pt.x <= vp_rect.width + && screen_pt.y >= 0.0 + && screen_pt.y <= vp_rect.height + { + let px = (screen_pt.x / vp_rect.width * img_w).round() as i32; + let py = (screen_pt.y / vp_rect.height * img_h).round() as i32; + + let mut s_min_x = px; + let mut s_max_x = px; + let mut s_min_y = py; + let mut s_max_y = py; + + if let (Some(p0), Some(p1)) = + (c.project(min_dvec, vp_rect), c.project(max_dvec, vp_rect)) + { + let x0 = (p0.x / vp_rect.width * img_w).round() as i32; + let y0 = (p0.y / vp_rect.height * img_h).round() as i32; + let x1 = (p1.x / vp_rect.width * img_w).round() as i32; + let y1 = (p1.y / vp_rect.height * img_h).round() as i32; + s_min_x = x0.min(x1); + s_max_x = x0.max(x1); + s_min_y = y0.min(y1); + s_max_y = y0.max(y1); + } + + // An unbounded entity (XLINE, RAY) projects to saturated i32 + // corners, whose difference overflows i32. + let width = (f64::from(s_max_x) - f64::from(s_min_x)).abs(); + let area = (width * (f64::from(s_max_y) - f64::from(s_min_y)).abs()) as f32; + let selected = selected_set.contains(&handle); + let layer = entity.common().layer.clone(); + + candidates.push(Candidate { + handle, + entity_type: crate::entities::names::ui_name(entity), + layer, + screen_pixel: [px, py], + screen_bounds: [s_min_x, s_min_y, s_max_x, s_max_y], + world_center: [center.x, center.y, center.z], + selected, + area, + }); + } + } + } + + // Selected entities prioritized, then larger visual area first + candidates.sort_by(|a, b| { + b.selected + .cmp(&a.selected) + .then_with(|| b.area.partial_cmp(&a.area).unwrap_or(std::cmp::Ordering::Equal)) + }); + + candidates.truncate(64); + + let visible_entities = candidates + .into_iter() + .enumerate() + .map(|(idx, cand)| { + json!({ + "tag": idx + 1, + "handle": format!("{:X}", cand.handle.value()), + "type": cand.entity_type, + "layer": cand.layer, + "screen_pixel": cand.screen_pixel, + "screen_bounds": cand.screen_bounds, + "world_center": cand.world_center, + "selected": cand.selected + }) + }) + .collect(); + + 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, + 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, 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()) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PyramidLevelInfo { + pub level: u32, + pub grid: [u32; 2], + pub tile_world_span: [f64; 2], + pub tiles_count: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PyramidManifest { + pub crs: String, + pub unit: String, + pub world_bounds: [f64; 4], + pub max_level: u32, + pub tile_size: u32, + pub tile_uri_template: String, + pub levels: Vec, +} + +pub fn compute_pyramid_manifest( + session_id: &str, + unit: &str, + world_bounds: [f64; 4], + max_level: u32, + tile_size: u32, +) -> PyramidManifest { + let min_x = world_bounds[0]; + let min_y = world_bounds[1]; + let max_x = world_bounds[2]; + let max_y = world_bounds[3]; + + let w = (max_x - min_x).abs().max(1e-4); + let h = (max_y - min_y).abs().max(1e-4); + + let max_lvl = max_level.clamp(1, 6); + let mut levels = Vec::new(); + + for level in 0..=max_lvl { + let cols = 1u32 << level; + let rows = 1u32 << level; + let tile_w = w / cols as f64; + let tile_h = h / rows as f64; + levels.push(PyramidLevelInfo { + level, + grid: [cols, rows], + tile_world_span: [tile_w, tile_h], + tiles_count: cols * rows, + }); + } + + PyramidManifest { + crs: "CAD_WCS".to_string(), + unit: unit.to_string(), + world_bounds: [min_x, min_y, max_x, max_y], + max_level: max_lvl, + tile_size, + tile_uri_template: format!("cad://session/{session_id}/tile/{{level}}/{{x}}/{{y}}.png"), + levels, + } +} + +pub fn compute_tile_bounds( + manifest_world_bounds: [f64; 4], + level: u32, + x: u32, + y: u32, +) -> Result<[f64; 4], String> { + let cols = 1u32 << level; + let rows = 1u32 << level; + + if x >= cols || y >= rows { + return Err(format!( + "Tile coordinates ({x}, {y}) out of range for level {level} (grid is {cols}x{rows})" + )); + } + + let min_x = manifest_world_bounds[0]; + let min_y = manifest_world_bounds[1]; + let max_x = manifest_world_bounds[2]; + let max_y = manifest_world_bounds[3]; + + let w = (max_x - min_x).abs().max(1e-4); + let h = (max_y - min_y).abs().max(1e-4); + + let tile_w = w / cols as f64; + let tile_h = h / rows as f64; + + let t_min_x = min_x + (x as f64) * tile_w; + let t_max_x = t_min_x + tile_w; + let t_max_y = max_y - (y as f64) * tile_h; + let t_min_y = t_max_y - tile_h; + + Ok([t_min_x, t_min_y, t_max_x, t_max_y]) +} + +#[allow(dead_code)] +pub fn point_to_tile( + manifest_world_bounds: [f64; 4], + level: u32, + point_x: f64, + point_y: f64, +) -> (u32, u32) { + let cols = 1u32 << level; + let rows = 1u32 << level; + + let min_x = manifest_world_bounds[0]; + let min_y = manifest_world_bounds[1]; + let max_x = manifest_world_bounds[2]; + let max_y = manifest_world_bounds[3]; + + let w = (max_x - min_x).abs().max(1e-4); + let h = (max_y - min_y).abs().max(1e-4); + + let tile_w = w / cols as f64; + let tile_h = h / rows as f64; + + let tx = (((point_x - min_x) / tile_w).floor() as i64).clamp(0, (cols - 1) as i64) as u32; + let ty = (((max_y - point_y) / tile_h).floor() as i64).clamp(0, (rows - 1) as i64) as u32; + + (tx, ty) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_draw_som_badge_renders_without_panics() { + let mut img = image::RgbaImage::from_pixel(120, 100, image::Rgba([40, 40, 40, 255])); + + // Render normal badge + draw_som_badge(&mut img, 50, 50, 1, false); + + // Center dot should be drawn + let dot = img.get_pixel(50, 50); + assert_eq!(*dot, image::Rgba([255, 60, 0, 255])); + + // Render selected badge with multi-digit tag near edge (clamped) + draw_som_badge(&mut img, 2, 2, 42, true); + let selected_dot = img.get_pixel(2, 2); + assert_eq!(*selected_dot, image::Rgba([0, 230, 255, 255])); + + // Right edge clamp + draw_som_badge(&mut img, 118, 98, 99, false); + } + + #[test] + fn test_compute_grounding_identifies_visible_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 0,0 5"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE -10,-10 10,10"}"#); + + // Set test canvas viewport size + app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + app.tabs[0].scene.fit_all(); + + let grounding = compute_grounding(&app, 800.0, 600.0, 800, 600); + + // Viewport world bounds should be valid + assert!(grounding.viewport_world_bounds["min"].is_array()); + assert!(grounding.viewport_world_bounds["max"].is_array()); + + // 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); + + let first = &grounding.visible_entities[0]; + assert_eq!(first["tag"], 1); + assert!(first["handle"].is_string()); + assert!(first["type"].is_string()); + assert!(first["screen_pixel"].is_array()); + assert!(first["screen_bounds"].is_array()); + assert!(first["world_center"].is_array()); + + 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"); + } + + #[test] + fn test_pyramid_tiling_manifest_and_bounds() { + let wb = [0.0, 0.0, 200.0, 100.0]; + let manifest = compute_pyramid_manifest("sess_test", "Millimeters", wb, 3, 512); + + assert_eq!(manifest.crs, "CAD_WCS"); + assert_eq!(manifest.unit, "Millimeters"); + assert_eq!(manifest.world_bounds, wb); + assert_eq!(manifest.max_level, 3); + assert_eq!(manifest.tile_size, 512); + assert_eq!(manifest.levels.len(), 4); // 0, 1, 2, 3 + + // Level 0: 1x1 tile covering full world bounds + assert_eq!(manifest.levels[0].grid, [1, 1]); + assert_eq!(manifest.levels[0].tiles_count, 1); + assert_eq!(manifest.levels[0].tile_world_span, [200.0, 100.0]); + let t00 = compute_tile_bounds(wb, 0, 0, 0).unwrap(); + assert_eq!(t00, [0.0, 0.0, 200.0, 100.0]); + + // Level 1: 2x2 grid (4 tiles, 100x50 span each) + assert_eq!(manifest.levels[1].grid, [2, 2]); + assert_eq!(manifest.levels[1].tiles_count, 4); + assert_eq!(manifest.levels[1].tile_world_span, [100.0, 50.0]); + + // (0, 0): top-left + let t1_00 = compute_tile_bounds(wb, 1, 0, 0).unwrap(); + assert_eq!(t1_00, [0.0, 50.0, 100.0, 100.0]); + + // (1, 0): top-right + let t1_10 = compute_tile_bounds(wb, 1, 1, 0).unwrap(); + assert_eq!(t1_10, [100.0, 50.0, 200.0, 100.0]); + + // (0, 1): bottom-left + let t1_01 = compute_tile_bounds(wb, 1, 0, 1).unwrap(); + assert_eq!(t1_01, [0.0, 0.0, 100.0, 50.0]); + + // (1, 1): bottom-right + let t1_11 = compute_tile_bounds(wb, 1, 1, 1).unwrap(); + assert_eq!(t1_11, [100.0, 0.0, 200.0, 50.0]); + + // Out-of-range checks + assert!(compute_tile_bounds(wb, 1, 2, 0).is_err()); + assert!(compute_tile_bounds(wb, 1, 0, 2).is_err()); + + // Point-to-tile mapping + assert_eq!(point_to_tile(wb, 1, 25.0, 75.0), (0, 0)); + assert_eq!(point_to_tile(wb, 1, 175.0, 75.0), (1, 0)); + assert_eq!(point_to_tile(wb, 1, 25.0, 25.0), (0, 1)); + assert_eq!(point_to_tile(wb, 1, 175.0, 25.0), (1, 1)); + } +} diff --git a/src/app/history.rs b/src/app/history.rs index 7133694c7..4ea0a0e99 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -685,15 +685,15 @@ impl OpenCADStudio { /// Add is delta-safe when its layer and XData application IDs already exist /// and it creates no block records. pub(super) fn delta_add_safe(&self, i: usize, entity: &EntityType) -> bool { - if matches!( - entity, - EntityType::Block(_) - | EntityType::BlockEnd(_) - | EntityType::Extended(codec::entities::ExtendedEntity { - data: codec::entities::ExtendedEntityData::SectionObject(_), - .. - }) - ) { + if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) + || matches!( + entity, + EntityType::Extended(extended) if matches!( + extended.data, + codec::entities::ExtendedEntityData::SectionObject(_) + ) + ) + { return false; } let layer = entity.common().layer.clone(); diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index d4bf15247..0d522ada2 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1354,7 +1354,7 @@ impl super::OpenCADStudio { let mut entity = Surface::new(SurfaceKind::Generic); entity.common = group.common; self.add_surface_model_preserving_style( - EntityType::Surface(entity), + EntityType::Surface(Box::new(entity)), group.body, ) } @@ -2316,7 +2316,7 @@ impl super::OpenCADStudio { } let n = surfaces.len(); for surf in surfaces { - self.tabs[i].scene.add_entity(EntityType::Surface(surf)); + self.tabs[i].scene.add_entity(EntityType::Surface(Box::new(surf))); } self.tabs[i].dirty = true; self.refresh_properties(); diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index b8d7acce9..edf47fe0c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -198,14 +198,14 @@ impl<'a> HostSession<'a> { } if let EntityType::Helix(new) = entity { let old = match old { - Some(EntityType::Helix(old)) => Some(old), + Some(EntityType::Helix(old)) => Some(&**old), _ => None, }; crate::entities::helix::normalize_scripted_helix(old, new)?; } if let EntityType::Table(new) = entity { let old = match old { - Some(EntityType::Table(old)) => Some(old), + Some(EntityType::Table(old)) => Some(&**old), _ => None, }; crate::entities::table::normalize_scripted_table(old, new); @@ -758,7 +758,7 @@ impl<'a> HostSession<'a> { surface.wires = model::edge_wires(&body); surface.acis_data = codec::entities::AcisData::from_sat(&sat.to_sat_string()); surface.common.layer = layer; - self.commit_profile_result(EntityType::Surface(surface), source, delete_source, "Create surface") + self.commit_profile_result(EntityType::Surface(Box::new(surface)), source, delete_source, "Create surface") } SolidOperation::Extrude { source, direction, layer, delete_source } => { if direction.iter().any(|v| !v.is_finite()) { @@ -788,7 +788,7 @@ impl<'a> HostSession<'a> { surface.wires = model::edge_wires(&body); surface.acis_data = codec::entities::AcisData::from_sat(&sat.to_sat_string()); surface.common.layer = layer; - EntityType::Surface(surface) + EntityType::Surface(Box::new(surface)) }; self.commit_profile_result(result, source, delete_source, "Extrude profile") } @@ -7201,7 +7201,7 @@ mod tests { let unliftable = host.add_entity(EntityType::Solid3D(codec::entities::Solid3D::new())); let mut plane = codec::entities::Surface::new(codec::entities::SurfaceKind::Plane); plane.acis_data = codec::entities::AcisData::new(); - let surface = host.add_entity(EntityType::Surface(plane)); + let surface = host.add_entity(EntityType::Surface(Box::new(plane))); let far = { dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(500, 500, 500), size=(1, 1, 1)).handle"); *solids_in(host.document()).last().unwrap() }; let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()); diff --git a/src/app/presspull_ops.rs b/src/app/presspull_ops.rs index a93d183d7..18eacfe20 100644 --- a/src/app/presspull_ops.rs +++ b/src/app/presspull_ops.rs @@ -140,7 +140,7 @@ impl super::OpenCADStudio { surface.surface_data = solid_history::extrusion_surface_data(record) .ok_or("PRESSPULL: the surface parameters could not be retained.")?; } - EntityType::Surface(surface) + EntityType::Surface(Box::new(surface)) }; (body, history, entity) } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bb7e0318e..8bfeab79a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -10938,7 +10938,7 @@ mod free_text_entry_tests { let handle = app.tabs[0] .scene .document - .add_entity(EntityType::Table(table.clone())) + .add_entity(EntityType::Table(Box::new(table.clone()))) .unwrap(); app.set_active_command(0, Box::new(TableCellEditCommand::new(handle, &table, 0, 0))); (app, handle) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c12f3646d..7dabd8108 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2865,7 +2865,9 @@ impl OpenCADStudio { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }; + + ..Default::default() +}; previews.push(guide); } } diff --git a/src/cli.rs b/src/cli.rs index 099650df0..bb589a5b6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -80,6 +80,11 @@ pub struct Cli { #[arg(help = crate::t!("Connect AI clients to the running editor over MCP stdio.").into_owned(), long_help = None)] pub mcp: bool, + /// Export and synchronize AI agent MCP tool schemas to ~/.gemini/antigravity/mcp/opencadstudio/ and exit. + #[arg(long = "sync-mcp-schemas")] + #[arg(help = crate::t!("Export and synchronize AI agent MCP tool schemas to ~/.gemini/antigravity/mcp/opencadstudio/ and exit.").into_owned(), long_help = None)] + pub sync_mcp_schemas: bool, + /// TCP port for --serve (defaults to stdin/stdout). #[arg(long, value_name = "PORT")] #[arg(help = crate::t!("TCP port for --serve (defaults to stdin/stdout).").into_owned(), long_help = None)] diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index edb51cd44..28f1428d8 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -4051,7 +4051,9 @@ fn tessellate_dimension_inner( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } if !ext2.is_empty() { wires.push(WireModel { @@ -4088,7 +4090,9 @@ fn tessellate_dimension_inner( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } else { wires.push(WireModel { @@ -4125,7 +4129,9 @@ fn tessellate_dimension_inner( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } @@ -4168,7 +4174,9 @@ fn tessellate_dimension_inner( // — not a crash. Follow-up: double-single-split via points_to_ds to // match emit_wire's paired fill path. fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); // Arrowheads and ticks: the dimension line's colour and weight, never // its dashes. if !geom.arrow_lines.is_empty() { @@ -4224,7 +4232,9 @@ fn tessellate_dimension_inner( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } // DIMTFILL: 0=none, 1=drawing background (mask), 2=DIMTFILLCLR. @@ -4279,7 +4289,9 @@ fn tessellate_dimension_inner( // (sub-metre error at UTM scale) — not a crash. Follow-up: // double-single-split via points_to_ds to match emit_wire. fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } } @@ -4326,7 +4338,9 @@ fn tessellate_dimension_inner( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } @@ -5855,6 +5869,15 @@ fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) { &wire.text_verts, map, ); + // OCS→WCS is rigid: unit height scale, no in-plane rotation change. + wire.map_searchable_runs( + &|p| { + let mapped = map(p[0], p[1], p[2]); + [mapped.0, mapped.1, mapped.2] + }, + 1.0, + 0.0, + ); for (point, _) in &mut wire.snap_pts { let mapped = map(point.x, point.y, point.z); *point = DVec3::new(mapped.0, mapped.1, mapped.2); diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 717ee110f..83b0b615e 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -705,6 +705,7 @@ impl LeaderTess for Leader { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), + ..Default::default() }; } @@ -887,6 +888,7 @@ impl LeaderTess for Leader { plinegen: true, fill_tris, fill_tris_low, + ..Default::default() } } } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 74de8d97a..b6992403f 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2106,9 +2106,11 @@ impl MultiLeaderTess for MultiLeader { // error at UTM scale) — not a crash. Follow-up: double-single-split // via points_to_ds to match emit_wire's paired fill path. fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); - let host = codec::EntityType::MultiLeader(ml.clone()); + let host = codec::EntityType::MultiLeader(Box::new(ml.clone())); for block_use in crate::scene::render_graph::entity_render_block_uses(document, &host, 1.0) .into_iter() .filter(|block_use| { @@ -2299,6 +2301,8 @@ impl MultiLeaderTess for MultiLeader { // cluster-position data. let mut deco_pts: Vec<[f32; 3]> = Vec::new(); let mut deco_fill: Vec<[f32; 3]> = Vec::new(); + let mut searchable: Vec = + Vec::new(); if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { for ts in &layout.strokes { let is_shaped = ts @@ -2331,17 +2335,18 @@ impl MultiLeaderTess for MultiLeader { continue; } let run = ts.run.as_ref().unwrap(); - let quads = crate::scene::text::glyph_quads::layout_glyph_quads( - &mut atlas, - run.height, - run.rotation, - run.width_factor, - run.oblique, - run.tracking, - &run.font, - run.bold, - &run.text, - ); + let (quads, pen_adv) = + crate::scene::text::glyph_quads::layout_glyph_quads( + &mut atlas, + run.height, + run.rotation, + run.width_factor, + run.oblique, + run.tracking, + &run.font, + run.bold, + &run.text, + ); // Inline `\C` / `\c` colour wins over the entity text // colour, matching the top-level Text arm. let gcolor = ts @@ -2356,6 +2361,24 @@ impl MultiLeaderTess for MultiLeader { gcolor, 0.0, ); + { + use crate::scene::model::wire_model::{ + clean_searchable_text, SearchableTextRun, + }; + let visible = clean_searchable_text(&run.text); + if !visible.is_empty() { + searchable.push(SearchableTextRun { + text: visible, + origin: [ts.origin[0], ts.origin[1], z as f64], + height: run.height, + rotation: run.rotation, + color: gcolor, + bold: run.bold, + font: run.font.clone(), + adv_width: pen_adv, + }); + } + } } } if !sdf_verts.is_empty() || !deco_pts.is_empty() || !deco_fill.is_empty() { @@ -2400,6 +2423,7 @@ impl MultiLeaderTess for MultiLeader { dash_from_start: false, dash_align_end: None, text_verts: sdf_verts, + searchable_text: searchable, name: name.clone(), points: deco_pts, points_low: Vec::new(), @@ -2492,7 +2516,9 @@ impl MultiLeaderTess for MultiLeader { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } @@ -2583,7 +2609,9 @@ impl MultiLeaderTess for MultiLeader { plinegen: true, fill_tris, fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } // Text frame — closed rectangle, matches text color. @@ -2629,7 +2657,9 @@ impl MultiLeaderTess for MultiLeader { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } } diff --git a/src/entities/table.rs b/src/entities/table.rs index 26c212d29..0130996b9 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1488,6 +1488,7 @@ pub fn tessellate_table( // SDF cell text: glyph quads (per-vertex coloured) collected across all // cells; emitted as one text-carrying wire at the end. let mut text_verts: Vec = Vec::new(); + let mut searchable: Vec = Vec::new(); let mut borders: HashMap<([u8; 4], u32), ([f32; 4], f32, Vec<[f32; 3]>)> = HashMap::default(); let mut emitted: rustc_hash::FxHashSet<(i32, i32, i32, i32, i32, i32)> = rustc_hash::FxHashSet::default(); @@ -2016,17 +2017,18 @@ pub fn tessellate_table( } continue; } - let quads = crate::scene::text::glyph_quads::layout_glyph_quads( - &mut atlas, - run.height, - run.rotation, - run.width_factor, - run.oblique, - run.tracking, - &run.font, - run.bold, - &run.text, - ); + let (quads, pen_adv) = + crate::scene::text::glyph_quads::layout_glyph_quads( + &mut atlas, + run.height, + run.rotation, + run.width_factor, + run.oblique, + run.tracking, + &run.font, + run.bold, + &run.text, + ); crate::scene::pipeline::text_gpu::push_glyph_vertices( &mut text_verts, &quads, @@ -2035,6 +2037,24 @@ pub fn tessellate_table( tcol, 0.0, ); + { + use crate::scene::model::wire_model::{ + clean_searchable_text, SearchableTextRun, + }; + let visible = clean_searchable_text(&run.text); + if !visible.is_empty() { + searchable.push(SearchableTextRun { + text: visible, + origin: [stroke.origin[0], stroke.origin[1], to.z as f64], + height: run.height, + rotation: run.rotation, + color: tcol, + bold: run.bold, + font: run.font.clone(), + adv_width: pen_adv, + }); + } + } } } } @@ -2084,7 +2104,9 @@ pub fn tessellate_table( // (sub-metre error at UTM scale) — not a crash. Follow-up: // double-single-split via points_to_ds to match emit_wire. fill_tris_low: Vec::new(), - } + + ..Default::default() +} }; let mut out: Vec = Vec::new(); @@ -2115,6 +2137,7 @@ pub fn tessellate_table( let mut w = mk(entity_color, vec![], vec![], line_weight_px); w.aabb = [nx as f32, ny as f32, xx as f32, xy as f32]; w.text_verts = text_verts; + w.searchable_text = searchable; out.push(w); } out diff --git a/src/entities/traits.rs b/src/entities/traits.rs index 93b2f0dea..fbb797baa 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -81,6 +81,63 @@ pub trait Transformable { fn apply_transform(&mut self, t: &EntityTransform); } +// The codec boxes its rare wide entity kinds (Surface, Table, MultiLeader, +// Helix, Extended) so every other entity is stored narrow; the boxed ones +// answer through their contents. +impl RenderConvertible for Box { + fn to_render(&self, document: &CadDocument) -> Option { + (**self).to_render(document) + } +} + +impl Grippable for Box { + fn grips(&self) -> Vec { + (**self).grips() + } + fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + (**self).apply_grip(grip_id, apply) + } + fn grip_menu(&self, grip_id: usize) -> Vec { + (**self).grip_menu(grip_id) + } + fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction) { + (**self).apply_grip_menu(grip_id, action) + } + fn apply_grip_menu_value(&mut self, grip_id: usize, action: GripMenuAction, value: f64) { + (**self).apply_grip_menu_value(grip_id, action, value) + } + fn grip_menu_value_prompt( + &self, + grip_id: usize, + action: GripMenuAction, + ) -> Option<&'static str> { + (**self).grip_menu_value_prompt(grip_id, action) + } + fn grip_menu_point_value( + &self, + grip_id: usize, + action: GripMenuAction, + point: glam::DVec3, + ) -> Option { + (**self).grip_menu_point_value(grip_id, action, point) + } +} + +impl PropertyEditable for Box { + fn geometry_properties(&self, text_style_names: &[String]) -> Vec { + (**self).geometry_properties(text_style_names) + } + fn apply_geom_prop(&mut self, field: &str, value: &str) { + (**self).apply_geom_prop(field, value) + } +} + +impl Transformable for Box { + fn apply_transform(&mut self, t: &EntityTransform) { + (**self).apply_transform(t) + } +} + /// Inquiry-time mass / area / perimeter properties for entities whose /// 2D footprint has a meaningful area or perimeter (Circle, Arc, Line, /// LwPolyline, Ellipse). Entities outside this set get `None` via the diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index f75a1a6ed..90d0f901b 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -15,11 +15,13 @@ use crate::scene::model::hatch_model::HatchPattern; use crate::scene::WireModel; use crate::scene::model::image_model::ImageModel; #[cfg(not(target_arch = "wasm32"))] +use crate::scene::model::wire_model::SearchableTextRun; +#[cfg(not(target_arch = "wasm32"))] use printpdf::{ BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem, - WindingOrder, + TextRenderingMode, WindingOrder, }; use std::path::Path; @@ -425,11 +427,14 @@ pub async fn pick_pdf_path_async(stem: String) -> Option { #[cfg(not(target_arch = "wasm32"))] fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result, String> { let mut doc = PdfDocument::new("Open CAD Studio Export"); + // Subset-embed one TrueType font per used system family before any page + // references them (fonts live on the document resource dictionary). + let fonts = SearchableFontSet::build(&mut doc, pages); // Borrowing all pages keeps their pixel Arcs alive until this cache is dropped. // Allocation addresses cannot be reused by another source during this export. let mut image_resources = std::collections::HashMap::new(); for (index, page) in pages.iter().enumerate() { - append_pdf_page(&mut doc, &mut image_resources, page, plot_style) + append_pdf_page(&mut doc, &mut image_resources, page, plot_style, &fonts) .map_err(|error| format!("Page {}: {error}", index + 1))?; } let mut warnings = Vec::new(); @@ -451,6 +456,7 @@ fn append_pdf_page( image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, page: &PdfPageInput, fallback_plot_style: Option<&PlotStyleTable>, + fonts: &SearchableFontSet, ) -> Result<(), String> { let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content; let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32); @@ -590,7 +596,7 @@ fn append_pdf_page( } draw_items.push((wire.draw_depth, 2u8, sequence, DrawItem::Wire(wire))); sequence += 1; - if !wire.text_verts.is_empty() { + if !wire.text_verts.is_empty() || !wire.searchable_text.is_empty() { draw_items.push((wire.draw_depth, 3u8, sequence, DrawItem::Text(wire))); sequence += 1; } @@ -653,6 +659,13 @@ fn append_pdf_page( continue; } DrawItem::Text(wire) => { + // Searchable layer + outlines coexist: the vector outlines + // always draw (pixel-identical plots, decorations, stroke + // fonts, shaped/mixed scripts all keep working) and the + // preserved runs add an *invisible* (`Tr 3`) text layer on + // top for searching, copying and screen readers. Zero + // visual change by construction — a gap in the text path + // can only affect extractability, never the rendered sheet. emit_text( &mut ops, std::slice::from_ref(&wire.wire), @@ -662,6 +675,16 @@ fn append_pdf_page( plot_style, options, ); + emit_searchable_text( + &mut ops, + std::slice::from_ref(&wire.wire), + ox, + oy, + clip, + plot_style, + options, + fonts, + ); last_color = None; last_lw = None; last_dash = None; @@ -1506,6 +1529,378 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] { } } +/// Invisible searchable text layer for laid-out runs (`Tr 3`). +/// +/// Each `SearchableTextRun` carries the visible string plus its final +/// world-space baseline origin, cap height, advance width and rotation — all +/// baked by the layout stage (MTEXT `\P` newlines arrive as separate per-line +/// runs, `attachment_point`/width wrapping and block placement already +/// applied), so the exporter only places a `BT … Tj ET` with an +/// arbitrary-rotation text matrix (`Tm`), honouring the MTEXT traps by +/// construction. +/// +/// Design (review V2): the vector outlines are **always** drawn — the plot +/// looks pixel-identical with or without this layer — and the runs add an +/// invisible (`Tr 3`) text layer for searching, copying and screen readers. +/// A gap here can only affect extractability, never the rendered sheet. +/// Decorations (underline/overline/strike), stroke fonts, shaped and mixed +/// scripts keep working because their outlines are untouched. +/// +/// Font choice per run (review V3 — "why Helvetica, not the DWG font?"): +/// - A run whose style resolves to an installed TrueType family is set in a +/// **subset CIDFontType2 of that exact family** (+ `/ToUnicode`), cut down +/// by `allsorts` to the glyphs the plot uses. Bold runs resolve the bold +/// face via fontdb weight matching. +/// - SHX/LFF stroke fonts have no embeddable font program (there is no TrueType +/// behind `txt`/`romans` — only pen strokes), unresolvable names and CFF/ +/// collection edge cases fall back to Base-14 Helvetica/Helvetica-Bold. +/// A Type3 font synthesized from the SDF atlas would cover those exactly and +/// is the natural follow-up; it needs no new layout data. +/// - `Tf` takes an *em* size but `run.height` is a *cap* height: the em size +/// is `height / cap_ratio` (per-font OS/2 sCapHeight, 0.72 for Helvetica) so +/// the invisible run aligns with the drawn cap height. +/// - Width-matching (`Tz`, baked into the `Tm` x-basis — equivalent, and the +/// only horizontal scaling printpdf exposes): `k = laid_advance / +/// natural_advance` from the run's stored pen advance and the font's hmtx +/// advances, so centered/right-aligned runs highlight where they draw. +/// - Scaling: the font size is converted world-mm → points via `MM_TO_PT`; +/// the page CTM (plot scale/rotation/clip) applies on top inside the same +/// graphics state as the wire geometry, so text and outlines scale together. +/// - Runs fully outside the export-window clip are skipped (rect overlap on +/// the run rect — same space as the clip polygon). +/// +/// Returns the number of runs emitted. +#[cfg(not(target_arch = "wasm32"))] +fn emit_searchable_text( + ops: &mut Vec, + wires: &[WireModel], + ox: f64, + oy: f64, + clip: Option<(f32, f32, f32, f32)>, + plot_style: Option<&PlotStyleTable>, + options: PdfPlotOptions, + fonts: &SearchableFontSet, +) -> usize { + use crate::scene::model::wire_model::run_rect_overlap; + /// Helvetica cap height ≈ 0.72 em. + const HELVETICA_CAP_RATIO: f32 = 0.72; + let mut emitted = 0; + for wire in wires { + if wire.searchable_text.is_empty() { + continue; + } + let mut ctb_color: Option<[f32; 3]> = None; + let mut screening = 1.0; + if let Some(ctb) = plot_style { + if wire.aci > 0 { + ctb_color = ctb.resolve_color(wire.aci); + screening = ctb.resolve_screening(wire.aci); + } + } + for run in &wire.searchable_text { + if run.text.is_empty() || run.height <= 0.0 || !run.height.is_finite() { + continue; + } + // Export-window clip culls by run rect (origin + advance × cap + // height box); the survivors inherit the clip path anyway. + if let Some((cx, cy, cw, ch)) = clip { + let px = run.origin[0] + ox; + let py = run.origin[1] + oy; + if !run_rect_overlap( + [px, py], + run.rotation, + run.adv_width as f64, + run.height as f64, + [cx as f64, cy as f64, (cx + cw) as f64, (cy + ch) as f64], + ) { + continue; + } + } + // Per-run font choice: the DWG style's own family when a subset + // is embedded, else the WinAnsi-gated Helvetica fallback. + enum RunFont<'a> { + Subset(&'a EmbeddedSubset), + Helvetica, + } + let choice = match crate::scene::text::font_face::Face::resolve(&run.font) { + crate::scene::text::font_face::Face::Ttf { family, .. } => fonts + .find(&family, run.bold) + .map(RunFont::Subset) + .unwrap_or(RunFont::Helvetica), + _ => RunFont::Helvetica, + }; + if matches!(choice, RunFont::Helvetica) && !is_winansi_encodable(&run.text) { + continue; + } + let rgb = ctb_color.unwrap_or_else(|| adapt_text_color([run.color[0], run.color[1], run.color[2]])); + let [r, g, b] = plotted_color(rgb, run.color[3], screening, options); + // World mm → page points; the page CTM (plot scale/rotation/clip) + // applies on top, exactly as for the wire geometry. + let x_pt = ((run.origin[0] + ox) * MM_TO_PT as f64) as f32; + let y_pt = ((run.origin[1] + oy) * MM_TO_PT as f64) as f32; + if !x_pt.is_finite() || !y_pt.is_finite() { + continue; + } + // Em size from cap height, and width-match factor baked into the + // Tm x-basis (= Tz, the only horizontal scaling printpdf exposes). + let (font_handle, size_pt, width_k) = match choice { + RunFont::Subset(sub) => { + let size = (run.height * MM_TO_PT / sub.cap_ratio).max(0.1); + (PdfFontHandle::External(sub.font_id.clone()), size, sub.width_factor(run)) + } + RunFont::Helvetica => { + let size = (run.height * MM_TO_PT / HELVETICA_CAP_RATIO).max(0.1); + let handle = PdfFontHandle::Builtin(if run.bold { + BuiltinFont::HelveticaBold + } else { + BuiltinFont::Helvetica + }); + (handle, size, 1.0) + } + }; + if !size_pt.is_finite() { + continue; + } + let (sin_r, cos_r) = run.rotation.sin_cos(); + ops.push(Op::StartTextSection); + ops.push(Op::SetFont { font: font_handle, size: Pt(size_pt) }); + ops.push(Op::SetTextRenderingMode { + mode: TextRenderingMode::Invisible, + }); + ops.push(Op::SetTextMatrix { + matrix: printpdf::TextMatrix::Raw([ + cos_r * width_k, + sin_r * width_k, + -sin_r, + cos_r, + x_pt, + y_pt, + ]), + }); + ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb { r, g, b, icc_profile: None }), + }); + ops.push(Op::ShowText { + items: vec![TextItem::Text(run.text.clone())], + }); + ops.push(Op::EndTextSection); + emitted += 1; + } + } + emitted +} + +/// Whether every char in `text` survives Base-14 WinAnsi encoding. +/// +/// ASCII + Latin-1 incl. French accents and the `°±Ø` DXF specials — but NOT +/// the C1 control range U+0080–U+009F (unassigned in WinAnsi; emitting them +/// would mojibake). Anything else is rejected per-run so the exporter never +/// emits a wrong `Tj` — those runs keep their outlines and stay unsearchable. +/// (Runs set in an embedded subset bypass this gate: Identity-H covers any +/// glyph the subset carries.) +#[cfg(not(target_arch = "wasm32"))] +fn is_winansi_encodable(text: &str) -> bool { + text.chars().all(|c| { + let cp = c as u32; + cp <= 0xFF && !(0x80..=0x9F).contains(&cp) + }) +} + +// ── Embedded subset fonts (one system family × weight, cut to used glyphs) ── + +/// One embedded subset TrueType font for the search layer: a single system +/// family at one weight, cut by `allsorts` to the glyphs the plot uses. +#[cfg(not(target_arch = "wasm32"))] +struct EmbeddedSubset { + family: String, + bold: bool, + font_id: printpdf::FontId, + /// Subset-space GID per char, re-parsed from the subset bytes (never + /// assumed from allsorts' assignment order). + gid_of: std::collections::BTreeMap, + /// Raw advance per subset GID, in font units (hmtx, for Tz). + width_of: std::collections::BTreeMap, + upem: u16, + /// Cap-height fraction of em (OS/2 sCapHeight, else 0.7). + cap_ratio: f32, +} + +#[cfg(not(target_arch = "wasm32"))] +impl EmbeddedSubset { + /// Width-match factor for a run: laid advance ÷ natural advance at the + /// run's cap height. 1.0 when the widths are unknown (never explodes a + /// selection highlight: clamped to a sane band). + fn width_factor(&self, run: &SearchableTextRun) -> f32 { + let natural: u32 = run + .text + .chars() + .filter_map(|c| self.gid_of.get(&c)) + .filter_map(|g| self.width_of.get(g)) + .map(|w| *w as u32) + .sum(); + if natural == 0 || run.adv_width <= 0.0 || run.height <= 0.0 { + return 1.0; + } + // laid (drawing mm) vs natural at cap height, both in em fractions: + // k = adv / (height * Σw / (upem * cap)). + let k = run.adv_width * self.upem as f32 * self.cap_ratio + / (run.height * natural as f32); + if !k.is_finite() { + 1.0 + } else { + k.clamp(0.25, 4.0) + } + } +} + +/// Embedded subsets for one export (Helvetica fallback needs no entry). +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct SearchableFontSet { + subsets: Vec, +} + +#[cfg(not(target_arch = "wasm32"))] +impl SearchableFontSet { + /// Collect every TTF-resolvable run's chars per (family, bold), subset + /// each bucket with `allsorts`, and register the subsets on the document. + /// Anything unresolvable (SHX/LFF names, big-font pairs, CFF outlines, + /// missing files) simply gets no entry — those runs take Helvetica. + fn build(doc: &mut PdfDocument, pages: &[PdfPageInput]) -> Self { + use std::collections::{BTreeMap, BTreeSet}; + let mut buckets: BTreeMap<(String, bool), BTreeSet> = BTreeMap::new(); + for page in pages { + for wire in page.content.wires.iter() { + for run in &wire.searchable_text { + if run.text.is_empty() { + continue; + } + if let crate::scene::text::font_face::Face::Ttf { family, .. } = + crate::scene::text::font_face::Face::resolve(&run.font) + { + buckets + .entry((family, run.bold)) + .or_default() + .extend(run.text.chars()); + } + } + } + } + let mut out = Self::default(); + for ((family, bold), chars) in &buckets { + if let Some(subset) = subset_family(doc, family, *bold, chars) { + out.subsets.push(subset); + } + } + out + } + + fn find(&self, family: &str, bold: bool) -> Option<&EmbeddedSubset> { + self.subsets + .iter() + .find(|s| s.family == family && s.bold == bold) + } +} + +/// Subset one system family to `chars`, embed the subset, and register it. +/// Returns `None` whenever embedding is impossible or pointless (no file, CFF +/// outlines the stub can't describe, subset failure, nothing mappable) — the +/// caller falls back to Helvetica, never to an invalid font dict. +#[cfg(not(target_arch = "wasm32"))] +fn subset_family( + doc: &mut PdfDocument, + family: &str, + bold: bool, + chars: &std::collections::BTreeSet, +) -> Option { + use allsorts::{binary::read::ReadScope, font_data::FontData, tables::FontTableProvider}; + let weight = if bold { + fontdb::Weight::BOLD + } else { + fontdb::Weight::NORMAL + }; + let (bytes, index) = crate::scene::text::sysfont::with_face_data_weighted( + family, + weight, + |data, index| (data.to_vec(), index), + )?; + // Original GIDs for the used chars (+ .notdef, mandatory for subsetting). + let orig = ttf_parser::Face::parse(&bytes, index).ok()?; + let mut ids: Vec = vec![0]; + for c in chars { + if let Some(gid) = orig.glyph_index(*c) { + if !ids.contains(&gid.0) { + ids.push(gid.0); + } + } + } + if ids.len() <= 1 { + return None; + } + let scope = ReadScope::new(&bytes); + let font_file = scope.read::>().ok()?; + let provider = font_file.table_provider(index as usize).ok()?; + // The no-text_layout ParsedFont stub can only describe TrueType + // (CIDFontType2 + FontFile2) — never mislabel CFF outlines as TrueType. + if provider.has_table(allsorts::tag::CFF) || provider.has_table(allsorts::tag::CFF2) { + return None; + } + let subset_bytes = allsorts::subset::subset( + &provider, + &ids, + &allsorts::subset::SubsetProfile::Pdf, + allsorts::subset::CmapTarget::Unicode, + ) + .ok()?; + // Re-parse the SUBSET: GID assignment is allsorts' business, and the maps + // below must be in subset space. Anything unmappable stays out. + let sub = ttf_parser::Face::parse(&subset_bytes, 0).ok()?; + let upem = sub.units_per_em().max(1); + let cap_ratio = sub + .capital_height() + .filter(|c| *c > 0) + .map(|c| c as f32 / upem as f32) + .filter(|r| (0.2..=1.0).contains(r)) + .unwrap_or(0.7); + let mut gid_chars: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut width_of: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for c in chars { + if let Some(gid) = sub.glyph_index(*c) { + gid_chars.insert(*c, gid.0); + width_of.insert(gid.0, sub.glyph_hor_advance(gid).unwrap_or(0)); + } + } + if gid_chars.is_empty() { + return None; + } + // printpdf (no text_layout) embeds `original_bytes` verbatim as the + // CIDFontType2 stream and derives ToUnicode/widths from these maps — so + // handing it the SUBSET bytes + subset-space maps yields a true subset + // embed with no lopdf surgery. (Metrics out before the bytes move.) + let (ascent, descent) = (sub.ascender(), sub.descender()); + let cp_map: std::collections::BTreeMap = + gid_chars.iter().map(|(c, g)| (*c as u32, *g)).collect(); + let parsed = printpdf::ParsedFont::with_glyph_data( + subset_bytes, + 0, + Some(family.to_string()), + cp_map, + width_of.clone(), + upem, + printpdf::FontMetrics { ascent, descent }, + ); + let font_id = doc.add_font(&parsed); + Some(EmbeddedSubset { + family: family.to_string(), + bold, + font_id, + gid_of: gid_chars, + width_of, + upem, + cap_ratio, + }) +} + /// Re-emit every wire's SDF text as vector geometry. /// /// Each visible glyph rides on `wire.text_verts` as one 6-vertex quad (two @@ -1514,6 +1909,10 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] { /// quad's `uv_min` and map it into that quad by affine interpolation of the /// plane rect — so a stroke (LFF) font emits polylines and a filled TrueType /// glyph emits filled triangles, exactly where the SDF quad sits. +/// +/// Always drawn: the visible glyph rendering (strokes for LFF/SHX pen fonts, +/// fills for TrueType outlines, solid bars for decorations). The invisible +/// searchable layer above adds extractability; it never replaces this. #[cfg(not(target_arch = "wasm32"))] fn emit_text( ops: &mut Vec, @@ -1905,7 +2304,7 @@ mod tests { fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire { use crate::scene::pipeline::text_gpu::push_glyph_vertices; use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas}; - let quads = { + let (quads, _) = { let mut atlas = sdf_atlas::text_atlas().lock().unwrap(); layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, text) }; @@ -1939,4 +2338,236 @@ mod tests { no_text.len() ); } + + fn searchable_wire(text: &str, origin: [f64; 3]) -> PlotWire { + use crate::scene::model::wire_model::SearchableTextRun; + PlotWire { + wire: WireModel { + searchable_text: vec![SearchableTextRun { + text: text.to_string(), + origin, + height: 10.0, + rotation: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + bold: false, + font: "txt".into(), + adv_width: 10.0 * text.chars().count().max(1) as f32, + }], + ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + }, + draw_depth: 0.0, + } + } + + // Outline-only export exposes zero BT operators and no extractable text; + // a preserved run must emit a real invisible (`Tr 3`) `BT … Tj ET` with + // the string, on top of the unchanged outlines. + #[test] + fn searchable_text_emits_bt_tj_with_string() { + let wire = searchable_wire("CMa-03", [20.0, 30.0, 0.0]); + let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + assert!(bytes.starts_with(b"%PDF"), "not a PDF"); + let stream = pdf_stream_text(&bytes); + assert!(stream.contains("BT"), "no text object (BT) in searchable export"); + assert!( + stream.contains("CMa-03"), + "searchable string missing from content stream" + ); + assert!( + stream.contains("/Helvetica"), + "searchable export should reference the text font resource" + ); + assert!( + stream.contains("Tj") || stream.contains("TJ"), + "no text-showing operator (Tj/TJ)" + ); + assert!( + stream.contains("Tr"), + "searchable layer must be invisible (Tr rendering mode)" + ); + } + + // Invisible layer coexists with outlines: the searchable file carries the + // string while the outlines-only file does not, and the outlines are + // identical in both (zero visual change — the layer only adds bytes). + #[test] + fn searchable_layer_coexists_with_outlines() { + let mut outline_only = text_wire("HELLO", [20.0, 20.0, 0.0]); + outline_only.wire.searchable_text.clear(); + let mut searchable = text_wire("HELLO", [20.0, 20.0, 0.0]); + searchable.wire.searchable_text = + searchable_wire("HELLO", [20.0, 20.0, 0.0]).wire.searchable_text; + + // Outlines are never suppressed: both wires keep their glyph quads. + assert!( + !searchable.wire.text_verts.is_empty(), + "searchable wire must keep its outline quads" + ); + + let outline_bytes = build_pdf_pages(&[test_page(vec![outline_only])], None).unwrap(); + let searchable_bytes = build_pdf_pages(&[test_page(vec![searchable])], None).unwrap(); + let outline_stream = pdf_stream_text(&outline_bytes); + let searchable_stream = pdf_stream_text(&searchable_bytes); + + assert!( + !outline_stream.contains("HELLO"), + "outline path must not leak extractable text" + ); + assert!( + searchable_stream.contains("HELLO") && searchable_stream.contains("BT"), + "searchable path must carry extractable BT/Tj text" + ); + assert!( + searchable_stream.contains("Tr"), + "searchable text must render invisibly (Tr)" + ); + assert!( + searchable_bytes.len() > outline_bytes.len(), + "invisible layer adds bytes ({} > {}); visuals come from the kept outlines", + searchable_bytes.len(), + outline_bytes.len() + ); + } + + // Per-run fallback: a non-WinAnsi run (Greek/Cyrillic/CJK/symbols) emits + // no `Tj` — its outlines still draw, so nothing renders as missing glyphs. + #[test] + fn searchable_text_skips_non_winansi_runs() { + let wire = searchable_wire("ΩΩ", [20.0, 20.0, 0.0]); + let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap()); + assert!( + !stream.contains("BT"), + "non-encodable run must not emit a text object" + ); + } + + // Rotation must ride a text matrix (Tm), not just an angle — a 90° run + // keeps its string and emits a rotated Tm. + #[test] + fn searchable_text_rotation_uses_text_matrix() { + let mut wire = searchable_wire("AB", [20.0, 20.0, 0.0]); + wire.wire.searchable_text[0].rotation = std::f32::consts::FRAC_PI_2; + let stream = pdf_stream_text(&build_pdf_pages(&[test_page(vec![wire])], None).unwrap()); + assert!(stream.contains("AB"), "rotated run lost its string"); + assert!(stream.contains("Tm"), "rotated run needs an explicit text matrix (Tm)"); + } + + #[test] + fn winansi_filter_covers_accents_and_rejects_cjk() { + assert!(is_winansi_encodable("CMa-03")); + assert!(is_winansi_encodable("crème °±Ø")); + assert!(!is_winansi_encodable("Ω")); + assert!(!is_winansi_encodable("中")); + // C1 controls are unassigned in WinAnsi: emitting them would mojibake + // (explicit \u escapes — a literal control char would not survive + // editors, and NBSP U+00A0 next door *is* encodable). + assert!(is_winansi_encodable("a\u{a0}b")); // NBSP is WinAnsi 0xA0 + assert!(!is_winansi_encodable("a\u{80}b")); + assert!(!is_winansi_encodable("a\u{9f}b")); + } + + #[test] + fn width_factor_matches_laid_to_natural_advance() { + use std::collections::BTreeMap; + let sub = EmbeddedSubset { + family: "Test".into(), + bold: false, + font_id: printpdf::FontId::new(), + gid_of: [('A', 1), ('B', 2)].into_iter().collect::>(), + width_of: [(1, 600), (2, 620)].into_iter().collect::>(), + upem: 1000, + cap_ratio: 0.7, + }; + let run = SearchableTextRun { + text: "AB".into(), + origin: [0.0, 0.0, 0.0], + height: 10.0, + rotation: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + bold: false, + font: "Test".into(), + // Laid advance exactly equals natural at cap height, so k = 1: + // adv = height * Σw / (upem * cap) = 10*1220/(1000*0.7). + adv_width: 10.0 * 1220.0 / (1000.0 * 0.7), + }; + assert!((sub.width_factor(&run) - 1.0).abs() < 1e-4); + let mut wide = run.clone(); + wide.adv_width *= 2.0; + assert!((sub.width_factor(&wide) - 2.0).abs() < 1e-4); + let mut empty = run; + empty.adv_width = 0.0; + assert_eq!(sub.width_factor(&empty), 1.0); + } + + // A TTF-backed run embeds a subset of its own family with ToUnicode — + // the DWG font, not Helvetica. Skips where no test font is installed. + #[test] + fn subset_embeds_the_runs_own_family_with_tounicode() { + let family = ["DejaVu Sans", "Liberation Sans", "Arial"] + .into_iter() + .find(|f| { + crate::scene::text::sysfont::with_face_data_weighted( + f, + fontdb::Weight::NORMAL, + |_, _| true, + ) + .unwrap_or(false) + }); + let Some(family) = family else { + eprintln!("no test TTF installed; skipping subset test"); + return; + }; + let full_len = + crate::scene::text::sysfont::with_face_data_weighted(family, fontdb::Weight::NORMAL, |data, _| { + data.len() + }) + .unwrap(); + let mut wire = searchable_wire("CMa-03 pincé", [20.0, 30.0, 0.0]); + wire.wire.searchable_text[0].font = family.to_string(); + // Unit level: the subset is built for the run's family and maps the + // used chars (extractability source of truth — the content stream + // carries hex GIDs, resolved via ToUnicode, not literal text). + let mut probe = printpdf::PdfDocument::new("subset-probe"); + let fonts = + SearchableFontSet::build(&mut probe, &[test_page(vec![wire.clone()])]); + let sub = fonts + .find(family, false) + .expect("subset embedded for the run's own family"); + assert!(sub.gid_of.contains_key(&'C'), "used chars must be mapped"); + assert!(sub.gid_of.contains_key(&'é'), "accents must survive subsetting"); + // PDF level: ToUnicode + embedded bytes, and no Helvetica fallback. + let bytes = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let stream = pdf_stream_text(&bytes); + assert!( + stream.contains("ToUnicode"), + "subset font must carry a ToUnicode CMap" + ); + assert!( + stream.contains("FontFile2"), + "subset font must embed its (TrueType) bytes" + ); + assert!( + !stream.contains("/Helvetica"), + "own-family run must not fall back to Helvetica" + ); + assert!( + bytes.len() < full_len, + "subset PDF ({} bytes) must be lighter than the full font ({} bytes)", + bytes.len(), + full_len + ); + } + + // Runs fully outside the export window never reach the content stream. + #[test] + fn clip_culls_runs_outside_the_export_window() { + let inside = searchable_wire("IN", [20.0, 20.0, 0.0]); + let outside = searchable_wire("OUT", [500.0, 500.0, 0.0]); + // Same page, windowed clip around the origin run only. + let mut page = test_page(vec![inside, outside]); + page.clip = Some((0.0, 0.0, 100.0, 100.0)); + let stream = pdf_stream_text(&build_pdf_pages(&[page], None).unwrap()); + assert!(stream.contains("IN"), "inside run must survive the clip"); + assert!(!stream.contains("OUT"), "outside run must be culled by the clip"); + } } diff --git a/src/main.rs b/src/main.rs index 90dea5130..d5674fee7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,16 @@ fn main() -> iced::Result { std::process::exit(if ok { 0 } else { 1 }); } + if args.sync_mcp_schemas { + let synced = mcp::sync_agent_tool_schemas(); + if synced { + println!("Successfully synchronized OpenCADStudio MCP schemas to ~/.gemini/antigravity/mcp/opencadstudio/"); + } else { + eprintln!("Antigravity MCP directory ~/.gemini/antigravity/mcp/ not found; skipped sync."); + } + return Ok(()); + } + // MCP is a client-neutral local entry point. It uses only stdin, // stdout and the authenticated GUI bridge, so it must run before any // logging or graphics setup can write to the protocol stream. diff --git a/src/mcp.rs b/src/mcp.rs index 659c305d5..e77f4e10d 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -6,6 +6,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde::Deserialize; use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; use std::{ collections::{HashMap, VecDeque}, fs::{File, OpenOptions}, @@ -23,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. 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."; +pub(crate) 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, supports diff: true for streaming dirty visual regions, and provides multiscale DeepZoom pyramidal tiling via tile: {level, x, y} or cad://session/{session_id}/pyramid/manifest.json and cad://session/{session_id}/tile/{level}/{x}/{y}.png. 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", @@ -31,6 +32,7 @@ const READ_OPS: &[&str] = &[ "records", "record_schema", "capabilities", + "tools", "entities", "layers", "header", @@ -45,11 +47,13 @@ const READ_OPS: &[&str] = &[ "audit", "text_search", "text_audit", + "capture", ]; const EXECUTE_OPS: &[&str] = &[ "new", "open", "activate", + "switch_document", "run", "start", "input", @@ -91,6 +95,7 @@ const BATCH_STEP_OPS: &[&str] = &[ "new", "open", "activate", + "switch_document", "run", "start", "input", @@ -204,6 +209,173 @@ impl TaskStore { } } +#[derive(Clone, Debug)] +struct CachedSnapshot { + session_id: String, + hash: String, + created_at: String, + mime_type: String, + data_base64: String, + bytes_len: usize, + metadata: Value, +} + +#[derive(Default)] +struct ResourceStore { + snapshots: HashMap, + order: VecDeque, + latest: HashMap, + last_captures: HashMap, + tiles: HashMap, + tile_order: VecDeque, + pyramid_manifests: HashMap, +} + +const MAX_STORED_SNAPSHOTS: usize = 32; +const MAX_STORED_TILES: usize = 64; + +impl ResourceStore { + fn insert( + &mut self, + session_id: &str, + hash: &str, + data_base64: String, + bytes_len: usize, + metadata: Value, + ) -> String { + let uri = format!("cad://session/{session_id}/snapshot/{hash}.png"); + let now = iso8601_now(); + let snapshot = CachedSnapshot { + session_id: session_id.to_string(), + hash: hash.to_string(), + created_at: now, + mime_type: "image/png".to_string(), + data_base64, + bytes_len, + metadata, + }; + + if !self.snapshots.contains_key(&uri) { + if self.order.len() >= MAX_STORED_SNAPSHOTS { + if let Some(oldest) = self.order.pop_front() { + self.snapshots.remove(&oldest); + } + } + self.order.push_back(uri.clone()); + } + self.snapshots.insert(uri.clone(), snapshot); + self.latest.insert(session_id.to_string(), uri.clone()); + uri + } + + fn insert_tile( + &mut self, + session_id: &str, + level: u32, + x: u32, + y: u32, + data_base64: String, + bytes_len: usize, + metadata: Value, + ) -> String { + let uri = format!("cad://session/{session_id}/tile/{level}/{x}/{y}.png"); + let now = iso8601_now(); + let snapshot = CachedSnapshot { + session_id: session_id.to_string(), + hash: format!("tile-{level}-{x}-{y}"), + created_at: now, + mime_type: "image/png".to_string(), + data_base64, + bytes_len, + metadata, + }; + + if !self.tiles.contains_key(&uri) { + if self.tile_order.len() >= MAX_STORED_TILES { + if let Some(oldest) = self.tile_order.pop_front() { + self.tiles.remove(&oldest); + } + } + self.tile_order.push_back(uri.clone()); + } + self.tiles.insert(uri.clone(), snapshot); + uri + } + + fn insert_pyramid_manifest(&mut self, session_id: &str, manifest: Value) -> String { + let uri = format!("cad://session/{session_id}/pyramid/manifest.json"); + self.pyramid_manifests.insert(session_id.to_string(), manifest); + uri + } + + fn get_by_uri(&self, uri: &str) -> Option<&CachedSnapshot> { + if uri.ends_with("/snapshot/latest.png") { + if let Some(session_id) = uri + .strip_prefix("cad://session/") + .and_then(|s| s.strip_suffix("/snapshot/latest.png")) + { + if let Some(target_uri) = self.latest.get(session_id) { + return self.snapshots.get(target_uri); + } + } + return None; + } + if uri.contains("/tile/") { + return self.tiles.get(uri); + } + self.snapshots.get(uri) + } + + fn list_resources(&self, active_session_ids: &[String]) -> Vec { + let mut list = Vec::new(); + for session_id in active_session_ids { + list.push(json!({ + "uri": format!("cad://session/{session_id}/viewport.png"), + "name": format!("Active Viewport ({session_id})"), + "description": format!("Live render of the active drawing viewport for session {session_id}."), + "mimeType": "image/png" + })); + list.push(json!({ + "uri": format!("cad://session/{session_id}/pyramid/manifest.json"), + "name": format!("Pyramid Manifest ({session_id})"), + "description": format!("Multiscale DeepZoom pyramidal tiling manifest for session {session_id}."), + "mimeType": "application/json" + })); + if let Some(latest_uri) = self.latest.get(session_id) { + if let Some(snap) = self.snapshots.get(latest_uri) { + list.push(json!({ + "uri": format!("cad://session/{session_id}/snapshot/latest.png"), + "name": format!("Latest Capture ({session_id})"), + "description": format!("Most recent viewport capture ({} bytes, captured at {})", snap.bytes_len, snap.created_at), + "mimeType": "image/png" + })); + } + } + } + for uri in &self.order { + if let Some(snap) = self.snapshots.get(uri) { + list.push(json!({ + "uri": uri, + "name": format!("Snapshot {}", &snap.hash[..snap.hash.len().min(8)]), + "description": format!("Captured frame for session {} ({} bytes, captured at {})", snap.session_id, snap.bytes_len, snap.created_at), + "mimeType": "image/png" + })); + } + } + for uri in &self.tile_order { + if let Some(tile) = self.tiles.get(uri) { + list.push(json!({ + "uri": uri, + "name": format!("Tile {}", tile.hash), + "description": format!("Pyramidal tile for session {} ({} bytes)", tile.session_id, tile.bytes_len), + "mimeType": "image/png" + })); + } + } + list + } +} + fn random_id() -> Result { let mut bytes = [0u8; 16]; getrandom::fill(&mut bytes).map_err(|error| error.to_string())?; @@ -787,10 +959,295 @@ fn shape_execute_response( Ok(response) } +fn read_resource( + uri: &str, + clients: &mut HashMap, + resources: &mut ResourceStore, +) -> Result { + if !uri.starts_with("cad://") { + return Err(format!("Unsupported resource URI scheme: {uri}")); + } + // Check cached snapshot image: cad://session/{session_id}/snapshot/{hash}.png or latest.png + if let Some(snapshot) = resources.get_by_uri(uri) { + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": snapshot.mime_type, + "blob": snapshot.data_base64 + } + ] + })); + } + // Check snapshot metadata json: cad://session/{session_id}/snapshot/{hash}.json + if uri.ends_with(".json") { + let png_uri = format!("{}.png", uri.trim_end_matches(".json")); + if let Some(snapshot) = resources.get_by_uri(&png_uri) { + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": "application/json", + "text": serde_json::to_string_pretty(&snapshot.metadata) + .unwrap_or_else(|_| snapshot.metadata.to_string()) + } + ] + })); + } + } + // Live viewport capture: cad://session/{session_id}/viewport.png, pyramid manifest, tiles, or state.json + if let Some(rest) = uri.strip_prefix("cad://session/") { + if let Some((session_id, subpath)) = rest.split_once('/') { + if subpath == "pyramid/manifest.json" { + if let Some(manifest) = resources.pyramid_manifests.get(session_id) { + let text = serde_json::to_string_pretty(manifest) + .unwrap_or_else(|_| manifest.to_string()); + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": "application/json", + "text": text + } + ] + })); + } + let mut bounds = [-100.0, -100.0, 100.0, 100.0]; + let mut unit = "Millimeters".to_string(); + if let Some(latest_uri) = resources.latest.get(session_id) { + if let Some(snap) = resources.snapshots.get(latest_uri) { + if let Some(spatial) = snap.metadata.get("_spatial") { + if let Some(u) = spatial.get("unit").and_then(Value::as_str) { + unit = u.to_string(); + } + if let Some(wb) = spatial.get("world_bounds") { + if let (Some(min), Some(max)) = ( + wb.get("min").and_then(Value::as_array), + wb.get("max").and_then(Value::as_array), + ) { + if min.len() >= 2 && max.len() >= 2 { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = ( + min[0].as_f64(), + min[1].as_f64(), + max[0].as_f64(), + max[1].as_f64(), + ) { + bounds = [x0, y0, x1, y1]; + } + } + } + } + } + } + } + let manifest = crate::app::control::vision::compute_pyramid_manifest( + session_id, &unit, bounds, 4, 512, + ); + let manifest_val = + serde_json::to_value(&manifest).map_err(|e| e.to_string())?; + resources.insert_pyramid_manifest(session_id, manifest_val.clone()); + let text = serde_json::to_string_pretty(&manifest_val) + .unwrap_or_else(|_| manifest_val.to_string()); + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": "application/json", + "text": text + } + ] + })); + } else if subpath.starts_with("tile/") { + let trimmed = subpath.strip_prefix("tile/").unwrap_or(""); + let is_json = trimmed.ends_with(".json"); + let core = trimmed.trim_end_matches(".png").trim_end_matches(".json"); + let parts: Vec<&str> = core.split('/').collect(); + if parts.len() == 3 { + if let (Ok(level), Ok(x), Ok(y)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + let png_uri = + format!("cad://session/{session_id}/tile/{level}/{x}/{y}.png"); + if is_json { + if let Some(tile_snap) = resources.tiles.get(&png_uri) { + let meta_text = serde_json::to_string_pretty(&tile_snap.metadata) + .unwrap_or_else(|_| tile_snap.metadata.to_string()); + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": "application/json", + "text": meta_text + } + ] + })); + } + } + + if let Some(tile_snap) = resources.tiles.get(&png_uri) { + return Ok(json!({ + "contents": [ + { + "uri": png_uri, + "mimeType": "image/png", + "blob": tile_snap.data_base64 + } + ] + })); + } + + let mut base_bounds = [-100.0, -100.0, 100.0, 100.0]; + if let Some(manifest) = resources.pyramid_manifests.get(session_id) { + if let Some(arr) = + manifest.get("world_bounds").and_then(Value::as_array) + { + if arr.len() == 4 { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = ( + arr[0].as_f64(), + arr[1].as_f64(), + arr[2].as_f64(), + arr[3].as_f64(), + ) { + base_bounds = [x0, y0, x1, y1]; + } + } + } + } + let tile_bounds = crate::app::control::vision::compute_tile_bounds( + base_bounds, level, x, y, + )?; + let path = std::env::temp_dir() + .join(format!("ocs-tile-{session_id}-{level}-{x}-{y}.png")); + let req = json!({ + "op": "capture", + "path": path.to_string_lossy(), + "scope": "viewport", + "view": "region", + "bounds": tile_bounds, + "max_dimension": 512, + }); + let result = client(clients, session_id)?.request(req, 20.0)?; + if result["ok"].as_bool() != Some(true) + || result["status"].as_str() != Some("completed") + { + return Err(result.to_string()); + } + let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; + let _ = std::fs::remove_file(path); + let b64 = BASE64.encode(&bytes); + let bytes_len = bytes.len(); + let mut meta = + result.get("result").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = meta.as_object_mut() { + obj.remove("path"); + obj.insert( + "pyramid".into(), + json!({ + "level": level, + "x": x, + "y": y, + "tile_world_bounds": tile_bounds, + "tile_uri": png_uri, + "manifest_uri": format!("cad://session/{session_id}/pyramid/manifest.json") + }), + ); + } + resources.insert_tile( + session_id, + level, + x, + y, + b64.clone(), + bytes_len, + meta.clone(), + ); + if is_json { + let meta_text = serde_json::to_string_pretty(&meta) + .unwrap_or_else(|_| meta.to_string()); + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": "application/json", + "text": meta_text + } + ] + })); + } + return Ok(json!({ + "contents": [ + { + "uri": png_uri, + "mimeType": "image/png", + "blob": b64 + } + ] + })); + } + } + } else if subpath == "viewport.png" { + let path = std::env::temp_dir().join(format!("ocs-resource-{}.png", random_id()?)); + let req = json!({ + "op": "capture", + "path": path.to_string_lossy(), + "scope": "viewport", + "max_dimension": 1600, + }); + let result = client(clients, session_id)? + .request(req, 15.0)?; + if result["ok"].as_bool() != Some(true) + || result["status"].as_str() != Some("completed") + { + return Err(result.to_string()); + } + let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; + let _ = std::fs::remove_file(path); + + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let hash = format!("{:x}", hasher.finalize()); + let b64 = BASE64.encode(&bytes); + let bytes_len = bytes.len(); + let meta = result.get("result").cloned().unwrap_or_else(|| json!({})); + + resources.insert(session_id, &hash, b64.clone(), bytes_len, meta); + + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": "image/png", + "blob": b64 + } + ] + })); + } else if subpath == "state.json" { + let cli = client(clients, session_id)?; + let state_text = serde_json::to_string_pretty(&cli.state) + .unwrap_or_else(|_| cli.state.to_string()); + return Ok(json!({ + "contents": [ + { + "uri": uri, + "mimeType": "application/json", + "text": state_text + } + ] + })); + } + } + } + + Err(format!("Resource not found: {uri}")) +} + fn call_tool( name: &str, arguments: &Value, clients: &mut HashMap, + resources: &mut ResourceStore, ) -> Result { match name { "ocs_sessions" => { @@ -803,6 +1260,14 @@ fn call_tool( if !READ_OPS.contains(&op) { return Err("Use ocs_execute for mutations".into()); } + if op == "tools" { + return Ok(json!({ + "ok": true, + "status": "completed", + "tools": tool_definitions(), + "instructions": INSTRUCTIONS + })); + } let mut request = arguments["parameters"] .as_object() .cloned() @@ -849,8 +1314,110 @@ fn call_tool( let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?)); 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); + let tile = arguments.get("tile"); + let want_manifest = arguments["pyramid_manifest"].as_bool().unwrap_or(false); + + if reset_baseline { + resources.last_captures.remove(session_id); + } + + let mut tile_info: Option<(u32, u32, u32, [f64; 4])> = None; + if let Some(tile_val) = tile { + if let (Some(level), Some(x), Some(y)) = ( + tile_val.get("level").and_then(Value::as_u64), + tile_val.get("x").and_then(Value::as_u64), + tile_val.get("y").and_then(Value::as_u64), + ) { + let mut base_bounds = [-100.0, -100.0, 100.0, 100.0]; + if let Some(bounds_arr) = arguments.get("bounds").and_then(Value::as_array) { + if bounds_arr.len() >= 4 { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = ( + bounds_arr[0].as_f64(), + bounds_arr[1].as_f64(), + bounds_arr[2].as_f64(), + bounds_arr[3].as_f64(), + ) { + base_bounds = [x0, y0, x1, y1]; + } + } + } else if let Some(manifest) = resources.pyramid_manifests.get(session_id) { + if let Some(arr) = manifest.get("world_bounds").and_then(Value::as_array) { + if arr.len() == 4 { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = ( + arr[0].as_f64(), + arr[1].as_f64(), + arr[2].as_f64(), + arr[3].as_f64(), + ) { + base_bounds = [x0, y0, x1, y1]; + } + } + } + } else if let Some(latest_uri) = resources.latest.get(session_id) { + if let Some(snap) = resources.snapshots.get(latest_uri) { + if let Some(spatial) = snap.metadata.get("_spatial") { + if let Some(wb) = spatial.get("world_bounds") { + if let (Some(min), Some(max)) = ( + wb.get("min").and_then(Value::as_array), + wb.get("max").and_then(Value::as_array), + ) { + if min.len() >= 2 && max.len() >= 2 { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = ( + min[0].as_f64(), + min[1].as_f64(), + max[0].as_f64(), + max[1].as_f64(), + ) { + base_bounds = [x0, y0, x1, y1]; + } + } + } + } + } + } + } + let tb = crate::app::control::vision::compute_tile_bounds( + base_bounds, + level as u32, + x as u32, + y as u32, + )?; + tile_info = Some((level as u32, x as u32, y as u32, tb)); + } + } + + let mut req = json!({ + "op": "capture", + "path": path.to_string_lossy(), + "scope": scope, + "max_dimension": if tile_info.is_some() && arguments.get("max_dimension").is_none() { 512 } else { max_dimension }, + }); + if let Some((_, _, _, tb)) = tile_info { + req["view"] = json!("region"); + req["bounds"] = json!(tb); + } else { + if let Some(view) = arguments.get("view").and_then(Value::as_str) { + req["view"] = json!(view); + } + if let Some(bounds) = arguments.get("bounds").and_then(Value::as_array) { + req["bounds"] = json!(bounds); + } + } + if let Some(focus) = arguments.get("focus_handles").and_then(Value::as_array) { + req["focus_handles"] = json!(focus); + } + if let Some(hl) = arguments.get("highlight_handles").and_then(Value::as_array) { + req["highlight_handles"] = json!(hl); + } + if let Some(annotate) = arguments.get("annotate").and_then(Value::as_bool) { + req["annotate"] = json!(annotate); + } let result = client(clients, session_id)? - .request(json!({"op":"capture","path":path.to_string_lossy(),"scope":scope,"max_dimension":max_dimension}), 30.0)?; + .request(req, 30.0)?; if result["ok"].as_bool() != Some(true) || result["status"].as_str() != Some("completed") { @@ -858,7 +1425,188 @@ fn call_tool( } let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; let _ = std::fs::remove_file(path); - Ok(json!({"$image":BASE64.encode(bytes)})) + let mut meta = result.get("result").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = meta.as_object_mut() { + 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, ¤t_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(¤t_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(&final_bytes); + let hash = format!("{:x}", hasher.finalize()); + 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() { + obj.insert("uri".into(), Value::String(uri.clone())); + obj.insert("latest_uri".into(), Value::String(format!("cad://session/{session_id}/snapshot/latest.png"))); + obj.insert("hash".into(), Value::String(hash.clone())); + obj.insert("bytes".into(), Value::from(bytes_len)); + } + + let mut final_uri = uri.clone(); + if let Some((level, x, y, tb)) = tile_info { + let tile_uri = resources.insert_tile( + session_id, + level, + x, + y, + b64.clone(), + bytes_len, + meta.clone(), + ); + final_uri = tile_uri.clone(); + let pyr = json!({ + "level": level, + "x": x, + "y": y, + "tile_world_bounds": tb, + "tile_uri": tile_uri, + "manifest_uri": format!("cad://session/{session_id}/pyramid/manifest.json") + }); + if let Some(obj) = meta.as_object_mut() { + obj.insert("pyramid".into(), pyr); + obj.insert("uri".into(), Value::String(final_uri.clone())); + } + } + + if want_manifest + || (!resources.pyramid_manifests.contains_key(session_id) && tile_info.is_none()) + { + let mut bounds = [-100.0, -100.0, 100.0, 100.0]; + let mut unit = "Millimeters".to_string(); + if let Some(spatial) = meta.get("_spatial") { + if let Some(u) = spatial.get("unit").and_then(Value::as_str) { + unit = u.to_string(); + } + if let Some(wb) = spatial.get("world_bounds") { + if let (Some(min), Some(max)) = ( + wb.get("min").and_then(Value::as_array), + wb.get("max").and_then(Value::as_array), + ) { + if min.len() >= 2 && max.len() >= 2 { + if let (Some(x0), Some(y0), Some(x1), Some(y1)) = ( + min[0].as_f64(), + min[1].as_f64(), + max[0].as_f64(), + max[1].as_f64(), + ) { + bounds = [x0, y0, x1, y1]; + } + } + } + } + } + let manifest = crate::app::control::vision::compute_pyramid_manifest( + session_id, &unit, bounds, 4, 512, + ); + if let Ok(manifest_val) = serde_json::to_value(&manifest) { + resources.insert_pyramid_manifest(session_id, manifest_val.clone()); + if want_manifest { + if let Some(obj) = meta.as_object_mut() { + obj.insert("pyramid_manifest".into(), manifest_val); + } + } + } + } + + match delivery { + "resource" => Ok(json!({ + "$resource": { + "uri": final_uri, + "mimeType": "image/png", + "hash": hash, + "bytes": bytes_len, + }, + "metadata": meta, + })), + "both" => Ok(json!({ + "$image": b64, + "$resource": { + "uri": final_uri, + "mimeType": "image/png", + "hash": hash, + "bytes": bytes_len, + }, + "metadata": meta, + })), + _ => Ok(json!({ + "$image": b64, + "metadata": meta, + })), + } } _ => Err(format!("Unknown tool: {name}")), } @@ -899,7 +1647,7 @@ fn execute_output_schema() -> Value { }) } -fn tool_definitions() -> Value { +pub(crate) fn tool_definitions() -> Value { json!([ { "name":"ocs_sessions", @@ -924,16 +1672,63 @@ fn tool_definitions() -> Value { }, { "name":"ocs_capture", - "description":"Capture the actual current OCS drawing viewport or window as a bounded PNG for visual verification.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"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."}},"required":["ocs_session_id"],"additionalProperties":false}, + "description":"Capture the actual current OCS drawing viewport or window as a bounded PNG with optional camera framing, entity highlighting, spatial coordinates and Set-of-Marks annotations.", + "inputSchema":{ + "type":"object", + "properties":{ + "ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."}, + "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."}, + "highlight_handles":{"type":"array","items":{"type":"string"},"description":"Hex handles of entities to select/highlight before capturing."}, + "annotate":{"type":"boolean","default":false,"description":"Overlay Set-of-Marks numbered tags on visible entities for visual grounding."}, + "tile":{"type":"object","properties":{"level":{"type":"integer","minimum":0,"maximum":6,"description":"Pyramid zoom level (0 = full overview, 1 = 2x2 grid, 2 = 4x4 grid, etc.)."},"x":{"type":"integer","minimum":0,"description":"Tile column index (0-indexed, left-to-right)."},"y":{"type":"integer","minimum":0,"description":"Tile row index (0-indexed, top-to-bottom)."}},"required":["level","x","y"],"description":"Fetch a specific DeepZoom pyramid tile. Automatically computes tile world bounds and frames the camera."}, + "pyramid_manifest":{"type":"boolean","default":false,"description":"If true, generates and returns the complete multiscale pyramid manifest (levels, grid dimensions, tile world spans and resource URIs) in the response metadata."} + }, + "required":["ocs_session_id"], + "additionalProperties":true + }, "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} } ]) } fn tool_result(value: Value) -> Value { - if let Some(image) = value.get("$image").and_then(Value::as_str) { - return json!({"content":[{"type":"image","data":image,"mimeType":"image/png"}]}); + if value.get("$resource").is_some() || value.get("$image").is_some() { + let mut content = Vec::new(); + if let Some(res) = value.get("$resource") { + let uri = res["uri"].as_str().unwrap_or(""); + content.push(json!({ + "type": "text", + "text": format!("Captured viewport to MCP resource: {uri}") + })); + content.push(json!({ + "type": "resource", + "resource": { + "uri": uri, + "mimeType": res["mimeType"].as_str().unwrap_or("image/png") + } + })); + } + if let Some(meta) = value.get("metadata") { + let meta_text = serde_json::to_string_pretty(meta).unwrap_or_else(|_| meta.to_string()); + content.push(json!({"type":"text","text":meta_text})); + } + if let Some(image) = value.get("$image").and_then(Value::as_str) { + content.push(json!({"type":"image","data":image,"mimeType":"image/png"})); + } + let structured = value.get("metadata").cloned().unwrap_or_else(|| json!({})); + return json!({ + "content": content, + "structuredContent": structured, + "isError": false + }); } let structured = if value.is_object() { value.clone() @@ -994,7 +1789,11 @@ fn task_value(task: &McpTask, status: &str) -> Value { value } -fn poll_task(task: &mut McpTask, clients: &mut HashMap) -> Value { +fn poll_task( + task: &mut McpTask, + clients: &mut HashMap, + resources: &mut ResourceStore, +) -> Value { if task.result.is_some() { return task_value(task, "completed"); } @@ -1004,7 +1803,7 @@ fn poll_task(task: &mut McpTask, clients: &mut HashMap) -> Va task.last_updated_at = iso8601_now(); let mut arguments = task.arguments.clone(); arguments["wait_seconds"] = Value::from(0); - match call_tool(&task.name, &arguments, clients) { + match call_tool(&task.name, &arguments, clients, resources) { Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => { task_value(task, "working") } @@ -1059,6 +1858,7 @@ fn handle_message( message: Value, clients: &mut HashMap, tasks: &mut TaskStore, + resources: &mut ResourceStore, ) -> Option { let id = message.get("id").cloned(); let method = message.get("method").and_then(Value::as_str)?; @@ -1089,7 +1889,10 @@ fn handle_message( id, json!({ "protocolVersion":protocol, - "capabilities":{"tools":{"listChanged":false}}, + "capabilities":{ + "tools":{"listChanged":false}, + "resources":{"subscribe":false,"listChanged":false} + }, "serverInfo":server_info(), "instructions":INSTRUCTIONS }), @@ -1100,7 +1903,11 @@ fn handle_message( protocol_result( json!({ "supportedVersions":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION], - "capabilities":{"tools":{},"extensions":{"io.modelcontextprotocol/tasks":{}}}, + "capabilities":{ + "tools":{}, + "resources":{}, + "extensions":{"io.modelcontextprotocol/tasks":{}} + }, "instructions":INSTRUCTIONS }), true, @@ -1108,6 +1915,43 @@ fn handle_message( ), ), "ping" => response(id, protocol_result(json!({}), modern, false)), + "resources/list" => { + let mut session_ids = Vec::new(); + if let Ok(available) = descriptors() { + for (desc, _) in available { + session_ids.push(desc.session_id); + } + } + for k in clients.keys() { + if !session_ids.contains(k) { + session_ids.push(k.clone()); + } + } + for k in resources.latest.keys() { + if !session_ids.contains(k) { + session_ids.push(k.clone()); + } + } + for k in resources.pyramid_manifests.keys() { + if !session_ids.contains(k) { + session_ids.push(k.clone()); + } + } + let list = resources.list_resources(&session_ids); + response( + id, + protocol_result(json!({ "resources": list }), modern, true), + ) + } + "resources/read" => { + let Some(uri) = params["uri"].as_str() else { + return Some(rpc_error(id, -32602, "Missing resource uri")); + }; + match read_resource(uri, clients, resources) { + Ok(contents) => response(id, protocol_result(contents, modern, true)), + Err(err) => rpc_error(id, -32002, err), + } + } "tools/list" => response( id, protocol_result(json!({"tools":tool_definitions()}), modern, true), @@ -1120,7 +1964,7 @@ fn handle_message( .get("arguments") .cloned() .unwrap_or_else(|| json!({})); - let called = call_tool(name, &arguments, clients); + let called = call_tool(name, &arguments, clients, resources); if modern && supports_tasks(¶ms) { if let Ok(value) = &called { if matches!(value["status"].as_str(), Some("accepted" | "running")) { @@ -1166,7 +2010,7 @@ fn handle_message( let Some(task) = tasks.get_mut(task_id) else { return Some(rpc_error(id, -32602, "Unknown or expired taskId")); }; - response(id, protocol_result(poll_task(task, clients), true, false)) + response(id, protocol_result(poll_task(task, clients, resources), true, false)) } "tasks/update" if modern && supports_tasks(¶ms) => { let Some(task_id) = params["taskId"].as_str() else { @@ -1190,20 +2034,66 @@ fn handle_message( }) } +/// Synchronize agent tool schemas and instructions to ~/.gemini/antigravity/mcp/opencadstudio/. +/// Returns true if the target directory was found or created and schemas were written. +pub fn sync_agent_tool_schemas() -> bool { + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + if home.is_empty() { + return false; + } + let mcp_root = std::path::PathBuf::from(home) + .join(".gemini") + .join("antigravity") + .join("mcp"); + if !mcp_root.exists() { + return false; + } + let base_dir = mcp_root.join("opencadstudio"); + if let Err(_) = std::fs::create_dir_all(&base_dir) { + return false; + } + + let tools = tool_definitions(); + let Some(tool_arr) = tools.as_array() else { + return false; + }; + for tool in tool_arr { + let Some(name) = tool["name"].as_str() else { + continue; + }; + let schema = json!({ + "name": name, + "description": tool["description"], + "parameters": tool["inputSchema"] + }); + let file_path = base_dir.join(format!("{name}.json")); + if let Ok(pretty) = serde_json::to_string_pretty(&schema) { + let _ = std::fs::write(&file_path, pretty); + } + } + let instructions_path = base_dir.join("instructions.md"); + let _ = std::fs::write(instructions_path, INSTRUCTIONS); + true +} + /// Run the MCP stdio loop until the client closes stdin. pub fn run() { + let _ = sync_agent_tool_schemas(); let stdin = io::stdin(); let stdout = io::stdout(); let mut output = stdout.lock(); let mut clients = HashMap::new(); let mut tasks = TaskStore::default(); + let mut resources = ResourceStore::default(); for line in crate::io::line_read::lines_capped( stdin.lock(), crate::io::line_read::MAX_LINE_BYTES, ) { let response = match line { Ok(line) if !line.trim().is_empty() => match serde_json::from_str::(&line) { - Ok(message) => handle_message(message, &mut clients, &mut tasks), + Ok(message) => handle_message(message, &mut clients, &mut tasks, &mut resources), Err(error) => Some(rpc_error(Value::Null, -32700, error)), }, Ok(_) => None, @@ -1274,6 +2164,10 @@ mod tests { tools[3]["inputSchema"]["properties"]["scope"]["default"], "viewport" ); + assert_eq!( + tools[3]["inputSchema"]["properties"]["delivery"]["default"], + "inline" + ); let find_variant = |op_name: &str| { request_variants .iter() @@ -1333,9 +2227,14 @@ mod tests { json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25"); + assert_eq!( + initialized["result"]["capabilities"]["resources"]["subscribe"], + false + ); assert!( initialized["result"]["instructions"] .as_str() @@ -1347,6 +2246,7 @@ mod tests { json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4); @@ -1361,12 +2261,14 @@ mod tests { json!({"jsonrpc":"2.0","id":"discover","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(discovered["result"]["resultType"], "complete"); assert_eq!(discovered["result"]["ttlMs"], CACHE_TTL_MS); assert_eq!(discovered["result"]["cacheScope"], "public"); assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28"); + assert!(discovered["result"]["capabilities"]["resources"].is_object()); assert!( discovered["result"]["capabilities"]["extensions"]["io.modelcontextprotocol/tasks"] .is_object() @@ -1380,6 +2282,7 @@ mod tests { json!({"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(listed["result"]["resultType"], "complete"); @@ -1394,6 +2297,7 @@ mod tests { json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"ocs_session_id":"missing","op":"save"}}}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -1406,6 +2310,7 @@ mod tests { json!({"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(rejected["error"]["code"], -32022); @@ -1423,6 +2328,7 @@ mod tests { json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"undo"}}}}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -1441,6 +2347,7 @@ mod tests { json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), &mut clients, &mut TaskStore::default(), + &mut ResourceStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -1455,6 +2362,329 @@ mod tests { assert!(error.contains("LINE 0,0 10,10"), "{error}"); } + #[test] + fn resources_list_and_read_snapshots() { + let mut clients = HashMap::new(); + let mut tasks = TaskStore::default(); + let mut resources = ResourceStore::default(); + + let dummy_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + let dummy_hash = "abcdef0123456789"; + let uri = resources.insert( + "sess1", + dummy_hash, + dummy_data.to_string(), + 68, + json!({"width": 100, "height": 100, "annotations": []}), + ); + assert_eq!(uri, "cad://session/sess1/snapshot/abcdef0123456789.png"); + + // Test resources/list + let listed = handle_message( + json!({"jsonrpc":"2.0","id":"r-list","method":"resources/list"}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + let res_arr = listed["result"]["resources"].as_array().unwrap(); + assert!(res_arr.iter().any(|r| r["uri"] == uri)); + assert!(res_arr.iter().any(|r| r["uri"] == "cad://session/sess1/snapshot/latest.png")); + + // Test resources/read with png + let read_png = handle_message( + json!({"jsonrpc":"2.0","id":"r-read","method":"resources/read","params":{"uri":uri}}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + let contents = read_png["result"]["contents"].as_array().unwrap(); + assert_eq!(contents[0]["mimeType"], "image/png"); + assert_eq!(contents[0]["blob"], dummy_data); + + // Test resources/read with latest + let read_latest = handle_message( + json!({"jsonrpc":"2.0","id":"r-read-lat","method":"resources/read","params":{"uri":"cad://session/sess1/snapshot/latest.png"}}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + assert_eq!(read_latest["result"]["contents"][0]["blob"], dummy_data); + + // Test resources/read with json metadata + let read_meta = handle_message( + json!({"jsonrpc":"2.0","id":"r-read-meta","method":"resources/read","params":{"uri":"cad://session/sess1/snapshot/abcdef0123456789.json"}}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + let text = read_meta["result"]["contents"][0]["text"].as_str().unwrap(); + assert!(text.contains("\"width\": 100")); + + // Test resources/read on non-existent resource returns -32002 error + let read_err = handle_message( + json!({"jsonrpc":"2.0","id":"r-err","method":"resources/read","params":{"uri":"cad://session/sess1/snapshot/nonexistent.png"}}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + assert_eq!(read_err["error"]["code"], -32002); + } + + #[test] + fn tool_result_handles_resource_and_both_modes() { + // Resource-only delivery mode + let res_value = json!({ + "$resource": { + "uri": "cad://session/s1/snapshot/hash123.png", + "mimeType": "image/png", + "hash": "hash123", + "bytes": 1024 + }, + "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")); + assert_eq!(content[1]["type"], "resource"); + assert_eq!(content[1]["resource"]["uri"], "cad://session/s1/snapshot/hash123.png"); + // Ensure no image block was sent + assert!(!content.iter().any(|c| c["type"] == "image")); + + // Both mode + let both_value = json!({ + "$image": "base64data", + "$resource": { + "uri": "cad://session/s1/snapshot/hash123.png", + "mimeType": "image/png", + "hash": "hash123", + "bytes": 1024 + }, + "metadata": {"width": 800} + }); + let both_res = tool_result(both_value); + let both_content = both_res["content"].as_array().unwrap(); + assert!(both_content.iter().any(|c| c["type"] == "resource")); + 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 test_pyramid_tiling_protocol_and_resources() { + let mut clients = HashMap::new(); + let mut tasks = TaskStore::default(); + let mut resources = ResourceStore::default(); + + // 1. Verify ocs_capture schema properties + 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!(props.get("tile").is_some()); + assert_eq!(props["tile"]["type"], "object"); + assert!(props.get("pyramid_manifest").is_some()); + assert_eq!(props["pyramid_manifest"]["type"], "boolean"); + + // 2. Insert pyramid manifest and tile + let manifest = crate::app::control::vision::compute_pyramid_manifest( + "sess1", + "Millimeters", + [0.0, 0.0, 500.0, 300.0], + 3, + 512, + ); + let manifest_val = serde_json::to_value(&manifest).unwrap(); + let manifest_uri = resources.insert_pyramid_manifest("sess1", manifest_val); + assert_eq!(manifest_uri, "cad://session/sess1/pyramid/manifest.json"); + + let tile_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + let tile_uri = resources.insert_tile( + "sess1", + 1, + 0, + 1, + tile_data.to_string(), + 68, + json!({ + "width": 512, + "height": 512, + "pyramid": { + "level": 1, + "x": 0, + "y": 1, + "tile_world_bounds": [0.0, 0.0, 250.0, 150.0] + } + }), + ); + assert_eq!(tile_uri, "cad://session/sess1/tile/1/0/1.png"); + + // 3. Test resources/list includes manifest and tile + let listed = handle_message( + json!({"jsonrpc":"2.0","id":"r-list-pyr","method":"resources/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + let res_arr = listed["result"]["resources"].as_array().unwrap(); + assert!(res_arr.iter().any(|r| r["uri"] == manifest_uri)); + assert!(res_arr.iter().any(|r| r["uri"] == tile_uri)); + + // 4. Test resources/read with manifest + let read_man = handle_message( + json!({"jsonrpc":"2.0","id":"r-read-man","method":"resources/read","params":{"uri":manifest_uri}}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + let man_contents = read_man["result"]["contents"].as_array().unwrap(); + assert_eq!(man_contents[0]["mimeType"], "application/json"); + assert!(man_contents[0]["text"].as_str().unwrap().contains("cad://session/sess1/tile/{level}/{x}/{y}.png")); + + // 5. Test resources/read with tile png + let read_tile = handle_message( + json!({"jsonrpc":"2.0","id":"r-read-tile","method":"resources/read","params":{"uri":tile_uri}}), + &mut clients, + &mut tasks, + &mut resources, + ) + .unwrap(); + let tile_contents = read_tile["result"]["contents"].as_array().unwrap(); + assert_eq!(tile_contents[0]["mimeType"], "image/png"); + assert_eq!(tile_contents[0]["blob"], tile_data); + + // 6. Test tool_result with tile payload + let tile_tool_output = json!({ + "$resource": { + "uri": tile_uri, + "mimeType": "image/png", + "hash": "tile-1-0-1", + "bytes": 68 + }, + "metadata": { + "width": 512, + "height": 512, + "pyramid": { + "level": 1, + "x": 0, + "y": 1, + "tile_world_bounds": [0.0, 0.0, 250.0, 150.0], + "tile_uri": tile_uri, + "manifest_uri": manifest_uri + } + } + }); + let structured = tool_result(tile_tool_output); + assert_eq!(structured["isError"], false); + assert_eq!(structured["structuredContent"]["pyramid"]["level"], 1); + assert_eq!(structured["structuredContent"]["pyramid"]["x"], 0); + assert_eq!(structured["structuredContent"]["pyramid"]["y"], 1); + assert_eq!(structured["structuredContent"]["pyramid"]["tile_uri"], tile_uri); + } + #[test] fn validates_batch_steps_and_compacts_state() { let batch = json!({ @@ -1641,7 +2871,50 @@ mod tests { assert!(schema["$defs"]["window"].is_object()); assert!(schema["$defs"]["batch_step"].is_object()); let json_str = schema.to_string(); - // Generates cleanly without exponential blowup: compact ~15KB to 30KB - assert!(json_str.len() < 40_000, "schema size is {} bytes", json_str.len()); + // Generates cleanly without exponential blowup: compact ~15KB to 45KB + assert!(json_str.len() < 45_000, "schema size is {} bytes", json_str.len()); + } + + #[test] + fn export_agent_tool_schemas() { + let synced = sync_agent_tool_schemas(); + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + let base_dir = std::path::PathBuf::from(home) + .join(".gemini") + .join("antigravity") + .join("mcp") + .join("opencadstudio"); + if base_dir.exists() { + assert!(synced, "sync_agent_tool_schemas must succeed when directory exists"); + assert!(base_dir.join("ocs_capture.json").exists()); + assert!(base_dir.join("ocs_execute.json").exists()); + assert!(base_dir.join("ocs_read.json").exists()); + assert!(base_dir.join("ocs_sessions.json").exists()); + assert!(base_dir.join("instructions.md").exists()); + } + } + + #[test] + fn read_op_tools_returns_current_schemas_and_instructions() { + let mut clients = HashMap::new(); + let mut resources = ResourceStore::default(); + let req = json!({ + "name": "ocs_read", + "arguments": { + "ocs_session_id": "test_session", + "op": "tools" + } + }); + let result = call_tool( + "ocs_read", + &req["arguments"], + &mut clients, + &mut resources, + ).expect("ocs_read op: tools must succeed"); + assert_eq!(result["ok"], true); + assert!(result["tools"].is_array()); + assert!(result["instructions"].is_string()); } } diff --git a/src/mcp_ops.rs b/src/mcp_ops.rs index 4cb5846c4..69237e423 100644 --- a/src/mcp_ops.rs +++ b/src/mcp_ops.rs @@ -116,6 +116,17 @@ pub static ACTIVATE: OpDef = OpDef { example: r#"{"op":"activate","document_id":1}"#, }; +pub static SWITCH_DOCUMENT: OpDef = OpDef { + name: "switch_document", + doc: "Alias for activate. Switch the active drawing tab to another open document.", + batchable: true, + params: &[ + req("document_id", Ty::Int, "Target document ID to activate."), + ], + rules: &[], + example: r#"{"op":"switch_document","document_id":1}"#, +}; + pub static RUN: OpDef = OpDef { name: "run", doc: "Execute a command string with prompt answers separated by spaces (e.g. 'LINE 0,0 10,10').", @@ -245,6 +256,8 @@ pub static EMBED_IMAGE: OpDef = OpDef { opt("at", Ty::Point, "Lower-left corner [x, y] or [x, y, z]."), opt("width", Ty::Num, "World width of the image."), opt("linked", Ty::Bool, "Store as path-linked RasterImage instead of embedded OLE2FRAME."), + opt("source_points", Ty::ArrayOf(&Ty::Point), "Pixel points in image [[px1,py1],[px2,py2]] for 2-point alignment."), + opt("target_points", Ty::ArrayOf(&Ty::Point), "CAD world points [[x1,y1],[x2,y2]] for 2-point alignment."), ], rules: &[], example: r#"{"op":"embed_image","path":"logo.png","at":[0,0]}"#, @@ -610,6 +623,7 @@ pub static OPS: &[&OpDef] = &[ &NEW, &OPEN, &ACTIVATE, + &SWITCH_DOCUMENT, &RUN, &START, &INPUT, diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 313f16e92..0b5c9abcb 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -366,7 +366,9 @@ impl CadCommand for AlignedDimensionCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } fn dyn_spec(&self) -> Option { @@ -482,7 +484,9 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index d26a84dec..dfc62bbdc 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -1275,7 +1275,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index 00472d3bd..7dc07e044 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -134,7 +134,9 @@ impl DataLinkPlaceCommand { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - } + + ..Default::default() +} } } @@ -160,7 +162,7 @@ impl CadCommand for DataLinkPlaceCommand { self.table.insertion_point = codec::types::Vector3::new(point.x, point.y, point.z); CmdResult::CommitAndExit( self.plane - .place_entity(EntityType::Table(self.table.clone())), + .place_entity(EntityType::Table(Box::new(self.table.clone()))), ) } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index c9155b8c1..f6bc3b091 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -364,7 +364,9 @@ fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } inventory::submit!(crate::command::CommandRegistration { names: &["DIMDIAMETER"] }); diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 06ed14422..ffddc10f9 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -755,7 +755,9 @@ fn preview_for_dimension(dimension: &Dimension) -> WireModel { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - } + + ..Default::default() +} } fn linear_preview( diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index f022f2657..dfe0d9893 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -506,7 +506,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - } + + ..Default::default() +} } inventory::submit!(crate::command::CommandRegistration { diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index f7894581c..14ce88c3e 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -470,7 +470,9 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } pub fn arrowhead_wings(tip: Vec3, next: Vec3, size: f32) -> [Vec3; 2] { diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 31a3df457..06009f0e9 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -590,7 +590,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } /// Project the two extension origins onto the dimension line, which passes diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 262010a0d..20d2b98a8 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -269,7 +269,7 @@ impl MLeaderCommand { ml.text_style_handle = style_handle; ml.context.text_style_handle = style_handle; } - let entity = self.plane.place_entity(EntityType::MultiLeader(ml)); + let entity = self.plane.place_entity(EntityType::MultiLeader(Box::new(ml))); if self.content_type == LeaderContentType::MText && self.text.is_empty() { CmdResult::CommitAndEditText(entity) } else { @@ -799,7 +799,9 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } fn arrowhead_wings(tip: Vec3, next: Vec3, size: f32) -> [Vec3; 2] { diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index ac78d9926..4ed0706c2 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -523,7 +523,9 @@ fn preview_wire(pts: &[Vec3]) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } fn point_segment_distance_xy(point: Vector3, start: Vector3, end: Vector3) -> f64 { diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 0f5fba809..a064627ab 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -315,7 +315,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 737ae0bd3..63fde8349 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -376,7 +376,9 @@ fn preview_wire(points: Vec) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] }); diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 7193b5397..a8648297a 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -271,7 +271,7 @@ impl TableCommand { row.style = Some(styles[row_index.min(2)].clone()); } } - self.plane.place_entity(EntityType::Table(table)) + self.plane.place_entity(EntityType::Table(Box::new(table))) } fn preview_grid( @@ -341,7 +341,9 @@ impl TableCommand { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - } + + ..Default::default() +} } } @@ -795,7 +797,7 @@ impl CadCommand for TableCellEditCommand { cell.set_text(&formatted); self.table.block_record_handle = None; Some(CmdResult::ReplaceMany( - vec![(self.handle, vec![EntityType::Table(self.table.clone())])], + vec![(self.handle, vec![EntityType::Table(Box::new(self.table.clone()))])], Vec::new(), )) } diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index d884253b4..504bbbc36 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -114,7 +114,9 @@ impl CadCommand for ToleranceCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/draw/helix.rs b/src/modules/draw/draw/helix.rs index eafb53c97..6ae3b42fc 100644 --- a/src/modules/draw/draw/helix.rs +++ b/src/modules/draw/draw/helix.rs @@ -139,7 +139,7 @@ impl HelixCommand { helix.turn_height = if curve.turns == 0.0 { 0.0 } else { height / curve.turns }; helix.handedness = self.counter_clockwise; helix.constraint = self.constraint; - Some(EntityType::Helix(helix)) + Some(EntityType::Helix(Box::new(helix))) } fn commit(&mut self, height: f64, axis: DVec3) -> CmdResult { diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index 51306e6d9..ccab5edbc 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -213,7 +213,9 @@ impl MlineCommand { plinegen: true, fill_tris, fill_tris_low, - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index 63a69e202..2df8b62fd 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -202,7 +202,9 @@ impl CadCommand for ImageCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index bc2bf4eb7..b0490ccc7 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -119,7 +119,9 @@ impl CadCommand for RayCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } @@ -408,7 +410,9 @@ impl CadCommand for XLineCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 6a33bc06a..de198a04f 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -401,7 +401,9 @@ impl CadCommand for AreaCommand { plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } fn area_preview_regions(&self) -> Option> { diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs index 8592655b6..f73b14745 100644 --- a/src/modules/draw/inquiry/dist.rs +++ b/src/modules/draw/inquiry/dist.rs @@ -124,7 +124,9 @@ impl CadCommand for DistCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index 11502eccc..416fb6172 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -103,7 +103,9 @@ impl MeasureGeomCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } /// Extract (radius) from a Circle or Arc; `None` for anything else. diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs index cf4460c34..5d190774f 100644 --- a/src/modules/draw/modify/reverse.rs +++ b/src/modules/draw/modify/reverse.rs @@ -75,7 +75,8 @@ impl ReverseCommand { } EntityType::Spline(sp) => Some(EntityType::Spline(reverse_spline(sp))), EntityType::Helix(helix) => { - crate::entities::helix::reversed(helix).map(EntityType::Helix) + crate::entities::helix::reversed(helix) + .map(|helix| EntityType::Helix(Box::new(helix))) } _ => None, } diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index e48082364..37db81855 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -4870,7 +4870,9 @@ fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } /// Insert the exact boundary crossings between consecutive samples so a diff --git a/src/modules/insert/pc_crop.rs b/src/modules/insert/pc_crop.rs index d9ca86f68..a5ca8e8dd 100644 --- a/src/modules/insert/pc_crop.rs +++ b/src/modules/insert/pc_crop.rs @@ -117,7 +117,10 @@ impl PointCloudCropCommand { if let ExtendedEntityData::PointCloudEx(data) = &mut cloud.data { change(data); } - CmdResult::UpdateEntityAndFinish { handle: self.handle, entity: EntityType::Extended(cloud) } + CmdResult::UpdateEntityAndFinish { + handle: self.handle, + entity: EntityType::Extended(Box::new(cloud)), + } } fn axes(data: &PointCloudExData) -> glam::DMat3 { @@ -296,7 +299,7 @@ impl CadCommand for PointCloudCropCommand { match self.picked.take() { Some(EntityType::Extended(cloud)) if matches!(cloud.data, ExtendedEntityData::PointCloudEx(_)) => { self.handle = handle; - self.cloud = Some(cloud); + self.cloud = Some(*cloud); if self.uncrop { return self.finish(|data| data.croppings.clear()); } diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 8caddb981..934fbc432 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2793,7 +2793,7 @@ pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType options.is_solid = false; *sweep_vector = Vector3::new(direction.x, direction.y, direction.z); } - EntityType::Surface(surface) + EntityType::Surface(Box::new(surface)) } pub fn empty_revolved_surface( @@ -2832,7 +2832,7 @@ pub fn empty_revolved_surface( *stored_start = start_angle; *solid = false; } - EntityType::Surface(surface) + EntityType::Surface(Box::new(surface)) } // ── Autocomplete registry ───────────────────────────────── @@ -2909,7 +2909,7 @@ mod thicken_command_tests { let handle = Handle::new(42); let mut command = ThickenCommand::new(vec![ (Handle::new(1), EntityType::Line(codec::entities::Line::default())), - (handle, EntityType::Surface(codec::entities::Surface::new(codec::entities::SurfaceKind::Plane))), + (handle, EntityType::Surface(Box::new(codec::entities::Surface::new(codec::entities::SurfaceKind::Plane)))), ]); assert!(!command.is_selection_gathering()); assert_eq!(command.handles, vec![handle]); diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 1234c014f..016640de0 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -3710,5 +3710,7 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } diff --git a/src/modules/model/sectionplane_cmd.rs b/src/modules/model/sectionplane_cmd.rs index b75d4b1ef..13c85c39a 100644 --- a/src/modules/model/sectionplane_cmd.rs +++ b/src/modules/model/sectionplane_cmd.rs @@ -172,7 +172,7 @@ impl SectionPlaneCommand { self.kind == SectionKind::Slice, depth, ); - EntityType::Extended(entity) + EntityType::Extended(Box::new(entity)) } fn line_at(&self, point: DVec3, viewing: DVec3, vertical_hint: DVec3) -> (Vec, DVec3) { diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index cca291c52..7aaf6a593 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -96,7 +96,9 @@ impl CadCommand for PlotWindowCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index 2ac31bd98..a7f6be8c9 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -175,6 +175,8 @@ impl CadCommand for ZoomWindowCommand { plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) } } diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 4617de531..119a2b7f7 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -54,6 +54,7 @@ pub struct LocalWire { /// expand-time transform (`emit_wire`) maps each vertex to world exactly /// like `points`, so block-instance text lands at the right place/scale. pub text_verts: Vec, + pub searchable_text: Vec, pub key_vertices: Vec<[f64; 3]>, pub snap_pts: Vec<(glam::DVec3, SnapHint)>, pub tangent_geoms: Vec, @@ -893,6 +894,7 @@ fn tessellate_sub_local( is_point: matches!(sub, EntityType::Point(_)), point_marker: wire.point_marker, text_verts: wire.text_verts, + searchable_text: wire.searchable_text, key_vertices: wire.key_vertices, snap_pts: wire.snap_pts, tangent_geoms: wire.tangent_geoms, @@ -1358,12 +1360,11 @@ fn translated_prototype_wire( } } } - if !wire.text_verts.is_empty() { - wire.text_verts = - crate::scene::model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| { - (x + delta[0], y + delta[1], z + delta[2]) - }); - } + wire.map_text_layout( + &|p| [p[0] + delta[0], p[1] + delta[1], p[2] + delta[2]], + 1.0, + 0.0, + ); if wire.aabb != WireModel::UNBOUNDED_AABB { wire.aabb[0] += delta_f32[0]; wire.aabb[1] += delta_f32[1]; @@ -1595,6 +1596,7 @@ struct BatchEntry { pick_tris_low: Vec<[f32; 3]>, /// Accumulated SDF glyph quads (world space) for block-instance text. text_verts: Vec, + searchable_text: Vec, min_x: f32, min_y: f32, max_x: f32, @@ -1779,6 +1781,7 @@ impl Batches { dash_from_start: false, dash_align_end: None, text_verts: b.text_verts, + searchable_text: b.searchable_text, name: name.to_string(), points: b.points, points_low: b.points_low, @@ -2562,7 +2565,9 @@ fn emit_wire( plinegen: lw.plinegen, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - }; + + ..Default::default() +}; out.extra_wires.push(wire); return; } @@ -2918,6 +2923,41 @@ fn emit_wire( draw_depth: tv.draw_depth, }); } + // Searchable runs follow the same insert transform: origin via the full + // matrix, cap height by the mean XY scale, rotation by the transformed + // X-axis angle — so block-instance text plots searchable at its placed + // size and angle, not its block-local one. + // + // Notes (review V2): `sx`/`sy` are axis *lengths* (always ≥ 0), so a + // mirrored insert (`sx = -1`) yields length 1, not a zero mean — there is + // no 0.1 pt collapse. Mirroring folds into `rotation += π` (readable + // invisible text) rather than a reflected `Tm`: the visible outlines + // carry the true mirror. Non-uniform scale, width factor, oblique and + // tracking are approximated by the mean scale here; exact reproduction + // needs `Tz`/`Tc`/skewed `Tm` (follow-up — selection-only mismatch on the + // invisible layer, never a visual defect). + if !lw.searchable_text.is_empty() { + let x_axis = accum_xform.apply_rotation(Vector3::new(1.0, 0.0, 0.0)); + let y_axis = accum_xform.apply_rotation(Vector3::new(0.0, 1.0, 0.0)); + let sx = (x_axis.x * x_axis.x + x_axis.y * x_axis.y + x_axis.z * x_axis.z).sqrt(); + let sy = (y_axis.x * y_axis.x + y_axis.y * y_axis.y + y_axis.z * y_axis.z).sqrt(); + let mean_scale = ((sx + sy) * 0.5).max(1e-6); + let rot_delta = (x_axis.y).atan2(x_axis.x); + let base_len = entry.searchable_text.len(); + entry.searchable_text.extend(lw.searchable_text.iter().cloned()); + crate::scene::model::wire_model::map_searchable_runs( + &mut entry.searchable_text[base_len..], + &|p| { + let v = accum_xform.apply(Vector3::new(p[0], p[1], p[2])); + [v.x, v.y, v.z] + }, + mean_scale, + rot_delta as f32, + ); + for run in &mut entry.searchable_text[base_len..] { + run.color = final_color; + } + } } fn transform_tangent( diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 7bed040dc..f07a9c024 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -961,7 +961,9 @@ fn tessellate_entity_inner( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }]; + + ..Default::default() +}]; } // Render non-annotative dimensions from their stored picture block. @@ -1207,7 +1209,9 @@ fn tessellate_entity_inner( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }; + + ..Default::default() +}; let expanded = expand_block_object( document, @@ -1592,7 +1596,9 @@ fn lod_stub_wire( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } /// Sub-pixel LOD stub for 3D entities. Emits the entity's 3D AABB as a @@ -1687,7 +1693,9 @@ fn lod_stub_wire_3d( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } /// Tessellate each visible AttributeEntity attached to an Insert and append diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 321f39d38..433932665 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -399,7 +399,9 @@ fn split_mixed_polyline( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } @@ -535,7 +537,9 @@ fn split_mixed_polyline( plinegen, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } else if let Some(first_arc) = out.first_mut() { first_arc.snap_pts = snap_pts; first_arc.key_vertices = key_vertices.to_vec(); @@ -614,7 +618,9 @@ fn point_cloud_wires( plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - }]; + + ..Default::default() +}]; let mode = crate::scene::frame::mode( document, crate::scene::frame::FrameKind::PointCloudClip, @@ -870,7 +876,9 @@ pub fn tessellate( plinegen: true, fill_tris, fill_tris_low, - }); + + ..Default::default() +}); } } let mut snap_attached = false; @@ -1039,7 +1047,9 @@ pub fn tessellate( plinegen: false, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } // Snap / key vertices ride the first emitted wire only (they describe @@ -1254,8 +1264,13 @@ pub fn tessellate( // materialisation and (for block content) the block-expand // transform — no separate document-wide collector. let mut sdf_verts: Vec = Vec::new(); + let mut searchable: Vec = + Vec::new(); { if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { + use crate::scene::model::wire_model::{ + clean_searchable_text, SearchableTextRun, + }; // Selection tints the whole run; otherwise inline `\C` // colours (bin key) win, falling back to entity colour. for group in &stroke_groups { @@ -1263,6 +1278,11 @@ pub fn tessellate( if crate::scene::text::web_font::requires_shaping(&run.text) { continue; } + // Searchable counterpart, cleaned once here and + // pushed after layout below (one run per laid-out + // group, so MTEXT wrap/attachment and rotation are + // already baked into the origin — no re-layout). + let visible = clean_searchable_text(&run.text); let slx_v = (group.origin[0] - ref_lx_v) * anno + ref_lx_v; let sly_v = (group.origin[1] - ref_ly_v) * anno + ref_ly_v; // Base colour only (inline `\C` wins). Selection / @@ -1279,17 +1299,18 @@ pub fn tessellate( gcolor, text_contrast_background(entity, bg_color), ); - let quads = crate::scene::text::glyph_quads::layout_glyph_quads( - &mut atlas, - run.height, - run.rotation, - run.width_factor, - run.oblique, - run.tracking, - &run.font, - run.bold, - &run.text, - ); + let (quads, pen_adv) = + crate::scene::text::glyph_quads::layout_glyph_quads( + &mut atlas, + run.height, + run.rotation, + run.width_factor, + run.oblique, + run.tracking, + &run.font, + run.bold, + &run.text, + ); if let Some(plane) = group.plane { let scaled_origin = [ plane.scale_origin[0] @@ -1319,6 +1340,40 @@ pub fn tessellate( 0.0, ); } + // Searchable run: same adapted colour as the quads, + // exact pen advance in world units (layout's final + // pen position × annotation factor, like the quads). + if !visible.is_empty() { + let adv_world = (pen_adv as f64 * anno) as f32; + let (origin, rotation) = if let Some(plane) = group.plane { + ( + [ + plane.scale_origin[0] + + (plane.origin[0] - plane.scale_origin[0]) + * anno, + plane.scale_origin[1] + + (plane.origin[1] - plane.scale_origin[1]) + * anno, + plane.scale_origin[2] + + (plane.origin[2] - plane.scale_origin[2]) + * anno, + ], + (plane.x_axis[1]).atan2(plane.x_axis[0]) as f32, + ) + } else { + ([slx_v, sly_v, elev_v], run.rotation) + }; + searchable.push(SearchableTextRun { + text: visible, + origin, + height: (run.height as f64 * anno) as f32, + rotation, + color: gcolor, + bold: run.bold, + font: run.font.clone(), + adv_width: adv_world, + }); + } } } } @@ -1428,7 +1483,9 @@ pub fn tessellate( plinegen: true, fill_tris: ft, fill_tris_low: ftl, - }); + + ..Default::default() +}); } // Text frame — a closed rectangle in the text // colour around the same box. @@ -1486,7 +1543,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } } @@ -1541,7 +1600,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } wires.push(WireModel { bg_adapt: None, @@ -1561,6 +1622,7 @@ pub fn tessellate( dash_from_start: false, dash_align_end: None, text_verts: sdf_verts, + searchable_text: searchable, name, points: Vec::new(), points_low: Vec::new(), @@ -1643,7 +1705,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } if !bin.fill_tris.is_empty() { @@ -1691,7 +1755,9 @@ pub fn tessellate( plinegen: true, fill_tris: bin.fill_tris, fill_tris_low: bin.fill_tris_low, - }); + + ..Default::default() +}); } } @@ -1720,6 +1786,7 @@ pub fn tessellate( dash_from_start: false, dash_align_end: None, text_verts: sdf_verts, + searchable_text: searchable, name: name.clone(), points: Vec::new(), points_low: Vec::new(), @@ -1774,7 +1841,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } return out; } @@ -1855,7 +1924,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }]; + + ..Default::default() +}]; } } } @@ -2006,7 +2077,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } } @@ -2054,7 +2127,9 @@ pub fn tessellate( display_visible: true, snap_only: false, plot_visible: true, - }); + + ..Default::default() +}); } if out.is_empty() { @@ -2092,7 +2167,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }); + + ..Default::default() +}); } return out; @@ -2152,7 +2229,9 @@ pub fn tessellate( aabb: WireModel::UNBOUNDED_AABB, fill_tris: Vec::new(), fill_tris_low: Vec::new(), - }]; + + ..Default::default() +}]; } RenderObject::SegmentedLines(points) => { @@ -2242,7 +2321,9 @@ pub fn tessellate( aabb: WireModel::UNBOUNDED_AABB, fill_tris: vec![], fill_tris_low: Vec::new(), - }]; + + ..Default::default() +}]; } RenderObject::TaperedLines(points, widths) => { @@ -2321,7 +2402,9 @@ pub fn tessellate( aabb: WireModel::UNBOUNDED_AABB, fill_tris: vec![], fill_tris_low: Vec::new(), - }]; + + ..Default::default() +}]; } } @@ -2431,7 +2514,9 @@ pub fn tessellate( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }] + + ..Default::default() +}] } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 151a808bf..dff34ff69 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3052,10 +3052,10 @@ impl Scene { let is_insert = matches!(entity, EntityType::Insert(_)); let is_section = matches!( entity, - EntityType::Extended(codec::entities::ExtendedEntity { - data: codec::entities::ExtendedEntityData::SectionObject(_), - .. - }) + EntityType::Extended(extended) if matches!( + extended.data, + codec::entities::ExtendedEntityData::SectionObject(_) + ) ); if self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) @@ -7691,16 +7691,14 @@ impl Scene { CACHE_CATEGORY_MESH, |h| { self.meshes.contains_key(&h) - || matches!( - self.document.get_entity(h), - Some(EntityType::Insert(_)) - | Some(EntityType::Extended( - codec::entities::ExtendedEntity { - data: codec::entities::ExtendedEntityData::SectionObject(_), - .. - } - )) - ) + || match self.document.get_entity(h) { + Some(EntityType::Insert(_)) => true, + Some(EntityType::Extended(extended)) => matches!( + extended.data, + codec::entities::ExtendedEntityData::SectionObject(_) + ), + _ => false, + } }, ) => { @@ -12153,15 +12151,15 @@ mod section_tests { #[test] fn adding_and_erasing_a_section_keeps_its_object_graph_consistent() { let mut scene = Scene::new(); - let handle = scene.add_entity(EntityType::Extended(ExtendedEntity { + let handle = scene.add_entity(EntityType::Extended(Box::new(ExtendedEntity { common: EntityCommon::new(), data: ExtendedEntityData::SectionObject(section(1, 0.0)), - })); + }))); let settings_handle = match scene.document.get_entity(handle).unwrap() { - EntityType::Extended(ExtendedEntity { - data: ExtendedEntityData::SectionObject(data), - .. - }) => data.settings_handle, + EntityType::Extended(extended) => match &extended.data { + ExtendedEntityData::SectionObject(data) => data.settings_handle, + _ => unreachable!(), + }, _ => unreachable!(), }; assert!(matches!( diff --git a/src/scene/model/loft_command_model.rs b/src/scene/model/loft_command_model.rs index 9055c2e51..aa671e172 100644 --- a/src/scene/model/loft_command_model.rs +++ b/src/scene/model/loft_command_model.rs @@ -91,5 +91,5 @@ pub fn surface_entity(record: &SolidHistoryLoft) -> EntityType { ruled_surface: settings.normals == 0, virtual_guide: false, cross_sections: Vec::new(), guide_curves: Vec::new(), path_curve: None, }; - EntityType::Surface(surface) + EntityType::Surface(Box::new(surface)) } diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index 1d40b0efc..da2310700 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -109,5 +109,5 @@ pub fn swept_surface_entity(record: &SolidHistorySweep) -> EntityType { ..SurfaceSweepOptions::default() }, }; - EntityType::Surface(surface) + EntityType::Surface(Box::new(surface)) } diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index c872803a6..4358d1e5c 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -364,6 +364,202 @@ impl PointMarker { /// resolution pass must leave it alone. Only `Batches::finalize` fills it in. pub type BgAdapt = Option>; +/// One laid-out text run preserved for searchable PDF export. +/// +/// `origin` is the final world-space baseline start (drawing units, f64 — +/// annotation scale and block placement already applied), `height` is the +/// world cap height (run.height × anno), `rotation` the run angle in radians. +/// `text` holds the visible characters only (DXF controls stripped, `%%` +/// specials decoded, `\P` → space — multi-line MTEXT arrives as one run per +/// visual line from the layout stage, so no re-wrapping is needed). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SearchableTextRun { + pub text: String, + pub origin: [f64; 3], + pub height: f32, + pub rotation: f32, + pub color: [f32; 4], + pub bold: bool, + /// Style font name as laid out (`GlyphRun.font`: TTF family, LFF/SHX + /// name, …). Lets the PDF exporter resolve the run's *actual* font and + /// embed a subset instead of always falling back to Helvetica. + pub font: String, + /// Laid-out advance width in drawing units (pen advance incl. spacing, + /// annotation scale applied). Drives `Tz` width-matching and run-level + /// culling at export; scaled alongside `height` by every transform. + pub adv_width: f32, +} + +impl SearchableTextRun { + /// World-space origin translated by `delta` (preview drags, block moves). + pub fn translated(&self, delta: [f64; 3]) -> Self { + let mut out = self.clone(); + out.origin[0] += delta[0]; + out.origin[1] += delta[1]; + out.origin[2] += delta[2]; + out + } +} + +/// Clean a laid-out run's raw string into visible searchable characters. +/// +/// The input is the exact string passed to `layout_glyph_quads` for the run, +/// so the extracted text cannot diverge from the laid-out glyph sequence — +/// cleaning only strips controls. Traps paid for at the format level: +/// - `\P` is a real newline → space here (runs are already per-line). +/// - Decoration toggles (`\L…\l`, `\O…\o`, `\K…\k`) and other backslash +/// controls are stripped, not searched. +/// - `\Snum^den;`/stacking → `num/den`; `\U+XXXX` → the char; `{…}` grouping +/// braces are dropped; `\M+…` paragraph controls are stripped. +/// - DXF `%%d`/`%%p`/`%%c` decode to `°`/`±`/`Ø`; `%%` + 3 digits → the char. +/// - Literal `\\` → `\`, `\~` → non-breaking space. +pub fn clean_searchable_text(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let bytes = raw.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'\\' && i + 1 < bytes.len() { + let n = bytes[i + 1]; + match n { + b'P' | b'p' => { + out.push(' '); + i += 2; + continue; + } + b'\\' => { + out.push('\\'); + i += 2; + continue; + } + b'~' => { + out.push('\u{a0}'); + i += 2; + continue; + } + // Stacking: `\Snum^den;` → `num/den` (searchable fraction). + b'S' | b's' => { + if let Some(semi) = raw[i + 2..].find(';') { + let body = &raw[i + 2..i + 2 + semi]; + if let Some(caret) = body.find('^') { + out.push_str(&body[..caret]); + out.push('/'); + out.push_str(&body[caret + 1..]); + } else { + out.push_str(body); + } + i += 2 + semi + 1; + } else { + i += 2; + } + continue; + } + // Unicode escape `\U+XXXX` (4–6 hex digits) → the char. + // Anything else (bad length, surrogate, > U+10FFFF) leaves + // the digits to be copied literally below — visible + // garbage-in beats silently dropped text. + b'U' | b'u' => { + let rest = &raw[i + 2..]; + let after_plus = rest.strip_prefix('+').unwrap_or(rest); + let digits: String = after_plus + .chars() + .take_while(|c| c.is_ascii_hexdigit()) + .collect(); + let mut pushed = false; + if (4..=6).contains(&digits.len()) { + if let Ok(cp) = u32::from_str_radix(&digits, 16) { + if let Some(ch) = char::from_u32(cp) { + out.push(ch); + pushed = true; + } + } + } + i += 2 + (rest.len() - after_plus.len()) + + if pushed { digits.len() } else { 0 }; + continue; + } + // Decoration / format toggles: drop both the opener and closer. + b'L' | b'l' | b'O' | b'o' | b'K' | b'k' | b'C' | b'c' | b'F' + | b'f' | b'H' | b'h' | b'Q' | b'q' | b'T' + | b't' | b'W' | b'w' | b'A' | b'a' | b'M' | b'm' => { + // `\C1;`, `\Farial;` etc. run to the next `;` — skip it. + if let Some(semi) = raw[i + 2..].find(';') { + i += 2 + semi + 1; + } else { + i += 2; + } + continue; + } + _ => { + i += 1; + continue; + } + } + } else if b == b'%' && i + 2 < bytes.len() && bytes[i + 1] == b'%' { + let c = bytes[i + 2]; + match c { + b'd' | b'D' => out.push('°'), + b'p' | b'P' => out.push('±'), + b'c' | b'C' => out.push('Ø'), + b'%' => out.push('%'), + _ if c.is_ascii_digit() + && i + 4 < bytes.len() + && bytes[i + 3].is_ascii_digit() + && bytes[i + 4].is_ascii_digit() => + { + let digits = + std::str::from_utf8(&bytes[i + 2..i + 5]).unwrap_or(""); + if let Ok(code) = digits.parse::() { + out.push(char::from_u32(code).unwrap_or('?')); + } else { + out.push_str("%%"); + out.push_str(digits); + } + i += 5; + continue; + } + _ => { + out.push('%'); + out.push('%'); + out.push(c as char); + } + } + i += 3; + continue; + } else if b == b'{' || b == b'}' { + // MTEXT grouping braces: structure only, not content. + i += 1; + continue; + } else { + // Copy one UTF-8 char. + if let Some(ch) = raw[i..].chars().next() { + out.push(ch); + i += ch.len_utf8(); + } else { + break; + } + } + } + // Collapse whitespace (MTEXT padding) but keep single spaces. + let mut collapsed = String::with_capacity(out.len()); + let mut prev_space = true; // trim leading + for ch in out.chars() { + if ch.is_whitespace() { + if !prev_space { + collapsed.push(' '); + prev_space = true; + } + } else { + collapsed.push(ch); + prev_space = false; + } + } + if collapsed.ends_with(' ') { + collapsed.pop(); + } + collapsed +} + /// The inputs `Batches::finalize` consumes when it resolves a colour. /// /// The flags are independent — a wire can paint the background itself while @@ -496,6 +692,17 @@ pub struct WireModel { /// exactly like `points` — no separate collector pass. The renderer /// gathers these across all wires into the text vertex buffer. pub text_verts: Vec, + /// Searchable text runs for PDF export (real text, not outlines). + /// + /// The SDF `text_verts` above carry only tessellated quads — the source + /// string is lost by the time the PDF exporter runs, so searchable text + /// was impossible without re-layout. Each entry preserves one laid-out + /// run (already positioned: MTEXT attachment/width/wrap, rotation and + /// block placement are baked into `origin`/`rotation`/`height`), letting + /// `io::pdf_export` emit `BT … Tj ET` with an embedded font instead of + /// vector outlines. Empty on non-text wires; transformed alongside + /// `text_verts` by the block-expand loop and preview clones. + pub searchable_text: Vec, /// Block-local composed draw-order offset in (-1, 1) for a wire that must /// order against its *siblings inside the block* — currently wide-polyline /// bands, whose solid area would otherwise cover later-drawn siblings. @@ -597,6 +804,7 @@ impl WireModel { pick_tris: Vec::new(), pick_tris_low: Vec::new(), text_verts: Vec::new(), + searchable_text: Vec::new(), name, points, points_low: Vec::new(), @@ -630,10 +838,9 @@ impl WireModel { if let Some(marker) = &mut out.point_marker { marker.origin += delta.as_dvec3(); } - if !out.text_verts.is_empty() { + { let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64); - out.text_verts = - map_text_verts(&self.text_verts, |x, y, z| (x + dx, y + dy, z + dz)); + out.map_text_layout(&|p| [p[0] + dx, p[1] + dy, p[2] + dz], 1.0, 0.0); } out.tangent_geoms = self .tangent_geoms @@ -661,6 +868,9 @@ impl WireModel { (p.x, p.y, p.z) }); } + // Preview-only clone: searchable export runs are rebuilt from source + // geometry, never carried on a rubber-band preview. + out.searchable_text.clear(); out.tangent_geoms.clear(); out } @@ -719,6 +929,7 @@ impl WireModel { .iter() .map(|tg| tg.rotated(center.as_dvec3(), axis.as_dvec3(), angle_rad as f64)) .collect(); + out.searchable_text.clear(); out } @@ -758,6 +969,7 @@ impl WireModel { .iter() .map(|tg| tg.scaled(center.as_dvec3(), factor as f64)) .collect(); + out.searchable_text.clear(); out } @@ -834,6 +1046,7 @@ pub fn stretched_windows( } }); } + out.searchable_text.clear(); out } @@ -903,6 +1116,7 @@ pub fn stretched_windows( .iter() .map(|tg| tg.mirrored(p1.as_dvec3(), plane_normal.as_dvec3())) .collect(); + out.searchable_text.clear(); out } @@ -959,6 +1173,117 @@ fn map_points( } } +impl WireModel { + /// Remap every text channel geometrically in ONE call. + /// + /// The SDF quads and the searchable runs describe the same placed text; + /// transforming one without the other strands invisible text away from its + /// outlines. Every geometric remap of text (block expand, OCS→WCS, paper + /// projection, preview drags) MUST go through here so the rule — origins + /// via `map`, cap height and advance scaled by `height_scale`, rotation + /// shifted by `rot_delta` — lives in exactly one place instead of being + /// re-implemented (and drifted) per call site. + pub fn map_text_layout( + &mut self, + map: &dyn Fn([f64; 3]) -> [f64; 3], + height_scale: f64, + rot_delta: f32, + ) { + if !self.text_verts.is_empty() { + self.text_verts = map_text_verts(&self.text_verts, |x, y, z| { + let p = map([x, y, z]); + (p[0], p[1], p[2]) + }); + } + self.map_searchable_runs(map, height_scale, rot_delta); + } + + /// Remap only the searchable runs (origin, cap height, advance, rotation). + /// Used where the quads need custom handling (per-vertex colour, per-quad + /// culling) but the runs must follow the same geometry — same rule as + /// [`Self::map_text_layout`], runs half. + pub fn map_searchable_runs( + &mut self, + map: &dyn Fn([f64; 3]) -> [f64; 3], + height_scale: f64, + rot_delta: f32, + ) { + map_searchable_runs(&mut self.searchable_text, map, height_scale, rot_delta); + } +} + +/// Remap a run slice geometrically: origins via `map`, cap height and advance +/// scaled by `height_scale`, rotation shifted by `rot_delta`. The single rule +/// behind [`WireModel::map_text_layout`] and [`WireModel::map_searchable_runs`]; +/// batch code (block expand) maps runs it appends to shared entries through +/// this so the geometry can never drift from the method pair. +pub fn map_searchable_runs( + runs: &mut [SearchableTextRun], + map: &dyn Fn([f64; 3]) -> [f64; 3], + height_scale: f64, + rot_delta: f32, +) { + for run in runs { + run.origin = map(run.origin); + run.height = (run.height as f64 * height_scale) as f32; + run.adv_width = (run.adv_width as f64 * height_scale) as f32; + run.rotation += rot_delta; + } +} + +/// Overlap of a text run's rect with an axis-aligned rect (SAT on both axes). +/// +/// The run occupies `adv_width` along its baseline direction and roughly +/// `height` above (cap) plus a descender allowance below: corners span +/// `[-0.3h, +1.0h]` across the baseline. `rect` is `[x0, y0, x1, y1]`. +/// Conservative by design (it only gates searchability, never drawing). +pub fn run_rect_overlap( + origin: [f64; 2], + rotation: f32, + adv_width: f64, + height: f64, + rect: [f64; 4], +) -> bool { + if !adv_width.is_finite() || !height.is_finite() || adv_width <= 0.0 || height <= 0.0 { + return true; + } + let (s, c) = rotation.sin_cos(); + let (dx, dy) = (c as f64, s as f64); + let (nx, ny) = (-dy, dx); + let corners = [ + [origin[0] + nx * -0.3 * height, origin[1] + ny * -0.3 * height], + [ + origin[0] + dx * adv_width + nx * -0.3 * height, + origin[1] + dy * adv_width + ny * -0.3 * height, + ], + [ + origin[0] + dx * adv_width + nx * height, + origin[1] + dy * adv_width + ny * height, + ], + [origin[0] + nx * height, origin[1] + ny * height], + ]; + // Separating-axis test on the OBB axes plus world x/y. + for (ax, ay) in [(dx, dy), (nx, ny), (1.0, 0.0), (0.0, 1.0)] { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for p in &corners { + let t = p[0] * ax + p[1] * ay; + lo = lo.min(t); + hi = hi.max(t); + } + let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY); + for p in [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]] { + let t = p[0] * ax + p[1] * ay; + rlo = rlo.min(t); + rhi = rhi.max(t); + } + if hi < rlo || rhi < lo { + return false; + } + } + true +} + /// Map every glyph vertex's double-single world position through `f`, re- /// splitting the result. The preview transforms above move `points`, but SDF /// glyph quads live in `text_verts` (absolute-world double-single) — so a text @@ -995,6 +1320,7 @@ impl Default for WireModel { Self { point_marker: None, text_verts: Vec::new(), + searchable_text: Vec::new(), name: String::new(), points: Vec::new(), points_low: Vec::new(), @@ -1129,4 +1455,64 @@ mod tests { panic!("Expected PlanarCircle"); } } + + #[test] + fn map_text_layout_moves_quads_and_runs_together() { + let mut wire = WireModel::default(); + wire.searchable_text.push(SearchableTextRun { + text: "AB".into(), + origin: [10.0, 20.0, 0.0], + height: 5.0, + rotation: 0.0, + color: WireModel::WHITE, + bold: false, + font: "txt".into(), + adv_width: 8.0, + }); + // Uniform scale ×2 about the origin plus translation: quads and runs + // must agree afterwards (the lockstep this helper exists to enforce). + wire.map_text_layout(&|p| [p[0] * 2.0 + 1.0, p[1] * 2.0, p[2]], 2.0, 0.5); + let run = &wire.searchable_text[0]; + assert_eq!(run.origin, [21.0, 40.0, 0.0]); + assert!((run.height - 10.0).abs() < 1e-4); + assert!((run.adv_width - 16.0).abs() < 1e-4); + assert!((run.rotation - 0.5).abs() < 1e-6); + } + + #[test] + fn run_rect_overlap_keeps_straddlers_drops_outsiders() { + assert!(run_rect_overlap([10.0, 20.0], 0.0, 8.0, 5.0, [0.0, 0.0, 30.0, 30.0])); + assert!(!run_rect_overlap([10.0, 20.0], 0.0, 8.0, 5.0, [100.0, 100.0, 130.0, 130.0])); + // A 90° run crossing the rect edge still counts (origin-only culling + // would have dropped it). + assert!(run_rect_overlap( + [28.0, 20.0], + std::f32::consts::FRAC_PI_2, + 8.0, + 5.0, + [0.0, 0.0, 30.0, 30.0] + )); + // Degenerate runs never cull (conservative: searchability only). + assert!(run_rect_overlap([500.0, 500.0], 0.0, 0.0, 5.0, [0.0, 0.0, 30.0, 30.0])); + } + + #[test] + fn searchable_text_cleaning_handles_mtext_traps() { + // `\P` is a real newline → space (runs arrive per-line). + assert_eq!(clean_searchable_text("A\\PB"), "A B"); + // Decoration toggles are stripped, not searched. + assert_eq!(clean_searchable_text("\\LAB\\l"), "AB"); + // DXF specials decode (incl. %%nnn codes). + assert_eq!(clean_searchable_text("%%d"), "°"); + assert_eq!(clean_searchable_text("%%p"), "±"); + assert_eq!(clean_searchable_text("%%c"), "Ø"); + assert_eq!(clean_searchable_text("%%065"), "A"); + // Stacking, unicode escapes and grouping braces. + assert_eq!(clean_searchable_text("\\S1^2;"), "1/2"); + assert_eq!(clean_searchable_text("\\U+00E9"), "é"); + assert_eq!(clean_searchable_text("{AB}"), "AB"); + // Plain text survives verbatim (drawing codes stay searchable). + assert_eq!(clean_searchable_text("CMa-03"), "CMa-03"); + assert_eq!(clean_searchable_text(""), ""); + } } diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 4d463004c..907f6aafd 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -639,7 +639,9 @@ pub fn frame_wire( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - } + + ..Default::default() +} } /// Clip a hatch fill boundary to `poly`. @@ -1116,7 +1118,7 @@ mod tests { text_verts: Vec::new(), points: vec![[5.0, 5.0, 0.0], [15.0, 5.0, 0.0]], ..Default::default() - }]; +}]; clip_wires(&mut wires, &poly); assert_eq!(wires.len(), 1); diff --git a/src/scene/project.rs b/src/scene/project.rs index 15b937265..fec1f4684 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -347,6 +347,29 @@ impl Scene { out.pattern_stations.clear(); } out.text_verts = projected_text; + // Searchable runs ride the same projection through the shared + // helper (origin map + height/advance scale), then cull by + // run-rect overlap — a run straddling the viewport edge is + // kept, where origin-only culling would drop visible text. + out.searchable_text = wire.searchable_text.clone(); + out.map_searchable_runs( + &|p| { + let q = proj_abs(p[0], p[1], p[2]); + [q[0] as f64, q[1] as f64, q[2] as f64] + }, + scale as f64, + 0.0, + ); + out.searchable_text.retain(|run| { + run.origin.iter().all(|v| v.is_finite()) + && crate::scene::model::wire_model::run_rect_overlap( + [run.origin[0], run.origin[1]], + run.rotation, + run.adv_width as f64, + run.height as f64, + [vp_x0 as f64, vp_y0 as f64, vp_x1 as f64, vp_y1 as f64], + ) + }); // Paper coordinates are small sheet units — no relative-to-eye // residual is needed, and keeping the model wire's points_low // here would add a model-scale offset to the paper points. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 8a536fa01..019803e51 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1002,10 +1002,14 @@ impl Scene { continue; } let settings_handle = self.document.get_entity(h).and_then(|entity| match entity { - EntityType::Extended(codec::entities::ExtendedEntity { - data: codec::entities::ExtendedEntityData::SectionObject(data), - .. - }) if !data.settings_handle.is_null() => Some(data.settings_handle), + EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::SectionObject(data) + if !data.settings_handle.is_null() => + { + Some(data.settings_handle) + } + _ => None, + }, _ => None, }); let section_managers: Vec = managers_by_section diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 302f8a2ba..2b8d081fe 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -147,6 +147,7 @@ pub fn apply_along( // Embedded linetype text (LtSeg::Text) renders as SDF glyph quads collected // here; emitted as one extra text-carrying WireModel at the end. let mut text_verts: Vec = Vec::new(); + let mut searchable: Vec = Vec::new(); let mut elem_idx: usize = 0; let mut elem_consumed: f32 = 0.0; @@ -269,7 +270,7 @@ pub fn apply_along( // SDF: glyph quads at the insert point (rotation baked in by // layout_glyph_quads), collected for the text wire. if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { - let quads = crate::scene::text::glyph_quads::layout_glyph_quads( + let (quads, pen_adv) = crate::scene::text::glyph_quads::layout_glyph_quads( &mut atlas, *tx_scale, fwd_angle, @@ -291,6 +292,28 @@ pub fn apply_along( color, 0.0, ); + { + use crate::scene::model::wire_model::{ + clean_searchable_text, SearchableTextRun, + }; + let visible = clean_searchable_text(&resolved); + if !visible.is_empty() { + searchable.push(SearchableTextRun { + text: visible, + origin: [ + insert[0] as f64, + insert[1] as f64, + insert[2] as f64, + ], + height: *tx_scale, + rotation: fwd_angle, + color, + bold: false, + font: style.to_string(), + adv_width: pen_adv, + }); + } + } } elem_idx += 1; @@ -342,7 +365,9 @@ pub fn apply_along( plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), - }) + + ..Default::default() +}) .collect(); // Embedded linetype text (SDF): one extra wire carrying the glyph quads, @@ -376,6 +401,7 @@ pub fn apply_along( dash_from_start: false, dash_align_end: None, text_verts, + searchable_text: searchable, name: name.to_string(), points: Vec::new(), points_low: Vec::new(), diff --git a/src/scene/text/glyph_quads.rs b/src/scene/text/glyph_quads.rs index ffe4d5109..8a1199d48 100644 --- a/src/scene/text/glyph_quads.rs +++ b/src/scene/text/glyph_quads.rs @@ -34,6 +34,13 @@ pub struct GlyphQuad { /// Lay `text` out as per-glyph quads. Mirrors `tessellate_text_run`'s transform /// and per-character advance so the quads land exactly where the strokes would. /// Whitespace and glyphs with no ink advance the pen but emit no quad. +/// +/// Returns the quads plus the final pen advance in run-local units +/// (`cursor × scale × |width_factor|`, always ≥ 0): the exact laid-out width +/// including spacing/tracking, so exporters can width-match (`Tz`) and cull +/// runs without re-deriving advances from a second layout pass. Mirrored +/// (negative width factor) runs collapse to a positive extent — the search +/// layer reads LTR while the outlines keep the true mirror. #[allow(clippy::too_many_arguments)] pub fn layout_glyph_quads( atlas: &mut GlyphAtlas, @@ -45,9 +52,9 @@ pub fn layout_glyph_quads( font_name: &str, bold: bool, text: &str, -) -> Vec { +) -> (Vec, f32) { if text.is_empty() || height <= 0.0 { - return vec![]; + return (vec![], 0.0); } let scale = height / 9.0; @@ -155,6 +162,7 @@ pub fn layout_glyph_quads( } } + let pen_advance = (cursor_x * scale * wf).abs(); // Any decoration still open at the end runs to the final pen position. for (slot, y) in [(under, UNDER_Y), (over, OVER_Y), (strike, STRIKE_Y)] { if let Some(s) = slot { @@ -176,7 +184,7 @@ pub fn layout_glyph_quads( }); } - quads + (quads, pen_advance) } // ── Tests ───────────────────────────────────────────────────────────────── @@ -198,7 +206,8 @@ mod tests { #[test] fn glyphs_advance_rightward() { let mut atlas = GlyphAtlas::new(512, 512); - let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); + let (quads, _) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); assert_eq!(quads.len(), 2, "two inked glyphs -> two quads"); assert!( center(&quads[1])[0] > center(&quads[0])[0], @@ -216,8 +225,10 @@ mod tests { #[test] fn space_emits_no_quad_but_advances() { let mut atlas = GlyphAtlas::new(512, 512); - let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); - let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A A"); + let (ab, _) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); + let (a_sp_a, _) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A A"); assert_eq!(a_sp_a.len(), 2, "space produces no quad"); assert!( center(&a_sp_a[1])[0] > center(&ab[1])[0], @@ -231,8 +242,9 @@ mod tests { // Use the baseline advance direction (glyph0 -> glyph1), which is +x when // flat and swings to +y after a 90° turn — independent of where a single // glyph's box centre sits within the cap height. - let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); - let turned = layout_glyph_quads( + let (flat, _) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); + let (turned, _) = layout_glyph_quads( &mut atlas, 10.0, std::f32::consts::FRAC_PI_2, @@ -256,8 +268,10 @@ mod tests { #[test] fn taller_text_makes_bigger_quads() { let mut atlas = GlyphAtlas::new(512, 512); - let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A"); - let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A"); + let (small, _) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A"); + let (big, _) = + layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A"); let extent = |q: &GlyphQuad| { let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); for p in &q.corners { @@ -278,13 +292,44 @@ mod tests { #[test] fn underline_emits_an_extra_solid_quad() { let mut atlas = GlyphAtlas::new(512, 512); - let plain = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); + let (plain, plain_adv) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); // `\L…\l` underlines the run — same glyph quads plus one decoration quad. - let under = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l"); + let (under, under_adv) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l"); assert_eq!(under.len(), plain.len() + 1, "underline adds one quad"); // The decoration quad samples the atlas's solid texel (degenerate UV). let deco = under.last().unwrap(); assert_eq!(deco.uv_min, deco.uv_max, "decoration quad uses the solid UV"); assert_eq!(deco.uv_min, atlas.solid_uv()); + // Decorations don't move the pen: same advance either way, and the + // advance covers both glyphs (positive, wider than one glyph). + assert!( + (under_adv - plain_adv).abs() < 1e-6, + "decoration must not move the pen: {under_adv} vs {plain_adv}" + ); + assert!(plain_adv > 0.0, "two glyphs advance the pen"); + } + + #[test] + fn pen_advance_scales_with_height_and_width_factor() { + let mut atlas = GlyphAtlas::new(512, 512); + let (_, base) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); + let (_, tall) = + layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); + assert!( + (tall / base - 2.0).abs() < 0.05, + "double height ~ double advance: {tall} vs {base}" + ); + let (_, wide) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 2.0, 0.0, 1.0, "txt", false, "AB"); + assert!( + (wide / base - 2.0).abs() < 0.05, + "double width factor ~ double advance: {wide} vs {base}" + ); + let (_, empty) = + layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, ""); + assert_eq!(empty, 0.0, "empty text advances nothing"); } } diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index a5d8458d2..138b2b40f 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -139,10 +139,18 @@ fn canonical_family_name_uncached(family: &str) -> Option { /// Resolve a family name to a concrete face id (regular weight/style). fn face_id(family: &str) -> Option { + face_id_weighted(family, fontdb::Weight::NORMAL) +} + +/// Resolve a family name to a concrete face id at the requested weight +/// (e.g. bold runs). Falls back through fontdb's fuzzy matching, so a +/// family without a bold face still resolves — to its regular face. +fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option { let db = &fonts().db; let canonical = canonical_family_name(family)?; let query = fontdb::Query { families: &[fontdb::Family::Name(&canonical)], + weight, ..Default::default() }; db.query(&query) @@ -157,6 +165,18 @@ pub fn with_face_data(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Optio fonts().db.with_face_data(id, f) } +/// Borrow the raw face bytes for `family` at `weight` (see +/// [`face_id_weighted`]). The PDF exporter uses this to embed the bold face +/// for bold runs instead of faux-stroking the regular one. +pub fn with_face_data_weighted( + family: &str, + weight: fontdb::Weight, + f: impl FnOnce(&[u8], u32) -> T, +) -> Option { + let id = face_id_weighted(family, weight)?; + fonts().db.with_face_data(id, f) +} + /// The family of the installed face whose file is named `name` — a bare file /// name or any path to one, matched case-insensitively. fn family_for_file(name: &str) -> Option { diff --git a/src/snap.rs b/src/snap.rs index 68b94be50..53bc6a779 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -3801,7 +3801,7 @@ mod ext_tests { text_verts: Vec::new(), points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]], ..Default::default() - }; +}; let wires = [wire]; // Extension-driven acquisition (endpoints_only): a midpoint — like an diff --git a/src/sys.rs b/src/sys.rs index 9c0a9c757..39be4f04c 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -194,6 +194,38 @@ pub fn reveal_in_file_manager(path: &std::path::Path) -> Result<(), String> { } } +/// If the main application window is minimized, restore it so that viewport captures succeed. +#[cfg(target_os = "windows")] +pub fn restore_window_if_minimized() { + use windows_sys::Win32::Foundation::{HWND, LPARAM}; + use windows_sys::Win32::System::Threading::GetCurrentProcessId; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindowThreadProcessId, IsIconic, IsWindowVisible, SetForegroundWindow, + ShowWindow, SW_RESTORE, + }; + type BOOL = i32; + + unsafe { + let current_pid = GetCurrentProcessId(); + unsafe extern "system" fn enum_wnd(hwnd: HWND, lparam: LPARAM) -> BOOL { + let target_pid = lparam as u32; + let mut wnd_pid: u32 = 0; + GetWindowThreadProcessId(hwnd, &mut wnd_pid); + if wnd_pid == target_pid && IsWindowVisible(hwnd) != 0 { + if IsIconic(hwnd) != 0 { + ShowWindow(hwnd, SW_RESTORE); + SetForegroundWindow(hwnd); + } + } + 1 + } + EnumWindows(Some(enum_wnd), current_pid as LPARAM); + } +} + +#[cfg(not(target_os = "windows"))] +pub fn restore_window_if_minimized() {} + /// Copy the rendered web canvas during the frame callback, before the browser /// clears its drawing buffer. Canvas readback avoids Iced's synchronous GPU map. #[cfg(target_arch = "wasm32")] diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index fef4702be..8ad8037b3 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -88,6 +88,7 @@ fn text_and_dim_reach_pdf_export() { .cloned() .map(|mut w| { w.wire.text_verts.clear(); + w.wire.searchable_text.clear(); w }) .collect(); diff --git a/tests/section_plane_roundtrip.rs b/tests/section_plane_roundtrip.rs index afa7f3395..da08c1c61 100644 --- a/tests/section_plane_roundtrip.rs +++ b/tests/section_plane_roundtrip.rs @@ -14,7 +14,7 @@ fn add_slice(scene: &mut Scene) -> Handle { thickness.values = vec![XDataValue::Real(0.75)]; common.extended_data.add_record(thickness); - scene.add_entity(EntityType::Extended(ExtendedEntity { + scene.add_entity(EntityType::Extended(Box::new(ExtendedEntity { common, data: ExtendedEntityData::SectionObject(SectionObjectData { state: 1, @@ -29,7 +29,7 @@ fn add_slice(scene: &mut Scene) -> Handle { back_line_vertices: Vec::new(), settings_handle: Handle::NULL, }), - })) + }))) } fn assert_section_graph(document: &codec::CadDocument, extension: &str) {