Status: design draft. Target architecture for the "everything is an expression" wave. The goal is to halve interpreter code, not to grow a third emulation layer over legacy statement dispatch.
1. Motivation
The grammar is already unified: scadlike_parser.py builds only expr_* nodes
(p_expression_if, p_expression_for, p_expression_let, ...). But the
interpreter still lives in two worlds:
SCL.parse_statementdispatches onstat_*wrappers (scl.py);SCL.parse_exprdispatches onexpr_*nodes (scl.py);statementize()(scadlike_parser.py) copies everyexpr_*node into astat_*wrapper so the legacy executor keeps working.
The result: one syntax construct has two implementations. if in an element
position is stat_if handled by parse_statement; if in a value position is
expr_if handled by parse_expr via _eval_block_value. Same for let
(stat_let/expr_let), for (statement/expr_array_for vs the expr_for
value path), assign, field, modifier.
This is a hybrid, not an architecture: an emulation layer sits on top of the
legacy statement executor instead of replacing it. The debt surfaces as crashes
such as UnhandledCaseError: unknown expression type 'expr_for' — the grammar
accepts a construct in a value position for which the interpreter has no handler.
2. Vision
Everything is a function that yields a value; a statement is the same expression plus an emit policy on the result.
Value ::= data (number, string, list, undef) | Node (geometry subtree/compound). Geometry is a data type, exactly like numbers and strings.cube(10)yields a Node;for (i=r) cube(i)yields a list of Nodes or a list of data values;1+2yields 3. A value never carries a grouping node — a compound is created only when the value is emitted into the scene (_emit_value). See §4.1.1.- The three outcome policies differ only in what happens after evaluation:
a = expr;— bind result to variablea;expr;— emit result into the geometry tree (unnamed object / data field);a: expr;— emit and bind (label).
- One AST node per construct. One evaluator. No
statementize, nostat_*.
The callable model already proves this pattern works: SCL._call(node, emit)
is a single dispatch whose behavior is selected by an emit flag
(emit=True → active context, emit=False → isolated node). The axis exists;
extending it to if/for/let/assign/modifier is the job of this design.
3. Current architecture inventory
3.1 Node types (statement vs expression)
| construct | element position | value position | status |
|---|---|---|---|
| call | stat_call → _call(emit=True) |
expr_call → _call(emit=False) |
already unified — the pattern to follow |
| if | stat_if (parse_statement) |
expr_if (parse_expr + _eval_block_value) |
duplicated |
| for | stat_for (_exec_for + SCLGroup wrap) |
expr_for / comprehension expr_array_for (parse_expr) |
duplicated |
| intersection_for | stat_intersection_for (_exec_for + SCLIntersection wrap) |
expr_intersection_for (_intersection_result) |
duplicated |
| let | stat_let |
expr_let |
duplicated |
| assign | stat_assign (eval_scope phase 1 merge) |
expr_assign (side effect + value) |
duplicated (expr_assign already used internally) |
| field / label | stat_field (name: expr;) |
expr_field |
duplicated |
| modifier | stat_modifier (root/debug/background/disable, 3-phase root) |
expr_modifier (child !/#) |
duplicated |
| bare value | stat_expr → _emit_value (node → child, data → field/warning) |
plain expression | emulation |
Definitions (stat_function_definition/stat_module_definition) and
use/include have no expr_* duplicates — they are declarative nodes.
3.2 Dispatchers and phases
eval_scope(scl.py) — three phases per OpenSCAD: Phase 0: register defs + processuse; Phase 1: evaluate allstat_assign/stat_fieldeagerly, merge "last RHS wins, evaluated at first occurrence"; Phase 2: evaluate everything else.parse_statement— a longif/elifover thestat_*types, with progress hooks and the root-modifier machinery inline.parse_expr— a long dispatch over theexpr_*types._eval_block_value(scl.py) — "value of a block": data → last data expression, geometry → isolated node (attach=False). This is already the block-value semantics needed by the unified model; it emulates a special case on top ofparse_statement.
3.3 Runtime state (kept as-is in the target model)
- Frame stack
SCLFrame(variables,callablesbundles,$configvars, path). - Context tree:
push_context/pop_context,SCLRoot/SCLPart3/SCLGroup/SCLIntersection/wrappers,fields,add_child_context. children()/$children/$parent_modules— frame vars +_parent_children_ctx.ShapeCache— orthogonal (cache keys attached to nodes).- Progress/cancel
state.s.progress_*— inlined inparse_statementand_exec_for. - Worker/IPC (worker_engine.py), editor, export — orthogonal.
4. Target design
4.1 Evaluation protocol
eval(node, emit=False) -> Value # plain value; side effects = var binds only
eval(node, emit=True) -> Value # value + emit result into active context
eval_seq(block, mode) -> Value # run elements in order, each with emit
# mode 'scene': emit into active context
# mode 'value': emit into isolated draft,
# return block value
- Element position =
eval_seq(block, 'scene'). - Value position =
eval(node, emit=False). - Block value (function bodies,
if/forbranches,letbodies): data → last data expression; geometry → the list of objects the block produced (one object → the object itself, two or more → a list, nothing →undef;_one_objectin scl.py)._eval_block_valueis that routine.
4.1.1 Value shape: lists while stored, group at emit
A "block value" is not a compound node at the moment it is produced. The rule is narrower:
- Named containers are nodes. A user module, a builtin module with a block
(
translate(){...},group()), a transparentecho/assert— each is a node with a name andfields, created at its own level. This is what makeswasher().hole_dandm.b.headwork, so it must not change. - Anonymous sequences are lists. A block literal, a loop body, an
ifbranch, aletbody, a function body block — they produce no node, so their value is just the objects they produced. No fields either: alabelinside an anonymous block binds a local variable, and a field exists only where a node does. - A one-element list is never produced. One object → the object; two or
more → a list. This keeps
x = for (i=[1,2,3]) sphere(i)flat andx[0]a node, i.e. existing code that indexes a loop value keeps working. intersection_foris the exception: its value is one node, never a list. There is no "list of iteration values" form — the value is the intersection of all of them (_intersect_value): the sameSCLIntersectionnode the statement form builds, detached (attach=False). A data-only or empty result is a node with an empty shape (volume 0), likeintersection(){}— normal, not an error.intersection()itself is the single aggregation routine shared by the builtinintersection(){}and bothintersection_forforms via_intersection_result(one cache keyintersection_for+ counters + child keys). Sox = intersection_for (...) {...}can be assigned,volume()'d, stored as a field, andx;renders the intersection.- A builtin module without a block is an anonymous container. Its value is
the objects its handler produced in the expression draft: one → that node,
two or more → a list, none → the draft itself (the handler put its shape
there directly, as
surface()does).import()of a multi-body STEP is the only builtin that produces more than one —import_file()adds one child per body — sox = import("multi.step")is a list of 2 andx[0],x[1]work, exactly as in OpenSCAD. Taking onlychildren[-1]here made the value form and the statement form disagree:import(f)emitted every body,a: import(f)only the last. - Emission is the only grouping point.
_emit_valueflattens nested lists into one SCLGroup with flat children, so a value and its statement form produce the same tree._get_shapeand friends flatten the same way, sovolume()/bbox_*()work on nested lists. - Inside
profile()there is no grouping at all (_should_group): contour primitives are bound to the live context, and a value-position block there runs against that live context instead of a draft.
Tests: tests/test_block_value.scad, tests/test_import_value.scad.
4.2 Outcome policies (derive from the existing _emit_value)
| policy | syntax | effect |
|---|---|---|
| bind | a = expr; |
set variable; nothing leaves the scope (no field, no emit) |
| emit | expr; |
Node → add_child_context; data → field on active node if named, otherwise computed and unclaimed (silent) |
| label | a: expr; |
bind + emit with field name a |
Emission is the only thing that names: bind never creates a field and never
emits, so what leaves a block is what was emitted. label and a named emit
both write into active_context.fields, the root included (root.fields), so
top-level data behaves like top-level nodes instead of warning. A node value is
left to the explicit x; — x = sphere(5); draws nothing, x; draws one
sphere and x; x; draws two (repeat emit is legal).
Fields are a flat stream of emissions: emitting a field is no different
from emitting geometry. When a block's body ends (pop_context), the block's
whole field dict is merged into the parent's — at that block's position in the
parent's body — and the merge is a plain update, so the last emission of a
name wins. Reading is a dict lookup, node.fields.get(name): no subtree walk,
no sentinel, no scoping of names by depth. Consequently
group(){ c: undef; group(){ c: 5; } } ≡ group(){ c: undef; c: 5; } → 5
group(){ c: 1; group(){ c: 2; } } → 2
group(){ group(){ c: 5; } c: undef; } → undef
The transfer cannot happen at attach time: push_context links the node to its
parent before the body runs, when it has no fields yet. That is why it lives
in pop_context, and why a draft (attach=False, value nodes) is marked
in_tree = False and forwards nothing — its fields arrive only when it is
really emitted, through add_child_context. An un-emitted local is a draft, so
its fields are never found.
Tests: tests/test_field_scope.scad.
4.3 Unified node table
| now | after |
|---|---|
stat_if + expr_if |
one if node |
stat_for + expr_for (+ comprehension expr_array_for) |
one for node |
stat_intersection_for + expr_intersection_for |
one intersection_for node |
stat_let + expr_let |
one let node |
stat_assign + expr_assign |
one assign node (bind side effect + value) |
stat_field + expr_field |
one label node (bind + field emit) |
stat_modifier + expr_modifier |
one modifier node |
stat_expr |
disappears (bare value element emits directly) |
statementize() |
disappears |
4.4 for as the pilot
One recursion over the counter product, parameterized by a leaf mode:
_for_iterate(counters, env, leaf, progress=False) # single implementation
- scene:
leaf = eval_seq(block, 'scene')(current_exec_for); - value:
leaf = <value of block>; result = compound (geometry) or list (data); nestedfor/intersection_forflatten (Cartesian product). Exception:intersection_forvalue is the single intersection node (§4.1.1); - comprehension:
leaf = <value of body>; result = list (currentexpr_array_for).
expr_for/expr_intersection_for value handlers live in parse_expr, on the
same iteration core.
4.5 Scope model
- Frame stack stays.
assign= side effect + value (already true forexpr_assign).- The OpenSCAD merge policy ("all assignments first, last RHS wins, evaluated at first occurrence") becomes an explicit scope option applied to top-level blocks, not a hardcoded phase of the eval loop. Inside pure value evaluation the order is the source order.
- Defs (
function/module) register into the frame bundle at scope setup.
4.6 Modifiers
! ~ # @ (plus */% operators) become functions over Node values:
root(node), disable(node), debug(node), background(node). The 3-phase
root selection (_last_root_tagged, root_first/root_last) moves into the
render/display layer as data flow over tagged nodes. Behavior must stay
identical (regression: test_modifiers_root_filter.scad).
4.7 Left unchanged
use/includelibrary mechanism and_expand_scopeflattening;- callable bundles with two slots (function + module with one name is
OpenSCAD compatibility;
_pick_callablestays — statement picks module, expression picks function); children()/$children/$parent_modules;- progress/cancel — one hook inside
eval/_for_iterate; - OCCT layer,
ShapeCache, worker IPC, editor, exporters.
5. Migration plan (full test suite green after every step)
- Step 0 — inventory & coverage. Enumerate node types, map tests to the
statement/expression pairs, add missing value-position tests (notably
expr_for). Baseline green. - Step 1 — unify
for(pilot)._for_iteratesingle recursion; rewrite_exec_for,_exec_array_forand add the value path on it; value handlers forexpr_for/expr_intersection_for(closes the value-position crash). Newtests/test_for_expression.scad(value assign, binop operand, nested for, geometry compound, intersection_for)._for_iterateis the single recursion;_for_countersis the single counter parser (statement/value/comprehension share it);_exec_for(counters, block, ln, kind, emit)is the singlefor/intersection_fortraversal —emit=Trueis the statement path (it creates the SCLGroup / SCLIntersection node),emit=Falseforforis the value list path (no grouping node; the group appears only at emit).intersection_foris the exception: both forms are oneSCLIntersectionnode, differing only in scene attachment, and the connection to the parent op (_intersection_result, shared with builtinintersection(){}) is one routine — §4.1.1. Counter normalization is identical in all modes: a string counter iterates per char in statement position too. Tests:tests/test_for_expression.scad,tests/test_block_value.scad,tests/test_intersection_for.scad. - Step 2 — unify
ifandlet. One node each with theemitflag;_eval_block_valuepromoted to the core block-value routine. - Step 3 —
assignandlabel. One node each;eval_scopephases become defs registration + explicit emit, merge policy as a scope option. - Step 4 — modifiers; delete
statementize(). Onemodifiernode; parser emits single-kind nodes;element=eval_seq(mode='scene'); deleteparse_statement, foldparse_exprintoeval. - Step 5 — cleanup. Remove dead helpers (
parse_blockwrappers, emulation comments), measure the line reduction.
6. Acceptance criteria
- Full test suite green after every step.
- Net code reduction:
parse_statement+parse_expr+statementize+_eval_block_value→ oneeval(+ small helpers). for(statement / value / comprehension) has exactly one implementation.- No
UnhandledCaseErrorreachable for any grammar node type. a = for(...),for(...);,a: for(...)all run through the sameforevaluation.
7. Risks and decisions
- Merge semantics of assignments is the highest-risk compatibility point
(OpenSCAD behavior). Keep as an explicit scope option; verify against
tests/use_func_stack.scad,tests/test_var_propagation.scad,tests/test_lexical_scope.scad. - Root modifier 3-phase tagging must not regress
(
tests/test_modifiers_root_filter.scad,tests/test_modifiers.scad). - Children of shapeless branches and empty-result semantics of boolean ops
must be preserved (
tests/test_bool_empty.scad,tests/test_children_nested.scad). use/includeflattening must keep per-file path tagging (tests/library_search.scad).