From b67269ccc441ae5d7bf37a2fd43e1e4cc19a520e Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 5 Oct 2026 19:38:46 +0800 Subject: [PATCH 01/30] Fix z-fighting between feature previews and the bodies in the Design tab The bodies now always draw over the preview on every face a feature leaves unchanged, for all features. Since a hole's cut sits inside the body, the Hole preview now hides the bodies and shows the result alone, as Fillet/Chamfer and Draft do. --- docs/HLSD/design-tab.md | 9 +++++++++ src/slic3r/GUI/3DScene.cpp | 7 +++++++ src/slic3r/GUI/3DScene.hpp | 4 ++++ src/slic3r/GUI/CAD/DesignCanvas.cpp | 10 +++++++--- src/slic3r/GUI/CAD/DesignPanel.cpp | 8 +++++--- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index c4bb9fd297..996447f764 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -189,6 +189,15 @@ degenerate edges are left out (`GeometryEngine::display_edges`), and the polylin once per shape, keyed by its `TShape`, because a recompute that leaves a body unchanged is the common case. +While a feature card is open, its preview ghost is the whole model the candidate would produce, +drawn translucent over the bodies, so every face the feature leaves alone is in both at the same +depth. The ghost is drawn with a depth bias that pushes it back (`GLVolume::depth_bias`), so on a shared face +the body always wins instead of the two copies z-fighting, and the ghost shows only where the +result reaches past the bodies. Material a feature removes lies inside the old solid and would not +show at all, so the tools whose result mostly coincides with the body — Fillet/Chamfer, Draft, +Hole and the Mate hover — hide the bodies once the preview is valid and draw the result alone, +opaque. + ## Showing what is selected A selection is drawn on the faces it names, never as a tint over the body: a translucent diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index 597b960710..77010cbf9d 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -310,6 +310,7 @@ GLVolume::GLVolume(float r, float g, float b, float a) , force_native_color(false) , force_neutral_color(false) , force_sinking_contours(false) + , depth_bias(false) , picking(false) , tverts_range(0, size_t(-1)) { @@ -1332,11 +1333,17 @@ void GLVolumeCollection::render(GLVolumeCollection::ERenderType type, shader->set_uniform("projection_matrix", projection_matrix); const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) * model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose(); shader->set_uniform("view_normal_matrix", view_normal_matrix); + if (volume.first->depth_bias) { + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(1.0f, 1.0f)); + } //BBS: add outline related logic if (volume.first->selected && shader_can_outline && GUI::wxGetApp().show_outline()) volume.first->render_with_outline(cnv_size); else volume.first->render(); + if (volume.first->depth_bias) + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); #if ENABLE_ENVIRONMENT_MAP if (use_environment_texture) diff --git a/src/slic3r/GUI/3DScene.hpp b/src/slic3r/GUI/3DScene.hpp index 663979690f..664cd109cf 100644 --- a/src/slic3r/GUI/3DScene.hpp +++ b/src/slic3r/GUI/3DScene.hpp @@ -232,6 +232,10 @@ public: bool force_neutral_color : 1; // Whether or not to force rendering of sinking contours bool force_sinking_contours : 1; + // Orca: draw this volume with a positive depth bias (glPolygonOffset, pushed away from the + // camera), so on a surface it shares with another volume the other volume wins the depth + // test instead of z-fighting it + bool depth_bias : 1; // Is render for picking bool picking : 1; // slice error diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 0e7296ccd4..6af0ee7ae1 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -378,10 +378,14 @@ void DesignCanvas::reload(bool keep_view) v->set_color(c); } } else if (obj_idx == 1) { - // The ghost is normally a faint blue overlay on the visible body. In preview-only - // mode it IS the result (base bodies hidden), so render it opaque so it reads as a - // finished solid rather than a see-through hint. + // The ghost is the whole resulting model, normally drawn as a faint blue overlay on + // the visible bodies. Every face the feature leaves alone is in both, at the same + // depth, so the ghost is drawn with a depth bias: the bodies win on those faces instead + // of the two copies z-fighting, and the ghost shows only where the result reaches past + // the bodies. In preview-only mode it IS the result (base bodies hidden), so render it + // opaque so it reads as a finished solid rather than a see-through hint. v->set_color(m_body_hidden ? ColorRGBA(0.40f, 0.82f, 1.0f, 1.0f) : ghost); + v->depth_bias = true; } } diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 046bf0486d..5ff0f4c8a8 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -11185,10 +11185,12 @@ void DesignPanel::refresh_preview() // Confirm so the user sees the gate before clicking; the red status says why. m_candidate_ok = ok; update_confirm_button(); - // Fillet/Chamfer/Draft: once the target edge/face yields a valid result, show ONLY the + // Fillet/Chamfer/Draft/Hole: once the target edge/face yields a valid result, show ONLY the // preview (hide the base bodies) so the user sees the finished shape, not the old solid - // doubled with the ghost. Before a valid pick the body stays visible so it can be picked. - m_viewport->set_body_hidden((m_active == Tool::Dressup || m_active == Tool::Draft) && ok); + // doubled with the ghost. A hole's cut lies inside the old solid, so without this the body + // would hide it entirely. Before a valid pick the body stays visible so it can be picked. + m_viewport->set_body_hidden((m_active == Tool::Dressup || m_active == Tool::Draft || + m_active == Tool::Hole) && ok); m_status->Refresh(); // Refresh the in-canvas Extrude depth arrow (self-gates: only while the Extrude card is open). From cd30b196b25c2431fb5730fc6a614c5b118ca9f0 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 5 Oct 2026 23:24:50 +0800 Subject: [PATCH 02/30] Let the camera pan and orbit freely while sketching in the Design tab Panning or orbiting with a right-drag no longer ends the polyline chain or drops the point already placed. Only a right-click that doesn't move does. In the Touchpad camera style, Alt+move and Shift+move now orbit and pan even while a draw tool is armed. --- docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md | 4 +- docs/HLSD/design-tab.md | 17 +++++---- src/slic3r/GUI/CAD/DesignCanvas.cpp | 26 +++++++------ src/slic3r/GUI/CAD/DesignSketchTool.cpp | 38 ++++++++++++------- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 13 ++++--- src/slic3r/GUI/GLCanvas3D.cpp | 5 ++- 6 files changed, 63 insertions(+), 40 deletions(-) diff --git a/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md b/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md index a9659ac4b0..b450835daa 100644 --- a/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md +++ b/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md @@ -262,9 +262,9 @@ and a band is not a "gesture" in the `CadLevel::Gesture` sense because nothing h |---|---|---|---|---|---| | `Move` | — | `update_hover` / `update_solid_hover` (**non-consuming**, returns false) | — | passthrough | hover only asks for a repaint, `:10022-10025` | | `LeftDown` | orbit may begin | latch press, **return false** | — | consume-or-orbit is the canvas's call | `:10058-10063`; consuming here killed orbit once already | -| `LeftDrag` > 8 px | — | start + drive band, **consume** | — | no longer orbits in this canvas | middle-drag orbits, right-drag pans (`:10013-10016`) | +| `LeftDrag` > 8 px | — | start + drive band, **consume**, when Shift is held or the left button has no camera action | — | otherwise the left button's drag action (Preferences > Control) | the band is Prepare's Shift+left-drag rectangle selection | | `LeftUp` | — | commit pick **or** resolve band | — | — | `:10035-10041` | -| `MiddleDrag` / `RightDrag` | — | must not see it | — | orbit / pan | camera gestures never reach the FSM | +| `MiddleDrag` / `RightDrag` | — | must not see it | — | the button's drag action (Preferences > Control) | camera gestures never reach the FSM; a right press reaches the tool only once its release shows it was a click | | `RightClick` | — | — | offer menu | — | `snaporca-xmh6` open: a right-click that only clears the sketch selection eats the offer | | `Esc` | — | — | `escape()` ladder | — | one route whatever holds focus (`:4228-4231`) | | `Del` / `Backspace` | — | `delete_selected_or_last_sketch_entity()` | char hook | — | `:4260` | diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 996447f764..d73c245718 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -154,15 +154,18 @@ rather than to its end points: a press that wandered past the budget at any mome navigation, even if it comes back to where it started, which is what stops a slow, careful orbit from ending in a menu. There is no time budget — a gesture that means something different when it is slow is exactly what the interaction charter rules out. The raycast uses the press -position, not the release. An armed sketch tool that already consumed the right -button (to terminate a chain, say) declines to also open a menu, through a read-and-clear flag. -Past either budget the event is navigation, and navigation does not transition the state -machine. +position, not the release. The sketch tool sees a right press only once the release has shown it +was a click: the press itself goes to the camera, which may pan or orbit with that button, and the +canvas replays it to the tool on a stationary release. A tool that uses the click (to terminate a +chain, say) keeps the menu closed. Past either budget the event is navigation, and navigation +does not transition the state machine. Navigation itself is Prepare's: the camera reads the drag actions set in Preferences > Control -for each button. The left button is shared with picking, so a whole body is swept with a -rectangle on plain left-drag only while no camera action is assigned to it, and with -Shift+left-drag otherwise — Prepare's own rectangle selection. +for each button, and in the Touchpad camera style a move with Alt held orbits and one with Shift +held pans, whatever tool is armed. The left button is shared with picking and drawing, so a tool +handle or a press that draws takes it first, as a gizmo does in Prepare; a whole body is swept +with a rectangle on plain left-drag only while no camera action is assigned to the left button, +and with Shift+left-drag otherwise — Prepare's own rectangle selection. Entering a sketch changes three things at once so the mode is legible: a banner above the canvas (a sibling of the canvas, not a child over it — on GTK a child window over a diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 6af0ee7ae1..a108221e34 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -1051,15 +1051,18 @@ void DesignCanvas::set_on_context_menu(std::function cb) return; m_ctx_bound = true; // 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 (kCadRightClickDriftPx). + // It only consumes when it actually opens the offer. + // Right-drag may pan or orbit (Preferences > Control). Without remembering where the press + // landed, every such drag ended by popping the offer over wherever the camera stopped — the + // menu appearing as the reward for moving the view. A right-click is the release of a + // STATIONARY press (kCadRightClickDriftPx); only that reaches the sketch tool or the offer. m_canvas_widget->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { m_ctx_press = e.GetPosition(); m_ctx_travelled = false; - e.Skip(); // the canvas still needs the press to seed the orbit + // Drop any press the tool still keeps (only a click's release takes it, so a pan's stays) + // before the canvas offers it this one, which ImGui may take instead. + m_sketch_tool.drop_right_click(); + e.Skip(); // the canvas still needs the press to seed a pan or an orbit }); m_canvas_widget->Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { if (e.RightIsDown()) { @@ -1070,12 +1073,13 @@ void DesignCanvas::set_on_context_menu(std::function cb) }); m_canvas_widget->Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& e) { const wxPoint d = e.GetPosition() - m_ctx_press; - // Always read-and-clear, even when another guard already rules the offer out, or a - // terminator recorded under one condition would still be pending under the next. - const bool terminated = m_sketch_tool.take_right_consumed(); - // Click, or navigation? A press that travelled orbited; one that did not, did not. + // Click, or navigation? A press that travelled panned or orbited; one that did not, did not. const bool is_click = !m_ctx_travelled && std::max(std::abs(d.x), std::abs(d.y)) <= kCadRightClickDriftPx; - if (m_on_context_menu && !terminated && !inline_busy() && is_click) { + // Ending a chain or abandoning an anchor uses the click up. + const bool terminated = is_click && m_canvas && m_sketch_tool.take_right_click(*m_canvas); + if (terminated) + m_canvas->set_as_dirty(); // drawn by the canvas's own RightUp (e.Skip below) or at idle + else if (m_on_context_menu && !inline_busy() && is_click) { // The menu belongs to what you POINTED AT — and pointing happened at the PRESS, not // at the release, so the raycast uses the press position. Within a 3 px budget the // two are the same pixel in practice; using the press is what makes that a diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index f27e09be5e..35992344d7 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -10315,14 +10315,14 @@ std::vector DesignSketchTool::connected_loop(int seed) const // the offer was excluded in sketch mode wholesale so a right-click could end a polyline chain, // abandon an anchor or exit a tool. That made every sketch row in the atlas unreachable. // The honest test is not "which mode are we in" but "did the tool actually USE this right-click", -// and only the tool knows. Wrapping on_mouse records that once, for every terminator, instead of -// threading a flag through the twenty-odd sites that consume a RightDown. +// and only the tool knows: take_right_click returns it for every terminator, from the +// twenty-odd sites that consume a RightDown. // Right-click abandons the anchor a draw tool has down. With NOTHING down there is nothing to // abandon — and consuming the click anyway made the offer unreachable from every armed draw tool: -// on_mouse records the consumption in m_right_consumed and DesignCanvas's RIGHT_UP handler -// suppresses the menu whenever it is set, so right-click became a no-op that also hid the one door -// to half the vocabulary (47 of 86 verbs have no shortcut). Measured on the rig: with Line armed, -// two right-clicks in a row produced no menu and no tool change; only Escape freed it. +// DesignCanvas's RIGHT_UP handler suppresses the menu whenever the tool used the click, so +// right-click became a no-op that also hid the one door to half the vocabulary (47 of 86 verbs +// have no shortcut). Measured on the rig: with Line armed, two right-clicks in a row produced no +// menu and no tool change; only Escape freed it. // Same rule as xmh6, which said it for the selection: clearing nothing is not a gesture // terminator. ghcz. bool DesignSketchTool::right_abandon() @@ -10334,12 +10334,24 @@ bool DesignSketchTool::right_abandon() return true; } +// The camera follows Preferences > Control here as in Prepare, so no tool may take a gesture the +// camera owns. A right press may start whatever drag action the right button is given, and +// whether it did is known only at the release: ending a chain on the press made every pan or +// orbit started there end the chain too. The press goes to the camera and is kept; +// DesignCanvas's RIGHT_UP handler replays it through take_right_click when it was a click. bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas) { - const bool consumed = on_mouse_impl(evt, canvas); - if (evt.RightDown()) - m_right_consumed = consumed; - return consumed; + if (evt.RightDown()) { + m_right_press = evt; + return false; + } + return on_mouse_impl(evt, canvas); +} + +bool DesignSketchTool::take_right_click(GLCanvas3D& canvas) +{ + auto press = std::exchange(m_right_press, {}); + return press && on_mouse_impl(*press, canvas); } bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) @@ -11288,9 +11300,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) return true; } if (evt.RightDown()) { - // Hand the click back (return false) so the offer opens: the m_right_consumed flag - // this return value feeds means "the tool USED this right-click", and a plain - // right-click in Select mode is not a gesture terminator. + // Hand the click back (return false) so the offer opens: take_right_click returns + // this value as "the tool USED this right-click", and a plain right-click in Select + // mode is not a gesture terminator. // // But do NOT drop the selection on the way out. The offer menu describes WHAT IS // SELECTED, so clearing first guaranteed it could only ever describe nothing: select diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 7e1c4a629d..85693dba27 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -9,14 +9,15 @@ #include "slic3r/GUI/GLSelectionRectangle.hpp" // left-drag rubber band over the committed bodies #include #include +#include #include +#include #include "libslic3r/Color.hpp" #include #include #include #include -class wxMouseEvent; class wxPoint; namespace Slic3r { @@ -149,10 +150,10 @@ public: // Right-click on a draw tool: true when an in-progress anchor was abandoned, false when // there was nothing to abandon — and false is what lets the offer menu open. ghcz. bool right_abandon(); - // True if the LAST right-press was consumed as a gesture terminator (end a polyline chain, - // abandon an anchor, exit a tool). Read-and-clear: the canvas asks on the matching release to - // decide whether that right-click was the user's, in which case it opens the offer. - bool take_right_consumed() { const bool b = m_right_consumed; m_right_consumed = false; return b; } + // Replays and clears the right press on_mouse kept for the camera: true if the tool used it as + // a gesture terminator (end a polyline chain, abandon an anchor), so no offer opens. + bool take_right_click(GLCanvas3D& canvas); + void drop_right_click() { m_right_press.reset(); } void render(GLCanvas3D& canvas); // The in-canvas value field, drawn by render() before any early return. Owned by // DesignCanvas; null until it sets it. Not a window — see SketchInlineEditor.hpp. @@ -1274,7 +1275,7 @@ private: void hit_display_sketch(const DisplaySketch& d, const Vec2d& p, double tol, int& edge_feat, int& edge_reg, int& edge_ent, double& edge_d, int& face_feat, int& face_reg) const; - bool m_right_consumed{false}; // last RightDown was a gesture terminator, not a menu + std::optional m_right_press; // right press not yet known to be a click bool m_escalate_repick{true}; // re-picking the same sub-element takes the whole body void render_solid_highlight(); // The above's edge and vertex highlight, from explicit arguments, so the committed selection diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 36f632096c..c1267e8d31 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -4311,7 +4311,10 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) } if (evt.LeftDown() && m_canvas != nullptr) m_canvas->SetFocus(); // grab keyboard focus so Delete/keys reach this canvas - if (m_design_sketch_tool->on_mouse(evt, *this)) { + // A Touchpad-style orbit or pan (a plain move with Alt or Shift held) is the camera's, + // whatever the tool would make of the move. Only a drag consults the button mappings. + const bool camera_move = evt.Moving() && (is_camera_rotate(evt, {}) || is_camera_pan(evt, {})); + if (!camera_move && m_design_sketch_tool->on_mouse(evt, *this)) { m_dirty = true; render(); // force an immediate redraw so the sketch overlay updates live return; From c300fa1e8dc3229b82705af2d7f6c3ff28840e05 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 03:41:33 +0800 Subject: [PATCH 03/30] Keep the Design tab's bed, grid and reference planes aligned on every plate --- docs/HLSD/design-tab.md | 9 +++++++ src/slic3r/GUI/CAD/DesignCanvas.cpp | 7 ++++- src/slic3r/GUI/CAD/DesignCanvas.hpp | 3 +++ src/slic3r/GUI/CAD/DesignPanel.cpp | 7 +++-- src/slic3r/GUI/GLCanvas3D.cpp | 40 ++++++++++++++++++++++------- src/slic3r/GUI/GLCanvas3D.hpp | 2 ++ 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index d73c245718..9a92647f61 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -246,6 +246,15 @@ The tab is a page of Orca's main window and answers to the same settings as Prep `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Edit, Show/hide and Delete on a feature, Move, Show/hide and Delete on a body — and the eye shows whether that row is hidden. +- **Plates.** This is the one thing the tab does not follow. The canvas has a bed of its own at + the printer bed's home position, whichever plate Prepare has current, and a new document's + modeling origin is that bed's centre. A bed that followed the current plate would slide out + from under a design: the origin is fixed once per document, baked into every sketch plane and + saved in the recipe, while the current plate can change between visits. Commit to Plate does + not need it either, since the committed object is placed on an empty spot of the current + plate. What the canvas does read from the plate is moved onto its bed: the exclude areas, the + plate box the camera orbits about when nothing is picked (`GLCanvas3D::_current_plate_box`), + and the first view, which starts as a copy of Prepare's camera. - **Viewport text.** The status line and the active tool's values are drawn by the canvas in its ImGui pass, so they go with the canvas: a top-level window over GL does not follow its frame and was left floating over other applications. diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index a108221e34..e1718f05f0 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -8,6 +8,7 @@ #include "slic3r/GUI/Camera.hpp" // N: look down the sketch plane normal #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/PartPlate.hpp" // the current plate's origin: the first view's offset #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/GLToolbar.hpp" #include "slic3r/GUI/Event.hpp" @@ -190,8 +191,11 @@ DesignCanvas::DesignCanvas(wxWindow* parent) refresh_bed(); // The view this canvas opens on. Built lazily, on the way into the Design tab, so this - // is the view the user is looking at right now. + // is the view the user is looking at right now — moved off the current plate onto the + // Design bed, which stays at the printer bed's home whichever plate is current. m_parked_camera = wxGetApp().plater()->get_camera(); + if (PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate()) + m_parked_camera.translate_world(-plate->get_origin()); // Before any of this class's own Binds below: wx calls dynamically bound handlers in // reverse order of binding, and GLCanvas3D swallows several events without skipping them — @@ -554,6 +558,7 @@ void DesignCanvas::refresh_bed() double printable_height = 100.0; const auto* ph_opt = config->opt("printable_height"); if (ph_opt) printable_height = ph_opt->value; + // No position: the Design bed stays at the printer bed's home, whichever plate is current. m_bed.set_shape(bed_shape_opt->values, printable_height, {}, {}, "", false); // mainline added extruder_areas/heights params } diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 9584550980..a80ab4bef2 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -3,6 +3,7 @@ #include #include "libslic3r/Point.hpp" +#include "libslic3r/BuildVolume.hpp" #include "libslic3r/Color.hpp" #include #include @@ -83,6 +84,8 @@ public: void finish_sketch(); bool is_sketching() const; void refresh_bed(); // re-sync the bed to the current printer (call on tab activation) + // Centre of the Design bed: the printer bed at its home position, whichever plate is current. + Vec2d bed_center() const { return m_bed.build_volume().bed_center(); } // The Camera is Plater-owned and shared with Prepare/Preview/Assemble; GLCanvas3D has no // per-canvas camera, so every orbit here would otherwise overwrite what the editor tabs // show. Exactly one of the two views is live at a time, so entering and leaving are the diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 5ff0f4c8a8..e7f77cf290 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -104,7 +104,6 @@ #include "libslic3r/Model.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" -#include "libslic3r/BuildVolume.hpp" #include "slic3r/GUI/MainFrame.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" #include "slic3r/GUI/Shortcuts.hpp" @@ -7345,8 +7344,12 @@ void DesignPanel::hydrate_from_model() // that has none yet. A design in progress keeps its origin: its sketches have it baked into // their planes, so moving it under them (a printer change between visits) would slide the // datum planes off the geometry. A loaded project brings its own (load_recipe). + // The centre of the Design bed, not the plater's: the plater moves its bed onto the current + // plate, so on plate 2+ the origin would land one plate stride away from the bed drawn here. + // Re-synced first because the control socket calls this without on_tab_shown(). if (!m_doc.origin_from_recipe) { - const Vec2d bc = plater->build_volume().bed_center(); + m_viewport->refresh_bed(); + const Vec2d bc = m_viewport->bed_center(); m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0); // A document started here takes the weld rule from the preference; a loaded one // brings its own (load_recipe). diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index c1267e8d31..63ff5dcdbd 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1875,7 +1875,7 @@ BoundingBoxf3 GLCanvas3D::volumes_bounding_box(bool current_plate_only) const bool is_limit = m_canvas_type != ECanvasType::CanvasAssembleView; if (is_limit) { if (current_plate_only) { - expand_part_plate_list_box = wxGetApp().plater()->get_partplate_list().get_curr_plate()->get_bounding_box(); + expand_part_plate_list_box = _current_plate_box(); } else { auto plate_list_box = wxGetApp().plater()->get_partplate_list().get_bounding_box(); auto horizontal_radius = 0.5 * sqrt(std::pow(plate_list_box.min[0] - plate_list_box.max[0], 2) + std::pow(plate_list_box.min[1] - plate_list_box.max[1], 2)); @@ -8267,7 +8267,30 @@ void GLCanvas3D::_render_bed(const Transform3d& view_matrix, const Transform3d& void GLCanvas3D::_render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body, int hover_id, bool render_cali, bool show_grid) { - wxGetApp().plater()->get_partplate_list().render(view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled); + PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list(); + // Design tab: its bed stays at the printer bed's home whichever plate is current, so the + // current plate's exclude areas are moved from that plate onto it. + PartPlate* curr_plate = plate_list.get_curr_plate(); + const Transform3d plate_view_matrix = m_axes_at_bed_center && curr_plate != nullptr ? + Transform3d(view_matrix * Geometry::translation_transform(-curr_plate->get_origin())) : view_matrix; + plate_list.render(plate_view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled); +} + +BoundingBoxf3 GLCanvas3D::_current_plate_box() const +{ + // Design tab: its own bed stands in for the current plate (see _render_platelist). + const BuildVolume& build_volume = m_bed.build_volume(); + if (m_axes_at_bed_center && build_volume.valid()) { + // Flat at z = 0 like the plate's own box. Merged, as PartPlate builds it: the min/max + // constructor leaves a flat box undefined. + const BoundingBoxf bb = build_volume.bounding_volume2d(); + BoundingBoxf3 box; + box.merge(Vec3d(bb.min.x(), bb.min.y(), 0.)); + box.merge(Vec3d(bb.max.x(), bb.max.y(), 0.)); + return box; + } + PartPlate* curr_plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); + return curr_plate != nullptr ? curr_plate->get_bounding_box() : BoundingBoxf3(); } // Design tab: CAD grid on the bed plane, drawn in place of the plate's corner-origin grid. @@ -11061,10 +11084,9 @@ std::optional GLCanvas3D::get_camera_orbit_target(ECameraNavigationType n { // 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(); + const BoundingBoxf3 plate_box = _current_plate_box(); if (navigation_type == ECameraNavigationType::Gesture) - return current_plate == nullptr ? std::nullopt : - std::make_optional(current_plate->get_bounding_box().center()); + return plate_box.defined ? std::make_optional(plate_box.center()) : std::nullopt; const GLGizmosManager::EType gizmo_type = m_gizmos.get_current_type(); const bool use_scene_target = m_canvas_type == ECanvasType::CanvasAssembleView || @@ -11084,15 +11106,15 @@ std::optional GLCanvas3D::get_camera_orbit_target(ECameraNavigationType n Vec3d target = Vec3d::Zero(); if (m_canvas_type == ECanvasType::CanvasPreview) { - if (current_plate != nullptr) - target = current_plate->get_bounding_box().center(); + if (plate_box.defined) + target = plate_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) + bbox = plate_box; if (bbox.defined) target = bbox.center(); } diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index e53a9d6aba..070db6771b 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -1441,6 +1441,8 @@ private: void _render_shadows(const Transform3d& view_matrix, const Transform3d& projection_matrix); //BBS: add part plate related logic void _render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true); + // The current plate's box (XY, at z = 0); in the Design tab, its own bed's. + BoundingBoxf3 _current_plate_box() const; // Design tab: draw the CAD grid (minor 10 mm + major 50 mm) in place of the plate's // corner-origin grid when the axes sit at the bed centre (modeling origin). Rebuilds its // GLModels lazily, only when the bed shape changed. From 34696f5651635fa85ec723b591f0bfe7fd377b86 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 03:44:15 +0800 Subject: [PATCH 04/30] Name the Design canvas flag after its role, not the axes it once moved --- src/slic3r/GUI/CAD/DesignCanvas.cpp | 2 +- src/slic3r/GUI/GLCanvas3D.cpp | 10 +++++----- src/slic3r/GUI/GLCanvas3D.hpp | 14 ++++++++------ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index e1718f05f0..d2c78ed9d7 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -92,7 +92,7 @@ DesignCanvas::DesignCanvas(wxWindow* parent) m_canvas->enable_plate_chrome(false); m_canvas->enable_labels(false); m_canvas->enable_sinking_contours(false); // they would be sliced from the plater's meshes - m_canvas->set_axes_at_bed_center(true); // triad at bed centre = modeling origin + m_canvas->set_design_canvas(true); // home-position bed, triad and CAD grid at the modeling origin m_canvas->set_design_sketch_tool(&m_sketch_tool); m_sketch_tool.on_commit = [this](const SketchProfile& prof, const SketchPlane& pl) { diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 63ff5dcdbd..46ef8efe0b 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2395,7 +2395,7 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) } else if (gizmo_type == GLGizmosManager::BrimEars && !camera.is_looking_downward()) show_grid = false; - if (m_axes_at_bed_center) + if (m_design_canvas) // Design tab: the plate grid is generated from the plate's front-left corner, so it // floats mid-cell under the modeling-origin triad. Suppress it here; a CAD grid centred // on the origin is rendered in its place (see _render_cad_grid). @@ -2411,7 +2411,7 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) m_frame_profiler.mark("bed"); 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 && show_bed) + if (m_design_canvas && 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()); m_frame_profiler.mark("plates"); @@ -8254,7 +8254,7 @@ void GLCanvas3D::_render_bed(const Transform3d& view_matrix, const Transform3d& */ //bool show_texture = true; //BBS set axes mode - if (m_axes_at_bed_center) { + if (m_design_canvas) { // Design tab: triad at the bed centre = modeling origin (set every frame because // set_shape/set_axes_mode otherwise reset it to the bed corner). const Vec2d bc = m_bed.build_volume().bed_center(); @@ -8271,7 +8271,7 @@ void GLCanvas3D::_render_platelist(const Transform3d& view_matrix, const Transfo // Design tab: its bed stays at the printer bed's home whichever plate is current, so the // current plate's exclude areas are moved from that plate onto it. PartPlate* curr_plate = plate_list.get_curr_plate(); - const Transform3d plate_view_matrix = m_axes_at_bed_center && curr_plate != nullptr ? + const Transform3d plate_view_matrix = m_design_canvas && curr_plate != nullptr ? Transform3d(view_matrix * Geometry::translation_transform(-curr_plate->get_origin())) : view_matrix; plate_list.render(plate_view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled); } @@ -8280,7 +8280,7 @@ BoundingBoxf3 GLCanvas3D::_current_plate_box() const { // Design tab: its own bed stands in for the current plate (see _render_platelist). const BuildVolume& build_volume = m_bed.build_volume(); - if (m_axes_at_bed_center && build_volume.valid()) { + if (m_design_canvas && build_volume.valid()) { // Flat at z = 0 like the plate's own box. Merged, as PartPlate builds it: the min/max // constructor leaves a flat box undefined. const BoundingBoxf bb = build_volume.bounding_volume2d(); diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 070db6771b..74c11860c3 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -604,9 +604,11 @@ private: GLToolbar* m_collapse_toolbar{nullptr}; std::function m_collapse_side; bool m_plate_chrome_enabled{true}; - // Design tab: render the world-axis triad at the bed centre (= modeling origin) instead of - // the bed corner. Default false preserves the main editor's corner triad. - bool m_axes_at_bed_center{false}; + // This canvas is the Design tab's. Its bed stays at the printer bed's home whichever plate is + // current, with the world-axis triad and a CAD grid at the bed centre (= modeling origin) in + // place of the corner triad and the plate grid, and the plate data it reads (exclude areas, + // the current plate's box) moved onto that bed. Default false leaves the editor tabs untouched. + bool m_design_canvas{false}; // Design tab: draw the printer bed and its plate grid at all. Default true, so the // main editor is untouched; the Design tab lets the user hide it to model without a bed. bool m_show_bed{true}; @@ -1025,7 +1027,7 @@ public: // initialized, which loads the toolbar's background. void set_collapse_toolbar(GLToolbar* toolbar, std::function side); void enable_plate_chrome(bool enable); - void set_axes_at_bed_center(bool b) { m_axes_at_bed_center = b; } + void set_design_canvas(bool b) { m_design_canvas = b; } void set_show_bed(bool b) { m_show_bed = b; } bool get_show_bed() const { return m_show_bed; } void enable_sinking_contours(bool enable) { m_sinking_contours_enabled = enable; } @@ -1444,8 +1446,8 @@ private: // The current plate's box (XY, at z = 0); in the Design tab, its own bed's. BoundingBoxf3 _current_plate_box() const; // Design tab: draw the CAD grid (minor 10 mm + major 50 mm) in place of the plate's - // corner-origin grid when the axes sit at the bed centre (modeling origin). Rebuilds its - // GLModels lazily, only when the bed shape changed. + // corner-origin grid, centred on the modeling origin. Rebuilds its GLModels lazily, only + // when the bed shape changed. void _render_cad_grid(const Transform3d& view_matrix, const Transform3d& projection_matrix); //BBS: add outline drawing logic void _render_objects(GLVolumeCollection::ERenderType type, bool with_outline = true); From 840d44b6118383a721ae3207c41a527192081024 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 11:21:18 +0800 Subject: [PATCH 05/30] Keep the Design tab's edge lines in step with the bodies after undo, hide and delete --- src/slic3r/GUI/CAD/DesignCanvas.cpp | 3 + src/slic3r/GUI/CAD/DesignCanvas.hpp | 5 +- src/slic3r/GUI/CAD/DesignPanel.cpp | 17 ++++-- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 73 +++++++++++++++++-------- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 17 ++++-- 5 files changed, 81 insertions(+), 34 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index d2c78ed9d7..09c22ba678 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -414,6 +414,7 @@ void DesignCanvas::set_bodies(const std::vector* body_meshes, if (body_meshes == nullptr || body_meshes->empty()) { clear_mesh(); return; } m_body_meshes = body_meshes; + m_sketch_tool.refresh_body_edges(); // of the bodies set_solid_pick() pointed the tool at m_lit_faces = m_sketch_tool.selected_faces(); rebuild_bodies(); reload(!m_first_frame); @@ -423,6 +424,8 @@ void DesignCanvas::clear_mesh() { m_body_meshes = nullptr; m_volumes.clear(); + // No solid, so no edge lines, pick or hover either, as after a rebuild that leaves no body. + m_sketch_tool.set_solid_pick(nullptr, nullptr, nullptr, nullptr); if (!m_model.objects.empty()) { m_model.delete_object((size_t)0); reload(true); diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index a80ab4bef2..f5abb14a95 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -49,10 +49,11 @@ public: // Multi-body display: one GLVolume per body, each coloured distinctly (per-body colour). // `visible` (optional, indexed by body) hides bodies whose flag is false. `body_meshes` is kept - // by address (a stable panel member) and read again whenever the selection changes. + // by address (a stable panel member) and read again whenever the selection changes. The + // bodies' edge lines are refreshed with them (DesignSketchTool::refresh_body_edges). void set_bodies(const std::vector* body_meshes, const std::vector& visible = {}); - void clear_mesh(); + void clear_mesh(); // no bodies: drops their volumes and the tool's edge lines, pick and selection void set_preview_mesh(const TriangleMesh& mesh); void clear_preview(); diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index e7f77cf290..c93ee937d5 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -7896,8 +7896,7 @@ void DesignPanel::set_tree_selection(int row) // The selection (the solid pick, the hit face and the committed-loop pick) names bodies, faces and // features by index, so replacing or renumbering the feature list (undo/redo, New Design, load, -// delete, reorder) drops it, the viewport's highlights with it. The solid highlight too: a rebuild -// that leaves no body never reaches set_solid_pick. The callers repaint. +// delete, reorder) drops it, the viewport's highlights with it. The callers repaint. void DesignPanel::drop_selection() { if (m_viewport != nullptr) @@ -8093,8 +8092,18 @@ void DesignPanel::on_toggle_visibility() refresh_tree(); // greys the row set_tree_selection(sel); // keep the toggled feature selected if (m_viewport != nullptr) { - if (m_doc.display_mesh.its.indices.empty()) m_viewport->clear_mesh(); - else feed_bodies(); + if (m_doc.display_mesh.its.indices.empty()) { + m_viewport->clear_mesh(); + } else { + // Not set_status_ok(), which would take the gizmo from a Move still open. + feed_bodies(); + // Hiding or showing a feature rebuilds the bodies and can renumber them, so the pick is + // re-pointed and its selection dropped. That also brings picking back for a body shown + // after everything was hidden (clear_mesh() dropped it). + m_viewport->set_solid_pick(&m_doc.bodies, &m_disp_pick_mesh, + &m_doc.display_tri_face, &m_doc.display_tri_body, + &m_body_visible, &m_body_xform); + } } sync_sketch_display(); // skips the hidden sketch + direct-renders set_status(StatusKind::Info, shown ? _L("Feature shown") : _L("Feature hidden")); diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 35992344d7..7049f7b08d 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -3473,18 +3473,19 @@ static double display_edge_tol(const TopoDS_Shape& shape) void DesignSketchTool::refresh_body_edges() { const size_t n = m_solid_bodies != nullptr ? m_solid_bodies->size() : 0; + bool rebuilt = n < m_body_edges_shape.size(); // a body went away m_body_edges.resize(n); - m_body_edges_key.resize(n, nullptr); + m_body_edges_shape.resize(n); m_body_edges_tol.resize(n, 0.0); for (size_t b = 0; b < n; ++b) { - const void* key = body_key(int(b)); - if (key == m_body_edges_key[b] && key != nullptr) - continue; - m_body_edges_key[b] = key; - m_body_edges[b].clear(); - if (key == nullptr) + if (is_current_shape(int(b), m_body_edges_shape[b])) continue; + rebuilt = rebuilt || !m_body_edges_shape[b].IsNull(); // a body sampled before was rebuilt const TopoDS_Shape& shape = (*m_solid_bodies)[b].shape; + m_body_edges_shape[b] = shape; + m_body_edges[b].clear(); + if (shape.IsNull()) + continue; m_body_edges_tol[b] = display_edge_tol(shape); try { m_body_edges[b] = GeometryEngine::display_edges(shape, m_body_edges_tol[b]); @@ -3492,15 +3493,36 @@ void DesignSketchTool::refresh_body_edges() m_body_edges[b].clear(); // an unsampleable edge costs its body the lines, nothing else } } + // A pick expires once the body it was taken on is rebuilt, which renumbers its faces and + // edges. Judged by that body's shape, not by these lines: a recompute that refreshed no lines + // (adding a datum, say) may come before a pick on the bodies it built, and that pick holds. + // The hover is resolved again on the next mouse move, so any rebuild just drops it. + if (m_solid_sel != SolidSel::None && !is_current_shape(m_sel_body, m_sel_shape)) + clear_solid_selection(); + else if (rebuilt) + m_pre = SolidPick{}; + // The face outlines of a rebuilt body let go of its shape, which may be a large import. + const auto stale = [this](const FaceHighlight& h) { return !is_current_shape(h.body, h.shape); }; + if (stale(m_sel_hl)) m_sel_hl = FaceHighlight{}; + if (stale(m_pre_hl)) m_pre_hl = FaceHighlight{}; + m_hl_faces.erase(std::remove_if(m_hl_faces.begin(), m_hl_faces.end(), stale), m_hl_faces.end()); } -// The identity of a body's current shape. Face ids and sampled edges hold while it does. -const void* DesignSketchTool::body_key(int body) const +const TopoDS_Shape* DesignSketchTool::body_shape(int body) const { if (m_solid_bodies == nullptr || body < 0 || body >= int(m_solid_bodies->size())) return nullptr; const TopoDS_Shape& shape = (*m_solid_bodies)[body].shape; - return shape.IsNull() ? nullptr : shape.TShape().get(); + return shape.IsNull() ? nullptr : &shape; +} + +// Whether `sampled` is still the body's shape. The caches hold the shape itself rather than its +// address, so the sampled TShape stays alive and no rebuilt body can be allocated in its place. +// IsSame also compares the Location, which the sampled lines are in. +bool DesignSketchTool::is_current_shape(int body, const TopoDS_Shape& sampled) const +{ + const TopoDS_Shape* shape = body_shape(body); + return shape != nullptr && sampled.IsSame(*shape); } // View-facing ribbons along a body's polylines (shape coordinates), `hw` either side of the line @@ -3603,6 +3625,7 @@ void DesignSketchTool::clear_solid_selection() { m_solid_sel = SolidSel::None; m_sel_body = m_sel_face = m_sel_edge = -1; + m_sel_shape = TopoDS_Shape(); m_sel_edge_pts.clear(); m_sel_edges_more.clear(); m_sel_edges_more_pts.clear(); @@ -3630,6 +3653,7 @@ void DesignSketchTool::select_body(int body) return; } m_sel_body = body; + m_sel_shape = (*m_solid_bodies)[body].shape; m_sel_face = m_sel_edge = -1; m_sel_edge_pts.clear(); m_sel_edges_more.clear(); @@ -3641,7 +3665,7 @@ void DesignSketchTool::set_highlight_faces(const std::vector { std::map> by_body; for (const auto& [b, f] : faces) - if (body_key(b) != nullptr) + if (body_shape(b) != nullptr) by_body[b].push_back(f); // Reuse each body's entry, so re-sending the same faces resamples nothing. std::vector next; @@ -3663,13 +3687,16 @@ DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, std::sort(faces.begin(), faces.end()); faces.erase(std::unique(faces.begin(), faces.end()), faces.end()); h.faces = std::move(faces); - h.key = body_key(body); - if (h.key == nullptr || h.faces.empty()) + const TopoDS_Shape* shape = body_shape(body); + if (shape == nullptr) + return h; + h.shape = *shape; + if (h.faces.empty()) return h; // Sampled at the body edges' tolerance; a whole body reuses its body edges. - const TopoDS_Shape& shape = (*m_solid_bodies)[body].shape; - const std::vector all = GeometryEngine::faces_of(shape); - const bool cached = body < int(m_body_edges_key.size()) && m_body_edges_key[body] == h.key; + const std::vector all = GeometryEngine::faces_of(*shape); + const bool cached = body < int(m_body_edges_shape.size()) + && is_current_shape(body, m_body_edges_shape[body]); if (cached && h.faces.size() == all.size() && h.faces.front() == 0 && h.faces.back() == int(all.size()) - 1) { h.edges = m_body_edges[body]; return h; @@ -3681,7 +3708,7 @@ DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, if (f >= 0 && f < int(all.size())) builder.Add(picked, all[f]); try { - h.edges = GeometryEngine::display_edges(picked, cached ? m_body_edges_tol[body] : display_edge_tol(shape)); + h.edges = GeometryEngine::display_edges(picked, cached ? m_body_edges_tol[body] : display_edge_tol(*shape)); } catch (const Standard_Failure&) { h.edges.clear(); // an edge that cannot be sampled costs the outline, never the fill } @@ -3691,12 +3718,13 @@ DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, std::vector> DesignSketchTool::picked_faces() const { std::vector> out; - if (body_key(m_sel_body) == nullptr) + const TopoDS_Shape* shape = body_shape(m_sel_body); + if (shape == nullptr) return out; if (m_solid_sel == SolidSel::Face && m_sel_face >= 0) out.emplace_back(m_sel_body, m_sel_face); else if (m_solid_sel == SolidSel::Whole) - for (int f = 0, n = GeometryEngine::face_count((*m_solid_bodies)[m_sel_body].shape); f < n; ++f) + for (int f = 0, n = GeometryEngine::face_count(*shape); f < n; ++f) out.emplace_back(m_sel_body, f); return out; } @@ -3705,7 +3733,7 @@ std::vector> DesignSketchTool::selected_faces() const { std::vector> out = picked_faces(); for (const FaceHighlight& h : m_hl_faces) - if (h.key != nullptr && h.key == body_key(h.body)) + if (is_current_shape(h.body, h.shape)) for (int f : h.faces) out.emplace_back(h.body, f); std::sort(out.begin(), out.end()); @@ -3718,7 +3746,7 @@ const DesignSketchTool::FaceHighlight& DesignSketchTool::cached_face_highlight(F { std::sort(faces.begin(), faces.end()); faces.erase(std::unique(faces.begin(), faces.end()), faces.end()); - if (cache.body != body || cache.faces != faces || cache.key != body_key(body)) + if (cache.body != body || cache.faces != faces || !is_current_shape(body, cache.shape)) cache = make_face_highlight(body, std::move(faces)); return cache; } @@ -3729,7 +3757,7 @@ const DesignSketchTool::FaceHighlight& DesignSketchTool::cached_face_highlight(F // version: the line alone, thinner and fainter. void DesignSketchTool::render_face_outline(const FaceHighlight& h, bool quiet) { - if (m_body_edges_hidden || h.key == nullptr || h.key != body_key(h.body) || !body_pickable(h.body)) + if (m_body_edges_hidden || !is_current_shape(h.body, h.shape) || !body_pickable(h.body)) return; // hidden, or the body was rebuilt and these face ids are stale const Camera& cam = wxGetApp().plater()->get_camera(); @@ -4006,6 +4034,7 @@ bool DesignSketchTool::handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent m_sel_edges_more_pts.clear(); m_sel_body = p.body; + m_sel_shape = (*m_solid_bodies)[p.body].shape; // resolve_solid_pick checked p.body m_sel_face = p.face; m_sel_edge = p.edge; m_sel_edge_pts = std::move(p.edge_pts); diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 85693dba27..cfcf2e8441 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -4,6 +4,7 @@ #include "libslic3r/Point.hpp" #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/CAD/CadDocument.hpp" // CadBody for per-body solid picking +#include #include "libslic3r/CAD/SketchInference.hpp" #include "slic3r/GUI/GLModel.hpp" #include "slic3r/GUI/GLSelectionRectangle.hpp" // left-drag rubber band over the committed bodies @@ -208,6 +209,9 @@ public: // their edges do not float over the preview. void set_body_edges_hidden(bool h) { m_body_edges_hidden = h; } void clear_solid_selection(); + // Resample the edges of each body whose shape changed and drop those of bodies that are gone. + // Also expires a pick or hover on a rebuilt body. The canvas calls it whenever it gets the bodies. + void refresh_body_edges(); bool has_solid_selection() const { return m_solid_sel != SolidSel::None; } // Every picked edge when the selection is an edge set (Shift/Ctrl+click adds and removes // edges of the same body): the earlier picks first, the last-clicked edge at the end. @@ -1226,19 +1230,20 @@ private: Vec3d body_xform_pt(int body, const Vec3d& p) const; // map an OCCT-shape point through the body xform // The bodies' B-rep edges, drawn as dark lines over the solids so faces and features read // apart. One polyline set per body in its own shape coordinates, resampled only for a body - // whose shape changed (keyed by the TShape), since set_solid_pick runs on every recompute. + // whose shape changed (see is_current_shape). std::vector>> m_body_edges; - std::vector m_body_edges_key; + std::vector m_body_edges_shape; // the shape each set was sampled from std::vector m_body_edges_tol; // the chord tolerance they were sampled at bool m_body_edges_hidden{false}; - void refresh_body_edges(); void render_body_edges(); - const void* body_key(int body) const; // the body's TShape, nullptr when there is none + const TopoDS_Shape* body_shape(int body) const; // nullptr when there is no such body or it has no shape + bool is_current_shape(int body, const TopoDS_Shape& sampled) const; // `sampled` is still the body's shape void append_ribbons(GLModel::Geometry& g, int body, const std::vector>& polylines, const Vec3d& vd, const Vec3d& pull, double hw) const; bool body_pickable(int b) const; // false when the body is explicitly hidden SolidSel m_solid_sel{SolidSel::None}; int m_sel_body{-1}; // which body the face/edge selection is on + TopoDS_Shape m_sel_shape; // that body's shape when picked: the ids index into it int m_sel_face{-1}; int m_sel_edge{-1}; std::vector m_sel_edge_pts; @@ -1283,11 +1288,11 @@ private: void render_solid_sel(SolidSel kind, const std::vector& edge_pts, const Vec3d& vertex_pt, const ColorRGBA& rgb); // A set of selected faces of one body, with their edges sampled once, keyed by the body's - // TShape so a recompute that rebuilt the body retires it. + // shape so a recompute that rebuilt the body retires it. struct FaceHighlight { int body{-1}; std::vector faces; // sorted - const void* key{nullptr}; + TopoDS_Shape shape; std::vector> edges; // in the body's shape coordinates }; FaceHighlight make_face_highlight(int body, std::vector faces) const; From 56ebde968a9f48df2031733bfbc8e8a87e88c9b5 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 11:55:41 +0800 Subject: [PATCH 06/30] Keep the Design tab's status line clear of the view buttons at any display scale The line started at a fixed inset scaled like the ImGui style, while the navigator and the round canvas buttons scale with the monitor's DPI on Windows, so at 150% the buttons covered its first words. It now starts past the edge the canvas reports for that corner. --- src/slic3r/GUI/CAD/DesignCanvas.cpp | 8 ++++---- src/slic3r/GUI/GLCanvas3D.cpp | 27 +++++++++++++++++++++++---- src/slic3r/GUI/GLCanvas3D.hpp | 3 +++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 09c22ba678..dac8060619 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -1183,9 +1183,6 @@ void DesignCanvas::set_readout(const std::string& text) if (m_canvas) m_canvas->set_as_dirty(); // drawn by the next frame (the tool feeds this from one) } -// Clear of the view cube and the two round view buttons, which own the bottom-left corner. -static constexpr float kStatusHudLeftInset = 190.f; - void DesignCanvas::set_status_text(const wxString& text, const wxColour& colour) { if (text == m_status_hud_last && colour == m_status_hud_colour) return; @@ -1220,8 +1217,11 @@ void DesignCanvas::render_hud() ImGuiWrapper::pop_common_window_style(); }; if (!m_status_hud_last.IsEmpty()) { + // Past the view cube and the round view buttons, which own the bottom-left corner. Asked + // of the canvas, which lays them out: they follow the monitor's DPI on Windows, where `em` + // does not, so a fixed inset in `em` let them cover the start of the line at 150%. // A sentence can be a sentence: it wraps to the room left of the readout chip. - const float left = kStatusHudLeftInset * em; + const float left = m_canvas->get_canvas_toolbar_right() + margin; const ImVec4 col = m_status_hud_colour.IsOk() ? ImVec4(m_status_hud_colour.Red() / 255.f, m_status_hud_colour.Green() / 255.f, m_status_hud_colour.Blue() / 255.f, 1.f) diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 46ef8efe0b..269770fd61 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2088,6 +2088,25 @@ float GLCanvas3D::get_collapse_toolbar_height() const return collapse_side() != CollapseSide::None ? collapse_toolbar().get_height() : 0; } +// The bottom-left corner: the 3D navigator, then the column of round canvas buttons beside it, in +// units of the toolbar scale. Shared by _render_3d_navigator, _render_canvas_toolbar and +// get_canvas_toolbar_right, so an overlay kept clear of the corner follows its layout. +static constexpr float NAVIGATOR_SIZE = 128.f; +static constexpr float CANVAS_TOOLBAR_MARGIN = 10.f; // off the canvas edge, when there is no navigator +static constexpr float CANVAS_TOOLBAR_PADDING = 2.f; +static constexpr float CANVAS_TOOLBAR_BUTTON = 36.f; + +float GLCanvas3D::get_canvas_toolbar_right() const +{ + // The toolbar scale, which on Windows follows the monitor's DPI where the ImGui style does not. + float sc = get_scale(); +#ifdef WIN32 + sc *= (float) get_dpi_for_window(wxGetApp().GetTopWindow()) / (float) DPI_DEFAULT; +#endif // WIN32 + const float left = wxGetApp().show_3d_navigator() ? NAVIGATOR_SIZE : CANVAS_TOOLBAR_MARGIN; + return (left + 2.f * CANVAS_TOOLBAR_PADDING + CANVAS_TOOLBAR_BUTTON) * sc; +} + GLToolbar& GLCanvas3D::collapse_toolbar() const { return m_collapse_toolbar != nullptr ? *m_collapse_toolbar : wxGetApp().plater()->get_collapse_toolbar(); @@ -6358,7 +6377,7 @@ void GLCanvas3D::_render_3d_navigator() } } - const float size = 128 * sc; + const float size = NAVIGATOR_SIZE * sc; m_canvas_toolbar_pos[0] = size; const auto result = ImGuizmo::ViewManipulate(cameraView, cameraProjection, ImGuizmo::OPERATION::ROTATE, ImGuizmo::MODE::WORLD, nullptr, camDistance, ImVec2(viewManipulateLeft, viewManipulateTop - size), ImVec2(size, size), @@ -10064,10 +10083,10 @@ void GLCanvas3D::_render_canvas_toolbar() sc *= (float) dpi / (float) DPI_DEFAULT; #endif // WIN32 - ImVec2 btn_size = ImVec2(36.f, 36.f) * sc; - ImVec2 margin = ImVec2(m_canvas_toolbar_pos[0] > 0 ? 0.f : (10.f * sc), 10.f * sc); + ImVec2 btn_size = ImVec2(CANVAS_TOOLBAR_BUTTON, CANVAS_TOOLBAR_BUTTON) * sc; + ImVec2 margin = ImVec2(m_canvas_toolbar_pos[0] > 0 ? 0.f : (CANVAS_TOOLBAR_MARGIN * sc), CANVAS_TOOLBAR_MARGIN * sc); ImVec2 spacing = ImVec2(6.f, 6.f) * sc; - ImVec2 padding = ImVec2(2.f, 2.f) * sc; + ImVec2 padding = ImVec2(CANVAS_TOOLBAR_PADDING, CANVAS_TOOLBAR_PADDING) * sc; Vec2i32 pos = { m_canvas_toolbar_pos[0] + margin.x, get_canvas_size().get_height() - margin.y diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 74c11860c3..6c0c58c109 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -1061,6 +1061,9 @@ public: bool is_collapse_toolbar_on_left() const; float get_collapse_toolbar_width() const; float get_collapse_toolbar_height() const; + // Right edge, in canvas pixels, of the bottom-left corner the 3D navigator and the round + // canvas buttons own. An overlay along the bottom edge starts past it. + float get_canvas_toolbar_right() const; void update_volumes_colors_by_extruder(); From afa9252b592a6a8e89714ae79fc832063c9f9d1f Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 12:11:34 +0800 Subject: [PATCH 07/30] Make the Fit camera button frame the Design tab's selection and sketches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the Design tab the button always swung the camera to the plate view and framed the whole bed, whatever was selected: the tab's picks and sketches are neither the canvas's selection nor its volumes. It now frames the selected faces, body, edges, vertex, sketch region or sketch entities, and with nothing selected everything on show — the visible bodies, the feature preview and the sketches — keeping the current view direction. An empty tab still frames the bed as before. --- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 71 +++++++++++ src/slic3r/GUI/CAD/DesignSketchTool.hpp | 7 ++ src/slic3r/GUI/GLCanvas3D.cpp | 27 +++-- tests/slic3rutils/CMakeLists.txt | 4 + tests/slic3rutils/test_design_sketch_tool.cpp | 110 ++++++++++++++++++ 5 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 tests/slic3rutils/test_design_sketch_tool.cpp diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 7049f7b08d..d5f0399d6e 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -3741,6 +3741,77 @@ std::vector> DesignSketchTool::selected_faces() const return out; } +BoundingBoxf3 DesignSketchTool::fit_box(const GLVolumeCollection& volumes) const +{ + BoundingBoxf3 box; + const auto add_entity = [&box, this](const SketchEntity& e, const SketchPlane& plane) { + bool closed = false; + for (const Vec2d& p : entity_polyline(e, closed)) + box.merge(plane.to_world(p)); + }; + const auto add_entities = [&add_entity](const std::vector& ents, const SketchPlane& plane) { + for (const SketchEntity& e : ents) + add_entity(e, plane); + }; + + // The selected faces, a whole body's included, from the pick mesh: it is in world + // coordinates already, moved bodies and all. + const std::vector> faces = selected_faces(); + if (!faces.empty() && m_solid_mesh != nullptr && m_solid_tri_face != nullptr && m_solid_tri_body != nullptr) { + const indexed_triangle_set& its = m_solid_mesh->its; + const size_t n = std::min({ its.indices.size(), m_solid_tri_face->size(), m_solid_tri_body->size() }); + for (size_t t = 0; t < n; ++t) + if (std::binary_search(faces.begin(), faces.end(), std::make_pair((*m_solid_tri_body)[t], (*m_solid_tri_face)[t]))) + for (int i = 0; i < 3; ++i) + box.merge(its.vertices[its.indices[t][i]].cast()); + } + if (m_solid_sel == SolidSel::Edge) { + for (const Vec3d& p : m_sel_edge_pts) + box.merge(p); + for (const std::vector& pts : m_sel_edges_more_pts) + for (const Vec3d& p : pts) + box.merge(p); + } else if (m_solid_sel == SolidSel::Vertex) + box.merge(m_sel_vertex_pt); + // A committed sketch's picked region with its holes; a stroke outside any region, the sketch. + if (m_display_pick >= 0) + for (const DisplaySketch& d : m_display_sketches) + if (d.feature == m_display_pick) + add_entities(m_display_pick_region >= 0 ? selected_loop_entities() : d.entities, d.plane); + if (m_active) { + for (int i : m_selection) + if (i >= 0 && i < int(m_entities.size())) + add_entity(m_entities[i], m_plane); + for (const auto& [i, role] : m_point_sel) + if (i >= 0 && i < int(m_entities.size())) { + const SketchEntity& e = m_entities[i]; + box.merge(m_plane.to_world(role == SketchPointRole::Center ? e.center : + role == SketchPointRole::P1 ? e.p1 : e.p0)); + } + } + + // Nothing selected: everything on show. A hidden body's volume is inactive. + if (!box.defined) { + for (const GLVolume* v : volumes.volumes) + if (v->is_active) + box.merge(v->transformed_bounding_box()); + for (const DisplaySketch& d : m_display_sketches) + add_entities(d.entities, d.plane); + if (m_active) + add_entities(m_entities, m_plane); + } + + // A vertex has no size, nor has a sketch along its normal, and the camera cannot frame an + // extent it does not see. Every axis gets at least a small neighbourhood. + if (box.defined) { + constexpr double min_extent = 10.; // mm + const Vec3d grow = (Vec3d::Constant(min_extent) - box.size()).cwiseMax(0.) * 0.5; + box.min -= grow; + box.max += grow; + } + return box; +} + const DesignSketchTool::FaceHighlight& DesignSketchTool::cached_face_highlight(FaceHighlight& cache, int body, std::vector faces) const { diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index cfcf2e8441..91adb207a0 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -2,6 +2,7 @@ #define slic3r_DesignSketchTool_hpp_ #include "libslic3r/Point.hpp" +#include "libslic3r/BoundingBox.hpp" #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/CAD/CadDocument.hpp" // CadBody for per-body solid picking #include @@ -24,6 +25,7 @@ class wxPoint; namespace Slic3r { class TriangleMesh; // fwd (libslic3r) — solid-pick mesh, non-owning pointer +class GLVolumeCollection; namespace GUI { @@ -226,6 +228,11 @@ public: void set_highlight_faces(const std::vector>& faces); // Every face drawn as selected, sorted: the committed pick's and the still-valid ones above. std::vector> selected_faces() const; + // What the canvas's Fit button frames: the selection — faces (the Feature tree's included), a + // body, edges, a vertex, a sketch region, the live sketch's picked entities — else everything + // on show: the visible bodies, the preview of the feature being edited and every sketch. + // Undefined when the tab shows none of it. `volumes` are the canvas's: bodies and preview. + BoundingBoxf3 fit_box(const GLVolumeCollection& volumes) const; // Move-body gizmo (M5): translate a whole body with three world-axis drag arrows // (X red / Y green / Z blue) anchored at the body centroid. Display-only — the host diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 269770fd61..63fb7230dc 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -10144,14 +10144,25 @@ void GLCanvas3D::_render_canvas_toolbar() ImTextureID z_hover_id = m_gizmos.get_icon_texture_id(m_is_dark ? GLGizmosManager::MENU_ICON_NAME::IC_CANVAS_ZOOM_DARK_HOVER : GLGizmosManager::MENU_ICON_NAME::IC_CANVAS_ZOOM_HOVER); if (ImGui::ImageButton3(z_normal_id, z_hover_id, btn_size)) { - select_view("plate"); - if (m_selection.is_empty()) { - if (m_canvas_type == ECanvasType::CanvasAssembleView) - zoom_to_volumes(); - else - zoom_to_bed(); - } else { - zoom_to_selection(); +#ifdef SLIC3R_CAD + // The Design tab selects and sketches outside the canvas's selection and volumes, so + // it names what to frame. Framed along the current view, which is often square to a + // sketch plane; an empty tab falls through to the bed. + const BoundingBoxf3 design_box = m_design_sketch_tool != nullptr ? m_design_sketch_tool->fit_box(m_volumes) : BoundingBoxf3(); + if (design_box.defined) + _zoom_to_box(design_box); + else +#endif + { + select_view("plate"); + if (m_selection.is_empty()) { + if (m_canvas_type == ECanvasType::CanvasAssembleView) + zoom_to_volumes(); + else + zoom_to_bed(); + } else { + zoom_to_selection(); + } } } else if (ImGui::IsItemHovered()) { auto tooltip_str_wx = _L("Fit camera to scene or selected object."); diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 566835f495..bd9ffafdd5 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -39,6 +39,10 @@ add_executable(${_TEST_NAME}_tests ../fff_print/test_helpers.cpp ) +if (SLIC3R_CAD) + target_sources(${_TEST_NAME}_tests PRIVATE test_design_sketch_tool.cpp) +endif () + if (MSVC) target_link_libraries(${_TEST_NAME}_tests Setupapi.lib) endif () diff --git a/tests/slic3rutils/test_design_sketch_tool.cpp b/tests/slic3rutils/test_design_sketch_tool.cpp new file mode 100644 index 0000000000..7b99cde012 --- /dev/null +++ b/tests/slic3rutils/test_design_sketch_tool.cpp @@ -0,0 +1,110 @@ +// Orca: This suite links libslic3r_gui; the Design tab's sketch tool needs no wx application or GL +// context until it renders. +#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 + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include "libslic3r/Point.hpp" +#include "slic3r/GUI/3DScene.hpp" +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" + +using namespace Slic3r; +using namespace Slic3r::GUI; +using Catch::Matchers::WithinAbs; + +namespace { + +SketchEntity circle(const Vec2d& c, double r) +{ + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = e.p0 = c; + e.radius = r; + return e; +} + +// Two committed sketches on XY: feature 1 a 10 mm circle at (50, 20), feature 2 a 5 mm one at (-60, 0). +void show_two_sketches(DesignSketchTool& tool) +{ + tool.set_display_sketches({ { { circle({ 50., 20. }, 10.) }, SketchPlane::XY(), 1 }, + { { circle({ -60., 0. }, 5.) }, SketchPlane::XY(), 2 } }); +} + +} // namespace + +TEST_CASE("Fit frames the picked sketch region, not the other sketches", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + tool.set_display_pick(1, 0); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(40., 0.5)); + CHECK_THAT(box.max.x(), WithinAbs(60., 0.5)); + CHECK_THAT(box.min.y(), WithinAbs(10., 0.5)); + CHECK_THAT(box.max.y(), WithinAbs(30., 0.5)); +} + +TEST_CASE("Fit frames every sketch when nothing is picked", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(-65., 0.5)); + CHECK_THAT(box.max.x(), WithinAbs(60., 0.5)); +} + +TEST_CASE("Fit frames the sketch being drawn along with the committed ones", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + // A 100 mm line up the XZ plane, whose y axis is world Z. + SketchEntity line; + line.p0 = { 0., 0. }; + line.p1 = { 0., 100. }; + tool.begin_edit({ line }, {}, SketchPlane::XZ()); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK_THAT(box.max.z(), WithinAbs(100., 1e-6)); + CHECK_THAT(box.min.x(), WithinAbs(-65., 0.5)); +} + +TEST_CASE("Fit gives a flat sketch depth, so it can be framed edge-on", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + tool.set_display_pick(2, 0); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK(box.size().z() > 0.); + CHECK_THAT(box.center().z(), WithinAbs(0., 1e-9)); +} + +TEST_CASE("Fit frames nothing when the Design tab shows nothing", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + CHECK_FALSE(tool.fit_box(no_bodies).defined); +} From e328b60992c67eccd9e0812cadafcfa3646bb1c8 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 14:19:46 +0800 Subject: [PATCH 08/30] Make the Design tab's reference planes readable where they cross The XY/XZ/YZ planes are cut along each other and drawn back to front, with lines along every crossing, and their fills are strong enough for the order to show. Before, they blended into one grey smear and were too pale to work with. --- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 222 +++++++++++++++--- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 16 ++ tests/slic3rutils/test_design_sketch_tool.cpp | 197 ++++++++++++++++ 3 files changed, 405 insertions(+), 30 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index d5f0399d6e..525a396bea 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -53,6 +53,7 @@ #include #include #include +#include #include "libslic3r/AppConfig.hpp" #include "libslic3r/Color.hpp" #include "slic3r/GUI/GLSelectionRectangle.hpp" @@ -5384,6 +5385,144 @@ void DesignSketchTool::drag_rib_handle(GLCanvas3D& canvas, const wxMouseEvent& e } // ---- Reference/base planes (Onshape-style default planes) ----------------------------- +namespace { +// One level of a BSP tree whose splitters are the planes themselves, in order: the painter's +// algorithm made exact for polygons that cross. Pieces of plane k (and of any plane lying in it) +// are in the splitter; every other piece is wholly on one side or is cut in two. Far side, then the +// splitter's own pieces, then the near side is back to front. A piece is only cut when it has +// corners strictly on both sides, so neither half can be degenerate. +void paint_back_to_front(std::vector&& in, size_t k, const std::vector& planes, double eps, + const Vec3d& eye, const Vec3d& forward, bool perspective, std::vector& out) +{ + if (in.empty()) + return; + if (k == planes.size()) { // not reached: every piece is in the splitter at its own plane's level + std::move(in.begin(), in.end(), std::back_inserter(out)); + return; + } + // The square's own normal: SketchPlane::normal is reversed on XZ, and the side tests only need + // one consistent choice. + const Vec3d n = planes[k].x_axis.cross(planes[k].y_axis).normalized(); + const double d = n.dot(planes[k].origin); + std::vector front, back, on; + for (PlanePiece& piece : in) { + if (piece.plane == int(k)) { + on.push_back(std::move(piece)); + continue; + } + std::vector s; + int sides = 0; + for (const Vec3d& c : piece.corners) { + s.push_back(n.dot(c) - d); + sides |= s.back() > eps ? 1 : s.back() < -eps ? 2 : 0; + } + if (sides == 0) + on.push_back(std::move(piece)); + else if (sides == 1) + front.push_back(std::move(piece)); + else if (sides == 2) + back.push_back(std::move(piece)); + else { + PlanePiece f{ piece.plane, {} }, b{ piece.plane, {} }; + const size_t m = piece.corners.size(); + for (size_t i = 0; i < m; ++i) { + const size_t j = (i + 1) % m; + const Vec3d& a = piece.corners[i]; + if (s[i] >= -eps) f.corners.push_back(a); + if (s[i] <= eps) b.corners.push_back(a); + if ((s[i] > eps && s[j] < -eps) || (s[i] < -eps && s[j] > eps)) { + const Vec3d x = a + (piece.corners[j] - a) * (s[i] / (s[i] - s[j])); + f.corners.push_back(x); + b.corners.push_back(x); + } + } + front.push_back(std::move(f)); + back.push_back(std::move(b)); + } + } + // An orthographic eye is at infinity behind the view direction. Camera::get_position() is a + // finite point there and can sit on the wrong side of a plane, so only the direction counts. + const bool eye_in_front = perspective ? n.dot(eye) - d > 0. : n.dot(forward) < 0.; + paint_back_to_front(std::move(eye_in_front ? back : front), k + 1, planes, eps, eye, forward, perspective, out); + std::move(on.begin(), on.end(), std::back_inserter(out)); + paint_back_to_front(std::move(eye_in_front ? front : back), k + 1, planes, eps, eye, forward, perspective, out); +} + +Vec2d in_frame(const SketchPlane& p, const Vec3d& x) { return Vec2d((x - p.origin).dot(p.x_axis), (x - p.origin).dot(p.y_axis)); } + +double plane_distance(const SketchPlane& p, const Vec3d& x) { return p.x_axis.cross(p.y_axis).normalized().dot(x - p.origin); } + +// Shrink the segment ab, which lies in p, to its part inside p's square (Liang-Barsky). False when +// nothing is left. +bool clip_to_square(Vec3d& a, Vec3d& b, const SketchPlane& p, double half, double eps) +{ + const Vec2d s = in_frame(p, a), d = in_frame(p, b) - s; + double t0 = 0., t1 = 1.; + for (int axis = 0; axis < 2; ++axis) + for (double sign : { -1., 1. }) { // keep sign * (s + t * d)[axis] <= half + const double room = half + eps - sign * s[axis], rate = sign * d[axis]; + if (rate > 0.) + t1 = std::min(t1, room / rate); + else if (rate < 0.) + t0 = std::max(t0, room / rate); + else if (room < 0.) + return false; + } + if (t1 - t0 < 1e-9) + return false; + const Vec3d ab = b - a; + b = a + ab * t1; + a = a + ab * t0; + return true; +} +} // namespace + +std::vector planes_back_to_front(const std::vector& planes, double half, + const Vec3d& eye, const Vec3d& forward, bool perspective) +{ + std::vector squares; + for (int i = 0; i < int(planes.size()); ++i) { + const SketchPlane& p = planes[i]; + squares.push_back({ i, { p.to_world(Vec2d(-half, -half)), p.to_world(Vec2d(half, -half)), + p.to_world(Vec2d(half, half)), p.to_world(Vec2d(-half, half)) } }); + } + const double eps = 1e-6 * std::max(half, 1.); + std::vector out; + paint_back_to_front(std::move(squares), 0, planes, eps, eye, forward, perspective, out); + + // What to outline: the piece's share of its square's border, and of where it crosses another + // square. Every other edge is a cut lying in the plane that made it, but the cut ran along that + // whole infinite plane, so it is clipped to that plane's square: a datum that never reaches a base + // plane gets no line across it. A piece is convex, so an edge with both ends on one side line of + // its square lies along that side. + for (PlanePiece& piece : out) { + const SketchPlane& own = planes[piece.plane]; + for (size_t i = 0; i < piece.corners.size(); ++i) { + const Vec3d& a = piece.corners[i]; + const Vec3d& b = piece.corners[(i + 1) % piece.corners.size()]; + const Vec2d fa = in_frame(own, a), fb = in_frame(own, b); + bool border = false; + for (int axis = 0; axis < 2; ++axis) + for (double side : { -half, half }) + border = border || (std::abs(fa[axis] - side) <= eps && std::abs(fb[axis] - side) <= eps); + if (border) { + piece.lines.emplace_back(a, b); + continue; + } + for (int k = 0; k < int(planes.size()); ++k) { + auto in_k = [&](const Vec3d& x) { return std::abs(plane_distance(planes[k], x)) <= 2. * eps; }; + // Not a plane the piece itself lies in: clipped to its own square, a cut is kept whole. + if (k == piece.plane || !in_k(a) || !in_k(b) || std::all_of(piece.corners.begin(), piece.corners.end(), in_k)) + continue; + Vec3d ca = a, cb = b; + if (clip_to_square(ca, cb, planes[k], half, eps)) + piece.lines.emplace_back(ca, cb); + } + } + } + return out; +} + void DesignSketchTool::set_base_pick(std::vector planes, std::vector bases, std::vector labels) { @@ -5419,49 +5558,72 @@ double DesignSketchTool::dbp_half_extent() const return half; } -// Draw the reference planes as large translucent labelled squares; the hovered one brightens. +// Draw the reference planes as labelled translucent squares outlined in their own hue; the hovered +// one brightens. Depth testing is off (they overlay the bed and any bodies), so draw order is the +// blend order — and the planes cross, so they go down piece by piece, back to front. void DesignSketchTool::render_base_pick() { if (!m_dbp_active || m_dbp_planes.empty()) return; using EPT = GLModel::Geometry::EPrimitiveType; using EVL = GLModel::Geometry::EVertexLayout; const double H = dbp_half_extent(); - // Onshape-ish per-plane tints: XY blue, XZ green, YZ red (keyed by base index 0/1/2; datums grey). - auto tint = [](int base, bool hot) -> ColorRGBA { - float a = hot ? 0.10f : 0.047f; // base planes kept faint (reduced ~2/3 from 0.30/0.14) - if (base == 0) return ColorRGBA(0.30f, 0.55f, 0.95f, a); - if (base == 1) return ColorRGBA(0.35f, 0.80f, 0.45f, a); - if (base == 2) return ColorRGBA(0.92f, 0.42f, 0.42f, a); - return ColorRGBA(0.70f, 0.72f, 0.78f, a); + // Two layers of alpha a blended in either order differ by only a^2 of their colour difference, + // so a faint fill hides which plane is in front however well the pieces are sorted: at 0.16 that + // is under 3%, and the crossing planes read as one grey smear. At 0.35 it is ~12%, and the bed + // grid still reads through all three. + constexpr float kFillAlpha = 0.35f, kFillAlphaHot = 0.50f; + constexpr float kLineAlpha = 0.90f, kLineAlphaHot = 1.00f; + // Onshape-ish per-plane hues: XY blue, XZ green, YZ red (keyed by base index 0/1/2; datums grey). + auto hue = [](int base) -> ColorRGBA { + if (base == 0) return ColorRGBA(0.30f, 0.55f, 0.95f, 1.0f); + if (base == 1) return ColorRGBA(0.35f, 0.80f, 0.45f, 1.0f); + if (base == 2) return ColorRGBA(0.92f, 0.42f, 0.42f, 1.0f); + return ColorRGBA(0.70f, 0.72f, 0.78f, 1.0f); }; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d vd = cam.get_dir_forward(); + const double hw = 1.5 / std::max(cam.get_zoom(), 1e-6); // outline ribbon, as on datum planes glsafe(::glDisable(GL_DEPTH_TEST)); glsafe(::glDisable(GL_CULL_FACE)); glsafe(::glEnable(GL_BLEND)); // alpha is ignored without this glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); - const SketchPlane saved_plane = m_plane; - for (size_t i = 0; i < m_dbp_planes.size(); ++i) { - const SketchPlane& p = m_dbp_planes[i]; - const Vec3d q0 = p.to_world(Vec2d(-H, -H)), q1 = p.to_world(Vec2d(H, -H)), - q2 = p.to_world(Vec2d(H, H)), q3 = p.to_world(Vec2d(-H, H)); - GLModel::Geometry quad; quad.format = { EPT::Triangles, EVL::P3 }; - quad.add_vertex((Vec3f)q0.cast()); quad.add_vertex((Vec3f)q1.cast()); - quad.add_vertex((Vec3f)q2.cast()); quad.add_vertex((Vec3f)q3.cast()); - quad.add_triangle(0, 1, 2); quad.add_triangle(0, 2, 3); - GLModel m; m.init_from(std::move(quad)); - const bool hot = (int(i) == m_dbp_hover); - const int base = (i < m_dbp_base.size()) ? m_dbp_base[i] : -1; - m.set_color(tint(base, hot)); - m.render(); + for (const PlanePiece& piece : planes_back_to_front(m_dbp_planes, H, cam.get_position(), vd, + cam.get_type() == Camera::EType::Perspective)) { + const bool hot = piece.plane == m_dbp_hover; + ColorRGBA col = hue(piece.plane < int(m_dbp_base.size()) ? m_dbp_base[piece.plane] : -1); + GLModel::Geometry fill; fill.format = { EPT::Triangles, EVL::P3 }; + for (const Vec3d& q : piece.corners) fill.add_vertex((Vec3f)q.cast()); + for (unsigned int i = 1; i + 1 < piece.corners.size(); ++i) fill.add_triangle(0, i, i + 1); // convex: a fan + GLModel fm; fm.init_from(std::move(fill)); + col.a(hot ? kFillAlphaHot : kFillAlpha); + fm.set_color(col); + fm.render(); - // Label near the top-left corner, drawn in the plane (draw_text lifts through m_plane). - if (i < m_dbp_labels.size() && !m_dbp_labels[i].empty()) { - m_plane = p; - const double th = H * 0.10; - const ColorRGBA lc = tint(base, true); ColorRGBA lcs(lc.r(), lc.g(), lc.b(), 1.0f); - draw_text(m_line_model, m_dbp_labels[i], Vec2d(-H + th * 2.0, H - th * 1.6), th, lcs); + // Its outline and crossing lines go down with it, so a line behind another plane is tinted + // by it exactly like the plane it lies on. + std::vector> strokes; + for (const auto& [a, b] : piece.lines) strokes.push_back({ a, b }); + GLModel::Geometry outline; + append_ribbons(outline, -1, strokes, vd, Vec3d::Zero(), hw); // body -1: already world coordinates + if (!outline.is_empty()) { + GLModel om; om.init_from(std::move(outline)); + col.a(hot ? kLineAlphaHot : kLineAlpha); + om.set_color(col); + om.render(); } } - m_plane = saved_plane; // draw_text renders each label immediately (draw_strokes self-renders) + + // Label near the top-left corner, drawn in the plane (draw_text lifts through m_plane). Labels + // are ImGui chips, on top of the planes whatever the order here. + const SketchPlane saved_plane = m_plane; + const double th = H * 0.10; + for (size_t i = 0; i < m_dbp_planes.size() && i < m_dbp_labels.size(); ++i) { + if (m_dbp_labels[i].empty()) continue; + m_plane = m_dbp_planes[i]; + draw_text(m_line_model, m_dbp_labels[i], Vec2d(-H + th * 2.0, H - th * 1.6), th, + hue(i < m_dbp_base.size() ? m_dbp_base[i] : -1)); + } + m_plane = saved_plane; glsafe(::glDisable(GL_BLEND)); } @@ -5474,7 +5636,7 @@ int DesignSketchTool::hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& // THE LABEL WINS, and it has to. Each plane's name is a screen-space chip centred on its // own in-plane anchor, and it is the one part of a base plane a user aims at deliberately — - // the quads are near-transparent and overlap everywhere. Ray-casting the quads alone made + // the quads are translucent and overlap everywhere. Ray-casting the quads alone made // the labels pure decoration: on a fresh document at 1920x1060, clicking "XY" reported // "XZ plane selected", because the XZ quad happens to sit in front at that pixel. Nothing // about the click was ambiguous to the user; they clicked the word XY. diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 91adb207a0..8d575241e9 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -54,6 +54,22 @@ inline ColorRGBA design_idle_face_color() { return ColorRGBA(0.72f, 0.76f, 0.80f, 0.14f); } + +// A convex piece of the square drawn on planes[plane], and the segments to outline with it: its share +// of the square's border and of the lines where it crosses the other squares. +struct PlanePiece +{ + int plane; + std::vector corners; + std::vector> lines; +}; +// The squares of half-extent `half` on `planes`, cut where they cross one another and ordered back +// to front for an eye at `eye` (perspective) or looking along `forward` (orthographic). Translucent +// planes that cross cannot be drawn in any per-plane order: each is partly in front of and partly +// behind the others. Drawn piece by piece in this order, each one tints only what is behind it. +std::vector planes_back_to_front(const std::vector& planes, double half, + const Vec3d& eye, const Vec3d& forward, bool perspective); + class DesignSketchTool { public: enum class Mode { Select, Dimension, Polyline, Line, CornerRect, CenterRect, ObliqueRect, diff --git a/tests/slic3rutils/test_design_sketch_tool.cpp b/tests/slic3rutils/test_design_sketch_tool.cpp index 7b99cde012..3446d813d2 100644 --- a/tests/slic3rutils/test_design_sketch_tool.cpp +++ b/tests/slic3rutils/test_design_sketch_tool.cpp @@ -13,8 +13,19 @@ #endif #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include + #include "libslic3r/BoundingBox.hpp" #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/Point.hpp" @@ -24,6 +35,7 @@ using namespace Slic3r; using namespace Slic3r::GUI; using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; namespace { @@ -43,6 +55,112 @@ void show_two_sketches(DesignSketchTool& tool) { { circle({ -60., 0. }, 5.) }, SketchPlane::XY(), 2 } }); } +// The Design tab's base planes, all through one origin, as DesignPanel shows them. +std::vector base_planes() { return { SketchPlane::XY(), SketchPlane::XZ(), SketchPlane::YZ() }; } + +struct View +{ + Vec3d eye; + Vec3d forward; + bool perspective; +}; + +// Eyes in four octants, off every plane, plus two orthographic directions. +const View kViews[] = { + { Vec3d(300., -400., 250.), Vec3d(-300., 400., -250.).normalized(), true }, + { Vec3d(-350., -200., 300.), Vec3d(350., 200., -300.).normalized(), true }, + { Vec3d(250., 300., -200.), Vec3d(-250., -300., 200.).normalized(), true }, + { Vec3d(-300., 350., -250.), Vec3d(300., -350., 250.).normalized(), true }, + { Vec3d::Zero(), Vec3d(-0.5, 0.7, -0.5).normalized(), false }, + { Vec3d::Zero(), Vec3d(0.3, 0.4, 0.85).normalized(), false }, +}; + +double area(const PlanePiece& piece, const SketchPlane& plane) +{ + Vec3d sum = Vec3d::Zero(); + for (size_t i = 0; i < piece.corners.size(); ++i) + sum += piece.corners[i].cross(piece.corners[(i + 1) % piece.corners.size()]); + return 0.5 * std::abs(sum.dot(plane.x_axis.cross(plane.y_axis))); +} + +// How far along the ray (from, unit dir) it crosses `piece`, or nothing if it misses. +std::optional hit_distance(const PlanePiece& piece, const SketchPlane& plane, const Vec3d& from, const Vec3d& dir) +{ + const Vec3d n = plane.x_axis.cross(plane.y_axis); + const double dn = n.dot(dir); + if (std::abs(dn) < 1e-9) + return std::nullopt; + const double t = n.dot(piece.corners.front() - from) / dn; + if (t <= 0.) + return std::nullopt; + const Vec3d x = from + dir * t; + double lo = 0., hi = 0.; + for (size_t i = 0; i < piece.corners.size(); ++i) { + const Vec3d& a = piece.corners[i]; + const Vec3d& b = piece.corners[(i + 1) % piece.corners.size()]; + const double s = (b - a).cross(x - a).dot(n); + lo = std::min(lo, s); + hi = std::max(hi, s); + } + if (lo < 0. && hi > 0.) + return std::nullopt; // outside one of the edges + return t; +} + +// Whether x lies on one of the segments the pieces are outlined with. +bool outlined(const std::vector& pieces, const Vec3d& x) +{ + for (const PlanePiece& piece : pieces) + for (const auto& [a, b] : piece.lines) { + const Vec3d ab = b - a; + const double t = std::clamp((x - a).dot(ab) / ab.squaredNorm(), 0., 1.); + if ((a + ab * t - x).norm() < 1e-6) + return true; + } + return false; +} + +std::vector pieces_of(const std::vector& planes) +{ + const View& view = kViews[0]; + return planes_back_to_front(planes, 75., view.eye, view.forward, view.perspective); +} + +struct SightLines +{ + int overlapping = 0; // sight lines through two or more pieces, where draw order matters + int out_of_order = 0; // ...of which meet a nearer piece before a farther one +}; + +// Translucent pieces blend correctly only if, along every line of sight, each piece is drawn after +// every piece behind it. +SightLines sight_lines(const std::vector& planes, double half, const View& view) +{ + const std::vector pieces = planes_back_to_front(planes, half, view.eye, view.forward, view.perspective); + std::mt19937 rng(7); + std::uniform_real_distribution coord(-0.95 * half, 0.95 * half); + SightLines seen; + for (int r = 0; r < 500; ++r) { + const Vec3d target(coord(rng), coord(rng), coord(rng)); + const Vec3d from = view.perspective ? view.eye : Vec3d(target - view.forward * (10. * half)); + const Vec3d dir = (target - from).normalized(); + double last = std::numeric_limits::max(); + int hits = 0; + bool ok = true; + for (const PlanePiece& piece : pieces) + if (const std::optional t = hit_distance(piece, planes[piece.plane], from, dir)) { + ok = ok && *t <= last + 1e-6 * half; + last = *t; + ++hits; + } + if (hits >= 2) { + ++seen.overlapping; + seen.out_of_order += ok ? 0 : 1; + } + } + return seen; +} + } // namespace TEST_CASE("Fit frames the picked sketch region, not the other sketches", "[DesignSketchTool]") @@ -108,3 +226,82 @@ TEST_CASE("Fit frames nothing when the Design tab shows nothing", "[DesignSketch GLVolumeCollection no_bodies; CHECK_FALSE(tool.fit_box(no_bodies).defined); } + +TEST_CASE("Crossing base planes are drawn back to front from any viewpoint", "[DesignSketchTool]") +{ + const View& view = kViews[GENERATE(range(0, int(std::size(kViews))))]; + const SightLines seen = sight_lines(base_planes(), 75., view); + CHECK(seen.overlapping >= 200); // of the 500: most lines of sight into the planes cross two + CHECK(seen.out_of_order == 0); +} + +TEST_CASE("Datum planes are drawn back to front among the base planes", "[DesignSketchTool]") +{ + // A datum parallel to XY 30 mm up, and one tilted 30 degrees about X through (0, 0, 10). + std::vector planes = base_planes(); + SketchPlane raised = SketchPlane::XY(); + raised.origin = Vec3d(0., 0., 30.); + SketchPlane tilted; + tilted.origin = Vec3d(0., 0., 10.); + tilted.y_axis = Vec3d(0., std::cos(M_PI / 6.), std::sin(M_PI / 6.)); + tilted.normal = tilted.x_axis.cross(tilted.y_axis); + planes.push_back(raised); + planes.push_back(tilted); + + const View& view = kViews[GENERATE(range(0, int(std::size(kViews))))]; + const SightLines seen = sight_lines(planes, 75., view); + CHECK(seen.overlapping >= 200); + CHECK(seen.out_of_order == 0); +} + +TEST_CASE("Base planes are outlined along every line where they cross", "[DesignSketchTool]") +{ + // XY, XZ and YZ cross along the three axes, all through the middle of each 75 mm half-square. + const std::vector pieces = pieces_of(base_planes()); + int missed = 0; + for (double t = -70.; t <= 70.; t += 10.) + for (const Vec3d& axis : { Vec3d(1., 0., 0.), Vec3d(0., 1., 0.), Vec3d(0., 0., 1.) }) + missed += outlined(pieces, axis * t) ? 0 : 1; + CHECK(missed == 0); +} + +TEST_CASE("A datum clear of the base planes gets no lines across it", "[DesignSketchTool]") +{ + // Parallel to YZ at x = 100, past the 75 mm half-squares of XY and XZ: the infinite XY and XZ + // planes still cut it, along z = 0 and y = 0, but the squares never meet. + std::vector planes = base_planes(); + SketchPlane beyond = SketchPlane::YZ(); + beyond.origin = Vec3d(100., 0., 0.); + planes.push_back(beyond); + + const std::vector pieces = pieces_of(planes); + CHECK_FALSE(outlined(pieces, Vec3d(100., 30., 0.))); + CHECK_FALSE(outlined(pieces, Vec3d(100., 0., 30.))); +} + +TEST_CASE("A line where two squares cross stops where either square ends", "[DesignSketchTool]") +{ + // Parallel to XY 30 mm up and moved 60 mm along X, so it spans x = -15..135. It meets XZ along + // y = 0, z = 30, but XZ's square only reaches x = 75. + std::vector planes = base_planes(); + SketchPlane raised = SketchPlane::XY(); + raised.origin = Vec3d(60., 0., 30.); + planes.push_back(raised); + + const std::vector pieces = pieces_of(planes); + CHECK(outlined(pieces, Vec3d(0., 0., 30.))); + CHECK(outlined(pieces, Vec3d(70., 0., 30.))); + CHECK_FALSE(outlined(pieces, Vec3d(100., 0., 30.))); +} + +TEST_CASE("Cutting the base planes along each other keeps every plane whole", "[DesignSketchTool]") +{ + const std::vector planes = base_planes(); + const double half = 75.; + const View& view = kViews[0]; + std::vector covered(planes.size(), 0.); + for (const PlanePiece& piece : planes_back_to_front(planes, half, view.eye, view.forward, view.perspective)) + covered[piece.plane] += area(piece, planes[piece.plane]); + for (double a : covered) + CHECK_THAT(a, WithinRel(4. * half * half, 1e-9)); +} From 0c2e26fa3df4f75c2da92c696fb395627b6a4110 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 14:48:48 +0800 Subject: [PATCH 09/30] Stop the Design tab's move arrows from jumping the body on the first drag Grabbing an arrow anywhere along its length snapped the body's centre to that point as soon as the mouse moved. The body now moves by how far the cursor travels from where the arrow was grabbed, including when it is grabbed while looking straight down the axis. --- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 61 +++++++++++-------- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 6 +- tests/slic3rutils/test_design_sketch_tool.cpp | 39 ++++++++++++ 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 525a396bea..ba6230c2ad 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -78,6 +78,7 @@ static bool point_in_poly(const Vec2d& q, const std::vector& poly); static bool ray_triangle(const Vec3d& ro, const Vec3d& rd, const Vec3d& v0, const Vec3d& v1, const Vec3d& v2, double& t); static double ray_segment_dist3(const Vec3d& ro, const Vec3d& rd, const Vec3d& a, const Vec3d& b); +static double ray_axis_proj(const Linef3& ray, const Vec3d& anchor, const Vec3d& dir); // Project a world-space point to canvas screen pixels (device px, GL viewport units; // origin top-left after the GL y-flip). Mirrors GLCanvas3D's world->screen pattern: @@ -5819,19 +5820,22 @@ bool DesignSketchTool::hit_test_move_arrow(GLCanvas3D& canvas, const wxMouseEven axis = best; return true; } -// Skew-line closest point of the mouse ray to the axis line through the ORIGINAL centroid -// -> signed offset along that axis (no clamp; a body can move either way). -void DesignSketchTool::drag_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis) +// Record how far along the arrow it was grabbed (NaN while the camera looks down the axis; the +// first drag sample that projects stands in), so the body's centre does not snap to the grab. +void DesignSketchTool::grab_move_arrow(int axis, const Linef3& ray) { - const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); - const Vec3d ro = r.a, rd = r.b - r.a; - const Vec3d axes[3] = { Vec3d::UnitX(), Vec3d::UnitY(), Vec3d::UnitZ() }; - const Vec3d e = axes[axis]; - const Vec3d w0 = m_mv_base - ro; - const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); - const double denom = a * c - b * b; - if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave offset as-is - m_mv_offset[axis] = (b * ee - c * dd) / denom; + m_mv_drag = axis; + m_mv_grab_along = ray_axis_proj(ray, m_mv_base + m_mv_offset, Vec3d::Unit(axis)); +} + +// Slide the body along the grabbed axis by the cursor's travel (no clamp; it can move either way). +void DesignSketchTool::drag_move_arrow(const Linef3& ray) +{ + if (m_mv_drag < 0 || m_mv_drag > 2) return; + const double proj = ray_axis_proj(ray, m_mv_base + m_mv_offset, Vec3d::Unit(m_mv_drag)); + if (std::isnan(proj)) return; // camera ∥ axis: leave offset as-is + if (std::isnan(m_mv_grab_along)) { m_mv_grab_along = proj; return; } + m_mv_offset[m_mv_drag] += proj - m_mv_grab_along; if (on_body_move_changed) on_body_move_changed(m_mv_body, compose_move_xform()); } @@ -6201,21 +6205,10 @@ int DesignSketchTool::hit_test_hole_handle(GLCanvas3D& canvas, const wxMouseEven return best; } -// Skew-line closest point of the mouse ray to an axis (anchor + t*dir) -> signed distance along -// dir. NaN when the camera is ~parallel to the axis (no meaningful projection). double DesignSketchTool::hole_axis_proj(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec3d& anchor, const Vec3d& dir) const { - const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); - const Vec3d ro = r.a, rd = r.b - r.a; - const Vec3d e = dir; - const Vec3d w0 = anchor - ro; - const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); - const double denom = a * c - b * b; - // Relative near-parallel guard: when the camera ray is ~along the axis (e.g. the depth axis in - // top view) denom collapses; a tiny absolute floor lets a huge, unstable projection through. - if (std::abs(denom) < 1e-4 * std::max(a * c, 1e-12)) return std::nan(""); - return (b * ee - c * dd) / denom; + return ray_axis_proj(canvas.mouse_ray(Point(evt.GetX(), evt.GetY())), anchor, dir); } void DesignSketchTool::start_hole_drag(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) @@ -10179,6 +10172,21 @@ static double ray_segment_dist3(const Vec3d& ro, const Vec3d& rd, const Vec3d& a return (pr - ps).norm(); } +// Skew-line closest point of the mouse ray to an axis (anchor + t*dir) -> signed distance along +// dir. NaN when the camera is ~parallel to the axis (no meaningful projection). +static double ray_axis_proj(const Linef3& ray, const Vec3d& anchor, const Vec3d& dir) +{ + const Vec3d ro = ray.a, rd = ray.vector(); + const Vec3d e = dir; + const Vec3d w0 = anchor - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + // Relative near-parallel guard: when the camera ray is ~along the axis (e.g. the depth axis in + // top view) denom collapses; a tiny absolute floor lets a huge, unstable projection through. + if (std::abs(denom) < 1e-4 * std::max(a * c, 1e-12)) return std::nan(""); + return (b * ee - c * dd) / denom; +} + // Screen-plane distance from p to a sketch entity, for click picking in Constrain // mode. Circles/arcs measure distance to the ring; points to their position. static double entity_pick_dist(const Vec2d& p, const SketchEntity& e) @@ -10687,7 +10695,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // exits move mode. A LeftDown that misses the arrows falls through to solid re-pick. if (m_mv_active) { if (m_mv_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { - if (m_mv_drag < 3) drag_move_arrow(canvas, evt, m_mv_drag); + if (m_mv_drag < 3) drag_move_arrow(canvas.mouse_ray(Point(evt.GetX(), evt.GetY()))); else drag_move_arc(canvas, evt, m_mv_drag - 3); return true; } @@ -10705,7 +10713,8 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { int axis = -1; if (hit_test_move_arrow(canvas, evt, axis)) { // translate arrows win over rings - m_mv_drag = axis; m_mv_press_x = evt.GetX(); m_mv_press_y = evt.GetY(); + grab_move_arrow(axis, canvas.mouse_ray(Point(evt.GetX(), evt.GetY()))); + m_mv_press_x = evt.GetX(); m_mv_press_y = evt.GetY(); return true; } if (hit_test_move_arc(canvas, evt, axis)) { diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 8d575241e9..88c003bc36 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -3,6 +3,7 @@ #include "libslic3r/Point.hpp" #include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Line.hpp" #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/CAD/CadDocument.hpp" // CadBody for per-body solid picking #include @@ -260,6 +261,9 @@ public: void clear_move_gizmo(); bool moving_body() const { return m_mv_active; } int move_body_index() const { return m_mv_body; } + // Press, then drag, translate arrow `axis` (0..2 = X/Y/Z) with the mouse ray under the cursor. + void grab_move_arrow(int axis, const Linef3& ray); + void drag_move_arrow(const Linef3& ray); std::function on_body_move_changed; // Fired on each cycle change: (level 0=None/1=Whole/2=Face/3=Edge, body index, face id, edge id). std::function on_solid_selection_changed; @@ -1432,6 +1436,7 @@ private: int m_mv_drag{-1}; // 0..2 = X/Y/Z arrow, 3..5 = X/Y/Z ring, -1 none double m_mv_radius{0.0}; // body bounding-sphere radius (mm); 0 = unknown int m_mv_press_x{0}, m_mv_press_y{0}; + double m_mv_grab_along{0.0}; // arrow grab point's distance from the anchor; NaN = unknown Transform3d compose_move_xform() const; // T(offset)*T(pivot)*rot*T(-pivot)*base_xform void ring_basis(int axis, Vec3d& e, Vec3d& u, Vec3d& v) const; // world axis + in-plane basis void render_move_gizmo(); @@ -1439,7 +1444,6 @@ private: double move_gizmo_arm(const Camera& cam) const; bool hit_test_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const; bool hit_test_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const; - void drag_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis); void drag_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis); bool arc_mouse_angle(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis, double& ang) const; void open_move_editor(int axis); diff --git a/tests/slic3rutils/test_design_sketch_tool.cpp b/tests/slic3rutils/test_design_sketch_tool.cpp index 3446d813d2..685f661fc3 100644 --- a/tests/slic3rutils/test_design_sketch_tool.cpp +++ b/tests/slic3rutils/test_design_sketch_tool.cpp @@ -28,6 +28,7 @@ #include "libslic3r/BoundingBox.hpp" #include "libslic3r/CAD/SketchEngine.hpp" +#include "libslic3r/Line.hpp" #include "libslic3r/Point.hpp" #include "slic3r/GUI/3DScene.hpp" #include "slic3r/GUI/CAD/DesignSketchTool.hpp" @@ -305,3 +306,41 @@ TEST_CASE("Cutting the base planes along each other keeps every plane whole", "[ for (double a : covered) CHECK_THAT(a, WithinRel(4. * half * half, 1e-9)); } + +TEST_CASE("A move arrow drag moves the body by the cursor's travel, wherever the arrow is grabbed", "[DesignSketchTool]") +{ + DesignSketchTool tool; + Transform3d moved = Transform3d::Identity(); + tool.on_body_move_changed = [&moved](int, const Transform3d& xform) { moved = xform; }; + tool.set_move_gizmo(0, Vec3d::Zero(), Transform3d::Identity(), 10.); + // Looking straight down onto the X arrow, the cursor over x = `x` on it. + const auto ray_at = [](double x) { return Linef3(Vec3d(x, 0., 100.), Vec3d(x, 0., 0.)); }; + + // Grabbed 12 mm out from the body centre: a 1 mm move moves the body 1 mm, not 13. + tool.grab_move_arrow(0, ray_at(12.)); + tool.drag_move_arrow(ray_at(13.)); + CHECK_THAT(moved.translation().x(), WithinAbs(1., 1e-9)); + tool.drag_move_arrow(ray_at(17.)); + CHECK_THAT(moved.translation().x(), WithinAbs(5., 1e-9)); + + // The next drag carries on from where the body was left: grabbed 3 mm past it, moved 2 mm. + tool.grab_move_arrow(0, ray_at(8.)); + tool.drag_move_arrow(ray_at(10.)); + CHECK_THAT(moved.translation().x(), WithinAbs(7., 1e-9)); +} + +TEST_CASE("A move arrow pressed while looking down its axis does not jump on the first move", "[DesignSketchTool]") +{ + DesignSketchTool tool; + Transform3d moved = Transform3d::Identity(); + tool.on_body_move_changed = [&moved](int, const Transform3d& xform) { moved = xform; }; + tool.set_move_gizmo(0, Vec3d::Zero(), Transform3d::Identity(), 10.); + const auto ray_at = [](double x) { return Linef3(Vec3d(x, 0., 100.), Vec3d(x, 0., 0.)); }; + + // The press projects nowhere on the X axis, so the first move only finds where it was grabbed. + tool.grab_move_arrow(0, Linef3(Vec3d(100., 0., 0.), Vec3d::Zero())); + tool.drag_move_arrow(ray_at(13.)); + CHECK_THAT(moved.translation().x(), WithinAbs(0., 1e-9)); + tool.drag_move_arrow(ray_at(15.)); + CHECK_THAT(moved.translation().x(), WithinAbs(2., 1e-9)); +} From 250fed88086bc3846a59fa6d337dc22f33fa9bda Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 16:30:29 +0800 Subject: [PATCH 10/30] Show the Design tab's reference planes as separate labelled squares around the origin The XY, XZ and YZ planes no longer cross through the bed. Each is a small square in its axis colour, set off from the axes into the corner that faces the default front view, with its name written in the plane. Dash-dot axes run between the squares and replace the bed's axis triad while they show. Hovering a plane greys it and selecting one makes it solid, and picking a solid face now clears a previously picked plane. --- src/slic3r/GUI/CAD/DesignCanvas.cpp | 5 + src/slic3r/GUI/CAD/DesignCanvas.hpp | 1 + src/slic3r/GUI/CAD/DesignPanel.cpp | 18 +- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 459 +++++++++++------- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 43 +- src/slic3r/GUI/GLCanvas3D.cpp | 5 +- tests/slic3rutils/test_design_sketch_tool.cpp | 174 ++++--- 7 files changed, 463 insertions(+), 242 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index dac8060619..7819cc248a 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -1037,6 +1037,11 @@ void DesignCanvas::set_on_datum_base_picked(std::function cb) m_sketch_tool.on_datum_base_picked = std::move(cb); } +void DesignCanvas::set_selected_base(std::function cb) +{ + m_sketch_tool.selected_base = std::move(cb); +} + void DesignCanvas::set_on_sketch_exit(std::function cb) { m_sketch_tool.on_exit = std::move(cb); diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index f5abb14a95..08ae1ea627 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -238,6 +238,7 @@ public: std::vector labels = {}); // clickable labelled reference planes void clear_base_pick(); void set_on_datum_base_picked(std::function cb); + void set_selected_base(std::function cb); // the reference plane drawn selected, or -1 void set_on_sketch_exit(std::function cb); // Esc -> exit the tool void set_on_sketch_exit_refused(std::function cb); // Esc declined: sketch has work void set_on_sketch_notice(std::function cb); // tool refusals/side effects diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index c93ee937d5..04862917d0 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -3717,8 +3717,13 @@ DesignPanel::DesignPanel(wxWindow* parent) m_pick_face_body = (level >= 1) ? body : -1; m_pick_face = (level >= 1) ? face : -1; // Last pick wins: a leftover loop pick would block Extrude's face push/pull branch, so - // Extrude would extrude a sketch instead of push/pulling the clicked face. - if (level >= 1) m_viewport->clear_loop_pick(); + // Extrude would extrude a sketch instead of push/pulling the clicked face. And a reference + // plane picked before stops being the one chosen, as a plane pick drops a face: otherwise it + // would come back as the sketch plane, and be drawn selected, once this pick is let go. + if (level >= 1) { + m_viewport->clear_loop_pick(); + m_plane_picked = false; + } // 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 @@ -4005,6 +4010,15 @@ DesignPanel::DesignPanel(wxWindow* parent) m_status->Refresh(); } }); + // The reference plane drawn selected: the Plane card's base, or else the plane a sketch would go + // on — chosen, and not overridden by a picked face (sketch_plane_target's test, without its + // per-face OCCT lookup, as this is asked every frame). + m_viewport->set_selected_base([this] { + if (m_active == Tool::Plane) + return m_plane_base != nullptr && m_pl_faceA < 0 ? m_plane_base->GetSelection() : -1; + const bool face = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) || (m_pick_face >= 0 && m_pick_face_body >= 0); + return m_plane_picked && !face ? m_ref_plane : -1; + }); // Move-body gizmo (M5): each drag/edit reports the body's new translation. Store it as a // display-only per-body transform and re-feed the moved meshes (the OCCT shape is untouched, diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index ba6230c2ad..baab2e7e9b 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -30,7 +30,7 @@ #include #include "libslic3r/TriangleMesh.hpp" -#include +#include #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/Point.hpp" #include @@ -5385,8 +5385,12 @@ void DesignSketchTool::drag_rib_handle(GLCanvas3D& canvas, const wxMouseEvent& e if (on_rib_thickness_changed) on_rib_thickness_changed(m_rb_thickness); } -// ---- Reference/base planes (Onshape-style default planes) ----------------------------- +// ---- Reference/base planes ------------------------------------------------------------- namespace { +// `text` in the stroke font, `height` tall and centred on the origin; `size` is its extent. With the +// font, further down. +std::vector> text_strokes(const std::string& text, double height, Vec2d& size); + // One level of a BSP tree whose splitters are the planes themselves, in order: the painter's // algorithm made exact for polygons that cross. Pieces of plane k (and of any plane lying in it) // are in the splitter; every other piece is wholly on one side or is cut in two. Far side, then the @@ -5449,33 +5453,21 @@ void paint_back_to_front(std::vector&& in, size_t k, const std::vect paint_back_to_front(std::move(eye_in_front ? front : back), k + 1, planes, eps, eye, forward, perspective, out); } -Vec2d in_frame(const SketchPlane& p, const Vec3d& x) { return Vec2d((x - p.origin).dot(p.x_axis), (x - p.origin).dot(p.y_axis)); } +// A base square's name: its cap height, and where the text is centred in the square's frame — inset +// from the frame's (+x, +y) corner, which lies by an axis toward its far end or at the outer corner, +// away from the other two names. +double label_height(double half) { return 0.30 * half; } +Vec2d label_centre(double half, const Vec2d& size) { return Vec2d::Constant(0.88 * half) - 0.5 * size; } -double plane_distance(const SketchPlane& p, const Vec3d& x) { return p.x_axis.cross(p.y_axis).normalized().dot(x - p.origin); } +// A datum's name is not in the stroke font: it is an ImGui chip anchored at this point of its square. +Vec2d datum_label_anchor(double half) { return Vec2d(-0.8 * half, 0.84 * half); } -// Shrink the segment ab, which lies in p, to its part inside p's square (Liang-Barsky). False when -// nothing is left. -bool clip_to_square(Vec3d& a, Vec3d& b, const SketchPlane& p, double half, double eps) -{ - const Vec2d s = in_frame(p, a), d = in_frame(p, b) - s; - double t0 = 0., t1 = 1.; - for (int axis = 0; axis < 2; ++axis) - for (double sign : { -1., 1. }) { // keep sign * (s + t * d)[axis] <= half - const double room = half + eps - sign * s[axis], rate = sign * d[axis]; - if (rate > 0.) - t1 = std::min(t1, room / rate); - else if (rate < 0.) - t0 = std::max(t0, room / rate); - else if (room < 0.) - return false; - } - if (t1 - t0 < 1e-9) - return false; - const Vec3d ab = b - a; - b = a + ab * t1; - a = a + ab * t0; - return true; -} +bool is_base_plane(int base) { return base >= 0 && base < 3; } + +// The octant the base squares sit in. Each name is written facing x_axis x y_axis, which is +Z for +// XY, -Y for XZ and +X for YZ: all three point into it, so a camera there looks into the corner the +// squares form and reads every name, and from the front XY lies below the X axis rather than behind XZ. +Vec3d reference_octant() { return Vec3d(1., -1., 1.); } } // namespace std::vector planes_back_to_front(const std::vector& planes, double half, @@ -5487,41 +5479,52 @@ std::vector planes_back_to_front(const std::vector& pla squares.push_back({ i, { p.to_world(Vec2d(-half, -half)), p.to_world(Vec2d(half, -half)), p.to_world(Vec2d(half, half)), p.to_world(Vec2d(-half, half)) } }); } - const double eps = 1e-6 * std::max(half, 1.); std::vector out; - paint_back_to_front(std::move(squares), 0, planes, eps, eye, forward, perspective, out); + paint_back_to_front(std::move(squares), 0, planes, 1e-6 * std::max(half, 1.), eye, forward, perspective, out); + return out; +} - // What to outline: the piece's share of its square's border, and of where it crosses another - // square. Every other edge is a cut lying in the plane that made it, but the cut ran along that - // whole infinite plane, so it is clipped to that plane's square: a datum that never reaches a base - // plane gets no line across it. A piece is convex, so an edge with both ends on one side line of - // its square lies along that side. - for (PlanePiece& piece : out) { - const SketchPlane& own = planes[piece.plane]; - for (size_t i = 0; i < piece.corners.size(); ++i) { - const Vec3d& a = piece.corners[i]; - const Vec3d& b = piece.corners[(i + 1) % piece.corners.size()]; - const Vec2d fa = in_frame(own, a), fb = in_frame(own, b); - bool border = false; - for (int axis = 0; axis < 2; ++axis) - for (double side : { -half, half }) - border = border || (std::abs(fa[axis] - side) <= eps && std::abs(fb[axis] - side) <= eps); - if (border) { - piece.lines.emplace_back(a, b); - continue; - } - for (int k = 0; k < int(planes.size()); ++k) { - auto in_k = [&](const Vec3d& x) { return std::abs(plane_distance(planes[k], x)) <= 2. * eps; }; - // Not a plane the piece itself lies in: clipped to its own square, a cut is kept whole. - if (k == piece.plane || !in_k(a) || !in_k(b) || std::all_of(piece.corners.begin(), piece.corners.end(), in_k)) - continue; - Vec3d ca = a, cb = b; - if (clip_to_square(ca, cb, planes[k], half, eps)) - piece.lines.emplace_back(ca, cb); - } +SketchPlane reference_square(const SketchPlane& plane, int base, double half) +{ + SketchPlane square = plane; + if (is_base_plane(base)) { + const Vec3d o = reference_octant(); + square.origin += (reference_square_gap(half) + half) * (plane.x_axis.dot(o) * plane.x_axis + plane.y_axis.dot(o) * plane.y_axis); + } + return square; +} + +std::array reference_label_box(const SketchPlane& square, double half, const std::string& text) +{ + const double th = label_height(half); + Vec2d size; + text_strokes(text, th, size); + const Vec2d c = label_centre(half, size); + const Vec2d r = 0.5 * size + Vec2d::Constant(0.15 * th); // a margin round the strokes, to aim at + return { square.to_world(c + Vec2d(-r.x(), -r.y())), square.to_world(c + Vec2d(r.x(), -r.y())), + square.to_world(c + Vec2d(r.x(), r.y())), square.to_world(c + Vec2d(-r.x(), r.y())) }; +} + +int pick_reference_square(const std::vector& squares, double half, const Vec3d& from, const Vec3d& dir) +{ + int best = -1; + double best_t = std::numeric_limits::max(); + for (int i = 0; i < int(squares.size()); ++i) { + const SketchPlane& s = squares[i]; + const Vec3d n = s.x_axis.cross(s.y_axis); + const double dn = n.dot(dir); + if (std::abs(dn) < 1e-12) + continue; // the ray runs along the square + const double t = n.dot(s.origin - from) / dn; + if (t <= 0. || t >= best_t) + continue; // behind the eye, or no nearer than one already hit + const Vec3d d = from + dir * t - s.origin; + if (std::abs(d.dot(s.x_axis)) <= half && std::abs(d.dot(s.y_axis)) <= half) { + best = i; + best_t = t; } } - return out; + return best; } void DesignSketchTool::set_base_pick(std::vector planes, std::vector bases, @@ -5543,140 +5546,237 @@ void DesignSketchTool::clear_base_pick() m_dbp_hover = -1; } -// Reference planes sit INSIDE the bed. They used to be 0.6 * the bed's larger side, i.e. a square -// 1.2x the plate, and three of them are drawn with depth testing off — so they painted over the -// plate grid from edge to edge and the bed simply was not readable any more. "The planes hide the -// bed", reported exactly that way. Small enough to leave the grid legible around them is also the -// Onshape look this was reaching for: a modest square at the origin, not a tablecloth. +// Sized from the bed, so the squares keep their size against the plate on screen: each is 0.16 of +// the bed's larger side, about the share of the view Fusion's origin planes take at the default zoom. +// Larger squares were reported as hiding the bed. double DesignSketchTool::dbp_half_extent() const { - double half = 75.0; + double half = 20.0; if (auto* pl = wxGetApp().plater()) { const BoundingBoxf bb = pl->build_volume().bounding_volume2d(); const double w = bb.max.x() - bb.min.x(), d = bb.max.y() - bb.min.y(); - if (w > 1.0 && d > 1.0) half = 0.3 * std::max(w, d); + if (w > 1.0 && d > 1.0) half = 0.08 * std::max(w, d); } return half; } -// Draw the reference planes as labelled translucent squares outlined in their own hue; the hovered -// one brightens. Depth testing is off (they overlay the bed and any bodies), so draw order is the -// blend order — and the planes cross, so they go down piece by piece, back to front. +std::vector DesignSketchTool::dbp_squares(double half) const +{ + std::vector squares; + for (size_t i = 0; i < m_dbp_planes.size(); ++i) + squares.push_back(reference_square(m_dbp_planes[i], i < m_dbp_base.size() ? m_dbp_base[i] : -1, half)); + return squares; +} + +// Dash-dot half-axes from the origin into the squares' octant, out past them along the gaps between +// them, and a disc on the origin. Both face the camera at a constant width on screen. +void DesignSketchTool::render_reference_axes(const Vec3d& origin, double half) +{ + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d vd = cam.get_dir_forward(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); // world units per screen pixel + const double len = 1.2 * (reference_square_gap(half) + 2.0 * half); + // A dash, then a dot, every kPeriod of the axis. + constexpr double kDash = 0.07, kDotFrom = 0.10, kDotTo = 0.115, kPeriod = 0.145; + const ColorRGBA colours[3] = { ColorRGBA(0.92f, 0.28f, 0.28f, 1.0f), ColorRGBA(0.30f, 0.80f, 0.34f, 1.0f), + ColorRGBA(0.32f, 0.55f, 0.95f, 1.0f) }; + for (int k = 0; k < 3; ++k) { + const Vec3d axis = Vec3d::Unit(k) * (len * reference_octant()[k]); + std::vector> strokes; + for (double t = 0.; t < 1.; t += kPeriod) { + strokes.push_back({ origin + axis * t, origin + axis * std::min(t + kDash, 1.) }); + if (t + kDotFrom < 1.) + strokes.push_back({ origin + axis * (t + kDotFrom), origin + axis * std::min(t + kDotTo, 1.) }); + } + GLModel::Geometry g; + append_ribbons(g, -1, strokes, vd, Vec3d::Zero(), 1.5 * upp); // body -1: already world coordinates + if (g.is_empty()) + continue; // seen end-on + GLModel m; + m.init_from(std::move(g)); + m.set_color(colours[k]); + m.render(); + } + + const Vec3d right = cam.get_dir_right(), up = cam.get_dir_up(); + auto disc = [&](double radius_px, const ColorRGBA& colour) { + constexpr int kSides = 24; + GLModel::Geometry g; + g.format = { EPT::Triangles, EVL::P3 }; + g.add_vertex((Vec3f) origin.cast()); + for (int i = 0; i < kSides; ++i) { + const double a = 2. * M_PI * i / kSides; + g.add_vertex((Vec3f) (origin + (right * std::cos(a) + up * std::sin(a)) * (radius_px * upp)).cast()); + } + for (int i = 0; i < kSides; ++i) + g.add_triangle(0, 1 + i, 1 + (i + 1) % kSides); + GLModel m; + m.init_from(std::move(g)); + m.set_color(colour); + m.render(); + }; + disc(6.0, ColorRGBA(0.30f, 0.30f, 0.32f, 1.0f)); // a dark ring round + disc(4.5, ColorRGBA(0.92f, 0.92f, 0.92f, 1.0f)); // a light centre +} + +// The reference planes, as in Fusion: translucent squares with the hovered one grey and the selected +// one solid, each base plane's name written in it, and the half-axes and origin under them. Depth +// testing is off (they overlay the bed and any bodies), so draw order is the blend order: back to +// front, piece by piece. void DesignSketchTool::render_base_pick() { if (!m_dbp_active || m_dbp_planes.empty()) return; using EPT = GLModel::Geometry::EPrimitiveType; using EVL = GLModel::Geometry::EVertexLayout; - const double H = dbp_half_extent(); - // Two layers of alpha a blended in either order differ by only a^2 of their colour difference, - // so a faint fill hides which plane is in front however well the pieces are sorted: at 0.16 that - // is under 3%, and the crossing planes read as one grey smear. At 0.35 it is ~12%, and the bed - // grid still reads through all three. - constexpr float kFillAlpha = 0.35f, kFillAlphaHot = 0.50f; - constexpr float kLineAlpha = 0.90f, kLineAlphaHot = 1.00f; - // Onshape-ish per-plane hues: XY blue, XZ green, YZ red (keyed by base index 0/1/2; datums grey). - auto hue = [](int base) -> ColorRGBA { - if (base == 0) return ColorRGBA(0.30f, 0.55f, 0.95f, 1.0f); - if (base == 1) return ColorRGBA(0.35f, 0.80f, 0.45f, 1.0f); - if (base == 2) return ColorRGBA(0.92f, 0.42f, 0.42f, 1.0f); - return ColorRGBA(0.70f, 0.72f, 0.78f, 1.0f); + const double H = dbp_half_extent(); + const std::vector squares = dbp_squares(H); + const int selected = selected_base ? selected_base() : -1; + auto base_of = [this](size_t i) { return i < m_dbp_base.size() ? m_dbp_base[i] : -1; }; + enum class Look { Idle, Hover, Selected }; + auto look = [&](size_t i) { + return base_of(i) >= 0 && base_of(i) == selected ? Look::Selected : int(i) == m_dbp_hover ? Look::Hover : Look::Idle; + }; + // Each base plane in the colour of the axis it is normal to: XY blue, XZ green, YZ red. A datum the + // Plane card offers as a base keeps a slate of its own, clear of the grey hover. + auto hue = [&](size_t i) { + switch (base_of(i)) { + case 0: return ColorRGBA(0.30f, 0.55f, 0.95f, 1.0f); + case 1: return ColorRGBA(0.35f, 0.80f, 0.45f, 1.0f); + case 2: return ColorRGBA(0.92f, 0.42f, 0.42f, 1.0f); + default: return ColorRGBA(0.55f, 0.60f, 0.72f, 1.0f); + } + }; + // Where squares overlap on screen, two layers of alpha a blended in either order differ by only + // a^2 of their colour difference, so much fainter fills would hide which one is in front however + // well the pieces are sorted. The selected plane goes near solid in its own colour: a fixed + // selection colour would match one of the three. + auto fill = [&](size_t i) { + ColorRGBA c = look(i) == Look::Hover ? ColorRGBA(0.80f, 0.80f, 0.80f, 1.0f) : hue(i); + c.a(look(i) == Look::Selected ? 0.85f : look(i) == Look::Hover ? 0.45f : 0.50f); + return c; + }; + auto ink = [&](size_t i) { + switch (look(i)) { + case Look::Selected: return ColorRGBA(0.20f, 0.25f, 0.35f, 1.0f); + case Look::Hover: return ColorRGBA(0.62f, 0.62f, 0.62f, 1.0f); + default: return hue(i); + } }; const Camera& cam = wxGetApp().plater()->get_camera(); - const Vec3d vd = cam.get_dir_forward(); - const double hw = 1.5 / std::max(cam.get_zoom(), 1e-6); // outline ribbon, as on datum planes glsafe(::glDisable(GL_DEPTH_TEST)); glsafe(::glDisable(GL_CULL_FACE)); - glsafe(::glEnable(GL_BLEND)); // alpha is ignored without this - glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); - for (const PlanePiece& piece : planes_back_to_front(m_dbp_planes, H, cam.get_position(), vd, - cam.get_type() == Camera::EType::Perspective)) { - const bool hot = piece.plane == m_dbp_hover; - ColorRGBA col = hue(piece.plane < int(m_dbp_base.size()) ? m_dbp_base[piece.plane] : -1); - GLModel::Geometry fill; fill.format = { EPT::Triangles, EVL::P3 }; - for (const Vec3d& q : piece.corners) fill.add_vertex((Vec3f)q.cast()); - for (unsigned int i = 1; i + 1 < piece.corners.size(); ++i) fill.add_triangle(0, i, i + 1); // convex: a fan - GLModel fm; fm.init_from(std::move(fill)); - col.a(hot ? kFillAlphaHot : kFillAlpha); - fm.set_color(col); - fm.render(); + glsafe(::glEnable(GL_BLEND)); // alpha is ignored without this; destination alpha stays 1 + glsafe(::glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA)); - // Its outline and crossing lines go down with it, so a line behind another plane is tinted - // by it exactly like the plane it lies on. - std::vector> strokes; - for (const auto& [a, b] : piece.lines) strokes.push_back({ a, b }); - GLModel::Geometry outline; - append_ribbons(outline, -1, strokes, vd, Vec3d::Zero(), hw); // body -1: already world coordinates - if (!outline.is_empty()) { - GLModel om; om.init_from(std::move(outline)); - col.a(hot ? kLineAlphaHot : kLineAlpha); - om.set_color(col); - om.render(); + for (size_t i = 0; i < m_dbp_planes.size(); ++i) + if (is_base_plane(base_of(i))) { // the base planes all pass through the modeling origin + render_reference_axes(m_dbp_planes[i].origin, H); + break; } - } - // Label near the top-left corner, drawn in the plane (draw_text lifts through m_plane). Labels - // are ImGui chips, on top of the planes whatever the order here. - const SketchPlane saved_plane = m_plane; - const double th = H * 0.10; - for (size_t i = 0; i < m_dbp_planes.size() && i < m_dbp_labels.size(); ++i) { - if (m_dbp_labels[i].empty()) continue; - m_plane = m_dbp_planes[i]; - draw_text(m_line_model, m_dbp_labels[i], Vec2d(-H + th * 2.0, H - th * 1.6), th, - hue(i < m_dbp_base.size() ? m_dbp_base[i] : -1)); + const std::vector pieces = planes_back_to_front(squares, H, cam.get_position(), cam.get_dir_forward(), + cam.get_type() == Camera::EType::Perspective); + const SketchPlane saved_plane = m_plane; + for (size_t k = 0; k < pieces.size(); ++k) { + const PlanePiece& piece = pieces[k]; + const size_t i = size_t(piece.plane); + GLModel::Geometry g; + g.format = { EPT::Triangles, EVL::P3 }; + for (const Vec3d& q : piece.corners) g.add_vertex((Vec3f)q.cast()); + for (unsigned int c = 1; c + 1 < piece.corners.size(); ++c) g.add_triangle(0, c, c + 1); // convex: a fan + GLModel m; + m.init_from(std::move(g)); + m.set_color(fill(i)); + m.render(); + + // A base plane's name goes down with its last piece, so a square in front tints it as it + // tints the fill. + if (!is_base_plane(base_of(i)) || i >= m_dbp_labels.size() || m_dbp_labels[i].empty() + || std::any_of(pieces.begin() + k + 1, pieces.end(), [&](const PlanePiece& p) { return p.plane == piece.plane; })) + continue; + const double th = label_height(H); + Vec2d size; + std::vector> strokes = text_strokes(m_dbp_labels[i], th, size); + const Vec2d c = label_centre(H, size); + for (auto& [a, b] : strokes) { + a += c; + b += c; + } + m_plane = squares[i]; // draw_strokes lifts plane coordinates through m_plane + draw_strokes(m_line_model, strokes, 0.07 * th, ink(i)); } + for (size_t i = 0; i < m_dbp_planes.size() && i < m_dbp_labels.size(); ++i) + if (!is_base_plane(base_of(i)) && !m_dbp_labels[i].empty()) { + m_plane = squares[i]; // draw_text anchors through m_plane too + draw_text(m_line_model, m_dbp_labels[i], datum_label_anchor(H), H * 0.10, ink(i)); + } m_plane = saved_plane; glsafe(::glDisable(GL_BLEND)); } -// Ray-pick the reference planes: intersect the mouse ray with each plane, keep hits inside the -// square, return the index of the nearest by |t|. -1 on miss. +// The reference plane under the cursor, or -1. A plane's name wins over the squares: it is the part +// of a plane a user aims at deliberately, and from the back the XZ square stands between the eye and +// the XY name, so a ray pick alone reports "XZ plane selected" for a click on the word XY. int DesignSketchTool::hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const { if (!m_dbp_active) return -1; - const double H = dbp_half_extent(); - - // THE LABEL WINS, and it has to. Each plane's name is a screen-space chip centred on its - // own in-plane anchor, and it is the one part of a base plane a user aims at deliberately — - // the quads are translucent and overlap everywhere. Ray-casting the quads alone made - // the labels pure decoration: on a fresh document at 1920x1060, clicking "XY" reported - // "XZ plane selected", because the XZ quad happens to sit in front at that pixel. Nothing - // about the click was ambiguous to the user; they clicked the word XY. - // Anchor and text height must track render_base_pick's, which is where they are drawn. - const Camera& cam = wxGetApp().plater()->get_camera(); - const double th = H * 0.10; - const Vec2d anchor(-H + th * 2.0, H - th * 1.6); - int lbest = -1; double lbest_d = 1e30; - for (size_t i = 0; i < m_dbp_planes.size(); ++i) { - if (i >= m_dbp_labels.size() || m_dbp_labels[i].empty()) continue; - const wxPoint sp = world_to_screen_px(cam, m_dbp_planes[i].to_world(anchor)); - if (sp.x < 0 && sp.y < 0) continue; // behind the camera - const double dx = std::abs(double(evt.GetX() - sp.x)); - const double dy = std::abs(double(evt.GetY() - sp.y)); - // Chip half-extents in px, scaled like the label itself. Generous rather than tight: - // missing the text and silently selecting a different plane is the failure being fixed. - const double hw = (9.0 + 5.0 * double(m_dbp_labels[i].size())) * double(m_render_scale); - const double hh = 11.0 * double(m_render_scale); - if (dx > hw || dy > hh) continue; - const double d = dx * dx + dy * dy; // nearest label if chips overlap + const double H = dbp_half_extent(); + const std::vector squares = dbp_squares(H); + const Camera& cam = wxGetApp().plater()->get_camera(); + // As world_to_screen_px, but refusing a point behind the eye, which would come back mirrored. + const Eigen::Matrix4d to_clip = cam.get_projection_matrix().matrix() * cam.get_view_matrix().matrix(); + const std::array& vp = cam.get_viewport(); + auto to_screen = [&](const Vec3d& x, Vec2d& out) { + const Eigen::Vector4d clip = to_clip * x.homogeneous(); + if (clip.w() <= 1e-9) + return false; + const Vec3d ndc = clip.head<3>() / clip.w(); + out = Vec2d(vp[0] + (ndc.x() * 0.5 + 0.5) * vp[2], vp[1] + (1.0 - (ndc.y() * 0.5 + 0.5)) * vp[3]); + return true; + }; + const Vec2d mouse(evt.GetX(), evt.GetY()); + int lbest = -1; + double lbest_d = std::numeric_limits::max(); + for (size_t i = 0; i < squares.size() && i < m_dbp_labels.size(); ++i) { + if (m_dbp_labels[i].empty()) continue; + Vec2d centre; + if (is_base_plane(i < m_dbp_base.size() ? m_dbp_base[i] : -1)) { + // The name lies in its square, so its box is hit as projected, turning with the plane. + const std::array box = reference_label_box(squares[i], H, m_dbp_labels[i]); + std::array q; + bool seen = true; + for (size_t c = 0; c < 4 && seen; ++c) seen = to_screen(box[c], q[c]); + if (!seen) continue; + double area = 0.; + bool left = true, right = true; // the cursor on one side of every edge: inside + for (size_t c = 0; c < 4; ++c) { + const Vec2d& a = q[c]; + const Vec2d& b = q[(c + 1) % 4]; + const double s = (b.x() - a.x()) * (mouse.y() - a.y()) - (b.y() - a.y()) * (mouse.x() - a.x()); + left = left && s >= 0.; + right = right && s <= 0.; + area += a.x() * b.y() - b.x() * a.y(); + } + if (std::abs(area) < 8. || !(left || right)) + continue; // edge-on, or outside + centre = 0.25 * (q[0] + q[1] + q[2] + q[3]); + } else { + // A datum's name is a screen chip. Generous rather than tight: missing the text and + // silently selecting a different plane is the failure this guards against. + if (!to_screen(squares[i].to_world(datum_label_anchor(H)), centre)) continue; + const double hw = (9.0 + 5.0 * double(m_dbp_labels[i].size())) * double(m_render_scale); + const double hh = 11.0 * double(m_render_scale); + if (std::abs(mouse.x() - centre.x()) > hw || std::abs(mouse.y() - centre.y()) > hh) continue; + } + const double d = (mouse - centre).squaredNorm(); // the nearest name if several hold the cursor if (d < lbest_d) { lbest_d = d; lbest = int(i); } } if (lbest >= 0) return lbest; const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); - const Vec3d ro = r.a, rd = r.b - r.a; - int best = -1; double best_t = 1e30; - for (size_t i = 0; i < m_dbp_planes.size(); ++i) { - const SketchPlane& p = m_dbp_planes[i]; - const double dn = rd.dot(p.normal); - if (std::abs(dn) < 1e-9) continue; // ray parallel to plane - const double t = (p.origin - ro).dot(p.normal) / dn; - if (t < 0) continue; // behind the camera - const Vec3d hit = ro + rd * t; - const Vec3d d = hit - p.origin; - if (std::abs(d.dot(p.x_axis)) > H || std::abs(d.dot(p.y_axis)) > H) continue; - if (t < best_t) { best_t = t; best = int(i); } - } - return best; + return pick_reference_square(squares, H, r.a, r.b - r.a); } // ---- Move-body gizmo (M5) ------------------------------------------------------------- @@ -7615,9 +7715,9 @@ void DesignSketchTool::draw_strokes(GLModel& model, const std::vector>& out, double& ad break; } } + +std::vector> text_strokes(const std::string& text, double height, Vec2d& size) +{ + std::vector> out; + double pen = 0.; + for (char c : text) { + std::vector> glyph; + double advance = 0.; + glyph_strokes(c, glyph, advance); + for (const auto& [a, b] : glyph) + out.emplace_back((a + Vec2d(pen, 0.)) * height, (b + Vec2d(pen, 0.)) * height); + pen += advance; + } + BoundingBoxf box; + for (const auto& [a, b] : out) { + box.merge(a); + box.merge(b); + } + if (!box.defined) { + size = Vec2d::Zero(); + return out; + } + const Vec2d mid = box.center(); + for (auto& [a, b] : out) { + a -= mid; + b -= mid; + } + size = box.size(); + return out; +} } // namespace void DesignSketchTool::draw_dim_label(const std::string& txt, const Vec2d& plane_center) @@ -7787,8 +7917,9 @@ void DesignSketchTool::draw_text(GLModel& /*model*/, const std::string& s, const double /*height*/, const ColorRGBA& /*color*/) { // ponytail: all sketch labels now render as Measure-gizmo-style ImGui labels for visual - // parity with the Prepare/Preview tabs; the old vector-font path (glyph_strokes/draw_strokes - // for text) is retired. Leader lines/arrows still draw via draw_strokes at the call sites. + // parity with the Prepare/Preview tabs; the vector font (glyph_strokes/draw_strokes) is kept + // only for text lying in a plane, the reference planes' names. Leader lines/arrows still draw + // via draw_strokes at the call sites. // // The one label we do NOT draw is the one under an OPEN value field: the field is anchored // over it and carries the same number plus its title, so leaving the label in place shows @@ -9525,7 +9656,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) render_datum_planes(); render_mate_connectors(); render_solid_highlight(); - if (m_dbp_active) render_base_pick(); + if (draws_reference_axes()) render_base_pick(); // the canvas drops the bed's triad on the same test if (m_dz_active) render_datum_gizmo(); if (m_hx_active) render_helix_gizmo(); if (m_rb_active) render_rib_gizmo(); diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 88c003bc36..48c238a916 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -11,6 +11,7 @@ #include "slic3r/GUI/GLModel.hpp" #include "slic3r/GUI/GLSelectionRectangle.hpp" // left-drag rubber band over the committed bodies #include +#include #include #include #include @@ -56,21 +57,35 @@ inline ColorRGBA design_idle_face_color() return ColorRGBA(0.72f, 0.76f, 0.80f, 0.14f); } -// A convex piece of the square drawn on planes[plane], and the segments to outline with it: its share -// of the square's border and of the lines where it crosses the other squares. +// A convex piece of the square drawn on planes[plane]. struct PlanePiece { - int plane; - std::vector corners; - std::vector> lines; + int plane; + std::vector corners; }; -// The squares of half-extent `half` on `planes`, cut where they cross one another and ordered back -// to front for an eye at `eye` (perspective) or looking along `forward` (orthographic). Translucent -// planes that cross cannot be drawn in any per-plane order: each is partly in front of and partly -// behind the others. Drawn piece by piece in this order, each one tints only what is behind it. +// The squares of half-extent `half` centred on `planes`' origins, cut where they cross one another +// and ordered back to front for an eye at `eye` (perspective) or looking along `forward` +// (orthographic). Translucent planes that cross cannot be drawn in any per-plane order: each is +// partly in front of and partly behind the others. Drawn piece by piece in this order, each one +// tints only what is behind it. std::vector planes_back_to_front(const std::vector& planes, double half, const Vec3d& eye, const Vec3d& forward, bool perspective); +// The square a reference plane is drawn as, given as the frame at its centre, half-extent `half`. +// The base planes XY, XZ and YZ (`base` 0, 1, 2) sit in the octant (+X, -Y, +Z), the one all three +// names face, reference_square_gap(half) clear of the two axes bounding each, so the three never cross +// and the axes run between them. Any other base (a datum) stays centred on its own origin. The frame is the same +// plane moved within itself, so planes_back_to_front and pick_reference_square take it unchanged. +SketchPlane reference_square(const SketchPlane& plane, int base, double half); +inline double reference_square_gap(double half) { return 0.25 * half; } +// The corners of the box around a base square's label, in the square: inset from its (+x, +y) corner and +// written along the square's x axis with its y axis up, so it turns with the plane. The box the hit +// test takes for the label: round the strokes render_base_pick draws, from the same layout, with a +// margin to aim at. +std::array reference_label_box(const SketchPlane& square, double half, const std::string& text); +// The nearest of `squares` (half-extent `half`) the ray from `from` along `dir` crosses, or -1. +int pick_reference_square(const std::vector& squares, double half, const Vec3d& from, const Vec3d& dir); + class DesignSketchTool { public: enum class Mode { Select, Dimension, Polyline, Line, CornerRect, CenterRect, ObliqueRect, @@ -348,6 +363,12 @@ public: std::vector labels = {}); void clear_base_pick(); std::function on_datum_base_picked; + // The base drawn as selected, or -1. Asked once a frame rather than set, because what decides it + // (the panel's chosen sketch plane, a picked face, the Plane card's base) changes in many places. + std::function selected_base; + // The reference planes are drawn this frame, with their own half-axes and origin mark: the canvas + // leaves out the bed's axes triad, which would sit on top of them. + bool draws_reference_axes() const { return m_dbp_active && !m_active; } // Visual Fillet/Chamfer gizmo. The Dressup tool is a DesignPanel docked card, so the sketch // tool is NOT active during it; when a solid EDGE is picked the panel passes the body centroid @@ -1418,7 +1439,9 @@ private: std::vector m_dbp_base; std::vector m_dbp_labels; int m_dbp_hover{-1}; - double dbp_half_extent() const; // bed-derived: reference planes are larger than the bed + double dbp_half_extent() const; // bed-derived square size + std::vector dbp_squares(double half) const; // reference_square of each entry + void render_reference_axes(const Vec3d& origin, double half); void render_base_pick(); int hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const; diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 63fb7230dc..729b3aa0c4 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2425,8 +2425,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. + // Design tab: while its reference planes are up they draw their own axes from the modeling + // origin, where the bed's triad would otherwise sit on top of them. if (show_bed) - _render_bed(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), m_show_world_axes); + _render_bed(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), + m_show_world_axes && !(m_design_sketch_tool != nullptr && m_design_sketch_tool->draws_reference_axes())); m_frame_profiler.mark("bed"); 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); diff --git a/tests/slic3rutils/test_design_sketch_tool.cpp b/tests/slic3rutils/test_design_sketch_tool.cpp index 685f661fc3..4e8a348645 100644 --- a/tests/slic3rutils/test_design_sketch_tool.cpp +++ b/tests/slic3rutils/test_design_sketch_tool.cpp @@ -59,6 +59,17 @@ void show_two_sketches(DesignSketchTool& tool) // The Design tab's base planes, all through one origin, as DesignPanel shows them. std::vector base_planes() { return { SketchPlane::XY(), SketchPlane::XZ(), SketchPlane::YZ() }; } +// The squares the base planes are drawn as, through a modeling origin at `origin`. +std::vector reference_squares(const Vec3d& origin, double half) +{ + std::vector squares; + for (SketchPlane plane : base_planes()) { + plane.origin += origin; + squares.push_back(reference_square(plane, int(squares.size()), half)); + } + return squares; +} + struct View { Vec3d eye; @@ -108,25 +119,6 @@ std::optional hit_distance(const PlanePiece& piece, const SketchPlane& p return t; } -// Whether x lies on one of the segments the pieces are outlined with. -bool outlined(const std::vector& pieces, const Vec3d& x) -{ - for (const PlanePiece& piece : pieces) - for (const auto& [a, b] : piece.lines) { - const Vec3d ab = b - a; - const double t = std::clamp((x - a).dot(ab) / ab.squaredNorm(), 0., 1.); - if ((a + ab * t - x).norm() < 1e-6) - return true; - } - return false; -} - -std::vector pieces_of(const std::vector& planes) -{ - const View& view = kViews[0]; - return planes_back_to_front(planes, 75., view.eye, view.forward, view.perspective); -} - struct SightLines { int overlapping = 0; // sight lines through two or more pieces, where draw order matters @@ -134,15 +126,15 @@ struct SightLines }; // Translucent pieces blend correctly only if, along every line of sight, each piece is drawn after -// every piece behind it. -SightLines sight_lines(const std::vector& planes, double half, const View& view) +// every piece behind it. The lines of sight aim at points of the box [lo, hi]. +SightLines sight_lines(const std::vector& planes, double half, const View& view, const Vec3d& lo, const Vec3d& hi) { const std::vector pieces = planes_back_to_front(planes, half, view.eye, view.forward, view.perspective); std::mt19937 rng(7); - std::uniform_real_distribution coord(-0.95 * half, 0.95 * half); + std::uniform_real_distribution unit(0., 1.); SightLines seen; for (int r = 0; r < 500; ++r) { - const Vec3d target(coord(rng), coord(rng), coord(rng)); + const Vec3d target = lo + (hi - lo).cwiseProduct(Vec3d(unit(rng), unit(rng), unit(rng))); const Vec3d from = view.perspective ? view.eye : Vec3d(target - view.forward * (10. * half)); const Vec3d dir = (target - from).normalized(); double last = std::numeric_limits::max(); @@ -231,7 +223,7 @@ TEST_CASE("Fit frames nothing when the Design tab shows nothing", "[DesignSketch TEST_CASE("Crossing base planes are drawn back to front from any viewpoint", "[DesignSketchTool]") { const View& view = kViews[GENERATE(range(0, int(std::size(kViews))))]; - const SightLines seen = sight_lines(base_planes(), 75., view); + const SightLines seen = sight_lines(base_planes(), 75., view, Vec3d::Constant(-0.95 * 75.), Vec3d::Constant(0.95 * 75.)); CHECK(seen.overlapping >= 200); // of the 500: most lines of sight into the planes cross two CHECK(seen.out_of_order == 0); } @@ -250,51 +242,11 @@ TEST_CASE("Datum planes are drawn back to front among the base planes", "[Design planes.push_back(tilted); const View& view = kViews[GENERATE(range(0, int(std::size(kViews))))]; - const SightLines seen = sight_lines(planes, 75., view); + const SightLines seen = sight_lines(planes, 75., view, Vec3d::Constant(-0.95 * 75.), Vec3d::Constant(0.95 * 75.)); CHECK(seen.overlapping >= 200); CHECK(seen.out_of_order == 0); } -TEST_CASE("Base planes are outlined along every line where they cross", "[DesignSketchTool]") -{ - // XY, XZ and YZ cross along the three axes, all through the middle of each 75 mm half-square. - const std::vector pieces = pieces_of(base_planes()); - int missed = 0; - for (double t = -70.; t <= 70.; t += 10.) - for (const Vec3d& axis : { Vec3d(1., 0., 0.), Vec3d(0., 1., 0.), Vec3d(0., 0., 1.) }) - missed += outlined(pieces, axis * t) ? 0 : 1; - CHECK(missed == 0); -} - -TEST_CASE("A datum clear of the base planes gets no lines across it", "[DesignSketchTool]") -{ - // Parallel to YZ at x = 100, past the 75 mm half-squares of XY and XZ: the infinite XY and XZ - // planes still cut it, along z = 0 and y = 0, but the squares never meet. - std::vector planes = base_planes(); - SketchPlane beyond = SketchPlane::YZ(); - beyond.origin = Vec3d(100., 0., 0.); - planes.push_back(beyond); - - const std::vector pieces = pieces_of(planes); - CHECK_FALSE(outlined(pieces, Vec3d(100., 30., 0.))); - CHECK_FALSE(outlined(pieces, Vec3d(100., 0., 30.))); -} - -TEST_CASE("A line where two squares cross stops where either square ends", "[DesignSketchTool]") -{ - // Parallel to XY 30 mm up and moved 60 mm along X, so it spans x = -15..135. It meets XZ along - // y = 0, z = 30, but XZ's square only reaches x = 75. - std::vector planes = base_planes(); - SketchPlane raised = SketchPlane::XY(); - raised.origin = Vec3d(60., 0., 30.); - planes.push_back(raised); - - const std::vector pieces = pieces_of(planes); - CHECK(outlined(pieces, Vec3d(0., 0., 30.))); - CHECK(outlined(pieces, Vec3d(70., 0., 30.))); - CHECK_FALSE(outlined(pieces, Vec3d(100., 0., 30.))); -} - TEST_CASE("Cutting the base planes along each other keeps every plane whole", "[DesignSketchTool]") { const std::vector planes = base_planes(); @@ -307,6 +259,98 @@ TEST_CASE("Cutting the base planes along each other keeps every plane whole", "[ CHECK_THAT(a, WithinRel(4. * half * half, 1e-9)); } +TEST_CASE("Base reference planes sit in the octant their names face, clear of the axes", "[DesignSketchTool]") +{ + // Each square is flat in one coordinate and at least `gap` out along the other two, toward + // (+X, -Y, +Z), so no two of them meet: a point of XY has z = 0, every point of XZ and YZ has + // z >= gap. + const Vec3d origin(128., 128., 0.), octant(1., -1., 1.); + const double half = 32., gap = reference_square_gap(half); + const std::vector squares = reference_squares(origin, half); + const int flat[] = { 2, 1, 0 }; // XY in z, XZ in y, YZ in x + for (int i = 0; i < 3; ++i) { + // The side a name reads from faces into the same octant. + CHECK(squares[i].x_axis.cross(squares[i].y_axis).dot(octant) > 0.); + for (const Vec2d& corner : { Vec2d(-half, -half), Vec2d(half, -half), Vec2d(half, half), Vec2d(-half, half) }) { + const Vec3d x = squares[i].to_world(corner) - origin; + for (int k = 0; k < 3; ++k) + if (k == flat[i]) + CHECK_THAT(x[k], WithinAbs(0., 1e-9)); + else { + CHECK(octant[k] * x[k] >= gap - 1e-9); + CHECK(octant[k] * x[k] <= gap + 2. * half + 1e-9); + } + } + } +} + +TEST_CASE("From the front no base reference plane stands behind another", "[DesignSketchTool]") +{ + // Looking from the front and above, as the Design tab opens: XY lies below the X axis on screen + // and XZ above it, and YZ is seen edge-on, so no line of sight crosses two squares. + const double half = 32., e = reference_square_gap(half) + 2. * half; + const std::vector squares = reference_squares(Vec3d::Zero(), half); + const double elevation = M_PI / 180. * GENERATE(20., 45., 70.); + const View front{ Vec3d::Zero(), Vec3d(0., std::cos(elevation), -std::sin(elevation)), false }; + CHECK(sight_lines(squares, half, front, Vec3d(0., -e, 0.), Vec3d(e, 0., e)).overlapping == 0); +} + +TEST_CASE("A datum's reference square stays centred on its origin", "[DesignSketchTool]") +{ + SketchPlane datum = SketchPlane::XY(); + datum.origin = Vec3d(10., 20., 30.); + CHECK((reference_square(datum, 3, 32.).origin - datum.origin).norm() < 1e-12); +} + +TEST_CASE("Base reference planes are drawn back to front without being cut", "[DesignSketchTool]") +{ + const double half = 32.; + const std::vector squares = reference_squares(Vec3d::Zero(), half); + int overlapping = 0; + for (const View& view : kViews) { + const std::vector pieces = planes_back_to_front(squares, half, view.eye, view.forward, view.perspective); + CHECK(pieces.size() == 3); + for (const PlanePiece& piece : pieces) + CHECK(piece.corners.size() == 4); + const double e = reference_square_gap(half) + 2. * half; + const SightLines seen = sight_lines(squares, half, view, Vec3d(0., -e, 0.), Vec3d(e, 0., e)); + CHECK(seen.out_of_order == 0); + overlapping += seen.overlapping; + } + // These views do see squares behind one another, so the order is actually put to the test. + CHECK(overlapping >= 50); +} + +TEST_CASE("A click takes the nearest reference square along the ray", "[DesignSketchTool]") +{ + const double half = 32.; // the squares span 8..72 out along both their axes + const std::vector squares = reference_squares(Vec3d::Zero(), half); + // From the front left through YZ (x = 0) at (0, -20, 40), then on through XZ (y = 0) at (20, 0, 40). + const Vec3d along(1., 1., 0.); + CHECK(pick_reference_square(squares, half, Vec3d(-40., -60., 40.), along) == 2); + // The same line walked the other way meets XZ first. + CHECK(pick_reference_square(squares, half, Vec3d(60., 40., 40.), -along) == 1); + // Straight down onto XY, and down the gap beside it, which holds nothing. + CHECK(pick_reference_square(squares, half, Vec3d(40., -40., 100.), Vec3d(0., 0., -1.)) == 0); + CHECK(pick_reference_square(squares, half, Vec3d(4., -40., 100.), Vec3d(0., 0., -1.)) == -1); + // Looking away from the squares. + CHECK(pick_reference_square(squares, half, Vec3d(40., -40., 100.), Vec3d(0., 0., 1.)) == -1); +} + +TEST_CASE("A base plane's name lies inside its own square", "[DesignSketchTool]") +{ + const double half = 32.; + const std::vector squares = reference_squares(Vec3d(128., 128., 0.), half); + const char* names[] = { "XY", "XZ", "YZ" }; + for (int i = 0; i < 3; ++i) + for (const Vec3d& corner : reference_label_box(squares[i], half, names[i])) { + const Vec3d d = corner - squares[i].origin; + CHECK_THAT(d.dot(squares[i].x_axis.cross(squares[i].y_axis)), WithinAbs(0., 1e-9)); + CHECK(std::abs(d.dot(squares[i].x_axis)) < half); + CHECK(std::abs(d.dot(squares[i].y_axis)) < half); + } +} + TEST_CASE("A move arrow drag moves the body by the cursor's travel, wherever the arrow is grabbed", "[DesignSketchTool]") { DesignSketchTool tool; From 064eee8b84004d43d64f909c0b7c9ee4a3f41ced Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 16:54:13 +0800 Subject: [PATCH 11/30] Stop the Design tab's body Move from getting stuck after Esc or a click off the gizmo Esc and Cancel now put the body back, and Confirm keeps the new position. A click off the gizmo only moves the camera and no longer leaves the Move card and its buttons dead. Starting another edit or a rebuild keeps the position, and undo is refused until the Move is confirmed or cancelled. --- docs/HLSD/design-tab.md | 11 ++- src/slic3r/GUI/CAD/DesignCanvas.cpp | 2 - src/slic3r/GUI/CAD/DesignCanvas.hpp | 1 - src/slic3r/GUI/CAD/DesignPanel.cpp | 95 ++++++++++++++++--------- src/slic3r/GUI/CAD/DesignPanel.hpp | 2 + src/slic3r/GUI/CAD/DesignSketchTool.cpp | 18 +++-- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 1 - 7 files changed, 86 insertions(+), 44 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 9a92647f61..739e0e47ca 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -149,6 +149,14 @@ explicit selection, the sketch ribbon's Cancel, which asks first, or `Ctrl+Z`. A *session* is deliberately not a `Tool` level; it is the environment the `Idle` level lives in, which makes the destructive path unrepresentable rather than merely unlikely. +A body Move is the one `Gesture` that outlives the press: its gizmo stays up between drags until +Confirm keeps the placement or `Esc` or Cancel puts the body back. Until then the selection is +held — a click off the gizmo only steers the camera — and undo is refused, since the placement +is not in the history. Anything that starts another edit (a feature card, a sketch, placing +imported art or text, another body's Move, a rebuild) keeps the placement, as switching gizmos +keeps a move in Prepare. The panel ends the Move in one place (`DesignPanel::end_body_move`), so +the gizmo, the Move / Rotate card and the ✓/✗ cannot outlive one another. + Right-click is read at button-up against one budget, 3 px of drift, applied to the whole press rather than to its end points: a press that wandered past the budget at any moment is navigation, even if it comes back to where it started, which is what stops a slow, careful @@ -226,7 +234,8 @@ replay costs up to a recompute, so the panel finds the faces once per row and to generation, off the UI thread, and only while no feature card is open. One selection is live at a time: a viewport pick clears the feature row and a feature row clears the viewport pick, as the Feature tree and Bodies list do between themselves. `Esc`, a click on empty space and an -empty rubber band all let go of it, whichever list or pick made it. +empty rubber band all let go of it, whichever list or pick made it — except while a body Move is +open, which holds the selection until it ends (see the interaction contract). ## Following the app diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 7819cc248a..72e29ff562 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -743,8 +743,6 @@ void DesignCanvas::clear_move_gizmo() request_repaint(); } -bool DesignCanvas::moving_body() const { return m_sketch_tool.moving_body(); } - void DesignCanvas::set_on_body_move_changed(std::function cb) { m_sketch_tool.on_body_move_changed = std::move(cb); diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 08ae1ea627..dc6b4bf41e 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -161,7 +161,6 @@ public: void begin_move_body(int body, const Vec3d& pivot, const Transform3d& base_xform, double body_radius); void clear_move_gizmo(); - bool moving_body() const; void set_on_body_move_changed(std::function cb); // Visual Fillet/Chamfer radius gizmo: when a solid edge is picked, anchor a radius arrow on // it; drag/edit fire the radius callback. Returns false if no edge is currently picked. diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 04862917d0..3e9c464e1f 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -3704,8 +3704,6 @@ DesignPanel::DesignPanel(wxWindow* parent) // One selection at a time: a pick replaces the Feature tree row. Not while a card is open: // the feature being edited keeps its row while the card's picks are made. if (level >= 1 && m_active == Tool::None && m_tree) m_tree->unselect(); - // A pick that fell through the move gizmo (clicked off the arrows) exits move mode. - if (m_viewport->moving_body()) m_viewport->clear_move_gizmo(); // Remember which body + face/edge so Extrude / dress-up target the RIGHT body. m_sel_solid_body = (level >= 1) ? body : -1; m_sel_solid_face = (level == 2) ? face : -1; // 4 = Vertex: a corner is not its face @@ -4468,6 +4466,9 @@ void DesignPanel::apply_dof_status(int dof, bool ok, bool has_constraints) void DesignPanel::set_ui_mode(UiMode m) { + // A session's ✓/✗ and Esc are its own; a Move left open would answer them instead, unseen, + // since the gizmo is not drawn in a sketch. + if (m != UiMode::Feature) end_body_move(true); m_ui_mode = m; if (m != UiMode::Sketch) m_sketch_on.clear(); // no stale "on the picked face" on the next hint // A committed loop picked before the session means nothing to the sketch map, but it would @@ -4554,7 +4555,9 @@ void DesignPanel::set_status_ok() set_status(wxString::Format(_L("OK — %zu triangles"), m_doc.display_mesh.its.indices.size())); if (m_viewport != nullptr) { - m_viewport->clear_move_gizmo(); // a recompute invalidates the gizmo's body centroid + // A recompute invalidates the gizmo's body centroid; a Move ends with it, keeping its pose. + if (body_move_pending()) end_body_move(true); + else m_viewport->clear_move_gizmo(); // the Transform card's, if up rebuild_disp_meshes(); // apply per-body Move transforms to the display/pick meshes // Point the solid-pick at the fresh body + TRANSFORMED pick mesh (stable address) + the // per-body xform vector (for edge sampling). Resets the whole/face/edge selection, whose @@ -4665,6 +4668,7 @@ void DesignPanel::on_add_text() void DesignPanel::open_text_dialog(int feat) { if (m_text_dlg != nullptr) { m_text_dlg->Raise(); return; } + end_body_move(true); m_text_editing = feat >= 0 && feat < int(m_doc.features.size()) && m_doc.features[feat].is_text(); m_text_feat = m_text_editing ? feat : -1; m_text_face_body = -1; @@ -5122,6 +5126,7 @@ void DesignPanel::add_imported_sketch( // Show the Insert Confirm/Cancel card while the imported art is being placed/sized. void DesignPanel::open_insert_card(const wxString& base_name) { + end_body_move(true); m_active = Tool::Insert; if (m_hdr_insert) m_hdr_insert->SetLabel(base_name); wxSizer* s = m_cards->GetSizer(); @@ -5167,6 +5172,7 @@ void DesignPanel::on_transform_imported(int feat_idx) const CadFeature& f = m_doc.features[feat_idx]; if (f.imported_regions.empty()) return; + end_body_move(true); // the art's placement gizmo and ✓/✗ take over from a Move left open // In-canvas bbox handles (replaces the Move/Scale dialog): drag a corner to scale, // the centre to move. Values stream back via set_on_imported_transform. m_viewport->begin_imported_transform(feat_idx, f.imported_regions, f.plane, @@ -5694,7 +5700,6 @@ void DesignPanel::on_add_transform() // below does not — without this the body would keep showing the // dragged pose after a Transform that failed to build. m_xf_gizmo_body = -1; - m_move_body = -1; } if (m_doc.bodies.empty()) { set_status(StatusKind::Warning, _L("Transform needs a body — add or import one first")); @@ -7769,6 +7774,12 @@ void DesignPanel::on_move_body() { const int b = m_sel_solid_body; if (m_viewport == nullptr) return; + // Whatever is open owns the canvas and the ✓/✗, so a Move armed under it could not be left. + // The Bodies list stays clickable meanwhile, hence the check here. + if (m_active != Tool::None || m_ui_mode != UiMode::Feature || m_text_dlg != nullptr) { + set_status(StatusKind::Info, _L("Finish or cancel what is open first")); + return; + } if (b < 0 || b >= int(m_doc.display_body_meshes.size())) { // Never fail silently here: the caller gates on bodies.size() while this needs a // tessellated per-body mesh, and when those disagreed the click did nothing at all. @@ -7776,6 +7787,13 @@ void DesignPanel::on_move_body() : _L("That body has no display mesh yet — recompute first")); return; } + const wxString hint = _L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)"); + // Already moving it: Cancel still goes back to where it began. Another body re-arms below, + // and the one being moved keeps its placement. + if (body_move_pending() && b == m_move_body) { + set_status(StatusKind::Info, hint); + return; + } sync_body_xform(); // Delta gizmo: pivot at the body's CURRENT world centroid; the tool composes the drag deltas // onto its current pose, so move + rotate both work (incl. on an already place-on-face'd body). @@ -7796,7 +7814,25 @@ void DesignPanel::on_move_body() if (m_move_angle) m_move_angle->SetValue(0.0); show_move_card(true); update_action_bar(); // surface the unified ✓/✗ while moving - set_status(StatusKind::Info, _L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); + set_status(StatusKind::Info, hint); +} + +// The one way out of the Move button's session, so the gizmo, the Move / Rotate card and the ✓/✗ +// go together. keep = true leaves the pose on screen, as starting another edit does; only Esc and +// Cancel put the body back. A no-op when no Move is up. +void DesignPanel::end_body_move(bool keep) +{ + if (!body_move_pending()) return; + if (!keep) { + sync_body_xform(); + if (m_move_body < int(m_body_xform.size())) + m_body_xform[m_move_body] = m_move_prev; + feed_bodies(); // re-render the reverted placement + } + m_viewport->clear_move_gizmo(); + m_move_body = -1; + show_move_card(false); + update_action_bar(); } // Transform card (add mode): arm the same move gizmo on the card's body so the geometry-first @@ -7817,8 +7853,6 @@ void DesignPanel::arm_transform_gizmo() if (m_viewport) m_viewport->begin_move_body(b, pivot, base, radius); m_xf_gizmo_body = b; m_xf_gizmo_base = base; - m_move_body = b; // the existing revert paths key off these two - m_move_prev = base; // Write the pivot into the card so the parametric feature reproduces what was dragged; // dx/dy/dz and angle start at zero (the gizmo reports deltas from this pose). if (m_xf_pivot_x) m_xf_pivot_x->SetValue(pivot.x()); @@ -7937,7 +7971,7 @@ void DesignPanel::drop_solid_pick() bool DesignPanel::begin_renumber() { if (m_ui_mode != UiMode::Feature || m_text_dlg != nullptr || m_active == Tool::Insert - || (m_active == Tool::None && m_viewport != nullptr && m_viewport->moving_body())) { + || body_move_pending()) { set_status(StatusKind::Info, _L("Finish or cancel what is open first")); return false; } @@ -8000,13 +8034,12 @@ void DesignPanel::on_new_design() void DesignPanel::clear_document() { tool_cancel(); // leave any active tool / sketch / constrain cleanly + end_body_move(true); // ...and a Move, which tool_cancel leaves while a value is pending m_doc.clear(); // features + bodies + meshes + history m_doc.auto_close_loops = wxGetApp().is_auto_close_sketch_loops(); // a new design: today's preference Slic3r::set_sketch_auto_close(m_doc.auto_close_loops); drop_selection(); m_edit_index = -1; - m_move_body = -1; - show_move_card(false); m_body_xform.clear(); if (m_viewport) { m_viewport->clear_move_gizmo(); m_viewport->clear_mesh(); } after_tree_edit(true); // rebuild the (now empty) tree + clear the viewport @@ -8090,6 +8123,11 @@ void DesignPanel::on_toggle_visibility() return; } + // Hiding or showing a feature can renumber the bodies, and the Move holds a body index. + if (body_move_pending()) { + set_status(StatusKind::Info, _L("Finish or cancel what is open first")); + return; + } int sel = tree_selection(); if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { set_status(StatusKind::Error, _L("Select a feature in the tree first")); @@ -8109,7 +8147,7 @@ void DesignPanel::on_toggle_visibility() if (m_doc.display_mesh.its.indices.empty()) { m_viewport->clear_mesh(); } else { - // Not set_status_ok(), which would take the gizmo from a Move still open. + // Not set_status_ok(), which would take the gizmo from an open Transform card. feed_bodies(); // Hiding or showing a feature rebuilds the bodies and can renumber them, so the pick is // re-pointed and its selection dropped. That also brings picking back for a body shown @@ -11308,6 +11346,7 @@ void DesignPanel::push_polygon_params() void DesignPanel::open_tool(Tool t) { + end_body_move(true); // first: arm_transform_gizmo below takes over the same gizmo m_active = t; m_candidate_ok = true; // a fresh card is confirmable until its preview says otherwise // Fillet/Chamfer/Draft no longer fade the body see-through; instead, once a valid target @@ -11606,7 +11645,6 @@ void DesignPanel::close_tool() if (m_viewport) m_viewport->clear_move_gizmo(); feed_bodies(); m_xf_gizmo_body = -1; - m_move_body = -1; } if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false); m_viewport->set_xray_focus(-1); } // restore the opaque solid wxSizer* s = m_cards->GetSizer(); @@ -11730,11 +11768,8 @@ void DesignPanel::cancel_tool() void DesignPanel::tool_confirm() { if (m_value_cont) { confirm_value(); return; } // value card owns ribbon ✓ while a value is pending - if (m_active == Tool::None && m_viewport && m_viewport->moving_body()) { // keep the placement, drop the gizmo - m_viewport->clear_move_gizmo(); - m_move_body = -1; - show_move_card(false); - update_action_bar(); + if (body_move_pending()) { // keep the placement, drop the gizmo + end_body_move(true); set_status_ok(); return; } @@ -11762,15 +11797,8 @@ void DesignPanel::tool_confirm() void DesignPanel::tool_cancel() { if (m_value_cont) { cancel_value(); return; } // value card owns ribbon ✗ while a value is pending - if (m_active == Tool::None && m_viewport && m_viewport->moving_body()) { // revert to the pose at move-start - sync_body_xform(); - if (m_move_body >= 0 && m_move_body < int(m_body_xform.size())) - m_body_xform[m_move_body] = m_move_prev; - m_viewport->clear_move_gizmo(); - m_move_body = -1; - show_move_card(false); - feed_bodies(); // re-render the reverted placement - update_action_bar(); + if (body_move_pending()) { // revert to the pose at move-start + end_body_move(false); set_status(StatusKind::Info, _L("Move cancelled")); return; } @@ -11811,8 +11839,7 @@ bool DesignPanel::confirm_enabled() const { if (m_value_cont) return true; if (m_active == Tool::None) - return (m_viewport && m_viewport->moving_body()) - || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain; + return body_move_pending() || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain; if (m_active == Tool::Insert) return true; return m_candidate_ok; } @@ -11836,9 +11863,8 @@ CadLevel DesignPanel::escape_level() const || (m_viewport && m_viewport->inline_busy()); // An uncommitted delta: clicks are down on an entity that does not exist yet, or a body is // being moved by a gizmo that has not been confirmed. - st.gesture_active = m_viewport - && (m_viewport->drawing_in_progress() - || (m_active == Tool::None && m_viewport->moving_body())); + st.gesture_active = body_move_pending() + || (m_viewport && m_viewport->drawing_in_progress()); // Something is armed and waiting for input: a feature card, a sketch draw tool, Constrain. // A sketch SESSION is deliberately not in this list — see escape(). st.tool_armed = (m_active != Tool::None) @@ -11912,7 +11938,7 @@ bool DesignPanel::menu_can_undo_redo(bool redo) const { if (m_ui_mode == UiMode::Sketch && m_viewport && m_viewport->is_sketching()) return redo ? m_viewport->can_redo_sketch_entity() : m_viewport->can_undo_sketch_entity(); - if (m_ui_mode != UiMode::Feature || m_active != Tool::None) return false; + if (m_ui_mode != UiMode::Feature || m_active != Tool::None || body_move_pending()) return false; return redo ? m_doc.can_redo() : m_doc.can_undo(); } @@ -11934,7 +11960,7 @@ void DesignPanel::update_action_bar() const bool active = (m_active != Tool::None) || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain - || (m_viewport && m_viewport->moving_body()); + || body_move_pending(); s->Show(m_tb_action, active, true); // HIDING THE BAR ORPHANS THE KEYBOARD, and that is the "app does not consent to sketch" // report. The ✓/✗ live in this bar, so the click that confirms a feature leaves focus on a @@ -11970,7 +11996,8 @@ void DesignPanel::do_undo_redo(bool redo) // v1: act only in Feature mode. While authoring/constraining a sketch (m_ui_mode) or // with a feature dialog open (m_active), Esc/Cancel is the way out — popping committed // history mid-tool would be ambiguous (and could orphan the tool's referenced feature). - if (m_ui_mode != UiMode::Feature || m_active != Tool::None) { + // A Move is not in the history at all, so an undo under it would keep it without asking. + if (m_ui_mode != UiMode::Feature || m_active != Tool::None || body_move_pending()) { set_status(StatusKind::Info, _L("Finish or cancel the current tool first (Esc)")); return; } diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 5e39362fad..4b08afa384 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -964,6 +964,8 @@ private: void rebuild_disp_meshes(); // recompute m_disp_* from m_doc + m_body_xform void feed_bodies(); // push m_disp_* + visibility/xform to the viewport void on_move_body(); // start the move gizmo on the selected body + bool body_move_pending() const { return m_move_body >= 0; } // the Move button's session is open + void end_body_move(bool keep); // leave it: keep the dragged pose, or put the body back void arm_transform_gizmo(); // arm the move gizmo on the Transform card's body (add mode only) void on_set_body_color(); // Color tool: pick a per-body display colour override void on_boolean_tool(); // Boolean (combine bodies): needs two solids, then opens the tool diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index baab2e7e9b..92c50eefcb 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -5791,6 +5791,10 @@ void DesignSketchTool::set_move_gizmo(int body, const Vec3d& pivot, const Transf m_mv_rot = Eigen::Matrix3d::Identity(); m_mv_drag = -1; m_mv_radius = std::max(body_radius, 0.0); + // The selection is held while the gizmo is up (see on_mouse_impl): no press half-way to a + // pick, and no hover outline promising a click that will not be taken. + m_pick_pending = false; + m_pre = SolidPick{}; } // Gizmo arm length in world mm. Orca's Prepare gizmos size themselves from the selection's @@ -10817,13 +10821,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) canvas.zoom_to_volumes(); return true; } - // Visual Extrude gizmo (C5b): while the Extrude card is open the depth arrow is - // grabbable — drag changes the depth live; a click (no drag) on the arrow opens the - // inline depth editor. Intercept before the early no-LeftDown bailout so Dragging/ - // LeftUp reach us; a LeftDown that misses the arrow falls through to solid/loop pick. // Move-body gizmo (M5): three world-axis arrows on the selected body. Drag an arrow to // translate live; a stationary click on it opens the inline offset editor; a right click - // exits move mode. A LeftDown that misses the arrows falls through to solid re-pick. + // opens the offer. Anything else is the camera's: see the end of this block. if (m_mv_active) { if (m_mv_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { if (m_mv_drag < 3) drag_move_arrow(canvas.mouse_ray(Point(evt.GetX(), evt.GetY()))); @@ -10854,7 +10854,11 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) double a0; if (arc_mouse_angle(canvas, evt, axis, a0)) m_mv_arc_a0 = a0; return true; } + // Off the gizmo a press only steers the camera: no pick, so the selection the + // Move acts on is held until it ends. + return false; } + if (evt.Moving()) return false; // no hover outline: see set_move_gizmo } // Datum-plane resize gizmo (C3): while the Plane card is open the 4 edge handles are // grabbable — drag changes the u/v extent live. A LeftDown that misses falls through. @@ -10913,6 +10917,10 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (h >= 0) return true; // caller render()s on true -> hover repaints on software GL } } + // Visual Extrude gizmo (C5b): while the Extrude card is open the depth arrow is + // grabbable — drag changes the depth live; a click (no drag) on the arrow opens the + // inline depth editor. Intercept before the early no-LeftDown bailout so Dragging/ + // LeftUp reach us; a LeftDown that misses the arrow falls through to solid/loop pick. if (m_ex_active) { if (m_ex_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { drag_extrude_arrow(canvas, evt, m_ex_drag); diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 48c238a916..5619c0b784 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -274,7 +274,6 @@ public: void set_move_gizmo(int body, const Vec3d& pivot, const Transform3d& base_xform, double body_radius = 0.0); void clear_move_gizmo(); - bool moving_body() const { return m_mv_active; } int move_body_index() const { return m_mv_body; } // Press, then drag, translate arrow `axis` (0..2 = X/Y/Z) with the mouse ray under the cursor. void grab_move_arrow(int axis, const Linef3& ray); From cd959d5d877ce6d18329f4e4575603289a5e75e8 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 18:02:32 +0800 Subject: [PATCH 12/30] Match PrusaSlicer's tip forming on CORE One filaments Ramming, load/unload, cooling-move and stamping values follow the PrusaSlicer 3.0 presets, which retune several of them relative to 2.9.6. The CORE One MMU3 0.6 nozzle now uses the 0.6 Generic PLA and Prusament rPLA profiles, as in PrusaSlicer. --- resources/profiles/Prusa.json | 82 +++++++++++-------- .../Generic ABS @Prusa CORE One HF 0.4.json | 3 + .../Generic ABS @Prusa CORE One HF 0.5.json | 3 + .../Generic ABS @Prusa CORE One HF 0.6.json | 3 + .../filament/Generic ABS @Prusa CORE One.json | 3 + .../Generic ASA @Prusa CORE One HF 0.4.json | 3 + .../Generic ASA @Prusa CORE One HF 0.5.json | 3 + .../Generic ASA @Prusa CORE One HF 0.6.json | 3 + .../filament/Generic ASA @Prusa CORE One.json | 3 + .../Generic PETG @Prusa CORE One HF 0.4.json | 3 + .../Generic PETG @Prusa CORE One HF 0.5.json | 3 + .../Generic PETG @Prusa CORE One HF 0.6.json | 3 + .../Generic PETG @Prusa CORE One.json | 3 + .../Generic PLA @Prusa CORE One 0.6.json | 6 +- .../Generic PLA @Prusa CORE One HF 0.4.json | 3 + .../Generic PLA @Prusa CORE One HF 0.5.json | 3 + .../Generic PLA @Prusa CORE One HF 0.6.json | 6 ++ .../filament/Generic PLA @Prusa CORE One.json | 6 +- .../Generic PLA Silk @Prusa CORE One 0.6.json | 3 + .../Generic PLA Silk @Prusa CORE One.json | 3 + .../Prusament ASA @CORE One HF 0.4.json | 3 + .../Prusament ASA @CORE One HF 0.5.json | 3 + .../Prusament ASA @CORE One HF 0.6.json | 3 + .../filament/Prusament ASA @CORE One.json | 3 + ...Prusament PA11 Carbon Fiber @CORE One.json | 3 + .../Prusament PC Blend @CORE One HF 0.4.json | 3 + .../Prusament PC Blend @CORE One HF 0.5.json | 3 + .../Prusament PC Blend @CORE One HF 0.6.json | 3 + .../Prusament PC Blend @CORE One.json | 3 + ...ament PC Blend Carbon Fiber @CORE One.json | 3 + .../Prusament PETG @CORE One HF 0.4.json | 3 + .../Prusament PETG @CORE One HF 0.5.json | 3 + .../Prusament PETG @CORE One HF 0.6.json | 3 + .../filament/Prusament PETG @CORE One.json | 3 + .../Prusament PLA @CORE One HF 0.4.json | 3 + .../filament/Prusament PLA @CORE One.json | 9 ++ .../filament/Prusament PVB @CORE One.json | 9 ++ .../Prusament rPLA @CORE One 0.6.json | 6 +- .../filament/Prusament rPLA @CORE One.json | 6 +- .../fdm_filament_template_nextruder_pet.json | 36 ++++++++ ...dm_filament_template_nextruder_pet_hf.json | 9 ++ .../fdm_filament_template_nextruder_pla.json | 36 ++++++++ ...dm_filament_template_nextruder_pla_hf.json | 9 ++ .../Prusa/machine/Prusa CORE One MMU3.json | 2 +- 44 files changed, 275 insertions(+), 40 deletions(-) create mode 100644 resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet.json create mode 100644 resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet_hf.json create mode 100644 resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla.json create mode 100644 resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla_hf.json diff --git a/resources/profiles/Prusa.json b/resources/profiles/Prusa.json index 57884bea9b..4a6980c74a 100644 --- a/resources/profiles/Prusa.json +++ b/resources/profiles/Prusa.json @@ -1,6 +1,6 @@ { "name": "Prusa", - "version": "02.04.00.15", + "version": "02.04.00.16", "force_update": "0", "description": "Prusa configurations", "machine_model_list": [ @@ -1572,49 +1572,25 @@ } ], "filament_list": [ - { - "name": "Generic ASA @Prusa CORE One", - "sub_path": "filament/Generic ASA @Prusa CORE One.json" - }, - { - "name": "Generic PLA Silk @Prusa CORE One", - "sub_path": "filament/Generic PLA Silk @Prusa CORE One.json" - }, { "name": "fdm_filament_common", "sub_path": "filament/fdm_filament_common.json" }, { - "name": "Generic ASA @Prusa CORE One 0.6", - "sub_path": "filament/Generic ASA @Prusa CORE One 0.6.json" + "name": "fdm_filament_template_nextruder_pet", + "sub_path": "filament/fdm_filament_template_nextruder_pet.json" }, { - "name": "Generic ASA @Prusa CORE One 0.8", - "sub_path": "filament/Generic ASA @Prusa CORE One 0.8.json" + "name": "fdm_filament_template_nextruder_pet_hf", + "sub_path": "filament/fdm_filament_template_nextruder_pet_hf.json" }, { - "name": "Generic ASA @Prusa CORE One HF 0.4", - "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.4.json" + "name": "fdm_filament_template_nextruder_pla", + "sub_path": "filament/fdm_filament_template_nextruder_pla.json" }, { - "name": "Generic ASA @Prusa CORE One HF 0.5", - "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.5.json" - }, - { - "name": "Generic ASA @Prusa CORE One HF 0.6", - "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.6.json" - }, - { - "name": "Generic ASA @Prusa CORE One HF 0.8", - "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.8.json" - }, - { - "name": "Generic PLA Silk @Prusa CORE One 0.6", - "sub_path": "filament/Generic PLA Silk @Prusa CORE One 0.6.json" - }, - { - "name": "Generic PLA Silk @Prusa CORE One 0.8", - "sub_path": "filament/Generic PLA Silk @Prusa CORE One 0.8.json" + "name": "fdm_filament_template_nextruder_pla_hf", + "sub_path": "filament/fdm_filament_template_nextruder_pla_hf.json" }, { "name": "fdm_filament_abs", @@ -1664,6 +1640,14 @@ "name": "fdm_filament_tpu", "sub_path": "filament/fdm_filament_tpu.json" }, + { + "name": "Generic ASA @Prusa CORE One", + "sub_path": "filament/Generic ASA @Prusa CORE One.json" + }, + { + "name": "Generic PLA Silk @Prusa CORE One", + "sub_path": "filament/Generic PLA Silk @Prusa CORE One.json" + }, { "name": "Generic ABS @Prusa MK3.5", "sub_path": "filament/Generic ABS @Prusa MK3.5.json" @@ -2160,6 +2144,38 @@ "name": "Generic TPU HF @Prusa MK3.5", "sub_path": "filament/Generic TPU HF @Prusa MK3.5.json" }, + { + "name": "Generic ASA @Prusa CORE One 0.6", + "sub_path": "filament/Generic ASA @Prusa CORE One 0.6.json" + }, + { + "name": "Generic ASA @Prusa CORE One 0.8", + "sub_path": "filament/Generic ASA @Prusa CORE One 0.8.json" + }, + { + "name": "Generic ASA @Prusa CORE One HF 0.4", + "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.4.json" + }, + { + "name": "Generic ASA @Prusa CORE One HF 0.5", + "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.5.json" + }, + { + "name": "Generic ASA @Prusa CORE One HF 0.6", + "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.6.json" + }, + { + "name": "Generic ASA @Prusa CORE One HF 0.8", + "sub_path": "filament/Generic ASA @Prusa CORE One HF 0.8.json" + }, + { + "name": "Generic PLA Silk @Prusa CORE One 0.6", + "sub_path": "filament/Generic PLA Silk @Prusa CORE One 0.6.json" + }, + { + "name": "Generic PLA Silk @Prusa CORE One 0.8", + "sub_path": "filament/Generic PLA Silk @Prusa CORE One 0.8.json" + }, { "name": "Generic ABS @Prusa MK4S HF0.4", "sub_path": "filament/Generic ABS @Prusa MK4S HF0.4.json" diff --git a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.4.json index caf36dc7d1..205704705f 100644 --- a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.4.json @@ -7,6 +7,9 @@ "setting_id": "ASYz8yASE82tQDII", "filament_id": "OFY9muEs", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.5.json b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.5.json index 4d0ae20a33..631940cee5 100644 --- a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.5.json +++ b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.5.json @@ -7,6 +7,9 @@ "setting_id": "mzZCm8XDmAR23YR6", "filament_id": "OFY9muEs", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.5 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.6.json b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.6.json index 6419e2a9d4..4b0a714f62 100644 --- a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.6.json +++ b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One HF 0.6.json @@ -7,6 +7,9 @@ "setting_id": "gJhX2KBpk4wfXIj0", "filament_id": "OFY9muEs", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.6 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One.json b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One.json index a3fcbdee47..5138e20528 100644 --- a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One.json +++ b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One.json @@ -6,6 +6,9 @@ "from": "system", "setting_id": "0k31oXq1pLQsHvrj", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "40" ], diff --git a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.4.json index 80c129fab9..c368fcae43 100644 --- a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.4.json @@ -7,6 +7,9 @@ "setting_id": "eKDfiVRE59itoppm", "filament_id": "OFLPAxz3", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.5.json b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.5.json index 4ea45b0f5b..07ee914c50 100644 --- a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.5.json +++ b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.5.json @@ -7,6 +7,9 @@ "setting_id": "ZjHf5Vvtf4ESNnWb", "filament_id": "OFLPAxz3", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.5 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.6.json b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.6.json index d6b2fff248..d602b9e774 100644 --- a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.6.json +++ b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One HF 0.6.json @@ -7,6 +7,9 @@ "setting_id": "aRDgUgQJWdNDJx5c", "filament_id": "OFLPAxz3", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.6 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One.json b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One.json index 09a8349005..d135ffcbad 100644 --- a/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One.json +++ b/resources/profiles/Prusa/filament/Generic ASA @Prusa CORE One.json @@ -6,6 +6,9 @@ "from": "system", "setting_id": "Q7fgojkHRZI0floy", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "40" ], diff --git a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.4.json index 510e1d2baf..38ab9dc4c2 100644 --- a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.4.json @@ -7,6 +7,9 @@ "setting_id": "7oUyjvRHLH4bJyX0", "filament_id": "OFYPdQJh", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.5.json b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.5.json index f28f5f10a3..f24f1f8f26 100644 --- a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.5.json +++ b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.5.json @@ -7,6 +7,9 @@ "setting_id": "6HiIOGURZWwman1l", "filament_id": "OFYPdQJh", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.5 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.6.json b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.6.json index 53dd49907d..c78dbe3b1e 100644 --- a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.6.json +++ b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One HF 0.6.json @@ -7,6 +7,9 @@ "setting_id": "KKiug7A9NOQPe1m7", "filament_id": "OFYPdQJh", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.6 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One.json b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One.json index b605f77a89..4f3e7f400c 100644 --- a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One.json +++ b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One.json @@ -6,6 +6,9 @@ "from": "system", "setting_id": "W71PW5bpf7CV16D4", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "35" ], diff --git a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One 0.6.json b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One 0.6.json index 009a827c7f..4718313292 100644 --- a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One 0.6.json +++ b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One 0.6.json @@ -8,12 +8,16 @@ "filament_id": "OFDSrzZ8", "instantiation": "true", "compatible_printers": [ - "Prusa CORE One 0.6 nozzle" + "Prusa CORE One 0.6 nozzle", + "Prusa CORE One MMU3 0.6 nozzle" ], "nozzle_temperature": [ "210" ], "slow_down_layer_time": [ "15" + ], + "filament_cooling_final_speed": [ + "1.5" ] } diff --git a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.4.json index e821c4d97d..cfaa133cb9 100644 --- a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.4.json @@ -7,6 +7,9 @@ "setting_id": "vniEPaoyABoAfLFa", "filament_id": "OFDSrzZ8", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle", "Prusa CORE One L HF 0.4 nozzle" diff --git a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.5.json b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.5.json index af02d26924..8ddcd915a8 100644 --- a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.5.json +++ b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.5.json @@ -7,6 +7,9 @@ "setting_id": "NPEs4gtBlKWupYZX", "filament_id": "OFDSrzZ8", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.5 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.6.json b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.6.json index dd830e04cd..912bd0d131 100644 --- a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.6.json +++ b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One HF 0.6.json @@ -7,6 +7,9 @@ "setting_id": "SnfULjrWGCiA7ljq", "filament_id": "OFDSrzZ8", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.6 nozzle" ], @@ -24,5 +27,8 @@ ], "slow_down_min_speed": [ "15" + ], + "filament_cooling_final_speed": [ + "1.5" ] } diff --git a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One.json b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One.json index 247ebd2763..6f19c61dc6 100644 --- a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One.json +++ b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One.json @@ -6,6 +6,9 @@ "from": "system", "setting_id": "r2i4c7DsYXYIMVCX", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], "chamber_temperature": [ "20" ], @@ -17,8 +20,7 @@ "Prusa CORE One L 0.4 nozzle", "Prusa CORE One MMU3 0.4 nozzle", "Prusa CORE One MMU3 0.25 nozzle", - "Prusa CORE One MMU3 0.3 nozzle", - "Prusa CORE One MMU3 0.6 nozzle" + "Prusa CORE One MMU3 0.3 nozzle" ], "fan_cooling_layer_time": [ "40" diff --git a/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One 0.6.json b/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One 0.6.json index 55f3698ba3..31000de70a 100644 --- a/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One 0.6.json +++ b/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One 0.6.json @@ -29,5 +29,8 @@ ], "slow_down_layer_time": [ "15" + ], + "filament_cooling_final_speed": [ + "1.5" ] } diff --git a/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One.json b/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One.json index 71aa3d915d..d22f3c6c6c 100644 --- a/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One.json +++ b/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One.json @@ -6,6 +6,9 @@ "from": "system", "setting_id": "F9lNHyhZwZi7aD74", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], "chamber_temperature": [ "20" ], diff --git a/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.4.json index e7977e0c94..19287185da 100644 --- a/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.4.json @@ -6,6 +6,9 @@ "setting_id": "tXktQXr79am1s5UL", "filament_id": "OFmFwUWM", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.5.json b/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.5.json index 26e053cd79..64c8a59399 100644 --- a/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.5.json +++ b/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.5.json @@ -6,6 +6,9 @@ "setting_id": "YbyojtjXvEtI4Tt3", "filament_id": "OFmFwUWM", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.5 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.6.json b/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.6.json index 5bde9d8869..9962f5cc76 100644 --- a/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.6.json +++ b/resources/profiles/Prusa/filament/Prusament ASA @CORE One HF 0.6.json @@ -6,6 +6,9 @@ "setting_id": "35H9TLr1p2Jy767g", "filament_id": "OFmFwUWM", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.6 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament ASA @CORE One.json b/resources/profiles/Prusa/filament/Prusament ASA @CORE One.json index fef0f31669..739b511db9 100644 --- a/resources/profiles/Prusa/filament/Prusament ASA @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament ASA @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "UUTjJ4Y8St3c87jD", "filament_id": "OFmFwUWM", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "40" ], diff --git a/resources/profiles/Prusa/filament/Prusament PA11 Carbon Fiber @CORE One.json b/resources/profiles/Prusa/filament/Prusament PA11 Carbon Fiber @CORE One.json index 0913f1ea9a..d32a445e21 100644 --- a/resources/profiles/Prusa/filament/Prusament PA11 Carbon Fiber @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament PA11 Carbon Fiber @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "sNGgucQjiFXwuFkq", "filament_id": "OF4MZtnE", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "40" ], diff --git a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.4.json index 4e5b5643bd..bdcf80c13c 100644 --- a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.4.json @@ -6,6 +6,9 @@ "setting_id": "pEsP537TBR5kozQB", "filament_id": "OFvDbkFq", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.5.json b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.5.json index ab9a127df8..0e82457803 100644 --- a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.5.json +++ b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.5.json @@ -6,6 +6,9 @@ "setting_id": "3IfLi1ZVCKKbEBUW", "filament_id": "OFvDbkFq", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.5 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.6.json b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.6.json index 642f421819..9d72d40e58 100644 --- a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.6.json +++ b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One HF 0.6.json @@ -6,6 +6,9 @@ "setting_id": "Q8qWw2G7l2UvxGut", "filament_id": "OFvDbkFq", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.6 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One.json b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One.json index 1ef6ad5868..00b62e3e37 100644 --- a/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament PC Blend @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "x1SkWhSSkVVIPwCF", "filament_id": "OFvDbkFq", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "40" ], diff --git a/resources/profiles/Prusa/filament/Prusament PC Blend Carbon Fiber @CORE One.json b/resources/profiles/Prusa/filament/Prusament PC Blend Carbon Fiber @CORE One.json index d691595bf5..edaad073df 100644 --- a/resources/profiles/Prusa/filament/Prusament PC Blend Carbon Fiber @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament PC Blend Carbon Fiber @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "B8Cm9l8i9osp5J2q", "filament_id": "OFdO6vpt", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "40" ], diff --git a/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.4.json index f34dcd29a7..5fc23cd3dc 100644 --- a/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.4.json @@ -6,6 +6,9 @@ "setting_id": "lwT1VxW5OkY36kUX", "filament_id": "OFkR8E2c", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.5.json b/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.5.json index 71001294fa..527974207b 100644 --- a/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.5.json +++ b/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.5.json @@ -6,6 +6,9 @@ "setting_id": "XAgW7fElXxhaKaBT", "filament_id": "OFkR8E2c", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.5 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.6.json b/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.6.json index cea5ef7809..271b04399f 100644 --- a/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.6.json +++ b/resources/profiles/Prusa/filament/Prusament PETG @CORE One HF 0.6.json @@ -6,6 +6,9 @@ "setting_id": "6aTGEZj2EmpTR1mu", "filament_id": "OFkR8E2c", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.6 nozzle" ], diff --git a/resources/profiles/Prusa/filament/Prusament PETG @CORE One.json b/resources/profiles/Prusa/filament/Prusament PETG @CORE One.json index 39090d8977..51aaba3419 100644 --- a/resources/profiles/Prusa/filament/Prusament PETG @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament PETG @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "LSvba9ds3XTwDMCG", "filament_id": "OFkR8E2c", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], "chamber_temperature": [ "35" ], diff --git a/resources/profiles/Prusa/filament/Prusament PLA @CORE One HF 0.4.json b/resources/profiles/Prusa/filament/Prusament PLA @CORE One HF 0.4.json index 8c1d806d98..a4e85823dd 100644 --- a/resources/profiles/Prusa/filament/Prusament PLA @CORE One HF 0.4.json +++ b/resources/profiles/Prusa/filament/Prusament PLA @CORE One HF 0.4.json @@ -6,6 +6,9 @@ "setting_id": "mf4Muog4IcsgkYoA", "filament_id": "OFnO4wnf", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla_hf" + ], "compatible_printers": [ "Prusa CORE One HF 0.4 nozzle", "Prusa CORE One L HF 0.4 nozzle" diff --git a/resources/profiles/Prusa/filament/Prusament PLA @CORE One.json b/resources/profiles/Prusa/filament/Prusament PLA @CORE One.json index e90e22ad51..a28ea07ed4 100644 --- a/resources/profiles/Prusa/filament/Prusament PLA @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament PLA @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "PuujUMjNKLZvZNAH", "filament_id": "OFnO4wnf", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], "chamber_temperature": [ "20" ], @@ -104,5 +107,11 @@ ], "slow_down_min_speed": [ "20" + ], + "filament_unloading_speed": [ + "100" + ], + "filament_unloading_speed_start": [ + "100" ] } diff --git a/resources/profiles/Prusa/filament/Prusament PVB @CORE One.json b/resources/profiles/Prusa/filament/Prusament PVB @CORE One.json index 56987a8093..9f6786bd38 100644 --- a/resources/profiles/Prusa/filament/Prusament PVB @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament PVB @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "085dGkrnPhatF07x", "filament_id": "OFh7mvPO", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], "chamber_temperature": [ "20" ], @@ -105,5 +108,11 @@ ], "slow_down_min_speed": [ "20" + ], + "filament_unloading_speed": [ + "60" + ], + "filament_unloading_speed_start": [ + "60" ] } diff --git a/resources/profiles/Prusa/filament/Prusament rPLA @CORE One 0.6.json b/resources/profiles/Prusa/filament/Prusament rPLA @CORE One 0.6.json index adbf76e56d..0af7c24c5e 100644 --- a/resources/profiles/Prusa/filament/Prusament rPLA @CORE One 0.6.json +++ b/resources/profiles/Prusa/filament/Prusament rPLA @CORE One 0.6.json @@ -8,12 +8,16 @@ "instantiation": "true", "compatible_printers": [ "Prusa CORE One 0.6 nozzle", - "Prusa CORE One HF 0.6 nozzle" + "Prusa CORE One HF 0.6 nozzle", + "Prusa CORE One MMU3 0.6 nozzle" ], "filament_max_volumetric_speed": [ "14" ], "slow_down_layer_time": [ "15" + ], + "filament_cooling_final_speed": [ + "1.5" ] } diff --git a/resources/profiles/Prusa/filament/Prusament rPLA @CORE One.json b/resources/profiles/Prusa/filament/Prusament rPLA @CORE One.json index 7f41ae32e4..73633d6044 100644 --- a/resources/profiles/Prusa/filament/Prusament rPLA @CORE One.json +++ b/resources/profiles/Prusa/filament/Prusament rPLA @CORE One.json @@ -7,6 +7,9 @@ "setting_id": "Bw6H4vFnq7YExyLd", "filament_id": "OFWRITFw", "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], "chamber_temperature": [ "20" ], @@ -22,8 +25,7 @@ "Prusa CORE One HF 0.5 nozzle", "Prusa CORE One MMU3 0.4 nozzle", "Prusa CORE One MMU3 0.25 nozzle", - "Prusa CORE One MMU3 0.3 nozzle", - "Prusa CORE One MMU3 0.6 nozzle" + "Prusa CORE One MMU3 0.3 nozzle" ], "fan_cooling_layer_time": [ "40" diff --git a/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet.json b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet.json new file mode 100644 index 0000000000..ba12bb2e62 --- /dev/null +++ b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet.json @@ -0,0 +1,36 @@ +{ + "type": "filament", + "name": "fdm_filament_template_nextruder_pet", + "from": "system", + "instantiation": "false", + "filament_ramming_parameters": [ + "250 100 42.4194 42.4194 42.4194 42.4194 42.4194| 0.05 42.4387 0.45 42.4387 0.95 42.4387 1.45 42.4387 1.95 42.4387 2.45 42.4387 2.95 42.4387 3.45 42.4387 3.95 42.4387 4.45 42.4387 4.95 42.4387" + ], + "filament_loading_speed": [ + "10" + ], + "filament_loading_speed_start": [ + "50" + ], + "filament_unloading_speed": [ + "60" + ], + "filament_unloading_speed_start": [ + "60" + ], + "filament_cooling_moves": [ + "3" + ], + "filament_cooling_initial_speed": [ + "5" + ], + "filament_cooling_final_speed": [ + "2.5" + ], + "filament_stamping_distance": [ + "45" + ], + "filament_stamping_loading_speed": [ + "26.5" + ] +} diff --git a/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet_hf.json b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet_hf.json new file mode 100644 index 0000000000..ba16fadbad --- /dev/null +++ b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pet_hf.json @@ -0,0 +1,9 @@ +{ + "type": "filament", + "name": "fdm_filament_template_nextruder_pet_hf", + "from": "system", + "instantiation": "false", + "filament_ramming_parameters": [ + "250 100 42.4194 42.4194| 0.05 42.4387 0.45 42.4387 0.95 42.4387 1.45 42.4387 1.95 42.4387 2.45 42.4387 2.95 42.4387 3.45 42.4387 3.95 42.4387 4.45 42.4387 4.95 42.4387" + ] +} diff --git a/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla.json b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla.json new file mode 100644 index 0000000000..d7b2a4b381 --- /dev/null +++ b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla.json @@ -0,0 +1,36 @@ +{ + "type": "filament", + "name": "fdm_filament_template_nextruder_pla", + "from": "system", + "instantiation": "false", + "filament_ramming_parameters": [ + "250 100 40.1613 40.3548 40.4516 40.3548 40.2581| 0.05 40.1483 0.45 40.3419 0.95 40.3419 1.45 40.3419 1.95 40.3419 2.45 40.3419 2.95 40.3419 3.45 40.3419 3.95 40.3419 4.45 40.3419 4.95 40.3419" + ], + "filament_loading_speed": [ + "10" + ], + "filament_loading_speed_start": [ + "50" + ], + "filament_unloading_speed": [ + "40" + ], + "filament_unloading_speed_start": [ + "30" + ], + "filament_cooling_moves": [ + "2" + ], + "filament_cooling_initial_speed": [ + "10" + ], + "filament_cooling_final_speed": [ + "3.5" + ], + "filament_stamping_distance": [ + "45" + ], + "filament_stamping_loading_speed": [ + "29" + ] +} diff --git a/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla_hf.json b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla_hf.json new file mode 100644 index 0000000000..675668725c --- /dev/null +++ b/resources/profiles/Prusa/filament/fdm_filament_template_nextruder_pla_hf.json @@ -0,0 +1,9 @@ +{ + "type": "filament", + "name": "fdm_filament_template_nextruder_pla_hf", + "from": "system", + "instantiation": "false", + "filament_ramming_parameters": [ + "250 100 39.871 39.871| 0.05 39.9547 0.45 39.9547 0.95 39.9547 1.45 39.9547 1.95 39.9547 2.45 39.9547 2.95 39.9547 3.45 39.9547 3.95 39.9547 4.45 39.9547 4.95 39.9547" + ] +} diff --git a/resources/profiles/Prusa/machine/Prusa CORE One MMU3.json b/resources/profiles/Prusa/machine/Prusa CORE One MMU3.json index a9af70344e..fdbe0969df 100644 --- a/resources/profiles/Prusa/machine/Prusa CORE One MMU3.json +++ b/resources/profiles/Prusa/machine/Prusa CORE One MMU3.json @@ -3,7 +3,7 @@ "name": "Prusa CORE One MMU3", "bed_model": "coreone_bed.stl", "bed_texture": "coreone.svg", - "default_materials": "Generic ABS @Prusa CORE One;Generic ASA @Prusa CORE One;Generic PETG @Prusa CORE One;Generic PLA @Prusa CORE One;Generic PLA Silk @Prusa CORE One;Generic TPU @Prusa CORE One", + "default_materials": "Generic ABS @Prusa CORE One;Generic ASA @Prusa CORE One;Generic PETG @Prusa CORE One;Generic PLA @Prusa CORE One;Generic PLA Silk @Prusa CORE One;Generic TPU @Prusa CORE One;Generic PLA @Prusa CORE One 0.6", "family": "Prusa", "hotend_model": "", "machine_tech": "FFF", From db33aaa4dcce4070402e0bba1540b583ba14644e Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 18:25:06 +0800 Subject: [PATCH 13/30] Extend Prusa's tip forming to the MK4 MMU3 and CORE One INDX --- docs/HLSD/prusa-indx-profiles.md | 9 ++- resources/profiles/Prusa.json | 60 ++++++++++++++++--- .../Generic ABS @Prusa CORE One INDX.json | 3 + .../filament/Generic ABS @Prusa MK4 MMU3.json | 19 ++++++ .../filament/Generic ASA @Prusa MK4 MMU3.json | 19 ++++++ .../filament/Generic PC @Prusa MK4 MMU3.json | 16 +++++ .../Generic PETG @Prusa CORE One INDX.json | 3 + .../Generic PETG @Prusa MK4 MMU3.json | 19 ++++++ .../Generic PLA @Prusa CORE One INDX.json | 3 + .../filament/Generic PLA @Prusa MK4 MMU3.json | 16 +++++ ...Generic PLA Silk @Prusa CORE One INDX.json | 3 + .../filament/Prusament ASA @MK4 MMU3.json | 19 ++++++ .../Prusament PC Blend @MK4 MMU3.json | 16 +++++ .../filament/Prusament PETG @MK4 MMU3.json | 19 ++++++ .../Prusament PETG @Prusa CORE One INDX.json | 3 + .../filament/Prusament PLA @MK4 MMU3.json | 22 +++++++ .../Prusament PLA @Prusa CORE One INDX.json | 3 + .../filament/Prusament rPLA @MK4 MMU3.json | 16 +++++ .../filament/fdm_filament_template_indx.json | 21 +++++++ .../machine/Prusa MK4 MMU3 0.4 nozzle.json | 2 +- .../machine/Prusa MK4 MMU3 0.6 nozzle.json | 2 +- .../Prusa/machine/Prusa MK4 MMU3 HF.json | 2 +- .../machine/Prusa MK4 MMU3 HF0.4 nozzle.json | 2 +- .../Prusa/machine/Prusa MK4 MMU3.json | 2 +- 24 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 resources/profiles/Prusa/filament/Generic ABS @Prusa MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Generic ASA @Prusa MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Generic PC @Prusa MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Generic PETG @Prusa MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Generic PLA @Prusa MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Prusament ASA @MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Prusament PC Blend @MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Prusament PETG @MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Prusament PLA @MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/Prusament rPLA @MK4 MMU3.json create mode 100644 resources/profiles/Prusa/filament/fdm_filament_template_indx.json diff --git a/docs/HLSD/prusa-indx-profiles.md b/docs/HLSD/prusa-indx-profiles.md index 272e5bb5aa..77a398b2d5 100644 --- a/docs/HLSD/prusa-indx-profiles.md +++ b/docs/HLSD/prusa-indx-profiles.md @@ -36,8 +36,13 @@ override is not available in these profiles. The source's `EXCLUDE_E_START` and `EXCLUDE_E_END` internal markers become comments rather than printer commands. Pressure-advance restoration and automatic pressure-advance emission use the -selected filament preset's settings. These profiles do not impose machine-owned -filament overrides. Dock-fan control retains the source's material and layer +selected filament preset's settings. The INDX filament presets include +`fdm_filament_template_indx`, whose start G-code sets the filament's pressure +advance with `M572` and then starts the firmware's automatic calibration with +`M573 R`, the commands PrusaSlicer emits after a tool change; the station purge +disables pressure advance before it. The template also carries Prusa's +multi-tool ramming and a 10 mm³ minimal purge. These profiles do not impose +machine-owned filament overrides. Dock-fan control retains the source's material and layer conditions; shutdown parks the tool and turns off the used heaters and dock fan. ## Configuration boundaries diff --git a/resources/profiles/Prusa.json b/resources/profiles/Prusa.json index 4a6980c74a..1b46c1deff 100644 --- a/resources/profiles/Prusa.json +++ b/resources/profiles/Prusa.json @@ -1572,10 +1572,18 @@ } ], "filament_list": [ + { + "name": "Generic PC @Prusa", + "sub_path": "filament/Generic PC @Prusa.json" + }, { "name": "fdm_filament_common", "sub_path": "filament/fdm_filament_common.json" }, + { + "name": "fdm_filament_template_indx", + "sub_path": "filament/fdm_filament_template_indx.json" + }, { "name": "fdm_filament_template_nextruder_pet", "sub_path": "filament/fdm_filament_template_nextruder_pet.json" @@ -1640,10 +1648,18 @@ "name": "fdm_filament_tpu", "sub_path": "filament/fdm_filament_tpu.json" }, + { + "name": "Generic PLA Silk @Prusa CORE One INDX", + "sub_path": "filament/Generic PLA Silk @Prusa CORE One INDX.json" + }, { "name": "Generic ASA @Prusa CORE One", "sub_path": "filament/Generic ASA @Prusa CORE One.json" }, + { + "name": "Generic PC @Prusa MK4 MMU3", + "sub_path": "filament/Generic PC @Prusa MK4 MMU3.json" + }, { "name": "Generic PLA Silk @Prusa CORE One", "sub_path": "filament/Generic PLA Silk @Prusa CORE One.json" @@ -2228,6 +2244,10 @@ "name": "Generic ABS HF @Prusa MINIIS 0.8", "sub_path": "filament/Generic ABS HF @Prusa MINIIS 0.8.json" }, + { + "name": "Generic ASA @Prusa MK4 MMU3", + "sub_path": "filament/Generic ASA @Prusa MK4 MMU3.json" + }, { "name": "Generic ASA @Prusa MK4S 0.6", "sub_path": "filament/Generic ASA @Prusa MK4S 0.6.json" @@ -2276,6 +2296,10 @@ "name": "Prusament ASA @CORE One HF 0.8", "sub_path": "filament/Prusament ASA @CORE One HF 0.8.json" }, + { + "name": "Prusament ASA @MK4 MMU3", + "sub_path": "filament/Prusament ASA @MK4 MMU3.json" + }, { "name": "Prusament ASA @MK4S 0.6", "sub_path": "filament/Prusament ASA @MK4S 0.6.json" @@ -2408,6 +2432,10 @@ "name": "Prusament PC Blend @CORE One HF 0.8", "sub_path": "filament/Prusament PC Blend @CORE One HF 0.8.json" }, + { + "name": "Prusament PC Blend @MK4 MMU3", + "sub_path": "filament/Prusament PC Blend @MK4 MMU3.json" + }, { "name": "Prusament PC Blend @MK4S 0.6", "sub_path": "filament/Prusament PC Blend @MK4S 0.6.json" @@ -2548,6 +2576,10 @@ "name": "Prusament PETG @CORE One HF 0.8", "sub_path": "filament/Prusament PETG @CORE One HF 0.8.json" }, + { + "name": "Prusament PETG @MK4 MMU3", + "sub_path": "filament/Prusament PETG @MK4 MMU3.json" + }, { "name": "Prusament PETG @MK4S 0.6", "sub_path": "filament/Prusament PETG @MK4S 0.6.json" @@ -2684,6 +2716,10 @@ "name": "Prusament PLA @CORE One HF 0.8", "sub_path": "filament/Prusament PLA @CORE One HF 0.8.json" }, + { + "name": "Prusament PLA @MK4 MMU3", + "sub_path": "filament/Prusament PLA @MK4 MMU3.json" + }, { "name": "Prusament PLA @MK4S 0.6", "sub_path": "filament/Prusament PLA @MK4S 0.6.json" @@ -2720,6 +2756,10 @@ "name": "Prusament rPLA @CORE One 0.8", "sub_path": "filament/Prusament rPLA @CORE One 0.8.json" }, + { + "name": "Prusament rPLA @MK4 MMU3", + "sub_path": "filament/Prusament rPLA @MK4 MMU3.json" + }, { "name": "Prusament rPLA @MK4S 0.6", "sub_path": "filament/Prusament rPLA @MK4S 0.6.json" @@ -2872,6 +2912,10 @@ "name": "Generic ABS @Prusa CORE One HF 0.8", "sub_path": "filament/Generic ABS @Prusa CORE One HF 0.8.json" }, + { + "name": "Generic ABS @Prusa MK4 MMU3", + "sub_path": "filament/Generic ABS @Prusa MK4 MMU3.json" + }, { "name": "Generic ASA @Prusa MK4S HF0.5", "sub_path": "filament/Generic ASA @Prusa MK4S HF0.5.json" @@ -2920,6 +2964,10 @@ "name": "Generic PETG @Prusa CORE One HF 0.8", "sub_path": "filament/Generic PETG @Prusa CORE One HF 0.8.json" }, + { + "name": "Generic PETG @Prusa MK4 MMU3", + "sub_path": "filament/Generic PETG @Prusa MK4 MMU3.json" + }, { "name": "Generic PLA @Prusa MK4S HF0.5", "sub_path": "filament/Generic PLA @Prusa MK4S HF0.5.json" @@ -2964,6 +3012,10 @@ "name": "Generic PLA @Prusa CORE One HF 0.8", "sub_path": "filament/Generic PLA @Prusa CORE One HF 0.8.json" }, + { + "name": "Generic PLA @Prusa MK4 MMU3", + "sub_path": "filament/Generic PLA @Prusa MK4 MMU3.json" + }, { "name": "Generic TPU @Prusa CORE One 0.6", "sub_path": "filament/Generic TPU @Prusa CORE One 0.6.json" @@ -2971,14 +3023,6 @@ { "name": "Generic TPU @Prusa CORE One 0.8", "sub_path": "filament/Generic TPU @Prusa CORE One 0.8.json" - }, - { - "name": "Generic PC @Prusa", - "sub_path": "filament/Generic PC @Prusa.json" - }, - { - "name": "Generic PLA Silk @Prusa CORE One INDX", - "sub_path": "filament/Generic PLA Silk @Prusa CORE One INDX.json" } ], "machine_list": [ diff --git a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One INDX.json b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One INDX.json index 7a154dfc54..6a4bdbf5fb 100644 --- a/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One INDX.json +++ b/resources/profiles/Prusa/filament/Generic ABS @Prusa CORE One INDX.json @@ -5,6 +5,9 @@ "from": "system", "setting_id": "JEatvybG9xIkrPk3", "instantiation": "true", + "include": [ + "fdm_filament_template_indx" + ], "filament_cost": [ "34.65" ], diff --git a/resources/profiles/Prusa/filament/Generic ABS @Prusa MK4 MMU3.json b/resources/profiles/Prusa/filament/Generic ABS @Prusa MK4 MMU3.json new file mode 100644 index 0000000000..9c3a15622a --- /dev/null +++ b/resources/profiles/Prusa/filament/Generic ABS @Prusa MK4 MMU3.json @@ -0,0 +1,19 @@ +{ + "type": "filament", + "name": "Generic ABS @Prusa MK4 MMU3", + "inherits": "Generic ABS @Prusa MK4", + "from": "system", + "setting_id": "uFmixo2FX1yxNsDB", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ], + "filament_minimal_purge_on_wipe_tower": [ + "35" + ] +} diff --git a/resources/profiles/Prusa/filament/Generic ASA @Prusa MK4 MMU3.json b/resources/profiles/Prusa/filament/Generic ASA @Prusa MK4 MMU3.json new file mode 100644 index 0000000000..dc9f5f3b0e --- /dev/null +++ b/resources/profiles/Prusa/filament/Generic ASA @Prusa MK4 MMU3.json @@ -0,0 +1,19 @@ +{ + "type": "filament", + "name": "Generic ASA @Prusa MK4 MMU3", + "inherits": "Generic ASA @Prusa MK4", + "from": "system", + "setting_id": "iq9bFkpmAOJ4ae4g", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ], + "filament_minimal_purge_on_wipe_tower": [ + "35" + ] +} diff --git a/resources/profiles/Prusa/filament/Generic PC @Prusa MK4 MMU3.json b/resources/profiles/Prusa/filament/Generic PC @Prusa MK4 MMU3.json new file mode 100644 index 0000000000..9d02147b66 --- /dev/null +++ b/resources/profiles/Prusa/filament/Generic PC @Prusa MK4 MMU3.json @@ -0,0 +1,16 @@ +{ + "type": "filament", + "name": "Generic PC @Prusa MK4 MMU3", + "inherits": "Generic PC @Prusa", + "from": "system", + "setting_id": "qMawlK9QPI7DcF8I", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ] +} diff --git a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One INDX.json b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One INDX.json index 7e72e330d8..fa9bab9192 100644 --- a/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One INDX.json +++ b/resources/profiles/Prusa/filament/Generic PETG @Prusa CORE One INDX.json @@ -5,6 +5,9 @@ "from": "system", "setting_id": "HbevqxqvYHuvRc3v", "instantiation": "true", + "include": [ + "fdm_filament_template_indx" + ], "filament_cost": [ "27.82" ], diff --git a/resources/profiles/Prusa/filament/Generic PETG @Prusa MK4 MMU3.json b/resources/profiles/Prusa/filament/Generic PETG @Prusa MK4 MMU3.json new file mode 100644 index 0000000000..ec32594010 --- /dev/null +++ b/resources/profiles/Prusa/filament/Generic PETG @Prusa MK4 MMU3.json @@ -0,0 +1,19 @@ +{ + "type": "filament", + "name": "Generic PETG @Prusa MK4 MMU3", + "inherits": "Generic PETG @Prusa MK4", + "from": "system", + "setting_id": "lBiZBFpSXZNIp5XG", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ], + "filament_minimal_purge_on_wipe_tower": [ + "35" + ] +} diff --git a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One INDX.json b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One INDX.json index 98583cf4b3..8c3c1e2a15 100644 --- a/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One INDX.json +++ b/resources/profiles/Prusa/filament/Generic PLA @Prusa CORE One INDX.json @@ -5,6 +5,9 @@ "from": "system", "setting_id": "4qjcaIf1vk3K1x4Z", "instantiation": "true", + "include": [ + "fdm_filament_template_indx" + ], "filament_cost": [ "25.4" ], diff --git a/resources/profiles/Prusa/filament/Generic PLA @Prusa MK4 MMU3.json b/resources/profiles/Prusa/filament/Generic PLA @Prusa MK4 MMU3.json new file mode 100644 index 0000000000..a83fbb411d --- /dev/null +++ b/resources/profiles/Prusa/filament/Generic PLA @Prusa MK4 MMU3.json @@ -0,0 +1,16 @@ +{ + "type": "filament", + "name": "Generic PLA @Prusa MK4 MMU3", + "inherits": "Generic PLA @Prusa MK4", + "from": "system", + "setting_id": "kZvIh84niJ487FsY", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ] +} diff --git a/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One INDX.json b/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One INDX.json index 4c180da592..d4791320a9 100644 --- a/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One INDX.json +++ b/resources/profiles/Prusa/filament/Generic PLA Silk @Prusa CORE One INDX.json @@ -5,6 +5,9 @@ "from": "system", "setting_id": "eKla6su22rYaL9iz", "instantiation": "true", + "include": [ + "fdm_filament_template_indx" + ], "filament_flow_ratio": [ "1", "1", diff --git a/resources/profiles/Prusa/filament/Prusament ASA @MK4 MMU3.json b/resources/profiles/Prusa/filament/Prusament ASA @MK4 MMU3.json new file mode 100644 index 0000000000..249fd6f025 --- /dev/null +++ b/resources/profiles/Prusa/filament/Prusament ASA @MK4 MMU3.json @@ -0,0 +1,19 @@ +{ + "type": "filament", + "name": "Prusament ASA @MK4 MMU3", + "inherits": "Prusament ASA @MK4", + "from": "system", + "setting_id": "IRJ2dtBgjCMfmpOH", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ], + "filament_minimal_purge_on_wipe_tower": [ + "35" + ] +} diff --git a/resources/profiles/Prusa/filament/Prusament PC Blend @MK4 MMU3.json b/resources/profiles/Prusa/filament/Prusament PC Blend @MK4 MMU3.json new file mode 100644 index 0000000000..d728830afd --- /dev/null +++ b/resources/profiles/Prusa/filament/Prusament PC Blend @MK4 MMU3.json @@ -0,0 +1,16 @@ +{ + "type": "filament", + "name": "Prusament PC Blend @MK4 MMU3", + "inherits": "Prusament PC Blend @MK4", + "from": "system", + "setting_id": "gmNuCspHHxgP6zH8", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ] +} diff --git a/resources/profiles/Prusa/filament/Prusament PETG @MK4 MMU3.json b/resources/profiles/Prusa/filament/Prusament PETG @MK4 MMU3.json new file mode 100644 index 0000000000..9e3caf2d9f --- /dev/null +++ b/resources/profiles/Prusa/filament/Prusament PETG @MK4 MMU3.json @@ -0,0 +1,19 @@ +{ + "type": "filament", + "name": "Prusament PETG @MK4 MMU3", + "inherits": "Prusament PETG @MK4", + "from": "system", + "setting_id": "7DQ6YEKyQKYDTKtE", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pet" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ], + "filament_minimal_purge_on_wipe_tower": [ + "35" + ] +} diff --git a/resources/profiles/Prusa/filament/Prusament PETG @Prusa CORE One INDX.json b/resources/profiles/Prusa/filament/Prusament PETG @Prusa CORE One INDX.json index 0fdb644150..14a4494246 100644 --- a/resources/profiles/Prusa/filament/Prusament PETG @Prusa CORE One INDX.json +++ b/resources/profiles/Prusa/filament/Prusament PETG @Prusa CORE One INDX.json @@ -6,6 +6,9 @@ "setting_id": "2viuOmBvXuQm2FwV", "filament_id": "OFkR8E2c", "instantiation": "true", + "include": [ + "fdm_filament_template_indx" + ], "filament_flow_ratio": [ "1" ], diff --git a/resources/profiles/Prusa/filament/Prusament PLA @MK4 MMU3.json b/resources/profiles/Prusa/filament/Prusament PLA @MK4 MMU3.json new file mode 100644 index 0000000000..d52b958727 --- /dev/null +++ b/resources/profiles/Prusa/filament/Prusament PLA @MK4 MMU3.json @@ -0,0 +1,22 @@ +{ + "type": "filament", + "name": "Prusament PLA @MK4 MMU3", + "inherits": "Prusament PLA @MK4", + "from": "system", + "setting_id": "MsDcN4npnmLRXOjm", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ], + "filament_unloading_speed": [ + "100" + ], + "filament_unloading_speed_start": [ + "100" + ] +} diff --git a/resources/profiles/Prusa/filament/Prusament PLA @Prusa CORE One INDX.json b/resources/profiles/Prusa/filament/Prusament PLA @Prusa CORE One INDX.json index f5f64a98b3..82fdd349da 100644 --- a/resources/profiles/Prusa/filament/Prusament PLA @Prusa CORE One INDX.json +++ b/resources/profiles/Prusa/filament/Prusament PLA @Prusa CORE One INDX.json @@ -6,6 +6,9 @@ "setting_id": "B2UB9QUbbjpn12Ka", "filament_id": "OFnO4wnf", "instantiation": "true", + "include": [ + "fdm_filament_template_indx" + ], "filament_flow_ratio": [ "1" ], diff --git a/resources/profiles/Prusa/filament/Prusament rPLA @MK4 MMU3.json b/resources/profiles/Prusa/filament/Prusament rPLA @MK4 MMU3.json new file mode 100644 index 0000000000..e8b182bbf0 --- /dev/null +++ b/resources/profiles/Prusa/filament/Prusament rPLA @MK4 MMU3.json @@ -0,0 +1,16 @@ +{ + "type": "filament", + "name": "Prusament rPLA @MK4 MMU3", + "inherits": "Prusament rPLA @MK4", + "from": "system", + "setting_id": "wKhvyGpgjmW6glLj", + "instantiation": "true", + "include": [ + "fdm_filament_template_nextruder_pla" + ], + "compatible_printers": [ + "Prusa MK4 MMU3 0.4 nozzle", + "Prusa MK4 MMU3 0.6 nozzle", + "Prusa MK4 MMU3 HF0.4 nozzle" + ] +} diff --git a/resources/profiles/Prusa/filament/fdm_filament_template_indx.json b/resources/profiles/Prusa/filament/fdm_filament_template_indx.json new file mode 100644 index 0000000000..4aea931123 --- /dev/null +++ b/resources/profiles/Prusa/filament/fdm_filament_template_indx.json @@ -0,0 +1,21 @@ +{ + "type": "filament", + "name": "fdm_filament_template_indx", + "from": "system", + "instantiation": "false", + "filament_multitool_ramming": [ + "1" + ], + "filament_multitool_ramming_flow": [ + "30" + ], + "filament_multitool_ramming_volume": [ + "18" + ], + "filament_minimal_purge_on_wipe_tower": [ + "10" + ], + "filament_start_gcode": [ + "M572 S{pressure_advance[filament_extruder_id]} ; Pressure advance\nM573 R" + ] +} diff --git a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.4 nozzle.json b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.4 nozzle.json index a330559184..f89da08a0d 100644 --- a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.4 nozzle.json +++ b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.4 nozzle.json @@ -16,7 +16,7 @@ ], "printer_model": "Prusa MK4 MMU3", "default_filament_profile": [ - "Generic PLA @System" + "Generic PLA @Prusa MK4 MMU3" ], "default_print_profile": "0.20mm SPEED @MK4 MMU3 0.4", "printer_notes": "Don't remove the following keywords! These keywords are used in the \"compatible printer\" condition of the print and filament profiles to link the particular print and filament profiles to this printer profile.\nPRINTER_MODEL_MK4IS\nPRINTER_MODEL_MK4MMU3\nPG" diff --git a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.6 nozzle.json b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.6 nozzle.json index dcbb537214..8b4fed8700 100644 --- a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.6 nozzle.json +++ b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 0.6 nozzle.json @@ -17,7 +17,7 @@ "printer_model": "Prusa MK4 MMU3", "printer_variant": "0.6", "default_filament_profile": [ - "Generic PLA @System" + "Generic PLA @Prusa MK4 MMU3" ], "default_print_profile": "0.25mm SPEED @MK4 MMU3 0.6", "printer_notes": "Don't remove the following keywords! These keywords are used in the \"compatible printer\" condition of the print and filament profiles to link the particular print and filament profiles to this printer profile.\nPRINTER_MODEL_MK4IS\nPRINTER_MODEL_MK4MMU3\nPG" diff --git a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF.json b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF.json index 11eaf998ec..1b33bcd514 100644 --- a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF.json +++ b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF.json @@ -8,5 +8,5 @@ "bed_model": "mk4_bed.stl", "bed_texture": "mk4is.svg", "hotend_model": "", - "default_materials": "Generic PLA @System;Generic PETG @System;Generic ABS @System" + "default_materials": "Generic PLA @Prusa MK4 MMU3;Generic PETG @Prusa MK4 MMU3;Generic ABS @Prusa MK4 MMU3" } diff --git a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF0.4 nozzle.json b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF0.4 nozzle.json index 9010b9d75d..95a8628e96 100644 --- a/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF0.4 nozzle.json +++ b/resources/profiles/Prusa/machine/Prusa MK4 MMU3 HF0.4 nozzle.json @@ -16,7 +16,7 @@ ], "printer_model": "Prusa MK4 MMU3 HF", "default_filament_profile": [ - "Generic PLA @System" + "Generic PLA @Prusa MK4 MMU3" ], "default_print_profile": "0.20mm SPEED @MK4 MMU3 HF0.4", "printer_notes": "Don't remove the following keywords! These keywords are used in the \"compatible printer\" condition of the print and filament profiles to link the particular print and filament profiles to this printer profile.\nPRINTER_MODEL_MK4IS\nPRINTER_MODEL_MK4MMU3\nPG\nHF_NOZZLE" diff --git a/resources/profiles/Prusa/machine/Prusa MK4 MMU3.json b/resources/profiles/Prusa/machine/Prusa MK4 MMU3.json index 76a181b22a..cc071c0839 100644 --- a/resources/profiles/Prusa/machine/Prusa MK4 MMU3.json +++ b/resources/profiles/Prusa/machine/Prusa MK4 MMU3.json @@ -8,5 +8,5 @@ "bed_model": "mk4_bed.stl", "bed_texture": "mk4is.svg", "hotend_model": "", - "default_materials": "Generic PLA @System;Generic PETG @System;Generic ABS @System" + "default_materials": "Generic PLA @Prusa MK4 MMU3;Generic PETG @Prusa MK4 MMU3;Generic ABS @Prusa MK4 MMU3" } From 78a22fca93b2944a99052f3beafd3c1ddcbdd175 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 18:38:37 +0800 Subject: [PATCH 14/30] Hide the Design tab's reference planes until a sketch needs them, with an Origin row to pin them --- docs/HLSD/design-tab.md | 15 +++- scripts/CAD/check-gui-click-edit.py | 24 ++--- scripts/CAD/check-gui-sketching.py | 20 +++-- src/slic3r/GUI/CAD/DesignPanel.cpp | 130 +++++++++++++++++----------- src/slic3r/GUI/CAD/DesignPanel.hpp | 17 ++-- 5 files changed, 131 insertions(+), 75 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 739e0e47ca..45da2fa607 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -181,6 +181,17 @@ canvas (a sibling of the canvas, not a child over it — on GTK a child window o a plate grid is never read as a sketch grid, and `N` to look normal to the plane. Code that changes any of the three belongs with a change to this section. +The reference planes — XY, XZ and YZ through the modeling origin, with their half-axes — are +drawn on demand, because three translucent squares over every model are noise once they are not +the thing being picked. Sketch mode brings them up, since choosing the sketch plane is exactly +when they are picked; a live session draws none, and Finish or Cancel takes them away. The +Feature tree's Origin row, pinned above the features and never removable, keeps them up outside a +sketch. Its state is a view preference in AppConfig rather than part of the recipe, so it costs +the project format nothing. The Plane tool keeps its own rule: the planes and the datums as Offset +bases, and nothing for the other methods, where a click on a plane would rewrite the datum's +references. The `P` and `A` keys are a separate, unpickable view helper and do not follow the +Origin row. + ## Rendering the bodies The tab draws its bodies through the same `GLCanvas3D` object path as Prepare, so how they look @@ -253,8 +264,8 @@ The tab is a page of Orca's main window and answers to the same settings as Prep constraint-row buttons are Orca's self-painted `Button`, because a native button cannot take a hover background on macOS. The Feature tree and Bodies lists are a custom-drawn `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Edit, - Show/hide and Delete on a feature, Move, Show/hide and Delete on a body — and the eye shows - whether that row is hidden. + Show/hide and Delete on a feature, Move, Show/hide and Delete on a body, and only Show/hide on + the Origin row above the features — and the eye shows whether that row is hidden. - **Plates.** This is the one thing the tab does not follow. The canvas has a bed of its own at the printer bed's home position, whichever plate Prepare has current, and a new document's modeling origin is that bed's centre. A bed that followed the current plate would slide out diff --git a/scripts/CAD/check-gui-click-edit.py b/scripts/CAD/check-gui-click-edit.py index 159bdb19f1..9d202a35b0 100755 --- a/scripts/CAD/check-gui-click-edit.py +++ b/scripts/CAD/check-gui-click-edit.py @@ -480,10 +480,13 @@ def type_into_open_field(value, mark): def enter_sketch(timeout=180): """Open a real sketch on a real plane, and PROVE it with the socket before drawing anything. - THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. Shift+S enters sketch MODE and pops the plane - offer; the offer must be dismissed; and the plane itself is chosen by clicking it in the - viewport BEFORE Shift+S. check-gui-sketching.py has always done all four steps. This ladder - did two of them — Design tab, then Shift+S — and went straight to the tool letters. + THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. The reference planes are hidden until Shift+S + enters sketch MODE, so the plane is clicked AFTER it. Entering with a plane an earlier rung + chose pops the plane offer at once, and a click outside a popup menu only closes it, so the + plane is clicked twice, apart: the second click always lands on the plane. No Escape — with + no offer up, Escape in an empty sketch leaves it. Then a tool letter starts the session. + check-gui-sketching.py does the same steps. This ladder once did two of them — Design tab, + then Shift+S — and went straight to the tool letters. That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and traced, and not one click draws anything, because there is no plane under them. The ladder @@ -499,9 +502,9 @@ def enter_sketch(timeout=180): click(132, 53) # Design tab time.sleep(2.0) dismiss_modals() - click(*PLANE_PX) # pick the plane IN THE VIEWPORT — before Shift+S - key("shift+s", 1.0) - key("Escape", 0.5) # entering sketch mode pops the offer; dismiss it + key("shift+s", 1.0) # sketch mode brings the reference planes up + click(*PLANE_PX, pause=0.8) # closes the offer if an earlier rung's plane popped it... + click(*PLANE_PX) # ...so this is the click that picks the plane IN THE VIEWPORT key("p", 0.6) # any sketch tool starts the session on that plane if try_call("sketch_describe") is not None: # NO Escape here. Every rung already opens with one to drop whatever tool the last @@ -511,14 +514,15 @@ def enter_sketch(timeout=180): # reported all eleven checks failed with "nothing opened" — the tools were arming # into an empty Feature-mode document. return - die("no sketch opened after plane click + Shift+S within " + die("no sketch opened after Shift+S + plane click within " f"{timeout}s — sketch_describe never answered on {A.sock} (trace {TRACE})") # tool key, the clicks that draw it, and one distinct value per queued field. The values are # deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them. -# Where the plane label sits in the viewport before a sketch is open. Same constant the gesture -# ladder uses; it is a label on the 3D view, not a widget, so it moves only if the camera does. +# Where the plane label sits in the viewport once sketch mode has brought the planes up. Same +# constant the gesture ladder uses; it is a label on the 3D view, not a widget, so it moves only +# if the camera does. PLANE_PX = (913, 359) # Every coordinate below stays inside 1000..1400 x 500..760 — the box check-gui-sketching.py's diff --git a/scripts/CAD/check-gui-sketching.py b/scripts/CAD/check-gui-sketching.py index d4ebd1f1ca..16a7713444 100644 --- a/scripts/CAD/check-gui-sketching.py +++ b/scripts/CAD/check-gui-sketching.py @@ -229,7 +229,9 @@ CHROME_DY = int(os.environ.get("ORCA_CAD_CHROME_DY", "26")) DESIGN_TAB = (128, 29 + CHROME_DY) -# Feature-tree rows, measured on the rig at 1920x1080: first row centre, then 23 px apart. +# Feature-tree rows, measured on the rig at 1920x1080: the list's first row is centred at y=215, +# then 23 px apart. That first row is the Origin, pinned above the features and never deleted, so +# the first FEATURE row is the second one: Delete on the Origin does nothing. # x=300, not the label: a second click ON the label opens the inline rename, and Delete then # edits the text instead of removing the feature. # @@ -237,7 +239,7 @@ DESIGN_TAB = (128, 29 + CHROME_DY) # unshifted click lands 26 px BELOW the first row -- just past its 23 px height -- so the row is # never selected, Delete does nothing, and reset_document spends 40 rounds on it before dying # with "could not empty the feature tree". That names the feature tree, which is not the fault. -TREE_ROW0 = (300, 215 + CHROME_DY) +TREE_ROW0 = (300, 215 + 23 + CHROME_DY) def go_design(): @@ -266,18 +268,20 @@ def reset_document(): def enter_sketch(tool_key, plane_px=(913, 359)): """Enter a sketch the way the design law says: pick the plane in the viewport, then the tool. - Shift+S enters sketch MODE and pops the offer; Escape dismisses it; the tool letter then - starts the session on the plane the click selected. All four steps are real input — nothing - here goes through the socket. + Shift+S enters sketch MODE, which brings the reference planes up; the click picks one; the + tool letter then starts the session on it. Entering with a plane an earlier rung chose pops + the offer at once, and a click outside a popup menu only closes it, so the plane is clicked + twice, apart. No Escape: with no offer up, Escape in an empty sketch leaves it. All of it is + real input — nothing here goes through the socket. """ leave_sketch() - click(*plane_px) key("shift+s", 0.8) - key("Escape", 0.4) # entering sketch mode pops the offer; dismiss it + click(*plane_px, pause=0.8) # closes the offer if an earlier rung's plane popped it... + click(*plane_px) # ...so this is the click that picks the plane key("p", 0.6) if try_call("sketch_describe") is None: shot("/shots/gl-enter-failed.png") - die("no sketch opened after plane click + Shift+S (see /shots/gl-enter-failed.png)") + die("no sketch opened after Shift+S + plane click (see /shots/gl-enter-failed.png)") calibrate_here() # THIS sketch's own camera map, on THIS sketch's own plane key(tool_key, 0.6) diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 3e9c464e1f..4ff5f17642 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -9,6 +9,7 @@ #include "slic3r/GUI/GUI_Utils.hpp" #include "slic3r/GUI/Widgets/Button.hpp" #include "libslic3r/CAD/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor +#include "libslic3r/AppConfig.hpp" // design_* view preferences #include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set #include "libslic3r/Format/OBJ.hpp" #include "libslic3r/Format/bbs_3mf.hpp" // put_other_changes: mark the project dirty outside the undo stack @@ -284,6 +285,10 @@ static std::string commit_icon(bool bodies) // The icons on each Feature tree and Bodies row (DesignRowList::Action::id). enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete }; +// The Feature tree's first row is the Origin, pinned above the features: feature i is row i + 1. +static constexpr int kFeatureRow0 = 1; +// The feature a Feature tree row shows, or wxNOT_FOUND for the Origin row (and for no row). +static int feature_of_row(int row) { return row >= kFeatureRow0 ? row - kFeatureRow0 : wxNOT_FOUND; } // A row's eye shows the state the row is in; its tip names what a click does. static DesignRowList::Action eye_action(bool shown) @@ -291,6 +296,12 @@ static DesignRowList::Action eye_action(bool shown) return { RowVisibility, shown ? "design_eye" : "design_eye_off", shown ? _L("Hide") : _L("Show") }; } +// What the Origin row's eye and its menu item do. +static wxString origin_toggle_text(bool shown) +{ + return shown ? _L("Hide reference planes and axes") : _L("Show reference planes and axes"); +} + // Prepare outlines every numeric field (rounded, #4A4A51 on dark). wxSpinCtrlDouble is a // native GTK control that cannot draw that, and Orca's own SpinInput is int-only — it would // silently truncate a 2.5 mm radius. So the double spin keeps its arrows and validation and @@ -1099,8 +1110,8 @@ DesignPanel::DesignPanel(wxWindow* parent) // is editable now (CadBody::user_name), so the verb opens the editor there. if (const int b = tree_body_selection(); b >= 0) m_parts->begin_rename(b); - else if (const int sel = tree_selection(); sel != wxNOT_FOUND) - m_tree->begin_rename(sel); + else if (tree_selection() != wxNOT_FOUND) + m_tree->begin_rename(m_tree->selection()); else set_status(_L("Select a feature, or a body, first — then rename it")); // not an error }; @@ -3164,8 +3175,8 @@ DesignPanel::DesignPanel(wxWindow* parent) FromDIP(SidebarProps::ContentMargin())); tree_inner->Add(new wxStaticLine(m_tree_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(SidebarProps::TitlebarMargin())); - // Sized to its rows, so a short history wastes no block, and scrolling past 9. - m_tree = new DesignRowList(m_tree_box, 9); + // Sized to its rows, so a short history wastes no block, and scrolling past 9 features. + m_tree = new DesignRowList(m_tree_box, 9 + kFeatureRow0); m_tree->SetBackgroundColour(dp_panel_bg()); tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12); @@ -3176,8 +3187,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // Picking a feature drops any body selection, so the two lists never both claim to be // "the target" — but ONLY when this tree has a selection. Each list notifies on every // change, so clicking a body row runs apply_body_row, whose m_tree->unselect() fires - // THIS handler, which would otherwise clear the body row the user had just clicked. - if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->unselect(); + // THIS handler, which would otherwise clear the body row the user had just clicked. The + // Origin row counts: it is a selected row like any other. + if (m_parts && m_tree->selection() != wxNOT_FOUND) m_parts->unselect(); const int sel = tree_selection(); // Likewise a viewport pick, which would be drawn just like the feature's faces. Not while // a card is open: the card reads that pick. @@ -3199,12 +3211,17 @@ DesignPanel::DesignPanel(wxWindow* parent) }; // Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas. - m_tree->on_activate = [this] { on_edit_feature(); }; + // The Origin row has nothing to edit. + m_tree->on_activate = [this] { + if (tree_selection() != wxNOT_FOUND) on_edit_feature(); + }; // The row's own Edit / Show-hide / Delete, on the row the click selected. The body list is // cleared here too, not left to on_select, which re-clicking the selected row does not run: - // a body row still selected would be what on_toggle_visibility acts on. - m_tree->on_action = [this](int, int id) { + // a body row still selected would be what on_toggle_visibility acts on. The Origin row's one + // icon is its eye. + m_tree->on_action = [this](int row, int id) { + if (feature_of_row(row) == wxNOT_FOUND) { toggle_origin(); return; } if (m_parts) m_parts->unselect(); switch (id) { case RowEdit: on_edit_feature(); break; @@ -3217,11 +3234,21 @@ DesignPanel::DesignPanel(wxWindow* parent) // double-click is Edit, and F2 is a function key nothing announces. A user who wants to name // a sketch tries the row, and the row answers. m_tree->on_menu = [this](int row, const wxPoint& screen) { + wxMenu menu; + // The Origin row is no feature: it cannot be renamed, edited, moved or deleted, and its + // menu holds exactly what its eye does. + const int feat = feature_of_row(row); + if (feat == wxNOT_FOUND) { + const int id_origin = wxWindow::NewControlId(); + menu.Append(id_origin, origin_toggle_text(m_show_origin)); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { toggle_origin(); }, id_origin); + m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); + return; + } // EVERYTHING A ROW CAN DO, in one place. The row's icons are the quick bar, but the menu // is the reference: the element you click answers with what applies to it, and a menu // grows without spending an icon nobody recognises. Split into what the row IS (name, // contents), where it SITS (order, visibility) and what removes it. - wxMenu menu; const int id_rename = wxWindow::NewControlId(); const int id_edit = wxWindow::NewControlId(); const int id_up = wxWindow::NewControlId(); @@ -3234,8 +3261,8 @@ DesignPanel::DesignPanel(wxWindow* parent) // Scale artwork acts on THIS feature's imported outline, so it belongs to the row and // is offered only where it means something. It used to hide inside the header's Move // button, which otherwise moved a body — two different subjects on one icon. - const bool art = row < int(m_doc.features.size()) && - !m_doc.features[row].imported_regions.empty(); + const bool art = feat < int(m_doc.features.size()) && + !m_doc.features[feat].imported_regions.empty(); if (art) menu.Append(id_art, _L("Scale artwork")); menu.AppendSeparator(); menu.Append(id_up, _L("Move up")); @@ -3250,7 +3277,7 @@ DesignPanel::DesignPanel(wxWindow* parent) menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_toggle_visibility(); }, id_vis); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del); if (art) - menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { on_transform_imported(row); }, id_art); + menu.Bind(wxEVT_MENU, [this, feat](wxCommandEvent&) { on_transform_imported(feat); }, id_art); m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); }; @@ -3259,8 +3286,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // renaming belongs on the row, not in a side-panel field. The list hands the name over after // its editor's events have finished, so rebuilding the rows here is safe. m_tree->on_rename = [this](int row, const wxString& name) { - if (row < 0 || row >= int(m_doc.features.size())) return; - m_doc.features[row].name = std::string(name.ToUTF8().data()); + const int feat = feature_of_row(row); + if (feat == wxNOT_FOUND || feat >= int(m_doc.features.size())) return; + m_doc.features[feat].name = std::string(name.ToUTF8().data()); refresh_tree(); // which syncs the recipe, so the save path persists the name }; @@ -4422,7 +4450,11 @@ DesignPanel::DesignPanel(wxWindow* parent) assert(dead.empty()); // debug builds stop here; release ships the log line } + m_show_origin = wxGetApp().app_config->get_bool("design_show_origin"); set_ui_mode(UiMode::Feature); + // The Origin row from the first paint: nothing else builds the rows before the first edit, + // project load or theme switch. + refresh_tree(); build_phase("wiring"); BOOST_LOG_TRIVIAL(info) << "Design tab build: total " << build_clock.Time() << " ms"; } @@ -4516,9 +4548,9 @@ void DesignPanel::set_ui_mode(UiMode m) m_form->FitInside(); } update_action_bar(); // Sketch/Constrain modes show the unified ✓/✗; Feature idle hides it - // The origin planes follow the mode: entering Sketch offers them even when a body exists, - // leaving it takes them back. Without this they would only refresh on the next tree - // rebuild, which is not an event that happens when you merely press Sketch. + // The reference planes follow the mode (update_reference_planes). Without this they would + // only refresh on the next tree rebuild, which is not an event that happens when you merely + // press Sketch. update_reference_planes(); // Say where you are, in words, across the top of the viewport. @@ -7256,7 +7288,7 @@ wxString DesignPanel::idle_hint() const { return m_doc.features.empty() ? _L("Nothing yet — import a STEP or a mesh from the toolbar,\n" - "or click a reference plane and right-click it to start a sketch.") + "or press Sketch and click a reference plane to start a sketch.") : _L("No solid yet — select a sketch and right-click it to Extrude."); } @@ -7284,7 +7316,7 @@ void DesignPanel::on_tab_shown() if (m_viewport) m_viewport->refresh_bed(); hydrate_from_model(); - update_reference_planes(); // entering the Design tab: show the XY/XZ/YZ planes if no object yet + update_reference_planes(); // entering the Design tab if (show_clock.Time() > 100) // a slow first show is what users report; the usual one is not news BOOST_LOG_TRIVIAL(info) << "Design tab shown: bed, project recipe and planes in " << show_clock.Time() << " ms"; m_laid_out = true; @@ -7515,15 +7547,19 @@ void DesignPanel::refresh_tree() // Preserve the selected row across the rebuild — set_rows() drops the selection, which made // every edit/add feel like it "lost" the selection (and broke Edit/Move/Delete on the - // just-touched feature). - const int keep = tree_selection(); + // just-touched feature). By row, so a selected Origin row stays selected too. + const int keep = m_tree->selection(); // Datum/reference planes carry no solid; feed them to the viewport so they render as // translucent rectangles (otherwise a Plane feature is invisible in the canvas). refresh_datum_planes(); - update_reference_planes(); // body added/removed -> show/hide the XY/XZ/YZ origin planes + update_reference_planes(); std::vector rows; - rows.reserve(m_doc.features.size()); + rows.reserve(m_doc.features.size() + kFeatureRow0); + // Always first and never removable: the reference planes' own switch, and nothing else. + DesignRowList::Action origin_eye = eye_action(m_show_origin); + origin_eye.tip = origin_toggle_text(m_show_origin); + rows.push_back({ "design_plane", _L("Origin"), {}, dp_item_text(), { origin_eye } }); for (size_t fi = 0; fi < m_doc.features.size(); ++fi) { const CadFeature& f = m_doc.features[fi]; DesignRowList::Row row; @@ -7618,7 +7654,7 @@ int DesignPanel::tree_body_selection() const // if a row was selected. bool DesignPanel::deselect_rows() { - const bool any = (m_tree && tree_selection() != wxNOT_FOUND) || tree_body_selection() >= 0; + const bool any = (m_tree && m_tree->selection() != wxNOT_FOUND) || tree_body_selection() >= 0; if (m_tree) m_tree->unselect(); if (m_parts) m_parts->unselect(); return any; @@ -7933,13 +7969,13 @@ bool DesignPanel::place_on_face() int DesignPanel::tree_selection() const { - return m_tree->selection(); + return feature_of_row(m_tree->selection()); } -void DesignPanel::set_tree_selection(int row) +void DesignPanel::set_tree_selection(int feature) { - if (row >= 0 && row < int(m_tree->GetItemCount())) - m_tree->select(row); + if (feature >= 0 && feature + kFeatureRow0 < int(m_tree->GetItemCount())) + m_tree->select(feature + kFeatureRow0); } // The selection (the solid pick, the hit face and the committed-loop pick) names bodies, faces and @@ -11042,10 +11078,9 @@ void DesignPanel::update_rib_gizmo() m_rib_thickness ? m_rib_thickness->GetValue() : 0.0); } -// Onshape default planes: the XY/XZ/YZ reference planes are persistent, transparent, labelled, and -// larger than the bed — shown as the FALLBACK when there is no object yet. When the Plane tool is -// open they additionally surface existing datums so a base can be picked. Single authority for the -// reference-plane overlay (set/clear_base_pick). +// The XY/XZ/YZ reference planes and their axes. Single authority for the overlay +// (set/clear_base_pick): the Plane tool offers them with the datums as Offset bases; otherwise they +// are up while the Feature tree's Origin row shows them, and while a sketch plane is being chosen. void DesignPanel::update_reference_planes() { if (!m_viewport) return; @@ -11072,26 +11107,23 @@ void DesignPanel::update_reference_planes() } return; } - // Fallback (Onshape default planes): show the 3 reference planes while there is no SOLID body - // yet — so they persist through the 2D-sketch phase and reappear after a sketch is confirmed - // (a sketch creates no body). They no longer block selection: clicking existing geometry wins, - // a base-plane pick only fires on a click that hit nothing else (see on_mouse fall-through). - // Available while there is no solid yet OR while the user is actually choosing a sketch - // plane. The second half fixes a dead end: delete a sketch on a document that still has a - // body, press Sketch, and act_sketch says "click a face or a reference plane" — with the - // reference planes already taken away, because a body existed. The instruction was - // impossible to follow and there was no way to start a sketch at all short of finding a - // face to click. - // - // Not simply always-on: m_dbp_active both RENDERS and picks, so three translucent planes - // would otherwise float over every finished model. Tying them to Sketch mode shows them - // exactly when they are the thing being chosen, and hides them again on Finish. - if (m_doc.bodies.empty() || m_ui_mode == UiMode::Sketch) + // Sketch mode is where a plane is the thing being picked (act_sketch asks for "a face or a + // reference plane"); a live session draws none (DesignSketchTool::draws_reference_axes). + // They never block selection: clicking existing geometry wins, and a base-plane pick only + // fires on a click that hit nothing else (see on_mouse fall-through). + if (m_show_origin || m_ui_mode == UiMode::Sketch) m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); else m_viewport->clear_base_pick(); } +void DesignPanel::toggle_origin() +{ + m_show_origin = !m_show_origin; + wxGetApp().app_config->set_bool("design_show_origin", m_show_origin); + refresh_tree(); // the Origin row's eye, and update_reference_planes(), which repaints +} + TriangleMesh DesignPanel::ghost_from_bodies(const std::vector& per_body) const { TriangleMesh out; @@ -11693,7 +11725,7 @@ void DesignPanel::close_tool() m_viewport->clear_datum_gizmo(); m_viewport->set_operand_bodies(-1, -1); m_viewport->set_highlight_sketches({}); - update_reference_planes(); // back to no-tool: show the origin planes if there is no object yet + update_reference_planes(); // back to no-tool update_cards_frame(); m_form->Layout(); m_form->FitInside(); update_action_bar(); // no feature tool active -> hide the bar (unless a mode keeps it) diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 4b08afa384..ed8ccaf9f2 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -383,7 +383,8 @@ private: void update_rib_gizmo(); // in-plane slab footprint + thickness handles (Rib card) void refresh_datum_planes(); // push resolved datum frames + per-plane u/v extents to viewport void refresh_mate_connectors(); // push connector frames so verse + polarity are visible - void update_reference_planes(); // persistent XY/XZ/YZ reference planes (fallback when no object) + void update_reference_planes(); // the XY/XZ/YZ planes + axes: Origin row, Sketch mode, Plane tool + void toggle_origin(); // the Origin row's eye: keep the reference planes up, or not CadDocument m_doc; @@ -573,6 +574,10 @@ private: // m_ref_plane is always a VALID plane, so it cannot itself distinguish "the user chose XY" // from "nobody has chosen anything yet". This does. bool m_plane_picked{false}; + // The Feature tree's Origin row: keeps the reference planes and their axes up + // (update_reference_planes). A view preference (AppConfig "design_show_origin"), not part of + // the recipe. + bool m_show_origin{false}; ComboBox* m_shape{nullptr}; ComboBox* m_mode{nullptr}; wxSpinCtrlDouble* m_width{nullptr}; @@ -913,9 +918,9 @@ private: std::function m_value_cont; // deferred apply, run on Confirm std::function m_value_cancel; // optional action when the card is cancelled - // Feature tree: one row per feature, in feature order, with a per-type icon and the row's - // own Edit / Show-hide / Delete icons. Callers use row indices via - // tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows. + // Feature tree: the Origin row, then one row per feature, in feature order, with a per-type + // icon and the row's own Edit / Show-hide / Delete icons. Feature i is row i + 1; callers use + // feature indices via tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows. DesignRowList* m_tree{nullptr}; // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected. // Finding them replays the history, so they are kept per row and topology generation. @@ -969,10 +974,10 @@ private: void arm_transform_gizmo(); // arm the move gizmo on the Transform card's body (add mode only) void on_set_body_color(); // Color tool: pick a per-body display colour override void on_boolean_tool(); // Boolean (combine bodies): needs two solids, then opens the tool - int tree_selection() const; // selected feature row, or wxNOT_FOUND + int tree_selection() const; // selected feature's index, or wxNOT_FOUND (none, or the Origin row) int tree_body_selection() const; // selected Parts-list body index, or -1 void refresh_parts(); // rebuild the Bodies list under the feature tree - void set_tree_selection(int row); + void set_tree_selection(int feature); static const char* tree_icon_for(CadFeatureType t); wxStaticText* m_status{nullptr}; From 206a7461eac956f795cadb38a1be904710933830 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 18:56:43 +0800 Subject: [PATCH 15/30] Give the Design tab's import icons an arrow and size its document icons consistently --- resources/images/design_export.svg | 1 + resources/images/design_import_mesh.svg | 1 + resources/images/design_new.svg | 1 + resources/images/design_step.svg | 2 +- src/slic3r/GUI/CAD/DesignPanel.cpp | 6 +++--- 5 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 resources/images/design_export.svg create mode 100644 resources/images/design_import_mesh.svg create mode 100644 resources/images/design_new.svg diff --git a/resources/images/design_export.svg b/resources/images/design_export.svg new file mode 100644 index 0000000000..b998ea19fc --- /dev/null +++ b/resources/images/design_export.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_import_mesh.svg b/resources/images/design_import_mesh.svg new file mode 100644 index 0000000000..7517578578 --- /dev/null +++ b/resources/images/design_import_mesh.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_new.svg b/resources/images/design_new.svg new file mode 100644 index 0000000000..dfe7425de4 --- /dev/null +++ b/resources/images/design_new.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_step.svg b/resources/images/design_step.svg index a4a92b7b20..45974dfd77 100644 --- a/resources/images/design_step.svg +++ b/resources/images/design_step.svg @@ -1 +1 @@ - + diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 4ff5f17642..48a2e37f32 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -1267,7 +1267,7 @@ DesignPanel::DesignPanel(wxWindow* parent) fadd("step", b_step); // Import mesh — same destination as STEP (an editable B-rep body), but the geometry has // to be reconstructed from triangles first (GeometryEngine::mesh_to_brep). - auto* b_mesh = icon_btn("param_triangles", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); + auto* b_mesh = icon_btn("design_import_mesh", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); }); m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); }; fadd("mesh", b_mesh); @@ -1651,12 +1651,12 @@ DesignPanel::DesignPanel(wxWindow* parent) auto add_doc = [this](ScalableButton* b) { m_tb_doc->Add(b, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); }; - auto* b_new = doc_btn("add", _L("New Design — clear the feature tree")); + auto* b_new = doc_btn("design_new", _L("New Design — clear the feature tree")); b_new->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_new_design(); }); add_doc(b_new); add_doc(static_cast(tb_slot["step"][0])); // 2. Import STEP add_doc(static_cast(tb_slot["mesh"][0])); // 3. Import mesh - auto* b_export = doc_btn("save", _L("Export STEP…")); + auto* b_export = doc_btn("design_export", _L("Export STEP…")); b_export->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); }); add_doc(b_export); From 406ef0dd035894258d5f07d67590e853ea152862 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 19:25:22 +0800 Subject: [PATCH 16/30] Stop the Design tab's import buttons from staying highlighted after a click --- src/slic3r/GUI/CAD/DesignPanel.cpp | 32 ++++++++++++++---------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 48a2e37f32..f5910157b8 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -644,9 +644,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // the addresses are created inside the widget-building loops, so not building would silently // delete 42 verbs from the offer while they still rendered. 7ih records the cleanup // that lets the construction go away too. - // What stays: the two doc-row imports (consumed by add_doc below) and the view controls, - // which are chrome_only in the atlas and so have no offer row to fall back on. - static const std::set kBarKeep = { "step", "mesh", "place", "section", "flip" }; + // What stays: the view controls, which are chrome_only in the atlas and so have no offer row + // to fall back on. + static const std::set kBarKeep = { "place", "section", "flip" }; auto fadd = [&tb_slot](const char* id, wxWindow* w) { if (kBarKeep.count(id) == 0) { w->Hide(); return; } tb_slot[id].push_back(w); @@ -1259,18 +1259,7 @@ DesignPanel::DesignPanel(wxWindow* parent) }, SHIFT('T')}, }); // Text / SVG insert tools live in the SKETCH toolbar (they produce 2D profiles = - // sketches), not here. STEP stays in Features: it imports a whole B-rep solid. - // Import STEP — standalone: a STEP comes in as a whole editable B-rep body, not a profile. - auto* b_step = icon_btn("design_step", _L("Import STEP (editable B-rep solid)")); - b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); }); - m_keys_feature[SHIFT('I')] = [this] { on_import_step(); }; - fadd("step", b_step); - // Import mesh — same destination as STEP (an editable B-rep body), but the geometry has - // to be reconstructed from triangles first (GeometryEngine::mesh_to_brep). - auto* b_mesh = icon_btn("design_import_mesh", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); - b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); }); - m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); }; - fadd("mesh", b_mesh); + // sketches), not here. Import STEP / mesh are document actions: see the doc row. auto* b_constrain = icon_btn("design_constrain", _L("Constrain selected sketch")); b_constrain->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_begin_constrain(); @@ -1654,8 +1643,17 @@ DesignPanel::DesignPanel(wxWindow* parent) auto* b_new = doc_btn("design_new", _L("New Design — clear the feature tree")); b_new->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_new_design(); }); add_doc(b_new); - add_doc(static_cast(tb_slot["step"][0])); // 2. Import STEP - add_doc(static_cast(tb_slot["mesh"][0])); // 3. Import mesh + // Import STEP — a STEP comes in as a whole editable B-rep body, not a profile. + auto* b_step = doc_btn("design_step", _L("Import STEP (editable B-rep solid)")); + b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); }); + m_keys_feature[SHIFT('I')] = [this] { on_import_step(); }; + add_doc(b_step); + // Import mesh — same destination as STEP (an editable B-rep body), but the geometry has + // to be reconstructed from triangles first (GeometryEngine::mesh_to_brep). + auto* b_mesh = doc_btn("design_import_mesh", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); + b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); }); + m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); }; + add_doc(b_mesh); auto* b_export = doc_btn("design_export", _L("Export STEP…")); b_export->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); }); add_doc(b_export); From 4d4e52722f2c70620aa80f791210b69e4795984f Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 20:16:55 +0800 Subject: [PATCH 17/30] Open the Design tab's sketches only once their plane is picked Sketch on a picked flat face or reference plane opens the sketch on it at once. With nothing picked it no longer enters sketch mode: the reference planes and axes come up, and the plane or flat face clicked next opens the sketch and puts them away. Esc or Cancel leaves without one. A picked plane is used up by the sketch on it and dropped by Esc or a click on nothing, the plane prompt is no longer replaced by a stale tool hint, and clicking the face a sketch was just cancelled on picks that face again rather than the whole body. --- docs/HLSD/design-tab.md | 14 +- scripts/CAD/check-gui-click-edit.py | 25 +-- scripts/CAD/check-gui-sketching.py | 15 +- scripts/CAD/tool_atlas.json | 4 +- src/slic3r/GUI/CAD/DesignCanvas.cpp | 7 - src/slic3r/GUI/CAD/DesignCanvas.hpp | 1 - src/slic3r/GUI/CAD/DesignOffer.hpp | 2 +- src/slic3r/GUI/CAD/DesignPanel.cpp | 251 ++++++++++++++++-------- src/slic3r/GUI/CAD/DesignPanel.hpp | 16 +- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 5 +- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 1 - 11 files changed, 220 insertions(+), 121 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 45da2fa607..58bab4bfcb 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -135,7 +135,7 @@ contract between them is stated in code rather than spread across handlers. | --- | --- | --- | | `Transient` | a value field or a popup menu | closes it; the tool stays armed | | `Gesture` | an uncommitted delta — an entity being drawn, a body being dragged | reverts it; committed work is untouched | -| `Tool` | a feature card, an armed sketch tool, a constrain session | exits it; drawn entities survive | +| `Tool` | a feature card, Sketch waiting for its plane, an armed sketch tool, a constrain session | exits it; drawn entities survive | | `Idle` | nothing transient | clears the selection, a Feature tree or Bodies row included; leaves a sketch session only if it is empty | `cad_escape_level()` is a `constexpr` free function over a POD of four booleans rather than a @@ -181,10 +181,18 @@ canvas (a sibling of the canvas, not a child over it — on GTK a child window o a plate grid is never read as a sketch grid, and `N` to look normal to the plane. Code that changes any of the three belongs with a change to this section. +Sketch mode is never entered without a plane under it, so the banner, the sketch keys and the +sketch offer always have a session to act on. Sketch on a picked flat face or reference plane opens +the session on it at once. With nothing picked it stays in Feature mode and waits for one — an +armed `Tool`, left with `Esc` or ✗, and ended by anything that starts another edit — and the +reference plane or flat face clicked next opens the session. A picked plane is a selection like a +face: the sketch on it uses it up, and `Esc` or a click on nothing lets go of it, so a plane that +can no longer be seen never decides where the next sketch goes. + The reference planes — XY, XZ and YZ through the modeling origin, with their half-axes — are drawn on demand, because three translucent squares over every model are noise once they are not -the thing being picked. Sketch mode brings them up, since choosing the sketch plane is exactly -when they are picked; a live session draws none, and Finish or Cancel takes them away. The +the thing being picked. Sketch brings them up while it waits for a plane, which is exactly when +they are picked, and the session the pick opens takes them away; a live session draws none. The Feature tree's Origin row, pinned above the features and never removable, keeps them up outside a sketch. Its state is a view preference in AppConfig rather than part of the recipe, so it costs the project format nothing. The Plane tool keeps its own rule: the planes and the datums as Offset diff --git a/scripts/CAD/check-gui-click-edit.py b/scripts/CAD/check-gui-click-edit.py index 9d202a35b0..b0ee63dee9 100755 --- a/scripts/CAD/check-gui-click-edit.py +++ b/scripts/CAD/check-gui-click-edit.py @@ -480,13 +480,14 @@ def type_into_open_field(value, mark): def enter_sketch(timeout=180): """Open a real sketch on a real plane, and PROVE it with the socket before drawing anything. - THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. The reference planes are hidden until Shift+S - enters sketch MODE, so the plane is clicked AFTER it. Entering with a plane an earlier rung - chose pops the plane offer at once, and a click outside a popup menu only closes it, so the - plane is clicked twice, apart: the second click always lands on the plane. No Escape — with - no offer up, Escape in an empty sketch leaves it. Then a tool letter starts the session. - check-gui-sketching.py does the same steps. This ladder once did two of them — Design tab, - then Shift+S — and went straight to the tool letters. + THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. The reference planes are hidden until Shift+S, + with nothing picked, brings them up to choose from, so the plane is clicked AFTER it. That + click opens the session on the plane and pops the tool offer, and a click outside a popup + menu only closes it, so the spot is clicked twice, apart: the second click closes the offer, + and inside the session it is empty canvas. No Escape — with no offer up, Escape in an empty + sketch leaves it. Then a tool letter arms the tool. check-gui-sketching.py does the same + steps. This ladder once did two of them — Design tab, then Shift+S — and went straight to + the tool letters. That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and traced, and not one click draws anything, because there is no plane under them. The ladder @@ -502,10 +503,10 @@ def enter_sketch(timeout=180): click(132, 53) # Design tab time.sleep(2.0) dismiss_modals() - key("shift+s", 1.0) # sketch mode brings the reference planes up - click(*PLANE_PX, pause=0.8) # closes the offer if an earlier rung's plane popped it... - click(*PLANE_PX) # ...so this is the click that picks the plane IN THE VIEWPORT - key("p", 0.6) # any sketch tool starts the session on that plane + key("shift+s", 1.0) # nothing picked: the reference planes come up + click(*PLANE_PX, pause=0.8) # picks the plane IN THE VIEWPORT: session open, offer up... + click(*PLANE_PX) # ...and this closes the offer + key("p", 0.6) # arms Point in the session if try_call("sketch_describe") is not None: # NO Escape here. Every rung already opens with one to drop whatever tool the last # one left armed, and Escape in the Design tab walks a LIFO: first press drops the @@ -520,7 +521,7 @@ def enter_sketch(timeout=180): # tool key, the clicks that draw it, and one distinct value per queued field. The values are # deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them. -# Where the plane label sits in the viewport once sketch mode has brought the planes up. Same +# Where the plane label sits in the viewport once Shift+S has brought the planes up. Same # constant the gesture ladder uses; it is a label on the 3D view, not a widget, so it moves only # if the camera does. PLANE_PX = (913, 359) diff --git a/scripts/CAD/check-gui-sketching.py b/scripts/CAD/check-gui-sketching.py index 16a7713444..9d8729c49f 100644 --- a/scripts/CAD/check-gui-sketching.py +++ b/scripts/CAD/check-gui-sketching.py @@ -268,16 +268,17 @@ def reset_document(): def enter_sketch(tool_key, plane_px=(913, 359)): """Enter a sketch the way the design law says: pick the plane in the viewport, then the tool. - Shift+S enters sketch MODE, which brings the reference planes up; the click picks one; the - tool letter then starts the session on it. Entering with a plane an earlier rung chose pops - the offer at once, and a click outside a popup menu only closes it, so the plane is clicked - twice, apart. No Escape: with no offer up, Escape in an empty sketch leaves it. All of it is - real input — nothing here goes through the socket. + leave_sketch()'s Escapes let go of every pick, so Shift+S finds nothing to sketch on and only + brings the reference planes up; the click on one opens the session on it and pops the tool + offer. A click outside a popup menu only closes it, so the same spot is clicked again, apart: + inside the session it is empty canvas, so it selects nothing if the offer is slow to appear. + No Escape: with no offer up, Escape in an empty sketch leaves it. The tool letter then arms + the tool. All of it is real input — nothing here goes through the socket. """ leave_sketch() key("shift+s", 0.8) - click(*plane_px, pause=0.8) # closes the offer if an earlier rung's plane popped it... - click(*plane_px) # ...so this is the click that picks the plane + click(*plane_px, pause=0.8) # picks the plane: the session opens and the offer pops... + click(*plane_px) # ...and this closes the offer key("p", 0.6) if try_call("sketch_describe") is None: shot("/shots/gl-enter-failed.png") diff --git a/scripts/CAD/tool_atlas.json b/scripts/CAD/tool_atlas.json index 1e7f1bf968..ef106a02ee 100644 --- a/scripts/CAD/tool_atlas.json +++ b/scripts/CAD/tool_atlas.json @@ -229,11 +229,11 @@ "none" ], "needs": {}, - "refusal": "Click a face or a reference plane in the viewport, then a sketch tool", + "refusal": "Select a flat face or a reference plane to sketch on", "gui": true, "action": "key:S+S", "icon": "design_sketch", - "hint": "Click a face or a reference plane, then pick a drawing tool" + "hint": "Sketch on the selected flat face or plane, or click one next" }, { "id": "extrude", diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 72e29ff562..a2ced05cc0 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -481,13 +481,6 @@ void DesignCanvas::begin_sketch(const SketchPlane& plane, DesignSketchTool::Mode if (m_canvas_widget) m_canvas_widget->Refresh(); } -void DesignCanvas::set_sketch_plane(const SketchPlane& plane) -{ - m_sketch_tool.set_plane(plane); // keeps the 2D entities; only the carrier plane changes - if (m_canvas) m_canvas->set_as_dirty(); - if (m_canvas_widget) m_canvas_widget->Refresh(); -} - void DesignCanvas::edit_sketch(const std::vector& entities, const std::vector& constraints, const SketchPlane& plane) diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index dc6b4bf41e..62becddc10 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -68,7 +68,6 @@ public: const std::vector& constraints, const SketchPlane& plane); void set_sketch_tool(DesignSketchTool::Mode mode); - void set_sketch_plane(const SketchPlane& plane); // re-plane the live sketch when a reference plane is clicked in 3D void set_sketch_construction(bool c); // Flip the sketch selection between construction and real geometry; returns the // number of entities changed (0 = nothing selected, caller falls back to the mode). diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp index 67133573a3..f58f9ab658 100644 --- a/src/slic3r/GUI/CAD/DesignOffer.hpp +++ b/src/slic3r/GUI/CAD/DesignOffer.hpp @@ -99,7 +99,7 @@ static const bool kOfferRowFlat[] = { }; static const OfferVerb kOfferVerbs[] = { - {"sketch", L("Sketch"), 0, "Shift+S", "key:S+S", L("Click a face or a reference plane in the viewport, then a sketch tool"), 0x00000403u, 0, 0, false, false, nullptr, "design_sketch", L("Click a face or a reference plane, then pick a drawing tool")}, + {"sketch", L("Sketch"), 0, "Shift+S", "key:S+S", L("Select a flat face or a reference plane to sketch on"), 0x00000403u, 0, 0, false, false, nullptr, "design_sketch", L("Sketch on the selected flat face or plane, or click one next")}, {"extrude", L("Extrude"), 1, "Shift+E", "key:S+E", L("Create a sketch, or pick a solid face, first"), 0x00004002u, 0, 0, false, false, nullptr, "design_extrude", L("Extrude a sketch profile, or push/pull a picked face")}, {"revolve", L("Revolve"), 1, "Shift+R", "key:S+R", L("Create a sketch profile to revolve first"), 0x00004000u, 0, 0, false, false, nullptr, "design_revolve", L("Revolve a profile about an axis")}, {"sweep", L("Sweep"), 1, "Shift+W", "key:S+W", L("Create a profile sketch to sweep first"), 0x00004000u, 0, 2, false, false, nullptr, "design_sweep", L("Sweep a profile along a path")}, diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index f5910157b8..ac9b5d59d6 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -302,6 +302,12 @@ static wxString origin_toggle_text(bool shown) return shown ? _L("Hide reference planes and axes") : _L("Show reference planes and axes"); } +// What Sketch asks for while it waits for a plane (DesignPanel::start_sketch). +static wxString sketch_plane_prompt() +{ + return _L("Click a reference plane or a flat face to sketch on it — Esc cancels"); +} + // Prepare outlines every numeric field (rounded, #4A4A51 on dark). wxSpinCtrlDouble is a // native GTK control that cannot draw that, and Orca's own SpinInput is int-only — it would // silently truncate a 2.5 mm radius. So the double spin keeps its arrows and validation and @@ -490,22 +496,11 @@ DesignPanel::DesignPanel(wxWindow* parent) m_construction->SetForegroundColour(dp_ctl_text()); auto select_tool = [this](DesignSketchTool::Mode mode, const wxString& hint) { if (!m_viewport) return; - if (!m_viewport->is_sketching()) { - wxString on; - const SketchPlane plane = sketch_plane_from_selection(on); - m_viewport->begin_sketch(plane, mode); - m_construction->SetValue(false); // a fresh session starts non-construction - m_sketch_on = on; // shown with the tool hint, so the target is visible - // The face has been CONSUMED as the sketch plane, so drop the pick. Leaving it live - // meant the next Extrude saw a selected face and push/pulled it instead of extruding - // the sketch just drawn — the same trap the imported-art path already guards against. - if (m_sel_solid_face >= 0 || m_pick_face >= 0) { - m_sel_solid_face = m_sel_solid_edge = m_sel_solid_body = -1; - m_pick_face = m_pick_face_body = -1; - } - } else { - m_viewport->set_sketch_tool(mode); - } + // Sketch mode opens with its session (start_sketch_on_target); this covers a mode a + // failed Constrain left without one. + if (!m_viewport->is_sketching()) + start_sketch_on_target(false); + m_viewport->set_sketch_tool(mode); m_viewport->set_sketch_construction(m_construction->GetValue()); set_status(StatusKind::Info, m_sketch_on.IsEmpty() ? hint : wxString::Format(_L("%s · on %s"), hint, m_sketch_on)); @@ -730,32 +725,9 @@ DesignPanel::DesignPanel(wxWindow* parent) }; auto* b_sketch = icon_btn("design_sketch", _L("Sketch")); - // Sketch means a tool. Pressing it used to set the mode and then ask, unconditionally, - // for the very thing the user had just done — click XZ, read "XZ plane selected, press - // Sketch", press Sketch, and be told to click a reference plane. The plane was never - // lost (m_ref_plane holds it and begin_sketch captures it when the first tool is armed); - // the sentence was simply false, and with right-click excluded in sketch mode there was - // no door to the tools at all, so the only way on was the toolbar this tab is retiring. - std::function act_sketch = [this] { - set_ui_mode(UiMode::Sketch); - wxString where; - const bool have_plane = sketch_plane_target(where); - set_status(StatusKind::Info, have_plane - ? wxString::Format(_L("Sketching on %s — pick a tool"), where) - : _L("Click a face or a reference plane in the viewport, then a sketch tool")); - if (m_sketch_hint) { // the card must agree with the status line, not argue with it - m_sketch_hint->SetLabel(have_plane - ? wxString::Format(_L("Drawing on %s.\nPick a tool, or press Menu for the list."), where) - : _L("Click a face or a reference plane, then a sketch tool.")); - m_sketch_hint->Refresh(); - m_cards->Layout(); - } - // Hand over the tools rather than naming them in a status line. CallAfter so the - // mode change has settled before a modal menu takes the loop; the menu carries each - // tool's shortcut, so pressing the key instead of picking a row costs nothing. - if (have_plane) - CallAfter([this] { show_offer_menu(offer_anchor()); }); - }; + // Sketch opens a sketch on the face or plane already picked, or asks for one first + // (start_sketch): sketch mode is never entered without a plane under it. + std::function act_sketch = [this] { start_sketch(); }; b_sketch->Bind(wxEVT_BUTTON, [act_sketch](wxCommandEvent&) { act_sketch(); }); m_keys_feature[SHIFT('S')] = act_sketch; fadd("sketch", b_sketch); @@ -1406,12 +1378,11 @@ DesignPanel::DesignPanel(wxWindow* parent) // the sketch tools, not in the generic Features strip. Each places the art // in-canvas, then commits via the Insert card's Confirm. { - // In Sketch MODE the art must land in the sketch — but begin_sketch() does not run - // until the first tool is armed, so pressing Sketch and then Text would find no - // session and commit a separate feature. Arm Select first: it starts the session on - // the picked plane without drawing anything, so Text behaves the same whether or not - // you had already drawn a line. (MODE vs SESSION — the distinction that has bitten - // this panel before.) + // In Sketch MODE the art must land in the sketch. The mode opens with its session + // (start_sketch_on_target), but a failed Constrain can leave it without one, and Text + // would then find no session and commit a separate feature. Arm Select first: it + // starts the session without drawing anything. (MODE vs SESSION — the distinction + // that has bitten this panel before.) auto ensure_sketch = [this, select_tool] { if (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->is_sketching()) select_tool(DesignSketchTool::Mode::Select, @@ -3125,11 +3096,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // looking and pointing. A combo duplicated that decision somewhere the geometry could not // see it, and once a face could be picked it went further and displayed a stale row that // contradicted the real target. e1p. - // Kept as a member, not a local: the card has to be able to STOP saying this. It asked - // for a plane even when one had just been picked, directly contradicting the status line - // two inches below it, which by then read "Sketching on XZ". - m_sketch_hint = new wxStaticText(m_cards, wxID_ANY, - _L("Click a face or a reference plane, then a sketch tool.")); + // A member, not a local: it names the plane the session opened on, which is known only + // when it opens (start_sketch_on_target writes it), so the card and the status line agree. + m_sketch_hint = new wxStaticText(m_cards, wxID_ANY, _L("Pick a tool, or press Menu for the list.")); m_sketch_hint->SetForegroundColour(dp_sec_text()); m_box_sketch_session->Add(m_sketch_hint, 0, wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 12); } @@ -3748,6 +3717,22 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->clear_loop_pick(); m_plane_picked = false; } + // Sketch is waiting for a plane: a flat face is one. m_pick_face is the face the click + // landed on whether it took the face, one of its edges or a corner — the rule Sketch + // applies to a face picked beforehand. A curved face says why it was not taken; a swept + // body or a click that let go of the pick leaves the prompt up. + if (sketch_plane_pick_live()) { + wxString on; + if (level >= 1 && sketch_plane_target(on)) { + start_sketch_on_target(true); + return; + } + set_status(StatusKind::Info, level >= 1 && m_pick_face >= 0 + ? _L("That face is not flat — click a flat face or a reference plane") + : sketch_plane_prompt()); + m_status->Refresh(); + return; + } // 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 @@ -3934,7 +3919,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // A click on nothing drops a list row as it drops a pick. Not while a card is open: the // feature being edited keeps its row. m_viewport->set_on_empty_pick([this] { - if (m_active == Tool::None && deselect_rows()) + if (m_active != Tool::None) return; + const bool row = deselect_rows(); + if (drop_plane_pick() || row) set_status(StatusKind::Info, _L("Nothing selected")); }); @@ -4012,25 +3999,25 @@ DesignPanel::DesignPanel(wxWindow* parent) refresh_plane_labels(); refresh_preview(); // re-resolve the frame + move the gizmo/ghosts to the new base } else { - // Clicking a reference plane in 3D IS how a sketch plane is chosen now. Record it and, - // when a session is already live, re-plane it immediately: begin_sketch captures the - // plane at first-tool-pick, so without this the entities would stay on the old plane - // while the committed feature landed on the new one. + // Clicking a reference plane in 3D IS how a sketch plane is chosen. The planes are + // only pickable outside a session (DesignSketchTool hit-tests them while no session + // is live), and the canvas reports the click after clearing the solid selection on the + // same miss, so the face pick is already empty and the sketch falls back to this plane. if (base >= 0) { m_ref_plane = base; m_plane_picked = true; // chosen, not merely defaulted to m_pick_face = m_pick_face_body = -1; // last pick wins: a plane beats a stale face - if (m_viewport && m_viewport->is_sketching()) - m_viewport->set_sketch_plane(plane_from_choice(m_ref_plane)); + } + if (base >= 0 && sketch_plane_pick_live()) { + start_sketch_on_target(true); // Sketch was waiting for exactly this + return; } const char* nm = (base == 0) ? "XY" : (base == 1) ? "XZ" : (base == 2) ? "YZ" : "datum"; - // Both halves named the TOOLBAR, which no longer carries either button: the tools - // moved to the offer. Name the gesture that actually works in each mode, and say - // what a plain click does, since the two are easy to confuse on a plane. - set_status(StatusKind::Ok, m_ui_mode == UiMode::Sketch - ? wxString::Format(_L("%s plane selected — right-click for the drawing tools"), nm) - : wxString::Format(_L("%s plane selected — right-click to sketch on it, " - "or click an object to select it"), nm)); + // Name the gesture that works, and what a plain click does, since the two are easy to + // confuse on a plane. + set_status(StatusKind::Ok, + wxString::Format(_L("%s plane selected — right-click to sketch on it, " + "or click an object to select it"), nm)); m_status->Refresh(); } }); @@ -4176,13 +4163,8 @@ DesignPanel::DesignPanel(wxWindow* parent) const int key = e.GetKeyCode(); const bool ctrl = e.ControlDown() || e.CmdDown(); const bool sketching = (m_ui_mode == UiMode::Sketch) && m_viewport && m_viewport->is_sketching(); - // Which key MAP applies is a question about the MODE, not about whether a session is - // already running. Gating the sketch map on is_sketching() made it unreachable by - // keyboard: is_sketching() only turns true inside select_tool()'s begin_sketch(), and - // select_tool() is what the sketch keys call — so the first letter after entering - // sketch mode fell through to the feature map, matched nothing (feature keys are - // Shift+letter), and did nothing. The mouse worked only because the toolbar flyout - // reaches select_tool() directly. That is why all 17 keys read as dead. 0ud. + // Which key MAP applies is a question about the MODE, not about the canvas tool: + // is_sketching() is true in Constrain too, which keeps the feature map. 0ud. const bool sketch_mode = (m_ui_mode == UiMode::Sketch); // Never steal editing keys from a focused text field or an open in-canvas value field — // Delete/Ctrl+Z there must edit the text, not the model. @@ -4500,6 +4482,9 @@ void DesignPanel::set_ui_mode(UiMode m) // since the gizmo is not drawn in a sketch. if (m != UiMode::Feature) end_body_move(true); m_ui_mode = m; + // Any mode change ends a plane choice under way; the refreshes below put the planes and the + // ✓/✗ right for it. + m_choosing_sketch_plane = false; if (m != UiMode::Sketch) m_sketch_on.clear(); // no stale "on the picked face" on the next hint // A committed loop picked before the session means nothing to the sketch map, but it would // still count as a selection and swallow the first Esc. The double-click that opens a sketch @@ -4546,9 +4531,8 @@ void DesignPanel::set_ui_mode(UiMode m) m_form->FitInside(); } update_action_bar(); // Sketch/Constrain modes show the unified ✓/✗; Feature idle hides it - // The reference planes follow the mode (update_reference_planes). Without this they would - // only refresh on the next tree rebuild, which is not an event that happens when you merely - // press Sketch. + // The reference planes follow the plane choice the mode change just ended + // (update_reference_planes); without this they would wait for the next tree rebuild. update_reference_planes(); // Say where you are, in words, across the top of the viewport. @@ -4699,6 +4683,7 @@ void DesignPanel::open_text_dialog(int feat) { if (m_text_dlg != nullptr) { m_text_dlg->Raise(); return; } end_body_move(true); + end_sketch_plane_choice(); // the text goes on the plane picked before, not on the next click m_text_editing = feat >= 0 && feat < int(m_doc.features.size()) && m_doc.features[feat].is_text(); m_text_feat = m_text_editing ? feat : -1; m_text_face_body = -1; @@ -5158,6 +5143,7 @@ void DesignPanel::open_insert_card(const wxString& base_name) { end_body_move(true); m_active = Tool::Insert; + end_sketch_plane_choice(); if (m_hdr_insert) m_hdr_insert->SetLabel(base_name); wxSizer* s = m_cards->GetSizer(); s->Show(m_box_insert, true, true); @@ -5203,6 +5189,7 @@ void DesignPanel::on_transform_imported(int feat_idx) if (f.imported_regions.empty()) return; end_body_move(true); // the art's placement gizmo and ✓/✗ take over from a Move left open + end_sketch_plane_choice(); // ...and from a plane choice: Scale artwork reaches here directly // In-canvas bbox handles (replaces the Move/Scale dialog): drag a corner to scale, // the centre to move. Values stream back via set_on_imported_transform. m_viewport->begin_imported_transform(feat_idx, f.imported_regions, f.plane, @@ -7658,6 +7645,16 @@ bool DesignPanel::deselect_rows() return any; } +// A picked reference plane is a selection like a face: the next Sketch opens on it, so letting go +// of everything has to let go of it too. The viewport asks for it every frame (set_selected_base). +bool DesignPanel::drop_plane_pick() +{ + if (!m_plane_picked) return false; + m_plane_picked = false; + if (m_viewport) m_viewport->request_repaint(); + return true; +} + void DesignPanel::update_section_flip_btn() { if (m_section_flip_btn) m_section_flip_btn->Enable(m_section_on); @@ -7828,6 +7825,7 @@ void DesignPanel::on_move_body() set_status(StatusKind::Info, hint); return; } + end_sketch_plane_choice(); // the gizmo takes the clicks a plane choice was waiting for sync_body_xform(); // Delta gizmo: pivot at the body's CURRENT world centroid; the tool composes the drag deltas // onto its current pose, so move + rotate both work (incl. on an already place-on-face'd body). @@ -7945,6 +7943,7 @@ bool DesignPanel::place_on_face() } const TopoDS_Face face = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); if (face.IsNull()) return false; + end_sketch_plane_choice(); // the body moves; a sketch was not started sync_body_xform(); const Transform3d old_x = m_body_xform[b]; // Outward face normal in the body's CURRENT displayed orientation. @@ -8017,6 +8016,9 @@ bool DesignPanel::begin_renumber() void DesignPanel::after_tree_edit(bool ok) { + // Any edit of the history (undo, delete, show/hide, a socket call...) writes the status line + // over the plane prompt, so it ends the choice rather than leave it waiting unannounced. + end_sketch_plane_choice(); update_undo_redo_buttons(); refresh_tree(); refresh_variables(); @@ -10094,6 +10096,7 @@ void DesignPanel::on_commit() // is shown on screen, not the pre-feature solid. (confirm_tool() applies + closes.) if (m_active != Tool::None) confirm_tool(); + end_sketch_plane_choice(); // the bodies leave for the plate; a sketch was not started if (m_doc.display_mesh.its.indices.empty()) { set_status(_L("Nothing to commit — add a feature first")); @@ -11105,11 +11108,11 @@ void DesignPanel::update_reference_planes() } return; } - // Sketch mode is where a plane is the thing being picked (act_sketch asks for "a face or a - // reference plane"); a live session draws none (DesignSketchTool::draws_reference_axes). + // Up while Sketch waits for a plane to be picked (start_sketch), which the session it opens + // takes away; a live session would draw none anyway (DesignSketchTool::draws_reference_axes). // They never block selection: clicking existing geometry wins, and a base-plane pick only // fires on a click that hit nothing else (see on_mouse fall-through). - if (m_show_origin || m_ui_mode == UiMode::Sketch) + if (m_show_origin || m_choosing_sketch_plane) m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); else m_viewport->clear_base_pick(); @@ -11119,9 +11122,77 @@ void DesignPanel::toggle_origin() { m_show_origin = !m_show_origin; wxGetApp().app_config->set_bool("design_show_origin", m_show_origin); + // A plane picked while they were up is not a selection once nobody can see it. + if (!m_show_origin) drop_plane_pick(); + // Not an edit: a plane choice under way stays, and keeps its planes up. refresh_tree(); // the Origin row's eye, and update_reference_planes(), which repaints } +void DesignPanel::start_sketch() +{ + if (m_ui_mode != UiMode::Feature) return; // already sketching or constraining + if (m_active != Tool::None || m_text_dlg != nullptr || body_move_pending()) { + set_status(StatusKind::Info, _L("Finish or cancel what is open first")); + return; + } + wxString where; + if (sketch_plane_target(where)) { + start_sketch_on_target(true); + return; + } + // Nothing to sketch on yet: offer the planes and stay in Feature mode until one is picked + // (set_on_datum_base_picked, set_on_solid_selection_changed). + m_choosing_sketch_plane = true; + update_reference_planes(); + update_action_bar(); // ✗ leaves the choice + set_status(StatusKind::Info, sketch_plane_prompt()); +} + +void DesignPanel::start_sketch_on_target(bool offer_tools) +{ + if (!m_viewport) return; + wxString on; + const SketchPlane plane = sketch_plane_from_selection(on); + m_viewport->begin_sketch(plane, DesignSketchTool::Mode::Select); + // The face or plane has been CONSUMED as the sketch plane, so drop the pick. A face left live + // meant the next Extrude saw a selected face and push/pulled it instead of extruding the + // sketch just drawn — the same trap the imported-art path already guards against — and a + // plane left picked would open the next sketch on it, unseen once the planes are hidden. + // The canvas's half goes too: left lit there, the next click on that face read as a second + // click on it and took the whole body, which has no plane to sketch on. + drop_solid_pick(); + m_plane_picked = false; + m_construction->SetValue(false); // a fresh session starts non-construction + set_ui_mode(UiMode::Sketch); // ends the plane choice, so the planes go + m_sketch_on = on; // shown with the tool hint, so the target is visible + set_status(StatusKind::Info, wxString::Format(_L("Sketching on %s — pick a tool"), on)); + if (m_sketch_hint) { // the card must agree with the status line, not argue with it + m_sketch_hint->SetLabel(wxString::Format(_L("Drawing on %s.\nPick a tool, or press Menu for the list."), on)); + m_sketch_hint->Refresh(); + m_cards->Layout(); + } + // Hand over the tools rather than naming them in a status line: the sketch bar carries none. + // CallAfter so the mode change has settled, and the pick that got here has returned, before a + // modal menu takes the loop; the menu carries each tool's shortcut, so pressing the key + // instead of picking a row costs nothing. + if (offer_tools) + CallAfter([this] { show_offer_menu(offer_anchor()); }); +} + +void DesignPanel::end_sketch_plane_choice() +{ + if (!m_choosing_sketch_plane) return; + m_choosing_sketch_plane = false; + update_reference_planes(); + update_action_bar(); +} + +bool DesignPanel::sketch_plane_pick_live() const +{ + return m_choosing_sketch_plane && m_ui_mode == UiMode::Feature && m_active == Tool::None + && m_text_dlg == nullptr && !body_move_pending(); +} + TriangleMesh DesignPanel::ghost_from_bodies(const std::vector& per_body) const { TriangleMesh out; @@ -11378,6 +11449,7 @@ void DesignPanel::open_tool(Tool t) { end_body_move(true); // first: arm_transform_gizmo below takes over the same gizmo m_active = t; + end_sketch_plane_choice(); // after m_active: the Plane card keeps its planes m_candidate_ok = true; // a fresh card is confirmable until its preview says otherwise // Fillet/Chamfer/Draft no longer fade the body see-through; instead, once a valid target // is picked, refresh_preview hides the base bodies entirely (preview-only). Keep it opaque @@ -11834,6 +11906,11 @@ void DesignPanel::tool_cancel() } if (m_active == Tool::Insert) { cancel_insert(); return; } if (m_active != Tool::None) { cancel_tool(); return; } + if (m_choosing_sketch_plane) { // Sketch was waiting for a plane: no sketch, planes away + end_sketch_plane_choice(); + set_status(StatusKind::Info, wxString()); + return; + } if (m_ui_mode == UiMode::Sketch) { // THE explicit discard. Esc no longer arrives here at all (it routes through escape(), // which cannot destroy anything), so this button is now the only way a drawn sketch is @@ -11895,9 +11972,10 @@ CadLevel DesignPanel::escape_level() const // being moved by a gizmo that has not been confirmed. st.gesture_active = body_move_pending() || (m_viewport && m_viewport->drawing_in_progress()); - // Something is armed and waiting for input: a feature card, a sketch draw tool, Constrain. - // A sketch SESSION is deliberately not in this list — see escape(). + // Something is armed and waiting for input: a feature card, Sketch waiting for its plane, a + // sketch draw tool, Constrain. A sketch SESSION is deliberately not in this list — see escape(). st.tool_armed = (m_active != Tool::None) + || m_choosing_sketch_plane || m_ui_mode == UiMode::Constrain || (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->sketch_is_selecting()); st.has_selection = m_viewport && m_viewport->has_any_selection(); @@ -11934,7 +12012,10 @@ void DesignPanel::escape() // CANDIDATE (never a committed feature — in edit mode reset_edit_state only forgets which // feature was being edited, the feature itself is untouched); an armed sketch tool falls // back to Select, leaving every entity already drawn exactly where it is. - if (m_active != Tool::None || m_ui_mode == UiMode::Constrain) { tool_cancel(); return; } + if (m_active != Tool::None || m_choosing_sketch_plane || m_ui_mode == UiMode::Constrain) { + tool_cancel(); + return; + } if (m_viewport && m_viewport->sketch_disarm_tool()) { set_status(StatusKind::Info, _L("Select")); } @@ -11945,7 +12026,8 @@ void DesignPanel::escape() // both of which say which one they are, and never through a key pressed on the way out of // something else. The Feature tree and Bodies rows count as selections too. const bool row = deselect_rows(); - if ((m_viewport && m_viewport->clear_any_selection()) || row) { + const bool plane = drop_plane_pick(); + if ((m_viewport && m_viewport->clear_any_selection()) || row || plane) { set_status(StatusKind::Info, wxString()); return; } @@ -11988,6 +12070,7 @@ void DesignPanel::update_action_bar() wxSizer* s = m_toolbar->GetSizer(); if (s == nullptr) return; const bool active = (m_active != Tool::None) + || m_choosing_sketch_plane // ✗ only: ✓ has nothing to confirm || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain || body_move_pending(); diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index ed8ccaf9f2..5f31d0fbeb 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -383,8 +383,14 @@ private: void update_rib_gizmo(); // in-plane slab footprint + thickness handles (Rib card) void refresh_datum_planes(); // push resolved datum frames + per-plane u/v extents to viewport void refresh_mate_connectors(); // push connector frames so verse + polarity are visible - void update_reference_planes(); // the XY/XZ/YZ planes + axes: Origin row, Sketch mode, Plane tool + void update_reference_planes(); // the XY/XZ/YZ planes + axes: Origin row, plane choice, Plane tool void toggle_origin(); // the Origin row's eye: keep the reference planes up, or not + // Sketch: open a session on the picked face or plane, or with none picked, put the planes up + // and wait for one (m_choosing_sketch_plane). + void start_sketch(); + void start_sketch_on_target(bool offer_tools); // a session on sketch_plane_from_selection() + void end_sketch_plane_choice(); // leave the plane choice without a sketch; no-op outside it + bool sketch_plane_pick_live() const; // may a pick made now still open the sketch? CadDocument m_doc; @@ -572,8 +578,13 @@ private: // deliberately no dropdown for it. e1p. int m_ref_plane{0}; // m_ref_plane is always a VALID plane, so it cannot itself distinguish "the user chose XY" - // from "nobody has chosen anything yet". This does. + // from "nobody has chosen anything yet". This does. A selection like a picked face: the sketch + // opened on it uses it up, and Esc or a click on nothing lets go of it. bool m_plane_picked{false}; + // Sketch was pressed with nothing to sketch on: the reference planes are up and the next + // reference plane or flat face clicked opens the sketch on it. Still Feature mode — sketch + // mode is entered only with the session (start_sketch_on_target). + bool m_choosing_sketch_plane{false}; // The Feature tree's Origin row: keeps the reference planes and their axes up // (update_reference_planes). A view preference (AppConfig "design_show_origin"), not part of // the recipe. @@ -933,6 +944,7 @@ private: void request_feature_highlight(); void update_feature_highlight(); bool deselect_rows(); // Esc / a click on nothing: drop the tree and Bodies rows + bool drop_plane_pick(); // ...and a picked reference plane; true if one was picked // Bodies list under the feature tree: one row per body (parallel to m_doc.bodies). Selecting // one highlights that body and makes it the target for the next op. DesignRowList* m_parts{nullptr}; diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 92c50eefcb..e2d3d04627 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -9486,7 +9486,10 @@ const ColorRGBA* DesignSketchTool::sketch_hl_color(int feature) const // forgotten by the next one. Cheap: three ints compared per frame. void DesignSketchTool::emit_step_hint() { - if (!on_step_changed) return; + // No session, no step. render() also runs while only the reference planes are up, and m_mode + // then holds the last session's tool (Polyline before the first), whose prompt used to replace + // the panel's own line — "click a reference plane" while Sketch waits for one. + if (!on_step_changed || !m_active) return; int step = 0, picks = 0; if (is_edit_op_mode()) { picks = (m_mode == Mode::Mirror) ? int(m_mirror_targets.size()) diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 5619c0b784..ed8f8330d5 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -173,7 +173,6 @@ public: // Returns false when no session is live, so the caller can fall back to a new feature. bool add_imported_regions(const std::vector>>& regions); void set_tool(Mode mode); // switch tool, keep accumulated entities - void set_plane(const SketchPlane& plane) { m_plane = plane; } // re-plane a live sketch (a reference plane was clicked mid-session); entities are 2D, re-lifted through the new plane void set_construction(bool c) { m_construction = c; } void set_polygon_sides(int n) { m_polygon_sides = (n < 3 ? 3 : n); } void set_polygon_circumscribed(bool c) { m_polygon_circumscribed = c; } From 6d8a13c33b84e19c035e0a627e663923b557fe33 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 19:47:05 +0800 Subject: [PATCH 18/30] Move the Design tab's Bed toggle into the Feature tree, under the Origin row The bed's show/hide state is now remembered across sessions like the Origin row's, shown by default. The GUI ladder's ribbon x-coordinates are shifted by the removed checkbox's derived width and still need re-measuring on the rig. --- docs/HLSD/design-tab.md | 14 ++-- resources/images/design_bed.svg | 1 + scripts/CAD/check-gui-click-edit.py | 1 + scripts/CAD/check-gui-sketching.py | 21 ++++-- src/libslic3r/AppConfig.cpp | 4 ++ src/slic3r/GUI/CAD/DesignPanel.cpp | 101 +++++++++++++++------------- src/slic3r/GUI/CAD/DesignPanel.hpp | 14 ++-- 7 files changed, 92 insertions(+), 64 deletions(-) create mode 100644 resources/images/design_bed.svg diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 58bab4bfcb..f7d48e0ee6 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -175,11 +175,11 @@ handle or a press that draws takes it first, as a gizmo does in Prepare; a whole with a rectangle on plain left-drag only while no camera action is assigned to the left button, and with Shift+left-drag otherwise — Prepare's own rectangle selection. -Entering a sketch changes three things at once so the mode is legible: a banner above the +Entering a sketch changes two things at once so the mode is legible: a banner above the canvas (a sibling of the canvas, not a child over it — on GTK a child window over a -`wxGLCanvas` is a native window and does not reliably stack over GL), the printer bed muted so -a plate grid is never read as a sketch grid, and `N` to look normal to the plane. Code that -changes any of the three belongs with a change to this section. +`wxGLCanvas` is a native window and does not reliably stack over GL), and `N` to look normal to +the plane. The printer bed stays: there is no sketch grid, so the plate grid is the only ground +reference a sketch has. Code that changes either belongs with a change to this section. Sketch mode is never entered without a plane under it, so the banner, the sketch keys and the sketch offer always have a session to act on. Sketch on a picked flat face or reference plane opens @@ -200,6 +200,10 @@ bases, and nothing for the other methods, where a click on a plane would rewrite references. The `P` and `A` keys are a separate, unpickable view helper and do not follow the Origin row. +The Bed row, pinned under the Origin row, is the printer bed's switch in the same way: it draws or +hides the bed and its plate grid in every mode, and `Ctrl+Shift+B` flips it from the keyboard. It +is a view preference in AppConfig too, and the bed is shown until it is turned off. + ## Rendering the bodies The tab draws its bodies through the same `GLCanvas3D` object path as Prepare, so how they look @@ -273,7 +277,7 @@ The tab is a page of Orca's main window and answers to the same settings as Prep hover background on macOS. The Feature tree and Bodies lists are a custom-drawn `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Edit, Show/hide and Delete on a feature, Move, Show/hide and Delete on a body, and only Show/hide on - the Origin row above the features — and the eye shows whether that row is hidden. + the Origin and Bed rows above the features — and the eye shows whether that row is hidden. - **Plates.** This is the one thing the tab does not follow. The canvas has a bed of its own at the printer bed's home position, whichever plate Prepare has current, and a new document's modeling origin is that bed's centre. A bed that followed the current plate would slide out diff --git a/resources/images/design_bed.svg b/resources/images/design_bed.svg new file mode 100644 index 0000000000..a9441e7b5d --- /dev/null +++ b/resources/images/design_bed.svg @@ -0,0 +1 @@ + diff --git a/scripts/CAD/check-gui-click-edit.py b/scripts/CAD/check-gui-click-edit.py index b0ee63dee9..5eb9b3c4f2 100755 --- a/scripts/CAD/check-gui-click-edit.py +++ b/scripts/CAD/check-gui-click-edit.py @@ -147,6 +147,7 @@ def seed_datadir(datadir): # ~90% case expects. A ladder whose result depends on the developer's own preferences is not # a gate. app["auto_close_sketch_loops"] = True + app["design_show_bed"] = True # SILENCE THE NETWORK PLUGIN PROMPT. Without this, GUI_App::post_init() re-raises "Bambu # Network Plug-in Required" from an IDLE event — after any modal sweep this driver does at # startup — and ShowModal() then runs a nested event loop. The app is alive, its window is diff --git a/scripts/CAD/check-gui-sketching.py b/scripts/CAD/check-gui-sketching.py index 9d8729c49f..b3c4514499 100644 --- a/scripts/CAD/check-gui-sketching.py +++ b/scripts/CAD/check-gui-sketching.py @@ -229,9 +229,18 @@ CHROME_DY = int(os.environ.get("ORCA_CAD_CHROME_DY", "26")) DESIGN_TAB = (128, 29 + CHROME_DY) +# The ribbon's document group lost its Bed checkbox and label when the bed toggle moved to the +# Feature tree, and every group to its right moved left by their width: wxLEFT 6 + the 18 px +# CheckBox + wxLEFT 4 + the "Bed" label (~24 px) + wxRIGHT 4, about 56 px. The x of +# CONSTRUCTION_CHECKBOX, CON_BTN and CON_BTN_SKETCH below is the one measured before that, plus +# this. DERIVED, NOT MEASURED: re-measure on the rig and fold it in. CONFIRM_BTN sits after the +# ribbon's stretch spacer and did not move. +RIBBON_DX = -56 + # Feature-tree rows, measured on the rig at 1920x1080: the list's first row is centred at y=215, -# then 23 px apart. That first row is the Origin, pinned above the features and never deleted, so -# the first FEATURE row is the second one: Delete on the Origin does nothing. +# then 23 px apart. The first two rows are the Origin and the Bed, pinned above the features and +# never deleted, so the first FEATURE row is the third one. Delete and double-click do nothing on +# a pinned row, and reset_document and the reopening double-click both need a feature. # x=300, not the label: a second click ON the label opens the inline rename, and Delete then # edits the text instead of removing the feature. # @@ -239,7 +248,7 @@ DESIGN_TAB = (128, 29 + CHROME_DY) # unshifted click lands 26 px BELOW the first row -- just past its 23 px height -- so the row is # never selected, Delete does nothing, and reset_document spends 40 rounds on it before dying # with "could not empty the feature tree". That names the feature tree, which is not the fault. -TREE_ROW0 = (300, 215 + 23 + CHROME_DY) +TREE_ROW0 = (300, 215 + 2 * 23 + CHROME_DY) def go_design(): @@ -742,7 +751,7 @@ def corner_pair(ents): die("no adjacent pair in what should be a rectangle") -CONSTRUCTION_CHECKBOX = (419, 75 + CHROME_DY) +CONSTRUCTION_CHECKBOX = (419 + RIBBON_DX, 75 + CHROME_DY) def draw_line(x0, y0, x1, y1, length, angle): @@ -871,7 +880,7 @@ def rung_extend(): # because the ladder only ever clicks "perpendicular" (3) and "equal" (5), both of which sit # before the first insertion. The next rung to use "tangent" would have clicked "collinear". CON_BTN_Y = 76 + CHROME_DY -CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate( +CON_BTN = {n: (449 + RIBBON_DX + 42 * i, CON_BTN_Y) for i, n in enumerate( ["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal", "equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric", "sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])} @@ -885,7 +894,7 @@ CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate( # which already carries CHROME_DY), but the X start depends on how wide the sketch toolbar to the # left of this group renders, and this fork keeps mainline's top row. Re-measure before trusting # D11 here: screenshot in sketch mode and detect the icon columns, do not derive it by offset. -CON_BTN_SKETCH = {n: (677 + 42 * i, CON_BTN_Y) for i, n in enumerate( +CON_BTN_SKETCH = {n: (677 + RIBBON_DX + 42 * i, CON_BTN_Y) for i, n in enumerate( ["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal", "equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric", "sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])} diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 663072d866..31cc17b97d 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -367,6 +367,10 @@ void AppConfig::set_defaults() // restores the conventional CAD representation for users who expect it (x0kd). if (get("design_connector_face_glyph").empty()) set_bool("design_connector_face_glyph", true); + + // Design tab: draw the printer bed and its plate grid (the Feature tree's Bed row). + if (get("design_show_bed").empty()) + set_bool("design_show_bed", true); #endif //#ifdef SUPPORT_SHOW_HINTS diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index ac9b5d59d6..964e1f23ca 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -285,9 +285,10 @@ static std::string commit_icon(bool bodies) // The icons on each Feature tree and Bodies row (DesignRowList::Action::id). enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete }; -// The Feature tree's first row is the Origin, pinned above the features: feature i is row i + 1. -static constexpr int kFeatureRow0 = 1; -// The feature a Feature tree row shows, or wxNOT_FOUND for the Origin row (and for no row). +// The Feature tree's first two rows are the Origin and the Bed, pinned above the features: +// feature i is row i + kFeatureRow0. +static constexpr int kOriginRow = 0, kBedRow = 1, kFeatureRow0 = 2; +// The feature a Feature tree row shows, or wxNOT_FOUND for a pinned row (and for no row). static int feature_of_row(int row) { return row >= kFeatureRow0 ? row - kFeatureRow0 : wxNOT_FOUND; } // A row's eye shows the state the row is in; its tip names what a click does. @@ -302,6 +303,13 @@ static wxString origin_toggle_text(bool shown) return shown ? _L("Hide reference planes and axes") : _L("Show reference planes and axes"); } +// What the Bed row's eye and its menu item do. +static wxString bed_toggle_text(bool shown) +{ + return shown ? _L("Hide the printer bed and its plate grid") + : _L("Show the printer bed and its plate grid"); +} + // What Sketch asks for while it waits for a plane (DesignPanel::start_sketch). static wxString sketch_plane_prompt() { @@ -611,17 +619,14 @@ DesignPanel::DesignPanel(wxWindow* parent) set_status(_L("Axonometric view, fitted")); }; - // Commit to Plate and the bed toggle were mouse-only: a toolbar button and a checkbox with - // no accelerator between them, so neither could be reached from the keyboard at all, nor by - // anything driving the keyboard. Ctrl+Shift+P is Plate, Ctrl+Shift+B is Bed; neither + // Commit to Plate and the bed toggle are mouse targets — a toolbar button and the Feature + // tree's Bed row — so without an accelerator neither could be reached from the keyboard, nor + // by anything driving the keyboard. Ctrl+Shift+P is Plate, Ctrl+Shift+B is Bed; neither // collides with Orca's own Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate). m_keys_feature['P' | SC_SHIFT | SC_CTRL] = [this] { on_commit(); }; m_keys_feature['B' | SC_SHIFT | SC_CTRL] = [this] { - if (!m_show_bed) return; - const bool show = !m_show_bed->GetValue(); - m_show_bed->SetValue(show); - if (m_viewport) m_viewport->set_show_bed(show); - set_status(show ? _L("Bed shown") : _L("Bed hidden")); + toggle_bed(); + set_status(m_show_bed ? _L("Bed shown") : _L("Bed hidden")); }; // Flyout rows show the design_* glyphs as they are: drawn in Orca's icon grey (#949494), which @@ -1518,10 +1523,10 @@ DesignPanel::DesignPanel(wxWindow* parent) } m_viewport->set_sketch_construction(m_construction->GetValue()); }); // STAYS on the bar. Construction is not a tool, it is a persistent MODE — the same kind - // of thing as the Bed checkbox — and the sketch bar is already shown only in Sketch mode, - // so it appears exactly while it can apply. Hiding it left Q and the offer's Construction - // row still toggling a checkbox nobody could see: you could not tell whether the next - // line would be construction geometry. A stateful toggle has to show its state. + // of thing as the Feature tree's Bed row — and the sketch bar is already shown only in + // Sketch mode, so it appears exactly while it can apply. Hiding it left Q and the offer's + // Construction row still toggling a checkbox nobody could see: you could not tell whether + // the next line would be construction geometry. A stateful toggle has to show its state. sadd_bar(m_construction); add_sep(m_tb_sketch); auto* b_del = icon_btn("design_delete", _L("Delete selected")); @@ -1629,23 +1634,6 @@ DesignPanel::DesignPanel(wxWindow* parent) b_export->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); }); add_doc(b_export); - // View option, not a document action: hide the printer bed to model without it. Lives in - // this row because it must stay reachable with no tool open — a card would come and go. - m_show_bed = new CheckBox(m_toolbar); - m_show_bed->SetValue(true); // bed visible by default, as the tab opens today - m_show_bed->SetToolTip(_L("Show the printer bed and its plate grid")); - // wxEVT_TOGGLEBUTTON, NOT wxEVT_CHECKBOX: Orca's CheckBox derives from - // wxBitmapToggleButton (Widgets/CheckBox.hpp), so a wxEVT_CHECKBOX handler never fires. - // Read the control rather than the event so the state cannot disagree with the glyph. - m_show_bed->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { - if (m_viewport) m_viewport->set_show_bed(m_show_bed->GetValue()); - e.Skip(); - }); - auto* bed_lbl = new wxStaticText(m_toolbar, wxID_ANY, _L("Bed")); - bed_lbl->SetForegroundColour(dp_sec_text()); - m_tb_doc->Add(m_show_bed, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 6); - m_tb_doc->Add(bed_lbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, 4); - // These act on bodies / the view, so they ride in the feature group, in the slots // the user assigned them (9, 11bis, 16). auto* b_place = doc_btn("toolbar_flatten", _L("Place on Face (F) — lay the picked face on the bed"), @@ -3155,7 +3143,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // "the target" — but ONLY when this tree has a selection. Each list notifies on every // change, so clicking a body row runs apply_body_row, whose m_tree->unselect() fires // THIS handler, which would otherwise clear the body row the user had just clicked. The - // Origin row counts: it is a selected row like any other. + // pinned Origin and Bed rows count: they are selected rows like any other. if (m_parts && m_tree->selection() != wxNOT_FOUND) m_parts->unselect(); const int sel = tree_selection(); // Likewise a viewport pick, which would be drawn just like the feature's faces. Not while @@ -3178,17 +3166,18 @@ DesignPanel::DesignPanel(wxWindow* parent) }; // Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas. - // The Origin row has nothing to edit. + // The pinned rows have nothing to edit. m_tree->on_activate = [this] { if (tree_selection() != wxNOT_FOUND) on_edit_feature(); }; // The row's own Edit / Show-hide / Delete, on the row the click selected. The body list is // cleared here too, not left to on_select, which re-clicking the selected row does not run: - // a body row still selected would be what on_toggle_visibility acts on. The Origin row's one + // a body row still selected would be what on_toggle_visibility acts on. A pinned row's one // icon is its eye. m_tree->on_action = [this](int row, int id) { - if (feature_of_row(row) == wxNOT_FOUND) { toggle_origin(); return; } + if (row == kOriginRow) { toggle_origin(); return; } + if (row == kBedRow) { toggle_bed(); return; } if (m_parts) m_parts->unselect(); switch (id) { case RowEdit: on_edit_feature(); break; @@ -3202,13 +3191,14 @@ DesignPanel::DesignPanel(wxWindow* parent) // a sketch tries the row, and the row answers. m_tree->on_menu = [this](int row, const wxPoint& screen) { wxMenu menu; - // The Origin row is no feature: it cannot be renamed, edited, moved or deleted, and its + // A pinned row is no feature: it cannot be renamed, edited, moved or deleted, and its // menu holds exactly what its eye does. const int feat = feature_of_row(row); if (feat == wxNOT_FOUND) { - const int id_origin = wxWindow::NewControlId(); - menu.Append(id_origin, origin_toggle_text(m_show_origin)); - menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { toggle_origin(); }, id_origin); + const bool bed = (row == kBedRow); + const int id_toggle = wxWindow::NewControlId(); + menu.Append(id_toggle, bed ? bed_toggle_text(m_show_bed) : origin_toggle_text(m_show_origin)); + menu.Bind(wxEVT_MENU, [this, bed](wxCommandEvent&) { bed ? toggle_bed() : toggle_origin(); }, id_toggle); m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); return; } @@ -4431,8 +4421,10 @@ DesignPanel::DesignPanel(wxWindow* parent) } m_show_origin = wxGetApp().app_config->get_bool("design_show_origin"); + m_show_bed = wxGetApp().app_config->get_bool("design_show_bed"); + if (m_viewport) m_viewport->set_show_bed(m_show_bed); set_ui_mode(UiMode::Feature); - // The Origin row from the first paint: nothing else builds the rows before the first edit, + // The pinned rows from the first paint: nothing else builds the rows before the first edit, // project load or theme switch. refresh_tree(); build_phase("wiring"); @@ -4542,8 +4534,8 @@ void DesignPanel::set_ui_mode(UiMode m) // Seen on the rig: pick XY, arm Line, and the viewport is an empty grey field — no bed, no // grid, no origin, nothing to judge a length or a direction against. The plate grid was // carrying the ground reference for the whole tab. The banner already says where you are; - // taking the floor away as well only made the sketch harder to draw. The Bed checkbox is the - // one thing that governs the bed, in every mode. + // taking the floor away as well only made the sketch harder to draw. The Feature tree's Bed + // row is the one thing that governs the bed, in every mode. if (m_sketch_banner != nullptr) { const bool sketching = (m == UiMode::Sketch); if (sketching && m_sketch_banner_txt != nullptr) @@ -7532,7 +7524,7 @@ void DesignPanel::refresh_tree() // Preserve the selected row across the rebuild — set_rows() drops the selection, which made // every edit/add feel like it "lost" the selection (and broke Edit/Move/Delete on the - // just-touched feature). By row, so a selected Origin row stays selected too. + // just-touched feature). By row, so a selected pinned row stays selected too. const int keep = m_tree->selection(); // Datum/reference planes carry no solid; feed them to the viewport so they render as @@ -7541,10 +7533,15 @@ void DesignPanel::refresh_tree() update_reference_planes(); std::vector rows; rows.reserve(m_doc.features.size() + kFeatureRow0); - // Always first and never removable: the reference planes' own switch, and nothing else. - DesignRowList::Action origin_eye = eye_action(m_show_origin); - origin_eye.tip = origin_toggle_text(m_show_origin); - rows.push_back({ "design_plane", _L("Origin"), {}, dp_item_text(), { origin_eye } }); + // Always first, in kOriginRow / kBedRow order, and never removable: the reference planes' and + // the printer bed's own switches, and nothing else. + auto add_pinned = [&rows](const char* icon, const wxString& label, bool shown, const wxString& tip) { + DesignRowList::Action eye = eye_action(shown); + eye.tip = tip; + rows.push_back({ icon, label, {}, dp_item_text(), { eye } }); + }; + add_pinned("design_plane", _L("Origin"), m_show_origin, origin_toggle_text(m_show_origin)); + add_pinned("design_bed", _L("Bed"), m_show_bed, bed_toggle_text(m_show_bed)); for (size_t fi = 0; fi < m_doc.features.size(); ++fi) { const CadFeature& f = m_doc.features[fi]; DesignRowList::Row row; @@ -11128,6 +11125,14 @@ void DesignPanel::toggle_origin() refresh_tree(); // the Origin row's eye, and update_reference_planes(), which repaints } +void DesignPanel::toggle_bed() +{ + m_show_bed = !m_show_bed; + wxGetApp().app_config->set_bool("design_show_bed", m_show_bed); + if (m_viewport) m_viewport->set_show_bed(m_show_bed); // repaints + refresh_tree(); // the Bed row's eye +} + void DesignPanel::start_sketch() { if (m_ui_mode != UiMode::Feature) return; // already sketching or constraining diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 5f31d0fbeb..013de06fc8 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -385,6 +385,7 @@ private: void refresh_mate_connectors(); // push connector frames so verse + polarity are visible void update_reference_planes(); // the XY/XZ/YZ planes + axes: Origin row, plane choice, Plane tool void toggle_origin(); // the Origin row's eye: keep the reference planes up, or not + void toggle_bed(); // the Bed row's eye (and Ctrl+Shift+B): draw the printer bed, or not // Sketch: open a session on the picked face or plane, or with none picked, put the planes up // and wait for one (m_choosing_sketch_plane). void start_sketch(); @@ -420,7 +421,6 @@ private: ScalableButton* m_commit_btn{nullptr}; // main face: runs the current commit mode DropDown* m_commit_drop{nullptr}; // commit-mode choices, owned via m_flyout_keepalive wxSizer* m_tb_doc{nullptr}; // toolbar document/view actions (new, commit, export, section, place) - CheckBox* m_show_bed{nullptr}; // view option: draw the printer bed + plate grid, or not wxSizer* m_box_move{nullptr}; // Move/Rotate numeric options (distance, axis, angle) wxSizer* m_box_sketch{nullptr}; wxSizer* m_box_extrude{nullptr}; @@ -589,6 +589,9 @@ private: // (update_reference_planes). A view preference (AppConfig "design_show_origin"), not part of // the recipe. bool m_show_origin{false}; + // The Feature tree's Bed row: draws the printer bed and its plate grid. A view preference + // (AppConfig "design_show_bed"), not part of the recipe. + bool m_show_bed{true}; ComboBox* m_shape{nullptr}; ComboBox* m_mode{nullptr}; wxSpinCtrlDouble* m_width{nullptr}; @@ -929,9 +932,10 @@ private: std::function m_value_cont; // deferred apply, run on Confirm std::function m_value_cancel; // optional action when the card is cancelled - // Feature tree: the Origin row, then one row per feature, in feature order, with a per-type - // icon and the row's own Edit / Show-hide / Delete icons. Feature i is row i + 1; callers use - // feature indices via tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows. + // Feature tree: the pinned Origin and Bed rows, then one row per feature, in feature order, + // with a per-type icon and the row's own Edit / Show-hide / Delete icons. Feature i is row + // i + 2; callers use feature indices via tree_selection()/set_tree_selection(); + // refresh_tree() rebuilds the rows. DesignRowList* m_tree{nullptr}; // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected. // Finding them replays the history, so they are kept per row and topology generation. @@ -986,7 +990,7 @@ private: void arm_transform_gizmo(); // arm the move gizmo on the Transform card's body (add mode only) void on_set_body_color(); // Color tool: pick a per-body display colour override void on_boolean_tool(); // Boolean (combine bodies): needs two solids, then opens the tool - int tree_selection() const; // selected feature's index, or wxNOT_FOUND (none, or the Origin row) + int tree_selection() const; // selected feature's index, or wxNOT_FOUND (none, or a pinned row) int tree_body_selection() const; // selected Parts-list body index, or -1 void refresh_parts(); // rebuild the Bodies list under the feature tree void set_tree_selection(int feature); From ce7d4815e65f9aa5cf92355d90bd6e1b2f92a968 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 20:42:29 +0800 Subject: [PATCH 19/30] Set the Design tab's Origin and Bed rows apart from the feature list They now sit above the features' framed list as fixed view switches: a click no longer selects them, they stay put while the features scroll, their eyes line up with the features' eyes, and their labels dim when hidden. Ctrl+Shift+O toggles the Origin from the keyboard, as Ctrl+Shift+B does the Bed. The test script's position for the first feature row is calculated, not measured, and needs re-measuring on the test setup. --- docs/HLSD/design-tab.md | 27 +++--- scripts/CAD/check-gui-sketching.py | 11 ++- src/slic3r/GUI/CAD/DesignPanel.cpp | 130 +++++++++++++++------------ src/slic3r/GUI/CAD/DesignPanel.hpp | 16 ++-- src/slic3r/GUI/CAD/DesignRowList.cpp | 34 ++++--- src/slic3r/GUI/CAD/DesignRowList.hpp | 20 +++-- 6 files changed, 144 insertions(+), 94 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index f7d48e0ee6..2d7810cd4f 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -193,16 +193,22 @@ The reference planes — XY, XZ and YZ through the modeling origin, with their h drawn on demand, because three translucent squares over every model are noise once they are not the thing being picked. Sketch brings them up while it waits for a plane, which is exactly when they are picked, and the session the pick opens takes them away; a live session draws none. The -Feature tree's Origin row, pinned above the features and never removable, keeps them up outside a -sketch. Its state is a view preference in AppConfig rather than part of the recipe, so it costs -the project format nothing. The Plane tool keeps its own rule: the planes and the datums as Offset -bases, and nothing for the other methods, where a click on a plane would rewrite the datum's -references. The `P` and `A` keys are a separate, unpickable view helper and do not follow the -Origin row. +Feature tree's Origin row keeps them up outside a sketch. Its state is a view preference in +AppConfig rather than part of the recipe, so it costs the project format nothing. The Plane tool +keeps its own rule: the planes and the datums as Offset bases, and nothing for the other methods, +where a click on a plane would rewrite the datum's references. The `P` and `A` keys are a +separate, unpickable view helper and do not follow the Origin row. -The Bed row, pinned under the Origin row, is the printer bed's switch in the same way: it draws or -hides the bed and its plate grid in every mode, and `Ctrl+Shift+B` flips it from the keyboard. It -is a view preference in AppConfig too, and the bed is shown until it is turned off. +The Bed row, under the Origin row, is the printer bed's switch in the same way: it draws or hides +the bed and its plate grid in every mode. It is a view preference in AppConfig too, and the bed is +shown until it is turned off. + +The two rows are view switches, not history, and the tree says so: they sit unframed on the Feature +tree's card, above the features' own framed list, and stay put while the features scroll. A click +never selects either row, since a selected Origin or Bed would have nothing to edit, move or +delete; the eye and the right-click menu are the only targets, and a row's label dims while its +thing is hidden, as a hidden body's does. Because the block never takes the focus, `Ctrl+Shift+O` +and `Ctrl+Shift+B` flip the Origin and the Bed from the keyboard. ## Rendering the bodies @@ -277,7 +283,8 @@ The tab is a page of Orca's main window and answers to the same settings as Prep hover background on macOS. The Feature tree and Bodies lists are a custom-drawn `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Edit, Show/hide and Delete on a feature, Move, Show/hide and Delete on a body, and only Show/hide on - the Origin and Bed rows above the features — and the eye shows whether that row is hidden. + the Origin and Bed rows, a separate non-selectable list above the features — and the eye shows + whether that row is hidden. - **Plates.** This is the one thing the tab does not follow. The canvas has a bed of its own at the printer bed's home position, whichever plate Prepare has current, and a new document's modeling origin is that bed's centre. A bed that followed the current plate would slide out diff --git a/scripts/CAD/check-gui-sketching.py b/scripts/CAD/check-gui-sketching.py index b3c4514499..df83803819 100644 --- a/scripts/CAD/check-gui-sketching.py +++ b/scripts/CAD/check-gui-sketching.py @@ -238,9 +238,11 @@ DESIGN_TAB = (128, 29 + CHROME_DY) RIBBON_DX = -56 # Feature-tree rows, measured on the rig at 1920x1080: the list's first row is centred at y=215, -# then 23 px apart. The first two rows are the Origin and the Bed, pinned above the features and -# never deleted, so the first FEATURE row is the third one. Delete and double-click do nothing on -# a pinned row, and reset_document and the reopening double-click both need a feature. +# then 23 px apart. The first two rows are the Origin and the Bed, unframed on the card above the +# features' own framed list, so the first FEATURE row sits two rows and TREE_GAP further down; +# reset_document and the reopening double-click both need a feature. TREE_GAP is the list's +# 12 px top margin plus its 1 px frame, derived rather than measured -- re-measure on the rig, like +# RIBBON_DX. # x=300, not the label: a second click ON the label opens the inline rename, and Delete then # edits the text instead of removing the feature. # @@ -248,7 +250,8 @@ RIBBON_DX = -56 # unshifted click lands 26 px BELOW the first row -- just past its 23 px height -- so the row is # never selected, Delete does nothing, and reset_document spends 40 rounds on it before dying # with "could not empty the feature tree". That names the feature tree, which is not the fault. -TREE_ROW0 = (300, 215 + 2 * 23 + CHROME_DY) +TREE_GAP = 12 + 1 +TREE_ROW0 = (300, 215 + 2 * 23 + TREE_GAP + CHROME_DY) def go_design(): diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 964e1f23ca..de07d4451c 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -285,11 +285,8 @@ static std::string commit_icon(bool bodies) // The icons on each Feature tree and Bodies row (DesignRowList::Action::id). enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete }; -// The Feature tree's first two rows are the Origin and the Bed, pinned above the features: -// feature i is row i + kFeatureRow0. -static constexpr int kOriginRow = 0, kBedRow = 1, kFeatureRow0 = 2; -// The feature a Feature tree row shows, or wxNOT_FOUND for a pinned row (and for no row). -static int feature_of_row(int row) { return row >= kFeatureRow0 ? row - kFeatureRow0 : wxNOT_FOUND; } +// The rows of the Feature tree's pinned block, above the features. +static constexpr int kOriginRow = 0, kBedRow = 1; // A row's eye shows the state the row is in; its tip names what a click does. static DesignRowList::Action eye_action(bool shown) @@ -619,15 +616,20 @@ DesignPanel::DesignPanel(wxWindow* parent) set_status(_L("Axonometric view, fitted")); }; - // Commit to Plate and the bed toggle are mouse targets — a toolbar button and the Feature - // tree's Bed row — so without an accelerator neither could be reached from the keyboard, nor - // by anything driving the keyboard. Ctrl+Shift+P is Plate, Ctrl+Shift+B is Bed; neither - // collides with Orca's own Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate). + // Commit to Plate and the bed and origin toggles are mouse targets — a toolbar button and the + // Feature tree's Bed and Origin rows, which never take the focus — so without an accelerator + // none could be reached from the keyboard, nor by anything driving the keyboard. Ctrl+Shift+P + // is Plate, Ctrl+Shift+B is Bed, Ctrl+Shift+O is Origin; none collides with Orca's own + // Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate). m_keys_feature['P' | SC_SHIFT | SC_CTRL] = [this] { on_commit(); }; m_keys_feature['B' | SC_SHIFT | SC_CTRL] = [this] { toggle_bed(); set_status(m_show_bed ? _L("Bed shown") : _L("Bed hidden")); }; + m_keys_feature['O' | SC_SHIFT | SC_CTRL] = [this] { + toggle_origin(); + set_status(m_show_origin ? _L("Reference planes shown") : _L("Reference planes hidden")); + }; // Flyout rows show the design_* glyphs as they are: drawn in Orca's icon grey (#949494), which // the icon cache maps per theme like every other sidebar icon, so they need no re-tint. @@ -3130,11 +3132,34 @@ DesignPanel::DesignPanel(wxWindow* parent) FromDIP(SidebarProps::ContentMargin())); tree_inner->Add(new wxStaticLine(m_tree_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(SidebarProps::TitlebarMargin())); + // The Origin and Bed switches sit on the card itself, unframed, above the features' own framed + // list: view switches, not history. A list of their own so they stay put while the features + // scroll, and a non-selectable one because selecting them would do nothing: only their eyes + // answer. 13 = the feature list's margin plus its frame, so the two lists' icons line up. + m_pinned = new DesignRowList(m_tree_box, 2, false, wxBORDER_NONE); + m_pinned->SetBackgroundColour(dp_panel_bg()); + tree_inner->Add(m_pinned, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 13); // Sized to its rows, so a short history wastes no block, and scrolling past 9 features. - m_tree = new DesignRowList(m_tree_box, 9 + kFeatureRow0); + m_tree = new DesignRowList(m_tree_box, 9); m_tree->SetBackgroundColour(dp_panel_bg()); tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12); + m_pinned->on_action = [this](int row, int) { + if (row == kBedRow) + toggle_bed(); + else if (row == kOriginRow) + toggle_origin(); + }; + // Exactly what the eye does, named. + m_pinned->on_menu = [this](int row, const wxPoint& screen) { + const bool bed = (row == kBedRow); + wxMenu menu; + const int id_toggle = wxWindow::NewControlId(); + menu.Append(id_toggle, bed ? bed_toggle_text(m_show_bed) : origin_toggle_text(m_show_origin)); + menu.Bind(wxEVT_MENU, [this, bed](wxCommandEvent&) { bed ? toggle_bed() : toggle_origin(); }, id_toggle); + m_pinned->PopupMenu(&menu, m_pinned->ScreenToClient(screen)); + }; + // Selecting a feature that leaves a body (Extrude, Fillet, Chamfer, Hole, ...) lights the // faces it made in the viewport — the fillet's round, not the whole part it sits on. m_tree->on_select = [this] { @@ -3142,8 +3167,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // Picking a feature drops any body selection, so the two lists never both claim to be // "the target" — but ONLY when this tree has a selection. Each list notifies on every // change, so clicking a body row runs apply_body_row, whose m_tree->unselect() fires - // THIS handler, which would otherwise clear the body row the user had just clicked. The - // pinned Origin and Bed rows count: they are selected rows like any other. + // THIS handler, which would otherwise clear the body row the user had just clicked. if (m_parts && m_tree->selection() != wxNOT_FOUND) m_parts->unselect(); const int sel = tree_selection(); // Likewise a viewport pick, which would be drawn just like the feature's faces. Not while @@ -3166,18 +3190,14 @@ DesignPanel::DesignPanel(wxWindow* parent) }; // Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas. - // The pinned rows have nothing to edit. m_tree->on_activate = [this] { if (tree_selection() != wxNOT_FOUND) on_edit_feature(); }; // The row's own Edit / Show-hide / Delete, on the row the click selected. The body list is // cleared here too, not left to on_select, which re-clicking the selected row does not run: - // a body row still selected would be what on_toggle_visibility acts on. A pinned row's one - // icon is its eye. - m_tree->on_action = [this](int row, int id) { - if (row == kOriginRow) { toggle_origin(); return; } - if (row == kBedRow) { toggle_bed(); return; } + // a body row still selected would be what on_toggle_visibility acts on. + m_tree->on_action = [this](int, int id) { if (m_parts) m_parts->unselect(); switch (id) { case RowEdit: on_edit_feature(); break; @@ -3190,22 +3210,11 @@ DesignPanel::DesignPanel(wxWindow* parent) // double-click is Edit, and F2 is a function key nothing announces. A user who wants to name // a sketch tries the row, and the row answers. m_tree->on_menu = [this](int row, const wxPoint& screen) { - wxMenu menu; - // A pinned row is no feature: it cannot be renamed, edited, moved or deleted, and its - // menu holds exactly what its eye does. - const int feat = feature_of_row(row); - if (feat == wxNOT_FOUND) { - const bool bed = (row == kBedRow); - const int id_toggle = wxWindow::NewControlId(); - menu.Append(id_toggle, bed ? bed_toggle_text(m_show_bed) : origin_toggle_text(m_show_origin)); - menu.Bind(wxEVT_MENU, [this, bed](wxCommandEvent&) { bed ? toggle_bed() : toggle_origin(); }, id_toggle); - m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); - return; - } // EVERYTHING A ROW CAN DO, in one place. The row's icons are the quick bar, but the menu // is the reference: the element you click answers with what applies to it, and a menu // grows without spending an icon nobody recognises. Split into what the row IS (name, // contents), where it SITS (order, visibility) and what removes it. + wxMenu menu; const int id_rename = wxWindow::NewControlId(); const int id_edit = wxWindow::NewControlId(); const int id_up = wxWindow::NewControlId(); @@ -3218,8 +3227,8 @@ DesignPanel::DesignPanel(wxWindow* parent) // Scale artwork acts on THIS feature's imported outline, so it belongs to the row and // is offered only where it means something. It used to hide inside the header's Move // button, which otherwise moved a body — two different subjects on one icon. - const bool art = feat < int(m_doc.features.size()) && - !m_doc.features[feat].imported_regions.empty(); + const bool art = row < int(m_doc.features.size()) && + !m_doc.features[row].imported_regions.empty(); if (art) menu.Append(id_art, _L("Scale artwork")); menu.AppendSeparator(); menu.Append(id_up, _L("Move up")); @@ -3234,7 +3243,7 @@ DesignPanel::DesignPanel(wxWindow* parent) menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_toggle_visibility(); }, id_vis); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del); if (art) - menu.Bind(wxEVT_MENU, [this, feat](wxCommandEvent&) { on_transform_imported(feat); }, id_art); + menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { on_transform_imported(row); }, id_art); m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); }; @@ -3243,9 +3252,8 @@ DesignPanel::DesignPanel(wxWindow* parent) // renaming belongs on the row, not in a side-panel field. The list hands the name over after // its editor's events have finished, so rebuilding the rows here is safe. m_tree->on_rename = [this](int row, const wxString& name) { - const int feat = feature_of_row(row); - if (feat == wxNOT_FOUND || feat >= int(m_doc.features.size())) return; - m_doc.features[feat].name = std::string(name.ToUTF8().data()); + if (row < 0 || row >= int(m_doc.features.size())) return; + m_doc.features[row].name = std::string(name.ToUTF8().data()); refresh_tree(); // which syncs the recipe, so the save path persists the name }; @@ -4424,9 +4432,9 @@ DesignPanel::DesignPanel(wxWindow* parent) m_show_bed = wxGetApp().app_config->get_bool("design_show_bed"); if (m_viewport) m_viewport->set_show_bed(m_show_bed); set_ui_mode(UiMode::Feature); - // The pinned rows from the first paint: nothing else builds the rows before the first edit, - // project load or theme switch. - refresh_tree(); + // The Origin and Bed rows from the first paint: nothing else builds them before the first + // edit, project load or theme switch. + refresh_pinned(); build_phase("wiring"); BOOST_LOG_TRIVIAL(info) << "Design tab build: total " << build_clock.Time() << " ms"; } @@ -7354,6 +7362,7 @@ void DesignPanel::on_sys_color_changed() wxGetApp().UpdateDarkUIWin(this); refresh_icons(); refresh_tree(); // the rows carry their own text colours + refresh_pinned(); Refresh(); } @@ -7524,7 +7533,7 @@ void DesignPanel::refresh_tree() // Preserve the selected row across the rebuild — set_rows() drops the selection, which made // every edit/add feel like it "lost" the selection (and broke Edit/Move/Delete on the - // just-touched feature). By row, so a selected pinned row stays selected too. + // just-touched feature). const int keep = m_tree->selection(); // Datum/reference planes carry no solid; feed them to the viewport so they render as @@ -7532,16 +7541,7 @@ void DesignPanel::refresh_tree() refresh_datum_planes(); update_reference_planes(); std::vector rows; - rows.reserve(m_doc.features.size() + kFeatureRow0); - // Always first, in kOriginRow / kBedRow order, and never removable: the reference planes' and - // the printer bed's own switches, and nothing else. - auto add_pinned = [&rows](const char* icon, const wxString& label, bool shown, const wxString& tip) { - DesignRowList::Action eye = eye_action(shown); - eye.tip = tip; - rows.push_back({ icon, label, {}, dp_item_text(), { eye } }); - }; - add_pinned("design_plane", _L("Origin"), m_show_origin, origin_toggle_text(m_show_origin)); - add_pinned("design_bed", _L("Bed"), m_show_bed, bed_toggle_text(m_show_bed)); + rows.reserve(m_doc.features.size()); for (size_t fi = 0; fi < m_doc.features.size(); ++fi) { const CadFeature& f = m_doc.features[fi]; DesignRowList::Row row; @@ -7963,13 +7963,13 @@ bool DesignPanel::place_on_face() int DesignPanel::tree_selection() const { - return feature_of_row(m_tree->selection()); + return m_tree->selection(); } -void DesignPanel::set_tree_selection(int feature) +void DesignPanel::set_tree_selection(int row) { - if (feature >= 0 && feature + kFeatureRow0 < int(m_tree->GetItemCount())) - m_tree->select(feature + kFeatureRow0); + if (row >= 0 && row < int(m_tree->GetItemCount())) + m_tree->select(row); } // The selection (the solid pick, the hit face and the committed-loop pick) names bodies, faces and @@ -11122,7 +11122,8 @@ void DesignPanel::toggle_origin() // A plane picked while they were up is not a selection once nobody can see it. if (!m_show_origin) drop_plane_pick(); // Not an edit: a plane choice under way stays, and keeps its planes up. - refresh_tree(); // the Origin row's eye, and update_reference_planes(), which repaints + update_reference_planes(); // repaints + refresh_pinned(); // the Origin row's eye } void DesignPanel::toggle_bed() @@ -11130,7 +11131,24 @@ void DesignPanel::toggle_bed() m_show_bed = !m_show_bed; wxGetApp().app_config->set_bool("design_show_bed", m_show_bed); if (m_viewport) m_viewport->set_show_bed(m_show_bed); // repaints - refresh_tree(); // the Bed row's eye + refresh_pinned(); // the Bed row's eye +} + +// View switches, not history: a view toggle rebuilds these two rows and leaves the features +// alone. The label dims while its thing is hidden, as a hidden body's does. +void DesignPanel::refresh_pinned() +{ + std::vector rows; + auto add = [&rows](const char* icon, const wxString& label, bool shown, const wxString& tip) { + DesignRowList::Action eye = eye_action(shown); + eye.tip = tip; + // A blank cell under the features' Delete, so the eye sits in the features' eye column. + rows.push_back({ icon, label, {}, shown ? dp_item_text() : dp_item_dim(), { eye, {} } }); + }; + // In kOriginRow / kBedRow order. + add("design_plane", _L("Origin"), m_show_origin, origin_toggle_text(m_show_origin)); + add("design_bed", _L("Bed"), m_show_bed, bed_toggle_text(m_show_bed)); + m_pinned->set_rows(std::move(rows)); } void DesignPanel::start_sketch() diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 013de06fc8..3ae91acb25 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -384,8 +384,9 @@ private: void refresh_datum_planes(); // push resolved datum frames + per-plane u/v extents to viewport void refresh_mate_connectors(); // push connector frames so verse + polarity are visible void update_reference_planes(); // the XY/XZ/YZ planes + axes: Origin row, plane choice, Plane tool - void toggle_origin(); // the Origin row's eye: keep the reference planes up, or not + void toggle_origin(); // the Origin row's eye (and Ctrl+Shift+O): keep the reference planes up void toggle_bed(); // the Bed row's eye (and Ctrl+Shift+B): draw the printer bed, or not + void refresh_pinned(); // rebuild the Origin and Bed rows (their eyes and label colours) // Sketch: open a session on the picked face or plane, or with none picked, put the planes up // and wait for one (m_choosing_sketch_plane). void start_sketch(); @@ -932,10 +933,11 @@ private: std::function m_value_cont; // deferred apply, run on Confirm std::function m_value_cancel; // optional action when the card is cancelled - // Feature tree: the pinned Origin and Bed rows, then one row per feature, in feature order, - // with a per-type icon and the row's own Edit / Show-hide / Delete icons. Feature i is row - // i + 2; callers use feature indices via tree_selection()/set_tree_selection(); - // refresh_tree() rebuilds the rows. + // Feature tree: the Origin and Bed rows, a fixed block of view switches that never scrolls + // and selects nothing, then, in a frame of its own below them, one row per feature, in feature + // order, with a per-type icon and the row's own Edit / Show-hide / Delete icons. Callers use + // row indices via tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows. + DesignRowList* m_pinned{nullptr}; DesignRowList* m_tree{nullptr}; // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected. // Finding them replays the history, so they are kept per row and topology generation. @@ -990,10 +992,10 @@ private: void arm_transform_gizmo(); // arm the move gizmo on the Transform card's body (add mode only) void on_set_body_color(); // Color tool: pick a per-body display colour override void on_boolean_tool(); // Boolean (combine bodies): needs two solids, then opens the tool - int tree_selection() const; // selected feature's index, or wxNOT_FOUND (none, or a pinned row) + int tree_selection() const; // selected feature row, or wxNOT_FOUND int tree_body_selection() const; // selected Parts-list body index, or -1 void refresh_parts(); // rebuild the Bodies list under the feature tree - void set_tree_selection(int feature); + void set_tree_selection(int row); static const char* tree_icon_for(CadFeatureType t); wxStaticText* m_status{nullptr}; diff --git a/src/slic3r/GUI/CAD/DesignRowList.cpp b/src/slic3r/GUI/CAD/DesignRowList.cpp index facf0aa5ed..1c39009cd5 100644 --- a/src/slic3r/GUI/CAD/DesignRowList.cpp +++ b/src/slic3r/GUI/CAD/DesignRowList.cpp @@ -29,11 +29,19 @@ static constexpr int kCellDip = 20; // square action cell, hover chip included static constexpr int kPadDip = 4; // row edges, and the gap before the action cells static constexpr int kGapDip = 6; // type icon to label -DesignRowList::DesignRowList(wxWindow* parent, int max_visible) - // wxVListBox defaults to wxBORDER_THEME; the sidebar's lists take a simple frame. - : wxVListBox(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_SIMPLE) +DesignRowList::DesignRowList(wxWindow* parent, int max_visible, bool selectable, long style) + // wxVListBox defaults to wxBORDER_THEME; `style` defaults to the simple frame the sidebar's + // lists take, and a list meant to sit unframed on its card passes wxBORDER_NONE. + : wxVListBox(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, style) , m_max_visible(std::max(max_visible, 1)) + , m_selectable(selectable) { + // A non-selectable list takes no focus: from the mouse because it consumes the presses that + // would focus it, below, and from the keyboard through AcceptsFocus(). wxGTK reads that once, + // while the base class is being built, before the override exists, so it is told here. + if (!m_selectable) + SetCanFocus(false); + Bind(wxEVT_LISTBOX, [this](wxCommandEvent&) { if (on_select) on_select(); }); Bind(wxEVT_LISTBOX_DCLICK, [this](wxCommandEvent&) { if (on_activate) on_activate(); }); @@ -46,18 +54,20 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) m_pressed = Hit{}; e.Skip(); }); - // A press on an action cell is remembered and skipped, so the list still selects the row. + // A press on an action cell is remembered and skipped, so the list still selects the row. A + // non-selectable list keeps every press from wxVListBox, which would select the row and take + // the focus. Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { const Hit h = hit_test(e.GetPosition()); m_pressed = h.cell >= 0 ? h : Hit{}; - e.Skip(); + if (m_selectable) e.Skip(); }); // The second press of a double-click on a cell is a press too, and is not skipped: the list // would otherwise turn it into a row double-click, so a quick double toggle of the eye would // also open the feature for editing. Bind(wxEVT_LEFT_DCLICK, [this](wxMouseEvent& e) { const Hit h = hit_test(e.GetPosition()); - if (h.cell < 0) { m_pressed = Hit{}; e.Skip(); return; } + if (h.cell < 0) { m_pressed = Hit{}; if (m_selectable) e.Skip(); return; } m_pressed = h; }); Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) { @@ -69,9 +79,9 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) const int id = m_rows[pressed.row].actions[pressed.cell].id; // After the click has finished dispatching: the action may rebuild these rows. The row // must still exist and still be the selected one, or the click is dropped rather than - // applied to whatever row took its place. + // applied to whatever row took its place. A non-selectable list has no selected row. CallAfter([this, row = pressed.row, id] { - if (row < int(GetItemCount()) && row == GetSelection() && on_action) + if (row < int(GetItemCount()) && (!m_selectable || row == GetSelection()) && on_action) on_action(row, id); }); }); @@ -79,7 +89,7 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) // press suppresses the wxEVT_CONTEXT_MENU that follows it. Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { const Hit h = hit_test(e.GetPosition()); - if (h.row != wxNOT_FOUND) select(h.row); + if (m_selectable && h.row != wxNOT_FOUND) select(h.row); e.Skip(); }); Bind(wxEVT_CONTEXT_MENU, [this](wxContextMenuEvent& e) { @@ -95,7 +105,7 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) return; // On MSW the menu comes with the button's release, and the pointer may have moved to // another row since the press selected one: the menu is for the row it opens over. - select(row); + if (m_selectable) select(row); if (on_menu) on_menu(row, screen); }); // wxVListBox takes every wheel event, even with nothing to scroll, so a short list would stop @@ -254,7 +264,7 @@ void DesignRowList::OnDrawBackground(wxDC& dc, const wxRect& rect, size_t n) con wxColour bg; if (IsSelected(n)) bg = StateColor::darkModeColorFor(wxColour("#BFE1DE")); - else if (int(n) == m_hover.row) + else if (m_selectable && int(n) == m_hover.row) // a non-selectable row lights only its icons bg = StateColor::darkModeColorFor(wxColour("#E5F0EE")); if (!bg.IsOk()) return; // the list already cleared to its background colour @@ -314,7 +324,7 @@ DesignRowList::Hit DesignRowList::hit_test(const wxPoint& pt) const const wxRect rect = GetItemRect(h.row); const auto& acts = m_rows[h.row].actions; for (size_t i = 0; i < acts.size(); ++i) - if (cell_rect(rect, acts.size(), i).Contains(pt)) { + if (!acts[i].icon.empty() && cell_rect(rect, acts.size(), i).Contains(pt)) { h.cell = int(i); break; } diff --git a/src/slic3r/GUI/CAD/DesignRowList.hpp b/src/slic3r/GUI/CAD/DesignRowList.hpp index ff1ecc482a..c100e80437 100644 --- a/src/slic3r/GUI/CAD/DesignRowList.hpp +++ b/src/slic3r/GUI/CAD/DesignRowList.hpp @@ -29,12 +29,16 @@ namespace Slic3r { namespace GUI { // Selection follows wxTreeCtrl's contract, which DesignPanel's mutual exclusion between the two // lists relies on: on_select runs on every change, whether the user or select() made it, and // selecting the row that is already selected changes nothing and notifies nobody. +// +// A list built non-selectable is a row of switches, not of objects: a click never selects a row, +// the pointer highlights only the action icon under it, and the list never takes the focus. Its +// action icons and its context menu are the only things that answer. class DesignRowList : public wxVListBox { public: struct Action { int id; // the owner's code for it, handed back to on_action - std::string icon; // icon name, e.g. "design_eye" + std::string icon; // icon name, e.g. "design_eye"; empty for a blank cell that only holds a column wxString tip; }; struct Row { @@ -50,7 +54,8 @@ public: static constexpr const char* hover_chip = "#D4D4D4"; // The list is as tall as its rows, at least one and at most `max_visible`; past that it scrolls. - DesignRowList(wxWindow* parent, int max_visible); + // `style` is its frame: wxBORDER_NONE for a list that sits unframed on its card. + DesignRowList(wxWindow* parent, int max_visible, bool selectable = true, long style = wxBORDER_SIMPLE); // Replace every row. Clears the selection without notifying and cancels a rename in progress; // the owner re-selects the row it keeps, which notifies. @@ -67,15 +72,19 @@ public: std::function on_select; // the selection changed std::function on_activate; // the selected row was double-clicked - // A row's action icon was clicked. Runs after the click has finished dispatching, and only - // while that row is still the selected one; the click itself selected it. + // A row's action icon was clicked. Runs after the click has finished dispatching, while the + // row still exists and — in a selectable list, where the click itself selected it — is still + // the selected one. std::function on_action; - // Context menu on a row, at a screen position; the row under the pointer is selected first. + // Context menu on a row, at a screen position; the row under the pointer is selected first, + // unless the list is non-selectable. std::function on_menu; // The editor committed `name` for `row`, trimmed and never empty (an empty commit cancels). // Runs after the editor's own events have finished, so the owner may rebuild the rows here. std::function on_rename; + bool AcceptsFocus() const override { return m_selectable && wxVListBox::AcceptsFocus(); } + protected: void OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const override; void OnDrawBackground(wxDC& dc, const wxRect& rect, size_t n) const override; @@ -103,6 +112,7 @@ private: std::vector m_rows; std::map m_icons; int m_max_visible; + bool m_selectable; int m_row_h{0}; // every row's height, from measure_row() Hit m_hover; Hit m_pressed; // action cell under the last left press From e7d6f5e6c062c211d6d0fada681eab43d694679d Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 6 Oct 2026 07:55:25 -0500 Subject: [PATCH 20/30] fix: crash in LAN mode when the printer type is not known yet (#16191) * fix: crash in LAN mode when the printer type is not known yet InputIpAddressDialog::set_machine_obj() built the help image name from the printer config with no fallback. If the printer type is empty or unknown, for example before the first push_all arrives on a flaky LAN link, the lookup returns "" and create_scaled_bitmap("_en") throws. The dialog is opened by the "LAN Connection Failed" handlers in MediaPlayCtrl and MediaFilePanel, where nothing catches the exception, so the app crashes. Use input_access_code_x1 when there is no image, and the _cn image for zh_CN, the same as ConnectPrinterDialog::init_bitmap(). Ported from Bambu Studio 52ca2ec5d1. * fix: return an empty bitmap for an empty icon name create_scaled_bitmap() threw when a caller passed an empty name. That happens when a printer config lookup has no entry, for example in AMSSetting::update_ams_img() for a printer type with no AMS image. Log an error and return wxNullBitmap instead. Ported from Bambu Studio 52ca2ec5d1. --- src/slic3r/GUI/ReleaseNote.cpp | 5 ++++- src/slic3r/GUI/wxExtensions.cpp | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/ReleaseNote.cpp b/src/slic3r/GUI/ReleaseNote.cpp index 9a0bc54b6e..67dc80ca6d 100644 --- a/src/slic3r/GUI/ReleaseNote.cpp +++ b/src/slic3r/GUI/ReleaseNote.cpp @@ -1816,7 +1816,10 @@ void InputIpAddressDialog::set_machine_obj(MachineObject* obj) m_input_printer_name->GetTextCtrl()->SetLabelText(m_obj->get_dev_name()); std::string img_str = DevPrinterConfigUtil::get_printer_connect_help_img(m_obj->printer_type); - auto diagram_bmp = create_scaled_bitmap(img_str + "_en", this, 198); + if (img_str.empty()) { img_str = "input_access_code_x1"; } + + std::string language = wxGetApp().app_config->get("language"); + auto diagram_bmp = create_scaled_bitmap(img_str + (language == "zh_CN" ? "_cn" : "_en"), this, 198); m_img_help->SetBitmap(diagram_bmp); diff --git a/src/slic3r/GUI/wxExtensions.cpp b/src/slic3r/GUI/wxExtensions.cpp index 5d38e87f4d..5babf843bc 100644 --- a/src/slic3r/GUI/wxExtensions.cpp +++ b/src/slic3r/GUI/wxExtensions.cpp @@ -456,6 +456,13 @@ wxBitmap create_scaled_bitmap( const std::string& bmp_name_in, const vector& array_new_color/* = vector*/)//used for semi transparent material) { static Slic3r::GUI::BitmapCache cache; + + // An empty name means the caller's icon lookup failed + if (bmp_name_in.empty() || bmp_name_in == ".png") { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": empty bitmap name"; + return wxNullBitmap; + } + if (bitmap2) { return create_scaled_bitmap2(bmp_name_in, cache, win, px_cnt, grayscale, resize, array_new_color); } From 5a8eb3f14e78ff3b388e92892f4ea4beb7afdb26 Mon Sep 17 00:00:00 2001 From: Ioannis Giannakas <59056762+igiannakas@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:07:01 +0100 Subject: [PATCH 21/30] Fix one wall on top dropping inner walls of narrow Arachne walls (#16174) * Fix one wall on top dropping inner walls of narrow Arachne walls * Fix clang tidy errors on the test suite --- src/libslic3r/PerimeterGenerator.cpp | 151 ++++++++++++++++++++++----- tests/fff_print/test_perimeters.cpp | 120 +++++++++++++++++++++ 2 files changed, 245 insertions(+), 26 deletions(-) diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 44d30aa789..e2b5090213 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -759,6 +759,59 @@ static void clip_inner_walls_over_top(std::vector & } } +// ORCA: only_one_wall_top - widest bead of the given walls. +static coord_t widest_bead(const std::vector &walls) +{ + coord_t widest = 0; + for (const Arachne::VariableWidthLines &group : walls) + for (const Arachne::ExtrusionLine &el : group) + for (const Arachne::ExtrusionJunction &j : el.junctions) + widest = std::max(widest, j.w); + return widest; +} + +// ORCA: only_one_wall_top - length of the walls running further than tolerance from the reference walls, outside the +// excluded area. +static double length_off_reference(const std::vector &walls, const Arachne::VariableWidthLines &reference, + const ExPolygons &excluded, coord_t tolerance) +{ + auto append_centerlines = [](const Arachne::VariableWidthLines &lines, Polylines &out) { + for (const Arachne::ExtrusionLine &el : lines) { + if (el.junctions.size() < 2) + continue; + Polyline ¢erline = out.emplace_back(); + centerline.points.reserve(el.junctions.size()); + for (const Arachne::ExtrusionJunction &j : el.junctions) + centerline.points.emplace_back(j.p); + } + }; + Polylines wall_centerlines; + Polylines reference_centerlines; + for (const Arachne::VariableWidthLines &group : walls) + append_centerlines(group, wall_centerlines); + append_centerlines(reference, reference_centerlines); + + Polylines off_reference = diff_pl(wall_centerlines, offset(reference_centerlines, float(tolerance))); + if (! excluded.empty()) + off_reference = diff_pl(off_reference, excluded); + return total_length(off_reference); +} + +// ORCA: only_one_wall_top - area covered by the given walls at their local widths. +static Polygons walls_footprint(const Arachne::VariableWidthLines &walls) +{ + Polygons footprint; + for (const Arachne::ExtrusionLine &el : walls) + for (size_t i = 1; i < el.junctions.size(); ++ i) { + const Arachne::ExtrusionJunction &a = el.junctions[i - 1]; + const Arachne::ExtrusionJunction &b = el.junctions[i]; + const coord_t width = std::max(a.w, b.w); + if (width > 0) + append(footprint, offset(Polyline(a.p, b.p), float(width) / 2.f)); + } + return union_(footprint); +} + void PerimeterGenerator::split_top_surfaces(const ExPolygons &orig_polygons, ExPolygons &top_fills, ExPolygons &non_top_polygons, ExPolygons &fill_clip) const { // other perimeters @@ -2546,44 +2599,90 @@ void PerimeterGenerator::process_arachne() if (inner_loop_number >= 0) { assert(upper_slices != nullptr); - // Infill contour bounding box. - BoundingBox infill_contour_bbox = get_extents(infill_contour); - infill_contour_bbox.offset(SCALED_EPSILON); - coord_t perimeter_width = this->perimeter_flow.scaled_width(); - // Get top ExPolygons from current infill contour. - Polygons upper_slices_clipped; - if (object_config->interface_shells) { - auto upper_slicer_same_region = to_expolygons(this->upper_slices_same_region->surfaces); - upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(upper_slicer_same_region, infill_contour_bbox); - } else - upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*upper_slices, infill_contour_bbox); + // Filter out areas that are too thin and expand top surface polygons a bit to hide the wall line. + // ORCA: skip if the top surface area is smaller than "min_width_top_surface" + const float top_surface_min_width = std::max(float(ext_perimeter_spacing) / 4.f + scaled(0.00001), float(scale_(config->min_width_top_surface.get_abs_value(unscale_(perimeter_width)))) / 4.f); - top_expolygons = diff_ex(infill_contour, upper_slices_clipped); + // Get top ExPolygons from the given contour. uncovered reports whether the upper layer leaves any of the + // contour uncovered, before bridges and too thin areas are filtered out. + auto get_top_expolygons = [&](const ExPolygons &contour, bool &uncovered) { + // Contour bounding box. + BoundingBox contour_bbox = get_extents(contour); + contour_bbox.offset(SCALED_EPSILON); + + Polygons upper_slices_clipped; + if (object_config->interface_shells) { + auto upper_slicer_same_region = to_expolygons(this->upper_slices_same_region->surfaces); + upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(upper_slicer_same_region, contour_bbox); + } else + upper_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*upper_slices, contour_bbox); + + ExPolygons top = diff_ex(contour, upper_slices_clipped); + uncovered = !top.empty(); + if (top.empty()) + return top; - if (!top_expolygons.empty()) { if (lower_slices != nullptr) { const float bridge_offset = float(std::max(ext_perimeter_spacing, perimeter_width)); - const Polygons lower_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*lower_slices, infill_contour_bbox); - const ExPolygons current_slices_bridges = offset_ex(diff_ex(top_expolygons, lower_slices_clipped), bridge_offset); + const Polygons lower_slices_clipped = ClipperUtils::clip_clipper_polygons_with_subject_bbox(*lower_slices, contour_bbox); + const ExPolygons current_slices_bridges = offset_ex(diff_ex(top, lower_slices_clipped), bridge_offset); // Remove bridges from top surface polygons. - top_expolygons = diff_ex(top_expolygons, current_slices_bridges); + top = diff_ex(top, current_slices_bridges); } - // Filter out areas that are too thin and expand top surface polygons a bit to hide the wall line. - // ORCA: skip if the top surface area is smaller than "min_width_top_surface" - const float top_surface_min_width = std::max(float(ext_perimeter_spacing) / 4.f + scaled(0.00001), float(scale_(config->min_width_top_surface.get_abs_value(unscale_(perimeter_width)))) / 4.f); // Shrink the polygon to remove the small areas, then expand it back out plus a maragin to hide the wall line a little. // ORCA: Expand the polygon with half the perimeter width in addition to the contracted amount, // not the full perimeter width as PS does, to enable thin lettering to print on the top surface without nozzle collisions // due to thin lines being generated - top_expolygons = offset2_ex(top_expolygons, -top_surface_min_width, top_surface_min_width + float(perimeter_width * 0.85)); + top = offset2_ex(top, -top_surface_min_width, top_surface_min_width + float(perimeter_width * 0.85)); // Get final top ExPolygons (bridges were excluded above, so they stay walled). - top_expolygons = intersection_ex(top_expolygons, infill_contour); + return intersection_ex(top, contour); + }; + // Walls with the full count, as generated when the single perimeter feature is disabled. Generated on first use. + std::vector full_perimeters; + Polygons full_inner_contour; + bool full_perimeters_generated = false; + auto generate_full_perimeters = [&]() { + if (full_perimeters_generated) + return; + Arachne::WallToolPaths full_tool_paths(last_p, bead_width_0, perimeter_spacing, coord_t(inner_loop_number + 2), wall_0_inset, layer_height, input_params_tmp); + full_perimeters = full_tool_paths.getToolPaths(); + full_inner_contour = full_tool_paths.getInnerContour(); + full_perimeters_generated = true; + }; + + // ORCA: the single wall pass allows Arachne 2 beads across a wall, so it fills a wall narrower than 3 outer wall + // widths by widening both, where the full pass adds a middle bead. Over the top surface that is the intent; + // anywhere else it leaves no room for the inner walls. When the single wall pass's outer walls run away from + // the full pass's outside the top surface, take the full pass's outer walls and the area inside them instead. + // Walls closer than outer_wall_tolerance count as the same wall: a widened bead's centerline moves by half + // the width added, and only beads widened by more than twice the tolerance are looked for. + const coord_t outer_wall_tolerance = bead_width_0 / 10; + if (widest_bead(perimeters) > bead_width_0 + 2 * outer_wall_tolerance) { + // The single wall pass's inner contour where it widens no bead: inside nominal width outer walls. + const ExPolygons nominal_infill_contour = offset_ex(last, -float(bead_width_0 + wall_0_inset)); + bool nominal_uncovered = false; + // Grown by an outer wall width to take in the outer walls bordering the top surface. + const ExPolygons top_zone = offset_ex(get_top_expolygons(nominal_infill_contour, nominal_uncovered), float(bead_width_0)); + if (nominal_uncovered) { + generate_full_perimeters(); + if (! full_perimeters.empty() && ! full_perimeters.front().empty() && + length_off_reference(perimeters, full_perimeters.front(), top_zone, outer_wall_tolerance) > double(perimeter_width)) { + perimeters = { full_perimeters.front() }; + infill_contour = diff_ex(nominal_infill_contour, walls_footprint(full_perimeters.front()), ApplySafetyOffset::Yes); + } + } + } + + bool uncovered = false; + top_expolygons = get_top_expolygons(infill_contour, uncovered); + + if (uncovered) { // ORCA: onion the real region (inside the outer wall) so the remaining walls follow the actual // geometry, then cut away the parts over the top surface. Re-onioning the non-top complement // instead - the fallback when there is no top fill - walls the top/non-top interface and rings @@ -2612,11 +2711,11 @@ void PerimeterGenerator::process_arachne() perimeters.insert(perimeters.end(), inner_perimeters.begin(), inner_perimeters.end()); infill_contour = union_ex(top_expolygons, inner_wall_tool_paths.getInnerContour()); } else { - // There is no top surface ExPolygon, so we call Arachne again with parameters - // like when the single perimeter feature is disabled. - Arachne::WallToolPaths no_single_perimeter_tool_paths(last_p, bead_width_0, perimeter_spacing, coord_t(inner_loop_number + 2), wall_0_inset, layer_height, input_params_tmp); - perimeters = no_single_perimeter_tool_paths.getToolPaths(); - infill_contour = union_ex(no_single_perimeter_tool_paths.getInnerContour()); + // There is no top surface ExPolygon, so use the walls generated like when the single perimeter + // feature is disabled. + generate_full_perimeters(); + perimeters = std::move(full_perimeters); + infill_contour = union_ex(full_inner_contour); } } //PS diff --git a/tests/fff_print/test_perimeters.cpp b/tests/fff_print/test_perimeters.cpp index 6ab3473e3c..1cbd9e9351 100644 --- a/tests/fff_print/test_perimeters.cpp +++ b/tests/fff_print/test_perimeters.cpp @@ -19,8 +19,10 @@ #include #include "libslic3r/libslic3r.h" #include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Point.hpp" +#include "libslic3r/Polyline.hpp" #include #include #include @@ -275,6 +277,124 @@ TEST_CASE("Only one wall on the first layer needs a bottom shell", "[Perimeters] namespace { +// The last layer of the tab, whose top surface shares an island with the tube walls rising past it. +const double tab_top_z = 5.0; + +// With the widths below the tube walls are 1.10mm wide once the precise outer wall offset (0.043mm a side) is +// taken off. That is narrower than 3 outer wall spacings (3 x 0.377 = 1.131mm), so an Arachne pass limited to a +// single wall fills it by widening its 2 beads, yet wide enough for the full 2 wall pass to add a middle wall +// (from 1.062mm). +const double narrow_wall = 1.186; + +// A 20x30x10 tube with narrow_wall thick walls, and a 20x8x5 tab against its -Y side. +Print &tube_with_tab(Print &print, Model &model, const DynamicPrintConfig &config) +{ + ModelObject *object = model.add_object(); + object->name = "tube_with_tab.stl"; + object->add_volume(make_cube(20., 30., 10.), ModelVolumeType::MODEL_PART, false); + // Overlaps the tube wall by 0.5mm so the two parts slice as one island. + TriangleMesh tab = make_cube(20., 8.5, 5.); + tab.translate(0.f, -8.f, 0.f); + object->add_volume(std::move(tab), ModelVolumeType::MODEL_PART, false); + TriangleMesh bore = make_cube(20. - 2. * narrow_wall, 30. - 2. * narrow_wall, 12.); + bore.translate(float(narrow_wall), float(narrow_wall), -1.f); + object->add_volume(std::move(bore), ModelVolumeType::NEGATIVE_VOLUME, false); + object->add_instance(); + object->ensure_on_bed(); + + print.auto_assign_extruders(object); + print.apply(model, config); + print.validate(); + print.set_status_silent(); + return print; +} + +// Every width the narrow_wall arithmetic depends on, so none of them rests on a default. +DynamicPrintConfig narrow_wall_config(bool only_one_wall_top, double top_surface_expansion) +{ + DynamicPrintConfig config = base_config("arachne"); + config.set_deserialize_strict({ + { "wall_loops", 2 }, + { "nozzle_diameter", "0.4" }, + { "line_width", 0.42 }, + { "outer_wall_line_width", 0.42 }, + { "inner_wall_line_width", 0.45 }, + { "min_bead_width", "85%" }, + { "precise_outer_wall", true }, + { "wall_sequence", "inner wall/outer wall" }, + { "only_one_wall_top", only_one_wall_top }, + { "top_surface_expansion", top_surface_expansion }, + }); + return config; +} + +// Inner wall length the layer at print_z extrudes within 3mm of its +Y edge: the tube wall facing away from the tab. +double far_wall_inner_wall_length(const Print &print, double print_z) +{ + for (const Layer *layer : print.objects().front()->layers()) { + if (std::abs(layer->print_z - print_z) > EPSILON) + continue; + BoundingBox band = get_extents(layer->lslices); + band.min.y() = band.max.y() - scaled(3.); + + Polylines inner_walls; + auto collect = [&inner_walls](const ExtrusionPaths &paths) { + for (const ExtrusionPath &path : paths) + if (path.role() == erPerimeter) + inner_walls.emplace_back(path.as_polyline()); + }; + for (const LayerRegion *region : layer->regions()) { + const ExtrusionEntityCollection walls = region->perimeters.flatten(); + for (const ExtrusionEntity *entity : walls.entities) { + if (const auto *loop = dynamic_cast(entity)) + collect(loop->paths); + else if (const auto *multi_path = dynamic_cast(entity)) + collect(multi_path->paths); + else if (const auto *path = dynamic_cast(entity)) + collect({ *path }); + } + } + return unscaled(total_length(intersection_pl(inner_walls, band.polygon()))); + } + return 0.; +} + +} // namespace + +// only_one_wall_top first lays out an island with a single Arachne wall and generates the inner walls inside it. +// On a wall narrower than 3 outer wall spacings that single wall pass widens its 2 beads to fill the wall and leaves +// no room for the middle wall, which is only intended over the top surface. The tube walls away from the tab are not +// under the tab's top surface, so on the tab's last layer they keep the inner wall they get with the option off. +TEST_CASE("Only one wall on top surfaces keeps the inner walls of narrow walls away from the top surface", "[Perimeters]") +{ + // 0 re-onions the region beside the top surface, 2 clips the inner walls over it. + const double top_surface_expansion = GENERATE(0.0, 2.0); + CAPTURE(top_surface_expansion); + + struct TabTopLayer { + double perimeters; + double far_wall_inner_walls; + }; + auto tab_top_layer_for = [top_surface_expansion](bool only_one_wall_top) { + Print print; + Model model; + tube_with_tab(print, model, narrow_wall_config(only_one_wall_top, top_surface_expansion)); + print.process(); + REQUIRE_FALSE(print.objects().empty()); + return TabTopLayer{ perimeter_length_at(print, tab_top_z), far_wall_inner_wall_length(print, tab_top_z) }; + }; + + const TabTopLayer plain = tab_top_layer_for(false); + const TabTopLayer one_wall = tab_top_layer_for(true); + + // The option acts on this layer: the inner walls under the tab's top surface are gone. + REQUIRE(plain.far_wall_inner_walls > 10.); + CHECK(one_wall.perimeters < plain.perimeters); + CHECK_THAT(one_wall.far_wall_inner_walls, Catch::Matchers::WithinAbs(plain.far_wall_inner_walls, 1.0)); +} + +namespace { + // The layer that closes the cavity of box_over_cavity(), the first one printed over air. const double cavity_ceiling_z = 6.2; From 2d90345d1ee38376a818ff3d6529c517d1abf621 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Tue, 6 Oct 2026 10:34:28 -0300 Subject: [PATCH 22/30] Move fill-pattern-tops wiki under 3D Honeycomb (#16214) --- src/slic3r/GUI/Tab.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index ace8646927..e08745666c 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -2928,7 +2928,7 @@ void TabPrint::build() optgroup->append_single_option_line("skin_infill_line_width", "strength_settings_patterns#locked-zag"); optgroup->append_single_option_line("skeleton_infill_line_width", "strength_settings_patterns#locked-zag"); optgroup->append_single_option_line("symmetric_infill_y_axis", "strength_settings_infill#symmetric-infill-y-axis"); - optgroup->append_single_option_line("infill_complete_top", "strength_settings_infill#infill-complete-top"); + optgroup->append_single_option_line("infill_complete_top", "strength_settings_patterns#fill-pattern-tops"); optgroup->append_single_option_line("infill_shift_step", "strength_settings_patterns#cross-hatch"); optgroup->append_single_option_line("lateral_lattice_angle_1", "strength_settings_patterns#lateral-lattice"); optgroup->append_single_option_line("lateral_lattice_angle_2", "strength_settings_patterns#lateral-lattice"); From c74ac8a6c342dd3883b4c42910266ee2476333a3 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Tue, 6 Oct 2026 11:36:47 -0300 Subject: [PATCH 23/30] Keep FPS/timings overlay below the toolbar on narrow canvases (#16215) --- src/slic3r/GUI/GLCanvas3D.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 9c2fca1957..e9724f41ed 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -7970,9 +7970,16 @@ void GLCanvas3D::_render_fps_overlay(int fps) const const float margin = 10.0f * get_scale(); const ImVec2 display_size = ImGui::GetIO().DisplaySize; ImVec2 pos(display_size.x - margin, margin); + // Last frame's size; zero until the overlay has been shown once. + const ImGuiWindow* self = ImGui::FindWindowByName("###fps_overlay"); + const float left = pos.x - (self != nullptr ? self->Size.x : 0.0f); // The Preview legend takes the top-right corner. if (const ImGuiWindow* legend = ImGui::FindWindowByName("Legend"); m_canvas_type == ECanvasType::CanvasPreview && legend != nullptr && legend->Active) pos = ImVec2(legend->Pos.x - margin, legend->Pos.y); + // The toolbar row can reach the corner on a narrow canvas; stack the overlay below it then. + else if (m_main_toolbar.is_enabled() && + get_main_toolbar_offset() + m_main_toolbar.get_width() + m_separator_toolbar.get_width() + m_gizmos.get_scaled_total_width() + m_assemble_view_toolbar.get_width() > left) + pos.y = std::max(m_main_toolbar.get_height(), m_gizmos.get_scaled_total_height()) + margin; ImGui::SetNextWindowPos(pos, ImGuiCond_Always, ImVec2(1.0f, 0.0f)); ImGui::SetNextWindowBgAlpha(0.35f); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f * get_scale()); From 13d4a0d922c5949e1ebe8c0ab20c10cdde014197 Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:41:25 -0300 Subject: [PATCH 24/30] Avoid center plug for internal solid infill (spiral inset) (#15705) avoid center plug for internal solid infill --- src/libslic3r/Fill/FillSpiralInset.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/libslic3r/Fill/FillSpiralInset.cpp b/src/libslic3r/Fill/FillSpiralInset.cpp index 8f0c75ff83..5931e813cf 100644 --- a/src/libslic3r/Fill/FillSpiralInset.cpp +++ b/src/libslic3r/Fill/FillSpiralInset.cpp @@ -352,6 +352,16 @@ void FillSpiralInset::_fill_surface_single(const FillParams& params, assert(params.use_arachne); assert(this->print_config != nullptr && this->print_object_config != nullptr); + // Internal solid infill must not add Arachne's standalone thin walls: one of those walls can + // become the extra centre point after the spiral has finished. Top and bottom surfaces keep + // Arachne, including the centre plug handled by generate_spiral_insets(). + if (params.extrusion_role == erSolidInfill) { + Polylines polylines; + this->_fill_surface_single(params, thickness_layers, direction, expolygon, polylines); + append(thick_polylines_out, to_thick_polylines(std::move(polylines), scaled(this->spacing))); + return; + } + // Only a solid surface is worth the variable width walls; a sparse one falls back to plain loops. if (params.density <= 0.9999f || params.dont_adjust) { Polylines polylines; From ec21c84738dee1fd1f1277dd5553ed652cc2ba4e Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 6 Oct 2026 22:46:00 +0800 Subject: [PATCH 25/30] Keep the Design tab's Bodies right-click menu open on Linux --- src/slic3r/GUI/CAD/DesignPanel.cpp | 7 ++++--- src/slic3r/GUI/CAD/DesignRowList.hpp | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index de07d4451c..ec8372848b 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -3363,9 +3363,10 @@ DesignPanel::DesignPanel(wxWindow* parent) // been promising this right-click since before it existed. m_parts->on_menu = [this, apply_body_row](int row, const wxPoint& screen) { apply_body_row(row); // unconditional — see above - // Let the modal menu take the loop after this handler returns — same CallAfter as the - // sketch path, which learned it the hard way. - CallAfter([this, screen] { show_offer_menu(screen); }); + // From inside the event, as the feature rows do, never CallAfter'd: wxGTK sends the + // context menu on the right PRESS, and a menu GTK pops after that event has finished + // has no trigger time, so the button's release closes it the moment it opens. + show_offer_menu(screen); }; // Renaming a BODY names the body itself. It does NOT rename the feature that created it: // an Extrude, a Cut and a Fillet all land on one body, so source_feature is one operation in diff --git a/src/slic3r/GUI/CAD/DesignRowList.hpp b/src/slic3r/GUI/CAD/DesignRowList.hpp index c100e80437..7c2c524691 100644 --- a/src/slic3r/GUI/CAD/DesignRowList.hpp +++ b/src/slic3r/GUI/CAD/DesignRowList.hpp @@ -77,7 +77,8 @@ public: // the selected one. std::function on_action; // Context menu on a row, at a screen position; the row under the pointer is selected first, - // unless the list is non-selectable. + // unless the list is non-selectable. Pop the menu from inside the call: wxGTK sends it on the + // right press, and a menu deferred past that event closes on the button's release. std::function on_menu; // The editor committed `name` for `row`, trimmed and never empty (an empty commit cancels). // Runs after the editor's own events have finished, so the owner may rebuild the rows here. From e4b08286447dd343cad0e3b95d3802d8180a9a2b Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 7 Oct 2026 00:01:07 +0800 Subject: [PATCH 26/30] Add Zoom to selection to the Design tab's rows and right-click menu --- docs/HLSD/design-tab.md | 28 ++-- resources/images/design_zoom.svg | 1 + scripts/CAD/tool_atlas.json | 26 ++++ src/slic3r/GUI/CAD/DesignCanvas.cpp | 10 ++ src/slic3r/GUI/CAD/DesignCanvas.hpp | 10 ++ src/slic3r/GUI/CAD/DesignOffer.hpp | 3 +- src/slic3r/GUI/CAD/DesignPanel.cpp | 131 +++++++++++++++--- src/slic3r/GUI/CAD/DesignPanel.hpp | 19 ++- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 76 ++++++---- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 11 +- src/slic3r/GUI/GLCanvas3D.hpp | 2 + tests/slic3rutils/test_design_sketch_tool.cpp | 73 ++++++++++ 12 files changed, 329 insertions(+), 61 deletions(-) create mode 100644 resources/images/design_zoom.svg diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 2d7810cd4f..6476be10fb 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -266,6 +266,15 @@ Feature tree and Bodies list do between themselves. `Esc`, a click on empty spac empty rubber band all let go of it, whichever list or pick made it — except while a body Move is open, which holds the selection until it ends (see the interaction contract). +Zoom to selection, on a Feature tree or Bodies row and in its right-click menu, frames one thing +along the current view direction, as the canvas's Fit button frames the selection. On a body it +frames the body whole, hidden or not, from its display mesh: the viewport never selects a hidden +body, so the selection cannot stand in for it. On a sketch it frames the sketch's own geometry, +drawn, consumed or suppressed; on any other feature, the faces the feature made, found as the row +highlight finds them and from the same cache. From the offer it frames whatever the selection is, +a body again included. A feature that is not a sketch and makes no faces, such as a datum plane +or a suppressed Extrude, has no Zoom to selection. + ## Following the app The tab is a page of Orca's main window and answers to the same settings as Prepare. @@ -281,10 +290,11 @@ The tab is a page of Orca's main window and answers to the same settings as Prep - **Sidebar icons.** Every clickable icon in the sidebar shows a hover chip. The card-header and constraint-row buttons are Orca's self-painted `Button`, because a native button cannot take a hover background on macOS. The Feature tree and Bodies lists are a custom-drawn - `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Edit, - Show/hide and Delete on a feature, Move, Show/hide and Delete on a body, and only Show/hide on - the Origin and Bed rows, a separate non-selectable list above the features — and the eye shows - whether that row is hidden. + `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Zoom to + selection, Edit, Show/hide and Delete on a feature, Zoom to selection, Move, Show/hide and + Delete on a body, and only Show/hide on the Origin and Bed rows, a separate non-selectable list + above the features — and the eye shows whether that row is hidden. A feature with nothing to + frame keeps a blank cell where Zoom to selection would be, so every icon stays in its column. - **Plates.** This is the one thing the tab does not follow. The canvas has a bed of its own at the printer bed's home position, whichever plate Prepare has current, and a new document's modeling origin is that bed's centre. A bed that followed the current plate would slide out @@ -316,12 +326,12 @@ permanent row index, verbs that do not apply shown disabled **in place with thei rather than removed. The invariant is that a verb's row index is identical in every selection where it appears and that adding a verb never moves an existing one — the hand learns the position, so the menu is never re-sorted, compacted or adaptively ordered. Above the families -sits one *flat* row, holding Rename and Color — what a selection is opened for most: its verbs are -items of their own at the top of the menu rather than a family's submenu. It is appended after -the eight, so it moved no existing index, and it reads the same from the viewport and from a row -of the Bodies list. +sits one *flat* row, holding Rename, Color and Zoom to selection — what a selection is opened for +most: its verbs are items of their own at the top of the menu rather than a family's submenu. It +is appended after the eight, so it moved no existing index, and it reads the same from the +viewport and from a row of the Bodies list. -An invariant across 92 verbs and 20 selection kinds does not survive by review, so the map +An invariant across 93 verbs and 20 selection kinds does not survive by review, so the map exists once, as data: `scripts/CAD/tool_atlas.json` carries every verb with its row, key, icon, accepted selections, preconditions and refusal string, and `scripts/CAD/gen_offer_table.py` emits `src/slic3r/GUI/CAD/DesignOffer.hpp` from it. The header is checked in and never diff --git a/resources/images/design_zoom.svg b/resources/images/design_zoom.svg new file mode 100644 index 0000000000..e9048dcff4 --- /dev/null +++ b/resources/images/design_zoom.svg @@ -0,0 +1 @@ + diff --git a/scripts/CAD/tool_atlas.json b/scripts/CAD/tool_atlas.json index ef106a02ee..af0653feff 100644 --- a/scripts/CAD/tool_atlas.json +++ b/scripts/CAD/tool_atlas.json @@ -1113,6 +1113,32 @@ "icon": "color_palette", "hint": "Set the selected body's display color" }, + { + "id": "zoom_to", + "name": "Zoom to selection", + "slot": "top", + "key": null, + "feature": null, + "mcp": null, + "mode": "model", + "accepts": [ + "body_solid", + "body_sheet", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex", + "sk_loop" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:zoom_to", + "icon": "design_zoom", + "hint": "Frame the selection in the view, keeping the view direction" + }, { "id": "delete", "name": "Delete", diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index a2ced05cc0..b765b58fa2 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -463,6 +463,16 @@ void DesignCanvas::fit_view() } } +bool DesignCanvas::zoom_to_box(BoundingBoxf3 box) +{ + if (m_canvas == nullptr || !box.defined) + return false; + DesignSketchTool::pad_box(box); + m_canvas->zoom_to_box(box); + request_repaint(); + return true; +} + void DesignCanvas::set_view(const std::string& view_name) { if (m_canvas) { diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 62becddc10..03f3b4ed54 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -3,6 +3,7 @@ #include #include "libslic3r/Point.hpp" +#include "libslic3r/BoundingBox.hpp" #include "libslic3r/BuildVolume.hpp" #include "libslic3r/Color.hpp" #include @@ -60,6 +61,15 @@ public: void fit_view(); void set_view(const std::string& view_name); + // Frame `box` along the current view direction, as the canvas's Fit button frames a selection. + // False, the camera left alone, when there is nothing to frame: an undefined box. + bool zoom_to_box(BoundingBoxf3 box); + // Boxes to hand zoom_to_box: the selection and body faces (see DesignSketchTool). + BoundingBoxf3 selection_box() const { return m_sketch_tool.selection_box(); } + BoundingBoxf3 faces_box(std::vector> faces) const + { + return m_sketch_tool.faces_box(std::move(faces)); + } void begin_sketch(const SketchPlane& plane, DesignSketchTool::Mode mode); // Re-open a committed entity sketch for full in-canvas editing (load geometry + diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp index f58f9ab658..7bb56c93a1 100644 --- a/src/slic3r/GUI/CAD/DesignOffer.hpp +++ b/src/slic3r/GUI/CAD/DesignOffer.hpp @@ -142,6 +142,7 @@ static const OfferVerb kOfferVerbs[] = { {"rename", L("Rename…"), 8, "F2", "btn:rename", L("Select a feature, or a body, to rename it"), 0x00004080u, 0, 0, false, false, nullptr, nullptr, L("Give this feature a name you will recognise in the tree (a body takes its name from the feature that makes it)")}, {"delete_face", L("Delete Face"), 7, nullptr, "fly:dressup#3", L("Delete Face needs a body — add or import one first"), 0x0000000eu, 1, 0, false, false, nullptr, "design_delete", L("Remove faces from a body and heal the solid")}, {"colour", L("Color"), 8, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", L("Set the selected body's display color")}, + {"zoom_to", L("Zoom to selection"), 8, nullptr, "btn:zoom_to", nullptr, 0x000041feu, 0, 0, false, false, nullptr, "design_zoom", L("Frame the selection in the view, keeping the view direction")}, {"delete", L("Delete"), 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", L("Delete what is selected")}, {"delete_body", L("Delete Body"), 7, nullptr, "btn:delete_body", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "design_delete", L("Delete this whole body — removes the feature it was made from")}, {"sk_line_t", L("Line"), 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true, L("Line"), "design_line", L("Line — click start, then end")}, @@ -201,7 +202,7 @@ static const OfferVerb kOfferVerbs[] = { {"sk_radius", L("Radius / diameter…"), 7, "V", "key:V", nullptr, 0x00020000u, 0, 0, false, true, nullptr, "design_dimension", L("Type the radius of this arc, or the diameter of this circle")}, {"sk_angdist", L("Angle / distance…"), 7, "V", "key:V", nullptr, 0x00080000u, 0, 0, false, true, nullptr, "design_dimension", L("Type the angle between two lines, or the distance between the two picks")}, }; -static const int kOfferVerbCount = 92; +static const int kOfferVerbCount = 93; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index ec8372848b..6b28c1b356 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -36,6 +36,7 @@ #include "libslic3r/Point.hpp" #include #include +#include #include "libslic3r/BoundingBox.hpp" #include "libslic3r/Color.hpp" #include "libslic3r/CAD/SketchSolver.hpp" @@ -284,7 +285,7 @@ static std::string commit_icon(bool bodies) } // The icons on each Feature tree and Bodies row (DesignRowList::Action::id). -enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete }; +enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete, RowZoom }; // The rows of the Feature tree's pinned block, above the features. static constexpr int kOriginRow = 0, kBedRow = 1; @@ -1078,6 +1079,7 @@ DesignPanel::DesignPanel(wxWindow* parent) b_color->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); }); fadd("color", b_color); m_verb_actions["btn:colour"] = [this] { on_set_body_color(); }; + m_verb_actions["btn:zoom_to"] = [this] { zoom_to_selection(); }; m_verb_actions["btn:delete"] = [this] { on_delete_feature(); }; // A row's double-click is Edit, so renaming needs a door of its own. The offer is this // tab's only tool vocabulary and the row IS the object, so rename sits in the offer, in @@ -3194,12 +3196,13 @@ DesignPanel::DesignPanel(wxWindow* parent) if (tree_selection() != wxNOT_FOUND) on_edit_feature(); }; - // The row's own Edit / Show-hide / Delete, on the row the click selected. The body list is - // cleared here too, not left to on_select, which re-clicking the selected row does not run: - // a body row still selected would be what on_toggle_visibility acts on. - m_tree->on_action = [this](int, int id) { + // The row's own Zoom to selection / Edit / Show-hide / Delete, on the row the click selected. + // The body list is cleared here too, not left to on_select, which re-clicking the selected row + // does not run: a body row still selected would be what on_toggle_visibility acts on. + m_tree->on_action = [this](int row, int id) { if (m_parts) m_parts->unselect(); switch (id) { + case RowZoom: zoom_to_feature(row); break; case RowEdit: on_edit_feature(); break; case RowVisibility: on_toggle_visibility(); break; case RowDelete: on_delete_feature(); break; @@ -3220,6 +3223,7 @@ DesignPanel::DesignPanel(wxWindow* parent) const int id_up = wxWindow::NewControlId(); const int id_down = wxWindow::NewControlId(); const int id_vis = wxWindow::NewControlId(); + const int id_zoom = wxWindow::NewControlId(); const int id_art = wxWindow::NewControlId(); const int id_del = wxWindow::NewControlId(); menu.Append(id_rename, _L("Rename\tF2")); @@ -3234,6 +3238,9 @@ DesignPanel::DesignPanel(wxWindow* parent) menu.Append(id_up, _L("Move up")); menu.Append(id_down, _L("Move down")); menu.Append(id_vis, _L("Show / hide")); + // Offered where the row's own icon is: on a row with something to frame. + const bool zoom = can_zoom_to_feature(row); + if (zoom) menu.Append(id_zoom, _L("Zoom to selection")); menu.AppendSeparator(); menu.Append(id_del, _L("Delete")); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { m_verb_actions["btn:rename"](); }, id_rename); @@ -3244,6 +3251,8 @@ DesignPanel::DesignPanel(wxWindow* parent) menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del); if (art) menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { on_transform_imported(row); }, id_art); + if (zoom) + menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { zoom_to_feature(row); }, id_zoom); m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); }; @@ -3345,12 +3354,14 @@ DesignPanel::DesignPanel(wxWindow* parent) set_status(StatusKind::Info, wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1)); }; m_parts->on_select = [this, apply_body_row] { apply_body_row(tree_body_selection()); }; - // The row's own Move / Show-hide / Delete. apply_body_row runs unconditionally, as for the - // menu below: a face picked in the viewport since the row was selected has moved - // m_sel_solid_body, which is the body on_move_body and on_delete_body act on. + // The row's own Zoom to selection / Move / Show-hide / Delete. apply_body_row runs + // unconditionally, as for the menu below: a face picked in the viewport since the row was + // selected has moved m_sel_solid_body, which is the body on_move_body and on_delete_body act + // on. m_parts->on_action = [this, apply_body_row](int row, int id) { apply_body_row(row); switch (id) { + case RowZoom: zoom_to_body(row); break; case RowMove: on_move_body(); break; case RowVisibility: on_toggle_visibility(); break; case RowDelete: on_delete_body(); break; @@ -7561,7 +7572,9 @@ void DesignPanel::refresh_tree() row.colour = !f.enabled ? dp_item_dim() : mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110) : dp_item_text(); + // A row with nothing to frame keeps a blank cell, so the icons stay in their columns. row.actions = { + { RowZoom, can_zoom_to_feature(int(fi)) ? "design_zoom" : "", _L("Zoom to selection") }, { RowEdit, "design_edit", _L("Edit") }, eye_action(f.enabled), { RowDelete, "design_delete", _L("Delete") }, @@ -7605,6 +7618,7 @@ void DesignPanel::refresh_parts() // Hidden bodies are greyed and their eye is closed, so the state reads at a glance. row.colour = vis ? dp_item_text() : dp_item_dim(); row.actions = { + { RowZoom, "design_zoom", _L("Zoom to selection") }, { RowMove, "design_move", _L("Move") }, eye_action(vis), { RowDelete, "design_delete", _L("Delete") }, @@ -7767,23 +7781,108 @@ void DesignPanel::update_feature_highlight() const int sel = tree_selection(); // Hidden while a feature card is open: the card's ghost and picks are what the view is about. const bool wanted = sel >= 0 && sel < int(m_doc.features.size()) && m_active == Tool::None; - if (wanted && (sel != m_hl_feature || m_hl_generation != m_doc.topo_generation)) { + // Re-sending the same faces is cheap: the viewport reuses what it has. + m_viewport->set_highlight_faces(wanted ? feature_faces(sel) : std::vector>{}); +} + +const std::vector>& DesignPanel::feature_faces(int f) +{ + if (f < 0 || f >= int(m_doc.features.size())) { + static const std::vector> none; + return none; + } + if (f != m_hl_feature || m_hl_generation != m_doc.topo_generation) { std::vector> faces; - const CadFeature& f = m_doc.features[sel]; - if (f.enabled && CadDocument::produces_body(f.type)) // the rest make no faces - run_off_ui_thread(this, _L("Finding the feature's faces…"), [this, sel, &faces] { + const CadFeature& feat = m_doc.features[f]; + if (feat.enabled && CadDocument::produces_body(feat.type)) // the rest make no faces + run_off_ui_thread(this, _L("Finding the feature's faces…"), [this, f, &faces] { try { - faces = m_doc.faces_made_by(sel); + faces = m_doc.faces_made_by(f); } catch (...) { faces.clear(); // a highlight is not worth an escaped exception } }); m_hl_faces = std::move(faces); - m_hl_feature = sel; + m_hl_feature = f; m_hl_generation = m_doc.topo_generation; } - // Re-sending the same faces is cheap: the viewport reuses what it has. - m_viewport->set_highlight_faces(wanted ? m_hl_faces : std::vector>{}); + return m_hl_faces; +} + +bool DesignPanel::can_zoom_to_feature(int f) const +{ + if (f < 0 || f >= int(m_doc.features.size())) + return false; + const CadFeature& feat = m_doc.features[f]; + return feat.type == CadFeatureType::Sketch || (feat.enabled && CadDocument::produces_body(feat.type)); +} + +void DesignPanel::zoom_to_feature(int f) +{ + // A rebuild's busy loop runs queued clicks while its worker owns the document. + if (s_doc_worker_busy.load() > 0) { + set_status(StatusKind::Info, _L("The model is being rebuilt — zoom again when it finishes")); + return; + } + if (m_viewport == nullptr || !can_zoom_to_feature(f)) + return; + const CadFeature& feat = m_doc.features[f]; + const wxString name = wxString::FromUTF8(feat.name); // before finding faces runs the event loop + BoundingBoxf3 box; + if (feat.type == CadFeatureType::Sketch) { + // Whichever geometry the sketch keeps, drawn or not: a sketch an Extrude consumed is + // still where the Extrude starts. + const auto add = [&box, &feat](const Vec2d& p) { box.merge(feat.plane.to_world(p)); }; + if (!feat.entities.empty()) { + box = DesignSketchTool::sketch_box(feat.entities, feat.plane); + } else if (!feat.imported_regions.empty()) { + for (const auto& region : transform_regions(feat.imported_regions, feat.import_offset, + feat.import_scale_x, feat.import_scale_y)) + for (const auto& contour : region) + for (const Vec2d& p : contour) + add(p); + } else if (!feat.profile.points.empty()) { + for (const Vec2d& p : feat.profile.points) + add(p); + } else { + // The legacy shape, centred on the plane origin (CadDocument::build_sketch_wire). + const Vec2d h = feat.shape == SketchShape::Circle ? Vec2d(feat.radius, feat.radius) + : Vec2d(feat.width, feat.height) * 0.5; + for (const Vec2d& p : { Vec2d(-h.x(), -h.y()), Vec2d(h.x(), -h.y()), Vec2d(h.x(), h.y()), Vec2d(-h.x(), h.y()) }) + add(p); + } + } else { + box = m_viewport->faces_box(feature_faces(f)); + } + if (!m_viewport->zoom_to_box(box)) + set_status(StatusKind::Info, wxString::Format(_L("%s has nothing on show to zoom to"), name)); +} + +void DesignPanel::zoom_to_body(int b) +{ + if (m_viewport == nullptr) + return; + // From the body's display mesh, Move applied, rather than the selection: the viewport selects + // no hidden body (DesignSketchTool::select_body). An empty mesh's box still reads defined. + BoundingBoxf3 box; + if (b >= 0 && b < int(m_disp_body_meshes.size()) && !m_disp_body_meshes[b].empty()) + box = m_disp_body_meshes[b].bounding_box(); + if (!m_viewport->zoom_to_box(box)) + set_status(StatusKind::Error, _L("That body has no display mesh yet — recompute first")); +} + +void DesignPanel::zoom_to_selection() +{ + if (m_viewport == nullptr) + return; + // A body, as its row frames it; the kind has checked the index. + const OfferSel kind = OfferSel(offer_selection_kind()); + if (kind == OfferSel::BodySolid || kind == OfferSel::BodySheet) { + zoom_to_body(m_sel_solid_body); + return; + } + if (!m_viewport->zoom_to_box(m_viewport->selection_box())) + set_status(StatusKind::Info, _L("Select something to zoom to")); } // Boolean (combine bodies) — one gate for every door onto the tool. A body-body operation diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 3ae91acb25..823282bb57 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -935,12 +935,14 @@ private: // Feature tree: the Origin and Bed rows, a fixed block of view switches that never scrolls // and selects nothing, then, in a frame of its own below them, one row per feature, in feature - // order, with a per-type icon and the row's own Edit / Show-hide / Delete icons. Callers use - // row indices via tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows. + // order, with a per-type icon and the row's own Zoom to selection / Edit / Show-hide / Delete + // icons. Callers use row indices via tree_selection()/set_tree_selection(); refresh_tree() + // rebuilds the rows. DesignRowList* m_pinned{nullptr}; DesignRowList* m_tree{nullptr}; - // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected. - // Finding them replays the history, so they are kept per row and topology generation. + // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected, and + // what Zoom to selection frames for a row. Finding them replays the history, so they are kept + // per row and topology generation (feature_faces). // request_feature_highlight() refreshes them after the current event; every change of row, // card or topology calls it. int m_hl_feature{-1}; // the row m_hl_faces were found for... @@ -949,6 +951,8 @@ private: bool m_hl_pending{false}; void request_feature_highlight(); void update_feature_highlight(); + // The faces feature `f` made, found again when the cached ones are another row's or topology's. + const std::vector>& feature_faces(int f); bool deselect_rows(); // Esc / a click on nothing: drop the tree and Bodies rows bool drop_plane_pick(); // ...and a picked reference plane; true if one was picked // Bodies list under the feature tree: one row per body (parallel to m_doc.bodies). Selecting @@ -991,6 +995,13 @@ private: void end_body_move(bool keep); // leave it: keep the dragged pose, or put the body back void arm_transform_gizmo(); // arm the move gizmo on the Transform card's body (add mode only) void on_set_body_color(); // Color tool: pick a per-body display colour override + // Zoom to selection: frame one thing along the current view, as the canvas's Fit button frames + // a selection. A body is framed whole, hidden or not; a feature by its sketch or the faces it + // made; anything else the offer is opened on, by what the Fit button frames for it. + void zoom_to_body(int b); + void zoom_to_feature(int f); + void zoom_to_selection(); + bool can_zoom_to_feature(int f) const; // a sketch, or a shown feature that makes faces void on_boolean_tool(); // Boolean (combine bodies): needs two solids, then opens the tool int tree_selection() const; // selected feature row, or wxNOT_FOUND int tree_body_selection() const; // selected Parts-list body index, or -1 diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index e2d3d04627..98d72bf1b7 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -3288,7 +3288,7 @@ std::vector DesignSketchTool::make_bspline(const std::vector DesignSketchTool::entity_polyline(const SketchEntity& e, bool& closed) const +std::vector DesignSketchTool::entity_polyline(const SketchEntity& e, bool& closed) { closed = false; switch (e.type) { @@ -3743,30 +3743,37 @@ std::vector> DesignSketchTool::selected_faces() const return out; } -BoundingBoxf3 DesignSketchTool::fit_box(const GLVolumeCollection& volumes) const +BoundingBoxf3 DesignSketchTool::faces_box(std::vector> faces) const +{ + // From the pick mesh: it is in world coordinates already, moved bodies and all. + BoundingBoxf3 box; + if (faces.empty() || m_solid_mesh == nullptr || m_solid_tri_face == nullptr || m_solid_tri_body == nullptr) + return box; + std::sort(faces.begin(), faces.end()); + const indexed_triangle_set& its = m_solid_mesh->its; + const size_t n = std::min({ its.indices.size(), m_solid_tri_face->size(), m_solid_tri_body->size() }); + for (size_t t = 0; t < n; ++t) + if (std::binary_search(faces.begin(), faces.end(), std::make_pair((*m_solid_tri_body)[t], (*m_solid_tri_face)[t]))) + for (int i = 0; i < 3; ++i) + box.merge(its.vertices[its.indices[t][i]].cast()); + return box; +} + +BoundingBoxf3 DesignSketchTool::sketch_box(const std::vector& entities, const SketchPlane& plane) { BoundingBoxf3 box; - const auto add_entity = [&box, this](const SketchEntity& e, const SketchPlane& plane) { + for (const SketchEntity& e : entities) { bool closed = false; for (const Vec2d& p : entity_polyline(e, closed)) box.merge(plane.to_world(p)); - }; - const auto add_entities = [&add_entity](const std::vector& ents, const SketchPlane& plane) { - for (const SketchEntity& e : ents) - add_entity(e, plane); - }; - - // The selected faces, a whole body's included, from the pick mesh: it is in world - // coordinates already, moved bodies and all. - const std::vector> faces = selected_faces(); - if (!faces.empty() && m_solid_mesh != nullptr && m_solid_tri_face != nullptr && m_solid_tri_body != nullptr) { - const indexed_triangle_set& its = m_solid_mesh->its; - const size_t n = std::min({ its.indices.size(), m_solid_tri_face->size(), m_solid_tri_body->size() }); - for (size_t t = 0; t < n; ++t) - if (std::binary_search(faces.begin(), faces.end(), std::make_pair((*m_solid_tri_body)[t], (*m_solid_tri_face)[t]))) - for (int i = 0; i < 3; ++i) - box.merge(its.vertices[its.indices[t][i]].cast()); } + return box; +} + +BoundingBoxf3 DesignSketchTool::selection_box() const +{ + // The selected faces, a whole body's included. + BoundingBoxf3 box = faces_box(selected_faces()); if (m_solid_sel == SolidSel::Edge) { for (const Vec3d& p : m_sel_edge_pts) box.merge(p); @@ -3779,11 +3786,11 @@ BoundingBoxf3 DesignSketchTool::fit_box(const GLVolumeCollection& volumes) const if (m_display_pick >= 0) for (const DisplaySketch& d : m_display_sketches) if (d.feature == m_display_pick) - add_entities(m_display_pick_region >= 0 ? selected_loop_entities() : d.entities, d.plane); + box.merge(sketch_box(m_display_pick_region >= 0 ? selected_loop_entities() : d.entities, d.plane)); if (m_active) { for (int i : m_selection) if (i >= 0 && i < int(m_entities.size())) - add_entity(m_entities[i], m_plane); + box.merge(sketch_box({ m_entities[i] }, m_plane)); for (const auto& [i, role] : m_point_sel) if (i >= 0 && i < int(m_entities.size())) { const SketchEntity& e = m_entities[i]; @@ -3791,27 +3798,36 @@ BoundingBoxf3 DesignSketchTool::fit_box(const GLVolumeCollection& volumes) const role == SketchPointRole::P1 ? e.p1 : e.p0)); } } + return box; +} +BoundingBoxf3 DesignSketchTool::fit_box(const GLVolumeCollection& volumes) const +{ + BoundingBoxf3 box = selection_box(); // Nothing selected: everything on show. A hidden body's volume is inactive. if (!box.defined) { for (const GLVolume* v : volumes.volumes) if (v->is_active) box.merge(v->transformed_bounding_box()); for (const DisplaySketch& d : m_display_sketches) - add_entities(d.entities, d.plane); + box.merge(sketch_box(d.entities, d.plane)); if (m_active) - add_entities(m_entities, m_plane); + box.merge(sketch_box(m_entities, m_plane)); } + pad_box(box); + return box; +} +void DesignSketchTool::pad_box(BoundingBoxf3& box) +{ // A vertex has no size, nor has a sketch along its normal, and the camera cannot frame an // extent it does not see. Every axis gets at least a small neighbourhood. - if (box.defined) { - constexpr double min_extent = 10.; // mm - const Vec3d grow = (Vec3d::Constant(min_extent) - box.size()).cwiseMax(0.) * 0.5; - box.min -= grow; - box.max += grow; - } - return box; + if (!box.defined) + return; + constexpr double min_extent = 10.; // mm + const Vec3d grow = (Vec3d::Constant(min_extent) - box.size()).cwiseMax(0.) * 0.5; + box.min -= grow; + box.max += grow; } const DesignSketchTool::FaceHighlight& DesignSketchTool::cached_face_highlight(FaceHighlight& cache, int body, diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index ed8f8330d5..70d76cb9eb 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -264,6 +264,15 @@ public: // on show: the visible bodies, the preview of the feature being edited and every sketch. // Undefined when the tab shows none of it. `volumes` are the canvas's: bodies and preview. BoundingBoxf3 fit_box(const GLVolumeCollection& volumes) const; + // The selection fit_box frames, with no fallback: undefined when nothing is selected. + BoundingBoxf3 selection_box() const; + // World box of (body, face id) faces on the pick mesh, hidden bodies included. + BoundingBoxf3 faces_box(std::vector> faces) const; + // World box of sketch entities drawn on `plane`. + static BoundingBoxf3 sketch_box(const std::vector& entities, const SketchPlane& plane); + // Grow a box the camera is to frame to at least a small extent on every axis: a vertex has no + // size, nor has a sketch along its normal. + static void pad_box(BoundingBoxf3& box); // Move-body gizmo (M5): translate a whole body with three world-axis drag arrows // (X red / Y green / Z blue) anchored at the body centroid. Display-only — the host @@ -1022,7 +1031,7 @@ private: bool op_ready() const; // required entities picked -> arrow/ghost live // Sample an entity into a 2D polyline for the overlay renderer. - std::vector entity_polyline(const SketchEntity& e, bool& closed) const; + static std::vector entity_polyline(const SketchEntity& e, bool& closed); // Closed regions formed by the current (non-construction) entities: each a CCW- // ordered boundary polygon on the plane. A circle is its own region; line/arc diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 6c0c58c109..faf826ed7c 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -1034,6 +1034,8 @@ public: #ifdef SLIC3R_CAD void set_design_sketch_tool(DesignSketchTool* tool) { m_design_sketch_tool = tool; } DesignSketchTool* get_design_sketch_tool() const { return m_design_sketch_tool; } + // The Design tab frames what it selects itself (DesignCanvas::zoom_to_box), as the Fit button does. + void zoom_to_box(const BoundingBoxf3& box) { _zoom_to_box(box); } #endif void enable_dynamic_background(bool enable) { m_dynamic_background_enabled = enable; } void enable_labels(bool enable) { m_labels.enable(enable); } diff --git a/tests/slic3rutils/test_design_sketch_tool.cpp b/tests/slic3rutils/test_design_sketch_tool.cpp index 4e8a348645..ee84c01d35 100644 --- a/tests/slic3rutils/test_design_sketch_tool.cpp +++ b/tests/slic3rutils/test_design_sketch_tool.cpp @@ -30,6 +30,8 @@ #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/Line.hpp" #include "libslic3r/Point.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/CAD/CadDocument.hpp" #include "slic3r/GUI/3DScene.hpp" #include "slic3r/GUI/CAD/DesignSketchTool.hpp" @@ -169,6 +171,9 @@ TEST_CASE("Fit frames the picked sketch region, not the other sketches", "[Desig CHECK_THAT(box.max.x(), WithinAbs(60., 0.5)); CHECK_THAT(box.min.y(), WithinAbs(10., 0.5)); CHECK_THAT(box.max.y(), WithinAbs(30., 0.5)); + // Zoom to selection frames the same region, the padding left to the canvas. + CHECK_THAT(tool.selection_box().min.x(), WithinAbs(40., 0.5)); + CHECK_THAT(tool.selection_box().size().z(), WithinAbs(0., 1e-9)); } TEST_CASE("Fit frames every sketch when nothing is picked", "[DesignSketchTool]") @@ -181,6 +186,8 @@ TEST_CASE("Fit frames every sketch when nothing is picked", "[DesignSketchTool]" REQUIRE(box.defined); CHECK_THAT(box.min.x(), WithinAbs(-65., 0.5)); CHECK_THAT(box.max.x(), WithinAbs(60., 0.5)); + // Zoom to selection has no such fallback. + CHECK_FALSE(tool.selection_box().defined); } TEST_CASE("Fit frames the sketch being drawn along with the committed ones", "[DesignSketchTool]") @@ -220,6 +227,72 @@ TEST_CASE("Fit frames nothing when the Design tab shows nothing", "[DesignSketch CHECK_FALSE(tool.fit_box(no_bodies).defined); } +TEST_CASE("A sketch's box lies on its own plane", "[DesignSketchTool]") +{ + // A 5 mm circle at (10, 20) on XZ, whose y axis is world Z. + const BoundingBoxf3 box = DesignSketchTool::sketch_box({ circle({ 10., 20. }, 5.) }, SketchPlane::XZ()); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(5., 0.05)); + CHECK_THAT(box.max.x(), WithinAbs(15., 0.05)); + CHECK_THAT(box.min.z(), WithinAbs(15., 0.05)); + CHECK_THAT(box.max.z(), WithinAbs(25., 0.05)); + CHECK_THAT(box.size().y(), WithinAbs(0., 1e-9)); + CHECK_FALSE(DesignSketchTool::sketch_box({}, SketchPlane::XY()).defined); + + SketchEntity point; + point.type = SketchEntity::Type::Point; + point.p0 = { 3., 4. }; + const BoundingBoxf3 dot = DesignSketchTool::sketch_box({ point }, SketchPlane::XY()); + REQUIRE(dot.defined); + CHECK_THAT(dot.min.x(), WithinAbs(3., 1e-9)); + CHECK_THAT(dot.min.y(), WithinAbs(4., 1e-9)); +} + +TEST_CASE("A face's box comes from the pick mesh, on a hidden body too", "[DesignSketchTool]") +{ + // One triangle per body: body 0 near the origin, body 1, hidden, 100 mm along X. + indexed_triangle_set its; + its.vertices = { { 0.f, 0.f, 0.f }, { 10.f, 0.f, 0.f }, { 0.f, 10.f, 5.f }, + { 100.f, 0.f, 0.f }, { 110.f, 0.f, 0.f }, { 100.f, 10.f, 5.f } }; + its.indices = { { 0, 1, 2 }, { 3, 4, 5 } }; + const TriangleMesh mesh(its); + const std::vector bodies(2); + const std::vector tri_face{ 0, 0 }; + const std::vector tri_body{ 0, 1 }; + const std::vector visible{ true, false }; + DesignSketchTool tool; + tool.set_solid_pick(&bodies, &mesh, &tri_face, &tri_body, &visible); + + const BoundingBoxf3 box = tool.faces_box({ { 1, 0 } }); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(100., 1e-6)); + CHECK_THAT(box.max.x(), WithinAbs(110., 1e-6)); + CHECK_THAT(box.max.z(), WithinAbs(5., 1e-6)); + CHECK_FALSE(tool.faces_box({}).defined); + + // CadDocument::faces_made_by may list a body's faces after a later body's. + const BoundingBoxf3 both = tool.faces_box({ { 1, 0 }, { 0, 0 } }); + REQUIRE(both.defined); + CHECK_THAT(both.min.x(), WithinAbs(0., 1e-6)); + CHECK_THAT(both.max.x(), WithinAbs(110., 1e-6)); +} + +TEST_CASE("Padding a box to frame gives every axis a small extent and leaves a long one alone", "[DesignSketchTool]") +{ + BoundingBoxf3 box; + box.merge(Vec3d(0., 0., 3.)); + box.merge(Vec3d(100., 4., 3.)); + DesignSketchTool::pad_box(box); + CHECK_THAT(box.size().x(), WithinAbs(100., 1e-9)); + CHECK_THAT(box.size().y(), WithinAbs(10., 1e-9)); + CHECK_THAT(box.size().z(), WithinAbs(10., 1e-9)); + CHECK_THAT(box.center().z(), WithinAbs(3., 1e-9)); + + BoundingBoxf3 none; + DesignSketchTool::pad_box(none); + CHECK_FALSE(none.defined); +} + TEST_CASE("Crossing base planes are drawn back to front from any viewpoint", "[DesignSketchTool]") { const View& view = kViews[GENERATE(range(0, int(std::size(kViews))))]; From 505a712f86e3e7d70c61c539dd3f3742d541af33 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 7 Oct 2026 00:30:25 +0800 Subject: [PATCH 27/30] Replace the Design tab's FEATURES label with a Sketch button --- resources/images/design_new_sketch.svg | 1 + src/slic3r/GUI/CAD/DesignPanel.cpp | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) create mode 100644 resources/images/design_new_sketch.svg diff --git a/resources/images/design_new_sketch.svg b/resources/images/design_new_sketch.svg new file mode 100644 index 0000000000..a645cf1dff --- /dev/null +++ b/resources/images/design_new_sketch.svg @@ -0,0 +1 @@ + diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 6b28c1b356..71f6bd487c 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -648,13 +648,12 @@ DesignPanel::DesignPanel(wxWindow* parent) // delete 42 verbs from the offer while they still rendered. 7ih records the cleanup // that lets the construction go away too. // What stays: the view controls, which are chrome_only in the atlas and so have no offer row - // to fall back on. + // to fall back on, and Sketch, the group's lead. All four are built with the doc buttons. static const std::set kBarKeep = { "place", "section", "flip" }; auto fadd = [&tb_slot](const char* id, wxWindow* w) { if (kBarKeep.count(id) == 0) { w->Hide(); return; } tb_slot[id].push_back(w); }; - m_tb_feature->Add(caption(_L("FEATURES")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); { // Onshape-style FEATURE flyouts: same themed-DropDown pattern as the sketch toolbar // (tinted glyphs, Body_14 measure, content-width popup) but each entry runs an @@ -732,13 +731,10 @@ DesignPanel::DesignPanel(wxWindow* parent) fadd(id, chev); }; - auto* b_sketch = icon_btn("design_sketch", _L("Sketch")); // Sketch opens a sketch on the face or plane already picked, or asks for one first - // (start_sketch): sketch mode is never entered without a plane under it. - std::function act_sketch = [this] { start_sketch(); }; - b_sketch->Bind(wxEVT_BUTTON, [act_sketch](wxCommandEvent&) { act_sketch(); }); - m_keys_feature[SHIFT('S')] = act_sketch; - fadd("sketch", b_sketch); + // (start_sketch): sketch mode is never entered without a plane under it. Its button is + // built with the doc buttons below. + m_keys_feature[SHIFT('S')] = [this] { start_sketch(); }; // Add material: every feature that grows new solid material — from a profile // (extrude/revolve/sweep/loft), from a face (thicken) or from a line (rib). feat_dropdown("material", "design_extrude", _L("Add material (extrude / revolve / sweep / loft / thicken / rib)"), { @@ -1638,6 +1634,12 @@ DesignPanel::DesignPanel(wxWindow* parent) b_export->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); }); add_doc(b_export); + // Sketch leads the feature group. Momentary like the imports: start_sketch() may only ask + // for a plane, or refuse, and a teal active-tool state would outlive either. + auto* b_sketch = doc_btn("design_new_sketch", _L("Create a new sketch")); + b_sketch->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { start_sketch(); }); + tb_slot["sketch"].push_back(b_sketch); + // These act on bodies / the view, so they ride in the feature group, in the slots // the user assigned them (9, 11bis, 16). auto* b_place = doc_btn("toolbar_flatten", _L("Place on Face (F) — lay the picked face on the bed"), From 7708ca568d091303e115752530aae2d4c4d7a8d7 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 7 Oct 2026 00:30:25 +0800 Subject: [PATCH 28/30] Keep the Design tab's camera still when the first body appears --- src/slic3r/GUI/CAD/DesignCanvas.cpp | 30 ++++++++++++----------------- src/slic3r/GUI/CAD/DesignCanvas.hpp | 3 +-- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index b765b58fa2..819e0be85d 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -349,7 +349,7 @@ void DesignCanvas::repaint_now() m_canvas_widget->Update(); // service the pending paint immediately (a modal popup owns the loop) } -void DesignCanvas::reload(bool keep_view) +void DesignCanvas::reload() { m_canvas->reset_volumes(); @@ -393,14 +393,8 @@ void DesignCanvas::reload(bool keep_view) } } - if (!keep_view) { - if (m_first_frame && !m_model.objects.empty()) { - m_canvas->select_view("iso"); - m_canvas->zoom_to_volumes(); - m_first_frame = false; - } - } - + // The camera stays where the user left it, even for the first body: Home and a double-click + // fit on demand. m_canvas->set_as_dirty(); if (m_canvas_widget) m_canvas_widget->Refresh(); @@ -417,7 +411,7 @@ void DesignCanvas::set_bodies(const std::vector* body_meshes, m_sketch_tool.refresh_body_edges(); // of the bodies set_solid_pick() pointed the tool at m_lit_faces = m_sketch_tool.selected_faces(); rebuild_bodies(); - reload(!m_first_frame); + reload(); } void DesignCanvas::clear_mesh() @@ -428,7 +422,7 @@ void DesignCanvas::clear_mesh() m_sketch_tool.set_solid_pick(nullptr, nullptr, nullptr, nullptr); if (!m_model.objects.empty()) { m_model.delete_object((size_t)0); - reload(true); + reload(); } } @@ -442,14 +436,14 @@ void DesignCanvas::set_preview_mesh(const TriangleMesh& mesh) obj->add_volume(mesh); obj->add_instance(); - reload(true); + reload(); } void DesignCanvas::clear_preview() { if (m_model.objects.size() > 1) { m_model.delete_object((size_t)1); - reload(true); + reload(); } } @@ -1263,7 +1257,7 @@ void DesignCanvas::sync_selected_faces() if (m_body_meshes == nullptr || m_model.objects.empty()) return; rebuild_bodies(); - reload(true); + reload(); }); } @@ -1315,7 +1309,7 @@ void DesignCanvas::set_operand_bodies(int target_body, int tool_body) if (m_hl_body_target == target_body && m_hl_body_tool == tool_body) return; m_hl_body_target = target_body; m_hl_body_tool = tool_body; - reload(true); // recolours the body volumes + reload(); // recolours the body volumes } void DesignCanvas::set_highlight_sketches(std::vector> hl) @@ -1328,7 +1322,7 @@ void DesignCanvas::set_body_translucent(bool on) { if (m_body_translucent == on) return; m_body_translucent = on; - reload(true); // re-applies object-0 alpha so the solid fades for the fillet preview + reload(); // re-applies object-0 alpha so the solid fades for the fillet preview } void DesignCanvas::set_xray_focus(int body) @@ -1336,7 +1330,7 @@ void DesignCanvas::set_xray_focus(int body) if (m_xray_focus == body) return; m_xray_focus = body; m_sketch_tool.set_pick_only_body(body); - reload(true); // re-applies per-body alpha so the non-focused bodies fade + reload(); // re-applies per-body alpha so the non-focused bodies fade } void DesignCanvas::set_body_hidden(bool on) @@ -1344,7 +1338,7 @@ void DesignCanvas::set_body_hidden(bool on) if (m_body_hidden == on) return; m_body_hidden = on; m_sketch_tool.set_body_edges_hidden(on); - reload(true); // hides/show base bodies + flips the ghost opaque/faint for preview-only mode + reload(); // hides/show base bodies + flips the ghost opaque/faint for preview-only mode } bool DesignCanvas::delete_selected_sketch_entities() diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 03f3b4ed54..a7413dd370 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -393,7 +393,7 @@ public: void repaint_now(); private: - void reload(bool keep_view); + void reload(); void swap_camera(); // enter_viewport / leave_viewport, in the one direction they share // Selected faces are filled by the canvas: each body's selected faces become a volume of their @@ -428,7 +428,6 @@ private: Camera m_parked_camera; bool m_camera_swapped{false}; // guards a leave without an enter, and the reverse Model m_model; - bool m_first_frame{true}; int m_hl_body_target{-1}; int m_hl_body_tool{-1}; bool m_body_translucent{false};// fillet/chamfer preview → render the body see-through From c1baacf8a084f1b7754175462b8ab892e9a4fb01 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Tue, 6 Oct 2026 14:09:36 -0300 Subject: [PATCH 29/30] fix: thin-walled holes classic walls flip the direction (#16051) Co-authored-by: Ioannis Giannakas <59056762+igiannakas@users.noreply.github.com> --- src/libslic3r/PerimeterGenerator.cpp | 33 ++++--- tests/fff_print/test_perimeters.cpp | 138 +++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index e2b5090213..41db0ed9db 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -61,6 +61,8 @@ public: bool is_smaller_width_perimeter; // Depth in the hierarchy. External perimeter has depth = 0. An external perimeter could be both a contour and a hole. unsigned short depth; + // ORCA: an external hole perimeter touching the next outer wall all around, with nothing between them. + bool is_thin_wall_hole = false; // Children contour, may be both CCW and CW oriented (outer contours or holes). std::vector children; @@ -118,7 +120,7 @@ static bool detect_steep_overhang(const PrintRegionConfig *config, } static ExtrusionEntityCollection traverse_loops(const PerimeterGenerator &perimeter_generator, const PerimeterGeneratorLoops &loops, ThickPolylines &thin_walls, - bool &steep_overhang_contour, bool &steep_overhang_hole, bool reverse_thin_wall_hole) + bool &steep_overhang_contour, bool &steep_overhang_hole) { // loops is an arrayref of ::Loop objects // turn each one into an ExtrusionLoop object @@ -285,24 +287,17 @@ static ExtrusionEntityCollection traverse_loops(const PerimeterGenerator &perime } else { const PerimeterGeneratorLoop &loop = loops[idx.first]; assert(thin_walls.empty()); - const bool reverse_children_thin_wall_hole = loops.size() == 1 && loop.is_contour && loop.children.size() == 1 && - (!loop.children.front().is_contour) && loop.children.front().children.empty(); - ExtrusionEntityCollection children = traverse_loops(perimeter_generator, loop.children, thin_walls, steep_overhang_contour, - steep_overhang_hole, reverse_children_thin_wall_hole); + ExtrusionEntityCollection children = traverse_loops(perimeter_generator, loop.children, thin_walls, steep_overhang_contour, steep_overhang_hole); out.entities.reserve(out.entities.size() + children.entities.size() + 1); ExtrusionLoop *eloop = static_cast(coll.entities[idx.first]); coll.entities[idx.first] = nullptr; - if ((perimeter_generator.config->wall_direction == WallDirection::CounterClockwise) == (loop.is_contour || reverse_thin_wall_hole)) + // Orca: holes run against the contour, except thin wall holes, which run with it. + if ((perimeter_generator.config->wall_direction == WallDirection::CounterClockwise) == (loop.is_contour || loop.is_thin_wall_hole)) eloop->make_counter_clockwise(); else eloop->make_clockwise(); - // Orca: Reverse print order for thin wall holes. - if (reverse_thin_wall_hole) { - std::reverse(out.entities.begin(), out.entities.end()); - } - eloop->inset_idx = loop.depth; if (loop.is_contour) { out.append(std::move(children.entities)); @@ -1499,6 +1494,8 @@ void PerimeterGenerator::process_classic() coord_t min_spacing = coord_t(perimeter_spacing * (1 - INSET_OVERLAP_TOLERANCE)); coord_t ext_min_spacing = coord_t(ext_perimeter_spacing * (1 - INSET_OVERLAP_TOLERANCE)); bool has_gap_fill = this->config->gap_infill_speed.get_at(get_extruder_index(*print_config, this->config->outer_wall_filament_id - 1)) > 0; + // ORCA: Use the smaller width as the lower bound to avoid overestimating safe overlap + const double gap_fill_min_width = 0.2 * std::min(perimeter_width, ext_perimeter_width) * (1 - INSET_OVERLAP_TOLERANCE); // BBS: this flow is for smaller external perimeter for small area coord_t ext_min_spacing_smaller = coord_t(ext_perimeter_spacing * (1 - SMALLER_EXT_INSET_OVERLAP_TOLERANCE)); @@ -1693,6 +1690,15 @@ void PerimeterGenerator::process_classic() last = std::move(offsets); + // ORCA: a thin wall hole has no room for gap fill or an inner wall anywhere beside its outer wall. + if (i == 0 && ! holes[0].empty()) { + const float room = float(0.5 * (ext_perimeter_spacing2 + gap_fill_min_width)); + const Polygons reach = to_polygons(offset2_ex(last, -room, room + float(SCALED_EPSILON))); + for (PerimeterGeneratorLoop &hole : holes[0]) + hole.is_thin_wall_hole = intersection_pl(Polylines{ hole.polygon.split_at_first_point() }, + ClipperUtils::clip_clipper_polygons_with_subject_bbox(reach, get_extents(hole.polygon).inflated(SCALED_EPSILON))).empty(); + } + //BBS: refer to superslicer //store surface for top infill if only_one_wall_top if (i == 0 && i!=loop_number && only_one_wall_top && !surface.is_bridge() && this->upper_slices != NULL) { @@ -1819,7 +1825,7 @@ void PerimeterGenerator::process_classic() steep_overhang_contour = true; steep_overhang_hole = true; } - ExtrusionEntityCollection entities = traverse_loops(*this, contours.front(), thin_walls, steep_overhang_contour, steep_overhang_hole, false); + ExtrusionEntityCollection entities = traverse_loops(*this, contours.front(), thin_walls, steep_overhang_contour, steep_overhang_hole); // All walls are counter-clockwise initially, so we don't need to reorient it if that's what we want if (config->overhang_reverse) { reorient_perimeters(entities, steep_overhang_contour, steep_overhang_hole, @@ -1946,8 +1952,7 @@ void PerimeterGenerator::process_classic() // fill gaps if (! gaps.empty()) { // collapse - // ORCA: Use the smaller width as the lower bound to avoid overestimating safe overlap - double min = 0.2 * std::min(perimeter_width, ext_perimeter_width) * (1 - INSET_OVERLAP_TOLERANCE); + double min = gap_fill_min_width; double max = 2. * perimeter_spacing; ExPolygons gaps_ex = diff_ex( //FIXME offset2 would be enough and cheaper. diff --git a/tests/fff_print/test_perimeters.cpp b/tests/fff_print/test_perimeters.cpp index 1cbd9e9351..070912c307 100644 --- a/tests/fff_print/test_perimeters.cpp +++ b/tests/fff_print/test_perimeters.cpp @@ -856,3 +856,141 @@ TEST_CASE("Fuzzy skin leaves the walls over an empty layer smooth", "[Perimeters CHECK(floating_first_layer >= 0.); CHECK(floating_first_layer < 0.001); } + +namespace { + +// A 20x20x5mm square tube whose walls are `wall` mm thick, except the far one at `far_wall` mm. +Print &square_tube(Print &print, Model &model, const DynamicPrintConfig &config, double wall, double far_wall) +{ + ModelObject *object = model.add_object(); + object->name = "square_tube.stl"; + object->add_volume(make_cube(20., 20., 5.), ModelVolumeType::MODEL_PART, false); + TriangleMesh cavity = make_cube(20. - 2. * wall, 20. - wall - far_wall, 5.); + cavity.translate(float(wall), float(wall), 0.f); + object->add_volume(std::move(cavity), ModelVolumeType::NEGATIVE_VOLUME, false); + object->add_instance(); + object->ensure_on_bed(); + + print.auto_assign_extruders(object); + print.apply(model, config); + print.validate(); + print.set_status_silent(); + print.process(); + return print; +} + +// Every setting the wall thicknesses below are measured against. With these widths the two outer walls of +// a 0.8mm wall touch, a 1mm wall leaves a gap between them for gap fill, and an inner wall needs about 1.5mm. +// Both one wall options are on, so the first and the last layer have a single wall whatever wall_loops asks. +DynamicPrintConfig hole_direction_config(int wall_loops, const char *wall_direction) +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "wall_generator", "classic" }, + { "wall_direction", wall_direction }, + { "wall_loops", wall_loops }, + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "outer_wall_line_width", 0.42 }, + { "inner_wall_line_width", 0.45 }, + { "detect_thin_wall", false }, + { "filter_out_gap_fill", 0 }, + { "top_shell_layers", 3 }, + { "bottom_shell_layers", 3 }, + { "only_one_wall_top", true }, + { "only_one_wall_first_layer", true }, + { "overhang_reverse", false }, + { "sparse_infill_density", "15%" }, + }); + return config; +} + +// The outer walls of a layer, contours and holes apart, and how many inner walls and gap fills it has. +struct WallDirections { + std::vector contours_ccw; + std::vector holes_ccw; + int inner_walls = 0; + size_t gap_fills = 0; +}; + +std::vector wall_directions(const Print &print) +{ + std::vector out; + for (const Layer *layer : print.objects().front()->layers()) { + WallDirections &walls = out.emplace_back(); + for (const LayerRegion *region : layer->regions()) { + walls.gap_fills += region->thin_fills.flatten().entities.size(); + for (const ExtrusionEntity *entity : region->perimeters.flatten().entities) { + if (! entity->is_loop()) + continue; + const ExtrusionLoop *loop = static_cast(entity); + if (loop->inset_idx > 0) + ++ walls.inner_walls; + else + (loop->loop_role() == elrHole ? walls.holes_ccw : walls.contours_ccw).push_back(loop->polygon().is_counter_clockwise()); + } + } + } + return out; +} + +} // namespace + +// Holes run against the wall direction, so the inside of a hole keeps its direction on the layers where the +// hole opens into the contour. That holds on every layer, the single wall ones included, as soon as anything +// fits beside the outer wall of the hole: infill, an inner wall, or only gap fill. The 1mm tube with a 3mm far +// side is the shape that used to flip, where the far side has an inner wall on most layers and gap fill runs +// around the rest. +TEST_CASE("Holes run against the wall direction", "[Perimeters]") +{ + const auto [wall, far_wall] = GENERATE(std::make_pair(7., 7.), std::make_pair(1., 1.), std::make_pair(1., 3.)); + const int wall_loops = GENERATE(1, 2); + const char *wall_direction = GENERATE("ccw", "cw"); + CAPTURE(wall, far_wall, wall_loops, wall_direction); + + Print print; + Model model; + square_tube(print, model, hole_direction_config(wall_loops, wall_direction), wall, far_wall); + const std::vector layers = wall_directions(print); + // 5mm at 0.2mm layers. + REQUIRE(layers.size() == 25); + // Without gap fill between the outer walls the thin tubes would test the case below instead. + if (wall < 2.) + REQUIRE(layers[layers.size() / 2].gap_fills > 0); + + const bool ccw = std::string(wall_direction) == "ccw"; + for (size_t i = 0; i < layers.size(); ++ i) { + CAPTURE(i); + REQUIRE(layers[i].contours_ccw.size() == 1); + REQUIRE(layers[i].holes_ccw.size() == 1); + CHECK(layers[i].contours_ccw.front() == ccw); + CHECK(layers[i].holes_ccw.front() == ! ccw); + } +} + +// A hole whose outer wall touches the contour's all around, with nothing between them, runs with the contour +// on every layer, so the two walls of a thin tube are laid side by side in the same direction. +TEST_CASE("A hole whose outer wall touches the contour's runs with it", "[Perimeters]") +{ + const int wall_loops = GENERATE(1, 2); + const char *wall_direction = GENERATE("ccw", "cw"); + CAPTURE(wall_loops, wall_direction); + + Print print; + Model model; + square_tube(print, model, hole_direction_config(wall_loops, wall_direction), 0.8, 0.8); + const std::vector layers = wall_directions(print); + REQUIRE(layers.size() == 25); + // Nothing fits between the two outer walls. + REQUIRE(layers[layers.size() / 2].gap_fills == 0); + REQUIRE(layers[layers.size() / 2].inner_walls == 0); + + const bool ccw = std::string(wall_direction) == "ccw"; + for (size_t i = 0; i < layers.size(); ++ i) { + CAPTURE(i); + REQUIRE(layers[i].contours_ccw.size() == 1); + REQUIRE(layers[i].holes_ccw.size() == 1); + CHECK(layers[i].contours_ccw.front() == ccw); + CHECK(layers[i].holes_ccw.front() == ccw); + } +} From f69eccd32af8d81ca39d241e45c0ea7b63a56a3c Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 7 Oct 2026 01:23:21 +0800 Subject: [PATCH 30/30] Open the Design tab on a front-right isometric view --- docs/HLSD/design-tab.md | 3 ++- src/slic3r/GUI/CAD/DesignCanvas.cpp | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 6476be10fb..9b8010ce2c 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -303,7 +303,8 @@ The tab is a page of Orca's main window and answers to the same settings as Prep not need it either, since the committed object is placed on an empty spot of the current plate. What the canvas does read from the plate is moved onto its bed: the exclude areas, the plate box the camera orbits about when nothing is picked (`GLCanvas3D::_current_plate_box`), - and the first view, which starts as a copy of Prepare's camera. + and the first view, which starts from Prepare's camera turned to the tab's iso view: the CAD + isometric from the front-right corner that Home returns to, not Prepare's front-left one. - **Viewport text.** The status line and the active tool's values are drawn by the canvas in its ImGui pass, so they go with the canvas: a top-level window over GL does not follow its frame and was left floating over other applications. diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 819e0be85d..0489df35c1 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -59,6 +59,16 @@ extern wxPopupWindow* wxCurrentPopupWindow; namespace Slic3r { namespace GUI { +// The Design tab's iso view is the CAD one: a true isometric from the front-right corner, which +// shows the navigator's Front, Right and Top faces. Camera's own "iso" looks from the front-left +// at 45 degrees and stays Prepare's and the thumbnails'. +static void select_iso_view(Camera& camera) +{ + const Vec3d target = camera.get_target(); + camera.look_at(target + camera.get_distance() * Vec3d(1., -1., 1.).normalized(), target, Vec3d::UnitZ()); + camera.auto_type(Camera::EType::Perspective); +} + DesignCanvas::DesignCanvas(wxWindow* parent) : wxPanel() { @@ -192,10 +202,12 @@ DesignCanvas::DesignCanvas(wxWindow* parent) // The view this canvas opens on. Built lazily, on the way into the Design tab, so this // is the view the user is looking at right now — moved off the current plate onto the - // Design bed, which stays at the printer bed's home whichever plate is current. + // Design bed, which stays at the printer bed's home whichever plate is current — turned to + // the iso view Home returns to, keeping its target and zoom. m_parked_camera = wxGetApp().plater()->get_camera(); if (PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate()) m_parked_camera.translate_world(-plate->get_origin()); + select_iso_view(m_parked_camera); // Before any of this class's own Binds below: wx calls dynamically bound handlers in // reverse order of binding, and GLCanvas3D swallows several events without skipping them — @@ -470,7 +482,10 @@ bool DesignCanvas::zoom_to_box(BoundingBoxf3 box) void DesignCanvas::set_view(const std::string& view_name) { if (m_canvas) { - m_canvas->select_view(view_name); + if (view_name == "iso") + select_iso_view(wxGetApp().plater()->get_camera()); // the Design camera while the tab is shown + else + m_canvas->select_view(view_name); m_canvas->zoom_to_volumes(); m_canvas->set_as_dirty(); if (m_canvas_widget)