Add Bevy-compatible CSG assembler

This commit is contained in:
Meta Vens 2026-05-07 00:08:17 +01:00
commit 379484f9fd
9 changed files with 1118 additions and 0 deletions

12
Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "vg_csg"
version = "0.1.0"
edition = "2024"
description = "Lean Bevy-math constructive geometry assembler for VibeGeometry blockouts"
license = "MIT"
[dependencies]
bevy_math = "0.17"
[dev-dependencies]
approx = "0.5"

View file

@ -0,0 +1,136 @@
# Realtime CSG Bevy Assembler
## Source Read
LogicalError's RealtimeCSG is useful as an architectural reference, not as a
kernel we can directly port. The public repository describes a Unity editor
plugin for CSG level editing and states that runtime editing is not supported
through a maintained API. It also states that the native C++ source is not
public.
The public C# layer still exposes the parts worth stealing cleanly:
- control meshes made from vertices, half edges, polygons, and texgen/surface
metadata
- tree, branch, and brush handles
- explicit brush operation types
- dirty generation counters
- a rebuild step that turns authored brushes into renderable meshes
That shape maps well to Bevy: keep authoring data as lightweight brush records,
rebuild only dirty worlds, and emit plain mesh buffers that can later be handed
to Bevy render assets.
## Current Port Shape
`crates/vg_csg` is the first lean Rust organ.
It uses `bevy_math` for `Vec3`, `Vec2`, and Bevy-compatible spatial types while
keeping output as simple arrays:
```rust
use bevy_math::Vec3;
use vg_csg::{LevelDsl, MaterialId};
let mut level = LevelDsl::new();
level.solid_box(
"wall",
Vec3::new(0.0, 0.0, 1.5),
Vec3::new(6.0, 0.3, 3.0),
MaterialId(1),
);
level.cut_box(
"door",
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(1.2, 0.6, 2.0),
);
let output = level.assemble();
assert!(output.mesh.triangle_count() > 12);
```
The first actual CSG operator is exact AABB subtraction against additive AABB
solids. A cutter splits a source box into up to six surviving slabs. This is
enough for doors, windows, vents, corridor punches, terraces, anchor trenches,
and level blockout loops.
Procedural non-CSG primitives are additive:
- `CylinderZ` for tethers, shafts, columns, conduits, and ring anchors
- `DomeCapZ` for flat-base pressure bubbles and caps
- `FloretArm` for sunflower/Citadel-scale solar and radiator petals
Those primitives are intentionally in the same brush stream as CSG boxes, so a
level script can mix hard architectural cuts with habitat-specific forms:
```rust
level.dome_cap_z(
"city dome",
vg_csg::DomeCapZSpec {
center: Vec3::ZERO,
radius: 20.0,
height: 8.0,
rings: 12,
segments: 64,
material: MaterialId(20),
},
);
level.floret_arm(
"radiator petal",
vg_csg::FloretArmSpec {
anchor: Vec3::new(20.0, 0.0, 0.2),
direction: Vec3::X,
length: 120.0,
root_width: 6.0,
tip_width: 18.0,
thickness: 0.12,
tip_lift: 3.0,
material: MaterialId(21),
},
);
```
## Doctrine
Think of brushes as legal claims on volume.
An additive brush says: "this matter exists." A subtractive brush says: "this
space must remain empty." The assembler is the clerk that resolves claims in
order and emits triangles only after the claims stop arguing.
For interactive work, the brush list is the durable design surface. The mesh is
a cache. This matters because agents should revise intent by moving, adding, or
renaming brushes, not by poking triangles after the fact.
Use CSG where negative space carries meaning:
- doorways, windows, hatches, service trenches
- docking sockets and tether anchor gaps
- ring terraces and mechanically repeated cutouts
- fast blockouts where the same cutter can punch many solids
Use additive procedural primitives where a form is better described by a field
or sweep than by carving:
- domes, rings, tethers, panels, radiators
- city-field parcels after street generation
- flower-like utility crests and other habitat-specific silhouettes
The immediate next upgrade is not "more primitives." It is a stronger convex
brush kernel: plane classification, polygon clipping, fragment merge policy,
and a BVH or spatial hash so cutter cost does not scale like a punishment.
## Limits
`vg_csg` is not yet a full RealtimeCSG replacement.
- only box subtractors affect box solids
- non-box subtractors are reported and ignored
- intersect brushes are reported and ignored
- output is Bevy-compatible mesh data, not yet a Bevy `Mesh` asset constructor
- there is no editor gizmo, ECS plugin, or incremental spatial index yet
Those limits are explicit because invisible ambition is how you get haunted by
your own abstractions. The useful loop exists now: script brushes, assemble,
inspect mesh stats, and replace the kernel under the same DSL when the target
demands it.

57
examples/csg_room.rs Normal file
View file

@ -0,0 +1,57 @@
use bevy_math::Vec3;
use vg_csg::{DomeCapZSpec, FloretArmSpec, LevelDsl, MaterialId};
fn main() {
let mut level = LevelDsl::new();
level.solid_box(
"floor",
Vec3::new(0.0, 0.0, -0.1),
Vec3::new(10.0, 10.0, 0.2),
MaterialId(1),
);
level.solid_box(
"wall with doorway",
Vec3::new(0.0, 5.0, 1.5),
Vec3::new(10.0, 0.3, 3.0),
MaterialId(2),
);
level.cut_box(
"doorway",
Vec3::new(0.0, 5.0, 1.0),
Vec3::new(1.4, 0.6, 2.0),
);
level.dome_cap_z(
"flat-base city dome",
DomeCapZSpec {
center: Vec3::new(0.0, 0.0, 0.0),
radius: 3.0,
height: 1.8,
rings: 8,
segments: 32,
material: MaterialId(3),
},
);
level.floret_arm(
"sunflower radiator arm",
FloretArmSpec {
anchor: Vec3::new(3.0, 0.0, 0.1),
direction: Vec3::X,
length: 16.0,
root_width: 1.5,
tip_width: 4.0,
thickness: 0.08,
tip_lift: 0.8,
material: MaterialId(4),
},
);
let output = level.assemble();
println!(
"brushes={} box_fragments={} vertices={} triangles={} warnings={}",
output.report.input_brushes,
output.report.emitted_box_fragments,
output.mesh.vertex_count(),
output.mesh.triangle_count(),
output.report.warnings.len()
);
}

262
src/assembler.rs Normal file
View file

@ -0,0 +1,262 @@
use bevy_math::Vec3;
use crate::{
Aabb, Brush, BrushId, BrushOp, MaterialId, Primitive, TriangleMesh, append_cylinder_z,
append_dome_cap_z, append_floret_arm,
primitives::{DomeCapZSpec, FloretArmSpec},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BuildWarning {
SubtractIgnoredForNonBox { brush: String },
IntersectUnsupported { brush: String },
}
#[derive(Clone, Debug, Default)]
pub struct BuildReport {
pub input_brushes: usize,
pub emitted_box_fragments: usize,
pub warnings: Vec<BuildWarning>,
}
#[derive(Clone, Debug)]
pub struct BuildOutput {
pub mesh: TriangleMesh,
pub report: BuildReport,
pub generation: u64,
}
#[derive(Clone, Debug)]
struct BoxSolid {
bounds: Aabb,
material: MaterialId,
}
#[derive(Clone, Debug, Default)]
pub struct Assembler {
brushes: Vec<Brush>,
next_id: u32,
generation: u64,
}
impl Assembler {
pub fn new() -> Self {
Self::default()
}
pub fn brushes(&self) -> &[Brush] {
&self.brushes
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn add_brush(
&mut self,
name: impl Into<String>,
op: BrushOp,
primitive: Primitive,
material: MaterialId,
) -> BrushId {
let id = BrushId(self.next_id);
self.next_id += 1;
self.brushes
.push(Brush::new(id, name, op, primitive, material));
self.generation = self.generation.wrapping_add(1);
id
}
pub fn solid_box(
&mut self,
name: impl Into<String>,
bounds: Aabb,
material: MaterialId,
) -> BrushId {
self.add_brush(name, BrushOp::Add, Primitive::Box { bounds }, material)
}
pub fn cut_box(&mut self, name: impl Into<String>, bounds: Aabb) -> BrushId {
self.add_brush(
name,
BrushOp::Subtract,
Primitive::Box { bounds },
MaterialId::default(),
)
}
pub fn build(&self) -> BuildOutput {
let mut report = BuildReport {
input_brushes: self.brushes.len(),
..BuildReport::default()
};
let mut mesh = TriangleMesh::new();
let mut boxes: Vec<BoxSolid> = Vec::new();
for brush in &self.brushes {
match brush.op {
BrushOp::Add => match &brush.primitive {
Primitive::Box { bounds } => boxes.push(BoxSolid {
bounds: *bounds,
material: brush.material,
}),
Primitive::CylinderZ {
center,
radius,
depth,
segments,
} => append_cylinder_z(
&mut mesh,
*center,
*radius,
*depth,
*segments,
brush.material,
),
Primitive::DomeCapZ {
center,
radius,
height,
rings,
segments,
} => append_dome_cap_z(
&mut mesh,
DomeCapZSpec {
center: *center,
radius: *radius,
height: *height,
rings: *rings,
segments: *segments,
material: brush.material,
},
),
Primitive::FloretArm {
anchor,
direction,
length,
root_width,
tip_width,
thickness,
tip_lift,
} => append_floret_arm(
&mut mesh,
FloretArmSpec {
anchor: *anchor,
direction: *direction,
length: *length,
root_width: *root_width,
tip_width: *tip_width,
thickness: *thickness,
tip_lift: *tip_lift,
material: brush.material,
},
),
},
BrushOp::Subtract => {
if let Some(cutter) = brush.as_box() {
boxes = subtract_from_boxes(&boxes, cutter);
} else {
report
.warnings
.push(BuildWarning::SubtractIgnoredForNonBox {
brush: brush.name.clone(),
});
}
}
BrushOp::Intersect => {
report.warnings.push(BuildWarning::IntersectUnsupported {
brush: brush.name.clone(),
});
}
}
}
report.emitted_box_fragments = boxes.len();
for solid in boxes {
mesh.append_box(solid.bounds, solid.material);
}
BuildOutput {
mesh,
report,
generation: self.generation,
}
}
pub fn sample_room_with_door() -> Self {
let wall = MaterialId(1);
let floor = MaterialId(2);
let mut asm = Self::new();
asm.solid_box(
"floor",
Aabb::from_center_size(Vec3::new(0.0, 0.0, -0.1), Vec3::new(8.0, 8.0, 0.2)),
floor,
);
asm.solid_box(
"north wall",
Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.5), Vec3::new(8.0, 0.25, 3.0)),
wall,
);
asm.cut_box(
"door void",
Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)),
);
asm
}
}
fn subtract_from_boxes(boxes: &[BoxSolid], cutter: Aabb) -> Vec<BoxSolid> {
let mut out = Vec::with_capacity(boxes.len() + 4);
for solid in boxes {
for bounds in solid.bounds.subtract_box(cutter) {
out.push(BoxSolid {
bounds,
material: solid.material,
});
}
}
out
}
#[cfg(test)]
mod tests {
use bevy_math::Vec3;
use super::*;
#[test]
fn centered_subtract_splits_box_into_six_fragments() {
let source = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0));
let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0));
let pieces = source.subtract_box(cutter);
assert_eq!(pieces.len(), 6);
assert!(pieces.iter().all(|piece| piece.is_valid()));
assert!(pieces.iter().all(|piece| !piece.intersects(cutter)));
}
#[test]
fn door_cut_leaves_no_box_fragment_inside_void() {
let output = Assembler::sample_room_with_door().build();
let cutter = Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0));
assert!(output.report.emitted_box_fragments > 2);
for tri in output.mesh.indices.chunks_exact(3) {
let center = (output.mesh.positions[tri[0] as usize]
+ output.mesh.positions[tri[1] as usize]
+ output.mesh.positions[tri[2] as usize])
/ 3.0;
assert!(!cutter.contains_point_strict(center, 1.0e-4));
}
}
#[test]
fn mesh_box_emits_twelve_triangles() {
let mut mesh = TriangleMesh::new();
mesh.append_box(Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), MaterialId(7));
assert_eq!(mesh.triangle_count(), 12);
assert_eq!(mesh.triangle_materials.len(), 12);
assert_eq!(mesh.triangle_materials[0], MaterialId(7));
}
}

216
src/brush.rs Normal file
View file

@ -0,0 +1,216 @@
use bevy_math::{Quat, Vec3};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct BrushId(pub u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct MaterialId(pub u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BrushOp {
Add,
Subtract,
Intersect,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Aabb {
pub min: Vec3,
pub max: Vec3,
}
impl Aabb {
pub fn from_center_size(center: Vec3, size: Vec3) -> Self {
let half = size * 0.5;
Self {
min: center - half,
max: center + half,
}
}
pub fn new(min: Vec3, max: Vec3) -> Self {
Self { min, max }
}
pub fn size(self) -> Vec3 {
self.max - self.min
}
pub fn center(self) -> Vec3 {
(self.min + self.max) * 0.5
}
pub fn is_valid(self) -> bool {
self.max.x > self.min.x && self.max.y > self.min.y && self.max.z > self.min.z
}
pub fn intersects(self, other: Self) -> bool {
self.min.x < other.max.x
&& self.max.x > other.min.x
&& self.min.y < other.max.y
&& self.max.y > other.min.y
&& self.min.z < other.max.z
&& self.max.z > other.min.z
}
pub fn intersection(self, other: Self) -> Option<Self> {
if !self.intersects(other) {
return None;
}
let min = self.min.max(other.min);
let max = self.max.min(other.max);
let hit = Self { min, max };
hit.is_valid().then_some(hit)
}
pub fn contains_point(self, point: Vec3) -> bool {
point.x >= self.min.x
&& point.x <= self.max.x
&& point.y >= self.min.y
&& point.y <= self.max.y
&& point.z >= self.min.z
&& point.z <= self.max.z
}
pub fn contains_point_strict(self, point: Vec3, epsilon: f32) -> bool {
point.x > self.min.x + epsilon
&& point.x < self.max.x - epsilon
&& point.y > self.min.y + epsilon
&& point.y < self.max.y - epsilon
&& point.z > self.min.z + epsilon
&& point.z < self.max.z - epsilon
}
pub fn subtract_box(self, cutter: Self) -> Vec<Self> {
let Some(hit) = self.intersection(cutter) else {
return vec![self];
};
let mut pieces = Vec::with_capacity(6);
push_valid(
&mut pieces,
Self::new(self.min, Vec3::new(hit.min.x, self.max.y, self.max.z)),
);
push_valid(
&mut pieces,
Self::new(Vec3::new(hit.max.x, self.min.y, self.min.z), self.max),
);
let x_min = self.min.x.max(hit.min.x);
let x_max = self.max.x.min(hit.max.x);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, self.min.y, self.min.z),
Vec3::new(x_max, hit.min.y, self.max.z),
),
);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, hit.max.y, self.min.z),
Vec3::new(x_max, self.max.y, self.max.z),
),
);
let y_min = self.min.y.max(hit.min.y);
let y_max = self.max.y.min(hit.max.y);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, y_min, self.min.z),
Vec3::new(x_max, y_max, hit.min.z),
),
);
push_valid(
&mut pieces,
Self::new(
Vec3::new(x_min, y_min, hit.max.z),
Vec3::new(x_max, y_max, self.max.z),
),
);
pieces
}
}
fn push_valid(pieces: &mut Vec<Aabb>, bounds: Aabb) {
if bounds.is_valid() {
pieces.push(bounds);
}
}
#[derive(Clone, Debug)]
pub enum Primitive {
Box {
bounds: Aabb,
},
CylinderZ {
center: Vec3,
radius: f32,
depth: f32,
segments: usize,
},
DomeCapZ {
center: Vec3,
radius: f32,
height: f32,
rings: usize,
segments: usize,
},
FloretArm {
anchor: Vec3,
direction: Vec3,
length: f32,
root_width: f32,
tip_width: f32,
thickness: f32,
tip_lift: f32,
},
}
#[derive(Clone, Debug)]
pub struct Brush {
pub id: BrushId,
pub name: String,
pub op: BrushOp,
pub primitive: Primitive,
pub material: MaterialId,
pub rotation: Quat,
pub generation: u64,
}
impl Brush {
pub fn new(
id: BrushId,
name: impl Into<String>,
op: BrushOp,
primitive: Primitive,
material: MaterialId,
) -> Self {
Self {
id,
name: name.into(),
op,
primitive,
material,
rotation: Quat::IDENTITY,
generation: 0,
}
}
pub fn set_dirty(&mut self) {
self.generation = self.generation.wrapping_add(1);
}
pub fn as_box(&self) -> Option<Aabb> {
match self.primitive {
Primitive::Box { bounds } => Some(bounds),
_ => None,
}
}
}

140
src/dsl.rs Normal file
View file

@ -0,0 +1,140 @@
use bevy_math::Vec3;
use crate::{
Aabb, Assembler, BrushId, BrushOp, BuildOutput, DomeCapZSpec, FloretArmSpec, MaterialId,
Primitive,
};
#[derive(Clone, Debug, Default)]
pub struct LevelDsl {
assembler: Assembler,
}
impl LevelDsl {
pub fn new() -> Self {
Self::default()
}
pub fn solid_box(
&mut self,
name: impl Into<String>,
center: Vec3,
size: Vec3,
material: MaterialId,
) -> BrushId {
self.assembler
.solid_box(name, Aabb::from_center_size(center, size), material)
}
pub fn cut_box(&mut self, name: impl Into<String>, center: Vec3, size: Vec3) -> BrushId {
self.assembler
.cut_box(name, Aabb::from_center_size(center, size))
}
pub fn cylinder_z(
&mut self,
name: impl Into<String>,
center: Vec3,
radius: f32,
depth: f32,
segments: usize,
material: MaterialId,
) -> BrushId {
self.assembler.add_brush(
name,
BrushOp::Add,
Primitive::CylinderZ {
center,
radius,
depth,
segments,
},
material,
)
}
pub fn dome_cap_z(&mut self, name: impl Into<String>, spec: DomeCapZSpec) -> BrushId {
self.assembler.add_brush(
name,
BrushOp::Add,
Primitive::DomeCapZ {
center: spec.center,
radius: spec.radius,
height: spec.height,
rings: spec.rings,
segments: spec.segments,
},
spec.material,
)
}
pub fn floret_arm(&mut self, name: impl Into<String>, spec: FloretArmSpec) -> BrushId {
self.assembler.add_brush(
name,
BrushOp::Add,
Primitive::FloretArm {
anchor: spec.anchor,
direction: spec.direction,
length: spec.length,
root_width: spec.root_width,
tip_width: spec.tip_width,
thickness: spec.thickness,
tip_lift: spec.tip_lift,
},
spec.material,
)
}
pub fn assemble(&self) -> BuildOutput {
self.assembler.build()
}
pub fn into_assembler(self) -> Assembler {
self.assembler
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dsl_builds_room_and_habitat_primitives() {
let mut dsl = LevelDsl::new();
dsl.solid_box(
"wall",
Vec3::new(0.0, 0.0, 1.5),
Vec3::new(5.0, 0.25, 3.0),
MaterialId(1),
);
dsl.cut_box("door", Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.5, 2.0));
dsl.dome_cap_z(
"city dome",
DomeCapZSpec {
center: Vec3::new(0.0, 0.0, 0.0),
radius: 3.0,
height: 1.5,
rings: 4,
segments: 16,
material: MaterialId(2),
},
);
dsl.floret_arm(
"solar petal",
FloretArmSpec {
anchor: Vec3::new(3.0, 0.0, 0.0),
direction: Vec3::X,
length: 12.0,
root_width: 1.2,
tip_width: 3.0,
thickness: 0.08,
tip_lift: 0.6,
material: MaterialId(3),
},
);
let output = dsl.assemble();
assert!(output.mesh.triangle_count() > 12);
assert!(output.report.warnings.is_empty());
}
}

21
src/lib.rs Normal file
View file

@ -0,0 +1,21 @@
//! Lean constructive geometry tools for VibeGeometry.
//!
//! This crate borrows RealtimeCSG's useful public shape: ordered brushes,
//! operation types, dirtied generations, and a rebuildable output mesh. The
//! hidden native RealtimeCSG kernel is not public, so this starts with the
//! smallest honest CSG organ: exact box subtraction against additive boxes,
//! plus additive procedural primitives for the habitat forms we keep needing.
mod assembler;
mod brush;
mod dsl;
mod mesh;
mod primitives;
pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, Primitive};
pub use dsl::LevelDsl;
pub use mesh::TriangleMesh;
pub use primitives::{
DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm,
};

102
src/mesh.rs Normal file
View file

@ -0,0 +1,102 @@
use bevy_math::{Vec2, Vec3};
use crate::{Aabb, MaterialId};
#[derive(Clone, Debug, Default)]
pub struct TriangleMesh {
pub positions: Vec<Vec3>,
pub normals: Vec<Vec3>,
pub uvs: Vec<Vec2>,
pub indices: Vec<u32>,
pub triangle_materials: Vec<MaterialId>,
}
impl TriangleMesh {
pub fn new() -> Self {
Self::default()
}
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
pub fn vertex_count(&self) -> usize {
self.positions.len()
}
pub fn append_box(&mut self, bounds: Aabb, material: MaterialId) {
let min = bounds.min;
let max = bounds.max;
let corners = [
Vec3::new(min.x, min.y, min.z),
Vec3::new(max.x, min.y, min.z),
Vec3::new(max.x, max.y, min.z),
Vec3::new(min.x, max.y, min.z),
Vec3::new(min.x, min.y, max.z),
Vec3::new(max.x, min.y, max.z),
Vec3::new(max.x, max.y, max.z),
Vec3::new(min.x, max.y, max.z),
];
self.append_quad(
[corners[0], corners[3], corners[2], corners[1]],
Vec3::NEG_Z,
material,
);
self.append_quad(
[corners[4], corners[5], corners[6], corners[7]],
Vec3::Z,
material,
);
self.append_quad(
[corners[0], corners[1], corners[5], corners[4]],
Vec3::NEG_Y,
material,
);
self.append_quad(
[corners[3], corners[7], corners[6], corners[2]],
Vec3::Y,
material,
);
self.append_quad(
[corners[0], corners[4], corners[7], corners[3]],
Vec3::NEG_X,
material,
);
self.append_quad(
[corners[1], corners[2], corners[6], corners[5]],
Vec3::X,
material,
);
}
pub fn append_quad(&mut self, points: [Vec3; 4], normal: Vec3, material: MaterialId) {
let base = self.positions.len() as u32;
self.positions.extend(points);
self.normals.extend([normal; 4]);
self.uvs.extend([
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(0.0, 1.0),
]);
self.indices
.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
self.triangle_materials.extend([material; 2]);
}
pub fn append_triangle(
&mut self,
points: [Vec3; 3],
normal: Vec3,
uvs: [Vec2; 3],
material: MaterialId,
) {
let base = self.positions.len() as u32;
self.positions.extend(points);
self.normals.extend([normal; 3]);
self.uvs.extend(uvs);
self.indices.extend([base, base + 1, base + 2]);
self.triangle_materials.push(material);
}
}

172
src/primitives.rs Normal file
View file

@ -0,0 +1,172 @@
use bevy_math::{Vec2, Vec3};
use crate::{MaterialId, TriangleMesh};
#[derive(Clone, Copy, Debug)]
pub struct DomeCapZSpec {
pub center: Vec3,
pub radius: f32,
pub height: f32,
pub rings: usize,
pub segments: usize,
pub material: MaterialId,
}
#[derive(Clone, Copy, Debug)]
pub struct FloretArmSpec {
pub anchor: Vec3,
pub direction: Vec3,
pub length: f32,
pub root_width: f32,
pub tip_width: f32,
pub thickness: f32,
pub tip_lift: f32,
pub material: MaterialId,
}
pub fn append_cylinder_z(
mesh: &mut TriangleMesh,
center: Vec3,
radius: f32,
depth: f32,
segments: usize,
material: MaterialId,
) {
let segments = segments.max(3);
let half = depth * 0.5;
let bottom_center = center - Vec3::Z * half;
let top_center = center + Vec3::Z * half;
for i in 0..segments {
let a0 = ring_angle(i, segments);
let a1 = ring_angle(i + 1, segments);
let p0 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, -half);
let p1 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, -half);
let p2 = center + Vec3::new(a1.cos() * radius, a1.sin() * radius, half);
let p3 = center + Vec3::new(a0.cos() * radius, a0.sin() * radius, half);
let normal = Vec3::new(((a0 + a1) * 0.5).cos(), ((a0 + a1) * 0.5).sin(), 0.0);
mesh.append_quad([p0, p1, p2, p3], normal, material);
mesh.append_triangle(
[bottom_center, p1, p0],
Vec3::NEG_Z,
[Vec2::ZERO, Vec2::X, Vec2::Y],
material,
);
mesh.append_triangle(
[top_center, p3, p2],
Vec3::Z,
[Vec2::ZERO, Vec2::X, Vec2::Y],
material,
);
}
}
pub fn append_dome_cap_z(mesh: &mut TriangleMesh, spec: DomeCapZSpec) {
let rings = spec.rings.max(2);
let segments = spec.segments.max(6);
let top = spec.center + Vec3::Z * spec.height;
for ring in 0..rings {
let t0 = ring as f32 / rings as f32;
let t1 = (ring + 1) as f32 / rings as f32;
let r0 = spec.radius * (1.0 - t0 * t0).sqrt();
let r1 = spec.radius * (1.0 - t1 * t1).sqrt();
let z0 = spec.center.z + spec.height * t0;
let z1 = spec.center.z + spec.height * t1;
for seg in 0..segments {
let a0 = ring_angle(seg, segments);
let a1 = ring_angle(seg + 1, segments);
let p0 = Vec3::new(
spec.center.x + a0.cos() * r0,
spec.center.y + a0.sin() * r0,
z0,
);
let p1 = Vec3::new(
spec.center.x + a1.cos() * r0,
spec.center.y + a1.sin() * r0,
z0,
);
if ring + 1 == rings {
let normal = (p0 - spec.center)
.cross(top - spec.center)
.normalize_or_zero();
mesh.append_triangle(
[p0, p1, top],
normal,
[Vec2::ZERO, Vec2::X, Vec2::Y],
spec.material,
);
} else {
let p2 = Vec3::new(
spec.center.x + a1.cos() * r1,
spec.center.y + a1.sin() * r1,
z1,
);
let p3 = Vec3::new(
spec.center.x + a0.cos() * r1,
spec.center.y + a0.sin() * r1,
z1,
);
let normal = ((p0 + p1 + p2 + p3) * 0.25 - spec.center).normalize_or_zero();
mesh.append_quad([p0, p1, p2, p3], normal, spec.material);
}
}
}
}
pub fn append_floret_arm(mesh: &mut TriangleMesh, spec: FloretArmSpec) {
let forward = spec.direction.normalize_or_zero();
let forward = if forward.length_squared() == 0.0 {
Vec3::X
} else {
forward
};
let mut side = forward.cross(Vec3::Z).normalize_or_zero();
if side.length_squared() == 0.0 {
side = Vec3::Y;
}
let root_center = spec.anchor;
let tip_center = spec.anchor + forward * spec.length + Vec3::Z * spec.tip_lift;
let z = Vec3::Z * (spec.thickness * 0.5);
let r = side * (spec.root_width * 0.5);
let t = side * (spec.tip_width * 0.5);
let bottom = [
root_center - r - z,
root_center + r - z,
tip_center + t - z,
tip_center - t - z,
];
let top = [
root_center - r + z,
root_center + r + z,
tip_center + t + z,
tip_center - t + z,
];
mesh.append_quad(
[bottom[0], bottom[3], bottom[2], bottom[1]],
Vec3::NEG_Z,
spec.material,
);
mesh.append_quad([top[0], top[1], top[2], top[3]], Vec3::Z, spec.material);
mesh.append_quad(
[bottom[0], bottom[1], top[1], top[0]],
-forward,
spec.material,
);
mesh.append_quad(
[bottom[3], top[3], top[2], bottom[2]],
forward,
spec.material,
);
mesh.append_quad([bottom[0], top[0], top[3], bottom[3]], -side, spec.material);
mesh.append_quad([bottom[1], bottom[2], top[2], top[1]], side, spec.material);
}
fn ring_angle(index: usize, segments: usize) -> f32 {
index as f32 * std::f32::consts::TAU / segments as f32
}