// 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 { 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 = `. 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 { 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!["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 _, 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 _, 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"))); } }