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.
This commit is contained in:
ysdirector committed 2026-09-13 23:53:50 +08:00
1 parent 05d95e2e72
commit 171fc48987
3 files changed
+116

No files matched your search

+61
View File
@@ -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
+1
View File
@@ -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;
+54
View File
@@ -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<String> {
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]