SCAD parser: array subscript, array env, user-defined modules
This commit is contained in:
parent
afd3d6a409
commit
0f2d8a022b
|
|
@ -111,6 +111,7 @@ pub enum Expr {
|
||||||
BinaryOp { op: BinaryOp, left: Box<Expr>, right: Box<Expr> },
|
BinaryOp { op: BinaryOp, left: Box<Expr>, right: Box<Expr> },
|
||||||
FnCall { name: String, args: Vec<Argument> },
|
FnCall { name: String, args: Vec<Argument> },
|
||||||
Ternary { cond: Box<Expr>, then_expr: Box<Expr>, else_expr: Box<Expr> },
|
Ternary { cond: Box<Expr>, then_expr: Box<Expr>, else_expr: Box<Expr> },
|
||||||
|
Index { array: Box<Expr>, index: Box<Expr> },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -375,7 +375,18 @@ impl Parser {
|
||||||
let operand = self.parse_unary()?;
|
let operand = self.parse_unary()?;
|
||||||
return Ok(Expr::UnaryOp { op: UnaryOp::Not, operand: Box::new(operand) });
|
return Ok(Expr::UnaryOp { op: UnaryOp::Not, operand: Box::new(operand) });
|
||||||
}
|
}
|
||||||
self.parse_primary()
|
self.parse_postfix()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_postfix(&mut self) -> Result<Expr, ParseError> {
|
||||||
|
let mut expr = self.parse_primary()?;
|
||||||
|
while self.peek_is(&Token::LBracket) {
|
||||||
|
self.expect(&Token::LBracket)?;
|
||||||
|
let index = self.parse_expr()?;
|
||||||
|
self.expect(&Token::RBracket)?;
|
||||||
|
expr = Expr::Index { array: Box::new(expr), index: Box::new(index) };
|
||||||
|
}
|
||||||
|
Ok(expr)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_primary(&mut self) -> Result<Expr, ParseError> {
|
fn parse_primary(&mut self) -> Result<Expr, ParseError> {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ enum Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Env = HashMap<String, Value>;
|
type Env = HashMap<String, Value>;
|
||||||
|
type ModuleDefs<'a> = HashMap<String, &'a ModuleDef>;
|
||||||
|
|
||||||
fn env_get_scalar(env: &Env, name: &str) -> Option<f64> {
|
fn env_get_scalar(env: &Env, name: &str) -> Option<f64> {
|
||||||
match env.get(name)? {
|
match env.get(name)? {
|
||||||
|
|
@ -38,7 +39,9 @@ fn env_insert_scalar(env: &mut Env, name: String, val: f64) {
|
||||||
/// Multiple top-level statements become an implicit union.
|
/// Multiple top-level statements become an implicit union.
|
||||||
pub fn lower_program(program: &Program) -> Result<SdfNode, LowerError> {
|
pub fn lower_program(program: &Program) -> Result<SdfNode, LowerError> {
|
||||||
let mut env = Env::new();
|
let mut env = Env::new();
|
||||||
let nodes = lower_statements(&program.statements, &mut env)?;
|
let mut modules = ModuleDefs::new();
|
||||||
|
collect_module_defs(&program.statements, &mut modules);
|
||||||
|
let nodes = lower_statements(&program.statements, &mut env, &modules)?;
|
||||||
|
|
||||||
match nodes.len() {
|
match nodes.len() {
|
||||||
0 => Err(LowerError { msg: "empty scene".into() }),
|
0 => Err(LowerError { msg: "empty scene".into() }),
|
||||||
|
|
@ -47,29 +50,37 @@ pub fn lower_program(program: &Program) -> Result<SdfNode, LowerError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lower_statements(stmts: &[Statement], env: &mut Env) -> Result<Vec<SdfNode>, LowerError> {
|
fn collect_module_defs<'a>(stmts: &'a [Statement], modules: &mut ModuleDefs<'a>) {
|
||||||
|
for stmt in stmts {
|
||||||
|
if let Statement::ModuleDef(def) = stmt {
|
||||||
|
modules.insert(def.name.clone(), def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lower_statements(stmts: &[Statement], env: &mut Env, modules: &ModuleDefs) -> Result<Vec<SdfNode>, LowerError> {
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
for stmt in stmts {
|
for stmt in stmts {
|
||||||
if let Some(node) = lower_statement(stmt, env)? {
|
if let Some(node) = lower_statement(stmt, env, modules)? {
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(nodes)
|
Ok(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lower_statement(stmt: &Statement, env: &mut Env) -> Result<Option<SdfNode>, LowerError> {
|
fn lower_statement(stmt: &Statement, env: &mut Env, modules: &ModuleDefs) -> Result<Option<SdfNode>, LowerError> {
|
||||||
match stmt {
|
match stmt {
|
||||||
Statement::ModuleCall(call) => lower_module_call(call, env).map(Some),
|
Statement::ModuleCall(call) => lower_module_call(call, env, modules).map(Some),
|
||||||
Statement::BooleanOp(bop) => lower_boolean(bop, env).map(Some),
|
Statement::BooleanOp(bop) => lower_boolean(bop, env, modules).map(Some),
|
||||||
Statement::Assignment(asgn) => {
|
Statement::Assignment(asgn) => {
|
||||||
if let Some(val) = eval_expr_env(&asgn.value, env) {
|
if let Some(val) = eval_value(&asgn.value, env) {
|
||||||
env_insert_scalar(env, asgn.name.clone(), val);
|
env.insert(asgn.name.clone(), val);
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Statement::ModuleDef(_) => Ok(None),
|
Statement::ModuleDef(_) => Ok(None),
|
||||||
Statement::ForLoop(fl) => lower_for_loop(fl, env).map(Some),
|
Statement::ForLoop(fl) => lower_for_loop(fl, env, modules).map(Some),
|
||||||
Statement::IfElse(ie) => lower_if_else(ie, env),
|
Statement::IfElse(ie) => lower_if_else(ie, env, modules),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,16 +88,15 @@ fn lower_statement(stmt: &Statement, env: &mut Env) -> Result<Option<SdfNode>, L
|
||||||
///
|
///
|
||||||
/// Each iteration gets its own copy of the environment with the loop
|
/// Each iteration gets its own copy of the environment with the loop
|
||||||
/// variable bound. Since iterations share no state, every unrolled
|
/// variable bound. Since iterations share no state, every unrolled
|
||||||
/// instance is an independent parallel branch — this is where serial
|
/// instance is an independent parallel branch.
|
||||||
/// SCAD becomes parallel geometry.
|
fn lower_for_loop(fl: &ForLoop, env: &mut Env, modules: &ModuleDefs) -> Result<SdfNode, LowerError> {
|
||||||
fn lower_for_loop(fl: &ForLoop, env: &mut Env) -> Result<SdfNode, LowerError> {
|
|
||||||
let values = expand_range(&fl.range, env)?;
|
let values = expand_range(&fl.range, env)?;
|
||||||
|
|
||||||
let mut branches = Vec::with_capacity(values.len());
|
let mut branches = Vec::with_capacity(values.len());
|
||||||
for val in values {
|
for val in values {
|
||||||
let mut inner_env = env.clone();
|
let mut inner_env = env.clone();
|
||||||
env_insert_scalar(&mut inner_env, fl.var.clone(), val);
|
env_insert_scalar(&mut inner_env, fl.var.clone(), val);
|
||||||
let nodes = lower_statements(&fl.body, &mut inner_env)?;
|
let nodes = lower_statements(&fl.body, &mut inner_env, modules)?;
|
||||||
match nodes.len() {
|
match nodes.len() {
|
||||||
0 => {}
|
0 => {}
|
||||||
1 => branches.push(nodes.into_iter().next().unwrap()),
|
1 => branches.push(nodes.into_iter().next().unwrap()),
|
||||||
|
|
@ -131,7 +141,6 @@ fn expand_range(range: &ForRange, env: &Env) -> Result<Vec<f64>, LowerError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety cap
|
|
||||||
if values.len() > 10000 {
|
if values.len() > 10000 {
|
||||||
return Err(LowerError { msg: "for loop exceeds 10000 iterations".into() });
|
return Err(LowerError { msg: "for loop exceeds 10000 iterations".into() });
|
||||||
}
|
}
|
||||||
|
|
@ -148,20 +157,17 @@ fn expand_range(range: &ForRange, env: &Env) -> Result<Vec<f64>, LowerError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lower if/else by evaluating the condition at lowering time.
|
/// Lower if/else by evaluating the condition at lowering time.
|
||||||
/// Constant conditions → dead code elimination. Variable conditions
|
fn lower_if_else(ie: &IfElse, env: &mut Env, modules: &ModuleDefs) -> Result<Option<SdfNode>, LowerError> {
|
||||||
/// include both branches (the SDF is defined everywhere; the condition
|
|
||||||
/// selects which geometry appears).
|
|
||||||
fn lower_if_else(ie: &IfElse, env: &mut Env) -> Result<Option<SdfNode>, LowerError> {
|
|
||||||
if let Some(cond) = eval_expr_env(&ie.condition, env) {
|
if let Some(cond) = eval_expr_env(&ie.condition, env) {
|
||||||
if cond != 0.0 {
|
if cond != 0.0 {
|
||||||
let nodes = lower_statements(&ie.then_body, env)?;
|
let nodes = lower_statements(&ie.then_body, env, modules)?;
|
||||||
match nodes.len() {
|
match nodes.len() {
|
||||||
0 => Ok(None),
|
0 => Ok(None),
|
||||||
1 => Ok(Some(nodes.into_iter().next().unwrap())),
|
1 => Ok(Some(nodes.into_iter().next().unwrap())),
|
||||||
_ => Ok(Some(SdfNode::Union(nodes))),
|
_ => Ok(Some(SdfNode::Union(nodes))),
|
||||||
}
|
}
|
||||||
} else if !ie.else_body.is_empty() {
|
} else if !ie.else_body.is_empty() {
|
||||||
let nodes = lower_statements(&ie.else_body, env)?;
|
let nodes = lower_statements(&ie.else_body, env, modules)?;
|
||||||
match nodes.len() {
|
match nodes.len() {
|
||||||
0 => Ok(None),
|
0 => Ok(None),
|
||||||
1 => Ok(Some(nodes.into_iter().next().unwrap())),
|
1 => Ok(Some(nodes.into_iter().next().unwrap())),
|
||||||
|
|
@ -171,10 +177,8 @@ fn lower_if_else(ie: &IfElse, env: &mut Env) -> Result<Option<SdfNode>, LowerErr
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-constant condition: include both branches as a union
|
let mut nodes = lower_statements(&ie.then_body, env, modules)?;
|
||||||
// (conservative — the SDF field includes all possible geometry)
|
nodes.extend(lower_statements(&ie.else_body, env, modules)?);
|
||||||
let mut nodes = lower_statements(&ie.then_body, env)?;
|
|
||||||
nodes.extend(lower_statements(&ie.else_body, env)?);
|
|
||||||
match nodes.len() {
|
match nodes.len() {
|
||||||
0 => Ok(None),
|
0 => Ok(None),
|
||||||
1 => Ok(Some(nodes.into_iter().next().unwrap())),
|
1 => Ok(Some(nodes.into_iter().next().unwrap())),
|
||||||
|
|
@ -183,7 +187,26 @@ fn lower_if_else(ie: &IfElse, env: &mut Env) -> Result<Option<SdfNode>, LowerErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate a constant expression with variable environment.
|
/// Evaluate an expression to a full Value (scalar or array).
|
||||||
|
fn eval_value(expr: &Expr, env: &Env) -> Option<Value> {
|
||||||
|
match expr {
|
||||||
|
Expr::Ident(name) => env.get(name).cloned(),
|
||||||
|
Expr::Vector(elems) => {
|
||||||
|
let vals: Option<Vec<f64>> = elems.iter().map(|e| eval_expr_env(e, env)).collect();
|
||||||
|
Some(Value::Array(vals?))
|
||||||
|
}
|
||||||
|
Expr::Index { array, index } => {
|
||||||
|
let idx = eval_expr_env(index, env)? as usize;
|
||||||
|
match eval_value(array, env)? {
|
||||||
|
Value::Array(arr) => arr.get(idx).map(|v| Value::Scalar(*v)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => eval_expr_env(expr, env).map(Value::Scalar),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a constant expression to a scalar f64.
|
||||||
fn eval_expr_env(expr: &Expr, env: &Env) -> Option<f64> {
|
fn eval_expr_env(expr: &Expr, env: &Env) -> Option<f64> {
|
||||||
match expr {
|
match expr {
|
||||||
Expr::Number(n) => Some(*n),
|
Expr::Number(n) => Some(*n),
|
||||||
|
|
@ -242,12 +265,20 @@ fn eval_expr_env(expr: &Expr, env: &Env) -> Option<f64> {
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Expr::Index { array, index } => {
|
||||||
|
let idx = eval_expr_env(index, env)? as usize;
|
||||||
|
match eval_value(array, env)? {
|
||||||
|
Value::Array(arr) => arr.get(idx).copied(),
|
||||||
|
Value::Scalar(v) if idx == 0 => Some(v),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lower_module_call(call: &ModuleCall, env: &mut Env) -> Result<SdfNode, LowerError> {
|
fn lower_module_call(call: &ModuleCall, env: &mut Env, modules: &ModuleDefs) -> Result<SdfNode, LowerError> {
|
||||||
let child_nodes = lower_statements(&call.children, env)?;
|
let child_nodes = lower_statements(&call.children, env, modules)?;
|
||||||
|
|
||||||
match call.name.as_str() {
|
match call.name.as_str() {
|
||||||
"sphere" => {
|
"sphere" => {
|
||||||
|
|
@ -311,12 +342,32 @@ fn lower_module_call(call: &ModuleCall, env: &mut Env) -> Result<SdfNode, LowerE
|
||||||
Ok(SdfNode::Scale { factor: [s, s, s], child: Box::new(child) })
|
Ok(SdfNode::Scale { factor: [s, s, s], child: Box::new(child) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Err(LowerError { msg: format!("unknown module: {}", call.name) }),
|
name => {
|
||||||
|
if let Some(def) = modules.get(name) {
|
||||||
|
let mut child_env = env.clone();
|
||||||
|
for (i, param) in def.params.iter().enumerate() {
|
||||||
|
let val = call.args.get(i)
|
||||||
|
.and_then(|a| eval_value(&a.value, &child_env))
|
||||||
|
.or_else(|| param.default.as_ref().and_then(|d| eval_value(d, &child_env)));
|
||||||
|
if let Some(v) = val {
|
||||||
|
child_env.insert(param.name.clone(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let nodes = lower_statements(&def.body, &mut child_env, modules)?;
|
||||||
|
match nodes.len() {
|
||||||
|
0 => Err(LowerError { msg: format!("module {name}() produced no geometry") }),
|
||||||
|
1 => Ok(nodes.into_iter().next().unwrap()),
|
||||||
|
_ => Ok(SdfNode::Union(nodes)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(LowerError { msg: format!("unknown module: {name}") })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lower_boolean(bop: &BooleanOp, env: &mut Env) -> Result<SdfNode, LowerError> {
|
fn lower_boolean(bop: &BooleanOp, env: &mut Env, modules: &ModuleDefs) -> Result<SdfNode, LowerError> {
|
||||||
let children = lower_statements(&bop.children, env)?;
|
let children = lower_statements(&bop.children, env, modules)?;
|
||||||
if children.is_empty() {
|
if children.is_empty() {
|
||||||
return Err(LowerError { msg: "boolean operation with no children".into() });
|
return Err(LowerError { msg: "boolean operation with no children".into() });
|
||||||
}
|
}
|
||||||
|
|
@ -345,8 +396,6 @@ fn require_single_child(mut nodes: Vec<SdfNode>, op: &str) -> Result<SdfNode, Lo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Argument extraction helpers
|
|
||||||
|
|
||||||
fn eval_const_bool(expr: &Expr) -> Option<bool> {
|
fn eval_const_bool(expr: &Expr) -> Option<bool> {
|
||||||
match expr {
|
match expr {
|
||||||
Expr::Bool(b) => Some(*b),
|
Expr::Bool(b) => Some(*b),
|
||||||
|
|
@ -457,7 +506,6 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn for_loop_unrolls() {
|
fn for_loop_unrolls() {
|
||||||
// 4 iterations → Union of 4 translated spheres
|
|
||||||
let node = parse_and_lower(
|
let node = parse_and_lower(
|
||||||
"for (i = [0:3]) translate([i*10, 0, 0]) sphere(1);"
|
"for (i = [0:3]) translate([i*10, 0, 0]) sphere(1);"
|
||||||
);
|
);
|
||||||
|
|
@ -466,7 +514,6 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn for_loop_with_step() {
|
fn for_loop_with_step() {
|
||||||
// [0 : 2 : 6] → values 0, 2, 4, 6 → 4 branches
|
|
||||||
let node = parse_and_lower(
|
let node = parse_and_lower(
|
||||||
"for (i = [0:2:6]) sphere(i);"
|
"for (i = [0:2:6]) sphere(i);"
|
||||||
);
|
);
|
||||||
|
|
@ -490,7 +537,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn if_constant_false_with_else() {
|
fn if_constant_false_with_else() {
|
||||||
let node = parse_and_lower("if (false) sphere(1); else cube(2);");
|
let node = parse_and_lower("if (false) sphere(1); else cube(2);");
|
||||||
assert!(matches!(node, SdfNode::Translate { .. })); // cube with center=false wraps in translate
|
assert!(matches!(node, SdfNode::Translate { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -499,25 +546,21 @@ mod tests {
|
||||||
let tokens = Lexer::new(src).tokenize().unwrap();
|
let tokens = Lexer::new(src).tokenize().unwrap();
|
||||||
let program = Parser::new(tokens).parse_program().unwrap();
|
let program = Parser::new(tokens).parse_program().unwrap();
|
||||||
let result = lower_program(&program);
|
let result = lower_program(&program);
|
||||||
assert!(result.is_err()); // no geometry produced → empty scene
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn variable_condition_includes_both_branches() {
|
fn variable_condition_includes_both_branches() {
|
||||||
// `x` is unknown → both branches included
|
|
||||||
let src = "x = 1; if (x) sphere(1); else cube(2);";
|
let src = "x = 1; if (x) sphere(1); else cube(2);";
|
||||||
let node = parse_and_lower(src);
|
let node = parse_and_lower(src);
|
||||||
// x=1 is known, so condition evaluates truthy → only sphere
|
|
||||||
assert!(matches!(node, SdfNode::Sphere { .. }));
|
assert!(matches!(node, SdfNode::Sphere { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nested_for_loops() {
|
fn nested_for_loops() {
|
||||||
// 3 * 3 = 9 branches
|
|
||||||
let node = parse_and_lower(
|
let node = parse_and_lower(
|
||||||
"for (i = [0:2]) for (j = [0:2]) translate([i*10, j*10, 0]) sphere(1);"
|
"for (i = [0:2]) for (j = [0:2]) translate([i*10, j*10, 0]) sphere(1);"
|
||||||
);
|
);
|
||||||
// Outer union of 3, each containing inner union of 3
|
|
||||||
assert_eq!(count_union_children(&node), 3);
|
assert_eq!(count_union_children(&node), 3);
|
||||||
if let SdfNode::Union(outer) = &node {
|
if let SdfNode::Union(outer) = &node {
|
||||||
for child in outer {
|
for child in outer {
|
||||||
|
|
@ -541,7 +584,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn for_with_variable_bounds() {
|
fn for_with_variable_bounds() {
|
||||||
let node = parse_and_lower("n = 3; for (i = [0:n]) sphere(i);");
|
let node = parse_and_lower("n = 3; for (i = [0:n]) sphere(i);");
|
||||||
assert_eq!(count_union_children(&node), 4); // 0, 1, 2, 3
|
assert_eq!(count_union_children(&node), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -549,4 +592,34 @@ mod tests {
|
||||||
let node = parse_and_lower("difference() { cube(10, center=true); sphere(5); }");
|
let node = parse_and_lower("difference() { cube(10, center=true); sphere(5); }");
|
||||||
assert!(matches!(node, SdfNode::Difference { .. }));
|
assert!(matches!(node, SdfNode::Difference { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn array_subscript() {
|
||||||
|
let node = parse_and_lower("arr = [1, 5, 10]; sphere(arr[1]);");
|
||||||
|
assert!(matches!(node, SdfNode::Sphere { radius } if (radius - 5.0).abs() < 1e-10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn array_subscript_in_for_loop() {
|
||||||
|
let node = parse_and_lower(
|
||||||
|
"vals = [2, 4, 6]; for (i = [0:2]) sphere(vals[i]);"
|
||||||
|
);
|
||||||
|
assert_eq!(count_union_children(&node), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_defined_module() {
|
||||||
|
let node = parse_and_lower(
|
||||||
|
"module ball() { sphere(5); } ball();"
|
||||||
|
);
|
||||||
|
assert!(matches!(node, SdfNode::Sphere { radius } if (radius - 5.0).abs() < 1e-10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_module_with_env() {
|
||||||
|
let node = parse_and_lower(
|
||||||
|
"r = 3; module ball() { sphere(r); } ball();"
|
||||||
|
);
|
||||||
|
assert!(matches!(node, SdfNode::Sphere { radius } if (radius - 3.0).abs() < 1e-10));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue