From 62afbf2b1ec723aa65c0a89303d00ca97f00b9b6 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Tue, 20 Oct 2026 17:07:51 +0200 Subject: [PATCH] Scripting: Lua 5.4 with a fry library (shapes, own meshes, lathe, extrude, add/subtract/overlap, points, the look); a Script panel (F5 / Ctrl+Enter runs, one undo step, an error takes it all back), Script menu, example scripts, fry_run; tests run every example Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 +- cmake/Dependencies.cmake | 22 + content/Fry3DX/scripts/box_with_lid.lua | 16 + content/Fry3DX/scripts/gear.lua | 14 + content/Fry3DX/scripts/little_town.lua | 21 + content/Fry3DX/scripts/spiral_stairs.lua | 15 + content/Fry3DX/scripts/vase.lua | 15 + content/Fry3DX/scripts/waves.lua | 12 + src/CMakeLists.txt | 7 +- src/app.cpp | 175 +++++ src/script.cpp | 806 +++++++++++++++++++++++ src/script.h | 37 ++ tests/CMakeLists.txt | 4 + tests/script_test.cpp | 77 +++ 14 files changed, 1220 insertions(+), 3 deletions(-) create mode 100644 content/Fry3DX/scripts/box_with_lid.lua create mode 100644 content/Fry3DX/scripts/gear.lua create mode 100644 content/Fry3DX/scripts/little_town.lua create mode 100644 content/Fry3DX/scripts/spiral_stairs.lua create mode 100644 content/Fry3DX/scripts/vase.lua create mode 100644 content/Fry3DX/scripts/waves.lua create mode 100644 src/script.cpp create mode 100644 src/script.h create mode 100644 tests/script_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c4a0e8..2e08a4b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,4 +47,4 @@ add_custom_target(content ALL enable_testing() add_subdirectory(tests EXCLUDE_FROM_ALL) add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure - DEPENDS mesh_test USES_TERMINAL VERBATIM) + DEPENDS mesh_test script_test USES_TERMINAL VERBATIM) diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index a643005..138c8ee 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -37,6 +37,10 @@ FetchContent_Declare(opusfile GIT_REPOSITORY https://github.com/xiph/opusfile.gi FetchContent_Declare(dejavu URL https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.zip URL_HASH SHA256=7576310b219e04159d35ff61dd4a4ec4cdba4f35c00e002a136f00e96a908b0a DOWNLOAD_EXTRACT_TIMESTAMP ON SOURCE_SUBDIR no-cmake) +# Lua: the scripting language (Script panel, scripts/*.lua). Small, plain C, nothing else needed. +FetchContent_Declare(lua URL https://www.lua.org/ftp/lua-5.4.7.tar.gz + URL_HASH SHA256=9fbf5e28ef86c69858f6d3d34eccc32e911c1a28b4120ff3e84aaa70cfbf1e30 + DOWNLOAD_EXTRACT_TIMESTAMP ON SOURCE_SUBDIR no-cmake) # KEEL_YAPF_DIR=/path/to/YAPF uses a local checkout instead. FetchContent_Declare(yapf GIT_REPOSITORY https://github.com/Nexoniarz/yapf.git GIT_TAG 06a4e7db9e7e6d90132473e5606f0e6f5b1d6001 SYSTEM) # YAPF 2 (+ yapf_tex_to_rgba8: any surface as RGBA8) @@ -113,6 +117,24 @@ if(NOT MSVC) target_compile_options(yapf PRIVATE -O2) # its codecs and the BC7 encoder, even in debug builds endif() +# Lua's core and the libraries scripts get (no io, os or loading modules: a script changes the +# scene, not the computer). +FetchContent_MakeAvailable(lua) +set(_lua_src lapi lcode lctype ldebug ldo ldump lfunc lgc llex lmem lobject lopcodes lparser lstate lstring ltable ltm lundump lvm lzio + lauxlib lbaselib lcorolib ldblib lmathlib lstrlib ltablib lutf8lib) +list(TRANSFORM _lua_src PREPEND ${lua_SOURCE_DIR}/src/) +list(TRANSFORM _lua_src APPEND .c) +# As C++: its errors are then exceptions, which unwind through the bindings' C++ properly. +set_source_files_properties(${_lua_src} PROPERTIES LANGUAGE CXX) +add_library(lua STATIC ${_lua_src}) +if(NOT MSVC) + target_compile_options(lua PRIVATE -w) +endif() +target_include_directories(lua SYSTEM PUBLIC ${lua_SOURCE_DIR}/src) +if(NOT WIN32) + target_link_libraries(lua PRIVATE m) +endif() + # Audio: miniaudio for output; Ogg Opus decoding (opusfile). set(OPUS_BUILD_TESTING OFF) set(OPUS_BUILD_PROGRAMS OFF) diff --git a/content/Fry3DX/scripts/box_with_lid.lua b/content/Fry3DX/scripts/box_with_lid.lua new file mode 100644 index 0000000..506650b --- /dev/null +++ b/content/Fry3DX/scripts/box_with_lid.lua @@ -0,0 +1,16 @@ +-- A box to print, with a lid and holes for M3 screws in its corners. +local w, d, h, wall = 0.08, 0.06, 0.04, 0.002 +local box = fry.add("rounded_cube", {width = w, depth = d, height = h, name = "Box", color = "#d9d4c7"}) +local inside = fry.add("cube", {width = w - 2 * wall, depth = d - 2 * wall, height = h}) +inside.position = {0, 0, wall} +box:subtract(inside) +local lid = fry.add("cube", {width = w, depth = d, height = wall, name = "Lid", color = "#ff7828"}) +lid.position = {w + 0.02, 0, 0} +for sx = -1, 1, 2 do + for sy = -1, 1, 2 do + local hole = fry.add("cylinder", {radius = 0.0016, height = 0.01, sides = 24}) + hole.position = {w + 0.02 + sx * (w / 2 - 0.006), sy * (d / 2 - 0.006), -0.002} + lid:subtract(hole) + end +end +fry.frame() diff --git a/content/Fry3DX/scripts/gear.lua b/content/Fry3DX/scripts/gear.lua new file mode 100644 index 0000000..4d88992 --- /dev/null +++ b/content/Fry3DX/scripts/gear.lua @@ -0,0 +1,14 @@ +-- A gear for 3D printing, with a hole for an M5 screw. +local teeth, radius, depth, thick = 16, 0.03, 0.004, 0.006 -- metres +local outline = {} +for i = 0, teeth * 4 - 1 do + local a = i / (teeth * 4) * 2 * math.pi + local r = (i % 4 < 2) and radius + depth or radius + outline[#outline + 1] = {math.cos(a) * r, math.sin(a) * r} +end +local gear = fry.extrude(outline, thick, "Gear") +gear.color = "#e0a030" +local hole = fry.add("cylinder", {radius = 0.0027, height = 0.02, sides = 32}) +hole.position = {0, 0, -0.005} +gear:subtract(hole) +fry.frame() diff --git a/content/Fry3DX/scripts/little_town.lua b/content/Fry3DX/scripts/little_town.lua new file mode 100644 index 0000000..4846e52 --- /dev/null +++ b/content/Fry3DX/scripts/little_town.lua @@ -0,0 +1,21 @@ +-- A little town: random houses on a grid, each a folder of its own. +math.randomseed(7) +local roofs = {"#a8432f", "#5a6470", "#8c5a3c"} +for x = -2, 2 do + for y = -2, 2 do + local w, d, h = 0.6 + math.random() * 0.3, 0.6 + math.random() * 0.3, 0.5 + math.random() * 0.9 + local walls = fry.add("cube", {width = w, depth = d, height = h, color = "#e8e0d0", name = "Walls"}) + walls.position = {x * 1.4, y * 1.4, 0} + -- a wedge rises towards -X: two of them back to back make a ridge + local roof = fry.add("wedge", {width = d + 0.1, length = w / 2 + 0.05, height = 0.35, name = "Roof"}) + local other = roof:copy() + roof.position = {x * 1.4 + w / 4 + 0.025, y * 1.4, h} + other.position = {x * 1.4 - w / 4 - 0.025, y * 1.4, h} + other.turn = {0, 0, 180} + local c = roofs[math.random(#roofs)] + roof.color, other.color = c, c + fry.group(string.format("House %d,%d", x, y), {walls, roof, other}) + end +end +fry.look{sun_turn = 60, sun_height = 35} +fry.frame() diff --git a/content/Fry3DX/scripts/spiral_stairs.lua b/content/Fry3DX/scripts/spiral_stairs.lua new file mode 100644 index 0000000..53adb16 --- /dev/null +++ b/content/Fry3DX/scripts/spiral_stairs.lua @@ -0,0 +1,15 @@ +-- Spiral stairs round a pillar: change the numbers and run it again (F5). +local steps, rise, turn = 18, 0.18, 22 -- how many, how high each, degrees each +local width = 0.9 + +fry.add("pillar", {radius = 0.12, height = steps * rise + 0.3, color = "#8a8a90", name = "Post"}) +local made = {} +for i = 0, steps - 1 do + local step = fry.add("cube", {width = width, depth = 0.32, height = 0.05, color = "#b07a4a", name = "Step"}) + step.turn = {0, 0, i * turn} + local a = math.rad(i * turn) + step.position = {math.cos(a) * width / 2, math.sin(a) * width / 2, (i + 1) * rise} + made[#made + 1] = step +end +fry.group("Stairs", made) +fry.frame() diff --git a/content/Fry3DX/scripts/vase.lua b/content/Fry3DX/scripts/vase.lua new file mode 100644 index 0000000..dd856ea --- /dev/null +++ b/content/Fry3DX/scripts/vase.lua @@ -0,0 +1,15 @@ +-- A vase turned on a lathe: the profile is {out, up} points from the bottom. +local profile = {} +local height, n = 0.4, 24 +for i = 0, n do + local t = i / n + -- a belly low down, a neck, a lip + local r = 0.06 + 0.07 * math.sin(t * math.pi * 1.1) + 0.02 * t * t + profile[#profile + 1] = {r, t * height} +end +table.insert(profile, 1, {0, 0}) +local vase = fry.lathe(profile, 64, "Vase") +vase.color = "#3c6e9f" +vase.rough = 0.25 +vase:thicken(0.004) -- walls, so it prints hollow +fry.frame() diff --git a/content/Fry3DX/scripts/waves.lua b/content/Fry3DX/scripts/waves.lua new file mode 100644 index 0000000..8b534b5 --- /dev/null +++ b/content/Fry3DX/scripts/waves.lua @@ -0,0 +1,12 @@ +-- Points moved by a formula: a plane made into waves, then given a thickness. +local p = fry.add("plane", {width = 2, depth = 2, cuts = 40, name = "Waves"}) +local pts = p:points() +for i, v in ipairs(pts) do + v.z = 0.08 * math.sin(v.x * 6) * math.cos(v.y * 5) +end +p:setpoints(pts) +p:thicken(0.03) +p.smooth = true +p.color = "#2f8f9d" +p:move(0, 0, 0.12) +fry.frame() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c0708d3..697af9d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -4,6 +4,9 @@ add_library(fry_core STATIC mesh.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker. target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(fry_core PUBLIC glm::glm libzstd_static) target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL) +# Scripts (Lua) that build and change the scene: also without a window, for the tests. +add_library(fry_script STATIC script.cpp) +target_link_libraries(fry_script PUBLIC fry_core PRIVATE lua) if(ANDROID) # SDL's Java side loads the app as libmain.so and calls its (SDL_)main add_library(fry3dx SHARED app.cpp icons.cpp) @@ -16,9 +19,9 @@ if(WIN32) # a window program (no console window), with its icon set_target_properties(fry3dx PROPERTIES WIN32_EXECUTABLE ON) target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup) endif() -target_link_libraries(fry3dx PRIVATE fry_core keel stb) +target_link_libraries(fry3dx PRIVATE fry_core fry_script keel stb) add_dependencies(fry3dx content) -foreach(t fry_core fry3dx) +foreach(t fry_core fry_script fry3dx) if(MSVC) target_compile_options(${t} PRIVATE /W3) else() diff --git a/src/app.cpp b/src/app.cpp index 4ccf238..3ccf623 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -6,6 +6,7 @@ #include "icons.h" #include "mesh.h" #include "scene.h" +#include "script.h" #include "shapes.h" #include @@ -123,6 +124,20 @@ struct MaterialInfo { const char* name; float density; // g/cm3 }; +// What the Script panel starts with. +const char* kFirstScript = R"(-- A script builds or changes the scene. F5 or Ctrl+Enter runs it; +-- Ctrl+Z takes back everything it did. The ? button lists what there is. + +local legs = 6 +for i = 1, legs do + local a = math.rad(i * 360 / legs) + local leg = fry.add("cylinder", {radius = 0.03, height = 0.7, color = "#c47a3c"}) + leg.position = {math.cos(a) * 0.45, math.sin(a) * 0.45, 0} +end +local top = fry.add("cylinder", {radius = 0.6, height = 0.05, sides = 48, color = "#d9a066"}) +top.position = {0, 0, 0.7} +fry.frame() +)"; const MaterialInfo kMaterials[] = {{"PLA", 1.24f}, {"PETG", 1.27f}, {"ABS", 1.04f}, {"ASA", 1.07f}, {"TPU", 1.21f}, {"Nylon", 1.14f}, {"Resin", 1.12f}}; const PaintToolInfo kPaintTools[] = { @@ -257,6 +272,14 @@ public: static keel::Cmd open("fry_open", [](const keel::Args& a) { if (a.size() > 1) self->openFile(a[1]); }, "Fry3DX: open a .fry file"); static keel::Cmd save("fry_save", [](const keel::Args& a) { if (a.size() > 1) self->saveFile(a[1]); }, "Fry3DX: save as"); static keel::Cmd exp("fry_export", [](const keel::Args& a) { if (a.size() > 1) self->exportFile(a[1]); }, "Fry3DX: export glTF"); + static keel::Cmd run("fry_run", [](const keel::Args& a) { + if (a.size() < 2) return; + std::ifstream in(a[1], std::ios::binary); + if (!in) return keel::log::warn("Couldn't open {}", a[1]); + self->scriptCode_.assign(std::istreambuf_iterator(in), {}); + self->scriptPath_ = a[1], self->showScript_ = true; + self->runScriptNow(); + }, "Fry3DX: run a Lua script file (fry_run path/to/script.lua)"); static keel::Cmd add("fry_add", [](const keel::Args& a) { if (a.size() > 1) self->addShape(a[1]); }, "Fry3DX: add a shape (cube, rounded_cube, table...)"); static keel::Cmd view("fry_view", [](const keel::Args& a) { static const char* names[] = {"persp", "front", "back", "left", "right", "top", "bottom"}; @@ -541,6 +564,7 @@ public: if (!touch()) statusBar(); if (!capturing() && (!touch() || viewsOpen_)) viewButtons(); if (!capturing() && touch()) touchBar(); + if (showScript_) scriptPanel(); if (showHelp_) helpWindow(); if (showAbout_) aboutWindow(); copiesWindow(); @@ -765,6 +789,11 @@ private: // ── UI ── int appliedTheme_ = -1; + // The Script panel: the script being written, where it's from, what it printed. + bool showScript_ = false, showScriptHelp_ = false; + std::string scriptCode_ = kFirstScript, scriptPath_; + std::vector> scriptLog_; // (line, an error) + bool scriptLogToBottom_ = false; bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false; std::string iniPath_, message_; double messageAt_ = -100.0; @@ -1264,6 +1293,11 @@ private: SDL_ShowSaveFileDialog(done, this, w, kind == 8 ? mf : stl, 1, at); } else if (kind == 5 || kind == 7) SDL_ShowSaveFileDialog(done, this, w, png, 1, at); + else if (kind == 10 || kind == 11) { + static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}}; + if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); + else SDL_ShowSaveFileDialog(done, this, w, lua, 1, scriptPath_.empty() || isUri(scriptPath_) ? at : scriptPath_.c_str()); + } else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); } void takeDialogResult() { @@ -1285,6 +1319,8 @@ private: return; } if (isUri(file) && (kind == 8 || kind == 9)) return exportFile(file, -1, kind == 8 ? ".3mf" : ".stl"); + if (kind == 10) return openScript(isUri(file) ? fromUri(file, "opened.lua") : file, fileName(file)); + if (kind == 11) return saveScript(file); if (kind == 1) openFile(file); else if (kind == 2) saveFile(file); else if (kind == 3) exportFile(file, filter); @@ -4300,6 +4336,7 @@ private: if (io.WantTextInput) return; const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt; auto key = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); }; + if (key(ImGuiKey_F5)) return runScriptNow(); if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model); if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint); if (ctrl && key(ImGuiKey_3)) setTab(Tab::Layout); @@ -4925,6 +4962,7 @@ private: const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.04f, nullptr, ¢er); const ImGuiID picture = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.45f, nullptr, ¢er); // (the Paint tab's) ImGui::DockBuilderDockWindow("Picture", picture); + ImGui::DockBuilderDockWindow("Script", picture); ImGui::DockBuilderDockWindow("Toolbox", left); ImGui::DockBuilderDockWindow("Tool options", leftBottom); ImGui::DockBuilderDockWindow("Layers", right); @@ -5050,6 +5088,17 @@ private: if (ImGui::MenuItem("Frame everything", "Home")) frameAll(); ImGui::EndMenu(); } + if (ImGui::BeginMenu("Script")) { + ImGui::MenuItem("The Script panel", nullptr, &showScript_); + if (ImGui::MenuItem("Run it", "F5")) runScriptNow(); + ImGui::Separator(); + if (ImGui::MenuItem("Open a script...")) showScript_ = true, dialog(10); + if (ImGui::MenuItem("Save the script as...")) dialog(11); + exampleScripts(); + ImGui::Separator(); + if (ImGui::MenuItem("What scripts can do")) showScript_ = showScriptHelp_ = true; + ImGui::EndMenu(); + } if (ImGui::BeginMenu("Help")) { if (ImGui::MenuItem("Keys and mouse", "F1")) showHelp_ = true; if (ImGui::MenuItem("About Fry3DX")) showAbout_ = true; @@ -6048,6 +6097,132 @@ private: ImGui::End(); } + // ───────────────────────────── scripts ───────────────────────────── + + void scriptLine(std::string text, bool error = false) { + size_t at = 0; + for (size_t nl; (nl = text.find('\n', at)) != std::string::npos; at = nl + 1) scriptLog_.push_back({text.substr(at, nl - at), error}); + scriptLog_.push_back({text.substr(at), error}); + if (scriptLog_.size() > 2000) scriptLog_.erase(scriptLog_.begin(), scriptLog_.begin() + 500); + scriptLogToBottom_ = true; + } + // Runs the Script panel's script: one undo step; if it stops with an error, everything it did + // is taken back. + void runScriptNow() { + if (mode_ == Mode::Edit) leaveEdit(); + const std::string name = scriptPath_.empty() ? "script" : fileName(scriptPath_); + pushUndo(); + bool frame = false; + fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }}; + const fry::ScriptResult r = fry::runScript(host, scriptCode_, name); + touchAll(); + if (r.ok && frame) frameAll(); + if (!r.ok) { + undo(); + redo_.clear(); + scriptLine(r.error, true); + showScript_ = true; + return say("The script stopped (nothing it did was kept): " + r.error.substr(0, r.error.find('\n'))); + } + dirty_ = true; + say(r.objectsMade ? std::format("{} ran: {} object{} made (Ctrl+Z takes it all back)", name, r.objectsMade, r.objectsMade == 1 ? "" : "s") + : std::format("{} ran (Ctrl+Z takes it back)", name)); + } + void openScript(const std::string& path, const std::string& shownName) { + std::ifstream in(path, std::ios::binary); + if (!in) return say("Couldn't open " + shownName); + scriptCode_.assign(std::istreambuf_iterator(in), {}); + scriptPath_ = shownName; + showScript_ = true; + say("Opened " + shownName + ": F5 runs it"); + } + void saveScript(std::string file) { + if (isUri(file)) { + const std::string temp = tempFile("script.lua"); + std::string error; + const bool ok = writeFileSafely(temp, scriptCode_.data(), scriptCode_.size(), &error) && toUri(temp, file); + return say(ok ? "Saved " + fileName(file) : "Couldn't write the file you picked"); + } + if (stdfs::path(file).extension() != ".lua") file += ".lua"; + std::string error; + if (!writeFileSafely(file, scriptCode_.data(), scriptCode_.size(), &error)) return say("Couldn't save the script: " + error); + scriptPath_ = file; + say("Saved " + fileName(file)); + } + // The scripts that come with it (content/Fry3DX/scripts), as menu items that load one. + void exampleScripts() { + if (!ImGui::BeginMenu("Examples")) return; + for (const std::string& f : keel::fs::list("scripts", ".lua")) { + std::string name = stdfs::path(f).stem().string(); + std::replace(name.begin(), name.end(), '_', ' '); + if (ImGui::MenuItem(name.c_str())) + if (auto text = keel::fs::readText(f)) scriptCode_ = *text, scriptPath_ = stdfs::path(f).filename().string(), showScript_ = true; + } + ImGui::EndMenu(); + } + static int growString(ImGuiInputTextCallbackData* d) { + if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) { + auto* str = static_cast(d->UserData); + str->resize(size_t(d->BufTextLen)); + d->Buf = str->data(); + } + return 0; + } + void scriptPanel() { + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + vp->WorkSize.y * 0.5f), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(std::min(620.0f, vp->WorkSize.x * 0.45f), vp->WorkSize.y * 0.7f), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Script", &showScript_)) return ImGui::End(); + pushAccent(); + if (ImGui::Button("Run (F5)")) runScriptNow(); + popAccent(); + ImGui::SameLine(); + if (ImGui::Button("Open...")) dialog(10); + ImGui::SameLine(); + if (ImGui::Button("Save as...")) dialog(11); + ImGui::SameLine(); + if (ImGui::Button("Examples")) ImGui::OpenPopup("examples"); + if (ImGui::BeginPopup("examples")) { + for (const std::string& f : keel::fs::list("scripts", ".lua")) { + std::string name = stdfs::path(f).stem().string(); + std::replace(name.begin(), name.end(), '_', ' '); + if (ImGui::Selectable(name.c_str())) + if (auto text = keel::fs::readText(f)) scriptCode_ = *text, scriptPath_ = stdfs::path(f).filename().string(); + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + if (ImGui::Button(showScriptHelp_ ? "Hide help" : "?")) showScriptHelp_ = !showScriptHelp_; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Everything a script can use"); + ImGui::SameLine(); + ImGui::TextDisabled("%s", scriptPath_.empty() ? "(not saved)" : fileName(scriptPath_).c_str()); + + const float logH = std::max(90.0f, ImGui::GetContentRegionAvail().y * 0.22f); + ImGui::PushFont(keel::ui::font(keel::ui::Font::Mono), 0.0f); + if (showScriptHelp_) { + ImGui::BeginChild("help", ImVec2(0, ImGui::GetContentRegionAvail().y * 0.45f), ImGuiChildFlags_FrameStyle); + ImGui::TextUnformatted(fry::scriptHelp()); + ImGui::EndChild(); + } + const ImVec2 editor(-FLT_MIN, std::max(120.0f, ImGui::GetContentRegionAvail().y - logH - ImGui::GetStyle().ItemSpacing.y)); + ImGui::InputTextMultiline("##code", scriptCode_.data(), scriptCode_.capacity() + 1, editor, + ImGuiInputTextFlags_AllowTabInput | ImGuiInputTextFlags_CallbackResize, growString, &scriptCode_); + const bool typing = ImGui::IsItemActive(); + ImGui::BeginChild("log", ImVec2(0, 0), ImGuiChildFlags_FrameStyle); + if (scriptLog_.empty()) ImGui::TextDisabled("What it prints shows here."); + for (const auto& [line, error] : scriptLog_) { + if (error) ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText)); + ImGui::TextUnformatted(line.c_str()); + if (error) ImGui::PopStyleColor(); + } + if (std::exchange(scriptLogToBottom_, false)) ImGui::SetScrollHereY(1.0f); + ImGui::EndChild(); + ImGui::PopFont(); + if (typing && ImGui::GetIO().KeyCtrl && (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) runScriptNow(); + if (typing && ImGui::IsKeyPressed(ImGuiKey_F5, false)) runScriptNow(); + ImGui::End(); + } + void helpWindow() { ImGui::SetNextWindowSize(ImVec2(580, 620), ImGuiCond_FirstUseEver); if (!ImGui::Begin("Keys and mouse", &showHelp_)) return ImGui::End(); diff --git a/src/script.cpp b/src/script.cpp new file mode 100644 index 0000000..13edc43 --- /dev/null +++ b/src/script.cpp @@ -0,0 +1,806 @@ +#include "script.h" +#include "shapes.h" + +// (Lua is built as C++ here — cmake/Dependencies.cmake — so its errors are exceptions and unwind +// through this file's strings and vectors properly.) +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace fry { +namespace { + +const char* kObject = "fry.object"; +const char* kVec = "fry.vec"; + +struct Context { + ScriptHost* host; + std::chrono::steady_clock::time_point start; + int made = 0; +}; + +Context& context(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, "fry.context"); + auto* c = static_cast(lua_touserdata(L, -1)); + lua_pop(L, 1); + return *c; +} +Scene& scene(lua_State* L) { return *context(L).host->scene; } + +Object* findObject(Scene& s, uint32_t id) { + for (Object& o : s.objects) + if (o.id == id) return &o; + return nullptr; +} + +std::string lower(std::string s) { + for (char& c : s) c = char(std::tolower(static_cast(c))); + return s; +} + +// ── Vectors: {x, y, z} tables that print and add up ── + +void pushVec(lua_State* L, glm::vec3 v) { + lua_createtable(L, 0, 3); + lua_pushnumber(L, v.x), lua_setfield(L, -2, "x"); + lua_pushnumber(L, v.y), lua_setfield(L, -2, "y"); + lua_pushnumber(L, v.z), lua_setfield(L, -2, "z"); + luaL_setmetatable(L, kVec); +} +// A vector from a table ({x=, y=, z=} or {1, 2, 3}) or one number (the same all ways). +glm::vec3 toVec(lua_State* L, int i) { + if (lua_isnumber(L, i)) return glm::vec3(float(lua_tonumber(L, i))); + luaL_checktype(L, i, LUA_TTABLE); + glm::vec3 v(0.0f); + const char* names[] = {"x", "y", "z"}; + for (int k = 0; k < 3; ++k) { + if (lua_getfield(L, i, names[k]) == LUA_TNIL) lua_pop(L, 1), lua_rawgeti(L, i, k + 1); + v[k] = float(lua_tonumber(L, -1)); + lua_pop(L, 1); + } + return v; +} +// Arguments from i: three numbers, or one vector. +glm::vec3 argVec(lua_State* L, int i) { + if (lua_isnumber(L, i) && lua_isnumber(L, i + 1)) return {float(lua_tonumber(L, i)), float(luaL_checknumber(L, i + 1)), float(luaL_optnumber(L, i + 2, 0))}; + return toVec(L, i); +} +int vecOp(lua_State* L, char op) { + const bool scalarB = lua_isnumber(L, 2), scalarA = lua_isnumber(L, 1); + const glm::vec3 a = toVec(L, 1), b = toVec(L, 2); + if (op == '+') pushVec(L, a + b); + else if (op == '-') pushVec(L, a - b); + else if (op == '*') pushVec(L, a * b); + else pushVec(L, scalarA && !scalarB ? b / a : a / b); + return 1; +} +int vecAdd(lua_State* L) { return vecOp(L, '+'); } +int vecSub(lua_State* L) { return vecOp(L, '-'); } +int vecMul(lua_State* L) { return vecOp(L, '*'); } +int vecDiv(lua_State* L) { return vecOp(L, '/'); } +int vecUnm(lua_State* L) { return pushVec(L, -toVec(L, 1)), 1; } +int vecLen(lua_State* L) { return lua_pushnumber(L, glm::length(toVec(L, 1))), 1; } +int vecString(lua_State* L) { + const glm::vec3 v = toVec(L, 1); + lua_pushstring(L, std::format("({:.4g}, {:.4g}, {:.4g})", v.x, v.y, v.z).c_str()); + return 1; +} + +// A colour: {r, g, b} (0..1), "#rrggbb", or a vector. +glm::vec3 toColor(lua_State* L, int i) { + if (lua_type(L, i) == LUA_TSTRING) { + std::string s = lua_tostring(L, i); + if (!s.empty() && s[0] == '#') s.erase(0, 1); + if (s.size() == 3) s = {s[0], s[0], s[1], s[1], s[2], s[2]}; + unsigned v = 0; + if (s.size() != 6 || std::sscanf(s.c_str(), "%x", &v) != 1) luaL_error(L, "a colour is \"#rrggbb\" or {r, g, b} (0 to 1)"); + return glm::vec3(float((v >> 16) & 255), float((v >> 8) & 255), float(v & 255)) / 255.0f; + } + if (lua_istable(L, i)) { + glm::vec3 c(0.0f); + const char* names[] = {"r", "g", "b"}; + for (int k = 0; k < 3; ++k) { + if (lua_getfield(L, i, names[k]) == LUA_TNIL) { + lua_pop(L, 1); + if (lua_getfield(L, i, k == 0 ? "x" : k == 1 ? "y" : "z") == LUA_TNIL) lua_pop(L, 1), lua_rawgeti(L, i, k + 1); + } + c[k] = float(lua_tonumber(L, -1)); + lua_pop(L, 1); + } + return glm::clamp(c, 0.0f, 1.0f); + } + luaL_error(L, "a colour is \"#rrggbb\" or {r, g, b} (0 to 1)"); + return {}; +} + +// ── Objects: handles by id ── + +void pushObject(lua_State* L, uint32_t id) { + *static_cast(lua_newuserdatauv(L, sizeof(uint32_t), 0)) = id; + luaL_setmetatable(L, kObject); +} +uint32_t objectId(lua_State* L, int i) { return *static_cast(luaL_checkudata(L, i, kObject)); } +Object& object(lua_State* L, int i) { + Object* o = findObject(scene(L), objectId(L, i)); + if (!o) luaL_error(L, "that object isn't there any more"); + return *o; +} + +std::string uniqueName(Scene& s, const std::string& base) { + auto taken = [&](const std::string& n) { return std::any_of(s.objects.begin(), s.objects.end(), [&](const Object& o) { return o.name == n; }); }; + if (!taken(base)) return base; + for (int i = 2;; ++i) + if (!taken(std::format("{} {}", base, i))) return std::format("{} {}", base, i); +} + +Object& newObject(lua_State* L, const std::string& name, Mesh mesh) { + Scene& s = scene(L); + Object o; + o.id = s.nextId++; + o.name = uniqueName(s, name); + o.mesh = std::move(mesh); + s.objects.push_back(std::move(o)); + ++context(L).made; + return s.objects.back(); +} + +void handMade(Object& o) { o.recipe.clear(), o.settings.clear(); } + +std::string mirrorText(int m) { + std::string s; + for (int a = 0; a < 3; ++a) + if (m & (1 << a)) s += char('x' + a); + return s; +} + +// Reading a property: false if there's none by that name. +bool getProperty(lua_State* L, Object& o, const std::string& k) { + if (k == "id") lua_pushinteger(L, o.id); + else if (k == "name") lua_pushstring(L, o.name.c_str()); + else if (k == "position") pushVec(L, o.position); + else if (k == "turn") pushVec(L, toEuler(o.rotation)); + else if (k == "size") pushVec(L, o.scale); + else if (k == "color" || k == "colour") { + lua_createtable(L, 3, 0); + for (int i = 0; i < 3; ++i) lua_pushnumber(L, o.color[i]), lua_rawseti(L, -2, i + 1); + } else if (k == "metal") lua_pushnumber(L, o.metal); + else if (k == "rough") lua_pushnumber(L, o.rough); + else if (k == "smooth") lua_pushboolean(L, o.smooth); + else if (k == "visible") lua_pushboolean(L, o.visible); + else if (k == "locked") lua_pushboolean(L, o.locked); + else if (k == "mirror") lua_pushstring(L, mirrorText(o.mirror).c_str()); + else if (k == "round") lua_pushnumber(L, o.round); + else if (k == "round_steps") lua_pushinteger(L, o.roundSteps); + else if (k == "smooth_levels") lua_pushinteger(L, o.smoothLevels); + else if (k == "shape") lua_pushstring(L, o.recipe.c_str()); + else if (k == "point_count") lua_pushinteger(L, lua_Integer(o.mesh.verts.size())); + else if (k == "face_count") lua_pushinteger(L, lua_Integer(o.mesh.faces.size())); + else return false; + return true; +} + +// Changing one: false if there's none by that name. +bool setProperty(lua_State* L, Object& o, const std::string& k, int v) { + if (k == "name") o.name = luaL_checkstring(L, v); + else if (k == "position") o.position = toVec(L, v); + else if (k == "turn") o.rotation = fromEuler(toVec(L, v)); + else if (k == "size") o.scale = toVec(L, v); + else if (k == "color" || k == "colour") o.color = toColor(L, v); + else if (k == "metal") o.metal = std::clamp(float(luaL_checknumber(L, v)), 0.0f, 1.0f); + else if (k == "rough") o.rough = std::clamp(float(luaL_checknumber(L, v)), 0.0f, 1.0f); + else if (k == "smooth") o.smooth = lua_toboolean(L, v); + else if (k == "visible") o.visible = lua_toboolean(L, v); + else if (k == "locked") o.locked = lua_toboolean(L, v); + else if (k == "mirror") { + const std::string s = lower(luaL_checkstring(L, v)); + o.mirror = (s.find('x') != std::string::npos ? 1 : 0) | (s.find('y') != std::string::npos ? 2 : 0) | (s.find('z') != std::string::npos ? 4 : 0); + } else if (k == "round") o.round = std::max(0.0f, float(luaL_checknumber(L, v))); + else if (k == "round_steps") o.roundSteps = std::clamp(int(luaL_checkinteger(L, v)), 1, 12); + else if (k == "smooth_levels") o.smoothLevels = std::clamp(int(luaL_checkinteger(L, v)), 0, 4); + else return false; + return true; +} + +// A library shape's setting by name (any case): its index, or -1. +int settingIndex(const ShapeRecipe& r, const std::string& name) { + for (size_t i = 0; i < r.settings.size(); ++i) + if (lower(r.settings[i].name) == lower(name)) return int(i); + return -1; +} +void remake(Object& o) { + if (const ShapeRecipe* r = findRecipe(o.recipe)) o.mesh = makeFromRecipe(*r, o.settings); +} + +// The objects in arguments from `first` on: objects, or lists of them. +std::vector objectArgs(lua_State* L, int first) { + std::vector ids; + for (int i = first; i <= lua_gettop(L); ++i) { + if (lua_istable(L, i) && !luaL_testudata(L, i, kObject)) { + const lua_Integer n = luaL_len(L, i); + for (lua_Integer k = 1; k <= n; ++k) { + lua_rawgeti(L, i, k); + ids.push_back(objectId(L, -1)); + lua_pop(L, 1); + } + } else ids.push_back(objectId(L, i)); + } + return ids; +} + +// ── Object methods ── + +int objMove(lua_State* L) { + object(L, 1).position += argVec(L, 2); + lua_settop(L, 1); + return 1; +} +int objRotate(lua_State* L) { + Object& o = object(L, 1); + o.rotation = glm::normalize(fromEuler(argVec(L, 2)) * o.rotation); + lua_settop(L, 1); + return 1; +} +int objScale(lua_State* L) { + object(L, 1).scale *= lua_gettop(L) == 2 && lua_isnumber(L, 2) ? glm::vec3(float(lua_tonumber(L, 2))) : argVec(L, 2); + lua_settop(L, 1); + return 1; +} +int objDelete(lua_State* L) { + const uint32_t id = objectId(L, 1); + Context& c = context(L); + std::erase_if(c.host->scene->objects, [&](const Object& o) { return o.id == id; }); + if (c.host->selected) c.host->selected->erase(id); + if (c.host->active && *c.host->active == id) *c.host->active = 0; + return 0; +} +int objCopy(lua_State* L) { + Object copy = object(L, 1); + Scene& s = scene(L); + copy.id = s.nextId++; + copy.name = uniqueName(s, copy.name); + s.objects.push_back(std::move(copy)); + ++context(L).made; + pushObject(L, s.objects.back().id); + return 1; +} +int objCombine(lua_State* L, Combine op) { + const uint32_t keep = objectId(L, 1); + const std::vector others = objectArgs(L, 2); + Scene& s = scene(L); + Object& a = object(L, 1); + Mesh result = a.hasFinish() ? finishedMesh(a) : a.mesh; + const glm::mat4 into = glm::inverse(objectMatrix(a)); + for (uint32_t id : others) { + const Object* b = findObject(s, id); + if (!b || id == keep) continue; + Mesh m = b->hasFinish() ? finishedMesh(*b) : b->mesh; + transform(m, into * objectMatrix(*b)); + if (!combine(result, m, op)) return luaL_error(L, op == Combine::Overlap ? "they don't overlap: nothing would be left" : "that wouldn't leave anything"); + } + Object& kept = object(L, 1); + kept.mesh = std::move(result); + kept.mirror = 0, kept.round = 0, kept.smoothLevels = 0; + handMade(kept); + std::erase_if(s.objects, [&](const Object& o) { return o.id != keep && std::find(others.begin(), others.end(), o.id) != others.end(); }); + lua_settop(L, 1); + return 1; +} +int objAdd(lua_State* L) { return objCombine(L, Combine::Add); } +int objSubtract(lua_State* L) { return objCombine(L, Combine::Subtract); } +int objOverlap(lua_State* L) { return objCombine(L, Combine::Overlap); } +int objJoin(lua_State* L) { + const uint32_t keep = objectId(L, 1); + const std::vector others = objectArgs(L, 2); + Scene& s = scene(L); + const glm::mat4 into = glm::inverse(objectMatrix(object(L, 1))); + Mesh joined = object(L, 1).mesh; + for (uint32_t id : others) + if (const Object* b = findObject(s, id); b && id != keep) append(joined, b->mesh, into * objectMatrix(*b)); + Object& a = object(L, 1); + a.mesh = std::move(joined); + handMade(a); + std::erase_if(s.objects, [&](const Object& o) { return o.id != keep && std::find(others.begin(), others.end(), o.id) != others.end(); }); + lua_settop(L, 1); + return 1; +} +int objThicken(lua_State* L) { + Object& o = object(L, 1); + thicken(o.mesh, float(luaL_checknumber(L, 2))); + handMade(o); + lua_settop(L, 1); + return 1; +} +int objSubdivide(lua_State* L) { + Object& o = object(L, 1); + const bool smooth = lua_isnone(L, 2) || lua_toboolean(L, 2); + const int times = std::clamp(int(luaL_optinteger(L, 3, 1)), 1, 4); + for (int i = 0; i < times; ++i) subdivide(o.mesh, smooth); + if (o.mesh.faces.size() > 2000000) return luaL_error(L, "that's too many faces"); + handMade(o); + lua_settop(L, 1); + return 1; +} +int objBevel(lua_State* L) { + Object& o = object(L, 1); + const float width = float(luaL_checknumber(L, 2)); + const int steps = std::clamp(int(luaL_optinteger(L, 3, 3)), 1, 12); + const float angle = float(luaL_optnumber(L, 4, 30.0)); + bevelEdges(o.mesh, sharpEdges(o.mesh, angle), width, steps); + handMade(o); + lua_settop(L, 1); + return 1; +} +int objBake(lua_State* L) { + Object& o = object(L, 1); + o.mesh = finishedMesh(o); + o.mirror = 0, o.round = 0, o.smoothLevels = 0; + handMade(o); + lua_settop(L, 1); + return 1; +} +int objPoints(lua_State* L) { + const Object& o = object(L, 1); + lua_createtable(L, int(o.mesh.verts.size()), 0); + for (size_t i = 0; i < o.mesh.verts.size(); ++i) pushVec(L, o.mesh.verts[i]), lua_rawseti(L, -2, lua_Integer(i + 1)); + return 1; +} +int objFaces(lua_State* L) { + const Object& o = object(L, 1); + lua_createtable(L, int(o.mesh.faces.size()), 0); + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + lua_createtable(L, int(o.mesh.faces[f].size()), 0); + for (size_t c = 0; c < o.mesh.faces[f].size(); ++c) lua_pushinteger(L, o.mesh.faces[f][c] + 1), lua_rawseti(L, -2, lua_Integer(c + 1)); + lua_rawseti(L, -2, lua_Integer(f + 1)); + } + return 1; +} +std::vector pointList(lua_State* L, int i) { + luaL_checktype(L, i, LUA_TTABLE); + std::vector pts; + const lua_Integer n = luaL_len(L, i); + for (lua_Integer k = 1; k <= n; ++k) { + lua_rawgeti(L, i, k); + pts.push_back(toVec(L, lua_gettop(L))); + lua_pop(L, 1); + } + return pts; +} +std::vector> faceList(lua_State* L, int i, size_t points) { + luaL_checktype(L, i, LUA_TTABLE); + std::vector> faces; + const lua_Integer n = luaL_len(L, i); + for (lua_Integer k = 1; k <= n; ++k) { + lua_rawgeti(L, i, k); + luaL_checktype(L, -1, LUA_TTABLE); + std::vector f; + const lua_Integer m = luaL_len(L, -1); + for (lua_Integer c = 1; c <= m; ++c) { + lua_rawgeti(L, -1, c); + const lua_Integer v = lua_tointeger(L, -1); + lua_pop(L, 1); + if (v < 1 || size_t(v) > points) luaL_error(L, "face %d: point %d isn't one of the %d points", int(k), int(v), int(points)); + f.push_back(int(v - 1)); + } + if (f.size() < 3) luaL_error(L, "face %d has fewer than 3 points", int(k)); + faces.push_back(std::move(f)); + lua_pop(L, 1); + } + return faces; +} +int objSetPoints(lua_State* L) { + std::vector pts = pointList(L, 2); + Object& o = object(L, 1); + if (pts.size() != o.mesh.verts.size()) return luaL_error(L, "it has %d points, not %d (setshape changes how many)", int(o.mesh.verts.size()), int(pts.size())); + o.mesh.verts = std::move(pts); + handMade(o); + lua_settop(L, 1); + return 1; +} +int objSetShape(lua_State* L) { + std::vector pts = pointList(L, 2); + auto faces = faceList(L, 3, pts.size()); + Object& o = object(L, 1); + o.mesh = {std::move(pts), std::move(faces)}; + o.uvs.clear(), o.picture.reset(), o.layers.clear(); + handMade(o); + lua_settop(L, 1); + return 1; +} +int objSet(lua_State* L) { // o:set("Width", 2) or o:set{width = 2, height = 1} + Object& o = object(L, 1); + const ShapeRecipe* r = findRecipe(o.recipe); + if (!r) return luaL_error(L, "%s isn't a library shape any more (its shape was changed by hand)", o.name.c_str()); + auto one = [&](const std::string& name, float value) { + const int i = settingIndex(*r, name); + if (i < 0) luaL_error(L, "a %s has no setting called %s", r->name, name.c_str()); + o.settings.resize(r->settings.size()); + const ShapeSetting& st = r->settings[size_t(i)]; + o.settings[size_t(i)] = std::clamp(st.whole ? std::round(value) : value, st.min, st.max); + }; + if (lua_istable(L, 2)) { + lua_pushnil(L); + while (lua_next(L, 2)) one(luaL_checkstring(L, -2), float(luaL_checknumber(L, -1))), lua_pop(L, 1); + } else one(luaL_checkstring(L, 2), float(luaL_checknumber(L, 3))); + remake(o); + lua_settop(L, 1); + return 1; +} +int objSettings(lua_State* L) { + const Object& o = object(L, 1); + lua_newtable(L); + if (const ShapeRecipe* r = findRecipe(o.recipe)) { + const std::vector s = o.settings.size() == r->settings.size() ? o.settings : defaultSettings(*r); + for (size_t i = 0; i < r->settings.size(); ++i) lua_pushnumber(L, s[i]), lua_setfield(L, -2, lower(r->settings[i].name).c_str()); + } + return 1; +} +int objBounds(lua_State* L) { + const Object& o = object(L, 1); + glm::vec3 lo(1e30f), hi(-1e30f); + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : o.mesh.verts) { + const glm::vec3 w = glm::vec3(m * glm::vec4(v, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + if (o.mesh.verts.empty()) lo = hi = o.position; + pushVec(L, lo), pushVec(L, hi); + return 2; +} +int objSelect(lua_State* L) { + const uint32_t id = objectId(L, 1); + Context& c = context(L); + if (c.host->selected) c.host->selected->insert(id); + if (c.host->active) *c.host->active = id; + lua_settop(L, 1); + return 1; +} +int objDrop(lua_State* L) { // stands it on the ground (z = 0) + lua_settop(L, 1); + lua_getfield(L, 1, "bounds"); + lua_pushvalue(L, 1); + lua_call(L, 1, 1); + const float bottom = toVec(L, 2).z; + object(L, 1).position.z -= bottom; + lua_settop(L, 1); + return 1; +} + +const luaL_Reg kMethods[] = { + {"move", objMove}, {"rotate", objRotate}, {"scale", objScale}, {"delete", objDelete}, {"copy", objCopy}, + {"add", objAdd}, {"subtract", objSubtract}, {"overlap", objOverlap}, {"join", objJoin}, {"thicken", objThicken}, + {"subdivide", objSubdivide}, {"bevel", objBevel}, {"bake", objBake}, {"points", objPoints}, {"faces", objFaces}, + {"setpoints", objSetPoints}, {"setshape", objSetShape}, {"set", objSet}, {"settings", objSettings}, {"bounds", objBounds}, + {"select", objSelect}, {"drop", objDrop}, {nullptr, nullptr}, +}; + +int objIndex(lua_State* L) { + const std::string k = luaL_checkstring(L, 2); + for (const luaL_Reg* m = kMethods; m->name; ++m) + if (k == m->name) return lua_pushcfunction(L, m->func), 1; + Object& o = object(L, 1); + if (getProperty(L, o, k)) return 1; + if (const ShapeRecipe* r = findRecipe(o.recipe); r && settingIndex(*r, k) >= 0) { // o.width, o.sides... for library shapes + const std::vector s = o.settings.size() == r->settings.size() ? o.settings : defaultSettings(*r); + return lua_pushnumber(L, s[size_t(settingIndex(*r, k))]), 1; + } + return luaL_error(L, "objects have no %s (see the Script panel's help)", k.c_str()); +} +int objNewIndex(lua_State* L) { + const std::string k = luaL_checkstring(L, 2); + Object& o = object(L, 1); + if (setProperty(L, o, k, 3)) return 0; + if (const ShapeRecipe* r = findRecipe(o.recipe); r && settingIndex(*r, k) >= 0) { + lua_settop(L, 3); + lua_pushcfunction(L, objSet); + lua_pushvalue(L, 1), lua_pushvalue(L, 2), lua_pushvalue(L, 3); + lua_call(L, 3, 0); + return 0; + } + return luaL_error(L, "objects have no %s to change (see the Script panel's help)", k.c_str()); +} +int objString(lua_State* L) { + const Object* o = findObject(scene(L), objectId(L, 1)); + lua_pushstring(L, o ? std::format("{} (#{})", o->name, o->id).c_str() : "(gone)"); + return 1; +} +int objEq(lua_State* L) { return lua_pushboolean(L, objectId(L, 1) == objectId(L, 2)), 1; } + +// Sets the properties (and library settings) in a table on the object at index o. +void applyOptions(lua_State* L, int obj, int opts) { + if (!lua_istable(L, opts)) return; + lua_pushnil(L); + while (lua_next(L, opts)) { + if (lua_type(L, -2) == LUA_TSTRING) { + lua_pushvalue(L, -2), lua_insert(L, -2); // key, key, value + lua_pushcfunction(L, objNewIndex); + lua_pushvalue(L, obj), lua_pushvalue(L, -4), lua_pushvalue(L, -4); + lua_call(L, 3, 0); + lua_pop(L, 2); + } else lua_pop(L, 1); + } +} + +// ── fry.* ── + +int fryAdd(lua_State* L) { + const std::string id = lower(luaL_checkstring(L, 1)); + const ShapeRecipe* r = findRecipe(id); + if (!r) return luaL_error(L, "there's no shape called %s (fry.shapes() lists them)", id.c_str()); + Object& o = newObject(L, r->name, makeFromRecipe(*r, defaultSettings(*r))); + o.recipe = r->id, o.settings = defaultSettings(*r), o.smooth = r->smooth; + pushObject(L, o.id); + applyOptions(L, lua_gettop(L), 2); + return 1; +} +int fryMesh(lua_State* L) { + std::vector pts = pointList(L, 1); + auto faces = faceList(L, 2, pts.size()); + Object& o = newObject(L, luaL_optstring(L, 3, "Mesh"), Mesh{std::move(pts), std::move(faces)}); + pushObject(L, o.id); + return 1; +} +int fryLathe(lua_State* L) { // a profile of {out, up} points turned round Z + luaL_checktype(L, 1, LUA_TTABLE); + std::vector profile; + const lua_Integer n = luaL_len(L, 1); + for (lua_Integer k = 1; k <= n; ++k) { + lua_rawgeti(L, 1, k); + const glm::vec3 v = toVec(L, lua_gettop(L)); + profile.push_back({std::max(v.x, 0.0f), v.y}); + lua_pop(L, 1); + } + if (profile.size() < 2) return luaL_error(L, "a lathe needs two or more {out, up} points"); + const int sides = std::clamp(int(luaL_optinteger(L, 2, 32)), 3, 512); + Object& o = newObject(L, luaL_optstring(L, 3, "Turned"), lathe(profile, sides)); + o.smooth = true; + pushObject(L, o.id); + return 1; +} +int fryExtrude(lua_State* L) { // an outline on the ground ({x, y} points) made `height` tall + std::vector pts = pointList(L, 1); + const float h = float(luaL_checknumber(L, 2)); + if (pts.size() < 3) return luaL_error(L, "an outline needs three or more points"); + for (glm::vec3& p : pts) p.z = 0; + Mesh m = extrudeOutline(pts, glm::vec3(0, 0, 1), h); + transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, h * 0.5f))); + Object& o = newObject(L, luaL_optstring(L, 3, "Outline"), std::move(m)); + pushObject(L, o.id); + return 1; +} +int fryObjects(lua_State* L) { + const Scene& s = scene(L); + lua_createtable(L, int(s.objects.size()), 0); + for (size_t i = 0; i < s.objects.size(); ++i) pushObject(L, s.objects[i].id), lua_rawseti(L, -2, lua_Integer(i + 1)); + return 1; +} +int fryFind(lua_State* L) { + const std::string name = luaL_checkstring(L, 1); + for (const Object& o : scene(L).objects) + if (o.name == name) return pushObject(L, o.id), 1; + return lua_pushnil(L), 1; +} +int frySelected(lua_State* L) { + Context& c = context(L); + lua_newtable(L); + if (!c.host->selected) return 1; + int i = 0; + for (const Object& o : c.host->scene->objects) // (in the scene's order, the last picked last) + if (c.host->selected->count(o.id) && (!c.host->active || o.id != *c.host->active)) pushObject(L, o.id), lua_rawseti(L, -2, ++i); + if (c.host->active && c.host->selected->count(*c.host->active) && findObject(*c.host->scene, *c.host->active)) + pushObject(L, *c.host->active), lua_rawseti(L, -2, ++i); + return 1; +} +int frySelect(lua_State* L) { + const std::vector ids = objectArgs(L, 1); + Context& c = context(L); + if (c.host->selected) c.host->selected->clear(), c.host->selected->insert(ids.begin(), ids.end()); + if (c.host->active) *c.host->active = ids.empty() ? 0 : ids.back(); + return 0; +} +int fryClear(lua_State* L) { + Context& c = context(L); + c.host->scene->objects.clear(); + c.host->scene->folders.clear(); + if (c.host->selected) c.host->selected->clear(); + if (c.host->active) *c.host->active = 0; + return 0; +} +int fryGroup(lua_State* L) { + const std::string name = luaL_checkstring(L, 1); + const std::vector ids = objectArgs(L, 2); + Scene& s = scene(L); + Folder f; + f.id = s.nextId++; + f.name = name; + for (Object& o : s.objects) + if (std::find(ids.begin(), ids.end(), o.id) != ids.end()) o.folder = f.id; + s.folders.push_back(f); + return 0; +} +int fryShapes(lua_State* L) { + lua_newtable(L); + int i = 0; + for (const ShapeRecipe& r : shapeRecipes()) { + lua_createtable(L, 0, 4); + lua_pushstring(L, r.id), lua_setfield(L, -2, "id"); + lua_pushstring(L, r.name), lua_setfield(L, -2, "name"); + lua_pushboolean(L, r.starter), lua_setfield(L, -2, "starter"); + lua_createtable(L, int(r.settings.size()), 0); + for (size_t k = 0; k < r.settings.size(); ++k) lua_pushstring(L, lower(r.settings[k].name).c_str()), lua_rawseti(L, -2, lua_Integer(k + 1)); + lua_setfield(L, -2, "settings"); + lua_rawseti(L, -2, ++i); + } + return 1; +} +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 { + const char* name; + float* v; + } nums[] = {{"sun_turn", &k.sunTurn}, {"sun_height", &k.sunHeight}, {"sun_strength", &k.sunStrength}, {"exposure", &k.exposure}, + {"bloom", &k.bloom}, {"saturation", &k.saturation}, {"vignette", &k.vignette}, {"shadow_soft", &k.shadowSoft}}; + struct Col { + const char* name; + glm::vec3* v; + } cols[] = {{"sun_color", &k.sunColor}, {"background", &k.background}, {"ground_color", &k.groundColor}}; + if (lua_istable(L, 1)) { + for (const Num& n : nums) + if (lua_getfield(L, 1, n.name) != LUA_TNIL) *n.v = float(luaL_checknumber(L, -1)), lua_pop(L, 1); + else lua_pop(L, 1); + for (const Col& c : cols) + if (lua_getfield(L, 1, c.name) != LUA_TNIL) *c.v = toColor(L, lua_gettop(L)), lua_pop(L, 1); + else lua_pop(L, 1); + if (lua_getfield(L, 1, "sky") != LUA_TNIL) k.sky = lua_toboolean(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, 1, "ground") != LUA_TNIL) k.ground = lua_toboolean(L, -1); + lua_pop(L, 1); + } + lua_newtable(L); + for (const Num& n : nums) lua_pushnumber(L, *n.v), lua_setfield(L, -2, n.name); + for (const Col& c : cols) { + lua_createtable(L, 3, 0); + for (int i = 0; i < 3; ++i) lua_pushnumber(L, (*c.v)[i]), lua_rawseti(L, -2, i + 1); + lua_setfield(L, -2, c.name); + } + lua_pushboolean(L, k.sky), lua_setfield(L, -2, "sky"); + lua_pushboolean(L, k.ground), lua_setfield(L, -2, "ground"); + return 1; +} +int fryVec(lua_State* L) { return pushVec(L, lua_istable(L, 1) ? toVec(L, 1) : glm::vec3(float(luaL_optnumber(L, 1, 0)), float(luaL_optnumber(L, 2, 0)), float(luaL_optnumber(L, 3, 0)))), 1; } +int fryPrint(lua_State* L) { + std::string line; + for (int i = 1; i <= lua_gettop(L); ++i) { + if (i > 1) line += "\t"; + line += luaL_tolstring(L, i, nullptr); + lua_pop(L, 1); + } + if (const auto& p = context(L).host->print) p(line); + return 0; +} +int fryFrame(lua_State* L) { + if (const auto& f = context(L).host->frame) f(); + return 0; +} +int fryHelp(lua_State* L) { + if (const auto& p = context(L).host->print) p(scriptHelp()); + return 0; +} + +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}, + {"vec", fryVec}, {"say", fryPrint}, {"frame", fryFrame}, {"help", fryHelp}, {nullptr, nullptr}, +}; + +// Stops a script that's run too long (an endless loop). +void watchdog(lua_State* L, lua_Debug*) { + const Context& c = context(L); + if (std::chrono::duration(std::chrono::steady_clock::now() - c.start).count() > c.host->timeLimit) + luaL_error(L, "stopped: it ran for more than %d seconds", int(c.host->timeLimit)); +} + +// Not more memory than this (a runaway table). +void* allocate(void* ud, void* ptr, size_t old, size_t size) { + size_t& used = *static_cast(ud); + if (size == 0) { + used -= ptr ? old : 0; + std::free(ptr); + return nullptr; + } + const size_t before = ptr ? old : 0; + if (used - before + size > (size_t(1) << 30)) return nullptr; + void* p = std::realloc(ptr, size); + if (p) used = used - before + size; + return p; +} + +int traceback(lua_State* L) { + luaL_traceback(L, L, lua_tostring(L, 1), 1); + return 1; +} + +} // namespace + +ScriptResult runScript(ScriptHost& host, const std::string& code, const std::string& name) { + size_t used = 0; + lua_State* L = lua_newstate(allocate, &used); + if (!L) return {false, "out of memory"}; + Context ctx{&host, std::chrono::steady_clock::now()}; + lua_pushlightuserdata(L, &ctx); + lua_setfield(L, LUA_REGISTRYINDEX, "fry.context"); + const luaL_Reg libs[] = {{LUA_GNAME, luaopen_base}, {LUA_TABLIBNAME, luaopen_table}, {LUA_STRLIBNAME, luaopen_string}, + {LUA_MATHLIBNAME, luaopen_math}, {LUA_UTF8LIBNAME, luaopen_utf8}, {LUA_COLIBNAME, luaopen_coroutine}}; + for (const luaL_Reg& l : libs) luaL_requiref(L, l.name, l.func, 1), lua_pop(L, 1); + for (const char* gone : {"dofile", "loadfile", "load", "collectgarbage"}) lua_pushnil(L), lua_setglobal(L, gone); + lua_pushcfunction(L, fryPrint); + lua_setglobal(L, "print"); + + luaL_newmetatable(L, kVec); + const luaL_Reg vecMeta[] = {{"__add", vecAdd}, {"__sub", vecSub}, {"__mul", vecMul}, {"__div", vecDiv}, + {"__unm", vecUnm}, {"__len", vecLen}, {"__tostring", vecString}, {nullptr, nullptr}}; + luaL_setfuncs(L, vecMeta, 0); + lua_pop(L, 1); + luaL_newmetatable(L, kObject); + const luaL_Reg objMeta[] = {{"__index", objIndex}, {"__newindex", objNewIndex}, {"__tostring", objString}, {"__eq", objEq}, {nullptr, nullptr}}; + luaL_setfuncs(L, objMeta, 0); + lua_pop(L, 1); + luaL_newlib(L, kFry); + lua_setglobal(L, "fry"); + lua_sethook(L, watchdog, LUA_MASKCOUNT, 100000); + + ScriptResult result; + lua_pushcfunction(L, traceback); + try { + if (luaL_loadbuffer(L, code.data(), code.size(), ("=" + name).c_str()) != LUA_OK || lua_pcall(L, 0, 0, 1) != LUA_OK) { + result.ok = false; + result.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "an error"; + } + } catch (const std::exception& e) { // (out of memory inside the modelling code) + result.ok = false; + result.error = e.what(); + } + result.objectsMade = ctx.made; + lua_close(L); + // Nothing selected that's gone. + if (host.selected) + std::erase_if(*host.selected, [&](uint32_t id) { return !findObject(*host.scene, id); }); + if (host.active && !findObject(*host.scene, *host.active)) *host.active = host.selected && !host.selected->empty() ? *host.selected->rbegin() : 0; + return result; +} + +const char* scriptHelp() { + return R"(Scripts are Lua (lua.org/manual/5.4). Sizes are metres, turns degrees, Z is up. + +fry.add(shape, {setting = value, property = value, ...}) a library shape: fry.add("cube", {width = 2, color = "#ff8000"}) +fry.shapes() every shape: {id, name, settings} +fry.mesh(points, faces, name) your own: points {{x,y,z}, ...}, faces {{1,2,3,4}, ...} (counter-clockwise from outside) +fry.lathe(profile, sides, name) a profile of {out, up} points turned round the Z axis (bottles, vases, bowls) +fry.extrude(outline, height, name) an outline of {x, y} points on the ground, made that tall +fry.objects() fry.find(name) fry.selected() fry.select(objects) fry.clear() +fry.group(name, objects) puts them in a folder +fry.look{sun_turn, sun_height, sun_strength, sun_color, sky, ground, background, ground_color, exposure, bloom, saturation, vignette, shadow_soft} +fry.vec(x, y, z) a vector (+ - * / work on them; #v is its length) +fry.frame() fry.say(text) print(...) fry.help() + +An object (o): + o.name o.position o.turn o.size o.color o.metal o.rough o.smooth o.visible o.locked + o.mirror ("x", "xy"...) o.round o.round_steps o.smooth_levels (finishing touches, not baked) + o.width, o.sides ... a library shape's own settings, changeable until its shape is changed by hand + o.id o.shape o.point_count o.face_count (to read) + o:move(x, y, z) o:rotate(x, y, z) o:scale(k or x, y, z) o:drop() (stand it on the ground) + o:copy() o:delete() o:select() o:bounds() -> lowest, highest corner + o:add(others...) o:subtract(others...) o:overlap(others...) o:join(others...) (the others go) + o:thicken(t) o:subdivide(smooth, times) o:bevel(width, steps, angle) o:bake() + o:points() o:faces() o:setpoints(points) o:setshape(points, faces) o:set(name, value) o:settings() +)"; +} + +} // namespace fry diff --git a/src/script.h b/src/script.h new file mode 100644 index 0000000..d815dbc --- /dev/null +++ b/src/script.h @@ -0,0 +1,37 @@ +#pragma once +// Scripts: Lua that builds and changes the scene (the Script panel, and scripts/*.lua). They get +// the `fry` library (shapes, objects, combining, the look) and Lua's own string, table, math +// and utf8; no io, os or loading modules: a script changes the scene, not the computer. +// The whole API is listed in scriptHelp(). +#include "scene.h" + +#include +#include +#include +#include + +namespace fry { + +// What a script works on: the app's scene and its selection (the last picked: `active`). +struct ScriptHost { + Scene* scene = nullptr; + std::set* selected = nullptr; + uint32_t* active = nullptr; + std::function print; // print(), fry.say + std::function frame; // fry.frame(): show everything (may be empty) + double timeLimit = 20.0; // seconds before a script's stopped +}; + +struct ScriptResult { + bool ok = true; + std::string error; // what went wrong, with the line + int objectsMade = 0; // (counted for the message afterwards) +}; + +// Runs `code` once (a fresh Lua each time); `name` shows in errors ("hello.lua"). +ScriptResult runScript(ScriptHost& host, const std::string& code, const std::string& name = "script"); + +// Every fry.* function and object property, one per line, for the Script panel's help. +const char* scriptHelp(); + +} // namespace fry diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d1d190f..0b79964 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,7 @@ add_executable(mesh_test mesh_test.cpp) target_link_libraries(mesh_test PRIVATE fry_core) add_test(NAME mesh_test COMMAND mesh_test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) +add_executable(script_test script_test.cpp) +target_link_libraries(script_test PRIVATE fry_script) +target_compile_definitions(script_test PRIVATE FRY_SCRIPTS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/scripts") +add_test(NAME script_test COMMAND script_test) diff --git a/tests/script_test.cpp b/tests/script_test.cpp new file mode 100644 index 0000000..16d4676 --- /dev/null +++ b/tests/script_test.cpp @@ -0,0 +1,77 @@ +// Scripts without a window: they make, change, combine and delete objects; mistakes come back as +// errors with their line, and an endless loop is stopped. +#include "script.h" + +#include +#include +#include +#include +#include + +using namespace fry; + +static int g_failures = 0; +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) ++g_failures, std::printf("FAIL: " __VA_ARGS__); \ + else std::printf("ok: " __VA_ARGS__); \ + std::printf("\n"); \ + } while (0) + +int main() { + Scene scene; + std::set selected; + uint32_t active = 0; + std::string out; + ScriptHost host{&scene, &selected, &active, [&](const std::string& s) { out += s + "\n"; }, {}, 2.0}; + + ScriptResult r = runScript(host, R"( + local box = fry.add("cube", {width = 2, color = "#ff8000", name = "Box"}) + box.position = {0, 0, 1} + box:move(1, 0, 0) + print(box.name, box.width, box.position.x) + local c = fry.add("cylinder", {radius = 0.3, height = 4, position = {1, 0.1, 0}}) + box:subtract(c) + fry.add("sphere"):delete() + local t = fry.lathe({{0, 0}, {0.3, 0}, {0.35, 0.5}, {0.1, 0.8}, {0, 0.8}}, 24, "Vase") + fry.extrude({{0, 0}, {1, 0}, {0, 1}}, 0.2, "Wedge") + fry.group("Things", {t}) + fry.select(box) + )"); + CHECK(r.ok, "a script runs (%s)", r.error.c_str()); + CHECK(scene.objects.size() == 3, "box, vase and wedge are left (%zu)", scene.objects.size()); + CHECK(out.find("Box\t2.0\t1.0") != std::string::npos, "print and properties: %s", out.c_str()); + const Object& box = scene.objects[0]; + CHECK(box.name == "Box" && box.recipe.empty() && box.mesh.faces.size() > 6, "the box had the cylinder taken out (%zu faces)", box.mesh.faces.size()); + CHECK(box.color.r > 0.99f && box.color.g > 0.49f && box.color.g < 0.51f, "colour from #ff8000"); + CHECK(scene.folders.size() == 1 && scene.objects[1].folder == scene.folders[0].id, "grouped into a folder"); + CHECK(active == box.id && selected.count(box.id), "selection from the script"); + + r = runScript(host, "local o = fry.add('cube')\no.wobble = 3", "bad.lua"); + CHECK(!r.ok && r.error.find("bad.lua:2") != std::string::npos, "an error says where: %s", r.error.substr(0, 80).c_str()); + r = runScript(host, "fry.add('teapot')"); + CHECK(!r.ok && r.error.find("no shape called teapot") != std::string::npos, "an unknown shape is an error"); + r = runScript(host, "while true do end"); + CHECK(!r.ok && r.error.find("stopped") != std::string::npos, "an endless loop is stopped"); + r = runScript(host, "io.open('x')"); + CHECK(!r.ok, "no io"); + r = runScript(host, "local o = fry.add('cube')\nlocal n = #o:points()\nlocal p = o:points()\nfor i = 1, n do p[i].z = p[i].z * 2 end\no:setpoints(p)\nlocal lo, hi = o:bounds()\nprint(hi.z - lo.z)"); + CHECK(r.ok && out.find("\n2.0\n") != std::string::npos, "points round-trip (%s)", r.error.c_str()); + r = runScript(host, "fry.clear()"); + CHECK(r.ok && scene.objects.empty() && active == 0 && selected.empty(), "clear"); + + // The examples that come with it all run. + for (const auto& e : std::filesystem::directory_iterator(FRY_SCRIPTS_DIR)) { + std::ifstream in(e.path()); + const std::string code((std::istreambuf_iterator(in)), {}); + Scene fresh; + std::set sel; + uint32_t act = 0; + ScriptHost h{&fresh, &sel, &act, {}, {}, 20.0}; + r = runScript(h, code, e.path().filename().string()); + CHECK(r.ok && !fresh.objects.empty(), "example %s (%d objects) %s", e.path().filename().string().c_str(), r.objectsMade, r.error.c_str()); + } + + std::printf(g_failures ? "%d FAILED\n" : "all passed\n", g_failures); + return g_failures ? 1 : 0; +}