release: 0.0.1

This commit is contained in:
Milovann Yanatchkov 2026-09-01 15:32:49 +02:00
commit 8db86c6a63
57 changed files with 6789 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
.claude/
.opencode/

6
Cargo.toml Normal file
View file

@ -0,0 +1,6 @@
[workspace]
members = ["lexer", "parser", "cli", "eval"]
resolver = "3"
[patch."https://gitaec.org/rvba/bimr-kernel.git"]
bimr-kernel = { path = "../bimr-kernel" }

21
LICENSE Normal file
View file

@ -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.

33
Makefile Normal file
View file

@ -0,0 +1,33 @@
# bimr-engine — test targets.
.PHONY: test test-eval test-cli test-core clippy fmt check build run-sample
test: test-core test-eval test-cli
test-core:
cargo test -p lexer -p parser
test-eval:
cargo test -p eval
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
run-sample:
cargo run --quiet --bin bimr-cli -- build samples/bimr/wall.bimr --output /tmp/opencode/wall.ifcx
@cat /tmp/opencode/wall.ifcx

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# bimr-engine
Parse and eval a ``.bimr`` file to generate a BIM model.

13
cli/Cargo.toml Normal file
View file

@ -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"] }

70
cli/src/main.rs Normal file
View file

@ -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: <input>.ifcx)
#[clap(short, long)]
output: Option<PathBuf>,
},
}
/// Read a file to string.
fn read_file(path: &PathBuf) -> Result<String, String> {
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<eval::Store, String> {
let src = read_file(input)?;
let program = parser::parse(&src).map_err(|errors| {
let msgs: Vec<String> = 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(())
}

81
cli/tests/build.rs Normal file
View file

@ -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<String, String> {
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 <input>.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, &copy).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
);
}

9
eval/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "eval"
version = "0.0.1"
edition = "2024"
[dependencies]
bimr = { package = "bimr-kernel", git = "https://gitaec.org/rvba/bimr-kernel.git" }
parser = { path = "../parser" }
serde_json = "1"

53
eval/src/error.rs Normal file
View file

@ -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 {}

1635
eval/src/eval.rs Normal file

File diff suppressed because it is too large Load diff

73
eval/src/guid.rs Normal file
View file

@ -0,0 +1,73 @@
// 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: 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);
}
}

24
eval/src/lib.rs Normal file
View file

@ -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;

View file

@ -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<Entity> = 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")),
}
}

View file

@ -0,0 +1,158 @@
// 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.
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<Entity> = 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::<Vec<_>>()
}
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<crate::guid::Guid>,
) -> 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::<Vec<_>>();
let lifted = bimr::set::List::try_new(cols.into_iter().map(Entity::Column).collect::<Vec<_>>())
.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")),
}
}

View file

@ -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::<Vec<_>>();
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<Vec<(Point, Point)>, Error> {
let pair_lists: Vec<Entity> = 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<String>, Scalars), Error> {
let mut names: Vec<String> = Vec::new();
let mut numbers: Vec<f64> = 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))
}

View file

@ -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;

View file

@ -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<Vec3> = 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::<Vec<_>>(),
)?,
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<Vec<Vec3>, 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")),
}
}

View file

@ -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<Entity> = 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")),
}
}

View file

@ -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")),
}
}

View file

@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
//! Element nodes — mirroring `bimr-kernel/src/element/`.
pub(crate) mod architecture;

View file

@ -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, &center)?;
let p = match &center_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))
}

View file

@ -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<bimr::geometry::Point> = 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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})")
}

View file

@ -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<Line> = 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})")
}

View file

@ -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())
}

View file

@ -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;

View file

@ -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)
)
}

View file

@ -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)
)
}

193
eval/src/nodes/mod.rs Normal file
View file

@ -0,0 +1,193 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
//! The nodes — one file per entity, mirroring the kernel's taxonomy.
//!
//! 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<Vec<&Expression>, 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<f64, Error> {
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<String, Error> {
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<String, Error> {
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()
}

View file

@ -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 `<binding>_<i>`, 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<usize, Error> {
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))
}

View file

@ -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 — `<binding>_<i>` — 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 `<binding>_<i>`, 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<usize, Error> {
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))
}

View file

@ -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<Entity> = 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 `<binding>_<i>`, 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})")
}

View file

@ -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;

View file

@ -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 `<binding>_<i>`, 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)
)
}

View file

@ -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::<Result<Vec<_>, _>>()?;
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::<Vec<_>>()
.join(", ");
format!("{name} = List({items})")
}

View file

@ -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;

179
eval/src/serialize.rs Normal file
View file

@ -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::<Vec<_>>()
.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<serde_json::Value> = 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)");
}
}

130
eval/src/store.rs Normal file
View file

@ -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<Node>,
/// GUID → arena slot.
pub guid_index: HashMap<Guid, usize>,
/// The ordered GUID log — the construction order.
pub order: Vec<Guid>,
/// DSL name → GUID (the name IS the stable guid).
pub named: HashMap<String, Guid>,
/// 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<Guid, Vec<Guid>>,
/// 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<Guid>,
}
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>,
) -> 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<_>>(),
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"));
}
}

21
eval/src/value.rs Normal file
View file

@ -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<Guid>,
}

21
grammar/README.md Normal file
View file

@ -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/`.

5
grammar/bimr.bnf Normal file
View file

@ -0,0 +1,5 @@
program ::= statement*
statement ::= identifier = expression
expression ::= function_call | identifier | integer | string
function_call ::= identifier ( arguments )
arguments ::= expression ( , expression )* |

12
lexer/Cargo.toml Normal file
View file

@ -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"

1
lexer/README.md Normal file
View file

@ -0,0 +1 @@
# BIMR parser

191
lexer/src/lexer.rs Normal file
View file

@ -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::<f64>().unwrap().to_bits())]
Float(u64),
#[regex(r"-?[0-9]+", callback = |lex| lex.slice().parse::<i32>().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<Token>) -> 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::<Vec<_>>();
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::<Vec<_>>();
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()
);
}
}
}
}
}
}
}

6
lexer/src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
pub mod lexer;
pub use crate::lexer::Token;

130
lexer/src/main.rs Normal file
View file

@ -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);
}
}

17
parser/Cargo.toml Normal file
View file

@ -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"

60
parser/src/ast.rs Normal file
View file

@ -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<Statement>,
}
/// 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<Expression>),
}
/// 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<Argument>,
}

7
parser/src/lib.rs Normal file
View file

@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
pub mod ast;
pub mod parser;
pub use parser::parse;

28
parser/src/main.rs Normal file
View file

@ -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(())
}

298
parser/src/parser.rs Normal file
View file

@ -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<Program, Vec<Simple<'_, Token>>> {
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<Simple<'tokens, Token>>>
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<Simple<'tokens, Token>>>
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<Simple<'tokens, Token>>>
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::<Vec<_>>()
.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::<Vec<_>>()
.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<Simple<'tokens, Token>>>
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());
}
}

128
samples/bimr/building.bimr Normal file
View file

@ -0,0 +1,128 @@
p1 = Point(-1500, -300, 0)
p2 = Point(0, 1000, 0)
p3 = Point(850, 800, 0)
p4 = Point(300, 0, 0)
p5 = Point(500, -800, 0)
p6 = Point(0, -1000, 0)
p7 = Point(-500, -800, 0)
p8 = Point(-300, 0, 0)
lst9 = List(p1, p2, p3, p4, p5, p6, p7, p8)
r10 = Random(lst9, seed=128, min=-150, max=150, dims=2)
crv11 = Curve(r10)
s12 = Slab(crv11, 150, 0)
s13 = Slab(crv11, 150, 330)
ct14 = Cut(crv11, 50)
ex15 = Explode(ct14)
fr16 = Frame(ex15, 200, 180, 35, 10)
p17 = Point(-1500, -300, 480)
p18 = Point(0, 1000, 480)
p19 = Point(850, 800, 480)
p20 = Point(300, 0, 480)
p21 = Point(500, -800, 480)
p22 = Point(0, -1000, 480)
p23 = Point(-500, -800, 480)
p24 = Point(-300, 0, 480)
lst25 = List(p17, p18, p19, p20, p21, p22, p23, p24)
r26 = Random(lst25, seed=129, min=-150, max=150, dims=2)
crv27 = Curve(r26)
s28 = Slab(crv27, 150, 480)
s29 = Slab(crv27, 150, 810)
ct30 = Cut(crv27, 50)
ex31 = Explode(ct30)
fr32 = Frame(ex31, 200, 180, 35, 10)
p33 = Point(-1500, -300, 960)
p34 = Point(0, 1000, 960)
p35 = Point(850, 800, 960)
p36 = Point(300, 0, 960)
p37 = Point(500, -800, 960)
p38 = Point(0, -1000, 960)
p39 = Point(-500, -800, 960)
p40 = Point(-300, 0, 960)
lst41 = List(p33, p34, p35, p36, p37, p38, p39, p40)
r42 = Random(lst41, seed=130, min=-150, max=150, dims=2)
crv43 = Curve(r42)
s44 = Slab(crv43, 150, 960)
s45 = Slab(crv43, 150, 1290)
ct46 = Cut(crv43, 50)
ex47 = Explode(ct46)
fr48 = Frame(ex47, 200, 180, 35, 10)
p49 = Point(-1500, -300, 1440)
p50 = Point(0, 1000, 1440)
p51 = Point(850, 800, 1440)
p52 = Point(300, 0, 1440)
p53 = Point(500, -800, 1440)
p54 = Point(0, -1000, 1440)
p55 = Point(-500, -800, 1440)
p56 = Point(-300, 0, 1440)
lst57 = List(p49, p50, p51, p52, p53, p54, p55, p56)
r58 = Random(lst57, seed=131, min=-150, max=150, dims=2)
crv59 = Curve(r58)
s60 = Slab(crv59, 150, 1440)
s61 = Slab(crv59, 150, 1770)
ct62 = Cut(crv59, 50)
ex63 = Explode(ct62)
fr64 = Frame(ex63, 200, 180, 35, 10)
p65 = Point(-1500, -300, 1920)
p66 = Point(0, 1000, 1920)
p67 = Point(850, 800, 1920)
p68 = Point(300, 0, 1920)
p69 = Point(500, -800, 1920)
p70 = Point(0, -1000, 1920)
p71 = Point(-500, -800, 1920)
p72 = Point(-300, 0, 1920)
lst73 = List(p65, p66, p67, p68, p69, p70, p71, p72)
r74 = Random(lst73, seed=132, min=-150, max=150, dims=2)
crv75 = Curve(r74)
s76 = Slab(crv75, 150, 1920)
s77 = Slab(crv75, 150, 2250)
ct78 = Cut(crv75, 50)
ex79 = Explode(ct78)
fr80 = Frame(ex79, 200, 180, 35, 10)
p81 = Point(-1500, -300, 2400)
p82 = Point(0, 1000, 2400)
p83 = Point(850, 800, 2400)
p84 = Point(300, 0, 2400)
p85 = Point(500, -800, 2400)
p86 = Point(0, -1000, 2400)
p87 = Point(-500, -800, 2400)
p88 = Point(-300, 0, 2400)
lst89 = List(p81, p82, p83, p84, p85, p86, p87, p88)
r90 = Random(lst89, seed=133, min=-150, max=150, dims=2)
crv91 = Curve(r90)
s92 = Slab(crv91, 150, 2400)
s93 = Slab(crv91, 150, 2730)
ct94 = Cut(crv91, 50)
ex95 = Explode(ct94)
fr96 = Frame(ex95, 200, 180, 35, 10)
p97 = Point(-1500, -300, 2880)
p98 = Point(0, 1000, 2880)
p99 = Point(850, 800, 2880)
p100 = Point(300, 0, 2880)
p101 = Point(500, -800, 2880)
p102 = Point(0, -1000, 2880)
p103 = Point(-500, -800, 2880)
p104 = Point(-300, 0, 2880)
lst105 = List(p97, p98, p99, p100, p101, p102, p103, p104)
r106 = Random(lst105, seed=134, min=-150, max=150, dims=2)
crv107 = Curve(r106)
s108 = Slab(crv107, 150, 2880)
s109 = Slab(crv107, 150, 3210)
ct110 = Cut(crv107, 50)
ex111 = Explode(ct110)
fr112 = Frame(ex111, 200, 180, 35, 10)
p113 = Point(-1500, -300, 3360)
p114 = Point(0, 1000, 3360)
p115 = Point(850, 800, 3360)
p116 = Point(300, 0, 3360)
p117 = Point(500, -800, 3360)
p118 = Point(0, -1000, 3360)
p119 = Point(-500, -800, 3360)
p120 = Point(-300, 0, 3360)
lst121 = List(p113, p114, p115, p116, p117, p118, p119, p120)
r122 = Random(lst121, seed=135, min=-150, max=150, dims=2)
crv123 = Curve(r122)
s124 = Slab(crv123, 150, 3360)
s125 = Slab(crv123, 150, 3690)
ct126 = Cut(crv123, 50)
ex127 = Explode(ct126)
fr128 = Frame(ex127, 200, 180, 35, 10)

View file

@ -0,0 +1,15 @@
p1 = Point(0, 0, 0)
c2 = Circle(p1, 1000)
ct3 = Cut(c2, 12)
ex4 = Explode(ct3)
fr5 = Frame(ex4, 100, 300, 60, 60)
p6 = Point(-200, 200, 0)
c7 = Circle(p6, 500)
ct8 = Cut(c7, 12)
ex9 = Explode(ct8)
fr10 = Frame(ex9, 100, 600, 60, 60)
p11 = Point(-1800, -1000, 0)
c12 = Circle(p11, 500)
ct13 = Cut(c12, 12)
ex14 = Explode(ct13)
fr15 = Frame(ex14, 100, 600, 60, 60)

1602
samples/bimr/grid.bimr Normal file

File diff suppressed because one or more lines are too long

View file

@ -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)

View file

@ -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)

5
samples/python/grid.py Normal file
View file

@ -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)