diff --git a/docs/CAD/design_tab.md b/docs/CAD/design_tab.md index 1be0a2ea33..170c5ed0ce 100644 --- a/docs/CAD/design_tab.md +++ b/docs/CAD/design_tab.md @@ -47,8 +47,9 @@ tool"*. It is also where a refusal explains itself. ## The offer -Right-click on the geometry, released without moving the mouse (an 8 px budget — a -right-drag that orbits the camera does not open it). Left-click still only selects, so +Right-click on the geometry, released without moving the mouse (a 3 px budget — a +right-drag that orbits the camera does not open it). How long the button is held does not +matter. Left-click still only selects, so pointing at things stays quiet. The offer also opens by itself the moment you press Sketch on a face or plane, showing the diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 8db2afbbc2..a3842b2223 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -135,6 +135,8 @@ DesignCanvas::DesignCanvas(wxWindow* parent) [this, commit](double v) { m_sketch_tool.set_inline_busy(false); if (commit) commit(v); + // A refused value re-opens the same field (SketchInlineEditor::refuse). + if (m_inline_editor && m_inline_editor->is_open()) m_sketch_tool.set_inline_busy(true); request_repaint(); }, [this, cancel]() { @@ -941,11 +943,6 @@ void DesignCanvas::set_on_solid_selection_changed(std::function cb) -{ - m_sketch_tool.on_place_on_face = std::move(cb); -} - void DesignCanvas::select_body(int body) { m_sketch_tool.select_body(body); @@ -1058,9 +1055,9 @@ void DesignCanvas::set_on_sketch_exit_refused(std::function cb) m_sketch_tool.on_exit_refused = std::move(cb); } -void DesignCanvas::set_on_move_exit(std::function cb) +void DesignCanvas::set_on_sketch_notice(std::function cb) { - m_sketch_tool.on_move_exit = std::move(cb); + m_sketch_tool.on_notice = std::move(cb); } void DesignCanvas::set_on_context_menu(std::function cb) @@ -1074,25 +1071,26 @@ void DesignCanvas::set_on_context_menu(std::function cb) // through to the polyline-chain end and the move gizmo, which were there first. // Right-drag pans. Without remembering where the press landed, every pan ended by popping // the offer over wherever the camera stopped — the menu appearing as the reward for moving - // the view. The offer is the release of a STATIONARY right-click, at the same 8 px budget - // the left-click pick uses. + // the view. The offer is the release of a STATIONARY right-click (kCadRightClickDriftPx). m_canvas_widget->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { - m_ctx_press = e.GetPosition(); - m_ctx_press_ms = wxGetLocalTimeMillis().GetValue(); + m_ctx_press = e.GetPosition(); + m_ctx_travelled = false; e.Skip(); // the canvas still needs the press to seed the orbit }); + m_canvas_widget->Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { + if (e.RightIsDown()) { + const wxPoint d = e.GetPosition() - m_ctx_press; + if (std::max(std::abs(d.x), std::abs(d.y)) > kCadRightClickDriftPx) m_ctx_travelled = true; + } + e.Skip(); + }); m_canvas_widget->Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& e) { const wxPoint d = e.GetPosition() - m_ctx_press; - const long long dt = wxGetLocalTimeMillis().GetValue() - m_ctx_press_ms; // 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? Both budgets must hold: a press that travelled orbited, and a - // press that was HELD was aiming to orbit even if the hand never quite moved. Two - // independent budgets because the two failure modes are independent — the drift one - // alone still popped a menu at the end of a slow, careful orbit. - const bool is_click = std::max(std::abs(d.x), std::abs(d.y)) <= kCadRightClickDriftPx - && dt <= kCadRightClickMs; + // Click, or navigation? A press that travelled 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) { // 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 @@ -1110,11 +1108,6 @@ void DesignCanvas::set_on_context_menu(std::function cb) }); } -void DesignCanvas::set_on_undo_redo(std::function cb) -{ - m_sketch_tool.on_undo_redo = std::move(cb); -} - void DesignCanvas::set_display_sketches(std::vector ds) { m_sketch_tool.set_display_sketches(std::move(ds)); @@ -1352,10 +1345,12 @@ void DesignCanvas::set_body_hidden(bool on) reload(true); // hides/show base bodies + flips the ghost opaque/faint for preview-only mode } -void DesignCanvas::delete_selected_sketch_entities() +bool DesignCanvas::delete_selected_sketch_entities() { + if (m_sketch_tool.selection().empty()) return false; m_sketch_tool.delete_selected(); request_repaint(); + return true; } bool DesignCanvas::inline_busy() const @@ -1394,16 +1389,16 @@ bool DesignCanvas::live_sketch_has_work() const return m_sketch_tool.live_sketch_has_work(); } -bool DesignCanvas::undo_last_sketch_entity() +bool DesignCanvas::redo_last_sketch_entity() { - const bool did = m_sketch_tool.undo_last_entity(); + const bool did = m_sketch_tool.redo_last_entity(); if (did) request_repaint(); return did; } -bool DesignCanvas::delete_selected_or_last_sketch_entity() +bool DesignCanvas::undo_last_sketch_entity() { - const bool did = m_sketch_tool.delete_selected_or_last(); + const bool did = m_sketch_tool.undo_last_entity(); if (did) request_repaint(); return did; } @@ -1450,6 +1445,7 @@ void DesignCanvas::open_inline_value(double current, std::function [this, commit](double v) { m_sketch_tool.set_inline_busy(false); if (commit) commit(v); + if (m_inline_editor && m_inline_editor->is_open()) m_sketch_tool.set_inline_busy(true); request_repaint(); }, [this, cancel]() { @@ -1560,6 +1556,13 @@ bool DesignCanvas::sketch_abort_gesture() return true; } +bool DesignCanvas::sketch_confirm_pending() +{ + if (!m_sketch_tool.confirm_pending()) return false; + request_repaint(); + return true; +} + bool DesignCanvas::sketch_disarm_tool() { if (!m_sketch_tool.disarm_tool()) return false; @@ -1569,7 +1572,7 @@ bool DesignCanvas::sketch_disarm_tool() bool DesignCanvas::drawing_in_progress() const { - return m_sketch_tool.pending_points() > 0; + return m_sketch_tool.gesture_pending(); } bool DesignCanvas::has_any_selection() const diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index fab2e556e3..026d4a524d 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -122,7 +122,6 @@ public: const std::vector* visible = nullptr, const std::vector* xform = nullptr); void set_on_solid_selection_changed(std::function cb); - void set_on_place_on_face(std::function cb); // F key: Place on Face void select_body(int body); // Parts-list -> highlight a whole body by index // Effective display colour of a body: the per-body override (Color tool) when set, // otherwise the auto body-index palette. Single source of truth shared with reload(). @@ -213,7 +212,7 @@ public: void set_on_datum_base_picked(std::function cb); 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_undo_redo(std::function cb); // Ctrl+Z / Ctrl+Shift+Z + void set_on_sketch_notice(std::function cb); // tool refusals/side effects // Persistently draw committed sketches (un-consumed ones stay visible). void set_display_sketches(std::vector ds); void set_highlight_sketches(std::vector> hl); @@ -235,13 +234,12 @@ public: void set_body_translucent(bool on); // render the solid see-through (fillet/chamfer preview) void set_xray_focus(int body); // >=0: fade+lock out every other body (CoordSys picking) void set_body_hidden(bool on); // preview-only: hide base bodies, show only the result ghost - void set_on_move_exit(std::function cb); // right-click finished the move-body gizmo // Right-click (or its platform equivalent) on the viewport with no tool running: open the // object-driven offer there. Fires with SCREEN coordinates. Deliberately NOT fired while a // tool is live — right-click already ends a polyline chain and finishes the move gizmo, and // taking those over would break two working interactions in order to add a third. void set_on_context_menu(std::function cb); - void delete_selected_sketch_entities(); + bool delete_selected_sketch_entities(); // false when nothing was selected bool inline_busy() const; // a sketch value field is open (guard keys) bool inline_has_focus() const; // the field itself holds keyboard focus void inline_commit(); // accept the typed value (Enter/Tab) @@ -251,8 +249,10 @@ public: // panel can do it when focus is not on the canvas. void request_sketch_exit(); bool live_sketch_has_work() const; // the live sketch holds entities a cancel would destroy - bool undo_last_sketch_entity(); // Ctrl+Z in a sketch: drop the last entity - bool delete_selected_or_last_sketch_entity(); // Delete in a sketch: selected, else last + bool undo_last_sketch_entity(); // Ctrl+Z in a sketch: drop the last drawn shape + bool redo_last_sketch_entity(); // Ctrl+Y in a sketch: bring it back + bool can_undo_sketch_entity() const { return m_sketch_tool.can_undo_entity(); } + bool can_redo_sketch_entity() const { return m_sketch_tool.can_redo_entity(); } void clear_sketch_selection(); // View toggles (keys P / A): origin planes, world axis triad. Each returns the new on/off @@ -299,9 +299,10 @@ public: // Esc routing (DesignInteraction.hpp). The panel decides WHICH level one press belongs to; // these are the levels it can act on inside the canvas. Each returns whether it did anything, // so the panel can fall through to the next level without asking twice. - bool sketch_abort_gesture(); // CadLevel::Gesture — drop the entity being drawn + bool sketch_abort_gesture(); // CadLevel::Gesture — drop the entity being drawn, or the tool's picks bool sketch_disarm_tool(); // CadLevel::Tool — armed sketch tool falls back to Select - bool drawing_in_progress() const;// an entity has clicks down but is not committed + bool sketch_confirm_pending(); // Enter — apply a ready edit-op or transform + bool drawing_in_progress() const;// clicks or picks are down but nothing is committed yet bool has_any_selection() const; // model pick or sketch pick bool clear_any_selection(); // CadLevel::Idle — drop both; true if anything was dropped bool sketch_first_selected_type(SketchEntity::Type& out) const; @@ -367,7 +368,8 @@ private: std::function m_on_context_menu; bool m_ctx_bound{false}; // bind the RIGHT_UP handler once, however often the cb is set wxPoint m_ctx_press{0, 0}; // right-press origin: a right-DRAG orbits, it must not offer - long long m_ctx_press_ms{0}; // and a right-HOLD is navigation too, however still it is held + bool m_ctx_travelled{false}; // the right press wandered past the budget at ANY point, + // so an orbit that ends where it began is still an orbit Bed3D m_bed; // The half of the camera swap above that is NOT on screen: the editor tabs' view while diff --git a/src/slic3r/GUI/CAD/DesignInteraction.hpp b/src/slic3r/GUI/CAD/DesignInteraction.hpp index d849b779e4..67a80bd494 100644 --- a/src/slic3r/GUI/CAD/DesignInteraction.hpp +++ b/src/slic3r/GUI/CAD/DesignInteraction.hpp @@ -48,10 +48,10 @@ static_assert(cad_escape_level({false, false, true, true}) == CadLevel::Tool, static_assert(cad_escape_level({false, false, false, true}) == CadLevel::Idle, "selection is idle-level"); static_assert(cad_escape_level({false, false, false, false}) == CadLevel::Idle, "empty is idle"); -// Right-click vs. right-hold-orbit. A press that stays put and is let go promptly is a click and -// summons the offer; anything longer or further was navigation, and navigation must never be -// rewarded with a menu over wherever the camera happened to stop. -inline constexpr int kCadRightClickMs = 200; // press->release budget +// Right-click vs. right-drag-orbit. A press that stays put summons the offer; one that travelled +// was navigation, and navigation must never be rewarded with a menu over wherever the camera +// happened to stop. Distance only, never duration: charter 6.2 rules out any gesture that +// depends on timing, and a press that did not move did not move the camera either. inline constexpr int kCadRightClickDriftPx = 3; // cursor drift budget, max(|dx|,|dy|) }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index d247ddfd26..097d79a343 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -276,25 +276,6 @@ static SketchPlane face_plane_inward(const TopoDS_Face& face) return p; } -// Highest upward-facing planar face of a solid — the surface the user is looking down on. -// Hole placement defaults here (instead of the z=0 datum) so the footprint sits on the top -// face at the right depth, not on the model's underside where a top-view drag reads parallax- -// shifted. Returns -1 if the shape has no clearly-upward face. -static int top_face_index_of(const TopoDS_Shape& shape) -{ - int best = -1; double bestz = -1e30; - const int n = GeometryEngine::face_count(shape); - for (int i = 0; i < n; ++i) { - const TopoDS_Face f = GeometryEngine::face_by_index(shape, i); - if (f.IsNull()) continue; - const Vec3d nrm = GeometryEngine::face_normal_world(f); - if (nrm.z() < 0.5) continue; // only faces pointing substantially up - const Vec3d c = GeometryEngine::face_centroid_world(f); - if (c.z() > bestz) { bestz = c.z(); best = i; } - } - return best; -} - DesignPanel::DesignPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) { @@ -393,10 +374,8 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->set_sketch_tool(mode); } m_viewport->set_sketch_construction(m_construction->GetValue()); - m_status->SetForegroundColour(wxNullColour); - set_status(m_sketch_on.IsEmpty() ? hint + set_status(StatusKind::Info, m_sketch_on.IsEmpty() ? hint : wxString::Format(_L("%s · on %s"), hint, m_sketch_on)); - m_status->Refresh(); }; // Sketch-tool shortcuts (single letters, active only while a sketch is open). Family tools @@ -446,9 +425,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // exactly normal. Sketch map only: in Feature mode the navigator orb owns orientation. m_keys_sketch['N'] = [this] { if (m_viewport && m_viewport->view_normal_to_sketch()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Normal to the sketch plane")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Normal to the sketch plane")); } }; m_keys_sketch['Q'] = [this] { @@ -474,9 +451,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // View toggles (single letters, active when no sketch is open): P origin planes, A world // axes, X section view (Alt+Wheel slides the cut). Distinct from Shift+P/Shift+X features. auto status_flag = [this](const wxString& on_msg, const wxString& off_msg, bool on) { - m_status->SetForegroundColour(wxNullColour); - set_status(on ? on_msg : off_msg); - m_status->Refresh(); + set_status(StatusKind::Info, on ? on_msg : off_msg); }; m_keys_feature['P'] = [this, status_flag] { if (m_viewport) status_flag(_L("Origin planes shown"), _L("Origin planes hidden"), @@ -487,6 +462,9 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->toggle_axes()); }; m_keys_feature['X'] = [this] { toggle_section_view(); }; // toggle the single section on/off + // F: Place on Face. Lives in this map, not in the canvas, so it works whatever holds focus + // (charter 6.2). While the section view is on, F flips the kept half instead (handled above). + m_keys_feature['F'] = [this] { place_on_face(); }; // Home: axonometric view, fitted to the model. // @@ -640,11 +618,9 @@ DesignPanel::DesignPanel(wxWindow* parent) set_ui_mode(UiMode::Sketch); wxString where; const bool have_plane = sketch_plane_target(where); - m_status->SetForegroundColour(wxNullColour); - set_status(have_plane + 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")); - m_status->Refresh(); 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) @@ -678,9 +654,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_extrude_face_src = -1; // ordinary sketch/loop extrude m_extrude_sketch_ref = resolve_extrude_sketch(); if (m_extrude_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch, or pick a solid face, first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch, or pick a solid face, first")); return; } open_tool(Tool::Extrude); @@ -689,9 +663,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_revolve_sketch_ref = resolve_extrude_sketch(); if (m_revolve_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch profile to revolve first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch profile to revolve first")); return; } open_tool(Tool::Revolve); @@ -701,9 +673,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_sweep_profile_ref = resolve_extrude_sketch(); m_sweep_path_ref = -1; // fresh sweep: default the picker to the first sketch if (m_sweep_profile_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a profile sketch to sweep first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a profile sketch to sweep first")); return; } open_tool(Tool::Sweep); @@ -715,9 +685,7 @@ DesignPanel::DesignPanel(wxWindow* parent) for (const auto& f : m_doc.features) if (f.type == CadFeatureType::Sketch) ++n; if (n < 2) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create at least two profile sketches to loft")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create at least two profile sketches to loft")); return; } m_loft_refs.clear(); // fresh loft: nothing pre-checked @@ -726,9 +694,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_thicken", _L("Thicken"), _L("Offset a solid face into a thin plate (new body)"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Thicken needs a solid body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Thicken needs a solid body — add or import one first")); return; } { @@ -756,9 +722,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_rib", _L("Rib"), _L("Grow a thin wall from an open sketch line, fused to a body"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Rib needs a solid body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Rib needs a solid body — add or import one first")); return; } { @@ -784,9 +748,7 @@ DesignPanel::DesignPanel(wxWindow* parent) } if (m_rib_sketch->GetCount() > 0) m_rib_sketch->SetSelection(0); else { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch with an open line first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch with an open line first")); return; } } @@ -801,9 +763,7 @@ DesignPanel::DesignPanel(wxWindow* parent) std::function act_pattern = [this] { // Pattern replicates an existing body — needs at least one solid. if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a solid body to pattern first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a solid body to pattern first")); return; } open_tool(Tool::Pattern); @@ -823,9 +783,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_surf_extrude_sketch_ref = resolve_extrude_sketch(); if (m_surf_extrude_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch first")); return; } open_tool(Tool::SurfaceExtrude); @@ -834,9 +792,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_surf_revolve_sketch_ref = resolve_extrude_sketch(); if (m_surf_revolve_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch profile to revolve first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch profile to revolve first")); return; } open_tool(Tool::SurfaceRevolve); @@ -847,9 +803,7 @@ DesignPanel::DesignPanel(wxWindow* parent) for (const auto& f : m_doc.features) if (f.type == CadFeatureType::Sketch) ++n; if (n < 2) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create at least two profile sketches to loft")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create at least two profile sketches to loft")); return; } m_surf_loft_refs.clear(); @@ -859,9 +813,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_surf_fill_sketch_ref = resolve_extrude_sketch(); if (m_surf_fill_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch profile to fill first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch profile to fill first")); return; } open_tool(Tool::SurfaceFill); @@ -870,9 +822,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { populate_sheet_body_choices(m_surf_offset_body); if (m_surf_offset_body->GetCount() == 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No sheet body to offset — create a surface feature first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No sheet body to offset — create a surface feature first")); return; } open_tool(Tool::SurfaceOffset); @@ -881,9 +831,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { populate_sheet_body_choices(m_surf_thicken_body); if (m_surf_thicken_body->GetCount() == 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No sheet body to thicken — create a surface feature first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No sheet body to thicken — create a surface feature first")); return; } open_tool(Tool::ThickenSurface); @@ -919,9 +867,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_sketch", _L("Project"), _L("Project body edges onto a plane as sketch entities"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Project needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Project needs a body — add or import one first")); return; } { @@ -935,8 +881,14 @@ DesignPanel::DesignPanel(wxWindow* parent) int(m_proj_source_body->GetCount()) - 1)); } populate_plane_choices(m_proj_plane); - m_sel_solid_face = -1; - m_proj_face_label->SetLabel(_L("(all edges)")); + // Keep what the user pointed at (L3): a picked face on the source body is the + // face to project, exactly as the offer promised when it showed Project on it. + // Only a pick on ANOTHER body is dropped, since it cannot belong to this source. + const int src = m_proj_source_body->GetSelection(); + if (m_sel_solid_face >= 0 && m_sel_solid_body != src) m_sel_solid_face = -1; + m_proj_face_label->SetLabel(m_sel_solid_face >= 0 + ? wxString::Format(_L("Face %d"), m_sel_solid_face) + : _L("(all edges)")); open_tool(Tool::Project); }, 0}, }); @@ -952,9 +904,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_move", _L("Transform"), _L("Move and/or rotate an existing body"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Transform needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Transform needs a body — add or import one first")); return; } { @@ -972,9 +922,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_mirror", _L("Mirror"), _L("Reflect a body about a plane"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mirror needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mirror needs a body — add or import one first")); return; } { @@ -1008,9 +956,7 @@ DesignPanel::DesignPanel(wxWindow* parent) std::function act_cut = [this] { // A plane cut needs at least one solid to slice. if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a solid body to cut first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a solid body to cut first")); return; } populate_plane_choices(m_cut_plane); @@ -1050,9 +996,7 @@ DesignPanel::DesignPanel(wxWindow* parent) const wxTreeItemId row = m_tree_items[sel]; CallAfter([this, row] { m_tree->SetFocus(); m_tree->EditLabel(row); }); } else { - m_status->SetForegroundColour(wxNullColour); // "nothing selected" is not an error - set_status(_L("Select a feature, or a body, first — then rename it")); - m_status->Refresh(); + set_status(_L("Select a feature, or a body, first — then rename it")); // not an error } }; m_verb_actions["btn:rename"] = rename_feature; @@ -1093,9 +1037,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_delete", _L("Delete Face"), _L("Remove faces from a body and heal the solid"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Delete Face needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Delete Face needs a body — add or import one first")); return; } { @@ -1125,13 +1067,13 @@ DesignPanel::DesignPanel(wxWindow* parent) m_hole_face_body = -1; m_hole_has_bounds = false; set_hole_target_label(-1); - // Use the explicitly-picked face; otherwise default to the top face of the - // selected (or first) body so the hole lands on the surface being viewed, not - // the z=0 datum under the model. The XY/XZ/YZ dropdown still overrides. - int hb = m_sel_solid_body, hf = m_sel_solid_face; - if (hf < 0 && !m_doc.bodies.empty()) { - hb = (hb >= 0 && hb < int(m_doc.bodies.size())) ? hb : 0; - hf = top_face_index_of(m_doc.bodies[hb].shape); + // The hole goes where the user pointed (L1): a picked face. The keyboard route + // used to invent one — the top face of the first body — while the offer refused + // the same verb with nothing picked; both routes now say the same thing. + const int hb = m_sel_solid_body, hf = m_sel_solid_face; + if (hf < 0 || hb < 0 || hb >= int(m_doc.bodies.size())) { + set_status(StatusKind::Error, _L("Pick a face or a plane to drill into")); + return; } if (hf >= 0 && hb >= 0 && hb < int(m_doc.bodies.size())) { const TopoDS_Face face = GeometryEngine::face_by_index( @@ -1185,15 +1127,16 @@ DesignPanel::DesignPanel(wxWindow* parent) m_thread_face_body = m_sel_solid_body; set_thread_target_label(from_face ? m_sel_solid_face : -1, from_face ? -1 : m_sel_solid_edge); - infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder - if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + infer_thread_spec(2.0 * cf.radius, cf.internal); // M size + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); if (m_thread_x) m_thread_x->SetValue(0.0); // on the axis if (m_thread_y) m_thread_y->SetValue(0.0); } else if (m_sel_solid_face >= 0 || m_sel_solid_edge >= 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread")); - m_status->Refresh(); + // Refuse and stay closed: opening the card anyway overwrote this reason with + // the preview's status one line later, and left a thread card with no target. + set_status(StatusKind::Error, _L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread")); + return; } open_tool(Tool::Thread); }, SHIFT('T')}, @@ -1323,7 +1266,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // (separator dropped: the group it divided is now reached from the offer) dropdown("design_line", _L("Line / polyline"), { {"design_line", DesignSketchTool::Mode::Line, _L("Line"), _L("Click start, then end — then set the exact length")}, - {"design_polyline", DesignSketchTool::Mode::Polyline, _L("Polyline"), _L("Click points; click first / right-click to close the loop")} }); + {"design_polyline", DesignSketchTool::Mode::Polyline, _L("Polyline"), _L("Click points; click the first point to close the loop, right-click or Enter to end the chain")} }); dropdown("design_rect", _L("Rectangle"), { {"design_rect", DesignSketchTool::Mode::CornerRect, _L("Corner rectangle"), _L("Click two opposite corners")}, {"design_crect", DesignSketchTool::Mode::CenterRect, _L("Center rectangle"), _L("Click center, then a corner")}, @@ -1344,7 +1287,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_ellipse", DesignSketchTool::Mode::Ellipse, _L("Ellipse"), _L("Click center, a major-axis end, then a point for the minor axis")}, {"design_ellipse_arc", DesignSketchTool::Mode::EllipseArc, _L("Elliptical arc"), _L("Click center, major-axis end, minor point, then arc start and end")} }); skbtn("design_bspline", DesignSketchTool::Mode::BSpline, _L("Spline"), - _L("Click control points; double-click or right-click to finish")); + _L("Click control points; double-click, right-click or Enter to finish")); skbtn("design_point", DesignSketchTool::Mode::Point, _L("Point"), _L("Click to place a point")); // (separator dropped: the group it divided is now reached from the offer) @@ -1391,9 +1334,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // Trim / Extend scissors — standalone sketch tools (NOT inside Constrain): click a // segment to cut it back to / out to its nearest intersection. One cut per click. skbtn("design_trim", DesignSketchTool::Mode::Trim, _L("Trim"), - _L("Click a segment to trim it back to its nearest intersection; right-click exits")); + _L("Click a segment to trim it back to its nearest intersection; Esc exits")); skbtn("design_extend", DesignSketchTool::Mode::Extend, _L("Extend"), - _L("Click a line or arc to extend it to the nearest entity; right-click exits")); + _L("Click a line or arc to extend it to the nearest entity; Esc exits")); // Constrain — grouped with the edit tools so it's easy to find (nde #13: it was buried // far-right next to Construction and went unnoticed). Commits the live sketch in place // and drops into Constrain mode (geometric/dimensional palette). @@ -1449,13 +1392,17 @@ DesignPanel::DesignPanel(wxWindow* parent) if (m_dressup_type) m_dressup_type->SetSelection(0); open_feature(k_dress); }; m_verb_actions["btn:dress#1"] = [this, open_feature] { if (m_dressup_type) m_dressup_type->SetSelection(1); open_feature(k_dress); }; + // Same order for all three families: choose the variant, then open, so the card, its + // header and the first preview are built for the variant that was picked (a circular + // pattern used to open showing the linear ghost and gizmo). for (int op = 0; op < 3; ++op) m_verb_actions["btn:bool#" + std::to_string(op)] = [this, open_feature, op] { - open_feature(k_bool); - if (m_bool_op) { m_bool_op->SetSelection(op); refresh_preview(); } }; + if (m_bool_op) m_bool_op->SetSelection(op); // the variant first, so the card opens on it + open_feature(k_bool); }; for (int t = 0; t < 2; ++t) m_verb_actions["btn:pat#" + std::to_string(t)] = [this, open_feature, t] { - open_feature(k_pat); if (m_pattern_type) m_pattern_type->SetSelection(t); }; + if (m_pattern_type) m_pattern_type->SetSelection(t); + open_feature(k_pat); }; auto* b_poly = icon_btn("design_polygon", _L("Polygon")); b_poly->Bind(wxEVT_BUTTON, [arm_polygon](wxCommandEvent&) { arm_polygon(); }); @@ -1482,11 +1429,9 @@ DesignPanel::DesignPanel(wxWindow* parent) ? m_viewport->toggle_sketch_construction_selection() : 0; if (n > 0) { m_construction->SetValue(!m_construction->GetValue()); // the mode did not move - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("Converted %d entit%s between construction and real geometry"), n, n == 1 ? "y" : "ies")); - m_status->Refresh(); return; } m_viewport->set_sketch_construction(m_construction->GetValue()); }); @@ -1996,8 +1941,9 @@ DesignPanel::DesignPanel(wxWindow* parent) m_thread_internal = new CheckBox(m_cards); m_thread_internal->SetValue(false); - // External rod uses the major radius; an internal tapped bore uses the minor - // (tap-drill) radius — re-derive the nominal radius when the role flips. + // The diameter field is the NOMINAL (major) diameter either way; what the role changes is + // the depth — an internal thread's groove runs from the tap-drill (minor) diameter out to + // the major one, an external one's from the major in to the root — so re-derive it. m_thread_internal->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { apply_thread_standard(); e.Skip(); @@ -2644,17 +2590,13 @@ DesignPanel::DesignPanel(wxWindow* parent) // Say why nothing happened. Clicking with no face picked used to be a silent no-op, // which is indistinguishable from the button being broken. if (m_sel_solid_face < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Click a face on the body first, then Add picked face")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Click a face on the body first, then Add picked face")); return; } // Adding the same face twice puts a duplicate id in delete_faces, which the // defeaturing algorithm has no reason to cope with. Re-clicking is a no-op, not an error. if (std::find(m_del_faces.begin(), m_del_faces.end(), m_sel_solid_face) != m_del_faces.end()) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Face %d is already in the list"), m_sel_solid_face)); - m_status->Refresh(); + set_status(StatusKind::Info, wxString::Format(_L("Face %d is already in the list"), m_sel_solid_face)); return; } { @@ -3109,16 +3051,12 @@ DesignPanel::DesignPanel(wxWindow* parent) // one click away on a row the user has just selected — the message points at it instead // of describing a problem with no way out. if (const std::string* why = (sel >= 0) ? mate_conflict_reason(sel) : nullptr) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(wxString::FromUTF8(*why) + _L(" — the eye suppresses this mate")); - m_status->Refresh(); + set_status(StatusKind::Error, wxString::FromUTF8(*why) + _L(" — the eye suppresses this mate")); } else if (sel >= 0 && sel < int(m_doc.features.size())) { // Name the two gestures the row supports, because neither is visible on it. - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("%s selected — F2 or right-click renames it, double-click edits it"), wxString::FromUTF8(m_doc.features[sel].name))); - m_status->Refresh(); } }); @@ -3298,9 +3236,7 @@ DesignPanel::DesignPanel(wxWindow* parent) if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { on_move_body(); } else { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Select a body row first, then move it")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select a body row first, then move it")); } }); // Boolean lives here as well as on the toolbar: combining two bodies is a body action, @@ -3355,9 +3291,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_sel_solid_face = m_sel_solid_edge = -1; m_sel_solid_vertex = false; m_pick_face = m_pick_face_body = -1; // chosen from the list, no face was pointed at - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1)); - m_status->Refresh(); + set_status(StatusKind::Info, wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1)); }; m_parts->Bind(wxEVT_TREE_SEL_CHANGED, [this, apply_body_row](wxTreeEvent&) { apply_body_row(tree_body_selection()); @@ -3533,8 +3467,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_feature_counter++; m_doc.add_sketch_profile(prof, plane, "Sketch" + std::to_string(m_feature_counter)); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch created — select it, then right-click to Extrude")); + set_status(StatusKind::Info, _L("Sketch created — select it, then right-click to Extrude")); refresh_tree(); }); @@ -3543,9 +3476,7 @@ DesignPanel::DesignPanel(wxWindow* parent) const std::vector& cons, const SketchPlane& plane) { if (ents.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Sketch empty — nothing committed")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Sketch empty — nothing committed")); return; } m_doc.checkpoint(); // undo boundary: committing / re-editing an entity sketch @@ -3560,8 +3491,7 @@ DesignPanel::DesignPanel(wxWindow* parent) if (m_doc.replace_feature(m_edit_index, edited)) { if (!cons.empty()) m_doc.solve_sketch_feature(m_edit_index); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch updated")); + set_status(StatusKind::Info, _L("Sketch updated")); m_edit_index = -1; refresh_tree(); sync_sketch_display(); @@ -3573,8 +3503,7 @@ DesignPanel::DesignPanel(wxWindow* parent) "Sketch" + std::to_string(m_feature_counter), cons); if (!cons.empty()) m_doc.solve_sketch_feature(sk); // enforce driving dimensions m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(cons.empty() + set_status(StatusKind::Info, cons.empty() ? _L("Sketch created — select it, then right-click to Extrude") : wxString::Format(_L("Sketch created (%zu driving dims) — select it, then right-click to Extrude"), cons.size())); @@ -3585,7 +3514,6 @@ DesignPanel::DesignPanel(wxWindow* parent) // Live length/angle readout while drawing a Line/Polyline segment. m_viewport->set_on_cursor_metrics([this](double len, double ang_deg, bool locked) { double a = ang_deg; if (a < 0.0) a += 360.0; // show bearing 0..360 - m_status->SetForegroundColour(wxNullColour); // APPENDED to the step guidance, never in place of it. This fires on every mouse move // while a segment is being dragged, so replacing the line wiped the instruction for the // step the user is in the middle of — one mouse move after the click that armed it. @@ -3617,6 +3545,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // Constrain to read stays blank until they happen to change something. m_dof_last = dof; m_dof_last_ok = ok; m_dof_last_has = has_constraints; apply_dof_status(dof, ok, has_constraints); + update_undo_redo_buttons(); // every edit of the live sketch re-solves, so this tracks it }); // Selection no longer writes the status line: on_sketch_step owns it, says the same thing @@ -3631,9 +3560,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->finish_sketch(); // commit live sketch (synchronous) m_extrude_sketch_ref = resolve_extrude_sketch(); if (m_extrude_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Could not resolve the sketch to extrude")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Could not resolve the sketch to extrude")); return; } set_ui_mode(UiMode::Feature); @@ -3645,9 +3572,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_sel_solid_face = m_sel_solid_edge = -1; m_pick_face = m_pick_face_body = -1; m_viewport->set_loop_pick(m_extrude_sketch_ref, region); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Face selected — set the depth and Confirm")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Face selected — set the depth and Confirm")); }); // Clicking a committed sketch loop on the plate (no live session) selects THAT loop: @@ -3705,13 +3630,11 @@ DesignPanel::DesignPanel(wxWindow* parent) } } set_tree_selection(feat); - m_status->SetForegroundColour(wxNullColour); - set_status(region >= 0 + set_status(StatusKind::Info, region >= 0 // "Region", not "Loop": what is selected — and what Extrude will consume — is the // bounded area including any holes in it, not a single closed curve. ? _L("Region selected — right-click to Extrude, or double-click to edit") : _L("Sketch selected — right-click to Extrude, or double-click to edit")); - m_status->Refresh(); }); // Double-click a committed sketch stroke: open THAT sketch for editing, where its entities @@ -3728,7 +3651,6 @@ DesignPanel::DesignPanel(wxWindow* parent) // F key (Prepare's Place on Face): the tool forwards it here when the Design viewport // has focus; we lay the selected body face on the bed. Returns false when no face is // selected so the key can fall through to the default handler. - m_viewport->set_on_place_on_face([this]() { return place_on_face(); }); // Clicking a solid cycles whole -> face -> edge. The tool draws the cyan overlay for ALL // levels now (per-body, so other bodies stay untinted) — no whole-compound set_body_highlight. @@ -3857,9 +3779,9 @@ DesignPanel::DesignPanel(wxWindow* parent) m_thread_on_face = true; m_thread_face_body = m_sel_solid_body; set_thread_target_label(from_face ? face : -1, from_face ? -1 : edge); - infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder - if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + infer_thread_spec(2.0 * cf.radius, cf.internal); // M size + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); refresh_preview(); } } @@ -3911,7 +3833,6 @@ DesignPanel::DesignPanel(wxWindow* parent) } if (got && m_viewport) m_viewport->set_escalate_on_repick(true); } - m_status->SetForegroundColour(wxNullColour); const int nb = int(m_doc.bodies.size()); const wxString bodytag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString(); // Each sub-element line ends by naming the NEXT click (gem). Escalation to the @@ -4019,11 +3940,10 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->set_sketch_plane(plane_from_choice(m_ref_plane)); } const char* nm = (base == 0) ? "XY" : (base == 1) ? "XZ" : (base == 2) ? "YZ" : "datum"; - m_status->SetForegroundColour(wxColour(120, 210, 120)); // 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(m_ui_mode == UiMode::Sketch + 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)); @@ -4062,10 +3982,8 @@ DesignPanel::DesignPanel(wxWindow* parent) const int nb = int(m_doc.bodies.size()); const wxString tag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString(); const Vec3d t = xform.translation(); - m_status->SetForegroundColour(wxNullColour); - set_status(tag + wxString::Format(_L("placed (%.1f, %.1f, %.1f) mm — drag arrows to move, rings to rotate"), + set_status(StatusKind::Info, tag + wxString::Format(_L("placed (%.1f, %.1f, %.1f) mm — drag arrows to move, rings to rotate"), t.x(), t.y(), t.z())); - m_status->Refresh(); }); // Fillet/Chamfer radius gizmo: dragging (or editing) the edge-anchored arrow writes the @@ -4141,23 +4059,21 @@ DesignPanel::DesignPanel(wxWindow* parent) set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Tool exited")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Tool exited")); }); // The tool declined to leave because the session holds geometry. There is no "press it again" // any more — the answer is a deliberate Finish or Cancel — so this is a plain statement of // where you are, not a warning shot. Only the STATUS LINE lives here; the tool reports via // this callback instead of writing text itself. + m_viewport->set_on_sketch_notice([this](const std::string& msg, bool error) { + set_status(error ? StatusKind::Error : StatusKind::Info, wxString::FromUTF8(msg)); + }); m_viewport->set_on_sketch_exit_refused([this]() { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch kept — Finish to commit it, Cancel to discard")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Sketch kept — Finish to commit it, Cancel to discard")); }); // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport → feature-history undo/redo. - m_viewport->set_on_undo_redo([this](bool redo) { do_undo_redo(redo); }); // Esc = the unified Cancel everywhere. Feature cards had no key exit (only the button); // CHAR_HOOK on the panel catches Esc from the card or viewport and routes to tool_cancel. @@ -4230,6 +4146,22 @@ DesignPanel::DesignPanel(wxWindow* parent) // acts on that one only; see DesignInteraction.hpp for the ladder and its invariant. if (key == WXK_ESCAPE) { escape(); return; } + // Enter mirrors the ✓ as Esc mirrors the ✗ (charter 4.2, L9): a ready sketch edit-op or + // transform first, else whatever the ✓ would confirm — gated exactly as the greyed button + // is, so a broken candidate cannot be committed from the keyboard either. An open value + // field keeps its own Enter (handled above or inside the field). + // Inside a sketch, Enter first ends what is in progress — applies a ready edit-op or + // transform, finishes a polyline/spline chain — then ends the armed draw tool (a + // continuous tool is ended, not confirmed, charter 4.2); only from Select does it finish + // the sketch, exactly as the ✓ does. + if (!in_text && !ctrl && (key == WXK_RETURN || key == WXK_NUMPAD_ENTER)) { + if (sketch_mode && m_viewport && m_viewport->is_sketching()) { + if (m_viewport->sketch_confirm_pending()) { update_undo_redo_buttons(); return; } + if (m_viewport->sketch_disarm_tool()) { set_status(_L("Select")); return; } + } + if (confirm_enabled()) { tool_confirm(); return; } + } + // The offer from the keyboard (charter 4.1): the Menu key, or Shift+F10 for keyboards that // do not have one. Same menu the right-click opens — show_offer_menu already decides which // half of the map applies via sketch_map_applies(), so nothing about the content is decided @@ -4249,16 +4181,21 @@ DesignPanel::DesignPanel(wxWindow* parent) if (!in_text && ctrl && (key == 'Z' || key == 'z' || key == WXK_CONTROL_Z || key == 'Y' || key == 'y' || key == WXK_CONTROL_Y)) { const bool redo = (key == 'Y' || key == 'y' || key == WXK_CONTROL_Y) || e.ShiftDown(); - if (sketching) { if (!redo) m_viewport->undo_last_sketch_entity(); } - else { do_undo_redo(redo); } + do_undo_redo(redo); // the same route as the Undo/Redo buttons return; } - // Delete — the selected sketch entities (or the last drawn one if none is selected), or the - // selected feature in Feature mode. Focus-independent, same reason as undo above. + // Delete — the selected sketch entities, or the selected feature in Feature mode. Only + // an explicit selection is ever destroyed (DesignInteraction.hpp): with nothing picked + // the key says so instead of deleting whatever happened to be drawn last. + // Focus-independent, same reason as undo above. // WXK_BACK too: on a keyboard whose Del is a chord (every laptop this runs on), Del is // the one destructive key nobody can reach, and Backspace is what users press. oql1. if (!in_text && (key == WXK_DELETE || (key == WXK_BACK && sketching))) { - if (sketching) { m_viewport->delete_selected_or_last_sketch_entity(); return; } + if (sketching) { + if (!m_viewport->delete_selected_sketch_entities()) + set_status(_L("Select something to delete")); + return; + } if (m_ui_mode == UiMode::Feature && m_active == Tool::None && tree_selection() != wxNOT_FOUND) { on_delete_feature(); return; } } @@ -4312,10 +4249,6 @@ DesignPanel::DesignPanel(wxWindow* parent) e.Skip(); }); - // Right-click finishes the move gizmo in the viewport; mirror that on the panel so the - // action bar (shown while moving) hides and the move state clears. - m_viewport->set_on_move_exit([this]() { m_move_body = -1; show_move_card(false); update_action_bar(); }); - // The offer (§4.1): right-click the geometry, get the verbs that apply to it. Left-click // still only selects, so pointing at things stays quiet. m_viewport->set_on_context_menu([this](const wxPoint& p) { show_offer_menu(p); }); @@ -4656,8 +4589,14 @@ void DesignPanel::on_import_svg() // app unresponsive and nothing repainted. The dialog is app-modal, so the document cannot be // touched while the worker owns it. Exceptions must not escape the worker: `work` is expected // to swallow them (OCCT throws Standard_Failure, which is not a std::exception). +// True while a worker owns the document. wxYield below runs pending events — and a queued MCP +// request is one — so the control socket must check this and refuse rather than change the +// document under the worker (DesignPanel::mcp_busy). +static std::atomic s_doc_worker_busy{0}; + static void run_off_ui_thread(wxWindow* parent, const wxString& message, const std::function& work) { + struct Busy { Busy() { ++s_doc_worker_busy; } ~Busy() { --s_doc_worker_busy; } } busy; std::atomic done{false}; std::thread worker([&work, &done]() { work(); @@ -4683,6 +4622,19 @@ static void run_off_ui_thread(wxWindow* parent, const wxString& message, const s worker.join(); } +bool DesignPanel::mcp_busy(bool sketch_method, std::string& why) const +{ + if (s_doc_worker_busy.load() > 0) { why = "the model is being rebuilt; try again when it finishes"; return true; } + // A GUI editor holds a candidate built from the document as it was when it opened; a + // feature added or removed underneath it would be overwritten, or overwrite, on Confirm. + if (m_active != Tool::None || m_edit_index >= 0) { why = "a feature card is open in the Design tab"; return true; } + if (m_ui_mode == UiMode::Constrain) { why = "the Design tab is constraining a sketch"; return true; } + // Sketch methods drive the live sketch session, which is the point of them; everything else + // changes the feature list, which a live sketch session is about to commit into. + if (!sketch_method && m_ui_mode == UiMode::Sketch) { why = "a sketch is open in the Design tab"; return true; } + return false; +} + // Rebuild the document off the UI thread. Every feature op (fillet, cut, shell, boolean, ...) // goes through recompute(), and on a heavy imported solid that is seconds of OCCT work — inline // it freezes the window. OCCT throws Standard_Failure, which is not a std::exception and would @@ -4761,10 +4713,8 @@ void DesignPanel::on_import_step() } }); if (solids.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(err.empty() ? _L("No solids found in STEP") - : (_L("STEP import failed: ") + wxString::FromUTF8(err))); - m_status->Refresh(); + set_status(StatusKind::Error, err.empty() ? _L("No solids found in STEP") + : (wxString::Format(_L("STEP import failed: %s"), kernel_error_text(err)))); return; } m_doc.checkpoint(); // undo boundary: importing STEP solids @@ -4789,20 +4739,16 @@ void DesignPanel::on_import_step() } }); if (!rebuilt) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("STEP import failed: ") + wxString::FromUTF8(m_doc.error)); - m_status->Refresh(); + set_status(StatusKind::Error, wxString::Format(_L("STEP import failed: %s"), kernel_error_text(m_doc.error))); return; } set_ui_mode(UiMode::Feature); // imported solids live in the feature timeline refresh_tree(); set_tree_selection(int(m_doc.features.size()) - 1); set_status_ok(); // canonical post-recompute viewport/pick/parts refresh - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("Imported %d solid(s) — pick a face or edge, then Fillet / Cut / Shell to modify"), int(solids.size()))); - m_status->Refresh(); } // Import a triangle mesh as a real B-rep body: the triangles are rebuilt into OCCT faces with @@ -4819,9 +4765,7 @@ void DesignPanel::on_import_mesh() const std::string path(dlg.GetPath().ToUTF8().data()); auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; // Load the triangles with the slicer's own readers — no new mesh dependency. @@ -4834,7 +4778,7 @@ void DesignPanel::on_import_mesh() ObjInfo obj_info; std::string obj_err; if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) { - fail(_L("Could not read the OBJ file: ") + wxString::FromUTF8(obj_err)); + fail(wxString::Format(_L("Could not read the OBJ file: %s"), wxString::FromUTF8(obj_err))); return; } } else { @@ -4863,11 +4807,11 @@ void DesignPanel::on_import_mesh() shape = GeometryEngine::mesh_to_brep(mesh.its, MESH_IMPORT_TOLERANCE, MESH_IMPORT_MERGE_ANGLE_DEG, stats); } catch (const Standard_Failure& e) { // on OCCT >= 8 Standard_Failure derives from std::exception — must precede that handler - fail(_L("Mesh conversion failed: ") + wxString::FromUTF8( - e.GetMessageString() ? e.GetMessageString() : "OCCT error")); + fail(wxString::Format(_L("Mesh conversion failed: %s"), + kernel_error_text(e.GetMessageString() ? e.GetMessageString() : ""))); return; } catch (const std::exception& e) { - fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(e.what())); + fail(wxString::Format(_L("Mesh conversion failed: %s"), kernel_error_text(e.what()))); return; } if (shape.IsNull()) { fail(_L("Mesh conversion produced no geometry")); return; } @@ -4882,7 +4826,7 @@ void DesignPanel::on_import_mesh() m_doc.features.push_back(f); if (!recompute_guarded(_L("Rebuilding model…"))) { - fail(_L("Mesh import failed: ") + wxString::FromUTF8(m_doc.error)); + fail(wxString::Format(_L("Mesh import failed: %s"), kernel_error_text(m_doc.error))); return; } set_ui_mode(UiMode::Feature); @@ -4894,14 +4838,12 @@ void DesignPanel::on_import_mesh() // watertight, say so and say why (boundary vs non-manifold edges) — that is a defect in the // source mesh the user needs to know about before they start cutting features into it. if (stats.is_solid) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("Imported solid — %d triangles → %d faces, volume %.2f mm³. Pick a face or edge, " "then Fillet / Cut / Shell to modify"), stats.kept_tris, stats.faces_final, stats.volume)); } else { - m_status->SetForegroundColour(wxColour(220, 160, 60)); // warning, not an error - set_status(wxString::Format( + set_status(StatusKind::Warning, wxString::Format( _L("Imported as an open shell (not watertight): %d boundary edge(s), %d non-manifold " "edge(s) — %d triangles → %d faces. The source mesh has holes or duplicated " "geometry; boolean features may fail on it"), @@ -4915,9 +4857,7 @@ void DesignPanel::add_imported_sketch( const wxString& base_name) { if (regions.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No importable geometry found")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No importable geometry found")); return; } // DRAWING, not importing: if a sketch is open, the art belongs IN it. The outlines become @@ -4925,10 +4865,8 @@ void DesignPanel::add_imported_sketch( // else on that plane. Committing a separate Sketch feature while the user is mid-sketch put // the text on its own plane-origin feature and left the sketch they were drawing untouched. if (m_viewport && m_viewport->is_sketching() && m_viewport->add_sketch_regions(regions)) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%s added to the sketch — Confirm to commit it"), + set_status(StatusKind::Info, wxString::Format(_L("%s added to the sketch — Confirm to commit it"), base_name)); - m_status->Refresh(); return; } m_doc.checkpoint(); // undo boundary: importing Text/SVG art @@ -4989,9 +4927,7 @@ void DesignPanel::open_insert_card(const wxString& base_name) update_cards_frame(); m_form->Layout(); m_form->FitInside(); update_action_bar(); // surface the unified ✓/✗ - m_status->SetForegroundColour(wxNullColour); - set_status(base_name + _L(" — drag to place/size, then Confirm")); - m_status->Refresh(); + set_status(StatusKind::Info, base_name + _L(" — drag to place/size, then Confirm")); } // Confirm: keep the placed art and leave the placement gizmo. The feature is already in @@ -5019,9 +4955,7 @@ void DesignPanel::cancel_insert() set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Insert cancelled")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Insert cancelled")); } void DesignPanel::on_transform_imported(int feat_idx) @@ -5035,9 +4969,7 @@ void DesignPanel::on_transform_imported(int feat_idx) // the centre to move. Values stream back via set_on_imported_transform. m_viewport->begin_imported_transform(feat_idx, f.imported_regions, f.plane, f.import_offset, f.import_scale_x, f.import_scale_y); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Drag a corner to scale, the centre to move — right-click when done")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Drag a corner to scale, the centre to move — Enter or ✓ when done, Esc to discard")); } void DesignPanel::on_add_sketch() @@ -5050,8 +4982,7 @@ void DesignPanel::on_add_sketch() m_doc.add_sketch(shape, plane, m_width->GetValue(), m_height->GetValue(), m_radius->GetValue(), "Sketch" + std::to_string(m_feature_counter)); m_doc.recompute(); // a lone sketch yields an empty body; that is expected - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Sketch added on %s — select it, then right-click to Extrude"), where)); + set_status(StatusKind::Info, wxString::Format(_L("Sketch added on %s — select it, then right-click to Extrude"), where)); refresh_tree(); } @@ -5109,7 +5040,7 @@ void DesignPanel::on_add_extrude() f.target_body = m_doc.features[m_extrude_sketch_ref].import_face_body; } if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5142,7 +5073,7 @@ void DesignPanel::on_add_dressup() m_doc.features[didx].target_body = m_sel_solid_body; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5204,7 +5135,7 @@ void DesignPanel::on_add_hole() m_doc.features[hidx].target_body = m_hole_face_body; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5229,40 +5160,38 @@ void DesignPanel::apply_thread_standard() m_thread_std->GetString(sel).utf8_string()); if (!s) return; - // Pitch and depth are the defining "measures" of the standard — always apply. + // Pitch and depth are the defining "measures" of the standard — always apply. The field + // holds the NOMINAL (major) diameter for both roles; the kernel bores an internal thread to + // major - 2 x depth, so its depth is the tapped one, (major - minor) / 2. + const bool internal = m_thread_internal && m_thread_internal->GetValue(); if (m_thread_pitch) m_thread_pitch->SetValue(s->pitch_mm); - if (m_thread_depth) m_thread_depth->SetValue(s->thread_depth_mm()); - - // Nominal diameter: external rod = major diameter; internal tapped bore = minor (tap-drill) - // diameter. On a picked cylindrical surface/edge the diameter comes from the real geometry, - // so don't override it there. (The field holds DIAMETER.) - if (!m_thread_on_face && m_thread_radius) { - const bool internal = m_thread_internal && m_thread_internal->GetValue(); - const double d = internal ? s->minor_diameter_mm() : s->major_diameter_mm; - m_thread_radius->SetValue(d); - } + if (m_thread_depth) m_thread_depth->SetValue(internal ? s->internal_depth_mm() : s->thread_depth_mm()); + if (m_thread_radius) m_thread_radius->SetValue(s->major_diameter_mm); if (m_status) set_status(wxString::Format(_L("Thread standard: %s (pitch %.3g mm)"), m_thread_std->GetString(sel), s->pitch_mm)); } -void DesignPanel::infer_thread_spec(double diameter) +void DesignPanel::infer_thread_spec(double diameter, bool internal) { - // Snap to the nearest standard thread by nominal (major) diameter, so picking a Ø9.9 boss - // gives M10 — the M diameter, pitch AND depth all follow from the cylinder's base diameter. + // Snap to the nearest standard thread, so picking a Ø9.9 boss gives M10 and a Ø5 tap-drill + // hole gives M6 — the M diameter, pitch AND depth all follow from the cylinder. A boss is + // matched on the MAJOR diameter; a hole on the MINOR one, since a hole to be tapped is + // drilled at the tap-drill size. const auto& stds = thread_standards(); int best = -1; double bestErr = 1e30; for (int i = 0; i < int(stds.size()); ++i) { - const double e = std::abs(stds[i].major_diameter_mm - diameter); + const double ref = internal ? stds[i].minor_diameter_mm() : stds[i].major_diameter_mm; + const double e = std::abs(ref - diameter); if (e < bestErr) { bestErr = e; best = i; } } if (best < 0) { if (m_thread_radius) m_thread_radius->SetValue(diameter); return; } const ThreadSpec& s = stds[best]; if (m_thread_std) m_thread_std->SetSelection(best + 1); // row 0 is "Custom" - if (m_thread_radius) m_thread_radius->SetValue(s.major_diameter_mm); // field = DIAMETER + if (m_thread_radius) m_thread_radius->SetValue(s.major_diameter_mm); // field = NOMINAL diameter if (m_thread_pitch) m_thread_pitch->SetValue(s.pitch_mm); - if (m_thread_depth) m_thread_depth->SetValue(s.thread_depth_mm()); + if (m_thread_depth) m_thread_depth->SetValue(internal ? s.internal_depth_mm() : s.thread_depth_mm()); } void DesignPanel::on_add_thread() @@ -5284,7 +5213,7 @@ void DesignPanel::on_add_thread() m_doc.features[tidx].target_body = m_thread_face_body; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5308,7 +5237,7 @@ void DesignPanel::on_add_revolve() mode, "Revolve" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5338,7 +5267,7 @@ void DesignPanel::on_add_sweep() "Sweep" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5366,7 +5295,7 @@ void DesignPanel::on_add_loft() "Loft" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5383,7 +5312,7 @@ void DesignPanel::on_add_surface_extrude() m_doc.add_surface_extrude(m_surf_extrude_sketch_ref, m_surf_extrude_distance->GetValue(), "SurfaceExtrude" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5400,7 +5329,7 @@ void DesignPanel::on_add_surface_revolve() m_surf_revolve_axis->GetSelection(), "SurfaceRevolve" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5420,7 +5349,7 @@ void DesignPanel::on_add_surface_loft() m_doc.add_surface_loft(refs, m_surf_loft_ruled->GetValue(), "SurfaceLoft" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5436,7 +5365,7 @@ void DesignPanel::on_add_surface_fill() m_doc.add_surface_fill(m_surf_fill_sketch_ref, "SurfaceFill" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5453,7 +5382,7 @@ void DesignPanel::on_add_surface_offset() m_doc.add_surface_offset(sel, m_surf_offset_distance->GetValue(), "SurfaceOffset" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5471,7 +5400,7 @@ void DesignPanel::on_add_thicken_surface() m_surf_thicken_flip->GetValue(), "ThickenSurface" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5567,7 +5496,7 @@ void DesignPanel::on_add_transform() m_doc.add_transform(target, trans, axis, pivot, m_xf_angle->GetValue(), m_xf_copy->GetValue(), "Transform" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5586,7 +5515,7 @@ void DesignPanel::on_add_mirror() m_doc.add_mirror(plane_from_choice(m_mirror_plane->GetSelection()), target, mode, "Mirror" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5599,9 +5528,7 @@ void DesignPanel::on_add_thicken() return; } if (m_sel_solid_face < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Pick a solid face to thicken first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Pick a solid face to thicken first")); return; } const int sel = m_thicken_body->GetSelection(); @@ -5610,7 +5537,7 @@ void DesignPanel::on_add_thicken() m_doc.add_thicken(target, m_sel_solid_face, m_thicken_thickness->GetValue(), m_thicken_flip->GetValue(), "Thicken" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5635,7 +5562,7 @@ void DesignPanel::on_add_rib() m_doc.add_rib(sketch_ref, m_rib_entity->GetValue(), m_rib_thickness->GetValue(), m_rib_depth->GetValue(), target, "Rib" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5655,7 +5582,7 @@ void DesignPanel::on_add_project() plane_from_choice(m_proj_plane->GetSelection()), "Project" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5668,9 +5595,7 @@ void DesignPanel::on_add_delete_face() return; } if (m_del_faces.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Add at least one face to delete first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Add at least one face to delete first")); return; } const int sel = m_del_face_body->GetSelection(); @@ -5679,7 +5604,7 @@ void DesignPanel::on_add_delete_face() m_doc.add_delete_face(target, m_del_faces, "DeleteFace" + std::to_string(m_feature_counter)); m_del_faces.clear(); // consumed; fresh state for the next use if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5693,7 +5618,7 @@ void DesignPanel::on_add_helix() m_helix_height->GetValue(), m_helix_left_handed->GetValue(), m_helix_taper->GetValue(), "Helix" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5704,17 +5629,13 @@ void DesignPanel::on_add_mate() const int sel_a = m_mate_cs_a->GetSelection(); const int sel_b = m_mate_cs_b->GetSelection(); if (sel_a == wxNOT_FOUND || sel_b == wxNOT_FOUND) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate needs two CoordSys features — create them first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mate needs two CoordSys features — create them first")); return; } const int cs_a = int(reinterpret_cast(m_mate_cs_a->GetClientData(sel_a))); const int cs_b = int(reinterpret_cast(m_mate_cs_b->GetClientData(sel_b))); if (cs_a == cs_b) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate: CS A and CS B must be different CoordSys features")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mate: CS A and CS B must be different CoordSys features")); return; } m_feature_counter++; @@ -5723,13 +5644,11 @@ void DesignPanel::on_add_mate() m_mate_flip->GetValue(), "Mate" + std::to_string(m_feature_counter)); if (idx < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate rejected")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mate rejected")); return; } if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5738,25 +5657,19 @@ void DesignPanel::on_add_mate() void DesignPanel::on_check_interference() { if (m_doc.bodies.size() < 2) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("No interference — need at least two solid bodies to check")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("No interference — need at least two solid bodies to check")); return; } const auto pairs = m_doc.check_interference(); if (pairs.empty()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("No interference found")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("No interference found")); return; } double worst = 0; for (const auto& p : pairs) if (p.volume > worst) worst = p.volume; - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%zu interference pairs, worst %.2f mm³"), + set_status(StatusKind::Info, wxString::Format(_L("%zu interference pairs, worst %.2f mm³"), pairs.size(), worst)); - m_status->Refresh(); wxString msg = _L("Interference pairs:\n\n"); for (const auto& p : pairs) { // 1-based, like every other body label in this panel and in the parts tree: @@ -5782,16 +5695,12 @@ void DesignPanel::on_mass_properties() // here with nothing selected; run_verb does. Nothing selected is not an error, hence the // neutral colour, not the error red. if (m_sel_solid_body < 0 || m_sel_solid_body >= int(m_doc.bodies.size())) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Select a solid body first — its mass properties are what is reported")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select a solid body first — its mass properties are what is reported")); return; } const auto mp = GeometryEngine::mass_properties(m_doc.bodies[m_sel_solid_body].shape); if (!mp.valid) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mass properties could not be computed for this body")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mass properties could not be computed for this body")); return; } // 1-based, and the body's own name when it has one — the same wording the parts list uses. @@ -5800,10 +5709,8 @@ void DesignPanel::on_mass_properties() name = wxString::FromUTF8(m_doc.bodies[m_sel_solid_body].name); if (!mp.is_solid) { // Sheet body: quoting a volume here would be inventing material that is not there. - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%s: sheet body — %.2f cm² of surface, no volume"), + set_status(StatusKind::Info, wxString::Format(_L("%s: sheet body — %.2f cm² of surface, no volume"), name, mp.surface_area / 100.0)); - m_status->Refresh(); wxMessageBox(wxString::Format(_L("%s\n\nSheet body (open shell)\nSurface area: %.2f cm²\n\n" "A sheet encloses no material, so it has no volume. " "Thicken it into a solid to get one."), @@ -5811,10 +5718,8 @@ void DesignPanel::on_mass_properties() _L("Mass properties"), wxOK, this); return; } - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%s: %.3f cm³, %.2f cm²"), + set_status(StatusKind::Info, wxString::Format(_L("%s: %.3f cm³, %.2f cm²"), name, mp.volume / 1000.0, mp.surface_area / 100.0)); - m_status->Refresh(); wxMessageBox(wxString::Format(_L("%s\n\nVolume: %.3f cm³\nSurface area: %.2f cm²"), name, mp.volume / 1000.0, mp.surface_area / 100.0), _L("Mass properties"), wxOK, this); @@ -5876,7 +5781,7 @@ void DesignPanel::on_add_pattern() "Pattern" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5960,7 +5865,7 @@ void DesignPanel::on_add_boolean() m_bool_keep->GetValue(), m_bool_tol->GetValue(), -1, -1, "Boolean" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5977,7 +5882,7 @@ void DesignPanel::on_add_cut() /*flip*/ false, /*keep_upper*/ true, /*keep_lower*/ true, m_cut_target->GetSelection(), "Cut" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -6195,9 +6100,52 @@ wxPoint DesignPanel::offer_anchor() const // One place that writes the status line, so every hint wraps instead of clipping at the panel // edge. wxStaticText::Wrap() is destructive, which is fine here: the label is replaced whole // each time, never appended to. -void DesignPanel::set_status(const wxString& text) +// Kernel and OCCT messages are written for developers ("fuse failed", "BRep_API: command not +// done", an empty string). The status line is for the person modelling, so the recurring ones +// are said in the drawing office's words; anything unrecognised is still shown rather than +// hidden, because a vague sentence that hides the only clue is worse than a terse one. +wxString DesignPanel::kernel_error_text(const std::string& err) +{ + struct Map { const char* needle; const char* sentence; }; + static const Map kMap[] = { + { "fuse failed", L("the new shape could not be joined to the body") }, + { "cut failed", L("the new shape could not be cut from the body") }, + { "intersect failed", L("the shapes could not be intersected") }, + { "no solid-producing features", L("nothing in the history makes a solid yet") }, + { "sketch profile wire failed", L("the sketch profile is not a clean closed loop") }, + { "does not intersect", L("the tool does not touch the body") }, + { "unknown identifier", L("an expression uses a variable that does not exist") }, + { "division by zero", L("an expression divides by zero") }, + { "variable cycle", L("two variables are defined in terms of each other") }, + }; + if (err.empty()) + return _L("a geometry operation failed"); + for (const Map& m : kMap) + if (err.find(m.needle) != std::string::npos) + return _(m.sentence); + if (err.find("OCCT") != std::string::npos || err.find("BRep") != std::string::npos + || err.find("Standard_") != std::string::npos || err.find("StdFail") != std::string::npos) + return _L("a geometry operation failed"); + return wxString::FromUTF8(err); +} + +void DesignPanel::set_status(StatusKind kind, const wxString& body) { if (m_status == nullptr) return; + wxColour colour; + const wchar_t* glyph = nullptr; + switch (kind) { + case StatusKind::Info: colour = wxNullColour; break; + case StatusKind::Ok: colour = wxColour(120, 210, 120); glyph = L"\u2713 "; break; // ✓ + case StatusKind::Warning: colour = wxColour(220, 160, 60); glyph = L"\u26A0 "; break; // ⚠ + case StatusKind::Error: colour = wxColour(235, 110, 110); glyph = L"\u2717 "; break; // ✗ + } + m_status->SetForegroundColour(colour); + // Callers that already lead with a glyph (the DOF line) keep theirs. + wxString text = body; + if (glyph != nullptr && !text.IsEmpty() && !text.StartsWith(L"\u2713") && !text.StartsWith(L"\u2717") + && !text.StartsWith(L"\u26A0")) + text = wxString(glyph) + text; m_status->SetLabel(text); // the ONE place that may call SetLabel directly // m_status is HIDDEN and kept only as the owner of the text and its colour — every caller // sets the colour on it just before calling here, so this stays the one place that knows @@ -6225,8 +6173,9 @@ void DesignPanel::set_status(const wxString& text) // apply); `picks` = the size of the set the gesture accumulates. // // It is deliberately explicit about the gesture that ENDS each tool, because none of them is -// discoverable: a click on empty space applies an edit-op or a transform, right-click cancels it, -// and Esc downgrades an armed tool to Select before it ever exits the sketch. +// discoverable: Enter (or ✓) applies an edit-op or a transform, Esc drops it, and a second Esc +// downgrades the armed tool to Select before it ever exits the sketch. Right-click only abandons +// the gesture in progress; with nothing in progress it opens the offer. static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks) { using Mode = DesignSketchTool::Mode; @@ -6251,11 +6200,11 @@ static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks : _L("Dimension — click the second point"); case Mode::Line: return step == 0 ? _L("Line — click the start point") - : _L("Line — click the end point, or type the length"); + : _L("Line — click the end point; its length and angle can then be typed"); case Mode::Polyline: return step == 0 ? _L("Polyline — click the first point") : pick_more(_L("Polyline — click the next point · click the start point " - "to close it · right-click to end the chain"), step); + "to close it · right-click, double-click or Enter ends the chain"), step); case Mode::CornerRect: return step == 0 ? _L("Rectangle — click one corner") : _L("Rectangle — click the opposite corner"); @@ -6272,7 +6221,7 @@ static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks : _L("Rounded rectangle — click to set the corner radius"); case Mode::CenterCircle: return step == 0 ? _L("Circle — click the centre") - : _L("Circle — click to set the radius, or type it"); + : _L("Circle — click to set the radius; it can then be typed"); case Mode::TwoPointCircle: return step == 0 ? _L("Circle (2 points) — click one end of the diameter") : _L("Circle (2 points) — click the other end of the diameter"); @@ -6315,60 +6264,60 @@ static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks : _L("Elliptical arc — click where the arc ends"); case Mode::BSpline: return step == 0 ? _L("Spline — click the first control point") - : pick_more(_L("Spline — click the next control point · right-click to " - "finish the curve"), step); + : pick_more(_L("Spline — click the next control point · right-click, " + "double-click or Enter finishes the curve"), step); case Mode::Point: return _L("Point — click to place one; the tool stays armed for more"); case Mode::Trim: - return _L("Trim — click a segment where it crosses another entity · right-click to exit"); + return _L("Trim — click a segment where it crosses another entity · Esc to exit"); case Mode::Extend: return _L("Extend — click a line or arc to grow it out to the nearest entity · " - "right-click to exit"); + "Esc to exit"); case Mode::Fillet: return step == 0 ? _L("Fillet — click the first of two lines that meet") : step == 1 ? _L("Fillet — click the second line") : _L("Fillet — drag the arrow, or click the number to type the radius · " - "click empty space to apply · right-click cancels"); + "Enter applies · Esc cancels"); case Mode::Chamfer: return step == 0 ? _L("Chamfer — click the first of two lines that meet") : step == 1 ? _L("Chamfer — click the second line") : _L("Chamfer — drag the arrow, or click the number to type the setback · " - "click empty space to apply · right-click cancels"); + "Enter applies · Esc cancels"); case Mode::Offset: return step == 0 ? _L("Offset — click the entity to offset") : _L("Offset — drag the arrow to either side, or click the number to type " - "the distance · click empty space to apply · right-click cancels"); + "the distance · Enter applies · Esc cancels"); case Mode::Mirror: // The two-phase pick is the one gesture users reported as unguided: nothing said the - // AXIS comes first, and nothing said an empty click is what applies it. + // AXIS comes first, and nothing said how it is applied. return step == 0 ? _L("Mirror — first click the LINE to mirror about (a construction line works)") : picks == 0 - ? _L("Mirror — axis set · now click the entities to mirror · right-click cancels") + ? _L("Mirror — axis set · now click the entities to mirror · Esc cancels") : wxString::Format(_L("Mirror — axis set · %d to mirror · click another to add or " - "remove it · click empty space to apply"), picks); + "remove it · Enter applies"), picks); case Mode::Move: return step == 0 ? _L("Move — click the entities to move") : pick_more(_L("Move — drag the handle, or click the number to type the " - "distance · click empty space to apply"), picks); + "distance · Enter applies"), picks); case Mode::Rotate: return step == 0 ? _L("Rotate — click the entities to rotate") : pick_more(_L("Rotate — drag the handle, or click the number to type the " - "angle · click empty space to apply"), picks); + "angle · Enter applies"), picks); case Mode::Scale: return step == 0 ? _L("Scale — click the entities to scale") : pick_more(_L("Scale — drag the handle, or click the number to type the " - "factor · click empty space to apply"), picks); + "factor · Enter applies"), picks); case Mode::Array: return step == 0 ? _L("Array — click the entities to repeat") : pick_more(_L("Array — drag the handle to set the step, click the count to " - "type it · click empty space to apply"), picks); + "type it · Enter applies"), picks); case Mode::PolarArray: return step == 0 ? _L("Polar array — click the entities to repeat") : pick_more(_L("Polar array — drag the handle to set the sweep, click the " - "count to type it · click empty space to apply"), picks); + "count to type it · Enter applies"), picks); case Mode::TransformArt: - return _L("Drag a corner to scale, the centre to move · right-click when done"); + return _L("Drag a corner to scale, the centre to move · Enter or ✓ when done, Esc to discard"); } return wxString(); } @@ -6391,9 +6340,7 @@ void DesignPanel::on_sketch_step(int mode, int step, int picks) text += _L(" · click a constraint badge to remove it"); m_sketch_step = text; if (text.IsEmpty() || m_status == nullptr) return; - m_status->SetForegroundColour(wxNullColour); - set_status(text); - m_status->Refresh(); + set_status(StatusKind::Info, text); } wxMenuItem* DesignPanel::append_offer_item(wxMenu* menu, int id, const wxString& text, @@ -6643,13 +6590,11 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) int idx = m_doc.add_mate(opts[i].kind, cs_a, cs_b, 0.0, 0.0, false, "Mate" + std::to_string(++m_feature_counter)); if (idx < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate rejected")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mate rejected")); return; } if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -6670,8 +6615,7 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) const int i = e.GetMenuId() - base; if (i < 0 || i >= int(bound.size()) || bound[i] == nullptr || bound[i]->hint == nullptr) return; - m_status->SetForegroundColour(wxNullColour); - set_status(wxGetTranslation(wxString::FromUTF8(bound[i]->hint))); + set_status(StatusKind::Info, wxGetTranslation(wxString::FromUTF8(bound[i]->hint))); m_status->Update(); // the popup owns the loop; without this the line repaints late }, base, base + 499); // 499: the mate section starts at base + 500 (see mate_base) menu.Bind(wxEVT_MENU, [this, &bound](wxCommandEvent& e) { @@ -6729,10 +6673,8 @@ void DesignPanel::arm_plane_pick(PlanePick target) // and reset_plane_refs() restores it when the pick is abandoned; only the arm side was missing. if (m_viewport) m_viewport->set_escalate_on_repick(false); const bool face = (target == PlanePick::FaceA || target == PlanePick::FaceB); - m_status->SetForegroundColour(wxNullColour); - set_status(face ? _L("Click a solid FACE in the viewport") + set_status(StatusKind::Info, face ? _L("Click a solid FACE in the viewport") : _L("Click a solid EDGE in the viewport")); - m_status->Refresh(); } // --- Axis helpers --- @@ -6769,10 +6711,8 @@ void DesignPanel::arm_axis_pick(AxisPick target) { m_axis_pick = target; if (m_viewport) m_viewport->set_escalate_on_repick(false); // same as arm_plane_pick - m_status->SetForegroundColour(wxNullColour); - set_status(target == AxisPick::Face ? _L("Click a solid FACE in the viewport") + set_status(StatusKind::Info, target == AxisPick::Face ? _L("Click a solid FACE in the viewport") : _L("Click a solid EDGE in the viewport")); - m_status->Refresh(); } // --- CoordSys helpers --- @@ -6837,10 +6777,8 @@ void DesignPanel::arm_coordsys_pick(CoordSysPick target) // escalation is off: clicking the face the card is pointing at is the ANSWER here, not a // request for its body. if (m_viewport) m_viewport->set_escalate_on_repick(false); - m_status->SetForegroundColour(wxNullColour); - set_status(target == CoordSysPick::Face ? _L("Click a solid FACE in the viewport") + set_status(StatusKind::Info, target == CoordSysPick::Face ? _L("Click a solid FACE in the viewport") : _L("Click a solid EDGE in the viewport")); - m_status->Refresh(); } bool DesignPanel::on_add_plane() @@ -6851,9 +6789,7 @@ bool DesignPanel::on_add_plane() // a success — the user asks for one construction and silently receives another. The kernel // keeps its fallback (it must return SOMETHING), but no user gesture should reach it. auto refuse = [this](const wxString& why) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(why); - m_status->Refresh(); + set_status(StatusKind::Error, why); }; switch ((PlaneType)m_plane_type->GetSelection()) { case PlaneType::Angle: @@ -6884,8 +6820,7 @@ bool DesignPanel::on_add_plane() "Plane" + std::to_string(m_feature_counter)); if (idx >= 0 && idx < int(m_doc.features.size())) apply_plane_refs(m_doc.features[idx]); m_doc.recompute(); // datum-only docs yield no body; that is expected/benign - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Plane added — pick it as a sketch plane")); + set_status(StatusKind::Info, _L("Plane added — pick it as a sketch plane")); refresh_tree(); return true; } @@ -6897,8 +6832,7 @@ void DesignPanel::on_add_axis() "Axis" + std::to_string(m_feature_counter)); if (idx >= 0 && idx < int(m_doc.features.size())) apply_axis_refs(m_doc.features[idx]); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Axis added")); + set_status(StatusKind::Info, _L("Axis added")); refresh_tree(); } @@ -6910,8 +6844,7 @@ void DesignPanel::on_add_coordsys() "Coord" + std::to_string(m_feature_counter)); if (idx >= 0 && idx < int(m_doc.features.size())) apply_coordsys_refs(m_doc.features[idx]); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Coord Sys added")); + set_status(StatusKind::Info, _L("Coord Sys added")); refresh_tree(); } @@ -6928,7 +6861,7 @@ void DesignPanel::on_add_shell() "Shell" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -6942,9 +6875,7 @@ void DesignPanel::on_add_draft() return; } if (m_sel_solid_face < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Draft needs a picked face — click a side face first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Draft needs a picked face — click a side face first")); return; } @@ -6953,7 +6884,7 @@ void DesignPanel::on_add_draft() "Draft" + std::to_string(m_feature_counter)); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -7045,27 +6976,35 @@ void DesignPanel::on_tab_shown() if (m_viewport) m_viewport->refresh_bed(); - // Modeling origin = bed centre, set BEFORE any recompute/datum-resolve so sketches and datums - // land in the middle of the bed (not the bed corner = world 0). - if (Plater* pl = wxGetApp().plater()) { - const Vec2d bc = pl->build_volume().bed_center(); - m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0); - } - - // Rehydrate the parametric model from a freshly loaded project (the 3MF carried the - // recipe in Metadata/orca_cad.bin). Only when nothing is in progress here, so we - // never clobber an active design when the user just toggles back to the Design tab. - if (m_doc.features.empty()) { - if (Plater* plater = wxGetApp().plater()) { - const std::string& blob = plater->model().cad_recipe; - if (!blob.empty()) load_recipe(blob); - } - } + hydrate_from_model(); update_reference_planes(); // entering the Design tab: show the XY/XZ/YZ planes if no object yet sync_sidebar_width(); // keep the panel as wide as Prepare's so the canvas edge doesn't jump if (m_viewport) m_viewport->force_repaint(); // the page was just re-shown: paint it for real } +// Rehydrate the parametric model from a freshly loaded project (the 3MF carried the recipe in +// Metadata/orca_cad.bin). Only into an EMPTY document, so an active design is never clobbered +// when the user toggles back to the tab. Called on tab show and by the control socket — which +// used to skip it, work on an empty document, and then overwrite the project's recipe with +// just its own features. +void DesignPanel::hydrate_from_model() +{ + if (!m_doc.features.empty()) return; + Plater* plater = wxGetApp().plater(); + if (plater == nullptr) return; + // Modeling origin = bed centre, set BEFORE any recompute/datum-resolve so sketches and + // datums land in the middle of the bed (not the bed corner = world 0) — only for a document + // 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). + if (!m_doc.origin_from_recipe) { + const Vec2d bc = plater->build_volume().bed_center(); + m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0); + } + const std::string& blob = plater->model().cad_recipe; + if (!blob.empty()) load_recipe(blob); +} + // Application close / language switch, from the plater's canvas teardown. void DesignPanel::unbind_canvas_event_handlers() { @@ -7102,11 +7041,9 @@ void DesignPanel::load_recipe(const std::string& blob) // the user unable to tell "update OrcaSlicer" from "your file is damaged". Same // error-loss class as the 31 McpControl sites (1de72de9ed): the message exists, it was // simply not passed on. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(m_doc.error.empty() + set_status(StatusKind::Error, m_doc.error.empty() ? _L("Could not restore the CAD model from this project") - : _L("Could not restore the CAD model: ") + wxString::FromUTF8(m_doc.error)); - m_status->Refresh(); + : wxString::Format(_L("Could not restore the CAD model: %s"), wxString::FromUTF8(m_doc.error))); return; } m_feature_counter = int(m_doc.features.size()); @@ -7256,7 +7193,6 @@ void DesignPanel::toggle_section_view() { if (!m_viewport) return; m_section_on = !m_section_on; - m_status->SetForegroundColour(wxNullColour); if (m_section_on) { m_section_cut_z = m_viewport->model_mid_z(); // start at the model's mid-height m_section_upper = false; // keep the lower half by default @@ -7276,10 +7212,8 @@ void DesignPanel::flip_section_view() if (!m_viewport || !m_section_on) return; m_section_upper = !m_section_upper; m_viewport->set_section_plane(true, m_section_cut_z, m_section_upper); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Section view — showing the %s half"), + set_status(StatusKind::Info, wxString::Format(_L("Section view — showing the %s half"), m_section_upper ? _L("upper") : _L("lower"))); - m_status->Refresh(); } void DesignPanel::sync_body_visible() @@ -7346,9 +7280,7 @@ void DesignPanel::feed_bodies() void DesignPanel::on_boolean_tool() { if (m_doc.bodies.size() < 2) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Boolean needs two bodies — create or import a second solid")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Boolean needs two bodies — create or import a second solid")); return; } populate_body_choices(); @@ -7362,10 +7294,8 @@ void DesignPanel::on_move_body() 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. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(b < 0 ? _L("Select a body first — click it in the viewport or the Bodies list") + set_status(StatusKind::Error, b < 0 ? _L("Select a body first — click it in the viewport or the Bodies list") : _L("That body has no display mesh yet — recompute first")); - m_status->Refresh(); return; } sync_body_xform(); @@ -7388,9 +7318,7 @@ 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 - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); } // Transform card (add mode): arm the same move gizmo on the card's body so the geometry-first @@ -7422,9 +7350,7 @@ void DesignPanel::arm_transform_gizmo() if (m_xf_dy) m_xf_dy->SetValue(0.0); if (m_xf_dz) m_xf_dz->SetValue(0.0); if (m_xf_angle) m_xf_angle->SetValue(0.0); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Drag the arrows to move, the rings to rotate — the numbers follow")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Drag the arrows to move, the rings to rotate — the numbers follow")); } // Color tool: open a colour picker on the selected body and store a per-body display-colour @@ -7437,9 +7363,7 @@ void DesignPanel::on_set_body_color() int b = tree_body_selection(); if (b < 0) b = m_sel_solid_body; if (b < 0 || b >= int(m_doc.bodies.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a body first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a body first")); return; } @@ -7457,9 +7381,7 @@ void DesignPanel::on_set_body_color() (unsigned char)picked.Blue(), (unsigned char)255); feed_bodies(); // same refresh path the visibility toggle uses → viewport updates immediately - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Body %d colour set"), b + 1)); - m_status->Refresh(); + set_status(StatusKind::Info, wxString::Format(_L("Body %d colour set"), b + 1)); } // Prepare's "Place on Face" (F), ported to Design. Pick a body face, then this rotates the @@ -7472,9 +7394,7 @@ bool DesignPanel::place_on_face() const int b = m_sel_solid_body; if (b < 0 || b >= int(m_doc.bodies.size()) || m_sel_solid_face < 0 || b >= int(m_doc.display_body_meshes.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Click a face on the solid, then press F")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Click a face on the solid, then press F")); return false; } const TopoDS_Face face = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); @@ -7495,9 +7415,7 @@ bool DesignPanel::place_on_face() x = Transform3d(Eigen::Translation3d(0.0, 0.0, -probe.bounding_box().min.z())) * x; m_body_xform[b] = x; set_status_ok(); // rebuild display/pick meshes, re-point picking; resets face selection - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Placed on face — body laid flat on the bed")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Placed on face — body laid flat on the bed")); return true; } @@ -7523,13 +7441,10 @@ void DesignPanel::after_tree_edit(bool ok) refresh_variables(); if (!ok) { // The edit was rolled back (recompute failed); the body is unchanged. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Edit rejected: ") + wxString::FromUTF8(m_doc.error)); - m_status->Refresh(); + set_status(StatusKind::Error, wxString::Format(_L("Edit rejected: %s"), kernel_error_text(m_doc.error))); return; } sync_recipe_to_model(); // deletes, reorders and suppressions change the document too - m_status->SetForegroundColour(wxNullColour); if (m_doc.display_mesh.its.indices.empty()) { if (m_viewport != nullptr) m_viewport->clear_mesh(); sync_sketch_display(); // empty body: show any un-consumed committed sketch @@ -7589,18 +7504,14 @@ void DesignPanel::on_delete_body() { const int nb = int(m_doc.bodies.size()); if (m_sel_solid_body < 0 || m_sel_solid_body >= nb) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a body first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a body first")); return; } const int src = m_doc.bodies[m_sel_solid_body].source_feature; if (src < 0 || src >= int(m_doc.features.size())) { // Only reachable for a body no feature claims — a stale recipe, or a feature type that // broke the "never replace a whole CadBody" invariant recompute() relies on. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("This body has no feature to delete — use New Design to start over")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("This body has no feature to delete — use New Design to start over")); return; } const std::string& raw = m_doc.features[src].name; @@ -7628,9 +7539,7 @@ void DesignPanel::on_delete_feature() // A Body row has no directly-removable feature (bodies are recomputed results); guide the // user to delete the feature that created it, or use New Design to wipe everything. if (tree_body_selection() >= 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select the FEATURE that created this body (or use New Design)")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select the FEATURE that created this body (or use New Design)")); return; } int sel = tree_selection(); @@ -7674,33 +7583,25 @@ void DesignPanel::on_toggle_visibility() // fell through to the FEATURE-level branch below instead of un-hiding the body. if (m_parts != nullptr && bsel < int(m_tree_body_items.size())) m_parts->SelectItem(m_tree_body_items[bsel]); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(now_visible ? _L("Body %d shown") + set_status(StatusKind::Info, wxString::Format(now_visible ? _L("Body %d shown") : _L("Body %d hidden"), bsel + 1)); - m_status->Refresh(); } return; } int sel = tree_selection(); if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a feature in the tree first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a feature in the tree first")); return; } const bool shown = !m_doc.features[sel].enabled; - m_doc.features[sel].enabled = shown; - - // recompute() reports an all-hidden / sketch-only document as false (no - // solid to build), but that is a VALID state for hide — so clear the body - // explicitly instead of letting after_tree_edit treat it as a rejected edit - // (which would skip the overlay refresh, leaving hidden art on screen). - if (!recompute_guarded(_L("Rebuilding model…"))) { - m_doc.body = TopoDS_Shape(); - m_doc.display_mesh = TriangleMesh{}; - m_doc.error.clear(); - } + // Through the document, not a bare flag flip: hiding a feature that makes a body shifts + // every later body index, and set_feature_enabled keeps those references on their bodies + // (and datum-plane references on their planes). It accepts an all-hidden / sketch-only + // result as the valid empty state it is, and rolls back — saying why — when a later + // feature cannot do without what was hidden. + m_doc.checkpoint(); + if (!m_doc.set_feature_enabled(sel, shown)) { after_tree_edit(false); return; } refresh_tree(); // greys the row set_tree_selection(sel); // keep the toggled feature selected if (m_viewport != nullptr) { @@ -7708,9 +7609,7 @@ void DesignPanel::on_toggle_visibility() else feed_bodies(); } sync_sketch_display(); // skips the hidden sketch + direct-renders - m_status->SetForegroundColour(wxNullColour); - set_status(shown ? _L("Feature shown") : _L("Feature hidden")); - m_status->Refresh(); + set_status(StatusKind::Info, shown ? _L("Feature shown") : _L("Feature hidden")); } void DesignPanel::on_move_feature(int delta) @@ -7742,9 +7641,7 @@ bool DesignPanel::enter_constrain_inline() m_viewport->finish_sketch(); // synchronous: packages live entities+constraints -> Sketch const int sk = resolve_extrude_sketch(); // last/selected Sketch feature if (sk < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Draw a sketch first, then Constrain")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Draw a sketch first, then Constrain")); return false; } set_tree_selection(sk); // tree drives on_begin_constrain / the constraint manager @@ -7768,16 +7665,12 @@ void DesignPanel::on_begin_constrain(int sel_override) set_tree_selection(sel); // keep the tree in step with what the viewport says } if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a sketch in the tree first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a sketch in the tree first")); return; } CadFeature& f = m_doc.features[sel]; if (f.type != CadFeatureType::Sketch) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Selected feature is not a sketch")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Selected feature is not a sketch")); return; } @@ -7786,17 +7679,13 @@ void DesignPanel::on_begin_constrain(int sel_override) if (!f.entities.empty()) { m_constrain_feat = sel; if (m_viewport) m_viewport->begin_constrain_entities(f.entities, f.plane); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Pick 1-2 lines, then a constraint; right-click exits")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Pick 1-2 lines, then a constraint; right-click exits")); return; } // Legacy profile path (Fase 3). if (f.profile.points.size() < 3) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Selected feature is not a sketch")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Selected feature is not a sketch")); return; } m_constrain_feat = sel; @@ -7805,9 +7694,7 @@ void DesignPanel::on_begin_constrain(int sel_override) if (f.constraints.empty()) f.constraints.push_back(SketchConstraintDef{SketchConstraintType::Fix, 0, -1, -1, -1, 0.0}); if (m_viewport) m_viewport->begin_constrain(f.profile, f.plane); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Pick 1-2 entities, then a constraint or dimension; right-click exits")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Pick 1-2 entities, then a constraint or dimension; right-click exits")); } // Why an entity-constraint pick was refused, as a localized string. The kernel's @@ -7863,9 +7750,7 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type) const ConstraintPlan plan = plan_entity_constraint(feat.entities, e0, e1, e2, type); auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; switch (plan.kind) { @@ -7902,9 +7787,7 @@ void DesignPanel::apply_live_constraint(SketchConstraintType type) m_viewport->sketch_entities(), e0, e1, e2, type); auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; // Shared commit: try_add_constraints appends→solves→keeps-or-rolls-back and leaves the // geometry untouched on failure, so the same over-constrained message the committed path @@ -7916,7 +7799,6 @@ void DesignPanel::apply_live_constraint(SketchConstraintType type) return; } m_viewport->request_repaint(); - m_status->SetForegroundColour(wxNullColour); // Say where it went and how to undo it, here at the moment of applying: the hint line // only refreshes when the step tuple changes, which applying a constraint does not. set_status(_L("Applied constraint · its badge is on the sketch — click the badge to remove it")); @@ -7965,9 +7847,7 @@ void DesignPanel::commit_entity_constraints(const std::vectorSetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Constraint rejected (over-constrained)")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Constraint rejected (over-constrained)")); return; } m_doc.recompute(); @@ -7978,9 +7858,7 @@ void DesignPanel::commit_entity_constraints(const std::vectorupdate_constrain_entities(m_doc.features[m_constrain_feat].entities); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Applied constraint")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Applied constraint")); refresh_constrain_dof(); // P3 DoF readout for the Constrain path rebuild_constraint_list(); // C3.4 manager: a row appeared @@ -8166,9 +8044,7 @@ void DesignPanel::delete_constraint(int idx) if (live_constraint_scope()) { if (!m_viewport->remove_sketch_constraint(idx)) return; - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Constraint deleted")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Constraint deleted")); return; } if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport) @@ -8188,9 +8064,7 @@ void DesignPanel::delete_constraint(int idx) m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Constraint deleted")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Constraint deleted")); refresh_constrain_dof(); rebuild_constraint_list(); } @@ -8199,15 +8073,11 @@ void DesignPanel::apply_edit_op(EditOp op) { if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport || !m_viewport->is_constraining_entities()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Press Constrain on a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Press Constrain on a sketch first")); return; } auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; int e0 = -1, e1 = -1; @@ -8263,8 +8133,7 @@ void DesignPanel::apply_edit_op(EditOp op) if (a >= int(f.entities.size())) return; auto out = SketchEngine::offset_entities({ f.entities[a] }, d); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Offset collapsed the entity")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Offset collapsed the entity")); return; } const int ni = int(f.entities.size()); // offset copy lands here for (auto& o : out) f.entities.push_back(o); @@ -8305,9 +8174,7 @@ void DesignPanel::apply_edit_op(EditOp op) if (a >= int(f.entities.size()) || b >= int(f.entities.size())) return; SketchEntity a_out, b_out, arc_out; if (!SketchEngine::fillet_lines(f.entities[a], f.entities[b], r, a_out, b_out, arc_out)) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Fillet failed (parallel lines or radius too large)")); - m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Fillet failed (parallel lines or radius too large)")); return; } f.entities[a] = a_out; f.entities[b] = b_out; @@ -8386,9 +8253,7 @@ void DesignPanel::apply_edit_op(EditOp op) if (a >= int(f.entities.size()) || b >= int(f.entities.size())) return; SketchEntity a_out, b_out, seg_out; if (!SketchEngine::chamfer_lines(f.entities[a], f.entities[b], d, a_out, b_out, seg_out)) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Chamfer failed (parallel lines or distance too large)")); - m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Chamfer failed (parallel lines or distance too large)")); return; } f.entities[a] = a_out; f.entities[b] = b_out; @@ -8575,8 +8440,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto copies = SketchEngine::array_entities( { src }, count, sp * dir, 0.0, Vec2d(0, 0)); if (copies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Array produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Array produced nothing")); return; } const int base = int(f.entities.size()); // first copy index for (auto& c : copies) f.entities.push_back(c); @@ -8653,8 +8517,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto out = SketchEngine::transform_entities( { f.entities[a] }, Vec2d(dx, dy), 0.0, 1.0, Vec2d(0, 0)); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Move produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Move produced nothing")); return; } f.entities[a] = out[0]; @@ -8721,8 +8584,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto out = SketchEngine::transform_entities( { f.entities[a] }, Vec2d(0, 0), deg * M_PI / 180.0, 1.0, piv); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Rotate produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Rotate produced nothing")); return; } f.entities[a] = out[0]; @@ -8780,8 +8642,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto out = SketchEngine::transform_entities( { f.entities[a] }, Vec2d(0, 0), 0.0, sf, piv); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Scale produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Scale produced nothing")); return; } f.entities[a] = out[0]; @@ -8850,8 +8711,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto copies = SketchEngine::array_entities( { src }, count, Vec2d(0, 0), angle_step, piv); if (copies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Polar array produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Polar array produced nothing")); return; } const int base = int(f.entities.size()); // first copy index for (auto& c : copies) f.entities.push_back(c); @@ -8923,9 +8783,7 @@ void DesignPanel::after_edit_op() sync_sketch_display(); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Applied edit")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Applied edit")); refresh_constrain_dof(); rebuild_constraint_list(); } @@ -8945,9 +8803,7 @@ void DesignPanel::request_value(const wxString& label, double def, double mn, do m_form->FitInside(); m_value_input->SetFocus(); m_value_input->SetSelection(-1, -1); // select all so typing replaces the value - m_status->SetForegroundColour(wxNullColour); - set_status(label + _L(" — type a value, press Enter (or Confirm)")); - m_status->Refresh(); + set_status(StatusKind::Info, label + _L(" — type a value, press Enter (or Confirm)")); } void DesignPanel::confirm_value() @@ -8976,9 +8832,7 @@ void DesignPanel::cancel_value() update_cards_frame(); m_form->Layout(); m_form->FitInside(); if (was_open) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } if (on_cancel) on_cancel(); // e.g. keep a pending line segment as drawn @@ -9000,9 +8854,7 @@ void DesignPanel::apply_constraint(SketchConstraintType type) if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || m_viewport == nullptr) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Press Constrain on a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Press Constrain on a sketch first")); return; } @@ -9014,16 +8866,12 @@ void DesignPanel::apply_constraint(SketchConstraintType type) // 3. Legacy profile path. if (!m_viewport->is_constraining()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Press Constrain on a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Press Constrain on a sketch first")); return; } int a = -1, b = -1; if (!m_viewport->selected_segment(a, b)) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Pick a segment in the viewport first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Pick a segment in the viewport first")); return; } CadFeature& feat = m_doc.features[m_constrain_feat]; @@ -9036,9 +8884,7 @@ void DesignPanel::apply_constraint(SketchConstraintType type) feat.constraints.pop_back(); // reject the non-converging addition feat.profile.points = saved_pts; // and restore the pre-solve geometry m_doc.abandon_checkpoint(); // restored: no state change, so no undo step - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Constraint rejected (over-constrained)")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Constraint rejected (over-constrained)")); return; } m_doc.recompute(); @@ -9047,10 +8893,8 @@ void DesignPanel::apply_constraint(SketchConstraintType type) m_viewport->update_constrain_profile(m_doc.features[m_constrain_feat].profile.points); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(type == SketchConstraintType::Horizontal ? _L("Applied Horizontal") + set_status(StatusKind::Info, type == SketchConstraintType::Horizontal ? _L("Applied Horizontal") : _L("Applied Vertical")); - m_status->Refresh(); } void DesignPanel::reset_edit_state() @@ -9433,9 +9277,7 @@ void DesignPanel::on_edit_feature() m_viewport->set_display_sketches({}); m_viewport->edit_sketch(f.entities, f.entity_constraints, f.plane); } - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Editing sketch — drag a handle or click a quote to edit")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Editing sketch — drag a handle or click a quote to edit")); } else { load_feature_into_dialog(f); open_tool(Tool::Sketch); @@ -9610,16 +9452,12 @@ void DesignPanel::on_edit_feature() // job, and that feature already exists — so point there rather than invent a dialog // that would only duplicate it. (Imported 2D Text/SVG art is different and IS // re-editable; it arrives as a Sketch feature with imported_regions, handled above.) - m_status->SetForegroundColour(wxNullColour); - set_status(_L("An imported solid has no parameters — use Transform to move or rotate it")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("An imported solid has no parameters — use Transform to move or rotate it")); break; default: // Every CadFeatureType now has a case. Kept as a guard so a type added later // announces itself instead of silently swallowing the Edit click. - m_status->SetForegroundColour(wxNullColour); - set_status(_L("This feature type can't be edited yet")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("This feature type can't be edited yet")); break; } } @@ -9630,9 +9468,7 @@ void DesignPanel::on_export_step() if (m_active != Tool::None) confirm_tool(); if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Nothing to export — add a feature first")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Nothing to export — add a feature first")); return; } wxFileDialog dlg(this, _L("Export STEP"), wxEmptyString, "model.step", @@ -9643,9 +9479,9 @@ void DesignPanel::on_export_step() sync_body_xform(); // export bodies at their displayed Move-gizmo positions std::string err; const bool ok = m_doc.export_step(dlg.GetPath().ToUTF8().data(), m_body_xform, err); - m_status->SetForegroundColour(ok ? wxColour(120, 210, 120) : wxColour(235, 110, 110)); - set_status(ok ? _L("Exported STEP") - : _L("STEP export failed: ") + wxString::FromUTF8(err)); + set_status(ok ? StatusKind::Ok : StatusKind::Error, + ok ? _L("Exported STEP") + : wxString::Format(_L("STEP export failed: %s"), wxString::FromUTF8(err))); m_status->Refresh(); } @@ -9787,6 +9623,10 @@ CadFeature DesignPanel::build_candidate(Tool t) const f.thread_internal = m_thread_internal->GetValue(); f.thread_x = m_thread_x->GetValue(); f.thread_y = m_thread_y->GetValue(); + // The field is the nominal (major) diameter. A thread from an older project being + // re-edited keeps the reading it was made with (f was seeded from it), so editing its + // pitch does not also silently change its diameter. + if (!editing) f.thread_major_nominal = true; if (m_thread_on_face) f.target_body = m_thread_face_body; // tap the right body break; case Tool::Shell: @@ -9824,6 +9664,13 @@ CadFeature DesignPanel::build_candidate(Tool t) const f.pattern_spacing = m_pattern_spacing->GetValue(); f.pattern_dir = m_pattern_dir->GetSelection(); f.pattern_angle = m_pattern_angle->GetValue(); + if (!editing) { + // What add_pattern commits, so the preview is the pattern that will be made: the + // bed-centred XY plane, and copies spanning the whole angle. + f.pattern_inclusive = true; + f.plane = SketchPlane::XY(); + f.plane.origin += m_doc.modeling_origin; + } break; case Tool::Plane: f.type = CadFeatureType::Plane; @@ -10669,7 +10516,6 @@ void DesignPanel::refresh_preview() if (m_active == Tool::Sketch || m_active == Tool::Plane || m_active == Tool::Axis || m_active == Tool::CoordSys || m_active == Tool::Helix || m_active == Tool::Project) { m_viewport->clear_preview(); - m_status->SetForegroundColour(wxColour(120, 210, 120)); wxString ready; switch (m_active) { case Tool::Plane: ready = _L("Plane ready"); break; @@ -10679,7 +10525,7 @@ void DesignPanel::refresh_preview() case Tool::Project: ready = _L("Project ready"); break; default: ready = _L("Sketch ready"); break; } - set_status(ready); + set_status(StatusKind::Ok, ready); for (wxButton* b : m_confirm_btns) if (b) b->Enable(true); m_status->Refresh(); update_datum_gizmo(); // Plane card: show/refresh the in-canvas resize handles @@ -10703,13 +10549,11 @@ void DesignPanel::refresh_preview() if (!has_two) { m_viewport->clear_preview(); m_viewport->set_body_hidden(false); // the ghost replaced them; give them back - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate needs at least two CoordSys features")); + set_status(StatusKind::Error, _L("Mate needs at least two CoordSys features")); } else if (same) { m_viewport->clear_preview(); m_viewport->set_body_hidden(false); - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate: CS A and CS B must be different")); + set_status(StatusKind::Error, _L("Mate: CS A and CS B must be different")); } else { sync_body_xform(); // same reason as the solid path below: drop stale per-body poses const int cs_a = int(reinterpret_cast(m_mate_cs_a->GetClientData(sel_a))); @@ -10720,11 +10564,9 @@ void DesignPanel::refresh_preview() m_mate_angle ? m_mate_angle->GetValue() : 0.0, m_mate_flip && m_mate_flip->GetValue(), err); if (ok) { - m_status->SetForegroundColour(wxColour(120, 210, 120)); - set_status(_L("Mate ready")); + set_status(StatusKind::Ok, _L("Mate ready")); } else { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Invalid: ") + wxString::FromUTF8(err)); + set_status(StatusKind::Error, wxString::Format(_L("Invalid: %s"), kernel_error_text(err))); } } for (wxButton* b : m_confirm_btns) if (b) b->Enable(ok); @@ -10766,12 +10608,10 @@ void DesignPanel::refresh_preview() if (ok) { m_viewport->set_preview_mesh(mesh); - m_status->SetForegroundColour(wxColour(120, 210, 120)); // ok = green - set_status(wxString::Format(_L("Preview — %zu triangles"), mesh.its.indices.size())); + set_status(StatusKind::Ok, wxString::Format(_L("Preview — %zu triangles"), mesh.its.indices.size())); } else { m_viewport->clear_preview(); - m_status->SetForegroundColour(wxColour(235, 110, 110)); // invalid = red - set_status(_L("Invalid: ") + wxString::FromUTF8(err)); + set_status(StatusKind::Error, wxString::Format(_L("Invalid: %s"), kernel_error_text(err))); } // Onshape parity: a broken candidate cannot be committed. Grey the active dialog's // Confirm so the user sees the gate before clicking; the red status says why. @@ -10848,11 +10688,9 @@ void DesignPanel::apply_move_card() m_body_xform[b] = x * m_move_prev; feed_bodies(); if (m_viewport) m_viewport->request_repaint(); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Body %d — moved (%.1f, %.1f, %.1f) mm, rotated %.1f°"), + set_status(StatusKind::Info, wxString::Format(_L("Body %d — moved (%.1f, %.1f, %.1f) mm, rotated %.1f°"), b + 1, d.x(), d.y(), d.z(), m_move_angle ? m_move_angle->GetValue() : 0.0)); - m_status->Refresh(); } void DesignPanel::show_move_card(bool show) @@ -11280,9 +11118,7 @@ void DesignPanel::cancel_tool() close_tool(); // Cancel discards the candidate: clear the stale "Preview …"/"Invalid …" // label and restore the neutral idle colour (Confirm keeps its "OK" status). - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } // One Confirm surface for the whole tab. Routes to the right commit by current context: @@ -11301,7 +11137,10 @@ void DesignPanel::tool_confirm() if (m_active == Tool::Insert) { finalize_insert(); return; } if (m_active != Tool::None) { confirm_tool(); return; } if (m_ui_mode == UiMode::Sketch) { - if (m_viewport && m_viewport->is_sketching()) m_viewport->finish_sketch(); + if (m_viewport && m_viewport->is_sketching()) { + m_viewport->sketch_confirm_pending(); // a ready edit-op/transform/chain is kept, not dropped + m_viewport->finish_sketch(); + } set_ui_mode(UiMode::Feature); return; } @@ -11310,9 +11149,7 @@ void DesignPanel::tool_confirm() if (m_viewport) m_viewport->end_constrain(); m_constrain_feat = -1; set_ui_mode(UiMode::Feature); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } } @@ -11330,9 +11167,7 @@ void DesignPanel::tool_cancel() show_move_card(false); feed_bodies(); // re-render the reverted placement update_action_bar(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Move cancelled")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Move cancelled")); return; } if (m_active == Tool::Insert) { cancel_insert(); return; } @@ -11356,9 +11191,7 @@ void DesignPanel::tool_cancel() set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); return; } if (m_ui_mode == UiMode::Constrain) { @@ -11366,12 +11199,24 @@ void DesignPanel::tool_cancel() if (m_viewport) m_viewport->end_constrain(); m_constrain_feat = -1; set_ui_mode(UiMode::Feature); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } } +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; + if (m_active == Tool::Insert) return true; + for (wxButton* b : m_confirm_btns) + if (b != nullptr && b->IsShownOnScreen()) return b->IsEnabled(); + for (wxButton* b : m_confirm_btns) + if (b != nullptr) return b->IsEnabled(); + return true; +} + // Which level of the interaction stack one Esc press belongs to. The rule itself lives in // DesignInteraction.hpp, decidable without a window; this only answers the four questions it // asks about THIS panel. @@ -11415,9 +11260,7 @@ void DesignPanel::escape() // puts the body back at the pose it had when the gizmo appeared. if (m_viewport && m_viewport->drawing_in_progress()) { m_viewport->sketch_abort_gesture(); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); return; } tool_cancel(); @@ -11430,9 +11273,7 @@ void DesignPanel::escape() // 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_viewport && m_viewport->sketch_disarm_tool()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Select")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select")); } return; @@ -11443,9 +11284,7 @@ void DesignPanel::escape() if (m_viewport && m_viewport->clear_any_selection()) { m_sel_sketch_region = -1; m_sel_sketch_feat = -1; - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); return; } // Nothing selected and nothing to unwind. An EMPTY sketch session may as well close — @@ -11456,20 +11295,31 @@ void DesignPanel::escape() return; } if (m_ui_mode == UiMode::Sketch) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch kept — Finish to commit it, Cancel to discard")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Sketch kept — Finish to commit it, Cancel to discard")); } return; } } +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; + return redo ? m_doc.can_redo() : m_doc.can_undo(); +} + void DesignPanel::update_undo_redo_buttons() { // Grey Undo/Redo to mirror exactly what do_undo_redo will do: it acts only in Feature // mode with no tool/dialog open (otherwise Esc is the way out), so reflect that gate here // as well as the document's available history. if (m_btn_undo == nullptr || m_btn_redo == nullptr) return; + if (m_ui_mode == UiMode::Sketch && m_viewport && m_viewport->is_sketching()) { + m_btn_undo->Enable(m_viewport->can_undo_sketch_entity()); + m_btn_redo->Enable(m_viewport->can_redo_sketch_entity()); + return; + } const bool gated = (m_ui_mode != UiMode::Feature) || (m_active != Tool::None); m_btn_undo->Enable(!gated && m_doc.can_undo()); m_btn_redo->Enable(!gated && m_doc.can_redo()); @@ -11509,20 +11359,24 @@ void DesignPanel::update_action_bar() void DesignPanel::do_undo_redo(bool redo) { + // Inside a live sketch, history is the sketch's own: the last drawn shape comes off, and + // comes back. Keys and buttons both land here, so they cannot disagree. + if (m_ui_mode == UiMode::Sketch && m_viewport && m_viewport->is_sketching()) { + const bool ok = redo ? m_viewport->redo_last_sketch_entity() : m_viewport->undo_last_sketch_entity(); + if (!ok) set_status(redo ? _L("Nothing to redo") : _L("Nothing to undo")); + update_undo_redo_buttons(); + return; + } // 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) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Finish or cancel the current tool first (Esc)")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Finish or cancel the current tool first (Esc)")); return; } const bool ok = redo ? m_doc.redo() : m_doc.undo(); if (!ok) { - m_status->SetForegroundColour(wxNullColour); - set_status(redo ? _L("Nothing to redo") : _L("Nothing to undo")); - m_status->Refresh(); + set_status(StatusKind::Info, redo ? _L("Nothing to redo") : _L("Nothing to undo")); return; } // The solid whole/face/edge pick and any in-place edit reference ids that recompute() @@ -11531,10 +11385,8 @@ void DesignPanel::do_undo_redo(bool redo) m_pick_face = m_pick_face_body = -1; // recompute() invalidated the face ids too reset_edit_state(); after_tree_edit(true); // refresh tree + viewport meshes + status from the restored doc - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(redo ? _L("Redo (%zu more)") : _L("Undo (%zu more)"), + set_status(StatusKind::Info, wxString::Format(redo ? _L("Redo (%zu more)") : _L("Undo (%zu more)"), redo ? m_doc.redo_depth() : m_doc.undo_depth())); - m_status->Refresh(); } // --- Document variables panel --------------------------------------------------------- @@ -11557,9 +11409,7 @@ void DesignPanel::on_add_variable() if (name.IsEmpty()) return; name.Trim(true).Trim(false); if (name.Contains(' ')) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Variable name must not contain spaces")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Variable name must not contain spaces")); return; } wxString expr = ::wxGetTextFromUser( @@ -11649,7 +11499,7 @@ std::vector DesignPanel::fields_for_tool(Tool t) case T::Extrude: return {"distance", "distance2", "taper_deg"}; case T::Dressup: return {"dressup_size"}; case T::Hole: return {"hole_diameter", "hole_depth", "hole_x", "hole_y"}; - case T::Thread: return {"thread_radius", "thread_pitch", "thread_height", "thread_depth", "thread_x", "thread_y"}; + case T::Thread: return {"thread_diameter", "thread_pitch", "thread_height", "thread_depth", "thread_x", "thread_y"}; case T::Shell: return {"shell_thickness"}; case T::Revolve: return {"revolve_angle"}; case T::Sweep: return {}; @@ -11702,6 +11552,12 @@ void DesignPanel::on_set_expr() const std::string field = fwx.ToUTF8().data(); const std::string expr = ewx.ToUTF8().data(); + // The picker is editable; a name the evaluator does not know would make every later + // recompute fail, so it is refused here with the reason. + if (!CadDocument::is_bindable_field(field)) { + set_status(StatusKind::Error, wxString::Format(_L("\"%s\" is not a value an expression can drive"), fwx)); + return; + } m_doc.checkpoint(); m_doc.features[m_edit_index].expr[field] = expr; @@ -11741,9 +11597,7 @@ void DesignPanel::on_clear_expr() const std::string field = fwx.ToUTF8().data(); auto& feat_expr = m_doc.features[m_edit_index].expr; if (feat_expr.find(field) == feat_expr.end()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No binding for that field")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No binding for that field")); return; } diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 16f0a2722c..34de9258aa 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -87,6 +87,13 @@ public: // Defined out of line in DesignPanel.cpp: it needs kOfferVerbs, which this header deliberately // does not include (the table is generated and belongs to the offer-menu code). bool mcp_run_verb(const char* verb_id); + // Would a request from the control socket collide with what the GUI is doing — a rebuild + // in progress, an open feature card, a sketch session? `sketch_method` = it drives the live + // sketch rather than the feature list. Fills `why` when it would. + bool mcp_busy(bool sketch_method, std::string& why) const; + // Load the project's recipe into an empty document, as showing the tab does. The control + // socket may be the first thing to touch the Design tab after a project was opened. + void hydrate_from_model(); private: enum class Tool { None, Sketch, Extrude, Dressup, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Boolean, Cut, Insert, Axis, CoordSys, SurfaceExtrude, SurfaceRevolve, SurfaceLoft, SurfaceFill, SurfaceOffset, ThickenSurface, Transform, Mirror, Thicken, Rib, Project, DeleteFace, Helix, Mate }; @@ -115,6 +122,7 @@ private: // used to be handled in four places that could not see each other, and that is how two // presses in a row reached past a tool and discarded the sketch under it. CadLevel escape_level() const; + bool confirm_enabled() const; // would the ✓ act right now (and is it not greyed)? void escape(); void update_action_bar(); // show the ✓/✗ bar iff a tool or mode is active @@ -125,7 +133,7 @@ private: void on_add_hole(); void on_add_thread(); void apply_thread_standard(); // fill pitch/depth/radius from m_thread_std selection - void infer_thread_spec(double diameter); // nearest M-standard from a picked cylinder diameter + void infer_thread_spec(double diameter, bool internal); // nearest standard from a picked cylinder void on_add_revolve(); void on_add_sweep(); void on_add_loft(); @@ -275,6 +283,12 @@ private: // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport. With a tool/dialog open it // cancels that (Esc-like); otherwise it undoes/redoes the committed feature history. void do_undo_redo(bool redo); +public: + // Edit > Undo / Redo while this tab is shown: the same route and the same gate as the keys + // and the toolbar buttons. + void menu_undo_redo(bool redo) { do_undo_redo(redo); } + bool menu_can_undo_redo(bool redo) const; +private: // The plane the Hole tool drills on: a picked face (inward, centred) or the dropdown. SketchPlane hole_plane() const; // The plane the Thread tool builds on: a picked cylindrical face (axis) or the dropdown. @@ -718,8 +732,13 @@ private: std::map> m_verb_actions; // Append an offer row with its toolbar glyph. The bitmap must be set BEFORE Append — // wxGTK builds the GtkMenuItem there and only makes an image item if one is present. - // Every status write goes through here so long hints wrap instead of clipping. - void set_status(const wxString& text); + // Every status write goes through here so long hints wrap instead of clipping. The kind + // sets the colour AND a leading glyph, so a message's meaning never rests on colour alone + // (charter 6.2), and every message sets its own kind instead of inheriting the last one's. + enum class StatusKind { Info, Ok, Warning, Error }; + void set_status(StatusKind kind, const wxString& text); + void set_status(const wxString& text) { set_status(StatusKind::Info, text); } + static wxString kernel_error_text(const std::string& err); // a kernel message, in words a modeller reads wxString idle_hint() const; // what to say when nothing is selected // Reason detect_mate_conflicts() recorded for a feature, or nullptr. Marks the tree row and // feeds the status line; a conflict is a diagnostic, not a document error. diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 078c33752e..376392c23e 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -4,6 +4,8 @@ #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/CAD/SketchInlineEditor.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "libslic3r/format.hpp" #include #include @@ -74,6 +76,7 @@ static void push_auto_close_pref() void DesignSketchTool::begin(const SketchPlane& plane, Mode mode) { + m_sketch_redo.clear(); push_auto_close_pref(); m_plane = plane; @@ -234,6 +237,9 @@ void DesignSketchTool::set_tool(Mode mode) // This MUST run before m_mode is reassigned: op_ready() and confirm_op() both switch on // m_mode, so after the assignment they would test the tool being switched TO. That read // op_ready()==0 with a=0 b=3 val=28.205 sitting right there — picked, valued, and dropped. + // Charter 4.2: "starting another operation while a valid feature is pending commits it" — + // the ghost is on screen, so switching away keeps it. Esc is the discard (disarm_tool drops + // the pending op/transform before it gets here). if (op_ready()) confirm_op(); // And the same rule for the transform gizmo: a ready edit-op committed here while a pending // transform was silently dropped, which is the same "the value was set, the ghost was drawn, @@ -253,6 +259,7 @@ void DesignSketchTool::set_tool(Mode mode) m_mode = mode; m_points.clear(); m_has_cursor = false; + m_cursor_snap = InferenceSnap{}; // the previous tool's snap marker is not this tool's // DRAIN THE QUEUED DIMENSIONS, do not just resync the baseline. on_inline_commit() above // commits the field that is open, and the commit callback installed by // open_next_autoedit_dim does `++m_autoedit_dim_idx; CallAfter(open_next_autoedit_dim)` — @@ -312,6 +319,7 @@ void DesignSketchTool::set_tool(Mode mode) void DesignSketchTool::cancel() { + m_sketch_redo.clear(); close_session_chrome(); // same orphaned-field freeze as finish() — see yce m_active = false; m_step_mode_last = -1; @@ -339,22 +347,71 @@ void DesignSketchTool::cancel() reset_tf(); } +bool DesignSketchTool::confirm_pending() +{ + if (op_ready()) { confirm_op(); return true; } + if (tf_ready()) { confirm_transform(); return true; } + if (!m_points.empty() && end_chain()) return true; + return false; +} + +// End a Polyline / Spline chain as drawn (open), the one rule behind right-click, double-click +// and Enter. The first click of a double-click lands as an ordinary LeftDown on some platforms, +// so a final point within a few pixels of the one before it is that click again, not a pole. +bool DesignSketchTool::end_chain() +{ + if (m_mode != Mode::Polyline && m_mode != Mode::BSpline) return false; + std::vector pts; + for (const Vec2d& q : m_points) + if (pts.empty() || (q - pts.back()).norm() > m_chain_dup_tol) pts.push_back(q); + const int base = int(m_entities.size()); + if (pts.size() >= 2) { + if (m_mode == Mode::Polyline) push_open_chain(pts); + else append_entities(make_bspline(pts)); + infer_auto_constraints(base); // end poles auto-Coincident -> loops close + } + m_points.clear(); + emit_step_hint(); + return true; +} + +void DesignSketchTool::show_refusal(const std::string& why) +{ + if (inline_editor != nullptr && inline_editor->refuse(why)) return; + notify(why); +} + // CadLevel::Gesture inside a sketch: drop the entity being drawn, keep the tool armed. bool DesignSketchTool::abort_gesture() { - if (m_points.empty()) return false; + if (!gesture_pending()) return false; m_points.clear(); m_has_cursor = false; + // Picks are a gesture too: drop them, never apply them, and keep the tool armed. + if (has_pending_picks()) { + reset_op(); + reset_tf(); + m_dim_has0 = false; + m_dim_e0 = -1; + m_dim_r0 = SketchPointRole::P0; + m_pick0 = m_pick1 = m_pick2 = -1; + m_sel_a = m_sel_b = -1; + m_selection.clear(); + m_point_sel.clear(); + if (on_selection_changed) on_selection_changed(0); + } + emit_step_hint(); return true; } // CadLevel::Tool inside a sketch: an armed draw/edit tool falls back to Select. -// Drop any pending edit-op BEFORE the downgrade: set_tool commits a ready one, and Esc must -// cancel it, never apply it. Right-click already discards it through its own branch. +// Drop any pending edit-op or transform BEFORE the downgrade: set_tool commits a ready one, +// and Esc must cancel it, never apply it. Right-click already discards it through its own branch. bool DesignSketchTool::disarm_tool() { if (m_mode == Mode::Select) return false; reset_op(); + reset_tf(); set_tool(Mode::Select); return true; } @@ -379,11 +436,6 @@ void DesignSketchTool::request_exit() else cancel(); } -void DesignSketchTool::request_undo_redo(bool redo) -{ - if (on_undo_redo) on_undo_redo(redo); -} - void DesignSketchTool::clear_selection() { if (m_selection.empty() && m_point_sel.empty()) return; @@ -392,6 +444,49 @@ void DesignSketchTool::clear_selection() if (on_selection_changed) on_selection_changed(0); } +bool DesignSketchTool::can_redo_entity() const +{ + return m_active && !m_sketch_redo.empty() + && m_sketch_redo.back().after_entities == m_entities.size() + && m_sketch_redo.back().after_constraints == m_constraints.size(); +} + +bool DesignSketchTool::redo_last_entity() +{ + if (!can_redo_entity()) { m_sketch_redo.clear(); return false; } + SketchSnap s = std::move(m_sketch_redo.back()); + m_sketch_redo.pop_back(); + m_entities = std::move(s.entities); + m_constraints = std::move(s.constraints); + m_features = std::move(s.features); + m_dimensions = std::move(s.dimensions); + m_selection.clear(); + m_point_sel.clear(); + reset_autoedit(); + resolve_live(); + if (on_selection_changed) on_selection_changed(0); + return true; +} + +bool DesignSketchTool::undo_last_entity() +{ + if (!m_active || m_entities.empty()) return false; + if (!can_redo_entity()) m_sketch_redo.clear(); // the sketch moved on since the last undo + SketchSnap snap{ m_entities, m_constraints, m_features, m_dimensions, 0, 0 }; + const int last = int(m_entities.size()) - 1; + int begin = last; + for (const Feature& f : m_features) + if (f.begin <= last && last < f.end && f.end == last + 1) begin = std::min(begin, f.begin); + m_selection.clear(); + for (int i = begin; i <= last; ++i) m_selection.push_back(i); + delete_selected(); + reset_autoedit(); + snap.after_entities = m_entities.size(); + snap.after_constraints = m_constraints.size(); + m_sketch_redo.push_back(std::move(snap)); + return true; +} + void DesignSketchTool::delete_selected() { if (m_selection.empty()) return; @@ -406,21 +501,39 @@ void DesignSketchTool::delete_selected() if (!del[i]) remap[i] = next++; for (int i = n - 1; i >= 0; --i) if (del[i]) m_entities.erase(m_entities.begin() + i); - // Drop constraints touching a deleted entity; remap the survivors. + // Drop constraints touching a deleted entity; remap the survivors. A negative reference is + // not "no entity" but a sentinel (origin, X axis, Y axis — kSketchRef*) and passes through + // unchanged: mapping it to -1 silently cut every constraint onto the origin or an axis. std::vector kept; + std::vector con_remap(m_constraints.size(), -1); auto live = [&](int e) { return e < 0 || (e < n && remap[e] >= 0); }; - auto map = [&](int e) { return e < 0 ? -1 : remap[e]; }; - for (SketchEntityConstraintDef c : m_constraints) { + auto map = [&](int e) { return e < 0 ? e : remap[e]; }; + for (int ci = 0; ci < int(m_constraints.size()); ++ci) { + SketchEntityConstraintDef c = m_constraints[ci]; if (!live(c.ea) || !live(c.eb) || !live(c.ec)) continue; c.ea = map(c.ea); c.eb = map(c.eb); c.ec = map(c.ec); + con_remap[ci] = int(kept.size()); kept.push_back(c); } m_constraints.swap(kept); m_selection.clear(); m_point_sel.clear(); - // v1: placed quotes reference entity indices that have shifted; drop them rather - // than risk a dangling reference (the driving constraints survive, reindexed). - m_dimensions.clear(); + // Placed quotes follow their geometry and their driving constraint: a quote whose entity or + // constraint went away goes with it, the others are reindexed. Clearing them all made one + // Delete hide every label while the constraints they showed kept driving the sketch. + { + std::vector kept_d; + for (DimAnnot a : m_dimensions) { + if (!live(a.ea) || !live(a.eb)) continue; + if (a.con >= 0) { + if (a.con >= int(con_remap.size()) || con_remap[a.con] < 0) continue; + a.con = con_remap[a.con]; + } + a.ea = map(a.ea); a.eb = map(a.eb); + kept_d.push_back(a); + } + m_dimensions.swap(kept_d); + } m_dim_has0 = false; m_pending_dim = -1; // The Dimension tool's pending FIRST pick is the same dangling-reference hazard as the placed @@ -617,9 +730,8 @@ void DesignSketchTool::apply_dimension(double v) kind == DimType::Diameter); if (!std::isfinite(v) || (needs_positive && v <= 0.0) || (!needs_positive && kind != DimType::Angle && kind != DimType::None && v < 0.0)) { - if (on_readout) - on_readout(needs_positive ? "the value must be greater than zero" - : "the value must not be negative (zero means coincident)"); + show_refusal(needs_positive ? _u8L("The value must be greater than zero") + : _u8L("The value must not be negative (zero means coincident)")); return; } switch (dimension_kind()) { @@ -670,8 +782,8 @@ void DesignSketchTool::apply_dimension(double v) } record_dimension_constraint(v); // store a driving constraint for this dimension resolve_live(); // live-solve so the viewport shows the solved sketch - if (!m_solve_ok && on_readout) // the number on screen is not the geometry's number - on_readout("that dimension cannot be satisfied — the sketch is over-constrained"); + if (!m_solve_ok) // the number on screen is not the geometry's number + notify(_u8L("That dimension cannot be satisfied — the sketch is over-constrained")); m_selection.clear(); if (on_selection_changed) on_selection_changed(0); } @@ -761,6 +873,14 @@ void DesignSketchTool::resolve_live_drag(int dragged_ei, SketchPointRole dragged : sketch_solve(m_entities, cons); m_dof = r.dof; m_solve_ok = r.ok; + // Constraints the solver had to leave out (no representation for the part they name) + // are said once, when their number changes — resolve_live runs on every drag frame. + if (int(r.skipped.size()) != m_skipped_last) { + m_skipped_last = int(r.skipped.size()); + if (m_skipped_last > 0) + notify(format(_u8L("%1% constraint(s) cannot act on this geometry (an ellipse or spline curve, " + "or a zero-size circle) and are ignored"), m_skipped_last), false); + } // Flag every entity referenced by a conflicting constraint so render() can // tint it red (Onshape/SolveSpace over-constrained feedback), and remember which // DIMENSIONS those constraints drive: a label showing a value the geometry does not have @@ -900,8 +1020,8 @@ bool DesignSketchTool::update_hover(GLCanvas3D& canvas, wxMouseEvent& evt) Vec2d p; screen_to_plane(canvas, evt, p); // Zoom-aware pick tolerance: project a point a few px away and measure in plane units. - const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 6, evt.GetY())); - const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + // The SAME budget a click grabs with: a handle that looks un-hovered must not be grabbable. + const double tol = screen_tol(canvas, evt, p, kPickPx); const bool had = m_has_hover_handle; const Handle prev = m_hover_handle; Handle h; @@ -1288,7 +1408,7 @@ void DesignSketchTool::open_angle_editor(int ei) if (!on_inline_edit) return; DimAnnot a; a.kind = DimType::Angle; a.ea = ei; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, measure_dim(a), "Angle", + on_inline_edit(px, measure_dim(a), _u8L("Angle (°)"), [this, ei](double deg) { set_line_angle(ei, deg); }, []() {}); } @@ -1306,16 +1426,7 @@ bool DesignSketchTool::open_selection_dimension_editor() if (!on_inline_edit || !selection_valid()) return false; const DimType k = dimension_kind(); if (k == DimType::None) return false; - const char* title = "Value"; - switch (k) { - case DimType::Length: title = "Length"; break; - case DimType::Radius: title = "Radius"; break; - case DimType::Diameter: title = "Diameter"; break; - case DimType::Angle: title = "Angle"; break; - case DimType::Distance: title = "Distance"; break; - case DimType::DistanceToLine: title = "Distance"; break; - default: break; - } + const std::string title = dimtype_title(k); // Anchor over the geometry it belongs to, not the panel: the value belongs to the element. DimAnnot a; a.kind = k; a.ea = m_selection.empty() ? -1 : m_selection[0]; @@ -1373,6 +1484,8 @@ void DesignSketchTool::open_next_autoedit_dim() on_inline_edit(px, step.value, step.title, [this, step](double v) { // commit: apply this dimension, then next if (step.apply) step.apply(v); + // A refused value brought the same field back (show_refusal): stay on this step. + if (inline_editor != nullptr && inline_editor->is_open()) return; ++m_autoedit_dim_idx; wxGetApp().CallAfter([this] { open_next_autoedit_dim(); }); }, @@ -1399,30 +1512,31 @@ void DesignSketchTool::arm_polyline_segment_edit() m_autoedit_dims.clear(); m_autoedit_dims.push_back({ mid, L, [this, k, a](double len) { // Length if (k < int(m_points.size())) { + if (!(len > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } Vec2d dd = m_points[k] - a; const double n = dd.norm(); - if (n > 1e-9 && len > 1e-9) m_points[k] = a + (len / n) * dd; + if (n > 1e-9) m_points[k] = a + (len / n) * dd; } - }, {}, "Length" }); + }, {}, _u8L("Length") }); m_autoedit_dims.push_back({ mid, deg, [this, k, a](double dg) { // Angle if (k < int(m_points.size())) { const double len = (m_points[k] - a).norm(); const double r = dg * M_PI / 180.0; m_points[k] = a + len * Vec2d(std::cos(r), std::sin(r)); } - }, {}, "Angle" }); + }, {}, _u8L("Angle (°)") }); m_autoedit_dim_idx = 0; wxGetApp().CallAfter([this] { open_next_autoedit_dim(); }); } std::string DesignSketchTool::dimtype_title(DimType k) const { switch (k) { - case DimType::Length: return "Length"; - case DimType::Diameter: return "Diameter"; - case DimType::Radius: return "Radius"; - case DimType::Angle: return "Angle"; - case DimType::Distance: return "Distance"; - case DimType::DistanceToLine: return "Distance"; - default: return "Value"; + case DimType::Length: return _u8L("Length"); + case DimType::Diameter: return _u8L("Diameter"); + case DimType::Radius: return _u8L("Radius"); + case DimType::Angle: return _u8L("Angle (°)"); + case DimType::Distance: return _u8L("Distance"); + case DimType::DistanceToLine: return _u8L("Distance"); + default: return _u8L("Value"); } } @@ -1461,13 +1575,19 @@ void DesignSketchTool::open_primary_autoedit() if (q.kind == DimType::Angle) { const int ei = q.ea; m_autoedit_dims.push_back({ q.label_pos, measure_dim(q), - [this, ei](double v) { set_line_angle(ei, v); }, { ei }, "Angle" }); + [this, ei](double v) { set_line_angle(ei, v); }, { ei }, _u8L("Angle (°)") }); continue; } DimAnnot a = q; a.value = measure_dim(a); m_autoedit_dims.push_back({ a.label_pos, a.value, [this, a](double v) mutable { + const bool positive = a.kind == DimType::Length || a.kind == DimType::Radius || a.kind == DimType::Diameter; + if (!std::isfinite(v) || (positive && v <= 0.0) || (!positive && v < 0.0)) { + show_refusal(positive ? _u8L("The value must be greater than zero") + : _u8L("The value must not be negative (zero means coincident)")); + return; + } a.value = v; a.con = upsert_dimension_constraint(constraint_for(a)); upsert_dimension(a); @@ -1492,8 +1612,8 @@ void DesignSketchTool::open_primary_autoedit() const double side = (m_entities[f.begin].p1 - m_entities[f.begin].p0).norm(); const Vec2d sp = m_entities[f.begin].p0 - f.c0; double deg = std::atan2(sp.y(), sp.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; - m_autoedit_dims.push_back({ m_live_poly_side_label, side, [this, fi](double v){ set_polygon_side(fi, v); }, span(fi), "Side" }); - m_autoedit_dims.push_back({ m_live_poly_angle_label, deg, [this, fi](double v){ set_polygon_angle(fi, v); }, span(fi), "Angle" }); + m_autoedit_dims.push_back({ m_live_poly_side_label, side, [this, fi](double v){ set_polygon_side(fi, v); }, span(fi), _u8L("Side") }); + m_autoedit_dims.push_back({ m_live_poly_angle_label, deg, [this, fi](double v){ set_polygon_angle(fi, v); }, span(fi), _u8L("Angle (°)") }); } } if (m_live_rrect_fi >= 0) { @@ -1503,43 +1623,43 @@ void DesignSketchTool::open_primary_autoedit() auto rr_w = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, v, std::abs(g.c1.y()-g.c0.y()), g.param); }; auto rr_h = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, std::abs(g.c1.x()-g.c0.x()), v, g.param); }; auto rr_r = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, std::abs(g.c1.x()-g.c0.x()), std::abs(g.c1.y()-g.c0.y()), v); }; - m_autoedit_dims.push_back({ m_live_rrect_w_label, w, rr_w, span(fi), "Width" }); - m_autoedit_dims.push_back({ m_live_rrect_h_label, h, rr_h, span(fi), "Height" }); - m_autoedit_dims.push_back({ m_live_rrect_r_label, r, rr_r, span(fi), "Radius" }); + m_autoedit_dims.push_back({ m_live_rrect_w_label, w, rr_w, span(fi), _u8L("Width") }); + m_autoedit_dims.push_back({ m_live_rrect_h_label, h, rr_h, span(fi), _u8L("Height") }); + m_autoedit_dims.push_back({ m_live_rrect_r_label, r, rr_r, span(fi), _u8L("Radius") }); } if (m_live_aslot_fi >= 0) { const Feature& f = m_features[m_live_aslot_fi]; const int fi = m_live_aslot_fi; const double Rc = (f.c1 - f.c0).norm(), fw = 2.0 * f.param; - m_autoedit_dims.push_back({ m_live_aslot_r_label, Rc, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, v, g.param); }, span(fi), "Radius" }); - m_autoedit_dims.push_back({ m_live_aslot_w_label, fw, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v*0.5)); }, span(fi), "Width" }); + m_autoedit_dims.push_back({ m_live_aslot_r_label, Rc, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, v, g.param); }, span(fi), _u8L("Radius") }); + m_autoedit_dims.push_back({ m_live_aslot_w_label, fw, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, (g.c1-g.c0).norm(), v * 0.5); }, span(fi), _u8L("Width") }); } if (m_live_slot_fi >= 0) { const Feature& f = m_features[m_live_slot_fi]; const int fi = m_live_slot_fi; - // Slot dims, in order: (1) inter-centre distance, (2) radius (= half-width), (3) angle. + // Slot dims, in order: (1) inter-centre distance, (2) full width (as the arc slot), (3) angle. const Vec2d d = f.c1 - f.c0; const double Lc = d.norm(); double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; - m_autoedit_dims.push_back({ m_live_slot_len_label, Lc, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, v, g.param); }, span(fi), "Length" }); - m_autoedit_dims.push_back({ m_live_slot_w_label, f.param, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v)); }, span(fi), "Radius" }); - m_autoedit_dims.push_back({ m_live_slot_angle_label, deg, [this, fi](double v){ set_slot_angle(fi, v); }, span(fi), "Angle" }); + m_autoedit_dims.push_back({ m_live_slot_len_label, Lc, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, v, g.param); }, span(fi), _u8L("Length") }); + m_autoedit_dims.push_back({ m_live_slot_w_label, 2.0 * f.param, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, (g.c1-g.c0).norm(), v * 0.5); }, span(fi), _u8L("Width") }); + m_autoedit_dims.push_back({ m_live_slot_angle_label, deg, [this, fi](double v){ set_slot_angle(fi, v); }, span(fi), _u8L("Angle (°)") }); } if (m_live_arc_ei >= 0) { // arc Radius is already a scalar step above; add its sweep angle const int ei = m_live_arc_ei; const SketchEntity& e = m_entities[ei]; const double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; - m_autoedit_dims.push_back({ m_live_arc_angle_label, swdeg, [this, ei](double v){ set_arc_sweep(ei, v); }, { ei }, "Angle" }); + m_autoedit_dims.push_back({ m_live_arc_angle_label, swdeg, [this, ei](double v){ set_arc_sweep(ei, v); }, { ei }, _u8L("Angle (°)") }); } if (m_live_ellipse_ei >= 0) { const int ei = m_live_ellipse_ei; const SketchEntity& e = m_entities[ei]; - m_autoedit_dims.push_back({ m_live_ellipse_major_label, e.radius, [this, ei](double v){ set_ellipse_axis(ei, true, v); }, { ei }, "Major" }); - m_autoedit_dims.push_back({ m_live_ellipse_minor_label, e.rminor, [this, ei](double v){ set_ellipse_axis(ei, false, v); }, { ei }, "Minor" }); + m_autoedit_dims.push_back({ m_live_ellipse_major_label, e.radius, [this, ei](double v){ set_ellipse_axis(ei, true, v); }, { ei }, _u8L("Semi-major axis") }); + m_autoedit_dims.push_back({ m_live_ellipse_minor_label, e.rminor, [this, ei](double v){ set_ellipse_axis(ei, false, v); }, { ei }, _u8L("Semi-minor axis") }); if (e.type == SketchEntity::Type::EllipseArc) { // + included sweep const double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; m_autoedit_dims.push_back({ m_live_ellipsearc_sweep_label, swdeg, - [this, ei](double v){ set_ellipsearc_sweep(ei, v); }, { ei }, "Angle" }); + [this, ei](double v){ set_ellipsearc_sweep(ei, v); }, { ei }, _u8L("Angle (°)") }); } } if (m_live_obrect_fi >= 0) { // oblique rect: W,H already added as scalars; + orientation @@ -1549,7 +1669,7 @@ void DesignSketchTool::open_primary_autoedit() double adeg = std::atan2(e0.p1.y() - e0.p0.y(), e0.p1.x() - e0.p0.x()) * 180.0 / M_PI; if (adeg < 0.0) adeg += 360.0; m_autoedit_dims.push_back({ m_live_obrect_angle_label, adeg, - [this, fi](double v){ set_rect_angle(fi, v); }, span(fi), "Angle" }); + [this, fi](double v){ set_rect_angle(fi, v); }, span(fi), _u8L("Angle (°)") }); } trace_autoedit(m_autoedit_dims.empty() ? "built NO steps (no live quote matched)" : "opening", @@ -1569,7 +1689,7 @@ void DesignSketchTool::open_polygon_side_editor(int fi) if (f.begin < 0 || f.begin >= int(m_entities.size())) return; const double side = (m_entities[f.begin].p1 - m_entities[f.begin].p0).norm(); const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, side, "Side", + on_inline_edit(px, side, _u8L("Side"), [this, fi](double v) { set_polygon_side(fi, v); }, []() {}); } @@ -1583,7 +1703,7 @@ void DesignSketchTool::open_polygon_angle_editor(int fi) double deg = std::atan2(sp.y(), sp.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, deg, "Angle", + on_inline_edit(px, deg, _u8L("Angle (°)"), [this, fi](double v) { set_polygon_angle(fi, v); }, []() {}); } @@ -1592,7 +1712,8 @@ void DesignSketchTool::open_polygon_angle_editor(int fi) // n-gon, circumradius R = side / (2 sin(pi/n)). void DesignSketchTool::set_polygon_side(int fi, double side) { - if (fi < 0 || fi >= int(m_features.size()) || side < 1e-6) return; + if (fi < 0 || fi >= int(m_features.size())) return; + if (!(side > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } const int n = std::max(3, m_features[fi].sides); const double R = side / (2.0 * std::sin(M_PI / double(n))); set_polygon_radius(fi, R); @@ -1837,7 +1958,7 @@ void DesignSketchTool::open_arc_angle_editor(int ei) if (e.type != SketchEntity::Type::Arc) return; double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, swdeg, "Angle", + on_inline_edit(px, swdeg, _u8L("Angle (°)"), [this, ei](double v) { set_arc_sweep(ei, v); }, []() {}); } @@ -1851,7 +1972,8 @@ void DesignSketchTool::set_arc_sweep(int ei, double deg) if (ei < 0 || ei >= int(m_entities.size())) return; SketchEntity& e = m_entities[ei]; if (e.type != SketchEntity::Type::Arc || e.radius < 1e-6) return; - double sweep = std::max(1e-3, std::min(deg, 359.999)) * M_PI / 180.0; + if (!(deg > 0.0 && deg < 360.0)) { show_refusal(_u8L("The angle must be between 0° and 360°")); return; } + double sweep = deg * M_PI / 180.0; const double sign = (e.end_angle >= e.start_angle) ? 1.0 : -1.0; e.end_angle = e.start_angle + sign * sweep; e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); @@ -1922,7 +2044,7 @@ void DesignSketchTool::open_ellipse_axis_editor(int ei, bool major) if (e.type != SketchEntity::Type::Ellipse && e.type != SketchEntity::Type::EllipseArc) return; const double v = major ? e.radius : e.rminor; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, major ? "Major" : "Minor", + on_inline_edit(px, v, major ? _u8L("Semi-major axis") : _u8L("Semi-minor axis"), [this, ei, major](double nv) { set_ellipse_axis(ei, major, nv); }, []() {}); } @@ -1930,11 +2052,16 @@ void DesignSketchTool::open_ellipse_axis_editor(int ei, bool major) // Set a semi-axis to `v`: major -> e.radius, minor -> e.rminor; keep OCCT a >= b. void DesignSketchTool::set_ellipse_axis(int ei, bool major, double v) { - if (ei < 0 || ei >= int(m_entities.size()) || v < 1e-6) return; + if (ei < 0 || ei >= int(m_entities.size())) return; SketchEntity& e = m_entities[ei]; if (e.type != SketchEntity::Type::Ellipse && e.type != SketchEntity::Type::EllipseArc) return; - if (major) e.radius = std::max(v, e.rminor); - else e.rminor = std::min(v, e.radius); + if (!(v > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } + if (major ? v < e.rminor : v > e.radius) { + show_refusal(_u8L("The semi-major axis cannot be shorter than the semi-minor axis")); + return; + } + if (major) e.radius = v; + else e.rminor = v; if (e.type == SketchEntity::Type::EllipseArc) { // endpoints ride the reshaped frame e.p0 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.start_angle); e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); @@ -1949,7 +2076,8 @@ void DesignSketchTool::set_ellipsearc_sweep(int ei, double deg) if (ei < 0 || ei >= int(m_entities.size())) return; SketchEntity& e = m_entities[ei]; if (e.type != SketchEntity::Type::EllipseArc) return; - const double sweep = std::max(1e-3, std::min(deg, 359.999)) * M_PI / 180.0; + if (!(deg > 0.0 && deg < 360.0)) { show_refusal(_u8L("The angle must be between 0° and 360°")); return; } + const double sweep = deg * M_PI / 180.0; const double sign = (e.end_angle >= e.start_angle) ? 1.0 : -1.0; e.end_angle = e.start_angle + sign * sweep; e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); @@ -2017,7 +2145,7 @@ void DesignSketchTool::open_rounded_rect_editor(int fi, int which) const double r = f.param; const double v = (which == 0) ? w : (which == 1) ? h : r; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, which == 0 ? "Width" : which == 1 ? "Height" : "Radius", + on_inline_edit(px, v, which == 0 ? _u8L("Width") : which == 1 ? _u8L("Height") : _u8L("Radius"), [this, fi, which](double nv) { const Feature& g = m_features[fi]; double gw = std::abs(g.c1.x() - g.c0.x()); @@ -2037,8 +2165,11 @@ void DesignSketchTool::set_rounded_rect(int fi, double w, double h, double r) if (fi < 0 || fi >= int(m_features.size())) return; Feature& f = m_features[fi]; if (f.begin < 0 || f.end > int(m_entities.size()) || f.end <= f.begin) return; - w = std::max(w, 1e-3); h = std::max(h, 1e-3); - r = std::max(1e-3, std::min(r, std::min(w, h) * 0.5 - 1e-4)); + if (!(w > 0.0 && h > 0.0 && r > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } + if (r >= std::min(w, h) * 0.5) { + show_refusal(_u8L("The corner radius must be less than half the shorter side")); + return; + } const double xmin = std::min(f.c0.x(), f.c1.x()), ymin = std::min(f.c0.y(), f.c1.y()); const double xmax = xmin + w, ymax = ymin + h; std::vector rebuilt = rounded_rect_entities(xmin, ymin, xmax, ymax, r); @@ -2058,12 +2189,12 @@ void DesignSketchTool::open_arc_slot_editor(int fi, bool radius) const double Rc = (f.c1 - f.c0).norm(); const double v = radius ? Rc : (2.0 * f.param); // width quote shows the FULL width const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, radius ? "Radius" : "Width", + on_inline_edit(px, v, radius ? _u8L("Radius") : _u8L("Width"), [this, fi, radius](double nv) { const Feature& g = m_features[fi]; const double gRc = (g.c1 - g.c0).norm(); if (radius) set_arc_slot(fi, nv, g.param); - else set_arc_slot(fi, gRc, std::max(1e-3, nv * 0.5)); // full width -> half + else set_arc_slot(fi, gRc, nv * 0.5); // full width -> half }, []() {}); } @@ -2082,8 +2213,11 @@ void DesignSketchTool::set_arc_slot(int fi, double Rc, double w) Vec2d dirE = Ec - center; if (dirS.squaredNorm() < 1e-12 || dirE.squaredNorm() < 1e-12) return; dirS.normalize(); dirE.normalize(); - Rc = std::max(Rc, 2e-3); - w = std::max(1e-3, std::min(w, Rc - 1e-3)); // make_arc_slot needs w < Rc + if (!(Rc > 0.0 && w > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } + if (w >= Rc) { // make_arc_slot needs w < Rc + show_refusal(_u8L("The slot is too wide for its radius — the width must be less than twice the radius")); + return; + } std::vector rebuilt = make_arc_slot(center, center + Rc * dirS, center + Rc * dirE, w); if (int(rebuilt.size()) != 4) return; @@ -2096,20 +2230,20 @@ void DesignSketchTool::set_arc_slot(int fi, double Rc, double w) } // Open the inline editor for a straight slot's dimension: which 0 = inter-centre distance, -// 1 = radius (half-width), 2 = centreline angle. Drives set_slot / set_slot_angle geometrically. +// 1 = full width (the arc slot's convention), 2 = centreline angle. Drives set_slot / set_slot_angle geometrically. void DesignSketchTool::open_slot_editor(int fi, int which) { if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return; const Feature& f = m_features[fi]; const Vec2d d = f.c1 - f.c0; double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; - const double v = (which == 0) ? d.norm() : (which == 1) ? f.param : deg; + const double v = (which == 0) ? d.norm() : (which == 1) ? 2.0 * f.param : deg; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, which == 0 ? "Length" : which == 1 ? "Radius" : "Angle", + on_inline_edit(px, v, which == 0 ? _u8L("Length") : which == 1 ? _u8L("Width") : _u8L("Angle (°)"), [this, fi, which](double nv) { const Feature& g = m_features[fi]; if (which == 0) set_slot(fi, nv, g.param); - else if (which == 1) set_slot(fi, (g.c1 - g.c0).norm(), std::max(1e-3, nv)); + else if (which == 1) set_slot(fi, (g.c1 - g.c0).norm(), nv * 0.5); // full width -> half else set_slot_angle(fi, nv); }, []() {}); @@ -2126,8 +2260,7 @@ void DesignSketchTool::set_slot(int fi, double length, double w) Vec2d dir = f.c1 - f.c0; if (dir.squaredNorm() < 1e-12) return; dir.normalize(); - length = std::max(length, 2e-3); - w = std::max(1e-3, w); + if (!(length > 0.0 && w > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } const Vec2d c0 = f.c0, c1 = f.c0 + length * dir; std::vector rebuilt = make_slot(c0, c1, w); if (int(rebuilt.size()) != 4) return; @@ -2208,12 +2341,18 @@ void DesignSketchTool::set_dimension_value(double v) { if (m_pending_dim < 0 || m_pending_dim >= int(m_dimensions.size())) return; DimAnnot& a = m_dimensions[m_pending_dim]; + const bool positive = a.kind == DimType::Length || a.kind == DimType::Radius || a.kind == DimType::Diameter; + if (!std::isfinite(v) || (positive && v <= 0.0) || (!positive && a.kind != DimType::Angle && v < 0.0)) { + show_refusal(positive ? _u8L("The value must be greater than zero") + : _u8L("The value must not be negative (zero means coincident)")); + return; + } a.value = v; if (a.con >= 0 && a.con < int(m_constraints.size())) m_constraints[a.con] = constraint_for(a); resolve_live(); - if (!m_solve_ok && on_readout) - on_readout(dimtype_title(a.kind) + " cannot be satisfied — the sketch is over-constrained"); + if (!m_solve_ok) + notify(format(_u8L("%1% cannot be satisfied — the sketch is over-constrained"), dimtype_title(a.kind))); m_pending_dim = -1; } @@ -2227,18 +2366,23 @@ std::string DesignSketchTool::dim_text(const DimAnnot& a) const char buf[32]; const char* prefix = (a.kind == DimType::Diameter) ? "\xC3\x98" // 'Ø' : (a.kind == DimType::Radius) ? "R" : ""; - const char* suffix = (a.kind == DimType::Angle) ? "\xC2\xB0" : ""; // '°' - std::snprintf(buf, sizeof(buf), "%s%.1f%s", prefix, a.value, suffix); + // Two decimals, trailing zeros dropped: the same precision the HUD readout uses, so a label + // and the readout never show the same length two ways. + std::snprintf(buf, sizeof(buf), "%.2f", a.value); // Force the international (en) decimal point: wx sets LC_NUMERIC to the user - // locale at startup, so snprintf("%.1f") can emit a comma. Normalise it. + // locale at startup, so snprintf can emit a comma. Normalise it. for (char& ch : buf) if (ch == ',') ch = '.'; - std::string out(buf); - if (a.kind != DimType::Angle) { - const bool use_in = wxGetApp().app_config->get_bool("use_inches"); - out += use_in ? " in" : " mm"; + std::string num(buf); + if (num.find('.') != std::string::npos) { + while (num.back() == '0') num.pop_back(); + if (num.back() == '.') num.pop_back(); } - return out; + if (num == "-0") num = "0"; + // The Design tab models in millimetres whatever the slicer's inch preference says: the + // kernel, the value fields and the readouts are all mm. The suffix used to follow that + // preference while the number did not, so an inch user read "25.4 in" for one inch. + return std::string(prefix) + num + (a.kind == DimType::Angle ? "\xC2\xB0" : " mm"); } void DesignSketchTool::apply_segment_length(double len) @@ -2420,6 +2564,28 @@ InferenceSnap DesignSketchTool::infer_at(GLCanvas3D& canvas, const wxMouseEvent& return infer_point_snap(m_entities, raw, tol); } +// Which clicks land on a snap target. Definition points (ends, corners, centres, points on a +// curve) snap; the click that only sets a SIZE (a radius, a width, a sweep direction) is free, +// so it can be placed anywhere without being dragged onto the nearest endpoint. +bool DesignSketchTool::click_snaps() const +{ + const size_t n = m_points.size(); + switch (m_mode) { + case Mode::Line: case Mode::Polyline: case Mode::TwoPointCircle: case Mode::ThreePointCircle: + case Mode::TangentArc: case Mode::BSpline: case Mode::Point: + case Mode::CornerRect: case Mode::CenterRect: + return true; + case Mode::ObliqueRect: case Mode::RoundedRect: case Mode::ThreePointArc: case Mode::Slot: + return n < 2; + case Mode::CenterArc: case Mode::ArcSlot: + return n == 1; + case Mode::CenterCircle: case Mode::Polygon: case Mode::Ellipse: case Mode::EllipseArc: + return n == 0; + default: + return false; + } +} + Vec2d DesignSketchTool::snap_vertex(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw, bool& snapped) const { @@ -7295,7 +7461,7 @@ void DesignSketchTool::render_live_quotes(double unit_per_px) // Distance-between-arc-centres + Radius quotes never registered as editable, so the // labels did nothing on click (nde #6). Draw both labels clear of the fillable face // and remember the feature so open_primary_autoedit / click-to-promote drive set_slot. - // Slot dims: (1) inter-centre distance, (2) radius (= half-width), (3) centreline angle. + // Slot dims: (1) inter-centre distance, (2) full width, (3) centreline angle. const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); const double th = std::max(15.0 * unit_per_px, 1e-4); Vec2d u = f.c1 - f.c0; @@ -7307,7 +7473,7 @@ void DesignSketchTool::render_live_quotes(double unit_per_px) DimAnnot len; len.kind = DimType::Length; len.value = Lc; m_live_slot_len_label = 0.5 * (f.c0 + f.c1) + n * (w + th * 2.0); draw_text(m_line_model, dim_text(len), m_live_slot_len_label, th, dc); - DimAnnot rd; rd.kind = DimType::Radius; rd.value = w; + DimAnnot rd; rd.kind = DimType::Length; rd.value = 2.0 * w; // full width, as the arc slot m_live_slot_w_label = f.c1 + u * (w + th * 2.0); draw_text(m_line_model, dim_text(rd), m_live_slot_w_label, th, dc); double deg = std::atan2(f.c1.y() - f.c0.y(), f.c1.x() - f.c0.x()) * 180.0 / M_PI; @@ -7851,8 +8017,12 @@ void DesignSketchTool::recompute_op_ghost() Vec2d u = e.p1 - e.p0; if (u.norm() > 1e-12) u.normalize(); m_op_dir = Vec2d(-u.y(), u.x()); // left normal = +distance side } else if (e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc) { + // The same "+distance = left of travel" rule the engine uses: inward for a circle + // and a CCW arc, outward for a CW arc. The arrow pointed outward for all of them, so + // dragging it out shrank a CCW arc. + const bool ccw = e.type == SketchEntity::Type::Circle || e.end_angle >= e.start_angle; m_op_anchor = e.center + Vec2d(e.radius, 0.0); - m_op_dir = Vec2d(1, 0); + m_op_dir = Vec2d(ccw ? -1.0 : 1.0, 0.0); } m_op_ghost = SketchEngine::offset_entities({ e }, m_op_value); } else if (m_mode == Mode::Mirror) { @@ -7879,7 +8049,7 @@ void DesignSketchTool::op_pick(int ei) else if (ei != m_op_a) { Vec2d cC, cBis; double cTh = 0.0; if (!op_corner(m_op_a, ei, cC, cBis, cTh)) { // parallel or straight: no corner - if (on_readout) on_readout("those two lines do not meet at a corner — pick two lines that do"); + notify(_u8L("Those two lines do not meet at a corner — pick two lines that do")); return; } m_op_b = ei; @@ -7937,12 +8107,18 @@ void DesignSketchTool::drag_op_arrow(const Vec2d& target) void DesignSketchTool::open_op_editor() { if (!on_inline_edit || !op_ready() || m_mode == Mode::Mirror) return; - const double sign = (m_mode == Mode::Offset && m_op_value < 0) ? -1.0 : 1.0; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, std::abs(m_op_value), "", - [this, sign](double v) { - m_op_value = (m_mode == Mode::Offset) ? sign * std::abs(v) : std::max(0.001, v); - // Entering a radius IS the commit. Leaving it as a preview meant the most obvious + // Offset is signed the same way the arrow drag is (the sign picks the side), so the field + // shows and accepts the sign instead of silently keeping the old one. Fillet/Chamfer sizes + // are positive: a non-positive one is refused, not clamped to a hairline. + on_inline_edit(px, m_op_value, m_mode == Mode::Offset ? _u8L("Distance") : m_mode == Mode::Fillet ? _u8L("Radius") : _u8L("Size"), + [this](double v) { + if (m_mode != Mode::Offset && v <= 0.0) { + show_refusal(_u8L("The size must be greater than zero")); + return; + } + m_op_value = v; + // Entering a radius IS the commit. Leaving it as a preview meant the most obvious // route of all — click the radius, type it, press Return — ended with the value set, // the ghost drawn, and no geometry written; the only paths that ever applied it were // finishing the whole sketch or clicking empty space, neither of which is signposted. @@ -8019,9 +8195,9 @@ void DesignSketchTool::confirm_op() ? SketchEngine::fillet_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra) : SketchEngine::chamfer_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra); if (!ok) { - if (on_readout) on_readout(fillet - ? "Fillet: the radius overruns a leg, or the two lines do not meet at a corner" - : "Chamfer: the distance overruns a leg, or the two lines do not meet at a corner"); + notify(fillet + ? _u8L("Fillet: the radius overruns a leg, or the two lines do not meet at a corner") + : _u8L("Chamfer: the distance overruns a leg, or the two lines do not meet at a corner")); reset_op(); return; } @@ -8043,8 +8219,8 @@ void DesignSketchTool::confirm_op() return (d.type == CT::Coincident && refs(d, a, ra) && refs(d, b, rb)) || self_len(d, a) || self_len(d, b); }); - if (released > 0 && on_readout) - on_readout(std::to_string(released) + " constraint(s) the fillet/chamfer invalidated were removed"); + if (released > 0) + notify(format(_u8L("%1% constraint(s) the fillet/chamfer invalidated were removed"), released), false); auto coin = [&](R xr, int ln, R lr) { SketchEntityConstraintDef d; d.type = CT::Coincident; d.ea = xi; d.ra = xr; d.eb = ln; d.rb = lr; return d; }; if (fillet) { @@ -8063,7 +8239,7 @@ void DesignSketchTool::confirm_op() const int a = m_op_a; auto out = SketchEngine::offset_entities({ m_entities[a] }, m_op_value); if (out.empty()) { - if (on_readout) on_readout("Offset: an ellipse or a spline has no parallel of its own kind — pick lines, arcs or circles"); + notify(_u8L("Offset: an ellipse or a spline has no parallel of its own kind — pick lines, arcs or circles")); reset_op(); return; } @@ -8340,20 +8516,25 @@ void DesignSketchTool::open_tf_editor_a() if (!on_inline_edit || !tf_ready()) return; const wxPoint px(m_last_mouse_x, m_last_mouse_y); double cur; - if (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) cur = std::abs(m_tf_angle) * 180.0 / M_PI; + if (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) cur = m_tf_angle * 180.0 / M_PI; else if (m_mode == Mode::Scale) cur = m_tf_scale; else cur = m_tf_delta.norm(); Vec2d dir = m_tf_delta; if (dir.norm() > 1e-9) dir.normalize(); else dir = Vec2d(1, 0); - const double sgn = (m_tf_angle < 0) ? -1.0 : 1.0; - on_inline_edit(px, cur, "", - [this, dir, sgn](double v) { + const char* title = (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) ? L("Angle (°)") + : (m_mode == Mode::Scale) ? L("Scale factor") : L("Distance"); + on_inline_edit(px, cur, _u8L(title), + [this, dir](double v) { switch (m_mode) { - case Mode::Move: case Mode::Array: m_tf_delta = dir * v; break; - case Mode::Rotate: case Mode::PolarArray: m_tf_angle = sgn * std::abs(v) * M_PI / 180.0; break; - case Mode::Scale: m_tf_scale = std::max(1e-3, v); break; + case Mode::Move: case Mode::Array: m_tf_delta = dir * v; break; // signed: flips the direction + case Mode::Rotate: case Mode::PolarArray: m_tf_angle = v * M_PI / 180.0; break; // signed: CCW positive + case Mode::Scale: + if (v <= 0.0) { show_refusal(_u8L("The scale factor must be greater than zero")); return; } + m_tf_scale = v; + break; default: break; } - recompute_tf_ghost(); + // Enter IS the commit, the same rule the edit-op field follows. + confirm_transform(); }, []() {}); } @@ -8363,8 +8544,15 @@ void DesignSketchTool::open_tf_editor_count() if (!on_inline_edit || !tf_ready()) return; if (m_mode != Mode::Array && m_mode != Mode::PolarArray) return; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, double(std::max(2, m_tf_count)), "", - [this](double v) { m_tf_count = std::max(2, int(v + 0.5)); recompute_tf_ghost(); }, + on_inline_edit(px, double(std::max(2, m_tf_count)), _u8L("Copies"), + [this](double v) { + if (v < 2.0 || std::abs(v - std::round(v)) > 1e-9) { + show_refusal(_u8L("The number of copies must be a whole number, 2 or more")); + return; + } + m_tf_count = int(std::round(v)); + confirm_transform(); + }, []() {}); } @@ -8477,8 +8665,8 @@ void DesignSketchTool::confirm_transform() } } }); - if (released > 0 && on_readout) - on_readout(std::to_string(released) + " constraint(s) released by the transform"); + if (released > 0) + notify(format(_u8L("%1% constraint(s) released by the transform"), released), false); } else if (m_mode == Mode::Array || m_mode == Mode::PolarArray) { // ADDITIVE: append copies of each subject, then bind each copy to its source. Lines // get Parallel+EqualLength (linear) or EqualLength only (polar — rotation breaks @@ -8532,8 +8720,8 @@ void DesignSketchTool::confirm_transform() } bool bound = false; for (auto& w : ladder) if (!w.empty() && try_add_constraints(w)) { bound = true; break; } - if (!bound && on_readout) - on_readout("the array copies are unconstrained — the solver refused every binding"); + if (!bound) + notify(_u8L("The array copies are unconstrained — the solver refused every binding"), false); } } reset_tf(); @@ -8563,8 +8751,12 @@ void DesignSketchTool::emit_step_hint() } else if (is_transform_mode()) { picks = int(m_tf_targets.size()); step = m_tf_targets.empty() ? 0 : 1; + } else if (m_mode == Mode::Constrain && m_constrain_entities) { + picks = int(m_pick0 >= 0) + int(m_pick1 >= 0) + int(m_pick2 >= 0); } else if (m_mode == Mode::Select || m_mode == Mode::Constrain) { picks = int(m_selection.size()); + } else if (m_mode == Mode::Dimension) { + step = m_dim_has0 ? 1 : 0; // the Dimension tool keeps its first pick, not m_points } else { step = int(m_points.size()); } @@ -8750,10 +8942,12 @@ void DesignSketchTool::render(GLCanvas3D& canvas) const ColorRGBA grey(0.55f, 0.55f, 0.60f, 1.0f); if (m_mode == Mode::Constrain) { - const ColorRGBA cyan(0.30f, 0.80f, 1.0f, 1.0f); - const ColorRGBA red(1.0f, 0.25f, 0.25f, 1.0f); + // The live session's palette, so a sketch looks the same whether you are drawing it or + // constraining it: picks wear THE selection colour, idle geometry orange (construction + // grey). It used to draw everything in the old near-selection cyan and the picks RED — + // the colour that means "conflicting" everywhere else in the sketch. + const ColorRGBA sel_col = design_selection_color(); if (m_constrain_entities) { - // Draw all entities cyan; picked Line entities highlighted red. std::vector markers; for (size_t i = 0; i < m_entities.size(); ++i) { const SketchEntity& e = m_entities[i]; @@ -8762,14 +8956,14 @@ void DesignSketchTool::render(GLCanvas3D& canvas) // references glow yellow (picked entities still win as red). const bool hl = !sel && std::find(m_constraint_hl.begin(), m_constraint_hl.end(), int(i)) != m_constraint_hl.end(); - const ColorRGBA col = sel ? red : (hl ? yellow : cyan); + const ColorRGBA col = sel ? sel_col : hl ? yellow : e.construction ? grey : orange; if (e.type == SketchEntity::Type::Point) { markers.push_back(e.p0); continue; } bool closed = false; std::vector poly = entity_polyline(e, closed); draw_quad_strip((sel || hl) ? m_highlight_model : m_line_model, poly, closed, col); } if (!markers.empty()) - draw_vertices(m_vertex_model, markers, cyan); + draw_vertices(m_vertex_model, markers, orange); // Constraint badges (C3.4b): iconic glyphs near each constraint's entity. { const double upp = 1.0 / std::max(camera.get_zoom(), 1e-6); @@ -8785,12 +8979,12 @@ void DesignSketchTool::render(GLCanvas3D& canvas) glsafe(::glEnable(GL_DEPTH_TEST)); return; } - draw_quad_strip(m_line_model, m_points, true, cyan); - draw_vertices(m_vertex_model, m_points, cyan); + draw_quad_strip(m_line_model, m_points, true, orange); + draw_vertices(m_vertex_model, m_points, orange); if (m_sel_a >= 0 && m_sel_b >= 0 && m_sel_a < int(m_points.size()) && m_sel_b < int(m_points.size())) { std::vector seg = { m_points[m_sel_a], m_points[m_sel_b] }; - draw_quad_strip(m_highlight_model, seg, false, red); + draw_quad_strip(m_highlight_model, seg, false, sel_col); } shader->stop_using(); glsafe(::glEnable(GL_CULL_FACE)); @@ -8954,7 +9148,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) } } if (!mids.empty()) - draw_vertices(m_vertex_model, mids, ColorRGBA(0.35f, 0.75f, 0.85f, 1.0f), 0.9); + draw_vertices(m_vertex_model, mids, ColorRGBA(0.85f, 0.85f, 0.88f, 1.0f), 0.9); // neutral: not the selection hue // Derived feature handles (A3): the circle RadiusHandle is not a SketchPointRole, // so the per-point pass above doesn't draw it. Render it (cyan) + the hovered @@ -8967,7 +9161,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) h.role == HandleRole::MinorAxis || h.role == HandleRole::BSplineCtrl) radius_h.push_back(h.pos); if (!radius_h.empty()) - draw_vertices(m_vertex_model, radius_h, ColorRGBA(0.30f, 0.75f, 0.95f, 1.0f), + draw_vertices(m_vertex_model, radius_h, ColorRGBA(0.95f, 0.95f, 0.95f, 1.0f), std::max(4.0 * upp, 1e-4)); if (m_has_hover_handle) draw_vertices(m_highlight_model, { m_hover_handle.pos }, white, @@ -9251,7 +9445,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) ColorRGBA hint(1.0f, 0.55f, 0.1f, 1.0f); // endpoint: orange switch (m_cursor_snap.kind) { case InferenceSnap::Kind::Midpoint: hint = ColorRGBA(0.35f, 0.90f, 0.75f, 1.0f); break; // teal - case InferenceSnap::Kind::Center: hint = ColorRGBA(0.30f, 0.80f, 1.0f, 1.0f); break; // cyan + case InferenceSnap::Kind::Center: hint = ColorRGBA(1.0f, 0.85f, 0.2f, 1.0f); break; // yellow (cyan = selected) case InferenceSnap::Kind::Origin: hint = ColorRGBA(1.0f, 0.30f, 0.85f, 1.0f); break; // magenta case InferenceSnap::Kind::OnEdge: hint = ColorRGBA(0.45f, 0.70f, 1.0f, 1.0f); break; // blue default: break; @@ -9800,6 +9994,13 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // opens somewhere unexpected can't leave the viewport unusable. else if (evt.Dragging() || evt.GetWheelRotation() != 0) return false; + // A click elsewhere accepts the typed value, as Enter would (the field then advances or + // closes). The click itself is still swallowed: it was aimed at leaving the field, not + // at drawing, and a point placed by it would land under a field that just vanished. + else if (evt.LeftDown()) { + if (on_inline_commit) on_inline_commit(); + return true; + } else return true; } @@ -9852,7 +10053,10 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (!moved && d < 3) open_move_editor(d); // stationary click on an arrow = edit offset return true; } - if (evt.RightDown()) { clear_move_gizmo(); canvas.set_as_dirty(); if (on_move_exit) on_move_exit(); return true; } + // Right-click opens the offer; the move is kept with Enter/✓ and reverted with Esc/✗, + // like every other pending edit. It used to keep the move and close the gizmo — a + // commit on the one button the charter reserves for "what can I do here". + if (evt.RightDown()) return false; if (evt.LeftDown()) { int axis = -1; if (hit_test_move_arrow(canvas, evt, axis)) { // translate arrows win over rings @@ -10229,19 +10433,19 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) Vec2d p; screen_to_plane(canvas, evt, p); const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); - if (apply_live_trim(p, tol * 3.0, m_mode == Mode::Extend)) { + if (apply_live_trim(p, screen_tol(canvas, evt, p, kToolPickPx), m_mode == Mode::Extend)) { resolve_live(); - } else if (on_readout) { + } else { // nde #15: don't fail silently. The pick found nothing to cut/extend — either // the click missed every live segment, or the picked segment has no crossing / // target among the OTHER live entities (committed sketches aren't trimmed). - on_readout(m_mode == Mode::Extend - ? std::string("Extend: click a line/arc that can reach another live entity") - : std::string("Trim: click a segment where it crosses another live entity")); + notify(m_mode == Mode::Extend + ? _u8L("Extend: click a line or an arc that can reach another entity") + : _u8L("Trim: click a segment where it crosses another entity")); } return true; } - if (evt.RightDown()) { request_exit(); return true; } + if (evt.RightDown()) return false; // nothing pending to abandon: the offer opens return false; // let move/drag orbit the camera } @@ -10266,8 +10470,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); // 1) live gizmo: click the value label to type, or grab the arrow to drag. if (op_ready() && m_mode != Mode::Mirror) { - const Linef3 rl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); - const double ltol = std::max(tol, (m_plane.project(rl.a, rl.vector()) - p).norm()); + const double ltol = std::max(tol, screen_tol(canvas, evt, p, kLabelPx)); if ((m_op_label - p).norm() <= ltol) { open_op_editor(); return true; } if (hit_test_op_arrow(p, tol)) { m_op_dragging_arrow = true; return true; } } @@ -10277,19 +10480,21 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double d = entity_pick_dist(p, m_entities[i]); if (d < best) { best = d; bi = int(i); } } - if (bi >= 0 && best <= tol * 3.0) { op_pick(bi); return true; } - // 3) empty click confirms a ready gesture. - if (op_ready()) confirm_op(); + if (bi >= 0 && best <= screen_tol(canvas, evt, p, kToolPickPx)) { op_pick(bi); return true; } + // 3) Empty space never commits (charter 4.2 withdrew that invisible gesture). A ready + // op stays pending and says how to finish it; half-made picks are a selection, and + // empty space clears a selection. + if (op_ready()) notify(_u8L("Press Enter or ✓ to apply, Esc to discard"), false); + else if (m_op_a >= 0) { reset_op(); m_selection.clear(); if (on_selection_changed) on_selection_changed(0); } return true; } if (evt.RightDown()) { - if (m_op_a >= 0 || !m_mirror_targets.empty()) { - reset_op(); - m_selection.clear(); - if (on_selection_changed) on_selection_changed(0); - } else { - request_exit(); - } + // The draw tools' rule (right_abandon): drop what is pending, else hand the click + // back so the offer opens. Leaving the tool is Esc's job, not right-click's. + if (m_op_a < 0 && m_mirror_targets.empty()) return false; + reset_op(); + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); return true; } return false; @@ -10319,8 +10524,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); // 1) live gizmo: click a value label to type, or grab the handle to drag. if (tf_ready()) { - const Linef3 rl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); - const double ltol = std::max(tol, (m_plane.project(rl.a, rl.vector()) - p).norm()); + const double ltol = std::max(tol, screen_tol(canvas, evt, p, kLabelPx)); if ((m_tf_label_a - p).norm() <= ltol) { open_tf_editor_a(); return true; } if ((m_tf_label_b - p).norm() <= ltol) { open_tf_editor_count(); return true; } if (hit_test_tf_handle(p, tol)) { m_tf_dragging = true; return true; } @@ -10331,19 +10535,16 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double d = entity_pick_dist(p, m_entities[i]); if (d < best) { best = d; bi = int(i); } } - if (bi >= 0 && best <= tol * 3.0) { tf_pick(bi); return true; } - // 3) empty click confirms a ready gesture. - if (tf_ready()) confirm_transform(); + if (bi >= 0 && best <= screen_tol(canvas, evt, p, kToolPickPx)) { tf_pick(bi); return true; } + // 3) Empty space never commits (charter 4.2): the pending transform waits for Enter/✓. + if (tf_ready()) notify(_u8L("Press Enter or ✓ to apply, Esc to discard"), false); return true; } if (evt.RightDown()) { - if (!m_tf_targets.empty()) { - reset_tf(); - m_selection.clear(); - if (on_selection_changed) on_selection_changed(0); - } else { - request_exit(); - } + if (m_tf_targets.empty()) return false; // nothing to abandon: the offer opens + reset_tf(); + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); return true; } return false; @@ -10359,8 +10560,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) } if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); - const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 10, evt.GetY())); - const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + const double tol = screen_tol(canvas, evt, p, kPickPx); const int h = hit_test_xform_handle(p, tol); if (h >= 0) { m_xform_handle = h; @@ -10375,7 +10575,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) return true; } if (evt.LeftUp()) { m_xform_handle = -1; return true; } - if (evt.RightDown()) { if (on_exit) on_exit(); else cancel(); return true; } + // Right-click opens the offer like everywhere else; the placement is kept with Enter/✓ + // and discarded with Esc/✗ (the panel's Insert card), never by a mouse button. + if (evt.RightDown()) return false; return false; } @@ -10393,6 +10595,12 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double d = entity_pick_dist(p, m_entities[i]); if (d < best) { best = d; bi = int(i); } } + // Same reach as every other tool that picks an entity to act on. With no limit, + // a click anywhere on screen picked whatever was nearest; empty space now clears. + if (bi >= 0 && best > screen_tol(canvas, evt, p, kToolPickPx)) { + bi = -1; + m_pick0 = m_pick1 = m_pick2 = -1; + } if (bi >= 0) { // Rolling three-slot selection: slots 0/1 feed all 2-entity // constraints; slot 2 is the Symmetric axis (only filled once @@ -10404,7 +10612,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) } return true; } - if (evt.RightDown()) { cancel(); return true; } + // The picks are this mode's selection, and right-click on a selection opens the + // offer for it — the constraint verbs. It used to cancel(), wiping the whole session + // behind the panel's back. Esc drops the picks. return false; } if (evt.LeftDown()) { @@ -10425,11 +10635,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) } return true; } - if (evt.RightDown()) { - cancel(); - return true; - } - return false; + return false; // right-click included: the offer opens on the picked segment } // Selection mode: click to pick an entity, Shift/Ctrl to extend, double-click @@ -10530,9 +10736,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) Vec2d p; screen_to_plane(canvas, evt, p); if (evt.LeftDClick()) { // double-click a quote label -> edit it - const Linef3 rd = canvas.mouse_ray(Point(evt.GetX() + 28, evt.GetY())); - const double dtol = std::max(2.0, (m_plane.project(rd.a, rd.vector()) - p).norm()); - const int di = hit_test_dimension(p, dtol); + // Labels have ONE budget, single- or double-click, and it is in pixels: the old + // 2 mm floor made a label's target grow without bound as the view zoomed in. + const int di = hit_test_dimension(p, screen_tol(canvas, evt, p, kLabelPx)); if (di >= 0) { edit_dimension(di); return true; } } // Zoom-aware tolerance: project a point 8 px away and measure in plane units. @@ -10546,8 +10752,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // opens — so typing a value sets the precise dimension. Generous label // tolerance (~24 px) since text labels are wider than a point grip. if (evt.LeftDown()) { - const Linef3 rdl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); - const double ltol = std::max(tol, (m_plane.project(rdl.a, rdl.vector()) - p).norm()); + const double ltol = std::max(tol, screen_tol(canvas, evt, p, kLabelPx)); const int di = hit_test_dimension(p, ltol); if (di >= 0) { open_value_editor(di); return true; } for (const DimAnnot& q : m_live_quotes) { @@ -10733,10 +10938,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDClick()) { // double-click a quote label -> edit it Vec2d p; screen_to_plane(canvas, evt, p); - const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 28, evt.GetY())); - const Vec2d p2 = m_plane.project(r2.a, r2.vector()); - const double di_tol = std::max(2.0, (p2 - p).norm()); - const int di = hit_test_dimension(p, di_tol); + const int di = hit_test_dimension(p, screen_tol(canvas, evt, p, kLabelPx)); if (di >= 0) edit_dimension(di); m_dim_has0 = false; return true; @@ -10759,8 +10961,8 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (t == SketchEntity::Type::Line) { a.kind = DimType::Length; place_dimension(a); } else if (t == SketchEntity::Type::Circle) { a.kind = DimType::Diameter; place_dimension(a); } else if (t == SketchEntity::Type::Arc) { a.kind = DimType::Radius; place_dimension(a); } - else if (on_readout) - on_readout("no dimension for that entity yet — click a line, a circle, an arc, or two points"); + else + notify(_u8L("No dimension for that entity yet — click a line, a circle, an arc, or two points")); } } else { if (got_pt && !(pe == m_dim_e0 && pr == m_dim_r0)) { @@ -10771,14 +10973,18 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) DimAnnot a; a.kind = DimType::DistanceToLine; a.ea = m_dim_e0; a.ra = m_dim_r0; a.eb = he; place_dimension(a); - } else if (he >= 0 && on_readout) { - on_readout("click a second point, or a line to measure the distance to"); + } else if (he >= 0) { + notify(_u8L("Click a second point, or a line to measure the distance to"), false); } m_dim_has0 = false; // reset after the second pick } return true; } - if (evt.RightDown()) { m_dim_has0 = false; return true; } + if (evt.RightDown()) { // abandon the first pick, if any (right_abandon's rule) + if (!m_dim_has0) return false; // nothing down: the offer opens + m_dim_has0 = false; + return true; + } return false; // let drag orbit the camera } @@ -10788,7 +10994,10 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) m_has_cursor = true; m_cursor_locked = false; bool vsnap = false; - m_cursor = snap_vertex(canvas, evt, m_cursor, vsnap); // preview-snap to endpoints + // Preview exactly what the next click will do: a snap marker over a click that then + // lands on the raw point promised a coincidence that never happened. + if (click_snaps()) m_cursor = snap_vertex(canvas, evt, m_cursor, vsnap); // preview-snap + else m_cursor_snap = InferenceSnap{}; // The chain's own start is not an entity yet, so snap_vertex cannot see it. Offer it // here: the cursor lands exactly on it, so the rubber band below IS the closing segment. if (snap_chain_start(canvas, evt, m_cursor)) vsnap = true; @@ -10822,17 +11031,13 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) bool lk = false; p = snap_dir(m_points.back(), p, lk); // lock new segment to inference angle } + m_chain_dup_tol = screen_tol(canvas, evt, p, 3.0); m_points.push_back(p); arm_polyline_segment_edit(); // refine this segment's Length+Angle, then continue return true; } if (evt.LeftDClick()) { - if (m_points.size() >= 2) { - const int base = int(m_entities.size()); - push_open_chain(m_points); // end as an open chain - infer_auto_constraints(base); - m_points.clear(); - } + if (m_points.size() >= 2) end_chain(); // end as an open chain return true; } if (evt.RightDown() && m_points.empty()) @@ -10848,11 +11053,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // is this tab's goal and it stays a four-action triangle either way; what changes // is that the closing edge is now something the user saw and chose, not one the // app appended on their behalf. - const int base = int(m_entities.size()); - if (m_points.size() >= 2) - push_open_chain(m_points); - infer_auto_constraints(base); - m_points.clear(); + end_chain(); return true; } break; @@ -10888,6 +11089,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -10910,6 +11112,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -10993,6 +11196,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -11026,6 +11230,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) case Mode::ThreePointCircle: { if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } m_points.push_back(p); if (m_points.size() == 3) { append_entities(make_three_point_circle(m_points[0], m_points[1], m_points[2])); @@ -11098,6 +11303,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) case Mode::Slot: { if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.size() < 2) { m_points.push_back(p); } else { @@ -11150,6 +11356,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) case Mode::Polygon: { if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -11211,18 +11418,14 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) m_snap_off = evt.ShiftDown(); bool vsnap = false; p = snap_vertex(canvas, evt, p, vsnap); // poles can land on endpoints + m_chain_dup_tol = screen_tol(canvas, evt, p, 3.0); m_points.push_back(p); return true; } if (evt.RightDown() && m_points.empty()) return false; // no poles down — ghcz, let the offer open if (evt.LeftDClick() || evt.RightDown()) { - if (m_points.size() >= 2) { - const int base = int(m_entities.size()); - append_entities(make_bspline(m_points)); - infer_auto_constraints(base); // end poles auto-Coincident -> loops close - } - m_points.clear(); + end_chain(); return true; } break; @@ -11232,6 +11435,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } push_point(p); return true; } diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 9763f74c60..62e97981f7 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -67,6 +67,17 @@ public: // row off arms a NEIGHBOURING tool and then grades whatever that drew. ekt9. Mode mode() const { return m_mode; } int pending_points() const { return int(m_points.size()); } + // Picks the armed tool is holding that are not yet an entity or a constraint: the Dimension + // tool's first anchor, an edit-op's or a transform's subjects, Constrain's picks. Together + // with pending_points() they are CadLevel::Gesture, so Esc drops them and keeps the tool. + bool has_pending_picks() const { + return m_dim_has0 || m_op_a >= 0 || !m_tf_targets.empty() || m_pick0 >= 0 || m_sel_a >= 0; + } + bool gesture_pending() const { return !m_points.empty() || has_pending_picks(); } + // Enter while an edit-op or a transform is ready: apply it, the same as its value field's + // Enter. False when nothing is pending, so the key can fall through to the tab's ✓. + bool confirm_pending(); + bool end_chain(); // Polyline / Spline: finish the open chain as drawn void emit_step_hint(); // fires on_step_changed when the step actually moved // Is an in-canvas value field open? While one is, the canvas is frozen and every letter is // swallowed — the single most common reason a driven gesture "does nothing". @@ -203,9 +214,6 @@ public: void clear_move_gizmo(); bool moving_body() const { return m_mv_active; } int move_body_index() const { return m_mv_body; } - // F key forwarded from the canvas (Prepare's Place on Face): returns true if it acted. - bool request_place_on_face() { return on_place_on_face ? on_place_on_face() : false; } - std::function on_place_on_face; 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; @@ -550,25 +558,15 @@ public: reset_autoedit(); if (on_readout) on_readout(std::string()); // the HUD is not redrawn once the tool stops } - // Ctrl+Z while sketching: drop the last drawn entity (reuses delete_selected's remap). - bool undo_last_entity() { - if (!m_active || m_entities.empty()) return false; - m_selection.assign(1, int(m_entities.size()) - 1); - delete_selected(); - reset_autoedit(); - return true; - } - // Delete while sketching: the selected entities, or the last drawn one if none is selected. - bool delete_selected_or_last() { - if (!m_active) return false; - if (m_selection.empty()) { - if (m_entities.empty()) return false; - m_selection.assign(1, int(m_entities.size()) - 1); - } - delete_selected(); - reset_autoedit(); - return true; - } + // Ctrl+Z while sketching: drop the last thing DRAWN — a whole rectangle, slot or polygon + // when the last entity belongs to one, since that was one gesture. Undoing one side of a + // rectangle left three lines and dissolved the shape. + bool undo_last_entity(); + // Ctrl+Y / Ctrl+Shift+Z while sketching: bring back what undo_last_entity removed, as long + // as nothing was drawn or deleted since (then the old state is no longer "the next step"). + bool redo_last_entity(); + bool can_undo_entity() const { return m_active && !m_entities.empty(); } + bool can_redo_entity() const; std::function on_selection_changed; // Dimension tool: infer a driving dimension from the current selection and set @@ -612,6 +610,14 @@ public: // characteristic dimensions). Empty string -> hide the HUD. The owner (DesignCanvas) // shows it as a floating corner label over the GL canvas. std::function on_readout; + // Why something did not happen, or what a gesture did as a side effect. NOT on_readout: + // that one is rewritten every frame from build_readout(), so a message sent through it was + // gone before anyone could read it. The host shows this on its persistent status line. + std::function on_notice; + void notify(const std::string& msg, bool error = true) { if (on_notice) on_notice(msg, error); } + // A typed value the tool cannot take: the open value field comes back with the reason, or, + // when no field is committing, the reason goes to the status line. + void show_refusal(const std::string& why); // Driving dimension constraints accumulated during the session (the Dimension // tool records a SketchEntityConstraintDef per applied dimension); committed @@ -633,17 +639,11 @@ public: // request_exit() declined to leave because the session holds geometry. The panel owns the // status line, so the tool reports through this instead of writing text itself. std::function on_exit_refused; - std::function on_move_exit; // right-click finished the move-body gizmo // The two inner Esc levels, callable on their own so the panel can route one press to one // level (see DesignInteraction.hpp). Each returns whether it had anything to unwind. bool abort_gesture(); // CadLevel::Gesture — drop the entity being drawn bool disarm_tool(); // CadLevel::Tool — armed draw/edit tool falls back to Select void request_exit(); - // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) while the Design canvas is focused: undo/redo the - // committed feature history. The tool just forwards to the host, which owns the - // CadDocument (the tool has no document of its own). redo == true requests redo. - std::function on_undo_redo; - void request_undo_redo(bool redo); private: bool screen_to_plane(GLCanvas3D& canvas, const wxMouseEvent& evt, Vec2d& out) const; @@ -660,10 +660,17 @@ private: // chains join across entities (a line + an arc can close into one loop). Shift // disables it. `snapped` reports whether a vertex was hit. Vec2d snap_vertex(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw, bool& snapped) const; + bool click_snaps() const; // does the NEXT click of the armed draw tool land on a snap target? // --- P1 inference / auto-constraint engine --------------------------------- // Plane-units tolerance equivalent to ~`px` screen pixels at the cursor. - double screen_tol(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& at, double px = 8.0) const; + // The sketcher's pick budgets, in screen pixels, converted to plane units by screen_tol(). + // One set for every tool, so the same thing is equally easy to hit whichever tool is armed. + static constexpr double kPickPx = 8.0; // grab a point, a handle or an edge; hover uses the same + static constexpr double kToolPickPx = 24.0; // pick an entity for a tool to act on (trim, edit-ops, + // transforms, constrain) + static constexpr double kLabelPx = 24.0; // click or double-click a value label + double screen_tol(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& at, double px = kPickPx) const; // Run kernel inference at the cursor, cache the target for the hint renderer. InferenceSnap infer_at(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw) const; // True if m_constraints already holds an equivalent Coincident between the two refs. @@ -1050,6 +1057,16 @@ private: Vec2d m_live_slot_angle_label{0,0}; // straight-slot centreline angle label int m_live_slot_fi{-1}; // the straight-slot Feature (rebuild edits) std::vector m_features; // parametric groups over m_entities + // Sketch-local redo: the state each undo_last_entity() replaced, and the entity/constraint + // counts it left behind (a mismatch means the sketch moved on and the redo is stale). + struct SketchSnap { + std::vector entities; std::vector constraints; + std::vector features; std::vector dimensions; + size_t after_entities{0}; size_t after_constraints{0}; + }; + std::vector m_sketch_redo; + int m_skipped_last{0}; // constraints the last solve could not apply (reported on change) + double m_chain_dup_tol{1e-9}; // plane units ~3 px at the last chain click (double-click repeat) int m_open_feature{-1}; // index of the Feature being built, or -1 // In-canvas edit-op gizmo state (Fillet/Chamfer/Offset/Mirror). GUI-only, reset by diff --git a/src/slic3r/GUI/CAD/McpControl.cpp b/src/slic3r/GUI/CAD/McpControl.cpp index 0ad05667c3..bc05e480c1 100644 --- a/src/slic3r/GUI/CAD/McpControl.cpp +++ b/src/slic3r/GUI/CAD/McpControl.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include @@ -1995,6 +1997,22 @@ std::string handle_on_main(const std::string& method, const json& params, const DesignPanel* panel = DesignPanel::ensure(); if (!panel) return rpc_error(id, -32001, "Design panel not ready"); + // A project opened without ever showing the Design tab has its recipe only in the Model; + // load it, as showing the tab would, before anything reads or writes the document. + panel->hydrate_from_model(); + + // Methods that only LOOK. Everything else changes the document or the live sketch, and + // must not do it under a GUI editor, a sketch session it does not own, or a rebuild in + // progress (the GUI's worker thread holds the document then). + static const std::set kReadOnly = { + "describe_tools", "describe_scene", "query_topology", "measure", "mass_properties", + "slice_body", "validate_against", "list_verbs", "sketch_describe", "sketch_validate", + "check_interference" }; + if (kReadOnly.count(method) == 0) { + std::string why; + if (panel->mcp_busy(method.rfind("sketch_", 0) == 0, why)) + return rpc_error(id, -32002, "Design tab busy: " + why); + } // Stale-id guard, checked here rather than in each handler. // @@ -2109,11 +2127,16 @@ std::string dispatch_request(const std::string& line) auto prom = std::make_shared>(); auto fut = prom->get_future(); + // 0 = queued, 1 = running, 2 = abandoned. A request that timed out while still QUEUED must + // never run: the client has been told it failed, and a retry would otherwise apply it twice. + auto state = std::make_shared>(0); // Nothing may escape this lambda. It is invoked by the wx event loop, which has no // handler of its own, so an escaping exception is std::terminate — the socket would // become a way for any client to kill the application. handle_on_main() catches what // it knows about; this catches what it does not, and still answers the caller. - wxGetApp().CallAfter([prom, method, params, id]() { + wxGetApp().CallAfter([prom, state, method, params, id]() { + int queued = 0; + if (!state->compare_exchange_strong(queued, 1)) return; // abandoned by a timeout try { prom->set_value(handle_on_main(method, params, id)); } catch (const std::exception& ex) { @@ -2122,20 +2145,33 @@ std::string dispatch_request(const std::string& line) prom->set_value(rpc_error(id, -32000, "internal error: unknown exception")); } }); - if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) - return rpc_error(id, -32000, "main-thread timeout"); + if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) { + int queued = 0; + if (state->compare_exchange_strong(queued, 2)) + return rpc_error(id, -32000, "main-thread timeout: the command was NOT run"); + // Already running: it will finish, so wait for its real answer rather than report a + // failure for a command that is in fact being applied. + fut.wait(); + } return fut.get(); } -// Read newline-delimited requests off one client connection until EOF. +// Read newline-delimited requests off one client connection until EOF. A line may not grow +// past kMaxLine: a client that never sends a newline would otherwise grow this buffer until the +// process runs out of memory. void serve_client(int cfd) { + constexpr size_t kMaxLine = 16u << 20; // 16 MB — far above any real request std::string buf; char chunk[4096]; for (;;) { ssize_t n = ::read(cfd, chunk, sizeof(chunk)); if (n <= 0) break; buf.append(chunk, size_t(n)); + if (buf.size() > kMaxLine && buf.find('\n') == std::string::npos) { + BOOST_LOG_TRIVIAL(error) << "MCP: request line over " << kMaxLine << " bytes; closing the connection"; + return; + } size_t nl; while ((nl = buf.find('\n')) != std::string::npos) { std::string line = buf.substr(0, nl); @@ -2155,9 +2191,30 @@ void serve_client(int cfd) } } +// Where the socket lives, for the exit handler. +std::string g_sock_path; + +void remove_socket_at_exit() +{ + struct stat st{}; + if (!g_sock_path.empty() && ::lstat(g_sock_path.c_str(), &st) == 0 && S_ISSOCK(st.st_mode)) + ::unlink(g_sock_path.c_str()); +} + void server_thread(std::string sock_path) { - ::unlink(sock_path.c_str()); + // Clear a stale socket from an earlier run — and ONLY a socket. ORCA_CAD_MCP names a path, + // and unlinking it unconditionally deleted whatever file that path happened to be. + { + struct stat st{}; + if (::lstat(sock_path.c_str(), &st) == 0) { + if (!S_ISSOCK(st.st_mode)) { + BOOST_LOG_TRIVIAL(error) << "MCP: " << sock_path << " exists and is not a socket; refusing to replace it"; + return; + } + ::unlink(sock_path.c_str()); + } + } int sfd = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sfd < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: socket() failed"; return; } @@ -2182,6 +2239,8 @@ void server_thread(std::string sock_path) ::close(sfd); ::unlink(sock_path.c_str()); return; } if (::listen(sfd, 1) < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: listen() failed"; ::close(sfd); return; } + g_sock_path = sock_path; + std::atexit(remove_socket_at_exit); // do not leave the socket file behind BOOST_LOG_TRIVIAL(info) << "MCP control listening on " << sock_path; for (;;) { diff --git a/src/slic3r/GUI/CAD/McpControl.hpp b/src/slic3r/GUI/CAD/McpControl.hpp index 12e3289eb5..3a07bf920d 100644 --- a/src/slic3r/GUI/CAD/McpControl.hpp +++ b/src/slic3r/GUI/CAD/McpControl.hpp @@ -1,22 +1,24 @@ #ifndef slic3r_GUI_McpControl_hpp_ #define slic3r_GUI_McpControl_hpp_ -// MCP control surface (slice 1): a local JSON-RPC 2.0 server, line-delimited over a -// Unix domain socket, that lets an external MCP bridge drive and perceive the Design -// tab. Off unless the env var ORCA_CAD_MCP is set: +// MCP control surface: a local JSON-RPC 2.0 server, line-delimited over a Unix domain +// socket, that lets an external MCP bridge drive and perceive the Design tab. Off unless the +// CAD feature is enabled in Preferences AND the env var ORCA_CAD_MCP is set: // ORCA_CAD_MCP=1 -> socket at /tmp/orca-cad-mcp.sock -// ORCA_CAD_MCP=/path/to.sock -> socket at that path -// All CAD work is marshalled onto the wx main thread and runs through the SAME -// CadDocument kernel the GUI uses (no parallel engine). Slice-1 methods: -// describe_tools, describe_scene, extrude. +// ORCA_CAD_MCP=/path/to.sock -> socket at that path (an existing non-socket file there is +// left alone and the server does not start) +// The socket is created 0600 and removed at exit. All CAD work is marshalled onto the wx main +// thread and runs through the SAME CadDocument kernel the GUI uses (no parallel engine); +// describe_tools lists the methods. A method that changes the document is refused (-32002) +// while the Design tab is busy with it — a rebuild, an open feature card, a sketch session. // // ponytail: Unix-socket only (POSIX). Windows compiles this to a no-op; add a named // pipe transport when a Windows agent actually needs it. namespace Slic3r { namespace GUI { -// Start the server thread iff ORCA_CAD_MCP is set. Safe to call once after the -// MainFrame + DesignPanel exist. No-op when the env var is unset or on Windows. +// Start the server thread iff ORCA_CAD_MCP is set. Safe to call once the MainFrame exists — +// the Design panel is built on the first request. No-op when the env var is unset or on Windows. void start_mcp_control_if_enabled(); }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/SketchInlineEditor.cpp b/src/slic3r/GUI/CAD/SketchInlineEditor.cpp index ae8bce8ebb..75371759f9 100644 --- a/src/slic3r/GUI/CAD/SketchInlineEditor.cpp +++ b/src/slic3r/GUI/CAD/SketchInlineEditor.cpp @@ -22,7 +22,8 @@ namespace { // Numbers are typed and shown with a POINT, whatever the locale: this field feeds a CAD kernel, // and a decimal comma reaching it as a thousands separator is a silent order-of-magnitude error. // Parsing accepts either separator because a keyboard's numeric pad may only offer one. -std::string fmt_value(double v, int digits = 2) +// Trailing zeros are dropped, so a count opens as "3" and a length as "37.457", not "37.46". +std::string fmt_value(double v, int digits = 3) { char fmt[16]; std::snprintf(fmt, sizeof(fmt), "%%.%df", digits); @@ -30,7 +31,13 @@ std::string fmt_value(double v, int digits = 2) std::snprintf(buf, sizeof(buf), fmt, v); for (char* c = buf; *c; ++c) if (*c == ',') *c = '.'; - return std::string(buf); + std::string s(buf); + if (s.find('.') != std::string::npos) { + while (!s.empty() && s.back() == '0') s.pop_back(); + if (!s.empty() && s.back() == '.') s.pop_back(); + } + if (s == "-0") s = "0"; + return s; } bool parse_value(const char* text, double& out) @@ -80,6 +87,9 @@ void SketchInlineEditor::open(const wxPoint& canvas_px, double value, const std: m_cancel = std::move(on_cancel); const std::string v = fmt_value(value); std::snprintf(m_buf, sizeof(m_buf), "%s", v.c_str()); + m_prefill_value = value; + m_prefill_text = v; + m_active = false; m_open = true; // ImGui takes keyboard focus for one frame on request; asking on the frame the field first // appears is what makes typing land without a click. There is no window manager to consult. @@ -91,6 +101,7 @@ void SketchInlineEditor::close() { m_open = false; m_focus_pending = false; + m_active = false; m_commit = nullptr; m_cancel = nullptr; m_err.clear(); @@ -114,10 +125,32 @@ void SketchInlineEditor::do_cancel() if (cb) cb(); } +bool SketchInlineEditor::refuse(const std::string& why) +{ + // Only meaningful while a commit callback runs and before it opened another field. + if (!m_in_commit || m_open) return false; + m_anchor = m_last.anchor; + m_title = m_last.title; + m_commit = m_last.commit; + m_cancel = m_last.cancel; + m_prefill_value = m_last.prefill_value; + m_prefill_text = m_last.prefill_text; + std::snprintf(m_buf, sizeof(m_buf), "%s", m_last.buf.c_str()); + m_err = why; + m_open = true; + m_focus_pending = true; + ux_trace("refused", m_title, "reason=" + why); + return true; +} + void SketchInlineEditor::do_commit() { double v = 0.0; - if (!parse_value(m_buf, v)) { + // An untouched field commits the value it opened with, not its rounded display: Enter on a + // 37.4567 line must not turn it into a 37.457 driving dimension. + if (m_prefill_text == m_buf) + v = m_prefill_value; + else if (!parse_value(m_buf, v)) { // Refusing input in silence is indistinguishable from the app having frozen: the field // just sits there and the user has no idea what it wants. Say so in the title line and // keep editing. @@ -128,11 +161,15 @@ void SketchInlineEditor::do_commit() } ux_trace("commit", m_title, std::string("typed=") + m_buf + " value=" + fmt_value(v, 4)); auto cb = m_commit; + m_last = Closed{ m_anchor, m_title, std::string(m_buf), m_commit, m_cancel, m_prefill_value, m_prefill_text }; close(); // AFTER close(): the callback may open the next queued dimension (a rectangle queues Width // then Height), and doing that into a field that still believes it is open would drop the // second one's prefill on the floor. + m_in_commit = true; if (cb) cb(v); + m_in_commit = false; + m_last = Closed{}; } bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) @@ -167,10 +204,15 @@ bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) // EnterReturnsTrue so Enter commits from inside the widget; AutoSelectAll so the prefill is // replaced by the first digit typed, which is what "pre-selected" meant when this was a // wxTextCtrl and is what makes typing a value a single gesture. + // No CharsDecimal: it lets only the LOCALE's decimal separator through, so in a C locale a + // comma could not be typed at all, and it admits '*' and '/' that the parser then refuses. + // parse_value() is the one judge of what is a number. const bool entered = ImGui::InputText("##sketchvalue_in", m_buf, sizeof(m_buf), ImGuiInputTextFlags_EnterReturnsTrue - | ImGuiInputTextFlags_AutoSelectAll - | ImGuiInputTextFlags_CharsDecimal); + | ImGuiInputTextFlags_AutoSelectAll); + m_active = ImGui::IsItemActive(); + // Tab accepts the value like Enter does (and moves on to the next queued field, if any). + const bool tabbed = m_active && ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_Tab)); // MEASUREMENT, not a fix: one line per frame saying whether ImGui believes it owns the // keyboard and whether our widget is the active one. "Typing does not arrive" has two very // different causes — no FRAMES (this canvas repaints on demand only, so an idle canvas never @@ -194,7 +236,7 @@ bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) // Act AFTER end(): do_commit can reopen the field for the next queued dimension, and that // must not happen inside this frame's window. - if (entered) + if (entered || tabbed) do_commit(); else if (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_Escape))) do_cancel(); diff --git a/src/slic3r/GUI/CAD/SketchInlineEditor.hpp b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp index 162d157864..a567d978be 100644 --- a/src/slic3r/GUI/CAD/SketchInlineEditor.hpp +++ b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp @@ -48,6 +48,10 @@ public: void close(); // drop it with neither callback void cancel(); // if open, run the registered cancel (keep-as-drawn) void commit(); // if open, run the registered commit (accept the typed value) + // Called from inside a commit callback that cannot accept the value (a negative length, a + // count of 1): the field comes back with the typed text and `why` in its title line, instead + // of closing as if the value had been taken. + bool refuse(const std::string& why); // true if the field came back bool is_open() const { return m_open; } // Draw it, and let ImGui do the editing. Called from DesignSketchTool::render() inside the @@ -69,11 +73,10 @@ public: // breaks the circle. std::function request_frame; - // Kept because callers ask them, but there is no longer any difference to report: with no - // window there is no state where the field is on screen but logically closed, and no state - // where it is open but somebody else holds the keyboard. bool is_mapped() const { return m_open; } - bool has_focus() const { return m_open; } + // Does the ImGui text widget own the keyboard? False once a click elsewhere deactivated it; + // the panel then forwards Enter/Tab to commit() itself, since ImGui no longer sees them. + bool has_focus() const { return m_open && (m_active || m_focus_pending); } void dismiss() { close(); } private: @@ -88,6 +91,17 @@ private: std::string m_title; std::string m_err; // why the last value was refused, shown in the title line char m_buf[64]{}; // the edited text; ImGui::InputText writes into it + double m_prefill_value{0.0}; // the exact value the field opened with... + std::string m_prefill_text; // ...and how it was shown: an untouched field commits the former + bool m_active{false}; // the InputText was the active item last frame + // What the last commit closed, so refuse() can bring the same field back. + struct Closed { + wxPoint anchor; std::string title; std::string buf; + std::function commit; std::function cancel; + double prefill_value{0.0}; std::string prefill_text; + }; + Closed m_last; + bool m_in_commit{false}; }; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 01ff6de714..c4d9173268 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -3415,68 +3415,10 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) return; } -#ifdef SLIC3R_CAD - const int ctrlMask = wxMOD_CONTROL; - const int shiftMask = wxMOD_SHIFT; -#endif - - // Design tab: Delete/Backspace removes the selected sketch entities while a - // sketch tool is active and the canvas has focus (dialog text fields are separate - // wx controls, so this never eats their editing keys). -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() - && (keyCode == WXK_DELETE || keyCode == WXK_BACK) - && !m_design_sketch_tool->selection().empty()) { - m_design_sketch_tool->delete_selected(); - m_dirty = true; - render(); - return; - } -#endif - - // Esc exits the active sketch tool (Onshape-like, layered: abort in-progress entity -> - // drop to Select -> exit the session back to Feature mode). -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() - && keyCode == WXK_ESCAPE) { - m_design_sketch_tool->request_exit(); - m_dirty = true; - render(); - return; - } -#endif - - // Design tab: Ctrl+Z / Ctrl+Shift+Z (and Ctrl+Y) undo/redo the Design feature - // history. Scoped by m_design_sketch_tool — only the Design canvas owns one — so the - // main 3D editor's undo/redo (the CanvasView3D-gated cases further below) is untouched. - // Handled here, before the generic Ctrl block, so it takes precedence and early-returns. -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && (evt.GetModifiers() & ctrlMask) != 0) { - const bool is_z = (keyCode == 'z' || keyCode == 'Z' || keyCode == WXK_CONTROL_Z); - const bool is_y = (keyCode == 'y' || keyCode == 'Y' || keyCode == WXK_CONTROL_Y); - if (is_z || is_y) { - const bool redo = is_y || ((evt.GetModifiers() & shiftMask) != 0); - m_design_sketch_tool->request_undo_redo(redo); - m_dirty = true; - render(); - return; - } - } -#endif - - // Design tab: F = Place on Face (Prepare's lay-flat), when the Design viewport is up - // and a body face is selected. The tool forwards to DesignPanel::place_on_face; it returns - // false (no face picked) so F falls through to the default handler below. -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && m_design_sketch_tool->has_display() - && (keyCode == 'f' || keyCode == 'F') && (evt.GetModifiers() & ctrlMask) == 0) { - if (m_design_sketch_tool->request_place_on_face()) { - m_dirty = true; - render(); - return; - } - } -#endif + // Design tab: Delete, Esc, Ctrl+Z/Y and F are NOT handled here. DesignPanel's CHAR_HOOK owns + // them (one route per key, whatever holds focus) and only lets a key through to this canvas + // when an in-canvas value field is open -- which is exactly when Backspace must edit the + // number rather than delete the geometry it measures. //BBS: add orient deactivate logic if (keyCode == WXK_ESCAPE @@ -3754,19 +3696,6 @@ static void key_released(int key) { s_keys_down.erase(key); } void GLCanvas3D::on_key(wxKeyEvent& evt) { - // Design tab: Delete/Backspace removes selected sketch entities. GTK delivers - // these as KEY_DOWN rather than CHAR, so handle it here too. -#ifdef SLIC3R_CAD - if (evt.GetEventType() == wxEVT_KEY_DOWN - && m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() - && (evt.GetKeyCode() == WXK_DELETE || evt.GetKeyCode() == WXK_BACK) - && !m_design_sketch_tool->selection().empty()) { - m_design_sketch_tool->delete_selected(); - m_dirty = true; - render(); - return; - } -#endif const int keyCode = evt.GetKeyCode(); if (evt.GetEventType() == wxEVT_KEY_DOWN) diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 343d95d3ff..8d0e40278f 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -1320,6 +1320,14 @@ void MainFrame::show_option(bool show) } } +#ifdef SLIC3R_CAD +DesignPanel* MainFrame::shown_design_panel() const +{ + DesignPanel* design = DesignPanel::if_built(); + return (design != nullptr && m_design_page != nullptr && m_design_page->IsShownOnScreen()) ? design : nullptr; +} +#endif + void MainFrame::init_tabpanel() { // wxNB_NOPAGETHEME: Disable Windows Vista theme for the Notebook background. The theme performance is terrible on // Windows 10 with multiple high resolution displays connected. @@ -1411,8 +1419,9 @@ void MainFrame::init_tabpanel() { #ifdef SLIC3R_CAD // The experimental feature is off by default, and when it is off the page is never // created, so the tab does not appear at all (the preference takes effect on the next - // start, like the other feature toggles). - if (wxGetApp().is_enable_cad_feature()) { + // start, like the other feature toggles). Nor in the G-code viewer, which has no Design tab to put + // it in — and no business opening a control socket onto one. + if (wxGetApp().is_enable_cad_feature() && wxGetApp().is_editor()) { // Experimental and heavy enough that building it unasked would cost more than it saves. m_design_page = new LazyPage(this, TAB_ID_DESIGN, -1); m_lazy_pages.push_back(m_design_page); @@ -3001,12 +3010,28 @@ void MainFrame::init_menubar_as_editor() #ifndef __APPLE__ // BBS undo append_shortcut_item(editMenu, Shortcut::Undo, true, _L("Undo"), - _L("Undo"), [this](wxCommandEvent&) { m_plater->undo(); }, - "menu_undo", nullptr, [this](){return m_plater->can_undo(); }, this); + _L("Undo"), [this](wxCommandEvent&) { +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(false); return; } +#endif + m_plater->undo(); }, + "menu_undo", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(false); +#endif + return m_plater->can_undo(); }, this); // BBS redo append_shortcut_item(editMenu, Shortcut::Redo, true, _L("Redo"), - _L("Redo"), [this](wxCommandEvent&) { m_plater->redo(); }, - "menu_redo", nullptr, [this](){return m_plater->can_redo(); }, this); + _L("Redo"), [this](wxCommandEvent&) { +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(true); return; } +#endif + m_plater->redo(); }, + "menu_redo", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(true); +#endif + return m_plater->can_redo(); }, this); editMenu->AppendSeparator(); // BBS Cut TODO append_shortcut_item(editMenu, Shortcut::Cut, true, _L("Cut"), @@ -3053,8 +3078,15 @@ void MainFrame::init_menubar_as_editor() if (handle_key_event(e)) { return; } +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(false); return; } +#endif m_plater->undo(); }, - "", nullptr, [this](){return m_plater->can_undo(); }, this); + "", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(false); +#endif + return m_plater->can_undo(); }, this); // BBS redo append_shortcut_item(editMenu, Shortcut::Redo, false, _L("Redo"), _L("Redo"), [this, handle_key_event](wxCommandEvent&) { @@ -3065,8 +3097,15 @@ void MainFrame::init_menubar_as_editor() if (handle_key_event(e)) { return; } +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(true); return; } +#endif m_plater->redo(); }, - "", nullptr, [this](){return m_plater->can_redo(); }, this); + "", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(true); +#endif + return m_plater->can_redo(); }, this); editMenu->AppendSeparator(); // BBS Cut TODO append_shortcut_item(editMenu, Shortcut::Cut, false, _L("Cut"), diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index 0033f41a18..798ddad274 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -495,6 +495,9 @@ public: // through LazyInstance's statics, and show_device() only moves pages in and out of the book. #ifdef SLIC3R_CAD LazyPage* m_design_page { nullptr }; + // The Design panel when its tab is the one on screen, else null. Edit > Undo/Redo act on + // the tab that is shown: its own history when that is Design, the plater's otherwise. + DesignPanel* shown_design_panel() const; #endif //BBS: GUI refactor LazyPage* m_monitor_page{ nullptr };