children(separate=true): forward children as separate CSG operands - #110
Merged
Conversation
difference() children() returns the union, not a difference. The children
arrive as ONE operand, so there is nothing to subtract from. Upstream
OpenSCAD does the same, and has no per-call way out of it.
module frame() { difference() children(separate=true); }
frame() { cube(50,center=true); sphere(30); cylinder(h=99,r=8); }
intersection() is the case with no workaround at all: for difference you
could already write difference() { children(0); children([1:$children-1]); }
since A-(B|C) == A-B-C, but A&(B|C) is not A&B&C.
The obvious implementation does not work. The wrapper children() builds is
already display-only (isBuiltin=false, so generateTreeImpl falls through to
plain concatenation) -- removing it changes nothing, because difference()
groups its operands by AST CHILD STATEMENT, not by node. children() is one
statement however many nodes it splices, so the group stays one group.
That per-statement grouping is deliberate and load-bearing (BOSL2's
attachable() returns parent + attachments as several bodies and must count
as one operand), so the feature is instead: let one statement contribute
SEVERAL groups.
A CSGNode::separateOperand flag, set on the forwarded nodes, does that. It
rides on the nodes rather than on the Evaluator, which buys three things
free: several splices in one statement work; nesting degrades correctly
(under translate() the nodes land in translate's frame, so difference()
still sees one operand); and there is no state to restore on the throw path.
group_sizes was being built twice, once per engine. Both now go through one
shared Evaluator::appendGroupSizes, landed first as a behaviour-neutral
commit-sized step with the suite green -- including the size-ZERO group a
statement that produced nothing contributes, which generateCsg relies on to
reset intersection() and bail difference().
Also closes a real gap this made testable: Op::CallChildren never called
warnUnexpectedBuiltinArgs, so a children() typo warned under the interpreter
and was silent under the VM.
Verified by mutation: breaking the two VM marking sites fails 7 of the 16
new tests, so the VM coverage is genuine rather than an interpreter
fallback. 1005 tests pass under both engines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
difference() children()returns the union, not a difference — the children arrive as one operand, so there is nothing to subtract from. Upstream OpenSCAD behaves the same and offers no per-call way out.intersection()is the case with no workaround at all. Fordifference()you could already writedifference() { children(0); children([1:$children-1]); }, sinceA-(B∪C) == A-B-C. That identity does not hold for intersection:A∩(B∪C) ≠ A∩B∩C.The obvious implementation does not work
The wrapper
children()builds is already display-only —isBuiltin=false, sogenerateTreeImplfalls through to plain concatenation. Removing it changes nothing, becausedifference()groups its operands by AST child statement, not by node.children()is one statement however many nodes it splices, so one group of N stays one group of N.That per-statement grouping is deliberate and load-bearing: BOSL2's
attachable()returns parent + attachments as several bodies and must count as one operand. So the feature is not "stop wrapping" but let one statement contribute several groups.Mechanism
A
CSGNode::separateOperandflag, set on the forwarded nodes. Putting it on the nodes rather than on theEvaluatorbuys three things for free:difference() { for (i=[0:1]) children(separate=true); });translate()the nodes land in translate's frame, sodifference()still sees one operand, with no code to make that happen;group_sizeswas being built twice, once per engine. Both now route through one sharedEvaluator::appendGroupSizes, landed first as a behaviour-neutral step with the suite green. It preserves the size-zero group that a statement producing no geometry contributes —generateCsguses that to resetintersection()and baildifference(), andIntersection.DisabledStatementIsTrulyEmptyAndDiscardsResultguards it.Also closes a gap this made testable:
Op::CallChildrennever calledwarnUnexpectedBuiltinArgs, so achildren()typo warned under the interpreter and was silent under the VM.Scope
Narrower than it looks. Only
union/difference/intersection/intersection_forgroup operands at all.hull/minkowski/minkowski_differenceread bodies via the unslicedflattenCsgTree, and the old wrapper was plain concatenation — so they already saw the children separately. Verified, not assumed, and pinned by a test.Verification
difference(){...}of the same shape. Both give 123000; plainchildren()gives 125000;intersectiongives 500 separated vs 1500 grouped — identical underOSCAD_BYTECODE_VM=0and=1.One judgement call, pinned by a test so it cannot drift: the mark rides on the nodes, so
module pass() { children(separate=true); }passes separateness outward through its own splice. Retreating to "stop at the module boundary" isany_of→all_ofinspliceModuleChildren.🤖 Generated with Claude Code