Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,41 @@ else.
- [x] PNG heightmap support for `surface()`
- [x] Additional `import()` formats: OFF, 3MF, AMF, DXF, SVG

## v3.5 — OpenSCAD Language Completeness (audit follow-up) ✓

The v3 phases above closed the 33 filed correctness issues, but "feature
complete with OpenSCAD" was never verified against OpenSCAD's actual
builtin/special-variable surface — a July 2026 completeness audit (source
read, not just the issue tracker) found real gaps the v3 checklist didn't
cover. Fixed:

- [x] `PI` constant (was entirely absent — scripts had to hard-code 3.14159)
- [x] `is_undef()`/`is_bool()`/`is_num()`/`is_string()`/`is_list()`/`is_function()` type predicates
- [x] `search()` (string/list/table search, matching OpenSCAD's flat-vs-nested result shape)
- [x] `version()`/`version_num()` (fixed compatibility level, so version-gated library code picks its modern branch)
- [x] `$preview`/`$t` special variables (fixed defaults: no animation/dual-render-pass distinction exists yet)
- [x] `parent_module(idx)`/`$parent_modules`
- [x] `linear_extrude(slices=...)` — was parsed but silently ignored; twist division count now honors it over the `$fn` default
- [x] `children([vector])` / `children([range])` — only a single plain-number index worked before
- [x] `echo(name=value)` now formats as `name = value`, matching OpenSCAD

**Explicitly deferred, not done in this pass** (tracked here so they aren't
lost, not because they don't matter):
- First-class function literals (`f = function(x) x*2;`, OpenSCAD 2019.05+)
as values assignable to variables/passable as arguments — the language
has named `function foo(x) = expr;` defs but no closure value type; adding
one touches the `Value` variant, the parser grammar, and call dispatch
broadly enough that it deserves its own pass rather than riding along with
smaller fixes.
- `$vpr`/`$vpt`/`$vpd` viewport special variables — would need the render
layer's camera state plumbed into the interpreter, a cross-subsystem wire
that doesn't exist today; rarely load-bearing in real `.scad` files.
- `cube`/`sphere`/`translate`/etc. are reserved lexer keywords rather than
ordinary identifiers, so (unlike real OpenSCAD) a script can't use one of
those names as a variable. Real-world impact is low, but it's a genuine
parser-level deviation and any fix is a grammar-level change, not a
point fix — out of scope here.

## v4 — Tooling & Visual Quality

- [ ] VS Code LSP extension (syntax highlighting, error squiggles, completions)
Expand Down
33 changes: 26 additions & 7 deletions src/csg/CsgEvaluator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m
Value v = m_interp->evaluate(*arg.value);
msg += first ? " " : ", ";
first = false;
if (!arg.name.empty()) msg += arg.name + " = ";
msg += formatValue(v);
}
m_scene->echoMessages.push_back(std::move(msg));
Expand Down Expand Up @@ -761,6 +762,7 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m
// Expose $children count and push the children context for children() access
m_interp->setVar("$children", Value::fromNumber(static_cast<double>(call.children.size())));
m_childrenStack.push_back({&call.children, &savedEnv});
m_interp->pushModuleName(call.name);

// Evaluate the module body and collect geometry
std::vector<CsgNodePtr> all;
Expand All @@ -771,6 +773,7 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m
}
--m_moduleDepth;

m_interp->popModuleName();
m_childrenStack.pop_back();
// Restore the caller's environment
m_interp->restoreEnv(std::move(savedEnv));
Expand Down Expand Up @@ -808,11 +811,22 @@ CsgNodePtr CsgEvaluator::evalChildren(const ModuleCallNode& call, const glm::mat
// children(i)'s index expression is written in the *callee* module body,
// so evaluate it in the callee's (still-active) scope, before switching
// to the caller's scope for the child AST nodes themselves.
std::optional<int> idx;
//
// The index argument can be a plain number (children(i)), a vector of
// numbers (children([0,2])), or a range (children([0:2])) — collect
// whichever form into a flat list of indices to evaluate below.
std::vector<int> indices;
if (!call.args.empty()) {
Value idxVal = m_interp->evaluate(*call.args[0].value);
if (idxVal.isNumber())
idx = static_cast<int>(idxVal.asNumber());
if (idxVal.isNumber()) {
indices.push_back(static_cast<int>(idxVal.asNumber()));
} else if (idxVal.isVector()) {
for (const auto& e : idxVal.asVec())
if (e.isNumber()) indices.push_back(static_cast<int>(e.asNumber()));
} else if (idxVal.isRange()) {
for (const auto& v : m_interp->expandRange(idxVal.rangeStart, idxVal.rangeStep, idxVal.rangeEnd))
if (v.isNumber()) indices.push_back(static_cast<int>(v.asNumber()));
}
}

// Children AST nodes were written at the call site and must be evaluated
Expand All @@ -830,10 +844,15 @@ CsgNodePtr CsgEvaluator::evalChildren(const ModuleCallNode& call, const glm::mat
if (auto c = evalNode(*child, xform, color))
all.push_back(std::move(c));
}
} else if (idx && *idx >= 0 && *idx < static_cast<int>(activeChildren->size())) {
// children(i) — evaluate the i-th child
if (auto c = evalNode(*(*activeChildren)[static_cast<std::size_t>(*idx)], xform, color))
all.push_back(std::move(c));
} else {
// children(i) / children([...]) / children([a:b]) — evaluate each
// requested index that's actually in range; out-of-range indices are
// skipped rather than aborting the whole call.
for (int idx : indices) {
if (idx < 0 || idx >= static_cast<int>(activeChildren->size())) continue;
if (auto c = evalNode(*(*activeChildren)[static_cast<std::size_t>(idx)], xform, color))
all.push_back(std::move(c));
}
}

// Restore the callee's environment now that caller-scope evaluation is done
Expand Down
13 changes: 10 additions & 3 deletions src/csg/MeshEvaluator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -412,11 +412,18 @@ manifold::Manifold MeshEvaluator::evalExtrusion(const CsgExtrusion& e,
float scaleX = static_cast<float>(getP("scale_x", 1.0));
float scaleY = static_cast<float>(getP("scale_y", 1.0));
double fnOvr = getP("$fn", 0.0);
double slices = getP("slices", 0.0);
// Divisions are only needed for twist (to smoothly interpolate the
// rotation). A plain scale taper works correctly with 0 divisions.
int nDivs = (twist != 0.0)
? std::max(1, static_cast<int>(fnOvr > 0 ? fnOvr : 10))
: 0;
// An explicit slices=... always wins over the $fn-derived default —
// it's OpenSCAD's dedicated knob for this and should be honored even
// when the caller also set $fn for an unrelated reason (e.g. a
// sibling circle() in the same file).
int nDivs = 0;
if (slices > 0.0)
nDivs = std::max(1, static_cast<int>(slices));
else if (twist != 0.0)
nDivs = std::max(1, static_cast<int>(fnOvr > 0 ? fnOvr : 10));
bool center = (getP("center", 0.0) != 0.0);

result = manifold::Manifold::Extrude(
Expand Down
113 changes: 111 additions & 2 deletions src/lang/Interpreter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
#include <limits>
#include <random>

static constexpr double kDeg2Rad = 3.14159265358979323846 / 180.0;
static constexpr double kRad2Deg = 180.0 / 3.14159265358979323846;
static constexpr double kPi = 3.14159265358979323846;
static constexpr double kDeg2Rad = kPi / 180.0;
static constexpr double kRad2Deg = 180.0 / kPi;

namespace chisel::lang {

Expand Down Expand Up @@ -77,6 +78,16 @@ Value Interpreter::evaluate(const ExprNode& expr) {
else if constexpr (std::is_same_v<T, VarRef>) {
auto it = m_env.find(node.name);
if (it != m_env.end()) return it->second;
// Predefined constants/special vars — checked only once the
// environment lookup misses, so a user assignment of the same
// name (e.g. `PI = 3;`) still wins, matching OpenSCAD (these
// aren't protected keywords, just default values).
if (node.name == "PI") return Value::fromNumber(kPi);
if (node.name == "$preview") return Value::fromBool(true); // no separate export-render pass exists yet
if (node.name == "$t") return Value::fromNumber(0.0); // no animation/scrubbing yet (see roadmap v4)
if (node.name == "$parent_modules")
return Value::fromNumber(m_moduleNameStack.empty() ? 0.0
: static_cast<double>(m_moduleNameStack.size() - 1));
return Value::undef();
}

Expand Down Expand Up @@ -616,6 +627,104 @@ Value Interpreter::callBuiltin(const std::string& name,
return Value::fromNumber(pairs.back().second);
}

// ---- Type predicates ----
if (name == "is_undef") return args.size() >= 1 ? Value::fromBool(args[0].isUndef()) : Value::undef();
if (name == "is_bool") return args.size() >= 1 ? Value::fromBool(args[0].isBool()) : Value::undef();
if (name == "is_num") return args.size() >= 1 ? Value::fromBool(args[0].isNumber()) : Value::undef();
if (name == "is_string") return args.size() >= 1 ? Value::fromBool(args[0].isString()) : Value::undef();
if (name == "is_list") return args.size() >= 1 ? Value::fromBool(args[0].isVector()) : Value::undef();
// No first-class function-literal value exists yet (see roadmap v3.5),
// so nothing can ever actually be one — always false rather than absent,
// which is still correct given today's set of possible Value tags.
if (name == "is_function") return args.size() >= 1 ? Value::fromBool(false) : Value::undef();

// ---- version() / version_num() ----
// ChiselCAD isn't OpenSCAD, so there's no real "version" to report; this
// reports a fixed OpenSCAD-compatibility level instead, chosen to match
// the newest feature this interpreter actually supports (general list
// comprehensions/each, from OpenSCAD 2019.05) — real-world .scad files
// commonly gate features with `if (version_num() >= 20190500)`, and this
// makes them pick the modern branch. Don't bump this without confirming
// the corresponding OpenSCAD-version feature is actually supported.
if (name == "version") return Value::fromVec({Value::fromNumber(2019),
Value::fromNumber(5),
Value::fromNumber(0)});
if (name == "version_num") return Value::fromNumber(20190500);

// ---- parent_module(idx) — name of the module idx levels up the current
// module-call chain (0 = immediate caller). See m_moduleNameStack. ----
if (name == "parent_module") {
if (args.size() >= 1 && args[0].isNumber()) {
int idx = static_cast<int>(args[0].asNumber());
int i = static_cast<int>(m_moduleNameStack.size()) - 2 - idx;
if (idx >= 0 && i >= 0)
return Value::fromString(m_moduleNameStack[static_cast<std::size_t>(i)]);
}
return Value::undef();
}

// ---- search(match_value, string_or_vector, [num_returns_per_match=1], [index_col_num=0]) ----
if (name == "search") {
if (args.size() < 2) return Value::undef();
const Value& target = args[1];
if (!target.isString() && !target.isVector()) return Value::undef();

int numReturns = 1;
if (args.size() >= 3 && args[2].isNumber()) numReturns = static_cast<int>(args[2].asNumber());
int indexCol = 0;
if (args.size() >= 4 && args[3].isNumber()) indexCol = static_cast<int>(args[3].asNumber());

std::size_t targetSize = target.isString() ? target.asString().size() : target.asVec().size();
auto elementAt = [&](std::size_t i) -> Value {
if (target.isString())
return Value::fromString(std::string(1, target.asString()[i]));
const Value& e = target.asVec()[i];
Comment thread
particlesector marked this conversation as resolved.
if (e.isVector()) {
if (indexCol == -1) return e; // compare against the whole row
if (indexCol >= 0 && static_cast<std::size_t>(indexCol) < e.asVec().size())
return e.asVec()[static_cast<std::size_t>(indexCol)];
return Value::undef();
}
return e;
};
auto searchOne = [&](const Value& needle) -> std::vector<Value> {
std::vector<Value> matches;
for (std::size_t i = 0; i < targetSize; ++i) {
if (valEqRec(needle, elementAt(i))) {
matches.push_back(Value::fromNumber(static_cast<double>(i)));
if (numReturns > 0 && static_cast<int>(matches.size()) >= numReturns) break;
}
}
return matches;
};

const Value& matchVal = args[0];
// A multi-character string or a vector match_value normally searches
// once per element and nests each element's matches in its own
// sub-vector; a single scalar/character match_value returns its
// matches directly (matching OpenSCAD: search("a","abcdabcd") ==
// [0], but search("abc","abcdabcd") == [[0],[1],[2]]).
//
// index_col_num == -1 is the exception: it means "compare match_value
// whole against each entire row", specifically so a *vector*
// match_value can be matched as one unit against table rows rather
// than decomposed per-element — so the per-element nesting above
// must not kick in for it.
if (indexCol != -1 && matchVal.isString() && matchVal.asString().size() > 1) {
std::vector<Value> outer;
for (char c : matchVal.asString())
outer.push_back(Value::fromVec(searchOne(Value::fromString(std::string(1, c)))));
return Value::fromVec(std::move(outer));
}
if (indexCol != -1 && matchVal.isVector()) {
std::vector<Value> outer;
for (const auto& needle : matchVal.asVec())
outer.push_back(Value::fromVec(searchOne(needle)));
return Value::fromVec(std::move(outer));
}
return Value::fromVec(searchOne(matchVal));
}

return Value::undef(); // unknown function
}

Expand Down
9 changes: 9 additions & 0 deletions src/lang/Interpreter.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <array>
#include <string>
#include <unordered_map>
#include <vector>

namespace chisel::lang {

Expand Down Expand Up @@ -55,9 +56,17 @@ class Interpreter {
std::unordered_map<std::string, Value> snapshotEnv() const { return m_env; }
void restoreEnv(std::unordered_map<std::string, Value> env) { m_env = std::move(env); }

// Module-call-name stack backing parent_module()/$parent_modules. Module
// calls are evaluated by CsgEvaluator, not here, so CsgEvaluator pushes/
// pops around each user-module call (mirroring how it already sets
// $children) rather than this class tracking module calls itself.
void pushModuleName(std::string name) { m_moduleNameStack.push_back(std::move(name)); }
void popModuleName() { m_moduleNameStack.pop_back(); }

private:
std::unordered_map<std::string, Value> m_env;
std::unordered_map<std::string, const FunctionDef*> m_funcDefs;
std::vector<std::string> m_moduleNameStack;

// Guards against unbounded recursion in user-defined functions (e.g. a
// missing base case) blowing the native call stack. Silent cap, no
Expand Down
Loading
Loading