From 8abe47047df9e714da59832fe00b000fe304e014 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 6 Jun 2026 06:27:48 -0400 Subject: [PATCH] Validate brush CSG inputs --- crates/editor/src/viewport/brush_csg.rs | 55 ++++++++++++++++++++++--- docs/editor/brushes.md | 2 +- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/viewport/brush_csg.rs b/crates/editor/src/viewport/brush_csg.rs index 96f2a92..e342020 100644 --- a/crates/editor/src/viewport/brush_csg.rs +++ b/crates/editor/src/viewport/brush_csg.rs @@ -2,7 +2,10 @@ use bevy::math::Affine3A; use bevy::prelude::*; -use shared::{BrushDesc, LevelObject}; +use shared::{ + brush_math::{validate_brush, BrushDiagnosticSeverity}, + BrushDesc, LevelObject, +}; use crate::history::{ delete_entities_with_history, set_brush_with_history, set_transform_with_history, @@ -86,6 +89,22 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) { set_status(world, "Select at least two brushes for CSG"); return; } + for entity in &selected { + let Some(brush) = world.get::(*entity) else { + continue; + }; + let validation = validate_brush(brush); + if !validation.is_valid() { + set_status( + world, + format!( + "CSG failed: selected brush is invalid: {}", + first_brush_error(&validation) + ), + ); + return; + } + } let Some(primary_bounds) = brush_world_bounds(world, selected[0]) else { set_status(world, "Primary brush has no valid bounds"); @@ -129,7 +148,21 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) { } }; - apply_bounds_result(world, selected[0], result); + let size = result.size(); + let new_brush = BrushDesc::cuboid(size); + let validation = validate_brush(&new_brush); + if !validation.is_valid() { + set_status( + world, + format!( + "CSG failed: result is invalid: {}", + first_brush_error(&validation) + ), + ); + return; + } + + apply_bounds_result(world, selected[0], result, new_brush); if matches!(op, BrushCsgOp::Merge | BrushCsgOp::Intersect) { delete_entities_with_history(world, &selected[1..]); } @@ -143,10 +176,22 @@ fn run_bounds_csg(world: &mut World, op: BrushCsgOp) { ); } -fn apply_bounds_result(world: &mut World, entity: Entity, bounds: BrushBounds) { +fn first_brush_error(report: &shared::brush_math::BrushValidationReport) -> &str { + report + .diagnostics + .iter() + .find(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Error) + .map(|diagnostic| diagnostic.message.as_str()) + .unwrap_or("Brush geometry is invalid.") +} + +fn apply_bounds_result( + world: &mut World, + entity: Entity, + bounds: BrushBounds, + new_brush: BrushDesc, +) { let center = bounds.center(); - let size = bounds.size(); - let new_brush = BrushDesc::cuboid(size); let old_transform = world.get::(entity).copied().unwrap_or_default(); let mut new_transform = old_transform; new_transform.translation = center; diff --git a/docs/editor/brushes.md b/docs/editor/brushes.md index 28ab47f..4fc94f7 100644 --- a/docs/editor/brushes.md +++ b/docs/editor/brushes.md @@ -29,7 +29,7 @@ The command palette exposes `brush.subtract`, `brush.intersect`, and `brush.merg - **Convex Merge** replaces the primary selected brush with the bounding cuboid union and deletes the other selected brushes. - **Subtract** clips the primary selected brush to the largest remaining axis-aligned slab after subtracting the second selected brush; the cutter remains in the scene. -These operations commit through history but are not full arbitrary-face CSG yet. Full convex clipping, multi-output subtraction, and material-preserving face matching remain future work. +These operations validate selected brushes before applying results and validate the generated result before mutating or deleting scene actors. Failed operations report status text and leave the scene untouched. Successful operations commit through history but are not full arbitrary-face CSG yet. Full convex clipping, multi-output subtraction, and material-preserving face matching remain future work. ## Current Limits