From 61aea6df20b411ad5bbcdc929a65ad8e197a0753 Mon Sep 17 00:00:00 2001 From: rvba Date: Mon, 31 Aug 2026 11:16:41 +0200 Subject: [PATCH] release: 0.0.1 --- .gitignore | 3 + Cargo.toml | 6 + LICENSE | 21 + Makefile | 41 + README.md | 3 + cli/Cargo.toml | 13 + cli/src/main.rs | 70 + cli/tests/build.rs | 81 + eval/Cargo.toml | 9 + eval/src/error.rs | 53 + eval/src/eval.rs | 1635 +++++++++++++++++ eval/src/guid.rs | 74 + eval/src/lib.rs | 24 + .../nodes/element/architecture/building.rs | 91 + eval/src/nodes/element/architecture/column.rs | 159 ++ eval/src/nodes/element/architecture/frame.rs | 200 ++ eval/src/nodes/element/architecture/mod.rs | 11 + eval/src/nodes/element/architecture/slab.rs | 128 ++ eval/src/nodes/element/architecture/storey.rs | 78 + eval/src/nodes/element/architecture/wall.rs | 85 + eval/src/nodes/element/mod.rs | 6 + eval/src/nodes/geometry/circle.rs | 57 + eval/src/nodes/geometry/curve.rs | 102 + eval/src/nodes/geometry/extrusion.rs | 108 ++ eval/src/nodes/geometry/line.rs | 62 + eval/src/nodes/geometry/mod.rs | 11 + eval/src/nodes/geometry/point.rs | 48 + eval/src/nodes/geometry/vector.rs | 52 + eval/src/nodes/mod.rs | 194 ++ eval/src/nodes/operator/cut.rs | 89 + eval/src/nodes/operator/divide.rs | 93 + eval/src/nodes/operator/explode.rs | 88 + eval/src/nodes/operator/mod.rs | 9 + eval/src/nodes/operator/random.rs | 124 ++ eval/src/nodes/set/list.rs | 68 + eval/src/nodes/set/mod.rs | 6 + eval/src/serialize.rs | 179 ++ eval/src/store.rs | 130 ++ eval/src/value.rs | 21 + grammar/README.md | 21 + grammar/bimr.bnf | 5 + lexer/Cargo.toml | 12 + lexer/README.md | 1 + lexer/src/lexer.rs | 191 ++ lexer/src/lib.rs | 6 + lexer/src/main.rs | 130 ++ parser/Cargo.toml | 17 + parser/src/ast.rs | 60 + parser/src/lib.rs | 7 + parser/src/main.rs | 28 + parser/src/parser.rs | 298 +++ samples/bimr/column_grid.bimr | 18 + samples/bimr/wall.bimr | 4 + samples/bimr/wall.ifcx | 181 ++ samples/bimr/wall_v2.bimr | 8 + samples/python/building.py | 30 + samples/python/cylinders.py | 12 + samples/python/grid.py | 5 + 58 files changed, 5266 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cli/Cargo.toml create mode 100644 cli/src/main.rs create mode 100644 cli/tests/build.rs create mode 100644 eval/Cargo.toml create mode 100644 eval/src/error.rs create mode 100644 eval/src/eval.rs create mode 100644 eval/src/guid.rs create mode 100644 eval/src/lib.rs create mode 100644 eval/src/nodes/element/architecture/building.rs create mode 100644 eval/src/nodes/element/architecture/column.rs create mode 100644 eval/src/nodes/element/architecture/frame.rs create mode 100644 eval/src/nodes/element/architecture/mod.rs create mode 100644 eval/src/nodes/element/architecture/slab.rs create mode 100644 eval/src/nodes/element/architecture/storey.rs create mode 100644 eval/src/nodes/element/architecture/wall.rs create mode 100644 eval/src/nodes/element/mod.rs create mode 100644 eval/src/nodes/geometry/circle.rs create mode 100644 eval/src/nodes/geometry/curve.rs create mode 100644 eval/src/nodes/geometry/extrusion.rs create mode 100644 eval/src/nodes/geometry/line.rs create mode 100644 eval/src/nodes/geometry/mod.rs create mode 100644 eval/src/nodes/geometry/point.rs create mode 100644 eval/src/nodes/geometry/vector.rs create mode 100644 eval/src/nodes/mod.rs create mode 100644 eval/src/nodes/operator/cut.rs create mode 100644 eval/src/nodes/operator/divide.rs create mode 100644 eval/src/nodes/operator/explode.rs create mode 100644 eval/src/nodes/operator/mod.rs create mode 100644 eval/src/nodes/operator/random.rs create mode 100644 eval/src/nodes/set/list.rs create mode 100644 eval/src/nodes/set/mod.rs create mode 100644 eval/src/serialize.rs create mode 100644 eval/src/store.rs create mode 100644 eval/src/value.rs create mode 100644 grammar/README.md create mode 100644 grammar/bimr.bnf create mode 100644 lexer/Cargo.toml create mode 100644 lexer/README.md create mode 100644 lexer/src/lexer.rs create mode 100644 lexer/src/lib.rs create mode 100644 lexer/src/main.rs create mode 100644 parser/Cargo.toml create mode 100644 parser/src/ast.rs create mode 100644 parser/src/lib.rs create mode 100644 parser/src/main.rs create mode 100644 parser/src/parser.rs create mode 100644 samples/bimr/column_grid.bimr create mode 100644 samples/bimr/wall.bimr create mode 100644 samples/bimr/wall.ifcx create mode 100644 samples/bimr/wall_v2.bimr create mode 100644 samples/python/building.py create mode 100644 samples/python/cylinders.py create mode 100644 samples/python/grid.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee83ca1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +.claude/ +.opencode/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..be2146e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["lexer", "parser", "cli", "eval"] +resolver = "3" + +[patch."ssh://git@git.rvba.fr:229/rvba/bimr-kernel.git"] +bimr-kernel = { path = "../bimr-kernel" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9e1af9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Milovann Yanatchkov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a5ee9b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# bimr-engine — test targets. +# +# The fork-A eval is the experiment; the copied core (lexer, grammar, +# parser) is the frozen harness. `make test` runs everything. + +.PHONY: test test-eval test-cli test-core clippy fmt check build run-sample + +test: test-core test-eval test-cli + +## Core — the copied harness, byte-identical to master +test-core: + cargo test -p lexer -p parser + +## The experiment — eval unit tests +test-eval: + cargo test -p eval + +## End-to-end — the cli gates on the wall corpus +test-cli: + cargo test -p cli + +clippy: + cargo clippy --all-targets + +fmt: + cargo fmt + +fmt-check: + cargo fmt --check + +check: + cargo fmt --check && cargo clippy --all-targets && cargo test + +build: + cargo build + +## Replay the reference sample through the cli (output to /tmp — never +## write back into the copied corpus) +run-sample: + cargo run --quiet --bin bimr-cli -- build samples/bimr/wall.bimr --output /tmp/opencode/wall.ifcx + @cat /tmp/opencode/wall.ifcx diff --git a/README.md b/README.md new file mode 100644 index 0000000..038af08 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# bimr-engine + +Parse and eval a ``.bimr`` file to generate a BIM model. diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..8ccda39 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cli" +version = "0.0.1" +edition = "2024" + +[[bin]] +name = "bimr-cli" +path = "src/main.rs" + +[dependencies] +parser = { path = "../parser" } +eval = { path = "../eval" } +clap = { version = "4", features = ["derive"] } diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..cd84c2d --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +use clap::{Parser, Subcommand}; +use std::fs; +use std::path::PathBuf; +use std::process; + +#[derive(Parser)] +#[clap(name = "bimr", version, about = "BIMR build tool")] +struct Cli { + #[clap(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Compile a .bimr file to IFCX + Build { + /// Input .bimr source file + input: PathBuf, + + /// Output path (default: .ifcx) + #[clap(short, long)] + output: Option, + }, +} + +/// Read a file to string. +fn read_file(path: &PathBuf) -> Result { + fs::read_to_string(path).map_err(|e| format!("Error reading {:?}: {e}", path)) +} + +/// Parse and evaluate a `.bimr` file, returning the entity store. +pub fn compile(input: &PathBuf) -> Result { + let src = read_file(input)?; + let program = parser::parse(&src).map_err(|errors| { + let msgs: Vec = errors.iter().map(|e| format!("{e:?}")).collect(); + format!("Parse errors:\n{}", msgs.join("\n")) + })?; + let file = input + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "model.bimr".to_string()); + let store = eval::eval_program(&program, &file).map_err(|e| format!("Eval error: {e}"))?; + Ok(store) +} + +fn main() { + let cli = Cli::parse(); + + if let Err(e) = run(cli.command) { + eprintln!("{e}"); + process::exit(1); + } +} + +fn run(command: Command) -> Result<(), String> { + match command { + Command::Build { input, output } => { + let store = compile(&input)?; + let (content, ext) = eval::to_output(&store); + let output_path = output.unwrap_or_else(|| input.with_extension(ext)); + fs::write(&output_path, &content) + .map_err(|e| format!("Error writing {:?}: {e}", output_path))?; + eprintln!("→ {:?}", output_path); + } + } + Ok(()) +} diff --git a/cli/tests/build.rs b/cli/tests/build.rs new file mode 100644 index 0000000..ab81462 --- /dev/null +++ b/cli/tests/build.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! cli end-to-end (plan 3, S5): `bimr-cli build` on the wall corpus +//! produces the compact BIMR source — master's format, with the +//! documented self-reference fix (`Wall(l1, …)` not `Wall(w1, …)`). + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +/// Master's checked-in ifcx artifact for the corpus — the byte-parity target. +const EXPECTED_IFCX: &str = include_str!("../../samples/bimr/wall.ifcx"); + +/// Per-user, cargo-managed temp dir — never the shared /tmp (sticky-bit +/// collisions across users, see the PermissionDenied incident). +fn tmp(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name) +} + +fn run_build(sample: &str, output: &PathBuf) -> Result { + let root = env!("CARGO_MANIFEST_DIR"); + let input = PathBuf::from(root).join("../samples/bimr").join(sample); + let out = Command::new(env!("CARGO_BIN_EXE_bimr-cli")) + .args([ + "build", + input.to_str().expect("utf8 path"), + "--output", + output.to_str().expect("utf8 path"), + ]) + .output() + .map_err(|e| format!("spawn: {e}"))?; + if !out.status.success() { + return Err(format!( + "bimr-cli failed: {}", + String::from_utf8_lossy(&out.stderr) + )); + } + fs::read_to_string(output).map_err(|e| format!("read output: {e}")) +} + +#[test] +fn build_wall_bimr_emits_master_ifcx() { + let content = run_build("wall.bimr", &tmp("wall_out.ifcx")).expect("build ok"); + assert_eq!(content, EXPECTED_IFCX); +} + +#[test] +fn build_rejects_unknown_constructors_cleanly() { + let sample = tmp("reject.bimr"); + fs::write(&sample, "x = Frob(p1)\n").expect("write sample"); + let out = Command::new(env!("CARGO_BIN_EXE_bimr-cli")) + .args(["build", sample.to_str().expect("utf8")]) + .output() + .expect("spawn"); + assert!(!out.status.success(), "List must be rejected"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not supported"), + "rejection must name the failure: {stderr}" + ); +} + +#[test] +fn build_output_defaults_to_ifcx_extension() { + // The cli default is .ifcx (master parity) — run on a copy in the + // target tmpdir so the copied corpus is never rewritten. + let input = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../samples/bimr/wall.bimr"); + let copy = tmp("wall_copy.bimr"); + fs::copy(&input, ©).expect("copy"); + let out = Command::new(env!("CARGO_BIN_EXE_bimr-cli")) + .args(["build", copy.to_str().expect("utf8")]) + .output() + .expect("spawn"); + assert!(out.status.success()); + let default_out = copy.with_extension("ifcx"); + assert_eq!( + fs::read_to_string(&default_out).expect("read"), + EXPECTED_IFCX + ); +} diff --git a/eval/Cargo.toml b/eval/Cargo.toml new file mode 100644 index 0000000..4aa1930 --- /dev/null +++ b/eval/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "eval" +version = "0.0.1" +edition = "2024" + +[dependencies] +bimr = { package = "bimr-kernel", git = "ssh://git@git.rvba.fr:229/rvba/bimr-kernel.git" } +parser = { path = "../parser" } +serde_json = "1" diff --git a/eval/src/error.rs b/eval/src/error.rs new file mode 100644 index 0000000..4941adf --- /dev/null +++ b/eval/src/error.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! One error type for the whole eval. Variants grow with the supported +//! subset — never with machinery. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + /// A construct outside the supported subset — an unknown constructor. + Unsupported(&'static str), + /// A named reference that does not resolve to a stored node. + UnknownName(String), + /// An argument resolved to the wrong entity nature. + WrongNature { + name: String, + expected: &'static str, + got: &'static str, + }, + /// A List argument carries the wrong element kind. + ListKind { + name: String, + expected: &'static str, + got: &'static str, + }, + /// A name the engine needs to create is already taken. + NameCollision(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Unsupported(what) => write!(f, "not supported: {what}"), + Error::UnknownName(name) => write!(f, "unknown name `{name}`"), + Error::WrongNature { + name, + expected, + got, + } => write!(f, "`{name}` is a {got}, expected a {expected}"), + Error::ListKind { + name, + expected, + got, + } => write!(f, "`{name}` holds {got}, expected a list of {expected}"), + Error::NameCollision(name) => { + write!(f, "`{name}` is already taken") + } + } + } +} + +impl std::error::Error for Error {} diff --git a/eval/src/eval.rs b/eval/src/eval.rs new file mode 100644 index 0000000..9216359 --- /dev/null +++ b/eval/src/eval.rs @@ -0,0 +1,1635 @@ +// 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"))); + } +} diff --git a/eval/src/guid.rs b/eval/src/guid.rs new file mode 100644 index 0000000..3c1157b --- /dev/null +++ b/eval/src/guid.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Identity — the engine-side `Guid`: name-derived deterministic GUIDs. +//! Graph identity lives here, in the engine; the kernel stays untouched. +//! +//! Settled scheme ([`engine_entity.md`](https://git.rvba.fr/rvba/bimr-factory)): +//! every GUID derives from its DSL origin. The hash is FNV-1a 64 — simple, +//! stable across runs and platforms (std's `DefaultHasher` is neither). + +use std::fmt; + +/// Entity identity — 64-bit, `Copy`, cheap to store on every entity struct. +/// +/// `Guid::from_name(file, name)` — deterministic, stable across re-runs and +/// edits; a rename is a new identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Guid(u64); + +impl Guid { + /// Deterministic GUID from the DSL origin: `stable_hash(file, name)`. + pub fn from_name(file: &str, name: &str) -> Guid { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis + for b in file + .bytes() + .chain(std::iter::once(0x1f)) + .chain(name.bytes()) + { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV prime + } + Guid(h) + } +} + +impl fmt::Display for Guid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:016x}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_and_stable() { + let a = Guid::from_name("wall.py", "p1"); + let b = Guid::from_name("wall.py", "p1"); + assert_eq!(a, b); + } + + #[test] + fn different_file_same_name() { + assert_ne!( + Guid::from_name("floor_01.py", "p1"), + Guid::from_name("floor_02.py", "p1") + ); + } + + #[test] + fn different_name_same_file() { + assert_ne!( + Guid::from_name("wall.py", "p1"), + Guid::from_name("wall.py", "p2") + ); + } + + #[test] + fn display() { + let g = Guid::from_name("wall.py", "p1"); + assert_eq!(g.to_string().len(), 16); + } +} diff --git a/eval/src/lib.rs b/eval/src/lib.rs new file mode 100644 index 0000000..7f1a805 --- /dev/null +++ b/eval/src/lib.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! The BIMR evaluation engine. +//! +//! Whole-source evaluation of a parsed `.bimr` program: one `Node` shape +//! `{ guid, entity, refs }`, GUID identity, two passes (structure, then +//! geometry), no operator machinery. Entities are kernel types built at a +//! single source; meshes are an on-demand per-node view. + +mod error; +mod eval; +pub mod guid; +mod nodes; +mod serialize; +mod store; +mod value; + +pub use error::Error; +pub use eval::eval_program; +pub use guid::Guid; +pub use serialize::{to_bimr, to_ifcx, to_output}; +pub use store::Store; +pub use value::Node; diff --git a/eval/src/nodes/element/architecture/building.rs b/eval/src/nodes/element/architecture/building.rs new file mode 100644 index 0000000..d0c0dc8 --- /dev/null +++ b/eval/src/nodes/element/architecture/building.rs @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Building — the whole engine story of `Building`. +//! +//! Nature lives in the kernel (`bimr::element::architecture::Building`): a +//! named gathering of Storey entities **by value** — a container, no mesh. +//! The storeys come from a List; the edge back to it lives in `refs`. + +use bimr::Entity; +use bimr::element::architecture::Building as KernelBuilding; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{lookup, name_of, ref_name, str_of}; +use crate::store::Store; +use crate::value::Node; + +/// `b = Building("b03", storeys)` — positional or keyword form. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let (building_name, source) = building_args(args)?; + let source_node = lookup(store, &source)?; + let storeys: Vec = match &source_node.entity { + Entity::List(list) => { + if list.kind() != "Storey" { + return Err(Error::ListKind { + name: source, + expected: "Storey", + got: list.kind(), + }); + } + list.items().to_vec() + } + other => { + return Err(Error::WrongNature { + name: source, + expected: "List", + got: other.type_name(), + }); + } + }; + let building = KernelBuilding::new(building_name, storeys); + store.insert( + file, + name, + Entity::Building(building), + vec![source_node.guid], + ); + Ok(()) +} + +/// The compact BIMR text: `bld1 = Building("b03", storeys)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, b: &KernelBuilding) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!("{name} = Building(\"{}\", {source_var})", b.name()) +} + +/// Building's argument mix: `Building("b03", src)` or +/// `Building(name="b03", storeys=src)`. +fn building_args(args: &[Argument]) -> Result<(String, String), Error> { + let mut building_name = None; + let mut source = None; + for arg in args { + match arg { + Argument::Keyword(key, expr) if key == "name" => { + building_name = Some(str_of(expr)?); + } + Argument::Keyword(key, expr) if key == "storeys" => { + source = Some(name_of(expr)?); + } + Argument::Keyword(..) => return Err(Error::Unsupported("keyword argument")), + Argument::Positional(expr) => match expr { + parser::ast::Expression::String(_) if building_name.is_none() => { + building_name = Some(str_of(expr)?); + } + _ if source.is_none() => source = Some(name_of(expr)?), + _ => return Err(Error::Unsupported("arity")), + }, + } + } + match (building_name, source) { + (Some(building_name), Some(source)) => Ok((building_name, source)), + _ => Err(Error::Unsupported("arity")), + } +} diff --git a/eval/src/nodes/element/architecture/column.rs b/eval/src/nodes/element/architecture/column.rs new file mode 100644 index 0000000..2990151 --- /dev/null +++ b/eval/src/nodes/element/architecture/column.rs @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Column — the whole engine story of `Column`. +//! +//! Nature lives in the kernel (`bimr::element::architecture::Column`): the +//! struct, the constructor, the mesh. The engine holds the column's base +//! **by value**; the edge back to the point node lives in `refs`. +//! +//! Lifting: a base that resolves to a List of Points produces a List of +//! Columns — one per point, the scalars broadcast +//! ([list_lifting](https://git.rvba.fr/rvba/bimr-factory)). + +use bimr::Entity; +use bimr::element::architecture::Column as KernelColumn; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// IFCX path prefix — the per-type counter lives with the emitter. +pub(crate) const IFCX_PREFIX: &str = "column"; + +/// `c = Column(p, height, width, depth)` — both positional and keyword +/// forms. `p` may be a List of Points: the call lifts into a List of +/// Columns, one per point, the scalars broadcast. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let (base, height, width, depth) = column_args(args)?; + let base_node = lookup(store, &base)?; + let items: Vec = match &base_node.entity { + Entity::Point(p) => { + let col = KernelColumn::new(*p, height, width, depth); + store.insert(file, name, Entity::Column(col), vec![base_node.guid]); + return Ok(()); + } + Entity::List(list) => { + if list.kind() != "Point" { + return Err(Error::ListKind { + name: base, + expected: "Point", + got: list.kind(), + }); + } + list.items().to_vec() + } + Entity::Divide(_) => { + // The created items — the record's output collection. + store + .outputs_of(base_node.guid) + .iter() + .map(|&guid| { + let node = store.resolve(guid).expect("indexed"); + match node.entity { + Entity::Point(p) => Entity::Point(p), + ref other => unreachable!("divide items are Points: {}", other.type_name()), + } + }) + .collect::>() + } + other => { + return Err(Error::WrongNature { + name: base, + expected: "Point", + got: other.type_name(), + }); + } + }; + lift( + store, + file, + name, + &items, + (height, width, depth), + vec![base_node.guid], + ) +} + +/// Lift: one Column per point, the scalars broadcast — a List of Columns +/// with kind `"Column"`, `refs` pointing at the lifted-over source. +fn lift( + store: &mut Store, + file: &str, + name: &str, + items: &[Entity], + scalars: (f64, f64, f64), + refs: Vec, +) -> Result<(), Error> { + let (height, width, depth) = scalars; + let cols = items + .iter() + .map(|item| match item { + Entity::Point(p) => KernelColumn::new(*p, height, width, depth), + other => unreachable!("lifting resolves Points: {}", other.type_name()), + }) + .collect::>(); + let lifted = bimr::set::List::try_new(cols.into_iter().map(Entity::Column).collect::>()) + .expect("one Column per item — homogeneous"); + store.insert(file, name, Entity::List(lifted), refs); + Ok(()) +} + +/// The compact BIMR text (reference format): +/// `c1 = Column(p1, height=300, section_width=30, section_height=30)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, c: &KernelColumn) -> String { + let name = ref_name(store, node.guid); + let base_var = ref_name(store, node.refs[0]); + format!( + "{name} = Column({base_var}, height={}, section_width={}, section_height={})", + fmt_num(c.height), + fmt_num(c.section_width), + fmt_num(c.section_height) + ) +} + +/// Column's argument mix: `Column(p, 300, 30, 30)` or `Column(p, height=300, +/// section_width=30, section_height=30)`. Any other keyword is rejected, not +/// ignored. +fn column_args(args: &[Argument]) -> Result<(String, f64, f64, f64), Error> { + let mut base = None; + let mut height = None; + let mut width = None; + let mut depth = None; + for arg in args { + match arg { + Argument::Keyword(key, expr) if key == "height" => { + height = Some(number(expr)?); + } + Argument::Keyword(key, expr) if key == "section_width" => { + width = Some(number(expr)?); + } + Argument::Keyword(key, expr) if key == "section_height" => { + depth = Some(number(expr)?); + } + Argument::Keyword(..) => return Err(Error::Unsupported("keyword argument")), + Argument::Positional(expr) => { + if base.is_none() { + base = Some(name_of(expr)?); + } else if height.is_none() { + height = Some(number(expr)?); + } else if width.is_none() { + width = Some(number(expr)?); + } else if depth.is_none() { + depth = Some(number(expr)?); + } + } + } + } + match (base, height, width, depth) { + (Some(base), Some(height), Some(width), Some(depth)) => Ok((base, height, width, depth)), + _ => Err(Error::Unsupported("arity")), + } +} diff --git a/eval/src/nodes/element/architecture/frame.rs b/eval/src/nodes/element/architecture/frame.rs new file mode 100644 index 0000000..6acb9c9 --- /dev/null +++ b/eval/src/nodes/element/architecture/frame.rs @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Frame — the whole engine story of `Frame`. +//! +//! Nature lives in the kernel (`bimr::element::architecture::Frame`): a +//! hollow rectangular solid — origin and end held **by value**, four +//! section scalars. The edges back to the point nodes live in `refs`. +//! +//! Lifting: a single name argument lifts over a List of **pairs** (each a +//! List of two Points — the shape `Explode` produces) — one Frame per pair, +//! origin = pair[0], end = pair[1]. + +use bimr::Entity; +use bimr::element::architecture::Frame as KernelFrame; +use bimr::geometry::Point; +use parser::ast::{Argument, Expression}; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, ref_name, resolve}; +use crate::store::Store; +use crate::value::Node; + +/// IFCX path prefix — the per-type counter lives with the emitter. +pub(crate) const IFCX_PREFIX: &str = "frame"; + +/// `f = Frame(origin, end, width, height, depth, thickness)` — both +/// positional and keyword forms — or `frs = Frame(pairs, w, h, d, tk)`, +/// lifting one Frame per pair. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let (names, (width, height, depth, thickness)) = frame_args(args)?; + match names.len() { + 2 => { + let origin_node = resolve(store, &names[0], "Point")?; + let end_node = resolve(store, &names[1], "Point")?; + let (o, e) = match (&origin_node.entity, &end_node.entity) { + (Entity::Point(o), Entity::Point(e)) => (*o, *e), + _ => unreachable!("resolve checked the nature"), + }; + store.insert( + file, + name, + Entity::Frame(KernelFrame::new(o, e, width, height, depth, thickness)), + vec![origin_node.guid, end_node.guid], + ); + Ok(()) + } + 1 => { + let source_node = lookup(store, &names[0])?; + let pairs = pairs_of(store, &names[0], source_node)?; + let frames = pairs + .into_iter() + .map(|(o, e)| { + Entity::Frame(KernelFrame::new(o, e, width, height, depth, thickness)) + }) + .collect::>(); + let lifted = + bimr::set::List::try_new(frames).expect("one Frame per pair — homogeneous"); + store.insert(file, name, Entity::List(lifted), vec![source_node.guid]); + Ok(()) + } + _ => Err(Error::Unsupported("arity")), + } +} + +/// The pairs a Frame lifts over: a List of Lists (each a pair of two +/// Points) or an `Explode` record (its created parts are the pairs). +fn pairs_of(store: &Store, name: &str, source: &Node) -> Result, Error> { + let pair_lists: Vec = match &source.entity { + Entity::Explode(_) => store + .outputs_of(source.guid) + .iter() + .map(|&guid| store.resolve(guid).expect("indexed").entity.clone()) + .collect(), + Entity::List(list) => { + if list.kind() != "List" { + return Err(Error::ListKind { + name: name.to_string(), + expected: "List", + got: list.kind(), + }); + } + list.items().to_vec() + } + other => { + return Err(Error::WrongNature { + name: name.to_string(), + expected: "List", + got: other.type_name(), + }); + } + }; + pair_lists + .iter() + .map(|pair| match pair { + Entity::List(pair) => { + if pair.kind() != "Point" || pair.length() != 2 { + return Err(Error::ListKind { + name: name.to_string(), + expected: "Point", + got: pair.kind(), + }); + } + let (o, e) = match (pair.get(0), pair.get(1)) { + (Some(Entity::Point(o)), Some(Entity::Point(e))) => (*o, *e), + _ => unreachable!("kind `Point` checked"), + }; + Ok((o, e)) + } + other => Err(Error::WrongNature { + name: name.to_string(), + expected: "List", + got: other.type_name(), + }), + }) + .collect() +} + +/// The compact BIMR text (reference format): +/// `fr1 = Frame(p1, p2, width=100, height=150, depth=12, thickness=5)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, f: &KernelFrame) -> String { + let name = ref_name(store, node.guid); + let origin_var = ref_name(store, node.refs[0]); + let end_var = ref_name(store, node.refs[1]); + let args = format!("{origin_var}, {end_var}"); + format!("{name} = {}", scalars_suffix(&args, f)) +} + +/// The lifted compact form: `frs = Frame(ex1, width=…, …)` — the pair +/// source is `refs[0]`; the scalars come from the first Frame (they are +/// broadcast). +pub(crate) fn to_bimr_lifted(store: &Store, node: &Node, f: &KernelFrame) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!("{name} = {}", scalars_suffix(&source_var, f)) +} + +fn scalars_suffix(args: &str, f: &KernelFrame) -> String { + format!( + "Frame({args}, width={}, height={}, depth={}, thickness={})", + fmt_num(f.width), + fmt_num(f.height), + fmt_num(f.depth), + fmt_num(f.thickness) + ) +} + +/// Frame's scalars — width, height, depth, thickness. +pub(crate) type Scalars = (f64, f64, f64, f64); + +/// Frame's argument mix. Identifiers become the point (or source) names, +/// numbers fill width, height, depth, thickness in order; keywords name +/// their scalar. Two names — the single form; one name — the lift. +fn frame_args(args: &[Argument]) -> Result<(Vec, Scalars), Error> { + let mut names: Vec = Vec::new(); + let mut numbers: Vec = Vec::new(); + let mut width = None; + let mut height = None; + let mut depth = None; + let mut thickness = None; + for arg in args { + match arg { + Argument::Keyword(key, expr) => match key.as_str() { + "width" => width = Some(number(expr)?), + "height" => height = Some(number(expr)?), + "depth" => depth = Some(number(expr)?), + "thickness" => thickness = Some(number(expr)?), + _ => return Err(Error::Unsupported("keyword argument")), + }, + Argument::Positional(expr) => match expr { + Expression::Identifier(_) => names.push(name_of(expr)?), + _ => numbers.push(number(expr)?), + }, + } + } + if names.is_empty() || names.len() > 2 { + return Err(Error::Unsupported("arity")); + } + let numbers_len = numbers.len() + + width.is_some() as usize + + height.is_some() as usize + + depth.is_some() as usize + + thickness.is_some() as usize; + if numbers_len != 4 { + return Err(Error::Unsupported("arity")); + } + let mut it = numbers.into_iter(); + let scalars = ( + width.unwrap_or_else(|| it.next().unwrap()), + height.unwrap_or_else(|| it.next().unwrap()), + depth.unwrap_or_else(|| it.next().unwrap()), + thickness.unwrap_or_else(|| it.next().unwrap()), + ); + Ok((names, scalars)) +} diff --git a/eval/src/nodes/element/architecture/mod.rs b/eval/src/nodes/element/architecture/mod.rs new file mode 100644 index 0000000..3262179 --- /dev/null +++ b/eval/src/nodes/element/architecture/mod.rs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Architecture element nodes — mirroring `bimr-kernel/src/element/architecture/`. + +pub(crate) mod building; +pub(crate) mod column; +pub(crate) mod frame; +pub(crate) mod storey; +pub(crate) mod slab; +pub(crate) mod wall; diff --git a/eval/src/nodes/element/architecture/slab.rs b/eval/src/nodes/element/architecture/slab.rs new file mode 100644 index 0000000..fdb6a77 --- /dev/null +++ b/eval/src/nodes/element/architecture/slab.rs @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Slab — the whole engine story of `Slab`. +//! +//! Nature lives in the kernel (`bimr::element::architecture::Slab`): a +//! floor/ceiling plate — a polyline profile by value, thickness, elevation. +//! The profile comes from a Curve (sampled at eight points per span), a +//! List of Points, or a `Divide` record. + +use bimr::Entity; +use bimr::element::architecture::Slab as KernelSlab; +use bimr::geometry::Polyline; +use bimr::math::Vec3; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// IFCX path prefix — the per-type counter lives with the emitter. +pub(crate) const IFCX_PREFIX: &str = "slab"; + +const SEGS_PER_SPAN: usize = 8; + +/// `s = Slab(source, thickness, elevation)` — positional or keyword form. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let (source, thickness, elevation) = slab_args(args)?; + let source_node = lookup(store, &source)?; + + let ring: Vec = match &source_node.entity { + Entity::List(list) => { + if list.kind() != "Point" { + return Err(Error::ListKind { + name: source, + expected: "Point", + got: list.kind(), + }); + } + points_of(list.items())? + } + Entity::Divide(_) => points_of( + &store + .outputs_of(source_node.guid) + .iter() + .map(|&guid| store.resolve(guid).expect("indexed").entity.clone()) + .collect::>(), + )?, + Entity::Curve(curve) => { + let n = curve.spline().control_points().len() * SEGS_PER_SPAN; + curve.points(n).iter().map(|p| p.to_vec3()).collect() + } + other => { + return Err(Error::WrongNature { + name: source, + expected: "List", + got: other.type_name(), + }); + } + }; + + let slab = KernelSlab::new(Polyline::new(ring), thickness, elevation); + store.insert(file, name, Entity::Slab(slab), vec![source_node.guid]); + Ok(()) +} + +fn points_of(items: &[Entity]) -> Result, Error> { + items + .iter() + .map(|item| match item { + Entity::Point(p) => Ok(p.to_vec3()), + other => Err(Error::ListKind { + name: String::new(), + expected: "Point", + got: other.type_name(), + }), + }) + .collect() +} + +/// The compact BIMR text: `s1 = Slab(crv, 150, 0)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, s: &KernelSlab) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!( + "{name} = Slab({source_var}, {}, {})", + fmt_num(s.thickness), + fmt_num(s.elevation) + ) +} + +/// Slab's argument mix: `Slab(src, t, e)` or +/// `Slab(src, thickness=t, elevation=e)`. Any other keyword is rejected. +fn slab_args(args: &[Argument]) -> Result<(String, f64, f64), Error> { + let mut source = None; + let mut thickness = None; + let mut elevation = None; + for arg in args { + match arg { + Argument::Keyword(key, expr) if key == "thickness" => { + thickness = Some(number(expr)?); + } + Argument::Keyword(key, expr) if key == "elevation" => { + elevation = Some(number(expr)?); + } + Argument::Keyword(..) => return Err(Error::Unsupported("keyword argument")), + Argument::Positional(expr) => { + if source.is_none() { + source = Some(name_of(expr)?); + } else if thickness.is_none() { + thickness = Some(number(expr)?); + } else if elevation.is_none() { + elevation = Some(number(expr)?); + } + } + } + } + match (source, thickness, elevation) { + (Some(source), Some(thickness), Some(elevation)) => Ok((source, thickness, elevation)), + _ => Err(Error::Unsupported("arity")), + } +} diff --git a/eval/src/nodes/element/architecture/storey.rs b/eval/src/nodes/element/architecture/storey.rs new file mode 100644 index 0000000..2b690a2 --- /dev/null +++ b/eval/src/nodes/element/architecture/storey.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Storey — the whole engine story of `Storey`. +//! +//! Nature lives in the kernel (`bimr::element::architecture::Storey`): a +//! datum elevation gathering entities **by value** — a container, no mesh. +//! The members come from a List (typically a lifted result); the edge back +//! to it lives in `refs`. + +use bimr::Entity; +use bimr::element::architecture::Storey as KernelStorey; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `l = Storey(elevation, source)` — positional or keyword form. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let (elevation, source) = storey_args(args)?; + let source_node = lookup(store, &source)?; + let members: Vec = match &source_node.entity { + Entity::List(list) => list.items().to_vec(), + other => { + return Err(Error::WrongNature { + name: source, + expected: "List", + got: other.type_name(), + }); + } + }; + let storey = KernelStorey::new(elevation, members); + store.insert(file, name, Entity::Storey(storey), vec![source_node.guid]); + Ok(()) +} + +/// The compact BIMR text: `st1 = Storey(0, frames)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, l: &KernelStorey) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!("{name} = Storey({}, {source_var})", fmt_num(l.elevation())) +} + +/// Storey's argument mix: `Storey(z, src)` or `Storey(elevation=z, entities=src)`. +fn storey_args(args: &[Argument]) -> Result<(f64, String), Error> { + let mut elevation = None; + let mut source = None; + for arg in args { + match arg { + Argument::Keyword(key, expr) if key == "elevation" => { + elevation = Some(number(expr)?); + } + Argument::Keyword(key, expr) if key == "entities" => { + source = Some(name_of(expr)?); + } + Argument::Keyword(..) => return Err(Error::Unsupported("keyword argument")), + Argument::Positional(expr) => match expr { + parser::ast::Expression::Identifier(_) if source.is_none() => { + source = Some(name_of(expr)?); + } + _ if elevation.is_none() => elevation = Some(number(expr)?), + _ if source.is_none() => source = Some(name_of(expr)?), + _ => return Err(Error::Unsupported("arity")), + }, + } + } + match (elevation, source) { + (Some(elevation), Some(source)) => Ok((elevation, source)), + _ => Err(Error::Unsupported("arity")), + } +} diff --git a/eval/src/nodes/element/architecture/wall.rs b/eval/src/nodes/element/architecture/wall.rs new file mode 100644 index 0000000..bfbb56e --- /dev/null +++ b/eval/src/nodes/element/architecture/wall.rs @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Wall — the whole engine story of `Wall`. +//! +//! Nature lives in the kernel (`bimr::element::architecture::Wall`): the +//! struct, the constructor, the mesh. The engine holds the wall's line **by +//! value**; the edge back to the line node lives in `refs`. + +use bimr::Entity; +use bimr::element::architecture::Wall as KernelWall; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, name_of, number, ref_name, resolve}; +use crate::store::Store; +use crate::value::Node; + +/// IFCX path prefix — the per-type counter lives with the emitter. +pub(crate) const IFCX_PREFIX: &str = "wall"; + +/// `w = Wall(l, width, height)` — both positional and keyword forms. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let (line, width, height) = wall_args(args)?; + let line_node = resolve(store, &line, "Line")?; + let l = match &line_node.entity { + Entity::Line(l) => *l, + other => unreachable!("resolve checked the nature: {}", other.type_name()), + }; + store.insert( + file, + name, + Entity::Wall(KernelWall::new(l, width, height)), + vec![line_node.guid], + ); + Ok(()) +} + +/// The compact BIMR text: `w1 = Wall(l1, thickness=20, height=300)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, w: &KernelWall) -> String { + let name = ref_name(store, node.guid); + let line_var = ref_name(store, node.refs[0]); + format!( + "{name} = Wall({line_var}, thickness={}, height={})", + fmt_num(w.width), + fmt_num(w.height) + ) +} + +/// Wall's argument mix: `Wall(l1, 20, 300)` or `Wall(l1, thickness=20, +/// height=300)`. Any other keyword is rejected, not ignored. +fn wall_args(args: &[Argument]) -> Result<(String, f64, f64), Error> { + let mut line = None; + let mut width = None; + let mut height = None; + for arg in args { + match arg { + Argument::Keyword(key, expr) if key == "thickness" => { + width = Some(number(expr)?); + } + Argument::Keyword(key, expr) if key == "height" => { + height = Some(number(expr)?); + } + Argument::Keyword(..) => return Err(Error::Unsupported("keyword argument")), + Argument::Positional(expr) => { + if line.is_none() { + line = Some(name_of(expr)?); + } else if width.is_none() { + width = Some(number(expr)?); + } else if height.is_none() { + height = Some(number(expr)?); + } + } + } + } + match (line, width, height) { + (Some(line), Some(width), Some(height)) => Ok((line, width, height)), + _ => Err(Error::Unsupported("arity")), + } +} diff --git a/eval/src/nodes/element/mod.rs b/eval/src/nodes/element/mod.rs new file mode 100644 index 0000000..1471cb3 --- /dev/null +++ b/eval/src/nodes/element/mod.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Element nodes — mirroring `bimr-kernel/src/element/`. + +pub(crate) mod architecture; diff --git a/eval/src/nodes/geometry/circle.rs b/eval/src/nodes/geometry/circle.rs new file mode 100644 index 0000000..cf47835 --- /dev/null +++ b/eval/src/nodes/geometry/circle.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Circle — the whole engine story of `Circle`. +//! +//! Nature lives in the kernel (`bimr::geometry::Circle`): a curve primitive, +//! center by value, radius; no mesh. The edge back to the center point node +//! lives in `refs`. + +use bimr::Entity; +use bimr::geometry::Circle as KernelCircle; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `c = Circle(p, radius)` — positional only. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = crate::nodes::positional_args(args)?; + if positional.len() != 2 { + return Err(Error::Unsupported("arity")); + } + let center = name_of(positional[0])?; + let radius = number(positional[1])?; + let center_node = lookup(store, ¢er)?; + let p = match ¢er_node.entity { + Entity::Point(p) => *p, + other => { + return Err(Error::WrongNature { + name: center, + expected: "Point", + got: other.type_name(), + }); + } + }; + store.insert( + file, + name, + Entity::Circle(KernelCircle::new(p, radius)), + vec![center_node.guid], + ); + Ok(()) +} + +/// The compact BIMR text: `c1 = Circle(p1, 200)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, c: &KernelCircle) -> String { + let name = ref_name(store, node.guid); + let center_var = ref_name(store, node.refs[0]); + format!("{name} = Circle({center_var}, {})", fmt_num(c.radius)) +} diff --git a/eval/src/nodes/geometry/curve.rs b/eval/src/nodes/geometry/curve.rs new file mode 100644 index 0000000..e621fa3 --- /dev/null +++ b/eval/src/nodes/geometry/curve.rs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Curve — the whole engine story of `Curve`. +//! +//! Nature lives in the kernel (`bimr::geometry::Curve`): a closed +//! Catmull-Rom spline through control points, sampled by `Divide`/`Cut` +//! like the Circle. The engine holds the control points **by value** (via +//! the kernel spline); the edge back to the source node lives in `refs`. +//! +//! The controls come from a List of Points or a `Divide` record. + +use bimr::Entity; +use bimr::geometry::{Curve as KernelCurve, Spline}; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{lookup, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `c = Curve(ctrl)` — positional only. The source is a List of Points +/// (at least three) or a `Divide` record (its created points). +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = crate::nodes::positional_args(args)?; + if positional.len() != 1 { + return Err(Error::Unsupported("arity")); + } + let source = crate::nodes::name_of(positional[0])?; + let source_node = lookup(store, &source)?; + + let points: Vec = match &source_node.entity { + Entity::List(list) => { + if list.kind() != "Point" { + return Err(Error::ListKind { + name: source, + expected: "Point", + got: list.kind(), + }); + } + let pts = list + .items() + .iter() + .map(|item| match item { + Entity::Point(p) => *p, + other => unreachable!("kind `{}` checked: {}", list.kind(), other.type_name()), + }) + .collect::>(); + if pts.len() < 3 { + return Err(Error::Unsupported("arity")); + } + pts + } + Entity::Divide(_) | Entity::Random(_) => { + let pts = store + .outputs_of(source_node.guid) + .iter() + .map(|&guid| { + let node = store.resolve(guid).expect("indexed"); + match node.entity { + Entity::Point(p) => p, + ref other => { + unreachable!("operator items are Points: {}", other.type_name()) + } + } + }) + .collect::>(); + if pts.len() < 3 { + return Err(Error::Unsupported("arity")); + } + pts + } + other => { + return Err(Error::WrongNature { + name: source, + expected: "List", + got: other.type_name(), + }); + } + }; + + let ctrl = points + .into_iter() + .map(|p| bimr::math::Vec3::new(p.x, p.y, p.z)) + .collect::>(); + let spline = Spline::new(ctrl).expect("at least three control points — gated"); + let curve = KernelCurve::new(spline); + store.insert(file, name, Entity::Curve(curve), vec![source_node.guid]); + Ok(()) +} + +/// The compact BIMR text: `crv1 = Curve(ctrl)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, _c: &KernelCurve) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!("{name} = Curve({source_var})") +} diff --git a/eval/src/nodes/geometry/extrusion.rs b/eval/src/nodes/geometry/extrusion.rs new file mode 100644 index 0000000..f81f19d --- /dev/null +++ b/eval/src/nodes/geometry/extrusion.rs @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Extrusion — the whole engine story of `Extrusion`. +//! +//! Nature lives in the kernel (`bimr::geometry::Extrusion`): the profile by +//! value (E1 resolved — no handle), the sweep vector; `entity.mesh()` builds +//! the side surface standalone. The engine keeps graph edges to the profile +//! source and the vector in `refs`. +//! +//! The profile comes from a List of Lines or a `Cut` record (its created +//! pieces); the sweep from a `Vector` node. + +use bimr::Entity; +use bimr::geometry::Extrusion as KernelExtrusion; +use bimr::geometry::Line; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{lookup, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// IFCX path prefix — the per-type counter lives with the emitter. +pub(crate) const IFCX_PREFIX: &str = "extrusion"; + +/// `e = Extrusion(profile, vec)` — positional only. The profile is a List of +/// Lines (kind `"Line"`) or a `Cut` record; the sweep a `Vector`. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = crate::nodes::positional_args(args)?; + if positional.len() != 2 { + return Err(Error::Unsupported("arity")); + } + let profile_name = crate::nodes::name_of(positional[0])?; + let vec_name = crate::nodes::name_of(positional[1])?; + + let profile_node = lookup(store, &profile_name)?; + let profile: Vec = match &profile_node.entity { + Entity::List(list) => { + if list.kind() != "Line" { + return Err(Error::ListKind { + name: profile_name, + expected: "Line", + got: list.kind(), + }); + } + list.items() + .iter() + .map(|item| match item { + Entity::Line(l) => *l, + other => unreachable!("kind `{}` checked: {}", list.kind(), other.type_name()), + }) + .collect() + } + Entity::Cut(_) => store + .outputs_of(profile_node.guid) + .iter() + .map(|&guid| { + let node = store.resolve(guid).expect("indexed"); + match node.entity { + Entity::Line(l) => l, + ref other => unreachable!("cut pieces are Lines: {}", other.type_name()), + } + }) + .collect(), + other => { + return Err(Error::WrongNature { + name: profile_name, + expected: "List", + got: other.type_name(), + }); + } + }; + + let vec_node = lookup(store, &vec_name)?; + let vec = match &vec_node.entity { + Entity::Vector(v) => v.to_vec3(), + other => { + return Err(Error::WrongNature { + name: vec_name, + expected: "Vector", + got: other.type_name(), + }); + } + }; + + let extrusion = KernelExtrusion::new(profile, vec); + store.insert( + file, + name, + Entity::Extrusion(extrusion), + vec![profile_node.guid, vec_node.guid], + ); + Ok(()) +} + +/// The compact BIMR text: `e1 = Extrusion(ct1, v1)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, _e: &KernelExtrusion) -> String { + let name = ref_name(store, node.guid); + let profile_var = ref_name(store, node.refs[0]); + let vec_var = ref_name(store, node.refs[1]); + format!("{name} = Extrusion({profile_var}, {vec_var})") +} diff --git a/eval/src/nodes/geometry/line.rs b/eval/src/nodes/geometry/line.rs new file mode 100644 index 0000000..af08102 --- /dev/null +++ b/eval/src/nodes/geometry/line.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Line — the whole engine story of `Line`. +//! +//! Nature lives in the kernel (`bimr::geometry::Line`). The engine +//! constructs it directly from the resolved point values; the graph edges +//! are the two point GUIDs. + +use bimr::Entity; +use bimr::geometry::Line as KernelLine; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{name_of, positional_args, ref_name, resolve}; +use crate::store::Store; +use crate::value::Node; + +/// `l = Line(p1, p2)` — both points resolved, both edges recorded. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = positional_args(args)?; + if positional.len() != 2 { + return Err(Error::Unsupported("arity")); + } + let start_node = resolve(store, &name_of(positional[0])?, "Point")?; + let end_node = resolve(store, &name_of(positional[1])?, "Point")?; + let (start, end) = match (&start_node.entity, &end_node.entity) { + (Entity::Point(s), Entity::Point(e)) => (*s, *e), + _ => unreachable!("resolve checked the nature"), + }; + store.insert( + file, + name, + Entity::Line(KernelLine::new(start, end)), + vec![start_node.guid, end_node.guid], + ); + Ok(()) +} + +/// The compact BIMR text: `l1 = Line(p1, p2)`. Endpoints: the refs point at +/// the point nodes — their DSL names are the emitted references. +pub(crate) fn to_bimr(store: &Store, node: &Node, _l: &KernelLine) -> String { + let name = ref_name(store, node.guid); + format!( + "{name} = Line({}, {})", + endpoint_name(store, node, 0), + endpoint_name(store, node, 1) + ) +} + +fn endpoint_name(store: &Store, node: &Node, index: usize) -> String { + node.refs + .get(index) + .copied() + .map(|g| ref_name(store, g)) + .unwrap_or_else(|| "?".to_string()) +} diff --git a/eval/src/nodes/geometry/mod.rs b/eval/src/nodes/geometry/mod.rs new file mode 100644 index 0000000..25184c0 --- /dev/null +++ b/eval/src/nodes/geometry/mod.rs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Geometry nodes — mirroring `bimr-kernel/src/geometry/`. + +pub(crate) mod circle; +pub(crate) mod curve; +pub(crate) mod extrusion; +pub(crate) mod line; +pub(crate) mod point; +pub(crate) mod vector; diff --git a/eval/src/nodes/geometry/point.rs b/eval/src/nodes/geometry/point.rs new file mode 100644 index 0000000..bcded9e --- /dev/null +++ b/eval/src/nodes/geometry/point.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Point — the whole engine story of `Point`. +//! +//! Nature lives in the kernel (`bimr::geometry::Point`): the struct, the +//! constructor, the payload. This file owns the engine faces: evaluation, +//! construction, and the compact serialization text. + +use bimr::Entity; +use bimr::geometry::Point as KernelPoint; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, number, positional_args, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `p = Point(x, y, z)` — a leaf, no refs. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = positional_args(args)?; + if positional.len() != 3 { + return Err(Error::Unsupported("arity")); + } + let (x, y, z) = ( + number(positional[0])?, + number(positional[1])?, + number(positional[2])?, + ); + store.insert(file, name, Entity::Point(KernelPoint::new(x, y, z)), vec![]); + Ok(()) +} + +/// The compact BIMR text: `p1 = Point(0, 0, 0)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, p: &KernelPoint) -> String { + let name = ref_name(store, node.guid); + format!( + "{name} = Point({}, {}, {})", + fmt_num(p.x), + fmt_num(p.y), + fmt_num(p.z) + ) +} diff --git a/eval/src/nodes/geometry/vector.rs b/eval/src/nodes/geometry/vector.rs new file mode 100644 index 0000000..818f1f9 --- /dev/null +++ b/eval/src/nodes/geometry/vector.rs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Vector — the whole engine story of `Vector`. +//! +//! Nature lives in the kernel (`bimr::geometry::Vector`): a 3D direction, +//! a graph parameter like Grasshopper vectors. A leaf, no refs. + +use bimr::Entity; +use bimr::geometry::Vector as KernelVector; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, number, positional_args, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `v = Vector(x, y, z)` — positional only. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = positional_args(args)?; + if positional.len() != 3 { + return Err(Error::Unsupported("arity")); + } + let (x, y, z) = ( + number(positional[0])?, + number(positional[1])?, + number(positional[2])?, + ); + store.insert( + file, + name, + Entity::Vector(KernelVector::new(x, y, z)), + vec![], + ); + Ok(()) +} + +/// The compact BIMR text: `v1 = Vector(0, 0, 300)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, v: &KernelVector) -> String { + let name = ref_name(store, node.guid); + format!( + "{name} = Vector({}, {}, {})", + fmt_num(v.x), + fmt_num(v.y), + fmt_num(v.z) + ) +} diff --git a/eval/src/nodes/mod.rs b/eval/src/nodes/mod.rs new file mode 100644 index 0000000..cd15203 --- /dev/null +++ b/eval/src/nodes/mod.rs @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! The nodes — one file per entity, mirroring the kernel's taxonomy +//! ([nodes layout](https://git.rvba.fr/rvba/bimr-factory/src/branch/main/task/refactor/2026-08-29/engine_nodes_layout.md)). +//! +//! Each node file owns the whole engine story of its entity: `eval` +//! (argument extraction), `build`-style construction, the compact `to_bimr` +//! text, and (for mesh-bearing entities) the IFCX path prefix. The dispatch +//! is a plain match — no table, no `dyn`, no registry. + +pub(crate) mod element; +pub(crate) mod geometry; +pub(crate) mod operator; +pub(crate) mod set; + +use bimr::Entity; +use parser::ast::{Argument, Expression}; + +use crate::error::Error; +use crate::guid::Guid; +use crate::store::Store; +use crate::value::Node; + +/// Dispatch a DSL call to its node. Anything else is simply an unknown +/// constructor — no blocklist, no categories. +pub(crate) fn call( + store: &mut Store, + file: &str, + name: &str, + callee: &str, + args: &[Argument], +) -> Result<(), Error> { + match callee { + "Point" => geometry::point::eval(store, file, name, args), + "Line" => geometry::line::eval(store, file, name, args), + "Circle" => geometry::circle::eval(store, file, name, args), + "Curve" => geometry::curve::eval(store, file, name, args), + "Vector" => geometry::vector::eval(store, file, name, args), + "Extrusion" => geometry::extrusion::eval(store, file, name, args), + "Wall" => element::architecture::wall::eval(store, file, name, args), + "Column" => element::architecture::column::eval(store, file, name, args), + "Frame" => element::architecture::frame::eval(store, file, name, args), + "Slab" => element::architecture::slab::eval(store, file, name, args), + "Storey" => element::architecture::storey::eval(store, file, name, args), + "Building" => element::architecture::building::eval(store, file, name, args), + "Divide" => operator::divide::eval(store, file, name, args), + "Cut" => operator::cut::eval(store, file, name, args), + "Explode" => operator::explode::eval(store, file, name, args), + "Random" => operator::random::eval(store, file, name, args), + "List" => set::list::eval(store, file, name, args), + _ => Err(Error::Unsupported("call")), + } +} + +/// The compact BIMR source line for one node — delegated to the node file. +pub(crate) fn to_bimr(store: &Store, node: &Node) -> String { + match &node.entity { + Entity::Point(p) => geometry::point::to_bimr(store, node, p), + Entity::Line(l) => geometry::line::to_bimr(store, node, l), + Entity::Circle(c) => geometry::circle::to_bimr(store, node, c), + Entity::Curve(c) => geometry::curve::to_bimr(store, node, c), + Entity::Vector(v) => geometry::vector::to_bimr(store, node, v), + Entity::Extrusion(e) => geometry::extrusion::to_bimr(store, node, e), + Entity::Wall(w) => element::architecture::wall::to_bimr(store, node, w), + Entity::Column(c) => element::architecture::column::to_bimr(store, node, c), + Entity::Frame(f) => element::architecture::frame::to_bimr(store, node, f), + Entity::Slab(s) => element::architecture::slab::to_bimr(store, node, s), + Entity::Storey(l) => element::architecture::storey::to_bimr(store, node, l), + Entity::Building(b) => element::architecture::building::to_bimr(store, node, b), + Entity::List(l) => match l.kind() { + // A lifted list's compact form is the lifted statement itself — + // the constructor call over its source list (refs[0]). A plain + // binding can only reach these kinds by lifting, so the item + // kind alone selects the emitter. + "Column" => element::architecture::column::to_bimr( + store, + node, + match l.get(0) { + Some(Entity::Column(c)) => c, + _ => unreachable!("kind `Column` guarantees the payload"), + }, + ), + "Frame" => element::architecture::frame::to_bimr_lifted( + store, + node, + match l.get(0) { + Some(Entity::Frame(f)) => f, + _ => unreachable!("kind `Frame` guarantees the payload"), + }, + ), + _ => set::list::to_bimr(store, node, l), + }, + Entity::Divide(d) => operator::divide::to_bimr(store, node, d), + Entity::Cut(c) => operator::cut::to_bimr(store, node, c), + Entity::Explode(e) => operator::explode::to_bimr(store, node, e), + Entity::Random(r) => operator::random::to_bimr(store, node, r), + other => panic!("to_bimr: unsupported entity {}", other.type_name()), + } +} + +/// The IFCX path prefix of the node module owning this entity, if that +/// module emits meshes. +pub(crate) fn ifcx_prefix(entity: &Entity) -> Option<&'static str> { + match entity { + Entity::Wall(_) => Some(element::architecture::wall::IFCX_PREFIX), + Entity::Column(_) => Some(element::architecture::column::IFCX_PREFIX), + Entity::Frame(_) => Some(element::architecture::frame::IFCX_PREFIX), + Entity::Slab(_) => Some(element::architecture::slab::IFCX_PREFIX), + Entity::Extrusion(_) => Some(geometry::extrusion::IFCX_PREFIX), + _ => None, + } +} + +// ── Shared plumbing ───────────────────────────────────────────────────── + +/// Resolve a DSL name to a stored node — any nature. +pub(crate) fn lookup<'a>(store: &'a Store, name: &str) -> Result<&'a Node, Error> { + let guid = store + .named + .get(name) + .copied() + .ok_or_else(|| Error::UnknownName(name.to_string()))?; + Ok(store.resolve(guid).expect("indexed")) +} + +/// Resolve a DSL name to a stored node of the expected nature. +pub(crate) fn resolve<'a>( + store: &'a Store, + name: &str, + expected: &'static str, +) -> Result<&'a Node, Error> { + let node = lookup(store, name)?; + let got = node.entity.type_name(); + if got != expected { + return Err(Error::WrongNature { + name: name.to_string(), + expected, + got, + }); + } + Ok(node) +} + +/// Only positional arguments flow through here — `Wall` handles its own +/// keyword mix (see `wall_args`). Keywords elsewhere are rejected, not +/// silently ignored. +pub(crate) fn positional_args(args: &[Argument]) -> Result, Error> { + args.iter() + .map(|arg| match arg { + Argument::Positional(expr) => Ok(expr), + Argument::Keyword(..) => Err(Error::Unsupported("keyword argument")), + }) + .collect() +} + +pub(crate) fn number(expr: &Expression) -> Result { + match expr { + Expression::Integer(i) => Ok(*i as f64), + Expression::Float(f) => Ok(*f), + _ => Err(Error::Unsupported("numeric argument")), + } +} + +pub(crate) fn name_of(expr: &Expression) -> Result { + match expr { + Expression::Identifier(name) => Ok(name.clone()), + _ => Err(Error::Unsupported("name argument")), + } +} + +/// A string-literal argument — `Building("b03", …)`. The lexer keeps the +/// surrounding quotes. +pub(crate) fn str_of(expr: &Expression) -> Result { + match expr { + Expression::String(s) => Ok(s.trim_matches('"').to_string()), + _ => Err(Error::Unsupported("string argument")), + } +} + +/// Format a float as a clean integer when it has no fractional part. +pub(crate) fn fmt_num(x: f64) -> String { + if x.fract() == 0.0 && x.abs() < 1e15 { + format!("{}", x as i64) + } else { + format!("{x}") + } +} + +/// The variable name a node is referenced by — its DSL name. Every node is +/// named; there are no anonymous values. +pub(crate) fn ref_name(store: &Store, guid: Guid) -> String { + store.name_of(guid).expect("named").to_string() +} diff --git a/eval/src/nodes/operator/cut.rs b/eval/src/nodes/operator/cut.rs new file mode 100644 index 0000000..6e9b750 --- /dev/null +++ b/eval/src/nodes/operator/cut.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Cut — the whole engine story of `Cut`. +//! +//! Nature lives in the kernel (`bimr::operator::Cut`): the source by value, +//! the piece count, the cutting methods (circle first). Same record shape +//! as `Divide`: the statement is a factory with a persistent root — the +//! record stays (parametric), the pieces are created as named nodes edged +//! to it, and resolving `d1` yields the created collection. + +use bimr::Entity; +use bimr::operator::Cut as KernelCut; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, positional_args, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `ct = Cut(source, n)` — positional only. The source must be a Circle +/// (the only cutting supported so far); `n` an integer ≥ 1. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = positional_args(args)?; + if positional.len() != 2 { + return Err(Error::Unsupported("arity")); + } + let source = name_of(positional[0])?; + let n = count(positional[1])?; + let source_node = lookup(store, &source)?; + let source_entity = match &source_node.entity { + Entity::Circle(c) => Entity::Circle(*c), + Entity::Curve(c) => Entity::Curve(c.clone()), + other => { + return Err(Error::WrongNature { + name: source, + expected: "Circle or Curve", + got: other.type_name(), + }); + } + }; + + // The record first — items and consumers hang off it. + let cut = KernelCut::new(source_entity.clone(), n); + let record = store.insert(file, name, Entity::Cut(cut), vec![source_node.guid]); + + // The created pieces: named `_`, edges to the record. All + // names checked before any node is created. + let lines = KernelCut::new(source_entity, n) + .lines() + .map_err(|_| Error::Unsupported("cut"))?; + let mut names = Vec::with_capacity(n); + for i in 0..n { + let item_name = format!("{name}_{i}"); + if store.named.contains_key(&item_name) { + return Err(Error::NameCollision(item_name)); + } + names.push(item_name); + } + let mut items = Vec::with_capacity(n); + for (i, item_name) in names.iter().enumerate() { + let guid = store.insert(file, item_name, Entity::Line(lines[i]), vec![record]); + items.push(guid); + } + store.outputs.insert(record, items); + Ok(()) +} + +/// The piece count: an integral number ≥ 1. +fn count(expr: &parser::ast::Expression) -> Result { + let x = number(expr)?; + if x < 1.0 || x.fract() != 0.0 || x > usize::MAX as f64 { + return Err(Error::Unsupported("arity")); + } + Ok(x as usize) +} + +/// The compact BIMR text: `ct1 = Cut(c1, 8)` — the record only; its created +/// pieces are engine-owned and never reach the artifact. +pub(crate) fn to_bimr(store: &Store, node: &Node, c: &KernelCut) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!("{name} = Cut({source_var}, {})", fmt_num(c.n() as f64)) +} diff --git a/eval/src/nodes/operator/divide.rs b/eval/src/nodes/operator/divide.rs new file mode 100644 index 0000000..ed7cf4c --- /dev/null +++ b/eval/src/nodes/operator/divide.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Divide — the whole engine story of `Divide`. +//! +//! Nature lives in the kernel (`bimr::operator::Divide`): the source by +//! value, the count, the division methods (circle division first). The +//! engine holds the source **by value**; the edge back to the source node +//! lives in `refs`. +//! +//! The statement is a factory with a persistent root: the Divide record +//! stays (the parametric point a future engine regenerates from), and the +//! divided entities are created as named nodes — `_` — with +//! edges back to the record. Resolving `d1` yields the created collection; +//! `Column(d1, …)` lifts over it. + +use bimr::Entity; +use bimr::operator::Divide as KernelDivide; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, positional_args, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `d = Divide(source, n)` — positional only. The source must be a Circle +/// (the only division supported so far); `n` an integer ≥ 1. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = positional_args(args)?; + if positional.len() != 2 { + return Err(Error::Unsupported("arity")); + } + let source = name_of(positional[0])?; + let n = count(positional[1])?; + let source_node = lookup(store, &source)?; + let source_entity = match &source_node.entity { + Entity::Circle(c) => Entity::Circle(*c), + Entity::Curve(c) => Entity::Curve(c.clone()), + other => { + return Err(Error::WrongNature { + name: source, + expected: "Circle or Curve", + got: other.type_name(), + }); + } + }; + let divide = KernelDivide::new(source_entity.clone(), n); + let record = store.insert(file, name, Entity::Divide(divide), vec![source_node.guid]); + + // The created entities: named `_`, edges to the record. All + // names are checked before any node is created — a collision never + // leaves a partially built family. + let points = KernelDivide::new(source_entity, n) + .points() + .map_err(|_| Error::Unsupported("divide"))?; + let mut names = Vec::with_capacity(n); + for i in 0..n { + let item_name = format!("{name}_{i}"); + if store.named.contains_key(&item_name) { + return Err(Error::NameCollision(item_name)); + } + names.push(item_name); + } + let mut items = Vec::with_capacity(n); + for (i, item_name) in names.iter().enumerate() { + let guid = store.insert(file, item_name, Entity::Point(points[i]), vec![record]); + items.push(guid); + } + store.outputs.insert(record, items); + Ok(()) +} + +/// The division count: an integral number ≥ 1. +fn count(expr: &parser::ast::Expression) -> Result { + let x = number(expr)?; + if x < 1.0 || x.fract() != 0.0 || x > usize::MAX as f64 { + return Err(Error::Unsupported("arity")); + } + Ok(x as usize) +} + +/// The compact BIMR text: `d1 = Divide(c1, 8)` — the record only; its +/// created items are engine-owned and never reach the artifact. +pub(crate) fn to_bimr(store: &Store, node: &Node, d: &KernelDivide) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!("{name} = Divide({source_var}, {})", fmt_num(d.n() as f64)) +} diff --git a/eval/src/nodes/operator/explode.rs b/eval/src/nodes/operator/explode.rs new file mode 100644 index 0000000..5352054 --- /dev/null +++ b/eval/src/nodes/operator/explode.rs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Explode — the whole engine story of `Explode`. +//! +//! Nature lives in the kernel (`bimr::operator::Explode`): a Line into its +//! two endpoints, a List of Lines into pairs (each pair a List of two +//! Points). Same record shape as `Divide`/`Cut`: the statement is a factory +//! with a persistent root — the record stays, the parts are created as +//! named nodes edged to it, and resolving `ex1` yields the created +//! collection. `Frame(ex1, …)` lifts one Frame per pair. + +use bimr::Entity; +use bimr::operator::Explode as KernelExplode; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{lookup, name_of, positional_args, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `ex = Explode(source)` — positional only. The source is a Line (two +/// endpoints) or a List of Lines / a `Cut` record (one pair per line). +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = positional_args(args)?; + if positional.len() != 1 { + return Err(Error::Unsupported("arity")); + } + let source = name_of(positional[0])?; + let source_node = lookup(store, &source)?; + + // Normalize the source into a kernel Entity for the explosion. + let source_entity = match &source_node.entity { + Entity::Cut(_) => { + let lines: Vec = store + .outputs_of(source_node.guid) + .iter() + .map(|&guid| { + let node = store.resolve(guid).expect("indexed"); + match node.entity { + Entity::Line(l) => Entity::Line(l), + ref other => unreachable!("cut pieces are Lines: {}", other.type_name()), + } + }) + .collect(); + Entity::List(bimr::set::List::try_new(lines).expect("cut pieces — all Lines")) + } + entity => entity.clone(), + }; + + // The record first — items and consumers hang off it. + let explode = KernelExplode::new(source_entity.clone()); + let record = store.insert(file, name, Entity::Explode(explode), vec![source_node.guid]); + + // The created parts: named `_`, edges to the record. All + // names checked before any node is created. + let parts = KernelExplode::new(source_entity) + .parts() + .map_err(|_| Error::Unsupported("explode"))?; + let mut names = Vec::with_capacity(parts.len()); + for i in 0..parts.len() { + let item_name = format!("{name}_{i}"); + if store.named.contains_key(&item_name) { + return Err(Error::NameCollision(item_name)); + } + names.push(item_name); + } + let mut items = Vec::with_capacity(parts.len()); + for (i, item_name) in names.iter().enumerate() { + let guid = store.insert(file, item_name, parts[i].clone(), vec![record]); + items.push(guid); + } + store.outputs.insert(record, items); + Ok(()) +} + +/// The compact BIMR text: `ex1 = Explode(ct1)` — the record only; its +/// created parts are engine-owned and never reach the artifact. +pub(crate) fn to_bimr(store: &Store, node: &Node, _e: &KernelExplode) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!("{name} = Explode({source_var})") +} diff --git a/eval/src/nodes/operator/mod.rs b/eval/src/nodes/operator/mod.rs new file mode 100644 index 0000000..2dee414 --- /dev/null +++ b/eval/src/nodes/operator/mod.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +// Operator nodes — mirroring the kernel's `operator` taxonomy. + +pub(crate) mod cut; +pub(crate) mod divide; +pub(crate) mod explode; +pub(crate) mod random; diff --git a/eval/src/nodes/operator/random.rs b/eval/src/nodes/operator/random.rs new file mode 100644 index 0000000..e0f4a08 --- /dev/null +++ b/eval/src/nodes/operator/random.rs @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Random — the whole engine story of `Random`. +//! +//! Nature lives in the kernel (`bimr::operator::Random`): a seeded +//! displacement of a List of Points — dims=2 keeps the elevation. Same +//! record shape as `Divide`/`Cut`/`Explode`: the statement is a factory +//! with a persistent root; the displaced points are created as named nodes +//! edged to it. `Curve(r1, …)` consumes them. + +use bimr::Entity; +use bimr::operator::Random as KernelRandom; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{fmt_num, lookup, name_of, number, ref_name}; +use crate::store::Store; +use crate::value::Node; + +/// `r = Random(source, seed=…, min=…, max=…, dims=…)` — the source +/// positional; dims defaults to 2. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let (source, seed, min, max, dims) = random_args(args)?; + let source_node = lookup(store, &source)?; + let source_entity = match &source_node.entity { + Entity::List(list) => { + if list.kind() != "Point" { + return Err(Error::ListKind { + name: source, + expected: "Point", + got: list.kind(), + }); + } + source_node.entity.clone() + } + other => { + return Err(Error::WrongNature { + name: source, + expected: "List", + got: other.type_name(), + }); + } + }; + + // The record first — items and consumers hang off it. + let random = KernelRandom::new(source_entity.clone(), seed, min, max, dims); + let record = store.insert(file, name, Entity::Random(random), vec![source_node.guid]); + + // The displaced points: named `_`, edges to the record. + // All names checked before any node is created. + let points = KernelRandom::new(source_entity, seed, min, max, dims) + .displaced() + .map_err(|_| Error::Unsupported("random"))?; + let mut names = Vec::with_capacity(points.len()); + for i in 0..points.len() { + let item_name = format!("{name}_{i}"); + if store.named.contains_key(&item_name) { + return Err(Error::NameCollision(item_name)); + } + names.push(item_name); + } + let mut items = Vec::with_capacity(points.len()); + for (i, item_name) in names.iter().enumerate() { + let guid = store.insert(file, item_name, Entity::Point(points[i]), vec![record]); + items.push(guid); + } + store.outputs.insert(record, items); + Ok(()) +} + +/// Random's argument mix: the source positional, the scalars by keyword — +/// `Random(base, seed=128, min=-150, max=150, dims=2)`. `dims` defaults +/// to 2. Any other keyword is rejected. +fn random_args(args: &[Argument]) -> Result<(String, u64, f64, f64, u8), Error> { + let mut source = None; + let mut seed = None; + let mut min = None; + let mut max = None; + let mut dims = None; + for arg in args { + match arg { + Argument::Keyword(key, expr) => match key.as_str() { + "seed" => seed = Some(number(expr)? as u64), + "min" => min = Some(number(expr)?), + "max" => max = Some(number(expr)?), + "dims" => dims = Some(number(expr)? as u8), + _ => return Err(Error::Unsupported("keyword argument")), + }, + Argument::Positional(expr) => { + if source.is_none() { + source = Some(name_of(expr)?); + } else { + return Err(Error::Unsupported("arity")); + } + } + } + } + match (source, seed, min, max) { + (Some(source), Some(seed), Some(min), Some(max)) => { + Ok((source, seed, min, max, dims.unwrap_or(2))) + } + _ => Err(Error::Unsupported("arity")), + } +} + +/// The compact BIMR text: +/// `r1 = Random(base, seed=128, min=-150, max=150, dims=2)`. +pub(crate) fn to_bimr(store: &Store, node: &Node, r: &KernelRandom) -> String { + let name = ref_name(store, node.guid); + let source_var = ref_name(store, node.refs[0]); + format!( + "{name} = Random({source_var}, seed={}, min={}, max={}, dims={})", + fmt_num(r.seed() as f64), + fmt_num(r.min()), + fmt_num(r.max()), + fmt_num(r.dims() as f64) + ) +} diff --git a/eval/src/nodes/set/list.rs b/eval/src/nodes/set/list.rs new file mode 100644 index 0000000..92b2ea8 --- /dev/null +++ b/eval/src/nodes/set/list.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! List — the whole engine story of `List`. +//! +//! Nature lives in the kernel (`bimr::set::List`): homogeneous by +//! construction, the element kind taken from the first item. The engine +//! resolves every item by name before calling the kernel constructor; the +//! graph edges are the item GUIDs. + +use bimr::Entity; +use bimr::set::List as KernelList; +use parser::ast::Argument; + +use crate::error::Error; +use crate::nodes::{lookup, name_of, positional_args, ref_name, resolve}; +use crate::store::Store; +use crate::value::Node; + +/// `lst = List(p1, p2, …)` — identifier references only, at least one item. +/// The first item may be of any nature — it sets the element kind; every +/// later item must match it. +pub(crate) fn eval( + store: &mut Store, + file: &str, + name: &str, + args: &[Argument], +) -> Result<(), Error> { + let positional = positional_args(args)?; + if positional.is_empty() { + return Err(Error::Unsupported("arity")); + } + let names = positional + .iter() + .map(|e| name_of(e)) + .collect::, _>>()?; + + let mut kind: Option<&'static str> = None; + let mut refs = Vec::with_capacity(names.len()); + let mut items = Vec::with_capacity(names.len()); + for n in &names { + let node = match kind { + None => lookup(store, n)?, + Some(k) => resolve(store, n, k)?, + }; + refs.push(node.guid); + items.push(node.entity.clone()); + kind = Some(node.entity.type_name()); + } + + let list = KernelList::try_new(items).expect("homogeneous by resolution"); + store.insert(file, name, Entity::List(list), refs); + Ok(()) +} + +/// The compact BIMR text of a plain list: `lst1 = List(p1, p2)`. +/// Lifted lists (item kind = a constructor nature) emit their lifted +/// statement instead — the dispatch lives in [`crate::nodes::to_bimr`]. +pub(crate) fn to_bimr(store: &Store, node: &Node, _l: &KernelList) -> String { + let name = ref_name(store, node.guid); + let items = node + .refs + .iter() + .map(|g| ref_name(store, *g)) + .collect::>() + .join(", "); + format!("{name} = List({items})") +} diff --git a/eval/src/nodes/set/mod.rs b/eval/src/nodes/set/mod.rs new file mode 100644 index 0000000..83a7ba6 --- /dev/null +++ b/eval/src/nodes/set/mod.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +// Set nodes — mirroring the kernel's `set` taxonomy. + +pub(crate) mod list; diff --git a/eval/src/serialize.rs b/eval/src/serialize.rs new file mode 100644 index 0000000..3b980d2 --- /dev/null +++ b/eval/src/serialize.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Serialization — compact BIMR source that reconstructs the model. +//! +//! Format rules: `fmt_num`, `"{}, {}, {}"` points, keyword-form Wall. The +//! compact form references entities by their DSL names (`w1 = Wall(l1, …)`) +//! so the emitted source reconstructs the model — that is the whole point. +//! +//! Geometry is an on-demand per-node view: emitters call `entity.mesh()` +//! directly and skip non-mesh-bearing entities. No cache, no side-table — +//! no geometric detail lives outside the kernel. A memoized view arrives +//! only when relations demand it. + +use serde_json::json; + +use crate::nodes; +use crate::store::Store; + +/// Emit BIMR DSL source that reconstructs the model held in `store`. +/// +/// The walk is the **statements log**, not the arena: a `Divide`'s created +/// items are engine-owned nodes (they never reach the artifact — the compact +/// `Divide` form regenerates them on eval). Named statements emit in +/// construction order; the per-entity text lives in the node modules. +pub fn to_bimr(store: &Store) -> String { + store + .statements + .iter() + .map(|&guid| { + let node = store.resolve(guid).expect("indexed"); + nodes::to_bimr(store, node) + }) + .collect::>() + .join("\n") +} + +/// Output content + file extension for the cli `build` command. +/// +/// The IFCX document is the build artifact. The compact BIMR source (above) +/// is the round-trip artifact, covered by tests. +pub fn to_output(store: &Store) -> (String, &'static str) { + (to_ifcx(store), "ifcx") +} + +/// Emit an IFCX document compatible with the buildingSMART IFC5 viewer. +/// +/// Same shape as the reference IFCX format: one data entry per +/// mesh-bearing entity, `wall-001`-style paths in construction order. +/// Validated byte-for-byte against the checked-in `samples/bimr/wall.ifcx`. +pub fn to_ifcx(store: &Store) -> String { + let mut data: Vec = Vec::new(); + // One counter per node-module prefix (`wall-001`, later `column-001`). + let mut counters: std::collections::HashMap<&'static str, usize> = Default::default(); + for node in &store.arena { + // A lifted List is not mesh-bearing itself; its items are. One entry + // per item, counters keyed by the item kind (`column-001` …). + let items: &[bimr::Entity] = match &node.entity { + bimr::Entity::List(list) => list.items(), + _ => std::slice::from_ref(&node.entity), + }; + for item in items { + let Some(mesh) = item.mesh() else { + continue; + }; + let prefix = + nodes::ifcx_prefix(item).expect("a mesh-bearing entity always has a node module"); + let n = counters.entry(prefix).or_insert(0); + *n += 1; + data.push(json!({ + "path": format!("{prefix}-{:03}", n), + "attributes": { + "usd::usdgeom::mesh": { + "points": mesh.points, + "faceVertexIndices": mesh.indices, + } + } + })); + } + } + let doc = json!({ + "header": { + "ifcxVersion": "ifcx_alpha", + "dataVersion": "1.0.0" + }, + "imports": [ + { "uri": "https://ifcx.dev/@openusd.org/usd@v1.ifcx" } + ], + "schemas": {}, + "data": data + }); + serde_json::to_string_pretty(&doc).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eval::eval_program; + use parser::ast::Program; + use serde_json::Value as JsonValue; + + 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", + ); + + /// The reference compact form for the same corpus: the wall references + /// its line by name (`w1 = Wall(l1, …)`), so the emitted source + /// reconstructs the model. + const WALL_COMPACT: &str = concat!( + "p1 = Point(0, 0, 0)\n", + "p2 = Point(500, 0, 0)\n", + "l1 = Line(p1, p2)\n", + "w1 = Wall(l1, thickness=20, height=300)", + ); + + /// The checked-in ifcx artifact for the corpus — the byte-parity + /// target. + const WALL_IFCX: &str = include_str!("../../samples/bimr/wall.ifcx"); + + fn wall_store() -> Store { + let program: Program = parser::parse(WALL).expect("parse ok"); + eval_program(&program, "wall.bimr").expect("eval ok") + } + + #[test] + fn compact_form_matches_reference_format() { + let store = wall_store(); + assert_eq!(to_bimr(&store), WALL_COMPACT); + } + + #[test] + fn ifcx_matches_reference_artifact() { + let store = wall_store(); + assert_eq!(to_ifcx(&store), WALL_IFCX); + } + + #[test] + fn ifcx_renders_one_mesh_entity() { + let store = wall_store(); + let doc: JsonValue = serde_json::from_str(&to_ifcx(&store)).unwrap(); + let data = doc["data"].as_array().unwrap(); + assert_eq!(data.len(), 1); + assert_eq!(data[0]["path"], json!("wall-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); + assert_eq!(doc["header"]["ifcxVersion"], json!("ifcx_alpha")); + } + + #[test] + fn round_trip_is_stable() { + // to_bimr → parse → eval → to_bimr must be a fixed point. + let store1 = wall_store(); + let src2 = to_bimr(&store1); + let program2: Program = parser::parse(&src2).expect("round-trip parses"); + let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals"); + assert_eq!(to_bimr(&store2), src2); + } + + #[test] + fn round_tripped_model_has_same_geometry() { + let store1 = wall_store(); + let src2 = to_bimr(&store1); + let program2: Program = parser::parse(&src2).expect("round-trip parses"); + let store2 = eval_program(&program2, "wall.bimr").expect("round-trip evals"); + assert_eq!(to_ifcx(&store1), to_ifcx(&store2)); + } + + #[test] + fn floats_stay_floats() { + let src = "a = Point(0.5, 1.25, -2.0)\n"; + let program: Program = parser::parse(src).expect("parse ok"); + let store = eval_program(&program, "f.bimr").expect("eval ok"); + assert_eq!(to_bimr(&store), "a = Point(0.5, 1.25, -2)"); + } +} diff --git a/eval/src/store.rs b/eval/src/store.rs new file mode 100644 index 0000000..3f66f23 --- /dev/null +++ b/eval/src/store.rs @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! The Store — ordered arena of nodes, indexed by GUID. +//! +//! Goal 3: the eval owns identity + graph edges + the ordered construction +//! log — nothing else. Goal 2: every node is named; its GUID is +//! `Guid::from_name(file, name)`. + +use std::collections::HashMap; + +use crate::guid::Guid; + +use crate::value::Node; + +#[derive(Debug, Default)] +pub struct Store { + /// Insertion order — the construction log. + pub arena: Vec, + /// GUID → arena slot. + pub guid_index: HashMap, + /// The ordered GUID log — the construction order. + pub order: Vec, + /// DSL name → GUID (the name IS the stable guid). + pub named: HashMap, + /// Record → the nodes it created — the construction tree's edges. + /// A `Divide` maps to its divided items; regeneration (future) walks + /// these from the changed record down. + pub outputs: HashMap>, + /// The binding GUID of each source statement, in order — the walk + /// `to_bimr` emits. Engine-created nodes (a Divide's items) are in the + /// arena but are not statements, so they never reach the artifact. + pub statements: Vec, +} + +impl Store { + pub fn new() -> Self { + Self::default() + } + + /// Insert a node: attach identity as data, record the edge list, log the + /// GUID. Every node is named — the GUID is `Guid::from_name(file, name)`. + pub fn insert( + &mut self, + file: &str, + name: &str, + entity: bimr::Entity, + refs: Vec, + ) -> Guid { + let guid = Guid::from_name(file, name); + self.named.insert(name.to_string(), guid); + let slot = self.arena.len(); + self.arena.push(Node { guid, entity, refs }); + self.guid_index.insert(guid, slot); + self.order.push(guid); + guid + } + + /// Resolve a GUID to its node. + pub fn resolve(&self, guid: Guid) -> Option<&Node> { + self.guid_index.get(&guid).map(|&slot| &self.arena[slot]) + } + + /// The nodes a record created, in order. + pub fn outputs_of(&self, guid: Guid) -> &[Guid] { + self.outputs.get(&guid).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Reverse lookup: the DSL name of a GUID, if it has one. + pub fn name_of(&self, guid: Guid) -> Option<&str> { + self.named + .iter() + .find(|entry| *entry.1 == guid) + .map(|(name, _)| name.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bimr::geometry::Point; + + #[test] + fn named_insert_assigns_stable_guid() { + let mut store = Store::new(); + let g = store.insert( + "wall.bimr", + "p1", + bimr::Entity::Point(Point::new(0.0, 0.0, 0.0)), + vec![], + ); + assert_eq!(g, Guid::from_name("wall.bimr", "p1")); + assert_eq!(store.named["p1"], g); + assert_eq!(store.resolve(g).unwrap().guid, g); + } + + #[test] + fn arena_is_the_construction_log() { + let mut store = Store::new(); + let g1 = store.insert( + "wall.bimr", + "p1", + bimr::Entity::Point(Point::new(0.0, 0.0, 0.0)), + vec![], + ); + let g2 = store.insert( + "wall.bimr", + "p2", + bimr::Entity::Point(Point::new(500.0, 0.0, 0.0)), + vec![], + ); + assert_eq!(store.order, vec![g1, g2]); + assert_eq!( + store.arena.iter().map(|n| n.guid).collect::>(), + vec![g1, g2] + ); + } + + #[test] + fn name_of_reverse_lookup() { + let mut store = Store::new(); + let g = store.insert( + "wall.bimr", + "p1", + bimr::Entity::Point(Point::new(0.0, 0.0, 0.0)), + vec![], + ); + assert_eq!(store.name_of(g), Some("p1")); + } +} diff --git a/eval/src/value.rs b/eval/src/value.rs new file mode 100644 index 0000000..09f7bb1 --- /dev/null +++ b/eval/src/value.rs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +//! Identity + graph edges + the entity, in one shape. +//! +//! Goal 1 (single-source): the engine stores the **kernel** entity directly — +//! there is no mirror and this file declares no per-entity variant. +//! Goal 2 (identity as data): every node carries its GUID; references are +//! GUIDs. + +use crate::guid::Guid; +use bimr::Entity; + +/// The one node shape of the engine. +#[derive(Debug, Clone)] +pub struct Node { + pub guid: Guid, + pub entity: Entity, + /// Graph edges — the entities this node was built from. + pub refs: Vec, +} diff --git a/grammar/README.md b/grammar/README.md new file mode 100644 index 0000000..f4857e5 --- /dev/null +++ b/grammar/README.md @@ -0,0 +1,21 @@ +# Grammar + +`bimr.bnf` is a human-readable reference describing the BIMR language grammar in BNF notation. It is not consumed by the build — it documents the rules that the lexer and parser implement in code. + +## How the pipeline works + +**Lexer** (`lexer/src/lexer.rs`) — powered by [`logos`](https://github.com/maciejhirsz/logos). The grammar is expressed directly as Rust attributes on the `Token` enum: regex patterns for identifiers, integers, and strings; literal patterns for punctuation (`=`, `,`, `(`, `)`). `logos` generates the tokeniser at compile time — no external grammar file is involved. + +**Parser** (`parser/src/parser.rs`) — powered by [`chumsky`](https://github.com/zesterer/chumsky). Consumes the token stream produced by the lexer and builds an AST. The grammar rules map directly to parser combinators: + +| BNF rule | Parser combinator | +|---|---| +| `program ::= statement*` | `statement().repeated().collect()` | +| `statement ::= identifier = expression` | `identifier().then_ignore(just(Equal)).then(expression())` | +| `expression ::= function_call \| identifier \| integer \| string` | `choice((function_call, base_expr()))` | +| `function_call ::= identifier ( arguments )` | `identifier().then(...).delimited_by(LeftParen, RightParen)` | +| `arguments ::= expression (, expression)*` | `base_expr().separated_by(just(Comma))` | + +## Summary + +`bimr.bnf` serves as a concise spec to understand the language at a glance. The source of truth is the Rust code in `lexer/` and `parser/`. diff --git a/grammar/bimr.bnf b/grammar/bimr.bnf new file mode 100644 index 0000000..97f726f --- /dev/null +++ b/grammar/bimr.bnf @@ -0,0 +1,5 @@ +program ::= statement* +statement ::= identifier = expression +expression ::= function_call | identifier | integer | string +function_call ::= identifier ( arguments ) +arguments ::= expression ( , expression )* | \ No newline at end of file diff --git a/lexer/Cargo.toml b/lexer/Cargo.toml new file mode 100644 index 0000000..ba3d7e0 --- /dev/null +++ b/lexer/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lexer" +version = "0.0.1" +edition = "2024" + +[dependencies] +logos = "0.15" +clap = { version = "4.5", features = ["derive"] } + +[lib] +name = "lexer" +path = "src/lib.rs" \ No newline at end of file diff --git a/lexer/README.md b/lexer/README.md new file mode 100644 index 0000000..e2a18c6 --- /dev/null +++ b/lexer/README.md @@ -0,0 +1 @@ +# BIMR parser diff --git a/lexer/src/lexer.rs b/lexer/src/lexer.rs new file mode 100644 index 0000000..175107b --- /dev/null +++ b/lexer/src/lexer.rs @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +use logos::{Lexer, Logos, Skip}; +use std::fmt; + +/// Tokens produced by the BIMR DSL lexer. +/// +/// Whitespace (spaces, tabs, carriage returns) is silently skipped. +/// Line comments (`#` up to the end of the line) are also skipped. +/// Newlines are consumed by `newline_callback` to track line/column position +/// for error reporting; they do not appear in the token stream. +/// +/// Floats are stored as raw `u64` bit patterns because `f64` does not implement +/// `Hash` or `Eq`. Reconstruct the value with `f64::from_bits(bits)`. +#[derive(Logos, Debug, PartialEq, Clone, Hash, Eq)] +#[logos(extras = (usize, usize))] +#[logos(skip r"[ \t\r\f]+")] +#[logos(skip r"#[^\n]*")] +#[regex(r"\n", newline_callback)] +pub enum Token { + #[allow(dead_code)] + Error, + + #[regex(r"[a-zA-Z_][a-zA-Z0-9_]*", callback = |lex| lex.slice().to_string())] + Identifier(String), + + // Float stored as raw bits — f64 doesn't implement Hash/Eq. + // Reconstruct with f64::from_bits(bits). + #[regex(r"-?[0-9]+\.[0-9]+", callback = |lex| lex.slice().parse::().unwrap().to_bits())] + Float(u64), + + #[regex(r"-?[0-9]+", callback = |lex| lex.slice().parse::().unwrap())] + Integer(i32), + + #[regex("\"([^\"\\\\]|\\\\.)*\"", callback = |lex| lex.slice().to_string())] + String(String), + + #[token("=")] + Equal, + + #[token(",")] + Comma, + + #[token("(")] + LeftParen, + + #[token(")")] + RightParen, + + #[regex(r"\n", newline_callback)] + Newline, +} + +/// Logos callback invoked on every newline character. +/// +/// Increments the line counter (`extras.0`) and records the byte offset of +/// the start of the new line (`extras.1`). Returns `Skip` so the newline +/// token is not emitted into the token stream. +fn newline_callback(lex: &mut Lexer) -> Skip { + lex.extras.0 += 1; + lex.extras.1 = lex.span().start + 1; + Skip +} + +impl fmt::Display for Token { + /// Formats a token as the original source text it was produced from. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Token::Error => write!(f, "Error"), + Token::Identifier(id) => write!(f, "{}", id), + Token::Float(bits) => write!(f, "{}", f64::from_bits(*bits)), + Token::Integer(i) => write!(f, "{}", i), + Token::String(s) => write!(f, "{}", s), + Token::Equal => write!(f, "="), + Token::Comma => write!(f, ","), + Token::LeftParen => write!(f, "("), + Token::RightParen => write!(f, ")"), + Token::Newline => writeln!(f), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use logos::Logos; + use std::fs; + use std::path::Path; + + /// Smoke-tests the lexer on a small inline BIMR program, + /// verifying that it produces a non-empty token stream starting with + /// an identifier followed by `=`. + #[test] + fn test_lexer_simple() { + let input = r#"p1 = Point(0,0,0) +p2 = Point(500,0,0) +l1 = Line(p1,p2) +v1 = Normal(l1,"up") +v2 = Vector(0,0,250) +f1 = Extrude(l1,v1) +v1 = Extrude(f1,v2) +w1 = Wall(v1)"#; + + let mut lexer = Token::lexer(input); + let mut tokens = Vec::new(); + for token in lexer.by_ref() { + match token { + Ok(t) => tokens.push(t), + Err(_) => panic!("Lexer error"), + } + } + + // Basic checks + assert!(!tokens.is_empty()); + assert!(matches!(tokens[0], Token::Identifier(_))); + assert!(matches!(tokens[1], Token::Equal)); + // More detailed checks can be added + } + + /// Verifies that `#` line comments are silently skipped, whether they + /// appear on their own line or trailing after code. + #[test] + fn test_lexer_skips_line_comments() { + let input = r#"# a full-line comment +p1 = Point(0,0,0) # trailing comment"#; + let tokens = Token::lexer(input) + .filter_map(Result::ok) + .collect::>(); + assert!(tokens.iter().all(|t| !matches!(t, Token::Error))); + assert_eq!(tokens[0], Token::Identifier("p1".to_string())); + assert!(tokens.contains(&Token::Identifier("Point".to_string()))); + assert!(tokens.len() < 20, "comment text leaked into token stream"); + } + + /// Verifies that a `#` inside a string literal is not treated as a + /// comment start. + #[test] + fn test_lexer_hash_inside_string_is_not_comment() { + let input = r#"x = "a#b""#; + let tokens = Token::lexer(input) + .filter_map(Result::ok) + .collect::>(); + assert!( + tokens.contains(&Token::String(r#""a#b""#.to_string())), + "expected the full string token, got {:?}", + tokens + ); + } + + /// Runs the lexer over every `.bimr` file in the `samples/` directory + /// and asserts that no lex errors occur, printing the offending line and + /// column when one does. + #[test] + #[allow(clippy::while_let_on_iterator)] + fn test_lexer_on_bimr_files() { + let folder_path = "../samples"; + assert!(Path::new(folder_path).exists()); + + for f in fs::read_dir(folder_path).unwrap() { + let entry = f.unwrap(); + let p = entry.path(); + if p.extension() + .map(|ext| ext.to_str() == Some("bimr")) + .unwrap_or(false) + { + println!("Lexing file: {:?}", p); + let input = fs::read_to_string(&p).expect("Failed to read file"); + + let mut lexer = Token::lexer(&input); + while let Some(token) = lexer.next() { + let span = lexer.span(); + match token { + Ok(_) => {} + Err(_) => { + let line = lexer.extras.0; + panic!( + "error lexing {:?}: '{:?}' at {:?} on line {}\n\n{}\n\n", + p, + &input[span.clone()], + span, + line, + input.lines().nth(line).unwrap() + ); + } + } + } + } + } + } +} diff --git a/lexer/src/lib.rs b/lexer/src/lib.rs new file mode 100644 index 0000000..45b8f09 --- /dev/null +++ b/lexer/src/lib.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +pub mod lexer; + +pub use crate::lexer::Token; diff --git a/lexer/src/main.rs b/lexer/src/main.rs new file mode 100644 index 0000000..ce32cf9 --- /dev/null +++ b/lexer/src/main.rs @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +mod lexer; + +use clap::Parser; +use lexer::Token; +use logos::Logos; +use std::fs; +use std::path::Path; + +#[derive(Parser)] +#[command(name = "bimr-lexer")] +#[command(about = "A lexer for BIMR DSL")] +#[command(version)] +struct Args { + /// Input BIMR (.bimr) file to lex + #[arg(value_name = "FILE|DIRECTORY")] + file: String, + + /// Print verbose output including all tokens + #[arg(short, long)] + verbose: bool, +} + +/// Lexes a single `.bimr` file and reports any errors to stderr. +/// +/// When `verbose` is true, every token is printed to stdout along with its +/// byte span and source text, and a success/failure summary is printed at the +/// end. +fn lex_file(file_path: &Path, verbose: bool) { + if verbose { + println!("Lexing file: {}", file_path.display()); + } + let input = fs::read_to_string(file_path).expect("Failed to read file"); + + let mut lexer = Token::lexer(&input); + let mut success = true; + + while let Some(token) = lexer.next() { + let span = lexer.span(); + match token { + Ok(t) => { + if verbose { + println!( + "Token: {:?}, Span: {:?}, Text: '{}'", + t, + span, + &input[span.clone()] + ); + } + } + Err(_) => { + success = false; + + let line_num = lexer.extras.0; + let line_start = lexer.extras.1; + + let line_text = input.lines().nth(line_num).unwrap_or(""); + let col_start = span.start - line_start; + let col_end = span.end - line_start; + + let mut visual_col = 0; + for (i, ch) in line_text.char_indices() { + if i >= col_start { + break; + } + if ch == '\t' { + visual_col += 8 - (visual_col % 8); + } else { + visual_col += 1; + } + } + + let indicator = " ".repeat(visual_col) + &"^".repeat((col_end - col_start).max(1)); + + eprintln!( + "Error lexing '{}': '{}' at {:?} on line {} column {}\n\n{}\n{}\n", + file_path.display(), + &input[span.clone()], + span, + line_num + 1, + col_start + 1, + line_text, + indicator + ); + } + } + } + + if verbose { + if success { + println!("✓ Successfully lexed '{}'", file_path.display()); + } else { + println!("✗ Failed to lex '{}'", file_path.display()); + } + } +} + +/// Entry point for the `bimr-lexer` CLI tool. +/// +/// Accepts a single file path or a directory. When a directory is given, every +/// `.bimr` file it contains is lexed in turn. +fn main() { + let args = Args::parse(); + let path = Path::new(&args.file); + + if !path.exists() { + eprintln!("Error: file or directory '{}' not found", args.file); + return; + } + + if path.is_dir() { + if args.verbose { + println!("Lexing all .bimr files in directory: {}", args.file); + } + for entry in fs::read_dir(path).expect("Failed to read directory") { + let entry = entry.expect("Failed to read directory entry"); + let file_path = entry.path(); + if file_path.is_file() + && let Some(ext) = file_path.extension() + && ext == "bimr" + { + lex_file(&file_path, args.verbose); + } + } + } else { + lex_file(path, args.verbose); + } +} diff --git a/parser/Cargo.toml b/parser/Cargo.toml new file mode 100644 index 0000000..4a78837 --- /dev/null +++ b/parser/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "parser" +version = "0.0.1" +edition = "2024" + +[dependencies] +chumsky = "0.10.0" +logos = "0.15" +lexer = { path = "../lexer" } + +[lib] +name = "parser" +path = "src/lib.rs" + +[[bin]] +name = "bimr-parser" +path = "src/main.rs" diff --git a/parser/src/ast.rs b/parser/src/ast.rs new file mode 100644 index 0000000..595f42f --- /dev/null +++ b/parser/src/ast.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +/// Root AST node. A BIMR program is an ordered sequence of statements. +#[derive(Debug, Clone)] +pub struct Program { + pub statements: Vec, +} + +/// A single statement. Every statement in the BIMR DSL is an assignment. +#[derive(Debug, Clone)] +pub struct Statement { + pub assignment: Assignment, +} + +/// An assignment binds the result of an expression to a named identifier. +/// Example: `w1 = Wall(l1, thickness=200, height=3000)` +#[derive(Debug, Clone)] +pub struct Assignment { + pub identifier: String, + pub expression: Expression, +} + +/// An expression on the right-hand side of an assignment. +/// +/// The BIMR DSL supports function calls, bare identifier references (variable +/// lookups), numeric literals, string literals, and inline tuples used as +/// positional point arguments inside `List(…)` calls. +#[derive(Debug, Clone)] +pub enum Expression { + FunctionCall(FunctionCall), + Identifier(String), + Integer(i32), + Float(f64), + String(String), + /// Inline tuple `(a, b)` or `(a, b, c)` — used for point literals inside + /// `List(…)` arguments. Requires at least two elements. + Tuple(Vec), +} + +/// A function argument — either positional or keyword. +/// +/// Example positional: `Wall(l1, 200, 3000)` → three `Positional` args. +/// Example keyword: `Wall(l1, thickness=200)` → one `Keyword` arg. +#[derive(Debug, Clone)] +pub enum Argument { + Positional(Expression), + Keyword(String, Expression), +} + +/// A function call expression: a name followed by a parenthesised argument list. +/// +/// All built-in BIMR constructors (`Point`, `Wall`, `Each`, …) and generative +/// operators are represented as `FunctionCall` nodes. Dispatch to the correct +/// `eval_call` arm happens in the evaluator by matching on `name`. +#[derive(Debug, Clone)] +pub struct FunctionCall { + pub name: String, + pub arguments: Vec, +} diff --git a/parser/src/lib.rs b/parser/src/lib.rs new file mode 100644 index 0000000..f09dd23 --- /dev/null +++ b/parser/src/lib.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +pub mod ast; +pub mod parser; + +pub use parser::parse; diff --git a/parser/src/main.rs b/parser/src/main.rs new file mode 100644 index 0000000..7eabc90 --- /dev/null +++ b/parser/src/main.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +use std::fs; + +/// Entry point for the `bimr-parser` CLI tool. +/// +/// Reads a `.bimr` source file given as the first command-line argument, +/// parses it, and prints the resulting AST (on success) or the list of parse +/// errors (on failure). +fn main() -> Result<(), std::io::Error> { + let file_path = std::env::args().nth(1).unwrap(); + let src = fs::read_to_string(&file_path).unwrap(); + + match parser::parse(&src) { + Ok(parsed) => { + println!("Parsed successfully!"); + println!("AST: {:#?}", parsed); + } + Err(errors) => { + for error in errors { + println!("Error: {:?}", error); + } + } + } + + Ok(()) +} diff --git a/parser/src/parser.rs b/parser/src/parser.rs new file mode 100644 index 0000000..4aae9a5 --- /dev/null +++ b/parser/src/parser.rs @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +use crate::ast::*; +use chumsky::prelude::*; +use chumsky::{input::Stream, input::ValueInput}; +use lexer::Token; +use logos::Logos; + +/// Parses a BIMR DSL source string into a `Program` AST. +/// +/// Lexes `input` with `logos`, wraps the token iterator in a `chumsky` +/// `Stream`, and runs the `program` combinator. Returns the AST on success or +/// a list of `Simple` parse errors on failure. +pub fn parse(input: &str) -> Result>> { + let token_iter = Token::lexer(input).spanned().map(|(tok, span)| match tok { + Ok(tok) => (tok, SimpleSpan::from(span)), + Err(()) => (Token::Error, span.into()), + }); + + let token_stream = Stream::from_iter(token_iter).map((0..input.len()).into(), |(t, s)| (t, s)); + + program().parse(token_stream).into_result() +} + +/// Parser combinator for a complete BIMR program. +/// +/// A program is zero or more `statement`s collected into a `Program` node. +fn program<'tokens, I>() -> impl Parser<'tokens, I, Program, extra::Err>> +where + I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>, +{ + statement() + .repeated() + .collect() + .map(|statements| Program { statements }) +} + +/// Parser combinator for a single assignment statement. +/// +/// Grammar: `identifier = expression` +fn statement<'tokens, I>() -> impl Parser<'tokens, I, Statement, extra::Err>> +where + I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>, +{ + identifier() + .then_ignore(just(Token::Equal)) + .then(expression()) + .map(|(identifier, expression)| Statement { + assignment: Assignment { + identifier, + expression, + }, + }) +} + +/// Parser combinator for an expression (RHS of an assignment). +/// +/// Tries, in order: function call, tuple, bare atom (identifier / number / +/// string). Function calls are tried first to avoid mis-parsing `Name(…)` as +/// a bare identifier followed by junk. +fn expression<'tokens, I>() +-> impl Parser<'tokens, I, Expression, extra::Err>> +where + I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>, +{ + // Atomic expressions — no tuple here to avoid ambiguity with function call parens. + let atom = || { + choice(( + identifier().map(Expression::Identifier), + select! { Token::Float(bits) => Expression::Float(f64::from_bits(bits)) }, + select! { Token::Integer(i) => Expression::Integer(i) }, + select! { Token::String(s) => Expression::String(s) }, + )) + }; + + // Tuple: (expr, expr) or (expr, expr, expr) — at least two elements. + // Used for inline point literals inside List(...) args. + // Constructed as a closure so it can be used in multiple places without Clone. + let tuple = || { + atom() + .separated_by(just(Token::Comma)) + .at_least(2) + .collect::>() + .delimited_by(just(Token::LeftParen), just(Token::RightParen)) + .map(Expression::Tuple) + }; + + // A single argument: either `ident=expr` (keyword) or `expr` (positional). + // atom() is used as the value expression to keep things non-recursive. + let argument = identifier() + .then_ignore(just(Token::Equal)) + .then(atom()) + .map(|(key, val)| Argument::Keyword(key, val)) + .or(choice((tuple(), atom())).map(Argument::Positional)); + + // Function call: Identifier(arg, ...) + let function_call = identifier() + .then( + argument + .separated_by(just(Token::Comma)) + .allow_trailing() + .collect::>() + .delimited_by(just(Token::LeftParen), just(Token::RightParen)), + ) + .map(|(name, arguments)| Expression::FunctionCall(FunctionCall { name, arguments })); + + choice((function_call, tuple(), atom())) +} + +/// Parser combinator that matches a single `Token::Identifier` and returns +/// its string value. +fn identifier<'tokens, I>() -> impl Parser<'tokens, I, String, extra::Err>> +where + I: ValueInput<'tokens, Token = Token, Span = SimpleSpan>, +{ + select! { Token::Identifier(s) => s } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_one(src: &str) -> Program { + parse(src).expect("parse failed") + } + + // ── Basic assignments ───────────────────────────────────────────────────── + + #[test] + fn test_parse_integer() { + let prog = parse_one("x = 42"); + assert_eq!(prog.statements.len(), 1); + let stmt = &prog.statements[0].assignment; + assert_eq!(stmt.identifier, "x"); + assert!(matches!(stmt.expression, Expression::Integer(42))); + } + + #[test] + #[allow(clippy::approx_constant)] + fn test_parse_float() { + let prog = parse_one("x = 3.14"); + let expr = &prog.statements[0].assignment.expression; + assert!(matches!(expr, Expression::Float(v) if (v - 3.14).abs() < 1e-10)); + } + + #[test] + fn test_parse_negative_int() { + let prog = parse_one("x = -7"); + let expr = &prog.statements[0].assignment.expression; + assert!(matches!(expr, Expression::Integer(-7))); + } + + #[test] + fn test_parse_string() { + // The lexer preserves the surrounding quotes in the token value. + let prog = parse_one(r#"x = "hello""#); + let expr = &prog.statements[0].assignment.expression; + assert!(matches!(expr, Expression::String(s) if s == r#""hello""#)); + } + + #[test] + fn test_parse_identifier() { + let prog = parse_one("x = y"); + let expr = &prog.statements[0].assignment.expression; + assert!(matches!(expr, Expression::Identifier(id) if id == "y")); + } + + // ── Function calls ──────────────────────────────────────────────────────── + + #[test] + fn test_parse_point() { + let prog = parse_one("p1 = Point(0, 0, 0)"); + let expr = &prog.statements[0].assignment.expression; + if let Expression::FunctionCall(fc) = expr { + assert_eq!(fc.name, "Point"); + assert_eq!(fc.arguments.len(), 3); + } else { + panic!("expected FunctionCall"); + } + } + + #[test] + fn test_parse_line() { + let prog = parse_one("l1 = Line(p1, p2)"); + let expr = &prog.statements[0].assignment.expression; + if let Expression::FunctionCall(fc) = expr { + assert_eq!(fc.name, "Line"); + assert_eq!(fc.arguments.len(), 2); + } else { + panic!("expected FunctionCall"); + } + } + + #[test] + fn test_parse_wall_with_keywords() { + let prog = parse_one("w1 = Wall(l1, thickness=200, height=3000)"); + let expr = &prog.statements[0].assignment.expression; + if let Expression::FunctionCall(fc) = expr { + assert_eq!(fc.name, "Wall"); + assert_eq!(fc.arguments.len(), 3); + assert!(matches!(&fc.arguments[0], Argument::Positional(_))); + assert!(matches!(&fc.arguments[1], Argument::Keyword(k, _) if k == "thickness")); + assert!(matches!(&fc.arguments[2], Argument::Keyword(k, _) if k == "height")); + } else { + panic!("expected FunctionCall"); + } + } + + #[test] + fn test_parse_each_operator() { + let src = r#"e1 = Each(div, "Frame", fw=200, height=300, tangent="auto")"#; + let prog = parse_one(src); + let expr = &prog.statements[0].assignment.expression; + if let Expression::FunctionCall(fc) = expr { + assert_eq!(fc.name, "Each"); + assert!(fc.arguments.len() >= 2); + } else { + panic!("expected FunctionCall"); + } + } + + // ── Multiple statements ─────────────────────────────────────────────────── + + #[test] + fn test_parse_multiple_statements() { + let src = "p1 = Point(0, 0, 0)\np2 = Point(100, 0, 0)\nl1 = Line(p1, p2)"; + let prog = parse_one(src); + assert_eq!(prog.statements.len(), 3); + } + + #[test] + fn test_parse_empty_program() { + let prog = parse_one(""); + assert!(prog.statements.is_empty()); + } + + // ── Inline tuples ───────────────────────────────────────────────────────── + + #[test] + fn test_parse_tuple_in_list() { + let src = "lst = List((0, 0, 0), (100, 0, 0))"; + let prog = parse_one(src); + let expr = &prog.statements[0].assignment.expression; + if let Expression::FunctionCall(fc) = expr { + assert_eq!(fc.name, "List"); + assert_eq!(fc.arguments.len(), 2); + for arg in &fc.arguments { + assert!(matches!(arg, Argument::Positional(Expression::Tuple(_)))); + } + } else { + panic!("expected FunctionCall"); + } + } + + // ── Comments ────────────────────────────────────────────────────────────── + + #[test] + fn test_parse_with_comments() { + let src = + "# a full-line comment\np1 = Point(0, 0, 0) # trailing comment\nl1 = Line(p1, p1)"; + let prog = parse_one(src); + assert_eq!(prog.statements.len(), 2); + } + + #[test] + fn test_parse_comment_before_statement() { + let prog = parse_one("# nothing here yet\nx = 42"); + assert_eq!(prog.statements.len(), 1); + assert_eq!(prog.statements[0].assignment.identifier, "x"); + } + + // ── Error handling ──────────────────────────────────────────────────────── + + #[test] + fn test_parse_error_missing_paren() { + let result = parse("p1 = Point(0, 0"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_error_unclosed_string() { + let result = parse(r#"x = "hello"#); + assert!(result.is_err()); + } + + #[test] + fn test_parse_error_bad_syntax() { + let result = parse("= 42"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_error_unknown_token() { + let result = parse("x = @@@"); + assert!(result.is_err()); + } +} diff --git a/samples/bimr/column_grid.bimr b/samples/bimr/column_grid.bimr new file mode 100644 index 0000000..bea2cb6 --- /dev/null +++ b/samples/bimr/column_grid.bimr @@ -0,0 +1,18 @@ +p1 = Point(0,0,0) +p2 = Point(0,1000,0) +p3 = Point(0,2000,0) +p4 = Point(0,3000,0) +p5 = Point(1000,0,0) +p6 = Point(1000,1000,0) +p7 = Point(1000,2000,0) +p8 = Point(1000,3000,0) +p9 = Point(2000,0,0) +p10 = Point(2000,1000,0) +p11 = Point(2000,2000,0) +p12 = Point(2000,3000,0) +p13 = Point(3000,0,0) +p14 = Point(3000,1000,0) +p15 = Point(3000,2000,0) +p16 = Point(3000,3000,0) +lst = List(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15, p16) +cols = Column(lst,300,30,30) diff --git a/samples/bimr/wall.bimr b/samples/bimr/wall.bimr new file mode 100644 index 0000000..1e035c4 --- /dev/null +++ b/samples/bimr/wall.bimr @@ -0,0 +1,4 @@ +p1 = Point(0,0,0) +p2 = Point(500,0,0) +l1 = Line(p1,p2) +w1 = Wall(l1,20,300) diff --git a/samples/bimr/wall.ifcx b/samples/bimr/wall.ifcx new file mode 100644 index 0000000..2628a33 --- /dev/null +++ b/samples/bimr/wall.ifcx @@ -0,0 +1,181 @@ +{ + "data": [ + { + "attributes": { + "usd::usdgeom::mesh": { + "faceVertexIndices": [ + 0, + 1, + 2, + 0, + 2, + 3, + 4, + 5, + 6, + 4, + 6, + 7, + 8, + 9, + 10, + 8, + 10, + 11, + 12, + 13, + 14, + 12, + 14, + 15, + 16, + 17, + 18, + 16, + 18, + 19, + 20, + 21, + 22, + 20, + 22, + 23 + ], + "points": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + -20.0, + 0.0 + ], + [ + 500.0, + -20.0, + 0.0 + ], + [ + 500.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 300.0 + ], + [ + 500.0, + 0.0, + 300.0 + ], + [ + 500.0, + -20.0, + 300.0 + ], + [ + 0.0, + -20.0, + 300.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 500.0, + 0.0, + 0.0 + ], + [ + 500.0, + 0.0, + 300.0 + ], + [ + 0.0, + 0.0, + 300.0 + ], + [ + 0.0, + -20.0, + 0.0 + ], + [ + 0.0, + -20.0, + 300.0 + ], + [ + 500.0, + -20.0, + 300.0 + ], + [ + 500.0, + -20.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 300.0 + ], + [ + 0.0, + -20.0, + 300.0 + ], + [ + 0.0, + -20.0, + 0.0 + ], + [ + 500.0, + 0.0, + 0.0 + ], + [ + 500.0, + -20.0, + 0.0 + ], + [ + 500.0, + -20.0, + 300.0 + ], + [ + 500.0, + 0.0, + 300.0 + ] + ] + } + }, + "path": "wall-001" + } + ], + "header": { + "dataVersion": "1.0.0", + "ifcxVersion": "ifcx_alpha" + }, + "imports": [ + { + "uri": "https://ifcx.dev/@openusd.org/usd@v1.ifcx" + } + ], + "schemas": {} +} \ No newline at end of file diff --git a/samples/bimr/wall_v2.bimr b/samples/bimr/wall_v2.bimr new file mode 100644 index 0000000..41c44dd --- /dev/null +++ b/samples/bimr/wall_v2.bimr @@ -0,0 +1,8 @@ +p1 = Point(0,0,0) +p2 = Point(800,0,0) +l1 = Line(p1,p2) +w1 = Wall(l1,20,300) +p3 = Point(0,1000,0) +p4 = Point(800,1000,0) +l2 = Line(p3,p4) +w2 = Wall(l2,20,300) diff --git a/samples/python/building.py b/samples/python/building.py new file mode 100644 index 0000000..42b1c39 --- /dev/null +++ b/samples/python/building.py @@ -0,0 +1,30 @@ +# Welcome to BIMR.NET! +# This is a live editor, try tweaking this Python example + +N_STOREYS = 8 +N_SEGMENTS = 50 +FRAME_W, FRAME_H, FRAME_D, FRAME_T = 200, 180, 35, 10 +SLAB_T = 150 +STOREY_H = FRAME_H + 2 * SLAB_T + +BASE = [ + (-1500, -300), + (0, 1000), + (850, 800), + (300, 0), + (500, -800), + (0, -1000), + (-500, -800), + (-300, 0), +] + +for storey in range(N_STOREYS): + z = storey * STOREY_H + base = List(*[Point(x, y, z) for x, y in BASE]) + pts = Random(base, seed=128 + storey, min=-150, max=150, dims=2) + curve = Curve(pts) + s_down = Slab(curve, SLAB_T, z) + s_up = Slab(curve, SLAB_T, z + SLAB_T + FRAME_H) + segments = Cut(curve, N_SEGMENTS) + pairs = Explode(segments) + frames = Frame(pairs, FRAME_W, FRAME_H, FRAME_D, FRAME_T) diff --git a/samples/python/cylinders.py b/samples/python/cylinders.py new file mode 100644 index 0000000..9468b35 --- /dev/null +++ b/samples/python/cylinders.py @@ -0,0 +1,12 @@ +# cylinders.py + +def cylinder(x,y,z,r,h): + center = Point(x, y, z) + circle = Circle(center, r) + segments = Cut(circle, 12) + pairs = Explode(segments) + frames = Frame(pairs, 100, h, 60, 60) + +cylinder(0,0,0,1000,300) +cylinder(-200,200,0,500,600) +cylinder(-1800,-1000,0,500,600) diff --git a/samples/python/grid.py b/samples/python/grid.py new file mode 100644 index 0000000..273ebd1 --- /dev/null +++ b/samples/python/grid.py @@ -0,0 +1,5 @@ +# grid.py + +pts = [Point(x * -100, y * -100, 0) for x in range(40) for y in range(40)] +grid = List(*pts) +cols = Column(grid, 300, 30, 30)