release: 0.0.1
This commit is contained in:
commit
8db86c6a63
57 changed files with 6789 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
.claude/
|
||||
.opencode/
|
||||
6
Cargo.toml
Normal file
6
Cargo.toml
Normal 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
21
LICENSE
Normal 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
33
Makefile
Normal 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
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# bimr-engine
|
||||
|
||||
Parse and eval a ``.bimr`` file to generate a BIM model.
|
||||
13
cli/Cargo.toml
Normal file
13
cli/Cargo.toml
Normal 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
70
cli/src/main.rs
Normal 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
81
cli/tests/build.rs
Normal 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, ©).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
9
eval/Cargo.toml
Normal 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
53
eval/src/error.rs
Normal 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
1635
eval/src/eval.rs
Normal file
File diff suppressed because it is too large
Load diff
73
eval/src/guid.rs
Normal file
73
eval/src/guid.rs
Normal 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
24
eval/src/lib.rs
Normal 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;
|
||||
91
eval/src/nodes/element/architecture/building.rs
Normal file
91
eval/src/nodes/element/architecture/building.rs
Normal 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")),
|
||||
}
|
||||
}
|
||||
158
eval/src/nodes/element/architecture/column.rs
Normal file
158
eval/src/nodes/element/architecture/column.rs
Normal 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")),
|
||||
}
|
||||
}
|
||||
200
eval/src/nodes/element/architecture/frame.rs
Normal file
200
eval/src/nodes/element/architecture/frame.rs
Normal 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))
|
||||
}
|
||||
11
eval/src/nodes/element/architecture/mod.rs
Normal file
11
eval/src/nodes/element/architecture/mod.rs
Normal 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;
|
||||
128
eval/src/nodes/element/architecture/slab.rs
Normal file
128
eval/src/nodes/element/architecture/slab.rs
Normal 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")),
|
||||
}
|
||||
}
|
||||
78
eval/src/nodes/element/architecture/storey.rs
Normal file
78
eval/src/nodes/element/architecture/storey.rs
Normal 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")),
|
||||
}
|
||||
}
|
||||
85
eval/src/nodes/element/architecture/wall.rs
Normal file
85
eval/src/nodes/element/architecture/wall.rs
Normal 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")),
|
||||
}
|
||||
}
|
||||
6
eval/src/nodes/element/mod.rs
Normal file
6
eval/src/nodes/element/mod.rs
Normal 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;
|
||||
57
eval/src/nodes/geometry/circle.rs
Normal file
57
eval/src/nodes/geometry/circle.rs
Normal 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, ¢er)?;
|
||||
let p = match ¢er_node.entity {
|
||||
Entity::Point(p) => *p,
|
||||
other => {
|
||||
return Err(Error::WrongNature {
|
||||
name: center,
|
||||
expected: "Point",
|
||||
got: other.type_name(),
|
||||
});
|
||||
}
|
||||
};
|
||||
store.insert(
|
||||
file,
|
||||
name,
|
||||
Entity::Circle(KernelCircle::new(p, radius)),
|
||||
vec![center_node.guid],
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The compact BIMR text: `c1 = Circle(p1, 200)`.
|
||||
pub(crate) fn to_bimr(store: &Store, node: &Node, c: &KernelCircle) -> String {
|
||||
let name = ref_name(store, node.guid);
|
||||
let center_var = ref_name(store, node.refs[0]);
|
||||
format!("{name} = Circle({center_var}, {})", fmt_num(c.radius))
|
||||
}
|
||||
102
eval/src/nodes/geometry/curve.rs
Normal file
102
eval/src/nodes/geometry/curve.rs
Normal 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})")
|
||||
}
|
||||
108
eval/src/nodes/geometry/extrusion.rs
Normal file
108
eval/src/nodes/geometry/extrusion.rs
Normal 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})")
|
||||
}
|
||||
62
eval/src/nodes/geometry/line.rs
Normal file
62
eval/src/nodes/geometry/line.rs
Normal 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())
|
||||
}
|
||||
11
eval/src/nodes/geometry/mod.rs
Normal file
11
eval/src/nodes/geometry/mod.rs
Normal 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;
|
||||
48
eval/src/nodes/geometry/point.rs
Normal file
48
eval/src/nodes/geometry/point.rs
Normal 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)
|
||||
)
|
||||
}
|
||||
52
eval/src/nodes/geometry/vector.rs
Normal file
52
eval/src/nodes/geometry/vector.rs
Normal 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
193
eval/src/nodes/mod.rs
Normal 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()
|
||||
}
|
||||
89
eval/src/nodes/operator/cut.rs
Normal file
89
eval/src/nodes/operator/cut.rs
Normal 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))
|
||||
}
|
||||
93
eval/src/nodes/operator/divide.rs
Normal file
93
eval/src/nodes/operator/divide.rs
Normal 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))
|
||||
}
|
||||
88
eval/src/nodes/operator/explode.rs
Normal file
88
eval/src/nodes/operator/explode.rs
Normal 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})")
|
||||
}
|
||||
9
eval/src/nodes/operator/mod.rs
Normal file
9
eval/src/nodes/operator/mod.rs
Normal 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;
|
||||
124
eval/src/nodes/operator/random.rs
Normal file
124
eval/src/nodes/operator/random.rs
Normal 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)
|
||||
)
|
||||
}
|
||||
68
eval/src/nodes/set/list.rs
Normal file
68
eval/src/nodes/set/list.rs
Normal 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})")
|
||||
}
|
||||
6
eval/src/nodes/set/mod.rs
Normal file
6
eval/src/nodes/set/mod.rs
Normal 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
179
eval/src/serialize.rs
Normal 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
130
eval/src/store.rs
Normal 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
21
eval/src/value.rs
Normal 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
21
grammar/README.md
Normal 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
5
grammar/bimr.bnf
Normal 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
12
lexer/Cargo.toml
Normal 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
1
lexer/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# BIMR parser
|
||||
191
lexer/src/lexer.rs
Normal file
191
lexer/src/lexer.rs
Normal 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
6
lexer/src/lib.rs
Normal 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
130
lexer/src/main.rs
Normal 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
17
parser/Cargo.toml
Normal 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
60
parser/src/ast.rs
Normal 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
7
parser/src/lib.rs
Normal 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
28
parser/src/main.rs
Normal 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
298
parser/src/parser.rs
Normal 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
128
samples/bimr/building.bimr
Normal 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)
|
||||
15
samples/bimr/cylinders.bimr
Normal file
15
samples/bimr/cylinders.bimr
Normal 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
1602
samples/bimr/grid.bimr
Normal file
File diff suppressed because one or more lines are too long
30
samples/python/building.py
Normal file
30
samples/python/building.py
Normal 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)
|
||||
12
samples/python/cylinders.py
Normal file
12
samples/python/cylinders.py
Normal 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
5
samples/python/grid.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue