diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 887d13aedc..cc3a1deb0a 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -20,6 +20,7 @@ #include "libslic3r/AppConfig.hpp" #include "3DScene.hpp" #include "BackgroundSlicingProcess.hpp" +#include "CameraUtils.hpp" #include "GLShader.hpp" #include "GUI.hpp" #include "Tab.hpp" @@ -117,6 +118,36 @@ void GLCanvas3D::load_render_colors() namespace Slic3r { namespace GUI { +static void pan_camera(Camera& camera, const Vec2d& screen_delta, const Vec3d& anchor) +{ + // Orca: Derive world-units-per-pixel from the projection which produced the visible frame. + // Perspective additionally scales with the eye-space depth of the point being dragged. + const auto& viewport = camera.get_viewport(); + const auto& projection = camera.get_projection_matrix().matrix(); + const double depth_scale = camera.get_type() == Camera::EType::Perspective ? + (anchor - camera.get_position()).dot(camera.get_dir_forward()) : 1.0; + const double projection_x = projection(0, 0) * viewport[2]; + const double projection_y = projection(1, 1) * viewport[3]; + + // Orca: X/Y projection coefficients already include zoom. Using them directly avoids a + // project/unproject round-trip through window depth, whose precision depends on the scene frustum. + if (viewport[2] > 0 && viewport[3] > 0 && anchor.allFinite() && depth_scale > EPSILON && + std::abs(projection_x) > EPSILON && std::abs(projection_y) > EPSILON) { + const Vec3d displacement = 2.0 * depth_scale * + (screen_delta.y() / projection_y * camera.get_dir_up() - + screen_delta.x() / projection_x * camera.get_dir_right()); + if (displacement.allFinite()) { + camera.translate(displacement); + return; + } + } + + // Orca: Preserve the former target-plane behavior if the projection or anchor is invalid. + // Screen Y grows downward, and the camera moves opposite to the drag. + camera.translate(camera.get_inv_zoom() * + (screen_delta.y() * camera.get_dir_up() - screen_delta.x() * camera.get_dir_right())); +} + #ifdef __WXGTK3__ // wxGTK3 seems to simulate OSX behavior in regard to HiDPI scaling support. RetinaHelper::RetinaHelper(wxWindow* window) : m_window(window), m_self(nullptr) {} @@ -2206,15 +2237,14 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) _render_background(); //BBS add partplater rendering logic - bool only_current = false, only_body = false, no_partplate = false; + bool only_current = false, only_body = false; + const bool show_bed = is_bed_visible(); bool show_grid = true; GLGizmosManager::EType gizmo_type = m_gizmos.get_current_type(); if (!m_main_toolbar.is_enabled()) { //only_body = true; only_current = true; } - else if ((gizmo_type == GLGizmosManager::FdmSupports) || (gizmo_type == GLGizmosManager::Seam) || (gizmo_type == GLGizmosManager::MmSegmentation) || (gizmo_type == GLGizmosManager::FuzzySkin)) - no_partplate = true; else if (gizmo_type == GLGizmosManager::BrimEars && !camera.is_looking_downward()) show_grid = false; if (m_axes_at_bed_center) @@ -2228,11 +2258,11 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) if (m_canvas_type == ECanvasType::CanvasView3D) { // m_show_bed gates the plate list too: hiding the bed but leaving its grid and outline // floating would read as a rendering fault rather than a deliberate view option. - if (!no_partplate && m_show_bed) + if (show_bed) _render_bed(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), m_show_world_axes); - if (!no_partplate && m_show_bed) //BBS: add outline logic + if (show_bed) //BBS: add outline logic _render_platelist(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), only_current, only_body, hover_id, true, show_grid); - if (m_axes_at_bed_center && m_show_bed && !no_partplate) + if (m_axes_at_bed_center && show_bed) // Design tab: replace the plate's corner-origin grid with the origin-centred CAD grid. _render_cad_grid(camera.get_view_matrix(), camera.get_projection_matrix()); @@ -3281,6 +3311,9 @@ void GLCanvas3D::unbind_event_handlers() m_canvas->Unbind(wxEVT_GESTURE_PAN, &GLCanvas3D::on_gesture, this); m_canvas->Unbind(wxEVT_GESTURE_ZOOM, &GLCanvas3D::on_gesture, this); m_canvas->Unbind(wxEVT_GESTURE_ROTATE, &GLCanvas3D::on_gesture, this); +#if __WXOSX__ + initGestures(m_canvas->GetHandle(), nullptr); +#endif } } @@ -4029,27 +4062,38 @@ void GLCanvas3D::on_gesture(wxGestureEvent &evt) auto & camera = wxGetApp().plater()->get_camera(); if (evt.GetEventType() == wxEVT_GESTURE_PAN) { - auto p = evt.GetPosition(); + // Orca: Gesture coordinates must use framebuffer pixels, and one stable world-space + // anchor must be retained for the complete gesture to prevent perspective drift. + const auto p = evt.GetPosition(); auto d = static_cast(evt).GetDelta(); - float z = 0; - const Vec3d &p2 = _mouse_to_3d({p.x, p.y}, &z); - const Vec3d &p1 = _mouse_to_3d({p.x - d.x, p.y - d.y}, &z); - camera.set_target(camera.get_target() + p1 - p2); + Vec2d screen_position(p.x, p.y); + Vec2d screen_delta(d.x, d.y); + apply_retina_scale(screen_position); + apply_retina_scale(screen_delta); + if (evt.IsGestureStart() || !m_gesture_pan_anchor.has_value()) + m_gesture_pan_anchor = get_camera_pan_anchor(camera, ECameraNavigationType::Gesture, + screen_position - screen_delta); + pan_camera(camera, screen_delta, *m_gesture_pan_anchor); + if (evt.IsGestureEnd()) + m_gesture_pan_anchor.reset(); } else if (evt.GetEventType() == wxEVT_GESTURE_ZOOM) { static float zoom_start = 1; if (evt.IsGestureStart()) zoom_start = camera.get_zoom(); camera.set_zoom(zoom_start * static_cast(evt).GetZoomFactor()); } else if (evt.GetEventType() == wxEVT_GESTURE_ROTATE) { - PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); + // Orca: Rotation starts a different navigation operation, so a previous pan anchor + // must not be reused; rotation and pan fallbacks share the same navigation pivot. + m_gesture_pan_anchor.reset(); bool rotate_limit = current_printer_technology() != ptSLA; static double last_rotate = 0; if (evt.IsGestureStart()) last_rotate = 0; auto rotate = static_cast(evt).GetRotationAngle() - last_rotate; last_rotate += rotate; - if (plate) - camera.rotate_on_sphere_with_target(-rotate, 0, rotate_limit, plate->get_bounding_box().center()); + const std::optional rotate_target = get_camera_orbit_target(ECameraNavigationType::Gesture); + if (rotate_target.has_value()) + camera.rotate_on_sphere_with_target(-rotate, 0, rotate_limit, *rotate_target); else camera.rotate_on_sphere(-rotate, 0, rotate_limit); camera.auto_type(Camera::EType::Perspective); @@ -4339,6 +4383,10 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) post_event(SimpleEvent(EVT_GLCANVAS_SWITCH_TO_GLOBAL)); } else if (evt.LeftDown() || evt.RightDown() || evt.MiddleDown()) { + // Orca: Retain the click position even if the first motion event crosses a surface edge. + m_mouse.set_start_position_2D_as_invalid(); + m_mouse.drag.start_position_2D = pos; + //BBS: add orient deactivate logic if (!m_gizmos.on_mouse(evt)) { if (_deactivate_arrange_menu() || _deactivate_orient_menu()) @@ -4532,6 +4580,9 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) } // do not process the dragging if the left mouse was set down in another canvas else if (is_camera_rotate(evt, button_mappings)) { + // Orca: Rotation and panning use different drag coordinates and cached anchors. + // Clear the pan state before processing rotation or switching buttons mid-drag. + m_mouse.set_start_position_2D_as_invalid(); if (!has_mouse_capture()) // ORCA keep tracking mouse position while drag active and cursor not in window bounds m_canvas->CaptureMouse(); @@ -4549,12 +4600,12 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) if (this->m_canvas_type == ECanvasType::CanvasAssembleView || m_gizmos.get_current_type() == GLGizmosManager::FdmSupports || m_gizmos.get_current_type() == GLGizmosManager::Seam || m_gizmos.get_current_type() == GLGizmosManager::MmSegmentation || m_gizmos.get_current_type() == GLGizmosManager::FuzzySkin) { - Vec3d rotate_target = Vec3d::Zero(); - if (!m_selection.is_empty()) - rotate_target = m_selection.get_bounding_box().center(); + // Orca: Reuse the centralized pivot policy for scene-oriented tools. + const std::optional rotate_target = get_camera_orbit_target(ECameraNavigationType::Mouse); + if (rotate_target.has_value()) + camera.rotate_on_sphere_with_target(rot.x(), rot.y(), false, *rotate_target); else - rotate_target = volumes_bounding_box().center(); - camera.rotate_on_sphere_with_target(rot.x(), rot.y(), false, rotate_target); + camera.rotate_on_sphere(rot.x(), rot.y(), false); } else { if (wxGetApp().app_config->get_bool("use_free_camera")) @@ -4578,28 +4629,11 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) } camera.rotate_on_sphere_with_target(rot.x(), rot.y(), rotate_limit, m_rotation_center); } else { - Vec3d rotate_target = Vec3d::Zero(); - if (m_canvas_type == ECanvasType::CanvasPreview) { - PartPlate *plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); - if (plate) - rotate_target = plate->get_bounding_box().center(); - } - else { - if (!m_selection.is_empty()) - rotate_target = m_selection.get_bounding_box().center(); - else { - // Rotate around the center of objects on current plate - auto bbox = volumes_bounding_box(true); - if (!bbox.defined) { - // Rotate around current plate center if current plate is empty - bbox = wxGetApp().plater()->get_partplate_list().get_curr_plate()->get_bounding_box(); - } - rotate_target = bbox.center(); - } - } - - if (!rotate_target.isZero()) - camera.rotate_on_sphere_with_target(rot.x(), rot.y(), rotate_limit, rotate_target); + // Orca: Keep regular mouse orbit and perspective-pan fallback centered + // on the same selection, active-plate, or scene reference. + const std::optional rotate_target = get_camera_orbit_target(ECameraNavigationType::Mouse); + if (rotate_target.has_value()) + camera.rotate_on_sphere_with_target(rot.x(), rot.y(), rotate_limit, *rotate_target); else camera.rotate_on_sphere(rot.x(), rot.y(), rotate_limit); } @@ -4615,16 +4649,14 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) m_mouse.drag.start_position_3D = Vec3d((double)pos(0), (double)pos(1), 0.0); } else if (is_camera_pan(evt, button_mappings)) { + // Orca: Pan uses screen coordinates and must not inherit the rotation start point. + m_mouse.set_start_position_3D_as_invalid(); if (!has_mouse_capture()) // ORCA keep tracking mouse position while drag active and cursor not in window bounds m_canvas->CaptureMouse(); // if dragging with right button or if button functions swapped and dragging with left button over blank area then pan if (m_mouse.is_start_position_2D_defined()) { - // get point in model space at Z = 0 - float z = 0.0f; - const Vec3d& cur_pos = _mouse_to_3d(pos, &z); - Vec3d orig = _mouse_to_3d(m_mouse.drag.start_position_2D, &z); Camera& camera = wxGetApp().plater()->get_camera(); if (this->m_canvas_type != ECanvasType::CanvasAssembleView) { // Orca: Use a constrained camera when navigating the 3D scene with a regular mouse, if the free camera is not selected @@ -4636,7 +4668,14 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) camera.recover_from_free_camera(); } - camera.set_target(camera.get_target() + orig - cur_pos); + // Orca: Cache the surface under the initial click and apply every incremental + // cursor delta at that depth, so perspective zoom and camera angle stay exact. + const Vec2d screen_delta = + pos.cast() - m_mouse.drag.start_position_2D.cast(); + if (!m_mouse.drag.camera_pan_anchor.has_value()) + m_mouse.drag.camera_pan_anchor = get_camera_pan_anchor(camera, ECameraNavigationType::Mouse, + m_mouse.drag.start_position_2D.cast()); + pan_camera(camera, screen_delta, *m_mouse.drag.camera_pan_anchor); m_dirty = true; m_mouse.ignore_right_up = true; // will be reset on button up event even if not right button is pressed } @@ -7329,11 +7368,8 @@ void GLCanvas3D::_picking_pass() m_hover_volume_idxs.clear(); m_hover_plate_idxs.clear(); - // Orca: ignore clipping plane if not applying - GLGizmoBase *current_gizmo = m_gizmos.get_current(); - const ClippingPlane clipping_plane = ((!current_gizmo || current_gizmo->apply_clipping_plane()) ? m_gizmos.get_clipping_plane() : - ClippingPlane::ClipsNothing()) - .inverted_normal(); + // Orca: Picking and camera navigation must interpret the active gizmo clipping plane identically. + const ClippingPlane clipping_plane = get_raycaster_clipping_plane(); const SceneRaycaster::HitResult hit = m_scene_raycaster.hit(m_mouse.position, wxGetApp().plater()->get_camera(), &clipping_plane); if (hit.is_valid()) { switch (hit.type) @@ -10627,6 +10663,129 @@ Vec3d GLCanvas3D::_mouse_to_bed_3d(const Point& mouse_pos) return mouse_ray(mouse_pos).intersect_plane(0.0); } +ClippingPlane GLCanvas3D::get_raycaster_clipping_plane() const +{ + // Orca: Ignore the gizmo clipping plane when the active tool does not apply it, and + // invert the result into the convention expected by SceneRaycaster. + GLGizmoBase* current_gizmo = m_gizmos.get_current(); + return ((!current_gizmo || current_gizmo->apply_clipping_plane()) ? m_gizmos.get_clipping_plane() : + ClippingPlane::ClipsNothing()) + .inverted_normal(); +} + +std::optional GLCanvas3D::get_camera_orbit_target(ECameraNavigationType navigation_type) const +{ + // Orca: Centralize the pre-existing pivot rules so orbiting and pan fallback cannot + // choose different reference depths for the same canvas and active tool. + PartPlate* current_plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); + if (navigation_type == ECameraNavigationType::Gesture) + return current_plate == nullptr ? std::nullopt : + std::make_optional(current_plate->get_bounding_box().center()); + + const GLGizmosManager::EType gizmo_type = m_gizmos.get_current_type(); + const bool use_scene_target = m_canvas_type == ECanvasType::CanvasAssembleView || + gizmo_type == GLGizmosManager::FdmSupports || gizmo_type == GLGizmosManager::Seam || + gizmo_type == GLGizmosManager::MmSegmentation || gizmo_type == GLGizmosManager::FuzzySkin; + if (use_scene_target) { + if (!m_selection.is_empty()) + return m_selection.get_bounding_box().center(); + + // Orca: Preserve the world-origin fallback used by orbit in an empty scene. + return volumes_bounding_box().center(); + } + + // Orca: Free-camera rotation uses Camera::m_target rather than a plate or selection pivot. + if (wxGetApp().app_config->get_bool("use_free_camera")) + return std::nullopt; + + Vec3d target = Vec3d::Zero(); + if (m_canvas_type == ECanvasType::CanvasPreview) { + if (current_plate != nullptr) + target = current_plate->get_bounding_box().center(); + } else if (!m_selection.is_empty()) { + target = m_selection.get_bounding_box().center(); + } else { + // Orca: Match regular mouse orbit: objects on the active plate, then the plate itself. + BoundingBoxf3 bbox = volumes_bounding_box(true); + if (!bbox.defined && current_plate != nullptr) + bbox = current_plate->get_bounding_box(); + if (bbox.defined) + target = bbox.center(); + } + + // Orca: Preserve the existing zero sentinel used by regular mouse orbit. + return target.isZero() ? std::nullopt : std::make_optional(target); +} + +bool GLCanvas3D::is_bed_visible() const +{ + if (m_canvas_type == ECanvasType::CanvasPreview) + return m_render_preview; + if (m_canvas_type != ECanvasType::CanvasView3D || !m_show_bed) + return false; + if (!m_main_toolbar.is_enabled()) + return true; + + const auto type = m_gizmos.get_current_type(); + return type != GLGizmosManager::FdmSupports && type != GLGizmosManager::Seam && + type != GLGizmosManager::MmSegmentation && type != GLGizmosManager::FuzzySkin; +} + +Vec3d GLCanvas3D::get_camera_pan_anchor(Camera& camera, ECameraNavigationType navigation_type, + const Vec2d& screen_position) const +{ + // Orthographic panning has the same scale at every depth, so no raycast is needed. + if (camera.get_type() != Camera::EType::Perspective) + return camera.get_target(); + + // Orca: Reject non-finite anchors and points behind the camera before their depth is + // allowed to scale a perspective pan. + const Vec3d camera_position = camera.get_position(); + const Vec3d camera_forward = camera.get_dir_forward(); + const auto is_valid_anchor = [&camera_position, &camera_forward](const Vec3d& anchor) { + return anchor.allFinite() && (anchor - camera_position).dot(camera_forward) > EPSILON; + }; + + // Orca: Prefer the nearest visible bed or volume surface and exclude gizmos and + // selected-volume picking priority from navigation depth selection. + const ClippingPlane clipping_plane = get_raycaster_clipping_plane(); + const bool bed_visible = is_bed_visible(); + const SceneRaycaster::HitResult hit = m_scene_raycaster.hit(screen_position, camera, &clipping_plane, + bed_visible ? SceneRaycaster::EHitMode::SceneOnly : SceneRaycaster::EHitMode::VolumesOnly); + if (hit.is_valid()) { + const Vec3d hit_position = hit.position.cast(); + if (is_valid_anchor(hit_position)) + return hit_position; + } + + // Orca: When the cursor is just outside the visible plate, use the point under it on the active + // plate plane. Using the plate center here would give it a different perspective depth. + PartPlate* current_plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); + // Orca: An almost edge-on perspective makes intersection depth extremely sensitive to + // the cursor's vertical position. Use the stable orbit depth around horizontal views. + static constexpr double min_plate_plane_forward_z = 0.05; + if (bed_visible && std::abs(camera_forward.z()) >= min_plate_plane_forward_z && + current_plate != nullptr && current_plate->get_bounding_box().defined) { + Vec3d ray_origin; + Vec3d ray_direction; + CameraUtils::ray_from_screen_pos(camera, screen_position, ray_origin, ray_direction); + const double z_direction = ray_direction.z(); + if (ray_origin.allFinite() && ray_direction.allFinite() && std::abs(z_direction) > EPSILON) { + const double plate_z = current_plate->get_bounding_box().center().z(); + const double distance = (plate_z - ray_origin.z()) / z_direction; + const Vec3d plate_position = ray_origin + distance * ray_direction; + const double eye_depth = (plate_position - camera_position).dot(camera_forward); + if (distance >= 0.0 && is_valid_anchor(plate_position) && + eye_depth >= camera.get_near_z() && eye_depth <= camera.get_far_z()) + return plate_position; + } + } + + // Orca: Near-horizontal rays and points outside the scene depth use the orbit reference point. + const std::optional orbit_target = get_camera_orbit_target(navigation_type); + return orbit_target.has_value() && is_valid_anchor(*orbit_target) ? *orbit_target : camera.get_target(); +} + // While it looks like we can call // this->reload_scene(true, true) // the two functions are quite different: diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 75820694b0..254fa9ddca 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -338,6 +338,8 @@ class GLCanvas3D int move_volume_idx{ -1 }; bool move_requires_threshold{ false }; Point move_start_threshold_position_2D{ Invalid_2D_Point }; + // Orca: Keep the world-space point selected at the start of a mouse pan. + std::optional camera_pan_anchor; }; bool dragging{ false }; @@ -346,7 +348,12 @@ class GLCanvas3D Drag drag; bool ignore_right_up; - void set_start_position_2D_as_invalid() { drag.start_position_2D = Drag::Invalid_2D_Point; } + // Orca: The screen-space start and world-space anchor describe the same pan session. + // Invalidating one must invalidate the other so a new drag cannot reuse stale depth. + void set_start_position_2D_as_invalid() { + drag.start_position_2D = Drag::Invalid_2D_Point; + drag.camera_pan_anchor.reset(); + } void set_start_position_3D_as_invalid() { drag.start_position_3D = Drag::Invalid_3D_Point; } void set_move_start_threshold_position_2D_as_invalid() { drag.move_start_threshold_position_2D = Drag::Invalid_2D_Point; } @@ -549,6 +556,8 @@ private: bool m_fps_overlay_tick{ false }; LayersEditing m_layers_editing; Mouse m_mouse; + // Orca: Gesture pans have their own lifecycle and stable world-space anchor. + std::optional m_gesture_pan_anchor; GLGizmosManager m_gizmos; //BBS: GUI refactor: GLToolbar mutable GLToolbar m_main_toolbar; @@ -1406,6 +1415,20 @@ private: // Convert the screen space coordinate to world coordinate on the bed. Vec3d _mouse_to_bed_3d(const Point& mouse_pos); + // Orca: Navigation type selects the legacy pivot policy used when no visible surface is hit. + enum class ECameraNavigationType : unsigned char + { + Mouse, + Gesture + }; + + // Orca: These helpers keep clipping, orbit pivots, and perspective-pan depth selection consistent. + ClippingPlane get_raycaster_clipping_plane() const; + bool is_bed_visible() const; + std::optional get_camera_orbit_target(ECameraNavigationType navigation_type) const; + Vec3d get_camera_pan_anchor(Camera& camera, ECameraNavigationType navigation_type, + const Vec2d& screen_position) const; + void _start_timer() { m_timer.Start(100, wxTIMER_CONTINUOUS); } void _stop_timer() { m_timer.Stop(); } diff --git a/src/slic3r/GUI/SceneRaycaster.cpp b/src/slic3r/GUI/SceneRaycaster.cpp index 64a091211b..408e910ff8 100644 --- a/src/slic3r/GUI/SceneRaycaster.cpp +++ b/src/slic3r/GUI/SceneRaycaster.cpp @@ -99,8 +99,11 @@ void SceneRaycaster::remove_raycaster(std::shared_ptr item) } } -SceneRaycaster::HitResult SceneRaycaster::hit(const Vec2d& mouse_pos, const Camera& camera, const ClippingPlane* clipping_plane) const +SceneRaycaster::HitResult SceneRaycaster::hit(const Vec2d& mouse_pos, const Camera& camera, + const ClippingPlane* clipping_plane, SceneRaycaster::EHitMode mode) const { + // Orca: Picking may favor an already selected volume for interaction, while camera + // navigation must always use the geometrically closest visible scene surface. // helper class used to return currently selected volume as hit when overlapping with other volumes // to allow the user to click and drag on a selected volume class VolumeKeeper @@ -110,7 +113,11 @@ SceneRaycaster::HitResult SceneRaycaster::hit(const Vec2d& mouse_pos, const Came bool m_selected_volume_already_found{ false }; public: - VolumeKeeper() { + explicit VolumeKeeper(bool enabled) { + // Orca: Disable selected-volume bias for navigation raycasts. + if (!enabled) + return; + const Selection& selection = wxGetApp().plater()->get_selection(); if (selection.is_single_volume() || selection.is_single_modifier()) { const GLVolume* volume = selection.get_first_volume(); @@ -135,7 +142,7 @@ SceneRaycaster::HitResult SceneRaycaster::hit(const Vec2d& mouse_pos, const Came } }; - VolumeKeeper volume_keeper; + VolumeKeeper volume_keeper(mode == EHitMode::Picking); double closest_hit_squared_distance = std::numeric_limits::max(); auto is_closest = [&closest_hit_squared_distance, &volume_keeper](const Camera& camera, const Vec3f& hit) { @@ -154,7 +161,7 @@ SceneRaycaster::HitResult SceneRaycaster::hit(const Vec2d& mouse_pos, const Came HitResult ret; - auto test_raycasters = [this, is_closest, clipping_plane, &volume_keeper](EType type, const Vec2d& mouse_pos, const Camera& camera, HitResult& ret) { + auto test_raycasters = [this, is_closest, clipping_plane, mode, &volume_keeper](EType type, const Vec2d& mouse_pos, const Camera& camera, HitResult& ret) { const ClippingPlane* clip_plane = (clipping_plane != nullptr && type == EType::Volume) ? clipping_plane : nullptr; const std::vector>* raycasters = get_raycasters(type); const Vec3f camera_forward = camera.get_dir_forward().cast(); @@ -163,12 +170,22 @@ SceneRaycaster::HitResult SceneRaycaster::hit(const Vec2d& mouse_pos, const Came if (!item->is_active()) continue; + // Each plate's component 0 is its surface; the remaining Bed IDs are controls. + // Keep controls clickable, but do not use them as camera-pan anchors. + if (mode != EHitMode::Picking && type == EType::Bed && + decode_id(type, item->get_id()) % PartPlate::GRABBER_COUNT != 0) + continue; + current_hit.raycaster_id = item->get_id(); const Transform3d& trafo = item->get_transform(); if (item->get_raycaster()->closest_hit(mouse_pos, trafo, camera, current_hit.position, current_hit.normal, clip_plane)) { current_hit.position = (trafo * current_hit.position.cast()).cast(); current_hit.normal = (trafo.matrix().block(0, 0, 3, 3).inverse().transpose() * current_hit.normal.cast()).normalized().cast(); - if (item->use_back_faces() || current_hit.normal.dot(camera_forward) < 0.0f) { + // Orca: Perspective rays away from the viewport center are not parallel to camera_forward. + // Keep picking's legacy policy, but accept every front-facing navigation surface. + const Vec3f view_direction = mode != EHitMode::Picking && camera.get_type() == Camera::EType::Perspective ? + Vec3f((current_hit.position.cast() - camera.get_position()).cast()) : camera_forward; + if (item->use_back_faces() || current_hit.normal.dot(view_direction) < 0.0f) { if (is_closest(camera, current_hit.position)) { if (volume_keeper.is_active()) { if (volume_keeper.check_hit_result(current_hit)) @@ -182,14 +199,18 @@ SceneRaycaster::HitResult SceneRaycaster::hit(const Vec2d& mouse_pos, const Came } }; - if (!m_gizmos.empty()) - test_raycasters(EType::Gizmo, mouse_pos, camera, ret); + // Orca: Gizmo geometry is an interaction target, not a valid depth anchor for camera movement. + if (mode == EHitMode::Picking) { + if (!m_gizmos.empty()) + test_raycasters(EType::Gizmo, mouse_pos, camera, ret); - if (!m_fallback_gizmos.empty() && !ret.is_valid()) - test_raycasters(EType::FallbackGizmo, mouse_pos, camera, ret); + if (!m_fallback_gizmos.empty() && !ret.is_valid()) + test_raycasters(EType::FallbackGizmo, mouse_pos, camera, ret); + } if (!m_gizmos_on_top || !ret.is_valid()) { - if (camera.is_looking_downward() && !m_bed.empty()) + // Orca: In perspective the bottom of the viewport can see the bed even at a horizontal view. + if ((mode == EHitMode::SceneOnly || (mode == EHitMode::Picking && camera.is_looking_downward())) && !m_bed.empty()) test_raycasters(EType::Bed, mouse_pos, camera, ret); if (!m_volumes.empty()) test_raycasters(EType::Volume, mouse_pos, camera, ret); diff --git a/src/slic3r/GUI/SceneRaycaster.hpp b/src/slic3r/GUI/SceneRaycaster.hpp index 99152712dd..9aa527abee 100644 --- a/src/slic3r/GUI/SceneRaycaster.hpp +++ b/src/slic3r/GUI/SceneRaycaster.hpp @@ -57,6 +57,15 @@ public: FallbackGizmo = 2000000 }; + // Orca: Navigation needs the closest visible scene surface, without picking-specific + // gizmo and selected-volume priority. + enum class EHitMode : unsigned char + { + Picking, + SceneOnly, + VolumesOnly // Navigation when the bed is hidden. + }; + struct HitResult { EType type{ EType::None }; @@ -97,7 +106,8 @@ public: void set_gizmos_on_top(bool value) { m_gizmos_on_top = value; } - HitResult hit(const Vec2d& mouse_pos, const Camera& camera, const ClippingPlane* clipping_plane = nullptr) const; + HitResult hit(const Vec2d& mouse_pos, const Camera& camera, const ClippingPlane* clipping_plane = nullptr, + EHitMode mode = EHitMode::Picking) const; #if ENABLE_RAYCAST_PICKING_DEBUG void render_hit(const Camera& camera); diff --git a/src/slic3r/Utils/MacDarkMode.mm b/src/slic3r/Utils/MacDarkMode.mm index 2bce7835e8..5a0020cbfa 100644 --- a/src/slic3r/Utils/MacDarkMode.mm +++ b/src/slic3r/Utils/MacDarkMode.mm @@ -1,7 +1,9 @@ #import "MacDarkMode.hpp" #include "../GUI/Widgets/Label.hpp" +#include "wx/graphics.h" #include "wx/osx/core/cfstring.h" +#include "wx/osx/private.h" #import @@ -334,11 +336,28 @@ bool addObserver = false; } @end +// Orca: A Shift-trackpad pan belongs to one GL view; sharing its lifecycle across views +// could make a gesture reuse another canvas's world-space anchor. +static char scroll_pan_active_key; +static char gesture_handler_key; + +static wxEvtHandler* get_gesture_handler(NSView* view) +{ + return static_cast([objc_getAssociatedObject(view, &gesture_handler_key) pointerValue]); +} + +static bool is_scroll_pan_active(NSView* view) +{ + return [objc_getAssociatedObject(view, &scroll_pan_active_key) boolValue]; +} + +static void set_scroll_pan_active(NSView* view, bool active) +{ + objc_setAssociatedObject(view, &scroll_pan_active_key, active ? @YES : nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} @implementation wxNSCustomOpenGLView (Gesture) -wxEvtHandler * _gestureHandler = nullptr; - - (void) onGestureMove: (NSPanGestureRecognizer*) gesture { wxPanGestureEvent evt; @@ -364,22 +383,43 @@ wxEvtHandler * _gestureHandler = nullptr; - (void) postEvent: (wxGestureEvent &) evt withGesture: (NSGestureRecognizer* ) gesture { NSPoint pos = [gesture locationInView: self]; - evt.SetPosition({(int) pos.x, (int) pos.y}); + evt.SetPosition(wxFromNSPoint(self, pos)); if (gesture.state == NSGestureRecognizerStateBegan) evt.SetGestureStart(); else if (gesture.state == NSGestureRecognizerStateEnded) evt.SetGestureEnd(); - _gestureHandler->ProcessEvent(evt); + if (wxEvtHandler* handler = get_gesture_handler(self)) + handler->ProcessEvent(evt); } - (void) scrollWheel2:(NSEvent *)event { bool shiftDown = [event modifierFlags] & NSShiftKeyMask; - if (_gestureHandler && shiftDown && event.hasPreciseScrollingDeltas) { + wxEvtHandler* handler = get_gesture_handler(self); + if (handler && shiftDown && event.hasPreciseScrollingDeltas) { wxPanGestureEvent evt; - evt.SetDelta({-(int)[event scrollingDeltaX], - (int)[event scrollingDeltaY]}); - _gestureHandler->ProcessEvent(evt); + // NSOpenGLView uses bottom-left coordinates; wx gestures use top-left coordinates. + const wxPoint pos = wxFromNSPoint(self, [self convertPoint:[event locationInWindow] fromView:nil]); + const wxPoint delta(-(int)[event scrollingDeltaX], -(int)[event scrollingDeltaY]); + // Orca: GLCanvas3D derives the anchor position as position - delta, so synthesize + // the post-delta position from the native cursor coordinate. + evt.SetPosition(pos + delta); + evt.SetDelta(delta); + // Orca: Preserve the anchor throughout a trackpad scroll, including its momentum events. + // Keep it after phase Ended: momentum may follow. The next Began replaces it. + const NSEventPhase phase = event.phase; + const NSEventPhase momentum_phase = event.momentumPhase; + const bool unphased = phase == NSEventPhaseNone && momentum_phase == NSEventPhaseNone; + if (!is_scroll_pan_active(self) || unphased || (phase & (NSEventPhaseMayBegin | NSEventPhaseBegan))) + evt.SetGestureStart(); + if (unphased || (phase & NSEventPhaseCancelled) || + (momentum_phase & (NSEventPhaseEnded | NSEventPhaseCancelled))) + evt.SetGestureEnd(); + set_scroll_pan_active(self, !evt.IsGestureEnd()); + handler->ProcessEvent(evt); } else { + // Orca: Switching away from Shift-pan must not reuse its depth when Shift is pressed again. + set_scroll_pan_active(self, false); [self scrollWheel2: event]; } } @@ -401,7 +441,9 @@ wxEvtHandler * _gestureHandler = nullptr; // [self addGestureRecognizer:pan]; // [self addGestureRecognizer:magnification]; // [self addGestureRecognizer:rotation]; - _gestureHandler = handler; + objc_setAssociatedObject(self, &gesture_handler_key, handler ? [NSValue valueWithPointer:handler] : nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + set_scroll_pan_active(self, false); } @end diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 57a0d4bfb8..08f1a6dd8f 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests test_creality_cfs_match.cpp test_dev_mapping.cpp test_filament_bitmap_utils.cpp + test_scene_raycaster.cpp test_lazy.cpp test_prebuild_queue.cpp test_staged_build.cpp diff --git a/tests/slic3rutils/test_scene_raycaster.cpp b/tests/slic3rutils/test_scene_raycaster.cpp new file mode 100644 index 0000000000..d51671e5c5 --- /dev/null +++ b/tests/slic3rutils/test_scene_raycaster.cpp @@ -0,0 +1,178 @@ +// Orca: This suite links libslic3r_gui; navigation raycasts need no wx application or GL context. +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include + // Match the GUI precompiled header: wx/msw/wrapcctl.h needs HDITEM from CommCtrl.h. + #include +#endif + +#include + +#include "slic3r/GUI/Camera.hpp" +#include "slic3r/GUI/CameraUtils.hpp" +#include "slic3r/GUI/PartPlate.hpp" +#include "slic3r/GUI/SceneRaycaster.hpp" + +using namespace Slic3r; +using namespace Slic3r::GUI; + +namespace { + +Camera horizontal_camera(Camera::EType type = Camera::EType::Perspective) +{ + Camera camera; + camera.set_type(type); + camera.look_at({0.0, 0.0, 10.0}, {0.0, 100.0, 10.0}, Vec3d::UnitZ()); + camera.set_viewport(0, 0, 600, 600); + camera.apply_projection(-1.0, 1.0, -1.0, 1.0, 1.0, 1000.0); + return camera; +} + +SceneRaycaster::HitResult scene_hit(const SceneRaycaster& scene, const Camera& camera, const Vec3d& point, + SceneRaycaster::EHitMode mode = SceneRaycaster::EHitMode::SceneOnly) +{ + return scene.hit(CameraUtils::project(camera, point).cast(), camera, nullptr, mode); +} + +} // namespace + +TEST_CASE("Navigation hits the visible bed below a horizontal perspective view", "[SceneRaycaster][Regression]") +{ + const MeshRaycaster bed(TriangleMesh( + {{-100.f, 20.f, 0.f}, {100.f, 20.f, 0.f}, {100.f, 200.f, 0.f}, {-100.f, 200.f, 0.f}}, + {{0, 1, 2}, {0, 2, 3}})); + SceneRaycaster scene; + scene.add_raycaster(SceneRaycaster::EType::Bed, 0, bed, Transform3d::Identity()); + const Camera camera = horizontal_camera(); + + const auto hit = scene_hit(scene, camera, {0.0, 100.0, 0.0}); + REQUIRE(hit.is_valid()); + CHECK(hit.type == SceneRaycaster::EType::Bed); + CHECK_THAT(hit.position.z(), Catch::Matchers::WithinAbs(0.0, 1e-4)); +} + +TEST_CASE("Navigation hits a visible side face away from the perspective view center", "[SceneRaycaster][Regression]") +{ + const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly); + const MeshRaycaster side(TriangleMesh( + {{10.f, 20.f, -100.f}, {10.f, 20.f, 100.f}, {10.f, 200.f, 100.f}, {10.f, 200.f, -100.f}}, + {{0, 1, 2}, {0, 2, 3}})); + SceneRaycaster scene; + scene.add_raycaster(SceneRaycaster::EType::Volume, 0, side, Transform3d::Identity()); + const Camera camera = horizontal_camera(); + + const auto hit = scene_hit(scene, camera, {10.0, 100.0, 10.0}, mode); + REQUIRE(hit.is_valid()); + CHECK(hit.type == SceneRaycaster::EType::Volume); + CHECK_THAT(hit.position.x(), Catch::Matchers::WithinAbs(10.0, 1e-4)); +} + +TEST_CASE("Navigation ignores gizmos and inactive volumes and chooses the nearest scene surface", "[SceneRaycaster]") +{ + const bool gizmos_on_top = GENERATE(false, true); + const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly); + const auto type = GENERATE(Camera::EType::Perspective, Camera::EType::Ortho); + const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0)); + SceneRaycaster scene; + scene.set_gizmos_on_top(gizmos_on_top); + scene.add_raycaster(SceneRaycaster::EType::Gizmo, 0, cube, Geometry::translation_transform({-10.0, 20.0, 0.0})); + scene.add_raycaster(SceneRaycaster::EType::FallbackGizmo, 0, cube, Geometry::translation_transform({-10.0, 30.0, 0.0})); + scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 40.0, 0.0}))->set_active(false); + scene.add_raycaster(SceneRaycaster::EType::Volume, 1, cube, Geometry::translation_transform({-10.0, 150.0, 0.0})); + scene.add_raycaster(SceneRaycaster::EType::Volume, 2, cube, Geometry::translation_transform({-10.0, 100.0, 0.0})); + const Camera camera = horizontal_camera(type); + + const auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode); + REQUIRE(hit.is_valid()); + CHECK(hit.type == SceneRaycaster::EType::Volume); + CHECK(hit.raycaster_id == 2); + CHECK_THAT(hit.position.y(), Catch::Matchers::WithinAbs(100.0, 1e-4)); +} + +TEST_CASE("Navigation skips bed raycasters when the bed is hidden", "[SceneRaycaster][Regression]") +{ + const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly); + const auto type = GENERATE(Camera::EType::Perspective, Camera::EType::Ortho); + const bool looking_downward = GENERATE(false, true); + const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0)); + SceneRaycaster scene; + scene.add_raycaster(SceneRaycaster::EType::Bed, 0, cube, Geometry::translation_transform({-10.0, 40.0, 0.0})); + scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 100.0, 0.0})); + Camera camera = horizontal_camera(type); + if (looking_downward) + camera.look_at({0.0, 0.0, 10.0}, {0.0, 100.0, 0.0}, Vec3d::UnitZ()); + + const auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode); + REQUIRE(hit.is_valid()); + CHECK(hit.type == (mode == SceneRaycaster::EHitMode::SceneOnly ? + SceneRaycaster::EType::Bed : SceneRaycaster::EType::Volume)); + + scene.remove_raycasters(SceneRaycaster::EType::Volume); + CHECK(scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode).is_valid() == + (mode == SceneRaycaster::EHitMode::SceneOnly)); +} + +TEST_CASE("Navigation ignores plate controls while retaining plate surfaces and volumes", "[SceneRaycaster][Regression]") +{ + const int plate_index = GENERATE(0, 2); + const int component = GENERATE(range(1, int(PartPlate::GRABBER_COUNT))); + const int bed_id = plate_index * PartPlate::GRABBER_COUNT; + const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0)); + SceneRaycaster scene; + scene.add_raycaster(SceneRaycaster::EType::Bed, bed_id + component, cube, + Geometry::translation_transform({-10.0, 40.0, 0.0})); + scene.add_raycaster(SceneRaycaster::EType::Bed, bed_id, cube, + Geometry::translation_transform({-10.0, 100.0, 0.0})); + scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, + Geometry::translation_transform({-10.0, 150.0, 0.0})); + const Camera camera = horizontal_camera(); + + auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}); + REQUIRE(hit.is_valid()); + CHECK(hit.type == SceneRaycaster::EType::Bed); + CHECK(hit.raycaster_id == bed_id); + + scene.remove_raycasters(SceneRaycaster::EType::Bed, bed_id); + hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}); + REQUIRE(hit.is_valid()); + CHECK(hit.type == SceneRaycaster::EType::Volume); + + // A control alone must leave navigation free to choose its fallback anchor. + scene.remove_raycasters(SceneRaycaster::EType::Volume); + CHECK_FALSE(scene_hit(scene, camera, {0.0, 100.0, 10.0}).is_valid()); +} + +TEST_CASE("Navigation respects the back-face policy away from the perspective view center", "[SceneRaycaster]") +{ + const bool use_back_faces = GENERATE(false, true); + const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly); + const MeshRaycaster side(TriangleMesh( + {{10.f, 20.f, -100.f}, {10.f, 20.f, 100.f}, {10.f, 200.f, 100.f}, {10.f, 200.f, -100.f}}, + {{0, 2, 1}, {0, 3, 2}})); + SceneRaycaster scene; + scene.add_raycaster(SceneRaycaster::EType::Volume, 0, side, Transform3d::Identity(), use_back_faces); + + const auto hit = scene_hit(scene, horizontal_camera(), {10.0, 100.0, 10.0}, mode); + CHECK(hit.is_valid() == use_back_faces); +} + +TEST_CASE("Navigation ignores volume surfaces removed by the clipping plane", "[SceneRaycaster]") +{ + const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly); + const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0)); + SceneRaycaster scene; + scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 100.0, 0.0})); + scene.add_raycaster(SceneRaycaster::EType::Volume, 1, cube, Geometry::translation_transform({-10.0, 150.0, 0.0})); + const Camera camera = horizontal_camera(); + const ClippingPlane clipping_plane(-Vec3d::UnitY(), -130.0); + + const auto hit = scene.hit({300.0, 300.0}, camera, &clipping_plane, mode); + REQUIRE(hit.is_valid()); + CHECK(hit.raycaster_id == 1); + CHECK_THAT(hit.position.y(), Catch::Matchers::WithinAbs(150.0, 1e-4)); +}