release: 0.0.1

This commit is contained in:
Milovann Yanatchkov 2026-08-31 10:10:18 +02:00
commit b73dec4f5f
43 changed files with 3190 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target
docs/book
docs/target
.claude/
.opencode/

90
Cargo.lock generated Normal file
View file

@ -0,0 +1,90 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bimr-kernel"
version = "0.0.1"
dependencies = [
"earcut",
"glam",
"i_overlay",
]
[[package]]
name = "earcut"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88459a2a8e3a514b6e6de38cf3aaa9250a894cb098f74a932db77fcc8341b6d0"
dependencies = [
"num-traits",
]
[[package]]
name = "glam"
version = "0.33.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436"
[[package]]
name = "i_float"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c614c2cfc06cfe8809ccc48cd445864502478e673721ca2a05931fc057ec33a0"
dependencies = [
"libm",
]
[[package]]
name = "i_key_sort"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c6c58d0c60705e66264ce0f788a69a2f21472aeb8188559e7c8c619dbdc10fa"
[[package]]
name = "i_overlay"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca5d3f41731d03eafa48213609113563099a479f593cc7c635b7e836fc52a3c8"
dependencies = [
"i_float",
"i_key_sort",
"i_shape",
"i_tree",
]
[[package]]
name = "i_shape"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67ec1fc3ad980a24e4761b763bf5bfbf58c1d88be1bffb14654d369d7f420258"
dependencies = [
"i_float",
]
[[package]]
name = "i_tree"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]

12
Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "bimr-kernel"
version = "0.0.1"
edition = "2024"
[lib]
name = "bimr"
[dependencies]
earcut = "0.4.5"
glam = { version = "0.33", default-features = false, features = ["std", "f64"] }
i_overlay = "8.1"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Milovann Yanatchkov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

7
Makefile Normal file
View file

@ -0,0 +1,7 @@
.PHONY: doc test
doc:
cargo doc --no-deps --open
test:
cargo test

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# bimr-kernel
Internal geometry kernel for BIMR.

12
docs/README.md Normal file
View file

@ -0,0 +1,12 @@
# bimr-kernel API Documentation
Generated with rustdoc.
## View
```bash
make doc # builds and opens in browser
cargo doc --no-deps --open # same
```
Output goes to `target/doc/bimr_kernel/index.html`.

View file

@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::Entity;
/// A building — a named gathering of storeys. A container: no mesh of its
/// own; the storeys (and their members) carry the geometry.
#[derive(Debug, Clone)]
pub struct Building {
name: String,
storeys: Vec<Entity>,
}
impl Building {
pub fn new(name: String, storeys: Vec<Entity>) -> Self {
Self { name, storeys }
}
/// The building name.
pub fn name(&self) -> &str {
&self.name
}
/// The gathered storeys, bottom-up.
pub fn storeys(&self) -> &[Entity] {
&self.storeys
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::element::architecture::Storey;
use crate::geometry::Point;
#[test]
fn a_building_gathers_storeys() {
let l0 = Storey::new(0.0, vec![Entity::Point(Point::new(0.0, 0.0, 0.0))]);
let l1 = Storey::new(3000.0, vec![Entity::Point(Point::new(0.0, 0.0, 3000.0))]);
let b = Building::new(
"b03".to_string(),
vec![Entity::Storey(l0), Entity::Storey(l1)],
);
assert_eq!(b.name(), "b03");
assert_eq!(b.storeys().len(), 2);
assert!(matches!(b.storeys()[0], Entity::Storey(_)));
}
}

View file

@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Point;
use crate::math::Vec3;
use crate::mesh::Mesh;
use crate::volume;
/// A BIM column — vertical structural member with rectangular section.
#[derive(Debug, Clone, Copy)]
pub struct Column {
pub base: Point,
pub height: f64,
pub section_width: f64,
pub section_height: f64,
}
impl Column {
pub fn new(base: Point, height: f64, section_width: f64, section_height: f64) -> Self {
Self {
base,
height,
section_width,
section_height,
}
}
pub fn mesh(&self) -> Mesh {
volume::centered_box(
Vec3::new(self.base.x, self.base.y, self.base.z),
self.height,
self.section_width,
self.section_height,
)
}
}

View file

@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Point;
use crate::mesh::Mesh;
use crate::volume;
/// A BIM window/door frame — a hollow rectangular solid.
#[derive(Debug, Clone, Copy)]
pub struct Frame {
pub origin: Point,
pub end: Point,
pub width: f64,
pub height: f64,
pub depth: f64,
pub thickness: f64,
}
impl Frame {
pub fn new(
origin: Point,
end: Point,
width: f64,
height: f64,
depth: f64,
thickness: f64,
) -> Self {
Self {
origin,
end,
width,
height,
depth,
thickness,
}
}
pub fn mesh(&self) -> Mesh {
volume::hollow_prism(
self.origin.to_vec3(),
self.end.to_vec3(),
self.height,
self.depth,
self.thickness,
)
}
}

View file

@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Architecture discipline elements.
pub use self::building::Building;
pub use self::column::Column;
pub use self::frame::Frame;
pub use self::storey::Storey;
pub use self::slab::Slab;
pub use self::wall::Wall;
mod building;
mod column;
mod frame;
mod storey;
mod slab;
mod wall;

View file

@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Polyline;
use crate::mesh::Mesh;
use crate::volume;
/// A BIM slab — floor/ceiling plate extruded from a polyline profile.
#[derive(Debug, Clone)]
pub struct Slab {
pub profile: Polyline,
pub thickness: f64,
pub elevation: f64,
}
impl Slab {
pub fn new(profile: Polyline, thickness: f64, elevation: f64) -> Self {
Self {
profile,
thickness,
elevation,
}
}
pub fn mesh(&self) -> Mesh {
volume::prism_geometry(&self.profile.points, self.thickness, self.elevation)
}
}

View file

@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::Entity;
/// A building storey — a datum elevation and the entities it gathers
/// (frames, columns, the slab). A container: no mesh of its own; the
/// members carry the geometry.
#[derive(Debug, Clone)]
pub struct Storey {
elevation: f64,
entities: Vec<Entity>,
}
impl Storey {
pub fn new(elevation: f64, entities: Vec<Entity>) -> Self {
Self {
elevation,
entities,
}
}
/// The storey datum.
pub fn elevation(&self) -> f64 {
self.elevation
}
/// The gathered entities, in construction order.
pub fn entities(&self) -> &[Entity] {
&self.entities
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Point;
#[test]
fn a_storey_gathers_entities() {
let st = Storey::new(
3000.0,
vec![
Entity::Point(Point::new(0.0, 0.0, 3000.0)),
Entity::Point(Point::new(1.0, 0.0, 3000.0)),
],
);
assert_eq!(st.elevation(), 3000.0);
assert_eq!(st.entities().len(), 2);
}
}

View file

@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Line;
use crate::mesh::Mesh;
use crate::volume;
/// A BIM wall: centerline + thickness + height.
#[derive(Debug, Clone, Copy)]
pub struct Wall {
pub line: Line,
pub width: f64,
pub height: f64,
}
impl Wall {
pub fn new(line: Line, width: f64, height: f64) -> Self {
Self {
line,
width,
height,
}
}
/// Triangulated mesh (box along the centerline).
pub fn mesh(&self) -> Mesh {
volume::path_box(
self.line.start.to_vec3(),
self.line.end.to_vec3(),
self.width,
self.height,
)
}
}

10
src/element/mod.rs Normal file
View file

@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// BIM building elements, grouped by discipline.
pub mod architecture;
pub mod site;
pub mod structure;
pub use self::architecture::{Column, Frame, Slab, Wall};

4
src/element/site/mod.rs Normal file
View file

@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Site discipline — future: Site, Terrain.

View file

@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Structure discipline — future: Beam, Column (structural).

187
src/entity.rs Normal file
View file

@ -0,0 +1,187 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::element::architecture::{Building, Column, Frame, Slab, Storey, Wall};
use crate::geometry::{Circle, Curve, Extrusion, Line, Point, Polyline, Spline, Vector};
use crate::mesh::Mesh;
use crate::operator::{Cut, Divide, Explode, Random};
use crate::set::List;
/// A BIMR entity — the global, typed, closed set of kernel objects.
///
/// Geometric primitives + math objects live under `geometry`, BIM building
/// elements under `element`. Math objects (like `Vector`) are first-class
/// entities: they flow through the construction graph as parameters, the same
/// way Grasshopper parameters feed node I/O. Each variant wraps a struct
/// defined in its own file; that file owns the entity's constructor and
/// geometry methods.
#[derive(Debug, Clone)]
pub enum Entity {
Point(Point),
Vector(Vector),
Line(Line),
Polyline(Polyline),
Spline(Spline),
Circle(Circle),
Curve(Curve),
Extrusion(Extrusion),
Wall(Wall),
Column(Column),
Slab(Slab),
Frame(Frame),
Storey(Storey),
Building(Building),
List(List),
Divide(Divide),
Cut(Cut),
Explode(Explode),
Random(Random),
}
impl Entity {
/// Static type name — used for error messages and diagnostics.
pub fn type_name(&self) -> &'static str {
match self {
Entity::Point(_) => "Point",
Entity::Vector(_) => "Vector",
Entity::Line(_) => "Line",
Entity::Polyline(_) => "Polyline",
Entity::Spline(_) => "Spline",
Entity::Circle(_) => "Circle",
Entity::Curve(_) => "Curve",
Entity::Extrusion(_) => "Extrusion",
Entity::Wall(_) => "Wall",
Entity::Column(_) => "Column",
Entity::Slab(_) => "Slab",
Entity::Frame(_) => "Frame",
Entity::Storey(_) => "Storey",
Entity::Building(_) => "Building",
Entity::List(_) => "List",
Entity::Divide(_) => "Divide",
Entity::Cut(_) => "Cut",
Entity::Explode(_) => "Explode",
Entity::Random(_) => "Random",
}
}
/// Geometry of this entity, if it has renderable geometry.
///
/// `Extrusion` resolves through the engine tree (its base is a handle), so
/// it does not build a mesh directly here — use `mesh_from_rings`.
pub fn mesh(&self) -> Option<Mesh> {
match self {
Entity::Wall(w) => Some(w.mesh()),
Entity::Column(c) => Some(c.mesh()),
Entity::Slab(s) => Some(s.mesh()),
Entity::Frame(f) => Some(f.mesh()),
Entity::Extrusion(e) => e.mesh(),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::{Line, Point, Polyline};
use crate::math::Vec3;
#[test]
fn test_type_name() {
let point = Entity::Point(Point::new(0.0, 0.0, 0.0));
let wall = Entity::Wall(Wall::new(
Line::new(Point::new(0.0, 0.0, 0.0), Point::new(1.0, 0.0, 0.0)),
0.2,
3.0,
));
assert_eq!(point.type_name(), "Point");
assert_eq!(wall.type_name(), "Wall");
assert_eq!(
Entity::Extrusion(Extrusion::new(Vec::new(), Vec3::Z)).type_name(),
"Extrusion"
);
}
#[test]
fn test_mesh_dispatch_wall() {
let wall = Entity::Wall(Wall::new(
Line::new(Point::new(0.0, 0.0, 0.0), Point::new(100.0, 0.0, 0.0)),
20.0,
300.0,
));
let m = wall.mesh().expect("wall has geometry");
assert_eq!(m.points.len(), 24);
assert_eq!(m.indices.len(), 36);
}
#[test]
fn test_mesh_dispatch_column() {
let col = Entity::Column(Column::new(Point::new(0.0, 0.0, 0.0), 300.0, 30.0, 30.0));
let m = col.mesh().expect("column has geometry");
assert_eq!(m.points.len(), 24);
assert_eq!(m.indices.len(), 36);
}
#[test]
fn test_mesh_dispatch_slab() {
let slab = Entity::Slab(Slab::new(
Polyline::new(vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(100.0, 0.0, 0.0),
Vec3::new(100.0, 100.0, 0.0),
Vec3::new(0.0, 100.0, 0.0),
]),
15.0,
0.0,
));
let m = slab.mesh().expect("slab has geometry");
assert_eq!(m.points.len(), 24);
assert_eq!(m.indices.len(), 36);
}
#[test]
fn test_mesh_dispatch_frame() {
let frame = Entity::Frame(Frame::new(
Point::new(0.0, 0.0, 0.0),
Point::new(100.0, 0.0, 0.0),
100.0,
150.0,
12.0,
5.0,
));
let m = frame.mesh().expect("frame has geometry");
assert_eq!(m.points.len(), 64);
assert_eq!(m.indices.len(), 96);
}
#[test]
fn test_mesh_dispatch_none() {
let point = Entity::Point(Point::new(0.0, 0.0, 0.0));
let line = Entity::Line(Line::new(
Point::new(0.0, 0.0, 0.0),
Point::new(1.0, 0.0, 0.0),
));
assert!(point.mesh().is_none());
assert!(line.mesh().is_none());
}
#[test]
fn test_list_type_name_and_no_mesh() {
use crate::set::List;
let list = List::try_new(vec![
Entity::Point(Point::new(0.0, 0.0, 0.0)),
Entity::Point(Point::new(1.0, 0.0, 0.0)),
])
.expect("homogeneous");
let entity = Entity::List(list);
assert_eq!(entity.type_name(), "List");
assert!(entity.mesh().is_none());
}
#[test]
fn test_circle_type_name_and_no_mesh() {
let circle = Entity::Circle(Circle::new(Point::new(0.0, 0.0, 0.0), 200.0));
assert_eq!(circle.type_name(), "Circle");
assert!(circle.mesh().is_none());
}
}

20
src/geometry/circle.rs Normal file
View file

@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Point;
/// A circle in the horizontal plane — center, radius.
///
/// A curve primitive, like [`crate::geometry::Line`]: construction
/// geometry, no mesh. Visibility comes when surfaces consume it.
#[derive(Debug, Clone, Copy)]
pub struct Circle {
pub center: Point,
pub radius: f64,
}
impl Circle {
pub fn new(center: Point, radius: f64) -> Self {
Self { center, radius }
}
}

128
src/geometry/curve.rs Normal file
View file

@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::{Point, Spline};
/// A closed curve — a Catmull-Rom spline through a set of control points.
///
/// Functions like the [`crate::geometry::Circle`]: `Divide` samples points
/// along it, `Cut` yields the pieces. Open curves come later — the spline
/// is closed by construction.
#[derive(Debug, Clone)]
pub struct Curve {
spline: Spline,
}
impl Curve {
pub fn new(spline: Spline) -> Self {
Self { spline }
}
/// The spline the curve holds.
pub fn spline(&self) -> &Spline {
&self.spline
}
/// `n` samples around the closed loop — parameter-uniform, the first
/// sample at the first control point. Coordinates within `1e-9` of
/// zero snap to it: CR through axis-aligned controls leaves the same
/// deterministic dust the circle's trig does.
pub fn points(&self, n: usize) -> Vec<Point> {
const SNAP: f64 = 1e-9;
let snap = |v: f64| if v.abs() < SNAP { 0.0 } else { v };
let ctrl = self.spline.control_points();
let m = ctrl.len();
if m < 2 || n == 0 {
return vec![];
}
(0..n)
.map(|i| {
let u = (i as f64 / n as f64) * m as f64;
let span = (u.floor() as usize) % m;
let t = u - u.floor();
let p0 = ctrl[(span + m - 1) % m];
let p1 = ctrl[span];
let p2 = ctrl[(span + 1) % m];
let p3 = ctrl[(span + 2) % m];
let t2 = t * t;
let t3 = t2 * t;
let pt = 0.5
* (2.0 * p1
+ (p2 - p0) * t
+ (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
+ (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
Point::new(snap(pt.x), snap(pt.y), snap(pt.z))
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Entity;
use crate::math::Vec3;
fn square_curve() -> Curve {
let ctrl = vec![
Vec3::new(100.0, 0.0, 0.0),
Vec3::new(0.0, 100.0, 0.0),
Vec3::new(-100.0, 0.0, 0.0),
Vec3::new(0.0, -100.0, 0.0),
];
Curve::new(Spline::new(ctrl).expect("4 control points"))
}
#[test]
fn sampling_at_the_control_count_returns_the_controls() {
// n == m: global parameter hits every span start — the control
// points come back, in order.
let pts = square_curve().points(4);
assert_eq!(pts.len(), 4);
assert_eq!((pts[0].x, pts[0].y), (100.0, 0.0));
assert_eq!((pts[1].x, pts[1].y), (0.0, 100.0));
assert_eq!((pts[2].x, pts[2].y), (-100.0, 0.0));
assert_eq!((pts[3].x, pts[3].y), (0.0, -100.0));
}
#[test]
fn sampling_is_wrapped_and_deterministic() {
let c = square_curve();
let a = c.points(8);
let b = c.points(8);
assert_eq!(a.len(), 8);
for (p, q) in a.iter().zip(b.iter()) {
assert_eq!((p.x, p.y, p.z), (q.x, q.y, q.z));
}
// The first sample is always the first control point.
assert_eq!((a[0].x, a[0].y), (100.0, 0.0));
}
#[test]
fn divide_over_a_curve_samples_it() {
let pts = crate::operator::Divide::new(Entity::Curve(square_curve()), 8)
.points()
.expect("curve divides");
assert_eq!(pts.len(), 8);
assert_eq!((pts[0].x, pts[0].y), (100.0, 0.0));
}
#[test]
fn cut_over_a_curve_yields_a_closed_loop() {
let lines = crate::operator::Cut::new(Entity::Curve(square_curve()), 4)
.lines()
.expect("curve cuts");
assert_eq!(lines.len(), 4);
for (a, b) in lines.iter().zip(lines.iter().skip(1)) {
assert_eq!((a.end.x, a.end.y), (b.start.x, b.start.y));
}
// The last piece closes to the first sample.
let first = lines[0].start;
assert_eq!((lines[3].end.x, lines[3].end.y), (first.x, first.y));
}
#[test]
fn zero_samples_is_empty() {
assert!(square_curve().points(0).is_empty());
}
}

122
src/geometry/extrusion.rs Normal file
View file

@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Line;
use crate::math::Vec3;
use crate::mesh::Mesh;
use crate::volume;
/// A swept solid: a profile of curve pieces swept by a vector.
///
/// The profile is held **by value** (E1 resolved — no handle): the mesh is
/// built standalone via [`Extrusion::mesh`]. The engine keeps graph edges to
/// the profile's nodes in `refs`; the kernel never sees them.
///
/// [`Extrusion::mesh_from_rings`] is kept for engines that resolve their own
/// rings — it builds from a pre-resolved ring stack and ignores the profile.
#[derive(Debug, Clone)]
pub struct Extrusion {
profile: Vec<Line>,
vec: Vec3,
}
impl Extrusion {
pub fn new(profile: Vec<Line>, vec: Vec3) -> Self {
Self { profile, vec }
}
/// The swept profile — curve pieces, in order.
pub fn profile(&self) -> &[Line] {
&self.profile
}
/// The sweep vector.
pub fn vec(&self) -> Vec3 {
self.vec
}
/// The side surface: one quad per profile piece. A closed profile (the
/// last piece ends where the first starts) wraps into a loop; an open
/// chain stays open. No caps.
pub fn mesh(&self) -> Option<Mesh> {
if self.profile.is_empty() {
return None;
}
let mut ring: Vec<Vec3> = self.profile.iter().map(|l| l.start.to_vec3()).collect();
let last_end = self.profile.last().unwrap().end.to_vec3();
let closed = ring.first() == Some(&last_end);
if !closed {
ring.push(last_end);
} else {
// The ribbon pairs i with i+1 — duplicate the first point so the
// loop closes.
ring.push(ring[0]);
}
let top: Vec<Vec3> = ring
.iter()
.map(|p| Vec3::new(p.x + self.vec.x, p.y + self.vec.y, p.z + self.vec.z))
.collect();
Some(volume::ribbon_geometry(&ring, &top))
}
/// Mesh from a pre-resolved stack of rings — the caller owns ring
/// resolution; the profile is ignored.
pub fn mesh_from_rings(&self, rings: &[Vec<Vec3>]) -> Option<Mesh> {
volume::extrusion_geometry(rings)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Point;
fn square_loop() -> Vec<Line> {
let p = [
Point::new(0.0, 0.0, 0.0),
Point::new(100.0, 0.0, 0.0),
Point::new(100.0, 100.0, 0.0),
Point::new(0.0, 100.0, 0.0),
];
(0..4).map(|i| Line::new(p[i], p[(i + 1) % 4])).collect()
}
#[test]
fn closed_profile_meshes_into_a_loop_of_quads() {
let e = Extrusion::new(square_loop(), Vec3::new(0.0, 0.0, 300.0));
let m = e.mesh().expect("profile meshes");
// 4 pieces → 4 quads → 16 points, 24 indices.
assert_eq!(m.points.len(), 16);
assert_eq!(m.indices.len(), 24);
// The top ring is the bottom swept by the vector — the origin's top
// is the 4th vertex of the first quad ([b0, b1, t1, t0]).
assert_eq!(m.points[3], [0.0, 0.0, 300.0]);
}
#[test]
fn open_profile_stays_open() {
let p0 = Point::new(0.0, 0.0, 0.0);
let p1 = Point::new(100.0, 0.0, 0.0);
let p2 = Point::new(100.0, 100.0, 0.0);
let e = Extrusion::new(vec![Line::new(p0, p1), Line::new(p1, p2)], Vec3::Z);
let m = e.mesh().expect("profile meshes");
// 2 pieces → 2 quads, no wrap face.
assert_eq!(m.points.len(), 8);
assert_eq!(m.indices.len(), 12);
}
#[test]
fn empty_profile_has_no_mesh() {
let e = Extrusion::new(Vec::new(), Vec3::Z);
assert!(e.mesh().is_none());
}
#[test]
fn mesh_from_rings_still_serves_the_chain_path() {
let e = Extrusion::new(Vec::new(), Vec3::ZERO);
let ring = vec![Vec3::ZERO, Vec3::X, Vec3::X + Vec3::Y, Vec3::Y];
let closed = vec![ring.clone(), ring];
assert!(e.mesh_from_rings(&closed).is_some());
assert!(e.mesh_from_rings(&[]).is_none());
}
}

24
src/geometry/line.rs Normal file
View file

@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Point;
use crate::math::Vec3;
use crate::mesh::curve::normal as line_normal;
/// A line segment between two points.
#[derive(Debug, Clone, Copy)]
pub struct Line {
pub start: Point,
pub end: Point,
}
impl Line {
pub fn new(start: Point, end: Point) -> Self {
Self { start, end }
}
/// Unit normal in the horizontal plane (moved from `curve::normal`).
pub fn normal(&self, magnitude: f64) -> Option<Vec3> {
line_normal(self.start.to_vec3(), self.end.to_vec3(), magnitude)
}
}

22
src/geometry/mod.rs Normal file
View file

@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Geometric primitives + math objects — root-level geometry entities.
pub use self::circle::Circle;
pub use self::curve::Curve;
pub use self::extrusion::Extrusion;
pub use self::line::Line;
pub use self::point::Point;
pub use self::polyline::Polyline;
pub use self::spline::Spline;
pub use self::vector::Vector;
mod circle;
mod curve;
mod extrusion;
mod line;
mod point;
mod polyline;
mod spline;
mod vector;

32
src/geometry/point.rs Normal file
View file

@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::math::Vec3;
/// A 3D point.
#[derive(Debug, Clone, Copy)]
pub struct Point {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl Point {
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
pub fn to_vec3(self) -> Vec3 {
Vec3::new(self.x, self.y, self.z)
}
}
impl From<Vec3> for Point {
fn from(v: Vec3) -> Self {
Self {
x: v.x,
y: v.y,
z: v.z,
}
}
}

29
src/geometry/polyline.rs Normal file
View file

@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::math::Vec3;
/// An ordered sequence of points forming a multi-segment path.
#[derive(Debug, Clone)]
pub struct Polyline {
pub points: Vec<Vec3>,
}
impl Polyline {
pub fn new(points: Vec<Vec3>) -> Self {
Self { points }
}
pub fn len(&self) -> usize {
self.points.len()
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
/// Arc-length table — moved from `curve::polyline_arc_lengths`.
pub fn arc_lengths(&self) -> (Vec<f64>, f64, Vec<f64>) {
crate::mesh::curve::polyline_arc_lengths(&self.points)
}
}

34
src/geometry/spline.rs Normal file
View file

@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::geometry::Polyline;
use crate::math::Vec3;
/// A Catmull-Rom spline through a closed set of control points.
#[derive(Debug, Clone)]
pub struct Spline {
ctrl: Vec<Vec3>,
}
impl Spline {
/// Build a spline from control points. Returns `None` if fewer than 2.
pub fn new(ctrl: Vec<Vec3>) -> Option<Self> {
if ctrl.len() < 2 {
return None;
}
Some(Self { ctrl })
}
/// Sample evenly-spaced points at elevation `z`.
pub fn sample(&self, z: f64, segs_per_span: usize) -> Polyline {
Polyline::new(crate::mesh::curve::catmull_rom(
&self.ctrl,
z,
segs_per_span,
))
}
pub fn control_points(&self) -> &[Vec3] {
&self.ctrl
}
}

41
src/geometry/vector.rs Normal file
View file

@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::math::Vec3;
/// A 3D direction vector — a graph entity wrapping the math primitive.
///
/// `math::Vec3` is the numeric backbone; this entity surface is what the DSL,
/// graph, and WASM/PyO3 adapters carry as parameter/result values.
#[derive(Debug, Clone, Copy)]
pub struct Vector {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl Vector {
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
pub fn to_vec3(self) -> Vec3 {
Vec3::new(self.x, self.y, self.z)
}
}
impl From<Vec3> for Vector {
fn from(v: Vec3) -> Self {
Self {
x: v.x,
y: v.y,
z: v.z,
}
}
}
impl From<Vector> for Vec3 {
fn from(v: Vector) -> Self {
Vec3::new(v.x, v.y, v.z)
}
}

26
src/lib.rs Normal file
View file

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
//! BIMR kernel — the numeric + geometry core of the BIMR runtime.
//!
//! Layered: `geometry`/`element` (entities) → `mesh` (geometry output) →
//! `math` (numbers). The global [`Entity`] enum is the typed, closed set of
//! kernel objects; each entity struct lives in its own file and owns its
//! constructor + `mesh()`.
mod entity;
pub mod element;
pub mod geometry;
pub mod math;
pub mod mesh;
pub mod operator;
pub mod set;
pub use entity::Entity;
pub use math::Vec3;
pub use mesh::Mesh;
// Convenience re-exports (settled public surface §4).
pub use math::random;
pub use mesh::volume;

173
src/math/mod.rs Normal file
View file

@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
pub use glam::DVec3 as Vec3;
pub mod random;
// ── Constants ─────────────────────────────────────────────────────────────────
/// World-up axis — the reference direction for horizontal-plane normal computation.
pub const WORLD_UP: Vec3 = Vec3::new(0.0, 0.0, 1.0);
// ── Scalar / vector constructors ──────────────────────────────────────────────
/// Encode a bare scalar `n` as a Vec3 positional argument.
///
/// The DSL represents each positional numeric argument as a `Vec3(n, 0, 0)`.
/// `numeric_triple` then unpacks three such values into `[x, y, z]` by reading
/// the x-component of each.
pub fn scalar_vec3(n: f64) -> Vec3 {
Vec3::new(n, 0.0, 0.0)
}
/// Resolve a string direction sentinel to a unit vector.
///
/// Returns `None` for unrecognised names so the caller can emit a proper error.
///
/// | Sentinel | Vector |
/// |----------|--------|
/// | `"up"` | `(0, 0, 1)` — world-up (Z+) |
/// | `"down"` | `(0, 0, -1)` — world-down (Z-) |
/// | `"x"` | `(1, 0, 0)` — X axis |
/// | `"y"` | `(0, 1, 0)` — Y axis |
/// | `"z"` | `(0, 0, 1)` — Z axis (alias for "up") |
pub fn resolve_direction(name: &str) -> Option<Vec3> {
match name {
"up" | "z" => Some(WORLD_UP),
"down" => Some(Vec3::new(0.0, 0.0, -1.0)),
"x" => Some(Vec3::new(1.0, 0.0, 0.0)),
"y" => Some(Vec3::new(0.0, 1.0, 0.0)),
_ => None,
}
}
// ── Vector operations ─────────────────────────────────────────────────────────
/// Vector addition.
pub fn add3(a: Vec3, b: Vec3) -> Vec3 {
a + b
}
/// Vector subtraction.
pub fn sub3(a: Vec3, b: Vec3) -> Vec3 {
a - b
}
/// Scalar multiplication.
pub fn scale3(a: Vec3, s: f64) -> Vec3 {
a * s
}
/// Vector magnitude.
pub fn mag3(a: Vec3) -> f64 {
a.length()
}
/// Cross product.
pub fn cross3(a: Vec3, b: Vec3) -> Vec3 {
a.cross(b)
}
#[cfg(test)]
mod tests {
use super::*;
// ── Vector operations ──────────────────────────────────────────────────────
#[test]
fn test_add3() {
assert_eq!(
add3(Vec3::new(1.0, 2.0, 3.0), Vec3::new(4.0, 5.0, 6.0)),
Vec3::new(5.0, 7.0, 9.0)
);
}
#[test]
fn test_add3_zero() {
assert_eq!(
add3(Vec3::new(1.0, -2.0, 3.0), Vec3::ZERO),
Vec3::new(1.0, -2.0, 3.0)
);
}
#[test]
fn test_sub3() {
assert_eq!(
sub3(Vec3::new(5.0, 7.0, 9.0), Vec3::new(4.0, 5.0, 6.0)),
Vec3::new(1.0, 2.0, 3.0)
);
}
#[test]
fn test_sub3_negate() {
assert_eq!(
sub3(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)),
Vec3::new(-1.0, -2.0, -3.0)
);
}
#[test]
fn test_scale3() {
assert_eq!(
scale3(Vec3::new(2.0, 4.0, 6.0), 0.5),
Vec3::new(1.0, 2.0, 3.0)
);
}
#[test]
fn test_scale3_zero() {
assert_eq!(scale3(Vec3::new(2.0, 4.0, 6.0), 0.0), Vec3::ZERO);
}
#[test]
fn test_mag3() {
assert!((mag3(Vec3::new(3.0, 4.0, 0.0)) - 5.0).abs() < 1e-12);
}
#[test]
fn test_mag3_zero() {
assert_eq!(mag3(Vec3::ZERO), 0.0);
}
#[test]
fn test_cross3() {
let x = Vec3::X;
let y = Vec3::Y;
assert_eq!(cross3(x, y), Vec3::Z);
}
#[test]
fn test_cross3_anti_commutative() {
let a = Vec3::new(2.0, 3.0, 4.0);
let b = Vec3::new(5.0, 6.0, 7.0);
assert_eq!(cross3(a, b), scale3(cross3(b, a), -1.0));
}
// ── Utilities ──────────────────────────────────────────────────────────────
#[test]
fn test_scalar_vec3() {
assert_eq!(scalar_vec3(42.0), Vec3::new(42.0, 0.0, 0.0));
}
#[test]
fn test_resolve_direction_up() {
assert_eq!(resolve_direction("up"), Some(Vec3::Z));
}
#[test]
fn test_resolve_direction_down() {
assert_eq!(resolve_direction("down"), Some(-Vec3::Z));
}
#[test]
fn test_resolve_direction_x() {
assert_eq!(resolve_direction("x"), Some(Vec3::X));
}
#[test]
fn test_resolve_direction_unknown() {
assert_eq!(resolve_direction("unknown"), None);
}
}

125
src/math/random.rs Normal file
View file

@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// ── MT19937 — minimal Mersenne Twister matching Python's random module ────────
const N: usize = 624;
const M: usize = 397;
const MATRIX_A: u32 = 0x9908b0df;
const UPPER_MASK: u32 = 0x80000000;
const LOWER_MASK: u32 = 0x7fffffff;
pub struct Mt19937 {
mt: [u32; N],
idx: usize,
}
impl Mt19937 {
/// Initialise the MT19937 state from a 32-bit seed.
pub fn seed(s: u32) -> Self {
let mut mt = [0u32; N];
mt[0] = s;
for i in 1..N {
mt[i] = 1812433253u32
.wrapping_mul(mt[i - 1] ^ (mt[i - 1] >> 30))
.wrapping_add(i as u32);
}
Self { mt, idx: N }
}
/// Regenerate all 624 state words using the recurrence relation.
fn generate(&mut self) {
static MAG: [u32; 2] = [0, MATRIX_A];
for i in 0..N {
let x = (self.mt[i] & UPPER_MASK) | (self.mt[(i + 1) % N] & LOWER_MASK);
self.mt[i] = self.mt[(i + M) % N] ^ (x >> 1) ^ MAG[(x & 1) as usize];
}
self.idx = 0;
}
/// Extract the next tempered u32 from the state array.
pub fn next_u32(&mut self) -> u32 {
if self.idx >= N {
self.generate();
}
let mut y = self.mt[self.idx];
self.idx += 1;
// Tempering
y ^= y >> 11;
y ^= (y << 7) & 0x9d2c5680;
y ^= (y << 15) & 0xefc60000;
y ^= y >> 18;
y
}
/// Uniform float in [0, 1) — same as Python's `random.random()`.
pub fn next_f64(&mut self) -> f64 {
let a = (self.next_u32() >> 5) as u64;
let b = (self.next_u32() >> 6) as u64;
((a * 67108864 + b) as f64) * (1.0 / 9007199254740992.0)
}
/// Uniform float in [min, max) — same as Python's `random.uniform(min, max)`.
pub fn uniform(&mut self, min: f64, max: f64) -> f64 {
min + self.next_f64() * (max - min)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_seed_reproducibility() {
let mut a = Mt19937::seed(42);
let mut b = Mt19937::seed(42);
for _ in 0..100 {
assert_eq!(a.next_u32(), b.next_u32());
}
}
#[test]
fn test_different_seeds_different() {
let mut a = Mt19937::seed(42);
let mut b = Mt19937::seed(99);
let first_a = a.next_u32();
let first_b = b.next_u32();
assert_ne!(first_a, first_b);
}
#[test]
fn test_uniform_range() {
let mut rng = Mt19937::seed(7);
for _ in 0..1000 {
let v = rng.uniform(5.0, 10.0);
assert!((5.0..10.0).contains(&v), "value {} out of range [5, 10)", v);
}
}
#[test]
fn test_uniform_reproducibility() {
let mut a = Mt19937::seed(123);
let mut b = Mt19937::seed(123);
for _ in 0..50 {
assert!((a.uniform(0.0, 1.0) - b.uniform(0.0, 1.0)).abs() < 1e-15);
}
}
#[test]
fn test_next_f64_range() {
let mut rng = Mt19937::seed(1);
for _ in 0..1000 {
let v = rng.next_f64();
assert!((0.0..1.0).contains(&v), "value {} out of [0, 1)", v);
}
}
#[test]
fn test_uniform_negative_range() {
let mut rng = Mt19937::seed(42);
for _ in 0..100 {
let v = rng.uniform(-10.0, -5.0);
assert!((-10.0..-5.0).contains(&v));
}
}
}

309
src/mesh/curve.rs Normal file
View file

@ -0,0 +1,309 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::math::{Vec3, WORLD_UP, cross3, mag3, scale3, sub3};
/// A Catmull-Rom spline through a closed set of control points.
///
/// Wraps the raw `catmull_rom()` function with a typed API.
/// The spline is implicitly closed — the last span wraps to the first point.
pub struct Spline {
ctrl: Vec<Vec3>,
}
impl Spline {
/// Build a spline from control points. Returns `None` if fewer than 2 points.
pub fn new(ctrl: Vec<Vec3>) -> Option<Self> {
if ctrl.len() < 2 {
return None;
}
Some(Self { ctrl })
}
/// Sample evenly-spaced points along the spline at elevation `z`.
///
/// Returns `segs_per_span` samples for each span between consecutive control
/// points (wrapping). Total output: `ctrl.len() x segs_per_span`.
pub fn sample(&self, z: f64, segs_per_span: usize) -> Vec<Vec3> {
catmull_rom(&self.ctrl, z, segs_per_span)
}
/// Return the control points.
pub fn control_points(&self) -> &[Vec3] {
&self.ctrl
}
}
/// A general one-dimensional curve.
///
/// Either a smooth [`Spline`] over control points, or an already-discretised
/// [`Polyline`](Vec) of sampled points.
pub enum Curve {
/// Spline through control points; sampling produces a polyline.
Spline(Spline),
/// Already-discretised curve — the points are the samples.
Polyline(Vec<Vec3>),
}
/// Catmull-Rom spline interpolation through a closed set of control points.
///
/// Produces a closed polyline by computing `segs_per_span` samples for each
/// span between consecutive control points (wrapping). Uses the standard
/// centripetal Catmull-Rom formula. The returned points do NOT include a
/// duplicate closing vertex — the polyline is implicitly closed.
///
/// `z` overrides the Z coordinate for all output points.
pub fn catmull_rom(ctrl: &[Vec3], z: f64, segs_per_span: usize) -> Vec<Vec3> {
let n = ctrl.len();
if n < 2 {
return vec![];
}
let mut pts = Vec::with_capacity(n * segs_per_span);
for span in 0..n {
let p0 = ctrl[(span + n - 1) % n];
let p1 = ctrl[span];
let p2 = ctrl[(span + 1) % n];
let p3 = ctrl[(span + 2) % n];
for k in 0..segs_per_span {
let t = k as f64 / segs_per_span as f64;
let t2 = t * t;
let t3 = t2 * t;
let point = 0.5
* (2.0 * p1
+ (p2 - p0) * t
+ (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
+ (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
pts.push(Vec3::new(point.x, point.y, z));
}
}
pts
}
/// Arc-length table for a closed polyline.
///
/// Returns `(segs, total, cumul)` where `segs[i]` is the length of segment
/// i → i+1 (wrapping), `total` is the sum, and `cumul[i]` is the arc length
/// at the start of segment i (`cumul[0] == 0`).
pub fn polyline_arc_lengths(points: &[Vec3]) -> (Vec<f64>, f64, Vec<f64>) {
let n = points.len();
let segs: Vec<f64> = (0..n)
.map(|i| {
let a = points[i];
let b = points[(i + 1) % n];
a.distance(b)
})
.collect();
let total: f64 = segs.iter().sum();
let mut cumul = vec![0f64; n + 1];
for i in 0..n {
cumul[i + 1] = cumul[i] + segs[i];
}
(segs, total, cumul)
}
/// Sample `n` equally-spaced points along a closed polyline by arc-length.
pub fn polyline_sample(points: &[Vec3], n: usize) -> Vec<Vec3> {
if n == 0 || points.len() < 2 {
return vec![];
}
let (segs, total, cumul) = polyline_arc_lengths(points);
(0..n)
.map(|k| {
let t = (k as f64 / n as f64) * total;
let seg = cumul
.partition_point(|&c| c <= t)
.saturating_sub(1)
.min(segs.len() - 1);
let u = if segs[seg] > 1e-12 {
(t - cumul[seg]) / segs[seg]
} else {
0.0
};
let a = points[seg];
let b = points[(seg + 1) % points.len()];
a.lerp(b, u)
})
.collect()
}
/// Compute equally-spaced tangent unit vectors along a closed polyline.
///
/// Returns the same number of tangents as `n`, at the same arc-length
/// parameters used by [`polyline_sample`].
pub fn polyline_tangents(points: &[Vec3], n: usize) -> Vec<Vec3> {
let default = Vec3::Y;
if n == 0 || points.len() < 2 {
return vec![default; n];
}
let m = points.len();
let (_, total, cumul) = polyline_arc_lengths(points);
(0..n)
.map(|k| {
let t = (k as f64 / n as f64) * total;
let seg = cumul
.partition_point(|&c| c <= t)
.saturating_sub(1)
.min(m - 1);
let a = points[seg];
let b = points[(seg + 1) % m];
let delta = b - a;
let len = delta.length();
if len > 1e-10 { delta / len } else { default }
})
.collect()
}
/// Normal vector to a line segment in the horizontal plane.
///
/// Computes the unit vector perpendicular to `(end - start)` rotated 90° using
/// `WORLD_UP`, then scales it by `magnitude`.
/// Returns `None` if the line has zero length.
pub fn normal(start: Vec3, end: Vec3, magnitude: f64) -> Option<Vec3> {
let dir = sub3(end, start);
let len = mag3(dir);
if len == 0.0 {
return None;
}
let unit = scale3(dir, 1.0 / len);
let n = cross3(unit, WORLD_UP);
Some(scale3(n, magnitude))
}
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
#[test]
fn test_spline_new_too_few() {
assert!(Spline::new(vec![]).is_none());
assert!(Spline::new(vec![v(0.0, 0.0, 0.0)]).is_none());
}
#[test]
fn test_spline_new_ok() {
let s = Spline::new(vec![v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0)]).unwrap();
assert_eq!(s.control_points().len(), 2);
}
#[test]
fn test_spline_sample_count() {
let ctrl = vec![
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
let s = Spline::new(ctrl).unwrap();
assert_eq!(s.sample(0.0, 10).len(), 40); // 4 spans x 10 segs
}
#[test]
fn test_catmull_rom_output_count() {
let ctrl = [
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
assert_eq!(catmull_rom(&ctrl, 0.0, 10).len(), 40);
}
#[test]
fn test_catmull_rom_too_few() {
assert!(catmull_rom(&[v(0.0, 0.0, 0.0)], 0.0, 10).is_empty());
}
#[test]
fn test_catmull_rom_z_override() {
let ctrl = [
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
for p in &catmull_rom(&ctrl, 50.0, 5) {
assert!((p.z - 50.0).abs() < 1e-12);
}
}
#[test]
fn test_polyline_arc_lengths_square() {
let sq = [
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
let (segs, total, _cumul) = polyline_arc_lengths(&sq);
assert_eq!(segs.len(), 4);
assert!((total - 400.0).abs() < 1e-10);
}
#[test]
fn test_polyline_sample_count() {
let sq = [
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
assert_eq!(polyline_sample(&sq, 8).len(), 8);
}
#[test]
fn test_polyline_sample_empty() {
assert!(polyline_sample(&[], 5).is_empty());
}
#[test]
fn test_polyline_sample_zero_n() {
assert!(polyline_sample(&[v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0)], 0).is_empty());
}
#[test]
fn test_polyline_tangents_count() {
let sq = [
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
assert_eq!(polyline_tangents(&sq, 8).len(), 8);
}
#[test]
fn test_polyline_tangents_unit_length() {
let sq = [
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
for t in polyline_tangents(&sq, 8) {
let len = t.length();
assert!((len - 1.0).abs() < 1e-10 || len < 1e-10);
}
}
#[test]
fn test_normal_perpendicular() {
let n = normal(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 1.0).unwrap();
assert!(n.x.abs() < 1e-12);
assert!(n.z.abs() < 1e-12);
}
#[test]
fn test_normal_zero_length() {
assert!(normal(v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0), 1.0).is_none());
}
#[test]
fn test_normal_magnitude() {
let n = normal(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 5.0).unwrap();
let len = n.length();
assert!((len - 5.0).abs() < 1e-10);
}
}

89
src/mesh/face.rs Normal file
View file

@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Face construction — the vertical quad standing on a line.
use crate::math::{Vec3, WORLD_UP, cross3};
const MIN_LENGTH: f64 = 1e-10;
/// The vertical quad standing on the line `start` -> `end`.
///
/// Corners are wound CCW around the face:
/// [bottom-left, bottom-right, top-right, top-left] — the bottom edge lies on
/// the line, the top edge is raised by `height` along world +Z.
pub fn vertical_face(start: Vec3, end: Vec3, height: f64) -> [Vec3; 4] {
[
start,
end,
end + WORLD_UP * height,
start + WORLD_UP * height,
]
}
/// Horizontal vector perpendicular to the line `start` -> `end`, scaled by
/// `magnitude` — the depth direction of a vertical face.
///
/// Points to the left of the travel direction (`cross(WORLD_UP, unit)`).
/// For a degenerate line (no horizontal component) the fallback is -X,
/// matching the historical tangent fallback.
pub fn depth_normal(start: Vec3, end: Vec3, magnitude: f64) -> Vec3 {
let dir = end - start;
let len = (dir.x * dir.x + dir.y * dir.y).sqrt();
if len < MIN_LENGTH {
return Vec3::new(-magnitude, 0.0, 0.0);
}
cross3(WORLD_UP, dir / len) * magnitude
}
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
#[test]
fn test_vertical_face_corners() {
let face = vertical_face(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 150.0);
assert_eq!(face[0], v(0.0, 0.0, 0.0));
assert_eq!(face[1], v(100.0, 0.0, 0.0));
assert_eq!(face[2], v(100.0, 0.0, 150.0));
assert_eq!(face[3], v(0.0, 0.0, 150.0));
}
#[test]
fn test_vertical_face_rotated() {
let face = vertical_face(v(5.0, 5.0, 2.0), v(5.0, 105.0, 2.0), 50.0);
assert_eq!(face[0], v(5.0, 5.0, 2.0));
assert_eq!(face[1], v(5.0, 105.0, 2.0));
assert_eq!(face[2], v(5.0, 105.0, 52.0));
assert_eq!(face[3], v(5.0, 5.0, 52.0));
}
#[test]
fn test_depth_normal_x_line() {
let n = depth_normal(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 12.0);
assert_eq!(n, v(0.0, 12.0, 0.0));
}
#[test]
fn test_depth_normal_y_line() {
let n = depth_normal(v(0.0, 0.0, 0.0), v(0.0, 100.0, 0.0), 12.0);
assert_eq!(n, v(-12.0, 0.0, 0.0));
}
#[test]
fn test_depth_normal_keeps_magnitude() {
let n = depth_normal(v(0.0, 0.0, 0.0), v(30.0, 40.0, 0.0), 12.0);
assert!(n.abs_diff_eq(v(-9.6, 7.2, 0.0), 1e-12));
assert!((n.length() - 12.0).abs() < 1e-12);
}
#[test]
fn test_depth_normal_degenerate_line() {
let n = depth_normal(v(0.0, 0.0, 0.0), v(0.0, 0.0, 100.0), 12.0);
assert_eq!(n, v(-12.0, 0.0, 0.0));
}
}

117
src/mesh/inset.rs Normal file
View file

@ -0,0 +1,117 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Polygon inset — thin adapter over i_overlay's outline offsetting.
use crate::math::Vec3;
use i_overlay::mesh::outline::offset::OutlineOffset;
use i_overlay::mesh::style::{LineJoin, OutlineStyle};
const MIN_EDGE: f64 = 1e-10;
/// Inset a convex planar quad by `offset` toward its interior.
///
/// `face` corners must be wound CCW around the face in the order produced by
/// [`crate::mesh::face::vertical_face`]: bottom-left, bottom-right, top-right,
/// top-left. The returned corners keep that correspondence — `result[i]` is
/// the interior-side corner of the two edges meeting at `face[i]`.
///
/// Returns `None` when the face is degenerate or the offset collapses it.
pub fn inset(face: &[Vec3; 4], offset: f64) -> Option<[Vec3; 4]> {
// Face-plane basis: u along the bottom edge, v along the left edge.
let origin = face[0];
let u = face[1] - face[0];
let v = face[3] - face[0];
let (ul, vl) = (u.length(), v.length());
if ul < MIN_EDGE || vl < MIN_EDGE {
return None;
}
let (u, v) = (u / ul, v / vl);
let contour: Vec<[f64; 2]> = face
.iter()
.map(|p| {
let d = *p - origin;
[d.dot(u), d.dot(v)]
})
.collect();
let style = OutlineStyle::new(-offset).line_join(LineJoin::Miter(1.0));
let ring = contour
.outline_as::<i64>(&style)
.into_iter()
.next()?
.into_iter()
.next()?;
if ring.len() != 4 {
return None;
}
// Rotate the CCW ring to start at the corner nearest the original
// bottom-left corner, restoring corner-to-corner correspondence.
let start = (0..4)
.min_by(|&a, &b| {
let da = dist2(ring[a], contour[0]);
let db = dist2(ring[b], contour[0]);
da.total_cmp(&db)
})
.unwrap();
let ring: [[f64; 2]; 4] = core::array::from_fn(|k| ring[(start + k) % 4]);
Some(ring.map(|[x, y]| origin + u * x + v * y))
}
fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (a[0] - b[0], a[1] - b[1]);
dx * dx + dy * dy
}
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
#[test]
fn test_square_inset() {
let face = [v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), v(100.0, 0.0, 100.0), v(0.0, 0.0, 100.0)];
let inner = inset(&face, 10.0).expect("inset of a 100x100 face by 10");
assert!(inner[0].abs_diff_eq(v(10.0, 0.0, 10.0), 1e-9));
assert!(inner[1].abs_diff_eq(v(90.0, 0.0, 10.0), 1e-9));
assert!(inner[2].abs_diff_eq(v(90.0, 0.0, 90.0), 1e-9));
assert!(inner[3].abs_diff_eq(v(10.0, 0.0, 90.0), 1e-9));
}
#[test]
fn test_rectangle_inset_frame_case() {
let face = [v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), v(100.0, 0.0, 150.0), v(0.0, 0.0, 150.0)];
let inner = inset(&face, 5.0).expect("inset of the frame face by 5");
assert!(inner[0].abs_diff_eq(v(5.0, 0.0, 5.0), 1e-9));
assert!(inner[1].abs_diff_eq(v(95.0, 0.0, 5.0), 1e-9));
assert!(inner[2].abs_diff_eq(v(95.0, 0.0, 145.0), 1e-9));
assert!(inner[3].abs_diff_eq(v(5.0, 0.0, 145.0), 1e-9));
}
#[test]
fn test_rotated_face_inset() {
let face = [v(0.0, 0.0, 0.0), v(100.0, 100.0, 0.0), v(100.0, 100.0, 50.0), v(0.0, 0.0, 50.0)];
let inner = inset(&face, 10.0).expect("inset of a rotated face");
assert!(inner[0].abs_diff_eq(v(10.0 / 2.0f64.sqrt(), 10.0 / 2.0f64.sqrt(), 10.0), 1e-9));
assert!(inner[2].abs_diff_eq(v(100.0 - 10.0 / 2.0f64.sqrt(), 100.0 - 10.0 / 2.0f64.sqrt(), 40.0), 1e-9));
}
#[test]
fn test_collapse_is_rejected() {
let face = [v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), v(100.0, 0.0, 100.0), v(0.0, 0.0, 100.0)];
assert!(inset(&face, 50.0).is_none());
assert!(inset(&face, 60.0).is_none());
}
#[test]
fn test_degenerate_face_is_rejected() {
let face = [v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0), v(0.0, 0.0, 100.0), v(0.0, 0.0, 100.0)];
assert!(inset(&face, 5.0).is_none());
}
}

98
src/mesh/mod.rs Normal file
View file

@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Mesh type and mesh-level operations — triangulation and ring winding.
use earcut::Earcut;
pub mod curve;
pub mod face;
pub mod inset;
pub mod surface;
pub mod volume;
/// Ensure a closed ring `[p0, ..., pn, p0]` is wound counter-clockwise (CCW)
/// when viewed from +Z. Reverses the unique vertices in-place if CW.
/// The ring must already be closed (first == last element).
pub fn ensure_ccw(ring: &mut Vec<(f64, f64)>) {
let n = ring.len();
if n < 4 {
return;
} // need at least 3 unique vertices + closing duplicate
let en = n - 1;
// Shoelace formula: positive result = CCW, negative = CW.
let area: f64 = (0..en)
.map(|i| {
let (x0, y0) = ring[i];
let (x1, y1) = ring[(i + 1) % en];
x0 * y1 - x1 * y0
})
.sum();
if area < 0.0 {
ring.truncate(en);
ring.reverse();
let first = ring[0];
ring.push(first);
}
}
/// Triangulate a closed 2D polygon ring (unique vertices, no duplicate closing point).
/// Returns a flat list of triangle indices into the input ring.
/// Handles convex and concave polygons. Holes not supported yet.
pub fn triangulate_polygon(ring: &[(f64, f64)]) -> Vec<u32> {
let pairs: Vec<[f64; 2]> = ring.iter().map(|&(x, y)| [x, y]).collect();
let mut ec = Earcut::new();
let mut tri: Vec<u32> = Vec::new();
ec.earcut(pairs, &[], &mut tri);
tri
}
/// A triangle mesh: vertices in same units as inputs + face-vertex indices (groups of 3).
pub struct Mesh {
pub points: Vec<[f64; 3]>,
pub indices: Vec<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ensure_ccw_already_ccw() {
let mut ring = vec![
(0.0, 0.0),
(100.0, 0.0),
(100.0, 100.0),
(0.0, 100.0),
(0.0, 0.0),
];
ensure_ccw(&mut ring);
assert_eq!(ring.len(), 5);
}
#[test]
fn test_ensure_ccw_cw_to_ccw() {
let mut ring = vec![
(0.0, 0.0),
(0.0, 100.0),
(100.0, 100.0),
(100.0, 0.0),
(0.0, 0.0),
];
ensure_ccw(&mut ring);
assert_eq!(ring.len(), 5);
}
#[test]
fn test_ensure_ccw_too_few() {
let mut ring = vec![(0.0, 0.0), (100.0, 0.0), (0.0, 0.0)];
ensure_ccw(&mut ring);
assert_eq!(ring.len(), 3);
}
#[test]
fn test_triangulate_quad() {
let ring = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
assert_eq!(triangulate_polygon(&ring).len(), 6);
}
}

99
src/mesh/surface.rs Normal file
View file

@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// 2D geometry — simple plane and surface wrapper.
use crate::math::{Vec3, WORLD_UP};
use crate::mesh::Mesh;
/// Default half-size of the plane patch in local units.
const PATCH_HALF_SIZE: f64 = 1.0;
/// A simple plane defined by an origin point and a normal.
pub struct Plane {
pub origin: Vec3,
pub normal: Vec3,
}
impl Plane {
/// Build a plane from an origin and a normal. The normal need not be
/// unit-length; it is normalised internally. Returns `None` if the normal
/// is (near) zero.
pub fn new(origin: Vec3, normal: Vec3) -> Option<Self> {
let len = normal.length();
if len < 1e-12 {
return None;
}
Some(Self {
origin,
normal: normal / len,
})
}
/// Tessellate a fixed-size rectangular patch of this plane into a [`Surface`].
///
/// The patch is centred on `origin`, spans `2 x PATCH_HALF_SIZE` along a
/// plane-local tangent basis, and its two triangles are wound so the face
/// normal points along the plane normal.
pub fn mesh(&self) -> Surface {
let n = self.normal;
// Choose a reference vector not parallel to the normal to seed the basis.
let ref_vec = if n.z.abs() < 0.9 { WORLD_UP } else { Vec3::X };
let u = ref_vec.cross(n).normalize();
let v = n.cross(u);
let s = PATCH_HALF_SIZE;
let c0 = self.origin + u * (-s) + v * (-s);
let c1 = self.origin + u * s + v * (-s);
let c2 = self.origin + u * s + v * s;
let c3 = self.origin + u * (-s) + v * s;
Surface {
mesh: Mesh {
points: vec![c0.to_array(), c1.to_array(), c2.to_array(), c3.to_array()],
indices: vec![0, 1, 2, 0, 2, 3],
},
}
}
}
/// A general two-dimensional surface, represented by its tessellated mesh.
pub struct Surface {
pub mesh: Mesh,
}
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
#[test]
fn test_plane_new_zero_normal() {
assert!(Plane::new(v(0.0, 0.0, 0.0), Vec3::ZERO).is_none());
}
#[test]
fn test_plane_normalises() {
let p = Plane::new(v(0.0, 0.0, 0.0), v(0.0, 0.0, 5.0)).unwrap();
assert!((p.normal - Vec3::Z).length() < 1e-12);
}
#[test]
fn test_plane_mesh_count() {
let p = Plane::new(v(0.0, 0.0, 0.0), Vec3::Z).unwrap();
let s = p.mesh();
assert_eq!(s.mesh.points.len(), 4);
assert_eq!(s.mesh.indices.len(), 6);
}
#[test]
fn test_plane_mesh_winding() {
let p = Plane::new(v(0.0, 0.0, 0.0), Vec3::Z).unwrap();
let s = p.mesh();
let [a, b, c] = [s.mesh.points[0], s.mesh.points[1], s.mesh.points[2]];
let n = (Vec3::from_array(b) - Vec3::from_array(a))
.cross(Vec3::from_array(c) - Vec3::from_array(a));
assert!(n.z > 0.0);
}
}

374
src/mesh/volume.rs Normal file
View file

@ -0,0 +1,374 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// 3D mesh operations — box, slab, polymesh and extrusion generation.
use super::{face, inset};
use crate::math::{Vec3, add3, sub3};
use crate::mesh::Mesh;
// ── Box mesh (shared by wall, column, frame, solid) ──────────────────────────
fn box8_mesh(p: &[[f64; 3]; 8]) -> Mesh {
const FACES: [[usize; 4]; 6] = [
[0, 3, 2, 1],
[4, 5, 6, 7],
[0, 1, 5, 4],
[3, 7, 6, 2],
[0, 4, 7, 3],
[1, 2, 6, 5],
];
let mut points: Vec<[f64; 3]> = Vec::with_capacity(24);
let mut indices: Vec<u32> = Vec::with_capacity(36);
for (fi, face) in FACES.iter().enumerate() {
let b = (fi * 4) as u32;
for &vi in face {
points.push(p[vi]);
}
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
}
Mesh { points, indices }
}
// ── Volume mesh constructors ──────────────────────────────────────────────────
/// Triangulated mesh for a BIM wall (box from centerline + width + height).
pub fn path_box(start: Vec3, end: Vec3, width: f64, height: f64) -> Mesh {
let (sx, sy, sz) = (start.x, start.y, start.z);
let (ex, ey, ez) = (end.x, end.y, end.z);
let w = width;
let h = height;
let (dx, dy) = (ex - sx, ey - sy);
let len = (dx * dx + dy * dy).sqrt();
let (ux, uy) = (dx / len, dy / len);
let (px, py) = (uy * w, -ux * w);
box8_mesh(&[
[sx, sy, sz],
[ex, ey, ez],
[ex + px, ey + py, ez],
[sx + px, sy + py, sz],
[sx, sy, sz + h],
[ex, ey, ez + h],
[ex + px, ey + py, ez + h],
[sx + px, sy + py, sz + h],
])
}
/// Triangulated mesh for a BIM column (centred rectangular prism).
pub fn centered_box(base: Vec3, height: f64, sw: f64, sh: f64) -> Mesh {
let (bx, by, bz) = (base.x, base.y, base.z);
let (w, d, h) = (sw * 0.5, sh * 0.5, height);
box8_mesh(&[
[bx - w, by - d, bz],
[bx + w, by - d, bz],
[bx + w, by + d, bz],
[bx - w, by + d, bz],
[bx - w, by - d, bz + h],
[bx + w, by - d, bz + h],
[bx + w, by + d, bz + h],
[bx - w, by + d, bz + h],
])
}
/// Triangulated mesh for a hollow BIM frame (rectangular ring solid).
///
/// The line `start` -> `end` is the bottom edge of the frame's face — the
/// frame's width IS the line length. `height` raises the face along world +Z,
/// `thickness` insets the face toward its interior, `depth` extrudes the ring
/// along the horizontal normal of the line ([`face::depth_normal`], left of
/// the travel direction).
///
/// Pipeline: [`face::vertical_face`] -> [`inset::inset`] -> [`ring_mesh`].
/// Produces 16 vertices and 16 quads (32 triangles); an empty mesh when the
/// line is degenerate or the inset collapses the face.
pub fn hollow_prism(start: Vec3, end: Vec3, height: f64, depth: f64, thickness: f64) -> Mesh {
let outer = face::vertical_face(start, end, height);
let Some(inner) = inset::inset(&outer, thickness) else {
return Mesh {
points: vec![],
indices: vec![],
};
};
ring_mesh(&outer, &inner, face::depth_normal(start, end, depth))
}
/// Assemble the hollow ring mesh from two corner-corresponding quad rings.
///
/// `outer` and `inner` are CCW quad corners in the face plane
/// ([`face::vertical_face`] + [`inset::inset`]); `depth` translates both rings
/// to the back. Produces the same 16-vertex / 16-quad layout as the current
/// `hollow_prism` body — vertex order, winding and emission included.
fn ring_mesh(outer: &[Vec3; 4], inner: &[Vec3; 4], depth: Vec3) -> Mesh {
let mut p: [[f64; 3]; 16] = [[0.0; 3]; 16];
for i in 0..4 {
p[i] = outer[i].to_array();
p[i + 4] = (outer[i] + depth).to_array();
p[i + 8] = inner[i].to_array();
p[i + 12] = (inner[i] + depth).to_array();
}
// 16 quads — winding chosen so each face normal points outward / toward void
const QUADS: [[usize; 4]; 16] = [
// Front ring (normal = -N, outward from front)
[0, 1, 9, 8],
[1, 2, 10, 9],
[2, 3, 11, 10],
[3, 0, 8, 11],
// Back ring (normal = +N, outward from back)
[4, 12, 13, 5],
[5, 13, 14, 6],
[6, 14, 15, 7],
[7, 15, 12, 4],
// Outer sides
[0, 4, 5, 1],
[1, 5, 6, 2],
[2, 6, 7, 3],
[3, 7, 4, 0],
// Inner sides (normals toward void center)
// bottom +U, right -T, top -U, left +T
[8, 9, 13, 12],
[9, 10, 14, 13],
[10, 11, 15, 14],
[11, 8, 12, 15],
];
let mut points: Vec<[f64; 3]> = Vec::with_capacity(64);
let mut indices: Vec<u32> = Vec::with_capacity(96);
for (fi, quad) in QUADS.iter().enumerate() {
let b = (fi * 4) as u32;
for &vi in quad {
points.push(p[vi]);
}
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
}
Mesh { points, indices }
}
/// Triangulated mesh for a solid (box from 4-corner base + 4-corner top).
pub fn box_geometry(base: &[Vec3; 4], top: &[Vec3; 4]) -> Mesh {
box8_mesh(&[
base[0].to_array(),
base[1].to_array(),
base[2].to_array(),
base[3].to_array(),
top[0].to_array(),
top[1].to_array(),
top[2].to_array(),
top[3].to_array(),
])
}
/// Triangulated mesh for a polymesh (extruded ribbon — variable-length prism).
pub fn ribbon_geometry(base: &[Vec3], top: &[Vec3]) -> Mesh {
let n = base.len();
let mut points: Vec<[f64; 3]> = Vec::with_capacity((n - 1) * 4);
let mut indices: Vec<u32> = Vec::with_capacity((n - 1) * 6);
for i in 0..(n - 1) {
let (b0, b1) = (base[i], base[i + 1]);
let (t0, t1) = (top[i], top[i + 1]);
let bi = (i * 4) as u32;
points.push(b0.to_array());
points.push(b1.to_array());
points.push(t1.to_array());
points.push(t0.to_array());
indices.extend_from_slice(&[bi, bi + 1, bi + 2, bi, bi + 2, bi + 3]);
}
Mesh { points, indices }
}
/// Triangulated mesh from a stack of coordinate rings (extrusion chain).
///
/// `rings` must have at least 2 entries (produced by walking a `Value::Extrusion`
/// chain). The first ring is the bottom, the last is the top.
///
/// - 2-point rings → box solid via [`box_geometry`] using the last extrusion vector.
/// - N-point rings → ribbon via [`ribbon_geometry`].
///
/// Returns `None` if fewer than 2 rings are provided.
pub fn extrusion_geometry(rings: &[Vec<Vec3>]) -> Option<Mesh> {
let n = rings.len();
if n < 2 {
return None;
}
let bottom = &rings[0];
let top = rings.last().unwrap();
if bottom.len() == 2 && n >= 3 {
let ring1 = &rings[1];
let base: [Vec3; 4] = [bottom[0], bottom[1], ring1[1], ring1[0]];
let prev = &rings[n - 2];
let v_last = sub3(top[0], prev[0]);
let top4: [Vec3; 4] = base.map(|p| add3(p, v_last));
Some(box_geometry(&base, &top4))
} else {
Some(ribbon_geometry(bottom, top))
}
}
/// Triangulated mesh for a BIM slab (polyline profile extruded downward by thickness).
/// Generates side faces + top and bottom caps.
pub fn prism_geometry(profile: &[Vec3], thickness: f64, elevation: f64) -> Mesh {
// Build closed XY ring, then normalise to CCW so all face normals point outward.
let mut ring: Vec<(f64, f64)> = profile.iter().map(|p| (p.x, p.y)).collect();
if ring.len() >= 2 && ring.first() != ring.last() {
let first = ring[0];
ring.push(first);
}
crate::mesh::ensure_ccw(&mut ring);
let n = ring.len();
let en = n.saturating_sub(1); // unique vertex count
let zt = elevation;
let zb = elevation - thickness;
let mut points: Vec<[f64; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
// Side faces (n-1 quads).
for i in 0..(n - 1) {
let (x0, y0) = ring[i];
let (x1, y1) = ring[i + 1];
let bi = points.len() as u32;
points.push([x0, y0, zb]);
points.push([x1, y1, zb]);
points.push([x1, y1, zt]);
points.push([x0, y0, zt]);
indices.extend_from_slice(&[bi, bi + 1, bi + 2, bi, bi + 2, bi + 3]);
}
if en >= 3 {
let tri = crate::mesh::triangulate_polygon(&ring[..en]);
// Top cap — CCW from above.
let start = points.len() as u32;
for &(x, y) in &ring[..en] {
points.push([x, y, zt]);
}
for t in tri.chunks(3) {
indices.extend_from_slice(&[start + t[0], start + t[1], start + t[2]]);
}
// Bottom cap — reversed winding.
let start = points.len() as u32;
for &(x, y) in &ring[..en] {
points.push([x, y, zb]);
}
for t in tri.chunks(3) {
indices.extend_from_slice(&[start + t[0], start + t[2], start + t[1]]);
}
}
Mesh { points, indices }
}
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
#[test]
fn test_path_box() {
let g = path_box(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 20.0, 300.0);
assert_eq!(g.points.len(), 24);
assert_eq!(g.indices.len(), 36);
}
#[test]
fn test_centered_box() {
let g = centered_box(v(0.0, 0.0, 0.0), 300.0, 30.0, 30.0);
assert_eq!(g.points.len(), 24);
assert_eq!(g.indices.len(), 36);
}
#[test]
fn test_hollow_prism() {
let g = hollow_prism(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 150.0, 12.0, 5.0);
assert_eq!(g.points.len(), 64);
assert_eq!(g.indices.len(), 96);
}
#[test]
fn test_hollow_prism_golden() {
// Golden test: the decomposed pipeline (face -> inset -> depth ->
// ring_mesh) emits this exact mesh for the equivalent input (origin
// (0,0,0), tangent (1,0,0), width 100, height 150, depth 12,
// thickness 5).
let g = hollow_prism(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 150.0, 12.0, 5.0);
// Quad 0 — front ring, bottom: outer BL, outer BR, inner BR, inner BL.
assert_eq!(g.points[0], [0.0, 0.0, 0.0]);
assert_eq!(g.points[1], [100.0, 0.0, 0.0]);
assert_eq!(g.points[2], [95.0, 0.0, 5.0]);
assert_eq!(g.points[3], [5.0, 0.0, 5.0]);
// Quad 1 — front ring, right.
assert_eq!(g.points[4], [100.0, 0.0, 0.0]);
assert_eq!(g.points[5], [100.0, 0.0, 150.0]);
assert_eq!(g.points[6], [95.0, 0.0, 145.0]);
assert_eq!(g.points[7], [95.0, 0.0, 5.0]);
// Quad 8 — outer side, bottom: the ring extrudes toward +Y.
assert_eq!(g.points[32], [0.0, 0.0, 0.0]);
assert_eq!(g.points[33], [0.0, 12.0, 0.0]);
assert_eq!(g.points[34], [100.0, 12.0, 0.0]);
assert_eq!(g.points[35], [100.0, 0.0, 0.0]);
}
#[test]
fn test_hollow_prism_degenerate_line() {
let g = hollow_prism(v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0), 150.0, 12.0, 5.0);
assert!(g.points.is_empty());
assert!(g.indices.is_empty());
}
#[test]
fn test_extrusion_geometry_empty() {
assert!(extrusion_geometry(&[]).is_none());
}
#[test]
fn test_prism_geometry() {
let profile = [
v(0.0, 0.0, 0.0),
v(100.0, 0.0, 0.0),
v(100.0, 100.0, 0.0),
v(0.0, 100.0, 0.0),
];
let g = prism_geometry(&profile, 15.0, 0.0);
assert_eq!(g.points.len(), 24);
assert_eq!(g.indices.len(), 36);
}
#[test]
fn test_indices_in_bounds() {
let g = path_box(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 20.0, 300.0);
for &idx in &g.indices {
assert!((idx as usize) < g.points.len());
}
}
#[test]
fn test_indices_triplet() {
let g = path_box(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 20.0, 300.0);
assert_eq!(g.indices.len() % 3, 0);
}
#[test]
fn test_path_box_bounds() {
let g = path_box(v(0.0, 0.0, 0.0), v(100.0, 0.0, 0.0), 20.0, 300.0);
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in &g.points {
for i in 0..3 {
if p[i] < lo[i] {
lo[i] = p[i];
}
if p[i] > hi[i] {
hi[i] = p[i];
}
}
}
assert!((hi[0] - lo[0] - 100.0).abs() < 1e-6);
assert!((hi[1] - lo[1] - 20.0).abs() < 1e-6);
assert!((hi[2] - lo[2] - 300.0).abs() < 1e-6);
}
}

119
src/operator/cut.rs Normal file
View file

@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use super::divide::circle_points;
use crate::Entity;
use crate::geometry::{Line, Point};
/// Cutting an entity into pieces — the same sampling as [`super::Divide`],
/// but the output is curve pieces: consecutive divided points pair into
/// lines, the last closing back to the first. A circle cuts into a closed
/// loop of chords.
#[derive(Debug, Clone)]
pub struct Cut {
source: Box<Entity>,
n: usize,
}
/// Why a cut could not be performed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CutError {
/// No cutting method for this source nature yet.
UnsupportedSource { got: &'static str },
}
impl Cut {
pub fn new(source: Entity, n: usize) -> Self {
Self {
source: Box::new(source),
n,
}
}
/// The entity being cut.
pub fn source(&self) -> &Entity {
&self.source
}
/// The piece count.
pub fn n(&self) -> usize {
self.n
}
/// Curve pieces along the source — circle and curve cutting: `n` chords
/// pairing consecutive divided points, the last closing to the first.
pub fn lines(&self) -> Result<Vec<Line>, CutError> {
match &*self.source {
Entity::Circle(circle) => Ok(circle_pieces(circle, self.n)),
Entity::Curve(curve) => Ok(loop_pieces(&curve.points(self.n))),
other => Err(CutError::UnsupportedSource {
got: other.type_name(),
}),
}
}
}
/// `n` pieces of a circle: the divided points, paired `(i, i+1)` and lastly
/// `(n-1, 0)` — a closed loop.
fn circle_pieces(circle: &crate::geometry::Circle, n: usize) -> Vec<Line> {
loop_pieces(&circle_points(circle, n))
}
/// Pieces pairing consecutive samples — `(i, i+1)`, the last closing to
/// the first. The shared shape of circle and curve cutting.
fn loop_pieces(pts: &[Point]) -> Vec<Line> {
(0..pts.len())
.map(|i| Line::new(pts[i], pts[(i + 1) % pts.len()]))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Point as P;
fn circle() -> crate::geometry::Circle {
crate::geometry::Circle::new(P::new(10.0, 20.0, 5.0), 100.0)
}
#[test]
fn circle_cuts_into_a_closed_loop_of_chords() {
let lines = Cut::new(Entity::Circle(circle()), 4)
.lines()
.expect("circle cutting");
assert_eq!(lines.len(), 4);
// Consecutive pairs…
assert!((lines[0].start.x - 110.0).abs() < 1e-9);
assert!((lines[0].end.x - 10.0).abs() < 1e-9 && (lines[0].end.y - 120.0).abs() < 1e-9);
// …and the last piece closes to the first point.
assert!((lines[3].end.x - 110.0).abs() < 1e-9);
for l in &lines {
assert_eq!(l.start.z, 5.0);
}
}
#[test]
fn pieces_chain_end_to_end() {
let lines = Cut::new(Entity::Circle(circle()), 8)
.lines()
.expect("circle cutting");
for (a, b) in lines.iter().zip(lines.iter().skip(1)) {
assert!((a.end.x - b.start.x).abs() < 1e-12);
assert!((a.end.y - b.start.y).abs() < 1e-12);
}
}
#[test]
fn unsupported_source_is_reported() {
let point = Entity::Point(P::new(0.0, 0.0, 0.0));
let err = Cut::new(point, 4).lines().unwrap_err();
assert_eq!(err, CutError::UnsupportedSource { got: "Point" });
}
#[test]
fn accessors_expose_the_record() {
let cut = Cut::new(Entity::Circle(circle()), 6);
assert_eq!(cut.source().type_name(), "Circle");
assert_eq!(cut.n(), 6);
}
}

134
src/operator/divide.rs Normal file
View file

@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::Entity;
use crate::geometry::{Circle, Point};
/// Division of an entity into equal parts — the source, held by value,
/// and the division count. The division itself is a method per output
/// nature: circle division yields [`Divide::points`].
///
/// The source is boxed: `Divide` sits inside `Entity`, so holding an
/// `Entity` directly would make the type cycle.
#[derive(Debug, Clone)]
pub struct Divide {
source: Box<Entity>,
n: usize,
}
/// Why a division could not be performed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DivideError {
/// No division method for this source nature yet.
UnsupportedSource { got: &'static str },
}
impl Divide {
pub fn new(source: Entity, n: usize) -> Self {
Self {
source: Box::new(source),
n,
}
}
/// The entity being divided.
pub fn source(&self) -> &Entity {
&self.source
}
/// The division count.
pub fn n(&self) -> usize {
self.n
}
/// Equally-spaced points along the source — circle and curve division:
/// `n` points around the loop.
pub fn points(&self) -> Result<Vec<Point>, DivideError> {
match &*self.source {
Entity::Circle(circle) => Ok(circle_points(circle, self.n)),
Entity::Curve(curve) => Ok(curve.points(self.n)),
other => Err(DivideError::UnsupportedSource {
got: other.type_name(),
}),
}
}
}
/// `n` equally-spaced points on a circle — `i * 2π/n`, radius r, at the
/// circle's center height. `n = 0` divides nothing.
///
/// Coordinates within `1e-9` of zero snap to it — trig dust (`cos(π/2) =
/// 6.1e-17`) is below any fabrication tolerance and would otherwise litter
/// the compact form. Deterministic: same input, same snap.
pub(crate) fn circle_points(circle: &Circle, n: usize) -> Vec<Point> {
const SNAP: f64 = 1e-9;
let snap = |v: f64| if v.abs() < SNAP { 0.0 } else { v };
(0..n)
.map(|i| {
let t = (i as f64) * std::f64::consts::TAU / (n as f64);
Point::new(
snap(circle.center.x + circle.radius * t.cos()),
snap(circle.center.y + circle.radius * t.sin()),
circle.center.z,
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Line;
use crate::geometry::Point as P;
fn circle() -> Circle {
Circle::new(P::new(10.0, 20.0, 5.0), 100.0)
}
#[test]
fn circle_divides_into_equally_spaced_points() {
let pts = Divide::new(Entity::Circle(circle()), 4)
.points()
.expect("circle division");
assert_eq!(pts.len(), 4);
let xy: Vec<(f64, f64)> = pts.iter().map(|p| (p.x, p.y)).collect();
for (x, y) in &xy {
assert!(((x - 10.0).powi(2) + (y - 20.0).powi(2) - 100.0f64.powi(2)).abs() < 1e-9);
}
// i = 0 starts on the positive x-axis; i = 1 is a quarter turn.
assert!((xy[0].0 - 110.0).abs() < 1e-9 && (xy[0].1 - 20.0).abs() < 1e-9);
assert!((xy[1].0 - 10.0).abs() < 1e-9 && (xy[1].1 - 120.0).abs() < 1e-9);
}
#[test]
fn divided_points_keep_the_center_height() {
let pts = Divide::new(Entity::Circle(circle()), 8)
.points()
.expect("circle division");
assert_eq!(pts.len(), 8);
assert!(pts.iter().all(|p| p.z == 5.0));
}
#[test]
fn single_division_is_one_point() {
let pts = Divide::new(Entity::Circle(circle()), 1)
.points()
.expect("circle division");
assert_eq!(pts.len(), 1);
assert!((pts[0].x - 110.0).abs() < 1e-9);
}
#[test]
fn unsupported_source_is_reported() {
let line = Entity::Line(Line::new(P::new(0.0, 0.0, 0.0), P::new(1.0, 0.0, 0.0)));
let err = Divide::new(line, 4).points().unwrap_err();
assert_eq!(err, DivideError::UnsupportedSource { got: "Line" });
}
#[test]
fn accessors_expose_the_record() {
let d = Divide::new(Entity::Circle(circle()), 6);
assert_eq!(d.source().type_name(), "Circle");
assert_eq!(d.n(), 6);
}
}

134
src/operator/explode.rs Normal file
View file

@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::Entity;
/// Exploding an entity into its natural parts — a Line into its two
/// endpoints, a List of Lines into a List of pairs (each pair a List of the
/// two Points). The parts are values; the engine decides which become nodes.
#[derive(Debug, Clone)]
pub struct Explode {
source: Box<Entity>,
}
/// Why an explosion could not be performed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExplodeError {
/// No explosion method for this source nature yet.
UnsupportedSource { got: &'static str },
}
impl Explode {
pub fn new(source: Entity) -> Self {
Self {
source: Box::new(source),
}
}
/// The entity being exploded.
pub fn source(&self) -> &Entity {
&self.source
}
/// The parts: a Line explodes to its two endpoints; a List of Lines
/// explodes to one pair (a List of two Points) per line.
pub fn parts(&self) -> Result<Vec<Entity>, ExplodeError> {
match &*self.source {
Entity::Line(line) => Ok(vec![Entity::Point(line.start), Entity::Point(line.end)]),
Entity::List(list) => {
if list.kind() != "Line" {
return Err(ExplodeError::UnsupportedSource { got: list.kind() });
}
list.items()
.iter()
.map(|item| match item {
Entity::Line(line) => {
let pair = crate::set::List::try_new(vec![
Entity::Point(line.start),
Entity::Point(line.end),
])
.expect("a pair of two Points — homogeneous");
Ok(Entity::List(pair))
}
other => Err(ExplodeError::UnsupportedSource {
got: other.type_name(),
}),
})
.collect()
}
other => Err(ExplodeError::UnsupportedSource {
got: other.type_name(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::{Line, Point};
use crate::set::List;
fn line() -> Line {
Line::new(Point::new(0.0, 0.0, 0.0), Point::new(100.0, 0.0, 0.0))
}
#[test]
fn a_line_explodes_to_its_two_endpoints() {
let parts = Explode::new(Entity::Line(line()))
.parts()
.expect("line explodes");
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], Entity::Point(p) if p.x == 0.0));
assert!(matches!(&parts[1], Entity::Point(p) if p.x == 100.0));
}
#[test]
fn a_list_of_lines_explodes_to_pairs() {
let l0 = line();
let l1 = Line::new(Point::new(100.0, 0.0, 0.0), Point::new(100.0, 100.0, 0.0));
let lines = List::try_new(vec![Entity::Line(l0), Entity::Line(l1)]).expect("lines");
let parts = Explode::new(Entity::List(lines))
.parts()
.expect("list explodes");
assert_eq!(parts.len(), 2);
for part in &parts {
match part {
Entity::List(pair) => {
assert_eq!(pair.kind(), "Point");
assert_eq!(pair.length(), 2);
}
other => panic!("expected a pair List, got {}", other.type_name()),
}
}
// The first pair holds the first line's endpoints, in order.
match &parts[0] {
Entity::List(pair) => {
assert!(matches!(pair.get(0), Some(Entity::Point(p)) if p.x == 0.0));
assert!(matches!(pair.get(1), Some(Entity::Point(p)) if p.x == 100.0));
}
_ => unreachable!(),
}
}
#[test]
fn a_list_of_the_wrong_kind_is_reported() {
let pts = List::try_new(vec![Entity::Point(Point::new(0.0, 0.0, 0.0))]).expect("pts");
let err = Explode::new(Entity::List(pts)).parts().unwrap_err();
assert_eq!(err, ExplodeError::UnsupportedSource { got: "Point" });
}
#[test]
fn unsupported_source_is_reported() {
let err = Explode::new(Entity::Point(Point::new(0.0, 0.0, 0.0)))
.parts()
.unwrap_err();
assert_eq!(err, ExplodeError::UnsupportedSource { got: "Point" });
}
#[test]
fn accessor_exposes_the_record() {
let e = Explode::new(Entity::Line(line()));
assert_eq!(e.source().type_name(), "Line");
}
}

14
src/operator/mod.rs Normal file
View file

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Operators — derived geometry computed from existing entities.
pub use self::cut::Cut;
pub use self::divide::Divide;
pub use self::explode::Explode;
pub use self::random::Random;
mod cut;
mod divide;
mod explode;
mod random;

180
src/operator/random.rs Normal file
View file

@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::Entity;
use crate::geometry::Point;
use crate::math::random::Mt19937;
/// A seeded random displacement of a List of Points — each point displaced
/// by a uniform offset within `[min, max)` per axis. `dims=2` keeps the
/// elevation (XY noise), `dims=3` displaces all axes. Deterministic per
/// seed: the same source and seed produce the same shape.
#[derive(Debug, Clone)]
pub struct Random {
source: Box<Entity>,
seed: u64,
min: f64,
max: f64,
dims: u8,
}
/// Why a displacement could not be performed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RandomError {
/// The source is not a List of Points.
UnsupportedSource { got: &'static str },
}
impl Random {
pub fn new(source: Entity, seed: u64, min: f64, max: f64, dims: u8) -> Self {
Self {
source: Box::new(source),
seed,
min,
max,
dims,
}
}
/// The displaced source.
pub fn source(&self) -> &Entity {
&self.source
}
pub fn seed(&self) -> u64 {
self.seed
}
pub fn min(&self) -> f64 {
self.min
}
pub fn max(&self) -> f64 {
self.max
}
pub fn dims(&self) -> u8 {
self.dims
}
/// The displaced points, in source order — one uniform draw per axis
/// per point, from a Mersenne Twister seeded by `seed`.
pub fn displaced(&self) -> Result<Vec<Point>, RandomError> {
let (items, kind) = match &*self.source {
Entity::List(list) => (list.items(), list.kind()),
other => {
return Err(RandomError::UnsupportedSource {
got: other.type_name(),
});
}
};
if kind != "Point" {
return Err(RandomError::UnsupportedSource { got: kind });
}
let mut rng = Mt19937::seed(self.seed as u32);
Ok(items
.iter()
.map(|item| {
let p = match item {
Entity::Point(p) => *p,
other => unreachable!("kind `{}` checked: {}", kind, other.type_name()),
};
let dx = rng.uniform(self.min, self.max);
let dy = rng.uniform(self.min, self.max);
let dz = if self.dims >= 3 {
rng.uniform(self.min, self.max)
} else {
0.0
};
Point::new(p.x + dx, p.y + dy, p.z + dz)
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::set::List;
fn base() -> Entity {
let pts: Vec<Entity> = (0..4)
.map(|i| Entity::Point(Point::new(i as f64 * 100.0, 0.0, 500.0)))
.collect();
Entity::List(List::try_new(pts).expect("points"))
}
#[test]
fn displacement_is_deterministic_per_seed() {
let a = Random::new(base(), 42, -150.0, 150.0, 2)
.displaced()
.expect("displaces");
let b = Random::new(base(), 42, -150.0, 150.0, 2)
.displaced()
.expect("displaces");
for (p, q) in a.iter().zip(b.iter()) {
assert_eq!((p.x, p.y, p.z), (q.x, q.y, q.z));
}
}
#[test]
fn different_seeds_differ() {
let a = Random::new(base(), 42, -150.0, 150.0, 2)
.displaced()
.expect("displaces");
let b = Random::new(base(), 43, -150.0, 150.0, 2)
.displaced()
.expect("displaces");
assert_ne!(
(a[0].x, a[0].y, a[0].z),
(b[0].x, b[0].y, b[0].z),
"different seeds, same first point — the shapes would not differ"
);
}
#[test]
fn dims_two_keeps_the_elevation() {
let pts = Random::new(base(), 7, -150.0, 150.0, 2)
.displaced()
.expect("displaces");
for p in &pts {
assert_eq!(p.z, 500.0);
}
}
#[test]
fn offsets_stay_within_the_range() {
let pts = Random::new(base(), 3, -150.0, 150.0, 2)
.displaced()
.expect("displaces");
for (i, p) in pts.iter().enumerate() {
let dx = p.x - i as f64 * 100.0;
assert!((-150.0..=150.0).contains(&dx), "dx {dx} out of range");
assert!((-150.0..=150.0).contains(&p.y), "dy out of range");
}
}
#[test]
fn the_count_follows_the_source() {
let pts = Random::new(base(), 1, -1.0, 1.0, 2)
.displaced()
.expect("displaces");
assert_eq!(pts.len(), 4);
}
#[test]
fn unsupported_source_is_reported() {
let err = Random::new(Entity::Point(Point::new(0.0, 0.0, 0.0)), 1, -1.0, 1.0, 2)
.displaced()
.unwrap_err();
assert_eq!(err, RandomError::UnsupportedSource { got: "Point" });
}
#[test]
fn accessors_expose_the_record() {
let r = Random::new(base(), 9, -5.0, 5.0, 3);
assert_eq!(r.source().type_name(), "List");
assert_eq!(r.seed(), 9);
assert_eq!((r.min(), r.max(), r.dims()), (-5.0, 5.0, 3));
}
}

122
src/set/list.rs Normal file
View file

@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
use crate::Entity;
/// A typed, ordered collection of kernel entities.
///
/// Homogeneous by construction: [`List::try_new`] takes the element kind
/// from the first item and rejects empty or mixed input — a `List` always
/// knows what it holds.
#[derive(Debug, Clone)]
pub struct List {
kind: &'static str,
items: Vec<Entity>,
}
/// Why a [`List`] could not be constructed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListError {
/// No items — the element kind is undecidable.
Empty,
/// Items of more than one kind.
Mixed {
expected: &'static str,
got: &'static str,
},
}
impl List {
/// Construct from items; the element kind is taken from the first item.
pub fn try_new(items: Vec<Entity>) -> Result<Self, ListError> {
let Some(first) = items.first() else {
return Err(ListError::Empty);
};
let kind = first.type_name();
for item in &items {
let got = item.type_name();
if got != kind {
return Err(ListError::Mixed {
expected: kind,
got,
});
}
}
Ok(Self { kind, items })
}
/// The element kind — `"Point"`, `"Wall"`, …
pub fn kind(&self) -> &'static str {
self.kind
}
/// Number of items.
pub fn length(&self) -> usize {
self.items.len()
}
/// The item at `i`, if in bounds.
pub fn get(&self, i: usize) -> Option<&Entity> {
self.items.get(i)
}
/// The items, in order.
pub fn items(&self) -> &[Entity] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Point;
fn point(x: f64) -> Entity {
Entity::Point(Point::new(x, 0.0, 0.0))
}
#[test]
fn kind_is_taken_from_the_first_item() {
let list = List::try_new(vec![point(0.0), point(1.0)]).expect("homogeneous");
assert_eq!(list.kind(), "Point");
assert_eq!(list.length(), 2);
}
#[test]
fn get_returns_items_in_order() {
let list = List::try_new(vec![point(0.0), point(1.0)]).expect("homogeneous");
assert!(matches!(list.get(0), Some(Entity::Point(p)) if p.x == 0.0));
assert!(matches!(list.get(1), Some(Entity::Point(p)) if p.x == 1.0));
assert!(list.get(2).is_none());
}
#[test]
fn empty_is_rejected() {
assert!(matches!(List::try_new(vec![]), Err(ListError::Empty)));
}
#[test]
fn mixed_is_rejected() {
let line = Entity::Line(crate::geometry::Line::new(
Point::new(0.0, 0.0, 0.0),
Point::new(1.0, 0.0, 0.0),
));
let err = List::try_new(vec![point(0.0), line]).unwrap_err();
assert!(matches!(
err,
ListError::Mixed {
expected: "Point",
got: "Line"
}
));
}
#[test]
fn nested_lists_are_homogeneous() {
let a = List::try_new(vec![point(0.0)]).expect("homogeneous");
let b = List::try_new(vec![point(1.0)]).expect("homogeneous");
let list = List::try_new(vec![Entity::List(a), Entity::List(b)]).expect("homogeneous");
assert_eq!(list.kind(), "List");
assert_eq!(list.length(), 2);
}
}

8
src/set/mod.rs Normal file
View file

@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// Collections of entities — sets, sequences.
pub use self::list::List;
mod list;