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
137 changes: 125 additions & 12 deletions include/openscad_cpp_evaluator/bytecode.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,101 @@ enum class Op {
// same seam, now a counter).
PopBuiltinWrap,

// -- CSG-wrap compilation (closes the LAST native-reentry source in ----
// -- the original NativeStatement gap: union()/difference()/ -
// -- intersection()) ----------------------------------------------------
// 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
// doesn't do: Evaluator's own resolveCsg (booleans.cpp) evaluates each
// TOP-LEVEL child statement of the block SEPARATELY and records how
// many CSGNodes it individually contributed ("group_sizes") -- e.g.
// `difference(){ A; B; C; }` = A - (B u C), preserving A's own group
// even when A itself expands to more than one body (an attachable()-
// style call returning parent+children as one operand). A flat
// all-children bracket the way Transform/Color/Modifier use would lose
// that grouping entirely. This op pair (PushCsgWrap/PopCsgWrap) plus
// Op::CsgGroupStart/CsgGroupEnd (below) replicate resolveCsg's exact
// two-pass shape (all assignments first, THEN one evalChildren-per-
// geometry-statement) as inline compiled bytecode instead, exactly as
// PushBuiltinWrap already did for translate/rotate/scale/mirror/
// multmatrix/resize/color/#/%/! -- same rationale (a recursive
// union()/difference()/intersection()-wrapped module chain was still a
// real Windows native-reentry depth risk, just never independently
// fixed when PushBuiltinWrap's own set was).
//
// 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
// emitBuiltinWrap's own pattern -- this is a genuine statement doing
// real work here, not a call transferring control to a declaration.
PushCsgWrap,

// Opens one "group" within an already-open Op::PushCsgWrap bracket --
// emitted immediately before each top-level GEOMETRY child statement's
// own inline-compiled bytecode (compileStatementList of exactly that
// one statement). Records ev.treeStack_.back().size() into
// csgWrapStack.back().groupStartSize -- always operates on the
// TOPMOST (innermost still-open) csgWrapStack entry, matching
// PopBuiltinWrap's own back()-is-always-mine LIFO discipline, so no
// operand is needed. Assignments among the block's children are
// compiled separately, BEFORE any CsgGroupStart/End pair at all (see
// emitCsgWrap) -- mirrors resolveCsg's own two-pass split exactly
// (Evaluator::evalChildren(assignNodes, effCtx) always runs to
// completion before the per-geoNode loop starts).
CsgGroupStart,

// Closes the matching Op::CsgGroupStart: computes
// ev.treeStack_.back().size() - csgWrapStack.back().groupStartSize
// (how many CSGNodes THIS one top-level statement just contributed,
// however many that turned out to be -- 0 for a statement whose own
// evaluation spliced nothing, e.g. a no-op unknown-module warning; >1
// for an attachable()-style multi-body operand) and appends it, as a
// Value, onto csgWrapStack.back().groupSizes -- exactly one entry per
// top-level geometry statement, in source order, mirroring resolveCsg's
// own `groupSizes.push_back(Value{...})` loop.
CsgGroupEnd,

// Closes the matching Op::PushCsgWrap bracket: pops
// 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,
// 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
// treeStack_. a = index into CompiledChunk::csgWrapSites (same site
// Push used).
//
// teardownVmCallStackDownTo's own exception path (bytecode_vm.cpp) pops
// frame->csgWrapStack.size() additional treeStack_ entries per
// torn-down frame, alongside its existing builtinWrapStack/
// ownsModuleSplice accounting -- same reasoning as PopBuiltinWrap's own
// doc comment: an exception can leave N of these brackets open in one
// frame.
PopCsgWrap,

// A `children()` / `children(N)` statement -- the runtime-varying
// sibling of Op::CallModule/Op::PushBuiltinWrap, closing the LAST
// dominant native-reentry source (BOSL2's attachable() calls
Expand Down Expand Up @@ -367,10 +462,11 @@ enum class Op {
// no separate site table needed).
CallChildren,

// A single "native passthrough" statement -- intersection_for,
// union/difference/intersection and every other builtin
// module call NOT covered by Op::PushBuiltinWrap (see that op's own
// doc comment for exactly which builtins ARE covered), the `*`
// 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
Expand All @@ -387,18 +483,21 @@ enum class Op {
// 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.)
// 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
// PushBuiltinWrap's own bracket) -- they now have Op::PushCsgWrap,
// above.)
// 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,
// evalStatement. These are still "leaf-shaped" for what's left here
// after Op::PushBuiltinWrap peeled off the proven-risky subset: real
// recursion safety for a recursive module chain is covered by
// CallModule/PushBuiltinWrap/ForIterNext/the Jump-based if/for control
// flow, not by how many of THESE sit alongside them in the same body
// -- true again now that the one construct that violated it
// (translate()-wrapped recursion) has its own real bytecode instead.
// now that Op::PushBuiltinWrap/Op::PushCsgWrap have peeled off every
// proven-risky construct: real recursion safety for a recursive module
// chain is covered by CallModule/PushBuiltinWrap/PushCsgWrap/
// ForIterNext/the Jump-based if/for control flow, not by how many of
// THESE sit alongside them in the same body.
NativeStatement,

// If/if-else's own condition, evaluated NATIVELY (Evaluator::
Expand Down Expand Up @@ -734,6 +833,19 @@ struct CompiledChunk {
const oscad::ASTNode* node = nullptr;
};

// 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*.
struct CsgWrapSite {
std::string op;
const oscad::ModularCall* node = nullptr;
};

// One Op::AssertStatement site -- see that op's own doc comment for
// the full contract. `conditionArgIndex`/`messageArgIndex` are indices
// into the site's own argCount-sized popped-argument array (source
Expand Down Expand Up @@ -796,6 +908,7 @@ struct CompiledChunk {
// own doc comments, above) -- always empty for a function chunk.
std::vector<ModuleCallSite> moduleCallSites;
std::vector<BuiltinWrapSite> builtinWrapSites;
std::vector<CsgWrapSite> csgWrapSites;
std::vector<AssertSite> assertSites;
std::vector<const oscad::Expression*> nativeExprs;
std::vector<const oscad::ASTNode*> nativeStatements;
Expand Down
27 changes: 27 additions & 0 deletions include/openscad_cpp_evaluator/bytecode_vm.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ struct PendingBuiltinWrap {
int siteIdx = -1;
};

// One still-open Op::PushCsgWrap bracket's own state -- see that op's own
// doc comment (bytecode.hpp) for the full contract. `groupSizes` is built
// up incrementally, one entry per Op::CsgGroupStart/CsgGroupEnd pair (one
// per top-level GEOMETRY child statement); `groupStartSize` is scratch
// space for the CURRENTLY OPEN group only (set by CsgGroupStart, consumed
// by the matching CsgGroupEnd) -- safe as a single scalar, not a stack of
// its own, because groups within one CSG wrap are siblings in sequence,
// never nested (unlike PushCsgWrap brackets themselves, which CAN nest,
// e.g. `union() { difference() {...} }` -- that's what makes
// VmFrame::csgWrapStack itself a real LIFO, below).
struct PendingCsgWrap {
std::string op;
std::uint64_t randsBefore = 0;
int siteIdx = -1;
std::vector<Value> groupSizes;
size_t groupStartSize = 0;
};

// One ListCompFor/statement-for assignment's own materialized iteration
// state -- see Op::IterMaterialize/IterReset/IterNext's own doc comments
// (bytecode.hpp). Lives in the header (not bytecode_vm.cpp's own anonymous
Expand Down Expand Up @@ -127,6 +145,15 @@ struct VmFrame {
// existing invariant ("whoever pops this frame drains its own open
// brackets first") already covers it.
std::vector<PendingBuiltinWrap> builtinWrapStack;
// Op::PushCsgWrap's own per-frame LIFO -- same role/lifetime/teardown
// discipline as builtinWrapStack, just for union()/difference()/
// intersection() (see PendingCsgWrap's own doc comment, above, and
// Op::PushCsgWrap's, bytecode.hpp). Not explicitly cleared in
// releaseVmFrame, same as builtinWrapStack/accumStack/ctxChain -- the
// existing invariant ("whoever pops this frame drains its own open
// brackets first", normally via matched Push/Pop, or via
// teardownVmCallStackDownTo on the exception path) already covers it.
std::vector<PendingCsgWrap> csgWrapStack;
// The ORIGINAL callee name at push time, used by
// Evaluator::exitUserCallSuccess's own returnHook call when this frame
// carries a bracket -- deliberately NOT updated by a later tail hop
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.13.4"
version = "0.13.5"
description = "C++ OpenSCAD evaluator with Python bindings"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
46 changes: 46 additions & 0 deletions src/bytecode_compiler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,45 @@ class Compiler {
out.push_back({Op::PopBuiltinWrap, idx, 0, &wrapperNode.position()});
}

// union()/difference()/intersection() -- see Op::PushCsgWrap's own doc
// comment (bytecode.hpp) for why these can't just reuse emitBuiltinWrap:
// resolveCsg (booleans.cpp) needs per-top-level-child-statement
// "group_sizes" bookkeeping, replicated here as one Op::CsgGroupStart/
// CsgGroupEnd pair per GEOMETRY child, with every ASSIGNMENT child
// compiled first, unconditionally, regardless of interleaving in
// source -- mirrors resolveCsg's own two-pass split (`assignNodes`
// fully evaluated, THEN one evalChildren call per `geoNodes` entry)
// exactly, including its ModuleDeclaration/FunctionDeclaration
// exclusion (a nested declaration inside a CSG block contributes to
// neither pass -- already hoisted into scope, nothing to run here).
void emitCsgWrap(const oscad::ModularCall& call, std::vector<Instruction>& out) {
out.push_back({Op::CheckDebugStatement, internNativeStatement(&call), 0, nullptr});
CompiledChunk::CsgWrapSite site;
site.op = call.name->name;
site.node = &call;
chunk_.csgWrapSites.push_back(std::move(site));
const int idx = static_cast<int>(chunk_.csgWrapSites.size()) - 1;
out.push_back({Op::PushCsgWrap, idx, 0, &call.position()});

std::vector<const oscad::ASTNode*> assignNodes;
std::vector<const oscad::ASTNode*> geoNodes;
for (const auto& c : call.children) {
if (c->kind() == oscad::NodeKind::Assignment) {
assignNodes.push_back(c.get());
} else if (c->kind() != oscad::NodeKind::ModuleDeclaration &&
c->kind() != oscad::NodeKind::FunctionDeclaration) {
geoNodes.push_back(c.get());
}
}
compileStatementList(assignNodes, out);
for (const oscad::ASTNode* geoNode : geoNodes) {
out.push_back({Op::CsgGroupStart, 0, 0, nullptr});
compileStatementList(std::vector<const oscad::ASTNode*>{geoNode}, out);
out.push_back({Op::CsgGroupEnd, 0, 0, nullptr});
}
out.push_back({Op::PopCsgWrap, idx, 0, &call.position()});
}

void compileOneStatement(const oscad::ASTNode& stmt, std::vector<Instruction>& out) {
using oscad::NodeKind;
trackSpan(stmt);
Expand Down Expand Up @@ -1434,6 +1473,13 @@ class Compiler {
out.push_back({Op::CallChildren, internNativeStatement(&stmt), 0, &stmt.position()});
return;
}
// union()/difference()/intersection() -- see emitCsgWrap's
// own doc comment for why these need their own bespoke
// bracket rather than emitBuiltinWrap's.
if (dispatchIt != dispatch.end() && dispatchIt->second == &resolveCsg) {
emitCsgWrap(call, out);
return;
}
// Every other builtin, or a name that didn't resolve to a
// user module statically -- native passthrough, exactly
// like echo/assert/etc. below. Never the recursion-depth
Expand Down
Loading