commit b73dec4f5f9e4c016ab361cb0bbc89d577776be7 Author: rvba Date: Mon Aug 31 10:10:18 2026 +0200 release: 0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa915a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +docs/book +docs/target +.claude/ +.opencode/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..20f70b1 --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..45db347 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9e1af9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Milovann Yanatchkov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e6bcddb --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: doc test + +doc: + cargo doc --no-deps --open + +test: + cargo test diff --git a/README.md b/README.md new file mode 100644 index 0000000..b303e4f --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# bimr-kernel + +Internal geometry kernel for BIMR. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..df3c2a4 --- /dev/null +++ b/docs/README.md @@ -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`. diff --git a/src/element/architecture/building.rs b/src/element/architecture/building.rs new file mode 100644 index 0000000..279057d --- /dev/null +++ b/src/element/architecture/building.rs @@ -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, +} + +impl Building { + pub fn new(name: String, storeys: Vec) -> 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(_))); + } +} diff --git a/src/element/architecture/column.rs b/src/element/architecture/column.rs new file mode 100644 index 0000000..30cbc6e --- /dev/null +++ b/src/element/architecture/column.rs @@ -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, + ) + } +} diff --git a/src/element/architecture/frame.rs b/src/element/architecture/frame.rs new file mode 100644 index 0000000..879a6ae --- /dev/null +++ b/src/element/architecture/frame.rs @@ -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, + ) + } +} diff --git a/src/element/architecture/mod.rs b/src/element/architecture/mod.rs new file mode 100644 index 0000000..f3bc79d --- /dev/null +++ b/src/element/architecture/mod.rs @@ -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; diff --git a/src/element/architecture/slab.rs b/src/element/architecture/slab.rs new file mode 100644 index 0000000..6bc869d --- /dev/null +++ b/src/element/architecture/slab.rs @@ -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) + } +} diff --git a/src/element/architecture/storey.rs b/src/element/architecture/storey.rs new file mode 100644 index 0000000..393999c --- /dev/null +++ b/src/element/architecture/storey.rs @@ -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, +} + +impl Storey { + pub fn new(elevation: f64, entities: Vec) -> 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); + } +} diff --git a/src/element/architecture/wall.rs b/src/element/architecture/wall.rs new file mode 100644 index 0000000..d45cb81 --- /dev/null +++ b/src/element/architecture/wall.rs @@ -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, + ) + } +} diff --git a/src/element/mod.rs b/src/element/mod.rs new file mode 100644 index 0000000..7821478 --- /dev/null +++ b/src/element/mod.rs @@ -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}; diff --git a/src/element/site/mod.rs b/src/element/site/mod.rs new file mode 100644 index 0000000..701aae0 --- /dev/null +++ b/src/element/site/mod.rs @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +// Site discipline — future: Site, Terrain. diff --git a/src/element/structure/mod.rs b/src/element/structure/mod.rs new file mode 100644 index 0000000..b10a95a --- /dev/null +++ b/src/element/structure/mod.rs @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov +// SPDX-License-Identifier: MIT + +// Structure discipline — future: Beam, Column (structural). diff --git a/src/entity.rs b/src/entity.rs new file mode 100644 index 0000000..c35a6e1 --- /dev/null +++ b/src/entity.rs @@ -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 { + 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()); + } +} diff --git a/src/geometry/circle.rs b/src/geometry/circle.rs new file mode 100644 index 0000000..5dc39a7 --- /dev/null +++ b/src/geometry/circle.rs @@ -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 } + } +} diff --git a/src/geometry/curve.rs b/src/geometry/curve.rs new file mode 100644 index 0000000..64d4d30 --- /dev/null +++ b/src/geometry/curve.rs @@ -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 { + 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()); + } +} diff --git a/src/geometry/extrusion.rs b/src/geometry/extrusion.rs new file mode 100644 index 0000000..6bc97c1 --- /dev/null +++ b/src/geometry/extrusion.rs @@ -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, + vec: Vec3, +} + +impl Extrusion { + pub fn new(profile: Vec, 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 { + if self.profile.is_empty() { + return None; + } + let mut ring: Vec = 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 = 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]) -> Option { + volume::extrusion_geometry(rings) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::Point; + + fn square_loop() -> Vec { + 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()); + } +} diff --git a/src/geometry/line.rs b/src/geometry/line.rs new file mode 100644 index 0000000..58467ae --- /dev/null +++ b/src/geometry/line.rs @@ -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 { + line_normal(self.start.to_vec3(), self.end.to_vec3(), magnitude) + } +} diff --git a/src/geometry/mod.rs b/src/geometry/mod.rs new file mode 100644 index 0000000..9750a3d --- /dev/null +++ b/src/geometry/mod.rs @@ -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; diff --git a/src/geometry/point.rs b/src/geometry/point.rs new file mode 100644 index 0000000..6c9abbb --- /dev/null +++ b/src/geometry/point.rs @@ -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 for Point { + fn from(v: Vec3) -> Self { + Self { + x: v.x, + y: v.y, + z: v.z, + } + } +} diff --git a/src/geometry/polyline.rs b/src/geometry/polyline.rs new file mode 100644 index 0000000..87d62d6 --- /dev/null +++ b/src/geometry/polyline.rs @@ -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, +} + +impl Polyline { + pub fn new(points: Vec) -> 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, Vec) { + crate::mesh::curve::polyline_arc_lengths(&self.points) + } +} diff --git a/src/geometry/spline.rs b/src/geometry/spline.rs new file mode 100644 index 0000000..a2a56c0 --- /dev/null +++ b/src/geometry/spline.rs @@ -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, +} + +impl Spline { + /// Build a spline from control points. Returns `None` if fewer than 2. + pub fn new(ctrl: Vec) -> Option { + 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 + } +} diff --git a/src/geometry/vector.rs b/src/geometry/vector.rs new file mode 100644 index 0000000..f8f6c8b --- /dev/null +++ b/src/geometry/vector.rs @@ -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 for Vector { + fn from(v: Vec3) -> Self { + Self { + x: v.x, + y: v.y, + z: v.z, + } + } +} + +impl From for Vec3 { + fn from(v: Vector) -> Self { + Vec3::new(v.x, v.y, v.z) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9639007 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/math/mod.rs b/src/math/mod.rs new file mode 100644 index 0000000..7e3fb9f --- /dev/null +++ b/src/math/mod.rs @@ -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 { + 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); + } +} diff --git a/src/math/random.rs b/src/math/random.rs new file mode 100644 index 0000000..fadaf75 --- /dev/null +++ b/src/math/random.rs @@ -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)); + } + } +} diff --git a/src/mesh/curve.rs b/src/mesh/curve.rs new file mode 100644 index 0000000..36ad5a0 --- /dev/null +++ b/src/mesh/curve.rs @@ -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, +} + +impl Spline { + /// Build a spline from control points. Returns `None` if fewer than 2 points. + pub fn new(ctrl: Vec) -> Option { + 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 { + 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), +} + +/// 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 { + 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, Vec) { + let n = points.len(); + let segs: Vec = (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 { + 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 { + 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 { + 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); + } +} diff --git a/src/mesh/face.rs b/src/mesh/face.rs new file mode 100644 index 0000000..977b154 --- /dev/null +++ b/src/mesh/face.rs @@ -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)); + } +} diff --git a/src/mesh/inset.rs b/src/mesh/inset.rs new file mode 100644 index 0000000..efe187d --- /dev/null +++ b/src/mesh/inset.rs @@ -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::(&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()); + } +} diff --git a/src/mesh/mod.rs b/src/mesh/mod.rs new file mode 100644 index 0000000..7552a1a --- /dev/null +++ b/src/mesh/mod.rs @@ -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 { + let pairs: Vec<[f64; 2]> = ring.iter().map(|&(x, y)| [x, y]).collect(); + let mut ec = Earcut::new(); + let mut tri: Vec = 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, +} + +#[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); + } +} diff --git a/src/mesh/surface.rs b/src/mesh/surface.rs new file mode 100644 index 0000000..4d614b0 --- /dev/null +++ b/src/mesh/surface.rs @@ -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 { + 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); + } +} diff --git a/src/mesh/volume.rs b/src/mesh/volume.rs new file mode 100644 index 0000000..4f51221 --- /dev/null +++ b/src/mesh/volume.rs @@ -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 = 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 = 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 = 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]) -> Option { + 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 = 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); + } +} diff --git a/src/operator/cut.rs b/src/operator/cut.rs new file mode 100644 index 0000000..c8bd28b --- /dev/null +++ b/src/operator/cut.rs @@ -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, + 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, 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 { + 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 { + (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); + } +} diff --git a/src/operator/divide.rs b/src/operator/divide.rs new file mode 100644 index 0000000..15a202b --- /dev/null +++ b/src/operator/divide.rs @@ -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, + 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, 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 { + 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); + } +} diff --git a/src/operator/explode.rs b/src/operator/explode.rs new file mode 100644 index 0000000..d0a1089 --- /dev/null +++ b/src/operator/explode.rs @@ -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, +} + +/// 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, 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"); + } +} diff --git a/src/operator/mod.rs b/src/operator/mod.rs new file mode 100644 index 0000000..75c3ceb --- /dev/null +++ b/src/operator/mod.rs @@ -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; diff --git a/src/operator/random.rs b/src/operator/random.rs new file mode 100644 index 0000000..f6de233 --- /dev/null +++ b/src/operator/random.rs @@ -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, + 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, 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 = (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)); + } +} diff --git a/src/set/list.rs b/src/set/list.rs new file mode 100644 index 0000000..1dc8e13 --- /dev/null +++ b/src/set/list.rs @@ -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, +} + +/// 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) -> Result { + 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); + } +} diff --git a/src/set/mod.rs b/src/set/mod.rs new file mode 100644 index 0000000..3a0d63f --- /dev/null +++ b/src/set/mod.rs @@ -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;