Replace vg_csg with submodule
This commit is contained in:
parent
2ff4baca4d
commit
c85b8ac352
20 changed files with 11 additions and 6040 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal 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
1
crates/vg_csg
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 1adeadc74da047cc8d8b8069d3d68f05dac88851
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
|
|
@ -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 ¤t 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue