diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index efe99db1b8..6f251d35eb 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -10,6 +10,11 @@ var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' var lastResizeHeight = 0; var matchIndex = {}; +// The user's current settings mode (from the C++ payload) plus the rank order of the modes. Each +// action carries the mode it requires, so "would this need a switch?" is a rank comparison. +var USER_MODE = "simple"; +var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 }; + // ---- windowed list render ---------------------------------------------------- // The command list is rendered in windows (append-on-scroll) so a huge settings pool doesn't build // the whole DOM per keystroke. Rows are exactly ROW_H tall (matches .row min-height 44px; see --row-h, @@ -105,11 +110,15 @@ function searchActions(actions, query) { matchIndex = {}; if (!q) { searchNeedle = ""; return list.slice(0); } searchNeedle = NormText(q, false); + // Mode keywords ("advanced"/"expert"/"developer") are a union, not a filter: the normal text + // search still runs on the full query, and every setting requiring a named mode is appended. + var modes = modeFilterFromQuery(q); // Compiled once per pass, reused over every field: non-global so no exec()/lastIndex state leaks // between fields, and EscapeRegExp keeps regex metachars in the query literal. var wwRe = new RegExp("\\b" + EscapeRegExp(searchNeedle) + "\\b"); var scored = []; + var seen = {}; for (var i = 0; i < list.length; i++) { var a = list[i]; var t = fieldMatchScore(titleNorm(a), wwRe); @@ -126,13 +135,28 @@ function searchActions(actions, query) { useEyebrowGroup: !!(a.group) }; scored.push({ a: a, s: score }); + seen[a.id] = true; } scored.sort(function (x, y) { if (x.s !== y.s) return y.s - x.s; if (x.a.title !== y.a.title) return x.a.title < y.a.title ? -1 : 1; return x.a.id < y.a.id ? -1 : x.a.id > y.a.id ? 1 : 0; }); - return scored.map(function (e) { return e.a; }); + var result = scored.map(function (e) { return e.a; }); + if (modes.length) { + // Settings requiring a named mode, after the ranked text matches and without duplicates. + var extras = []; + for (var j = 0; j < list.length; j++) { + if (!seen[list[j].id] && modes.indexOf(list[j].mode) !== -1) + extras.push(list[j]); + } + extras.sort(function (x, y) { + if (x.title !== y.title) return x.title < y.title ? -1 : 1; + return x.id < y.id ? -1 : x.id > y.id ? 1 : 0; + }); + result = result.concat(extras); + } + return result; } // Pure: how many rows must be materialized to cover the given starting index plus `size` more. @@ -264,6 +288,40 @@ function prettySource(source) { return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); }); } +// True when the action's required mode is above the user's current mode, i.e. selecting it will +// prompt a mode switch. Unknown/empty modes are treated as "simple" so commands never flag. +function needsModeSwitch(item, userMode) { + var need = MODE_RANK[(item && item.mode) || "simple"] || 0; + var have = MODE_RANK[userMode || "simple"] || 0; + return need > have; +} + +// Short mode tag for an action that needs a switch, or "" when it is already available. +function modeBadge(item, userMode) { + if (!needsModeSwitch(item, userMode)) return ""; + if (item.mode === "develop") return "Developer"; + if (item.mode === "expert") return "Expert"; + if (item.mode === "advanced") return "Advanced"; + return ""; +} + +// Search keywords that name a settings mode. "developer" (and the internal "develop") both select +// Developer; Simple is deliberately absent so it never floods the list with every command. +var MODE_WORDS = { advanced: "advanced", expert: "expert", developer: "develop", develop: "develop" }; + +// The mode values named as whole words in `query`, deduped. Case/diacritic-insensitive via Norm. A +// query with no mode keyword returns [] so the normal text search is completely unaffected. +function modeFilterFromQuery(query) { + var norm = NormText(String(query || "").trim(), false); + var found = []; + norm.split(/[^a-z0-9]+/).forEach(function (word) { + var mode = MODE_WORDS[word]; + if (mode && found.indexOf(mode) === -1) + found.push(mode); + }); + return found; +} + // Accessible label "Title from Pretty Source", disambiguated with the opaque action id when another // action shares the same title+source (case/separator-insensitive) - so two rows never read out identically. function actionLabel(action, actions) { @@ -289,6 +347,7 @@ function stateFromPayload(payload) { actions: payload.actions || [], favourites: payload.favourites || [], recent: payload.recent || [], + userMode: payload.user_mode || "simple", query: "", sel: { zone: "list", i: 0 }, lastResizeHeight: 0, @@ -348,6 +407,7 @@ window.HandleStudio = function (payload) { ACTIONS = next.actions; FAVS = next.favourites; RECENTS = next.recent; + USER_MODE = next.userMode; query = next.query; sel = next.sel; lastResizeHeight = next.lastResizeHeight; @@ -470,7 +530,8 @@ function renderFav() { tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : ""); tile.style.setProperty("--h", hue(id)); fillTile(tile, a); - tile.title = a.title; + var tileBadge = modeBadge(a, USER_MODE); + tile.title = a.title + (tileBadge ? " (" + tileBadge + ")" : ""); tile.setAttribute("aria-label", actionLabel(a, ACTIONS)); tile.onclick = function () { sel = { zone: "fav", i: i }; activateEntry(a); }; // Numbered quick-launch badge (Alt/Option+digit), drawn on the corner. @@ -584,6 +645,13 @@ function renderActionRow(a, i) { line.className = "row-line"; var name = markedText("row-name", a.title, mi ? mi.title : null); line.appendChild(name); + var badge = modeBadge(a, USER_MODE); + if (badge) { + var tag = document.createElement("span"); + tag.className = "row-mode"; + tag.textContent = badge; + line.appendChild(tag); + } if (a.shortcut) { var sc = document.createElement("div"); sc.className = "row-sc"; diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js index 61976ac9b6..c8fd43add6 100644 --- a/resources/web/dialog/SpeedDial/speeddial.test.js +++ b/resources/web/dialog/SpeedDial/speeddial.test.js @@ -211,4 +211,53 @@ assert.equal(ctx.tileCode(monoPool[2], monoPool), "FR", assert.equal(ctx.tileCode({ id: "x", title: "Slice", source: "OrcaSlicer" }, [{ id: "x", title: "Slice", source: "OrcaSlicer" }]), "S", "a unique title resolves to the bare title initial"); +// needsModeSwitch: a setting is gated only when its required mode outranks the user's current mode. +assert.equal(ctx.needsModeSwitch({ mode: "advanced" }, "simple"), true, "Advanced is gated in Simple mode"); +assert.equal(ctx.needsModeSwitch({ mode: "expert" }, "simple"), true, "Expert is gated in Simple mode"); +assert.equal(ctx.needsModeSwitch({ mode: "expert" }, "advanced"), true, "Expert is gated in Advanced mode"); +assert.equal(ctx.needsModeSwitch({ mode: "develop" }, "expert"), true, "Developer is gated in Expert mode"); +assert.equal(ctx.needsModeSwitch({ mode: "simple" }, "simple"), false, "a Simple setting is not gated"); +assert.equal(ctx.needsModeSwitch({ mode: "advanced" }, "advanced"), false, "an Advanced setting is not gated in Advanced mode"); +assert.equal(ctx.needsModeSwitch({ mode: "develop" }, "develop"), false, "a Developer setting is not gated in Developer mode"); +assert.equal(ctx.needsModeSwitch({}, "simple"), false, "a command with no mode is never gated"); + +// modeBadge: the tag text for gated settings, empty once the setting is available. +assert.equal(ctx.modeBadge({ mode: "advanced" }, "simple"), "Advanced", "Advanced badge text"); +assert.equal(ctx.modeBadge({ mode: "expert" }, "simple"), "Expert", "Expert badge text"); +assert.equal(ctx.modeBadge({ mode: "develop" }, "simple"), "Developer", "Developer badge text"); +assert.equal(ctx.modeBadge({ mode: "advanced" }, "advanced"), "", "no badge when the mode already matches"); + +// modeFilterFromQuery: whole-word, case-insensitive mode keywords -> internal mode values. "developer" +// maps to the internal "develop"; "simple" is deliberately not a keyword; a prefix is not a match. +assert.deepEqual(ctx.modeFilterFromQuery("advanced"), ["advanced"], "Advanced is a mode keyword"); +assert.deepEqual(ctx.modeFilterFromQuery("Expert"), ["expert"], "the keyword is case-insensitive"); +assert.deepEqual(ctx.modeFilterFromQuery("developer"), ["develop"], "Developer maps to the develop value"); +assert.deepEqual(ctx.modeFilterFromQuery("develop"), ["develop"], "the internal develop spelling also works"); +assert.deepEqual(ctx.modeFilterFromQuery("expert retraction"), ["expert"], "a keyword is found among other text"); +assert.deepEqual(ctx.modeFilterFromQuery("advanced expert"), ["advanced", "expert"], "multiple keywords are deduped in order"); +assert.deepEqual(ctx.modeFilterFromQuery("advanced advanced"), ["advanced"], "a repeated keyword is deduped"); +assert.deepEqual(ctx.modeFilterFromQuery("simple"), [], "Simple is not a mode keyword"); +assert.deepEqual(ctx.modeFilterFromQuery("advance"), [], "a mode-word prefix is not a whole-word match"); +assert.deepEqual(ctx.modeFilterFromQuery(""), [], "an empty query names no mode"); + +// searchActions mode union: a mode keyword keeps the normal text matches AND appends every setting +// requiring that mode. Not a filter - a Simple setting literally named "Advanced..." still shows, and +// commands (mode "simple") are never pulled in by a keyword. +const modePool = [ + { id: "a1", title: "Top Surface Layers", source: "Quality", group: "Quality : Layers", mode: "advanced" }, + { id: "a2", title: "Advanced Detection", source: "Quality", group: "Quality", mode: "simple" }, + { id: "a3", title: "Retraction Length", source: "Process", group: "Process : Quality", mode: "expert" }, + { id: "a4", title: "Slice", source: "OrcaSlicer", group: "Commands", mode: "simple" } +]; +var adv = ctx.searchActions(modePool, "advanced"); +assert.deepEqual(adv.map(function (a) { return a.id; }), ["a2", "a1"], + "a text match (a2) ranks above the mode-only setting (a1), and no expert/command leaks in"); +var expert = ctx.searchActions(modePool, "expert"); +assert.deepEqual(expert.map(function (a) { return a.id; }), ["a3"], "the expert keyword pulls in the expert setting"); +assert.deepEqual(ctx.searchActions(modePool, "developer"), [], "no developer settings means no mode extras"); +assert.equal(ctx.searchActions(modePool, "retraction")[0].id, "a3", + "a query with no mode keyword is unaffected by the mode union"); +assert.equal(ctx.searchActions([{ id: "both", title: "Advanced", source: "Quality", group: "", mode: "advanced" }], "advanced").length, + 1, "a setting that both matches text and requires the mode appears exactly once"); + console.log("ok"); diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css index 07f0c992eb..d6bc8ff8bf 100644 --- a/resources/web/dialog/SpeedDial/style.css +++ b/resources/web/dialog/SpeedDial/style.css @@ -364,6 +364,18 @@ body { gap: 3px; } +/* Mode tag on settings above the user's current mode (Advanced/Expert/Developer). */ +.row-mode { + flex: 0 0 auto; + padding: 1px 6px; + font-size: 10px; + line-height: 1.4; + border-radius: 8px; + color: var(--plugin-status-warn); + background: var(--plugin-status-warn-bg); + white-space: nowrap; +} + kbd { display: inline-flex; align-items: center; diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index 35503af44e..d1b6e13fe1 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -162,6 +162,17 @@ std::string setting_type_context(Preset::Type type) } } +// Stable, non-localized mode token for the webview, which maps it to a badge ("Developer" etc.). +const char* mode_key(ConfigOptionMode mode) +{ + switch (mode) { + case comAdvanced: return "advanced"; + case comExpert: return "expert"; + case comDevelop: return "develop"; + default: return "simple"; + } +} + // A config setting exposed as a first-class action: selecting it jumps the sidebar to the option. // The id is keyed by opt_key+type (NOT the display label), so renaming/localizing never re-keys // the action; title/group/source are purely for display + search. run() performs the jump, and @@ -180,7 +191,8 @@ struct SettingAction : AppAction std::string title, std::string group, std::wstring category_in, - std::string source_name) + std::string source_name, + ConfigOptionMode mode_in) : AppAction(AppActionId{id_for(opt_key_in, type_in)}, std::move(title), kOrcaSourceKey, std::move(source_name)) , opt_key(std::move(opt_key_in)) , type(type_in) @@ -188,8 +200,9 @@ struct SettingAction : AppAction { // A setting is a single-phase command: activating it jumps the sidebar to the option // (like the sidebar's own settings search), then the dial closes. run() performs the jump. - this->kind = AppActionKind::Command; - this->group = std::move(group); + this->kind = AppActionKind::Command; + this->group = std::move(group); + this->required_mode = mode_in; } AppActionRunResult run(const std::string& /*param*/) const override @@ -509,10 +522,9 @@ void ActionRegistry::materialize_setting_actions() // Reuse the Sidebar's live searcher: it's the only OptionsSearcher whose groups_and_categories // map is populated (Tab::add_key feeds it at build time), and it already mirrors the current - // configs/mode/printer-technology - i.e. exactly what the sidebar's own search would show. A - // fresh OptionsSearcher has an empty groups_and_categories, so append_options() would drop every - // option and nothing would materialise. Turn each visible option into a SettingAction. - const std::vector& options = wxGetApp().sidebar().get_searcher().all_options(); + // configs/printer-technology. Use the all-modes view so the Speed Dial lists every setting, + // including those above the user's current mode, and can prompt to switch before jumping. + const std::vector& options = wxGetApp().sidebar().get_searcher().all_modes_options(); // Load the persisted per-action state ONCE (not per-option) so a re-materialised setting keeps // its recency/favourite; mirroring seed_state but amortised over the whole option set. @@ -540,7 +552,7 @@ void ActionRegistry::materialize_setting_actions() // title = the option leaf name (last label segment); group stays empty so the source path // (above) is the single display/search breadcrumb rather than being duplicated. auto action = std::make_unique(opt.opt_key(), opt.type, boost::nowide::narrow(label_w), std::string(), - opt.category_local, boost::nowide::narrow(path)); + opt.category_local, boost::nowide::narrow(path), opt.mode); action->favourite = std::find(favs.begin(), favs.end(), id) != favs.end(); if (auto it = stats.find(id); it != stats.end() && it->is_object()) { @@ -726,7 +738,8 @@ nlohmann::json ActionRegistry::snapshot() {"source", a->source_name()}, {"group", a->group}, {"input", a->input}, - {"shortcut", ""}}); + {"shortcut", ""}, + {"mode", mode_key(a->required_mode)}}); }; nlohmann::json actions = nlohmann::json::array(); @@ -765,7 +778,10 @@ nlohmann::json ActionRegistry::snapshot() for (const AppAction* a : recent) recent_json.push_back(action_to_json(a)); - return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}, {"recent", std::move(recent_json)}}; + return {{"actions", std::move(actions)}, + {"favourites", std::move(favourites)}, + {"recent", std::move(recent_json)}, + {"user_mode", mode_key(wxGetApp().get_mode())}}; } // ---- tab options (enumerate the MainFrame notebook's current pages) ---------- diff --git a/src/slic3r/GUI/ActionRegistry.hpp b/src/slic3r/GUI/ActionRegistry.hpp index 2b7af0d132..c1667e184d 100644 --- a/src/slic3r/GUI/ActionRegistry.hpp +++ b/src/slic3r/GUI/ActionRegistry.hpp @@ -2,6 +2,8 @@ #include +#include + #include #include @@ -75,6 +77,9 @@ struct AppAction // Second-phase input descriptor for the palette: "percent" (jump to layer by a 0-100 // value) or "tab" (pick a notebook tab). Empty = run immediately on activation. std::string input; + // Settings mode required to edit this action (SettingActions only). The palette prompts before + // running an action whose mode is above the user's current mode. comSimple for everything else. + ConfigOptionMode required_mode = comSimple; virtual ~AppAction() = default; // Re-resolves + runs (UI thread). `param` carries an optional per-run argument for @@ -106,6 +111,13 @@ private: std::string m_source_name; // display name of the action's source }; +// True when a setting at `setting_mode` cannot be edited in `current_mode` and the UI must switch +// first. Developer settings are handled as a separate prompt by the Speed Dial. +inline bool requires_mode_switch(ConfigOptionMode setting_mode, ConfigOptionMode current_mode) +{ + return setting_mode > current_mode; +} + // Cap + dedupe a persisted favourite-id list, preserving first-occurrence order. A stale or // hand-edited config must never grow the quick-launch bar past `limit`, and a duplicated id must collapse to its first pin. std::vector cap_favourites(const std::vector& ids, size_t limit); diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index d8e54978fa..2678cd5d4b 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -578,113 +578,131 @@ wxMenu* MenuFactory::append_submenu_add_generic(wxMenu* menu, ModelVolumeType ty return sub_menu; } +// Orca: handy models shipped under /handy_models. Defining everything in one table keeps +// the menu label, the files to load and the per-model behavior in a single place. Labels are wrapped +// in L() so they are picked up for translation. Shared with the command palette. +const std::vector& MenuFactory::handy_models() +{ + static const std::vector models = { + {"orca_cube", L("Orca Cube"), {"OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true}, + {"orcasliced_combo", L("OrcaSliced Combo"), {"OrcaSliced.3mf", "OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true}, + {"orca_badge", L("Orca Badge"), {"OrcaBadge.3mf"}}, + {"orca_tolerance_test", L("Orca Tolerance Test"), {"OrcaToleranceTest.drc"}}, + {"3dbenchy", L("3DBenchy"), {"3DBenchy.drc"}}, + {"cali_cat", L("Cali Cat"), {"calicat.drc"}}, + {"autodesk_fdm_test", L("Autodesk FDM Test"), {"ksr_fdmtest_v4.drc"}}, + {"voron_cube", L("Voron Cube"), {"Voron_Design_Cube_v7.drc"}}, + {"stanford_bunny", L("Stanford Bunny"), {"Stanford_Bunny.drc"}}, + {"orca_string_hell", L("Orca String Hell"), {"Orca_stringhell.drc"}, false, true}, + }; + return models; +} + +void MenuFactory::load_handy_model(std::size_t index) +{ + const std::vector& models = handy_models(); + if (index >= models.size()) + return; + const HandyModel& model = models[index]; + + std::vector input_files; + input_files.reserve(model.file_names.size()); + for (const auto& file_name : model.file_names) + input_files.push_back((boost::filesystem::path(Slic3r::resources_dir()) / "handy_models" / file_name)); + + Plater* pl = plater(); + if (!pl) + return; + pl->load_files(input_files, LoadStrategy::LoadModel); + if (model.arrange_after_import) { + pl->set_prepare_state(Job::PREPARE_STATE_MENU); + pl->arrange(); + } + + // Suggest to change settings for stringhell + // This serves as mini tutorial for new users + if (model.is_stringhell) { + wxGetApp().CallAfter([=] { + DynamicPrintConfig* m_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; + + bool is_only_one_wall_top = m_config->opt_bool("only_one_wall_top"); + auto min_width_top_surface = m_config->option("min_width_top_surface")->value; + if (is_only_one_wall_top && min_width_top_surface > 0) { + wxString msg_text = _L("This model features text embossment on the top surface. For optimal results, it is " + "advisable to set the 'One Wall Threshold (min_width_top_surface)' " + "to 0 for the 'Only One Wall on Top Surfaces' to work best.\n" + "Yes - Change these settings automatically\n" + "No - Do not change these settings for me"); + + MessageDialog dialog(wxGetApp().plater(), msg_text, _L("Suggestion"), wxICON_WARNING | wxYES | wxNO); + if (dialog.ShowModal() == wxID_YES) { + m_config->set_key_value("min_width_top_surface", new ConfigOptionFloatOrPercent(0, false)); + wxGetApp().get_tab(Preset::TYPE_PRINT)->update_dirty(); + wxGetApp().get_tab(Preset::TYPE_PRINT)->reload_config(); + } + wxGetApp().plater()->update(); + } + }); + } +} + // Orca: add submenu for adding handy models wxMenu* MenuFactory::append_submenu_add_handy_model(wxMenu* menu, ModelVolumeType type) { auto sub_menu = new wxMenu; - // Orca: handy models shipped under /handy_models. Defining everything in one table - // keeps the menu label, the files to load and the per-model behavior in a single place and - // avoids repeating the label strings (and the value-vs-pointer comparison pitfalls that come - // with that). Labels are wrapped in L() so they are picked up for translation. - struct HandyModel - { - const char* label; - std::vector file_names; - bool arrange_after_import = false; - bool is_stringhell = false; - }; - static const std::vector handy_models = { - {L("Orca Cube"), {"OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true}, - {L("OrcaSliced Combo"), {"OrcaSliced.3mf", "OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true}, - {L("Orca Badge"), {"OrcaBadge.3mf"}}, - {L("Orca Tolerance Test"), {"OrcaToleranceTest.drc"}}, - {L("3DBenchy"), {"3DBenchy.drc"}}, - {L("Cali Cat"), {"calicat.drc"}}, - {L("Autodesk FDM Test"), {"ksr_fdmtest_v4.drc"}}, - {L("Voron Cube"), {"Voron_Design_Cube_v7.drc"}}, - {L("Stanford Bunny"), {"Stanford_Bunny.drc"}}, - {L("Orca String Hell"), {"Orca_stringhell.drc"}, false, true}, - }; - - for (const auto& model : handy_models) { - append_menu_item( - sub_menu, wxID_ANY, _(model.label), "", - [&model](wxCommandEvent&) { - std::vector input_files; - input_files.reserve(model.file_names.size()); - for (const auto& file_name : model.file_names) - input_files.push_back((boost::filesystem::path(Slic3r::resources_dir()) / "handy_models" / file_name)); - - plater()->load_files(input_files, LoadStrategy::LoadModel); - if (model.arrange_after_import) { - plater()->set_prepare_state(Job::PREPARE_STATE_MENU); - plater()->arrange(); - } - - // Suggest to change settings for stringhell - // This serves as mini tutorial for new users - if (model.is_stringhell) { - wxGetApp().CallAfter([=] { - DynamicPrintConfig* m_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; - - bool is_only_one_wall_top = m_config->opt_bool("only_one_wall_top"); - auto min_width_top_surface = m_config->option("min_width_top_surface")->value; - if (is_only_one_wall_top && min_width_top_surface > 0) { - wxString msg_text = _L("This model features text embossment on the top surface. For optimal results, it is " - "advisable to set the 'One Wall Threshold (min_width_top_surface)' " - "to 0 for the 'Only One Wall on Top Surfaces' to work best.\n" - "Yes - Change these settings automatically\n" - "No - Do not change these settings for me"); - - MessageDialog dialog(wxGetApp().plater(), msg_text, _L("Suggestion"), wxICON_WARNING | wxYES | wxNO); - if (dialog.ShowModal() == wxID_YES) { - m_config->set_key_value("min_width_top_surface", new ConfigOptionFloatOrPercent(0, false)); - wxGetApp().get_tab(Preset::TYPE_PRINT)->update_dirty(); - wxGetApp().get_tab(Preset::TYPE_PRINT)->reload_config(); - } - wxGetApp().plater()->update(); - } - }); - } - }, - "", menu); + const std::vector& models = handy_models(); + for (std::size_t i = 0; i < models.size(); ++i) { + append_menu_item(sub_menu, wxID_ANY, _(models[i].label), "", + [i](wxCommandEvent&) { MenuFactory::load_handy_model(i); }, "", menu); } - return sub_menu; } + +// Create a Text/SVG volume through the matching gizmo. `type == INVALID` means "create a new object". +// Shared by the add menu and the command palette. +static void add_volume_with_gizmo(GLGizmosManager::EType gizmo_type, ModelVolumeType type) +{ + Plater* pl = plater(); + if (!pl) + return; + const GLCanvas3D* canvas = pl->canvas3D(); + if (!canvas) + return; + GLGizmoBase* gizmo_base = canvas->get_gizmos_manager().get_gizmo(gizmo_type); + if (!gizmo_base) + return; + + ModelVolumeType volume_type = type; + // no selected object means create new object + if (volume_type == ModelVolumeType::INVALID) + volume_type = ModelVolumeType::MODEL_PART; + + auto screen_position = canvas->get_popup_menu_position(); + if (gizmo_type == GLGizmosManager::Emboss) { + auto* emboss = dynamic_cast(gizmo_base); + if (emboss == nullptr) + return; + if (screen_position.has_value()) + emboss->create_volume(volume_type, *screen_position); + else + emboss->create_volume(volume_type); + } else if (gizmo_type == GLGizmosManager::Svg) { + auto* svg = dynamic_cast(gizmo_base); + if (svg == nullptr) + return; + if (screen_position.has_value()) + svg->create_volume(volume_type, *screen_position); + else + svg->create_volume(volume_type); + } +} + +void MenuFactory::add_text_volume(ModelVolumeType type) { add_volume_with_gizmo(GLGizmosManager::Emboss, type); } +void MenuFactory::add_svg_volume(ModelVolumeType type) { add_volume_with_gizmo(GLGizmosManager::Svg, type); } + static void append_menu_itemm_add_(const wxString& name, GLGizmosManager::EType gizmo_type, wxMenu *menu, ModelVolumeType type, bool is_submenu_item) { - auto add_ = [type, gizmo_type](const wxCommandEvent & /*unnamed*/) { - const GLCanvas3D *canvas = plater()->canvas3D(); - const GLGizmosManager &mng = canvas->get_gizmos_manager(); - GLGizmoBase *gizmo_base = mng.get_gizmo(gizmo_type); - - ModelVolumeType volume_type = type; - // no selected object means create new object - if (volume_type == ModelVolumeType::INVALID) - volume_type = ModelVolumeType::MODEL_PART; - - auto screen_position = canvas->get_popup_menu_position(); - if (gizmo_type == GLGizmosManager::Emboss) { - auto emboss = dynamic_cast(gizmo_base); - assert(emboss != nullptr); - if (emboss == nullptr) return; - if (screen_position.has_value()) { - emboss->create_volume(volume_type, *screen_position); - } else { - emboss->create_volume(volume_type); - } - } else if (gizmo_type == GLGizmosManager::Svg) { - auto svg = dynamic_cast(gizmo_base); - assert(svg != nullptr); - if (svg == nullptr) return; - if (screen_position.has_value()) { - svg->create_volume(volume_type, *screen_position); - } else { - svg->create_volume(volume_type); - } - } - }; + auto add_ = [type, gizmo_type](const wxCommandEvent & /*unnamed*/) { add_volume_with_gizmo(gizmo_type, type); }; if (type == ModelVolumeType::MODEL_PART || type == ModelVolumeType::NEGATIVE_VOLUME || type == ModelVolumeType::PARAMETER_MODIFIER || type == ModelVolumeType::INVALID // cannot use gizmo without selected object diff --git a/src/slic3r/GUI/GUI_Factories.hpp b/src/slic3r/GUI/GUI_Factories.hpp index f80ef85d53..ec7b0a0b03 100644 --- a/src/slic3r/GUI/GUI_Factories.hpp +++ b/src/slic3r/GUI/GUI_Factories.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -51,6 +52,23 @@ public: static std::vector get_text_volume_bitmaps(); static std::vector get_svg_volume_bitmaps(); + // Orca: handy models shipped under /handy_models. The menu and the command palette + // share this table so the model list and its per-model behavior live in one place. + struct HandyModel + { + const char* key; + const char* label; + std::vector file_names; + bool arrange_after_import = false; + bool is_stringhell = false; + }; + static const std::vector& handy_models(); + static void load_handy_model(std::size_t index); + + // Add a Text/SVG volume through the Emboss/SVG gizmo. Shared by the add menu and the palette. + static void add_text_volume(ModelVolumeType type); + static void add_svg_volume(ModelVolumeType type); + MenuFactory(); ~MenuFactory() = default; diff --git a/src/slic3r/GUI/NativeCommands.cpp b/src/slic3r/GUI/NativeCommands.cpp index 25d29df116..7dba9cf155 100644 --- a/src/slic3r/GUI/NativeCommands.cpp +++ b/src/slic3r/GUI/NativeCommands.cpp @@ -2,18 +2,23 @@ #include "calib_dlg.hpp" #include "Camera.hpp" +#include "DailyTips.hpp" #include "GCodeViewer.hpp" #include "GLCanvas3D.hpp" #include "GUI.hpp" #include "GUI_App.hpp" +#include "GUI_Factories.hpp" +#include "GUI_ObjectList.hpp" #include "I18N.hpp" #include "IMSlider.hpp" #include "MainFrame.hpp" +#include "NetworkTestDialog.hpp" #include "Plater.hpp" #include "PluginsDialog.hpp" #include "PlateSettingsDialog.hpp" #include "DeviceCore/DevManager.h" +#include #include #include @@ -24,6 +29,8 @@ #include #include +#include + namespace Slic3r { namespace GUI { namespace { @@ -112,6 +119,19 @@ AppActionRunResult calib_command(CalibKind kind) return {AppActionRunResult::Level::Success}; } +// Palette-only: developer mode overrides the saved mode (get_mode returns comDevelop), so choosing +// Simple/Advanced/Expert must clear it first. Mirrors Preferences: persist the flag, then update. +void select_mode(ConfigOptionMode mode) +{ + GUI_App& app = wxGetApp(); + const bool was_developer = app.app_config->get_bool("developer_mode"); + if (was_developer) + app.app_config->set_bool("developer_mode", false); + app.save_mode(mode); + if (was_developer) + app.app_config->save(); +} + std::vector build_command_catalog() { std::vector out; @@ -174,17 +194,26 @@ std::vector build_command_catalog() // ---- Mode ---- add("mode_simple", _u8L("Mode: Simple"), _u8L("Mode"), [](const std::string&) { - wxGetApp().save_mode(comSimple); + select_mode(comSimple); return AppActionRunResult{AppActionRunResult::Level::Success}; }); add("mode_advanced", _u8L("Mode: Advanced"), _u8L("Mode"), [](const std::string&) { - wxGetApp().save_mode(comAdvanced); + select_mode(comAdvanced); return AppActionRunResult{AppActionRunResult::Level::Success}; }); add("mode_expert", _u8L("Mode: Expert"), _u8L("Mode"), [](const std::string&) { - wxGetApp().save_mode(comExpert); + select_mode(comExpert); return AppActionRunResult{AppActionRunResult::Level::Success}; }); + // Mirrors Preferences > Developer > Developer mode: flip the flag, persist, refresh the UI. + add("toggle_developer_mode", _u8L("Toggle Developer Mode"), _u8L("Mode"), [](const std::string&) { + GUI_App& app = wxGetApp(); + const bool on = !app.app_config->get_bool("developer_mode"); + app.app_config->set_bool("developer_mode", on); + app.app_config->save(); + app.update_mode(); + return AppActionRunResult{AppActionRunResult::Level::Success, on ? _L("Developer mode enabled.") : _L("Developer mode disabled.")}; + }); // ---- Export pipeline ---- add("export_gcode", _u8L("Export G-code"), _u8L("Slice & Export"), [](const std::string&) { @@ -320,6 +349,57 @@ std::vector build_command_catalog() return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_arrange(); }, [](Plater* p) { p->orient(); }); }); + // ---- Add Primitive ---- (the Add > Add Primitive submenu; creates a new object) + auto add_primitive = [&](std::string key, std::string title, const char* type_name) { + add(std::move(key), std::move(title), _u8L("Add Primitive"), [type_name](const std::string&) { + Plater* plater = wxGetApp().plater(); + if (plater) { + ensure_3d_view(plater); + if (ObjectList* list = wxGetApp().obj_list()) + list->load_generic_subobject(type_name, ModelVolumeType::INVALID); + } + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + }; + add_primitive("add_primitive_cube", _u8L("Cube"), "Cube"); + add_primitive("add_primitive_cylinder", _u8L("Cylinder"), "Cylinder"); + add_primitive("add_primitive_sphere", _u8L("Sphere"), "Sphere"); + add_primitive("add_primitive_cone", _u8L("Cone"), "Cone"); + add_primitive("add_primitive_disc", _u8L("Disc"), "Disc"); + add_primitive("add_primitive_torus", _u8L("Torus"), "Torus"); + add("add_primitive_text", _u8L("Text"), _u8L("Add Primitive"), [](const std::string&) { + Plater* plater = wxGetApp().plater(); + if (plater) { + ensure_3d_view(plater); + if (GLCanvas3D* canvas = plater->canvas3D()) + canvas->clear_popup_menu_position(); + MenuFactory::add_text_volume(ModelVolumeType::INVALID); + } + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("add_primitive_svg", _u8L("SVG"), _u8L("Add Primitive"), [](const std::string&) { + Plater* plater = wxGetApp().plater(); + if (plater) { + ensure_3d_view(plater); + if (GLCanvas3D* canvas = plater->canvas3D()) + canvas->clear_popup_menu_position(); + MenuFactory::add_svg_volume(ModelVolumeType::INVALID); + } + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + + // ---- Add Handy models ---- (the Add > Add Handy models submenu) + const std::vector& handy = MenuFactory::handy_models(); + for (std::size_t i = 0; i < handy.size(); ++i) { + add("add_handy_" + std::string(handy[i].key), Slic3r::GUI::I18N::translate_utf8(handy[i].label), _u8L("Add Handy models"), + [i](const std::string&) { + if (Plater* plater = wxGetApp().plater()) + ensure_3d_view(plater); + MenuFactory::load_handy_model(i); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + } + // ---- Plate ---- add("plate_add", _u8L("Add Plate"), _u8L("Plate"), [](const std::string&) { Plater* plater = wxGetApp().plater(); @@ -458,6 +538,53 @@ std::vector build_command_catalog() return AppActionRunResult{AppActionRunResult::Level::Success}; }); + // ---- Help ---- (mirrors the top-bar Help menu, plus the wiki/YouTube links) + add("help_keyboard_shortcuts", _u8L("Keyboard Shortcuts"), _u8L("Help"), [](const std::string&) { + wxGetApp().keyboard_shortcuts(); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("help_setup_wizard", _u8L("Setup Wizard"), _u8L("Help"), [](const std::string&) { + wxGetApp().ShowUserGuide(); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("help_open_config_folder", _u8L("Show Configuration Folder"), _u8L("Help"), [](const std::string&) { + Slic3r::GUI::desktop_open_datadir_folder(); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("help_troubleshoot", _u8L("Troubleshoot Center"), _u8L("Help"), [](const std::string&) { + wxGetApp().troubleshoot(); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("help_network_test", _u8L("Open Network Test"), _u8L("Help"), [](const std::string&) { + NetworkTestDialog dlg(wxGetApp().mainframe); + dlg.ShowModal(); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("help_tip_of_the_day", _u8L("Show Tip of the Day"), _u8L("Help"), [](const std::string&) { + if (Plater* plater = wxGetApp().plater()) { + plater->get_dailytips()->open(); + if (GLCanvas3D* canvas = plater->get_current_canvas3D()) + canvas->set_as_dirty(); + } + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("help_check_updates", _u8L("Check for Updates"), _u8L("Help"), [](const std::string&) { + wxGetApp().check_new_version_sf(true, 1); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("help_about", _u8L("About OrcaSlicer"), _u8L("Help"), [](const std::string&) { + Slic3r::GUI::about(); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("open_wiki", _u8L("Open Wiki"), _u8L("Help"), [](const std::string&) { + wxLaunchDefaultBrowser("https://www.orcaslicer.com/wiki/", wxBROWSER_NEW_WINDOW); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + add("open_youtube", _u8L("Open YouTube Channel"), _u8L("Help"), [](const std::string&) { + wxLaunchDefaultBrowser("https://www.youtube.com/@OfficialOrcaSlicer/videos", wxBROWSER_NEW_WINDOW); + return AppActionRunResult{AppActionRunResult::Level::Success}; + }); + // ---- Plugins ---- add("open_plugins", _u8L("Open Plugins"), _u8L("Plugins"), [](const std::string&) { wxGetApp().open_plugins_dialog(); diff --git a/src/slic3r/GUI/Search.cpp b/src/slic3r/GUI/Search.cpp index f8fc51ed02..77b80208fc 100644 --- a/src/slic3r/GUI/Search.cpp +++ b/src/slic3r/GUI/Search.cpp @@ -85,7 +85,7 @@ static std::string get_key(const std::string &opt_key, Preset::Type type) { retu void OptionsSearcher::append_options(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode) { - auto emplace = [this, type](const std::string key, const wxString &label) { + auto emplace = [this, type](std::vector