Replace vg_csg with submodule

This commit is contained in:
Meta Vens 2026-05-07 22:59:42 +01:00
commit c85b8ac352
20 changed files with 11 additions and 6040 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "crates/vg_csg"]
path = crates/vg_csg
url = https://github.com/GameCult/vg-csg

1
crates/vg_csg Submodule

@ -0,0 +1 @@
Subproject commit 1adeadc74da047cc8d8b8069d3d68f05dac88851

View file

@ -1,12 +0,0 @@
[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

@ -1,372 +0,0 @@
use std::{
hint::black_box,
time::{Duration, Instant},
};
use bevy_math::{Quat, Vec3};
use vg_csg::{Aabb, Assembler, BrushOp, MaterialId, Primitive};
const WARMUP_ITERS: usize = 8;
const MEASURE_ITERS: usize = 64;
fn main() {
let cases = [
PerfCase {
name: "single_center_cut",
brushes: 2,
build: single_center_cut,
},
PerfCase {
name: "room_grid_8x8_doors",
brushes: 192,
build: room_grid_8x8_doors,
},
PerfCase {
name: "rotated_cut_stack_64",
brushes: 65,
build: rotated_cut_stack_64,
},
PerfCase {
name: "common_box_chain_64",
brushes: 64,
build: common_box_chain_64,
},
PerfCase {
name: "distant_cutters_512",
brushes: 513,
build: distant_cutters_512,
},
PerfCase {
name: "distant_oriented_cutters_128",
brushes: 129,
build: distant_oriented_cutters_128,
},
];
for case in cases {
run_case(case, PerfMode::Stable);
run_case(case, PerfMode::Dirty);
run_case(case, PerfMode::IncrementalDirty);
run_case(case, PerfMode::Routed);
}
}
#[derive(Clone, Copy)]
struct PerfCase {
name: &'static str,
brushes: usize,
build: fn() -> Assembler,
}
#[derive(Clone, Copy)]
enum PerfMode {
Stable,
Dirty,
IncrementalDirty,
Routed,
}
impl PerfMode {
fn as_str(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Dirty => "dirty",
Self::IncrementalDirty => "incremental_dirty",
Self::Routed => "routed",
}
}
fn build(self, assembler: &mut Assembler, iteration: usize) -> vg_csg::BuildOutput {
match self {
Self::Stable => assembler.build(),
Self::Dirty => {
nudge_tail_brush(assembler, iteration);
assembler.rebuild()
}
Self::IncrementalDirty => {
nudge_tail_brush(assembler, iteration);
assembler.build_incremental()
}
Self::Routed => assembler.rebuild_routed_surfaces(),
}
}
}
fn run_case(case: PerfCase, mode: PerfMode) {
let mut assembler = (case.build)();
if matches!(mode, PerfMode::Routed) && !assembler.supports_routed_surfaces() {
return;
}
let warmup = if matches!(mode, PerfMode::IncrementalDirty) {
assembler.build_incremental()
} else {
assembler.build()
};
black_box(warmup.mesh.triangle_count());
for _ in 0..WARMUP_ITERS {
let output = mode.build(&mut assembler, 0);
black_box(output.mesh.triangle_count());
}
let mut timings = Vec::with_capacity(MEASURE_ITERS);
let mut triangles = 0;
let mut fragments = 0;
let mut warnings = 0;
let mut candidate_pairs = 0;
let mut rejected_pairs = 0;
let mut reused_mesh = false;
let mut used_checkpoint = false;
let mut checkpoint_rebuild_from = None::<usize>;
let mut direct_dirty_rebuild = false;
for iteration in 0..MEASURE_ITERS {
let start = Instant::now();
let output = mode.build(&mut assembler, iteration);
let elapsed = start.elapsed();
triangles = output.mesh.triangle_count();
fragments = output.report.emitted_convex_fragments;
warnings = output.report.warnings.len();
candidate_pairs = output.report.candidate_pairs;
rejected_pairs = output.report.rejected_pairs;
reused_mesh = output.report.reused_mesh;
used_checkpoint = output.report.used_checkpoint;
checkpoint_rebuild_from = output.report.checkpoint_rebuild_from;
direct_dirty_rebuild = output.report.direct_dirty_rebuild;
black_box((
triangles,
fragments,
warnings,
candidate_pairs,
rejected_pairs,
reused_mesh,
used_checkpoint,
checkpoint_rebuild_from,
direct_dirty_rebuild,
));
timings.push(elapsed);
}
timings.sort_unstable();
let total = timings
.iter()
.fold(Duration::ZERO, |sum, value| sum + *value);
let mean_ns = total.as_nanos() / timings.len() as u128;
let min_ns = timings.first().map_or(0, Duration::as_nanos);
let max_ns = timings.last().map_or(0, Duration::as_nanos);
let p50_ns = percentile_ns(&timings, 50);
let p95_ns = percentile_ns(&timings, 95);
println!(
"{{\"kernel\":\"vg_csg\",\"mode\":\"{}\",\"scenario\":\"{}\",\"brushes\":{},\"iterations\":{},\"warmup_iterations\":{},\"mean_ns\":{},\"min_ns\":{},\"p50_ns\":{},\"p95_ns\":{},\"max_ns\":{},\"triangles\":{},\"fragments\":{},\"warnings\":{},\"candidate_pairs\":{},\"rejected_pairs\":{},\"reused_mesh\":{},\"used_checkpoint\":{},\"checkpoint_rebuild_from\":{},\"direct_dirty_rebuild\":{}}}",
mode.as_str(),
case.name,
case.brushes,
MEASURE_ITERS,
WARMUP_ITERS,
mean_ns,
min_ns,
p50_ns,
p95_ns,
max_ns,
triangles,
fragments,
warnings,
candidate_pairs,
rejected_pairs,
reused_mesh,
used_checkpoint,
checkpoint_rebuild_from.map_or_else(|| "null".to_string(), |index| index.to_string()),
direct_dirty_rebuild
);
}
fn nudge_tail_brush(assembler: &mut Assembler, iteration: usize) {
let Some(brush) = assembler.brushes().last().cloned() else {
return;
};
let phase = if iteration.is_multiple_of(2) {
0.0
} else {
0.01
};
match brush.primitive {
Primitive::Box { bounds } => {
let center = bounds.center();
let size = bounds.size() + Vec3::splat(phase);
assembler.set_brush_primitive(
brush.id,
Primitive::Box {
bounds: Aabb::from_center_size(center, size),
},
);
}
Primitive::OrientedBox {
center,
size,
rotation,
} => {
assembler.set_brush_primitive(
brush.id,
Primitive::OrientedBox {
center,
size: size + Vec3::splat(phase),
rotation,
},
);
}
_ => {}
}
}
fn percentile_ns(values: &[Duration], percentile: usize) -> u128 {
if values.is_empty() {
return 0;
}
let index = ((values.len() - 1) * percentile) / 100;
values[index].as_nanos()
}
fn single_center_cut() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
MaterialId(1),
);
asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)));
asm
}
fn room_grid_8x8_doors() -> Assembler {
let mut asm = Assembler::new();
let wall = MaterialId(1);
let floor = MaterialId(2);
let cell = 6.0;
for y in 0..8 {
for x in 0..8 {
let center = Vec3::new((x as f32 - 3.5) * cell, (y as f32 - 3.5) * cell, 0.0);
asm.solid_box(
format!("floor_{x}_{y}"),
Aabb::from_center_size(
center + Vec3::new(0.0, 0.0, -0.1),
Vec3::new(5.6, 5.6, 0.2),
),
floor,
);
asm.solid_box(
format!("north_wall_{x}_{y}"),
Aabb::from_center_size(
center + Vec3::new(0.0, 2.8, 1.5),
Vec3::new(5.6, 0.25, 3.0),
),
wall,
);
asm.cut_box(
format!("door_{x}_{y}"),
Aabb::from_center_size(center + Vec3::new(0.0, 2.8, 1.0), Vec3::new(1.2, 0.5, 2.0)),
);
}
}
asm
}
fn rotated_cut_stack_64() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"slab",
Aabb::from_center_size(Vec3::ZERO, Vec3::new(32.0, 32.0, 4.0)),
MaterialId(1),
);
for index in 0..64 {
let angle = index as f32 * 0.173;
let radius = 11.0 + (index % 7) as f32 * 0.35;
let center = Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0);
asm.cut_oriented_box(
format!("rotated_void_{index}"),
center,
Vec3::new(1.0 + (index % 3) as f32 * 0.3, 8.0, 5.0),
Quat::from_rotation_z(angle),
);
}
asm
}
fn common_box_chain_64() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::new(32.0, 16.0, 8.0)),
MaterialId(1),
);
for index in 1..64 {
let t = index as f32 / 63.0;
let center = Vec3::new(
(t - 0.5) * 6.0,
(t * std::f32::consts::TAU).sin() * 1.5,
0.0,
);
asm.add_brush(
format!("common_{index}"),
BrushOp::Intersect,
Primitive::OrientedBox {
center,
size: Vec3::new(30.0 - t * 8.0, 14.0, 7.0),
rotation: Quat::from_rotation_z(t * 0.2),
},
MaterialId(1),
);
}
asm
}
fn distant_cutters_512() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
MaterialId(1),
);
for index in 0..512 {
let row = index / 32;
let col = index % 32;
asm.cut_box(
format!("far_void_{index}"),
Aabb::from_center_size(
Vec3::new(1000.0 + col as f32 * 4.0, 1000.0 + row as f32 * 4.0, 0.0),
Vec3::splat(1.0),
),
);
}
asm
}
fn distant_oriented_cutters_128() -> Assembler {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
MaterialId(1),
);
for index in 0..128 {
let row = index / 16;
let col = index % 16;
asm.cut_oriented_box(
format!("far_oriented_void_{index}"),
Vec3::new(1000.0 + col as f32 * 4.0, 1000.0 + row as f32 * 4.0, 0.0),
Vec3::splat(1.0),
Quat::from_rotation_z(index as f32 * 0.037),
);
}
asm
}

View file

@ -1,57 +0,0 @@
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={} convex_fragments={} vertices={} triangles={} warnings={}",
output.report.input_brushes,
output.report.emitted_convex_fragments,
output.mesh.vertex_count(),
output.mesh.triangle_count(),
output.report.warnings.len()
);
}

View file

@ -1,536 +0,0 @@
use std::{
fmt::Write as _,
fs, io,
path::{Path, PathBuf},
};
use bevy_math::{Quat, Vec3};
use vg_csg::{DomeCapZSpec, LevelDsl, MaterialId, TriangleMesh};
const FLOOR: MaterialId = MaterialId(1);
const WALL: MaterialId = MaterialId(2);
const GLASS: MaterialId = MaterialId(3);
const SOLAR: MaterialId = MaterialId(4);
const RADIATOR: MaterialId = MaterialId(5);
const MACHINE: MaterialId = MaterialId(6);
const TOWER: MaterialId = MaterialId(7);
const PARK: MaterialId = MaterialId(8);
const LIGHT: MaterialId = MaterialId(9);
fn main() -> io::Result<()> {
let mut level = LevelDsl::new();
add_machine_nave(&mut level);
add_anchor_city(&mut level);
add_petal_hinges(&mut level);
add_service_arteries(&mut level);
let mut output = level.assemble();
append_curved_ray_florets(&mut output.mesh);
let out_dir = output_dir();
fs::create_dir_all(&out_dir)?;
write_obj_bundle(&out_dir, "dream_machine_level", &output.mesh)?;
fs::write(
out_dir.join("dream_machine_level_preview.svg"),
svg_preview(&output.mesh),
)?;
println!(
"dream_machine_level brushes={} convex_fragments={} vertices={} triangles={} candidate_pairs={} rejected_pairs={} warnings={} obj={} preview={}",
output.report.input_brushes,
output.report.emitted_convex_fragments,
output.mesh.vertex_count(),
output.mesh.triangle_count(),
output.report.candidate_pairs,
output.report.rejected_pairs,
output.report.warnings.len(),
out_dir.join("dream_machine_level.obj").display(),
out_dir.join("dream_machine_level_preview.svg").display(),
);
if !output.report.warnings.is_empty() {
eprintln!("warnings: {:?}", output.report.warnings);
}
Ok(())
}
fn add_machine_nave(level: &mut LevelDsl) {
level.cylinder_z(
"round machine garden foundation",
Vec3::new(0.0, 0.0, -0.22),
26.0,
0.44,
96,
FLOOR,
);
level.cylinder_z(
"inner raised city terrace",
Vec3::new(0.0, 0.0, 0.08),
19.0,
0.34,
96,
WALL,
);
level.solid_box(
"central reactor plinth",
Vec3::new(0.0, 0.0, 0.45),
Vec3::new(13.0, 13.0, 0.9),
MACHINE,
);
level.cylinder_z(
"reactor throat",
Vec3::new(0.0, 0.0, 2.0),
4.4,
4.0,
48,
LIGHT,
);
level.dome_cap_z(
"glass computation dome",
DomeCapZSpec {
center: Vec3::new(0.0, 0.0, 0.7),
radius: 11.0,
height: 7.0,
rings: 12,
segments: 64,
material: GLASS,
},
);
for index in 0..16 {
let angle = index as f32 * std::f32::consts::TAU / 16.0;
let radial = Vec3::new(angle.cos(), angle.sin(), 0.0);
level.cut_oriented_box(
format!("diagonal vent throat {index}"),
radial * 17.5 + Vec3::new(0.0, 0.0, 0.24),
Vec3::new(1.1, 15.0, 1.5),
Quat::from_rotation_z(angle + 0.55),
);
}
}
fn add_anchor_city(level: &mut LevelDsl) {
for ring in 0..4 {
let radius = 11.8 + ring as f32 * 3.0;
let blocks = 8 + ring * 8;
let height_base = if ring == 0 {
5.8
} else {
4.4 - ring as f32 * 0.65
};
let mat = if ring == 0 {
TOWER
} else if ring == 2 {
PARK
} else {
WALL
};
for index in 0..blocks {
let t = index as f32 / blocks as f32;
let angle = t * std::f32::consts::TAU + ring as f32 * 0.21;
let jitter = seeded_wave(ring as f32 * 17.0 + index as f32 * 3.1);
let radial = Vec3::new(angle.cos(), angle.sin(), 0.0);
let tangent_angle = angle + std::f32::consts::FRAC_PI_2;
let center = radial * (radius + jitter * 0.55);
let footprint = Vec3::new(1.05 + jitter.abs() * 0.65, 0.85 + ring as f32 * 0.12, 1.0);
let height = (height_base + jitter * 1.5).max(0.55);
level.solid_oriented_box(
format!("anchor city block r{ring} b{index}"),
Vec3::new(center.x, center.y, height * 0.5),
Vec3::new(footprint.x, footprint.y, height),
Quat::from_rotation_z(tangent_angle),
mat,
);
}
}
for index in 0..12 {
let angle = index as f32 * std::f32::consts::TAU / 12.0;
let radial = Vec3::new(angle.cos(), angle.sin(), 0.0);
level.solid_oriented_box(
format!("anchor plaza spoke {index}"),
radial * 14.5 + Vec3::new(0.0, 0.0, 0.34),
Vec3::new(12.0, 0.34, 0.12),
Quat::from_rotation_z(angle),
LIGHT,
);
}
}
fn add_petal_hinges(level: &mut LevelDsl) {
for index in 0..18 {
let angle = index as f32 * std::f32::consts::TAU / 18.0;
let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
level.solid_oriented_box(
format!("petal hinge spine {index}"),
direction * 28.0 + Vec3::new(0.0, 0.0, 1.0),
Vec3::new(5.0, 0.55, 1.2),
Quat::from_rotation_z(angle),
MACHINE,
);
}
}
#[derive(Clone, Copy, Debug)]
struct CurvedPanelSpec {
anchor: Vec3,
direction: Vec3,
length: f32,
root_width: f32,
tip_width: f32,
thickness: f32,
lift: f32,
bend: f32,
segments: usize,
material: MaterialId,
}
fn append_curved_ray_florets(mesh: &mut TriangleMesh) {
for index in 0..18 {
let angle = index as f32 * std::f32::consts::TAU / 18.0;
let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
let material = if index % 2 == 0 { SOLAR } else { RADIATOR };
append_curved_panel(
mesh,
CurvedPanelSpec {
anchor: direction * 29.0 + Vec3::Z * 0.58,
direction,
length: if index % 3 == 0 { 74.0 } else { 58.0 },
root_width: 5.2,
tip_width: 16.5,
thickness: 0.16,
lift: if index % 2 == 0 { 7.0 } else { 4.4 },
bend: if index % 2 == 0 { 7.5 } else { -5.5 },
segments: 9,
material,
},
);
}
}
fn append_curved_panel(mesh: &mut TriangleMesh, spec: CurvedPanelSpec) {
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 segments = spec.segments.max(2);
let mut left_top = Vec::with_capacity(segments + 1);
let mut right_top = Vec::with_capacity(segments + 1);
let mut left_bottom = Vec::with_capacity(segments + 1);
let mut right_bottom = Vec::with_capacity(segments + 1);
let half_thickness = Vec3::Z * (spec.thickness * 0.5);
for index in 0..=segments {
let t = index as f32 / segments as f32;
let smooth_t = t * t * (3.0 - 2.0 * t);
let width = spec.root_width + (spec.tip_width - spec.root_width) * smooth_t;
let center = spec.anchor
+ forward * (spec.length * t)
+ side * (spec.bend * t * (1.0 - t))
+ Vec3::Z * (spec.lift * smooth_t);
let half_width = side * (width * 0.5);
left_top.push(center - half_width + half_thickness);
right_top.push(center + half_width + half_thickness);
left_bottom.push(center - half_width - half_thickness);
right_bottom.push(center + half_width - half_thickness);
}
for index in 0..segments {
mesh.append_quad(
[
left_top[index],
right_top[index],
right_top[index + 1],
left_top[index + 1],
],
Vec3::Z,
spec.material,
);
mesh.append_quad(
[
left_bottom[index],
left_bottom[index + 1],
right_bottom[index + 1],
right_bottom[index],
],
Vec3::NEG_Z,
spec.material,
);
mesh.append_quad(
[
left_bottom[index],
left_top[index],
left_top[index + 1],
left_bottom[index + 1],
],
-side,
spec.material,
);
mesh.append_quad(
[
right_bottom[index],
right_bottom[index + 1],
right_top[index + 1],
right_top[index],
],
side,
spec.material,
);
}
mesh.append_quad(
[left_bottom[0], right_bottom[0], right_top[0], left_top[0]],
-forward,
spec.material,
);
mesh.append_quad(
[
left_bottom[segments],
left_top[segments],
right_top[segments],
right_bottom[segments],
],
forward,
spec.material,
);
}
fn add_service_arteries(level: &mut LevelDsl) {
for ring in 0..3 {
let segments = 18 + ring * 6;
let radius = 23.0 + ring as f32 * 4.8;
for index in 0..segments {
if (index + ring) % 3 == 0 {
continue;
}
let angle = index as f32 * std::f32::consts::TAU / segments as f32;
let tangent = angle + std::f32::consts::FRAC_PI_2;
let center = Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.92);
let length = std::f32::consts::TAU * radius / segments as f32 * 0.82;
let material = if ring == 1 { LIGHT } else { MACHINE };
level.solid_oriented_box(
format!("orbiting cargo artery r{ring} s{index}"),
center,
Vec3::new(length, 0.52, 0.36),
Quat::from_rotation_z(tangent),
material,
);
}
}
for index in 0..18 {
let angle = index as f32 * std::f32::consts::TAU / 18.0 + 0.18;
let radial = Vec3::new(angle.cos(), angle.sin(), 0.0);
level.solid_oriented_box(
format!("outer machine pier {index}"),
radial * 26.0 + Vec3::new(0.0, 0.0, 1.05),
Vec3::new(2.0, 0.65, 2.1),
Quat::from_rotation_z(angle),
MACHINE,
);
}
}
fn seeded_wave(value: f32) -> f32 {
(value.sin() * 0.63 + (value * 1.73).cos() * 0.37).clamp(-1.0, 1.0)
}
fn output_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("experiments")
.join("generated")
.join("dream_machine_level")
}
fn write_obj_bundle(dir: &Path, name: &str, mesh: &TriangleMesh) -> io::Result<()> {
fs::write(dir.join(format!("{name}.mtl")), material_library())?;
fs::write(dir.join(format!("{name}.obj")), obj_text(name, mesh))?;
Ok(())
}
fn obj_text(name: &str, mesh: &TriangleMesh) -> String {
let mut out = String::new();
let _ = writeln!(out, "mtllib {name}.mtl");
let _ = writeln!(out, "o {name}");
for position in &mesh.positions {
let _ = writeln!(out, "v {} {} {}", position.x, position.y, position.z);
}
for uv in &mesh.uvs {
let _ = writeln!(out, "vt {} {}", uv.x, uv.y);
}
for normal in &mesh.normals {
let _ = writeln!(out, "vn {} {} {}", normal.x, normal.y, normal.z);
}
let mut active_material = None;
for (tri_index, face) in mesh.indices.chunks_exact(3).enumerate() {
let material = mesh
.triangle_materials
.get(tri_index)
.copied()
.unwrap_or_default();
if active_material != Some(material) {
let _ = writeln!(out, "usemtl {}", material_name(material));
active_material = Some(material);
}
let a = face[0] + 1;
let b = face[1] + 1;
let c = face[2] + 1;
let _ = writeln!(out, "f {a}/{a}/{a} {b}/{b}/{b} {c}/{c}/{c}");
}
out
}
fn material_library() -> String {
[
material_text(FLOOR, "0.36 0.35 0.31", "0.05 0.05 0.05"),
material_text(WALL, "0.42 0.43 0.42", "0.04 0.04 0.04"),
material_text(GLASS, "0.30 0.80 1.00", "0.20 0.55 0.70"),
material_text(SOLAR, "0.05 0.11 0.20", "0.00 0.18 0.28"),
material_text(RADIATOR, "0.70 0.58 0.44", "0.28 0.12 0.04"),
material_text(MACHINE, "0.16 0.17 0.18", "0.06 0.07 0.08"),
material_text(TOWER, "0.72 0.76 0.78", "0.12 0.16 0.18"),
material_text(PARK, "0.22 0.46 0.25", "0.02 0.08 0.02"),
material_text(LIGHT, "0.08 0.75 0.90", "0.00 0.65 0.85"),
]
.join("\n")
}
fn material_text(id: MaterialId, kd: &str, ke: &str) -> String {
format!("newmtl {}\nKd {kd}\nKe {ke}\nNs 80\n", material_name(id))
}
fn material_name(id: MaterialId) -> &'static str {
match id {
FLOOR => "mat_floor",
WALL => "mat_wall",
GLASS => "mat_glass",
SOLAR => "mat_solar",
RADIATOR => "mat_radiator",
MACHINE => "mat_machine",
TOWER => "mat_tower",
PARK => "mat_park",
LIGHT => "mat_light",
_ => "mat_default",
}
}
fn svg_preview(mesh: &TriangleMesh) -> String {
let width = 1600.0_f32;
let height = 1050.0_f32;
let margin = 42.0_f32;
let mut projected = Vec::with_capacity(mesh.positions.len());
for point in &mesh.positions {
let x = (point.x - point.y) * 0.82;
let y = (point.x + point.y) * 0.34 - point.z * 1.15;
let depth = point.x + point.y + point.z * 0.7;
projected.push((x, y, depth));
}
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
for (x, y, _) in &projected {
min_x = min_x.min(*x);
max_x = max_x.max(*x);
min_y = min_y.min(*y);
max_y = max_y.max(*y);
}
let span_x = (max_x - min_x).max(1.0);
let span_y = (max_y - min_y).max(1.0);
let scale = ((width - margin * 2.0) / span_x).min((height - margin * 2.0) / span_y);
let ox = (width - span_x * scale) * 0.5 - min_x * scale;
let oy = (height - span_y * scale) * 0.5 - min_y * scale;
let mut tris = Vec::with_capacity(mesh.triangle_count());
for (tri_index, face) in mesh.indices.chunks_exact(3).enumerate() {
let a = projected[face[0] as usize];
let b = projected[face[1] as usize];
let c = projected[face[2] as usize];
let depth = (a.2 + b.2 + c.2) / 3.0;
let points = [
(a.0 * scale + ox, a.1 * scale + oy),
(b.0 * scale + ox, b.1 * scale + oy),
(c.0 * scale + ox, c.1 * scale + oy),
];
let material = mesh
.triangle_materials
.get(tri_index)
.copied()
.unwrap_or_default();
tris.push((depth, material, points));
}
tris.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out = String::new();
let _ = writeln!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\">"
);
let _ = writeln!(
out,
"<rect width=\"100%\" height=\"100%\" fill=\"#111315\"/>"
);
let _ = writeln!(
out,
"<g stroke=\"#07090a\" stroke-width=\"0.45\" stroke-linejoin=\"round\">"
);
for (_, material, points) in tris {
let _ = writeln!(
out,
"<polygon points=\"{:.2},{:.2} {:.2},{:.2} {:.2},{:.2}\" fill=\"{}\" opacity=\"{}\"/>",
points[0].0,
points[0].1,
points[1].0,
points[1].1,
points[2].0,
points[2].1,
svg_fill(material),
svg_opacity(material),
);
}
let _ = writeln!(out, "</g>");
let _ = writeln!(out, "</svg>");
out
}
fn svg_fill(id: MaterialId) -> &'static str {
match id {
FLOOR => "#5c5a51",
WALL => "#6b6d6a",
GLASS => "#54cff5",
SOLAR => "#112134",
RADIATOR => "#b59670",
MACHINE => "#292c2f",
TOWER => "#b7c0c4",
PARK => "#387541",
LIGHT => "#20d6f0",
_ => "#808080",
}
}
fn svg_opacity(id: MaterialId) -> &'static str {
match id {
GLASS => "0.42",
LIGHT => "0.88",
SOLAR | RADIATOR => "0.94",
_ => "1.0",
}
}

View file

@ -1,922 +0,0 @@
use std::{
fmt::Write as _,
fs, io,
path::{Path, PathBuf},
};
use bevy_math::Vec3;
use vg_csg::{LevelDsl, MaterialId, TriangleMesh};
const DECK: MaterialId = MaterialId(21);
const SHELL: MaterialId = MaterialId(22);
const DARK: MaterialId = MaterialId(23);
const MACHINE: MaterialId = MaterialId(24);
const PIPE: MaterialId = MaterialId(25);
const AMBER: MaterialId = MaterialId(26);
const BLUE: MaterialId = MaterialId(27);
const GLASS: MaterialId = MaterialId(28);
const HAZARD: MaterialId = MaterialId(29);
const GEAR: MaterialId = MaterialId(30);
const INNER_LOOP_RADIUS: f32 = 6.6;
const OUTER_LOOP_RADIUS: f32 = 16.0;
const ROOM_HEIGHT: f32 = 7.2;
fn main() -> io::Result<()> {
let mut level = LevelDsl::new();
let mut shell_mesh = TriangleMesh::new();
append_curved_room_shell(&mut shell_mesh);
append_machinery_island_shroud(&mut shell_mesh);
add_central_machinery_island(&mut level);
add_inner_manifold_faces(&mut level);
add_outer_staging_zone(&mut level);
add_supervisor_gallery(&mut level);
add_service_arteries_and_harness_gear(&mut level);
add_ribs_and_panel_language(&mut level);
let mut output = level.assemble();
let index_offset = output.mesh.positions.len() as u32;
output.mesh.positions.extend(shell_mesh.positions);
output.mesh.normals.extend(shell_mesh.normals);
output.mesh.uvs.extend(shell_mesh.uvs);
output.mesh.indices.extend(
shell_mesh
.indices
.into_iter()
.map(|index| index + index_offset),
);
output
.mesh
.triangle_materials
.extend(shell_mesh.triangle_materials);
let out_dir = output_dir();
fs::create_dir_all(&out_dir)?;
write_obj_bundle(&out_dir, "industrial_maintenance_loop", &output.mesh)?;
fs::write(
out_dir.join("industrial_maintenance_loop_preview.svg"),
svg_preview(&output.mesh),
)?;
println!(
"industrial_maintenance_loop brushes={} convex_fragments={} vertices={} triangles={} candidate_pairs={} rejected_pairs={} warnings={} obj={} preview={}",
output.report.input_brushes,
output.report.emitted_convex_fragments,
output.mesh.vertex_count(),
output.mesh.triangle_count(),
output.report.candidate_pairs,
output.report.rejected_pairs,
output.report.warnings.len(),
out_dir.join("industrial_maintenance_loop.obj").display(),
out_dir
.join("industrial_maintenance_loop_preview.svg")
.display(),
);
if !output.report.warnings.is_empty() {
eprintln!("warnings: {:?}", output.report.warnings);
}
Ok(())
}
#[derive(Clone, Copy, Debug)]
struct LoopFrame {
angle: f32,
radius: f32,
}
impl LoopFrame {
fn at(angle: f32, radius: f32) -> Self {
Self { angle, radius }
}
fn tangent(self) -> Vec3 {
Vec3::new(-self.angle.sin(), self.angle.cos(), 0.0)
}
fn radial(self) -> Vec3 {
Vec3::new(self.angle.cos(), self.angle.sin(), 0.0)
}
fn point(self, tangent: f32, radial: f32, z: f32) -> Vec3 {
self.radial() * (self.radius + radial) + self.tangent() * tangent + Vec3::Z * z
}
fn yaw(self) -> bevy_math::Quat {
bevy_math::Quat::from_rotation_z(self.angle + std::f32::consts::FRAC_PI_2)
}
}
#[derive(Clone, Copy, Debug)]
struct LoopBox {
tangent: f32,
radial: f32,
z: f32,
size_tangent: f32,
size_radial: f32,
size_z: f32,
material: MaterialId,
}
fn loop_box(level: &mut LevelDsl, name: impl Into<String>, frame: LoopFrame, spec: LoopBox) {
level.solid_oriented_box(
name,
frame.point(spec.tangent, spec.radial, spec.z),
Vec3::new(spec.size_tangent, spec.size_radial, spec.size_z),
frame.yaw(),
spec.material,
);
}
fn append_curved_room_shell(mesh: &mut TriangleMesh) {
append_annular_band(
mesh,
ArcBandSpec::new(INNER_LOOP_RADIUS, OUTER_LOOP_RADIUS, -2.85, 2.85, 0.0, DECK).segments(56),
);
append_annular_band(
mesh,
ArcBandSpec::new(
INNER_LOOP_RADIUS - 0.35,
OUTER_LOOP_RADIUS + 0.25,
-2.85,
2.85,
ROOM_HEIGHT,
SHELL,
)
.segments(56),
);
append_vertical_arc_wall(
mesh,
ArcWallSpec::new(
OUTER_LOOP_RADIUS + 0.1,
-2.85,
2.85,
0.0,
ROOM_HEIGHT,
SHELL,
)
.segments(56),
);
append_vertical_arc_wall(
mesh,
ArcWallSpec::new(
INNER_LOOP_RADIUS - 0.25,
-2.85,
2.85,
0.0,
ROOM_HEIGHT,
DARK,
)
.segments(56),
);
for index in 0..28 {
let a0 = -2.85 + index as f32 * (5.70 / 28.0);
let a1 = a0 + 5.70 / 28.0 * 0.82;
append_annular_band(
mesh,
ArcBandSpec::new(
INNER_LOOP_RADIUS + 0.4,
OUTER_LOOP_RADIUS - 0.55,
a0,
a1,
0.035,
if index % 5 == 0 { HAZARD } else { DECK },
),
);
}
}
fn add_central_machinery_island(level: &mut LevelDsl) {
for index in 0..24 {
let angle = index as f32 * std::f32::consts::TAU / 24.0;
let frame = LoopFrame::at(angle, 5.45);
let height = 2.0 + (index % 4) as f32 * 0.75;
loop_box(
level,
format!("machinery vertical casing {index}"),
frame,
LoopBox {
tangent: 0.0,
radial: 0.0,
z: height * 0.5 + 0.35,
size_tangent: 0.85,
size_radial: 0.7,
size_z: height,
material: if index % 3 == 0 { PIPE } else { MACHINE },
},
);
loop_box(
level,
format!("amber island screen {index}"),
frame,
LoopBox {
tangent: 0.0,
radial: 0.38,
z: 1.55 + (index % 3) as f32 * 0.55,
size_tangent: 0.48,
size_radial: 0.08,
size_z: 0.42,
material: AMBER,
},
);
}
}
fn append_machinery_island_shroud(mesh: &mut TriangleMesh) {
append_vertical_arc_wall(
mesh,
ArcWallSpec::new(
5.25,
-std::f32::consts::PI,
std::f32::consts::PI,
0.15,
ROOM_HEIGHT,
DARK,
)
.segments(72),
);
append_vertical_arc_wall(
mesh,
ArcWallSpec::new(
5.72,
-std::f32::consts::PI,
std::f32::consts::PI,
0.0,
0.45,
MACHINE,
)
.segments(72),
);
append_vertical_arc_wall(
mesh,
ArcWallSpec::new(
5.72,
-std::f32::consts::PI,
std::f32::consts::PI,
ROOM_HEIGHT - 0.45,
ROOM_HEIGHT,
MACHINE,
)
.segments(72),
);
}
fn add_inner_manifold_faces(level: &mut LevelDsl) {
for bay in 0..18 {
let angle = -2.55 + bay as f32 * (5.10 / 17.0);
let frame = LoopFrame::at(angle, INNER_LOOP_RADIUS);
loop_box(
level,
format!("gasket-rimmed manifold panel {bay}"),
frame,
LoopBox {
tangent: 0.0,
radial: -0.35,
z: 1.45,
size_tangent: 1.05,
size_radial: 0.22,
size_z: 1.9,
material: MACHINE,
},
);
loop_box(
level,
format!("round access hatch square proxy {bay}"),
frame,
LoopBox {
tangent: -0.18,
radial: -0.52,
z: 1.25,
size_tangent: 0.52,
size_radial: 0.12,
size_z: 0.52,
material: DARK,
},
);
loop_box(
level,
format!("diagnostic amber strip {bay}"),
frame,
LoopBox {
tangent: 0.26,
radial: -0.55,
z: 1.95,
size_tangent: 0.36,
size_radial: 0.08,
size_z: 0.58,
material: AMBER,
},
);
if bay % 2 == 0 {
add_pipe_bundle(level, frame, bay);
}
}
}
fn add_pipe_bundle(level: &mut LevelDsl, frame: LoopFrame, index: usize) {
for pipe in 0..3 {
loop_box(
level,
format!("inner manifold cable bundle {index}-{pipe}"),
frame,
LoopBox {
tangent: -0.45 + pipe as f32 * 0.45,
radial: -0.78,
z: 3.2 + pipe as f32 * 0.42,
size_tangent: 0.16,
size_radial: 0.28,
size_z: 2.3,
material: PIPE,
},
);
}
}
fn add_outer_staging_zone(level: &mut LevelDsl) {
for bay in 0..20 {
let angle = -2.65 + bay as f32 * (5.30 / 19.0);
let frame = LoopFrame::at(angle, OUTER_LOOP_RADIUS - 1.15);
loop_box(
level,
format!("bolted outer anchor rail {bay}"),
frame,
LoopBox {
tangent: 0.0,
radial: 0.0,
z: 0.55,
size_tangent: 1.25,
size_radial: 0.18,
size_z: 0.18,
material: HAZARD,
},
);
loop_box(
level,
format!("sliding rail block {bay}"),
frame,
LoopBox {
tangent: if bay % 2 == 0 { -0.32 } else { 0.34 },
radial: -0.2,
z: 0.78,
size_tangent: 0.28,
size_radial: 0.42,
size_z: 0.32,
material: MACHINE,
},
);
if bay % 4 == 1 {
add_tool_locker(level, frame, bay);
}
if bay % 5 == 2 {
add_cart_and_rescue_line(level, frame, bay);
}
}
}
fn add_tool_locker(level: &mut LevelDsl, frame: LoopFrame, bay: usize) {
loop_box(
level,
format!("outer tool locker {bay}"),
frame,
LoopBox {
tangent: 0.0,
radial: 0.78,
z: 1.05,
size_tangent: 0.72,
size_radial: 0.55,
size_z: 1.8,
material: MACHINE,
},
);
loop_box(
level,
format!("blue locker readout {bay}"),
frame,
LoopBox {
tangent: 0.18,
radial: 0.48,
z: 1.45,
size_tangent: 0.26,
size_radial: 0.06,
size_z: 0.36,
material: BLUE,
},
);
}
fn add_cart_and_rescue_line(level: &mut LevelDsl, frame: LoopFrame, bay: usize) {
loop_box(
level,
format!("low maintenance cart {bay}"),
frame,
LoopBox {
tangent: -0.18,
radial: -0.72,
z: 0.38,
size_tangent: 0.85,
size_radial: 0.55,
size_z: 0.55,
material: GEAR,
},
);
loop_box(
level,
format!("clipped rescue line {bay}"),
frame,
LoopBox {
tangent: 0.36,
radial: -0.62,
z: 0.88,
size_tangent: 0.1,
size_radial: 1.15,
size_z: 0.1,
material: PIPE,
},
);
}
fn add_supervisor_gallery(level: &mut LevelDsl) {
let frame = LoopFrame::at(-1.85, OUTER_LOOP_RADIUS - 1.0);
loop_box(
level,
"single supervisor gallery deck",
frame,
LoopBox {
tangent: 0.0,
radial: 0.1,
z: 3.65,
size_tangent: 5.4,
size_radial: 1.35,
size_z: 0.28,
material: MACHINE,
},
);
loop_box(
level,
"supervisor gallery glass face",
frame,
LoopBox {
tangent: 0.0,
radial: -0.65,
z: 4.55,
size_tangent: 5.2,
size_radial: 0.12,
size_z: 1.35,
material: GLASS,
},
);
for step in 0..9 {
loop_box(
level,
format!("visible gallery stair step {step}"),
frame,
LoopBox {
tangent: -3.2 + step as f32 * 0.34,
radial: -1.6 + step as f32 * 0.12,
z: 0.42 + step as f32 * 0.33,
size_tangent: 0.54,
size_radial: 0.72,
size_z: 0.12,
material: HAZARD,
},
);
}
for panel in 0..4 {
loop_box(
level,
format!("blue supervisor console {panel}"),
frame,
LoopBox {
tangent: -1.8 + panel as f32 * 1.2,
radial: 0.08,
z: 4.15,
size_tangent: 0.75,
size_radial: 0.18,
size_z: 0.42,
material: BLUE,
},
);
}
}
fn add_service_arteries_and_harness_gear(level: &mut LevelDsl) {
for mouth in 0..7 {
let angle = -0.55 + mouth as f32 * 0.18;
let frame = LoopFrame::at(angle, INNER_LOOP_RADIUS);
loop_box(
level,
format!("dark service artery mouth {mouth}"),
frame,
LoopBox {
tangent: 0.0,
radial: -0.72,
z: 1.0 + (mouth % 3) as f32 * 0.55,
size_tangent: 0.5,
size_radial: 0.32,
size_z: 0.42,
material: DARK,
},
);
loop_box(
level,
format!("pipe tunnel throat {mouth}"),
frame,
LoopBox {
tangent: 0.0,
radial: -1.35,
z: 1.0 + (mouth % 3) as f32 * 0.55,
size_tangent: 0.32,
size_radial: 1.2,
size_z: 0.28,
material: PIPE,
},
);
}
let rack_frame = LoopFrame::at(0.78, OUTER_LOOP_RADIUS - 0.8);
loop_box(
level,
"dry-operation mobility gear wall rack",
rack_frame,
LoopBox {
tangent: 0.0,
radial: 0.65,
z: 1.65,
size_tangent: 2.1,
size_radial: 0.24,
size_z: 2.2,
material: MACHINE,
},
);
for item in 0..8 {
loop_box(
level,
format!("folded harness loop and cartridge {item}"),
rack_frame,
LoopBox {
tangent: -0.85 + item as f32 * 0.24,
radial: 0.38,
z: 0.8 + (item % 4) as f32 * 0.42,
size_tangent: 0.12,
size_radial: 0.18,
size_z: 0.62,
material: if item % 3 == 0 { BLUE } else { GEAR },
},
);
}
for tube in 0..5 {
loop_box(
level,
format!("coiled oxygenation tube proxy {tube}"),
rack_frame,
LoopBox {
tangent: -0.75 + tube as f32 * 0.38,
radial: -0.42,
z: 0.46,
size_tangent: 0.28,
size_radial: 0.28,
size_z: 0.24,
material: PIPE,
},
);
}
}
fn add_ribs_and_panel_language(level: &mut LevelDsl) {
for rib in 0..18 {
let angle = -2.75 + rib as f32 * (5.50 / 17.0);
let frame = LoopFrame::at(angle, (INNER_LOOP_RADIUS + OUTER_LOOP_RADIUS) * 0.5);
loop_box(
level,
format!("manufactured shell rib {rib}"),
frame,
LoopBox {
tangent: 0.0,
radial: 0.0,
z: ROOM_HEIGHT - 0.28,
size_tangent: 0.18,
size_radial: OUTER_LOOP_RADIUS - INNER_LOOP_RADIUS + 0.8,
size_z: 0.48,
material: MACHINE,
},
);
let outer = LoopFrame::at(angle, OUTER_LOOP_RADIUS - 0.15);
loop_box(
level,
format!("outer shell pressure housing {rib}"),
outer,
LoopBox {
tangent: 0.0,
radial: 0.0,
z: 3.15,
size_tangent: 0.75,
size_radial: 0.42,
size_z: 1.7,
material: if rib % 4 == 0 { AMBER } else { SHELL },
},
);
}
}
#[derive(Clone, Copy, Debug)]
struct ArcBandSpec {
inner: f32,
outer: f32,
start: f32,
end: f32,
segments: usize,
z: f32,
material: MaterialId,
}
impl ArcBandSpec {
fn new(inner: f32, outer: f32, start: f32, end: f32, z: f32, material: MaterialId) -> Self {
Self {
inner,
outer,
start,
end,
segments: 1,
z,
material,
}
}
fn segments(mut self, segments: usize) -> Self {
self.segments = segments;
self
}
}
#[derive(Clone, Copy, Debug)]
struct ArcWallSpec {
radius: f32,
start: f32,
end: f32,
segments: usize,
z_min: f32,
z_max: f32,
material: MaterialId,
}
impl ArcWallSpec {
fn new(
radius: f32,
start: f32,
end: f32,
z_min: f32,
z_max: f32,
material: MaterialId,
) -> Self {
Self {
radius,
start,
end,
segments: 1,
z_min,
z_max,
material,
}
}
fn segments(mut self, segments: usize) -> Self {
self.segments = segments;
self
}
}
fn append_annular_band(mesh: &mut TriangleMesh, spec: ArcBandSpec) {
let segments = spec.segments.max(1);
for index in 0..segments {
let a0 = spec.start + (spec.end - spec.start) * index as f32 / segments as f32;
let a1 = spec.start + (spec.end - spec.start) * (index + 1) as f32 / segments as f32;
let p0 = polar_point(spec.inner, a0, spec.z);
let p1 = polar_point(spec.outer, a0, spec.z);
let p2 = polar_point(spec.outer, a1, spec.z);
let p3 = polar_point(spec.inner, a1, spec.z);
let normal = if spec.z > ROOM_HEIGHT * 0.5 {
Vec3::NEG_Z
} else {
Vec3::Z
};
mesh.append_quad([p0, p1, p2, p3], normal, spec.material);
}
}
fn append_vertical_arc_wall(mesh: &mut TriangleMesh, spec: ArcWallSpec) {
let segments = spec.segments.max(1);
for index in 0..segments {
let a0 = spec.start + (spec.end - spec.start) * index as f32 / segments as f32;
let a1 = spec.start + (spec.end - spec.start) * (index + 1) as f32 / segments as f32;
let p0 = polar_point(spec.radius, a0, spec.z_min);
let p1 = polar_point(spec.radius, a1, spec.z_min);
let p2 = polar_point(spec.radius, a1, spec.z_max);
let p3 = polar_point(spec.radius, a0, spec.z_max);
let normal = Vec3::new(((a0 + a1) * 0.5).cos(), ((a0 + a1) * 0.5).sin(), 0.0);
mesh.append_quad([p0, p1, p2, p3], normal, spec.material);
}
}
fn polar_point(radius: f32, angle: f32, z: f32) -> Vec3 {
Vec3::new(angle.cos() * radius, angle.sin() * radius, z)
}
fn output_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("experiments")
.join("generated")
.join("industrial_maintenance_loop")
}
fn write_obj_bundle(dir: &Path, name: &str, mesh: &TriangleMesh) -> io::Result<()> {
fs::write(dir.join(format!("{name}.mtl")), material_library())?;
fs::write(dir.join(format!("{name}.obj")), obj_text(name, mesh))?;
Ok(())
}
fn obj_text(name: &str, mesh: &TriangleMesh) -> String {
let mut out = String::new();
let _ = writeln!(out, "mtllib {name}.mtl");
let _ = writeln!(out, "o {name}");
for position in &mesh.positions {
let _ = writeln!(out, "v {} {} {}", position.x, position.y, position.z);
}
for uv in &mesh.uvs {
let _ = writeln!(out, "vt {} {}", uv.x, uv.y);
}
for normal in &mesh.normals {
let _ = writeln!(out, "vn {} {} {}", normal.x, normal.y, normal.z);
}
let mut active_material = None;
for (tri_index, face) in mesh.indices.chunks_exact(3).enumerate() {
let material = mesh
.triangle_materials
.get(tri_index)
.copied()
.unwrap_or_default();
if active_material != Some(material) {
let _ = writeln!(out, "usemtl {}", material_name(material));
active_material = Some(material);
}
let a = face[0] + 1;
let b = face[1] + 1;
let c = face[2] + 1;
let _ = writeln!(out, "f {a}/{a}/{a} {b}/{b}/{b} {c}/{c}/{c}");
}
out
}
fn material_library() -> String {
[
material_text(DECK, "0.21 0.20 0.18", "0.015 0.012 0.010"),
material_text(SHELL, "0.28 0.26 0.22", "0.015 0.012 0.010"),
material_text(DARK, "0.045 0.043 0.040", "0.0 0.0 0.0"),
material_text(MACHINE, "0.18 0.17 0.15", "0.018 0.015 0.012"),
material_text(PIPE, "0.09 0.085 0.075", "0.01 0.008 0.006"),
material_text(AMBER, "0.95 0.48 0.12", "0.70 0.26 0.035"),
material_text(BLUE, "0.08 0.55 0.95", "0.03 0.28 0.60"),
material_text(GLASS, "0.30 0.65 0.90", "0.04 0.20 0.36"),
material_text(HAZARD, "0.85 0.58 0.12", "0.20 0.09 0.02"),
material_text(GEAR, "0.18 0.22 0.24", "0.02 0.025 0.03"),
]
.join("\n")
}
fn material_text(id: MaterialId, kd: &str, ke: &str) -> String {
format!("newmtl {}\nKd {kd}\nKe {ke}\nNs 65\n", material_name(id))
}
fn material_name(id: MaterialId) -> &'static str {
match id {
DECK => "mat_scuffed_deck",
SHELL => "mat_curved_shell",
DARK => "mat_dark_substrate",
MACHINE => "mat_machinery",
PIPE => "mat_pipe_trunks",
AMBER => "mat_amber_screens",
BLUE => "mat_blue_diagnostics",
GLASS => "mat_gallery_glass",
HAZARD => "mat_anchor_hazard",
GEAR => "mat_mobility_gear",
_ => "mat_default",
}
}
fn svg_preview(mesh: &TriangleMesh) -> String {
let width = 1600.0_f32;
let height = 1050.0_f32;
let margin = 34.0_f32;
let mut projected = Vec::with_capacity(mesh.positions.len());
for point in &mesh.positions {
let x = (point.x - point.y) * 0.92;
let y = (point.x + point.y) * 0.38 - point.z * 1.35;
let depth = point.x + point.y + point.z * 0.75;
projected.push((x, y, depth));
}
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
for (x, y, _) in &projected {
min_x = min_x.min(*x);
max_x = max_x.max(*x);
min_y = min_y.min(*y);
max_y = max_y.max(*y);
}
let span_x = (max_x - min_x).max(1.0);
let span_y = (max_y - min_y).max(1.0);
let scale = ((width - margin * 2.0) / span_x).min((height - margin * 2.0) / span_y);
let ox = (width - span_x * scale) * 0.5 - min_x * scale;
let oy = (height - span_y * scale) * 0.5 - min_y * scale;
let mut tris = Vec::with_capacity(mesh.triangle_count());
for (tri_index, face) in mesh.indices.chunks_exact(3).enumerate() {
let a = projected[face[0] as usize];
let b = projected[face[1] as usize];
let c = projected[face[2] as usize];
let depth = (a.2 + b.2 + c.2) / 3.0;
let points = [
(a.0 * scale + ox, a.1 * scale + oy),
(b.0 * scale + ox, b.1 * scale + oy),
(c.0 * scale + ox, c.1 * scale + oy),
];
let material = mesh
.triangle_materials
.get(tri_index)
.copied()
.unwrap_or_default();
tris.push((depth, material, points));
}
tris.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out = String::new();
let _ = writeln!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\">"
);
let _ = writeln!(
out,
"<rect width=\"100%\" height=\"100%\" fill=\"#090908\"/>"
);
let _ = writeln!(
out,
"<g stroke=\"#050504\" stroke-width=\"0.42\" stroke-linejoin=\"round\">"
);
for (_, material, points) in tris {
let _ = writeln!(
out,
"<polygon points=\"{:.2},{:.2} {:.2},{:.2} {:.2},{:.2}\" fill=\"{}\" opacity=\"{}\"/>",
points[0].0,
points[0].1,
points[1].0,
points[1].1,
points[2].0,
points[2].1,
svg_fill(material),
svg_opacity(material),
);
}
let _ = writeln!(out, "</g>");
let _ = writeln!(out, "</svg>");
out
}
fn svg_fill(id: MaterialId) -> &'static str {
match id {
DECK => "#35322d",
SHELL => "#474239",
DARK => "#10100f",
MACHINE => "#2c2924",
PIPE => "#181612",
AMBER => "#e88b23",
BLUE => "#1796dd",
GLASS => "#68b9df",
HAZARD => "#d79827",
GEAR => "#29363b",
_ => "#808080",
}
}
fn svg_opacity(id: MaterialId) -> &'static str {
match id {
SHELL => "0.28",
DECK => "0.84",
DARK => "0.72",
GLASS => "0.36",
AMBER | BLUE => "0.92",
_ => "1.0",
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,369 +0,0 @@
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, Eq, Hash)]
pub enum PolygonCategory {
Inside,
Outside,
Aligned,
ReverseAligned,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Aabb {
pub min: Vec3,
pub max: Vec3,
}
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 {
min: center - half,
max: center + half,
}
}
pub fn new(min: Vec3, max: Vec3) -> Self {
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
}
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 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
&& 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 intersects_any(self, others: impl IntoIterator<Item = Self>) -> bool {
others.into_iter().any(|other| self.intersects(other))
}
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,
},
OrientedBox {
center: Vec3,
size: Vec3,
rotation: Quat,
},
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,
}
}
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,
])
}

View file

@ -1,904 +0,0 @@
use bevy_math::{Quat, Vec2, Vec3};
use crate::{Aabb, MaterialId, PolygonCategory, TriangleMesh};
const EPSILON: f32 = 1.0e-5;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Plane {
pub normal: Vec3,
pub distance: f32,
}
impl Plane {
pub fn from_point_normal(point: Vec3, normal: Vec3) -> Self {
let normal = normal.normalize_or_zero();
Self {
normal,
distance: -normal.dot(point),
}
}
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, PartialEq)]
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, PartialEq)]
pub struct ConvexSolid {
pub polygons: Vec<ConvexPolygon>,
pub clip_planes: Vec<Plane>,
pub material: MaterialId,
pub bounds: Aabb,
}
#[derive(Clone, Debug, Default)]
pub struct CategorizedPolygons {
pub inside: Vec<ConvexPolygon>,
pub outside: Vec<ConvexPolygon>,
pub aligned: Vec<ConvexPolygon>,
pub reverse_aligned: Vec<ConvexPolygon>,
}
#[derive(Default)]
pub struct PolygonRouteScratch {
pieces: Vec<CategorizationPiece>,
inside_pieces: Vec<CategorizationPiece>,
cap_points: Vec<Vec3>,
}
impl ConvexSolid {
pub fn from_aabb(bounds: Aabb, material: MaterialId) -> Self {
Self::box_from_center_size(bounds.center(), bounds.size(), Quat::IDENTITY, material)
}
pub fn box_from_center_size(
center: Vec3,
size: Vec3,
rotation: Quat,
material: MaterialId,
) -> Self {
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);
let faces = [
([0, 3, 2, 1], Vec3::NEG_Z),
([4, 5, 6, 7], Vec3::Z),
([0, 1, 5, 4], Vec3::NEG_Y),
([3, 7, 6, 2], Vec3::Y),
([0, 4, 7, 3], Vec3::NEG_X),
([1, 2, 6, 5], Vec3::X),
];
let mut polygons = Vec::with_capacity(6);
let mut clip_planes = Vec::with_capacity(6);
for (indices, local_normal) in faces {
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::new(vertices, normal, material));
}
Self {
bounds: Aabb::from_points(&corners),
polygons,
clip_planes,
material,
}
}
pub fn subtract_convex(&self, cutter: &Self) -> Vec<Self> {
self.clone().subtract_convex_owned(cutter)
}
pub fn subtract_convex_owned(self, cutter: &Self) -> Vec<Self> {
let mut fragments = Vec::new();
let mut remainder = Some(self);
for plane in &cutter.clip_planes {
let Some(current) = remainder.take() else {
break;
};
match current.split_owned(*plane) {
SplitResult::Front(front) => {
fragments.push(front);
break;
}
SplitResult::Back(back) => {
remainder = Some(back);
}
SplitResult::Both { front, back } => {
fragments.push(front);
remainder = Some(back);
}
SplitResult::Coplanar(solid) => {
remainder = Some(solid);
}
}
}
fragments
}
pub fn intersect_convex(&self, cutter: &Self) -> Option<Self> {
self.clone().intersect_convex_owned(cutter)
}
pub fn intersect_convex_owned(self, cutter: &Self) -> Option<Self> {
if cutter.contains_solid(&self) {
return Some(self);
}
if self.contains_solid(cutter) {
return Some(cutter.clone_with_material(self.material));
}
let mut remainder = self;
for plane in &cutter.clip_planes {
remainder = match remainder.split_owned(*plane) {
SplitResult::Front(_) => return None,
SplitResult::Back(back) | SplitResult::Coplanar(back) => back,
SplitResult::Both { back, .. } => back,
};
}
Some(remainder)
}
pub fn contains_point(&self, point: Vec3) -> bool {
self.clip_planes
.iter()
.all(|plane| plane.signed_distance(point) <= EPSILON)
}
pub fn contains_solid(&self, other: &Self) -> bool {
other
.polygons
.iter()
.flat_map(|polygon| polygon.vertices.iter())
.all(|point| self.contains_point(*point))
}
pub fn clone_with_material(&self, material: MaterialId) -> Self {
let mut solid = self.clone();
solid.material = material;
for polygon in &mut solid.polygons {
polygon.material = material;
}
solid
}
pub fn append_to_mesh(&self, mesh: &mut TriangleMesh) {
for polygon in &self.polygons {
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(
triangle,
normal,
[Vec2::ZERO, Vec2::X, Vec2::Y],
polygon.material,
);
}
}
}
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;
}
}
}
pub fn categorize_polygons_against(&self, cutter: &Self) -> CategorizedPolygons {
PolygonRouteScratch::default().categorize_against(&self.polygons, cutter)
}
pub fn route_polygons_outside_of(
polygons: Vec<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter);
categorized.outside
}
pub fn route_polygons_inside_of(
polygons: Vec<ConvexPolygon>,
cutter: &Self,
) -> Vec<ConvexPolygon> {
let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter);
let mut inside = categorized.inside;
inside.extend(categorized.aligned);
inside.extend(categorized.reverse_aligned);
inside
}
pub fn subtract_convex_routed_polygons(&self, cutter: &Self) -> Vec<ConvexPolygon> {
let mut polygons = Vec::new();
let source = self.categorize_polygons_against(cutter);
polygons.extend(source.outside);
let cutter_inside_source = cutter.categorize_polygons_against(self);
for mut polygon in cutter_inside_source.inside {
polygon.reversed = !polygon.reversed;
polygons.push(polygon);
}
for mut polygon in cutter_inside_source.aligned {
polygon.reversed = !polygon.reversed;
polygons.push(polygon);
}
for mut polygon in cutter_inside_source.reverse_aligned {
polygon.reversed = !polygon.reversed;
polygons.push(polygon);
}
polygons
}
pub fn append_polygons_to_mesh(polygons: &[ConvexPolygon], mesh: &mut TriangleMesh) {
for polygon in polygons {
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(
triangle,
normal,
[Vec2::ZERO, Vec2::X, Vec2::Y],
polygon.material,
);
}
}
}
fn split_owned(self, plane: Plane) -> SplitResult {
let mut saw_front = false;
let mut saw_back = false;
for polygon in &self.polygons {
for vertex in &polygon.vertices {
let distance = plane.signed_distance(*vertex);
saw_front |= distance > EPSILON;
saw_back |= distance < -EPSILON;
}
}
match (saw_front, saw_back) {
(true, false) => return SplitResult::Front(self),
(false, true) => return SplitResult::Back(self),
(false, false) => return SplitResult::Coplanar(self),
(true, true) => {}
}
let mut front_polygons = Vec::with_capacity(self.polygons.len() + 1);
let mut back_polygons = Vec::with_capacity(self.polygons.len() + 1);
let mut cap_points = Vec::with_capacity(self.polygons.len() * 2);
for polygon in &self.polygons {
let front = clip_polygon(&polygon.vertices, plane, KeepSide::Front, &mut cap_points);
if front.len() >= 3 {
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(polygon.with_vertices(back));
}
}
let cap_points = unique_points_on_plane(cap_points);
if cap_points.len() >= 3 {
front_polygons.push(make_cap_polygon(&cap_points, -plane.normal, self.material));
back_polygons.push(make_cap_polygon(&cap_points, plane.normal, self.material));
}
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,
},
}
}
}
impl PolygonRouteScratch {
pub fn categorize_against(
&mut self,
polygons: &[ConvexPolygon],
cutter: &ConvexSolid,
) -> CategorizedPolygons {
let mut categorized = CategorizedPolygons::default();
for polygon in polygons {
self.pieces.clear();
self.pieces.push(CategorizationPiece {
polygon: polygon.clone(),
aligned_category: None,
});
for plane in &cutter.clip_planes {
self.inside_pieces.clear();
for piece in self.pieces.drain(..) {
classify_piece_against_plane(
piece,
*plane,
&mut self.inside_pieces,
&mut categorized,
&mut self.cap_points,
);
}
std::mem::swap(&mut self.pieces, &mut self.inside_pieces);
}
for piece in self.pieces.drain(..) {
match piece.aligned_category {
Some(PolygonCategory::Aligned) => categorized.aligned.push(piece.polygon),
Some(PolygonCategory::ReverseAligned) => {
categorized.reverse_aligned.push(piece.polygon);
}
_ => categorized.inside.push(piece.polygon),
}
}
}
categorized
}
pub fn route_outside_of(
&mut self,
polygons: Vec<ConvexPolygon>,
cutter: &ConvexSolid,
) -> Vec<ConvexPolygon> {
self.categorize_against(&polygons, cutter).outside
}
pub fn route_inside_of(
&mut self,
polygons: Vec<ConvexPolygon>,
cutter: &ConvexSolid,
) -> Vec<ConvexPolygon> {
let categorized = self.categorize_against(&polygons, cutter);
let mut inside = categorized.inside;
inside.extend(categorized.aligned);
inside.extend(categorized.reverse_aligned);
inside
}
}
enum SplitResult {
Front(ConvexSolid),
Back(ConvexSolid),
Both {
front: ConvexSolid,
back: ConvexSolid,
},
Coplanar(ConvexSolid),
}
struct CategorizationPiece {
polygon: ConvexPolygon,
aligned_category: Option<PolygonCategory>,
}
#[derive(Clone, Copy)]
enum KeepSide {
Front,
Back,
}
fn classify_piece_against_plane(
mut piece: CategorizationPiece,
plane: Plane,
inside_pieces: &mut Vec<CategorizationPiece>,
categorized: &mut CategorizedPolygons,
cap_points: &mut Vec<Vec3>,
) {
let piece_plane = piece
.polygon
.vertices
.first()
.map(|point| Plane::from_point_normal(*point, piece.polygon.normal));
if piece_plane.is_some_and(|piece_plane| piece_plane.is_coplanar_with(plane)) {
piece.aligned_category = Some(if piece.polygon.normal.dot(plane.normal) >= 0.0 {
PolygonCategory::Aligned
} else {
PolygonCategory::ReverseAligned
});
inside_pieces.push(piece);
return;
}
let mut saw_front = false;
let mut saw_back = false;
for vertex in &piece.polygon.vertices {
let distance = plane.signed_distance(*vertex);
saw_front |= distance > EPSILON;
saw_back |= distance < -EPSILON;
}
match (saw_front, saw_back) {
(true, false) => {
let mut polygon = piece.polygon;
polygon.category = PolygonCategory::Outside;
categorized.outside.push(polygon);
}
(false, true) | (false, false) => inside_pieces.push(piece),
(true, true) => {
cap_points.clear();
let outside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Front, cap_points);
if outside.len() >= 3 {
let mut outside_polygon = piece.polygon.with_vertices(outside);
outside_polygon.category = PolygonCategory::Outside;
categorized.outside.push(outside_polygon);
}
let inside = clip_polygon(&piece.polygon.vertices, plane, KeepSide::Back, cap_points);
if inside.len() >= 3 {
inside_pieces.push(CategorizationPiece {
polygon: piece.polygon.with_vertices(inside),
aligned_category: piece.aligned_category,
});
}
}
}
}
fn clip_polygon(
vertices: &[Vec3],
plane: Plane,
keep: KeepSide,
cap_points: &mut Vec<Vec3>,
) -> Vec<Vec3> {
let mut output = Vec::with_capacity(vertices.len() + 1);
if vertices.is_empty() {
return output;
}
let mut previous = *vertices.last().expect("checked non-empty");
let mut previous_distance = plane.signed_distance(previous);
let mut previous_inside = inside(previous_distance, keep);
for &current in vertices {
let current_distance = plane.signed_distance(current);
let current_inside = inside(current_distance, keep);
if current_inside != previous_inside {
let t = previous_distance / (previous_distance - current_distance);
let intersection = previous + (current - previous) * t;
output.push(intersection);
cap_points.push(intersection);
}
if current_inside {
output.push(current);
}
previous = current;
previous_distance = current_distance;
previous_inside = current_inside;
}
output
}
fn inside(distance: f32, keep: KeepSide) -> bool {
match keep {
KeepSide::Front => distance >= -EPSILON,
KeepSide::Back => distance <= EPSILON,
}
}
fn make_cap_polygon(points: &[Vec3], normal: Vec3, material: MaterialId) -> ConvexPolygon {
let center = points.iter().copied().sum::<Vec3>() / points.len() as f32;
let axis = if normal.z.abs() < 0.9 {
Vec3::Z
} else {
Vec3::Y
};
let tangent = normal.cross(axis).normalize_or_zero();
let bitangent = normal.cross(tangent).normalize_or_zero();
let mut vertices = points.to_vec();
vertices.sort_by(|a, b| {
let da = *a - center;
let db = *b - center;
let aa = da.dot(bitangent).atan2(da.dot(tangent));
let ab = db.dot(bitangent).atan2(db.dot(tangent));
aa.total_cmp(&ab)
});
if polygon_normal(&vertices).dot(normal) < 0.0 {
vertices.reverse();
}
ConvexPolygon::new(vertices, normal, material)
}
fn unique_points_on_plane(points: Vec<Vec3>) -> Vec<Vec3> {
let mut unique = Vec::new();
'outer: for point in points {
for seen in &unique {
let delta: Vec3 = point - *seen;
if delta.length_squared() < EPSILON * EPSILON {
continue 'outer;
}
}
unique.push(point);
}
unique
}
fn planes_from_polygons(polygons: &[ConvexPolygon]) -> Vec<Plane> {
polygons
.iter()
.filter_map(|polygon| {
polygon
.vertices
.first()
.map(|point| Plane::from_point_normal(*point, polygon.normal))
})
.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;
}
(vertices[1] - vertices[0])
.cross(vertices[2] - vertices[0])
.normalize_or_zero()
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_box_parity(solid: &ConvexSolid, material: MaterialId) {
assert_eq!(solid.polygons.len(), 6);
assert_eq!(solid.clip_planes.len(), 6);
assert!(
solid
.polygons
.iter()
.all(|polygon| polygon.vertices.len() == 4)
);
assert!(
solid
.polygons
.iter()
.all(|polygon| polygon.material == material)
);
}
#[test]
fn parity_box_brush_from_planes_has_six_quad_polygons() {
let solid = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(9),
);
assert_box_parity(&solid, MaterialId(9));
}
#[test]
fn subtract_center_box_emits_six_convex_fragments() {
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::ZERO,
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(0),
);
let fragments = source.subtract_convex(&cutter);
assert_eq!(fragments.len(), 6);
assert!(
fragments
.iter()
.all(|fragment| !fragment.polygons.is_empty())
);
}
#[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 categorize_crossing_coplanar_face_splits_visible_categories() {
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::new(0.0, 0.0, 1.0),
Vec3::new(2.0, 2.0, 2.0),
Quat::IDENTITY,
MaterialId(0),
);
let categorized = source.categorize_polygons_against(&cutter);
assert_eq!(categorized.aligned.len(), 1);
assert!(categorized.outside.len() > 6);
assert!(categorized.inside.is_empty());
assert!(categorized.reverse_aligned.is_empty());
assert!(
categorized
.aligned
.iter()
.all(|polygon| polygon.category == PolygonCategory::Aligned)
);
assert!(
categorized
.outside
.iter()
.all(|polygon| polygon.category == PolygonCategory::Outside)
);
}
#[test]
fn subtract_rotated_box_creates_non_axis_aligned_faces() {
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::ZERO,
Vec3::new(2.0, 5.0, 2.0),
Quat::from_rotation_z(std::f32::consts::FRAC_PI_4),
MaterialId(0),
);
let fragments = source.subtract_convex(&cutter);
assert!(fragments.len() > 2);
assert!(fragments.iter().any(|fragment| {
fragment
.polygons
.iter()
.any(|polygon| polygon.normal.x.abs() > 0.01 && polygon.normal.y.abs() > 0.01)
}));
}
#[test]
fn routed_subtraction_emits_source_shell_and_reversed_cutter_shell() {
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::ZERO,
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(0),
);
let routed = source.subtract_convex_routed_polygons(&cutter);
assert_eq!(routed.len(), 24);
assert_eq!(
routed
.iter()
.filter(|polygon| polygon.material == MaterialId(1))
.count(),
18
);
assert_eq!(
routed
.iter()
.filter(|polygon| polygon.material == MaterialId(0))
.count(),
6
);
assert_eq!(
routed
.iter()
.filter(|polygon| polygon.material == MaterialId(0) && polygon.reversed)
.count(),
6
);
}
#[test]
fn routed_subtraction_clips_crossing_cutter_surfaces_to_source() {
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::new(1.5, 0.0, 0.0),
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(0),
);
let routed = source.subtract_convex_routed_polygons(&cutter);
assert!(!routed.is_empty());
assert!(
routed
.iter()
.flat_map(|polygon| polygon.vertices.iter())
.all(|vertex| source.bounds.contains_point(*vertex))
);
assert!(
routed
.iter()
.any(|polygon| polygon.material == MaterialId(0) && polygon.reversed)
);
}
}

View file

@ -1,163 +0,0 @@
use bevy_math::{Quat, 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 solid_oriented_box(
&mut self,
name: impl Into<String>,
center: Vec3,
size: Vec3,
rotation: Quat,
material: MaterialId,
) -> BrushId {
self.assembler
.solid_oriented_box(name, center, size, rotation, material)
}
pub fn cut_oriented_box(
&mut self,
name: impl Into<String>,
center: Vec3,
size: Vec3,
rotation: Quat,
) -> BrushId {
self.assembler
.cut_oriented_box(name, center, size, rotation)
}
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());
}
}

View file

@ -1,320 +0,0 @@
use crate::{Brush, BrushOp};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DemandPair {
pub source_index: usize,
pub operator_index: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DemandFrontier {
pub pairs: Vec<DemandPair>,
pub rejected_pairs: usize,
pub operator_brushes: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DirtyDemandFrontier {
pub first_dirty_index: Option<usize>,
pub pairs: Vec<DemandPair>,
pub rejected_pairs: usize,
pub operator_brushes: usize,
pub skipped_prefix_pairs: usize,
}
impl DemandFrontier {
pub fn from_ordered_brushes(brushes: &[Brush]) -> Self {
let mut frontier = Self::default();
let mut active_sources = Vec::<usize>::new();
for (operator_index, brush) in brushes.iter().enumerate() {
match brush.op {
BrushOp::Add => active_sources.push(operator_index),
BrushOp::Subtract => {
frontier.operator_brushes += 1;
frontier.collect_pairs(brushes, &active_sources, operator_index);
}
BrushOp::Intersect => {
frontier.operator_brushes += 1;
let retained = frontier.collect_pairs(brushes, &active_sources, operator_index);
active_sources = retained;
}
}
}
frontier
}
pub fn candidate_pairs(&self) -> usize {
self.pairs.len()
}
fn collect_pairs(
&mut self,
brushes: &[Brush],
active_sources: &[usize],
operator_index: usize,
) -> Vec<usize> {
let operator_bounds = brushes[operator_index].bounds();
let mut retained = Vec::new();
for &source_index in active_sources {
if brushes[source_index].bounds().intersects(operator_bounds) {
self.pairs.push(DemandPair {
source_index,
operator_index,
});
retained.push(source_index);
} else {
self.rejected_pairs += 1;
}
}
retained
}
}
impl DirtyDemandFrontier {
pub fn from_dirty_indices(
brushes: &[Brush],
dirty_indices: impl IntoIterator<Item = usize>,
) -> Self {
let first_dirty_index = dirty_indices
.into_iter()
.filter(|index| *index < brushes.len())
.min();
let Some(first_dirty_index) = first_dirty_index else {
return Self::default();
};
let mut frontier = Self {
first_dirty_index: Some(first_dirty_index),
..Self::default()
};
let mut active_sources = Vec::<usize>::new();
for (operator_index, brush) in brushes.iter().enumerate() {
match brush.op {
BrushOp::Add => active_sources.push(operator_index),
BrushOp::Subtract => {
frontier.operator_brushes += usize::from(operator_index >= first_dirty_index);
frontier.collect_pairs(
brushes,
&active_sources,
operator_index,
first_dirty_index,
);
}
BrushOp::Intersect => {
frontier.operator_brushes += usize::from(operator_index >= first_dirty_index);
let retained = frontier.collect_pairs(
brushes,
&active_sources,
operator_index,
first_dirty_index,
);
active_sources = retained;
}
}
}
frontier
}
pub fn candidate_pairs(&self) -> usize {
self.pairs.len()
}
fn collect_pairs(
&mut self,
brushes: &[Brush],
active_sources: &[usize],
operator_index: usize,
first_dirty_index: usize,
) -> Vec<usize> {
let operator_bounds = brushes[operator_index].bounds();
let mut retained = Vec::new();
for &source_index in active_sources {
if brushes[source_index].bounds().intersects(operator_bounds) {
if operator_index >= first_dirty_index {
self.pairs.push(DemandPair {
source_index,
operator_index,
});
} else {
self.skipped_prefix_pairs += 1;
}
retained.push(source_index);
} else if operator_index >= first_dirty_index {
self.rejected_pairs += 1;
} else {
self.skipped_prefix_pairs += 1;
}
}
retained
}
}
#[cfg(test)]
mod tests {
use bevy_math::{Quat, Vec3};
use super::*;
use crate::{Aabb, Brush, MaterialId, Primitive};
fn brush(name: &str, op: BrushOp, bounds: Aabb) -> Brush {
Brush::new(
crate::BrushId(0),
name,
op,
Primitive::Box { bounds },
MaterialId(1),
)
}
#[test]
fn frontier_keeps_only_touching_ordered_pairs() {
let brushes = vec![
brush(
"source",
BrushOp::Add,
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
),
brush(
"far cutter",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::splat(20.0), Vec3::ONE),
),
brush(
"near cutter",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::ZERO, Vec3::ONE),
),
];
let frontier = DemandFrontier::from_ordered_brushes(&brushes);
assert_eq!(frontier.operator_brushes, 2);
assert_eq!(frontier.rejected_pairs, 1);
assert_eq!(
frontier.pairs,
vec![DemandPair {
source_index: 0,
operator_index: 2,
}]
);
}
#[test]
fn intersection_frontier_reduces_active_sources() {
let brushes = vec![
brush(
"a",
BrushOp::Add,
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
),
brush(
"b",
BrushOp::Add,
Aabb::from_center_size(Vec3::new(20.0, 0.0, 0.0), Vec3::splat(2.0)),
),
Brush::new(
crate::BrushId(2),
"common",
BrushOp::Intersect,
Primitive::OrientedBox {
center: Vec3::ZERO,
size: Vec3::splat(3.0),
rotation: Quat::IDENTITY,
},
MaterialId(1),
),
brush(
"cut",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::ZERO, Vec3::ONE),
),
];
let frontier = DemandFrontier::from_ordered_brushes(&brushes);
assert_eq!(frontier.operator_brushes, 2);
assert_eq!(frontier.rejected_pairs, 1);
assert_eq!(
frontier.pairs,
vec![
DemandPair {
source_index: 0,
operator_index: 2,
},
DemandPair {
source_index: 0,
operator_index: 3,
},
]
);
}
#[test]
fn dirty_frontier_preserves_prefix_before_first_dirty_brush() {
let brushes = vec![
brush(
"source",
BrushOp::Add,
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)),
),
brush(
"early",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)),
),
brush(
"dirty",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
),
brush(
"later",
BrushOp::Subtract,
Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)),
),
];
let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [2]);
assert_eq!(frontier.first_dirty_index, Some(2));
assert_eq!(frontier.operator_brushes, 2);
assert_eq!(frontier.skipped_prefix_pairs, 1);
assert_eq!(frontier.candidate_pairs(), 2);
assert_eq!(
frontier.pairs,
vec![
DemandPair {
source_index: 0,
operator_index: 2,
},
DemandPair {
source_index: 0,
operator_index: 3,
},
]
);
}
#[test]
fn dirty_frontier_ignores_out_of_range_dirty_indices() {
let brushes = vec![brush(
"source",
BrushOp::Add,
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
)];
let frontier = DirtyDemandFrontier::from_dirty_indices(&brushes, [99]);
assert_eq!(frontier.first_dirty_index, None);
assert_eq!(frontier.candidate_pairs(), 0);
assert_eq!(frontier.operator_brushes, 0);
}
}

View file

@ -1,31 +0,0 @@
//! Lean constructive geometry tools for VibeGeometry.
//!
//! This crate borrows RealtimeCSG's useful public shape: ordered brushes,
//! operation types, dirtied generations, prefix checkpoints, and rebuildable
//! output meshes. 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 convex;
mod dsl;
mod frontier;
mod mesh;
mod primitives;
mod tree;
pub use assembler::{Assembler, BuildOutput, BuildReport, BuildWarning};
pub use brush::{Aabb, Brush, BrushId, BrushOp, MaterialId, PolygonCategory, Primitive};
pub use convex::{CategorizedPolygons, ConvexPolygon, ConvexSolid, Plane, PolygonRouteScratch};
pub use dsl::LevelDsl;
pub use frontier::{DemandFrontier, DemandPair, DirtyDemandFrontier};
pub use mesh::TriangleMesh;
pub use primitives::{
DomeCapZSpec, FloretArmSpec, append_cylinder_z, append_dome_cap_z, append_floret_arm,
};
pub use tree::{
CsgBranchOp, CsgNode, CsgNodeId, CsgOperationType, CsgTree, CsgTreeArena, CsgTreeBranch,
CsgTreeBrush,
};

View file

@ -1,102 +0,0 @@
use bevy_math::{Vec2, Vec3};
use crate::{Aabb, MaterialId};
#[derive(Clone, Debug, Default, PartialEq)]
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);
}
}

View file

@ -1,172 +0,0 @@
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
}

View file

@ -1,401 +0,0 @@
use crate::{Aabb, Assembler, Brush, BrushId, BrushOp, MaterialId, Primitive};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgNodeId(pub u32);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgTreeBrush {
pub node: CsgNodeId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgTreeBranch {
pub node: CsgNodeId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CsgTree {
pub root: CsgNodeId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CsgOperationType {
Additive = 0,
Subtractive = 1,
Intersecting = 2,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CsgBranchOp {
Addition,
Subtraction,
Common,
}
#[derive(Clone, Debug)]
pub enum CsgNode {
Brush(Brush),
Branch {
name: String,
op: CsgBranchOp,
children: Vec<CsgNodeId>,
},
}
#[derive(Clone, Debug, Default)]
pub struct CsgTreeArena {
nodes: Vec<CsgNode>,
}
impl CsgTreeArena {
pub fn new() -> Self {
Self::default()
}
pub fn nodes(&self) -> &[CsgNode] {
&self.nodes
}
pub fn node(&self, id: CsgNodeId) -> Option<&CsgNode> {
self.nodes.get(id.0 as usize)
}
pub fn node_mut(&mut self, id: CsgNodeId) -> Option<&mut CsgNode> {
self.nodes.get_mut(id.0 as usize)
}
pub fn brush_count(&self) -> usize {
self.nodes
.iter()
.filter(|node| matches!(node, CsgNode::Brush(_)))
.count()
}
pub fn branch_count(&self) -> usize {
self.nodes
.iter()
.filter(|node| matches!(node, CsgNode::Branch { .. }))
.count()
}
pub fn generate_brush(
&mut self,
name: impl Into<String>,
operation: CsgOperationType,
primitive: Primitive,
material: MaterialId,
) -> CsgTreeBrush {
let id = CsgNodeId(self.nodes.len() as u32);
let brush = Brush::new(
BrushId(id.0),
name,
operation.into_brush_op(),
primitive,
material,
);
self.nodes.push(CsgNode::Brush(brush));
CsgTreeBrush { node: id }
}
pub fn generate_branch(
&mut self,
name: impl Into<String>,
op: CsgBranchOp,
children: impl IntoIterator<Item = CsgNodeId>,
) -> CsgTreeBranch {
let id = CsgNodeId(self.nodes.len() as u32);
self.nodes.push(CsgNode::Branch {
name: name.into(),
op,
children: children.into_iter().collect(),
});
CsgTreeBranch { node: id }
}
pub fn generate_tree(&self, root: CsgNodeId) -> CsgTree {
CsgTree { root }
}
pub fn set_brush_operation_type(
&mut self,
brush: CsgTreeBrush,
operation: CsgOperationType,
) -> bool {
let Some(CsgNode::Brush(node)) = self.node_mut(brush.node) else {
return false;
};
node.op = operation.into_brush_op();
node.set_dirty();
true
}
pub fn set_branch_operation_type(&mut self, branch: CsgTreeBranch, op: CsgBranchOp) -> bool {
let Some(CsgNode::Branch { op: current_op, .. }) = self.node_mut(branch.node) else {
return false;
};
*current_op = op;
true
}
pub fn set_child_nodes(
&mut self,
branch: CsgTreeBranch,
children: impl IntoIterator<Item = CsgNodeId>,
) -> bool {
let Some(CsgNode::Branch {
children: current_children,
..
}) = self.node_mut(branch.node)
else {
return false;
};
*current_children = children.into_iter().collect();
true
}
pub fn child_nodes(&self, branch: CsgTreeBranch) -> Option<&[CsgNodeId]> {
let Some(CsgNode::Branch { children, .. }) = self.node(branch.node) else {
return None;
};
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);
assembler
}
fn compile_node(
&self,
id: CsgNodeId,
assembler: &mut Assembler,
operation_override: Option<CsgOperationType>,
) {
let Some(node) = self.node(id) else {
return;
};
match node {
CsgNode::Brush(brush) => {
let op = operation_override
.map(CsgOperationType::into_brush_op)
.unwrap_or(brush.op);
assembler.add_brush(
brush.name.clone(),
op,
brush.primitive.clone(),
brush.material,
);
}
CsgNode::Branch { op, children, .. } => match op {
CsgBranchOp::Addition => {
for child in children {
self.compile_node(*child, assembler, operation_override);
}
}
CsgBranchOp::Subtraction => {
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::Subtractive),
);
}
}
}
CsgBranchOp::Common => {
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 {
pub fn into_brush_op(self) -> BrushOp {
match self {
Self::Additive => BrushOp::Add,
Self::Subtractive => BrushOp::Subtract,
Self::Intersecting => BrushOp::Intersect,
}
}
}
impl From<BrushOp> for CsgOperationType {
fn from(value: BrushOp) -> Self {
match value {
BrushOp::Add => Self::Additive,
BrushOp::Subtract => Self::Subtractive,
BrushOp::Intersect => Self::Intersecting,
}
}
}
#[cfg(test)]
mod tests {
use bevy_math::Vec3;
use super::*;
use crate::{Aabb, Primitive};
#[test]
fn tree_subtraction_compiles_to_ordered_brush_stream() {
let mut arena = CsgTreeArena::new();
let solid = arena.generate_brush(
"solid",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
},
MaterialId(1),
);
let void = arena.generate_brush(
"void",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
},
MaterialId(0),
);
let branch = arena.generate_branch(
"solid minus void",
CsgBranchOp::Subtraction,
[solid.node, void.node],
);
let assembler = arena.compile_tree_to_assembler(arena.generate_tree(branch.node));
assert_eq!(assembler.brushes().len(), 2);
assert_eq!(assembler.brushes()[0].op, BrushOp::Add);
assert_eq!(assembler.brushes()[1].op, BrushOp::Subtract);
assert_eq!(assembler.build().report.emitted_convex_fragments, 6);
}
#[test]
fn tree_edit_api_replaces_children_and_operations() {
let mut arena = CsgTreeArena::new();
let a = arena.generate_brush(
"a",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)),
},
MaterialId(1),
);
let b = arena.generate_brush(
"b",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(1.0)),
},
MaterialId(1),
);
let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [a.node]);
assert!(arena.set_child_nodes(branch, [a.node, b.node]));
assert_eq!(arena.child_nodes(branch).expect("branch").len(), 2);
assert!(arena.set_branch_operation_type(branch, CsgBranchOp::Subtraction));
assert!(arena.set_brush_operation_type(b, CsgOperationType::Subtractive));
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);
}
}

View file

@ -1,166 +0,0 @@
use bevy_math::{Quat, Vec3};
use vg_csg::{
Aabb, Assembler, ConvexSolid, CsgBranchOp, CsgOperationType, CsgTreeArena, MaterialId,
PolygonCategory, Primitive,
};
#[test]
fn parity_polygon_category_vocabulary_matches_public_demo() {
let categories = [
PolygonCategory::Inside,
PolygonCategory::Outside,
PolygonCategory::Aligned,
PolygonCategory::ReverseAligned,
];
assert_eq!(categories.len(), 4);
}
#[test]
fn parity_operation_type_ordinals_match_public_api() {
assert_eq!(CsgOperationType::Additive as u8, 0);
assert_eq!(CsgOperationType::Subtractive as u8, 1);
assert_eq!(CsgOperationType::Intersecting as u8, 2);
}
#[test]
fn parity_demo_branch_vocabulary_matches_public_demo() {
let branches = [
CsgBranchOp::Addition,
CsgBranchOp::Subtraction,
CsgBranchOp::Common,
];
assert_eq!(branches.len(), 3);
}
#[test]
fn parity_tree_api_exposes_brush_branch_tree_handles() {
let mut arena = CsgTreeArena::new();
let brush = arena.generate_brush(
"brush",
CsgOperationType::Additive,
Primitive::Box {
bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::ONE),
},
MaterialId(1),
);
let branch = arena.generate_branch("branch", CsgBranchOp::Addition, [brush.node]);
let tree = arena.generate_tree(branch.node);
assert_eq!(arena.brush_count(), 1);
assert_eq!(arena.branch_count(), 1);
assert_eq!(arena.child_nodes(branch).expect("branch"), &[brush.node]);
assert_eq!(tree.root, branch.node);
}
#[test]
fn parity_box_brush_observable_counts_match_realtime_csg_demo() {
let solid = ConvexSolid::box_from_center_size(
Vec3::ZERO,
Vec3::splat(2.0),
Quat::IDENTITY,
MaterialId(3),
);
assert_eq!(solid.clip_planes.len(), 6);
assert_eq!(solid.polygons.len(), 6);
assert_eq!(
solid
.polygons
.iter()
.map(|polygon| polygon.vertices.len())
.sum::<usize>(),
24
);
}
#[test]
fn parity_center_box_subtraction_has_six_surviving_regions() {
let mut asm = Assembler::new();
asm.solid_box(
"source",
Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)),
MaterialId(1),
);
asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)));
let output = asm.build();
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)
);
}
#[test]
fn parity_crossing_polygons_split_before_final_category() {
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::new(0.0, 0.0, 1.0),
Vec3::new(2.0, 2.0, 2.0),
Quat::IDENTITY,
MaterialId(0),
);
let categorized = source.categorize_polygons_against(&cutter);
assert_eq!(categorized.aligned.len(), 1);
assert!(categorized.outside.len() > source.polygons.len());
assert_eq!(categorized.inside.len(), 0);
assert_eq!(categorized.reverse_aligned.len(), 0);
}

View file

@ -39,9 +39,10 @@ tables, loops, variants, cleanup, validation, docs, and scene/render
orchestration; Geometry Script emits inspectable Geometry Nodes groups; `bpy`
handles Blender scene machinery and evaluated evidence.
`crates/vg_csg` is the first Rust/Bevy-side interactive geometry organ.
It has also been extracted into the public standalone repo
`https://github.com/GameCult/vg-csg` for independent development and sharing.
`crates/vg_csg` is the first Rust/Bevy-side interactive geometry organ. In
VibeGeometry it is now a git submodule pointing at
`https://github.com/GameCult/vg-csg`, kept at the same path so workspace and
`vg_grammar` path dependencies still resolve.
That repo's `main` was rewritten from filtered VibeGeometry history rather than
a synthetic import, preserving the CSG crate, docs, native bridge, and perf
harness commit chain with one standalone packaging commit on top.

View file

@ -76,3 +76,4 @@
{"ts": "2026-05-07T17:36:47+00:00", "type": "implementation", "status": "ok", "note": "Added industrial_maintenance_loop Rust CSG fixture from grounded reference prompt. Durable DSL lesson: curved habitat room details should be authored in local tangent/radial/up frames per domain, then compiled into world brushes or direct mesh bands."}
{"ts": "2026-05-07T21:41:02+00:00", "type": "infrastructure", "status": "ok", "note": "Extracted crates/vg_csg into public standalone repo GameCult/vg-csg using gh. Standalone crate includes source, tests, examples, CSG docs, README, MIT license, and Cargo.lock; cargo test and clippy passed before publication."}
{"ts": "2026-05-07T21:53:22+00:00", "type": "infrastructure", "status": "revised", "note": "Rewrote GameCult/vg-csg public history from a filtered VibeGeometry CSG extraction instead of the first synthetic import. History now preserves crate, CSG docs, native bridge, and benchmark harness commits, with one standalone packaging commit on top; pushed with --force-with-lease."}
{"ts": "2026-05-07T21:58:31+00:00", "type": "infrastructure", "status": "ok", "note": "Replaced embedded crates/vg_csg source in VibeGeometry with a git submodule at the same path pointing to https://github.com/GameCult/vg-csg commit 1adeadc. Workspace cargo test and clippy pass with the submodule."}

View file

@ -46,7 +46,7 @@ live_architecture:
- `vibegeometry/` provides reusable Python helpers for coordinate contracts, deterministic fields, batched geometry, Blender object/material/camera setup, and verifier checks.
- Geometry Script emits inspectable reusable Geometry Nodes groups.
- bpy handles Blender objects, modifiers, materials, cameras, lights, files, renders, and evaluated-geometry checks.
- Rust/Bevy-side CSG tooling provides an interactive brush-and-boolean assembler path for fast level geometry, starting with box CSG and habitat-specific additive primitives.
- Rust/Bevy-side CSG tooling lives in the `crates/vg_csg` submodule, which points at `https://github.com/GameCult/vg-csg`, and provides an interactive brush-and-boolean assembler path for fast level geometry, starting with box CSG and habitat-specific additive primitives.
- `vg_grammar` provides the authoring grammar above CSG: procedural rules emit semantic solid/void claims, then `vg_csg` adjudicates the space.
- External Python tools inspect source blends, generate converter drafts, compare outputs, and maintain corpus evidence.
persistence:
@ -84,7 +84,7 @@ current_artifacts:
- docs/research/builds/dream-machine-level.md
- docs/research/builds/industrial-maintenance-loop.md
- docs/research/realtime-csg-bevy-assembler.md
- crates/vg_csg/
- crates/vg_csg/ submodule -> https://github.com/GameCult/vg-csg
- crates/vg_grammar/
- vibegeometry/
tools: