diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 671a08b77..6eff6d918 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -134,63 +134,130 @@ fn parse_point_2d(val: &Value) -> Result<[f64; 2], Value> { Ok([x, y]) } +fn parse_calibrate( + cal: &Value, + pixel_width: u32, + pixel_height: u32, +) -> Result<(codec::types::Vector3, f64, f64), Value> { + let pt_a = cal + .get("point_a") + .or_else(|| cal.get("pixel_a")) + .or_else(|| cal.get("p1")) + .or_else(|| cal.get("source_a")); + let pt_b = cal + .get("point_b") + .or_else(|| cal.get("pixel_b")) + .or_else(|| cal.get("p2")) + .or_else(|| cal.get("source_b")); + let dist = cal + .get("distance") + .or_else(|| cal.get("real_distance")) + .or_else(|| cal.get("length")) + .and_then(Value::as_f64); + + if let (Some(pa), Some(pb), Some(d_real)) = (pt_a, pt_b, dist) { + if !d_real.is_finite() || d_real <= 0.0 { + return Err(failure( + "invalid_distance", + "Calibration distance must be positive", + )); + } + let p1 = parse_point_2d(pa)?; + let p2 = parse_point_2d(pb)?; + let d_px = ((p2[0] - p1[0]).powi(2) + (p2[1] - p1[1]).powi(2)).sqrt(); + if d_px <= 1e-6 { + return Err(failure( + "invalid_points", + "Calibration reference points must be distinct", + )); + } + let scale = d_real / d_px; + let width = pixel_width as f64 * scale; + + let align_to = cal + .get("align_to") + .or_else(|| cal.get("origin")) + .or_else(|| cal.get("target_a")) + .map(parse_point_2d) + .transpose()? + .unwrap_or([0.0, 0.0]); + + let x_ll = align_to[0] - p1[0] * scale; + let y_ll = align_to[1] - (pixel_height as f64 - p1[1]) * scale; + return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale)); + } + + if let (Some(src_pts), Some(tgt_pts)) = ( + cal.get("source_points").and_then(Value::as_array), + cal.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 width = pixel_width as f64 * scale; + let x_ll = p_t1[0] - p_s1[0] * scale; + let y_ll = p_t1[1] - (pixel_height as f64 - p_s1[1]) * scale; + return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale)); + } + } + + Err(failure( + "invalid_calibrate", + "calibrate requires point_a:[x,y], point_b:[x,y], distance:number, and optional align_to:[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`. + /// `embed_image` — pack the picture at `path` into an OLE2FRAME or linked RasterImage + /// placed with its lower-left corner at `at` (default width ≈ pixel_width/100). + /// Supports automatic reference scaling and calibration via `calibrate`: + /// `{"point_a": [px1, py1], "point_b": [px2, py2], "distance": 6500.0, "align_to": [0, 0]}`. #[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, width) = if let (Some(src_pts), Some(tgt_pts)) = ( + + let (at, width, scale) = if let Some(cal) = req.get("calibrate") { + parse_calibrate(cal, image.pixel_width, image.pixel_height)? + } else 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) - } + let dummy = json!({"source_points": src_pts, "target_points": tgt_pts}); + parse_calibrate(&dummy, image.pixel_width, image.pixel_height)? } 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) + let scale = width / image.pixel_width as f64; + (embed_point(req)?, width, scale) }; + + let final_at = if req.get("calibrate").is_none() && req.get("source_points").is_none() { + at + } else { + embed_point(req).unwrap_or(at) + }; + let final_width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(width); + let height = final_width * image.pixel_height as f64 / image.pixel_width as f64; + + let layer_name = req["layer"].as_str().filter(|l| !l.is_empty()); + let lock_layer = req["lock_layer"].as_bool().unwrap_or(false); + 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 - // drawing (unlike the embedded OLE2FRAME default below). self.push_undo_snapshot(i, "IMAGEATTACH"); - let height = width * image.pixel_height as f64 / image.pixel_width as f64; let handle = { let document = &mut self.tabs[i].scene.document; let definition_handle = document.allocate_handle(); @@ -206,45 +273,91 @@ impl OpenCADStudio { ); let mut entity = codec::entities::RasterImage::with_size( path, - at, + final_at, image.pixel_width as f64, image.pixel_height as f64, - width, + final_width, height, ); entity.definition_handle = Some(definition_handle); + if let Some(l_name) = layer_name { + entity.common.layer = l_name.to_string(); + if !document.layers.contains(l_name) { + let mut layer_record = codec::tables::Layer::new(l_name.to_string()); + layer_record.handle = document.allocate_handle(); + if lock_layer { + layer_record.flags.locked = true; + } + let _ = document.layers.add(layer_record); + } else if lock_layer { + if let Some(l) = document.layers.get_mut(l_name) { + l.flags.locked = true; + } + } + } document .add_entity(codec::EntityType::RasterImage(entity)) .map_err(|e| failure("embed_failed", e))? }; self.tabs[i].scene.populate_images_from_document(); + if layer_name.is_some() { + self.refresh_layer_panel(); + } self.post_ref_op(i); self.set_control_result(json!({ "handle": format!("{:X}", handle.value()), "kind": "RasterImage", "path": path, - "at": [at.x, at.y, at.z], - "width": width, + "at": [final_at.x, final_at.y, final_at.z], + "width": final_width, "height": height, + "scale": scale, "linked": true, + "layer": layer_name.unwrap_or("0"), })); return Ok(Task::none()); } + self.push_undo_snapshot(i, "IMAGEEMBED"); let handle = crate::io::ole_embed::add_embedded_image( &mut self.tabs[i].scene.document, &image, - at, - width, + final_at, + final_width, ) .map_err(|e| failure("embed_failed", e))?; + + if let Some(l_name) = layer_name { + let document = &mut self.tabs[i].scene.document; + if !document.layers.contains(l_name) { + let mut layer_record = codec::tables::Layer::new(l_name.to_string()); + layer_record.handle = document.allocate_handle(); + if lock_layer { + layer_record.flags.locked = true; + } + let _ = document.layers.add(layer_record); + } else if lock_layer { + if let Some(l) = document.layers.get_mut(l_name) { + l.flags.locked = true; + } + } + if let Some(ent) = document.get_entity_mut(handle) { + ent.common_mut().layer = l_name.to_string(); + } + self.refresh_layer_panel(); + } + self.post_ref_op(i); self.set_control_result(json!({ "handle": format!("{:X}", handle.value()), "kind": "Ole2Frame", "path": path, - "at": [at.x, at.y, at.z], - "width": width, + "at": [final_at.x, final_at.y, final_at.z], + "width": final_width, + "height": height, + "scale": scale, + "linked": false, + "layer": layer_name.unwrap_or("0"), })); Ok(Task::none()) } diff --git a/src/mcp_ops.rs b/src/mcp_ops.rs index 69237e423..c7ef661d8 100644 --- a/src/mcp_ops.rs +++ b/src/mcp_ops.rs @@ -249,18 +249,21 @@ pub static ACTION: OpDef = OpDef { pub static EMBED_IMAGE: OpDef = OpDef { name: "embed_image", - doc: "Embed or link a raster image into the drawing.", + doc: "Embed or link a raster image into the drawing with optional reference scaling and calibration.", batchable: true, params: &[ req("path", Ty::Str, "Path to the image file."), 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("calibrate", Ty::Raw(r#"{"type":"object","description":"Automatic 2-point reference calibration: {point_a:[px1,py1], point_b:[px2,py2], distance:650.0, align_to:[0,0]}"}"#), "Automatic 2-point reference calibration: {point_a:[px1,py1], point_b:[px2,py2], distance:650.0, align_to:[0,0]}."), + opt("layer", Ty::Str, "Layer name to place the image on (auto-created if missing, e.g. '_XREF')."), + opt("lock_layer", Ty::Bool, "Lock the target layer after attaching."), 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]}"#, + example: r#"{"op":"embed_image","path":"plan.png","linked":true,"calibrate":{"point_a":[52,910],"point_b":[450,910],"distance":6500},"layer":"_XREF","lock_layer":true}"#, }; pub static WBLOCK: OpDef = OpDef {