Design: a verb's address is data, so the toolbar widget can stop existing

snaporca-7ih's remaining half. Both flyout factories registered their verbs INSIDE the
widget-building loop, so the ~40 retired tool buttons had to be constructed and then
Hide()n: skipping construction would have deleted 42 offer verbs (26 fly:<family>#<row>
+ 16 Shift+keys) while their rows still rendered and did nothing when picked.

Register first, build second. The addresses are pure data; the widget is one door onto
them, not their owner. A family absent from kBarKeep now returns before any wxWindow is
made. The keep-list stays a one-line data decision, not a structural one.

And close the class of bug for good: the constructor now verifies, once, that every verb
the atlas marks wired resolves to a real registration, logging each break and asserting in
debug. Rows that render and do nothing have shipped three times (edit_feature and sk_move
with action:null, then this) and are invisible from either side alone.

Verified on the snaporca rig by walking the offer, not by reading the code — all four
at-risk address kinds run with no widget behind them: fly:design_rect#2 drew an OBLIQUE
rectangle (the third variant, not the family's first), key:S+E opened Extrude with its
10 mm gizmo, fly:material#4 opened Thicken. Hover hints, icons and nesting intact. This
fork is code-identical here bar the two permitted DropDown divergences; it still owes a
build of its own (snaporca-5pl).

Two hints were wrong and are fixed: Cut said "Split the body with a plane", colliding with
the Split verb one row away and pointing at a card for a value the canvas already offers as
a draggable arrow; Split never said its plane comes from a picked face.

Also, because it blocked the verification and will block the next one: gui-session.sh
killed by full path while its own app_pid() matched by basename, so a differently-pathed
instance survived, held the single-instance lock, and got reported as a healthy session —
a Jul-30 binary nearly passed as this build. It now kills by basename and prints which
binary is actually on screen. Traps 6 and 7 documented.
This commit is contained in:
Tommaso Bianchi
2026-08-02 08:37:44 +02:00
parent 96816f725c
commit cfc2555c3a
5 changed files with 110 additions and 24 deletions
+28
View File
@@ -121,3 +121,31 @@ invisible and the session hunts a phantom. Pass `BIN` explicitly:
Note also that the GUI containers do **not** mount `scripts/`: `/OrcaSlicer/scripts` inside them
is the baked copy, so a local edit to `gui-session.sh` has no effect until you
`docker cp scripts/gui-session.sh <container>:/OrcaSlicer/scripts/`.
---
## Trap 6 — `src/Release/` resolves resources to `build/resources`, which may not exist
The binary derives `resources_dir()` from its own location, so the `src/Release/` one looks in
`/OrcaSlicer/build/resources` while the packaged one looks inside `build/package/`. Only the
packaging step creates the latter; nothing creates the former. Without it the app fails every
`Failed to add custom font ".../build/resources/fonts/…"`, logs `Health check is not running`,
and **exits 255 with nothing on stdout** — which reads exactly like a crash in whatever you just
changed. Measured 2026-08-02: an hour was nearly spent bisecting a GUI change that was fine.
`build/` is the shared cache volume, so one symlink fixes it permanently, and pointing it at the
bind-mounted repo tree means the rig also picks up new `resources/images/*.svg` without a rebuild:
docker exec <fork>-gui ln -sfn /OrcaSlicer/resources /OrcaSlicer/build/resources
Tell the two apart before debugging: a resource failure dies in the first second with no window;
a real fault in your code gets past the version banner. Compare
`~/.config/<App>/log/<newest>.log.0` against a known-good run — 47 lines versus 340 is the tell.
## Trap 7 — a single-instance app plus a path-matched `pkill`
`gui-session.sh` used to kill by `"$BIN"`, while its own `app_pid()` matched by BASENAME. Launch
with a `BIN` that differs from the running instance's path and the old process survives, keeps
the single-instance lock, and the new one exits seconds after loading fonts — then `status`
reports the *stale* pid as a healthy session. Fixed by killing on the basename; `status` now also
prints `binary : $(readlink -f /proc/<pid>/exe)`. **Read that line before trusting a screenshot.**
+2 -2
View File
@@ -567,7 +567,7 @@
"gui": true,
"action": "key:S+X",
"icon": "design_cut",
"hint": "Split the body with a plane; set the offset in the card"
"hint": "Trim the body with a plane — drag the offset arrow; keep one half or both"
},
{
"id": "split",
@@ -586,7 +586,7 @@
"gui": false,
"action": null,
"icon": null,
"hint": "Split the body into separate solids"
"hint": "Split the body along a picked face into two solids"
},
{
"id": "fillet",
+13 -1
View File
@@ -53,6 +53,10 @@ status() {
else echo "vnc : DOWN"; fi
local p; p="$(app_pid || true)"
echo "app : ${p:-DOWN}"
# WHICH binary is on screen, not just that something is. A pid alone cannot tell you whether
# you are looking at the build you just linked or one from last week, and that is precisely
# the question every rig verification is asking.
[ -n "${p:-}" ] && echo "binary : $(readlink -f "/proc/$p/exe" 2>/dev/null || echo unknown)"
[ -n "${p:-}" ] && echo "windows : $(xdotool search --name . getwindowname %@ 2>/dev/null | paste -sd'|' -)"
return 0
}
@@ -73,7 +77,15 @@ if ! pgrep -x x11vnc >/dev/null && command -v x11vnc >/dev/null; then
fi
# --- app ------------------------------------------------------------------------------------
pkill -9 -f "$BIN" 2>/dev/null || true
# Kill by BASENAME, not by "$BIN". The app enforces a single instance, so an older copy launched
# from a DIFFERENT path (the packaged build/package/bin/ one, say, when BIN points at the freshly
# linked build/src/Release/ one) survives a path-matched pkill, keeps the instance lock, and the
# new process exits seconds after loading fonts — leaving no error anywhere. app_pid() below has
# always matched by basename, so status then reported that stale process as a healthy session:
# the launch looked green while the window on screen was days old. Measured 2026-08-02, where it
# nearly passed a UI change against a Jul-30 binary. The killer and the reporter must agree on
# what counts as "the app".
pkill -9 -f "$(basename "$BIN")" 2>/dev/null || true
sleep 2
nohup "$BIN" >"$LOG" 2>&1 &
echo "launched $(basename "$BIN") pid $!"
+2 -2
View File
@@ -99,8 +99,8 @@ static const OfferVerb kOfferVerbs[] = {
{"hole", "Hole", 2, "Shift+H", "key:S+H", "Pick a face or a plane to drill into", 0x00000402u, 1, 0, false, false, nullptr, "design_hole", "Drill a hole, centred on a picked face or placed on a plane"},
{"thread", "Thread", 2, "Shift+T", "key:S+T", "Pick a cylindrical surface (bore / outer) or a circular edge for a thread", 0x00000024u, 1, 0, false, false, nullptr, "design_thread", "Thread a cylindrical surface (inner bore / outer) or a circular edge"},
{"shell", "Shell", 2, "Shift+K", "key:S+K", "Shell needs a solid body", 0x00000082u, 1, 0, false, false, nullptr, "design_shell", "Hollow the body to a wall thickness, opening a picked face"},
{"cut", "Cut", 2, "Shift+X", "key:S+X", "Create a solid body to cut first", 0x00000480u, 1, 0, false, false, nullptr, "design_cut", "Split the body with a plane; set the offset in the card"},
{"split", "Split", 2, nullptr, nullptr, "Split needs a solid body", 0x00000080u, 1, 0, false, false, nullptr, nullptr, "Split the body into separate solids"},
{"cut", "Cut", 2, "Shift+X", "key:S+X", "Create a solid body to cut first", 0x00000480u, 1, 0, false, false, nullptr, "design_cut", "Trim the body with a plane — drag the offset arrow; keep one half or both"},
{"split", "Split", 2, nullptr, nullptr, "Split needs a solid body", 0x00000080u, 1, 0, false, false, nullptr, nullptr, "Split the body along a picked face into two solids"},
{"fillet", "Fillet", 3, "Shift+F", "btn:dress#0", "Pick an edge to round", 0x000000b2u, 1, 0, false, false, nullptr, "design_filletedge", "Pick an edge, then drag the radius arrow or type it"},
{"chamfer", "Chamfer", 3, nullptr, "btn:dress#1", "Pick an edge to bevel", 0x000000b2u, 1, 0, false, false, nullptr, "design_chamfer", "Pick an edge, then drag the distance arrow or type it"},
{"draft", "Draft", 3, "Shift+D", "key:S+D", "Pick a face to taper", 0x0000000au, 1, 0, false, false, nullptr, "design_draft", "Tilt a picked face by a draft angle"},
+65 -19
View File
@@ -8,8 +8,10 @@
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/log/trivial.hpp> // the offer/atlas join check reports on the log
#include <Standard_Failure.hxx>
#include <cassert>
#include <map>
#include <set>
#include <wx/sizer.h>
@@ -477,6 +479,21 @@ DesignPanel::DesignPanel(wxWindow* parent)
FeatFlyout() : drop(items) {}
};
auto feat_dropdown = [&](const char* id, const char* def_icon, const wxString& grp, std::vector<FeatVar> vars) {
// REGISTER FIRST, BUILD SECOND. A verb's two addresses — "fly:<family>#<row>" for the
// offer and its Shift+key — are data. The widget is one door onto them, not their
// owner. Doing this before any wxWindow exists is what lets the build below be skipped
// outright for a family the bar no longer carries. It used to sit INSIDE the build
// loop, so a retired family still had to be constructed and then Hide()n: skipping it
// would have deleted 42 verbs from the offer while their rows still rendered and did
// nothing when picked. snaporca-7ih.
// Keyed on "fly:<family>#<row>" so the generated table can name a variant without the
// item struct growing a field at 26 call sites.
for (size_t i = 0; i < vars.size(); ++i) {
if (vars[i].key) m_keys_feature[vars[i].key] = vars[i].action; // key runs the same action
m_verb_actions["fly:" + std::string(id) + "#" + std::to_string(i)] = vars[i].action;
}
if (kBarKeep.count(id) == 0)
return; // reached from the offer alone — no button, no chevron, no popup
auto* b = icon_btn(def_icon, grp);
b->SetFont(Label::Body_14); // measure popup labels in the popup's font (no truncation)
auto fo = std::make_shared<FeatFlyout>();
@@ -488,12 +505,6 @@ DesignPanel::DesignPanel(wxWindow* parent)
fo->items.push_back(it);
fo->actions.push_back(std::move(v.action));
fo->icon_names.emplace_back(v.icon);
if (v.key) m_keys_feature[v.key] = fo->actions.back(); // key runs the same action
// …and the offer reaches the same action by its ratified address. Keyed on
// "fly:<family>#<row>" so the generated table can name it without the item
// struct growing a field at 26 call sites.
m_verb_actions["fly:" + std::string(id) + "#" +
std::to_string(fo->actions.size() - 1)] = fo->actions.back();
}
fo->btn = b;
fo->drop.Create(b);
@@ -524,7 +535,6 @@ DesignPanel::DesignPanel(wxWindow* parent)
chev->SetForegroundColour(dp_sec_text());
chev->SetFont(Label::Body_9);
fadd(id, chev);
return b;
};
auto* b_sketch = icon_btn("design_sketch", _L("Sketch"));
@@ -1088,6 +1098,19 @@ DesignPanel::DesignPanel(wxWindow* parent)
ToolFlyout() : drop(items) {}
};
auto dropdown = [&](const char* def_icon, const wxString& grp, std::vector<SkVar> vars) {
// Register first, build second — the same law as feat_dropdown, for the same reason.
// The offer reaches each tool by its ratified address; without these the offer could
// name a family but only ever arm its FIRST tool: picking "Rectangle" ran key:R and
// gave you a corner rectangle, with oblique and rounded unreachable. Keyed on the icon
// id (already unique per family) so no call site grows an argument. snaporca-6vs.
for (size_t i = 0; i < vars.size(); ++i) {
const DesignSketchTool::Mode mode = vars[i].mode;
const wxString hint = vars[i].hint;
m_verb_actions["fly:" + std::string(def_icon) + "#" + std::to_string(i)] =
[mode, hint, select_tool] { select_tool(mode, hint); };
}
if (kBarKeep.count(def_icon) == 0)
return; // the drawing tools live in the offer; nothing of this family is built
auto* b = icon_btn(def_icon, grp);
// messureSize() measures labels with the PARENT's font (this button) but the
// popup draws them in Body_14 — so an under-sized button font truncates rows.
@@ -1104,17 +1127,6 @@ DesignPanel::DesignPanel(wxWindow* parent)
fo->modes.push_back(v.mode);
fo->hints.push_back(v.hint);
fo->icon_names.emplace_back(v.icon);
// …and the offer reaches the same tool by its ratified address, exactly as
// feat_dropdown does for the model verbs. Without this the offer could name a
// family but only ever arm its FIRST tool: picking "Rectangle" ran key:R and
// gave you a corner rectangle, with oblique and rounded unreachable.
// Keyed on the icon id (already unique per family) so no call site grows an
// argument. snaporca-6vs.
const DesignSketchTool::Mode mode = v.mode;
const wxString hint = v.hint;
m_verb_actions["fly:" + std::string(def_icon) + "#" +
std::to_string(fo->modes.size() - 1)] =
[mode, hint, select_tool] { select_tool(mode, hint); };
}
fo->btn = b;
fo->drop.Create(b);
@@ -1148,7 +1160,6 @@ DesignPanel::DesignPanel(wxWindow* parent)
chev->SetForegroundColour(dp_sec_text());
chev->SetFont(Label::Body_9);
sadd(chev); // follows its button off the bar
return b;
};
skbtn("design_select", DesignSketchTool::Mode::Select, _L("Select"),
_L("Click to select; Shift to add; double-click for a whole loop"));
@@ -3660,6 +3671,41 @@ DesignPanel::DesignPanel(wxWindow* parent)
outer->Add(body, 1, wxEXPAND);
SetSizer(outer);
// The atlas says a verb is wired; the registrations above say what it runs. Nothing checks
// that the two agree, and a broken join is INVISIBLE — the row renders, is enabled, and does
// nothing when picked. That defect has shipped three times (edit_feature and sk_move carrying
// action:null, then a whole flyout family the moment its widget stopped being built), and it
// cannot be seen by reading either side alone. Verify the join once, here, where every
// registration is complete. Cheap: 86 map lookups, once per panel.
{
std::vector<std::string> dead;
for (int i = 0; i < kOfferVerbCount; ++i) {
const OfferVerb& v = kOfferVerbs[i];
if (v.action == nullptr || *v.action == '\0')
continue; // kernel support, no GUI path — the row shows disabled
const std::string a(v.action);
bool ok = false;
if (a.rfind("key:", 0) == 0) { // resolved exactly as run_offer_action() resolves it
const std::string k = a.substr(4);
if (k.size() >= 3 && k[0] == 'S' && k[1] == '+') {
auto it = m_keys_feature.find(int(k[2]) | SC_SHIFT);
ok = it != m_keys_feature.end() && bool(it->second);
} else if (!k.empty()) {
auto it = m_keys_sketch.find(int(k[0]));
ok = it != m_keys_sketch.end() && bool(it->second);
}
} else {
auto it = m_verb_actions.find(a);
ok = it != m_verb_actions.end() && bool(it->second);
}
if (!ok)
dead.emplace_back(std::string(v.id) + " -> " + a);
}
for (const std::string& d : dead)
BOOST_LOG_TRIVIAL(error) << "Design offer: wired verb has no action: " << d;
assert(dead.empty()); // debug builds stop here; release ships the log line
}
set_ui_mode(UiMode::Feature);
}