diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5abcefb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "crates/vg_csg"] + path = crates/vg_csg + url = https://github.com/GameCult/vg-csg diff --git a/crates/vg_csg b/crates/vg_csg new file mode 160000 index 0000000..1adeadc --- /dev/null +++ b/crates/vg_csg @@ -0,0 +1 @@ +Subproject commit 1adeadc74da047cc8d8b8069d3d68f05dac88851 diff --git a/crates/vg_csg/Cargo.toml b/crates/vg_csg/Cargo.toml deleted file mode 100644 index 3493273..0000000 --- a/crates/vg_csg/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/vg_csg/examples/csg_perf_fixture.rs b/crates/vg_csg/examples/csg_perf_fixture.rs deleted file mode 100644 index 175bf05..0000000 --- a/crates/vg_csg/examples/csg_perf_fixture.rs +++ /dev/null @@ -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::; - 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 -} diff --git a/crates/vg_csg/examples/csg_room.rs b/crates/vg_csg/examples/csg_room.rs deleted file mode 100644 index 9b5e6f2..0000000 --- a/crates/vg_csg/examples/csg_room.rs +++ /dev/null @@ -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() - ); -} diff --git a/crates/vg_csg/examples/dream_machine_level.rs b/crates/vg_csg/examples/dream_machine_level.rs deleted file mode 100644 index 4ded006..0000000 --- a/crates/vg_csg/examples/dream_machine_level.rs +++ /dev/null @@ -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, - "" - ); - let _ = writeln!( - out, - "" - ); - let _ = writeln!( - out, - "" - ); - for (_, material, points) in tris { - let _ = writeln!( - out, - "", - 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, ""); - let _ = writeln!(out, ""); - 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", - } -} diff --git a/crates/vg_csg/examples/industrial_maintenance_loop.rs b/crates/vg_csg/examples/industrial_maintenance_loop.rs deleted file mode 100644 index 0c4c195..0000000 --- a/crates/vg_csg/examples/industrial_maintenance_loop.rs +++ /dev/null @@ -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, 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, - "" - ); - let _ = writeln!( - out, - "" - ); - let _ = writeln!( - out, - "" - ); - for (_, material, points) in tris { - let _ = writeln!( - out, - "", - 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, ""); - let _ = writeln!(out, ""); - 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", - } -} diff --git a/crates/vg_csg/src/assembler.rs b/crates/vg_csg/src/assembler.rs deleted file mode 100644 index c4f66b4..0000000 --- a/crates/vg_csg/src/assembler.rs +++ /dev/null @@ -1,1508 +0,0 @@ -use std::cell::RefCell; - -use bevy_math::Vec3; - -use crate::{ - Aabb, Brush, BrushId, BrushOp, ConvexPolygon, ConvexSolid, DirtyDemandFrontier, MaterialId, - PolygonRouteScratch, Primitive, TriangleMesh, append_cylinder_z, append_dome_cap_z, - append_floret_arm, - primitives::{DomeCapZSpec, FloretArmSpec}, -}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum BuildWarning { - SubtractIgnoredForNonBox { brush: String }, - IntersectIgnoredForNonBox { brush: String }, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct BuildReport { - pub input_brushes: usize, - pub emitted_convex_fragments: usize, - pub operator_brushes: usize, - pub candidate_pairs: usize, - pub rejected_pairs: usize, - pub reused_mesh: bool, - pub used_checkpoint: bool, - pub checkpoint_rebuild_from: Option, - pub direct_dirty_rebuild: bool, - pub warnings: Vec, -} - -#[derive(Clone, Debug)] -pub struct BuildOutput { - pub mesh: TriangleMesh, - pub report: BuildReport, - pub generation: u64, -} - -#[derive(Clone, Debug, Default)] -pub struct Assembler { - brushes: Vec, - compiled: Vec, - cache: RefCell>, - box_cache: RefCell>, - general_cache: RefCell>, - first_dirty_index: RefCell>, - next_id: u32, - generation: u64, -} - -#[derive(Clone, Debug)] -struct CompiledBrush { - op: BrushOp, - material: MaterialId, - bounds: Aabb, - geometry: CompiledGeometry, - name: String, -} - -#[derive(Clone, Debug)] -enum CompiledGeometry { - Box(Aabb), - Convex(ConvexSolid), - 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, Default)] -struct BoxBuildCache { - generation: u64, - valid_checkpoint_index: usize, - checkpoints: Vec, -} - -#[derive(Clone, Debug, Default, PartialEq)] -struct BoxBuildState { - boxes: Vec<(Aabb, MaterialId)>, - report: BuildReport, -} - -#[derive(Clone, Debug, Default)] -struct GeneralBuildCache { - generation: u64, - valid_checkpoint_index: usize, - checkpoints: Vec, -} - -#[derive(Clone, Debug, Default, PartialEq)] -struct GeneralBuildState { - mesh: TriangleMesh, - solids: Vec, - report: BuildReport, -} - -impl Assembler { - pub fn new() -> Self { - Self::default() - } - - pub fn brushes(&self) -> &[Brush] { - &self.brushes - } - - pub fn generation(&self) -> u64 { - self.generation - } - - pub fn brush_index(&self, id: BrushId) -> Option { - self.brushes.iter().position(|brush| brush.id == id) - } - - pub fn dirty_frontier_for_indices( - &self, - dirty_indices: impl IntoIterator, - ) -> DirtyDemandFrontier { - DirtyDemandFrontier::from_dirty_indices(&self.brushes, dirty_indices) - } - - pub fn dirty_frontier_for_brushes( - &self, - dirty_brushes: impl IntoIterator, - ) -> DirtyDemandFrontier { - let indices = dirty_brushes - .into_iter() - .filter_map(|id| self.brush_index(id)) - .collect::>(); - self.dirty_frontier_for_indices(indices) - } - - pub fn add_brush( - &mut self, - name: impl Into, - op: BrushOp, - primitive: Primitive, - material: MaterialId, - ) -> BrushId { - let id = BrushId(self.next_id); - self.next_id += 1; - let index = self.brushes.len(); - let brush = Brush::new(id, name, op, primitive, material); - self.compiled.push(CompiledBrush::from_brush(&brush)); - self.brushes.push(brush); - self.generation = self.generation.wrapping_add(1); - self.mark_dirty_index(index); - id - } - - pub fn set_brush_primitive(&mut self, id: BrushId, primitive: Primitive) -> bool { - let Some(index) = self.brush_index(id) else { - return false; - }; - self.brushes[index].primitive = primitive; - self.brushes[index].set_dirty(); - self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); - self.generation = self.generation.wrapping_add(1); - self.mark_dirty_index(index); - true - } - - pub fn set_brush_op(&mut self, id: BrushId, op: BrushOp) -> bool { - let Some(index) = self.brush_index(id) else { - return false; - }; - self.brushes[index].op = op; - self.brushes[index].set_dirty(); - self.compiled[index] = CompiledBrush::from_brush(&self.brushes[index]); - self.generation = self.generation.wrapping_add(1); - self.mark_dirty_index(index); - true - } - - pub fn solid_box( - &mut self, - name: impl Into, - bounds: Aabb, - material: MaterialId, - ) -> BrushId { - self.add_brush(name, BrushOp::Add, Primitive::Box { bounds }, material) - } - - pub fn cut_box(&mut self, name: impl Into, bounds: Aabb) -> BrushId { - self.add_brush( - name, - BrushOp::Subtract, - Primitive::Box { bounds }, - MaterialId::default(), - ) - } - - pub fn solid_oriented_box( - &mut self, - name: impl Into, - center: Vec3, - size: Vec3, - rotation: bevy_math::Quat, - material: MaterialId, - ) -> BrushId { - self.add_brush( - name, - BrushOp::Add, - Primitive::OrientedBox { - center, - size, - rotation, - }, - material, - ) - } - - pub fn cut_oriented_box( - &mut self, - name: impl Into, - center: Vec3, - size: Vec3, - rotation: bevy_math::Quat, - ) -> BrushId { - self.add_brush( - name, - BrushOp::Subtract, - Primitive::OrientedBox { - center, - size, - rotation, - }, - MaterialId::default(), - ) - } - - pub fn build(&self) -> BuildOutput { - if let Some(output) = self - .cache - .borrow() - .as_ref() - .filter(|output| output.generation == self.generation) - { - return output.clone(); - } - - let output = self.build_with_checkpoints(); - *self.cache.borrow_mut() = Some(output.clone()); - *self.first_dirty_index.borrow_mut() = None; - output - } - - pub fn build_incremental(&self) -> BuildOutput { - if let Some(output) = self - .cache - .borrow() - .as_ref() - .filter(|output| output.generation == self.generation) - { - return output.clone(); - } - - let first_dirty_index = *self.first_dirty_index.borrow(); - let output = self - .try_build_axis_aligned_boxes_from_cache(first_dirty_index) - .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)); - *self.cache.borrow_mut() = Some(output.clone()); - *self.first_dirty_index.borrow_mut() = None; - output - } - - pub fn rebuild_incremental_for_indices( - &self, - dirty_indices: impl IntoIterator, - ) -> BuildOutput { - let first_dirty_index = self - .dirty_frontier_for_indices(dirty_indices) - .first_dirty_index; - self.try_build_axis_aligned_boxes_from_cache(first_dirty_index) - .unwrap_or_else(|| self.build_general_from_cache(first_dirty_index)) - } - - pub fn rebuild_incremental_for_brushes( - &self, - dirty_brushes: impl IntoIterator, - ) -> BuildOutput { - let indices = dirty_brushes - .into_iter() - .filter_map(|id| self.brush_index(id)) - .collect::>(); - self.rebuild_incremental_for_indices(indices) - } - - pub fn rebuild(&self) -> BuildOutput { - self.build_uncached() - } - - pub fn rebuild_routed_surfaces(&self) -> BuildOutput { - self.try_build_routed_surfaces() - .unwrap_or_else(|| self.build_uncached()) - } - - pub fn supports_routed_surfaces(&self) -> bool { - self.supports_routed_surface_subset() - } - - fn mark_dirty_index(&self, index: usize) { - let mut first_dirty_index = self.first_dirty_index.borrow_mut(); - *first_dirty_index = Some(first_dirty_index.map_or(index, |dirty| dirty.min(index))); - } - - fn try_build_routed_surfaces(&self) -> Option { - if !self.supports_routed_surface_subset() { - return None; - } - - let mut report = BuildReport { - input_brushes: self.brushes.len(), - ..BuildReport::default() - }; - let mut source = None::; - let mut surfaces = Vec::::new(); - let mut previous_cutters = Vec::::new(); - let mut scratch = PolygonRouteScratch::default(); - - for brush in &self.compiled { - match brush.op { - BrushOp::Add => { - if source.is_some() { - return None; - } - let solid = brush.convex_cutter()?; - surfaces = solid.polygons.clone(); - source = Some(solid); - } - BrushOp::Subtract => { - report.operator_brushes += 1; - let source_solid = source.as_ref()?; - if !source_solid.bounds.intersects(brush.bounds) { - report.rejected_pairs += 1; - continue; - } - let cutter = brush.convex_cutter()?; - report.candidate_pairs += 1; - surfaces = scratch.route_outside_of(surfaces, &cutter); - - let mut caps = scratch.route_inside_of(cutter.polygons.clone(), source_solid); - for previous in &previous_cutters { - caps = scratch.route_outside_of(caps, previous); - } - for cap in &mut caps { - cap.material = source_solid.material; - cap.reversed = !cap.reversed; - } - surfaces.extend(caps); - previous_cutters.push(cutter); - } - BrushOp::Intersect => return None, - } - } - - let mut mesh = TriangleMesh::new(); - ConvexSolid::append_polygons_to_mesh(&surfaces, &mut mesh); - report.emitted_convex_fragments = surfaces.len(); - - Some(BuildOutput { - mesh, - report, - generation: self.generation, - }) - } - - fn supports_routed_surface_subset(&self) -> bool { - let mut source = None::; - let mut candidate_subtracts = 0usize; - - for brush in &self.compiled { - match brush.op { - BrushOp::Add => { - if source.is_some() || brush.convex_cutter().is_none() { - return false; - } - source = Some(brush.bounds); - } - BrushOp::Subtract => { - let Some(source_bounds) = source else { - return false; - }; - if brush.convex_cutter().is_none() { - return false; - } - if source_bounds.intersects(brush.bounds) { - candidate_subtracts += 1; - if candidate_subtracts > 1 { - return false; - } - } - } - BrushOp::Intersect => return false, - } - } - - source.is_some() && candidate_subtracts == 1 - } - - fn build_uncached(&self) -> BuildOutput { - if self - .compiled - .iter() - .all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_))) - { - return self.build_axis_aligned_boxes_direct(); - } - - self.build_general_direct() - } - - fn build_with_checkpoints(&self) -> BuildOutput { - self.try_build_axis_aligned_boxes_from_cache(Some(0)) - .unwrap_or_else(|| self.build_general_from_cache(Some(0))) - } - - fn build_general_direct(&self) -> BuildOutput { - let mut state = GeneralBuildState::default(); - for brush in &self.compiled { - state.apply_compiled_brush(brush); - } - state.into_output(self.brushes.len(), self.generation) - } - - fn build_axis_aligned_boxes_direct(&self) -> BuildOutput { - let mut state = BoxBuildState::default(); - for brush in &self.compiled { - state.apply_compiled_box(brush); - } - state.report.direct_dirty_rebuild = true; - state.into_output_with_mesh(self.brushes.len(), self.generation, None) - } - - fn try_build_axis_aligned_boxes_from_cache( - &self, - first_dirty_index: Option, - ) -> Option { - if !self - .compiled - .iter() - .all(|brush| matches!(brush.geometry, CompiledGeometry::Box(_))) - { - return None; - } - - let brush_count = self.compiled.len(); - let previous_output = self.cache.borrow().as_ref().cloned(); - if let Some(first_dirty_index) = first_dirty_index - && first_dirty_index > 0 - { - return Some(self.build_axis_aligned_boxes_direct()); - } - let rebuild_from = match first_dirty_index { - Some(index) => index.min(brush_count), - None => self - .box_cache - .borrow() - .as_ref() - .filter(|cache| { - cache.generation == self.generation - && cache.valid_checkpoint_index == brush_count - && cache.checkpoints.len() == brush_count + 1 - }) - .map_or(0, |_| brush_count), - }; - let cached_state = self - .box_cache - .borrow() - .as_ref() - .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) - .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); - let (mut state, start_index) = if let Some(state) = cached_state { - (state, rebuild_from) - } else { - (BoxBuildState::default(), 0) - }; - if start_index > 0 { - state.report.used_checkpoint = true; - state.report.checkpoint_rebuild_from = Some(start_index); - } - - let mut checkpoints = (start_index == 0).then(|| vec![BoxBuildState::default()]); - - let mut changed_geometry = start_index == 0; - for brush in &self.compiled[start_index..] { - changed_geometry |= state.apply_compiled_box(brush); - if let Some(checkpoints) = &mut checkpoints { - checkpoints.push(state.clone()); - } - } - - if let Some(checkpoints) = checkpoints { - *self.box_cache.borrow_mut() = Some(BoxBuildCache { - generation: self.generation, - valid_checkpoint_index: brush_count, - checkpoints, - }); - } else if let Some(cache) = self.box_cache.borrow_mut().as_mut() { - cache.generation = self.generation; - cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); - } - - let reused_mesh = (!changed_geometry) - .then_some(previous_output) - .flatten() - .map(|output| output.mesh); - Some(state.into_output_with_mesh(self.brushes.len(), self.generation, reused_mesh)) - } - - fn build_general_from_cache(&self, first_dirty_index: Option) -> BuildOutput { - let brush_count = self.compiled.len(); - let previous_output = self.cache.borrow().as_ref().cloned(); - let rebuild_from = match first_dirty_index { - Some(index) => index.min(brush_count), - None => self - .general_cache - .borrow() - .as_ref() - .filter(|cache| { - cache.generation == self.generation - && cache.valid_checkpoint_index == brush_count - && cache.checkpoints.len() == brush_count + 1 - }) - .map_or(0, |_| brush_count), - }; - let cached_state = self - .general_cache - .borrow() - .as_ref() - .filter(|cache| rebuild_from <= cache.valid_checkpoint_index) - .and_then(|cache| cache.checkpoints.get(rebuild_from).cloned()); - let (mut state, start_index) = if let Some(state) = cached_state { - (state, rebuild_from) - } else { - (GeneralBuildState::default(), 0) - }; - if start_index > 0 { - state.report.used_checkpoint = true; - state.report.checkpoint_rebuild_from = Some(start_index); - } - - let mut checkpoints = (start_index == 0).then(|| vec![GeneralBuildState::default()]); - - let mut changed_geometry = start_index == 0; - for brush in &self.compiled[start_index..] { - changed_geometry |= state.apply_compiled_brush(brush); - if let Some(checkpoints) = &mut checkpoints { - checkpoints.push(state.clone()); - } - } - - if let Some(checkpoints) = checkpoints { - *self.general_cache.borrow_mut() = Some(GeneralBuildCache { - generation: self.generation, - valid_checkpoint_index: brush_count, - checkpoints, - }); - } else if let Some(cache) = self.general_cache.borrow_mut().as_mut() { - cache.generation = self.generation; - cache.valid_checkpoint_index = cache.valid_checkpoint_index.min(start_index); - } - - let reused_mesh = (!changed_geometry) - .then_some(previous_output) - .flatten() - .map(|output| output.mesh); - state.into_output_with_mesh(self.brushes.len(), self.generation, reused_mesh) - } - - pub fn sample_room_with_door() -> Self { - let wall = MaterialId(1); - let floor = MaterialId(2); - let mut asm = Self::new(); - asm.solid_box( - "floor", - Aabb::from_center_size(Vec3::new(0.0, 0.0, -0.1), Vec3::new(8.0, 8.0, 0.2)), - floor, - ); - asm.solid_box( - "north wall", - Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.5), Vec3::new(8.0, 0.25, 3.0)), - wall, - ); - asm.cut_box( - "door void", - Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)), - ); - asm - } -} - -impl CompiledBrush { - fn from_brush(brush: &Brush) -> Self { - let bounds = brush.bounds(); - let geometry = match &brush.primitive { - Primitive::Box { bounds } => CompiledGeometry::Box(*bounds), - Primitive::OrientedBox { - center, - size, - rotation, - } => CompiledGeometry::Convex(ConvexSolid::box_from_center_size( - *center, - *size, - *rotation, - brush.material, - )), - Primitive::CylinderZ { - center, - radius, - depth, - segments, - } => CompiledGeometry::CylinderZ { - center: *center, - radius: *radius, - depth: *depth, - segments: *segments, - }, - Primitive::DomeCapZ { - center, - radius, - height, - rings, - segments, - } => CompiledGeometry::DomeCapZ { - center: *center, - radius: *radius, - height: *height, - rings: *rings, - segments: *segments, - }, - Primitive::FloretArm { - anchor, - direction, - length, - root_width, - tip_width, - thickness, - tip_lift, - } => CompiledGeometry::FloretArm { - anchor: *anchor, - direction: *direction, - length: *length, - root_width: *root_width, - tip_width: *tip_width, - thickness: *thickness, - tip_lift: *tip_lift, - }, - }; - - Self { - op: brush.op, - material: brush.material, - bounds, - geometry, - name: brush.name.clone(), - } - } - - fn convex_cutter(&self) -> Option { - match &self.geometry { - CompiledGeometry::Box(bounds) => Some(ConvexSolid::from_aabb(*bounds, self.material)), - CompiledGeometry::Convex(solid) => Some(solid.clone()), - _ => None, - } - } -} - -impl BoxBuildState { - fn apply_compiled_box(&mut self, brush: &CompiledBrush) -> bool { - let CompiledGeometry::Box(bounds) = brush.geometry else { - unreachable!("box build state received a non-box brush"); - }; - - match brush.op { - BrushOp::Add => { - self.boxes.push((bounds, brush.material)); - true - } - BrushOp::Subtract => { - self.report.operator_brushes += 1; - if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) { - self.report.rejected_pairs += self.boxes.len(); - return false; - } - - let mut out = Vec::with_capacity(self.boxes.len() + 4); - for (solid, material) in self.boxes.drain(..) { - if solid.intersects(bounds) { - self.report.candidate_pairs += 1; - out.extend( - solid - .subtract_box(bounds) - .into_iter() - .map(|piece| (piece, material)), - ); - } else { - self.report.rejected_pairs += 1; - out.push((solid, material)); - } - } - self.boxes = out; - true - } - BrushOp::Intersect => { - self.report.operator_brushes += 1; - if !bounds.intersects_any(self.boxes.iter().map(|(bounds, _)| *bounds)) { - let changed = !self.boxes.is_empty(); - self.report.rejected_pairs += self.boxes.len(); - self.boxes.clear(); - return changed; - } - - self.boxes = self - .boxes - .drain(..) - .filter_map(|(solid, material)| { - if let Some(hit) = solid.intersection(bounds) { - self.report.candidate_pairs += 1; - Some((hit, material)) - } else { - self.report.rejected_pairs += 1; - None - } - }) - .collect(); - true - } - } - } - - fn into_output_with_mesh( - mut self, - input_brushes: usize, - generation: u64, - reused_mesh: Option, - ) -> BuildOutput { - let mut mesh = TriangleMesh::new(); - self.report.input_brushes = input_brushes; - self.report.emitted_convex_fragments = self.boxes.len(); - if let Some(reused_mesh) = reused_mesh { - self.report.reused_mesh = true; - mesh = reused_mesh; - } else { - for (bounds, material) in self.boxes { - mesh.append_box(bounds, material); - } - } - - BuildOutput { - mesh, - report: self.report, - generation, - } - } -} - -impl GeneralBuildState { - fn apply_compiled_brush(&mut self, brush: &CompiledBrush) -> bool { - match brush.op { - BrushOp::Add => { - self.add_brush_geometry(brush); - true - } - BrushOp::Subtract => { - self.report.operator_brushes += 1; - if !brush - .bounds - .intersects_any(self.solids.iter().map(|solid| solid.bounds)) - { - self.report.rejected_pairs += self.solids.len(); - return false; - } - if let Some(cutter) = brush.convex_cutter() { - self.solids = subtract_from_solids( - std::mem::take(&mut self.solids), - &cutter, - &mut self.report, - ); - true - } else { - let has_candidate = - record_solid_pairs(&self.solids, brush.bounds, &mut self.report); - if has_candidate { - self.report - .warnings - .push(BuildWarning::SubtractIgnoredForNonBox { - brush: brush.name.clone(), - }); - } - false - } - } - BrushOp::Intersect => { - self.report.operator_brushes += 1; - if !brush - .bounds - .intersects_any(self.solids.iter().map(|solid| solid.bounds)) - { - let changed = !self.solids.is_empty(); - self.report.rejected_pairs += self.solids.len(); - self.solids.clear(); - return changed; - } - if let Some(cutter) = brush.convex_cutter() { - self.solids = intersect_solids( - std::mem::take(&mut self.solids), - &cutter, - &mut self.report, - ); - true - } else { - let has_candidate = - record_solid_pairs(&self.solids, brush.bounds, &mut self.report); - let changed = !self.solids.is_empty(); - self.solids.clear(); - if has_candidate { - self.report - .warnings - .push(BuildWarning::IntersectIgnoredForNonBox { - brush: brush.name.clone(), - }); - } - changed - } - } - } - } - - fn add_brush_geometry(&mut self, brush: &CompiledBrush) { - match &brush.geometry { - CompiledGeometry::Box(bounds) => { - self.solids - .push(ConvexSolid::from_aabb(*bounds, brush.material)); - } - CompiledGeometry::Convex(solid) => self.solids.push(solid.clone()), - CompiledGeometry::CylinderZ { - center, - radius, - depth, - segments, - } => append_cylinder_z( - &mut self.mesh, - *center, - *radius, - *depth, - *segments, - brush.material, - ), - CompiledGeometry::DomeCapZ { - center, - radius, - height, - rings, - segments, - } => append_dome_cap_z( - &mut self.mesh, - DomeCapZSpec { - center: *center, - radius: *radius, - height: *height, - rings: *rings, - segments: *segments, - material: brush.material, - }, - ), - CompiledGeometry::FloretArm { - anchor, - direction, - length, - root_width, - tip_width, - thickness, - tip_lift, - } => append_floret_arm( - &mut self.mesh, - FloretArmSpec { - anchor: *anchor, - direction: *direction, - length: *length, - root_width: *root_width, - tip_width: *tip_width, - thickness: *thickness, - tip_lift: *tip_lift, - material: brush.material, - }, - ), - } - } - - fn into_output(self, input_brushes: usize, generation: u64) -> BuildOutput { - self.into_output_with_mesh(input_brushes, generation, None) - } - - fn into_output_with_mesh( - mut self, - input_brushes: usize, - generation: u64, - reused_mesh: Option, - ) -> BuildOutput { - self.report.input_brushes = input_brushes; - self.report.emitted_convex_fragments = self.solids.len(); - if let Some(reused_mesh) = reused_mesh { - self.report.reused_mesh = true; - self.mesh = reused_mesh; - } else { - for solid in self.solids { - solid.append_to_mesh(&mut self.mesh); - } - } - - BuildOutput { - mesh: self.mesh, - report: self.report, - generation, - } - } -} - -fn subtract_from_solids( - solids: Vec, - cutter: &ConvexSolid, - report: &mut BuildReport, -) -> Vec { - let mut out = Vec::with_capacity(solids.len() + 4); - for solid in solids { - if solid.bounds.intersects(cutter.bounds) { - report.candidate_pairs += 1; - out.extend(solid.subtract_convex_owned(cutter)); - } else { - report.rejected_pairs += 1; - out.push(solid); - } - } - out -} - -fn intersect_solids( - solids: Vec, - cutter: &ConvexSolid, - report: &mut BuildReport, -) -> Vec { - let mut out = Vec::with_capacity(solids.len()); - for solid in solids { - if !solid.bounds.intersects(cutter.bounds) { - report.rejected_pairs += 1; - continue; - } - report.candidate_pairs += 1; - if let Some(fragment) = solid.intersect_convex_owned(cutter) { - out.push(fragment); - } - } - out -} - -fn record_solid_pairs(solids: &[ConvexSolid], bounds: Aabb, report: &mut BuildReport) -> bool { - let mut has_candidate = false; - for solid in solids { - if solid.bounds.intersects(bounds) { - report.candidate_pairs += 1; - has_candidate = true; - } else { - report.rejected_pairs += 1; - } - } - has_candidate -} - -#[cfg(test)] -mod tests { - use bevy_math::Vec3; - - use super::*; - - #[test] - fn centered_subtract_splits_box_into_six_fragments() { - let source = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)); - let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); - let pieces = source.subtract_box(cutter); - - assert_eq!(pieces.len(), 6); - assert!(pieces.iter().all(|piece| piece.is_valid())); - assert!(pieces.iter().all(|piece| !piece.intersects(cutter))); - } - - #[test] - fn door_cut_leaves_no_box_fragment_inside_void() { - let output = Assembler::sample_room_with_door().build(); - let cutter = Aabb::from_center_size(Vec3::new(0.0, 4.0, 1.0), Vec3::new(1.2, 0.5, 2.0)); - - assert!(output.report.emitted_convex_fragments > 2); - for tri in output.mesh.indices.chunks_exact(3) { - let center = (output.mesh.positions[tri[0] as usize] - + output.mesh.positions[tri[1] as usize] - + output.mesh.positions[tri[2] as usize]) - / 3.0; - assert!(!cutter.contains_point_strict(center, 1.0e-4)); - } - } - - #[test] - fn rotated_door_cut_produces_diagonal_portal_faces() { - let mut asm = Assembler::new(); - asm.solid_box( - "wall", - Aabb::from_center_size(Vec3::new(0.0, 0.0, 1.5), Vec3::new(5.0, 0.5, 3.0)), - MaterialId(1), - ); - asm.cut_oriented_box( - "angled void", - Vec3::new(0.0, 0.0, 1.5), - Vec3::new(1.5, 2.0, 2.0), - bevy_math::Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), - ); - - let output = asm.build(); - assert!(output.report.emitted_convex_fragments > 2); - assert!(output.mesh.normals.iter().any(|normal| { - normal.x.abs() > 0.2 && normal.y.abs() > 0.2 && normal.z.abs() < 0.1 - })); - } - - #[test] - fn mesh_box_emits_twelve_triangles() { - let mut mesh = TriangleMesh::new(); - mesh.append_box(Aabb::from_center_size(Vec3::ZERO, Vec3::ONE), MaterialId(7)); - - assert_eq!(mesh.triangle_count(), 12); - assert_eq!(mesh.triangle_materials.len(), 12); - assert_eq!(mesh.triangle_materials[0], MaterialId(7)); - } - - #[test] - fn distant_subtract_skips_convex_split_and_keeps_source_solid() { - let mut asm = Assembler::new(); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), - MaterialId(1), - ); - asm.cut_box( - "far void", - Aabb::from_center_size(Vec3::splat(10.0), Vec3::splat(1.0)), - ); - - let output = asm.build(); - assert_eq!(output.report.emitted_convex_fragments, 1); - assert_eq!(output.report.operator_brushes, 1); - assert_eq!(output.report.candidate_pairs, 0); - assert_eq!(output.report.rejected_pairs, 1); - assert_eq!(output.mesh.triangle_count(), 12); - } - - #[test] - fn intersecting_box_keeps_common_convex_region() { - let mut asm = Assembler::new(); - asm.solid_box( - "a", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), - MaterialId(1), - ); - asm.add_brush( - "b", - BrushOp::Intersect, - Primitive::Box { - bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), - }, - MaterialId(1), - ); - - let output = asm.build(); - assert_eq!(output.report.emitted_convex_fragments, 1); - assert_eq!(output.report.operator_brushes, 1); - assert_eq!(output.report.candidate_pairs, 1); - assert_eq!(output.report.rejected_pairs, 0); - assert_eq!(output.report.warnings.len(), 0); - assert_eq!(output.mesh.triangle_count(), 12); - } - - #[test] - fn cached_build_and_dirty_rebuild_emit_same_output() { - let mut asm = Assembler::new(); - asm.solid_box( - "slab", - Aabb::from_center_size(Vec3::ZERO, Vec3::new(8.0, 8.0, 2.0)), - MaterialId(1), - ); - asm.cut_box( - "center void", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), - ); - asm.cut_oriented_box( - "angled void", - Vec3::new(1.0, 1.0, 0.0), - Vec3::new(1.25, 4.0, 2.5), - bevy_math::Quat::from_rotation_z(0.35), - ); - - let stable = asm.build(); - let cached = asm.build(); - let dirty = asm.rebuild(); - - assert_eq!( - stable.report.emitted_convex_fragments, - dirty.report.emitted_convex_fragments - ); - assert_eq!( - stable.report.operator_brushes, - dirty.report.operator_brushes - ); - assert_eq!(stable.report.candidate_pairs, dirty.report.candidate_pairs); - assert_eq!(stable.report.rejected_pairs, dirty.report.rejected_pairs); - assert_eq!(stable.report.warnings, dirty.report.warnings); - assert_eq!(stable.mesh.triangle_count(), dirty.mesh.triangle_count()); - assert_eq!(stable.mesh.vertex_count(), dirty.mesh.vertex_count()); - assert_eq!(stable.mesh.positions, dirty.mesh.positions); - assert_eq!(stable.mesh.indices, dirty.mesh.indices); - assert_eq!(cached.mesh.positions, stable.mesh.positions); - assert_eq!(cached.mesh.indices, stable.mesh.indices); - } - - #[test] - fn routed_surface_rebuild_emits_clean_center_cut_boundary() { - let mut asm = Assembler::new(); - let cutter = Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), - MaterialId(1), - ); - asm.cut_box("void", cutter); - - let routed = asm.rebuild_routed_surfaces(); - - assert_eq!(routed.report.operator_brushes, 1); - assert_eq!(routed.report.candidate_pairs, 1); - assert_eq!(routed.report.rejected_pairs, 0); - assert_eq!(routed.report.emitted_convex_fragments, 24); - assert!(routed.mesh.triangle_count() < asm.rebuild().mesh.triangle_count()); - for tri in routed.mesh.indices.chunks_exact(3) { - let center = (routed.mesh.positions[tri[0] as usize] - + routed.mesh.positions[tri[1] as usize] - + routed.mesh.positions[tri[2] as usize]) - / 3.0; - assert!(!cutter.contains_point_strict(center, 1.0e-4)); - } - } - - #[test] - fn routed_surface_rebuild_falls_back_for_intersections() { - let mut asm = Assembler::new(); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), - MaterialId(1), - ); - asm.add_brush( - "common", - BrushOp::Intersect, - Primitive::Box { - bounds: Aabb::from_center_size(Vec3::X, Vec3::splat(4.0)), - }, - MaterialId(1), - ); - - let stable = asm.rebuild(); - let routed = asm.rebuild_routed_surfaces(); - - assert_eq!( - routed.report.emitted_convex_fragments, - stable.report.emitted_convex_fragments - ); - assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); - assert_eq!(routed.mesh.positions, stable.mesh.positions); - assert_eq!(routed.mesh.indices, stable.mesh.indices); - } - - #[test] - fn routed_surface_rebuild_falls_back_for_multiple_candidate_cutters() { - let mut asm = Assembler::new(); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), - MaterialId(1), - ); - asm.cut_oriented_box( - "a", - Vec3::new(-0.5, 0.0, 0.0), - Vec3::new(1.0, 5.0, 5.0), - bevy_math::Quat::from_rotation_z(0.25), - ); - asm.cut_oriented_box( - "b", - Vec3::new(0.5, 0.0, 0.0), - Vec3::new(1.0, 5.0, 5.0), - bevy_math::Quat::from_rotation_z(-0.25), - ); - - let stable = asm.rebuild(); - let routed = asm.rebuild_routed_surfaces(); - - assert_eq!( - routed.report.emitted_convex_fragments, - stable.report.emitted_convex_fragments - ); - assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); - assert_eq!(routed.mesh.positions, stable.mesh.positions); - assert_eq!(routed.mesh.indices, stable.mesh.indices); - } - - #[test] - fn routed_surface_rebuild_falls_back_when_no_cutter_touches_source() { - let mut asm = Assembler::new(); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), - MaterialId(1), - ); - asm.cut_box( - "far", - Aabb::from_center_size(Vec3::splat(20.0), Vec3::splat(2.0)), - ); - - let stable = asm.rebuild(); - let routed = asm.rebuild_routed_surfaces(); - - assert_eq!( - routed.report.emitted_convex_fragments, - stable.report.emitted_convex_fragments - ); - assert_eq!(routed.mesh.triangle_count(), stable.mesh.triangle_count()); - assert_eq!(routed.mesh.positions, stable.mesh.positions); - assert_eq!(routed.mesh.indices, stable.mesh.indices); - } - - #[test] - fn mutating_brush_primitive_invalidates_cache_and_exposes_dirty_frontier() { - let mut asm = Assembler::new(); - let source = asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), - MaterialId(1), - ); - let cutter = asm.cut_box("void", Aabb::from_center_size(Vec3::ZERO, Vec3::splat(1.0))); - let before = asm.build(); - - assert!(asm.set_brush_primitive( - cutter, - Primitive::Box { - bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), - }, - )); - let frontier = asm.dirty_frontier_for_brushes([cutter]); - let after = asm.build(); - - assert_eq!(frontier.first_dirty_index, Some(1)); - assert_eq!(frontier.operator_brushes, 1); - assert_eq!(frontier.candidate_pairs(), 1); - assert_eq!(frontier.rejected_pairs, 0); - assert!(after.generation > before.generation); - assert_ne!(after.mesh.positions, before.mesh.positions); - assert_eq!(asm.brush_index(source), Some(0)); - assert_eq!(asm.brush_index(cutter), Some(1)); - } - - #[test] - fn incremental_box_rebuild_reuses_prefix_and_matches_full_rebuild() { - let mut asm = Assembler::new(); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), - MaterialId(1), - ); - asm.cut_box( - "early", - Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), - ); - let dirty = asm.cut_box( - "dirty", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), - ); - asm.cut_box( - "late", - Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), - ); - let before = asm.build(); - - assert!(asm.set_brush_primitive( - dirty, - Primitive::Box { - bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)), - }, - )); - let incremental = asm.build_incremental(); - let full = asm.rebuild(); - - assert!(!incremental.report.used_checkpoint); - assert!(incremental.report.direct_dirty_rebuild); - assert_eq!(incremental.mesh.positions, full.mesh.positions); - assert_eq!(incremental.mesh.indices, full.mesh.indices); - assert_eq!( - incremental.report.candidate_pairs, - full.report.candidate_pairs - ); - assert_eq!( - incremental.report.rejected_pairs, - full.report.rejected_pairs - ); - assert_eq!(incremental.generation, full.generation); - assert_ne!(incremental.mesh.positions, before.mesh.positions); - } - - #[test] - fn explicit_incremental_box_rebuild_matches_full_rebuild() { - let mut asm = Assembler::new(); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), - MaterialId(1), - ); - asm.cut_box( - "early", - Aabb::from_center_size(Vec3::new(-2.0, 0.0, 0.0), Vec3::splat(2.0)), - ); - let dirty = asm.cut_box( - "dirty", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(2.0)), - ); - asm.cut_box( - "late", - Aabb::from_center_size(Vec3::new(2.0, 0.0, 0.0), Vec3::splat(2.0)), - ); - asm.build_incremental(); - - assert!(asm.set_brush_primitive( - dirty, - Primitive::Box { - bounds: Aabb::from_center_size(Vec3::ZERO, Vec3::splat(3.0)), - }, - )); - let incremental = asm.rebuild_incremental_for_brushes([dirty]); - let full = asm.rebuild(); - - assert_eq!(incremental.mesh.positions, full.mesh.positions); - assert_eq!(incremental.mesh.indices, full.mesh.indices); - assert_eq!( - incremental.report.candidate_pairs, - full.report.candidate_pairs - ); - assert_eq!( - incremental.report.rejected_pairs, - full.report.rejected_pairs - ); - } - - #[test] - fn incremental_distant_tail_edit_reuses_previous_mesh() { - let mut asm = Assembler::new(); - asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(8.0)), - MaterialId(1), - ); - let far = asm.cut_oriented_box( - "far", - Vec3::splat(100.0), - Vec3::splat(1.0), - bevy_math::Quat::from_rotation_z(0.2), - ); - let before = asm.build_incremental(); - - assert!(asm.set_brush_primitive( - far, - Primitive::OrientedBox { - center: Vec3::splat(120.0), - size: Vec3::splat(1.0), - rotation: bevy_math::Quat::from_rotation_z(0.2), - }, - )); - let after = asm.build_incremental(); - - assert_eq!(after.mesh, before.mesh); - assert!(after.report.reused_mesh); - assert!(after.report.used_checkpoint); - assert_eq!(after.report.checkpoint_rebuild_from, Some(1)); - assert!(!after.report.direct_dirty_rebuild); - assert_eq!(after.report.candidate_pairs, 0); - assert_eq!(after.report.rejected_pairs, 1); - } - - #[test] - fn incremental_convex_rebuild_matches_full_rebuild() { - let mut asm = Assembler::new(); - asm.solid_box( - "slab", - Aabb::from_center_size(Vec3::ZERO, Vec3::new(16.0, 16.0, 4.0)), - MaterialId(1), - ); - - let mut tail = BrushId(0); - for index in 0..12 { - let angle = index as f32 * 0.217; - let center = Vec3::new(angle.cos() * 4.0, angle.sin() * 4.0, 0.0); - tail = asm.cut_oriented_box( - format!("rotated_void_{index}"), - center, - Vec3::new(1.0, 6.0, 5.0), - bevy_math::Quat::from_rotation_z(angle), - ); - } - asm.build_incremental(); - - assert!(asm.set_brush_primitive( - tail, - Primitive::OrientedBox { - center: Vec3::new(3.0, 0.0, 0.0), - size: Vec3::new(1.25, 6.0, 5.0), - rotation: bevy_math::Quat::from_rotation_z(0.5), - }, - )); - let incremental = asm.build_incremental(); - let full = asm.rebuild(); - - assert!(incremental.report.used_checkpoint); - assert_eq!(incremental.report.checkpoint_rebuild_from, Some(12)); - assert!(!incremental.report.direct_dirty_rebuild); - assert_eq!(incremental.mesh.positions, full.mesh.positions); - assert_eq!(incremental.mesh.indices, full.mesh.indices); - assert_eq!( - incremental.report.candidate_pairs, - full.report.candidate_pairs - ); - assert_eq!( - incremental.report.rejected_pairs, - full.report.rejected_pairs - ); - } - - #[test] - fn mutating_brush_operation_changes_ordered_output() { - let mut asm = Assembler::new(); - let _source = asm.solid_box( - "source", - Aabb::from_center_size(Vec3::ZERO, Vec3::splat(4.0)), - MaterialId(1), - ); - let other = asm.solid_box( - "other", - Aabb::from_center_size(Vec3::new(0.5, 0.0, 0.0), Vec3::splat(3.0)), - MaterialId(1), - ); - let before = asm.rebuild(); - - assert!(asm.set_brush_op(other, BrushOp::Intersect)); - let after = asm.rebuild(); - - assert_eq!(after.report.operator_brushes, 1); - assert_eq!(after.report.candidate_pairs, 1); - assert!(after.mesh.triangle_count() < before.mesh.triangle_count()); - } - - #[test] - fn rotated_cut_stack_regression_preserves_ordered_output_shape() { - 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), - bevy_math::Quat::from_rotation_z(angle), - ); - } - - let output = asm.rebuild(); - - assert_eq!(output.report.emitted_convex_fragments, 280); - assert_eq!(output.mesh.triangle_count(), 3404); - assert_eq!(output.report.candidate_pairs, 804); - assert_eq!(output.report.rejected_pairs, 9417); - } -} diff --git a/crates/vg_csg/src/brush.rs b/crates/vg_csg/src/brush.rs deleted file mode 100644 index 1b29638..0000000 --- a/crates/vg_csg/src/brush.rs +++ /dev/null @@ -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) -> bool { - others.into_iter().any(|other| self.intersects(other)) - } - - pub fn intersection(self, other: Self) -> Option { - 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 { - 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, 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, - 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 { - 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, - ]) -} diff --git a/crates/vg_csg/src/convex.rs b/crates/vg_csg/src/convex.rs deleted file mode 100644 index 36a1006..0000000 --- a/crates/vg_csg/src/convex.rs +++ /dev/null @@ -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, - 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, 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::() / self.vertices.len() as f32 - } - - fn with_vertices(&self, vertices: Vec) -> 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, - pub clip_planes: Vec, - pub material: MaterialId, - pub bounds: Aabb, -} - -#[derive(Clone, Debug, Default)] -pub struct CategorizedPolygons { - pub inside: Vec, - pub outside: Vec, - pub aligned: Vec, - pub reverse_aligned: Vec, -} - -#[derive(Default)] -pub struct PolygonRouteScratch { - pieces: Vec, - inside_pieces: Vec, - cap_points: Vec, -} - -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.clone().subtract_convex_owned(cutter) - } - - pub fn subtract_convex_owned(self, cutter: &Self) -> Vec { - 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.clone().intersect_convex_owned(cutter) - } - - pub fn intersect_convex_owned(self, cutter: &Self) -> Option { - 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, - cutter: &Self, - ) -> Vec { - let categorized = PolygonRouteScratch::default().categorize_against(&polygons, cutter); - categorized.outside - } - - pub fn route_polygons_inside_of( - polygons: Vec, - cutter: &Self, - ) -> Vec { - 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 { - 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, - cutter: &ConvexSolid, - ) -> Vec { - self.categorize_against(&polygons, cutter).outside - } - - pub fn route_inside_of( - &mut self, - polygons: Vec, - cutter: &ConvexSolid, - ) -> Vec { - 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, -} - -#[derive(Clone, Copy)] -enum KeepSide { - Front, - Back, -} - -fn classify_piece_against_plane( - mut piece: CategorizationPiece, - plane: Plane, - inside_pieces: &mut Vec, - categorized: &mut CategorizedPolygons, - cap_points: &mut Vec, -) { - 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, -) -> Vec { - 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::() / 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) -> Vec { - 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 { - 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 { - 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) - ); - } -} diff --git a/crates/vg_csg/src/dsl.rs b/crates/vg_csg/src/dsl.rs deleted file mode 100644 index 1195a90..0000000 --- a/crates/vg_csg/src/dsl.rs +++ /dev/null @@ -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, - 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, 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, - 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, - 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, - 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, 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, 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()); - } -} diff --git a/crates/vg_csg/src/frontier.rs b/crates/vg_csg/src/frontier.rs deleted file mode 100644 index aff6e18..0000000 --- a/crates/vg_csg/src/frontier.rs +++ /dev/null @@ -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, - pub rejected_pairs: usize, - pub operator_brushes: usize, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct DirtyDemandFrontier { - pub first_dirty_index: Option, - pub pairs: Vec, - 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::::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 { - 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, - ) -> 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::::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 { - 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); - } -} diff --git a/crates/vg_csg/src/lib.rs b/crates/vg_csg/src/lib.rs deleted file mode 100644 index b1e80db..0000000 --- a/crates/vg_csg/src/lib.rs +++ /dev/null @@ -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, -}; diff --git a/crates/vg_csg/src/mesh.rs b/crates/vg_csg/src/mesh.rs deleted file mode 100644 index 94cfb3d..0000000 --- a/crates/vg_csg/src/mesh.rs +++ /dev/null @@ -1,102 +0,0 @@ -use bevy_math::{Vec2, Vec3}; - -use crate::{Aabb, MaterialId}; - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct TriangleMesh { - pub positions: Vec, - pub normals: Vec, - pub uvs: Vec, - pub indices: Vec, - pub triangle_materials: Vec, -} - -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); - } -} diff --git a/crates/vg_csg/src/primitives.rs b/crates/vg_csg/src/primitives.rs deleted file mode 100644 index 576955b..0000000 --- a/crates/vg_csg/src/primitives.rs +++ /dev/null @@ -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 -} diff --git a/crates/vg_csg/src/tree.rs b/crates/vg_csg/src/tree.rs deleted file mode 100644 index e32b581..0000000 --- a/crates/vg_csg/src/tree.rs +++ /dev/null @@ -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, - }, -} - -#[derive(Clone, Debug, Default)] -pub struct CsgTreeArena { - nodes: Vec, -} - -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, - 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, - op: CsgBranchOp, - children: impl IntoIterator, - ) -> 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, - ) -> 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 { - 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, - ) { - 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 { - 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 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); - } -} diff --git a/crates/vg_csg/tests/realtime_csg_parity.rs b/crates/vg_csg/tests/realtime_csg_parity.rs deleted file mode 100644 index fde5893..0000000 --- a/crates/vg_csg/tests/realtime_csg_parity.rs +++ /dev/null @@ -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::(), - 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); -} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 0e070ae..ecedff1 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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. diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 2cda13f..2790dd0 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml index f16750e..4b576bc 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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: