Skip to content

render() in expression position - #5

Merged
revarbat merged 1 commit into
mainfrom
render-expression
Aug 20, 2026
Merged

render() in expression position#5
revarbat merged 1 commit into
mainfrom
render-expression

Conversation

@revarbat

Copy link
Copy Markdown
Member

obj = render() { cube(1); }; evaluates its children as geometry and yields a value, so a script can measure and reuse its own geometry. This is the parser half — grammar, AST node, serialization. The evaluator half is a separate PR.

render becomes a reserved keyword

Not a stylistic choice. LALR(1) cannot tell render( in expression position apart from a function call: on NAME with lookahead ( the parser must choose between reducing primary: NAME . and shifting, and bison's shift-over-reduce would silently turn every function call in an expression into a module instantiation.

The grammar's own established answer — already used for list comprehensions — is a position-restricted nonterminal behind a distinguishing token, and only a keyword qualifies here.

render_stmt and render_expr share an RHS but never conflict: LALR merges states only on identical LR(0) cores, and after shifting "render" the statement and expression kernels differ. %expect stays at 2 — bison verifies this at build time, which is what makes the claim safe to assert.

The statement form is untouched

render_stmt's action calls the existing makeModularCall, so render() cube(1); still produces a ModularCall named "render" and everything downstream — builtin dispatch, the argument allowlist, pretty_print's fmtExpr/fmtInst split, json_io — sees exactly what it saw before. Only the expression form gets a new NodeKind.

RenderExpression derives from Expression (not its sibling ModuleInstantiation), so every existing unchecked static_cast in driver.hpp stays correct and nothing needs widening to ASTNode.

One thing the design review got wrong

fmtExpr needed a dedicated arm rather than falling through to toString(). toString() follows the reference's terminator-free child format, which is not reparseable hererender() cube(1) unbraced is precisely the form that fails to parse. fmtBlock always braces and routes children through the statement printer that supplies their semicolons. PrettyPrintOutputReparses is an idempotency test guarding it.

Costs, all pinned by tests

  • render can no longer be a variable, module, function, argument, or member name. BOSL2 uses none of these (checked: 8 render() statement calls, zero identifier uses). $render is unaffected.
  • obj = render() cube(1); does not parse — a bare call's child_statement swallows the ;, leaving the assignment unterminated. Inherent to OpenSCAD's grammar; the braced form is the idiom. Asserted so it is pinned rather than rediscovered.

Testing

647 tests pass (632 existing + 15 new), including JSON round-trip, formatter idempotency, and scope hoisting inside the child block.

🤖 Generated with Claude Code

`obj = render() { cube(1); };` evaluates its children as geometry and
yields a value, so a script can measure and reuse its own geometry. This
commit is the parser half: the grammar, the AST node, and serialization.
The evaluator half lands separately.

`render` becomes a reserved keyword. That is not a stylistic choice --
LALR(1) cannot tell `render(` in expression position apart from a
function call. On NAME with lookahead `(` the parser must choose between
reducing `primary: NAME .` and shifting; bison's shift-over-reduce would
silently turn every function call in an expression into a module
instantiation. The grammar's own established answer, already used for
list comprehensions, is a position-restricted nonterminal behind a
distinguishing token, and only a keyword qualifies here.

The two rules share an RHS but never conflict: LALR merges states only on
identical LR(0) cores, and after shifting "render" the statement and
expression kernels differ. %expect stays at 2 -- bison verifies this at
build time, which is why the claim is safe to make.

The STATEMENT form is deliberately unchanged: render_stmt calls the
existing makeModularCall, so it still produces a ModularCall named
"render" and everything downstream sees exactly what it saw before. Only
the expression form gets a new NodeKind, which is what keeps
pretty_print's fmtExpr/fmtInst split and the ModularCall allowlist out of
this change.

RenderExpression derives from Expression (not ModuleInstantiation, its
sibling), so every existing unchecked static_cast in driver.hpp stays
correct and nothing needs widening to ASTNode.

fmtExpr gets a dedicated arm using fmtBlock rather than falling through
to toString(). toString() follows the reference's terminator-free child
format, which is not reparseable here -- `render() cube(1)` unbraced is
precisely the form that fails to parse. fmtBlock always braces and routes
children through the statement printer that supplies their semicolons.

Costs, pinned by tests: `render` can no longer be a variable, module,
function, argument name, or member name. BOSL2 uses none of these. And
`obj = render() cube(1);` does not parse -- the child_statement swallows
the `;` -- so the braced form is the idiom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@revarbat
revarbat merged commit 8a651c3 into main Aug 20, 2026
3 checks passed
@revarbat
revarbat deleted the render-expression branch August 20, 2026 19:27
revarbat added a commit to BelfrySCAD/openscad_cpp_evaluator that referenced this pull request Aug 20, 2026
* Measure geometry from an expression: render() returns an object()

    obj = render() { difference() { cube(100); sphere(20); } };
    echo(obj.volume, obj.genus, obj.boundingbox);
    polyhedron(obj);

OpenSCAD could not previously measure its own geometry: the numbers only
exist after the geometry is built, and the language had no way to reach
them. This adds the evaluator half (the parser half is the submodule bump
in this same commit).

It measures and DISCARDS -- nothing is drawn. That is what makes the
semantics uniform: no side effects, so top level, module bodies, function
bodies, list comprehensions and ternaries all behave identically, and
function purity survives. `function fits(w) = render() { cube(w); }.volume
< 500;` works with no exception carved out for it.

The two-pass problem needed no new pipeline. generatePartialTree() already
generates mid-resolve for the debugger's live render, and documents why it
is safe (the resolve pass never reads CSGNode::bodies). measureCsgSubtree
does the same thing for one subtree, on its own treeStack_ frame, which is
popped and discarded so it can never reach the drawn tree.

Discarding costs one guard. measuring_ suppresses the four writes that
exist solely to describe DRAWN geometry -- idToNode/idToColor in
tagGenerated and tagDisplayOnly, the restampCachedIds call on a cache hit,
and cacheProducer_ -- because those tables are cleared once per pass, so a
leak is permanent and surfaces much later as wrong click-to-source. The
geometry cache itself deliberately stays ON: cacheKey is content-addressed,
so an entry a measurement stores is genuinely reusable by the real render.
The same flag suppresses checkDebug, which would otherwise inject stops at
the paused statement's own callStack_ depth and corrupt
lastStmtByDepth_'s duplicate-collapse state.

Mesh output is VNF-shaped -- [x,y,z] vertices, 0-based faces, CLOCKWISE
seen from outside -- so it feeds polyhedron() and BOSL2 unchanged. Two
details there are load-bearing and silent when wrong: the winding is
reversed on the way out (Manifold's triVerts is CCW), and vertices are
welded by exact position (Manifold splits property-vertices, so without
the weld the round-tripped polyhedron is an OPEN mesh). Both are pinned by
a round-trip test that asserts a POSITIVE volume -- verified empirically
that a reversed mesh comes back as volume -1 with Status()==NoError, so
the sign is the whole signal and abs() would hide it.

polyhedron() and polygon() now accept the object directly, so the round
trip is one call rather than two arguments. Works for any object with the
keys, not just one render() produced.

Measurements come from Manifold (Volume/SurfaceArea/Genus/BoundingBox),
never hand-computed. An empty result reports dim=0 with boundingbox=undef
rather than Manifold's empty Box, which is {+inf,-inf} and would poison
downstream arithmetic. An open surface still returns its mesh, with volume
and genus unavailable.

Not yet done: the bytecode VM currently falls back to the interpreter for
a declaration containing a render expression, via compileExpr's existing
NotCompilable safety net. A real Kind::Measure bracket follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Compile render() expressions to bytecode instead of bailing

A declaration containing a render() expression previously fell back to the
interpreter wholesale, via compileExpr's NotCompilable safety net. The
interpreter is many times slower than the VM, so a single measurement
anywhere in a function dragged that whole function onto the slow path.

No new opcode. A new BuiltinWrapSite::Kind::Measure reuses the existing
Op::PushBuiltinWrap/PopBuiltinWrap pair, which is what lets it inherit the
entire bracket lifecycle -- push/pop counting, ctxChain discipline,
exception teardown -- for free. A new op pair would have needed a fourth
parallel per-frame stack, and the teardown's pop ordering would then have
had to satisfy both nesting orders at once.

That ordering turned out to be a non-problem, and the comments claiming
otherwise are corrected here: the three loops in teardownVmCallStackDownTo
are COUNTS, not targeted pops. Popping N off the back of treeStack_ removes
the top N whichever group counted them, so the grouping is bookkeeping, not
sequencing. Only measuring_ needed restoring, which is one line reading
front().savedMeasuring -- recorded by every kind so no kind-inspection is
needed.

The Pop branch's RAII guard deliberately wraps measureCsgSubtree rather
than preceding it: measuring_ must stay true across the generate, since
that flag is what suppresses the four provenance writes. Restoring early
would leave them inert on the VM path only -- no crash, no wrong geometry,
just silently wrong click-to-source. ProvenanceStaysCleanUnderTheVm is the
only thing that catches it.

One real bug this surfaced, and it is why the interpreter landed first:
statement opcodes now run inside FUNCTION chunks for the first time, and a
compiled function keeps its parameters and lets in frame SLOTS that no
EvalContext can see. The children of a render expression resolve names
through the context, so `function f(w) = render() { cube(w); }.volume;`
found no `w` at all and silently measured an undef-sized cube -- returning
volume 0 rather than erroring. Kind::Measure now captures (name, slot) for
every visible local at compile time and republishes them into the
children's context at Push. Caught by the test suite, not by inspection.

Op::PopBuiltinWrap asserts both the operand-stack and treeStack_ depths it
recorded at Push. Every compileOneStatement case is operand-stack-neutral,
but nothing structurally enforces that, and this is the first bracket to
run statements with a non-empty operand stack beneath it.

Tests: 12 scripts asserted identical under both engines, a positive check
that the chunks actually compile (the regression guard against
reintroducing the bail), captured-locals, provenance under the VM, and five
throw paths including one originating deep inside a nested module frame so
the multi-frame teardown loop runs. 953 tests pass under both
OSCAD_BYTECODE_VM=0 and =1, and clean under ASan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Bump to 0.38.0; document render() in expression position

Minor, not patch: a new language construct plus a newly reserved word.

The CLAUDE.md section leads with the two things that will actually bite
someone -- `render` can no longer be an identifier, and
`obj = render() cube(1);` does not parse (the child_statement swallows the
semicolon) -- then the non-obvious invariants: winding is reversed on the
way out, vertices are welded by exact position, a reversed mesh reports
NEGATIVE volume so tests must never use abs(), and genus is Manifold's for
the whole result so a sealed internal cavity reports -1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Point the parser submodule at merged main

BelfrySCAD/openscad_cpp_parser#5 (render() in expression position) is in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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