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
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,10 @@ grep for `ponytail:`.
lexically nested inside an already-active call," picking `childCtx()` vs `callCtx()`
accordingly; read its doc comment before touching it, it's the second-trickiest mechanism in this
codebase after the CSG tree stack), `evalUserModule`/`evalUserFunction`/`evalFunctionLiteral`,
and `builtinChildren` (children()/children(N), deferred evaluation). `evalFunctionCall`'s
and `builtinChildren` (children()/children(N), deferred evaluation; `children(separate=true)`
additionally marks each forwarded node `separateOperand` so the enclosing
union/difference/intersection treats them as separate operands rather than one grouped one --
a BelfrySCAD extension, see `Evaluator::appendGroupSizes`). `evalFunctionCall`'s
precedence order (checked in this exact sequence): `import` (special-cased, its own
module/expression-context split) → `isBuiltinFunctionName` (function_builtins.hpp — always wins
if true) → user function lookup → function-literal *value* probe → "unknown function" warning.
Expand Down
4 changes: 4 additions & 0 deletions include/openscad_cpp_evaluator/bytecode_vm.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ struct VmFrame {
// mirrors evalModularCall's own `&node` (csg_resolve.cpp). Only
// meaningful when ownsModuleSplice is true.
const oscad::ASTNode* moduleSpliceCallNode = nullptr;
// children(separate=true) on the call this frame is forwarding for.
// Only meaningful while ownsModuleSplice is true, same as the two
// fields above; reset in releaseVmFrame for the same pooling reason.
bool separateChildren = false;

// Whether callStack_.back() (at push time) genuinely IS this frame's
// own logical call, and therefore safe for a tail hop inside this
Expand Down
18 changes: 18 additions & 0 deletions include/openscad_cpp_evaluator/csg_node.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ struct CSGNode {
CSGParams params; // resolve step's plain-data output
bool uncacheable = false; // set by a later phase (ManifoldCache, Phase 8)

// Set only by children(separate=true): this node begins its OWN operand
// group in an enclosing union/difference/intersection/intersection_for,
// instead of merging into the single group its enclosing statement
// would otherwise form. See Evaluator::appendGroupSizes.
//
// Deliberately absent from cacheKey()'s allowlist (manifold_cache.cpp):
// it changes the PARENT's hashed "group_sizes" param, never this node's
// own geometry, so the parent already re-keys and this node must not.
bool separateOperand = false;

// The call site that entered this node's call chain from the top
// level, captured at RESOLVE time -- non-owning, AST-lifetime-bound
// like `node`. nullptr for a node resolved at top level.
Expand Down Expand Up @@ -73,4 +83,12 @@ struct CSGNode {
std::optional<std::string> cachedKey;
};

// Marks every node from `from` onward as starting its own operand group --
// what children(separate=true) does to the geometry it just forwarded.
// A free function, not a member, so all three splice sites (user_calls.cpp
// and bytecode_vm.cpp's two) can reach it from this header alone.
inline void markSeparateOperands(std::vector<std::unique_ptr<CSGNode>>& nodes, size_t from) {
for (size_t i = from; i < nodes.size(); ++i) nodes[i]->separateOperand = true;
}

} // namespace oscadeval
29 changes: 29 additions & 0 deletions include/openscad_cpp_evaluator/evaluator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,30 @@ class Evaluator {
// statement.
size_t currentTreeFrameSize() const { return treeStack_.back().size(); }

// Records the group(s) one child statement contributed, given the frame
// size captured before it ran. Normally that is exactly one group, of
// however many nodes the statement pushed -- including a group of size
// ZERO for a statement that produced no geometry (a disabled `*cube()`),
// which generateCsg relies on to reset intersection() and to bail
// difference(). Only children(separate=true) produces more than one:
// each node it marked starts a fresh group, so its forwarded children
// reach the enclosing operator as separate operands.
//
// Shared by all four group builders -- resolveCsg, resolveIntersectionFor
// and the VM's Op::CsgGroupEnd (which serves both) -- because
// "group_sizes" is otherwise built twice, once per engine, and would
// drift.
void appendGroupSizes(std::vector<Value>& groupSizes, size_t before) const {
const std::vector<std::unique_ptr<CSGNode>>& frame = treeStack_.back();
size_t start = before;
for (size_t i = before + 1; i < frame.size(); ++i) {
if (!frame[i]->separateOperand) continue;
groupSizes.push_back(Value{static_cast<double>(i - start)});
start = i;
}
groupSizes.push_back(Value{static_cast<double>(frame.size() - start)});
}

// Generate pass: walks `tree` bottom-up (children before their own
// node), calling each node's registered GenerateFn (falling back to
// concatenating children's bodies for a kind with none registered).
Expand Down Expand Up @@ -342,6 +366,11 @@ class Evaluator {
struct ChildrenForward {
EvalContext evalCtx;
std::vector<const oscad::ASTNode*> nodes;
// children(separate=true): hand these to the enclosing union/
// difference/intersection as SEPARATE operands rather than as one
// grouped operand. Acted on after the nodes are evaluated, by
// marking the CSGNodes they produced -- see markSeparateOperands.
bool separate = false;
};
std::optional<ChildrenForward> prepareChildrenForward(const CallArgs& args, EvalContext& ctx);

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"

[project]
name = "openscad_cpp_evaluator"
version = "0.41.0"
version = "0.42.0"
description = "C++ OpenSCAD evaluator with Python bindings"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
10 changes: 7 additions & 3 deletions src/builtins/booleans.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ std::optional<manifold::CrossSection> toCrossSection(const std::vector<ColoredBo
// operand (unioned together), and each subsequent statement's bodies are
// unioned then subtracted -- a flat evaluation would lose this grouping and
// misbehave when e.g. BOSL2's attachable() returns multiple bodies (parent
// + attached children) as one operand. Mirrors _resolve_csg/_generate_csg,
// + attached children) as one operand.
//
// One statement can contribute MORE than one group: children(separate=true)
// marks the nodes it forwards so each starts its own operand group, which is
// how `difference() children(separate=true)` subtracts children 1..n from
// child 0. See Evaluator::appendGroupSizes, which both engines share. Mirrors _resolve_csg/_generate_csg,
// minus _attach_tri_colors' multi-color-merge provenance (ponytail: a
// merged body always takes its first contributing child's color, matching
// this evaluator's own pre-tri_colors baseline behavior -- revisit
Expand Down Expand Up @@ -101,8 +106,7 @@ CSGParams resolveCsg(Evaluator& ev, const oscad::ModularCall& node, EvalContext&
for (const oscad::ASTNode* geoNode : geoNodes) {
const size_t before = ev.currentTreeFrameSize();
ev.evalChildren(std::vector<const oscad::ASTNode*>{geoNode}, effCtx);
const size_t after = ev.currentTreeFrameSize();
groupSizes.push_back(Value{static_cast<double>(after - before)});
ev.appendGroupSizes(groupSizes, before);
}

CSGParams params;
Expand Down
3 changes: 1 addition & 2 deletions src/builtins/control.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ CSGParams resolveIntersectionFor(Evaluator& ev, const oscad::ModularIntersection
if (!bodyNodes.empty()) ev.checkDebug(*bodyNodes.front(), parentCtx, /*forced=*/false, /*exprLevel=*/true);
const size_t before = ev.currentTreeFrameSize();
ev.evalChildren(bodyNodes, parentCtx);
const size_t after = ev.currentTreeFrameSize();
groupSizes.push_back(Value{static_cast<double>(after - before)});
ev.appendGroupSizes(groupSizes, before);
return;
}
const auto& assign = node.assignments[depth];
Expand Down
2 changes: 1 addition & 1 deletion src/builtins/registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const std::vector<std::string>* builtinParamNames(const std::string& name) {
{"intersection", {}},
{"hull", {}},
{"minkowski", {"convexity"}},
{"children", {"index"}},
{"children", {"index", "separate"}},
{"render", {"convexity"}},
// "repair" is this port's own addition, not an upstream parameter.
{"import",
Expand Down
18 changes: 14 additions & 4 deletions src/bytecode_vm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ void pushBracketedModuleFrame(Evaluator& ev, const CompiledChunk& chunk, const o
// because module chunks never contain tail-call opcodes); don't inherit
// a pooled function frame's stale true here either.
void pushChildrenForwardFrame(Evaluator& ev, const CompiledChunk& chunk, EvalContext evalCtx,
std::uint64_t randsBefore, const oscad::ASTNode& callNode) {
std::uint64_t randsBefore, const oscad::ASTNode& callNode, bool separate) {
if (ev.vmCallStack_.size() >= Evaluator::kMaxVmCallStackDepth) {
ev.error("Recursion too deep while forwarding children()", callNode);
}
Expand All @@ -381,6 +381,7 @@ void pushChildrenForwardFrame(Evaluator& ev, const CompiledChunk& chunk, EvalCon
frame->ownsModuleSplice = true;
frame->moduleRandsBefore = randsBefore;
frame->moduleSpliceCallNode = &callNode;
frame->separateChildren = separate;
ev.vmCallStack_.push_back(std::move(frame));
ev.vmCallBrackets_.emplace_back(std::nullopt);
}
Expand Down Expand Up @@ -505,6 +506,7 @@ Value driveVm(Evaluator& ev, size_t floor) {
const bool ownsModuleSplice = finished->ownsModuleSplice;
const std::uint64_t moduleRandsBefore = finished->moduleRandsBefore;
const oscad::ASTNode* moduleSpliceCallNode = finished->moduleSpliceCallNode;
const bool separateChildren = finished->separateChildren;
while (!finished->ctxChain.empty()) finished->ctxChain.pop_back();
// Module frames never fire returnHook (native evalUserModule
// never did either -- a module call has no "return value"
Expand All @@ -514,6 +516,7 @@ Value driveVm(Evaluator& ev, size_t floor) {
if (isModule && ownsModuleSplice) {
std::vector<std::unique_ptr<CSGNode>> children = std::move(ev.treeStack_.back());
ev.treeStack_.pop_back();
if (separateChildren) markSeparateOperands(children, 0);
ev.spliceModuleChildren(std::move(children), moduleRandsBefore, *moduleSpliceCallNode);
}
if (isFloorFrame) {
Expand Down Expand Up @@ -1023,6 +1026,11 @@ Value driveVm(Evaluator& ev, size_t floor) {
static_cast<const oscad::ModularCall*>(f.chunk->nativeStatements[static_cast<size_t>(ins.a)]);
EvalContext scopedCtx = ctx.withScope(callNode->scope() ? callNode->scope() : ctx.scope);
ev.checkDebug(*callNode, scopedCtx);
// Same order as evalModularCall's own (csg_resolve.cpp):
// warn, then resolve. Without this a children() typo
// warns only under the interpreter, so no test for that
// warning could run under both engines.
warnUnexpectedBuiltinArgs(ev, *callNode);
const std::uint64_t randsBefore = ev.randsCallCount();
auto [args, effCtx] = resolveCallArgs(ev, callNode->arguments, scopedCtx);
std::optional<Evaluator::ChildrenForward> fwd = ev.prepareChildrenForward(args, effCtx);
Expand All @@ -1039,9 +1047,11 @@ Value driveVm(Evaluator& ev, size_t floor) {
const CompiledChunk* chunk = (ev.useBytecodeVm() && ev.inResolvePass())
? ev.lookupOrCompileChildrenListChunk(fwd->nodes)
: nullptr;
// Read before fwd->evalCtx is moved from.
const bool separate = fwd->separate;
if (chunk) {
ev.treeStack_.emplace_back();
pushChildrenForwardFrame(ev, *chunk, std::move(fwd->evalCtx), randsBefore, *callNode);
pushChildrenForwardFrame(ev, *chunk, std::move(fwd->evalCtx), randsBefore, *callNode, separate);
// f.pc deliberately NOT advanced -- resumes when
// the pushed frame completes; driveVm's completion
// branch runs the splice (isModule &&
Expand All @@ -1065,6 +1075,7 @@ Value driveVm(Evaluator& ev, size_t floor) {
}
std::vector<std::unique_ptr<CSGNode>> children = std::move(ev.treeStack_.back());
ev.treeStack_.pop_back();
if (separate) markSeparateOperands(children, 0);
ev.spliceModuleChildren(std::move(children), randsBefore, *callNode);
++f.pc;
}
Expand Down Expand Up @@ -1304,8 +1315,7 @@ Value driveVm(Evaluator& ev, size_t floor) {
}
case Op::CsgGroupEnd: {
PendingCsgWrap& pending = f.csgWrapStack.back();
const size_t after = ev.treeStack_.back().size();
pending.groupSizes.push_back(Value{static_cast<double>(after - pending.groupStartSize)});
ev.appendGroupSizes(pending.groupSizes, pending.groupStartSize);
++f.pc;
break;
}
Expand Down
7 changes: 6 additions & 1 deletion src/csg_resolve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,12 @@ void Evaluator::spliceModuleChildren(std::vector<std::unique_ptr<CSGNode>> child
// landed on.
for (auto& c : children) c->uncacheable = true;
}
if (children.size() > 1) {
// children(separate=true) marked these to start their own operand
// groups, and the group walk only inspects the enclosing frame's top
// level -- so the wrapper would hide the marks. Splice instead.
const bool anySeparate =
std::any_of(children.begin(), children.end(), [](const auto& c) { return c->separateOperand; });
if (children.size() > 1 && !anySeparate) {
auto unionNode = std::make_unique<CSGNode>();
unionNode->kind = "union";
unionNode->node = &callNode;
Expand Down
12 changes: 10 additions & 2 deletions src/user_calls.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ void Evaluator::releaseVmFrame(std::unique_ptr<VmFrame> frame) {
frame->ownsModuleSplice = false;
frame->moduleRandsBefore = 0;
frame->moduleSpliceCallNode = nullptr;
frame->separateChildren = false;
vmFramePool_.push_back(std::move(frame));
}

Expand Down Expand Up @@ -932,6 +933,11 @@ Value Evaluator::parentModuleName(int idx) const {

std::optional<Evaluator::ChildrenForward> Evaluator::prepareChildrenForward(const CallArgs& args, EvalContext& ctx) {
Value idxArg = getArg(args, 0, "index", Value{});
// Positional slot 1 is accepted as well as the name: adding "separate"
// to the builtin's parameter list already suppresses the "Too many
// unnamed arguments" warning for children(0, true), so reading it
// named-only would silently ignore an argument the author wrote.
const bool separate = truthy(getArg(args, 1, "separate", Value{false}));
if (!ctx.childrenNodes || ctx.childrenNodes->empty()) return std::nullopt;
const EvalContext* callerCtx = ctx.childrenCallerCtx;
if (!callerCtx) return std::nullopt;
Expand Down Expand Up @@ -977,7 +983,7 @@ std::optional<Evaluator::ChildrenForward> Evaluator::prepareChildrenForward(cons
}

if (std::holds_alternative<std::monostate>(idxArg)) {
return ChildrenForward{std::move(evalCtx), *ctx.childrenNodes};
return ChildrenForward{std::move(evalCtx), *ctx.childrenNodes, separate};
}

// children(N) indexes child *statements*, not output bodies -- a
Expand Down Expand Up @@ -1041,13 +1047,15 @@ std::optional<Evaluator::ChildrenForward> Evaluator::prepareChildrenForward(cons
picked.push_back(geoNodes[static_cast<size_t>(idx)]);
}
if (picked.empty()) return std::nullopt;
return ChildrenForward{std::move(evalCtx), std::move(picked)};
return ChildrenForward{std::move(evalCtx), std::move(picked), separate};
}

void Evaluator::builtinChildren(const CallArgs& args, EvalContext& ctx) {
std::optional<ChildrenForward> fwd = prepareChildrenForward(args, ctx);
if (!fwd) return;
const size_t before = currentTreeFrameSize();
evalChildren(fwd->nodes, fwd->evalCtx);
if (fwd->separate) markSeparateOperands(treeStack_.back(), before);
}

} // namespace oscadeval
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ add_executable(oscad_eval_tests
test_transforms.cpp
test_render_expr.cpp
test_booleans.cpp
test_children_separate.cpp
test_control_flow.cpp
test_tail_calls.cpp
test_function_builtins.cpp
Expand Down
Loading