Advance CSG category kernel groundwork
This commit is contained in:
parent
e0cbecf539
commit
83fc507071
6 changed files with 530 additions and 42 deletions
|
|
@ -69,10 +69,16 @@ assert!(output.mesh.triangle_count() > 12);
|
|||
|
||||
The first actual CSG operator was exact AABB subtraction against additive AABB
|
||||
solids. The current kernel has moved to convex decomposition: an additive
|
||||
convex solid is split against each outward cutter plane, outside fragments are
|
||||
emitted, and the inside remainder is discarded after the final plane. This
|
||||
supports angled box cuts and gives us the correct shape for a future general
|
||||
convex brush kernel.
|
||||
convex solid is split against each outward cutter plane, subtraction emits the
|
||||
outside fragments, and intersection keeps the inside remainder. This supports
|
||||
angled box cuts, real `Common` branch output for convex boxes, and gives us the
|
||||
correct shape for a future general convex brush kernel.
|
||||
|
||||
Polygons now carry the first routing metadata the mature kernel needs:
|
||||
category, visibility, reversal, and bounds. Whole polygons can already be
|
||||
classified as inside, outside, aligned, or reverse-aligned against a convex
|
||||
brush when they do not cross cutter planes. That is deliberately not sold as
|
||||
the full router yet. It is the labeled wire we will plug the splitter into.
|
||||
|
||||
The important doctrinal adjustment from the public demo and blog series is that
|
||||
real-time CSG should think in **classification**, not only carving. A polygon is
|
||||
|
|
@ -134,12 +140,13 @@ 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:
|
||||
Use CSG where boolean 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
|
||||
- common-volume filters where rooms, shafts, or constraint regions overlap
|
||||
|
||||
Use additive procedural primitives where a form is better described by a field
|
||||
or sweep than by carving:
|
||||
|
|
@ -148,10 +155,10 @@ or sweep than by carving:
|
|||
- 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, category routing,
|
||||
fragment merge policy, and a BVH or spatial hash so cutter cost does not scale
|
||||
like a punishment.
|
||||
The immediate next upgrade is not "more primitives." It is the category-router
|
||||
kernel: split crossing polygons, route classified source polygons through
|
||||
boolean branches, preserve material/surface metadata, and then add a BVH or
|
||||
spatial hash so cutter cost does not scale like a punishment.
|
||||
|
||||
## Limits
|
||||
|
||||
|
|
@ -159,7 +166,7 @@ like a punishment.
|
|||
|
||||
- subtractive convex support currently covers axis-aligned and oriented boxes
|
||||
- non-box subtractors are reported and ignored
|
||||
- intersect brushes are reported and ignored
|
||||
- intersect/common support currently covers axis-aligned and oriented boxes
|
||||
- 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
|
||||
- current subtraction emits split fragments with generated cap polygons; the
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::{
|
|||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BuildWarning {
|
||||
SubtractIgnoredForNonBox { brush: String },
|
||||
IntersectUnsupported { brush: String },
|
||||
IntersectIgnoredForNonBox { brush: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -208,9 +208,15 @@ impl Assembler {
|
|||
}
|
||||
}
|
||||
BrushOp::Intersect => {
|
||||
report.warnings.push(BuildWarning::IntersectUnsupported {
|
||||
brush: brush.name.clone(),
|
||||
});
|
||||
if let Some(cutter) = convex_cutter_for_brush(brush) {
|
||||
solids = intersect_solids(&solids, &cutter);
|
||||
} else {
|
||||
report
|
||||
.warnings
|
||||
.push(BuildWarning::IntersectIgnoredForNonBox {
|
||||
brush: brush.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -269,7 +275,23 @@ fn convex_cutter_for_brush(brush: &Brush) -> Option<ConvexSolid> {
|
|||
fn subtract_from_solids(solids: &[ConvexSolid], cutter: &ConvexSolid) -> Vec<ConvexSolid> {
|
||||
let mut out = Vec::with_capacity(solids.len() + 4);
|
||||
for solid in solids {
|
||||
out.extend(solid.subtract_convex(cutter));
|
||||
if solid.bounds.intersects(cutter.bounds) {
|
||||
out.extend(solid.subtract_convex(cutter));
|
||||
} else {
|
||||
out.push(solid.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn intersect_solids(solids: &[ConvexSolid], cutter: &ConvexSolid) -> Vec<ConvexSolid> {
|
||||
let mut out = Vec::with_capacity(solids.len());
|
||||
for solid in solids {
|
||||
if solid.bounds.intersects(cutter.bounds)
|
||||
&& let Some(fragment) = solid.intersect_convex(cutter)
|
||||
{
|
||||
out.push(fragment);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -337,4 +359,45 @@ mod tests {
|
|||
assert_eq!(mesh.triangle_materials.len(), 12);
|
||||
assert_eq!(mesh.triangle_materials[0], MaterialId(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distant_subtract_skips_convex_split_and_keeps_source_solid() {
|
||||
let mut asm = Assembler::new();
|
||||
asm.solid_box(
|
||||
"source",
|
||||
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
|
||||
MaterialId(1),
|
||||
);
|
||||
asm.cut_box(
|
||||
"far void",
|
||||
Aabb::from_center_size(Vec3::splat(10.0), Vec3::splat(1.0)),
|
||||
);
|
||||
|
||||
let output = asm.build();
|
||||
assert_eq!(output.report.emitted_convex_fragments, 1);
|
||||
assert_eq!(output.mesh.triangle_count(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersecting_box_keeps_common_convex_region() {
|
||||
let mut asm = Assembler::new();
|
||||
asm.solid_box(
|
||||
"a",
|
||||
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
|
||||
MaterialId(1),
|
||||
);
|
||||
asm.add_brush(
|
||||
"b",
|
||||
BrushOp::Intersect,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)),
|
||||
},
|
||||
MaterialId(1),
|
||||
);
|
||||
|
||||
let output = asm.build();
|
||||
assert_eq!(output.report.emitted_convex_fragments, 1);
|
||||
assert_eq!(output.report.warnings.len(), 0);
|
||||
assert_eq!(output.mesh.triangle_count(), 12);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
136
src/brush.rs
136
src/brush.rs
|
|
@ -28,6 +28,13 @@ pub struct Aabb {
|
|||
}
|
||||
|
||||
impl Aabb {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
min: Vec3::splat(f32::INFINITY),
|
||||
max: Vec3::splat(f32::NEG_INFINITY),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_center_size(center: Vec3, size: Vec3) -> Self {
|
||||
let half = size * 0.5;
|
||||
Self {
|
||||
|
|
@ -40,6 +47,32 @@ impl Aabb {
|
|||
Self { min, max }
|
||||
}
|
||||
|
||||
pub fn from_points(points: &[Vec3]) -> Self {
|
||||
let mut bounds = Self::empty();
|
||||
for point in points {
|
||||
bounds.include_point(*point);
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
pub fn include_point(&mut self, point: Vec3) {
|
||||
self.min = self.min.min(point);
|
||||
self.max = self.max.max(point);
|
||||
}
|
||||
|
||||
pub fn union(self, other: Self) -> Self {
|
||||
if !self.has_finite_extents() {
|
||||
return other;
|
||||
}
|
||||
if !other.has_finite_extents() {
|
||||
return self;
|
||||
}
|
||||
Self {
|
||||
min: self.min.min(other.min),
|
||||
max: self.max.max(other.max),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(self) -> Vec3 {
|
||||
self.max - self.min
|
||||
}
|
||||
|
|
@ -52,6 +85,10 @@ impl Aabb {
|
|||
self.max.x > self.min.x && self.max.y > self.min.y && self.max.z > self.min.z
|
||||
}
|
||||
|
||||
pub fn has_finite_extents(self) -> bool {
|
||||
self.min.is_finite() && self.max.is_finite()
|
||||
}
|
||||
|
||||
pub fn intersects(self, other: Self) -> bool {
|
||||
self.min.x < other.max.x
|
||||
&& self.max.x > other.min.x
|
||||
|
|
@ -226,4 +263,103 @@ impl Brush {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
self.primitive.bounds()
|
||||
}
|
||||
}
|
||||
|
||||
impl Primitive {
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
match *self {
|
||||
Self::Box { bounds } => bounds,
|
||||
Self::OrientedBox {
|
||||
center,
|
||||
size,
|
||||
rotation,
|
||||
} => oriented_box_bounds(center, size, rotation),
|
||||
Self::CylinderZ {
|
||||
center,
|
||||
radius,
|
||||
depth,
|
||||
..
|
||||
} => Aabb::new(
|
||||
center - Vec3::new(radius, radius, depth * 0.5),
|
||||
center + Vec3::new(radius, radius, depth * 0.5),
|
||||
),
|
||||
Self::DomeCapZ {
|
||||
center,
|
||||
radius,
|
||||
height,
|
||||
..
|
||||
} => Aabb::new(
|
||||
center - Vec3::new(radius, radius, 0.0),
|
||||
center + Vec3::new(radius, radius, height),
|
||||
),
|
||||
Self::FloretArm {
|
||||
anchor,
|
||||
direction,
|
||||
length,
|
||||
root_width,
|
||||
tip_width,
|
||||
thickness,
|
||||
tip_lift,
|
||||
} => floret_bounds(
|
||||
anchor, direction, length, root_width, tip_width, thickness, tip_lift,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn oriented_box_bounds(center: Vec3, size: Vec3, rotation: Quat) -> Aabb {
|
||||
let half = size * 0.5;
|
||||
let local_corners = [
|
||||
Vec3::new(-half.x, -half.y, -half.z),
|
||||
Vec3::new(half.x, -half.y, -half.z),
|
||||
Vec3::new(half.x, half.y, -half.z),
|
||||
Vec3::new(-half.x, half.y, -half.z),
|
||||
Vec3::new(-half.x, -half.y, half.z),
|
||||
Vec3::new(half.x, -half.y, half.z),
|
||||
Vec3::new(half.x, half.y, half.z),
|
||||
Vec3::new(-half.x, half.y, half.z),
|
||||
];
|
||||
let corners = local_corners.map(|point| center + rotation * point);
|
||||
Aabb::from_points(&corners)
|
||||
}
|
||||
|
||||
fn floret_bounds(
|
||||
anchor: Vec3,
|
||||
direction: Vec3,
|
||||
length: f32,
|
||||
root_width: f32,
|
||||
tip_width: f32,
|
||||
thickness: f32,
|
||||
tip_lift: f32,
|
||||
) -> Aabb {
|
||||
let forward = 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 = anchor;
|
||||
let tip_center = anchor + forward * length + Vec3::Z * tip_lift;
|
||||
let z = Vec3::Z * (thickness * 0.5);
|
||||
let r = side * (root_width * 0.5);
|
||||
let t = side * (tip_width * 0.5);
|
||||
Aabb::from_points(&[
|
||||
root_center - r - z,
|
||||
root_center + r - z,
|
||||
root_center - r + z,
|
||||
root_center + r + z,
|
||||
tip_center - t - z,
|
||||
tip_center + t - z,
|
||||
tip_center - t + z,
|
||||
tip_center + t + z,
|
||||
])
|
||||
}
|
||||
|
|
|
|||
183
src/convex.rs
183
src/convex.rs
|
|
@ -1,6 +1,6 @@
|
|||
use bevy_math::{Quat, Vec2, Vec3};
|
||||
|
||||
use crate::{Aabb, MaterialId, TriangleMesh};
|
||||
use crate::{Aabb, MaterialId, PolygonCategory, TriangleMesh};
|
||||
|
||||
const EPSILON: f32 = 1.0e-5;
|
||||
|
||||
|
|
@ -22,6 +22,11 @@ impl Plane {
|
|||
pub fn signed_distance(self, point: Vec3) -> f32 {
|
||||
self.normal.dot(point) + self.distance
|
||||
}
|
||||
|
||||
pub fn is_coplanar_with(self, other: Self) -> bool {
|
||||
self.normal.dot(other.normal).abs() > 1.0 - EPSILON
|
||||
&& (self.distance - other.distance).abs() <= EPSILON
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -29,6 +34,43 @@ pub struct ConvexPolygon {
|
|||
pub vertices: Vec<Vec3>,
|
||||
pub normal: Vec3,
|
||||
pub material: MaterialId,
|
||||
pub category: PolygonCategory,
|
||||
pub visible: bool,
|
||||
pub reversed: bool,
|
||||
pub bounds: Aabb,
|
||||
}
|
||||
|
||||
impl ConvexPolygon {
|
||||
pub fn new(vertices: Vec<Vec3>, normal: Vec3, material: MaterialId) -> Self {
|
||||
Self {
|
||||
bounds: Aabb::from_points(&vertices),
|
||||
vertices,
|
||||
normal: normal.normalize_or_zero(),
|
||||
material,
|
||||
category: PolygonCategory::Aligned,
|
||||
visible: true,
|
||||
reversed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn centroid(&self) -> Vec3 {
|
||||
if self.vertices.is_empty() {
|
||||
return Vec3::ZERO;
|
||||
}
|
||||
self.vertices.iter().copied().sum::<Vec3>() / self.vertices.len() as f32
|
||||
}
|
||||
|
||||
fn with_vertices(&self, vertices: Vec<Vec3>) -> Self {
|
||||
Self {
|
||||
bounds: Aabb::from_points(&vertices),
|
||||
vertices,
|
||||
normal: self.normal,
|
||||
material: self.material,
|
||||
category: self.category,
|
||||
visible: self.visible,
|
||||
reversed: self.reversed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -36,6 +78,7 @@ pub struct ConvexSolid {
|
|||
pub polygons: Vec<ConvexPolygon>,
|
||||
pub clip_planes: Vec<Plane>,
|
||||
pub material: MaterialId,
|
||||
pub bounds: Aabb,
|
||||
}
|
||||
|
||||
impl ConvexSolid {
|
||||
|
|
@ -76,14 +119,11 @@ impl ConvexSolid {
|
|||
let normal = rotation * local_normal;
|
||||
let vertices = indices.map(|index| corners[index]).to_vec();
|
||||
clip_planes.push(Plane::from_point_normal(vertices[0], normal));
|
||||
polygons.push(ConvexPolygon {
|
||||
vertices,
|
||||
normal,
|
||||
material,
|
||||
});
|
||||
polygons.push(ConvexPolygon::new(vertices, normal, material));
|
||||
}
|
||||
|
||||
Self {
|
||||
bounds: Aabb::from_points(&corners),
|
||||
polygons,
|
||||
clip_planes,
|
||||
material,
|
||||
|
|
@ -120,16 +160,40 @@ impl ConvexSolid {
|
|||
fragments
|
||||
}
|
||||
|
||||
pub fn intersect_convex(&self, cutter: &Self) -> Option<Self> {
|
||||
let mut remainder = self.clone();
|
||||
|
||||
for plane in &cutter.clip_planes {
|
||||
remainder = match remainder.split(*plane) {
|
||||
SplitResult::Front(_) => return None,
|
||||
SplitResult::Back(back) | SplitResult::Coplanar(back) => back,
|
||||
SplitResult::Both { back, .. } => back,
|
||||
};
|
||||
}
|
||||
|
||||
Some(remainder)
|
||||
}
|
||||
|
||||
pub fn append_to_mesh(&self, mesh: &mut TriangleMesh) {
|
||||
for polygon in &self.polygons {
|
||||
if polygon.vertices.len() < 3 {
|
||||
if !polygon.visible || polygon.vertices.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let base = polygon.vertices[0];
|
||||
for i in 1..polygon.vertices.len() - 1 {
|
||||
let triangle = if polygon.reversed {
|
||||
[base, polygon.vertices[i + 1], polygon.vertices[i]]
|
||||
} else {
|
||||
[base, polygon.vertices[i], polygon.vertices[i + 1]]
|
||||
};
|
||||
let normal = if polygon.reversed {
|
||||
-polygon.normal
|
||||
} else {
|
||||
polygon.normal
|
||||
};
|
||||
mesh.append_triangle(
|
||||
[base, polygon.vertices[i], polygon.vertices[i + 1]],
|
||||
polygon.normal,
|
||||
triangle,
|
||||
normal,
|
||||
[Vec2::ZERO, Vec2::X, Vec2::Y],
|
||||
polygon.material,
|
||||
);
|
||||
|
|
@ -137,6 +201,14 @@ impl ConvexSolid {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn categorize_whole_polygons_against(&mut self, cutter: &Self) {
|
||||
for polygon in &mut self.polygons {
|
||||
if let Some(category) = whole_polygon_category_against(polygon, cutter) {
|
||||
polygon.category = category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split(&self, plane: Plane) -> SplitResult {
|
||||
let mut saw_front = false;
|
||||
let mut saw_back = false;
|
||||
|
|
@ -162,20 +234,12 @@ impl ConvexSolid {
|
|||
for polygon in &self.polygons {
|
||||
let front = clip_polygon(&polygon.vertices, plane, KeepSide::Front, &mut cap_points);
|
||||
if front.len() >= 3 {
|
||||
front_polygons.push(ConvexPolygon {
|
||||
normal: polygon.normal,
|
||||
vertices: front,
|
||||
material: polygon.material,
|
||||
});
|
||||
front_polygons.push(polygon.with_vertices(front));
|
||||
}
|
||||
|
||||
let back = clip_polygon(&polygon.vertices, plane, KeepSide::Back, &mut cap_points);
|
||||
if back.len() >= 3 {
|
||||
back_polygons.push(ConvexPolygon {
|
||||
normal: polygon.normal,
|
||||
vertices: back,
|
||||
material: polygon.material,
|
||||
});
|
||||
back_polygons.push(polygon.with_vertices(back));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,11 +251,13 @@ impl ConvexSolid {
|
|||
|
||||
SplitResult::Both {
|
||||
front: Self {
|
||||
bounds: bounds_from_polygons(&front_polygons),
|
||||
clip_planes: planes_from_polygons(&front_polygons),
|
||||
polygons: front_polygons,
|
||||
material: self.material,
|
||||
},
|
||||
back: Self {
|
||||
bounds: bounds_from_polygons(&back_polygons),
|
||||
clip_planes: planes_from_polygons(&back_polygons),
|
||||
polygons: back_polygons,
|
||||
material: self.material,
|
||||
|
|
@ -284,11 +350,7 @@ fn make_cap_polygon(points: &[Vec3], normal: Vec3, material: MaterialId) -> Conv
|
|||
vertices.reverse();
|
||||
}
|
||||
|
||||
ConvexPolygon {
|
||||
vertices,
|
||||
normal,
|
||||
material,
|
||||
}
|
||||
ConvexPolygon::new(vertices, normal, material)
|
||||
}
|
||||
|
||||
fn unique_points_on_plane(points: Vec<Vec3>) -> Vec<Vec3> {
|
||||
|
|
@ -317,6 +379,55 @@ fn planes_from_polygons(polygons: &[ConvexPolygon]) -> Vec<Plane> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn bounds_from_polygons(polygons: &[ConvexPolygon]) -> Aabb {
|
||||
polygons.iter().fold(Aabb::empty(), |bounds, polygon| {
|
||||
bounds.union(polygon.bounds)
|
||||
})
|
||||
}
|
||||
|
||||
fn whole_polygon_category_against(
|
||||
polygon: &ConvexPolygon,
|
||||
cutter: &ConvexSolid,
|
||||
) -> Option<PolygonCategory> {
|
||||
let polygon_plane = polygon
|
||||
.vertices
|
||||
.first()
|
||||
.map(|point| Plane::from_point_normal(*point, polygon.normal))?;
|
||||
|
||||
for cutter_plane in &cutter.clip_planes {
|
||||
if polygon_plane.is_coplanar_with(*cutter_plane)
|
||||
&& polygon
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|vertex| cutter_plane.signed_distance(*vertex).abs() <= EPSILON)
|
||||
{
|
||||
return Some(if polygon.normal.dot(cutter_plane.normal) >= 0.0 {
|
||||
PolygonCategory::Aligned
|
||||
} else {
|
||||
PolygonCategory::ReverseAligned
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut fully_inside = true;
|
||||
for cutter_plane in &cutter.clip_planes {
|
||||
let mut all_outside_this_plane = true;
|
||||
for vertex in &polygon.vertices {
|
||||
let distance = cutter_plane.signed_distance(*vertex);
|
||||
if distance > EPSILON {
|
||||
fully_inside = false;
|
||||
} else {
|
||||
all_outside_this_plane = false;
|
||||
}
|
||||
}
|
||||
if all_outside_this_plane {
|
||||
return Some(PolygonCategory::Outside);
|
||||
}
|
||||
}
|
||||
|
||||
fully_inside.then_some(PolygonCategory::Inside)
|
||||
}
|
||||
|
||||
fn polygon_normal(vertices: &[Vec3]) -> Vec3 {
|
||||
if vertices.len() < 3 {
|
||||
return Vec3::ZERO;
|
||||
|
|
@ -383,6 +494,30 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_overlapping_boxes_emits_single_common_solid() {
|
||||
let source = ConvexSolid::box_from_center_size(
|
||||
Vec3::ZERO,
|
||||
Vec3::splat(4.0),
|
||||
Quat::IDENTITY,
|
||||
MaterialId(1),
|
||||
);
|
||||
let cutter = ConvexSolid::box_from_center_size(
|
||||
Vec3::X,
|
||||
Vec3::splat(4.0),
|
||||
Quat::IDENTITY,
|
||||
MaterialId(0),
|
||||
);
|
||||
|
||||
let common = source.intersect_convex(&cutter).expect("intersection");
|
||||
|
||||
assert!(common.bounds.is_valid());
|
||||
assert_eq!(common.polygons.len(), 6);
|
||||
assert!(common.bounds.contains_point(Vec3::ZERO));
|
||||
assert!(common.bounds.contains_point(Vec3::new(2.0, 0.0, 0.0)));
|
||||
assert!(!common.bounds.contains_point(Vec3::new(-2.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtract_rotated_box_creates_non_axis_aligned_faces() {
|
||||
let source = ConvexSolid::box_from_center_size(
|
||||
|
|
|
|||
101
src/tree.rs
101
src/tree.rs
|
|
@ -1,4 +1,4 @@
|
|||
use crate::{Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive};
|
||||
use crate::{Aabb, Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct CsgNodeId(pub u32);
|
||||
|
|
@ -160,6 +160,13 @@ impl CsgTreeArena {
|
|||
Some(children)
|
||||
}
|
||||
|
||||
pub fn node_bounds(&self, id: CsgNodeId) -> Option<Aabb> {
|
||||
match self.node(id)? {
|
||||
CsgNode::Brush(brush) => Some(brush.bounds()),
|
||||
CsgNode::Branch { op, children, .. } => self.branch_bounds(*op, children),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compile_tree_to_assembler(&self, tree: CsgTree) -> Assembler {
|
||||
let mut assembler = Assembler::new();
|
||||
self.compile_node(tree.root, &mut assembler, None);
|
||||
|
|
@ -207,13 +214,32 @@ impl CsgTreeArena {
|
|||
}
|
||||
}
|
||||
CsgBranchOp::Common => {
|
||||
for child in children {
|
||||
self.compile_node(*child, assembler, Some(CsgOperationType::Intersecting));
|
||||
if let Some((first, rest)) = children.split_first() {
|
||||
self.compile_node(*first, assembler, operation_override);
|
||||
for child in rest {
|
||||
self.compile_node(
|
||||
*child,
|
||||
assembler,
|
||||
Some(CsgOperationType::Intersecting),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_bounds(&self, op: CsgBranchOp, children: &[CsgNodeId]) -> Option<Aabb> {
|
||||
let mut child_bounds = children.iter().filter_map(|child| self.node_bounds(*child));
|
||||
match op {
|
||||
CsgBranchOp::Addition => child_bounds.reduce(Aabb::union),
|
||||
CsgBranchOp::Subtraction => child_bounds.next(),
|
||||
CsgBranchOp::Common => {
|
||||
let first = child_bounds.next()?;
|
||||
child_bounds.try_fold(first, |bounds, child| bounds.intersection(child))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CsgOperationType {
|
||||
|
|
@ -303,4 +329,73 @@ mod tests {
|
|||
assert_eq!(arena.brush_count(), 2);
|
||||
assert_eq!(arena.branch_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_bounds_follow_boolean_branch_shape() {
|
||||
let mut arena = CsgTreeArena::new();
|
||||
let a = arena.generate_brush(
|
||||
"a",
|
||||
CsgOperationType::Additive,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
|
||||
},
|
||||
MaterialId(1),
|
||||
);
|
||||
let b = arena.generate_brush(
|
||||
"b",
|
||||
CsgOperationType::Additive,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(Vec3::X * 2.5, Vec3::splat(2.0)),
|
||||
},
|
||||
MaterialId(1),
|
||||
);
|
||||
let union = arena.generate_branch("union", CsgBranchOp::Addition, [a.node, b.node]);
|
||||
let common = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]);
|
||||
let subtract =
|
||||
arena.generate_branch("subtract", CsgBranchOp::Subtraction, [a.node, b.node]);
|
||||
|
||||
assert_eq!(
|
||||
arena.node_bounds(union.node).expect("union"),
|
||||
Aabb::new(Vec3::splat(-2.0), Vec3::new(3.5, 2.0, 2.0))
|
||||
);
|
||||
assert_eq!(
|
||||
arena.node_bounds(common.node).expect("common"),
|
||||
Aabb::new(Vec3::new(1.5, -1.0, -1.0), Vec3::new(2.0, 1.0, 1.0))
|
||||
);
|
||||
assert_eq!(
|
||||
arena.node_bounds(subtract.node).expect("subtract"),
|
||||
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_common_compiles_to_additive_source_then_intersections() {
|
||||
let mut arena = CsgTreeArena::new();
|
||||
let a = arena.generate_brush(
|
||||
"a",
|
||||
CsgOperationType::Additive,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
|
||||
},
|
||||
MaterialId(1),
|
||||
);
|
||||
let b = arena.generate_brush(
|
||||
"b",
|
||||
CsgOperationType::Additive,
|
||||
Primitive::Box {
|
||||
bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)),
|
||||
},
|
||||
MaterialId(1),
|
||||
);
|
||||
let branch = arena.generate_branch("common", CsgBranchOp::Common, [a.node, b.node]);
|
||||
|
||||
let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node));
|
||||
let output = assembler.build();
|
||||
|
||||
assert_eq!(assembler.brushes()[0].op, BrushOp::Add);
|
||||
assert_eq!(assembler.brushes()[1].op, BrushOp::Intersect);
|
||||
assert_eq!(output.report.emitted_convex_fragments, 1);
|
||||
assert_eq!(output.report.warnings.len(), 0);
|
||||
assert_eq!(output.mesh.triangle_count(), 12);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,3 +89,55 @@ fn parity_center_box_subtraction_has_six_surviving_regions() {
|
|||
assert_eq!(output.report.emitted_convex_fragments, 6);
|
||||
assert_eq!(output.report.warnings.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_identical_box_polygons_classify_as_aligned() {
|
||||
let cutter = ConvexSolid::box_from_center_size(
|
||||
Vec3::ZERO,
|
||||
Vec3::splat(2.0),
|
||||
Quat::IDENTITY,
|
||||
MaterialId(0),
|
||||
);
|
||||
let mut source = ConvexSolid::box_from_center_size(
|
||||
Vec3::ZERO,
|
||||
Vec3::splat(2.0),
|
||||
Quat::IDENTITY,
|
||||
MaterialId(1),
|
||||
);
|
||||
|
||||
source.categorize_whole_polygons_against(&cutter);
|
||||
|
||||
assert_eq!(source.polygons.len(), 6);
|
||||
assert!(
|
||||
source
|
||||
.polygons
|
||||
.iter()
|
||||
.all(|polygon| polygon.category == PolygonCategory::Aligned)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_outer_box_polygons_classify_as_outside_inner_box() {
|
||||
let cutter = ConvexSolid::box_from_center_size(
|
||||
Vec3::ZERO,
|
||||
Vec3::splat(2.0),
|
||||
Quat::IDENTITY,
|
||||
MaterialId(0),
|
||||
);
|
||||
let mut source = ConvexSolid::box_from_center_size(
|
||||
Vec3::ZERO,
|
||||
Vec3::splat(4.0),
|
||||
Quat::IDENTITY,
|
||||
MaterialId(1),
|
||||
);
|
||||
|
||||
source.categorize_whole_polygons_against(&cutter);
|
||||
|
||||
assert_eq!(source.polygons.len(), 6);
|
||||
assert!(
|
||||
source
|
||||
.polygons
|
||||
.iter()
|
||||
.all(|polygon| polygon.category == PolygonCategory::Outside)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue