Skip to content

children(separate=true): forward children as separate CSG operands - #110

Merged
revarbat merged 1 commit into
mainfrom
children-separate
Aug 23, 2026
Merged

children(separate=true): forward children as separate CSG operands#110
revarbat merged 1 commit into
mainfrom
children-separate

Conversation

@revarbat

Copy link
Copy Markdown
Member

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.

module frame() { difference() children(separate=true); }
frame() {
    cube(50, center=true);   // child 0 -- the positive operand
    sphere(30);              // subtracted
    cylinder(h=99, r=8);     // subtracted
}

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. 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, 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 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::separateOperand flag, set on the forwarded nodes. Putting it on the nodes rather than on the Evaluator buys three things for free:

  • several splices in one statement work (difference() { for (i=[0:1]) children(separate=true); });
  • nesting degrades correctly — under translate() the nodes land in translate's frame, so difference() still sees one operand, with no code to make that happen;
  • nothing to restore on the throw path.

group_sizes was being built twice, once per engine. Both now route through one shared Evaluator::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 — generateCsg uses that to reset intersection() and bail difference(), and Intersection.DisabledStatementIsTrulyEmptyAndDiscardsResult guards it.

Also closes a gap this made testable: Op::CallChildren never called warnUnexpectedBuiltinArgs, so a children() typo warned under the interpreter and was silent under the VM.

Scope

Narrower than it looks. Only union/difference/intersection/intersection_for group operands at all. hull/minkowski/minkowski_difference read bodies via the unsliced flattenCsgTree, and the old wrapper was plain concatenation — so they already saw the children separately. Verified, not assumed, and pinned by a test.

Verification

  • 1005 tests pass under both engines, 16 of them new.
  • Mutation-tested: breaking the two VM marking sites fails 7 of the 16 new tests, so the VM coverage is genuine rather than a silent fallback to the interpreter.
  • Oracle: the hand-written difference(){...} of the same shape. Both give 123000; plain children() gives 125000; intersection gives 500 separated vs 1500 grouped — identical under OSCAD_BYTECODE_VM=0 and =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" is any_ofall_of in spliceModuleChildren.

🤖 Generated with Claude Code

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>
@revarbat
revarbat merged commit 25a1dfa into main Aug 23, 2026
3 checks passed
@revarbat
revarbat deleted the children-separate branch August 23, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant