From bb403b82cfd96fb3600b07d9a4353b65d29bf5bd Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Fri, 31 Jul 2026 18:11:59 +0200 Subject: [PATCH] Design: left-drag sweeps a rubber band, and it takes the whole body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pick cycle died two commits ago and left no viewport route to a whole body at all: one click resolves vertex, edge or face, double-click is already zoom-to-fit, and the only way to take a body was the Bodies list — a geometry-first violation for as long as it stood. The rubber band is that route. Left-drag is the gesture, as asked. That button was orbit, so this canvas now maps the mouse the way every CAD the user already knows does: left selects, middle orbits, right pans. The change is a single flag on GLCanvas3D set only by DesignCanvas, so Prepare and Preview keep the mouse their users learned. Sketch mode inherits the same mapping, which is the consistent reading — Design is one modality, not two. Past an 8 px budget a press becomes a sweep, anchored at the ORIGINAL press point rather than at the frame where the threshold was crossed, so the first few pixels are not lost. Below the budget it is still a click and the existing vertex/edge/face pick runs untouched. Sampling is the display mesh's triangle vertices plus centroids — the same points the ray pick tests, already in world coordinates — and the body with the most samples inside wins, because the selection callback downstream carries one body. Crossing semantics: touching selects. Enclosed-only for left-to-right and crossing for right-to-left is the fuller CAD convention and is deferred, not forgotten; with one selectable body it would have bought nothing. Two defects fixed on the way, both found by exercising this: Right-drag pans, and every pan ended by popping the offer over wherever the camera stopped — the context menu arriving as the reward for moving the view. The offer is now the release of a STATIONARY right-click, at the same 8 px budget the pick uses. The selection handler wrote m_status twice. Only the later write ever reached the screen, so the earlier block had been dead since it was written, and its labels drifted out of step with the live ones unnoticed — including a vertex fix I made this morning in the branch that never renders. Deleted, with a note saying why, rather than left as two writers for the next person to pick the wrong one. Verified on :11 against a fresh build: click takes face 5; left-drag across the body reports "selected (whole body)" with the whole solid tinted and the camera unmoved; left-drag over empty space clears; stationary right-click opens the offer; right-drag pans with no menu; middle-drag orbits. Precedence re-checked after the deletion — face at 25 px from the corner, vertex from 10 px in. Refs snaporca-9xw. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyRwbuq6fjn3VV9U9UvhBM --- src/slic3r/GUI/DesignCanvas.cpp | 20 ++++++- src/slic3r/GUI/DesignCanvas.hpp | 1 + src/slic3r/GUI/DesignPanel.cpp | 17 ++---- src/slic3r/GUI/DesignSketchTool.cpp | 87 ++++++++++++++++++++++++++++- src/slic3r/GUI/DesignSketchTool.hpp | 8 ++- src/slic3r/GUI/GLCanvas3D.cpp | 4 ++ src/slic3r/GUI/GLCanvas3D.hpp | 5 ++ 7 files changed, 124 insertions(+), 18 deletions(-) diff --git a/src/slic3r/GUI/DesignCanvas.cpp b/src/slic3r/GUI/DesignCanvas.cpp index f0e58d12cf..68683896f6 100644 --- a/src/slic3r/GUI/DesignCanvas.cpp +++ b/src/slic3r/GUI/DesignCanvas.cpp @@ -12,6 +12,8 @@ #include "MeshUtils.hpp" // ClippingPlane (section view) #include "libslic3r/Config.hpp" #include +#include +#include #include #include @@ -43,6 +45,12 @@ DesignCanvas::DesignCanvas(wxWindow* parent) m_canvas->set_process(wxGetApp().plater()->get_background_process()); m_canvas->set_type(GLCanvas3D::ECanvasType::CanvasView3D); + // CAD navigation, this canvas only: left-drag sweeps a selection rubber band, so orbit + // moves to middle-drag and pan to right-drag. Design is a different modality from + // Prepare/Preview and every CAD the user already knows maps the mouse this way; the other + // tabs are untouched. + m_canvas->set_cad_navigation(true); + m_canvas->enable_picking(false); // viewport face/edge picking is custom (TODO) m_canvas->enable_moving(false); m_canvas->enable_gizmos(false); @@ -798,8 +806,18 @@ void DesignCanvas::set_on_context_menu(std::function cb) // Bound AFTER GLCanvas3D's own handlers, so this runs first and can consume the event. // It only consumes when it actually opens the offer; every other right-click still falls // through to the polyline-chain end and the move gizmo, which were there first. + // Right-drag pans. Without remembering where the press landed, every pan ended by popping + // the offer over wherever the camera stopped — the menu appearing as the reward for moving + // the view. The offer is the release of a STATIONARY right-click, at the same 8 px budget + // the left-click pick uses. + m_canvas_widget->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { + m_ctx_press = e.GetPosition(); + e.Skip(); // the canvas still needs the press to seed the pan + }); m_canvas_widget->Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& e) { - if (m_on_context_menu && !is_sketching() && !inline_busy()) { + const wxPoint d = e.GetPosition() - m_ctx_press; + if (m_on_context_menu && !is_sketching() && !inline_busy() + && std::max(std::abs(d.x), std::abs(d.y)) <= 8) { m_on_context_menu(m_canvas_widget->ClientToScreen(e.GetPosition())); return; // consumed } diff --git a/src/slic3r/GUI/DesignCanvas.hpp b/src/slic3r/GUI/DesignCanvas.hpp index c3250bc577..0762678343 100644 --- a/src/slic3r/GUI/DesignCanvas.hpp +++ b/src/slic3r/GUI/DesignCanvas.hpp @@ -269,6 +269,7 @@ private: std::function m_on_context_menu; bool m_ctx_bound{false}; // bind the RIGHT_UP handler once, however often the cb is set + wxPoint m_ctx_press{0, 0}; // right-press origin: a right-DRAG pans, it must not offer Bed3D m_bed; Model m_model; diff --git a/src/slic3r/GUI/DesignPanel.cpp b/src/slic3r/GUI/DesignPanel.cpp index ba5d516150..f142059ee1 100644 --- a/src/slic3r/GUI/DesignPanel.cpp +++ b/src/slic3r/GUI/DesignPanel.cpp @@ -3133,19 +3133,10 @@ DesignPanel::DesignPanel(wxWindow* parent) // Say what got picked. Without this the ONLY feedback is the viewport highlight, so a // pick that registers but draws faintly is indistinguishable from one that never // happened — which is precisely how this failure was reported and why it resisted - // diagnosis. Cards that show their own labels still do. - if (m_status != nullptr) { - m_status->SetForegroundColour(wxNullColour); - if (level <= 0) - m_status->SetLabel(_L("Selection cleared")); - else if (level == 1) - m_status->SetLabel(wxString::Format(_L("Body %d selected"), body + 1)); - else if (level == 2) - m_status->SetLabel(wxString::Format(_L("Body %d, face %d"), body + 1, face)); - else - m_status->SetLabel(wxString::Format(_L("Body %d, edge %d selected"), body + 1, edge)); - m_status->Refresh(); - } + // diagnosis. Cards that show their own labels still do. The label itself is written + // ONCE, at the end of this handler — a second writer here only ever produced text that + // the later one overwrote before a frame was drawn, and reading it as the live string + // is how a vertex pick came to be "fixed" in a branch that never reaches the screen. // If the Fillet/Chamfer card is open, re-anchor (or drop) the radius arrow on the new pick // and rebuild the ghost — once an edge is picked the preview-only mode hides the base body. if (m_active == Tool::Dressup) { sync_dressup_target(); update_fillet_gizmo(); refresh_preview(); } diff --git a/src/slic3r/GUI/DesignSketchTool.cpp b/src/slic3r/GUI/DesignSketchTool.cpp index 4d05e6a263..bb214f6151 100644 --- a/src/slic3r/GUI/DesignSketchTool.cpp +++ b/src/slic3r/GUI/DesignSketchTool.cpp @@ -2785,8 +2785,59 @@ static void dp_pick_trace(const char* fmt, ...) std::fflush(stderr); } -// LeftDown on the solid cycles whole->face->edge. Returns true if the click hit the solid -// (consumed); false otherwise so the caller can try committed-sketch loop picking. +// Resolve a swept rubber band into a whole-body selection. +// +// Sample points are the display mesh's triangle vertices plus each triangle's centroid. That +// mesh is already in world coordinates — the very points the ray pick tests — so no per-body +// transform is needed here. A body counts as swept when any of its samples lands inside the +// rectangle (crossing semantics: touching selects, which is the forgiving reading of a sweep), +// and the body with the most samples inside wins because the selection callback downstream +// carries exactly one body. +// +// ponytail: crossing over a triangle sample set. A rectangle small enough to sit entirely +// inside one flat triangle selects nothing — drag a bigger one, or click. Real multi-body +// selection (and the homogeneous-set rule that goes with it) is snaporca-9xw. +void DesignSketchTool::pick_bodies_in_rectangle() +{ + if (m_solid_mesh == nullptr || m_solid_tri_body == nullptr || m_solid_bodies == nullptr) + return; + const indexed_triangle_set& its = m_solid_mesh->its; + std::vector pts; + std::vector owner; + pts.reserve(its.indices.size() * 4); + owner.reserve(its.indices.size() * 4); + for (size_t i = 0; i < its.indices.size(); ++i) { + const int b = (i < m_solid_tri_body->size()) ? (*m_solid_tri_body)[i] : -1; + if (!body_pickable(b)) continue; // hidden bodies aren't swept either + const auto& idx = its.indices[i]; + Vec3d c = Vec3d::Zero(); + for (int k = 0; k < 3; ++k) { + const Vec3d p = its.vertices[idx(k)].cast(); + pts.push_back(p); owner.push_back(b); c += p; + } + pts.push_back(c / 3.0); owner.push_back(b); + } + + std::vector hits(m_solid_bodies->size(), 0); + if (!pts.empty()) + for (unsigned int i : m_rubber.contains(pts)) + if (i < owner.size() && owner[i] >= 0 && owner[i] < int(hits.size())) + ++hits[owner[i]]; + + int best = -1, best_n = 0; + for (int b = 0; b < int(hits.size()); ++b) + if (hits[b] > best_n) { best_n = hits[b]; best = b; } + + if (best < 0) clear_solid_selection(); // swept empty space -> drop the selection + else select_body(best); + dp_pick_trace("rubber band -> body=%d (%d samples)", best, best_n); + if (on_solid_selection_changed) + on_solid_selection_changed(int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge); +} + +// A click on the solid takes the smallest thing under the cursor (vertex/edge/face). Returns +// true if the click hit the solid (consumed); false otherwise so the caller can try +// committed-sketch loop picking. Whole bodies are taken by the rubber band, not by clicking. bool DesignSketchTool::handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent& evt) { if (m_solid_bodies == nullptr || m_solid_mesh == nullptr) { @@ -2822,7 +2873,8 @@ bool DesignSketchTool::handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent // ---- one click, one deterministic result --------------------------------------------- // NO CYCLE. A click selects the SMALLEST thing under the cursor: the edge if the pointer is - // within tolerance of one, otherwise the face. A DOUBLE-click takes the whole body. + // within tolerance of one, otherwise the face. The WHOLE body is taken by a left-drag + // rubber band (pick_bodies_in_rectangle) — a different gesture for a different scale. // // What this replaces: click 1 = whole body, click 2 = face, click 3 = nearest edge, click 4 // = back to whole. That made "click a face" a two-click gesture and "click an edge" a @@ -6737,6 +6789,9 @@ void DesignSketchTool::render(GLCanvas3D& canvas) return; } render_view_helpers(); // origin planes / world axes — drawn whenever their toggle is on + m_rubber.render(canvas); // left-drag rubber band (no-op unless one is being swept). Drawn + // here, ahead of every early return below, so a band over an empty + // plate is still visible. if (m_active && m_mode != Mode::Constrain && m_entities.empty() && m_points.empty() && m_display_sketches.empty()) { if (on_readout) on_readout(std::string()); @@ -7769,6 +7824,32 @@ bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas) return true; } } + // Left-drag rubber band -> whole body. Past the click budget the press becomes a sweep: + // the rectangle is anchored at the ORIGINAL press point (not at the frame where the + // threshold was crossed, which would lose the first few pixels) and the events are + // consumed from here on. Left-drag no longer orbits in this canvas — DesignCanvas puts + // orbit on middle-drag and pan on right-drag, the CAD convention — so nothing downstream + // is being starved of a gesture it used to own. + if (evt.Dragging() && evt.LeftIsDown() && m_pick_pending) { + if (!m_rubber.is_dragging()) { + if (std::max(std::abs(evt.GetX() - m_pick_press_x), + std::abs(evt.GetY() - m_pick_press_y)) <= 8) + return false; // still inside the click budget + m_rubber.start_dragging(Vec2d(m_pick_press_x, m_pick_press_y), + GLSelectionRectangle::Select); + } + m_rubber.dragging(Vec2d(evt.GetX(), evt.GetY())); + return true; + } + // Any event with the left button up ends a band — not just LeftUp. A release that lands + // outside the canvas never sends us one, and a band left running would then paint a + // rectangle that follows the cursor with no button held. + if (m_rubber.is_dragging() && !evt.LeftIsDown()) { + m_pick_pending = false; + if (evt.LeftUp()) pick_bodies_in_rectangle(); // a release elsewhere selects nothing + m_rubber.stop_dragging(); + return evt.LeftUp(); + } // Click vs drag. Consuming the LeftDown here killed camera orbit/pan the moment a // solid was on screen: Orca starts a rotate drag on the press, so swallowing it meant // the canvas never began one. (A SpaceMouse kept working — it never goes through diff --git a/src/slic3r/GUI/DesignSketchTool.hpp b/src/slic3r/GUI/DesignSketchTool.hpp index 0ec808abb2..d4c7fde6d9 100644 --- a/src/slic3r/GUI/DesignSketchTool.hpp +++ b/src/slic3r/GUI/DesignSketchTool.hpp @@ -7,6 +7,7 @@ #include "libslic3r/SketchInference.hpp" #include "libslic3r/SketchSolver.hpp" #include "GLModel.hpp" +#include "GLSelectionRectangle.hpp" // left-drag rubber band over the committed bodies #include #include #include @@ -906,7 +907,12 @@ private: int m_sel_edge{-1}; std::vector m_sel_edge_pts; Vec3d m_sel_vertex_pt{Vec3d::Zero()}; // world point of a picked vertex - bool handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent& evt); // cycle + notify + bool handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent& evt); // pick + notify + // Left-drag rubber band: sweep a rectangle over the plate to take a whole body. Orbit + // moves to middle-drag in this canvas (DesignCanvas::set_cad_navigation) so the left + // button is free for it, which is the CAD convention (Onshape/SolidWorks). + GLSelectionRectangle m_rubber; + void pick_bodies_in_rectangle(); // resolve the swept rectangle -> whole-body selection void render_solid_highlight(); void render_datum_planes(); // translucent rectangles for datum/reference planes void render_view_helpers(); // world origin planes + axis triad (P / A toggles) diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index bbff72cf38..bd32467e50 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -4981,6 +4981,8 @@ bool GLCanvas3D::is_camera_rotate(const wxMouseEvent& evt, const std::map m_old_size{ 0, 0 }; bool m_is_touchpad_navigation{ false }; + // CAD navigation (Design tab only): left-drag is a selection rubber band, so orbit moves + // to middle-drag and pan to right-drag — the Onshape/SolidWorks mapping. Off everywhere + // else, so Prepare/Preview keep the mouse the user already learned. + bool m_cad_navigation{ false }; // Screen is only refreshed from the OnIdle handler if it is dirty. bool m_dirty; @@ -1077,6 +1081,7 @@ public: bool clicked_button_matches_action(const wxMouseEvent& evt, MouseAction action, const std::map& mappings) const; bool is_camera_rotate(const wxMouseEvent& evt, const std::map& mappings) const; bool is_camera_pan(const wxMouseEvent& evt, const std::map& mappings) const; + void set_cad_navigation(bool b) { m_cad_navigation = b; } Size get_canvas_size() const; Vec2d get_local_mouse_position() const;