diff --git a/src/app/Application.cpp b/src/app/Application.cpp index d307b7e..3b75c0d 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -107,6 +107,20 @@ void Application::run() { initImGui(); m_camera.init(m_config.cameraDistance); + m_camera.setState(m_config.cameraYaw, m_config.cameraPitch, + m_config.cameraDistance, + {m_config.cameraTargetX, m_config.cameraTargetY, m_config.cameraTargetZ}); + m_meshBuilder.setWarnOverlappingRoots(m_config.warnOverlappingRoots); + + // Restore last-opened file when none was provided on the command line + if (m_state.scadPath.empty() && !m_config.lastFilePath.empty()) { + std::filesystem::path lastPath(m_config.lastFilePath); + std::error_code ec; + if (std::filesystem::exists(lastPath, ec)) { + m_state.scadPath = lastPath; + m_firstMesh = false; // use restored camera, don't auto-fit + } + } if (!m_state.scadPath.empty()) { auto ext = m_state.scadPath.extension().string(); @@ -188,6 +202,26 @@ void Application::run() { } vkDeviceWaitIdle(m_ctx.device()); + + // Persist window state + int ww = 0, wh = 0; + glfwGetWindowSize(m_window, &ww, &wh); + if (ww > 0 && wh > 0) { + m_config.windowWidth = ww; + m_config.windowHeight = wh; + } + + // Persist camera state + m_config.cameraDistance = m_camera.distance(); + m_config.cameraYaw = m_camera.yaw(); + m_config.cameraPitch = m_camera.pitch(); + m_config.cameraTargetX = m_camera.target().x; + m_config.cameraTargetY = m_camera.target().y; + m_config.cameraTargetZ = m_camera.target().z; + + // Persist last opened file + m_config.lastFilePath = m_state.scadPath.string(); + m_config.save(Config::defaultPath()); } @@ -387,6 +421,9 @@ void Application::drawMenuBar() { if (ImGui::MenuItem("Presentation Mode", "P", m_presentationMode)) m_presentationMode = !m_presentationMode; ImGui::Separator(); + if (ImGui::MenuItem("Preferences...")) + m_showPrefs = true; + ImGui::Separator(); if (ImGui::BeginMenu("Rendering")) { bool isSolid = (m_renderMode == render::RenderMode::Solid); @@ -431,6 +468,44 @@ void Application::drawMenuBar() { ImGui::EndMenuBar(); } +// --------------------------------------------------------------------------- +// Preferences popup +// --------------------------------------------------------------------------- +void Application::drawPrefsPopup() { + if (m_showPrefs) { + ImGui::OpenPopup("Preferences"); + m_showPrefs = false; + } + + ImGui::SetNextWindowSize({360, 0}, ImGuiCond_Always); + if (ImGui::BeginPopupModal("Preferences", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { + + // ── Analysis ───────────────────────────────────────────────────── + ImGui::SeparatorText("Analysis"); + + bool prev = m_config.warnOverlappingRoots; + ImGui::Checkbox("Warn on overlapping root objects", &m_config.warnOverlappingRoots); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "After each build, test whether any top-level objects\n" + "overlap and warn if so. Has a small per-pair cost;\n" + "disable for large scenes with many root objects."); + if (m_config.warnOverlappingRoots != prev) { + m_meshBuilder.setWarnOverlappingRoots(m_config.warnOverlappingRoots); + if (!m_state.scadPath.empty()) + m_meshBuilder.requestBuild(m_state.scadPath); + } + + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::Button("Close", {80, 0})) + ImGui::CloseCurrentPopup(); + + ImGui::EndPopup(); + } +} + // --------------------------------------------------------------------------- // ImGui // --------------------------------------------------------------------------- @@ -565,6 +640,8 @@ void Application::drawImGui() { ImGui::EndPopup(); } + drawPrefsPopup(); + if (m_showAbout) ImGui::OpenPopup("About ChiselCAD"); diff --git a/src/app/Application.h b/src/app/Application.h index 55bc2a8..004772e 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -43,6 +43,7 @@ class Application { // ImGui drawing void drawMenuBar(); void drawImGui(); + void drawPrefsPopup(); // Camera / file helpers void fitToView(); @@ -99,6 +100,7 @@ class Application { // UI state bool m_showChiselPanel = true; bool m_showAbout = false; + bool m_showPrefs = false; float m_fontScale = 1.0f; // Export error shown in a modal diff --git a/src/app/Config.cpp b/src/app/Config.cpp index 8b8fb3b..85c1ad1 100644 --- a/src/app/Config.cpp +++ b/src/app/Config.cpp @@ -32,10 +32,17 @@ Config Config::load(const std::filesystem::path& path) { if (j.contains("windowWidth")) cfg.windowWidth = j["windowWidth"]; if (j.contains("windowHeight")) cfg.windowHeight = j["windowHeight"]; if (j.contains("cameraDistance")) cfg.cameraDistance = j["cameraDistance"]; + if (j.contains("cameraYaw")) cfg.cameraYaw = j["cameraYaw"].get(); + if (j.contains("cameraPitch")) cfg.cameraPitch = j["cameraPitch"].get(); + if (j.contains("cameraTargetX")) cfg.cameraTargetX = j["cameraTargetX"].get(); + if (j.contains("cameraTargetY")) cfg.cameraTargetY = j["cameraTargetY"].get(); + if (j.contains("cameraTargetZ")) cfg.cameraTargetZ = j["cameraTargetZ"].get(); + if (j.contains("lastFilePath")) cfg.lastFilePath = j["lastFilePath"]; if (j.contains("globalFn")) cfg.globalFn = j["globalFn"]; if (j.contains("globalFs")) cfg.globalFs = j["globalFs"]; if (j.contains("globalFa")) cfg.globalFa = j["globalFa"]; - if (j.contains("fontSize")) cfg.fontSize = j["fontSize"]; + if (j.contains("fontSize")) cfg.fontSize = j["fontSize"]; + if (j.contains("warnOverlappingRoots")) cfg.warnOverlappingRoots = j["warnOverlappingRoots"]; } catch (const std::exception& e) { spdlog::warn("Config load failed: {}", e.what()); } @@ -50,10 +57,17 @@ void Config::save(const std::filesystem::path& path) const { j["windowWidth"] = windowWidth; j["windowHeight"] = windowHeight; j["cameraDistance"] = cameraDistance; + j["cameraYaw"] = cameraYaw; + j["cameraPitch"] = cameraPitch; + j["cameraTargetX"] = cameraTargetX; + j["cameraTargetY"] = cameraTargetY; + j["cameraTargetZ"] = cameraTargetZ; + j["lastFilePath"] = lastFilePath; j["globalFn"] = globalFn; j["globalFs"] = globalFs; j["globalFa"] = globalFa; - j["fontSize"] = fontSize; + j["fontSize"] = fontSize; + j["warnOverlappingRoots"] = warnOverlappingRoots; std::ofstream f(path); f << j.dump(2); } catch (const std::exception& e) { diff --git a/src/app/Config.h b/src/app/Config.h index 692fbc7..92658b4 100644 --- a/src/app/Config.h +++ b/src/app/Config.h @@ -12,11 +12,21 @@ struct Config { int windowWidth = 1280; int windowHeight = 800; float cameraDistance = 50.0f; + float cameraYaw = 0.0f; + float cameraPitch = 0.4f; + float cameraTargetX = 0.0f; + float cameraTargetY = 0.0f; + float cameraTargetZ = 0.0f; + std::string lastFilePath; + double globalFn = 0.0; double globalFs = 2.0; double globalFa = 12.0; int fontSize = 1; // 0=small(0.85×), 1=normal(1.0×), 2=large(1.3×) + // Analysis preferences + bool warnOverlappingRoots = false; + static Config load(const std::filesystem::path& path); void save(const std::filesystem::path& path) const; diff --git a/src/app/MeshBuilder.cpp b/src/app/MeshBuilder.cpp index 351a641..4c89120 100644 --- a/src/app/MeshBuilder.cpp +++ b/src/app/MeshBuilder.cpp @@ -170,9 +170,9 @@ void MeshBuilder::buildOne(std::filesystem::path path, int gen) { csg::MeshCache cache; csg::MeshEvaluator meshEval(cache); meshEval.useManifoldSphere = m_useManifoldSphere.load(); - manifold::Manifold manifoldMesh; + std::vector rootMeshes; try { - manifoldMesh = meshEval.evaluate(scene); + rootMeshes = meshEval.evaluate(scene); } catch (const std::exception& e) { result->errorMsg = std::string("Mesh error: ") + e.what(); storeError(std::move(result)); @@ -184,18 +184,78 @@ void MeshBuilder::buildOne(std::filesystem::path path, int gen) { // ---- Phase: Converting to vertex buffers ---- m_phase = BuildPhase::Converting; - // Capture mesh properties before flat-shading (volume is exact, counts comparable) - { - result->volume = manifoldMesh.Volume(); - result->surfaceArea = manifoldMesh.SurfaceArea(); + // Each root is converted independently and appended — this keeps objects + // that are spatially inside other objects visible (no boolean union across roots). + std::vector rootVertStart; // per-root start index into result->verts + rootVertStart.reserve(rootMeshes.size()); + + for (const auto& m : rootMeshes) { + rootVertStart.push_back(static_cast(result->verts.size())); + + result->volume += m.Volume(); + result->surfaceArea += m.SurfaceArea(); - auto rawMesh = manifoldMesh.GetMeshGL(); - result->triCount = static_cast(rawMesh.triVerts.size() / 3); - result->vertCount = static_cast( + auto rawMesh = m.GetMeshGL(); + result->triCount += static_cast(rawMesh.triVerts.size() / 3); + result->vertCount += static_cast( rawMesh.numProp > 0 ? rawMesh.vertProperties.size() / rawMesh.numProp : 0); + + std::vector verts; + std::vector indices; + manifoldToMesh(m, verts, indices); + + // Offset indices by the current vertex count before appending + const auto base = static_cast(result->verts.size()); + for (auto& idx : indices) idx += base; + + result->verts.insert(result->verts.end(), verts.begin(), verts.end()); + result->indices.insert(result->indices.end(), indices.begin(), indices.end()); } - manifoldToMesh(manifoldMesh, result->verts, result->indices); + // ---- Optional: pairwise overlap detection ---- + if (m_warnOverlappingRoots.load() && rootMeshes.size() > 1) { + // Compute per-root AABB from the already-converted vertex data + const auto totalVerts = static_cast(result->verts.size()); + auto rootAABB = [&](std::size_t ri) -> std::pair { + uint32_t start = rootVertStart[ri]; + uint32_t end = (ri + 1 < rootVertStart.size()) + ? rootVertStart[ri + 1] : totalVerts; + glm::vec3 bmin{ 1e30f, 1e30f, 1e30f}; + glm::vec3 bmax{-1e30f, -1e30f, -1e30f}; + for (uint32_t vi = start; vi < end; ++vi) { + bmin = glm::min(bmin, result->verts[vi].pos); + bmax = glm::max(bmax, result->verts[vi].pos); + } + return {bmin, bmax}; + }; + + auto aabbOverlap = [](glm::vec3 mn1, glm::vec3 mx1, + glm::vec3 mn2, glm::vec3 mx2) { + return (mn1.x <= mx2.x && mx1.x >= mn2.x) && + (mn1.y <= mx2.y && mx1.y >= mn2.y) && + (mn1.z <= mx2.z && mx1.z >= mn2.z); + }; + + for (std::size_t i = 0; i < rootMeshes.size(); ++i) { + if (gen != m_currentGen.load()) return; // newer build queued — abort + auto [mn1, mx1] = rootAABB(i); + for (std::size_t j = i + 1; j < rootMeshes.size(); ++j) { + auto [mn2, mx2] = rootAABB(j); + if (!aabbOverlap(mn1, mx1, mn2, mx2)) continue; + + // AABBs overlap — run exact Manifold intersection + manifold::Manifold sect = rootMeshes[i] ^ rootMeshes[j]; + if (std::abs(sect.Volume()) > 1e-6) { + lang::Diagnostic warn; + warn.level = lang::DiagLevel::Warning; + warn.message = "Objects " + std::to_string(i + 1) + + " and " + std::to_string(j + 1) + + " overlap — wrap in union() or difference() if intentional"; + result->diags.push_back(std::move(warn)); + } + } + } + } result->elapsedMs = elapsedMs(); m_elapsedMs = result->elapsedMs; diff --git a/src/app/MeshBuilder.h b/src/app/MeshBuilder.h index 776abc0..216240f 100644 --- a/src/app/MeshBuilder.h +++ b/src/app/MeshBuilder.h @@ -54,7 +54,8 @@ class MeshBuilder { // discarded when poll() is next called. void requestBuild(std::filesystem::path path); - void setUseManifoldSphere(bool v) noexcept { m_useManifoldSphere.store(v); } + void setUseManifoldSphere(bool v) noexcept { m_useManifoldSphere.store(v); } + void setWarnOverlappingRoots(bool v) noexcept { m_warnOverlappingRoots.store(v); } // Call once per frame from the main (Vulkan) thread. // Returns a finished BuildResult when one is ready, nullptr otherwise. @@ -86,6 +87,7 @@ class MeshBuilder { // Incremented by requestBuild(); read by poll() to detect stale results. std::atomic m_currentGen{0}; std::atomic m_useManifoldSphere{false}; + std::atomic m_warnOverlappingRoots{false}; // Readable from main thread for UI without locks. std::atomic m_phase{BuildPhase::Idle}; diff --git a/src/csg/CsgEvaluator.cpp b/src/csg/CsgEvaluator.cpp index 41e2ebd..6ed44b2 100644 --- a/src/csg/CsgEvaluator.cpp +++ b/src/csg/CsgEvaluator.cpp @@ -20,6 +20,11 @@ CsgScene CsgEvaluator::evaluate(const ParseResult& result) { CsgScene CsgEvaluator::evaluate(const ParseResult& result, Interpreter& interp) { m_interp = &interp; + // Index module definitions by name for O(1) lookup during calls + m_moduleDefs.clear(); + for (const auto& def : result.moduleDefs) + m_moduleDefs[def.name] = &def; + CsgScene scene; scene.globalFn = result.globalFn; scene.globalFs = result.globalFs; @@ -32,6 +37,7 @@ CsgScene CsgEvaluator::evaluate(const ParseResult& result, Interpreter& interp) } m_interp = nullptr; + m_moduleDefs.clear(); return scene; } @@ -51,6 +57,8 @@ CsgNodePtr CsgEvaluator::evalNode(const AstNode& node, const glm::mat4& xform) { return evalIf(n, xform); else if constexpr (std::is_same_v) return evalFor(n, xform); + else if constexpr (std::is_same_v) + return evalModuleCall(n, xform); return nullptr; }, node); } @@ -250,4 +258,65 @@ CsgNodePtr CsgEvaluator::evalFor(const ForNode& node, const glm::mat4& xform) { return makeBoolean(std::move(u)); } +// --------------------------------------------------------------------------- +// Module call — bind args, evaluate body, restore environment +// --------------------------------------------------------------------------- +CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::mat4& xform) { + auto it = m_moduleDefs.find(call.name); + if (it == m_moduleDefs.end()) return nullptr; // undefined module + + const ModuleDef& def = *it->second; + + // Snapshot the interpreter env so we can restore it after the call + auto savedEnv = m_interp->snapshotEnv(); + + // Bind positional and named arguments to module parameters + std::size_t posIdx = 0; + for (const auto& arg : call.args) { + if (arg.name.empty()) { + // Positional: bind to parameter at posIdx + if (posIdx < def.params.size()) + m_interp->setVar(def.params[posIdx].name, + Value::fromNumber(m_interp->evalNumber(*arg.value))); + ++posIdx; + } else { + // Named: bind to the matching parameter + m_interp->setVar(arg.name, + Value::fromNumber(m_interp->evalNumber(*arg.value))); + } + } + + // Fill in defaults for parameters that were not supplied + for (std::size_t i = 0; i < def.params.size(); ++i) { + const auto& param = def.params[i]; + // Skip params already bound by positional or named args + bool alreadyBound = (i < posIdx); + if (!alreadyBound) { + for (const auto& arg : call.args) + if (arg.name == param.name) { alreadyBound = true; break; } + } + if (!alreadyBound && param.defaultVal) + m_interp->setVar(param.name, + Value::fromNumber(m_interp->evalNumber(*param.defaultVal))); + } + + // Evaluate the module body and collect geometry + std::vector all; + for (const auto& child : def.body) { + if (auto c = evalNode(*child, xform)) + all.push_back(std::move(c)); + } + + // Restore the caller's environment + m_interp->restoreEnv(std::move(savedEnv)); + + if (all.empty()) return nullptr; + if (all.size() == 1) return all[0]; + + CsgBoolean u; + u.op = CsgBoolean::Op::Union; + u.children = std::move(all); + return makeBoolean(std::move(u)); +} + } // namespace chisel::csg diff --git a/src/csg/CsgEvaluator.h b/src/csg/CsgEvaluator.h index c0afc94..441358d 100644 --- a/src/csg/CsgEvaluator.h +++ b/src/csg/CsgEvaluator.h @@ -3,6 +3,7 @@ #include "lang/AST.h" #include "lang/Interpreter.h" #include +#include namespace chisel::csg { @@ -24,12 +25,16 @@ class CsgEvaluator { private: chisel::lang::Interpreter* m_interp = nullptr; // non-owning, set during evaluate() + // Module definitions indexed by name — populated at evaluate() entry. + std::unordered_map m_moduleDefs; + CsgNodePtr evalNode(const chisel::lang::AstNode& node, const glm::mat4& xform); CsgNodePtr evalPrimitive(const chisel::lang::PrimitiveNode& p, const glm::mat4& xform); CsgNodePtr evalBoolean(const chisel::lang::BooleanNode& b, const glm::mat4& xform); CsgNodePtr evalTransform(const chisel::lang::TransformNode& t, const glm::mat4& xform); CsgNodePtr evalIf(const chisel::lang::IfNode& n, const glm::mat4& xform); CsgNodePtr evalFor(const chisel::lang::ForNode& n, const glm::mat4& xform); + CsgNodePtr evalModuleCall(const chisel::lang::ModuleCallNode& n, const glm::mat4& xform); glm::mat4 makeMatrix(const chisel::lang::TransformNode& t) const; }; diff --git a/src/csg/MeshEvaluator.cpp b/src/csg/MeshEvaluator.cpp index fee7268..c722bb1 100644 --- a/src/csg/MeshEvaluator.cpp +++ b/src/csg/MeshEvaluator.cpp @@ -68,23 +68,17 @@ static manifold::mat3x4 toAffine(const glm::mat4& m) { MeshEvaluator::MeshEvaluator(MeshCache& cache) : m_cache(cache) {} -manifold::Manifold MeshEvaluator::evaluate(const CsgScene& scene) { +std::vector MeshEvaluator::evaluate(const CsgScene& scene) { PrimitiveGen gen; gen.globalFn = scene.globalFn; gen.globalFs = scene.globalFs; gen.globalFa = scene.globalFa; gen.useManifoldSphere = useManifoldSphere; - if (scene.roots.empty()) - return {}; - - if (scene.roots.size() == 1) - return evalNode(*scene.roots[0], gen); - - // Multiple roots → union (OpenSCAD semantics) - manifold::Manifold result = evalNode(*scene.roots[0], gen); - for (std::size_t i = 1; i < scene.roots.size(); ++i) - result = result + evalNode(*scene.roots[i], gen); + std::vector result; + result.reserve(scene.roots.size()); + for (const auto& root : scene.roots) + result.push_back(evalNode(*root, gen)); return result; } diff --git a/src/csg/MeshEvaluator.h b/src/csg/MeshEvaluator.h index 33a30c3..0e9d870 100644 --- a/src/csg/MeshEvaluator.h +++ b/src/csg/MeshEvaluator.h @@ -3,14 +3,17 @@ #include "MeshCache.h" #include "PrimitiveGen.h" #include +#include namespace chisel::csg { // --------------------------------------------------------------------------- -// MeshEvaluator — converts a CsgScene into a single Manifold by evaluating +// MeshEvaluator — converts a CsgScene into Manifold meshes by evaluating // the CSG tree bottom-up using the Manifold boolean engine. // -// Multiple root nodes are implicitly unioned (OpenSCAD semantics). +// Each root node is evaluated independently and returned as a separate +// Manifold so that objects inside other objects remain visible (matching +// OpenSCAD preview semantics). The caller concatenates vertex buffers. // Results for individual subtrees are cached in MeshCache to avoid // recomputing unchanged geometry across reloads. // --------------------------------------------------------------------------- @@ -21,8 +24,8 @@ class MeshEvaluator { // Use Manifold's built-in sphere instead of the OpenSCAD-compatible UV sphere. bool useManifoldSphere = false; - // Evaluate the full scene; returns the combined mesh. - manifold::Manifold evaluate(const CsgScene& scene); + // Evaluate the full scene; returns one Manifold per root node. + std::vector evaluate(const CsgScene& scene); private: manifold::Manifold evalNode(const CsgNode& node, const PrimitiveGen& gen); diff --git a/src/editor/DiagnosticsPanel.cpp b/src/editor/DiagnosticsPanel.cpp index 1d73d96..b9e1da3 100644 --- a/src/editor/DiagnosticsPanel.cpp +++ b/src/editor/DiagnosticsPanel.cpp @@ -25,22 +25,32 @@ void DiagnosticsPanel::drawInline() { : ImVec4{1.0f, 0.8f, 0.3f, 1.0f}; ImGui::PushStyleColor(ImGuiCol_Text, col); - char label[512]; - std::snprintf(label, sizeof(label), "%d:%d: %s##diag%d", - d.loc.line + 1, d.loc.col + 1, d.message.c_str(), i); - - if (ImGui::Selectable(label)) { - std::filesystem::path filePath = - d.filePath.empty() ? m_scadPath : std::filesystem::path(d.filePath); - if (!filePath.empty()) - openInExternalEditor(filePath, - static_cast(d.loc.line + 1), - static_cast(d.loc.col + 1)); + + // Diagnostics with no file and no source location are runtime warnings + // (e.g. geometry analysis) — show as plain text with no line:col prefix. + const bool isRuntimeWarning = d.filePath.empty() && + d.loc == (chisel::lang::SourceLoc{}); + + if (isRuntimeWarning) { + ImGui::TextUnformatted(d.message.c_str()); + } else { + char label[512]; + std::snprintf(label, sizeof(label), "%d:%d: %s##diag%d", + d.loc.line + 1, d.loc.col + 1, d.message.c_str(), i); + + if (ImGui::Selectable(label)) { + std::filesystem::path filePath = + d.filePath.empty() ? m_scadPath : std::filesystem::path(d.filePath); + if (!filePath.empty()) + openInExternalEditor(filePath, + static_cast(d.loc.line + 1), + static_cast(d.loc.col + 1)); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Click to open in editor"); } - ImGui::PopStyleColor(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Click to open in editor"); + ImGui::PopStyleColor(); } } diff --git a/src/lang/AST.h b/src/lang/AST.h index b232f99..c3fb877 100644 --- a/src/lang/AST.h +++ b/src/lang/AST.h @@ -15,13 +15,14 @@ struct BooleanNode; struct TransformNode; struct IfNode; struct ForNode; +struct ModuleCallNode; // --------------------------------------------------------------------------- // AstNode — the top-level variant // All nodes are heap-allocated via unique_ptr so the tree is // easy to move/own and the variant stays small. // --------------------------------------------------------------------------- -using AstNode = std::variant; +using AstNode = std::variant; using AstNodePtr = std::unique_ptr; // --------------------------------------------------------------------------- @@ -124,12 +125,53 @@ struct AssignStmt { SourceLoc loc; }; +// --------------------------------------------------------------------------- +// ModuleParam — one formal parameter in a module definition +// --------------------------------------------------------------------------- +struct ModuleParam { + std::string name; + ExprPtr defaultVal; // nullptr means no default (required) +}; + +// --------------------------------------------------------------------------- +// ModuleDef — a user-defined module: module name(params) { body } +// --------------------------------------------------------------------------- +struct ModuleDef { + std::string name; + std::vector params; + std::vector body; + SourceLoc loc; +}; + +// --------------------------------------------------------------------------- +// ModuleArg — one actual argument in a module call +// --------------------------------------------------------------------------- +struct ModuleArg { + std::string name; // empty string = positional argument + ExprPtr value; +}; + +// --------------------------------------------------------------------------- +// ModuleCallNode — a call to a user-defined module: name(args) { children } +// --------------------------------------------------------------------------- +struct ModuleCallNode { + std::string name; + std::vector args; + std::vector children; // body passed as children (future use) + SourceLoc loc; +}; + +inline AstNodePtr makeModuleCall(ModuleCallNode n) { + return std::make_unique(std::move(n)); +} + // --------------------------------------------------------------------------- // ParseResult — the output of a successful parse // --------------------------------------------------------------------------- struct ParseResult { std::vector roots; // geometry-producing top-level nodes std::vector assignments; // variable assignments (x = expr;) + std::vector moduleDefs; // user-defined module definitions double globalFn = 0.0; // $fn if set at file scope (0 = unset) double globalFs = 2.0; // $fs default double globalFa = 12.0; // $fa default diff --git a/src/lang/Interpreter.h b/src/lang/Interpreter.h index 6c13387..22150f0 100644 --- a/src/lang/Interpreter.h +++ b/src/lang/Interpreter.h @@ -31,10 +31,14 @@ class Interpreter { // Missing elements default to 0.0. std::array evalVec3(const ExprNode& expr) const; - // For-loop variable binding — used by CsgEvaluator::evalFor. + // For-loop / module call variable binding. Value getVar(const std::string& name) const; void setVar(const std::string& name, Value val); + // Environment snapshot/restore for module call scoping. + std::unordered_map snapshotEnv() const { return m_env; } + void restoreEnv(std::unordered_map env) { m_env = std::move(env); } + private: std::unordered_map m_env; diff --git a/src/lang/Lexer.cpp b/src/lang/Lexer.cpp index 691899f..23f058d 100644 --- a/src/lang/Lexer.cpp +++ b/src/lang/Lexer.cpp @@ -26,6 +26,7 @@ static const std::unordered_map kKeywords = { {"if", TokenKind::If}, {"else", TokenKind::Else}, {"for", TokenKind::For}, + {"module", TokenKind::Module}, }; // --------------------------------------------------------------------------- diff --git a/src/lang/Parser.cpp b/src/lang/Parser.cpp index 4cbd5ca..5d4b504 100644 --- a/src/lang/Parser.cpp +++ b/src/lang/Parser.cpp @@ -69,6 +69,12 @@ void Parser::parseStatement(ParseResult& result) { return; } + // Module definition: module name(...) { ... } + if (check(TokenKind::Module)) { + parseModuleDef(result); + return; + } + // Variable assignment: ident = expr; (no '(' follows the ident) if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { parseAssignment(result); @@ -140,6 +146,12 @@ AstNodePtr Parser::parseNode() { case TokenKind::For: return parseFor(); + case TokenKind::Ident: + // Could be a module call: name(args) { ... } + if (peek(1).kind == TokenKind::LParen) + return parseModuleCall(); + return nullptr; + default: return nullptr; } @@ -514,6 +526,82 @@ ExprPtr Parser::parsePrimary() { return makeExpr(NumberLit{0.0, peek().loc}); } +// --------------------------------------------------------------------------- +// module — module name(param, param = default, ...) { body } +// --------------------------------------------------------------------------- +void Parser::parseModuleDef(ParseResult& result) { + const Token& kw = advance(); // consume 'module' + ModuleDef def; + def.loc = kw.loc; + def.name = expect(TokenKind::Ident, "expected module name").text; + + expect(TokenKind::LParen, "expected '(' after module name"); + while (!check(TokenKind::RParen) && !atEnd()) { + ModuleParam param; + param.name = expect(TokenKind::Ident, "expected parameter name").text; + if (match(TokenKind::Equals)) + param.defaultVal = parseExpr(); + def.params.push_back(std::move(param)); + if (!match(TokenKind::Comma)) break; + } + expect(TokenKind::RParen, "expected ')' after parameter list"); + + // Body must be a brace block for module definitions + expect(TokenKind::LBrace, "expected '{' for module body"); + while (!check(TokenKind::RBrace) && !atEnd()) { + auto child = parseNode(); + if (child) { + def.body.push_back(std::move(child)); + } else if (check(TokenKind::Semicolon)) { + advance(); + } else if (!check(TokenKind::RBrace)) { + // Handle assignments inside module body + if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { + // Variable assignment in module body — ignore for now (not scope-captured) + advance(); advance(); parseExpr(); match(TokenKind::Semicolon); + } else { + synchronize(); + } + } + } + expect(TokenKind::RBrace, "expected '}' to close module body"); + + result.moduleDefs.push_back(std::move(def)); +} + +// --------------------------------------------------------------------------- +// module call — name(arg, name = arg, ...) { optional children } +// --------------------------------------------------------------------------- +AstNodePtr Parser::parseModuleCall() { + const Token& name_tok = advance(); // consume identifier + ModuleCallNode node; + node.loc = name_tok.loc; + node.name = name_tok.text; + + expect(TokenKind::LParen, "expected '(' in module call"); + while (!check(TokenKind::RParen) && !atEnd()) { + ModuleArg arg; + // Named argument: ident = expr + if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { + arg.name = advance().text; // ident + advance(); // = + } + arg.value = parseExpr(); + node.args.push_back(std::move(arg)); + if (!match(TokenKind::Comma)) break; + } + expect(TokenKind::RParen, "expected ')' after module arguments"); + + // Optional children body (not yet used by the evaluator) + if (check(TokenKind::LBrace) || (!check(TokenKind::Semicolon) && !atEnd() && peek().kind != TokenKind::RBrace)) { + node.children = parseBody(); + } else { + match(TokenKind::Semicolon); + } + + return makeModuleCall(std::move(node)); +} + // --------------------------------------------------------------------------- // parseBody: { children* } or single child node // --------------------------------------------------------------------------- diff --git a/src/lang/Parser.h b/src/lang/Parser.h index cac4814..c995e28 100644 --- a/src/lang/Parser.h +++ b/src/lang/Parser.h @@ -38,6 +38,10 @@ class Parser { AstNodePtr parseTransform(TokenKind k); AstNodePtr parseIf(); AstNodePtr parseFor(); + AstNodePtr parseModuleCall(); + + // ---- module definitions ----------------------------------------------- + void parseModuleDef(ParseResult& result); // ---- expressions (Pratt parser) -------------------------------------- ExprPtr parseExpr(int minPrec = 0); diff --git a/src/lang/Token.h b/src/lang/Token.h index 47a1043..68789e4 100644 --- a/src/lang/Token.h +++ b/src/lang/Token.h @@ -47,9 +47,10 @@ enum class TokenKind : uint8_t { Mirror, // Control flow - If, // if - Else, // else - For, // for + If, // if + Else, // else + For, // for + Module, // module // Range separator Colon, // : diff --git a/src/render/Camera.cpp b/src/render/Camera.cpp index 7db8ff6..317bda7 100644 --- a/src/render/Camera.cpp +++ b/src/render/Camera.cpp @@ -15,6 +15,13 @@ void Camera::init(float distance) { m_distance = distance; } +void Camera::setState(float yaw, float pitch, float distance, glm::vec3 target) { + m_yaw = yaw; + m_pitch = pitch; + m_distance = distance; + m_target = target; +} + void Camera::onScroll(double dy) { m_distance -= static_cast(dy) * m_distance * 0.1f; m_distance = std::max(0.5f, m_distance); diff --git a/src/render/Camera.h b/src/render/Camera.h index 22c52c3..6b606fb 100644 --- a/src/render/Camera.h +++ b/src/render/Camera.h @@ -32,7 +32,12 @@ class Camera { // mesh appears centered in the visible area rather than the full framebuffer). void shiftTargetRight(float worldAmount); - float distance() const { return m_distance; } + float distance() const { return m_distance; } + float yaw() const { return m_yaw; } + float pitch() const { return m_pitch; } + glm::vec3 target() const { return m_target; } + + void setState(float yaw, float pitch, float distance, glm::vec3 target); glm::mat4 view() const; glm::mat4 projection(float aspectRatio) const; diff --git a/tests/module_test.scad b/tests/module_test.scad new file mode 100644 index 0000000..ded709b --- /dev/null +++ b/tests/module_test.scad @@ -0,0 +1,62 @@ +// module test — V2 Tier 3 +// Each scene is a translate() block; comment out all but one to isolate. +$fn = 32; + +// ─── Scene 1 (0, 0, 0): simple parameterized sphere module ──────────────── +module ball(r) { + sphere(r = r); +} +translate([0, 0, 0]) + ball(5); + +// ─── Scene 2 (25, 0, 0): module with default parameter ──────────────────── +module disk(r, h = 3) { + cylinder(r = r, h = h, center = true); +} +translate([25, 0, 0]) + disk(r = 6); + +// ─── Scene 3 (55, 0, 0): module called with all params ──────────────────── +translate([55, 0, 0]) + disk(r = 4, h = 8); + +// ─── Scene 4 (85, 0, 0): module with multiple primitives (union) ────────── +module capsule(r, h) { + cylinder(r = r, h = h, center = true); + translate([0, 0, h/2]) sphere(r = r); + translate([0, 0, -h/2]) sphere(r = r); +} +translate([85, 0, 0]) + capsule(r = 3, h = 10); + +// ─── Scene 5 (0, -40, 0): module called in a for loop ───────────────────── +module dot(r) { + sphere(r = r); +} +translate([0, -40, 0]) + for (i = [0:4]) + translate([i * 12, 0, 0]) dot(i + 1); + +// ─── Scene 6 (0, -80, 0): module using an expression as argument ─────────── +base = 4; +translate([0, -80, 0]) + for (i = [0:3]) + translate([i * 14, 0, 0]) disk(r = base + i, h = (i + 1) * 2); + +// ─── Scene 7 (0, -120, 0): rounded_box — minkowski of cube + sphere ──────── +module rounded_box(w, h, d, r) { + minkowski() { + cube([w - r*2, h - r*2, d - r*2], center = true); + sphere(r = r); + } +} +translate([0, -120, 0]) + rounded_box(w = 20, h = 12, d = 8, r = 2); + +// ─── Scene 8 (40, -120, 0): module env isolation — caller r unchanged ────── +r = 99; +translate([40, -120, 0]) + ball(6); +// sphere at (65,-120,0) should use r=99 (caller's env restored after ball()) +translate([65, -120, 0]) + sphere(r = r); diff --git a/tests/test_csg_evaluator.cpp b/tests/test_csg_evaluator.cpp index ef04657..74dfcaa 100644 --- a/tests/test_csg_evaluator.cpp +++ b/tests/test_csg_evaluator.cpp @@ -357,3 +357,95 @@ TEST_CASE("CsgEval:for empty range yields no geometry", "[csg]") { auto s = evaluate("for (i = [5:3]) sphere(r=1);"); REQUIRE(s.roots.empty()); } + +// --------------------------------------------------------------------------- +// User-defined modules +// --------------------------------------------------------------------------- +TEST_CASE("CsgEval:simple module call produces geometry", "[csg]") { + auto s = evaluate( + "module ball(r) { sphere(r=r); }" + "ball(5);" + ); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Sphere); + REQUIRE(asLeaf(s.roots[0]).params.at("r") == Approx(5.0)); +} + +TEST_CASE("CsgEval:module call with named args", "[csg]") { + auto s = evaluate( + "module pill(r, h) { cylinder(r=r, h=h); }" + "pill(h=10, r=3);" + ); + REQUIRE(s.roots.size() == 1); + const auto& leaf = asLeaf(s.roots[0]); + REQUIRE(leaf.kind == CsgLeaf::Kind::Cylinder); + REQUIRE(leaf.params.at("r") == Approx(3.0)); + REQUIRE(leaf.params.at("h") == Approx(10.0)); +} + +TEST_CASE("CsgEval:module call with default param", "[csg]") { + auto s = evaluate( + "module disk(r, h = 2) { cylinder(r=r, h=h); }" + "disk(r=6);" + ); + REQUIRE(s.roots.size() == 1); + const auto& leaf = asLeaf(s.roots[0]); + REQUIRE(leaf.params.at("r") == Approx(6.0)); + REQUIRE(leaf.params.at("h") == Approx(2.0)); +} + +TEST_CASE("CsgEval:module with multi-primitive body wraps in union", "[csg]") { + auto s = evaluate( + "module combo() { sphere(r=1); cube([2,2,2]); }" + "combo();" + ); + REQUIRE(s.roots.size() == 1); + const auto& b = asBool(s.roots[0]); + REQUIRE(b.op == CsgBoolean::Op::Union); + REQUIRE(b.children.size() == 2); +} + +TEST_CASE("CsgEval:module call restores caller env", "[csg]") { + // 'r' exists in caller scope; module should not clobber it + auto s = evaluate( + "module ball(r) { sphere(r=r); }" + "r = 99;" + "ball(3);" + "sphere(r=r);" + ); + REQUIRE(s.roots.size() == 2); + REQUIRE(asLeaf(s.roots[0]).params.at("r") == Approx(3.0)); + REQUIRE(asLeaf(s.roots[1]).params.at("r") == Approx(99.0)); +} + +TEST_CASE("CsgEval:undefined module call yields no geometry", "[csg]") { + // unknown_module() is not defined — should silently produce nothing + Lexer lexer("unknown_module(5);"); + auto tokens = lexer.tokenize(); + Parser parser(std::move(tokens)); + auto result = parser.parse(); + // Parser produces a ModuleCallNode (structural), no errors + CsgEvaluator ev; + auto s = ev.evaluate(result); + REQUIRE(s.roots.empty()); +} + +TEST_CASE("CsgEval:module call inherits outer transform", "[csg]") { + auto s = evaluate( + "module dot() { sphere(r=1); }" + "translate([4, 0, 0]) dot();" + ); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).transform[3][0] == Approx(4.0f)); +} + +TEST_CASE("CsgEval:module called multiple times", "[csg]") { + auto s = evaluate( + "module dot(r) { sphere(r=r); }" + "dot(1); dot(2); dot(3);" + ); + REQUIRE(s.roots.size() == 3); + REQUIRE(asLeaf(s.roots[0]).params.at("r") == Approx(1.0)); + REQUIRE(asLeaf(s.roots[1]).params.at("r") == Approx(2.0)); + REQUIRE(asLeaf(s.roots[2]).params.at("r") == Approx(3.0)); +} diff --git a/tests/test_lexer.cpp b/tests/test_lexer.cpp index 43296be..9ca0db1 100644 --- a/tests/test_lexer.cpp +++ b/tests/test_lexer.cpp @@ -60,6 +60,21 @@ TEST_CASE("Lexer:for keyword and colon", "[lexer]") { REQUIRE(kinds(":") == std::vector{TokenKind::Colon}); } +TEST_CASE("Lexer:module keyword", "[lexer]") { + REQUIRE(kinds("module") == std::vector{TokenKind::Module}); +} + +TEST_CASE("Lexer:module definition header", "[lexer]") { + // module foo(r, h) { + auto t = kinds("module foo(r, h) {"); + REQUIRE(t[0] == TokenKind::Module); + REQUIRE(t[1] == TokenKind::Ident); + REQUIRE(t[2] == TokenKind::LParen); + REQUIRE(t[3] == TokenKind::Ident); + REQUIRE(t[5] == TokenKind::Ident); + REQUIRE(t[6] == TokenKind::RParen); +} + TEST_CASE("Lexer:for range tokens", "[lexer]") { // for (i = [0:5]) → for ( i = [ 0 : 5 ] ) auto t = kinds("for (i = [0:5])"); diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index 6e46be7..7f0f10a 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -356,6 +356,68 @@ TEST_CASE("Parser:for with brace body", "[parser]") { REQUIRE(f.children.size() == 2); } +// --------------------------------------------------------------------------- +// Module definitions and calls +// --------------------------------------------------------------------------- +static const ModuleCallNode& asModuleCall(const AstNodePtr& n) { + return std::get(*n); +} + +TEST_CASE("Parser:module definition stored in moduleDefs", "[parser]") { + auto r = parse("module my_box(w, h) { cube([w, h, h]); }"); + REQUIRE(r.roots.empty()); + REQUIRE(r.moduleDefs.size() == 1); + REQUIRE(r.moduleDefs[0].name == "my_box"); + REQUIRE(r.moduleDefs[0].params.size() == 2); + REQUIRE(r.moduleDefs[0].params[0].name == "w"); + REQUIRE(r.moduleDefs[0].params[1].name == "h"); + REQUIRE(r.moduleDefs[0].body.size() == 1); +} + +TEST_CASE("Parser:module param with default value", "[parser]") { + auto r = parse("module pill(r = 2, h = 5) { cylinder(r=r, h=h); }"); + REQUIRE(r.moduleDefs.size() == 1); + REQUIRE(r.moduleDefs[0].params[0].name == "r"); + REQUIRE(r.moduleDefs[0].params[0].defaultVal != nullptr); + REQUIRE(r.moduleDefs[0].params[1].name == "h"); +} + +TEST_CASE("Parser:module call positional args", "[parser]") { + auto r = parse( + "module box(w, h) { cube([w, h, h]); }" + "box(10, 5);" + ); + REQUIRE(r.moduleDefs.size() == 1); + REQUIRE(r.roots.size() == 1); + auto& c = asModuleCall(r.roots[0]); + REQUIRE(c.name == "box"); + REQUIRE(c.args.size() == 2); + REQUIRE(c.args[0].name.empty()); // positional + REQUIRE(c.args[1].name.empty()); +} + +TEST_CASE("Parser:module call named args", "[parser]") { + auto r = parse( + "module pill(r, h) { cylinder(r=r, h=h); }" + "pill(h = 10, r = 3);" + ); + REQUIRE(r.roots.size() == 1); + auto& c = asModuleCall(r.roots[0]); + REQUIRE(c.args.size() == 2); + REQUIRE(c.args[0].name == "h"); + REQUIRE(c.args[1].name == "r"); +} + +TEST_CASE("Parser:multiple module defs", "[parser]") { + auto r = parse( + "module a() { sphere(r=1); }" + "module b() { cube([2,2,2]); }" + "a(); b();" + ); + REQUIRE(r.moduleDefs.size() == 2); + REQUIRE(r.roots.size() == 2); +} + // --------------------------------------------------------------------------- // Error recovery // ---------------------------------------------------------------------------