bimr-engine/eval/src/eval.rs
2026-08-31 11:16:41 +02:00

1635 lines
61 KiB
Rust

// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
//! The eval walk — the relation graph built from the AST (goal 4).
//!
//! Per-entity behavior lives in the node modules ([`crate::nodes`]); this
//! file owns the walk itself: program → bindings → dispatch.
use parser::ast::{Expression, FunctionCall, Program};
use crate::error::Error;
use crate::store::Store;
/// Evaluate a program: walk the statements once, build the graph.
///
/// `file` is the source name the GUIDs derive from
/// (`stable_hash(file, name)`).
pub fn eval_program(program: &Program, file: &str) -> Result<Store, Error> {
let mut store = Store::new();
for stmt in &program.statements {
let assignment = &stmt.assignment;
eval_binding(
&mut store,
file,
&assignment.identifier,
&assignment.expression,
)?;
// The statement log — to_bimr's walk. Engine-created nodes (a
// Divide's items) never enter it.
let guid = store
.named
.get(&assignment.identifier)
.copied()
.expect("a statement binds its name");
store.statements.push(guid);
}
Ok(store)
}
/// One binding: `name = <expression>`.
fn eval_binding(
store: &mut Store,
file: &str,
name: &str,
expression: &Expression,
) -> Result<(), Error> {
let Expression::FunctionCall(call) = expression else {
return Err(Error::Unsupported(
"right-hand side must be a constructor call",
));
};
eval_call(store, file, name, call)
}
fn eval_call(store: &mut Store, file: &str, name: &str, call: &FunctionCall) -> Result<(), Error> {
crate::nodes::call(store, file, name, call.name.as_str(), &call.arguments)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::guid::Guid;
const WALL: &str = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(500,0,0)\n",
"l1 = Line(p1,p2)\n",
"w1 = Wall(l1,20,300)\n",
);
fn eval_src(src: &str) -> Result<Store, Error> {
let program = parser::parse(src).expect("parse ok");
eval_program(&program, "wall.bimr")
}
#[test]
fn wall_sample_builds_four_nodes() {
let store = eval_src(WALL).expect("eval ok");
assert_eq!(store.arena.len(), 4);
assert_eq!(
store
.order
.iter()
.map(|g| store.name_of(*g).unwrap())
.collect::<Vec<_>>(),
vec!["p1", "p2", "l1", "w1"]
);
assert_eq!(store.order[0], Guid::from_name("wall.bimr", "p1"));
}
#[test]
fn wall_holds_line_by_value_with_guid_backref() {
let store = eval_src(WALL).expect("eval ok");
let w = store.resolve(Guid::from_name("wall.bimr", "w1")).unwrap();
assert_eq!(w.refs, vec![Guid::from_name("wall.bimr", "l1")]);
match &w.entity {
bimr::Entity::Wall(wall) => {
assert_eq!(wall.line.start.x, 0.0);
assert_eq!(wall.line.end.x, 500.0);
assert_eq!(wall.width, 20.0);
assert_eq!(wall.height, 300.0);
}
other => panic!("expected Wall, got {}", other.type_name()),
}
}
#[test]
fn line_records_both_point_edges() {
let store = eval_src(WALL).expect("eval ok");
let l = store.resolve(Guid::from_name("wall.bimr", "l1")).unwrap();
assert_eq!(
l.refs,
vec![
Guid::from_name("wall.bimr", "p1"),
Guid::from_name("wall.bimr", "p2"),
]
);
}
#[test]
fn unknown_constructors_are_rejected() {
// No curated blocklist: anything outside the dispatch is simply an
// unknown call.
for src in [
"x = Each(source=s, element=\"wall\", params=p)\n",
"x = Level(elevation=0, entities=e)\n",
"x = Mesh(e)\n",
"x = Frob(p1)\n",
] {
let err = eval_src(src).unwrap_err();
assert!(
matches!(err, Error::Unsupported("call")),
"{src}: got {err:?}"
);
}
}
#[test]
fn unknown_keywords_are_rejected() {
let err = eval_src("p1 = Point(x=0, y=0, z=0)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
}
#[test]
fn wall_keyword_form_round_trips() {
// The reference to_bimr() emits this exact keyword form — the eval must
// accept what its own serializer produces.
let store = eval_src("w1 = Wall(l1, thickness=20, height=300)\n");
assert!(store.is_err()); // l1 undefined — but the failure must come
// from resolution, not the keyword form
let src = "a = Point(0,0,0)\nb = Point(1,0,0)\nl1 = Line(a, b)\nw1 = Wall(l1, thickness=20, height=300)\n";
assert!(eval_src(src).is_ok());
}
#[test]
fn unknown_name_is_reported() {
let err = eval_src("l1 = Line(nope, p2)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
#[test]
fn wrong_nature_is_reported() {
let src = "p1 = Point(0,0,0)\np2 = Point(1,0,0)\nw1 = Wall(p1, 20, 300)\n";
let err = eval_src(src).unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "p1".to_string(),
expected: "Line",
got: "Point",
}
);
}
#[test]
fn arity_is_rejected() {
let err = eval_src("p1 = Point(0, 0)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
}
const LIST_SRC: &str = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"lst = List(p1, p2)\n",
);
#[test]
fn list_builds_one_node_with_item_edges() {
let store = eval_src(LIST_SRC).expect("eval ok");
assert_eq!(store.arena.len(), 3);
let l = store.resolve(Guid::from_name("wall.bimr", "lst")).unwrap();
assert_eq!(
l.refs,
vec![
Guid::from_name("wall.bimr", "p1"),
Guid::from_name("wall.bimr", "p2"),
]
);
match &l.entity {
bimr::Entity::List(list) => {
assert_eq!(list.kind(), "Point");
assert_eq!(list.length(), 2);
}
other => panic!("expected List, got {}", other.type_name()),
}
}
#[test]
fn list_compact_form_round_trips() {
let store = eval_src(LIST_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
"p1 = Point(0, 0, 0)\np2 = Point(1, 0, 0)\nlst = List(p1, p2)"
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn list_holds_walls_of_the_first_items_nature() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"w1 = Wall(l1, 20, 300)\n",
"w2 = Wall(l1, 30, 200)\n",
"lst = List(w1, w2)\n",
);
let store = eval_src(src).expect("eval ok");
match &store.arena[5].entity {
bimr::Entity::List(list) => assert_eq!(list.kind(), "Wall"),
other => panic!("expected List, got {}", other.type_name()),
}
}
#[test]
fn mixed_list_is_rejected() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lst = List(p1, l1)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "l1".to_string(),
expected: "Point",
got: "Line",
}
);
}
#[test]
fn empty_list_is_rejected() {
let err = eval_src("lst = List()\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
}
#[test]
fn list_keywords_are_rejected() {
let err = eval_src("lst = List(items=p1)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
}
#[test]
fn list_unknown_item_is_reported() {
let err = eval_src("lst = List(nope)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
#[test]
fn list_is_not_mesh_bearing() {
let store = eval_src(LIST_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_ifcx(&store),
crate::serialize::to_ifcx(&empty_store())
);
}
fn empty_store() -> crate::store::Store {
let program = parser::parse("").expect("parse ok");
eval_program(&program, "wall.bimr").expect("eval ok")
}
// ── Circle: a curve primitive over a named center ───────────────────────
const CIRCLE_SRC: &str = concat!("p1 = Point(0,0,0)\n", "c1 = Circle(p1, 200)\n",);
#[test]
fn circle_builds_from_resolved_center() {
let store = eval_src(CIRCLE_SRC).expect("eval ok");
assert_eq!(store.arena.len(), 2);
let c = store.resolve(Guid::from_name("wall.bimr", "c1")).unwrap();
assert_eq!(c.refs, vec![Guid::from_name("wall.bimr", "p1")]);
match &c.entity {
bimr::Entity::Circle(circle) => {
assert_eq!(circle.center.x, 0.0);
assert_eq!(circle.radius, 200.0);
}
other => panic!("expected Circle, got {}", other.type_name()),
}
}
#[test]
fn circle_compact_form_round_trips() {
let store = eval_src(CIRCLE_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
"p1 = Point(0, 0, 0)\nc1 = Circle(p1, 200)"
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn circle_center_must_be_a_point() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"c1 = Circle(l1, 200)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "l1".to_string(),
expected: "Point",
got: "Line",
}
);
}
#[test]
fn circle_arity_is_rejected() {
let err = eval_src("p1 = Point(0,0,0)\nc1 = Circle(p1)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
}
#[test]
fn circle_keywords_are_rejected() {
let err = eval_src("p1 = Point(0,0,0)\nc1 = Circle(p1, r=200)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
}
#[test]
fn circle_unknown_center_is_reported() {
let err = eval_src("c1 = Circle(nope, 200)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
#[test]
fn circle_is_not_mesh_bearing() {
let store = eval_src(CIRCLE_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_ifcx(&store),
crate::serialize::to_ifcx(&empty_store())
);
}
// ── Divide: the operator entity — circle division first ─────────────────
const DIVIDE_SRC: &str = concat!(
"p1 = Point(0,0,0)\n",
"c1 = Circle(p1, 100)\n",
"d1 = Divide(c1, 4)\n",
);
#[test]
fn divide_holds_source_by_value_with_edge() {
let store = eval_src(DIVIDE_SRC).expect("eval ok");
// p1, c1, the record, and the 4 created items.
assert_eq!(store.arena.len(), 7);
let d = store.resolve(Guid::from_name("wall.bimr", "d1")).unwrap();
assert_eq!(d.refs, vec![Guid::from_name("wall.bimr", "c1")]);
match &d.entity {
bimr::Entity::Divide(div) => {
assert_eq!(div.source().type_name(), "Circle");
assert_eq!(div.n(), 4);
}
other => panic!("expected Divide, got {}", other.type_name()),
}
// The created items: named <binding>_<i>, edges to the record.
let record = Guid::from_name("wall.bimr", "d1");
let items = store.outputs_of(record);
assert_eq!(items.len(), 4);
for (i, &guid) in items.iter().enumerate() {
let node = store.resolve(guid).unwrap();
assert_eq!(store.name_of(guid), Some(format!("d1_{i}").as_str()));
assert_eq!(node.refs, vec![record]);
assert!(matches!(node.entity, bimr::Entity::Point(_)));
}
// The record is a statement; its items are engine-owned.
assert_eq!(store.statements.len(), 3);
assert!(!store.statements.contains(&items[0]));
}
#[test]
fn divide_items_are_created_at_the_divided_positions() {
let store = eval_src(DIVIDE_SRC).expect("eval ok");
let record = Guid::from_name("wall.bimr", "d1");
let first = store.resolve(store.outputs_of(record)[0]).unwrap();
match &first.entity {
// i = 0 starts on the positive x-axis: (100, 0).
bimr::Entity::Point(p) => assert_eq!((p.x, p.y, p.z), (100.0, 0.0, 0.0)),
other => panic!("expected Point, got {}", other.type_name()),
}
}
#[test]
fn divide_compact_form_round_trips() {
let store = eval_src(DIVIDE_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
"p1 = Point(0, 0, 0)\nc1 = Circle(p1, 100)\nd1 = Divide(c1, 4)"
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn divide_source_must_be_a_circle() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"d1 = Divide(l1, 4)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "l1".to_string(),
expected: "Circle or Curve",
got: "Line",
}
);
}
#[test]
fn divide_count_must_be_a_positive_integer() {
for n in ["0", "-2", "2.5"] {
let src = format!("p1 = Point(0,0,0)\nc1 = Circle(p1, 100)\nd1 = Divide(c1, {n})\n");
let err = eval_src(&src).unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")), "{n}: {err:?}");
}
}
#[test]
fn divide_keywords_are_rejected() {
let err = eval_src("p1 = Point(0,0,0)\nc1 = Circle(p1, 100)\nd1 = Divide(c1, n=4)\n")
.unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
}
#[test]
fn divide_unknown_source_is_reported() {
let err = eval_src("d1 = Divide(nope, 4)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
#[test]
fn divide_item_name_collision_is_rejected() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"c1 = Circle(p1, 100)\n",
"d1_0 = Point(1,1,1)\n",
"d1 = Divide(c1, 4)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(err, Error::NameCollision("d1_0".to_string()));
}
#[test]
fn divide_is_not_mesh_bearing() {
let store = eval_src(DIVIDE_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_ifcx(&store),
crate::serialize::to_ifcx(&empty_store())
);
}
#[test]
fn column_over_divide_lifts_a_ring_of_columns() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"c1 = Circle(p1, 100)\n",
"d1 = Divide(c1, 6)\n",
"cols = Column(d1, 300, 30, 30)\n",
);
let store = eval_src(src).expect("eval ok");
// p1, c1, d1, 6 created items, cols.
assert_eq!(store.arena.len(), 10);
let cols = store.resolve(Guid::from_name("wall.bimr", "cols")).unwrap();
assert_eq!(cols.refs, vec![Guid::from_name("wall.bimr", "d1")]);
match &cols.entity {
bimr::Entity::List(list) => {
assert_eq!(list.kind(), "Column");
assert_eq!(list.length(), 6);
// i = 0 starts on the positive x-axis — the first column's
// base sits at (100, 0).
assert!(matches!(
list.get(0),
Some(bimr::Entity::Column(col)) if col.base.x == 100.0 && col.base.y == 0.0
));
}
other => panic!("expected List, got {}", other.type_name()),
}
// And the lifted statement round-trips.
let src2 = crate::serialize::to_bimr(&store);
assert!(
src2.contains("cols = Column(d1, height=300, section_width=30, section_height=30)")
);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
// ── Vector: a graph parameter ───────────────────────────────────────────
#[test]
fn vector_builds_a_leaf() {
let store = eval_src("v1 = Vector(0, 0, 300)\n").expect("eval ok");
assert_eq!(store.arena.len(), 1);
let v = store.resolve(Guid::from_name("wall.bimr", "v1")).unwrap();
assert!(v.refs.is_empty());
match &v.entity {
bimr::Entity::Vector(vec) => assert_eq!((vec.x, vec.y, vec.z), (0.0, 0.0, 300.0)),
other => panic!("expected Vector, got {}", other.type_name()),
}
assert_eq!(crate::serialize::to_bimr(&store), "v1 = Vector(0, 0, 300)");
}
#[test]
fn vector_arity_and_keywords_are_rejected() {
let err = eval_src("v1 = Vector(0, 0)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
let err = eval_src("v1 = Vector(x=0, y=0, z=1)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
}
// ── Cut: curve pieces — a closed loop on a circle ───────────────────────
const CUT_SRC: &str = concat!(
"p1 = Point(0,0,0)\n",
"c1 = Circle(p1, 100)\n",
"ct1 = Cut(c1, 4)\n",
);
#[test]
fn cut_builds_record_and_line_pieces() {
let store = eval_src(CUT_SRC).expect("eval ok");
// p1, c1, the record, and the 4 created pieces.
assert_eq!(store.arena.len(), 7);
let record = Guid::from_name("wall.bimr", "ct1");
let ct = store.resolve(record).unwrap();
assert_eq!(ct.refs, vec![Guid::from_name("wall.bimr", "c1")]);
match &ct.entity {
bimr::Entity::Cut(cut) => {
assert_eq!(cut.source().type_name(), "Circle");
assert_eq!(cut.n(), 4);
}
other => panic!("expected Cut, got {}", other.type_name()),
}
// The created pieces: named <binding>_<i>, edges to the record.
let items = store.outputs_of(record);
assert_eq!(items.len(), 4);
for (i, &guid) in items.iter().enumerate() {
let node = store.resolve(guid).unwrap();
assert_eq!(store.name_of(guid), Some(format!("ct1_{i}").as_str()));
assert_eq!(node.refs, vec![record]);
assert!(matches!(node.entity, bimr::Entity::Line(_)));
}
// The record is a statement; its pieces are engine-owned.
assert_eq!(store.statements.len(), 3);
assert!(!store.statements.contains(&items[0]));
}
#[test]
fn cut_pieces_form_a_closed_loop() {
let store = eval_src(CUT_SRC).expect("eval ok");
let record = Guid::from_name("wall.bimr", "ct1");
let piece = |i: usize| match &store.resolve(store.outputs_of(record)[i]).unwrap().entity {
bimr::Entity::Line(l) => *l,
other => panic!("expected Line, got {}", other.type_name()),
};
// i = 0 starts on the positive x-axis: (100, 0) → (0, 100).
let p0 = piece(0);
assert_eq!((p0.start.x, p0.start.y), (100.0, 0.0));
assert_eq!((p0.end.x, p0.end.y), (0.0, 100.0));
// The last piece closes back to the first point.
let p3 = piece(3);
assert_eq!((p3.end.x, p3.end.y), (100.0, 0.0));
}
#[test]
fn cut_compact_form_round_trips() {
let store = eval_src(CUT_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
"p1 = Point(0, 0, 0)\nc1 = Circle(p1, 100)\nct1 = Cut(c1, 4)"
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn cut_rejections() {
// Wrong source.
let err = eval_src("p1 = Point(0,0,0)\nct1 = Cut(p1, 4)\n").unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "p1".to_string(),
expected: "Circle or Curve",
got: "Point",
}
);
// Piece-name collision.
let src = concat!(
"p1 = Point(0,0,0)\n",
"c1 = Circle(p1, 100)\n",
"ct1_0 = Point(1,1,1)\n",
"ct1 = Cut(c1, 4)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(err, Error::NameCollision("ct1_0".to_string()));
// Count gate.
let err =
eval_src("p1 = Point(0,0,0)\nc1 = Circle(p1, 100)\nct1 = Cut(c1, 2.5)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
// Keywords.
let err =
eval_src("p1 = Point(0,0,0)\nc1 = Circle(p1, 100)\nct1 = Cut(c1, n=4)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
// Unknown source.
let err = eval_src("ct1 = Cut(nope, 4)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
// ── Extrusion: profile by value, swept by a Vector ──────────────────────
const CYLINDER_SRC: &str = concat!(
"p1 = Point(0,0,0)\n",
"c1 = Circle(p1, 100)\n",
"ct1 = Cut(c1, 4)\n",
"v1 = Vector(0, 0, 300)\n",
"e1 = Extrusion(ct1, v1)\n",
);
#[test]
fn extrusion_from_cut_holds_profile_by_value() {
let store = eval_src(CYLINDER_SRC).expect("eval ok");
// p1, c1, ct1, 4 pieces, v1, e1.
assert_eq!(store.arena.len(), 9);
let e = store.resolve(Guid::from_name("wall.bimr", "e1")).unwrap();
assert_eq!(
e.refs,
vec![
Guid::from_name("wall.bimr", "ct1"),
Guid::from_name("wall.bimr", "v1"),
]
);
match &e.entity {
bimr::Entity::Extrusion(ex) => {
assert_eq!(ex.profile().len(), 4);
assert_eq!((ex.vec().x, ex.vec().y, ex.vec().z), (0.0, 0.0, 300.0));
}
other => panic!("expected Extrusion, got {}", other.type_name()),
}
}
#[test]
fn extrusion_meshes_without_caps() {
let store = eval_src(CYLINDER_SRC).expect("eval ok");
// 4-piece closed loop → 4 quads → 16 points, 24 indices; no caps.
match &store.arena[8].entity {
bimr::Entity::Extrusion(ex) => {
let m = ex.mesh().expect("extrusion meshes");
assert_eq!(m.points.len(), 16);
assert_eq!(m.indices.len(), 24);
}
other => panic!("expected Extrusion, got {}", other.type_name()),
}
// And it flows through the IFCX emitter as a mesh-bearing entity.
let doc: serde_json::Value =
serde_json::from_str(&crate::serialize::to_ifcx(&store)).unwrap();
let data = doc["data"].as_array().unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0]["path"], serde_json::json!("extrusion-001"));
let mesh = &data[0]["attributes"]["usd::usdgeom::mesh"];
assert_eq!(mesh["points"].as_array().unwrap().len(), 16);
assert_eq!(mesh["faceVertexIndices"].as_array().unwrap().len(), 24);
}
#[test]
fn extrusion_compact_form_round_trips() {
let store = eval_src(CYLINDER_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
concat!(
"p1 = Point(0, 0, 0)\n",
"c1 = Circle(p1, 100)\n",
"ct1 = Cut(c1, 4)\n",
"v1 = Vector(0, 0, 300)\n",
"e1 = Extrusion(ct1, v1)",
)
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn extrusion_over_a_list_of_lines() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lines = List(l1)\n",
"v1 = Vector(0, 0, 10)\n",
"e1 = Extrusion(lines, v1)\n",
);
let store = eval_src(src).expect("eval ok");
match &store.arena[5].entity {
bimr::Entity::Extrusion(ex) => {
assert_eq!(ex.profile().len(), 1);
let m = ex.mesh().expect("single piece meshes");
assert_eq!(m.points.len(), 4);
}
other => panic!("expected Extrusion, got {}", other.type_name()),
}
}
#[test]
fn extrusion_rejections() {
// Profile of the wrong kind.
let err = eval_src(
"p1 = Point(0,0,0)\nlst = List(p1)\nv1 = Vector(0,0,1)\ne1 = Extrusion(lst, v1)\n",
)
.unwrap_err();
assert_eq!(
err,
Error::ListKind {
name: "lst".to_string(),
expected: "Line",
got: "Point",
}
);
// Profile of the wrong nature.
let err = eval_src("p1 = Point(0,0,0)\nv1 = Vector(0,0,1)\ne1 = Extrusion(p1, v1)\n")
.unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "p1".to_string(),
expected: "List",
got: "Point",
}
);
// Sweep must be a Vector.
let err = eval_src("p1 = Point(0,0,0)\np2 = Point(1,0,0)\nl1 = Line(p1, p2)\nlst = List(l1)\ne1 = Extrusion(lst, p2)\n").unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "p2".to_string(),
expected: "Vector",
got: "Point",
}
);
// Arity.
let err = eval_src("v1 = Vector(0,0,1)\ne1 = Extrusion(v1)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
// Keywords.
let err = eval_src("v1 = Vector(0,0,1)\ne1 = Extrusion(src=v1, vec=v1)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
// Unknown names.
let err = eval_src("v1 = Vector(0,0,1)\ne1 = Extrusion(nope, v1)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
const COLUMN_SRC: &str = concat!("p1 = Point(0,0,0)\n", "c1 = Column(p1, 300, 30, 30)\n",);
#[test]
fn column_builds_from_resolved_base() {
let store = eval_src(COLUMN_SRC).expect("eval ok");
assert_eq!(store.arena.len(), 2);
let c = store.resolve(Guid::from_name("wall.bimr", "c1")).unwrap();
assert_eq!(c.refs, vec![Guid::from_name("wall.bimr", "p1")]);
match &c.entity {
bimr::Entity::Column(col) => {
assert_eq!(col.base.x, 0.0);
assert_eq!(col.height, 300.0);
assert_eq!(col.section_width, 30.0);
assert_eq!(col.section_height, 30.0);
}
other => panic!("expected Column, got {}", other.type_name()),
}
}
#[test]
fn column_compact_form_matches_reference_and_round_trips() {
// The reference to_bimr() emits this exact keyword form — the eval must
// accept what its own serializer produces, and the emission must be
// the fixed point.
let store = eval_src(COLUMN_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
"p1 = Point(0, 0, 0)\nc1 = Column(p1, height=300, section_width=30, section_height=30)"
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn column_base_must_be_a_point() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"c1 = Column(l1, 300, 30, 30)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "l1".to_string(),
expected: "Point",
got: "Line",
}
);
}
#[test]
fn column_arity_is_rejected() {
let err = eval_src("p1 = Point(0,0,0)\nc1 = Column(p1, 300, 30)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
}
#[test]
fn column_unknown_keyword_is_rejected() {
let err = eval_src(
"p1 = Point(0,0,0)\nc1 = Column(p1, h=300, section_width=30, section_height=30)\n",
)
.unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
}
#[test]
fn column_unknown_base_is_reported() {
let err = eval_src("c1 = Column(nope, 300, 30, 30)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
#[test]
fn column_ifcx_emits_column_mesh() {
let store = eval_src(COLUMN_SRC).expect("eval ok");
let doc: serde_json::Value =
serde_json::from_str(&crate::serialize::to_ifcx(&store)).unwrap();
let data = doc["data"].as_array().unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0]["path"], serde_json::json!("column-001"));
let mesh = &data[0]["attributes"]["usd::usdgeom::mesh"];
assert_eq!(mesh["points"].as_array().unwrap().len(), 24);
assert_eq!(mesh["faceVertexIndices"].as_array().unwrap().len(), 36);
}
// ── Lifting: a node over a List produces a List (replaces Each) ─────────
/// 2x2 grid — the small stand-in for the 4x4 gate corpus.
const LIFT_SRC: &str = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1000,0,0)\n",
"p3 = Point(0,1000,0)\n",
"p4 = Point(1000,1000,0)\n",
"pts = List(p1, p2, p3, p4)\n",
"cols = Column(pts, 300, 30, 30)\n",
);
#[test]
fn column_over_list_lifts_to_list_of_columns() {
let store = eval_src(LIFT_SRC).expect("eval ok");
assert_eq!(store.arena.len(), 6);
let cols = store.resolve(Guid::from_name("wall.bimr", "cols")).unwrap();
assert_eq!(cols.refs, vec![Guid::from_name("wall.bimr", "pts")]);
match &cols.entity {
bimr::Entity::List(list) => {
assert_eq!(list.kind(), "Column");
assert_eq!(list.length(), 4);
assert!(matches!(list.get(2), Some(bimr::Entity::Column(c)) if c.base.y == 1000.0));
}
other => panic!("expected List, got {}", other.type_name()),
}
}
#[test]
fn lifted_compact_form_round_trips() {
let store = eval_src(LIFT_SRC).expect("eval ok");
let src2 = crate::serialize::to_bimr(&store);
assert!(
src2.contains("cols = Column(pts, height=300, section_width=30, section_height=30)")
);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
// And the geometry survives: 4 columns in both stores.
for store in [&store, &store2] {
match &store.arena[5].entity {
bimr::Entity::List(list) => assert_eq!(list.length(), 4),
other => panic!("expected List, got {}", other.type_name()),
}
}
}
#[test]
fn lifted_ifcx_emits_one_mesh_per_item() {
let store = eval_src(LIFT_SRC).expect("eval ok");
let doc: serde_json::Value =
serde_json::from_str(&crate::serialize::to_ifcx(&store)).unwrap();
let data = doc["data"].as_array().unwrap();
assert_eq!(data.len(), 4);
let paths: Vec<_> = data.iter().map(|d| d["path"].as_str().unwrap()).collect();
assert_eq!(
paths,
["column-001", "column-002", "column-003", "column-004"]
);
}
#[test]
fn lifting_rejects_wrong_kind_list() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lines = List(l1)\n",
"cols = Column(lines, 300, 30, 30)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(
err,
Error::ListKind {
name: "lines".to_string(),
expected: "Point",
got: "Line",
}
);
}
#[test]
fn single_column_over_non_point_is_still_wrong_nature() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"c1 = Column(l1, 300, 30, 30)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "l1".to_string(),
expected: "Point",
got: "Line",
}
);
}
#[test]
fn float_arguments_work() {
let store = eval_src("p1 = Point(0.5, 1.25, -2.0)\n").expect("eval ok");
match &store.arena[0].entity {
bimr::Entity::Point(p) => assert_eq!((p.x, p.y, p.z), (0.5, 1.25, -2.0)),
other => panic!("expected Point, got {}", other.type_name()),
}
}
// ── Frame: a hollow rectangular solid between two named points ──────────
const FRAME_SRC: &str = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(100,0,0)\n",
"fr1 = Frame(p1, p2, 100, 300, 20, 10)\n",
);
#[test]
fn frame_builds_from_resolved_points() {
let store = eval_src(FRAME_SRC).expect("eval ok");
assert_eq!(store.arena.len(), 3);
let f = store.resolve(Guid::from_name("wall.bimr", "fr1")).unwrap();
assert_eq!(
f.refs,
vec![
Guid::from_name("wall.bimr", "p1"),
Guid::from_name("wall.bimr", "p2"),
]
);
match &f.entity {
bimr::Entity::Frame(fr) => {
assert_eq!((fr.origin.x, fr.origin.y, fr.origin.z), (0.0, 0.0, 0.0));
assert_eq!((fr.end.x, fr.end.y, fr.end.z), (100.0, 0.0, 0.0));
assert_eq!(
(fr.width, fr.height, fr.depth, fr.thickness),
(100.0, 300.0, 20.0, 10.0)
);
}
other => panic!("expected Frame, got {}", other.type_name()),
}
}
#[test]
fn frame_compact_form_matches_reference_and_round_trips() {
// The reference to_bimr() emits this exact keyword form — the eval must
// accept what its own serializer produces, and the emission must be
// the fixed point.
let store = eval_src(FRAME_SRC).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
"p1 = Point(0, 0, 0)\np2 = Point(100, 0, 0)\nfr1 = Frame(p1, p2, width=100, height=300, depth=20, thickness=10)"
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn frame_ifcx_emits_frame_mesh() {
let store = eval_src(FRAME_SRC).expect("eval ok");
let doc: serde_json::Value =
serde_json::from_str(&crate::serialize::to_ifcx(&store)).unwrap();
let data = doc["data"].as_array().unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0]["path"], serde_json::json!("frame-001"));
let mesh = &data[0]["attributes"]["usd::usdgeom::mesh"];
assert_eq!(mesh["points"].as_array().unwrap().len(), 64);
assert_eq!(mesh["faceVertexIndices"].as_array().unwrap().len(), 96);
}
#[test]
fn frame_rejections() {
// Origin must be a Point.
let err = eval_src("p1 = Point(0,0,0)\np2 = Point(1,0,0)\nl1 = Line(p1, p2)\nfr1 = Frame(l1, p1, 1, 2, 3, 4)\n").unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "l1".to_string(),
expected: "Point",
got: "Line",
}
);
// Arity.
let err = eval_src("p1 = Point(0,0,0)\nfr1 = Frame(p1, p1, 1, 2, 3)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
// Unknown keyword.
let err =
eval_src("p1 = Point(0,0,0)\nfr1 = Frame(p1, p1, w=1, h=2, d=3, t=4)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
// Unknown name.
let err = eval_src("fr1 = Frame(nope, p1, 1, 2, 3, 4)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
// ── Explode + Frame lift: the ring of frames, no generated names ────────
#[test]
fn explode_builds_record_and_pair_parts() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lines = List(l1)\n",
"ex1 = Explode(lines)\n",
);
let store = eval_src(src).expect("eval ok");
// p1, p2, l1, lines, ex1, 1 pair part.
assert_eq!(store.arena.len(), 6);
let record = Guid::from_name("wall.bimr", "ex1");
let ex = store.resolve(record).unwrap();
assert_eq!(ex.refs, vec![Guid::from_name("wall.bimr", "lines")]);
assert!(matches!(ex.entity, bimr::Entity::Explode(_)));
// The created part: one pair (a List of two Points), edged to the
// record, not a statement.
let items = store.outputs_of(record);
assert_eq!(items.len(), 1);
let pair = store.resolve(items[0]).unwrap();
assert_eq!(store.name_of(items[0]), Some("ex1_0"));
assert_eq!(pair.refs, vec![record]);
match &pair.entity {
bimr::Entity::List(list) => {
assert_eq!(list.kind(), "Point");
assert_eq!(list.length(), 2);
}
other => panic!("expected a pair List, got {}", other.type_name()),
}
// The record IS a statement (p1, p2, l1, lines, ex1); its part is not.
assert_eq!(store.statements.len(), 5);
assert!(!store.statements.contains(&items[0]));
}
#[test]
fn explode_compact_form_round_trips() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lines = List(l1)\n",
"ex1 = Explode(lines)\n",
);
let store = eval_src(src).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
"p1 = Point(0, 0, 0)\np2 = Point(1, 0, 0)\nl1 = Line(p1, p2)\nlines = List(l1)\nex1 = Explode(lines)"
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn explode_rejections() {
// Exploding a Point is not supported.
let err = eval_src("p1 = Point(0,0,0)\nex1 = Explode(p1)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("explode")));
// Arity.
let err = eval_src(
"p1 = Point(0,0,0)\np2 = Point(1,0,0)\nl1 = Line(p1, p2)\nex1 = Explode(l1, l1)\n",
)
.unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
// Collision.
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"ex1_0 = Point(2,2,2)\n",
"ex1 = Explode(l1)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(err, Error::NameCollision("ex1_0".to_string()));
// Unknown name.
let err = eval_src("ex1 = Explode(nope)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
#[test]
fn frame_lifts_over_an_explode_record() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lines = List(l1)\n",
"ex1 = Explode(lines)\n",
"frs = Frame(ex1, 100, 300, 20, 10)\n",
);
let store = eval_src(src).expect("eval ok");
// p1, p2, l1, lines, ex1, 1 pair, frs.
assert_eq!(store.arena.len(), 7);
let frs = store.resolve(Guid::from_name("wall.bimr", "frs")).unwrap();
assert_eq!(frs.refs, vec![Guid::from_name("wall.bimr", "ex1")]);
match &frs.entity {
bimr::Entity::List(list) => {
assert_eq!(list.kind(), "Frame");
assert_eq!(list.length(), 1);
assert!(matches!(
list.get(0),
Some(bimr::Entity::Frame(f)) if f.origin.x == 0.0 && f.end.x == 1.0
));
}
other => panic!("expected List, got {}", other.type_name()),
}
// The lifted statement round-trips through the pair-source form.
let src2 = crate::serialize::to_bimr(&store);
assert!(src2.contains("frs = Frame(ex1, width=100, height=300, depth=20, thickness=10)"));
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn frame_lift_rejections() {
// A flat List of Points is not pairs.
let err = eval_src("p1 = Point(0,0,0)\nlst = List(p1)\nfrs = Frame(lst, 1, 2, 3, 4)\n")
.unwrap_err();
assert_eq!(
err,
Error::ListKind {
name: "lst".to_string(),
expected: "List",
got: "Point",
}
);
// A wrong-nature source.
let err = eval_src("p1 = Point(0,0,0)\nfrs = Frame(p1, 1, 2, 3, 4)\n").unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "p1".to_string(),
expected: "List",
got: "Point",
}
);
}
// ── Curve: a closed spline — Divide and Cut work like the Circle ────────
/// Peanut control points: two lobes, a waisted middle.
const PEANUT_CTRL: &str = "1600,0|600,500|0,250|-600,500|-1600,0|-600,-500|0,-250|600,-500";
fn peanut_curve_src() -> String {
let mut src = String::from("p1 = Point(0,0,0)\n");
for (i, pair) in PEANUT_CTRL.split('|').enumerate() {
let (x, y) = pair.split_once(',').unwrap();
src.push_str(&format!("c{i} = Point({x}, {y}, 0)\n"));
}
src.push_str("ctrl = List(c0, c1, c2, c3, c4, c5, c6, c7)\ncrv = Curve(ctrl)\n");
src
}
#[test]
fn curve_builds_from_a_list_of_points() {
let src = peanut_curve_src();
let store = eval_src(&src).expect("eval ok");
// p1 + 8 controls + ctrl + crv.
assert_eq!(store.arena.len(), 11);
let crv = store.resolve(Guid::from_name("wall.bimr", "crv")).unwrap();
assert_eq!(crv.refs, vec![Guid::from_name("wall.bimr", "ctrl")]);
match &crv.entity {
bimr::Entity::Curve(curve) => {
assert_eq!(curve.spline().control_points().len(), 8);
// The spline keeps the control coordinates.
assert_eq!(
(
curve.spline().control_points()[0].x,
curve.spline().control_points()[0].y
),
(1600.0, 0.0)
);
}
other => panic!("expected Curve, got {}", other.type_name()),
}
}
#[test]
fn divide_over_a_curve_creates_sampled_points() {
let mut src = peanut_curve_src();
src.push_str("d1 = Divide(crv, 8)\n");
let store = eval_src(&src).expect("eval ok");
// p1 + 8 controls + ctrl + crv + d1 + 8 items.
assert_eq!(store.arena.len(), 20);
// n == control count: the samples are the control points, so the
// first created item sits at the first control.
let first = store
.resolve(store.outputs_of(Guid::from_name("wall.bimr", "d1"))[0])
.unwrap();
match &first.entity {
bimr::Entity::Point(p) => assert_eq!((p.x, p.y), (1600.0, 0.0)),
other => panic!("expected Point, got {}", other.type_name()),
}
}
#[test]
fn cut_over_a_curve_creates_a_closed_loop() {
let mut src = peanut_curve_src();
src.push_str("ct1 = Cut(crv, 8)\n");
let store = eval_src(&src).expect("eval ok");
let record = Guid::from_name("wall.bimr", "ct1");
let items = store.outputs_of(record);
assert_eq!(items.len(), 8);
let piece = |i: usize| match &store.resolve(items[i]).unwrap().entity {
bimr::Entity::Line(l) => *l,
other => panic!("expected Line, got {}", other.type_name()),
};
for i in 0..7 {
assert_eq!(
(piece(i).end.x, piece(i).end.y),
(piece(i + 1).start.x, piece(i + 1).start.y)
);
}
// The last piece closes to the first sample.
let first = piece(0).start;
assert_eq!((piece(7).end.x, piece(7).end.y), (first.x, first.y));
}
#[test]
fn the_peanut_pipeline_extrudes() {
let mut src = peanut_curve_src();
src.push_str("ct1 = Cut(crv, 24)\nv1 = Vector(0, 0, 3000)\npnt = Extrusion(ct1, v1)\n");
let store = eval_src(&src).expect("eval ok");
match &store.arena[store.arena.len() - 1].entity {
bimr::Entity::Extrusion(ex) => {
assert_eq!(ex.profile().len(), 24);
let m = ex.mesh().expect("peanut meshes");
// 24-piece closed loop → 24 quads.
assert_eq!(m.points.len(), 96);
assert_eq!(m.indices.len(), 144);
}
other => panic!("expected Extrusion, got {}", other.type_name()),
}
// The compact form keeps the parametric chain and round-trips.
let src2 = crate::serialize::to_bimr(&store);
assert!(src2.contains("crv = Curve(ctrl)"));
assert!(src2.contains("pnt = Extrusion(ct1, v1)"));
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn curve_rejections() {
// A List of Lines is not control points.
let err = eval_src("p1 = Point(0,0,0)\np2 = Point(1,0,0)\nl1 = Line(p1, p2)\nlst = List(l1)\ncrv = Curve(lst)\n").unwrap_err();
assert_eq!(
err,
Error::ListKind {
name: "lst".to_string(),
expected: "Point",
got: "Line",
}
);
// Fewer than three controls.
let err = eval_src(
"p1 = Point(0,0,0)\np2 = Point(1,0,0)\nlst = List(p1, p2)\ncrv = Curve(lst)\n",
)
.unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
// Keywords.
let err = eval_src("p1 = Point(0,0,0)\np2 = Point(1,0,0)\np3 = Point(2,0,0)\nlst = List(p1, p2, p3)\ncrv = Curve(pts=lst)\n").unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
// Unknown name.
let err = eval_src("crv = Curve(nope)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
// ── Random: a seeded displacement — different level shapes ──────────────
#[test]
fn random_builds_record_and_displaced_points() {
let src = concat!(
"p1 = Point(0,0,500)\n",
"p2 = Point(1,0,500)\n",
"base = List(p1, p2)\n",
"r1 = Random(base, seed=42, min=-150, max=150, dims=2)\n",
);
let store = eval_src(src).expect("eval ok");
// p1, p2, base, r1, 2 displaced points.
assert_eq!(store.arena.len(), 6);
let record = Guid::from_name("wall.bimr", "r1");
let r = store.resolve(record).unwrap();
assert_eq!(r.refs, vec![Guid::from_name("wall.bimr", "base")]);
assert!(matches!(r.entity, bimr::Entity::Random(_)));
// The displaced points: named, edged to the record, z preserved.
let items = store.outputs_of(record);
assert_eq!(items.len(), 2);
for (i, &guid) in items.iter().enumerate() {
let node = store.resolve(guid).unwrap();
assert_eq!(store.name_of(guid), Some(format!("r1_{i}").as_str()));
assert_eq!(node.refs, vec![record]);
match &node.entity {
bimr::Entity::Point(p) => {
assert_eq!(p.z, 500.0);
assert!((-150.0..=150.0).contains(&p.x) || (p.x - 1.0).abs() <= 150.0);
}
other => panic!("expected Point, got {}", other.type_name()),
}
}
assert_eq!(store.statements.len(), 4);
}
#[test]
fn random_is_deterministic_and_seed_sensitive() {
let src = |seed: u64| {
format!(
concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"base = List(p1, p2)\n",
"r1 = Random(base, seed={seed}, min=-150, max=150, dims=2)\n",
),
seed = seed
)
};
let a = eval_src(&src(42)).expect("eval ok");
let b = eval_src(&src(42)).expect("eval ok");
let c = eval_src(&src(43)).expect("eval ok");
let point = |store: &crate::store::Store, guid| match &store.resolve(guid).unwrap().entity {
bimr::Entity::Point(p) => (p.x, p.y),
other => panic!("expected Point, got {}", other.type_name()),
};
let a0 = point(&a, a.outputs_of(Guid::from_name("wall.bimr", "r1"))[0]);
let b0 = point(&b, b.outputs_of(Guid::from_name("wall.bimr", "r1"))[0]);
let c0 = point(&c, c.outputs_of(Guid::from_name("wall.bimr", "r1"))[0]);
assert_eq!(a0, b0, "same seed must rebuild the same shape");
assert_ne!(a0, c0, "different seeds must differ");
}
#[test]
fn random_compact_form_round_trips() {
let src = concat!(
"p1 = Point(0,0,500)\n",
"p2 = Point(1,0,500)\n",
"base = List(p1, p2)\n",
"r1 = Random(base, seed=42, min=-150, max=150, dims=2)\n",
);
let store = eval_src(src).expect("eval ok");
assert_eq!(
crate::serialize::to_bimr(&store),
concat!(
"p1 = Point(0, 0, 500)\n",
"p2 = Point(1, 0, 500)\n",
"base = List(p1, p2)\n",
"r1 = Random(base, seed=42, min=-150, max=150, dims=2)",
)
);
let src2 = crate::serialize::to_bimr(&store);
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn curve_over_random_takes_the_displaced_points() {
let src = concat!(
"p1 = Point(1500,0,0)\n",
"p2 = Point(0,1000,0)\n",
"p3 = Point(-1500,0,0)\n",
"p4 = Point(0,-1000,0)\n",
"base = List(p1, p2, p3, p4)\n",
"r1 = Random(base, seed=7, min=-50, max=50, dims=2)\n",
"crv = Curve(r1)\n",
);
let store = eval_src(src).expect("eval ok");
match &store.arena[store.arena.len() - 1].entity {
bimr::Entity::Curve(curve) => {
assert_eq!(curve.spline().control_points().len(), 4);
}
other => panic!("expected Curve, got {}", other.type_name()),
}
}
#[test]
fn random_rejections() {
// Source must be a List of Points.
let err =
eval_src("p1 = Point(0,0,0)\nr1 = Random(p1, seed=1, min=0, max=1)\n").unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "p1".to_string(),
expected: "List",
got: "Point",
}
);
// Missing keywords.
let err = eval_src("p1 = Point(0,0,0)\nlst = List(p1)\nr1 = Random(lst, seed=1, min=0)\n")
.unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
// Unknown keyword.
let err = eval_src(
"p1 = Point(0,0,0)\nlst = List(p1)\nr1 = Random(lst, seed=1, min=0, max=1, count=2)\n",
)
.unwrap_err();
assert!(matches!(err, Error::Unsupported("keyword argument")));
// Collision.
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"base = List(p1, p2)\n",
"r1_0 = Point(2,2,2)\n",
"r1 = Random(base, seed=1, min=0, max=1)\n",
);
let err = eval_src(src).unwrap_err();
assert_eq!(err, Error::NameCollision("r1_0".to_string()));
// Unknown name.
let err = eval_src("r1 = Random(nope, seed=1, min=0, max=1)\n").unwrap_err();
assert_eq!(err, Error::UnknownName("nope".to_string()));
}
// ── Slab, Storey, Building — the building containers ─────────────────────
fn building_ctrl_src() -> String {
let base = [
(-1500.0, -300.0),
(0.0, 1000.0),
(850.0, 800.0),
(300.0, 0.0),
(500.0, -800.0),
(0.0, -1000.0),
(-500.0, -800.0),
(-300.0, 0.0),
];
let mut src = String::new();
for (i, (x, y)) in base.iter().enumerate() {
src.push_str(&format!("c{i} = Point({x}, {y}, 0)\n"));
}
src.push_str("ctrl = List(c0, c1, c2, c3, c4, c5, c6, c7)\ncrv = Curve(ctrl)\n");
src
}
#[test]
fn slab_builds_from_a_curve_profile() {
let src = building_ctrl_src() + "s1 = Slab(crv, 150, 0)\n";
let store = eval_src(&src).expect("eval ok");
// p-less: 8 controls + ctrl + crv + s1.
assert_eq!(store.arena.len(), 11);
let s = store.resolve(Guid::from_name("wall.bimr", "s1")).unwrap();
assert_eq!(s.refs, vec![Guid::from_name("wall.bimr", "crv")]);
match &s.entity {
bimr::Entity::Slab(slab) => {
assert_eq!(slab.thickness, 150.0);
assert_eq!(slab.elevation, 0.0);
// The curve sampled at 8 points per span (8 controls).
assert_eq!(slab.profile.points.len(), 64);
}
other => panic!("expected Slab, got {}", other.type_name()),
}
// The slab is mesh-bearing — a prism with caps.
assert!(s.entity.mesh().is_some());
}
#[test]
fn slab_compact_form_round_trips() {
let src = building_ctrl_src() + "s1 = Slab(crv, 150, 0)\n";
let store = eval_src(&src).expect("eval ok");
let src2 = crate::serialize::to_bimr(&store);
assert!(src2.contains("s1 = Slab(crv, 150, 0)"));
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn storey_gathers_a_lifted_result() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lines = List(l1)\n",
"ex1 = Explode(lines)\n",
"frs = Frame(ex1, 100, 300, 20, 10)\n",
"st = Storey(3000, frs)\n",
);
let store = eval_src(src).expect("eval ok");
let st = store.resolve(Guid::from_name("wall.bimr", "st")).unwrap();
assert_eq!(st.refs, vec![Guid::from_name("wall.bimr", "frs")]);
match &st.entity {
bimr::Entity::Storey(storey) => {
assert_eq!(storey.elevation(), 3000.0);
assert_eq!(storey.entities().len(), 1);
assert!(matches!(storey.entities()[0], bimr::Entity::Frame(_)));
}
other => panic!("expected Storey, got {}", other.type_name()),
}
// Containers are not mesh-bearing — no IFCX duplication.
assert!(st.entity.mesh().is_none());
// The compact form keeps the container statement.
assert!(crate::serialize::to_bimr(&store).contains("st = Storey(3000, frs)"));
}
#[test]
fn building_gathers_storeys() {
let src = concat!(
"p1 = Point(0,0,0)\n",
"p2 = Point(1,0,0)\n",
"l1 = Line(p1, p2)\n",
"lines = List(l1)\n",
"ex1 = Explode(lines)\n",
"frs = Frame(ex1, 100, 300, 20, 10)\n",
"st0 = Storey(0, frs)\n",
"st1 = Storey(3000, frs)\n",
"sts = List(st0, st1)\n",
"bld = Building(\"b03\", sts)\n",
);
let store = eval_src(src).expect("eval ok");
let bld = store.resolve(Guid::from_name("wall.bimr", "bld")).unwrap();
assert_eq!(bld.refs, vec![Guid::from_name("wall.bimr", "sts")]);
match &bld.entity {
bimr::Entity::Building(b) => {
assert_eq!(b.name(), "b03");
assert_eq!(b.storeys().len(), 2);
assert!(matches!(b.storeys()[0], bimr::Entity::Storey(_)));
}
other => panic!("expected Building, got {}", other.type_name()),
}
let src2 = crate::serialize::to_bimr(&store);
assert!(src2.contains("bld = Building(\"b03\", sts)"));
let program2 = parser::parse(&src2).expect("round-trip parses");
let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals");
assert_eq!(crate::serialize::to_bimr(&store2), src2);
}
#[test]
fn container_rejections() {
// Storey needs a List.
let err = eval_src("p1 = Point(0,0,0)\nst = Storey(0, p1)\n").unwrap_err();
assert_eq!(
err,
Error::WrongNature {
name: "p1".to_string(),
expected: "List",
got: "Point",
}
);
// Building needs a List of Storeys.
let err = eval_src("p1 = Point(0,0,0)\nlst = List(p1)\nbld = Building(\"b\", lst)\n")
.unwrap_err();
assert_eq!(
err,
Error::ListKind {
name: "lst".to_string(),
expected: "Storey",
got: "Point",
}
);
// Slab arity.
let err = eval_src(
"p1 = Point(0,0,0)\np2 = Point(1,0,0)\nlst = List(p1, p2)\ns1 = Slab(lst, 150)\n",
)
.unwrap_err();
assert!(matches!(err, Error::Unsupported("arity")));
}
}