Add Bevy-compatible CSG assembler
This commit is contained in:
commit
379484f9fd
9 changed files with 1118 additions and 0 deletions
12
Cargo.toml
Normal file
12
Cargo.toml
Normal 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"
|
||||
136
docs/realtime-csg-bevy-assembler.md
Normal file
136
docs/realtime-csg-bevy-assembler.md
Normal 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
57
examples/csg_room.rs
Normal 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
262
src/assembler.rs
Normal 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
216
src/brush.rs
Normal 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
140
src/dsl.rs
Normal 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
21
src/lib.rs
Normal 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
102
src/mesh.rs
Normal 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
172
src/primitives.rs
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue