diff --git a/content/Fry3DX/plugins/stamp.lua b/content/Fry3DX/plugins/stamp.lua new file mode 100644 index 0000000..e4ddb0b --- /dev/null +++ b/content/Fry3DX/plugins/stamp.lua @@ -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, +} diff --git a/src/app.cpp b/src/app.cpp index 444d3bd..a0e8d51 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -212,6 +212,10 @@ float distToSegment(ImVec2 p, ImVec2 a, ImVec2 b) { 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& poly) { bool in = false; 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); self->endStroke(); }, "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 if (a.size() < 5 || !self->viewReady()) return; 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 (slide_.active) cancelSlide(); 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 selected_.clear(); } @@ -1020,6 +1034,8 @@ private: }; std::vector plugins_; bool showPlugins_ = false; + std::pair 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 ── std::vector commands_; fry::CommandSearch search_; @@ -1643,7 +1659,7 @@ private: } return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name)); } - tool_ = t; + tool_ = t, pluginTool_ = {}; drawPts_.clear(), knifePts_.clear(); if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements(); } @@ -5251,6 +5267,10 @@ private: 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 (!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) @@ -6652,11 +6672,53 @@ private: addMenuItems(); 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(); - for (const ToolInfo& t : kTools) - if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name); + if (pluginTool_.first.empty()) // (a plug-in tool: its row shows it's on) + for (const ToolInfo& t : kTools) + if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name); ImGui::End(); } + // The plug-in tool that's on: which plug-in and which of its tools (none: -1). + std::pair 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) { 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(); 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() { std::error_code ec; stdfs::create_directories(pluginsFolder(), ec); @@ -7944,9 +8016,21 @@ private: if (ImGui::Button("How to write one")) showScript_ = showScriptHelp_ = true; ImGui::Spacing(); if (plugins_.empty()) ImGui::TextDisabled("None yet."); + else { + ImGui::SetNextItemWidth(-1); + inputString("##filter", pluginFilter_, "Look for a plug-in..."); + } std::optional> toggle; for (size_t i = 0; i < plugins_.size(); ++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::BeginChild("plugin", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY); bool on = e.on; @@ -7966,11 +8050,16 @@ private: } else if (e.p) { 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::PluginTool& t : e.p->tools) adds += std::format("{}{} (a tool in the Toolbox)", adds.empty() ? "" : ", ", t.name); size_t mats = 0; for (const MaterialPreset& m : ownMaterials()) mats += m.from == e.file; 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()); } 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::PopID(); } diff --git a/src/script.cpp b/src/script.cpp index ab93023..110ed71 100644 --- a/src/script.cpp +++ b/src/script.cpp @@ -745,6 +745,20 @@ int fryCommand(lua_State* L) { p->commands.push_back(std::move(c)); 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 Look& k = scene(L).look; struct Num { @@ -803,7 +817,7 @@ const luaL_Reg kFry[] = { {"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}, {"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). @@ -945,6 +959,22 @@ ScriptResult Plugin::run(ScriptHost& host, size_t command, const std::vectorL || 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() { 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() 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.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. diff --git a/src/script.h b/src/script.h index 96d61d9..0e27409 100644 --- a/src/script.h +++ b/src/script.h @@ -44,6 +44,12 @@ struct PluginCommand { 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 { public: 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); // 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& 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::vector commands; + std::vector tools; private: struct State;