BCad fork

Unified callable model (object expressions)
Login

Unified callable model (object expressions)

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

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

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.

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

  1. 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.
  2. f(x)[0]; in statement position is parsed as indexing.
  3. Functions may accept a child block and call children() inside the body.
  4. echo/assert are 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, so x = echo("...") { d: 42; cube(10); }; gives x.d == 42 and volume(x) == 1000. (Implementation: builtin entries with transparent: True; _exec_builtin_module wraps them in an SCLGroup draft and returns that draft — an anonymous block has no node of its own, see docs/design_unified_eval.md §4.1.1.)
  5. 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 of v = expr; v;. Works in every statement position, including braceless bodies (if (flag) ok: 7;). A plain assignment name = expr; binds the variable and nothing else: what leaves a block is what was emitted, so group(){ b = 32; } exposes no field b (b: 32; or b = 32; b; does). An assignment never emits: x = sphere(5); draws nothing, x; draws, x; x; draws twice. Node values emitted as name; (or via a label) are stored as a field, so nested fields are reachable by full path (a.b.head).

Tests