release: 0.0.1
This commit is contained in:
commit
b73dec4f5f
43 changed files with 3190 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/target
|
||||||
|
docs/book
|
||||||
|
docs/target
|
||||||
|
.claude/
|
||||||
|
.opencode/
|
||||||
90
Cargo.lock
generated
Normal file
90
Cargo.lock
generated
Normal 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
12
Cargo.toml
Normal 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
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Milovann Yanatchkov
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
7
Makefile
Normal file
7
Makefile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.PHONY: doc test
|
||||||
|
|
||||||
|
doc:
|
||||||
|
cargo doc --no-deps --open
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# bimr-kernel
|
||||||
|
|
||||||
|
Internal geometry kernel for BIMR.
|
||||||
12
docs/README.md
Normal file
12
docs/README.md
Normal 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`.
|
||||||
48
src/element/architecture/building.rs
Normal file
48
src/element/architecture/building.rs
Normal 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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/element/architecture/column.rs
Normal file
36
src/element/architecture/column.rs
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/element/architecture/frame.rs
Normal file
47
src/element/architecture/frame.rs
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/element/architecture/mod.rs
Normal file
18
src/element/architecture/mod.rs
Normal 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;
|
||||||
28
src/element/architecture/slab.rs
Normal file
28
src/element/architecture/slab.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/element/architecture/storey.rs
Normal file
51
src/element/architecture/storey.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/element/architecture/wall.rs
Normal file
34
src/element/architecture/wall.rs
Normal 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
10
src/element/mod.rs
Normal 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
4
src/element/site/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Site discipline — future: Site, Terrain.
|
||||||
4
src/element/structure/mod.rs
Normal file
4
src/element/structure/mod.rs
Normal 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
187
src/entity.rs
Normal 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
20
src/geometry/circle.rs
Normal 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
128
src/geometry/curve.rs
Normal 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
122
src/geometry/extrusion.rs
Normal 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
24
src/geometry/line.rs
Normal 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
22
src/geometry/mod.rs
Normal 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
32
src/geometry/point.rs
Normal 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
29
src/geometry/polyline.rs
Normal 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
34
src/geometry/spline.rs
Normal 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
41
src/geometry/vector.rs
Normal 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
26
src/lib.rs
Normal 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
173
src/math/mod.rs
Normal 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
125
src/math/random.rs
Normal 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
309
src/mesh/curve.rs
Normal 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
89
src/mesh/face.rs
Normal 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
117
src/mesh/inset.rs
Normal 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
98
src/mesh/mod.rs
Normal 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
99
src/mesh/surface.rs
Normal 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
374
src/mesh/volume.rs
Normal 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
119
src/operator/cut.rs
Normal 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
134
src/operator/divide.rs
Normal 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
134
src/operator/explode.rs
Normal 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
14
src/operator/mod.rs
Normal 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
180
src/operator/random.rs
Normal 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
122
src/set/list.rs
Normal 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
8
src/set/mod.rs
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue