diff --git a/docs/CAD/design_tab.md b/docs/CAD/design_tab.md index 80a94373f6..2270909bcc 100644 --- a/docs/CAD/design_tab.md +++ b/docs/CAD/design_tab.md @@ -60,6 +60,9 @@ pointing at things stays quiet. The offer also opens by itself the moment you press Sketch on a face or plane, showing the sketch tools — the app hands you the tools directly. +The first line names what the rows act on — *"Flat face 4 of Body 2"*, *"Sketch line"*, +*"Nothing selected"* — so the menu is readable even when the selection is not under the cursor. + **Eight families, always in this fixed order:** Create, Add material, Remove, Dress-up, Repeat, Transform, Reference, Modify. @@ -302,6 +305,10 @@ variable and the whole model follows. ## Import and export +**Text** asks for the words, the font (any installed font, bold, italic) and the height, and +shows the outline that will be inserted with its size in millimetres before anything is added. +The last font and height are remembered. + **Import STEP** brings in a real B-rep solid, not a mesh: its faces and edges can be filleted, shelled and cut like anything modelled here. diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index e95ec4e937..2e856c5620 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -301,6 +301,7 @@ src/slic3r/GUI/CAD/DesignPanel.cpp src/slic3r/GUI/CAD/DesignSketchTool.cpp src/slic3r/GUI/CAD/SketchInlineEditor.cpp src/slic3r/GUI/CAD/DesignOffer.hpp +src/slic3r/GUI/CAD/DesignTextDialog.cpp src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp src/slic3r/GUI/KeyChord.cpp diff --git a/src/libslic3r/CAD/SketchImport.cpp b/src/libslic3r/CAD/SketchImport.cpp index 7140776603..21c03a259a 100644 --- a/src/libslic3r/CAD/SketchImport.cpp +++ b/src/libslic3r/CAD/SketchImport.cpp @@ -67,12 +67,18 @@ ImportRegions text_to_regions(const std::string& utf8, double size_mm, return {}; const std::string path = font_path.empty() ? default_font_path() : font_path; - std::unique_ptr ff = Emboss::create_font_file(path.c_str()); - if (!ff) - return {}; - Emboss::FontFileWithCache fwc(std::move(ff)); - if (!fwc.has_value()) + return text_to_regions(utf8, size_mm, + std::shared_ptr(Emboss::create_font_file(path.c_str()))); +} + +ImportRegions text_to_regions(const std::string& utf8, double size_mm, + const std::shared_ptr& ff) +{ + if (utf8.empty() || size_mm <= 0.0 || !ff) return {}; + Emboss::FontFileWithCache fwc; + fwc.font_file = ff; + fwc.cache = std::make_shared(); FontProp prop(static_cast(size_mm)); // per_glyph=false HealedExPolygons healed = Emboss::text2shapes(fwc, utf8.c_str(), prop); diff --git a/src/libslic3r/CAD/SketchImport.hpp b/src/libslic3r/CAD/SketchImport.hpp index 0873b0b861..5b720f3892 100644 --- a/src/libslic3r/CAD/SketchImport.hpp +++ b/src/libslic3r/CAD/SketchImport.hpp @@ -3,11 +3,14 @@ #include "libslic3r/Point.hpp" // Vec2d +#include #include #include namespace Slic3r { +namespace Emboss { struct FontFile; } + // A rigid imported region: contour[0] = outer loop, contour[1..] = holes; // points in plane (u,v) millimetres. The nested vector type matches // CadFeature::imported_regions exactly, so results assign directly. @@ -19,6 +22,11 @@ using ImportRegions = std::vector; // font (resources/fonts). Returns an empty vector on any failure. ImportRegions text_to_regions(const std::string& utf8, double size_mm, const std::string& font_path = std::string()); +// Same, from an already loaded font — the GUI resolves a system font (face, bold, italic) +// to one and keeps it while the user types. Returns an empty vector when the font is null or +// yields no shape. +ImportRegions text_to_regions(const std::string& utf8, double size_mm, + const std::shared_ptr& font); // Parse an SVG file's filled paths into regions (mm), centred on the origin. // `scale` multiplies the authored size (1.0 = as authored). Returns an empty diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index f6a8249d4f..13129781b1 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -820,6 +820,8 @@ if (SLIC3R_CAD) list(APPEND SLIC3R_GUI_SOURCES GUI/CAD/DesignPanel.cpp GUI/CAD/DesignPanel.hpp + GUI/CAD/DesignTextDialog.cpp + GUI/CAD/DesignTextDialog.hpp GUI/CAD/DesignCanvas.cpp GUI/CAD/DesignCanvas.hpp GUI/CAD/DesignSketchTool.cpp diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index aef3d3e0a8..1cf84e5849 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -1,6 +1,7 @@ #include "slic3r/GUI/CAD/DesignPanel.hpp" #include "slic3r/GUI/CAD/DesignCanvas.hpp" #include "slic3r/GUI/CAD/DesignSketchTool.hpp" +#include "slic3r/GUI/CAD/DesignTextDialog.hpp" // Text: font, height, live outline #include "slic3r/GUI/CAD/DesignOffer.hpp" // generated offer table — see docs/ux/tool_atlas.json #include "libslic3r/CAD/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor #include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set @@ -4562,15 +4563,12 @@ void DesignPanel::sync_sketch_display() void DesignPanel::on_add_text() { - wxTextEntryDialog dlg(this, _L("Text to insert:"), _L("Text"), wxEmptyString); - if (dlg.ShowModal() != wxID_OK) + // Font, height and a live outline with its size are chosen up front; the bbox handles + // (Move/Scale) still resize the art in the canvas afterwards. + DesignTextDialog dlg(this); + if (dlg.ShowModal() != wxID_OK || dlg.regions().empty()) return; - const wxString text = dlg.GetValue(); - if (text.empty()) - return; - const std::string utf8(text.ToUTF8().data()); - // Insert at a default height; resize in-canvas via the bbox handles (Move/Scale). - add_imported_sketch(text_to_regions(utf8, 10.0), _L("Text")); + add_imported_sketch(dlg.regions(), _L("Text")); } void DesignPanel::on_import_svg() @@ -6369,6 +6367,38 @@ static void offer_trace(const char* fmt, ...) fflush(stderr); } +// The offer menu's title line for selection `kind` (an OfferSel), or empty for none. +wxString DesignPanel::offer_header(int kind) const +{ + const int nb = int(m_doc.bodies.size()); + auto body = [&]() { + if (m_sel_solid_body < 0 || m_sel_solid_body >= nb) return wxString(); + const std::string& n = m_doc.bodies[m_sel_solid_body].name; + return n.empty() ? wxString::Format(_L("Body %d"), m_sel_solid_body + 1) : wxString::FromUTF8(n); + }; + switch (OfferSel(kind)) { + case OfferSel::None: return _L("Nothing selected"); + case OfferSel::BodySolid: return wxString::Format(_L("%s — solid body"), body()); + case OfferSel::BodySheet: return wxString::Format(_L("%s — surface body"), body()); + case OfferSel::FacePlanar: return wxString::Format(_L("Flat face %d of %s"), m_sel_solid_face, body()); + case OfferSel::FaceCyl: return wxString::Format(_L("Cylindrical face %d of %s"), m_sel_solid_face, body()); + case OfferSel::FaceOther: return wxString::Format(_L("Face %d of %s"), m_sel_solid_face, body()); + case OfferSel::EdgeStr: return wxString::Format(_L("Straight edge %d of %s"), m_sel_solid_edge, body()); + case OfferSel::EdgeCirc: return wxString::Format(_L("Circular edge %d of %s"), m_sel_solid_edge, body()); + case OfferSel::Vertex: return wxString::Format(_L("Vertex of %s"), body()); + case OfferSel::SkLoop: return _L("Sketch profile"); + case OfferSel::SkNone: return _L("Sketch — nothing selected"); + case OfferSel::SkLine: return _L("Sketch line"); + case OfferSel::SkArc: return _L("Sketch curve"); + case OfferSel::SkPoint: return _L("Sketch point"); + case OfferSel::Sk2Ent: { + const int n = m_viewport ? m_viewport->sketch_selection_count() : 0; + return wxString::Format(_L("%d sketch entities"), n); + } + default: return wxString(); + } +} + void DesignPanel::show_offer_menu(const wxPoint& screen_pos) { const int kind = offer_selection_kind(); @@ -6420,6 +6450,17 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) std::vector bound; // menu id offset -> verb const int base = wxID_HIGHEST + 4200; + // Header: WHAT the rows below act on, named the way the tree and the cards name it. The rows + // only make sense against the selection, and the selection is not always visible under the + // cursor that opened the menu. Greyed, so it reads as a title and cannot be run. + { + const wxString head = offer_header(kind); + if (!head.empty()) { + menu.Append(wxID_ANY, head)->Enable(false); + menu.AppendSeparator(); + } + } + for (int row = 0; row < kOfferRowCount; ++row) { std::vector live, family; for (int i = 0; i < kOfferVerbCount; ++i) { diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index b7161fdb3a..9d4f274b17 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -755,6 +755,7 @@ private: // monitor, and the menu would map there — detached from the geometry it is about. wxPoint offer_anchor() const; int offer_selection_kind() const; // an OfferSel, as int to keep the header light + wxString offer_header(int kind) const; // the offer menu's title line for that selection // Does the SKETCH half of the map apply? A mode question, not a session one: begin_sketch // does not run until the first tool is armed, so between "press Sketch" and "pick a tool" // is_sketching() is still false — precisely when the drawing tools must be on offer. The diff --git a/src/slic3r/GUI/CAD/DesignTextDialog.cpp b/src/slic3r/GUI/CAD/DesignTextDialog.cpp new file mode 100644 index 0000000000..73fd8535e6 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignTextDialog.cpp @@ -0,0 +1,223 @@ +#include "DesignTextDialog.hpp" + +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/Widgets/DialogButtons.hpp" +#include "slic3r/Utils/WxFontUtils.hpp" +#include "libslic3r/AppConfig.hpp" +#include "libslic3r/Emboss.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace Slic3r { namespace GUI { + +static const char* kFontKey = "cad_text_font"; // WxFontUtils::store_wxFont descriptor +static const char* kHeightKey = "cad_text_height"; // mm + +DesignTextDialog::DesignTextDialog(wxWindow* parent) + : DPIDialog(parent, wxID_ANY, _L("Text"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE) +{ + SetFont(wxGetApp().normal_font()); + SetBackgroundColour(wxGetApp().dark_mode() ? wxColour(0x2d, 0x2d, 0x31) : *wxWHITE); + const int em = em_unit(); + + // Last used font and height, else the system GUI font at 10 mm. + wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + double height = 10.0; + if (AppConfig* cfg = wxGetApp().app_config) { + const std::string desc = cfg->get(kFontKey); + if (!desc.empty()) { + wxFont f = WxFontUtils::load_wxFont(desc); + if (f.IsOk()) font = f; + } + const std::string h = cfg->get(kHeightKey); + if (!h.empty()) { + try { height = std::clamp(std::stod(h), 0.5, 500.0); } catch (...) {} + } + } + + auto* form = new wxFlexGridSizer(2, em / 2, em); + form->AddGrowableCol(1, 1); + + m_text = new wxTextCtrl(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(30 * em, -1), + wxTE_PROCESS_ENTER); + m_text->SetHint(_L("Type the text to insert")); + form->Add(new wxStaticText(this, wxID_ANY, _L("Text")), 0, wxALIGN_CENTER_VERTICAL); + form->Add(m_text, 1, wxEXPAND); + + // Scalable faces only ('@' faces are the vertical-writing aliases GTK/MSW list twice). + wxArrayString faces = wxFontEnumerator::GetFacenames(wxFONTENCODING_SYSTEM, false); + faces.erase(std::remove_if(faces.begin(), faces.end(), [](const wxString& f) { return f.StartsWith("@"); }), + faces.end()); + faces.Sort(); + m_face = new wxChoice(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, faces); + int sel = m_face->FindString(font.GetFaceName()); + m_face->SetSelection(sel != wxNOT_FOUND ? sel : 0); + form->Add(new wxStaticText(this, wxID_ANY, _L("Font")), 0, wxALIGN_CENTER_VERTICAL); + form->Add(m_face, 1, wxEXPAND); + + auto* style = new wxBoxSizer(wxHORIZONTAL); + m_bold = new wxCheckBox(this, wxID_ANY, _L("Bold")); + m_italic = new wxCheckBox(this, wxID_ANY, _L("Italic")); + m_bold->SetValue(WxFontUtils::is_bold(font)); + m_italic->SetValue(WxFontUtils::is_italic(font)); + style->Add(m_bold, 0, wxRIGHT, em); + style->Add(m_italic, 0); + form->AddSpacer(0); + form->Add(style, 0); + + m_height = new wxSpinCtrlDouble(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(10 * em, -1), + wxSP_ARROW_KEYS, 0.5, 500.0, height, 0.5); + m_height->SetDigits(1); + form->Add(new wxStaticText(this, wxID_ANY, _L("Height (mm)")), 0, wxALIGN_CENTER_VERTICAL); + form->Add(m_height, 0); + + // The outline of what will be inserted, fitted to the box, with its real size under it. + m_preview = new wxWindow(this, wxID_ANY, wxDefaultPosition, wxSize(30 * em, 10 * em)); + m_preview->SetBackgroundStyle(wxBG_STYLE_PAINT); + m_preview->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { draw_preview(m_preview); }); + m_preview->Bind(wxEVT_SIZE, [this](wxSizeEvent& e) { m_preview->Refresh(); e.Skip(); }); + m_size = new wxStaticText(this, wxID_ANY, wxEmptyString); + + auto* buttons = new DialogButtons(this, {"OK", "Cancel"}); + m_ok = buttons->GetOK(); + + auto* top = new wxBoxSizer(wxVERTICAL); + top->Add(form, 0, wxEXPAND | wxALL, em); + top->Add(m_preview, 1, wxEXPAND | wxLEFT | wxRIGHT, em); + top->Add(m_size, 0, wxLEFT | wxRIGHT | wxTOP, em); + top->Add(buttons, 0, wxEXPAND); + + m_text->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { update_preview(); }); + m_text->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { + if (!m_regions.empty()) EndModal(wxID_OK); // Enter = OK, as everywhere in the tab + }); + auto refont = [this](wxCommandEvent&) { load_font(); update_preview(); }; + m_face->Bind(wxEVT_CHOICE, refont); + m_bold->Bind(wxEVT_CHECKBOX, refont); + m_italic->Bind(wxEVT_CHECKBOX, refont); + m_height->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { update_preview(); }); + m_height->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { update_preview(); }); + Bind(wxEVT_BUTTON, [this](wxCommandEvent& e) { + if (e.GetId() != wxID_OK) { e.Skip(); return; } + if (m_regions.empty()) return; // nothing to insert: the size line says why + if (AppConfig* cfg = wxGetApp().app_config) { + cfg->set(kFontKey, WxFontUtils::store_wxFont(current_font())); + cfg->set(kHeightKey, std::to_string(m_height->GetValue())); + } + EndModal(wxID_OK); + }); + + wxGetApp().UpdateDlgDarkUI(this); + SetSizerAndFit(top); + CenterOnParent(); + + load_font(); + update_preview(); + m_text->SetFocus(); +} + +wxString DesignTextDialog::text() const { return m_text->GetValue(); } + +wxFont DesignTextDialog::current_font() const +{ + wxFontInfo info(12); + if (m_face->GetSelection() != wxNOT_FOUND) + info.FaceName(m_face->GetStringSelection()); + info.Bold(m_bold->GetValue()).Italic(m_italic->GetValue()); + return wxFont(info); +} + +void DesignTextDialog::load_font() +{ + const wxFont font = current_font(); + m_font_file.reset(); + if (font.IsOk() && WxFontUtils::can_load(font)) + m_font_file = WxFontUtils::create_font_file(font); +} + +void DesignTextDialog::update_preview() +{ + const std::string utf8(m_text->GetValue().ToUTF8().data()); + m_regions.clear(); + if (m_font_file && !utf8.empty()) + m_regions = text_to_regions(utf8, m_height->GetValue(), m_font_file); + + wxString line; + if (!m_font_file) + line = _L("This font cannot be used for text — pick another one"); + else if (utf8.empty()) + line = _L("Type the text to see its size"); + else if (m_regions.empty()) + line = _L("This font has no outline for this text"); + else { + double lo_x = std::numeric_limits::max(), lo_y = lo_x; + double hi_x = -lo_x, hi_y = -lo_x; + for (const auto& r : m_regions) + for (const auto& c : r) + for (const Vec2d& p : c) { + lo_x = std::min(lo_x, p.x()); hi_x = std::max(hi_x, p.x()); + lo_y = std::min(lo_y, p.y()); hi_y = std::max(hi_y, p.y()); + } + line = wxString::Format(_L("Size: %.1f × %.1f mm"), hi_x - lo_x, hi_y - lo_y); + } + m_size->SetLabel(line); + if (m_ok) m_ok->Enable(!m_regions.empty()); + m_preview->Refresh(); +} + +void DesignTextDialog::draw_preview(wxWindow* canvas) +{ + wxAutoBufferedPaintDC dc(canvas); + const bool dark = wxGetApp().dark_mode(); + dc.SetBackground(wxBrush(dark ? wxColour(0x1e, 0x1e, 0x22) : wxColour(0xf4, 0xf4, 0xf6))); + dc.Clear(); + if (m_regions.empty()) return; + + double lo_x = std::numeric_limits::max(), lo_y = lo_x, hi_x = -lo_x, hi_y = -lo_x; + for (const auto& r : m_regions) + for (const auto& c : r) + for (const Vec2d& p : c) { + lo_x = std::min(lo_x, p.x()); hi_x = std::max(hi_x, p.x()); + lo_y = std::min(lo_y, p.y()); hi_y = std::max(hi_y, p.y()); + } + const wxSize sz = canvas->GetClientSize(); + const double pad = 0.08 * std::min(sz.x, sz.y); + const double w = std::max(hi_x - lo_x, 1e-6), h = std::max(hi_y - lo_y, 1e-6); + const double k = std::min((sz.x - 2 * pad) / w, (sz.y - 2 * pad) / h); + const double ox = 0.5 * (sz.x - k * w), oy = 0.5 * (sz.y + k * h); // y up -> screen y down + + std::unique_ptr gc(wxGraphicsContext::Create(dc)); + if (!gc) return; + wxGraphicsPath path = gc->CreatePath(); + for (const auto& r : m_regions) + for (const auto& c : r) { + if (c.empty()) continue; + path.MoveToPoint(ox + k * (c[0].x() - lo_x), oy - k * (c[0].y() - lo_y)); + for (size_t i = 1; i < c.size(); ++i) + path.AddLineToPoint(ox + k * (c[i].x() - lo_x), oy - k * (c[i].y() - lo_y)); + path.CloseSubpath(); + } + gc->SetBrush(wxBrush(wxColour(0x00, 0x96, 0x88))); // Orca accent + gc->SetPen(*wxTRANSPARENT_PEN); + gc->FillPath(path, wxODDEVEN_RULE); // holes (the counter of an "o") stay open +} + +void DesignTextDialog::on_dpi_changed(const wxRect&) +{ + Fit(); + Refresh(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignTextDialog.hpp b/src/slic3r/GUI/CAD/DesignTextDialog.hpp new file mode 100644 index 0000000000..76ac86267f --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignTextDialog.hpp @@ -0,0 +1,59 @@ +#ifndef slic3r_GUI_DesignTextDialog_hpp_ +#define slic3r_GUI_DesignTextDialog_hpp_ + +#include "slic3r/GUI/GUI_Utils.hpp" +#include "libslic3r/CAD/SketchImport.hpp" + +#include + +#include + +class wxTextCtrl; +class wxChoice; +class wxCheckBox; +class wxSpinCtrlDouble; +class wxStaticText; + +namespace Slic3r { +namespace Emboss { struct FontFile; } +namespace GUI { + +// Text for the Design tab: the words, the font (face, bold, italic) and the height, with a +// live outline of exactly what will be inserted and its size in millimetres. The outline IS +// the result — the same vectorisation the sketch receives — so there is nothing to guess. +// Enter inserts, Esc cancels (charter 4.2). The last font and height are remembered. +class DesignTextDialog : public DPIDialog +{ +public: + explicit DesignTextDialog(wxWindow* parent); + + // The vectorised text, centred on the origin, in mm. Empty unless the dialog ended wxID_OK. + const ImportRegions& regions() const { return m_regions; } + wxString text() const; + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void load_font(); // m_face/m_bold/m_italic -> m_font_file + void update_preview(); // text/font/height -> m_regions + size label + wxFont current_font() const; + void draw_preview(wxWindow* canvas); + + wxTextCtrl* m_text{nullptr}; + wxChoice* m_face{nullptr}; + wxCheckBox* m_bold{nullptr}; + wxCheckBox* m_italic{nullptr}; + wxSpinCtrlDouble* m_height{nullptr}; + wxWindow* m_preview{nullptr}; + wxStaticText* m_size{nullptr}; + wxWindow* m_ok{nullptr}; + + std::shared_ptr m_font_file; + ImportRegions m_regions; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GUI_DesignTextDialog_hpp_