From 5001f0ad918e35a8b0720770881d4933ad3be17e Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 16:48:03 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(lang):=20Tier=20A=20language=20complet?= =?UTF-8?q?eness=20=E2=80=94=20ternary,=20indexing,=20let,=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - undef literal - Ternary operator: condition ? then : else - List/vector indexing: v[i], v[i][j] (chained postfix) - let expression: let(x=expr) body_expr (lexical scope) - let statement: let(x=expr) { geometry } (geometry scope) - User-defined functions: function f(params) = expr; (recursive) - for-loop now iterates full Values (supports vector elements) - Module call arg binding now preserves Value type (was always number) - New math: asin, acos, atan, atan2, norm, cross, sign - New builtins: concat, str, chr, ord, log10, len on strings - 17 new test cases, 115 new assertions Co-Authored-By: Claude Sonnet 4.6 --- docs/roadmap.md | 36 +++++ src/csg/CsgEvaluator.cpp | 53 +++++-- src/csg/CsgEvaluator.h | 1 + src/lang/AST.h | 50 +++++-- src/lang/Expr.h | 61 ++++++-- src/lang/Interpreter.cpp | 275 +++++++++++++++++++++++++++++++++---- src/lang/Interpreter.h | 23 ++-- src/lang/Lexer.cpp | 4 + src/lang/Parser.cpp | 132 +++++++++++++++++- src/lang/Parser.h | 10 +- src/lang/Token.h | 18 ++- tests/2d_extrude_test.scad | 2 +- tests/test_interpreter.cpp | 156 +++++++++++++++++++++ 13 files changed, 735 insertions(+), 86 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e2682c3..6f94069 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -27,6 +27,42 @@ - [ ] Extrusion: `linear_extrude`, `rotate_extrude` - [ ] `hull()` and `minkowski()` +## v2.5 — OpenSCAD Language Completeness + +### Tier A — High Impact (used constantly) +- [ ] List indexing `v[i]` +- [ ] Ternary operator `condition ? a : b` +- [ ] User-defined functions `function f(x) = expr;` +- [ ] `let` expression `let (x=10) child` +- [ ] `undef` literal +- [ ] `concat()` built-in + +### Tier B — Math & String Completeness +- [ ] Inverse trig: `asin()`, `acos()`, `atan()`, `atan2()` +- [ ] Vector math: `norm()`, `cross()`, `sign()` +- [ ] `rands()`, `lookup()` +- [ ] String literals + `str()`, `chr()`, `ord()` +- [ ] `len()` on strings + +### Tier C — Module System Completeness +- [ ] `children()` / `$children` +- [ ] `echo()` +- [ ] `assert()` +- [ ] Recursive functions (enabled by user-defined functions) + +### Tier D — Geometry Operations +- [ ] `multmatrix()` +- [ ] `color()` +- [ ] `offset()` +- [ ] `projection()` +- [ ] `render()` + +### Tier E — File I/O (complex) +- [ ] `include <>` / `use <>` +- [ ] `import()` +- [ ] `surface()` +- [ ] `text()` (requires font rendering — significant work) + ## v3 — Tooling & Visual Quality - [ ] VS Code LSP extension (syntax highlighting, error squiggles, completions) diff --git a/src/csg/CsgEvaluator.cpp b/src/csg/CsgEvaluator.cpp index b0c0f02..c69993e 100644 --- a/src/csg/CsgEvaluator.cpp +++ b/src/csg/CsgEvaluator.cpp @@ -14,6 +14,7 @@ static constexpr double kDeg2Rad = 3.14159265358979323846 / 180.0; CsgScene CsgEvaluator::evaluate(const ParseResult& result) { Interpreter defaultInterp; defaultInterp.loadAssignments(result); + defaultInterp.loadFunctions(result); return evaluate(result, defaultInterp); } @@ -61,6 +62,8 @@ CsgNodePtr CsgEvaluator::evalNode(const AstNode& node, const glm::mat4& xform) { return evalModuleCall(n, xform); else if constexpr (std::is_same_v) return evalExtrusion(n, xform); + else if constexpr (std::is_same_v) + return evalLet(n, xform); return nullptr; }, node); } @@ -307,7 +310,7 @@ CsgNodePtr CsgEvaluator::evalIf(const IfNode& node, const glm::mat4& xform) { // --------------------------------------------------------------------------- CsgNodePtr CsgEvaluator::evalFor(const ForNode& node, const glm::mat4& xform) { // Build the sequence of iteration values - std::vector values; + std::vector values; static constexpr int kMaxIter = 10000; if (node.range.isRange) { @@ -319,20 +322,21 @@ CsgNodePtr CsgEvaluator::evalFor(const ForNode& node, const glm::mat4& xform) { if (step == 0.0) return nullptr; if (step > 0.0) for (double v = start; v <= end + 1e-10 && (int)values.size() < kMaxIter; v += step) - values.push_back(v); + values.push_back(Value::fromNumber(v)); else for (double v = start; v >= end - 1e-10 && (int)values.size() < kMaxIter; v += step) - values.push_back(v); + values.push_back(Value::fromNumber(v)); } else { + // List form — preserve full Value (supports iterating over vectors) for (const auto& e : node.range.list) - values.push_back(m_interp->evalNumber(*e)); + values.push_back(m_interp->evaluate(*e)); } // Save the loop variable's current binding, iterate, then restore const Value saved = m_interp->getVar(node.var); std::vector all; - for (double v : values) { - m_interp->setVar(node.var, Value::fromNumber(v)); + for (const Value& v : values) { + m_interp->setVar(node.var, v); for (const auto& child : node.children) { if (auto c = evalNode(*child, xform)) all.push_back(std::move(c)); @@ -365,30 +369,25 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m 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))); + m_interp->evaluate(*arg.value)); ++posIdx; } else { - // Named: bind to the matching parameter - m_interp->setVar(arg.name, - Value::fromNumber(m_interp->evalNumber(*arg.value))); + m_interp->setVar(arg.name, m_interp->evaluate(*arg.value)); } } - // Fill in defaults for parameters that were not supplied + // Fill in defaults for parameters 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))); + m_interp->setVar(param.name, m_interp->evaluate(*param.defaultVal)); } // Evaluate the module body and collect geometry @@ -449,4 +448,28 @@ CsgNodePtr CsgEvaluator::evalExtrusion(const ExtrusionNode& e, const glm::mat4& return makeExtrusion(std::move(ext)); } +// --------------------------------------------------------------------------- +// let — bind variables in scope, evaluate children, restore +// --------------------------------------------------------------------------- +CsgNodePtr CsgEvaluator::evalLet(const LetNode& node, const glm::mat4& xform) { + auto savedEnv = m_interp->snapshotEnv(); + for (const auto& [name, valExpr] : node.bindings) + m_interp->setVar(name, m_interp->evaluate(*valExpr)); + + std::vector all; + for (const auto& child : node.children) { + if (auto c = evalNode(*child, xform)) + all.push_back(std::move(c)); + } + m_interp->restoreEnv(std::move(savedEnv)); + + if (all.empty()) return nullptr; + if (all.size() == 1) return std::move(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 a42696f..2ca1ed7 100644 --- a/src/csg/CsgEvaluator.h +++ b/src/csg/CsgEvaluator.h @@ -36,6 +36,7 @@ class CsgEvaluator { CsgNodePtr evalFor(const chisel::lang::ForNode& n, const glm::mat4& xform); CsgNodePtr evalModuleCall(const chisel::lang::ModuleCallNode& n, const glm::mat4& xform); CsgNodePtr evalExtrusion(const chisel::lang::ExtrusionNode& e, const glm::mat4& xform); + CsgNodePtr evalLet(const chisel::lang::LetNode& 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 464e47a..aa541ab 100644 --- a/src/lang/AST.h +++ b/src/lang/AST.h @@ -17,13 +17,12 @@ struct IfNode; struct ForNode; struct ModuleCallNode; struct ExtrusionNode; +struct LetNode; // --------------------------------------------------------------------------- // 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; // --------------------------------------------------------------------------- @@ -184,16 +183,49 @@ inline AstNodePtr makeExtrusion(ExtrusionNode n) { return std::make_unique(std::move(n)); } +// --------------------------------------------------------------------------- +// FunctionParam — one formal parameter in a function definition +// --------------------------------------------------------------------------- +struct FunctionParam { + std::string name; + ExprPtr defaultVal; // nullptr means no default (required) +}; + +// --------------------------------------------------------------------------- +// FunctionDef — user-defined function: function name(params) = expr; +// --------------------------------------------------------------------------- +struct FunctionDef { + std::string name; + std::vector params; + ExprPtr body; // expression — not a geometry block + SourceLoc loc; +}; + +// --------------------------------------------------------------------------- +// LetNode — statement-level let: let(x = expr) { children } +// Creates a scoped variable binding for child geometry. +// --------------------------------------------------------------------------- +struct LetNode { + std::vector> bindings; + std::vector children; + SourceLoc loc; +}; + +inline AstNodePtr makeLetNode(LetNode 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 + std::vector roots; // geometry-producing top-level nodes + std::vector assignments; // variable assignments (x = expr;) + std::vector moduleDefs; // user-defined module definitions + std::vector functionDefs; // user-defined function 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 }; } // namespace chisel::lang diff --git a/src/lang/Expr.h b/src/lang/Expr.h index be94803..a5437b9 100644 --- a/src/lang/Expr.h +++ b/src/lang/Expr.h @@ -2,25 +2,30 @@ #include "Token.h" #include #include +#include #include #include namespace chisel::lang { // --------------------------------------------------------------------------- -// Forward declarations — allows ExprPtr to be used inside node structs -// before ExprNode is fully defined (same pattern as AST.h). +// Forward declarations // --------------------------------------------------------------------------- struct NumberLit; struct BoolLit; +struct UndefLit; struct VectorLit; struct VarRef; struct BinaryExpr; struct UnaryExpr; +struct TernaryExpr; +struct IndexExpr; +struct LetExpr; struct FunctionCall; -using ExprNode = std::variant; +using ExprNode = std::variant; using ExprPtr = std::unique_ptr; template @@ -41,8 +46,12 @@ struct BoolLit { SourceLoc loc; }; +struct UndefLit { + SourceLoc loc; +}; + struct VectorLit { - std::vector elements; // each element is an expression + std::vector elements; SourceLoc loc; }; @@ -56,10 +65,10 @@ struct VarRef { // --------------------------------------------------------------------------- struct BinaryExpr { enum class Op { - Add, Sub, Mul, Div, Mod, // arithmetic - Eq, Ne, // equality - Lt, Le, Gt, Ge, // comparison - And, Or // logical + Add, Sub, Mul, Div, Mod, + Eq, Ne, + Lt, Le, Gt, Ge, + And, Or }; Op op; ExprPtr left; @@ -74,10 +83,38 @@ struct UnaryExpr { SourceLoc loc; }; +// condition ? then : else_ +struct TernaryExpr { + ExprPtr condition; + ExprPtr then; + ExprPtr else_; + SourceLoc loc; +}; + +// target[index] +struct IndexExpr { + ExprPtr target; + ExprPtr index; + SourceLoc loc; +}; + +// let(x = expr, ...) body_expr +struct LetExpr { + std::vector> bindings; + ExprPtr body; + SourceLoc loc; +}; + +// One argument in a function call — named or positional +struct FunctionArg { + std::string name; // empty = positional + ExprPtr value; +}; + struct FunctionCall { - std::string name; - std::vector args; // positional arguments - SourceLoc loc; + std::string name; + std::vector args; + SourceLoc loc; }; } // namespace chisel::lang diff --git a/src/lang/Interpreter.cpp b/src/lang/Interpreter.cpp index b8a7e59..5db3159 100644 --- a/src/lang/Interpreter.cpp +++ b/src/lang/Interpreter.cpp @@ -1,7 +1,9 @@ #include "Interpreter.h" #include +#include static constexpr double kDeg2Rad = 3.14159265358979323846 / 180.0; +static constexpr double kRad2Deg = 180.0 / 3.14159265358979323846; namespace chisel::lang { @@ -9,15 +11,19 @@ namespace chisel::lang { // Environment loading // --------------------------------------------------------------------------- void Interpreter::loadAssignments(const ParseResult& result) { - for (const auto& stmt : result.assignments) { + for (const auto& stmt : result.assignments) m_env[stmt.name] = evaluate(*stmt.value); - } +} + +void Interpreter::loadFunctions(const ParseResult& result) { + for (const auto& def : result.functionDefs) + m_funcDefs[def.name] = &def; } // --------------------------------------------------------------------------- // evaluate — dispatch on ExprNode variant // --------------------------------------------------------------------------- -Value Interpreter::evaluate(const ExprNode& expr) const { +Value Interpreter::evaluate(const ExprNode& expr) { return std::visit([&](const auto& node) -> Value { using T = std::decay_t; @@ -28,6 +34,9 @@ Value Interpreter::evaluate(const ExprNode& expr) const { else if constexpr (std::is_same_v) { return Value::fromBool(node.value); } + else if constexpr (std::is_same_v) { + return Value::undef(); + } else if constexpr (std::is_same_v) { std::vector elems; elems.reserve(node.elements.size()); @@ -40,7 +49,7 @@ Value Interpreter::evaluate(const ExprNode& expr) const { else if constexpr (std::is_same_v) { auto it = m_env.find(node.name); if (it != m_env.end()) return it->second; - return Value::undef(); // undefined variable → undef + return Value::undef(); } // ---- Binary expression ---- @@ -70,12 +79,33 @@ Value Interpreter::evaluate(const ExprNode& expr) const { } } + // Vector + vector component-wise + if (lv.isVector() && rv.isVector() && + (node.op == BinaryExpr::Op::Add || node.op == BinaryExpr::Op::Sub)) { + const auto& lv_ = lv.asVec(); + const auto& rv_ = rv.asVec(); + std::size_t n = std::min(lv_.size(), rv_.size()); + std::vector out; + out.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + if (lv_[i].isNumber() && rv_[i].isNumber()) { + double l = lv_[i].asNumber(), r = rv_[i].asNumber(); + out.push_back(Value::fromNumber( + node.op == BinaryExpr::Op::Add ? l + r : l - r)); + } else { + out.push_back(Value::undef()); + } + } + return Value::fromVec(std::move(out)); + } + // Logical / mixed-type fallback auto valEq = [](const Value& a, const Value& b) -> bool { if (a.tag != b.tag) return false; if (a.isNumber()) return a.asNumber() == b.asNumber(); if (a.isBool()) return a.asBool() == b.asBool(); - return false; // undef/vector/string: never equal here + if (a.isUndef()) return true; + return false; }; switch (node.op) { case BinaryExpr::Op::And: return Value::fromBool(bool(lv) && bool(rv)); @@ -93,19 +123,100 @@ Value Interpreter::evaluate(const ExprNode& expr) const { switch (node.op) { case UnaryExpr::Op::Neg: if (v.isNumber()) return Value::fromNumber(-v.asNumber()); + if (v.isVector()) { + std::vector out; + out.reserve(v.asVec().size()); + for (const auto& e : v.asVec()) + out.push_back(e.isNumber() ? Value::fromNumber(-e.asNumber()) : Value::undef()); + return Value::fromVec(std::move(out)); + } return Value::undef(); case UnaryExpr::Op::Not: return Value::fromBool(!bool(v)); } } + // ---- Ternary expression ---- + else if constexpr (std::is_same_v) { + return bool(evaluate(*node.condition)) + ? evaluate(*node.then) + : evaluate(*node.else_); + } + + // ---- Index expression: target[index] ---- + else if constexpr (std::is_same_v) { + Value target = evaluate(*node.target); + Value idx = evaluate(*node.index); + if (!idx.isNumber()) return Value::undef(); + int i = static_cast(idx.asNumber()); + if (i < 0) return Value::undef(); + if (target.isVector()) { + if (static_cast(i) >= target.asVec().size()) + return Value::undef(); + return target.asVec()[static_cast(i)]; + } + if (target.isString()) { + if (static_cast(i) >= target.asString().size()) + return Value::undef(); + return Value::fromString(std::string(1, target.asString()[static_cast(i)])); + } + return Value::undef(); + } + + // ---- Let expression: let(x = expr, ...) body ---- + else if constexpr (std::is_same_v) { + auto savedEnv = snapshotEnv(); + for (const auto& [name, valExpr] : node.bindings) + setVar(name, evaluate(*valExpr)); + Value result = evaluate(*node.body); + restoreEnv(std::move(savedEnv)); + return result; + } + // ---- Function call ---- else if constexpr (std::is_same_v) { - std::vector args; - args.reserve(node.args.size()); - for (const auto& a : node.args) - args.push_back(evaluate(*a)); - return callBuiltin(node.name, args); + // Collect positional and named argument values + std::vector posArgs; + std::vector> namedArgs; + for (const auto& arg : node.args) { + Value v = evaluate(*arg.value); + if (arg.name.empty()) + posArgs.push_back(std::move(v)); + else + namedArgs.push_back({arg.name, std::move(v)}); + } + + // Try user-defined function first + auto fit = m_funcDefs.find(node.name); + if (fit != m_funcDefs.end()) { + const FunctionDef& def = *fit->second; + auto savedEnv = snapshotEnv(); + + std::size_t posIdx = 0; + for (const auto& param : def.params) { + // Check named args + bool bound = false; + for (const auto& [n, v] : namedArgs) { + if (n == param.name) { setVar(param.name, v); bound = true; break; } + } + if (!bound) { + if (posIdx < posArgs.size()) + setVar(param.name, posArgs[posIdx++]); + else if (param.defaultVal) + setVar(param.name, evaluate(*param.defaultVal)); + // else: unbound → undef (already the case in a fresh env) + } + } + + Value result = evaluate(*def.body); + restoreEnv(std::move(savedEnv)); + return result; + } + + // Fall back to built-ins (positional args only) + std::vector allArgs = std::move(posArgs); + for (auto& [n, v] : namedArgs) allArgs.push_back(std::move(v)); + return callBuiltin(node.name, allArgs); } return Value::undef(); @@ -115,7 +226,7 @@ Value Interpreter::evaluate(const ExprNode& expr) const { // --------------------------------------------------------------------------- // evalNumber — evaluate and coerce to double // --------------------------------------------------------------------------- -double Interpreter::evalNumber(const ExprNode& expr) const { +double Interpreter::evalNumber(const ExprNode& expr) { Value v = evaluate(expr); if (v.isNumber()) return v.asNumber(); if (v.isBool()) return v.asBool() ? 1.0 : 0.0; @@ -123,9 +234,9 @@ double Interpreter::evalNumber(const ExprNode& expr) const { } // --------------------------------------------------------------------------- -// evalVec3 — evaluate a VectorLit and return first three elements as doubles +// evalVec3 — evaluate and return first three elements as doubles // --------------------------------------------------------------------------- -std::array Interpreter::evalVec3(const ExprNode& expr) const { +std::array Interpreter::evalVec3(const ExprNode& expr) { std::array result = {0.0, 0.0, 0.0}; Value v = evaluate(expr); if (!v.isVector()) return result; @@ -137,7 +248,7 @@ std::array Interpreter::evalVec3(const ExprNode& expr) const { } // --------------------------------------------------------------------------- -// getVar / setVar — used by CsgEvaluator to bind for-loop variables +// getVar / setVar // --------------------------------------------------------------------------- Value Interpreter::getVar(const std::string& name) const { auto it = m_env.find(name); @@ -149,30 +260,142 @@ void Interpreter::setVar(const std::string& name, Value val) { } // --------------------------------------------------------------------------- -// Built-in functions (V2a subset — math functions added in V2c) +// Built-in functions // --------------------------------------------------------------------------- Value Interpreter::callBuiltin(const std::string& name, const std::vector& args) const { - auto num = [&](std::size_t i) { + auto num = [&](std::size_t i) -> double { return (i < args.size() && args[i].isNumber()) ? args[i].asNumber() : 0.0; }; + // ---- Math ---- if (name == "abs") return args.size() >= 1 ? Value::fromNumber(std::abs(num(0))) : Value::undef(); if (name == "sqrt") return args.size() >= 1 ? Value::fromNumber(std::sqrt(num(0))) : Value::undef(); - if (name == "sin") return args.size() >= 1 ? Value::fromNumber(std::sin(num(0) * kDeg2Rad)) : Value::undef(); - if (name == "cos") return args.size() >= 1 ? Value::fromNumber(std::cos(num(0) * kDeg2Rad)) : Value::undef(); - if (name == "tan") return args.size() >= 1 ? Value::fromNumber(std::tan(num(0) * kDeg2Rad)) : Value::undef(); if (name == "pow") return args.size() >= 2 ? Value::fromNumber(std::pow(num(0), num(1))) : Value::undef(); - if (name == "min") return args.size() >= 2 ? Value::fromNumber(std::min(num(0), num(1))) : Value::undef(); - if (name == "max") return args.size() >= 2 ? Value::fromNumber(std::max(num(0), num(1))) : Value::undef(); if (name == "floor") return args.size() >= 1 ? Value::fromNumber(std::floor(num(0))) : Value::undef(); if (name == "ceil") return args.size() >= 1 ? Value::fromNumber(std::ceil(num(0))) : Value::undef(); if (name == "round") return args.size() >= 1 ? Value::fromNumber(std::round(num(0))) : Value::undef(); - if (name == "log") return args.size() >= 1 ? Value::fromNumber(std::log(num(0))) : Value::undef(); - if (name == "exp") return args.size() >= 1 ? Value::fromNumber(std::exp(num(0))) : Value::undef(); - if (name == "len") return (args.size() >= 1 && args[0].isVector()) - ? Value::fromNumber(static_cast(args[0].asVec().size())) - : Value::undef(); + if (name == "exp") return args.size() >= 1 ? Value::fromNumber(std::exp(num(0))) : Value::undef(); + if (name == "log") return args.size() >= 1 ? Value::fromNumber(std::log(num(0))) : Value::undef(); + if (name == "log10") return args.size() >= 1 ? Value::fromNumber(std::log10(num(0))): Value::undef(); + if (name == "sign") return args.size() >= 1 + ? Value::fromNumber(num(0) > 0.0 ? 1.0 : num(0) < 0.0 ? -1.0 : 0.0) + : Value::undef(); + + // ---- Trig (degrees) ---- + if (name == "sin") return args.size() >= 1 ? Value::fromNumber(std::sin(num(0) * kDeg2Rad)) : Value::undef(); + if (name == "cos") return args.size() >= 1 ? Value::fromNumber(std::cos(num(0) * kDeg2Rad)) : Value::undef(); + if (name == "tan") return args.size() >= 1 ? Value::fromNumber(std::tan(num(0) * kDeg2Rad)) : Value::undef(); + if (name == "asin") return args.size() >= 1 ? Value::fromNumber(std::asin(num(0)) * kRad2Deg) : Value::undef(); + if (name == "acos") return args.size() >= 1 ? Value::fromNumber(std::acos(num(0)) * kRad2Deg) : Value::undef(); + if (name == "atan") return args.size() >= 1 ? Value::fromNumber(std::atan(num(0)) * kRad2Deg) : Value::undef(); + if (name == "atan2") return args.size() >= 2 ? Value::fromNumber(std::atan2(num(0), num(1)) * kRad2Deg) : Value::undef(); + + // ---- min/max — variadic ---- + if (name == "min") { + if (args.empty()) return Value::undef(); + // If first arg is a vector, take min of its elements + if (args.size() == 1 && args[0].isVector()) { + double m = std::numeric_limits::infinity(); + for (const auto& e : args[0].asVec()) + if (e.isNumber()) m = std::min(m, e.asNumber()); + return std::isinf(m) ? Value::undef() : Value::fromNumber(m); + } + double m = num(0); + for (std::size_t i = 1; i < args.size(); ++i) m = std::min(m, num(i)); + return Value::fromNumber(m); + } + if (name == "max") { + if (args.empty()) return Value::undef(); + if (args.size() == 1 && args[0].isVector()) { + double m = -std::numeric_limits::infinity(); + for (const auto& e : args[0].asVec()) + if (e.isNumber()) m = std::max(m, e.asNumber()); + return std::isinf(m) ? Value::undef() : Value::fromNumber(m); + } + double m = num(0); + for (std::size_t i = 1; i < args.size(); ++i) m = std::max(m, num(i)); + return Value::fromNumber(m); + } + + // ---- Vector ---- + if (name == "norm") { + if (args.size() >= 1 && args[0].isVector()) { + double sum = 0.0; + for (const auto& e : args[0].asVec()) + if (e.isNumber()) sum += e.asNumber() * e.asNumber(); + return Value::fromNumber(std::sqrt(sum)); + } + return Value::undef(); + } + if (name == "cross") { + if (args.size() >= 2 && args[0].isVector() && args[1].isVector()) { + const auto& a = args[0].asVec(); + const auto& b = args[1].asVec(); + if (a.size() >= 3 && b.size() >= 3) { + double ax = a[0].asNumber(), ay = a[1].asNumber(), az = a[2].asNumber(); + double bx = b[0].asNumber(), by = b[1].asNumber(), bz = b[2].asNumber(); + return Value::fromVec({ + Value::fromNumber(ay * bz - az * by), + Value::fromNumber(az * bx - ax * bz), + Value::fromNumber(ax * by - ay * bx) + }); + } + } + return Value::undef(); + } + + // ---- List ---- + if (name == "len") { + if (args.size() >= 1) { + if (args[0].isVector()) return Value::fromNumber(static_cast(args[0].asVec().size())); + if (args[0].isString()) return Value::fromNumber(static_cast(args[0].asString().size())); + } + return Value::undef(); + } + if (name == "concat") { + std::vector result; + for (const auto& arg : args) { + if (arg.isVector()) + for (const auto& elem : arg.asVec()) result.push_back(elem); + else + result.push_back(arg); + } + return Value::fromVec(std::move(result)); + } + + // ---- String ---- + if (name == "str") { + std::string out; + for (const auto& arg : args) { + if (arg.isNumber()) { + char buf[32]; + double v = arg.asNumber(); + if (v == static_cast(v)) + std::snprintf(buf, sizeof(buf), "%lld", static_cast(v)); + else + std::snprintf(buf, sizeof(buf), "%g", v); + out += buf; + } else if (arg.isBool()) { out += arg.asBool() ? "true" : "false"; } + else if (arg.isString()) { out += arg.asString(); } + else if (arg.isUndef()) { out += "undef"; } + } + return Value::fromString(std::move(out)); + } + if (name == "chr") { + if (args.size() >= 1 && args[0].isNumber()) { + int code = static_cast(args[0].asNumber()); + if (code >= 0 && code <= 127) + return Value::fromString(std::string(1, static_cast(code))); + } + return Value::undef(); + } + if (name == "ord") { + if (args.size() >= 1 && args[0].isString() && !args[0].asString().empty()) + return Value::fromNumber(static_cast( + static_cast(args[0].asString()[0]))); + return Value::undef(); + } return Value::undef(); // unknown function } diff --git a/src/lang/Interpreter.h b/src/lang/Interpreter.h index 22150f0..050edd0 100644 --- a/src/lang/Interpreter.h +++ b/src/lang/Interpreter.h @@ -10,37 +10,36 @@ namespace chisel::lang { // --------------------------------------------------------------------------- // Interpreter — evaluates ExprNode trees against a variable environment. -// -// Usage: -// Interpreter interp; -// interp.loadAssignments(parseResult); // populate env from x = expr; -// double r = interp.evalNumber(*expr); // resolve a param expression // --------------------------------------------------------------------------- class Interpreter { public: - // Populate the environment by evaluating all AssignStmts in the result. + // Populate the environment from variable assignments. void loadAssignments(const ParseResult& result); + // Register user-defined functions (non-owning pointers into result). + // result must outlive this interpreter instance. + void loadFunctions(const ParseResult& result); + // Evaluate an expression to a Value. - Value evaluate(const ExprNode& expr) const; + Value evaluate(const ExprNode& expr); // Convenience: evaluate and coerce to double (undef → 0.0). - double evalNumber(const ExprNode& expr) const; + double evalNumber(const ExprNode& expr); // Evaluate a VectorLit and return the first three elements as doubles. - // Missing elements default to 0.0. - std::array evalVec3(const ExprNode& expr) const; + std::array evalVec3(const ExprNode& expr); // 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. + // Environment snapshot/restore for 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; + std::unordered_map m_env; + std::unordered_map m_funcDefs; Value callBuiltin(const std::string& name, const std::vector& args) const; diff --git a/src/lang/Lexer.cpp b/src/lang/Lexer.cpp index 8fef308..e42fb34 100644 --- a/src/lang/Lexer.cpp +++ b/src/lang/Lexer.cpp @@ -32,6 +32,9 @@ static const std::unordered_map kKeywords = { {"polygon", TokenKind::Polygon}, {"linear_extrude", TokenKind::LinearExtrude}, {"rotate_extrude", TokenKind::RotateExtrude}, + {"undef", TokenKind::Undef}, + {"function", TokenKind::Function}, + {"let", TokenKind::Let}, }; // --------------------------------------------------------------------------- @@ -123,6 +126,7 @@ std::vector Lexer::tokenize() { if (match('=')) tokens.push_back(makeToken(TokenKind::EqualEqual, startOffset)); else tokens.push_back(makeToken(TokenKind::Equals, startOffset)); break; + case '?': tokens.push_back(makeToken(TokenKind::Question, startOffset)); break; case '&': if (match('&')) tokens.push_back(makeToken(TokenKind::AmpAmp, startOffset)); else addError("expected '&&'", makeToken(TokenKind::Eof, startOffset).loc); diff --git a/src/lang/Parser.cpp b/src/lang/Parser.cpp index 3b9aa1b..1f0aaa9 100644 --- a/src/lang/Parser.cpp +++ b/src/lang/Parser.cpp @@ -75,6 +75,12 @@ void Parser::parseStatement(ParseResult& result) { return; } + // Function definition: function name(params) = expr; + if (check(TokenKind::Function)) { + parseFunctionDef(result); + return; + } + // Variable assignment: ident = expr; (no '(' follows the ident) if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { parseAssignment(result); @@ -153,6 +159,9 @@ AstNodePtr Parser::parseNode() { case TokenKind::For: return parseFor(); + case TokenKind::Let: + return parseLetNode(); + case TokenKind::Ident: // Could be a module call: name(args) { ... } if (peek(1).kind == TokenKind::LParen) @@ -466,6 +475,21 @@ ExprPtr Parser::parseExpr(int minPrec) { bin.loc = loc; lhs = makeExpr(std::move(bin)); } + + // Ternary operator — lowest precedence, only at top level (minPrec == 0) + if (minPrec == 0 && check(TokenKind::Question)) { + const Token& q = advance(); // consume '?' + auto thenExpr = parseExpr(0); + expect(TokenKind::Colon, "expected ':' in ternary expression"); + auto elseExpr = parseExpr(0); + TernaryExpr t; + t.condition = std::move(lhs); + t.then = std::move(thenExpr); + t.else_ = std::move(elseExpr); + t.loc = q.loc; + lhs = makeExpr(std::move(t)); + } + return lhs; } @@ -480,7 +504,23 @@ ExprPtr Parser::parseUnary() { auto operand = parseUnary(); return makeExpr(UnaryExpr{UnaryExpr::Op::Not, std::move(operand), tok.loc}); } - return parsePrimary(); + return parsePostfix(); +} + +// Postfix operators applied to any primary: expr[index] +ExprPtr Parser::parsePostfix() { + auto expr = parsePrimary(); + while (check(TokenKind::LBracket)) { + SourceLoc loc = advance().loc; // consume '[' + auto idx = parseExpr(); + expect(TokenKind::RBracket, "expected ']' after index"); + IndexExpr ie; + ie.target = std::move(expr); + ie.index = std::move(idx); + ie.loc = loc; + expr = makeExpr(std::move(ie)); + } + return expr; } ExprPtr Parser::parsePrimary() { @@ -498,6 +538,15 @@ ExprPtr Parser::parsePrimary() { SourceLoc loc = advance().loc; return makeExpr(BoolLit{false, loc}); } + // undef literal + if (check(TokenKind::Undef)) { + SourceLoc loc = advance().loc; + return makeExpr(UndefLit{loc}); + } + // let expression: let(x = expr, ...) body + if (check(TokenKind::Let)) { + return parseLetExpr(); + } // Parenthesised expression if (match(TokenKind::LParen)) { auto expr = parseExpr(); @@ -520,12 +569,19 @@ ExprPtr Parser::parsePrimary() { if (check(TokenKind::Ident)) { const Token& name_tok = advance(); if (match(TokenKind::LParen)) { - // Function call: name(arg, arg, ...) + // Function call: name(arg, name=arg, ...) FunctionCall fc; fc.name = name_tok.text; fc.loc = name_tok.loc; while (!check(TokenKind::RParen) && !atEnd()) { - fc.args.push_back(parseExpr()); + FunctionArg arg; + // Named arg: ident = expr + if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { + arg.name = advance().text; // consume ident + advance(); // consume '=' + } + arg.value = parseExpr(); + fc.args.push_back(std::move(arg)); if (!match(TokenKind::Comma)) break; } expect(TokenKind::RParen, "expected ')' after function arguments"); @@ -580,6 +636,76 @@ void Parser::parseModuleDef(ParseResult& result) { result.moduleDefs.push_back(std::move(def)); } +// --------------------------------------------------------------------------- +// function definition — function name(params) = expr; +// --------------------------------------------------------------------------- +void Parser::parseFunctionDef(ParseResult& result) { + const Token& kw = advance(); // consume 'function' + FunctionDef def; + def.loc = kw.loc; + def.name = expect(TokenKind::Ident, "expected function name").text; + + expect(TokenKind::LParen, "expected '(' after function name"); + while (!check(TokenKind::RParen) && !atEnd()) { + FunctionParam 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 ')'"); + expect(TokenKind::Equals, "expected '=' in function definition"); + def.body = parseExpr(); + match(TokenKind::Semicolon); + + result.functionDefs.push_back(std::move(def)); +} + +// --------------------------------------------------------------------------- +// let statement — let(x = expr, ...) { children } +// --------------------------------------------------------------------------- +AstNodePtr Parser::parseLetNode() { + const Token& kw = advance(); // consume 'let' + LetNode node; + node.loc = kw.loc; + + expect(TokenKind::LParen, "expected '(' after 'let'"); + while (!check(TokenKind::RParen) && !atEnd()) { + std::string name = expect(TokenKind::Ident, "expected variable name").text; + expect(TokenKind::Equals, "expected '='"); + auto val = parseExpr(); + node.bindings.push_back({std::move(name), std::move(val)}); + if (!match(TokenKind::Comma)) break; + } + expect(TokenKind::RParen, "expected ')'"); + + node.children = parseBody(); + return makeLetNode(std::move(node)); +} + +// --------------------------------------------------------------------------- +// let expression — let(x = expr, ...) body_expr +// --------------------------------------------------------------------------- +ExprPtr Parser::parseLetExpr() { + const Token& kw = advance(); // consume 'let' + LetExpr expr; + expr.loc = kw.loc; + + expect(TokenKind::LParen, "expected '(' after 'let'"); + while (!check(TokenKind::RParen) && !atEnd()) { + std::string name = expect(TokenKind::Ident, "expected variable name").text; + expect(TokenKind::Equals, "expected '='"); + auto val = parseExpr(); + expr.bindings.push_back({std::move(name), std::move(val)}); + if (!match(TokenKind::Comma)) break; + } + expect(TokenKind::RParen, "expected ')'"); + + expr.body = parseExpr(); + return makeExpr(std::move(expr)); +} + // --------------------------------------------------------------------------- // module call — name(arg, name = arg, ...) { optional children } // --------------------------------------------------------------------------- diff --git a/src/lang/Parser.h b/src/lang/Parser.h index 5c3fa9d..f80f3b0 100644 --- a/src/lang/Parser.h +++ b/src/lang/Parser.h @@ -41,14 +41,20 @@ class Parser { AstNodePtr parseModuleCall(); AstNodePtr parseExtrusion(TokenKind k); - // ---- module definitions ----------------------------------------------- + // ---- module / function definitions ------------------------------------ void parseModuleDef(ParseResult& result); + void parseFunctionDef(ParseResult& result); + + // ---- let statement --------------------------------------------------- + AstNodePtr parseLetNode(); // ---- expressions (Pratt parser) -------------------------------------- ExprPtr parseExpr(int minPrec = 0); ExprPtr parseUnary(); + ExprPtr parsePostfix(); // handles postfix [idx] after primary ExprPtr parsePrimary(); - ExprPtr parseVecExpr(); // parse [x, y, z] → VectorLit ExprPtr + ExprPtr parseLetExpr(); + ExprPtr parseVecExpr(); // parse [x, y, z] → VectorLit ExprPtr // ---- argument helpers ------------------------------------------------ void parseParamList(std::unordered_map& params, diff --git a/src/lang/Token.h b/src/lang/Token.h index d46eba1..3e9a511 100644 --- a/src/lang/Token.h +++ b/src/lang/Token.h @@ -46,11 +46,16 @@ enum class TokenKind : uint8_t { Scale, Mirror, - // Control flow - If, // if - Else, // else - For, // for - Module, // module + // Control flow / definitions + If, // if + Else, // else + For, // for + Module, // module + Function, // function + Let, // let + + // Literals + Undef, // undef // 2-D primitives Square, Circle, Polygon, @@ -75,10 +80,11 @@ enum class TokenKind : uint8_t { Greater, // > GreaterEqual, // >= - // Logical + // Logical / ternary Bang, // ! AmpAmp, // && PipePipe, // || + Question, // ? // Punctuation LParen, // ( diff --git a/tests/2d_extrude_test.scad b/tests/2d_extrude_test.scad index b26e011..c983e60 100644 --- a/tests/2d_extrude_test.scad +++ b/tests/2d_extrude_test.scad @@ -35,7 +35,7 @@ translate([25, 30, 0]) // Scene 7: linear_extrude with twist → helical prism translate([50, 30, 0]) - linear_extrude(height=20, twist=90, $fn=24) + linear_extrude(height=20, twist=90) square([5, 5], center=true); // Scene 8: 2-D boolean inside extrude → hollow tube diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp index 4fabea2..e3197dc 100644 --- a/tests/test_interpreter.cpp +++ b/tests/test_interpreter.cpp @@ -190,3 +190,159 @@ TEST_CASE("Interp:variable assignment chain", "[interp]") { REQUIRE(interp.evalNumber(eBase) == Approx(10.0)); REQUIRE(interp.evalNumber(eFactor) == Approx(2.0)); } + +// --------------------------------------------------------------------------- +// Tier A: undef literal +// --------------------------------------------------------------------------- +TEST_CASE("Interp:undef literal", "[interp][tier-a]") { + std::string src = "_v = undef;"; + Lexer lexer(src); auto tokens = lexer.tokenize(); + Parser parser(std::move(tokens)); auto result = parser.parse(); + Interpreter interp; + Value v = interp.evaluate(*result.assignments[0].value); + REQUIRE(v.isUndef()); + REQUIRE(interp.evalNumber(*result.assignments[0].value) == Approx(0.0)); +} + +// --------------------------------------------------------------------------- +// Tier A: ternary operator +// --------------------------------------------------------------------------- +TEST_CASE("Interp:ternary true branch", "[interp][tier-a]") { + REQUIRE(evalNum("true ? 1 : 2") == Approx(1.0)); + REQUIRE(evalNum("false ? 1 : 2") == Approx(2.0)); + REQUIRE(evalNum("1 > 0 ? 10 : 20") == Approx(10.0)); + REQUIRE(evalNum("1 < 0 ? 10 : 20") == Approx(20.0)); +} + +TEST_CASE("Interp:nested ternary", "[interp][tier-a]") { + REQUIRE(evalNum("1 == 1 ? (2 == 2 ? 42 : 0) : 0") == Approx(42.0)); +} + +// --------------------------------------------------------------------------- +// Tier A: list indexing +// --------------------------------------------------------------------------- +TEST_CASE("Interp:list index", "[interp][tier-a]") { + REQUIRE(evalNum("[10, 20, 30][0]") == Approx(10.0)); + REQUIRE(evalNum("[10, 20, 30][1]") == Approx(20.0)); + REQUIRE(evalNum("[10, 20, 30][2]") == Approx(30.0)); +} + +TEST_CASE("Interp:list index out of bounds returns 0", "[interp][tier-a]") { + REQUIRE(evalNum("[1, 2][5]") == Approx(0.0)); // undef → 0 +} + +TEST_CASE("Interp:chained index", "[interp][tier-a]") { + REQUIRE(evalNum("[[1,2],[3,4]][1][0]") == Approx(3.0)); +} + +// --------------------------------------------------------------------------- +// Tier A: let expression +// --------------------------------------------------------------------------- +TEST_CASE("Interp:let expression", "[interp][tier-a]") { + REQUIRE(evalNum("let(x=5) x * 2") == Approx(10.0)); + REQUIRE(evalNum("let(x=3, y=4) x + y") == Approx(7.0)); +} + +TEST_CASE("Interp:let does not leak into outer scope", "[interp][tier-a]") { + // after let expression, the outer scope is restored + std::string src = "outer = 99; _v = let(outer = 1) outer;"; + Lexer lexer(src); auto tokens = lexer.tokenize(); + Parser parser(std::move(tokens)); auto result = parser.parse(); + Interpreter interp; + interp.loadAssignments(result); + // After loading, outer should still be 99 + ExprNode eOuter = VarRef{"outer", {}}; + REQUIRE(interp.evalNumber(eOuter) == Approx(99.0)); +} + +// --------------------------------------------------------------------------- +// Tier A: user-defined functions +// --------------------------------------------------------------------------- +// m_funcDefs stores non-owning pointers into ParseResult, so result must +// outlive the Interpreter. Return both together. +struct InterpCtx { + ParseResult result; + Interpreter interp; +}; + +static InterpCtx loadEnvWithFuncs(std::string_view src) { + Lexer lexer(src); + auto tokens = lexer.tokenize(); + Parser parser(std::move(tokens)); + InterpCtx ctx; + ctx.result = parser.parse(); + ctx.interp.loadAssignments(ctx.result); + ctx.interp.loadFunctions(ctx.result); + return ctx; +} + +// Helper: build a FunctionCall expression with positional number args +static ExprNode makeCall(std::string name, std::vector nums) { + FunctionCall fc; + fc.name = std::move(name); + for (double v : nums) { + FunctionArg arg; + arg.value = makeExpr(NumberLit{v, {}}); + fc.args.push_back(std::move(arg)); + } + return fc; +} + +TEST_CASE("Interp:user function basic", "[interp][tier-a]") { + auto ctx = loadEnvWithFuncs("function double(x) = x * 2;"); + ExprNode call = makeCall("double", {5.0}); + REQUIRE(ctx.interp.evalNumber(call) == Approx(10.0)); +} + +TEST_CASE("Interp:user function with default param", "[interp][tier-a]") { + auto ctx = loadEnvWithFuncs("function inc(x, step=1) = x + step;"); + ExprNode call1 = makeCall("inc", {10.0}); + REQUIRE(ctx.interp.evalNumber(call1) == Approx(11.0)); + ExprNode call2 = makeCall("inc", {10.0, 5.0}); + REQUIRE(ctx.interp.evalNumber(call2) == Approx(15.0)); +} + +TEST_CASE("Interp:recursive user function", "[interp][tier-a]") { + auto ctx = loadEnvWithFuncs("function fact(n) = n <= 1 ? 1 : n * fact(n - 1);"); + ExprNode call = makeCall("fact", {5.0}); + REQUIRE(ctx.interp.evalNumber(call) == Approx(120.0)); +} + +// --------------------------------------------------------------------------- +// Tier A: concat +// --------------------------------------------------------------------------- +TEST_CASE("Interp:concat", "[interp][tier-a]") { + REQUIRE(evalNum("len(concat([1,2],[3,4]))") == Approx(4.0)); + REQUIRE(evalNum("concat([1,2],[3,4])[2]") == Approx(3.0)); +} + +// --------------------------------------------------------------------------- +// Tier A: new math — inverse trig, norm, cross, sign +// --------------------------------------------------------------------------- +TEST_CASE("Interp:inverse trig", "[interp][tier-a]") { + REQUIRE(evalNum("asin(1)") == Approx(90.0).margin(1e-9)); + REQUIRE(evalNum("acos(1)") == Approx(0.0).margin(1e-9)); + REQUIRE(evalNum("atan(1)") == Approx(45.0).margin(1e-9)); + REQUIRE(evalNum("atan2(1,1)") == Approx(45.0).margin(1e-9)); +} + +TEST_CASE("Interp:norm", "[interp][tier-a]") { + REQUIRE(evalNum("norm([3,4])") == Approx(5.0).margin(1e-9)); + REQUIRE(evalNum("norm([0,0,1])") == Approx(1.0)); +} + +TEST_CASE("Interp:sign", "[interp][tier-a]") { + REQUIRE(evalNum("sign(5)") == Approx(1.0)); + REQUIRE(evalNum("sign(-3)") == Approx(-1.0)); + REQUIRE(evalNum("sign(0)") == Approx(0.0)); +} + +TEST_CASE("Interp:cross product", "[interp][tier-a]") { + REQUIRE(evalNum("cross([1,0,0],[0,1,0])[2]") == Approx(1.0)); + REQUIRE(evalNum("cross([1,0,0],[0,1,0])[0]") == Approx(0.0)); +} + +TEST_CASE("Interp:str function", "[interp][tier-a]") { + REQUIRE(evalNum("len(str(42))") == Approx(2.0)); + REQUIRE(evalNum("len(str(3.14))") == Approx(4.0)); +} From 1ba887a52ac5581313892c6bd539ea3ec2741f98 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 16:50:30 -0700 Subject: [PATCH 2/3] test(scad): add Tier A visual test file 10 scenes covering ternary, list indexing, let statement/expression, user-defined functions, recursive functions, concat, and for-loop over vector lists. Co-Authored-By: Claude Sonnet 4.6 --- tests/tier_a_test.scad | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/tier_a_test.scad diff --git a/tests/tier_a_test.scad b/tests/tier_a_test.scad new file mode 100644 index 0000000..1e7f887 --- /dev/null +++ b/tests/tier_a_test.scad @@ -0,0 +1,74 @@ +// Tier A visual test — ternary, indexing, let, user functions, concat +// Open in ChiselCAD (and optionally OpenSCAD) to compare results. + +// --- Scene 1: ternary operator — cube if true, sphere if false +// Both should appear as cubes (condition is true) +translate([0, 0, 0]) + cube([true ? 8 : 4, true ? 8 : 4, true ? 8 : 4]); + +translate([12, 0, 0]) + cube([false ? 8 : 4, false ? 8 : 4, false ? 8 : 4]); // smaller cube + +// --- Scene 2: list indexing — extract components from a vector +// sizes = [6, 4, 10]; build a box using each component via index +sizes = [6, 4, 10]; +translate([0, 16, 0]) + cube([sizes[0], sizes[1], sizes[2]]); + +// --- Scene 3: let statement — scoped radius variable +// Two spheres built inside let blocks with different r values +translate([20, 16, 0]) + let(r = 5) + sphere(r = r, $fn = 32); + +translate([34, 16, 0]) + let(r = 3) + sphere(r = r, $fn = 32); + +// --- Scene 4: let expression in a parameter +// Cylinder height computed via let expression +translate([0, 32, 0]) + cylinder(h = let(base=4, factor=3) base * factor, r = 3, $fn = 24); + +// --- Scene 5: user-defined function — scale factor applied to geometry +function scaled(base, factor) = base * factor; + +translate([16, 32, 0]) + cube([scaled(3, 2), scaled(2, 2), scaled(1, 4)]); + +// --- Scene 6: recursive function — fibonacci-like height tower +function fib(n) = n <= 1 ? n : fib(n-1) + fib(n-2); + +translate([0, 50, 0]) + for (i = [1, 2, 3, 4, 5, 6]) + translate([(i-1) * 5, 0, 0]) + cylinder(h = fib(i), r = 1.5, $fn = 16); + +// --- Scene 7: concat — join two lists and iterate +pts = concat([[0,0],[10,0],[10,10]], [[0,10],[5,5]]); +translate([0, 66, 0]) + for (pt = pts) + translate([pt[0], pt[1], 0]) + cylinder(h = 3, r = 1, $fn = 12); + +// --- Scene 8: ternary + variable in module param +function clamp(v, lo, hi) = v < lo ? lo : (v > hi ? hi : v); + +translate([28, 50, 0]) { + sphere(r = clamp(2, 1, 5), $fn = 24); // 2 — within range + translate([10, 0, 0]) sphere(r = clamp(8, 1, 5), $fn = 24); // clamped to 5 + translate([24, 0, 0]) sphere(r = clamp(0, 1, 5), $fn = 24); // clamped to 1 +} + +// --- Scene 9: for loop over vector list (now supports non-numeric values) +offsets = [[0,0,0], [8,0,0], [16,0,0], [8,8,0]]; +translate([0, 82, 0]) + for (off = offsets) + translate(off) + cube([5, 5, 5]); + +// --- Scene 10: nested let + ternary +translate([40, 82, 0]) + let(w = 10, h = 6) + let(half_w = w / 2) + cube([w, half_w > 3 ? half_w : 3, h]); From d6d75883c1e6768d2bd3503e68fe85bc6974b514 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 17:00:16 -0700 Subject: [PATCH 3/3] fix(lang): for loop and transform accept expressions, not just literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - for (var = expr): iterable can now be any expression; if it evaluates to a vector the elements are expanded automatically, enabling `for (pt = pts)` where pts is a variable holding a list of vectors - translate/rotate/scale/mirror: argument is now parsed as a general expression (was parseVecExpr requiring literal [x,y,z]), enabling `translate(offset_var)` where offset_var holds a [x,y,z] value - rotate(scalar): scalar angle now correctly rotates around Z axis (was producing a zero rotation via evalVec3 → {0,0,0}) Co-Authored-By: Claude Sonnet 4.6 --- src/csg/CsgEvaluator.cpp | 26 +++++++++++++++--- src/lang/Parser.cpp | 57 ++++++++++++++++++++++------------------ 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src/csg/CsgEvaluator.cpp b/src/csg/CsgEvaluator.cpp index c69993e..0b1a4de 100644 --- a/src/csg/CsgEvaluator.cpp +++ b/src/csg/CsgEvaluator.cpp @@ -229,7 +229,18 @@ CsgNodePtr CsgEvaluator::evalTransform(const TransformNode& t, const glm::mat4& // Rotation order: Z first, then Y, then X (OpenSCAD convention). // --------------------------------------------------------------------------- glm::mat4 CsgEvaluator::makeMatrix(const TransformNode& t) const { - auto vec = m_interp->evalVec3(*t.vec); + Value rotVal = m_interp->evaluate(*t.vec); // evaluate once, share result + auto vec = [&]() -> std::array { + if (rotVal.isVector()) { + std::array r = {0.0, 0.0, 0.0}; + for (std::size_t i = 0; i < 3 && i < rotVal.asVec().size(); ++i) + if (rotVal.asVec()[i].isNumber()) r[i] = rotVal.asVec()[i].asNumber(); + return r; + } + if (rotVal.isNumber()) + return {0.0, 0.0, rotVal.asNumber()}; // scalar → Z axis + return {0.0, 0.0, 0.0}; + }(); const double vx = vec[0], vy = vec[1], vz = vec[2]; glm::mat4 m{1.0f}; @@ -243,6 +254,7 @@ glm::mat4 CsgEvaluator::makeMatrix(const TransformNode& t) const { break; case TransformNode::Kind::Rotate: { + // scalar rotate(angle) → Z-axis; vector → XYZ Euler float rx = static_cast(vx * kDeg2Rad); float ry = static_cast(vy * kDeg2Rad); float rz = static_cast(vz * kDeg2Rad); @@ -327,9 +339,15 @@ CsgNodePtr CsgEvaluator::evalFor(const ForNode& node, const glm::mat4& xform) { for (double v = start; v >= end - 1e-10 && (int)values.size() < kMaxIter; v += step) values.push_back(Value::fromNumber(v)); } else { - // List form — preserve full Value (supports iterating over vectors) - for (const auto& e : node.range.list) - values.push_back(m_interp->evaluate(*e)); + // List form — evaluate each element; if one evaluates to a Vector, + // expand it so that `for (pt = pts)` iterates over pts' elements. + for (const auto& e : node.range.list) { + Value v = m_interp->evaluate(*e); + if (v.isVector()) + for (const auto& elem : v.asVec()) values.push_back(elem); + else + values.push_back(std::move(v)); + } } // Save the loop variable's current binding, iterate, then restore diff --git a/src/lang/Parser.cpp b/src/lang/Parser.cpp index 1f0aaa9..32bcac7 100644 --- a/src/lang/Parser.cpp +++ b/src/lang/Parser.cpp @@ -239,7 +239,7 @@ AstNodePtr Parser::parseTransform(TokenKind k) { } expect(TokenKind::LParen, "expected '(' after transform name"); - node.vec = parseVecExpr(); + node.vec = parseExpr(); // [x,y,z] literal or any expression that yields a vector expect(TokenKind::RParen, "expected ')' after transform vector"); node.children = parseBody(); @@ -279,41 +279,46 @@ AstNodePtr Parser::parseFor() { expect(TokenKind::LParen, "expected '(' after 'for'"); node.var = expect(TokenKind::Ident, "expected loop variable").text; expect(TokenKind::Equals, "expected '=' after loop variable"); - expect(TokenKind::LBracket, "expected '[' for range/list"); - // Parse first expression — determines range vs list form - auto first = parseExpr(); + if (check(TokenKind::LBracket)) { + advance(); // consume '[' + + // Parse first expression — determines range vs list form + auto first = parseExpr(); - if (check(TokenKind::Colon)) { - // Range form: [start : end] or [start : step : end] - advance(); // consume ':' - auto second = parseExpr(); if (check(TokenKind::Colon)) { + // Range form: [start : end] or [start : step : end] advance(); // consume ':' - auto third = parseExpr(); - // [first:second:third] = [start:step:end] - node.range.isRange = true; - node.range.start = std::move(first); - node.range.step = std::move(second); - node.range.end = std::move(third); + auto second = parseExpr(); + if (check(TokenKind::Colon)) { + advance(); // consume ':' + auto third = parseExpr(); + node.range.isRange = true; + node.range.start = std::move(first); + node.range.step = std::move(second); + node.range.end = std::move(third); + } else { + node.range.isRange = true; + node.range.start = std::move(first); + node.range.end = std::move(second); + } } else { - // [first:second] = [start:end], implicit step of 1 - node.range.isRange = true; - node.range.start = std::move(first); - node.range.end = std::move(second); + // List form: [first, ...] + node.range.isRange = false; + node.range.list.push_back(std::move(first)); + while (match(TokenKind::Comma)) { + if (check(TokenKind::RBracket)) break; + node.range.list.push_back(parseExpr()); + } } + expect(TokenKind::RBracket, "expected ']' after range/list"); } else { - // List form: [first, ...] + // Expression form: for (var = expr) — expr must evaluate to a vector node.range.isRange = false; - node.range.list.push_back(std::move(first)); - while (match(TokenKind::Comma)) { - if (check(TokenKind::RBracket)) break; - node.range.list.push_back(parseExpr()); - } + node.range.list.push_back(parseExpr()); } - expect(TokenKind::RBracket, "expected ']' after range/list"); - expect(TokenKind::RParen, "expected ')' after for header"); + expect(TokenKind::RParen, "expected ')' after for header"); node.children = parseBody(); return makeFor(std::move(node));