From f5422651f682f34cb2187806d4576f0b8d411600 Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Sat, 1 Aug 2026 08:17:52 -0700 Subject: [PATCH] Extend Op::PushCsgWrap to intersection_for -- the true last gap intersection_for was the one remaining construct still falling to Op::NativeStatement when it wraps a recursive call, capped at the old kMaxDriveVmNativeDepth=40 native-reentry ceiling -- unlike every other builtin fixed so far, it isn't a ModularCall at all, and its own group_sizes (resolveIntersectionFor, control.cpp) are keyed by RUNTIME cartesian-product loop iteration count rather than a static list of source statements, so it couldn't just reuse emitCsgWrap's own shape. Widened CompiledChunk::CsgWrapSite (`node` generalized from ModularCall* to ASTNode*, new `hasArgs`/`includeOpParam` flags, both false only for intersection_for -- it has no `.arguments` to resolve and no "op" key in its own CSGParams) so Op::PushCsgWrap/PopCsgWrap's existing bracket covers it too. compileIntersectionForLoop reuses compileForLoop's own cartesian-loop scaffold verbatim (Op::NativeIterMaterialize/ ForIterNext/ForIterEnd/IterReset -- verified Op::ForIterNext's own ctx construction is exactly equivalent to resolveIntersectionFor's own recurse lambda), with Op::CsgGroupStart/CsgGroupEnd wrapped around the body so each full iteration becomes one group instead of one source statement. Verified with a properly-relinked CLI binary (a stale one earlier in this investigation silently hid the fix on first attempt) -- succeeds at depth 1500, was capped at 39 before. Co-Authored-By: Claude Sonnet 5 --- include/openscad_cpp_evaluator/bytecode.hpp | 144 ++++++++++++-------- pyproject.toml | 2 +- src/bytecode_compiler.cpp | 82 ++++++++++- src/bytecode_vm.cpp | 34 +++-- tests/test_bytecode_compiler.cpp | 40 ++++++ 5 files changed, 230 insertions(+), 72 deletions(-) diff --git a/include/openscad_cpp_evaluator/bytecode.hpp b/include/openscad_cpp_evaluator/bytecode.hpp index c8205ba..82d0872 100644 --- a/include/openscad_cpp_evaluator/bytecode.hpp +++ b/include/openscad_cpp_evaluator/bytecode.hpp @@ -327,7 +327,7 @@ enum class Op { // -- CSG-wrap compilation (closes the LAST native-reentry source in ---- // -- the original NativeStatement gap: union()/difference()/ - - // -- intersection()) ---------------------------------------------------- + // -- intersection()/intersection_for) ----------------------------------- // union()/difference()/intersection() weren't covered by the original // Op::PushBuiltinWrap (see that op's own doc comment) because they need // bespoke bookkeeping PushBuiltinWrap's single all-children bracket @@ -350,24 +350,30 @@ enum class Op { // // a = index into CompiledChunk::csgWrapSites. Runtime handler: captures // ev.randsCallCount() BEFORE argument resolution (same rands-in-args - // taint reasoning as PushBuiltinWrap), resolves the (rare, $-only) - // arguments via resolveCallArgs exactly like resolveCsg itself does - // (discarding the positional/named result -- union/difference/ - // intersection take no real parameters -- keeping only the possibly- - // $-scoped child ctx), pushes that ctx onto f.ctxChain unconditionally - // (mirrors Transform/Color's own unconditional push -- a bare - // `union() {...}` with no `$fn=...` override still pushes a ctx that's - // merely a copy, cheap and uniform rather than a special-cased branch), - // pushes a fresh ev.treeStack_ frame (every child statement's own - // CSGNode(s), across every group, land flat in this ONE frame -- - // mirrors Evaluator::buildTreeNode/evalModularCall's own single - // treeStack_.emplace_back() around the whole call, not one per group), - // and stashes {op, randsBefore, siteIdx, empty groupSizes} onto - // VmFrame::csgWrapStack (a real per-frame LIFO, same reasoning as - // builtinWrapStack: nested/sequenced CSG wraps within one frame's own - // instruction stream, e.g. `union() { difference() {...} }`). The - // compiler always emits a plain Op::CheckDebugStatement immediately - // before this (see emitCsgWrap, bytecode_compiler.cpp), mirroring + // taint reasoning as PushBuiltinWrap); for a `hasArgs` site (union/ + // difference/intersection) resolves the (rare, $-only) arguments via + // resolveCallArgs exactly like resolveCsg itself does (discarding the + // positional/named result -- these take no real parameters -- keeping + // only the possibly-$-scoped child ctx) and pushes that ctx onto + // f.ctxChain unconditionally (mirrors Transform/Color's own + // unconditional push -- a bare `union() {...}` with no `$fn=...` + // override still pushes a ctx that's merely a copy, cheap and uniform + // rather than a special-cased branch); intersection_for (`hasArgs` + // false -- it isn't a call, has no `.arguments`) skips both entirely, + // matching resolveIntersectionFor's own use of its caller's ctx + // unchanged (its per-ITERATION child ctxs are a completely separate + // concern, handled by the compiled cartesian loop's own Op:: + // ForIterNext, not by this bracket). Either way: pushes a fresh + // ev.treeStack_ frame (every child statement's/iteration's own + // CSGNode(s) land flat in this ONE frame -- mirrors Evaluator:: + // buildTreeNode/evalModularCall's own single treeStack_.emplace_back() + // around the whole call, not one per group), and stashes {op, + // randsBefore, siteIdx, empty groupSizes} onto VmFrame::csgWrapStack (a + // real per-frame LIFO, same reasoning as builtinWrapStack: nested/ + // sequenced CSG wraps within one frame's own instruction stream, e.g. + // `union() { difference() {...} }`). The compiler always emits a plain + // Op::CheckDebugStatement immediately before this (see emitCsgWrap/ + // compileIntersectionForLoop, bytecode_compiler.cpp), mirroring // emitBuiltinWrap's own pattern -- this is a genuine statement doing // real work here, not a call transferring control to a declaration. PushCsgWrap, @@ -401,14 +407,19 @@ enum class Op { // VmFrame::csgWrapStack's own top entry FIRST (before anything that can // itself throw, e.g. setTreeDepthOrThrow below -- same "exception- // teardown's own pending count must already be right" reasoning as - // PopBuiltinWrap), pops the ctx PushCsgWrap unconditionally pushed, + // PopBuiltinWrap), pops the ctx Push pushed (only when `hasArgs`), // pops ev.treeStack_ to retrieve every group's own CSGNode(s) (flat, // exactly like resolveCsg's own `children` result -- group boundaries // live only in group_sizes, never in the CSGNode list's own shape), and // builds the tagged CSGNode exactly like Evaluator::buildTreeNode's own - // post-resolveBody() half does, with params = {"op": site.op, - // "group_sizes": ValueList(pending.groupSizes)} -- byte-for-byte what - // native resolveCsg returns -- pushing the result onto the new top of + // post-resolveBody() half does, with params = {"group_sizes": + // ValueList(pending.groupSizes)} plus, only when `includeOpParam`, + // "op": site.op -- byte-for-byte what native resolveCsg returns for + // union/difference/intersection (generateCsg needs "op" to + // disambiguate the ONE function it shares across all 3) and what + // native resolveIntersectionFor returns (no "op" key at all -- + // generateIntersectionFor is its own dedicated, separately-registered + // function, never needs one) -- pushing the result onto the new top of // treeStack_. a = index into CompiledChunk::csgWrapSites (same site // Push used). // @@ -462,33 +473,40 @@ enum class Op { // no separate site table needed). CallChildren, - // A single "native passthrough" statement -- intersection_for, every - // OTHER builtin module call not covered by Op::PushBuiltinWrap/ - // Op::PushCsgWrap (see those ops' own doc comments for exactly which - // builtins ARE covered -- cube/sphere/hull/linear_extrude/etc., the - // ones that never wrap a recursive call in idiomatic OpenSCAD), the `*` - // modifier's own no-op case, or a user-module call that didn't - // resolve at compile time (shadowed, forward-declared, or otherwise - // not statically known) -- anything compileStatementList doesn't give - // its own real bytecode. (Assignment/ModularEcho/ModularAssert/ - // ModularLet used to fall here too; they now have their own real - // bytecode -- Op::StoreModuleVar/Op::Echo/Op::AssertStatement/ - // Op::OpenLetScope+StoreLetVar -- purely a throughput change, since - // none of these were ever the recursion-depth risk this compiler - // targets. `#`/`%`/`!` modifiers and translate/rotate/scale/mirror/ - // multmatrix/resize/color used to fall here too, for the SAME - // throughput reasoning -- WRONG in that one specific case: a - // recursive module call wrapped in one of these is exactly the - // pattern that made this op's own native reentry a genuine Windows - // crash risk in practice, not just a missed optimization. See - // Op::PushBuiltinWrap's own doc comment for the real story and the - // fix. children() fell here too, and was the LAST and largest such - // reentry source once PushBuiltinWrap's own set was covered -- it now - // has Op::CallChildren, above. union()/difference()/intersection() - // fell here too, and were the last remaining REAL native-reentry risk - // (bespoke group_sizes bookkeeping meant they couldn't just reuse + // A single "native passthrough" statement -- every builtin module call + // not covered by Op::PushBuiltinWrap/Op::PushCsgWrap (see those ops' + // own doc comments for exactly which builtins ARE covered -- cube/ + // sphere/hull/linear_extrude/etc., the ones that never wrap a + // recursive call in idiomatic OpenSCAD), the `*` modifier's own no-op + // case, or a user-module call that didn't resolve at compile time + // (shadowed, forward-declared, or otherwise not statically known) -- + // anything compileStatementList doesn't give its own real bytecode. + // (Assignment/ModularEcho/ModularAssert/ModularLet used to fall here + // too; they now have their own real bytecode -- Op::StoreModuleVar/ + // Op::Echo/Op::AssertStatement/Op::OpenLetScope+StoreLetVar -- purely + // a throughput change, since none of these were ever the recursion- + // depth risk this compiler targets. `#`/`%`/`!` modifiers and + // translate/rotate/scale/mirror/multmatrix/resize/color used to fall + // here too, for the SAME throughput reasoning -- WRONG in that one + // specific case: a recursive module call wrapped in one of these is + // exactly the pattern that made this op's own native reentry a + // genuine Windows crash risk in practice, not just a missed + // optimization. See Op::PushBuiltinWrap's own doc comment for the + // real story and the fix. children() fell here too, and was the LAST + // and largest such reentry source once PushBuiltinWrap's own set was + // covered -- it now has Op::CallChildren, above. union()/difference()/ + // intersection() fell here too, and were the last remaining REAL + // native-reentry risk among ModularCall-shaped statements (bespoke + // group_sizes bookkeeping meant they couldn't just reuse // PushBuiltinWrap's own bracket) -- they now have Op::PushCsgWrap, - // above.) + // above. intersection_for -- NOT a ModularCall at all, its own + // NodeKind -- fell here too and was the true LAST native-reentry gap; + // it now shares Op::PushCsgWrap's own bracket (CsgWrapSite::hasArgs/ + // includeOpParam both false for it) wrapped around a compiled + // cartesian-product loop reusing Op::ForIterNext/ForIterEnd/ + // NativeIterMaterialize verbatim (see compileIntersectionForLoop, + // bytecode_compiler.cpp) -- one Op::CsgGroupStart/CsgGroupEnd pair per + // full iteration instead of per source statement.) // a = index into CompiledChunk::nativeStatements. Runtime just does // what Evaluator::evalChildren's own per-statement loop already does // for one node: derive childCtx via ctx.withScope(...), checkDebug, @@ -836,14 +854,30 @@ struct CompiledChunk { // One Op::PushCsgWrap/PopCsgWrap site pair -- see those ops' own doc // comments for the full "why union/difference/intersection need // bespoke group_sizes bookkeeping instead of just reusing - // BuiltinWrapSite" rationale. `op` is always "union"/"difference"/ - // "intersection" (the only 3 names resolveDispatch() maps to - // resolveCsg) -- always a genuine ModularCall (unlike BuiltinWrapSite's - // Modifier kind, this construct has no non-ModularCall variant), so - // `node` is typed precisely rather than a generic ASTNode*. + // BuiltinWrapSite" rationale. `op` is "union"/"difference"/ + // "intersection" (the 3 names resolveDispatch() maps to resolveCsg, + // always a genuine ModularCall) OR "intersection_for" (NOT a + // ModularCall -- a distinct NodeKind/ModuleInstantiation subtype with + // its own `.assignments`/`.body`, see compileIntersectionForLoop, + // bytecode_compiler.cpp) -- `node` is therefore the generic ASTNode* + // BuiltinWrapSite's own Modifier kind already established this pattern + // for, down-cast to ModularCall only when `hasArgs` is true. + // + // `hasArgs`/`includeOpParam` are both true for union/difference/ + // intersection, both false for intersection_for: it isn't a call at + // all (no `.arguments`, so Op::PushCsgWrap's handler must skip + // resolveCallArgs/the ctx push entirely -- matches native + // resolveIntersectionFor, which uses the SAME ctx its caller passed in + // unchanged, never a $-scoped child of its own), and its own + // CSGParams has no "op" key at all (generateIntersectionFor is a + // dedicated, separately-registered generate function, unlike + // generateCsg which needs "op" in params to disambiguate union/ + // difference/intersection from ONE shared function). struct CsgWrapSite { std::string op; - const oscad::ModularCall* node = nullptr; + bool hasArgs = true; + bool includeOpParam = true; + const oscad::ASTNode* node = nullptr; }; // One Op::AssertStatement site -- see that op's own doc comment for diff --git a/pyproject.toml b/pyproject.toml index 3d5066b..af14161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "0.13.5" +version = "0.13.7" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/src/bytecode_compiler.cpp b/src/bytecode_compiler.cpp index 9dc4277..33e26a8 100644 --- a/src/bytecode_compiler.cpp +++ b/src/bytecode_compiler.cpp @@ -1241,6 +1241,76 @@ class Compiler { out.push_back({Op::PopCsgWrap, idx, 0, &call.position()}); } + // intersection_for(assignments...) { body } -- the cartesian-product + // analog of emitCsgWrap: same bracket (CsgWrapSite::hasArgs=false, + // includeOpParam=false -- see that struct's own doc comment, + // bytecode.hpp), but each "group" is one full loop ITERATION's own + // contribution instead of one top-level source STATEMENT's, mirroring + // resolveIntersectionFor's own two-level shape exactly (control.cpp): + // a cartesian-product loop over `node.assignments`, and at the + // innermost base case, ONE currentTreeFrameSize()-delta group per full + // iteration. Reuses compileForLoop's own cartesian-loop scaffold + // verbatim (Op::NativeIterMaterialize/ForIterNext/ForIterEnd/ + // IterReset) -- Op::ForIterNext's own ctx construction, + // `ctx.childCtx(nullptr, std::nullopt, ctx.childrenNodes, + // ctx.childrenCallerCtx)`, is exactly what resolveIntersectionFor's + // own recurse lambda does too (`parentCtx.childCtx(nullptr, + // std::nullopt, ctx.childrenNodes, ctx.childrenCallerCtx)`), since + // childrenNodes/childrenCallerCtx propagate unchanged through every + // nested childCtx either way -- just with Op::CsgGroupStart/ + // CsgGroupEnd wrapped around the body instead of a bare + // compileStatementList. The leading checkDebug is only emitted when + // node.body isn't empty, mirroring resolveIntersectionFor's own `if + // (!bodyNodes.empty())` guard exactly (unlike compileForLoop's own + // ModularFor sibling, which always emits one even for an empty body, + // against the FOR node itself as a fallback marker -- intersection_for + // has no such native fallback, so neither does this) -- but + // CsgGroupStart/CsgGroupEnd themselves are UNCONDITIONAL, since native + // measures a (possibly zero-size) group regardless of body emptiness. + void compileIntersectionForLoop(const oscad::ModularIntersectionFor& n, std::vector& out) { + out.push_back({Op::CheckDebugStatement, internNativeStatement(&n), 0, nullptr}); + CompiledChunk::CsgWrapSite site; + site.op = "intersection_for"; + site.hasArgs = false; + site.includeOpParam = false; + site.node = &n; + chunk_.csgWrapSites.push_back(std::move(site)); + const int idx = static_cast(chunk_.csgWrapSites.size()) - 1; + out.push_back({Op::PushCsgWrap, idx, 0, &n.position()}); + + const size_t numDims = n.assignments.size(); + std::vector iterListIds(numDims); + for (size_t d = 0; d < numDims; ++d) { + iterListIds[d] = nextIterList_++; + out.push_back({Op::NativeIterMaterialize, internNativeExpr(n.assignments[d]->expr.get()), iterListIds[d], + &n.assignments[d]->position()}); + } + std::vector topIdx(numDims); + std::vector forIterNextIdx(numDims); + for (size_t d = 0; d < numDims; ++d) { + if (d > 0) out.push_back({Op::IterReset, iterListIds[d], 0, nullptr}); + topIdx[d] = out.size(); + forIterNextIdx[d] = out.size(); + Instruction ins; + ins.op = Op::ForIterNext; + ins.a = internName(n.assignments[d]->name->name); + ins.b = iterListIds[d]; + ins.node = n.assignments[d].get(); + out.push_back(ins); + } + if (!n.body.empty()) { + out.push_back({Op::NativeCheckDebugExprLevel, internNativeStatement(n.body.front().get()), 0, nullptr}); + } + out.push_back({Op::CsgGroupStart, 0, 0, nullptr}); + compileStatementList(n.body, out); + out.push_back({Op::CsgGroupEnd, 0, 0, nullptr}); + for (size_t i = numDims; i-- > 0;) { + out.push_back({Op::ForIterEnd, static_cast(topIdx[i]), 0, nullptr}); + out[forIterNextIdx[i]].c = static_cast(out.size()); + } + out.push_back({Op::PopCsgWrap, idx, 0, &n.position()}); + } + void compileOneStatement(const oscad::ASTNode& stmt, std::vector& out) { using oscad::NodeKind; trackSpan(stmt); @@ -1533,18 +1603,20 @@ class Compiler { compileForLoop(static_cast(stmt), out); return; } + case NodeKind::ModularIntersectionFor: { + compileIntersectionForLoop(static_cast(stmt), out); + return; + } default: // ModularModifierDisable (`*`) -- deliberately native: its // child never evaluates at all (see evalStatement's own // ModularModifierDisable case, stmt_eval.cpp), so there's // no recursion to eliminate here in the first place. - // intersection_for -- deliberately native (bespoke - // per-statement grouping, same reasoning as union/ - // difference/intersection; not covered by - // Op::PushBuiltinWrap, see that op's own doc comment). // `#`/`%`/`!` and translate/rotate/scale/mirror/multmatrix/ // resize/color have their own real bytecode cases now, - // above; echo/assert/assignment/let-block already did. + // above; echo/assert/assignment/let-block already did; + // intersection_for has its own case now too (above), + // reusing Op::PushCsgWrap via compileIntersectionForLoop. out.push_back({Op::NativeStatement, internNativeStatement(&stmt), 0, &stmt.position()}); return; } diff --git a/src/bytecode_vm.cpp b/src/bytecode_vm.cpp index 8567ea8..57ed890 100644 --- a/src/bytecode_vm.cpp +++ b/src/bytecode_vm.cpp @@ -1118,14 +1118,23 @@ Value driveVm(Evaluator& ev, size_t floor) { // Captured BEFORE argument resolution -- same // rands-in-args taint reasoning as Op::PushBuiltinWrap. const std::uint64_t randsBefore = ev.randsCallCount(); - // union/difference/intersection take no positional - // arguments in real OpenSCAD -- `args` is discarded, - // exactly mirroring resolveCsg's own `(void)args;` - // (booleans.cpp). Only `effCtx` (a possibly-$-scoped - // child ctx, e.g. `difference($fn=8) {...}`) matters. - auto [args, effCtx] = resolveCallArgs(ev, site.node->arguments, ctx); - (void)args; - f.ctxChain.push_back(std::move(effCtx)); + if (site.hasArgs) { + // union/difference/intersection take no positional + // arguments in real OpenSCAD -- `args` is discarded, + // exactly mirroring resolveCsg's own `(void)args;` + // (booleans.cpp). Only `effCtx` (a possibly-$-scoped + // child ctx, e.g. `difference($fn=8) {...}`) matters. + auto [args, effCtx] = + resolveCallArgs(ev, static_cast(*site.node).arguments, ctx); + (void)args; + f.ctxChain.push_back(std::move(effCtx)); + } + // intersection_for (hasArgs=false) isn't a call at all + // -- no arguments to resolve, no ctx of its own to + // push; matches resolveIntersectionFor's own use of + // its caller's ctx unchanged (control.cpp). Its per- + // ITERATION child ctxs are pushed separately, by the + // compiled loop's own Op::ForIterNext. ev.treeStack_.emplace_back(); f.csgWrapStack.push_back({site.op, randsBefore, ins.a, {}, 0}); ++f.pc; @@ -1152,12 +1161,15 @@ Value driveVm(Evaluator& ev, size_t floor) { f.csgWrapStack.pop_back(); const CompiledChunk::CsgWrapSite& site = f.chunk->csgWrapSites[static_cast(pending.siteIdx)]; - f.ctxChain.pop_back(); + if (site.hasArgs) f.ctxChain.pop_back(); std::vector> children = std::move(ev.treeStack_.back()); ev.treeStack_.pop_back(); - // Mirrors resolveCsg's own params exactly (booleans.cpp). + // Mirrors resolveCsg's own params exactly for union/ + // difference/intersection (booleans.cpp) -- and + // resolveIntersectionFor's own (no "op" key at all, + // control.cpp) when !includeOpParam. CSGParams params; - params["op"] = Value{pending.op}; + if (site.includeOpParam) params["op"] = Value{pending.op}; params["group_sizes"] = Value{std::make_shared(ValueList{std::move(pending.groupSizes)})}; // Mirrors Evaluator::buildTreeNode's own post- diff --git a/tests/test_bytecode_compiler.cpp b/tests/test_bytecode_compiler.cpp index bd9a508..8143995 100644 --- a/tests/test_bytecode_compiler.cpp +++ b/tests/test_bytecode_compiler.cpp @@ -1514,6 +1514,46 @@ TEST(ModuleBodyCompiles, CompiledCsgWrapEvaluatesAllAssignmentsBeforeAnyGeometry EXPECT_NEAR(e.bodies[0].body->Volume(), 1.0, 1e-6); } +// -- intersection_for -- the TRUE last native-reentry gap, closed by ------- +// -- reusing Op::PushCsgWrap around a compiled cartesian-product loop. ----- +// DebugHooksParity.IntersectionForMarksBodyEntryOnlyNotEachBinding +// (test_debug_hooks.cpp) and every existing IntersectionFor.* test +// (test_control_flow.cpp -- including the multi-body-per-iteration +// grouping case, combineBodies' own union branch) already exercise this +// new compiled path under the ambient VM-on default and pass unchanged; +// these two are the NEW behavior this fix specifically adds. + +// A recursive call wrapped in intersection_for() used to fall to +// Op::NativeStatement like every other uncovered construct -- one real +// native reentry per level, capped at the old kMaxDriveVmNativeDepth=40. +// `i=[0:0]` (a single-element range) keeps this to one group per level, +// same shape/depth as the union/difference/intersection depth tests above +// (comfortably under kMaxCsgTreeDepth=2000). +TEST(ModuleBodyCompiles, IntersectionForWrappedRecursionSucceedsWellPastTheOldNativeReentryLimit) { + ScopedVm vm(true); + Evaluated e = evalSrc("module recur(n) { intersection_for (i = [0:0]) { recur2(n); } }\n" + "module recur2(n) { if (n > 0) { recur(n - 1); } else { cube(1); } }\n" + "recur(1500);"); + ASSERT_EQ(e.bodies.size(), 1u); +} + +// Nested (2-dimensional) cartesian product, compiled -- proves +// compileIntersectionForLoop's reuse of compileForLoop's own multi- +// dimension IterReset/ForIterNext/ForIterEnd scaffold is correct for MORE +// than one dimension, not just the single-assignment shape every other +// existing intersection_for test uses. i in {0,1}, j in {0,1} -> 4 total +// iterations, each contributing exactly one echo() call. +TEST(ModuleBodyCompiles, CompiledIntersectionForHandlesTwoDimensionalCartesianProduct) { + ScopedVm vm(true); + int echoCount = 0; + Evaluator ev([&](const std::string&) { ++echoCount; }); + auto ast = parseSrc("intersection_for (i = [0,1], j = [0,1]) { echo(i, j); cube(1); }"); + auto scope = oscad::buildScopes(ast); + EvalContext ctx = EvalContext::makeRoot(scope.get()); + ev.resolveTree(ast, ctx); + EXPECT_EQ(echoCount, 4); +} + // The BOSL2 attachable() shape this whole effort targets: a wrapper module // whose body is just `children();`, applied at every level of a recursive // chain. children() used to fall to Op::NativeStatement -- one genuine