From 1307c35ac6b17edd236cebbfd19a7ecd391faef2 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Wed, 22 Apr 2026 20:39:30 -0700 Subject: [PATCH 1/5] feat(lang): add user-defined modules (Tier 3) Implements module keyword, definition parsing, argument binding (positional and named), default parameters, scoped environment save/restore, and CSG evaluation. Adds 7 module-focused parser tests, 8 CSG evaluator tests, and a visual SCAD test file. Co-Authored-By: Claude Sonnet 4.6 --- src/csg/CsgEvaluator.cpp | 69 +++++++++++++++++++++++++++ src/csg/CsgEvaluator.h | 5 ++ src/lang/AST.h | 44 ++++++++++++++++- src/lang/Interpreter.h | 6 ++- src/lang/Lexer.cpp | 1 + src/lang/Parser.cpp | 88 ++++++++++++++++++++++++++++++++++ src/lang/Parser.h | 4 ++ src/lang/Token.h | 7 +-- tests/module_test.scad | 62 ++++++++++++++++++++++++ tests/test_csg_evaluator.cpp | 92 ++++++++++++++++++++++++++++++++++++ tests/test_lexer.cpp | 15 ++++++ tests/test_parser.cpp | 62 ++++++++++++++++++++++++ 12 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 tests/module_test.scad 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/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/tests/module_test.scad b/tests/module_test.scad new file mode 100644 index 0000000..8503f3b --- /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 // --------------------------------------------------------------------------- From 3ad416c127c079f66b72e112a8cbe3e65d9f0f5e Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Wed, 22 Apr 2026 21:18:02 -0700 Subject: [PATCH 2/5] fix(mesh): evaluate root nodes independently, not as a boolean union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level objects in a SCAD file are independent meshes — a union is an explicit operation the user writes. The previous code ran Manifold boolean union across all roots, silently removing any geometry interior to a larger sibling and causing contained objects to disappear from the rendered mesh. Each root is now evaluated to its own Manifold and the vertex/index buffers are concatenated without any inter-root boolean ops, matching OpenSCAD's preview behaviour. Co-Authored-By: Claude Sonnet 4.6 --- src/app/MeshBuilder.cpp | 32 +++++++++++++++++++++----------- src/csg/MeshEvaluator.cpp | 16 +++++----------- src/csg/MeshEvaluator.h | 11 +++++++---- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/app/MeshBuilder.cpp b/src/app/MeshBuilder.cpp index 351a641..c73593e 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,28 @@ 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). + for (const auto& m : rootMeshes) { + 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); - } - manifoldToMesh(manifoldMesh, result->verts, result->indices); + 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()); + } result->elapsedMs = elapsedMs(); m_elapsedMs = result->elapsedMs; 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); From c274310d11904c90fe319407df7e465414046d50 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Wed, 22 Apr 2026 21:37:58 -0700 Subject: [PATCH 3/5] feat(ui): add Preferences popup and overlapping-root warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Preferences modal (View → Preferences...) as the foundation for user-opt-in analysis flags. The first flag, "Warn on overlapping root objects", runs pairwise AABB-then-Manifold intersection checks on the worker thread after each build; any overlapping pair gets a yellow warning in the Diagnostics panel without a line:col prefix (runtime, not source). DiagnosticsPanel now shows runtime warnings (no file + zero SourceLoc) as plain non-clickable text instead of "1:1: message". Co-Authored-By: Claude Sonnet 4.6 --- src/app/Application.cpp | 43 ++++++++++++++++++++++++++++ src/app/Application.h | 8 ++++++ src/app/MeshBuilder.cpp | 50 +++++++++++++++++++++++++++++++++ src/app/MeshBuilder.h | 4 ++- src/editor/DiagnosticsPanel.cpp | 38 ++++++++++++++++--------- 5 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index d307b7e..4976516 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -387,6 +387,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 +434,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_prefs.warnOverlappingRoots; + ImGui::Checkbox("Warn on overlapping root objects", &m_prefs.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_prefs.warnOverlappingRoots != prev) { + m_meshBuilder.setWarnOverlappingRoots(m_prefs.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 +606,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..a065262 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(); @@ -96,9 +97,16 @@ class Application { bool m_presentationMode = false; double m_lastFrameTime = 0.0; + // User preferences (opt-in analysis / rendering flags) + struct AppPrefs { + bool warnOverlappingRoots = false; + }; + AppPrefs m_prefs; + // 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/MeshBuilder.cpp b/src/app/MeshBuilder.cpp index c73593e..4c89120 100644 --- a/src/app/MeshBuilder.cpp +++ b/src/app/MeshBuilder.cpp @@ -186,7 +186,12 @@ void MeshBuilder::buildOne(std::filesystem::path path, int gen) { // 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(); @@ -206,6 +211,51 @@ void MeshBuilder::buildOne(std::filesystem::path path, int gen) { result->verts.insert(result->verts.end(), verts.begin(), verts.end()); result->indices.insert(result->indices.end(), indices.begin(), indices.end()); } + + // ---- 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/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(); } } From 16c010694c721dc46234166a1874ab4d2364998b Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Wed, 22 Apr 2026 21:44:40 -0700 Subject: [PATCH 4/5] refactor(config): persist preferences in existing Config JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves warnOverlappingRoots out of the ephemeral AppPrefs struct and into Config, which already round-trips to ~/.chiselcad/config.json at startup and shutdown. Removes AppPrefs — it was a premature separation. Initialises MeshBuilder from config on startup so the preference survives restarts. Co-Authored-By: Claude Sonnet 4.6 --- src/app/Application.cpp | 9 +++++---- src/app/Application.h | 6 ------ src/app/Config.cpp | 6 ++++-- src/app/Config.h | 3 +++ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 4976516..9a134eb 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -107,6 +107,7 @@ void Application::run() { initImGui(); m_camera.init(m_config.cameraDistance); + m_meshBuilder.setWarnOverlappingRoots(m_config.warnOverlappingRoots); if (!m_state.scadPath.empty()) { auto ext = m_state.scadPath.extension().string(); @@ -450,15 +451,15 @@ void Application::drawPrefsPopup() { // ── Analysis ───────────────────────────────────────────────────── ImGui::SeparatorText("Analysis"); - bool prev = m_prefs.warnOverlappingRoots; - ImGui::Checkbox("Warn on overlapping root objects", &m_prefs.warnOverlappingRoots); + 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_prefs.warnOverlappingRoots != prev) { - m_meshBuilder.setWarnOverlappingRoots(m_prefs.warnOverlappingRoots); + if (m_config.warnOverlappingRoots != prev) { + m_meshBuilder.setWarnOverlappingRoots(m_config.warnOverlappingRoots); if (!m_state.scadPath.empty()) m_meshBuilder.requestBuild(m_state.scadPath); } diff --git a/src/app/Application.h b/src/app/Application.h index a065262..004772e 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -97,12 +97,6 @@ class Application { bool m_presentationMode = false; double m_lastFrameTime = 0.0; - // User preferences (opt-in analysis / rendering flags) - struct AppPrefs { - bool warnOverlappingRoots = false; - }; - AppPrefs m_prefs; - // UI state bool m_showChiselPanel = true; bool m_showAbout = false; diff --git a/src/app/Config.cpp b/src/app/Config.cpp index 8b8fb3b..a934672 100644 --- a/src/app/Config.cpp +++ b/src/app/Config.cpp @@ -35,7 +35,8 @@ Config Config::load(const std::filesystem::path& path) { 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()); } @@ -53,7 +54,8 @@ void Config::save(const std::filesystem::path& path) const { 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..36b8d7e 100644 --- a/src/app/Config.h +++ b/src/app/Config.h @@ -17,6 +17,9 @@ struct Config { 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; From ef3274c27ff7ee84d19f2cec55780914f7d1d793 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Wed, 22 Apr 2026 21:59:23 -0700 Subject: [PATCH 5/5] feat(config): persist window size, camera state, and last opened file On exit, saves the actual GLFW window dimensions, full camera state (yaw, pitch, distance, target), and the last opened file path to config. On startup, restores all three so the app resumes exactly where the user left off. Camera auto-fit is skipped when a saved file+camera are restored. Co-Authored-By: Claude Sonnet 4.6 --- src/app/Application.cpp | 33 +++++++++++++++++++++++++++++++++ src/app/Config.cpp | 12 ++++++++++++ src/app/Config.h | 7 +++++++ src/render/Camera.cpp | 7 +++++++ src/render/Camera.h | 7 ++++++- tests/module_test.scad | 2 +- 6 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 9a134eb..3b75c0d 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -107,8 +107,21 @@ 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(); for (auto& c : ext) c = static_cast(std::tolower(static_cast(c))); @@ -189,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()); } diff --git a/src/app/Config.cpp b/src/app/Config.cpp index a934672..85c1ad1 100644 --- a/src/app/Config.cpp +++ b/src/app/Config.cpp @@ -32,6 +32,12 @@ 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"]; @@ -51,6 +57,12 @@ 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; diff --git a/src/app/Config.h b/src/app/Config.h index 36b8d7e..92658b4 100644 --- a/src/app/Config.h +++ b/src/app/Config.h @@ -12,6 +12,13 @@ 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; 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 index 8503f3b..ded709b 100644 --- a/tests/module_test.scad +++ b/tests/module_test.scad @@ -59,4 +59,4 @@ 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); + sphere(r = r);