forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/attdef-parity
# Conflicts: # Cargo.lock # src/modules/draw/draw/attdef.rs
This commit is contained in:
commit
53f2983e9c
77 files changed
+4034
-243
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -116,20 +116,73 @@ fn embed_point(req: &Value) -> Result<codec::types::Vector3, Value> {
|
||||
}
|
||||
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<Task<Message>, 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())
|
||||
|
||||
+184
-10
@@ -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<String> = 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<codec::Handle> = 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<codec::Handle> = 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(),
|
||||
|
||||
@@ -53,6 +53,7 @@ fn listen(sender: mpsc::Sender<Envelope>) -> 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 {
|
||||
|
||||
@@ -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<Value>,
|
||||
}
|
||||
|
||||
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<codec::Handle> =
|
||||
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<image::RgbaImage>,
|
||||
pub diff_overlay: image::RgbaImage,
|
||||
}
|
||||
|
||||
pub(crate) fn compute_visual_diff(
|
||||
baseline: &image::RgbaImage,
|
||||
current: &image::RgbaImage,
|
||||
pixel_to_world_matrix: &[f64; 6],
|
||||
) -> VisualDiffResult {
|
||||
let (w, h) = (current.width(), current.height());
|
||||
let mut diff_overlay = image::RgbaImage::new(w, h);
|
||||
|
||||
if baseline.width() != w || baseline.height() != h {
|
||||
return VisualDiffResult {
|
||||
changed: true,
|
||||
change_percentage: 100.0,
|
||||
changed_pixels: (w * h) as usize,
|
||||
dirty_pixel_bounds: Some([0, 0, w.saturating_sub(1), h.saturating_sub(1)]),
|
||||
dirty_world_bounds: None,
|
||||
patch_resolution: Some([w, h]),
|
||||
cropped_patch: Some(current.clone()),
|
||||
diff_overlay: current.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut min_x = u32::MAX;
|
||||
let mut min_y = u32::MAX;
|
||||
let mut max_x = 0u32;
|
||||
let mut max_y = 0u32;
|
||||
let mut changed_pixels = 0usize;
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p1 = baseline.get_pixel(x, y);
|
||||
let p2 = current.get_pixel(x, y);
|
||||
|
||||
let dr = (p1[0] as i32 - p2[0] as i32).abs();
|
||||
let dg = (p1[1] as i32 - p2[1] as i32).abs();
|
||||
let db = (p1[2] as i32 - p2[2] as i32).abs();
|
||||
let da = (p1[3] as i32 - p2[3] as i32).abs();
|
||||
|
||||
let is_diff = (dr + dg + db + da) > 20;
|
||||
|
||||
if is_diff {
|
||||
changed_pixels += 1;
|
||||
min_x = min_x.min(x);
|
||||
min_y = min_y.min(y);
|
||||
max_x = max_x.max(x);
|
||||
max_y = max_y.max(y);
|
||||
|
||||
// Highlight changed pixel in vibrant green
|
||||
diff_overlay.put_pixel(x, y, image::Rgba([0, 255, 128, 255]));
|
||||
} else {
|
||||
// Dim unchanged background: grayscale attenuated to 35%
|
||||
let gray = ((p2[0] as f32 * 0.299 + p2[1] as f32 * 0.587 + p2[2] as f32 * 0.114) * 0.35) as u8;
|
||||
diff_overlay.put_pixel(x, y, image::Rgba([gray, gray, gray, 200]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_pixels = (w * h) as f64;
|
||||
let change_percentage = if total_pixels > 0.0 {
|
||||
(changed_pixels as f64 / total_pixels) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if changed_pixels == 0 {
|
||||
return VisualDiffResult {
|
||||
changed: false,
|
||||
change_percentage: 0.0,
|
||||
changed_pixels: 0,
|
||||
dirty_pixel_bounds: None,
|
||||
dirty_world_bounds: None,
|
||||
patch_resolution: None,
|
||||
cropped_patch: None,
|
||||
diff_overlay,
|
||||
};
|
||||
}
|
||||
|
||||
let pad = 16u32;
|
||||
let b_min_x = min_x.saturating_sub(pad);
|
||||
let b_min_y = min_y.saturating_sub(pad);
|
||||
let b_max_x = (max_x + pad).min(w.saturating_sub(1));
|
||||
let b_max_y = (max_y + pad).min(h.saturating_sub(1));
|
||||
|
||||
let patch_w = (b_max_x - b_min_x + 1).max(1);
|
||||
let patch_h = (b_max_y - b_min_y + 1).max(1);
|
||||
|
||||
let m = pixel_to_world_matrix;
|
||||
let px0 = b_min_x as f64;
|
||||
let py0 = b_min_y as f64;
|
||||
let px1 = b_max_x as f64;
|
||||
let py1 = b_max_y as f64;
|
||||
|
||||
let p00_x = m[0] * px0 + m[1] * py0 + m[4];
|
||||
let p00_y = m[2] * px0 + m[3] * py0 + m[5];
|
||||
let p10_x = m[0] * px1 + m[1] * py0 + m[4];
|
||||
let p10_y = m[2] * px1 + m[3] * py0 + m[5];
|
||||
let p01_x = m[0] * px0 + m[1] * py1 + m[4];
|
||||
let p01_y = m[2] * px0 + m[3] * py1 + m[5];
|
||||
let p11_x = m[0] * px1 + m[1] * py1 + m[4];
|
||||
let p11_y = m[2] * px1 + m[3] * py1 + m[5];
|
||||
|
||||
let w_min_x = p00_x.min(p10_x).min(p01_x).min(p11_x);
|
||||
let w_max_x = p00_x.max(p10_x).max(p01_x).max(p11_x);
|
||||
let w_min_y = p00_y.min(p10_y).min(p01_y).min(p11_y);
|
||||
let w_max_y = p00_y.max(p10_y).max(p01_y).max(p11_y);
|
||||
|
||||
let cropped = image::imageops::crop_imm(current, b_min_x, b_min_y, patch_w, patch_h).to_image();
|
||||
|
||||
VisualDiffResult {
|
||||
changed: true,
|
||||
change_percentage,
|
||||
changed_pixels,
|
||||
dirty_pixel_bounds: Some([b_min_x, b_min_y, b_max_x, b_max_y]),
|
||||
dirty_world_bounds: Some([w_min_x, w_min_y, w_max_x, w_max_y]),
|
||||
patch_resolution: Some([patch_w, patch_h]),
|
||||
cropped_patch: Some(cropped),
|
||||
diff_overlay,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode_image_png(img: &image::RgbaImage) -> Result<Vec<u8>, String> {
|
||||
use std::io::Cursor;
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode PNG: {e}"))?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
#[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<PyramidLevelInfo>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2865,7 +2865,9 @@ impl OpenCADStudio {
|
||||
plinegen: true,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
};
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
previews.push(guide);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user