Scripts kept in the .fry (the Script panel lists the file's scripts: new, examples, open, duplicate, remove, rename); your own materials (Save its look as a material, kept for every file; fry.material in scripts); plug-ins: scripts in the plug-ins folder that stay loaded and add menu commands with settings (fry.command, fry.plugin), a Plug-ins window, three that come with it (stairs, scatter, more materials); a new About window; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-20 19:01:07 +02:00
commit 3bcd50033d
12 changed files with 969 additions and 93 deletions

View file

@ -0,0 +1,13 @@
-- More materials for the picker (Properties > Material), and for scripts: o.material = "marble".
-- fry.material works in any script; in a plug-in they're there every time Fry3DX starts.
fry.plugin{name = "More materials", about = "Marble, rose gold, titanium, carbon, jade, walnut and others."}
local list = {
{"Marble", "#e8e4dc", 0, 0.15}, {"Black marble", "#1d1d20", 0, 0.12}, {"Rose gold", "#f2b6a0", 1, 0.2},
{"Silver", "#f2f2f0", 1, 0.12}, {"Titanium", "#a8a6a0", 1, 0.35}, {"Carbon", "#202224", 0, 0.25},
{"Jade", "#4f9e74", 0, 0.2}, {"Terracotta", "#c0603c", 0, 0.9}, {"Walnut", "#5b3a26", 0, 0.6},
{"Oak", "#c49a64", 0, 0.65}, {"Red car paint", "#b0101a", 0.3, 0.1}, {"Teal plastic", "#1f9a96", 0, 0.35},
}
for _, m in ipairs(list) do
fry.material{name = m[1], color = m[2], metal = m[3], rough = m[4]}
end

View file

@ -0,0 +1,27 @@
-- Scatter: copies of what's picked, strewn round it on the ground, each turned and sized a bit
-- differently (rocks, trees, crates). In the Object menu.
fry.plugin{name = "Scatter", about = "Copies of the picked object strewn round it, each a little different."}
fry.command{
name = "Scatter copies", menu = "object",
help = "Copies of the last picked object round it on the ground.",
settings = {{"copies", 12}, {"radius", 2}, {"size_change", 0.3}, {"seed", 1}},
run = function(s)
local picked = fry.selected()
local o = picked[#picked]
if not o then error("pick an object to scatter first") end
math.randomseed(math.floor(s.seed))
local base = o.position
local made = {}
for i = 1, math.max(1, math.floor(s.copies)) do
local c = o:copy()
local a, r = math.random() * 2 * math.pi, math.sqrt(math.random()) * s.radius
c.position = {base.x + math.cos(a) * r, base.y + math.sin(a) * r, base.z}
c:rotate(0, 0, math.random() * 360)
c:scale(1 + (math.random() * 2 - 1) * s.size_change)
made[#made + 1] = c
end
fry.group(o.name .. " scattered", made)
fry.select(made)
end,
}

View file

@ -0,0 +1,40 @@
-- Stairs, straight or round a post: two commands in the Add menu, each asking for its sizes.
-- A plug-in is a script that stays loaded: fry.command puts a command in a menu, and its run
-- function is called (with the settings asked for) each time it's picked.
fry.plugin{name = "Stairs", about = "Straight stairs and spiral stairs, from the Add menu."}
fry.command{
name = "Stairs", menu = "add",
help = "A flight of steps going up along Y.",
settings = {{"steps", 12}, {"rise", 0.18}, {"going", 0.28}, {"width", 0.9}},
run = function(s)
local made = {}
for i = 0, math.max(1, math.floor(s.steps)) - 1 do
local step = fry.add("cube", {width = s.width, depth = s.going, height = s.rise * (i + 1), color = "#b07a4a", name = "Step"})
step.position = {0, i * s.going, 0}
step:drop() -- (its bottom on the ground)
made[#made + 1] = step
end
fry.group("Stairs", made)
fry.select(made)
end,
}
fry.command{
name = "Spiral stairs", menu = "add",
help = "Steps turning round a post.",
settings = {{"steps", 18}, {"rise", 0.18}, {"turn", 22}, {"width", 0.9}},
run = function(s)
local steps = math.max(1, math.floor(s.steps))
local made = {fry.add("pillar", {radius = 0.12, height = steps * s.rise + 0.3, color = "#8a8a90", name = "Post"})}
for i = 0, steps - 1 do
local step = fry.add("cube", {width = s.width, depth = 0.32, height = 0.05, color = "#b07a4a", name = "Step"})
local a = math.rad(i * s.turn)
step.turn = {0, 0, i * s.turn}
step.position = {math.cos(a) * s.width / 2, math.sin(a) * s.width / 2, (i + 1) * s.rise}
made[#made + 1] = step
end
fry.group("Spiral stairs", made)
fry.select(made)
end,
}

View file

@ -36,6 +36,7 @@ if(WIN32) # a window program (no console window), with its icon
target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup) target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup)
endif() endif()
target_link_libraries(fry3dx PRIVATE fry_core fry_script keel stb) target_link_libraries(fry3dx PRIVATE fry_core fry_script keel stb)
target_compile_definitions(fry3dx PRIVATE FRY_VERSION="${PROJECT_VERSION}")
add_dependencies(fry3dx content) add_dependencies(fry3dx content)
foreach(t fry_core fry_script fry3dx) foreach(t fry_core fry_script fry3dx)
if(MSVC) if(MSVC)

View file

@ -15,6 +15,7 @@
#include <SDL3/SDL_main.h> // (main becomes the SDL_main SDL's Java side calls) #include <SDL3/SDL_main.h> // (main becomes the SDL_main SDL's Java side calls)
#endif #endif
#include <core/cvar.h> #include <core/cvar.h>
#include <core/sysinfo.h>
#include <core/log.h> #include <core/log.h>
#include <engine.h> #include <engine.h>
#include <fs/vfs.h> #include <fs/vfs.h>
@ -273,6 +274,7 @@ keel::Cvar fry_touch("fry_touch", "0", keel::CVAR_ARCHIVE, "Fry3DX: the phone la
keel::Cvar fry_uiscale("fry_uiscale", "0", 0, "Fry3DX: draw the UI this many times bigger (0: by the screen)"); keel::Cvar fry_uiscale("fry_uiscale", "0", 0, "Fry3DX: draw the UI this many times bigger (0: by the screen)");
keel::Cvar fry_uisize("fry_uisize", "1", keel::CVAR_ARCHIVE, "Fry3DX: the phone layout's size, times"); keel::Cvar fry_uisize("fry_uisize", "1", keel::CVAR_ARCHIVE, "Fry3DX: the phone layout's size, times");
keel::Cvar fry_theme("fry_theme", "0", keel::CVAR_ARCHIVE, "Fry3DX: how it looks: 0 like the desktop, 1 dark, 2 light"); keel::Cvar fry_theme("fry_theme", "0", keel::CVAR_ARCHIVE, "Fry3DX: how it looks: 0 like the desktop, 1 dark, 2 light");
keel::Cvar fry_pluginsoff("fry_pluginsoff", "", keel::CVAR_ARCHIVE, "Fry3DX: plug-ins turned off (their file names, with | between)");
keel::Cvar fry_autosave("fry_autosave", "2", keel::CVAR_ARCHIVE, "Fry3DX: autosave every this many minutes while there are changes (0: off)"); keel::Cvar fry_autosave("fry_autosave", "2", keel::CVAR_ARCHIVE, "Fry3DX: autosave every this many minutes while there are changes (0: off)");
class App : public keel::Game { class App : public keel::Game {
@ -283,6 +285,11 @@ public:
bool wantsCursor() const override { return true; } bool wantsCursor() const override { return true; }
bool dockingUi() const override { return true; } bool dockingUi() const override { return true; }
// "Spiral_stairs" is "Spiral stairs" (typed in the console).
static bool sameCommand(const std::string& name, std::string typed) {
std::replace(typed.begin(), typed.end(), '_', ' ');
return materialId(name) == materialId(typed);
}
void init() override { void init() override {
static App* self = this; static App* self = this;
// Console commands: for scripts and tests (the ` key opens the console). // Console commands: for scripts and tests (the ` key opens the console).
@ -293,8 +300,8 @@ public:
if (a.size() < 2) return; if (a.size() < 2) return;
std::ifstream in(a[1], std::ios::binary); std::ifstream in(a[1], std::ios::binary);
if (!in) return keel::log::warn("Couldn't open {}", a[1]); if (!in) return keel::log::warn("Couldn't open {}", a[1]);
self->scriptCode_.assign(std::istreambuf_iterator<char>(in), {}); self->addScript(scriptName(a[1]), std::string(std::istreambuf_iterator<char>(in), {}));
self->scriptPath_ = a[1], self->showScript_ = true; self->scriptPath_ = a[1];
self->runScriptNow(); self->runScriptNow();
}, "Fry3DX: run a Lua script file (fry_run path/to/script.lua)"); }, "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 add("fry_add", [](const keel::Args& a) { if (a.size() > 1) self->addShape(a[1]); }, "Fry3DX: add a shape (cube, rounded_cube, table...)");
@ -543,11 +550,29 @@ public:
static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover"); static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover");
static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel"); static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel");
static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window");
static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) {
if (a.size() > 1 && a[1] == "reload") self->loadPlugins();
else self->showPlugins_ = true;
}, "Fry3DX: the Plug-ins window (fry_plugins reload: load them again)");
static keel::Cmd plugin("fry_plugin", [](const keel::Args& a) { // fry_plugin "Command name" [settings...]
if (a.size() < 2) return;
for (size_t i = 0; i < self->plugins_.size(); ++i)
if (self->plugins_[i].p)
for (size_t c = 0; c < self->plugins_[i].p->commands.size(); ++c)
if (sameCommand(self->plugins_[i].p->commands[c].name, a[1])) {
std::vector<double> v;
for (size_t k = 2; k < a.size(); ++k) v.push_back(std::stod(a[k]));
return self->runPluginCommand(i, c, v);
}
keel::log::warn("No plug-in command called {}", a[1]);
}, "Fry3DX: run a plug-in's command: fry_plugin Spiral_stairs 12 0.18 (_ for spaces, any case)");
keel::execute("r_autoexposure 0; r_ssr 0; r_volumetric 0; r_shafts 0; r_dynres 0; r_renderscale 1; fps_max 144"); keel::execute("r_autoexposure 0; r_ssr 0; r_volumetric 0; r_shafts 0; r_dynres 0; r_renderscale 1; fps_max 144");
#if defined(__ANDROID__) // a phone: none of the heavy parts (and its GPU's driver is kept to well-trodden ground) #if defined(__ANDROID__) // a phone: none of the heavy parts (and its GPU's driver is kept to well-trodden ground)
keel::execute("r_gpuculling 0; r_occlusion 0; r_volumetricfog 0; r_mirrors 0; r_taa 0; r_shadowres 2048; r_lightshadows 0; fps_max 60"); keel::execute("r_gpuculling 0; r_occlusion 0; r_volumetricfog 0; r_mirrors 0; r_taa 0; r_shadowres 2048; r_lightshadows 0; fps_max 60");
#endif #endif
newScene(); newScene();
loadOwnMaterials();
loadPlugins();
if (!startPath_.empty()) openFile(startPath_); if (!startPath_.empty()) openFile(startPath_);
findAutosaves(); findAutosaves();
} }
@ -600,6 +625,8 @@ public:
if (showScript_) scriptPanel(); if (showScript_) scriptPanel();
if (showHelp_) helpWindow(); if (showHelp_) helpWindow();
if (showAbout_) aboutWindow(); if (showAbout_) aboutWindow();
if (showPlugins_) pluginsWindow();
commandForm();
copiesWindow(); copiesWindow();
thickenWindow(); thickenWindow();
saveQuestion(); saveQuestion();
@ -840,7 +867,25 @@ private:
int appliedTheme_ = -1; int appliedTheme_ = -1;
// The Script panel: the script being written, where it's from, what it printed. // The Script panel: the script being written, where it's from, what it printed.
bool showScript_ = false, showScriptHelp_ = false; bool showScript_ = false, showScriptHelp_ = false;
std::string scriptCode_ = kFirstScript, scriptPath_; size_t script_ = 0; // the file's script shown in the Script panel
std::string scriptPath_; // where it was last saved or opened from (Save as starts there)
// Plug-ins (Script > Plug-ins): each file, whether it's on, and how loading it went.
struct PluginEntry {
std::string path, file, error;
bool bundled = false, on = true;
std::unique_ptr<fry::Plugin> p;
};
std::vector<PluginEntry> plugins_;
bool showPlugins_ = false;
// A plug-in's command waiting for its settings (the form), and a material being named.
struct PendingCommand {
size_t plugin = 0, command = 0;
std::vector<double> values;
};
std::optional<PendingCommand> pendingCommand_;
bool nameMaterial_ = false;
std::string materialName_;
bool materialKeepColor_ = false;
std::vector<std::pair<std::string, bool>> scriptLog_; // (line, an error) std::vector<std::pair<std::string, bool>> scriptLog_; // (line, an error)
bool scriptLogToBottom_ = false; bool scriptLogToBottom_ = false;
bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false; bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false;
@ -884,7 +929,10 @@ private:
dirty_ = true, ++edits_; dirty_ = true, ++edits_;
} }
void restore(Snapshot& s) { void restore(Snapshot& s) {
// (Not the scripts: they're edited like text, not undone with the model.)
std::vector<fry::SceneScript> scripts = std::move(scene_.scripts);
scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode; scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode;
scene_.scripts = std::move(scripts);
selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges; selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges;
if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object; if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object;
touchAll(); touchAll();
@ -905,6 +953,7 @@ private:
void newScene() { void newScene() {
scene_ = {}; scene_ = {};
script_ = 0, scriptPath_.clear();
path_.clear(); path_.clear();
undo_.clear(), redo_.clear(), selected_.clear(); undo_.clear(), redo_.clear(), selected_.clear();
mode_ = Mode::Object, active_ = 0; mode_ = Mode::Object, active_ = 0;
@ -1357,7 +1406,7 @@ private:
else if (kind == 10 || kind == 11) { else if (kind == 10 || kind == 11) {
static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}}; static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}};
if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); 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_ShowSaveFileDialog(done, this, w, lua, 1, scriptPath_.empty() || isUri(scriptPath_) ? at : scriptPath_.c_str()); // (at: the last folder)
} }
else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false);
} }
@ -4713,7 +4762,17 @@ private:
} }
} }
void logoImage(float size) { void logoImage(float size) {
if (haveLogo_) ImGui::Image(ImTextureID(r_->uiTexture(logo_)), ImVec2(size, size)); if (!haveLogo_) return;
const ImVec2 at = ImGui::GetCursorScreenPos();
ImGui::Dummy(ImVec2(size, size));
uiImage(ImGui::GetWindowDrawList(), logo_, at, ImVec2(at.x + size, at.y + size));
}
// A picture of ours in the UI, read the way ImGui's colours are (not turned linear: too dark).
void uiImage(ImDrawList* dl, keel::TextureId t, ImVec2 a, ImVec2 b) {
const keel::UiRawRead raw = r_->uiRawRead(t);
if (raw.begin) dl->AddCallback(raw.begin, raw.data);
dl->AddImage(ImTextureID(r_->uiTexture(t)), a, b);
if (raw.end) dl->AddCallback(raw.end, raw.data);
} }
float gridStep() const { return std::pow(10.0f, std::floor(std::log10(std::max(viewHeight() / 4.0f, 1e-4f)))); } float gridStep() const { return std::pow(10.0f, std::floor(std::log10(std::max(viewHeight() / 4.0f, 1e-4f)))); }
@ -5196,6 +5255,7 @@ private:
} }
if (ImGui::BeginMenu("Add")) { if (ImGui::BeginMenu("Add")) {
addMenuItems(); addMenuItems();
pluginMenuItems("add");
ImGui::EndMenu(); ImGui::EndMenu();
} }
if (ImGui::BeginMenu("Object")) { if (ImGui::BeginMenu("Object")) {
@ -5213,6 +5273,7 @@ private:
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem("Make it thick (or hollow)...", nullptr, false, any)) showThicken_ = true; if (ImGui::MenuItem("Make it thick (or hollow)...", nullptr, false, any)) showThicken_ = true;
if (ImGui::MenuItem("Join flat faces side by side", nullptr, false, any)) cleanFlatFaces(); if (ImGui::MenuItem("Join flat faces side by side", nullptr, false, any)) cleanFlatFaces();
pluginMenuItems("object");
ImGui::EndMenu(); ImGui::EndMenu();
} }
if (ImGui::BeginMenu("Shape")) { if (ImGui::BeginMenu("Shape")) {
@ -5262,12 +5323,30 @@ private:
} }
if (ImGui::BeginMenu("Script")) { if (ImGui::BeginMenu("Script")) {
ImGui::MenuItem("The Script panel", nullptr, &showScript_); ImGui::MenuItem("The Script panel", nullptr, &showScript_);
if (ImGui::MenuItem("Run it", "F5")) runScriptNow(); if (ImGui::MenuItem("Run it", "F5", false, currentScript())) runScriptNow();
if (!scene_.scripts.empty() && ImGui::BeginMenu("This file's scripts")) {
for (size_t i = 0; i < scene_.scripts.size(); ++i)
if (ImGui::MenuItem(scene_.scripts[i].name.c_str(), nullptr, i == script_)) script_ = i, showScript_ = true;
ImGui::EndMenu();
}
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem("New script")) addScript("Script", kFirstScript);
if (ImGui::MenuItem("Open a script...")) showScript_ = true, dialog(10); if (ImGui::MenuItem("Open a script...")) showScript_ = true, dialog(10);
if (ImGui::MenuItem("Save the script as...")) dialog(11); if (ImGui::MenuItem("Save the script as...", nullptr, false, currentScript())) dialog(11);
exampleScripts(); exampleScripts();
ImGui::Separator(); ImGui::Separator();
if (ImGui::BeginMenu("Plug-ins")) {
bool any = false;
for (size_t i = 0; i < plugins_.size(); ++i)
if (plugins_[i].p)
for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c)
if (plugins_[i].p->commands[c].menu == "script") pluginMenuItem(i, c), any = true;
if (any) ImGui::Separator();
if (ImGui::MenuItem("Plug-ins...")) showPlugins_ = true;
if (ImGui::MenuItem("Open the plug-ins folder")) openPluginsFolder();
if (ImGui::MenuItem("Load them again")) loadPlugins();
ImGui::EndMenu();
}
if (ImGui::MenuItem("What scripts can do")) showScript_ = showScriptHelp_ = true; if (ImGui::MenuItem("What scripts can do")) showScript_ = showScriptHelp_ = true;
ImGui::EndMenu(); ImGui::EndMenu();
} }
@ -5992,7 +6071,7 @@ private:
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
dl->PushClipRect(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), true); dl->PushClipRect(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), true);
dl->AddRectFilled(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), IM_COL32(24, 24, 28, 255)); dl->AddRectFilled(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), IM_COL32(24, 24, 28, 255));
dl->AddImage(ImTextureID(r_->uiTexture(paintTex_[o->id])), toScreen({0, 0}), toScreen({float(W), float(H)})); uiImage(dl, paintTex_[o->id], toScreen({0, 0}), toScreen({float(W), float(H)}));
dl->AddRect(toScreen({0, 0}), toScreen({float(W), float(H)}), IM_COL32(0, 0, 0, 255)); dl->AddRect(toScreen({0, 0}), toScreen({float(W), float(H)}), IM_COL32(0, 0, 0, 255));
if (tab_ == Tab::Layout) { if (tab_ == Tab::Layout) {
if (o->uvsFit()) layoutCanvas(*o, dl, toScreen, mouse, hovered, W, H); if (o->uvsFit()) layoutCanvas(*o, dl, toScreen, mouse, hovered, W, H);
@ -6212,28 +6291,79 @@ private:
} }
// A material to start from: sets colour, metal and rough in one go (each a little ball of it). // A material to start from: sets colour, metal and rough in one go (each a little ball of it).
// Yours (saved here, or from plug-ins and scripts) first; "Save its look as a material" adds one.
void materialPicker(Object& o) { void materialPicker(Object& o) {
auto matches = [&](const MaterialPreset& m) {
return std::abs(m.metal - o.metal) < 0.01f && std::abs(m.rough - o.rough) < 0.01f &&
(m.keepColor || glm::all(glm::lessThan(glm::abs(m.color - o.color), glm::vec3(0.01f))));
};
const MaterialPreset* now = nullptr; const MaterialPreset* now = nullptr;
for (const MaterialPreset& m : materialPresets()) for (const MaterialPreset& m : materialPresets())
if (std::abs(m.metal - o.metal) < 0.01f && std::abs(m.rough - o.rough) < 0.01f && (m.keepColor || glm::all(glm::lessThan(glm::abs(m.color - o.color), glm::vec3(0.01f))))) now = &m; if (matches(m)) now = &m;
for (const MaterialPreset& m : ownMaterials())
if (matches(m)) now = &m;
ImGui::SetNextItemWidth(ImGui::CalcItemWidth()); ImGui::SetNextItemWidth(ImGui::CalcItemWidth());
if (!ImGui::BeginCombo("Material", now ? now->name : "(its own)", ImGuiComboFlags_HeightLarge)) return; if (ImGui::BeginCombo("Material", now ? now->name.c_str() : "(its own)", ImGuiComboFlags_HeightLarge)) {
for (const MaterialPreset& m : materialPresets()) { std::optional<std::string> remove;
const ImVec2 p = ImGui::GetCursorScreenPos(); auto item = [&](const MaterialPreset& m, bool own) {
const float h = ImGui::GetTextLineHeight(); const ImVec2 p = ImGui::GetCursorScreenPos();
ImGui::PushID(m.id); const float h = ImGui::GetTextLineHeight();
if (ImGui::Selectable("##m", now == &m, 0, ImVec2(0, h))) pushUndo(), applyMaterial(o, m), touched(o.id); ImGui::PushID(m.id.c_str());
ImGui::PopID(); ImGui::PushID(own);
// A ball: its colour, darker round the edge, a highlight as sharp as it's smooth. if (ImGui::Selectable("##m", now == &m, 0, ImVec2(0, h))) pushUndo(), applyMaterial(o, m), touched(o.id);
const glm::vec3 c = m.keepColor ? o.color : m.color; if (own && m.from.empty() && ImGui::BeginPopupContextItem()) {
const ImVec2 mid(p.x + h * 0.5f, p.y + h * 0.5f); if (ImGui::MenuItem("Remove it")) remove = m.id;
ImDrawList* dl = ImGui::GetWindowDrawList(); ImGui::EndPopup();
dl->AddCircleFilled(mid, h * 0.45f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1))); }
dl->AddCircleFilled(ImVec2(mid.x - h * 0.05f, mid.y - h * 0.05f), h * 0.36f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r, c.g, c.b, 1))); if (own && ImGui::IsItemHovered())
dl->AddCircleFilled(ImVec2(mid.x - h * 0.14f, mid.y - h * 0.14f), h * (0.06f + 0.1f * m.rough), IM_COL32(255, 255, 255, int(230 * (1.0f - m.rough * 0.8f)))); ImGui::SetTooltip("%s", m.from.empty() ? "Yours (right-click to remove)" : ("From " + m.from).c_str());
dl->AddText(ImVec2(p.x + h * 1.4f, p.y), ImGui::GetColorU32(ImGuiCol_Text), m.name); ImGui::PopID();
ImGui::PopID();
// A ball: its colour, darker round the edge, a highlight as sharp as it's smooth.
const glm::vec3 c = m.keepColor ? o.color : m.color;
const ImVec2 mid(p.x + h * 0.5f, p.y + h * 0.5f);
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddCircleFilled(mid, h * 0.45f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1)));
dl->AddCircleFilled(ImVec2(mid.x - h * 0.05f, mid.y - h * 0.05f), h * 0.36f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r, c.g, c.b, 1)));
dl->AddCircleFilled(ImVec2(mid.x - h * 0.14f, mid.y - h * 0.14f), h * (0.06f + 0.1f * m.rough), IM_COL32(255, 255, 255, int(230 * (1.0f - m.rough * 0.8f))));
dl->AddText(ImVec2(p.x + h * 1.4f, p.y), ImGui::GetColorU32(ImGuiCol_Text), m.name.c_str());
};
if (!ownMaterials().empty()) {
ImGui::SeparatorText("Yours");
for (const MaterialPreset& m : ownMaterials()) item(m, true);
ImGui::SeparatorText("Fry3DX's");
}
for (const MaterialPreset& m : materialPresets()) item(m, false);
ImGui::Separator();
if (ImGui::Selectable("Save its look as a material...")) nameMaterial_ = true, materialName_.clear(), materialKeepColor_ = false;
ImGui::EndCombo();
if (remove && removeOwnMaterial(*remove)) saveOwnMaterials();
}
if (std::exchange(nameMaterial_, false)) ImGui::OpenPopup("Save as a material");
ImGui::SetNextWindowSize(ImVec2(340, 0));
if (ImGui::BeginPopupModal("Save as a material", nullptr, ImGuiWindowFlags_NoResize)) {
ImGui::TextWrapped("Its colour, metal and rough as one of your materials, in the picker for every file.");
if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere();
ImGui::SetNextItemWidth(-FLT_MIN);
inputString("##name", materialName_, "Its name (\"Teal plastic\")");
ImGui::Checkbox("Only how metal and rough (keep each object's colour)", &materialKeepColor_);
const bool taken = std::any_of(materialPresets().begin(), materialPresets().end(), [&](const MaterialPreset& m) { return m.id == materialId(materialName_); });
if (taken) ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "That's one of Fry3DX's own: another name");
ImGui::BeginDisabled(materialId(materialName_).empty() || taken);
pushAccent();
const bool save = ImGui::Button("Save", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false);
popAccent();
ImGui::EndDisabled();
ImGui::SameLine();
if (save && !materialId(materialName_).empty() && !taken) {
addOwnMaterial({materialId(materialName_), materialName_, o.color, o.metal, o.rough, materialKeepColor_, ""});
saveOwnMaterials();
say("Saved \"" + materialName_ + "\": it's in the Material picker now");
ImGui::CloseCurrentPopup();
}
if (ImGui::Button("Cancel", ImVec2(120, 0))) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
} }
ImGui::EndCombo();
} }
void editProperties(Object& o, float w) { void editProperties(Object& o, float w) {
@ -6375,15 +6505,39 @@ private:
if (scriptLog_.size() > 2000) scriptLog_.erase(scriptLog_.begin(), scriptLog_.begin() + 500); if (scriptLog_.size() > 2000) scriptLog_.erase(scriptLog_.begin(), scriptLog_.begin() + 500);
scriptLogToBottom_ = true; scriptLogToBottom_ = true;
} }
fry::SceneScript* currentScript() { return script_ < scene_.scripts.size() ? &scene_.scripts[script_] : nullptr; }
// "little_town.lua" -> "little town" (a script's name in the list).
static std::string scriptName(const std::string& path) {
std::string n = stdfs::path(fileName(path)).stem().string();
std::replace(n.begin(), n.end(), '_', ' ');
return n.empty() ? "Script" : n;
}
// Adds a script to the file's list and shows it (the same one again: just shows it).
void addScript(std::string name, std::string code) {
for (size_t i = 0; i < scene_.scripts.size(); ++i)
if (scene_.scripts[i].name == name && scene_.scripts[i].code == code) return void((script_ = i, showScript_ = true));
const std::string base = name;
for (int n = 2; std::any_of(scene_.scripts.begin(), scene_.scripts.end(), [&](const fry::SceneScript& s) { return s.name == name; }); ++n)
name = std::format("{} {}", base, n);
scene_.scripts.push_back({name, std::move(code)});
script_ = scene_.scripts.size() - 1;
showScript_ = true, dirty_ = true;
}
// Runs the Script panel's script: one undo step; if it stops with an error, everything it did // Runs the Script panel's script: one undo step; if it stops with an error, everything it did
// is taken back. // is taken back.
void runScriptNow() { void runScriptNow() {
const fry::SceneScript* sc = currentScript();
if (!sc) return say("No script to run: Script > New script, or open one");
if (mode_ == Mode::Edit) leaveEdit(); if (mode_ == Mode::Edit) leaveEdit();
const std::string name = scriptPath_.empty() ? "script" : fileName(scriptPath_); const std::string name = sc->name, code = sc->code; // (the list itself may change while it runs)
pushUndo(); pushUndo();
bool frame = false; bool frame = false;
fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }}; fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }};
const fry::ScriptResult r = fry::runScript(host, scriptCode_, name); const fry::ScriptResult r = fry::runScript(host, code, name);
finishRun(r, name, frame);
}
// After a script or plug-in command: kept as one undo step, or all taken back on an error.
void finishRun(const fry::ScriptResult& r, const std::string& name, bool frame) {
touchAll(); touchAll();
if (r.ok && frame) frameAll(); if (r.ok && frame) frameAll();
if (!r.ok) { if (!r.ok) {
@ -6391,7 +6545,7 @@ private:
redo_.clear(); redo_.clear();
scriptLine(r.error, true); scriptLine(r.error, true);
showScript_ = true; showScript_ = true;
return say("The script stopped (nothing it did was kept): " + r.error.substr(0, r.error.find('\n'))); return say(name + " stopped (nothing it did was kept): " + r.error.substr(0, r.error.find('\n')));
} }
dirty_ = true; dirty_ = true;
say(r.objectsMade ? std::format("{} ran: {} object{} made (Ctrl+Z takes it all back)", name, r.objectsMade, r.objectsMade == 1 ? "" : "s") say(r.objectsMade ? std::format("{} ran: {} object{} made (Ctrl+Z takes it all back)", name, r.objectsMade, r.objectsMade == 1 ? "" : "s")
@ -6400,35 +6554,224 @@ private:
void openScript(const std::string& path, const std::string& shownName) { void openScript(const std::string& path, const std::string& shownName) {
std::ifstream in(path, std::ios::binary); std::ifstream in(path, std::ios::binary);
if (!in) return say("Couldn't open " + shownName); if (!in) return say("Couldn't open " + shownName);
scriptCode_.assign(std::istreambuf_iterator<char>(in), {}); addScript(scriptName(shownName), std::string(std::istreambuf_iterator<char>(in), {}));
scriptPath_ = shownName; scriptPath_ = isUri(path) ? std::string() : path;
showScript_ = true; say("Opened " + shownName + ": it's kept in this file now. F5 runs it");
say("Opened " + shownName + ": F5 runs it");
} }
void saveScript(std::string file) { void saveScript(std::string file) {
const fry::SceneScript* sc = currentScript();
if (!sc) return;
if (isUri(file)) { if (isUri(file)) {
const std::string temp = tempFile("script.lua"); const std::string temp = tempFile("script.lua");
std::string error; std::string error;
const bool ok = writeFileSafely(temp, scriptCode_.data(), scriptCode_.size(), &error) && toUri(temp, file); const bool ok = writeFileSafely(temp, sc->code.data(), sc->code.size(), &error) && toUri(temp, file);
return say(ok ? "Saved " + fileName(file) : "Couldn't write the file you picked"); return say(ok ? "Saved " + fileName(file) : "Couldn't write the file you picked");
} }
if (stdfs::path(file).extension() != ".lua") file += ".lua"; if (stdfs::path(file).extension() != ".lua") file += ".lua";
std::string error; std::string error;
if (!writeFileSafely(file, scriptCode_.data(), scriptCode_.size(), &error)) return say("Couldn't save the script: " + error); if (!writeFileSafely(file, sc->code.data(), sc->code.size(), &error)) return say("Couldn't save the script: " + error);
scriptPath_ = file; scriptPath_ = file;
say("Saved " + fileName(file)); say("Saved " + fileName(file));
} }
// The scripts that come with it (content/Fry3DX/scripts), as menu items that load one. // The scripts that come with it (content/Fry3DX/scripts), as menu items that add one.
void exampleScripts() { void exampleScripts() {
if (!ImGui::BeginMenu("Examples")) return; if (!ImGui::BeginMenu("Examples")) return;
for (const std::string& f : keel::fs::list("scripts", ".lua")) { exampleItems(false);
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(); ImGui::EndMenu();
} }
void exampleItems(bool selectable) {
for (const std::string& f : keel::fs::list("scripts", ".lua")) {
const std::string name = scriptName(f);
if (selectable ? ImGui::Selectable(name.c_str()) : ImGui::MenuItem(name.c_str()))
if (auto text = keel::fs::readText(f)) addScript(name, *text);
}
}
// ── Own materials (materials.txt in the settings folder) ──
static std::string ownMaterialsPath() { return keel::prefPath() + "materials.txt"; }
void loadOwnMaterials() {
std::ifstream in(ownMaterialsPath(), std::ios::binary);
if (in) parseOwnMaterials(std::string(std::istreambuf_iterator<char>(in), {}));
}
void saveOwnMaterials() {
const std::string text = ownMaterialsText();
std::string error;
if (!writeFileSafely(ownMaterialsPath(), text.data(), text.size(), &error)) say("Couldn't keep your materials: " + error);
}
// ── Plug-ins: content/Fry3DX/plugins (they come with it) and the settings folder's plugins ──
static std::string pluginsFolder() { return keel::prefPath() + "plugins/"; }
std::set<std::string> pluginsOff() const {
std::set<std::string> off;
std::istringstream in(fry_pluginsoff.str());
for (std::string f; std::getline(in, f, '|');)
if (!f.empty()) off.insert(f);
return off;
}
void setPluginOn(const std::string& file, bool on) {
std::set<std::string> off = pluginsOff();
if (on) off.erase(file);
else off.insert(file);
std::string text;
for (const std::string& f : off) text += (text.empty() ? "" : "|") + f;
fry_pluginsoff.set(text);
loadPlugins();
}
void loadPlugins() {
for (const PluginEntry& e : plugins_) fry::clearOwnMaterials(e.file);
plugins_.clear();
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
std::vector<std::pair<PluginEntry, std::string>> found; // (and its code)
for (const std::string& f : keel::fs::list("plugins", ".lua"))
if (auto text = keel::fs::readText(f)) found.push_back({PluginEntry{f, fileName(f), "", true}, *text});
std::vector<stdfs::path> mine;
for (const auto& d : stdfs::directory_iterator(pluginsFolder(), ec))
if (d.path().extension() == ".lua") mine.push_back(d.path());
std::sort(mine.begin(), mine.end());
for (const stdfs::path& f : mine) {
std::ifstream in(f, std::ios::binary);
found.push_back({PluginEntry{f.string(), f.filename().string()}, std::string(std::istreambuf_iterator<char>(in), {})});
}
const std::set<std::string> off = pluginsOff();
fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do)
std::set<uint32_t> none;
uint32_t noActive = 0;
for (auto& [e, code] : found) {
e.on = !off.count(e.file);
if (e.on) {
e.p = std::make_unique<fry::Plugin>(e.file);
fry::ScriptHost host{&scratch, &none, &noActive, [this, f = e.file](const std::string& t) { scriptLine(f + ": " + t); }, {}};
if (!e.p->load(host, code, &e.error)) {
fry::clearOwnMaterials(e.file);
scriptLine(e.error, true);
keel::log::warn("Plug-in {} didn't load: {}", e.file, e.error.substr(0, e.error.find('\n')));
}
}
plugins_.push_back(std::move(e));
}
size_t loaded = 0;
for (const PluginEntry& e : plugins_) loaded += e.p && e.error.empty();
if (loaded) keel::log::info("Plug-ins: {} loaded", loaded);
}
void openPluginsFolder() {
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
SDL_OpenURL(("file://" + pluginsFolder()).c_str());
}
bool pluginUsable(size_t i) const { return i < plugins_.size() && plugins_[i].p && plugins_[i].error.empty(); }
void pluginMenuItems(const char* menu) {
bool first = true;
for (size_t i = 0; i < plugins_.size(); ++i)
if (pluginUsable(i))
for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c)
if (plugins_[i].p->commands[c].menu == menu) {
if (std::exchange(first, false)) ImGui::SeparatorText("Plug-ins");
pluginMenuItem(i, c);
}
}
void pluginMenuItem(size_t i, size_t c) {
const fry::PluginCommand& cmd = plugins_[i].p->commands[c];
if (ImGui::MenuItem((cmd.name + (cmd.settings.empty() ? "" : "...")).c_str())) {
if (cmd.settings.empty()) runPluginCommand(i, c, {});
else {
pendingCommand_ = PendingCommand{i, c, {}};
for (const auto& [n, v] : cmd.settings) pendingCommand_->values.push_back(v);
}
}
if (!cmd.help.empty() && ImGui::IsItemHovered()) ImGui::SetTooltip("%s (%s)", cmd.help.c_str(), plugins_[i].p->name.c_str());
}
void runPluginCommand(size_t i, size_t c, const std::vector<double>& values) {
if (!pluginUsable(i) || c >= plugins_[i].p->commands.size()) return;
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
bool frame = false;
fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }};
const fry::ScriptResult r = plugins_[i].p->run(host, c, values);
finishRun(r, plugins_[i].p->commands[c].name, frame);
}
// A plug-in command's settings, asked for before it runs.
void commandForm() {
if (!pendingCommand_) return;
if (!pluginUsable(pendingCommand_->plugin)) return pendingCommand_.reset();
const fry::PluginCommand& cmd = plugins_[pendingCommand_->plugin].p->commands[pendingCommand_->command];
const std::string title = cmd.name + "###plugincommand";
if (!ImGui::IsPopupOpen("###plugincommand")) ImGui::OpenPopup(title.c_str());
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(340, 0));
if (!ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_NoResize)) return;
if (!cmd.help.empty()) ImGui::TextWrapped("%s", cmd.help.c_str());
ImGui::TextDisabled("From %s", plugins_[pendingCommand_->plugin].p->name.c_str());
for (size_t k = 0; k < cmd.settings.size(); ++k) {
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f);
std::string label = cmd.settings[k].first;
if (!label.empty()) label[0] = char(std::toupper(static_cast<unsigned char>(label[0])));
std::replace(label.begin(), label.end(), '_', ' ');
ImGui::InputDouble(label.c_str(), &pendingCommand_->values[k], 0, 0, "%.4g");
}
ImGui::Spacing();
pushAccent();
const bool go = ImGui::Button("Make it", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false);
popAccent();
ImGui::SameLine();
const bool cancel = ImGui::Button("Cancel", ImVec2(120, 0));
if (go || cancel) {
ImGui::CloseCurrentPopup();
const PendingCommand p = *pendingCommand_;
pendingCommand_.reset();
if (go) runPluginCommand(p.plugin, p.command, p.values);
}
ImGui::EndPopup();
}
void pluginsWindow() {
using keel::ui::Role;
ImGui::SetNextWindowSize(ImVec2(520, 520), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Plug-ins", &showPlugins_, ImGuiWindowFlags_NoDocking)) return ImGui::End();
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextSecondary));
ImGui::TextWrapped("Plug-ins are scripts that stay loaded: they add commands to the menus and materials to the picker. "
"Put a .lua file in the plug-ins folder and load them again.");
ImGui::PopStyleColor();
if (ImGui::Button("Open the plug-ins folder")) openPluginsFolder();
ImGui::SameLine();
if (ImGui::Button("Load them again")) loadPlugins();
ImGui::SameLine();
if (ImGui::Button("How to write one")) showScript_ = showScriptHelp_ = true;
ImGui::Spacing();
if (plugins_.empty()) ImGui::TextDisabled("None yet.");
std::optional<std::pair<std::string, bool>> toggle;
for (size_t i = 0; i < plugins_.size(); ++i) {
const PluginEntry& e = plugins_[i];
ImGui::PushID(int(i));
ImGui::BeginChild("plugin", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
bool on = e.on;
if (ImGui::Checkbox("##on", &on)) toggle = {e.file, on};
if (ImGui::IsItemHovered()) ImGui::SetTooltip(on ? "On: turn it off" : "Off: turn it on");
ImGui::SameLine();
ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f);
ImGui::TextUnformatted(e.p ? e.p->name.c_str() : scriptName(e.file).c_str());
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextDisabled("%s%s", e.file.c_str(), e.bundled ? " (comes with Fry3DX)" : "");
if (e.p && !e.p->about.empty()) ImGui::TextWrapped("%s", e.p->about.c_str());
if (!e.error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::DangerText));
ImGui::TextWrapped("Didn't load: %s", e.error.c_str());
ImGui::PopStyleColor();
} 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");
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");
ImGui::EndChild();
ImGui::PopID();
}
ImGui::End();
if (toggle) setPluginOn(toggle->first, toggle->second);
}
static int growString(ImGuiInputTextCallbackData* d) { static int growString(ImGuiInputTextCallbackData* d) {
if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) { if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* str = static_cast<std::string*>(d->UserData); auto* str = static_cast<std::string*>(d->UserData);
@ -6437,34 +6780,77 @@ private:
} }
return 0; return 0;
} }
// A one-line field for a std::string (it grows as it's typed into).
static bool inputString(const char* label, std::string& text, const char* hint = nullptr) {
return ImGui::InputTextWithHint(label, hint ? hint : "", text.data(), text.capacity() + 1, ImGuiInputTextFlags_CallbackResize, growString, &text);
}
void scriptPanel() { void scriptPanel() {
using keel::ui::Role;
const ImGuiViewport* vp = ImGui::GetMainViewport(); 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::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); ImGui::SetNextWindowSize(ImVec2(std::min(780.0f, vp->WorkSize.x * 0.55f), vp->WorkSize.y * 0.7f), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Script", &showScript_)) return ImGui::End(); if (!ImGui::Begin("Script", &showScript_)) return ImGui::End();
// The file's scripts (saved in the .fry with it).
const float listW = std::min(180.0f, ImGui::GetContentRegionAvail().x * 0.3f);
ImGui::BeginChild("scripts", ImVec2(listW, 0));
ImGui::TextColored(roleColor(Role::TextTertiary), "In this file");
std::optional<size_t> remove, copy;
for (size_t i = 0; i < scene_.scripts.size(); ++i) {
ImGui::PushID(int(i));
if (ImGui::Selectable(scene_.scripts[i].name.c_str(), i == script_)) script_ = i;
if (ImGui::BeginPopupContextItem()) {
if (ImGui::MenuItem("Run it")) script_ = i, runScriptNow();
if (ImGui::MenuItem("Duplicate")) copy = i;
if (ImGui::MenuItem("Remove from the file")) remove = i;
ImGui::EndPopup();
}
ImGui::PopID();
}
if (scene_.scripts.empty()) ImGui::TextDisabled("None yet");
ImGui::Spacing();
if (ImGui::Button("+ New", ImVec2(-FLT_MIN, 0))) addScript("Script", kFirstScript);
if (ImGui::Button("Examples", ImVec2(-FLT_MIN, 0))) ImGui::OpenPopup("examples");
if (ImGui::BeginPopup("examples")) {
exampleItems(true);
ImGui::EndPopup();
}
if (ImGui::Button("Open...", ImVec2(-FLT_MIN, 0))) dialog(10);
ImGui::EndChild();
if (copy) addScript(scene_.scripts[*copy].name, scene_.scripts[*copy].code + "\n");
if (remove) {
scene_.scripts.erase(scene_.scripts.begin() + long(*remove));
if (script_ >= scene_.scripts.size()) script_ = scene_.scripts.empty() ? 0 : scene_.scripts.size() - 1;
dirty_ = true;
}
ImGui::SameLine();
ImGui::BeginGroup();
fry::SceneScript* sc = currentScript();
if (!sc) {
ImGui::Dummy(ImVec2(0, 40));
ImGui::TextWrapped("This file has no scripts yet. A script is Lua that builds or changes the scene; it's saved in the .fry "
"with the model.");
pushAccent();
if (ImGui::Button("+ New script")) addScript("Script", kFirstScript);
popAccent();
ImGui::SameLine();
if (ImGui::Button("What scripts can do")) addScript("Script", kFirstScript), showScriptHelp_ = true;
ImGui::EndGroup();
return ImGui::End();
}
pushAccent(); pushAccent();
if (ImGui::Button("Run (F5)")) runScriptNow(); if (ImGui::Button("Run (F5)")) runScriptNow();
popAccent(); popAccent();
ImGui::SameLine(); ImGui::SameLine();
if (ImGui::Button("Open...")) dialog(10); ImGui::SetNextItemWidth(std::max(80.0f, ImGui::GetContentRegionAvail().x - 190));
if (inputString("##name", sc->name)) dirty_ = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its name (click to change)");
ImGui::SameLine(); ImGui::SameLine();
if (ImGui::Button("Save as...")) dialog(11); if (ImGui::Button("Save as .lua")) 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(); ImGui::SameLine();
if (ImGui::Button(showScriptHelp_ ? "Hide help" : "?")) showScriptHelp_ = !showScriptHelp_; if (ImGui::Button(showScriptHelp_ ? "Hide help" : "?")) showScriptHelp_ = !showScriptHelp_;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Everything a script can use"); 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); const float logH = std::max(90.0f, ImGui::GetContentRegionAvail().y * 0.22f);
ImGui::PushFont(keel::ui::font(keel::ui::Font::Mono), 0.0f); ImGui::PushFont(keel::ui::font(keel::ui::Font::Mono), 0.0f);
@ -6474,10 +6860,11 @@ private:
ImGui::EndChild(); ImGui::EndChild();
} }
const ImVec2 editor(-FLT_MIN, std::max(120.0f, ImGui::GetContentRegionAvail().y - logH - ImGui::GetStyle().ItemSpacing.y)); 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, if (ImGui::InputTextMultiline("##code", sc->code.data(), sc->code.capacity() + 1, editor,
ImGuiInputTextFlags_AllowTabInput | ImGuiInputTextFlags_CallbackResize, growString, &scriptCode_); ImGuiInputTextFlags_AllowTabInput | ImGuiInputTextFlags_CallbackResize, growString, &sc->code))
dirty_ = true;
const bool typing = ImGui::IsItemActive(); const bool typing = ImGui::IsItemActive();
ImGui::BeginChild("log", ImVec2(0, 0), ImGuiChildFlags_FrameStyle); ImGui::BeginChild("log", ImVec2(-FLT_MIN, 0), ImGuiChildFlags_FrameStyle);
if (scriptLog_.empty()) ImGui::TextDisabled("What it prints shows here."); if (scriptLog_.empty()) ImGui::TextDisabled("What it prints shows here.");
for (const auto& [line, error] : scriptLog_) { for (const auto& [line, error] : scriptLog_) {
if (error) ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText)); if (error) ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText));
@ -6487,6 +6874,7 @@ private:
if (std::exchange(scriptLogToBottom_, false)) ImGui::SetScrollHereY(1.0f); if (std::exchange(scriptLogToBottom_, false)) ImGui::SetScrollHereY(1.0f);
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopFont(); ImGui::PopFont();
ImGui::EndGroup();
if (typing && ImGui::GetIO().KeyCtrl && (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) runScriptNow(); if (typing && ImGui::GetIO().KeyCtrl && (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) runScriptNow();
if (typing && ImGui::IsKeyPressed(ImGuiKey_F5, false)) runScriptNow(); if (typing && ImGui::IsKeyPressed(ImGuiKey_F5, false)) runScriptNow();
ImGui::End(); ImGui::End();
@ -6536,15 +6924,78 @@ private:
} }
void aboutWindow() { void aboutWindow() {
ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always); using keel::ui::Role;
const float w = 460.0f;
ImGui::SetNextWindowSize(ImVec2(w, 0), ImGuiCond_Always);
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("About Fry3DX", &showAbout_, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24));
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - 160) * 0.5f); const bool open = ImGui::Begin("About Fry3DX", &showAbout_, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking);
logoImage(160); ImGui::PopStyleVar();
const char* title = "Fry3DX — Frying 3D Extreme"; if (!open) return ImGui::End();
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title).x) * 0.5f); auto centred = [](const char* text, ImVec4 colour) {
ImGui::TextColored(accentText(), "%s", title); ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(text).x) * 0.5f);
ImGui::TextWrapped("A 3D modeller that does what you'd expect: pick a tool, click and drag. Made by Nexoniarz; open source (Apache License 2.0)."); ImGui::TextColored(colour, "%s", text);
};
// The logo, warmed from behind by the stove.
const float logo = 128.0f;
const ImVec2 at(ImGui::GetWindowPos().x + (ImGui::GetWindowWidth() - logo) * 0.5f, ImGui::GetCursorScreenPos().y + 4);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 mid(at.x + logo * 0.5f, at.y + logo * 0.62f);
for (int i = 16; i >= 1; --i) dl->AddCircleFilled(mid, logo * (0.3f + 0.04f * float(i)), keel::ui::accent(0.018f), 64);
ImGui::SetCursorScreenPos(at);
logoImage(logo);
ImGui::Dummy(ImVec2(0, 6));
ImGui::PushFont(keel::ui::font(keel::ui::Font::Title), 30.0f);
centred("Fry3DX", roleColor(Role::Text));
ImGui::PopFont();
centred("Frying 3D Extreme", accentText());
centred("Version " FRY_VERSION, roleColor(Role::TextSecondary));
ImGui::Dummy(ImVec2(0, 8));
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextSecondary));
ImGui::TextWrapped("A 3D modeller that does what you'd expect: pick a tool, click and drag. Model, sculpt, paint, render and get it ready "
"to print, and write a script when clicking gets repetitive.");
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, 4));
// What it's running on (for a bug report: Copy).
const keel::GpuInfo gpu = r_->gpuInfo();
const auto& machine = keel::sys::machine();
const std::vector<std::pair<const char*, std::string>> rows = {
{"Version", FRY_VERSION}, {"Drawn with", r_->apiName()}, {"Graphics", gpu.name.empty() ? "?" : gpu.name}, {"System", machine.os}, {"Processor", machine.cpu},
{"Plug-ins", std::format("{} loaded", std::count_if(plugins_.begin(), plugins_.end(), [](const PluginEntry& e) { return e.p && e.error.empty(); }))}};
ImGui::BeginChild("details", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
if (ImGui::BeginTable("details", 2, ImGuiTableFlags_SizingFixedFit)) {
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
for (const auto& [k, v] : rows) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextColored(roleColor(Role::TextTertiary), "%s", k);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", v.c_str());
}
ImGui::EndTable();
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, 4));
const float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3;
pushAccent();
if (ImGui::Button("Website", ImVec2(bw, 0))) SDL_OpenURL("https://nexoniarz.github.io/Fry3DX/");
popAccent();
ImGui::SameLine();
if (ImGui::Button("Source on GitHub", ImVec2(bw, 0))) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX");
ImGui::SameLine();
if (ImGui::Button("Copy details", ImVec2(bw, 0))) {
std::string text = "Fry3DX";
for (const auto& [k, v] : rows) text += std::format("\n{}: {}", k, v);
SDL_SetClipboardText(text.c_str());
say("The details are copied: paste them into a bug report");
}
ImGui::Dummy(ImVec2(0, 6));
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextTertiary));
ImGui::TextWrapped("Made by Nexoniarz, open source (Apache License 2.0). Built on SDL 3, Dear ImGui, Lua 5.4, GLM, zstd, stb, volk and VMA; "
"the look is Niveus.");
ImGui::PopStyleColor();
ImGui::End(); ImGui::End();
} }
}; };

View file

@ -46,7 +46,61 @@ const std::vector<MaterialPreset>& materialPresets() {
return list; return list;
} }
namespace {
std::string quoted(const std::string& s);
std::string readQuoted(std::istream& in);
std::vector<MaterialPreset>& own() {
static std::vector<MaterialPreset> list;
return list;
}
} // namespace
const std::vector<MaterialPreset>& ownMaterials() { return own(); }
std::string materialId(const std::string& name) {
std::string id;
for (char c : name) id += char(std::tolower(static_cast<unsigned char>(c)));
while (!id.empty() && id.back() == ' ') id.pop_back();
return id;
}
void addOwnMaterial(MaterialPreset m) {
if (m.id.empty()) m.id = materialId(m.name);
if (m.name.empty()) m.name = m.id;
m.metal = std::clamp(m.metal, 0.0f, 1.0f), m.rough = std::clamp(m.rough, 0.0f, 1.0f), m.color = glm::clamp(m.color, 0.0f, 1.0f);
for (MaterialPreset& o : own())
if (o.id == m.id) return void(o = std::move(m));
own().push_back(std::move(m));
}
bool removeOwnMaterial(const std::string& id) { return std::erase_if(own(), [&](const MaterialPreset& m) { return m.id == id; }) > 0; }
void clearOwnMaterials(const std::string& from) { std::erase_if(own(), [&](const MaterialPreset& m) { return m.from == from; }); }
std::string ownMaterialsText() {
std::ostringstream out;
out.precision(6);
for (const MaterialPreset& m : own())
if (m.from.empty())
out << "material " << quoted(m.name) << " " << m.color.r << " " << m.color.g << " " << m.color.b << " " << m.metal << " " << m.rough << " "
<< m.keepColor << "\n";
return out.str();
}
void parseOwnMaterials(const std::string& text) {
std::istringstream in(text);
for (std::string line; std::getline(in, line);) {
std::istringstream l(line);
std::string word;
if (!(l >> word) || word != "material") continue;
MaterialPreset m;
m.name = readQuoted(l);
if (l >> m.color.r >> m.color.g >> m.color.b >> m.metal >> m.rough >> m.keepColor; !m.name.empty() && l) addOwnMaterial(m);
}
}
const MaterialPreset* findMaterial(std::string_view id) { const MaterialPreset* findMaterial(std::string_view id) {
for (const MaterialPreset& m : own())
if (id == m.id) return &m;
for (const MaterialPreset& m : materialPresets()) for (const MaterialPreset& m : materialPresets())
if (id == m.id) return &m; if (id == m.id) return &m;
return nullptr; return nullptr;
@ -147,6 +201,8 @@ std::string sceneText(const Scene& scene) {
<< " " << k.ground << " " << k.background.r << " " << k.background.g << " " << k.background.b << " " << k.groundColor.r << " " << k.groundColor.g << " " << " " << k.ground << " " << k.background.r << " " << k.background.g << " " << k.background.b << " " << k.groundColor.r << " " << k.groundColor.g << " "
<< k.groundColor.b << " " << k.exposure << " " << k.ao << " " << k.bloom << " " << k.saturation << " " << k.vignette << " " << k.sharpen << " " << k.groundColor.b << " " << k.exposure << " " << k.ao << " " << k.bloom << " " << k.saturation << " " << k.vignette << " " << k.sharpen << " "
<< k.shadowSoft << "\n"; << k.shadowSoft << "\n";
// Scripts: their length, then the code as it is (any characters, any lines).
for (const SceneScript& s : scene.scripts) out << "script " << quoted(s.name) << " " << s.code.size() << "\n" << s.code << "\n";
for (const Reference& r : scene.references) for (const Reference& r : scene.references)
out << "reference " << quoted(r.path) << " " << axisName(r.axis) << " " << r.offset.x << " " << r.offset.y << " " << r.width << " " << r.opacity out << "reference " << quoted(r.path) << " " << axisName(r.axis) << " " << r.offset.x << " " << r.offset.y << " " << r.width << " " << r.opacity
<< " " << r.visible << "\n"; << " " << r.visible << "\n";
@ -240,6 +296,19 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
for (std::string line; std::getline(in, line);) { for (std::string line; std::getline(in, line);) {
std::istringstream l(line); std::istringstream l(line);
if (!(l >> word)) continue; if (!(l >> word)) continue;
if (word == "script" && !o) {
SceneScript sc;
sc.name = readQuoted(l);
size_t size = 0;
l >> size;
sc.code.resize(std::min<size_t>(size, size_t(64) << 20));
in.read(sc.code.data(), std::streamsize(sc.code.size()));
sc.code.resize(size_t(in.gcount()));
s.scripts.push_back(std::move(sc));
std::string rest;
std::getline(in, rest); // (the newline after it)
continue;
}
if (word == "reference") { if (word == "reference") {
Reference r; Reference r;
r.id = s.nextId++; r.id = s.nextId++;

View file

@ -121,8 +121,14 @@ struct Look {
float exposure = 1.0f, ao = 1.0f, bloom = 0.035f, saturation = 1.05f, vignette = 0.25f, sharpen = 0.3f, shadowSoft = 1.0f; float exposure = 1.0f, ao = 1.0f, bloom = 0.035f, saturation = 1.05f, vignette = 0.25f, sharpen = 0.3f, shadowSoft = 1.0f;
}; };
// A script kept in the file (the Script panel's list): run when you run it, never by itself.
struct SceneScript {
std::string name, code;
};
struct Scene { struct Scene {
Look look; Look look;
std::vector<SceneScript> scripts;
std::vector<Object> objects; std::vector<Object> objects;
std::vector<Folder> folders; std::vector<Folder> folders;
std::vector<Reference> references; std::vector<Reference> references;
@ -132,14 +138,25 @@ struct Scene {
// Materials to start from: a colour (unless `keepColor`: only how metal and rough it is) and // Materials to start from: a colour (unless `keepColor`: only how metal and rough it is) and
// how metal and rough. Shown in the view (Solid and Rendered) and exported with the object. // how metal and rough. Shown in the view (Solid and Rendered) and exported with the object.
struct MaterialPreset { struct MaterialPreset {
const char* id; // as scripts name it std::string id; // as scripts name it (lower case)
const char* name; // as shown std::string name; // as shown
glm::vec3 color; // sRGB glm::vec3 color; // sRGB
float metal, rough; float metal, rough;
bool keepColor = false; bool keepColor = false;
std::string from; // your own: who made it ("" saved from the picker; else a plug-in's or script's name)
}; };
const std::vector<MaterialPreset>& materialPresets(); const std::vector<MaterialPreset>& materialPresets();
const MaterialPreset* findMaterial(std::string_view id); // Your own, after those: saved from the picker (ownMaterialsText, kept by the app) or made by
// scripts and plug-ins (fry.material). One with the same id is replaced.
const std::vector<MaterialPreset>& ownMaterials();
void addOwnMaterial(MaterialPreset m);
bool removeOwnMaterial(const std::string& id);
void clearOwnMaterials(const std::string& from); // a plug-in's, when it's reloaded or turned off
std::string materialId(const std::string& name); // "Teal plastic" -> "teal plastic"
// The picker's (from == "") as text, and back (added to the rest).
std::string ownMaterialsText();
void parseOwnMaterials(const std::string& text);
const MaterialPreset* findMaterial(std::string_view id); // yours first
void applyMaterial(Object& o, const MaterialPreset& m); void applyMaterial(Object& o, const MaterialPreset& m);
glm::mat4 objectMatrix(const Object& o); glm::mat4 objectMatrix(const Object& o);

View file

@ -26,6 +26,8 @@ struct Context {
ScriptHost* host; ScriptHost* host;
std::chrono::steady_clock::time_point start; std::chrono::steady_clock::time_point start;
int made = 0; int made = 0;
std::string name; // the script's or plug-in's (materials it makes say so)
Plugin* plugin = nullptr; // when it's a plug-in's (fry.command, fry.plugin)
}; };
Context& context(lua_State* L) { Context& context(lua_State* L) {
@ -672,9 +674,77 @@ int fryShapes(lua_State* L) {
int fryMaterials(lua_State* L) { // fry.materials(): every material's name, for o.material = ... int fryMaterials(lua_State* L) { // fry.materials(): every material's name, for o.material = ...
lua_newtable(L); lua_newtable(L);
int i = 0; int i = 0;
for (const MaterialPreset& m : materialPresets()) lua_pushstring(L, m.id), lua_rawseti(L, -2, ++i); for (const MaterialPreset& m : ownMaterials()) lua_pushstring(L, m.id.c_str()), lua_rawseti(L, -2, ++i);
for (const MaterialPreset& m : materialPresets()) lua_pushstring(L, m.id.c_str()), lua_rawseti(L, -2, ++i);
return 1; return 1;
} }
// Reads t[key] (a table at index t) as a number, or `otherwise`.
double fieldNumber(lua_State* L, int t, const char* key, double otherwise) {
lua_getfield(L, t, key);
const double v = lua_isnil(L, -1) ? otherwise : luaL_checknumber(L, -1);
lua_pop(L, 1);
return v;
}
std::string fieldString(lua_State* L, int t, const char* key, const char* otherwise = "") {
lua_getfield(L, t, key);
std::string v = lua_isnil(L, -1) ? otherwise : luaL_checkstring(L, -1);
lua_pop(L, 1);
return v;
}
int fryMaterial(lua_State* L) { // fry.material{name = "Teal plastic", color = "#20a0a0", metal = 0, rough = 0.4, keep_color = false}
luaL_checktype(L, 1, LUA_TTABLE);
MaterialPreset m;
m.name = fieldString(L, 1, "name");
if (m.name.empty()) luaL_error(L, "a material needs a name: fry.material{name = \"...\", color = ...}");
m.id = materialId(m.name);
if (lua_getfield(L, 1, "color") != LUA_TNIL) m.color = toColor(L, lua_gettop(L));
else m.color = glm::vec3(0.8f);
lua_pop(L, 1);
m.metal = float(fieldNumber(L, 1, "metal", 0.0)), m.rough = float(fieldNumber(L, 1, "rough", 0.5));
lua_getfield(L, 1, "keep_color");
m.keepColor = lua_toboolean(L, -1);
lua_pop(L, 1);
m.from = context(L).name;
for (const MaterialPreset& b : materialPresets())
if (b.id == m.id) luaL_error(L, "\"%s\" is one of Fry3DX's own materials: give yours another name", m.name.c_str());
addOwnMaterial(m);
lua_pushstring(L, m.id.c_str());
return 1;
}
int fryPlugin(lua_State* L) { // fry.plugin{name = "Stairs and such", about = "..."}
Plugin* p = context(L).plugin;
if (!p) return 0; // (a plain script: nothing to name)
luaL_checktype(L, 1, LUA_TTABLE);
if (std::string n = fieldString(L, 1, "name"); !n.empty()) p->name = n;
p->about = fieldString(L, 1, "about");
return 0;
}
// fry.command{name = "Stairs", menu = "add", help = "...", settings = {{"steps", 12}, ...}, run = function(s) ... end}
int fryCommand(lua_State* L) {
Plugin* p = context(L).plugin;
if (!p) luaL_error(L, "fry.command is for plug-ins: put this script in the plug-ins folder (Script > Plug-ins)");
luaL_checktype(L, 1, LUA_TTABLE);
PluginCommand c;
c.name = fieldString(L, 1, "name");
if (c.name.empty()) luaL_error(L, "a command needs a name");
c.help = fieldString(L, 1, "help");
c.menu = lower(fieldString(L, 1, "menu", "script"));
if (c.menu != "add" && c.menu != "object" && c.menu != "script") luaL_error(L, "menu is \"add\", \"object\" or \"script\"");
if (lua_getfield(L, 1, "settings") == LUA_TTABLE) {
const int t = lua_gettop(L);
for (lua_Integer i = 1; lua_rawgeti(L, t, i) == LUA_TTABLE; ++i) {
lua_rawgeti(L, -1, 1), lua_rawgeti(L, -2, 2);
c.settings.push_back({luaL_checkstring(L, -2), luaL_optnumber(L, -1, 0)});
lua_pop(L, 3);
}
lua_pop(L, 1);
}
lua_pop(L, 1);
if (lua_getfield(L, 1, "run") != LUA_TFUNCTION) luaL_error(L, "a command needs run = function(settings) ... end");
c.ref = luaL_ref(L, LUA_REGISTRYINDEX);
p->commands.push_back(std::move(c));
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 {
@ -732,7 +802,8 @@ int fryHelp(lua_State* L) {
const luaL_Reg kFry[] = { 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}, {nullptr, nullptr}, {"vec", fryVec}, {"say", fryPrint}, {"frame", fryFrame}, {"help", fryHelp}, {"material", fryMaterial}, {"plugin", fryPlugin},
{"command", fryCommand}, {nullptr, nullptr},
}; };
// Stops a script that's run too long (an endless loop). // Stops a script that's run too long (an endless loop).
@ -762,14 +833,11 @@ int traceback(lua_State* L) {
return 1; return 1;
} }
} // namespace // A Lua with Lua's safe libraries and the fry library, `ctx` as its context.
lua_State* newState(size_t* used, Context* ctx) {
ScriptResult runScript(ScriptHost& host, const std::string& code, const std::string& name) { lua_State* L = lua_newstate(allocate, used);
size_t used = 0; if (!L) return nullptr;
lua_State* L = lua_newstate(allocate, &used); lua_pushlightuserdata(L, ctx);
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"); lua_setfield(L, LUA_REGISTRYINDEX, "fry.context");
const luaL_Reg libs[] = {{LUA_GNAME, luaopen_base}, {LUA_TABLIBNAME, luaopen_table}, {LUA_STRLIBNAME, luaopen_string}, 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}}; {LUA_MATHLIBNAME, luaopen_math}, {LUA_UTF8LIBNAME, luaopen_utf8}, {LUA_COLIBNAME, luaopen_coroutine}};
@ -790,33 +858,100 @@ ScriptResult runScript(ScriptHost& host, const std::string& code, const std::str
luaL_newlib(L, kFry); luaL_newlib(L, kFry);
lua_setglobal(L, "fry"); lua_setglobal(L, "fry");
lua_sethook(L, watchdog, LUA_MASKCOUNT, 100000); lua_sethook(L, watchdog, LUA_MASKCOUNT, 100000);
return L;
}
ScriptResult result; // Calls the function on top (under its `args`) with a traceback; the error, or empty.
std::string call(lua_State* L, int args) {
const int fn = lua_gettop(L) - args;
lua_pushcfunction(L, traceback); lua_pushcfunction(L, traceback);
lua_insert(L, fn);
std::string error;
try { try {
if (luaL_loadbuffer(L, code.data(), code.size(), ("=" + name).c_str()) != LUA_OK || lua_pcall(L, 0, 0, 1) != LUA_OK) { if (lua_pcall(L, args, 0, fn) != LUA_OK) error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "an error", lua_pop(L, 1);
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) } catch (const std::exception& e) { // (out of memory inside the modelling code)
result.ok = false; error = e.what();
result.error = e.what();
} }
lua_settop(L, fn - 1);
return error;
}
// Nothing selected that's gone.
void tidySelection(ScriptHost& host) {
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;
}
} // namespace
ScriptResult runScript(ScriptHost& host, const std::string& code, const std::string& name) {
size_t used = 0;
Context ctx{&host, std::chrono::steady_clock::now()};
ctx.name = name;
lua_State* L = newState(&used, &ctx);
if (!L) return {false, "out of memory"};
ScriptResult result;
if (luaL_loadbuffer(L, code.data(), code.size(), ("=" + name).c_str()) != LUA_OK) result.error = lua_tostring(L, -1);
else result.error = call(L, 0);
result.ok = result.error.empty();
result.objectsMade = ctx.made; result.objectsMade = ctx.made;
lua_close(L); lua_close(L);
// Nothing selected that's gone. tidySelection(host);
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; return result;
} }
struct Plugin::State {
size_t used = 0;
Context ctx{};
lua_State* L = nullptr;
};
Plugin::Plugin(std::string f) : file(std::move(f)), name(file), s_(std::make_unique<State>()) {
if (name.ends_with(".lua")) name.resize(name.size() - 4);
std::replace(name.begin(), name.end(), '_', ' ');
}
Plugin::~Plugin() {
if (s_->L) lua_close(s_->L);
}
bool Plugin::load(ScriptHost& host, const std::string& code, std::string* error) {
s_->ctx = Context{&host, std::chrono::steady_clock::now()};
s_->ctx.name = file, s_->ctx.plugin = this; // (its materials say which file: clearOwnMaterials(file))
s_->L = newState(&s_->used, &s_->ctx);
std::string e = !s_->L ? "out of memory"
: luaL_loadbuffer(s_->L, code.data(), code.size(), ("=" + file).c_str()) != LUA_OK ? lua_tostring(s_->L, -1)
: call(s_->L, 0);
tidySelection(host);
if (e.empty()) return true;
if (error) *error = e;
commands.clear();
return false;
}
ScriptResult Plugin::run(ScriptHost& host, size_t command, const std::vector<double>& settings) {
if (!s_->L || command >= commands.size()) return {false, "that command isn't there any more"};
const PluginCommand& c = commands[command];
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, c.ref);
lua_createtable(L, 0, int(c.settings.size()));
for (size_t i = 0; i < c.settings.size(); ++i)
lua_pushnumber(L, i < settings.size() ? settings[i] : c.settings[i].second), lua_setfield(L, -2, c.settings[i].first.c_str());
ScriptResult r;
r.error = call(L, 1);
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.
fry.add(shape, {setting = value, property = value, ...}) a library shape: fry.add("cube", {width = 2, color = "#ff8000"}) 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.shapes() every shape: {id, name, settings}
fry.materials() every material's name, for o.material fry.materials() every material's name, for o.material (yours first)
fry.material{name, color, metal, rough, keep_color} your own material, for o.material and the picker
fry.mesh(points, faces, name) your own: points {{x,y,z}, ...}, faces {{1,2,3,4}, ...} (counter-clockwise from outside) 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.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.extrude(outline, height, name) an outline of {x, y} points on the ground, made that tall
@ -839,6 +974,12 @@ An object (o):
o:sculpt(brush, at, radius, strength, times) brush: raise, dig, puff, shrink, smooth, o:sculpt(brush, at, radius, strength, times) brush: raise, dig, puff, shrink, smooth,
flatten, pinch, spread; at: a point on it (the scene's space). Made finer first if needed flatten, pinch, spread; at: a point on it (the scene's space). Made finer first if needed
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.
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.
run makes and changes things like a script does: one undo step, and an error takes it back
)"; )";
} }

View file

@ -7,8 +7,11 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <memory>
#include <set> #include <set>
#include <string> #include <string>
#include <utility>
#include <vector>
namespace fry { namespace fry {
@ -31,6 +34,35 @@ struct ScriptResult {
// Runs `code` once (a fresh Lua each time); `name` shows in errors ("hello.lua"). // 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"); ScriptResult runScript(ScriptHost& host, const std::string& code, const std::string& name = "script");
// Plug-ins: a script in the plug-ins folder, run once when Fry3DX starts and kept loaded. What
// it adds stays until it's turned off or reloaded: commands in the menus (fry.command), materials
// (fry.material, as any script can).
struct PluginCommand {
std::string name, help;
std::string menu; // "add", "object", or "script" (Script > Plug-ins)
std::vector<std::pair<std::string, double>> settings; // asked for first: (name, starting value)
int ref = 0; // (its run function, in the plug-in's Lua)
};
class Plugin {
public:
explicit Plugin(std::string file); // "stairs.lua": shows in errors, and as its name until fry.plugin{name=} says
~Plugin();
Plugin(const Plugin&) = delete;
Plugin& operator=(const Plugin&) = delete;
// Runs the plug-in's file (it registers what it adds). False, with the error, if it stopped.
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<double>& settings);
std::string file, name, about;
std::vector<PluginCommand> commands;
private:
struct State;
std::unique_ptr<State> s_;
};
// Every fry.* function and object property, one per line, for the Script panel's help. // Every fry.* function and object property, one per line, for the Script panel's help.
const char* scriptHelp(); const char* scriptHelp();

View file

@ -3,5 +3,5 @@ target_link_libraries(mesh_test PRIVATE fry_core)
add_test(NAME mesh_test COMMAND mesh_test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) add_test(NAME mesh_test COMMAND mesh_test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
add_executable(script_test script_test.cpp) add_executable(script_test script_test.cpp)
target_link_libraries(script_test PRIVATE fry_script) target_link_libraries(script_test PRIVATE fry_script)
target_compile_definitions(script_test PRIVATE FRY_SCRIPTS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/scripts") target_compile_definitions(script_test PRIVATE FRY_SCRIPTS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/scripts" FRY_PLUGINS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/plugins")
add_test(NAME script_test COMMAND script_test) add_test(NAME script_test COMMAND script_test)

View file

@ -413,6 +413,13 @@ int main() {
"loads its own file back the same, pictures exactly"); "loads its own file back the same, pictures exactly");
CHECK(std::filesystem::file_size(dir / "p.fry") < 256 * 256 * 4 / 8, "packed small (%zu bytes)", size_t(std::filesystem::file_size(dir / "p.fry"))); CHECK(std::filesystem::file_size(dir / "p.fry") < 256 * 256 * 4 / 8, "packed small (%zu bytes)", size_t(std::filesystem::file_size(dir / "p.fry")));
CHECK(loadPackage((dir / "a.fry").string()).has_value(), "opens the old text .fry too"); CHECK(loadPackage((dir / "a.fry").string()).has_value(), "opens the old text .fry too");
Scene sc = s; // scripts kept in it, any characters and lines (an "end" or "object" line in one too)
sc.scripts = {{"Town", "-- a town\nobject \"x\"\nend\nlocal s = \"\\n\"\n"}, {"Empty", ""}, {"Last", "print(1)"}};
CHECK(savePackage((dir / "s.fry").string(), sc), "saves scripts in it");
auto sb = loadPackage((dir / "s.fry").string());
CHECK(sb && sb->scripts.size() == 3 && sb->scripts[0].name == "Town" && sb->scripts[0].code == sc.scripts[0].code && sb->scripts[1].code.empty() &&
sb->scripts[2].code == "print(1)" && sb->objects.size() == 1 && sb->objects[0].name == s.objects[0].name,
"scripts come back the same, and the model with them");
Scene g = s; Scene g = s;
g.folders = {{50, "Parts", 0, true, true, false}, {51, "Screws", 50, false, true, true}}; g.folders = {{50, "Parts", 0, true, true, false}, {51, "Screws", 50, false, true, true}};
g.objects[0].folder = 51; g.objects[0].folder = 51;

View file

@ -2,6 +2,7 @@
// errors with their line, and an endless loop is stopped. // errors with their line, and an endless loop is stopped.
#include "script.h" #include "script.h"
#include <cmath>
#include <cstdio> #include <cstdio>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@ -67,6 +68,83 @@ int main() {
r = runScript(host, "fry.clear()"); r = runScript(host, "fry.clear()");
CHECK(r.ok && scene.objects.empty() && active == 0 && selected.empty(), "clear"); CHECK(r.ok && scene.objects.empty() && active == 0 && selected.empty(), "clear");
{ // Your own materials: from a script, found by name (any case), and as text and back.
clearOwnMaterials("mats.lua");
r = runScript(host, "fry.material{name = 'Teal Plastic', color = '#1f9a96', rough = 0.35}\nlocal o = fry.add('cube')\no.material = 'teal plastic'", "mats.lua");
CHECK(r.ok && findMaterial("teal plastic") && std::abs(scene.objects.back().rough - 0.35f) < 1e-4f && scene.objects.back().color.g > 0.5f,
"fry.material, then o.material (%s)", r.error.c_str());
CHECK(findMaterial("teal plastic")->from == "mats.lua", "it says which script made it");
r = runScript(host, "fry.material{name = 'Gold', color = '#ffffff'}");
CHECK(!r.ok, "not one of Fry3DX's own names");
addOwnMaterial({"", "Mine \"quoted\"", {0.1f, 0.2f, 0.3f}, 0.5f, 0.25f, true, ""});
const std::string text = ownMaterialsText();
removeOwnMaterial(materialId("Mine \"quoted\""));
CHECK(!findMaterial(materialId("Mine \"quoted\"")), "removed");
parseOwnMaterials(text);
const MaterialPreset* m = findMaterial(materialId("Mine \"quoted\""));
CHECK(m && m->keepColor && std::abs(m->metal - 0.5f) < 1e-4f && text.find("Teal") == std::string::npos, "the picker's own as text and back (only those)");
clearOwnMaterials("mats.lua");
CHECK(!findMaterial("teal plastic"), "a script's materials cleared");
}
{ // A plug-in: loaded once, its commands run later (with their settings), its errors taken back.
Plugin p("stairs_and_such.lua");
Scene scratch;
ScriptHost load{&scratch, &selected, &active, {}, {}, 2.0};
std::string error;
const bool ok = p.load(load, R"(
fry.plugin{name = "Boxes", about = "Rows of boxes"}
local made = 0
fry.command{name = "Row", menu = "add", settings = {{"count", 3}, {"gap", 1.5}}, run = function(s)
for i = 1, s.count do fry.add("cube").position = {i * s.gap, 0, 0} end
made = made + 1
print("made " .. made)
end}
fry.command{name = "Broken", run = function() fry.add("cube"); error("no") end}
fry.add("sphere") -- (loading touches only its own scratch scene)
)", &error);
CHECK(ok && p.name == "Boxes" && p.about == "Rows of boxes" && p.commands.size() == 2 && p.commands[0].menu == "add" && p.commands[1].menu == "script" &&
p.commands[0].settings.size() == 2 && p.commands[0].settings[1].second == 1.5,
"a plug-in loads and registers its commands (%s)", error.c_str());
Scene s2;
out.clear();
ScriptHost h{&s2, &selected, &active, [&](const std::string& t) { out += t + "\n"; }, {}, 2.0};
r = p.run(h, 0, {4, 2});
r = p.run(h, 0, {1, 2});
CHECK(r.ok && s2.objects.size() == 5 && std::abs(s2.objects[3].position.x - 8.0f) < 1e-4f && out.find("made 2") != std::string::npos,
"its command runs with the settings, and keeps its state between runs (%s)", r.error.c_str());
r = p.run(h, 1, {});
CHECK(!r.ok && r.error.find("no") != std::string::npos, "an error in a command comes back");
r = runScript(host, "fry.command{name = 'x', run = function() end}");
CHECK(!r.ok && r.error.find("plug-ins") != std::string::npos, "fry.command only in plug-ins");
Plugin bad("bad.lua");
CHECK(!bad.load(load, "fry.command{name = 'x'}", &error) && bad.commands.empty(), "a command with no run: it doesn't load");
}
// The plug-ins that come with it load.
for (const auto& e : std::filesystem::directory_iterator(FRY_PLUGINS_DIR)) {
std::ifstream in(e.path());
const std::string code((std::istreambuf_iterator<char>(in)), {});
Plugin p(e.path().filename().string());
Scene fresh;
std::set<uint32_t> sel;
uint32_t act = 0;
ScriptHost h{&fresh, &sel, &act, {}, {}, 20.0};
std::string error;
const bool ok = p.load(h, code, &error);
bool ran = true;
for (size_t c = 0; c < p.commands.size(); ++c) {
Scene s;
s.objects.push_back(Object{});
s.objects.back().id = s.nextId++, s.objects.back().name = "Rock", s.objects.back().mesh = makeCube();
std::set<uint32_t> picked{s.objects.back().id};
uint32_t last = s.objects.back().id;
ScriptHost run{&s, &picked, &last, {}, {}, 20.0};
const ScriptResult rr = p.run(run, c, {});
if (!rr.ok || s.objects.size() < 2) ran = false, error = p.commands[c].name + ": " + rr.error;
}
CHECK(ok && ran, "plug-in %s (%zu commands) %s", e.path().filename().string().c_str(), p.commands.size(), error.c_str());
clearOwnMaterials(e.path().filename().string());
}
// The examples that come with it all run. // The examples that come with it all run.
for (const auto& e : std::filesystem::directory_iterator(FRY_SCRIPTS_DIR)) { for (const auto& e : std::filesystem::directory_iterator(FRY_SCRIPTS_DIR)) {
std::ifstream in(e.path()); std::ifstream in(e.path());