forked from 1b-agent/opencadstudio-upstream
fix(view): correct 3D mesh interaction
Sync projection controls from the active camera, preserve the UCS icon in perspective views, and use visible or selected 3D geometry for orbit pivots. Dense mesh vertex grips remain hidden.
This commit is contained in:
1 parent
1045283082
commit
0df2356243
6 files changed
+136
-80
No files matched your search
@@ -351,9 +351,8 @@ impl OpenCADStudio {
|
||||
(origin, (x.as_vec3(), y.as_vec3(), z.as_vec3()))
|
||||
}
|
||||
|
||||
/// Adopt the active viewport's grid *display* into the live toggle. Called
|
||||
/// on load and whenever the active tab or viewport changes, so the grid
|
||||
/// drawing follows the active viewport.
|
||||
/// Adopt the active viewport's display state into the live toggles. Called
|
||||
/// on load and whenever the active tab or viewport changes.
|
||||
///
|
||||
/// Grid *snap* (`SnapType::Grid`) is deliberately NOT adopted here: it stays
|
||||
/// off by default everywhere and is controlled solely by the user's snap
|
||||
@@ -363,6 +362,9 @@ impl OpenCADStudio {
|
||||
if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() {
|
||||
self.show_grid = grid_on;
|
||||
}
|
||||
let ortho = self.tabs[i].scene.active_camera_projection()
|
||||
== crate::scene::Projection::Orthographic;
|
||||
self.ribbon.set_ortho(ortho);
|
||||
}
|
||||
|
||||
pub(in crate::app) fn sync_render_mode_to_active_tile(&mut self, i: usize) {
|
||||
@@ -982,12 +984,14 @@ impl OpenCADStudio {
|
||||
return Task::none();
|
||||
} else if self.tabs[i].scene.current_layout == "Model" {
|
||||
if sel.orbit_pivot.is_none() {
|
||||
// Selection centre when something is selected,
|
||||
// otherwise the point under the cursor. (#229)
|
||||
sel.orbit_pivot = self.tabs[i]
|
||||
.scene
|
||||
.orbit_pivot()
|
||||
.or(Some(self.tabs[i].last_cursor_world));
|
||||
let scene = &self.tabs[i].scene;
|
||||
let bounds = scene.active_model_tile_bounds(vp_size.0, vp_size.1);
|
||||
sel.orbit_pivot = Some(
|
||||
scene
|
||||
.orbit_pivot()
|
||||
.or_else(|| scene.view_center_surface_pivot(bounds))
|
||||
.unwrap_or_else(|| scene.camera.borrow().target),
|
||||
);
|
||||
}
|
||||
let pivot = sel.orbit_pivot;
|
||||
drop(sel);
|
||||
|
||||
@@ -1101,11 +1101,7 @@ impl RenderConvertible for Mesh {
|
||||
|
||||
impl Grippable for Mesh {
|
||||
fn grips(&self) -> Vec<GripDef> {
|
||||
self.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| square_grip(i, glam::DVec3::new(v.x, v.y, v.z)))
|
||||
.collect()
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
|
||||
|
||||
+63
-12
@@ -7878,6 +7878,34 @@ impl Scene {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn view_center_surface_pivot(
|
||||
&self,
|
||||
bounds: iced::Rectangle,
|
||||
) -> Option<glam::DVec3> {
|
||||
let camera = self.camera.borrow();
|
||||
let view_rot = camera.view_proj_rte(bounds);
|
||||
let eye = camera.eye();
|
||||
drop(camera);
|
||||
let meshes = self.interaction_meshes_arc();
|
||||
let center = iced::Point::new(bounds.width * 0.5, bounds.height * 0.5);
|
||||
pick::hit_test::mesh_click_point(
|
||||
center,
|
||||
meshes.iter().filter_map(|set| {
|
||||
let handle = set.entity_handle()?;
|
||||
let mesh = set.geometry_lods().first()?;
|
||||
Some((
|
||||
handle,
|
||||
mesh,
|
||||
set.instance_transform,
|
||||
mesh_interaction_aabb(set)?,
|
||||
))
|
||||
}),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
)
|
||||
}
|
||||
|
||||
/// Hover may use the coarsest cached solid LOD. Click selection keeps the
|
||||
/// full-resolution mesh, while rollover only needs a stable parent handle
|
||||
/// and must stay within an interactive frame budget.
|
||||
@@ -9061,8 +9089,8 @@ impl Scene {
|
||||
|
||||
/// The AABB centre of the current selection, in absolute world coordinates
|
||||
/// (same space as `Camera::target`) — the point the 3D view orbits around
|
||||
/// when something is selected. `None` when nothing is selected; the caller
|
||||
/// then orbits about the point under the cursor. (#229)
|
||||
/// when something is selected. `None` keeps the current camera target as
|
||||
/// the orbit centre. (#229)
|
||||
pub fn orbit_pivot(&self) -> Option<glam::DVec3> {
|
||||
if self.selected.is_empty() {
|
||||
return None;
|
||||
@@ -9086,9 +9114,16 @@ impl Scene {
|
||||
self.annotation_all_visible(),
|
||||
None,
|
||||
);
|
||||
let mut min = glam::DVec2::splat(f64::INFINITY);
|
||||
let mut max = glam::DVec2::splat(f64::NEG_INFINITY);
|
||||
let mut min = glam::DVec3::splat(f64::INFINITY);
|
||||
let mut max = glam::DVec3::splat(f64::NEG_INFINITY);
|
||||
let mut any = false;
|
||||
let mut include = |point: glam::DVec3| {
|
||||
if point.is_finite() {
|
||||
min = min.min(point);
|
||||
max = max.max(point);
|
||||
any = true;
|
||||
}
|
||||
};
|
||||
for wire in &wires {
|
||||
let Some(h) = Self::handle_from_wire_name(&wire.name) else {
|
||||
continue;
|
||||
@@ -9096,17 +9131,33 @@ impl Scene {
|
||||
if !self.selected.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
for &[x, y, _] in &wire.points {
|
||||
if x.is_finite() && y.is_finite() {
|
||||
min = min.min(glam::DVec2::new(x as f64, y as f64));
|
||||
max = max.max(glam::DVec2::new(x as f64, y as f64));
|
||||
any = true;
|
||||
}
|
||||
for (index, &[x, y, z]) in wire.points.iter().enumerate() {
|
||||
let [lx, ly, lz] = wire
|
||||
.points_low
|
||||
.get(index)
|
||||
.copied()
|
||||
.unwrap_or([0.0; 3]);
|
||||
include(glam::DVec3::new(
|
||||
x as f64 + lx as f64,
|
||||
y as f64 + ly as f64,
|
||||
z as f64 + lz as f64,
|
||||
));
|
||||
}
|
||||
}
|
||||
for set in self.interaction_meshes_arc().iter() {
|
||||
let Some(handle) = set.entity_handle() else {
|
||||
continue;
|
||||
};
|
||||
if !self.selected.contains(&handle) {
|
||||
continue;
|
||||
}
|
||||
if let Some(aabb) = mesh_interaction_aabb(set) {
|
||||
include(glam::DVec3::new(aabb[0], aabb[1], aabb[2]));
|
||||
include(glam::DVec3::new(aabb[3], aabb[4], aabb[5]));
|
||||
}
|
||||
}
|
||||
if any {
|
||||
let c = (min + max) * 0.5;
|
||||
Some(glam::DVec3::new(c.x, c.y, 0.0))
|
||||
Some((min + max) * 0.5)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -446,6 +446,15 @@ impl Scene {
|
||||
self.camera.borrow().rotation
|
||||
}
|
||||
|
||||
pub fn active_camera_projection(&self) -> view::camera::Projection {
|
||||
if let Some(handle) = self.active_viewport {
|
||||
if let Some(camera) = self.camera_for_viewport(handle) {
|
||||
return camera.projection;
|
||||
}
|
||||
}
|
||||
self.camera.borrow().projection
|
||||
}
|
||||
|
||||
/// View-rotation matrix for the active viewport (MSPACE), or the
|
||||
/// paper-space camera's matrix when not in MSPACE.
|
||||
/// Used by ViewCube hit-testing so clicks map to the correct camera.
|
||||
|
||||
+33
-13
@@ -658,20 +658,40 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
hits.into_iter().map(|(_, name)| name).collect()
|
||||
}
|
||||
|
||||
type MeshPickItem<'a> = (
|
||||
Handle,
|
||||
&'a MeshModel,
|
||||
Option<acadrust::types::Transform>,
|
||||
[f64; 6],
|
||||
);
|
||||
|
||||
pub(crate) fn mesh_click_hit<'a>(
|
||||
cursor: Point,
|
||||
meshes: impl Iterator<
|
||||
Item = (
|
||||
Handle,
|
||||
&'a MeshModel,
|
||||
Option<acadrust::types::Transform>,
|
||||
[f64; 6],
|
||||
),
|
||||
>,
|
||||
meshes: impl Iterator<Item = MeshPickItem<'a>>,
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: Rectangle,
|
||||
) -> Option<Handle> {
|
||||
mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, handle, _)| handle)
|
||||
}
|
||||
|
||||
pub(crate) fn mesh_click_point<'a>(
|
||||
cursor: Point,
|
||||
meshes: impl Iterator<Item = MeshPickItem<'a>>,
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: Rectangle,
|
||||
) -> Option<glam::DVec3> {
|
||||
mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, _, point)| point)
|
||||
}
|
||||
|
||||
fn mesh_click_result<'a>(
|
||||
cursor: Point,
|
||||
meshes: impl Iterator<Item = MeshPickItem<'a>>,
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: Rectangle,
|
||||
) -> Option<(f64, Handle, glam::DVec3)> {
|
||||
let profile = crate::perf::enabled().then(std::time::Instant::now);
|
||||
let mut set_count = 0usize;
|
||||
let mut bound_hits = 0usize;
|
||||
@@ -692,7 +712,7 @@ pub(crate) fn mesh_click_hit<'a>(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, Handle)> = None;
|
||||
let mut best: Option<(f64, Handle, glam::DVec3)> = None;
|
||||
for (handle, mesh, transform, aabb) in meshes {
|
||||
set_count += 1;
|
||||
let Some((near_t, _)) = ray_aabb(near, world_direction, aabb) else {
|
||||
@@ -700,7 +720,7 @@ pub(crate) fn mesh_click_hit<'a>(
|
||||
};
|
||||
bound_hits += 1;
|
||||
exact_triangles += mesh.indices.len() / 3;
|
||||
if best.is_some_and(|(distance, _)| near_t > distance) {
|
||||
if best.is_some_and(|(distance, _, _)| near_t > distance) {
|
||||
continue;
|
||||
}
|
||||
let model = transform.map(codec_transform_matrix);
|
||||
@@ -747,8 +767,8 @@ pub(crate) fn mesh_click_hit<'a>(
|
||||
let local_hit = origin + direction * local_t;
|
||||
let world_hit = model.map_or(local_hit, |model| model.transform_point3(local_hit));
|
||||
let distance = (world_hit - near).dot(world_direction);
|
||||
if distance >= 0.0 && best.is_none_or(|(current, _)| distance < current) {
|
||||
best = Some((distance, handle));
|
||||
if distance >= 0.0 && best.is_none_or(|(current, _, _)| distance < current) {
|
||||
best = Some((distance, handle, world_hit));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,7 +781,7 @@ pub(crate) fn mesh_click_hit<'a>(
|
||||
exact_triangles,
|
||||
);
|
||||
}
|
||||
best.map(|(_, h)| h)
|
||||
best
|
||||
}
|
||||
|
||||
fn codec_transform_matrix(transform: acadrust::types::Transform) -> glam::DMat4 {
|
||||
|
||||
+17
-41
@@ -1841,39 +1841,28 @@ fn ucs_icon_geometry(
|
||||
return None;
|
||||
}
|
||||
|
||||
// Project to NDC (including depth) then to screen pixels.
|
||||
let w2ndc = |world: Vec3| -> Option<Vec3> {
|
||||
let ndc = vp.project_point3(world);
|
||||
if !ndc.x.is_finite() || !ndc.y.is_finite() || !ndc.z.is_finite() {
|
||||
// Transform directions directly. Projecting a point at the eye origin is
|
||||
// undefined in perspective views.
|
||||
let axis_screen = |axis: Vec3| -> Option<(f32, f32, f32, f32)> {
|
||||
let clip = vp * axis.extend(0.0);
|
||||
if !clip.x.is_finite() || !clip.y.is_finite() || !clip.z.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(ndc)
|
||||
};
|
||||
let ndc2s = |ndc: Vec3| -> Point {
|
||||
Point::new(
|
||||
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
|
||||
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
|
||||
)
|
||||
let dx = clip.x * 0.5 * bounds.width;
|
||||
let dy = -clip.y * 0.5 * bounds.height;
|
||||
Some((dx, dy, (dx * dx + dy * dy).sqrt(), clip.z))
|
||||
};
|
||||
|
||||
// Project the UCS axis directions (not fixed world axes) so the tripod
|
||||
// rotates to the active UCS. Directions are translation-invariant, so a
|
||||
// common origin is fine.
|
||||
let (ax, ay, az) = axes;
|
||||
let org = w2ndc(Vec3::ZERO)?;
|
||||
let xn = w2ndc(ax)?;
|
||||
let yn = w2ndc(ay)?;
|
||||
let zn = w2ndc(az)?;
|
||||
|
||||
let org_s = ndc2s(org);
|
||||
let (xdx, xdy, xlen, xdepth) = axis_screen(ax)?;
|
||||
let (ydx, ydy, ylen, ydepth) = axis_screen(ay)?;
|
||||
let (zdx, zdy, zlen, zdepth) = axis_screen(az)?;
|
||||
let corner = Point::new(
|
||||
bounds.x + UCS_ICON_MARGIN,
|
||||
bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
|
||||
);
|
||||
// Snap the tripod to the projected UCS origin when it is on-screen
|
||||
// (UCSICON ORigin); otherwise keep it in the corner. `org_s` (the
|
||||
// rotation-only origin) stays the reference for axis-tip directions —
|
||||
// those are translation-invariant, so only the anchor point moves.
|
||||
// (UCSICON ORigin); otherwise keep it in the corner.
|
||||
let (icon_origin, at_origin) = match origin_screen {
|
||||
Some(p)
|
||||
if p.x >= bounds.x
|
||||
@@ -1886,37 +1875,24 @@ fn ucs_icon_geometry(
|
||||
_ => (corner, false),
|
||||
};
|
||||
|
||||
// Raw screen-space displacement for each axis tip.
|
||||
let raw = |ndc_tip: Vec3| -> (f32, f32, f32) {
|
||||
let s = ndc2s(ndc_tip);
|
||||
let dx = s.x - org_s.x;
|
||||
let dy = s.y - org_s.y;
|
||||
(dx, dy, (dx * dx + dy * dy).sqrt())
|
||||
};
|
||||
|
||||
let (xdx, xdy, xlen) = raw(xn);
|
||||
let (ydx, ydy, ylen) = raw(yn);
|
||||
let (zdx, zdy, zlen) = raw(zn);
|
||||
|
||||
// Scale so the longest projected axis fills UCS_ICON_LEN; shorter axes
|
||||
// stay proportionally shorter (this IS the foreshortening effect).
|
||||
let max_len = xlen.max(ylen).max(zlen).max(1e-4);
|
||||
let sc = UCS_ICON_LEN / max_len;
|
||||
|
||||
let mk = |dx: f32, dy: f32, len: f32, tip_z: f32| IconAxis {
|
||||
let mk = |dx: f32, dy: f32, len: f32, depth: f32| IconAxis {
|
||||
dx: dx * sc,
|
||||
dy: dy * sc,
|
||||
sc_len: len * sc,
|
||||
// depth > 0 → tip farther than origin (into screen); < 0 → toward viewer.
|
||||
depth: tip_z - org.z,
|
||||
depth,
|
||||
};
|
||||
Some((
|
||||
icon_origin,
|
||||
at_origin,
|
||||
[
|
||||
mk(xdx, xdy, xlen, xn.z),
|
||||
mk(ydx, ydy, ylen, yn.z),
|
||||
mk(zdx, zdy, zlen, zn.z),
|
||||
mk(xdx, xdy, xlen, xdepth),
|
||||
mk(ydx, ydy, ylen, ydepth),
|
||||
mk(zdx, zdy, zlen, zdepth),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user