From 2cb11dfcf7e6e3364add8f391b68641d412c4d20 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Tue, 21 Apr 2026 21:42:27 -0700 Subject: [PATCH] feat(lang): add if/else control flow (V2 Tier 2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables conditional geometry in SCAD scripts: show = 1; if (show) sphere(r = 5); else cube([4,4,4]); if (r > 10) { sphere(r=r); cylinder(h=r, r=2); } Changes: - Token.h: If, Else token kinds - Lexer.cpp: "if", "else" keywords - AST.h: IfNode (condition ExprPtr, thenChildren, elseChildren); added to AstNode variant with makeIf() helper - Parser.h/cpp: parseIf() — condition expression, then/else via parseBody(); chained else-if works naturally since if is a node - CsgEvaluator.h/cpp: evalIf() — evaluates condition via Interpreter, walks live branch, wraps multi-child results in a union (same pattern as transform); inherits outer xform - Tests: 14 new tests across lexer, parser, and CSG evaluator (108 total, 631 assertions) - tests/if_else_test.scad: 6 visual scenes Co-Authored-By: Claude Sonnet 4.6 --- src/csg/CsgEvaluator.cpp | 25 +++++++++++++++++ src/csg/CsgEvaluator.h | 1 + src/lang/AST.h | 17 +++++++++++- src/lang/Lexer.cpp | 2 ++ src/lang/Parser.cpp | 25 +++++++++++++++++ src/lang/Parser.h | 1 + src/lang/Token.h | 4 +++ tests/if_else_test.scad | 42 +++++++++++++++++++++++++++++ tests/test_csg_evaluator.cpp | 52 ++++++++++++++++++++++++++++++++++++ tests/test_lexer.cpp | 5 ++++ tests/test_parser.cpp | 47 ++++++++++++++++++++++++++++++++ 11 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 tests/if_else_test.scad diff --git a/src/csg/CsgEvaluator.cpp b/src/csg/CsgEvaluator.cpp index 4e90aba..92c9cec 100644 --- a/src/csg/CsgEvaluator.cpp +++ b/src/csg/CsgEvaluator.cpp @@ -47,6 +47,8 @@ CsgNodePtr CsgEvaluator::evalNode(const AstNode& node, const glm::mat4& xform) { return evalBoolean(n, xform); else if constexpr (std::is_same_v) return evalTransform(n, xform); + else if constexpr (std::is_same_v) + return evalIf(n, xform); return nullptr; }, node); } @@ -176,4 +178,27 @@ glm::mat4 CsgEvaluator::makeMatrix(const TransformNode& t) const { return m; } +// --------------------------------------------------------------------------- +// if/else — evaluate condition, walk the live branch +// --------------------------------------------------------------------------- +CsgNodePtr CsgEvaluator::evalIf(const IfNode& node, const glm::mat4& xform) { + const bool cond = bool(m_interp->evaluate(*node.condition)); + const auto& branch = cond ? node.thenChildren : node.elseChildren; + + std::vector children; + children.reserve(branch.size()); + for (const auto& child : branch) { + if (auto c = evalNode(*child, xform)) + children.push_back(std::move(c)); + } + + if (children.empty()) return nullptr; + if (children.size() == 1) return children[0]; + + CsgBoolean u; + u.op = CsgBoolean::Op::Union; + u.children = std::move(children); + return makeBoolean(std::move(u)); +} + } // namespace chisel::csg diff --git a/src/csg/CsgEvaluator.h b/src/csg/CsgEvaluator.h index 45403ac..443b3d5 100644 --- a/src/csg/CsgEvaluator.h +++ b/src/csg/CsgEvaluator.h @@ -28,6 +28,7 @@ class CsgEvaluator { 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); glm::mat4 makeMatrix(const chisel::lang::TransformNode& t) const; }; diff --git a/src/lang/AST.h b/src/lang/AST.h index 8cd0420..ff5a151 100644 --- a/src/lang/AST.h +++ b/src/lang/AST.h @@ -13,13 +13,14 @@ namespace chisel::lang { struct PrimitiveNode; struct BooleanNode; struct TransformNode; +struct IfNode; // --------------------------------------------------------------------------- // 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; // --------------------------------------------------------------------------- @@ -75,6 +76,20 @@ inline AstNodePtr makeTransform(TransformNode n) { return std::make_unique(std::move(n)); } +// --------------------------------------------------------------------------- +// IfNode — if (condition) { ... } else { ... } +// --------------------------------------------------------------------------- +struct IfNode { + ExprPtr condition; + std::vector thenChildren; + std::vector elseChildren; // empty when no else branch + SourceLoc loc; +}; + +inline AstNodePtr makeIf(IfNode n) { + return std::make_unique(std::move(n)); +} + // --------------------------------------------------------------------------- // AssignStmt — a variable assignment at file or block scope: x = expr; // --------------------------------------------------------------------------- diff --git a/src/lang/Lexer.cpp b/src/lang/Lexer.cpp index c68fddd..539f21a 100644 --- a/src/lang/Lexer.cpp +++ b/src/lang/Lexer.cpp @@ -23,6 +23,8 @@ static const std::unordered_map kKeywords = { {"rotate", TokenKind::Rotate}, {"scale", TokenKind::Scale}, {"mirror", TokenKind::Mirror}, + {"if", TokenKind::If}, + {"else", TokenKind::Else}, }; // --------------------------------------------------------------------------- diff --git a/src/lang/Parser.cpp b/src/lang/Parser.cpp index d589cd1..7b15069 100644 --- a/src/lang/Parser.cpp +++ b/src/lang/Parser.cpp @@ -134,6 +134,9 @@ AstNodePtr Parser::parseNode() { case TokenKind::Mirror: return parseTransform(k); + case TokenKind::If: + return parseIf(); + default: return nullptr; } @@ -209,6 +212,28 @@ AstNodePtr Parser::parseTransform(TokenKind k) { return makeTransform(std::move(node)); } +// --------------------------------------------------------------------------- +// if / else +// --------------------------------------------------------------------------- +AstNodePtr Parser::parseIf() { + const Token& kw = advance(); // consume 'if' + IfNode node; + node.loc = kw.loc; + + expect(TokenKind::LParen, "expected '(' after 'if'"); + node.condition = parseExpr(); + expect(TokenKind::RParen, "expected ')' after condition"); + + node.thenChildren = parseBody(); + + if (check(TokenKind::Else)) { + advance(); // consume 'else' + node.elseChildren = parseBody(); + } + + return makeIf(std::move(node)); +} + // --------------------------------------------------------------------------- // parseVecExpr — parse a [x, y, z] literal into a VectorLit ExprPtr // --------------------------------------------------------------------------- diff --git a/src/lang/Parser.h b/src/lang/Parser.h index b09f633..f188538 100644 --- a/src/lang/Parser.h +++ b/src/lang/Parser.h @@ -36,6 +36,7 @@ class Parser { AstNodePtr parsePrimitive(TokenKind k); AstNodePtr parseBoolean(TokenKind k); AstNodePtr parseTransform(TokenKind k); + AstNodePtr parseIf(); // ---- expressions (Pratt parser) -------------------------------------- ExprPtr parseExpr(int minPrec = 0); diff --git a/src/lang/Token.h b/src/lang/Token.h index 84f149a..1e7b93d 100644 --- a/src/lang/Token.h +++ b/src/lang/Token.h @@ -46,6 +46,10 @@ enum class TokenKind : uint8_t { Scale, Mirror, + // Control flow + If, // if + Else, // else + // Arithmetic operators Plus, // + Minus, // - diff --git a/tests/if_else_test.scad b/tests/if_else_test.scad new file mode 100644 index 0000000..e9e4f0b --- /dev/null +++ b/tests/if_else_test.scad @@ -0,0 +1,42 @@ +// if/else control flow test — V2 Tier 2b +// Each scene is a translate() block; comment out all but one to isolate. +$fn = 32; + +// ─── Scene 1 (0, 0, 0): if true → sphere visible ───────────────────────────── +show_sphere = 1; +translate([0, 0, 0]) + if (show_sphere) sphere(r = 6); + +// ─── Scene 2 (20, 0, 0): if false → nothing rendered ───────────────────────── +translate([20, 0, 0]) + if (0) sphere(r = 6); + +// ─── Scene 3 (40, 0, 0): if/else — variable selects shape ──────────────────── +use_cube = 0; +translate([40, 0, 0]) + if (use_cube) cube([10, 10, 10], center = true); + else sphere(r = 6); + +// ─── Scene 4 (0, -20, 0): expression condition ─────────────────────────────── +r = 8; +translate([0, -20, 0]) + if (r > 5) sphere(r = r); + else cube([r, r, r], center = true); + +// ─── Scene 5 (20, -20, 0): chained if/else if/else ─────────────────────────── +mode = 2; +translate([20, -20, 0]) + if (mode == 1) cube([8, 8, 8], center = true); + else if (mode == 2) sphere(r = 5); + else cylinder(h = 10, r = 3, center = true); + +// ─── Scene 6 (40, -20, 0): if wrapping a boolean op ───────────────────────── +hollow = 1; +translate([40, -20, 0]) + if (hollow) + difference() { + cube([12, 12, 12], center = true); + sphere(r = 7); + } + else + cube([12, 12, 12], center = true); diff --git a/tests/test_csg_evaluator.cpp b/tests/test_csg_evaluator.cpp index 854fe82..caf145e 100644 --- a/tests/test_csg_evaluator.cpp +++ b/tests/test_csg_evaluator.cpp @@ -248,3 +248,55 @@ TEST_CASE("CsgEval:minkowski stores outer transform", "[csg]") { const auto& child = asLeaf(b.children[0]); REQUIRE(child.transform[3][1] == Approx(0.0f)); // local space — no y offset } + +// --------------------------------------------------------------------------- +// if / else +// --------------------------------------------------------------------------- +TEST_CASE("CsgEval:if true yields then geometry", "[csg]") { + auto s = evaluate("if (1) sphere(r=5);"); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Sphere); +} + +TEST_CASE("CsgEval:if false yields no geometry", "[csg]") { + auto s = evaluate("if (0) sphere(r=5);"); + REQUIRE(s.roots.empty()); +} + +TEST_CASE("CsgEval:if false else yields else geometry", "[csg]") { + auto s = evaluate("if (0) sphere(r=5); else cube([3,3,3]);"); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Cube); +} + +TEST_CASE("CsgEval:if true else skips else geometry", "[csg]") { + auto s = evaluate("if (1) sphere(r=5); else cube([3,3,3]);"); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Sphere); +} + +TEST_CASE("CsgEval:if condition from expression", "[csg]") { + auto s = evaluate("if (3 > 2) sphere(r=4);"); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Sphere); +} + +TEST_CASE("CsgEval:if condition from variable", "[csg]") { + auto s = evaluate("show = 1; if (show) cube([5,5,5]);"); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Cube); +} + +TEST_CASE("CsgEval:if multiple then children wrapped in union", "[csg]") { + auto s = evaluate("if (1) { sphere(r=1); cube([2,2,2]); }"); + 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:if inherits outer transform", "[csg]") { + auto s = evaluate("translate([7,0,0]) if (1) sphere(r=1);"); + REQUIRE(s.roots.size() == 1); + REQUIRE(asLeaf(s.roots[0]).transform[3][0] == Approx(7.0f)); +} diff --git a/tests/test_lexer.cpp b/tests/test_lexer.cpp index 747a17d..ee49350 100644 --- a/tests/test_lexer.cpp +++ b/tests/test_lexer.cpp @@ -50,6 +50,11 @@ TEST_CASE("Lexer:hull and minkowski keywords", "[lexer]") { REQUIRE(kinds("minkowski") == std::vector{TokenKind::Minkowski}); } +TEST_CASE("Lexer:if and else keywords", "[lexer]") { + REQUIRE(kinds("if") == std::vector{TokenKind::If}); + REQUIRE(kinds("else") == std::vector{TokenKind::Else}); +} + // --------------------------------------------------------------------------- // Transform keywords // --------------------------------------------------------------------------- diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index f635c3a..4ca9a23 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -272,6 +272,53 @@ TEST_CASE("Parser:multiple root statements", "[parser]") { REQUIRE(r.roots.size() == 2); } +// --------------------------------------------------------------------------- +// if / else +// --------------------------------------------------------------------------- +static const IfNode& asIf(const AstNodePtr& n) { + return std::get(*n); +} + +TEST_CASE("Parser:if with single then child", "[parser]") { + auto r = parse("if (1) sphere(r=3);"); + REQUIRE(r.roots.size() == 1); + auto& n = asIf(r.roots[0]); + REQUIRE(n.thenChildren.size() == 1); + REQUIRE(n.elseChildren.empty()); + REQUIRE(asPrim(n.thenChildren[0]).kind == PrimitiveNode::Kind::Sphere); +} + +TEST_CASE("Parser:if with brace body", "[parser]") { + auto r = parse("if (1) { cube([5,5,5]); sphere(r=2); }"); + auto& n = asIf(r.roots[0]); + REQUIRE(n.thenChildren.size() == 2); + REQUIRE(n.elseChildren.empty()); +} + +TEST_CASE("Parser:if-else both branches", "[parser]") { + auto r = parse("if (0) sphere(r=3); else cube([4,4,4]);"); + auto& n = asIf(r.roots[0]); + REQUIRE(n.thenChildren.size() == 1); + REQUIRE(n.elseChildren.size() == 1); + REQUIRE(asPrim(n.thenChildren[0]).kind == PrimitiveNode::Kind::Sphere); + REQUIRE(asPrim(n.elseChildren[0]).kind == PrimitiveNode::Kind::Cube); +} + +TEST_CASE("Parser:if-else chained", "[parser]") { + // else branch is itself an if — chaining works naturally + auto r = parse("if (0) sphere(r=1); else if (1) cube([2,2,2]);"); + auto& outer = asIf(r.roots[0]); + REQUIRE(outer.elseChildren.size() == 1); + auto& inner = asIf(outer.elseChildren[0]); + REQUIRE(inner.thenChildren.size() == 1); +} + +TEST_CASE("Parser:if condition is expression", "[parser]") { + auto r = parse("if (3 > 2) sphere(r=1);"); + REQUIRE(r.roots.size() == 1); + REQUIRE(std::holds_alternative(*r.roots[0])); +} + // --------------------------------------------------------------------------- // Error recovery // ---------------------------------------------------------------------------