Design tab: Text dialog with font, height and live outline; offer header

- Text opens a dialog instead of a bare text entry: any installed font
  (bold, italic), the height in mm, and a live outline of exactly what
  will be inserted with its size. Enter inserts, Esc cancels; the last
  font and height are remembered. text_to_regions gains an overload
  taking a loaded font.
- The offer menu opens with a greyed title naming what the rows act on
  ("Flat face 4 of Body 2", "Sketch line", "Nothing selected").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK4VgguuCAk2hZLWgcjJb9
This commit is contained in:
Claude
2026-09-30 08:34:25 +00:00
parent c74ddf8bb5
commit 205bc1b332
9 changed files with 361 additions and 13 deletions
+11 -5
View File
@@ -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<Emboss::FontFile> 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<const Emboss::FontFile>(Emboss::create_font_file(path.c_str())));
}
ImportRegions text_to_regions(const std::string& utf8, double size_mm,
const std::shared_ptr<const Emboss::FontFile>& ff)
{
if (utf8.empty() || size_mm <= 0.0 || !ff)
return {};
Emboss::FontFileWithCache fwc;
fwc.font_file = ff;
fwc.cache = std::make_shared<Emboss::Glyphs>();
FontProp prop(static_cast<float>(size_mm)); // per_glyph=false
HealedExPolygons healed = Emboss::text2shapes(fwc, utf8.c_str(), prop);
+8
View File
@@ -3,11 +3,14 @@
#include "libslic3r/Point.hpp" // Vec2d
#include <memory>
#include <string>
#include <vector>
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<ImportRegion>;
// 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<const Emboss::FontFile>& 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
+2
View File
@@ -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
+49 -8
View File
@@ -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<const OfferVerb*> 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<const OfferVerb*> live, family;
for (int i = 0; i < kOfferVerbCount; ++i) {
+1
View File
@@ -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
+223
View File
@@ -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 <wx/checkbox.h>
#include <wx/choice.h>
#include <wx/dcbuffer.h>
#include <wx/fontenum.h>
#include <wx/graphics.h>
#include <wx/sizer.h>
#include <wx/spinctrl.h>
#include <wx/stattext.h>
#include <wx/textctrl.h>
#include <algorithm>
#include <limits>
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<double>::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<double>::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<wxGraphicsContext> 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
+59
View File
@@ -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 <wx/font.h>
#include <memory>
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<const Emboss::FontFile> m_font_file;
ImportRegions m_regions;
};
} // namespace GUI
} // namespace Slic3r
#endif // slic3r_GUI_DesignTextDialog_hpp_