Plug-in mouse tools (fry.tool{name, help, click(at, normal, object)}): listed in the Toolbox, a click in the view calls it (one undo); Stamp comes as an example; the Plug-ins window: look for one, make your own copy of one that comes with Fry3DX

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 08:10:24 +02:00
commit b9b6932d0e
4 changed files with 155 additions and 4 deletions

View file

@ -0,0 +1,22 @@
-- Stamp: a mouse tool. Click on anything (or the ground) and a copy of the picked object is put
-- there, standing on the surface. Shows how a plug-in adds a tool to the Toolbox (fry.tool).
fry.plugin{name = "Stamp", about = "A tool: click to put copies of the picked object on what you click, standing on it."}
fry.tool{
name = "Stamp copies",
help = "Click to put a copy of the picked object there, standing on the surface (pick one first).",
click = function(at, normal, onto)
local picked = fry.selected()
local o = picked[#picked]
if not o or (onto and o.id == onto.id) then
-- nothing picked to copy (or clicking on it): a little ball, to show where
o = fry.add("sphere", {radius = 0.1})
o.position = at + normal * 0.1
return
end
local c = o:copy()
local lo, hi = o:bounds()
c.position = at + normal * ((hi.z - lo.z) / 2)
o:select()
end,
}

View file

@ -212,6 +212,10 @@ float distToSegment(ImVec2 p, ImVec2 a, ImVec2 b) {
return std::sqrt(dx * dx + dy * dy); return std::sqrt(dx * dx + dy * dy);
} }
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return char(std::tolower(c)); });
return s;
}
bool insidePolygon(ImVec2 p, const std::vector<ImVec2>& poly) { bool insidePolygon(ImVec2 p, const std::vector<ImVec2>& poly) {
bool in = false; bool in = false;
for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++)
@ -478,6 +482,14 @@ public:
for (int i = 0; i <= 40; ++i) self->paintAt(ImVec2(p0.x + (p1.x - p0.x) * float(i) / 40, p0.y + (p1.y - p0.y) * float(i) / 40), i == 0); for (int i = 0; i <= 40; ++i) self->paintAt(ImVec2(p0.x + (p1.x - p0.x) * float(i) / 40, p0.y + (p1.y - p0.y) * float(i) / 40), i == 0);
self->endStroke(); self->endStroke();
}, "Fry3DX: fry_stroke x0 y0 x1 y1: a brush stroke across the view (0..1)"); }, "Fry3DX: fry_stroke x0 y0 x1 y1: a brush stroke across the view (0..1)");
static keel::Cmd ptool("fry_tooluse", [](const keel::Args& a) { // a plug-in tool by name, clicked at x y (0..1 of the view)
if (a.size() < 4 || !self->viewReady()) return;
for (const PluginEntry& e : self->plugins_)
if (e.p)
for (const fry::PluginTool& t : e.p->tools)
if (t.name == a[1]) self->pluginTool_ = {e.file, t.name};
self->clickPluginTool(vp(a[2], a[3]));
}, "Fry3DX: fry_tooluse \"name\" x y: click with a plug-in tool (0..1 of the view)");
static keel::Cmd stencil("fry_stencil", [](const keel::Args& a) { // a box stencil, 0..1 of the view static keel::Cmd stencil("fry_stencil", [](const keel::Args& a) { // a box stencil, 0..1 of the view
if (a.size() < 5 || !self->viewReady()) return; if (a.size() < 5 || !self->viewReady()) return;
const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]);
@ -703,6 +715,8 @@ public:
else if (drag_.active) cancelDrag(); else if (drag_.active) cancelDrag();
else if (slide_.active) cancelSlide(); else if (slide_.active) cancelSlide();
else if (printTool_ == PrintTool::Measure) printTool_ = PrintTool::None, measure_.clear(); else if (printTool_ == PrintTool::Measure) printTool_ = PrintTool::None, measure_.clear();
else if (!pluginTool_.first.empty()) pluginTool_ = {};
else if (drawingStencil_ || stencilOn_) drawingStencil_ = stencilOn_ = false, stencil_.clear();
else if (mode_ == Mode::Edit) leaveEdit(); else if (mode_ == Mode::Edit) leaveEdit();
else selected_.clear(); else selected_.clear();
} }
@ -1020,6 +1034,8 @@ private:
}; };
std::vector<PluginEntry> plugins_; std::vector<PluginEntry> plugins_;
bool showPlugins_ = false; bool showPlugins_ = false;
std::pair<std::string, std::string> pluginTool_; // (the plug-in tool that's on: its file and name)
std::string pluginFilter_;
// ── Commands: found by typing, on keys of your own, round the mouse ── // ── Commands: found by typing, on keys of your own, round the mouse ──
std::vector<fry::Command> commands_; std::vector<fry::Command> commands_;
fry::CommandSearch search_; fry::CommandSearch search_;
@ -1643,7 +1659,7 @@ private:
} }
return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name)); return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name));
} }
tool_ = t; tool_ = t, pluginTool_ = {};
drawPts_.clear(), knifePts_.clear(); drawPts_.clear(), knifePts_.clear();
if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements(); if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements();
} }
@ -5251,6 +5267,10 @@ private:
return; return;
} }
if (!mouseInView(io)) return; if (!mouseInView(io)) return;
if (!pluginTool_.first.empty() && mode_ == Mode::Object) {
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && hoverHandle(io.MousePos) < 0) clickPluginTool(io.MousePos);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) || !pluginToolFound()) return;
}
if (tool_ == Tool::Draw) { if (tool_ == Tool::Draw) {
if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return; if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
if (touch()) return void((boxing_ = touchOrbit_ = tapDraw_ = true, boxStart_ = io.MousePos)); // (a finger: a tap puts a point down when it lifts, a drag turns the view) if (touch()) return void((boxing_ = touchOrbit_ = tapDraw_ = true, boxStart_ = io.MousePos)); // (a finger: a tap puts a point down when it lifts, a drag turns the view)
@ -6652,11 +6672,53 @@ private:
addMenuItems(); addMenuItems();
ImGui::EndPopup(); ImGui::EndPopup();
} }
// Mouse tools from plug-ins (fry.tool): by name, as plug-ins load again.
if (mode_ == Mode::Object) {
bool first = true;
for (size_t i = 0; i < plugins_.size(); ++i)
if (pluginUsable(i))
for (const fry::PluginTool& t : plugins_[i].p->tools) {
if (std::exchange(first, false)) ImGui::SeparatorText("Plug-in tools");
const bool on = pluginTool_ == std::pair(plugins_[i].file, t.name);
if (ImGui::Selectable(std::format("{}##{}", t.name, plugins_[i].file).c_str(), on, 0, ImVec2(w, 0)))
on ? setTool(Tool::Select) : void((pluginTool_ = {plugins_[i].file, t.name}, tool_ = Tool::Select, say(t.name + ": click in the view (Esc stops)")));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s%s(%s)", t.help.c_str(), t.help.empty() ? "" : "\n", plugins_[i].p->name.c_str());
}
}
ImGui::Spacing(); ImGui::Spacing();
if (pluginTool_.first.empty()) // (a plug-in tool: its row shows it's on)
for (const ToolInfo& t : kTools) for (const ToolInfo& t : kTools)
if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name); if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name);
ImGui::End(); ImGui::End();
} }
// The plug-in tool that's on: which plug-in and which of its tools (none: -1).
std::pair<int, int> pluginToolAt() const {
for (size_t i = 0; i < plugins_.size(); ++i)
if (plugins_[i].file == pluginTool_.first && pluginUsable(i))
for (size_t k = 0; k < plugins_[i].p->tools.size(); ++k)
if (plugins_[i].p->tools[k].name == pluginTool_.second) return {int(i), int(k)};
return {-1, -1};
}
bool pluginToolFound() const { return pluginToolAt().first >= 0; }
// A plug-in tool's click: the spot on what's under the mouse, or on the ground.
void clickPluginTool(ImVec2 mouse) {
const auto [i, k] = pluginToolAt();
if (i < 0) return pluginTool_ = {}, say("That plug-in tool isn't loaded any more");
const Ray ray = mouseRay(mouse);
glm::vec3 at, n(0, 0, 1);
uint32_t onto = 0;
const Hit h = pickObject(ray, 0, true);
if (const Object* o = h.object ? find(h.object) : nullptr) {
at = ray.origin + ray.dir * h.t, onto = o->id;
n = glm::normalize(glm::transpose(glm::inverse(glm::mat3(objectMatrix(*o)))) * faceNormal(o->mesh, h.face));
} else if (auto g = hitPlane(ray, glm::vec3(0.0f), n)) at = *g;
else return;
pushUndo();
bool frame = false;
fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }};
const fry::ScriptResult r = plugins_[size_t(i)].p->click(host, size_t(k), at, n, onto);
finishRun(r, pluginTool_.second, frame);
}
void paintToolbox(float w) { void paintToolbox(float w) {
const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f; const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
@ -7859,6 +7921,16 @@ private:
for (const PluginEntry& e : plugins_) loaded += e.p && e.error.empty(); for (const PluginEntry& e : plugins_) loaded += e.p && e.error.empty();
if (loaded) keel::log::info("Plug-ins: {} loaded", loaded); if (loaded) keel::log::info("Plug-ins: {} loaded", loaded);
} }
void copyPlugin(const PluginEntry& e) {
const auto text = keel::fs::readText(e.path);
if (!text) return say("Couldn't read " + e.file);
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
const stdfs::path to = stdfs::path(pluginsFolder()) / ("my_" + e.file);
if (!stdfs::exists(to)) std::ofstream(to, std::ios::binary) << *text;
setPluginOn(e.file, false); // (loads them again)
say("Your copy: " + to.string() + " (Open the plug-ins folder, change it, Load them again)");
}
void openPluginsFolder() { void openPluginsFolder() {
std::error_code ec; std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec); stdfs::create_directories(pluginsFolder(), ec);
@ -7944,9 +8016,21 @@ private:
if (ImGui::Button("How to write one")) showScript_ = showScriptHelp_ = true; if (ImGui::Button("How to write one")) showScript_ = showScriptHelp_ = true;
ImGui::Spacing(); ImGui::Spacing();
if (plugins_.empty()) ImGui::TextDisabled("None yet."); if (plugins_.empty()) ImGui::TextDisabled("None yet.");
else {
ImGui::SetNextItemWidth(-1);
inputString("##filter", pluginFilter_, "Look for a plug-in...");
}
std::optional<std::pair<std::string, bool>> toggle; std::optional<std::pair<std::string, bool>> toggle;
for (size_t i = 0; i < plugins_.size(); ++i) { for (size_t i = 0; i < plugins_.size(); ++i) {
const PluginEntry& e = plugins_[i]; const PluginEntry& e = plugins_[i];
if (!pluginFilter_.empty()) {
std::string all = lower(e.file + " " + (e.p ? e.p->name + " " + e.p->about : std::string()));
if (e.p)
for (const fry::PluginCommand& c : e.p->commands) all += " " + lower(c.name + " " + c.help);
if (e.p)
for (const fry::PluginTool& t : e.p->tools) all += " " + lower(t.name + " " + t.help);
if (all.find(lower(pluginFilter_)) == std::string::npos) continue;
}
ImGui::PushID(int(i)); ImGui::PushID(int(i));
ImGui::BeginChild("plugin", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY); ImGui::BeginChild("plugin", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
bool on = e.on; bool on = e.on;
@ -7966,11 +8050,16 @@ private:
} else if (e.p) { } else if (e.p) {
std::string adds; std::string adds;
for (const fry::PluginCommand& c : e.p->commands) adds += std::format("{}{} ({} menu)", adds.empty() ? "" : ", ", c.name, c.menu == "add" ? "Add" : c.menu == "object" ? "Object" : "Script > Plug-ins"); for (const fry::PluginCommand& c : e.p->commands) adds += std::format("{}{} ({} menu)", adds.empty() ? "" : ", ", c.name, c.menu == "add" ? "Add" : c.menu == "object" ? "Object" : "Script > Plug-ins");
for (const fry::PluginTool& t : e.p->tools) adds += std::format("{}{} (a tool in the Toolbox)", adds.empty() ? "" : ", ", t.name);
size_t mats = 0; size_t mats = 0;
for (const MaterialPreset& m : ownMaterials()) mats += m.from == e.file; for (const MaterialPreset& m : ownMaterials()) mats += m.from == e.file;
if (mats) adds += std::format("{}{} material{}", adds.empty() ? "" : ", ", mats, mats == 1 ? "" : "s"); if (mats) adds += std::format("{}{} material{}", adds.empty() ? "" : ", ", mats, mats == 1 ? "" : "s");
ImGui::TextColored(roleColor(Role::TextTertiary), "Adds: %s", adds.empty() ? "nothing yet" : adds.c_str()); ImGui::TextColored(roleColor(Role::TextTertiary), "Adds: %s", adds.empty() ? "nothing yet" : adds.c_str());
} else ImGui::TextDisabled("Turned off"); } else ImGui::TextDisabled("Turned off");
if (e.bundled) { // (to change one that comes with Fry3DX: your own copy, in your folder)
if (ImGui::SmallButton("Make my own copy to change")) copyPlugin(e);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Copies it into your plug-ins folder (as my_%s) and turns this one off", e.file.c_str());
}
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopID(); ImGui::PopID();
} }

View file

@ -745,6 +745,20 @@ int fryCommand(lua_State* L) {
p->commands.push_back(std::move(c)); p->commands.push_back(std::move(c));
return 0; return 0;
} }
// fry.tool{name = "Stamp", help = "...", click = function(at, normal, object) ... end}
int fryTool(lua_State* L) {
Plugin* p = context(L).plugin;
if (!p) luaL_error(L, "fry.tool is for plug-ins: put this script in the plug-ins folder (Script > Plug-ins)");
luaL_checktype(L, 1, LUA_TTABLE);
PluginTool t;
t.name = fieldString(L, 1, "name");
if (t.name.empty()) luaL_error(L, "a tool needs a name");
t.help = fieldString(L, 1, "help");
if (lua_getfield(L, 1, "click") != LUA_TFUNCTION) luaL_error(L, "a tool needs click = function(at, normal, object) ... end");
t.ref = luaL_ref(L, LUA_REGISTRYINDEX);
p->tools.push_back(std::move(t));
return 0;
}
int fryLook(lua_State* L) { // fry.look{sun_turn = 40, sky = false, ...}; returns the look as it is int fryLook(lua_State* L) { // fry.look{sun_turn = 40, sky = false, ...}; returns the look as it is
Look& k = scene(L).look; Look& k = scene(L).look;
struct Num { struct Num {
@ -803,7 +817,7 @@ const luaL_Reg kFry[] = {
{"add", fryAdd}, {"mesh", fryMesh}, {"lathe", fryLathe}, {"extrude", fryExtrude}, {"objects", fryObjects}, {"find", fryFind}, {"add", fryAdd}, {"mesh", fryMesh}, {"lathe", fryLathe}, {"extrude", fryExtrude}, {"objects", fryObjects}, {"find", fryFind},
{"selected", frySelected}, {"select", frySelect}, {"clear", fryClear}, {"group", fryGroup}, {"shapes", fryShapes}, {"look", fryLook}, {"materials", fryMaterials}, {"selected", frySelected}, {"select", frySelect}, {"clear", fryClear}, {"group", fryGroup}, {"shapes", fryShapes}, {"look", fryLook}, {"materials", fryMaterials},
{"vec", fryVec}, {"say", fryPrint}, {"frame", fryFrame}, {"help", fryHelp}, {"material", fryMaterial}, {"plugin", fryPlugin}, {"vec", fryVec}, {"say", fryPrint}, {"frame", fryFrame}, {"help", fryHelp}, {"material", fryMaterial}, {"plugin", fryPlugin},
{"command", fryCommand}, {nullptr, nullptr}, {"command", fryCommand}, {"tool", fryTool}, {nullptr, nullptr},
}; };
// Stops a script that's run too long (an endless loop). // Stops a script that's run too long (an endless loop).
@ -945,6 +959,22 @@ ScriptResult Plugin::run(ScriptHost& host, size_t command, const std::vector<dou
return r; return r;
} }
ScriptResult Plugin::click(ScriptHost& host, size_t tool, glm::vec3 at, glm::vec3 normal, uint32_t object) {
if (!s_->L || tool >= tools.size()) return {false, "that tool isn't there any more"};
s_->ctx.host = &host, s_->ctx.start = std::chrono::steady_clock::now(), s_->ctx.made = 0;
lua_State* L = s_->L;
lua_rawgeti(L, LUA_REGISTRYINDEX, tools[tool].ref);
pushVec(L, at), pushVec(L, normal);
if (object && findObject(*host.scene, object)) pushObject(L, object);
else lua_pushnil(L);
ScriptResult r;
r.error = call(L, 3);
r.ok = r.error.empty();
r.objectsMade = s_->ctx.made;
tidySelection(host);
return r;
}
const char* scriptHelp() { const char* scriptHelp() {
return R"(Scripts are Lua (lua.org/manual/5.4). Sizes are metres, turns degrees, Z is up. return R"(Scripts are Lua (lua.org/manual/5.4). Sizes are metres, turns degrees, Z is up.
@ -976,6 +1006,7 @@ An object (o):
o:points() o:faces() o:setpoints(points) o:setshape(points, faces) o:set(name, value) o:settings() o:points() o:faces() o:setpoints(points) o:setshape(points, faces) o:set(name, value) o:settings()
Plug-ins: scripts in the plug-ins folder (Script > Plug-ins), run when Fry3DX starts and kept. Plug-ins: scripts in the plug-ins folder (Script > Plug-ins), run when Fry3DX starts and kept.
fry.tool{name, help, click} (plug-ins) a mouse tool in the Toolbox: click(at, normal, object) where the view's clicked
fry.plugin{name, about} what it's called in the Plug-ins window fry.plugin{name, about} what it's called in the Plug-ins window
fry.command{name, menu, help, settings, run} a command in a menu: menu "add", "object" or "script"; fry.command{name, menu, help, settings, run} a command in a menu: menu "add", "object" or "script";
settings {{"steps", 12}, {"rise", 0.18}} are asked for first, then run(s) gets s.steps, s.rise. settings {{"steps", 12}, {"rise", 0.18}} are asked for first, then run(s) gets s.steps, s.rise.

View file

@ -44,6 +44,12 @@ struct PluginCommand {
int ref = 0; // (its run function, in the plug-in's Lua) int ref = 0; // (its run function, in the plug-in's Lua)
}; };
// A mouse tool a plug-in adds (fry.tool): with it on, a click in the view calls it with the spot.
struct PluginTool {
std::string name, help;
int ref = 0; // (its click function)
};
class Plugin { class Plugin {
public: public:
explicit Plugin(std::string file); // "stairs.lua": shows in errors, and as its name until fry.plugin{name=} says explicit Plugin(std::string file); // "stairs.lua": shows in errors, and as its name until fry.plugin{name=} says
@ -54,9 +60,12 @@ public:
bool load(ScriptHost& host, const std::string& code, std::string* error); bool load(ScriptHost& host, const std::string& code, std::string* error);
// One of its commands, with the settings asked for (in order). Like a script: one run. // One of its commands, with the settings asked for (in order). Like a script: one run.
ScriptResult run(ScriptHost& host, size_t command, const std::vector<double>& settings); ScriptResult run(ScriptHost& host, size_t command, const std::vector<double>& settings);
// One of its tools, clicked: where (the world), the surface's way out there, the object (0: the ground).
ScriptResult click(ScriptHost& host, size_t tool, glm::vec3 at, glm::vec3 normal, uint32_t object);
std::string file, name, about; std::string file, name, about;
std::vector<PluginCommand> commands; std::vector<PluginCommand> commands;
std::vector<PluginTool> tools;
private: private:
struct State; struct State;