Structure quality matters more than full OpenSCAD compatibility; accepted deviations are listed at the end.
BCad unifies OpenSCAD's two separate concepts — functions and modules — into a
single callable concept. A call in any position yields a value; a statement is
a call + emit. This keeps user code simpler and removes the duplicated
function/module dispatch from the interpreter.
Semantics
- One definition table.
SCLFrame.callablesmaps a name to a bundle of two definition slots —{'function': node|None, 'module': node|None}. A function and a module with the same name coexist (as in OpenSCAD): redefining the same kind is silent and last-wins, the other kind is left untouched; user callables shadow builtins. - One dispatch point.
SCL._call(node, emit):emit=False(expression) — returns a value: a geometry node or data.emit=True(statement) — the value is emitted into the active scene tree.- When a name has both kinds defined,
_pick_callable(bundle, emit)selects the kind by position, as in OpenSCAD: statement → module, expression → function. A single kind is used in any position (unified model).
- Functions are modules, modules are functions. A function body is an expression sequence (last value is the result) and may contain geometry; a module may be used in an expression to produce a value.
children()in functions. A call site may pass a child block (f(10){...}); inside the bodychildren()resolves it. Without a blockchildren()yields nothing (it never leaks into the caller's children).
Expressions
// builtin primitive / block module as a value
a = sphere(10);
b = difference() {
sphere(10);
sphere(8);
};
// user module as a value (with data fields)
module bolt(d, l) {
cylinder(d = d, h = l);
diameter = d;
diameter; // named data expression -> field of the node
}
b = bolt(10, 20);
b.diameter; // 10
b["diameter"]; // 10
b.shape; // underlying geometry (SCLShape)
// user function producing geometry
function mk_cube(x) = cube(x);
c = mk_cube(5); // node value
// braceless child in an expression
t = translate([10, 0, 0]) cube(10);
// deep braceless chains in expressions (recursive child_value,
// symmetric with statement chains)
q = rotate(45) translate([10, 0, 0]) cube(5);
// emit a stored node back into the scene
c; // appears at the scene root (repeatable)
Statements
x = 42;
x; // top-level data statement -> warning (no scene value)
module part() {
d = 7;
d; // inside a module: data becomes a field of the node
cube(d);
}
p = part();
p.d; // 7
p; // emit the node into the scene root
Data fields
SCLContext.fields stores data emitted inside a module body. Field values are
accessible via dot and bracket access on the node value. Geometry and fields
coexist in one node.
A field of the active node is created by emission of a named value — a label
name: expr; or name; where name already holds the value. An assignment
name = expr; on its own does not: it binds a local variable of the block
and nothing leaves it.
module bolt(h, d) {
cylinder(h=h, d=d);
head: 4 * h / 10; // label: local variable head AND field "head"
}
module assembly() {
b = bolt(h=20, d=8); // node with fields
b; // emit geometry AND store the node as field "b"
plates: b.head; // label with any expression
doubled: plates * 2; // the label-defined variable is usable below
}
m: assembly(); // top-level label: scene + variable m + field m
echo(m.b.head); // 8 — full path into an auto-named nested node
- What leaves a block is what was emitted.
b = 32;binds a local variable of the block —group(){ b = 32; }exposes no fieldb. Either the labelb: 32;or the explicit emitb = 32; b;publishes it. A node value can only be published by emit too:s = sphere(5);on its own is a local variable,s;puts it in the tree. name: expr;(label) isname = expr; name;in one line: the variable binds at its assignment point (Phase 1 ofeval_scope, like anyname = expr;), and the fieldnameof the active node is emitted at the statement's position (Phase 2) from that same value. A later assignment in the same scope therefore sees the label variable:
Whenx: 5; y = x + 3; // 8 — the label bound x before y was evaluatedexpris a node, the field keeps the geometry; the node is built once (Phase 1) and emitted once (Phase 2).- Labels and assignments share the variables namespace. Reassigning the same
name (
x: 5; x = 7;) follows the last-wins merge: both the variable and the emitted field become7(matchingx = 5; x; x = 7;). Forward references keep OpenSCAD assignment-point semantics —y = x + 3; x: 5;leavesyundefined, exactly likey = x + 3; x = 5;. - Modifier-wrapped assignments/labels (
!x = 5;,!x: 5;,%x = 5;) are not collected by Phase 1 and bind late (Phase 2), mirroring!x = 5;— Phase-1 statements don't see them. Only the label/emit creates a field. name;wherenamealready holds a node stores that node undernameas well (the geometry is still emitted into the tree). Nested fields therefore stay reachable by their full path:m.b.head. This is the auto-naming rule.- A plain
name;with a non-geometry value keeps working as before (field). - Unnamed data expressions warn nothing —
42;,sin(x);,[1, 2];are computed and never claimed. That is normal behaviour, not a mistake in the program. Emitted named data at the scene root is stored inroot.fields, symmetric with node values, which already got a root field. - Fields of a block form a flat stream; the last emission of a name wins. Emitting a field is no different from emitting geometry: a nested block's fields arrive in the parent's body at that block's position, and the parent's own labels arrive at theirs. See Field access below.
Field access
Fields live on SCLContext.fields and are read through dot / bracket access on
the node value: node.name, node["name"], nested a.b.c.
- Only emitted named values create fields — labels (
name: expr;) and auto-named node statements (name;wherenameis already a node). An anonymous child (cube(10);inside a block) contributes no field, only geometry, and a plain assignment contributes none either. - Fields are a flat emission stream — the last emission of a name wins.
The result of a block is a flat stream of emissions, and emitting a field is no different from emitting geometry: a nested block's fields land in the parent's body at that block's position, exactly when its geometry does. Soa = translate([0,0,10]) { rotate(45) { c: 32; d: sphere(10); } }; a.c // 32 a.d // the rotate nodegroup(){ c: undef; group(){ c: 5; } }is equivalent togroup(){ c: undef; c: 5; }and gives5. Depth is not consulted at all — only execution order — which is the same last-wins as two variables in one scope: a nested block standing below an outer label overrides it (group(){ c: 1; group(){ c: 2; } }→2, and reversing the two statements gives1). A field present butundefis an ordinary emission, sogroup(){ group(){ c: 5; } c: undef; }givesundef. Only emitted fields are reachable — an un-emitted local is not in the stream, sogroup(){ b = 32; group(){ c: 7; } }givesb == undefandc == 7, andgroup(){ f = group(){ a: 1; }; }givesa == undef(the draft is not in the tree until it is emitted).
Because the transfer happens when a block's body ends, a node is not a scope for its children's names:group/translate/colorhand their whole stream up. Nesting only ever adds wrappers to the path — it never hides a name. - Containers keep their block's fields.
union/group/translate/echo/assertblock modules build their own context node; labels inside the block become fields of that node. Anonymous children are not promoted: they stay just geometry. - Full path through auto-named nodes: every emitted node that has a name
(label or existing variable) is also stored as a field, so nested data stays
reachable as
h.pilar.numberinstead of requiring local extracts. - Inline field read works on any expression that produces a node:
translate([0,0,0]) { test: 10; s: sphere(10); }.testis10. - Braceless children support postfix member access:
f() x.yisf() (x.y)— the dot/index binds to the child, not to the whole call chain ((f() x).ywould come fromexpression : expression DOT IDat the top level).translate([0,0,20]) x.stherefore wrapsx.s, not the translate node. Parenthesized children (f() (x.y)) work the same way. echo/assertexpressions follow the same rule: they return their own group node, soy: echo("...") { test2: 42; c: translate(...) sphere(10); }givesy.test2 == 42andy.c— the labelled translate node.
Grammar
The parser builds only expr_* nodes: a sequence is a block_list of elements,
and statementize() (scadlike_parser.py) wraps each element's expression into
the stat_* node the executor (parse_statement / eval_scope) understands.
block_list : element
| block_list element
| block_list SEMICOLON
| element ELSE element # if (c) a; else b;
| block_list ELSE element
element : expression [SEMICOLON]
| ID ':' expression [SEMICOLON] # label field
| TILDE element | NOT element # ~ disable, ! root
| AT element | HASH element # @ background, # debug
expression : literals, calls (with/without a block body), unary/binary/ternary
operators, dot/index access, assignment, if/for/let, block
literal, module/function definitions, use/include, child_expr
child_block : '{' block_list '}' | '{' '}' # always a list
child_expr : call | call child_expr | call child_block
| ID | '(' expression ')' | ID ':' expression
| NOT child_expr | HASH child_expr # only ! (root) and # (debug)
| child_expr '.' ID | child_expr '[' expression ']' # postfix binds to the child
| if / for / intersection_for / let
body : expression | ID ':' expression # if/for/let/module_def body, always a list
body is the single source of truth for if/for/let/module_def/call bodies: a
{...} block is expanded to its inner block_list, a label becomes a
stat_field, and anything else is statementize()d — the result is always a
list. child_expr is recursive, so deep braceless chains
(x = rotate(45) translate([10,0,0]) cube(5);) work in expressions exactly as
they do in statements. Postfix .member/[index] on a braceless child binds to
the child itself: f() x.y ≡ f() (x.y).
Parser tables are rebuilt automatically; the conflict counts are reported when the
tables regenerate (see the comment above p_child_block in scadlike_parser.py).
Braceless child modifiers are only ! (root) and # (debug). ~ (disable) and
@ (background) are element modifiers only (statement position or braces); */%
are only binary operators TIMES/MOD. Before variant C, */% were also
disable/background, and the dual role broke arithmetic after a call
(sqrt(4)*3 -> ParserError "invalid expression 3", sqrt(4)*a -> silent disable
printing 2 instead of 6; same bug with %). Changing the symbols removed the
ambiguity at the cost of a few more reduce/reduce conflicts (TILDE/AT then start
an element).
Wave 1: unified expression (everything is an expression)
The separate syntactic statement category is gone. One category expression
covers literals, calls (with/without block body), binary/unary/ternary ops,
dot/index access, assignment, if/for/let, block literal {...}, module/function
definitions, use/include. A sequence is block_list : element ... with
element : expression SEMICOLON?; each element is statementized via
statementize() (stat_expr/stat_call/stat_if/stat_for/stat_let/
stat_field/stat_modifier/...), and the interpreter (scl.py) decides by
context: in a sequence the node executes as a statement (emits), in a value
position it returns a value.
body is the single source of truth for if/for/let/module_def bodies:
body : expression | ID COLON expression — the ; is NOT absorbed into the
body (absorbing it made the function-body expr_sequence swallow the next
statement and caused a RecursionError).
The idiom if (c) a; else b; (used by check()-modules) is
supported at the block_list level: block_list : element ELSE element
(first element) and block_list : block_list ELSE element (subsequent). The
; ends the element as usual and else is bound by a fixup (p_block_list,
_merge_else) to the last stat_if (dangling else → nearest; else if chains
work). An else without a matching if raises ParserError — NOT
SyntaxError, because ply catches SyntaxError raised inside an action and
enters error recovery instead of propagating it.
Accepted deviations from OpenSCAD
- Braceless array-literal children require braces:
translate([0,0,0]) [1,2,3];is not accepted without{ }—[...]right after a call reads as indexing (see 2). Parenthesized value children (translate([0,0,0]) (x+1);) parse and behave like any other unnamed data expression — computed, unclaimed, silent. Name it (name: expr;, or emit a variable/node) if it should be collected. Call chains (rotate(45) translate(...) cube(5)) are fine. f(x)[0];in statement position is parsed as indexing.- Functions may accept a child block and call
children()inside the body. echo/assertare modules in every position and keep OpenSCAD's transparent pass-through semantics. In statement position they print/assert first, then children (if any) are executed through in a fresh module scope, exactly as in OpenSCAD (assert(false, ...) { ... }fails before its children run). In expression position a call returns the module's own node — a group built from the block: labels inside the block become fields of that node and geometry is collected into its shape, sox = echo("...") { d: 42; cube(10); };givesx.d == 42andvolume(x) == 1000. (Implementation: builtin entries withtransparent: True;_exec_builtin_modulewraps them in anSCLGroupdraft and returns that draft — an anonymous block has no node of its own, seedocs/design_unified_eval.md§4.1.1.)- Emission names data (bcad extension): a label
name: expr;binds a local variable in the current scope AND makes it a named field of the active node —a: 4;is one line instead ofv = expr; v;. Works in every statement position, including braceless bodies (if (flag) ok: 7;). A plain assignmentname = expr;binds the variable and nothing else: what leaves a block is what was emitted, sogroup(){ b = 32; }exposes no fieldb(b: 32;orb = 32; b;does). An assignment never emits:x = sphere(5);draws nothing,x;draws,x; x;draws twice. Node values emitted asname;(or via a label) are stored as a field, so nested fields are reachable by full path (a.b.head).
Tests
tests/test_field_scope.scad— the emission rule for fields: assignment stays local and creates no field, label and explicit emit publish it, dot / bracket / node field / full path reads, the flat stream (nested block below an outer label overrides it, and the equivalencegroup(){ c: undef; group(){ c: 5; } }≡group(){ c: undef; c: 5; }), last sibling wins, propagation up throughtranslate/rotate/color, an un-emitted draft keeps its fields to itself until it is emitted, and zero is a value, notundeftests/test_object_expression.scad— node values, fields, reuse, nested block expressions, children(n) indexingtests/test_object_expression_edge.scad— for-loop values, empty builtin node, object() dict, reusable emittests/test_unified_callable.scad— function-as-node, children() in functions, braceless child in expressions, silent redefinition, user-over-builtin shadowing, same-name module+function coexistence (both definition orders), echo/assert children pass-through (union wrapper + expression + braceless child)