release: 0.0.1
This commit is contained in:
commit
61aea6df20
58 changed files with 5266 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."ssh://git@git.rvba.fr:229/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.
|
||||||
41
Makefile
Normal file
41
Makefile
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# bimr-engine — test targets.
|
||||||
|
#
|
||||||
|
# The fork-A eval is the experiment; the copied core (lexer, grammar,
|
||||||
|
# parser) is the frozen harness. `make test` runs everything.
|
||||||
|
|
||||||
|
.PHONY: test test-eval test-cli test-core clippy fmt check build run-sample
|
||||||
|
|
||||||
|
test: test-core test-eval test-cli
|
||||||
|
|
||||||
|
## Core — the copied harness, byte-identical to master
|
||||||
|
test-core:
|
||||||
|
cargo test -p lexer -p parser
|
||||||
|
|
||||||
|
## The experiment — eval unit tests
|
||||||
|
test-eval:
|
||||||
|
cargo test -p eval
|
||||||
|
|
||||||
|
## End-to-end — the cli gates on the wall corpus
|
||||||
|
test-cli:
|
||||||
|
cargo test -p cli
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
cargo clippy --all-targets
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
cargo fmt --check
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo fmt --check && cargo clippy --all-targets && cargo test
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
## Replay the reference sample through the cli (output to /tmp — never
|
||||||
|
## write back into the copied corpus)
|
||||||
|
run-sample:
|
||||||
|
cargo run --quiet --bin bimr-cli -- build samples/bimr/wall.bimr --output /tmp/opencode/wall.ifcx
|
||||||
|
@cat /tmp/opencode/wall.ifcx
|
||||||
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 = "ssh://git@git.rvba.fr:229/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
74
eval/src/guid.rs
Normal file
74
eval/src/guid.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//! Identity — the engine-side `Guid`: name-derived deterministic GUIDs.
|
||||||
|
//! Graph identity lives here, in the engine; the kernel stays untouched.
|
||||||
|
//!
|
||||||
|
//! Settled scheme ([`engine_entity.md`](https://git.rvba.fr/rvba/bimr-factory)):
|
||||||
|
//! every GUID derives from its DSL origin. The hash is FNV-1a 64 — simple,
|
||||||
|
//! stable across runs and platforms (std's `DefaultHasher` is neither).
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Entity identity — 64-bit, `Copy`, cheap to store on every entity struct.
|
||||||
|
///
|
||||||
|
/// `Guid::from_name(file, name)` — deterministic, stable across re-runs and
|
||||||
|
/// edits; a rename is a new identity.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct Guid(u64);
|
||||||
|
|
||||||
|
impl Guid {
|
||||||
|
/// Deterministic GUID from the DSL origin: `stable_hash(file, name)`.
|
||||||
|
pub fn from_name(file: &str, name: &str) -> Guid {
|
||||||
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis
|
||||||
|
for b in file
|
||||||
|
.bytes()
|
||||||
|
.chain(std::iter::once(0x1f))
|
||||||
|
.chain(name.bytes())
|
||||||
|
{
|
||||||
|
h ^= b as u64;
|
||||||
|
h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV prime
|
||||||
|
}
|
||||||
|
Guid(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Guid {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{:016x}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic_and_stable() {
|
||||||
|
let a = Guid::from_name("wall.py", "p1");
|
||||||
|
let b = Guid::from_name("wall.py", "p1");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_file_same_name() {
|
||||||
|
assert_ne!(
|
||||||
|
Guid::from_name("floor_01.py", "p1"),
|
||||||
|
Guid::from_name("floor_02.py", "p1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_name_same_file() {
|
||||||
|
assert_ne!(
|
||||||
|
Guid::from_name("wall.py", "p1"),
|
||||||
|
Guid::from_name("wall.py", "p2")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display() {
|
||||||
|
let g = Guid::from_name("wall.py", "p1");
|
||||||
|
assert_eq!(g.to_string().len(), 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
159
eval/src/nodes/element/architecture/column.rs
Normal file
159
eval/src/nodes/element/architecture/column.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//! Column — the whole engine story of `Column`.
|
||||||
|
//!
|
||||||
|
//! Nature lives in the kernel (`bimr::element::architecture::Column`): the
|
||||||
|
//! struct, the constructor, the mesh. The engine holds the column's base
|
||||||
|
//! **by value**; the edge back to the point node lives in `refs`.
|
||||||
|
//!
|
||||||
|
//! Lifting: a base that resolves to a List of Points produces a List of
|
||||||
|
//! Columns — one per point, the scalars broadcast
|
||||||
|
//! ([list_lifting](https://git.rvba.fr/rvba/bimr-factory)).
|
||||||
|
|
||||||
|
use bimr::Entity;
|
||||||
|
use bimr::element::architecture::Column as KernelColumn;
|
||||||
|
use parser::ast::Argument;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::nodes::{fmt_num, lookup, name_of, number, ref_name};
|
||||||
|
use crate::store::Store;
|
||||||
|
use crate::value::Node;
|
||||||
|
|
||||||
|
/// IFCX path prefix — the per-type counter lives with the emitter.
|
||||||
|
pub(crate) const IFCX_PREFIX: &str = "column";
|
||||||
|
|
||||||
|
/// `c = Column(p, height, width, depth)` — both positional and keyword
|
||||||
|
/// forms. `p` may be a List of Points: the call lifts into a List of
|
||||||
|
/// Columns, one per point, the scalars broadcast.
|
||||||
|
pub(crate) fn eval(
|
||||||
|
store: &mut Store,
|
||||||
|
file: &str,
|
||||||
|
name: &str,
|
||||||
|
args: &[Argument],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let (base, height, width, depth) = column_args(args)?;
|
||||||
|
let base_node = lookup(store, &base)?;
|
||||||
|
let items: Vec<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)
|
||||||
|
)
|
||||||
|
}
|
||||||
194
eval/src/nodes/mod.rs
Normal file
194
eval/src/nodes/mod.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//! The nodes — one file per entity, mirroring the kernel's taxonomy
|
||||||
|
//! ([nodes layout](https://git.rvba.fr/rvba/bimr-factory/src/branch/main/task/refactor/2026-08-29/engine_nodes_layout.md)).
|
||||||
|
//!
|
||||||
|
//! Each node file owns the whole engine story of its entity: `eval`
|
||||||
|
//! (argument extraction), `build`-style construction, the compact `to_bimr`
|
||||||
|
//! text, and (for mesh-bearing entities) the IFCX path prefix. The dispatch
|
||||||
|
//! is a plain match — no table, no `dyn`, no registry.
|
||||||
|
|
||||||
|
pub(crate) mod element;
|
||||||
|
pub(crate) mod geometry;
|
||||||
|
pub(crate) mod operator;
|
||||||
|
pub(crate) mod set;
|
||||||
|
|
||||||
|
use bimr::Entity;
|
||||||
|
use parser::ast::{Argument, Expression};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::guid::Guid;
|
||||||
|
use crate::store::Store;
|
||||||
|
use crate::value::Node;
|
||||||
|
|
||||||
|
/// Dispatch a DSL call to its node. Anything else is simply an unknown
|
||||||
|
/// constructor — no blocklist, no categories.
|
||||||
|
pub(crate) fn call(
|
||||||
|
store: &mut Store,
|
||||||
|
file: &str,
|
||||||
|
name: &str,
|
||||||
|
callee: &str,
|
||||||
|
args: &[Argument],
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match callee {
|
||||||
|
"Point" => geometry::point::eval(store, file, name, args),
|
||||||
|
"Line" => geometry::line::eval(store, file, name, args),
|
||||||
|
"Circle" => geometry::circle::eval(store, file, name, args),
|
||||||
|
"Curve" => geometry::curve::eval(store, file, name, args),
|
||||||
|
"Vector" => geometry::vector::eval(store, file, name, args),
|
||||||
|
"Extrusion" => geometry::extrusion::eval(store, file, name, args),
|
||||||
|
"Wall" => element::architecture::wall::eval(store, file, name, args),
|
||||||
|
"Column" => element::architecture::column::eval(store, file, name, args),
|
||||||
|
"Frame" => element::architecture::frame::eval(store, file, name, args),
|
||||||
|
"Slab" => element::architecture::slab::eval(store, file, name, args),
|
||||||
|
"Storey" => element::architecture::storey::eval(store, file, name, args),
|
||||||
|
"Building" => element::architecture::building::eval(store, file, name, args),
|
||||||
|
"Divide" => operator::divide::eval(store, file, name, args),
|
||||||
|
"Cut" => operator::cut::eval(store, file, name, args),
|
||||||
|
"Explode" => operator::explode::eval(store, file, name, args),
|
||||||
|
"Random" => operator::random::eval(store, file, name, args),
|
||||||
|
"List" => set::list::eval(store, file, name, args),
|
||||||
|
_ => Err(Error::Unsupported("call")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compact BIMR source line for one node — delegated to the node file.
|
||||||
|
pub(crate) fn to_bimr(store: &Store, node: &Node) -> String {
|
||||||
|
match &node.entity {
|
||||||
|
Entity::Point(p) => geometry::point::to_bimr(store, node, p),
|
||||||
|
Entity::Line(l) => geometry::line::to_bimr(store, node, l),
|
||||||
|
Entity::Circle(c) => geometry::circle::to_bimr(store, node, c),
|
||||||
|
Entity::Curve(c) => geometry::curve::to_bimr(store, node, c),
|
||||||
|
Entity::Vector(v) => geometry::vector::to_bimr(store, node, v),
|
||||||
|
Entity::Extrusion(e) => geometry::extrusion::to_bimr(store, node, e),
|
||||||
|
Entity::Wall(w) => element::architecture::wall::to_bimr(store, node, w),
|
||||||
|
Entity::Column(c) => element::architecture::column::to_bimr(store, node, c),
|
||||||
|
Entity::Frame(f) => element::architecture::frame::to_bimr(store, node, f),
|
||||||
|
Entity::Slab(s) => element::architecture::slab::to_bimr(store, node, s),
|
||||||
|
Entity::Storey(l) => element::architecture::storey::to_bimr(store, node, l),
|
||||||
|
Entity::Building(b) => element::architecture::building::to_bimr(store, node, b),
|
||||||
|
Entity::List(l) => match l.kind() {
|
||||||
|
// A lifted list's compact form is the lifted statement itself —
|
||||||
|
// the constructor call over its source list (refs[0]). A plain
|
||||||
|
// binding can only reach these kinds by lifting, so the item
|
||||||
|
// kind alone selects the emitter.
|
||||||
|
"Column" => element::architecture::column::to_bimr(
|
||||||
|
store,
|
||||||
|
node,
|
||||||
|
match l.get(0) {
|
||||||
|
Some(Entity::Column(c)) => c,
|
||||||
|
_ => unreachable!("kind `Column` guarantees the payload"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"Frame" => element::architecture::frame::to_bimr_lifted(
|
||||||
|
store,
|
||||||
|
node,
|
||||||
|
match l.get(0) {
|
||||||
|
Some(Entity::Frame(f)) => f,
|
||||||
|
_ => unreachable!("kind `Frame` guarantees the payload"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ => set::list::to_bimr(store, node, l),
|
||||||
|
},
|
||||||
|
Entity::Divide(d) => operator::divide::to_bimr(store, node, d),
|
||||||
|
Entity::Cut(c) => operator::cut::to_bimr(store, node, c),
|
||||||
|
Entity::Explode(e) => operator::explode::to_bimr(store, node, e),
|
||||||
|
Entity::Random(r) => operator::random::to_bimr(store, node, r),
|
||||||
|
other => panic!("to_bimr: unsupported entity {}", other.type_name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IFCX path prefix of the node module owning this entity, if that
|
||||||
|
/// module emits meshes.
|
||||||
|
pub(crate) fn ifcx_prefix(entity: &Entity) -> Option<&'static str> {
|
||||||
|
match entity {
|
||||||
|
Entity::Wall(_) => Some(element::architecture::wall::IFCX_PREFIX),
|
||||||
|
Entity::Column(_) => Some(element::architecture::column::IFCX_PREFIX),
|
||||||
|
Entity::Frame(_) => Some(element::architecture::frame::IFCX_PREFIX),
|
||||||
|
Entity::Slab(_) => Some(element::architecture::slab::IFCX_PREFIX),
|
||||||
|
Entity::Extrusion(_) => Some(geometry::extrusion::IFCX_PREFIX),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared plumbing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolve a DSL name to a stored node — any nature.
|
||||||
|
pub(crate) fn lookup<'a>(store: &'a Store, name: &str) -> Result<&'a Node, Error> {
|
||||||
|
let guid = store
|
||||||
|
.named
|
||||||
|
.get(name)
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| Error::UnknownName(name.to_string()))?;
|
||||||
|
Ok(store.resolve(guid).expect("indexed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a DSL name to a stored node of the expected nature.
|
||||||
|
pub(crate) fn resolve<'a>(
|
||||||
|
store: &'a Store,
|
||||||
|
name: &str,
|
||||||
|
expected: &'static str,
|
||||||
|
) -> Result<&'a Node, Error> {
|
||||||
|
let node = lookup(store, name)?;
|
||||||
|
let got = node.entity.type_name();
|
||||||
|
if got != expected {
|
||||||
|
return Err(Error::WrongNature {
|
||||||
|
name: name.to_string(),
|
||||||
|
expected,
|
||||||
|
got,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only positional arguments flow through here — `Wall` handles its own
|
||||||
|
/// keyword mix (see `wall_args`). Keywords elsewhere are rejected, not
|
||||||
|
/// silently ignored.
|
||||||
|
pub(crate) fn positional_args(args: &[Argument]) -> Result<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
18
samples/bimr/column_grid.bimr
Normal file
18
samples/bimr/column_grid.bimr
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
p1 = Point(0,0,0)
|
||||||
|
p2 = Point(0,1000,0)
|
||||||
|
p3 = Point(0,2000,0)
|
||||||
|
p4 = Point(0,3000,0)
|
||||||
|
p5 = Point(1000,0,0)
|
||||||
|
p6 = Point(1000,1000,0)
|
||||||
|
p7 = Point(1000,2000,0)
|
||||||
|
p8 = Point(1000,3000,0)
|
||||||
|
p9 = Point(2000,0,0)
|
||||||
|
p10 = Point(2000,1000,0)
|
||||||
|
p11 = Point(2000,2000,0)
|
||||||
|
p12 = Point(2000,3000,0)
|
||||||
|
p13 = Point(3000,0,0)
|
||||||
|
p14 = Point(3000,1000,0)
|
||||||
|
p15 = Point(3000,2000,0)
|
||||||
|
p16 = Point(3000,3000,0)
|
||||||
|
lst = List(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15, p16)
|
||||||
|
cols = Column(lst,300,30,30)
|
||||||
4
samples/bimr/wall.bimr
Normal file
4
samples/bimr/wall.bimr
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
p1 = Point(0,0,0)
|
||||||
|
p2 = Point(500,0,0)
|
||||||
|
l1 = Line(p1,p2)
|
||||||
|
w1 = Wall(l1,20,300)
|
||||||
181
samples/bimr/wall.ifcx
Normal file
181
samples/bimr/wall.ifcx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"attributes": {
|
||||||
|
"usd::usdgeom::mesh": {
|
||||||
|
"faceVertexIndices": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
14,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
17,
|
||||||
|
18,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
19,
|
||||||
|
20,
|
||||||
|
21,
|
||||||
|
22,
|
||||||
|
20,
|
||||||
|
22,
|
||||||
|
23
|
||||||
|
],
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
-20.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
-20.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
0.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
-20.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
-20.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
0.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
-20.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
-20.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
-20.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
-20.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
-20.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.0,
|
||||||
|
-20.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
-20.0,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
-20.0,
|
||||||
|
300.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
500.0,
|
||||||
|
0.0,
|
||||||
|
300.0
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"path": "wall-001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"header": {
|
||||||
|
"dataVersion": "1.0.0",
|
||||||
|
"ifcxVersion": "ifcx_alpha"
|
||||||
|
},
|
||||||
|
"imports": [
|
||||||
|
{
|
||||||
|
"uri": "https://ifcx.dev/@openusd.org/usd@v1.ifcx"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemas": {}
|
||||||
|
}
|
||||||
8
samples/bimr/wall_v2.bimr
Normal file
8
samples/bimr/wall_v2.bimr
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
p1 = Point(0,0,0)
|
||||||
|
p2 = Point(800,0,0)
|
||||||
|
l1 = Line(p1,p2)
|
||||||
|
w1 = Wall(l1,20,300)
|
||||||
|
p3 = Point(0,1000,0)
|
||||||
|
p4 = Point(800,1000,0)
|
||||||
|
l2 = Line(p3,p4)
|
||||||
|
w2 = Wall(l2,20,300)
|
||||||
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