From 171fc48987749cb3a6acb5a3dffc04ba9e74d3c7 Mon Sep 17 00:00:00 2001 From: ysdirector Date: Sun, 13 Sep 2026 23:53:50 +0800 Subject: [PATCH] feat(viewport): Ctrl+click opens an entity PE_URL hyperlink Entities that carry the standard PE_URL XDATA record (what HYPERLINK writes) can now be followed from the canvas: Ctrl+click opens the link in the system browser and consumes the click, so no box/lasso selection starts. - scene::selection::pe_url_of() reads the record with the same rule as the Properties panel hyperlink field; unit-tested. - The ViewCube/compass hit region wins over a hyperlink drawn beneath it. - Unchanged behaviour when Ctrl is not held or the entity has no link. --- src/app/update/viewport.rs | 61 ++++++++++++++++++++++++++++++++++++++ src/scene/mod.rs | 1 + src/scene/selection.rs | 54 +++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f9a0ff386..ca72ae938 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3029,6 +3029,67 @@ impl OpenCADStudio { }; let (vw, vh) = vp_size; + // Ctrl+click on an entity carrying a standard `PE_URL` hyperlink opens + // that link in the system browser (the AutoCAD behaviour). Only in + // model space with no command running; a hit consumes the click, so no + // box/lasso selection starts. Anything else (no Ctrl, no link) falls + // through to the existing logic untouched. + if self.ctrl_down + && self.tabs[i].active_cmd.is_none() + && self.tabs[i].scene.current_layout == "Model" + { + // Match the selection pick below: map the cursor into the active + // model tile and use a zero-origin bounds rectangle. + let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh); + let local = iced::Point { + x: p.x - tile_b.x, + y: p.y - tile_b.y, + }; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: tile_b.width, + height: tile_b.height, + }; + let (view_rot, eye) = { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + }; + // The ViewCube/compass gizmo is drawn over the top-right corner: + // clicks inside its hit region belong to the gizmo, never to a + // hyperlink drawn underneath it. + let (ccx, ccy, cw, ch) = match self.tabs[i] + .scene + .active_viewport + .and_then(|hndl| self.tabs[i].scene.viewport_screen_rect(hndl, (vw, vh))) + { + Some(rect) => (p.x - rect.x, p.y - rect.y, rect.width, rect.height), + None => (local.x, local.y, tile_b.width, tile_b.height), + }; + let on_gizmo = scene::hit_test(ccx, ccy, cw, ch, view_rot, VIEWCUBE_PX).is_some(); + let wires = self.tabs[i].scene.hit_test_wires(); + let hit = if on_gizmo { + None + } else { + scene::pick::hit_test::click_hit( + local, + &*wires, + view_rot, + eye, + bounds, + self.tabs[i].scene.document.header.lineweight_display, + crate::ui::overlay::pick_box_aperture_px(self.pick_box), + ) + .and_then(|s| Scene::handle_from_wire_name(s)) + }; + if let Some(url) = + hit.and_then(|handle| scene::pe_url_of(&self.tabs[i].scene.document, handle)) + { + self.command_line.push_info(&format!("Opening hyperlink: {url}")); + return crate::sys::open_url(&url, self.main_window); + } + } + // An engaged grip owns the next left press (click-move-click placement // or the release of a press-drag). Do not let the same press arm the // normal box/lasso state or trigger another viewport control; the diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 51262e058..cae9650ad 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -46,6 +46,7 @@ mod preview; mod project; mod scene_markers; mod selection; +pub use selection::pe_url_of; pub mod sketch_constraints; mod sketch_persist; mod sketch_solve; diff --git a/src/scene/selection.rs b/src/scene/selection.rs index a98c7e119..2549c6470 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1,5 +1,22 @@ use super::*; +/// The entity's standard `PE_URL` hyperlink (the record AutoCAD's HYPERLINK +/// command writes). +/// +/// Returns the first non-empty string value of the record; `None` when the +/// entity or record is missing, the record holds no string, or it is empty. +/// Matches how the Properties panel reads its `"hyperlink"` field. +pub fn pe_url_of(doc: &CadDocument, handle: Handle) -> Option { + doc.get_entity(handle) + .and_then(|entity| entity.common().extended_data.get_record("PE_URL")) + .and_then(|record| { + record.values.iter().find_map(|value| match value { + acadrust::xdata::XDataValue::String(text) if !text.is_empty() => Some(text.clone()), + _ => None, + }) + }) +} + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -1116,6 +1133,43 @@ impl Scene { #[cfg(test)] mod tests { + #[test] + fn pe_url_of_reads_standard_hyperlink_xdata() { + use acadrust::entities::Point; + use acadrust::xdata::{ExtendedDataRecord, XDataValue}; + + let mut doc = CadDocument::new(); + + // An entity carrying a PE_URL hyperlink resolves to that link. + let mut linked = Point::new(); + let mut rec = ExtendedDataRecord::new("PE_URL"); + rec.add_value(XDataValue::String("https://example.com/gui".to_string())); + linked.common.extended_data.add_record(rec); + let linked_handle = doc + .add_entity(EntityType::Point(linked)) + .expect("linked entity added"); + assert_eq!( + pe_url_of(&doc, linked_handle).as_deref(), + Some("https://example.com/gui") + ); + + // No PE_URL record -> None. + let plain_handle = doc + .add_entity(EntityType::Point(Point::new())) + .expect("plain entity added"); + assert_eq!(pe_url_of(&doc, plain_handle), None); + + // Empty string in the record -> None. + let mut empty = Point::new(); + let mut empty_rec = ExtendedDataRecord::new("PE_URL"); + empty_rec.add_value(XDataValue::String(String::new())); + empty.common.extended_data.add_record(empty_rec); + let empty_handle = doc + .add_entity(EntityType::Point(empty)) + .expect("empty-link entity added"); + assert_eq!(pe_url_of(&doc, empty_handle), None); + } + use super::*; #[test]