Skip to content

Fix dependent for-loop/list-comp ranges (later dim sees earlier binding) - #70

Merged
revarbat merged 2 commits into
mainfrom
fix-dependent-for-loop-ranges
Aug 2, 2026
Merged

Fix dependent for-loop/list-comp ranges (later dim sees earlier binding)#70
revarbat merged 2 commits into
mainfrom
fix-dependent-for-loop-ranges

Conversation

@revarbat

@revarbat revarbat commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

A real, pre-existing correctness bug (predates all my work this week — reproduces on the already-published v0.13.4) found while investigating a user-reported "partial model + spurious warnings" report on a real-world script.

  • A later for-clause/list-comprehension-for dimension's own range/RHS expression could never see an earlier dimension's current binding — for (i=[0:2], j=[0:i]) (a standard triangular-loop idiom) or [for (p=[1:N], pt=f(p)) pt] (a common BOSL/BOSL2 pattern) both warned Ignoring unknown variable and silently produced undef/wrong results, cascading into large amounts of spurious downstream warnings and incomplete geometry.
  • Root cause: every dimension's own RHS expression was evaluated in one flat pass, upfront, against the ORIGINAL (pre-loop) ctx/scope, before ANY variable was bound — present in six independent copies of the same shape: evalFor/evalListElement's ListCompFor case/resolveIntersectionFor (interpreter) and compileForLoop/compileIntersectionForLoop/compileListElement's ListCompFor case (VM compiler) — including the intersection_for compiler added earlier this week (Extend Op::PushCsgWrap to intersection_for -- the true last native-reentry gap #69), which inherited the bug from compileForLoop's own scaffold it reused.
  • Verified wrong against real OpenSCAD.app directly for all three constructs (not just an internal inconsistency) — real-world repro (a user's snappy-reprap/full_assembly.scad): 6024 spurious warnings and a visibly incomplete model before this fix, 2 (a real, unrelated, pre-existing warning already in the source) after. STL output grew from 4.0MB to 5.7MB (the "partial model" the user reported).
  • Fix: each dimension's own RHS now evaluates INSIDE the recursion/nested-loop-emission (interpreter) or is emitted structurally INSIDE the enclosing dimension's own loop body (compiler), re-evaluating once per outer-dimension binding with every earlier dimension already bound — ordinary nested-loop semantics, matching real OpenSCAD. Op::IterReset becomes unreachable as a result (left defined, not removed, in this change).

Test plan

  • Full 745-test suite green both OSCAD_BYTECODE_VM on/off, up from 739 (6 new tests: dependent-range correctness for all 3 constructs × interpreter/compiled, each pinned against real OpenSCAD.app's own verified output).
  • Minimal repro (fillet_path-shaped) now matches real OpenSCAD.app's echo output exactly, both VM on and off.
  • Real-world repro (snappy-reprap/full_assembly.scad) warning count: 6024 → 2.
  • Version bumped to 0.13.8.

🤖 Generated with Claude Code

revarbat and others added 2 commits August 1, 2026 14:00
A later for-clause/list-comprehension-for dimension's own range/RHS
expression could never see an earlier dimension's current binding --
`for (i=[0:2], j=[0:i])` (a standard triangular-loop idiom) or
`[for (p=[1:N], pt=f(p)) pt]` (a common BOSL/BOSL2 pattern, e.g.
snappy-reprap's wiring.scad fillet_path) both warned "Ignoring unknown
variable" and silently produced undef/wrong results, cascading into
large amounts of spurious downstream "undefined operation" warnings
and partial/wrong geometry in real scripts.

Root cause: every dimension's own RHS expression was evaluated in one
flat pass, upfront, against the ORIGINAL (pre-loop) ctx/scope, before
ANY variable was bound -- present in six independent copies of the same
shape: evalFor/evalListElement's ListCompFor case/resolveIntersectionFor
(interpreter) and compileForLoop/compileIntersectionForLoop/
compileListElement's ListCompFor case (VM compiler, including the
intersection_for compiler added earlier this week, which inherited the
bug from compileForLoop's own scaffold it reused). Verified wrong
against real OpenSCAD.app directly for all three constructs, not just
an internal inconsistency -- confirmed by a real-world repro
(snappy-reprap's full_assembly.scad): 6024 spurious warnings and a
visibly incomplete model before this fix, 2 (a real, unrelated,
pre-existing warning) after.

Fix: each dimension's own RHS now evaluates INSIDE the recursion/nested-
loop-emission (interpreter) or is emitted structurally INSIDE the
enclosing dimension's own loop body (compiler), so it re-evaluates once
per outer-dimension binding with every earlier dimension already bound
-- exactly mirroring ordinary nested-loop semantics, matching real
OpenSCAD. Op::IterReset becomes unreachable as a result (materializing
now naturally resets the iterator's own index as a side effect on every
re-entry) -- left defined rather than removed in this same change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
prepareChildrenForward's dyn-variable copy loop, meant only to forward
$fn/$fa/$fs/$t-style overrides from the intermediate wrapper's scope,
also swept up $children/$parent_modules and overwrote the correctly
inherited target values with the wrapper module's own bookkeeping.

This silently broke the common BOSL/BOSL2 guarded-forwarding idiom
`module left(x) { if ($children > N) children(N); }`: the guard read
the wrapper's own 1-child count instead of the real caller's, so it
evaluated false and dropped geometry with no warning. Found via a
real user project (snappy-reprap) where this pattern accounts for a
large fraction of the rendered model.

Bump to 0.13.9.
@revarbat

revarbat commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Pushed a second, more severe fix onto this branch after further real-world testing against the same snappy-reprap project surfaced a deeper bug.

Root cause: prepareChildrenForward's dyn-variable copy loop (meant only to forward $fn/$fa/$fs/$t-style overrides across a children() forwarding chain) also swept up and overwrote $children/$parent_modules with the wrapper module's own bookkeeping instead of preserving the real target's inherited values. This silently broke the common BOSL/BOSL2 guarded-forwarding idiom (module left(x) { if ($children > N) children(N); }) — the guard read the wrapper's own 1-child count instead of the real caller's, evaluated false, and dropped geometry with no warning at all.

Combined with the dependent-for-loop-range fix already on this branch, snappy-reprap's full_assembly.scad now produces 507 bodies (up from 422 with just the for-loop fix, 66 on the pre-session baseline) with only the 2 pre-existing unrelated warnings (down from ~6000).

Added a regression test (UserModule.GuardedIndexedChildrenForwardingThroughWrapperPreservesRealChildrenCount) mirroring the real-world pattern. Full 746-test suite passes both VM on and off. Bumped to 0.13.9.

@revarbat
revarbat merged commit c7059f9 into main Aug 2, 2026
3 checks passed
@revarbat
revarbat deleted the fix-dependent-for-loop-ranges branch August 2, 2026 01:45
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