This commit is contained in:
parent
93ab6c2ea2
commit
02beeb7085
@ -13,6 +13,10 @@ use crate::ui::selection_ops::{focus_editor_camera_on_selection, reset_selected_
|
|||||||
use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM};
|
use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM};
|
||||||
use crate::ui::UiState;
|
use crate::ui::UiState;
|
||||||
use crate::viewport::brush_tool::start_draw_brush_tool;
|
use crate::viewport::brush_tool::start_draw_brush_tool;
|
||||||
|
use crate::viewport::{
|
||||||
|
intersect_selected_brushes, merge_selected_brushes, selected_brush_count,
|
||||||
|
subtract_selected_brushes,
|
||||||
|
};
|
||||||
|
|
||||||
/// Named editor command invokable from the palette and BRP.
|
/// Named editor command invokable from the palette and BRP.
|
||||||
pub trait EditorCommand: Send + Sync {
|
pub trait EditorCommand: Send + Sync {
|
||||||
@ -182,6 +186,9 @@ fn register_builtin_commands(mut registry: ResMut<EditorCommandRegistry>) {
|
|||||||
registry.register(Box::new(FocusActiveVolumesCommand));
|
registry.register(Box::new(FocusActiveVolumesCommand));
|
||||||
registry.register(Box::new(SelectVolumesAtCameraCommand));
|
registry.register(Box::new(SelectVolumesAtCameraCommand));
|
||||||
registry.register(Box::new(DrawBrushCommand));
|
registry.register(Box::new(DrawBrushCommand));
|
||||||
|
registry.register(Box::new(IntersectBrushesCommand));
|
||||||
|
registry.register(Box::new(MergeBrushesCommand));
|
||||||
|
registry.register(Box::new(SubtractBrushesCommand));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_palette_commands(world: &mut World) {
|
fn run_palette_commands(world: &mut World) {
|
||||||
@ -354,6 +361,66 @@ impl EditorCommand for DrawBrushCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct IntersectBrushesCommand;
|
||||||
|
|
||||||
|
impl EditorCommand for IntersectBrushesCommand {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"brush.intersect"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &str {
|
||||||
|
"Brush Intersect"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled_reason(&self, world: &World) -> Option<String> {
|
||||||
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(&self, world: &mut World) {
|
||||||
|
intersect_selected_brushes(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergeBrushesCommand;
|
||||||
|
|
||||||
|
impl EditorCommand for MergeBrushesCommand {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"brush.merge_convex"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &str {
|
||||||
|
"Brush Convex Merge"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled_reason(&self, world: &World) -> Option<String> {
|
||||||
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(&self, world: &mut World) {
|
||||||
|
merge_selected_brushes(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubtractBrushesCommand;
|
||||||
|
|
||||||
|
impl EditorCommand for SubtractBrushesCommand {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"brush.subtract"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(&self) -> &str {
|
||||||
|
"Brush Subtract"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled_reason(&self, world: &World) -> Option<String> {
|
||||||
|
(selected_brush_count(world) < 2).then(|| "Select at least two brushes".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(&self, world: &mut World) {
|
||||||
|
subtract_selected_brushes(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CreatePostProcessVolumeCommand;
|
struct CreatePostProcessVolumeCommand;
|
||||||
|
|
||||||
impl EditorCommand for CreatePostProcessVolumeCommand {
|
impl EditorCommand for CreatePostProcessVolumeCommand {
|
||||||
|
|||||||
248
crates/editor/src/viewport/brush_csg.rs
Normal file
248
crates/editor/src/viewport/brush_csg.rs
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
//! MVP brush CSG operations for convex cuboid/prism blockout brushes.
|
||||||
|
|
||||||
|
use bevy::math::Affine3A;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use shared::{BrushDesc, LevelObject};
|
||||||
|
|
||||||
|
use crate::history::{
|
||||||
|
delete_entities_with_history, set_brush_with_history, set_transform_with_history,
|
||||||
|
};
|
||||||
|
use crate::scene_io::SceneIo;
|
||||||
|
use crate::ui::UiState;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
struct BrushBounds {
|
||||||
|
min: Vec3,
|
||||||
|
max: Vec3,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrushBounds {
|
||||||
|
fn union(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
min: self.min.min(other.min),
|
||||||
|
max: self.max.max(other.max),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intersection(self, other: Self) -> Option<Self> {
|
||||||
|
let min = self.min.max(other.min);
|
||||||
|
let max = self.max.min(other.max);
|
||||||
|
((max.x - min.x) > 0.001 && (max.y - min.y) > 0.001 && (max.z - min.z) > 0.001)
|
||||||
|
.then_some(Self { min, max })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn center(self) -> Vec3 {
|
||||||
|
(self.min + self.max) * 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(self) -> Vec3 {
|
||||||
|
self.max - self.min
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume(self) -> f32 {
|
||||||
|
let size = self.size();
|
||||||
|
size.x.max(0.0) * size.y.max(0.0) * size.z.max(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_brush_count(world: &World) -> usize {
|
||||||
|
world
|
||||||
|
.resource::<UiState>()
|
||||||
|
.selected_entities
|
||||||
|
.iter()
|
||||||
|
.filter(|entity| world.get::<BrushDesc>(*entity).is_some())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn intersect_selected_brushes(world: &mut World) {
|
||||||
|
run_bounds_csg(world, BrushCsgOp::Intersect);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge_selected_brushes(world: &mut World) {
|
||||||
|
run_bounds_csg(world, BrushCsgOp::Merge);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subtract_selected_brushes(world: &mut World) {
|
||||||
|
run_bounds_csg(world, BrushCsgOp::Subtract);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum BrushCsgOp {
|
||||||
|
Intersect,
|
||||||
|
Merge,
|
||||||
|
Subtract,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_bounds_csg(world: &mut World, op: BrushCsgOp) {
|
||||||
|
let selected: Vec<Entity> = world
|
||||||
|
.resource::<UiState>()
|
||||||
|
.selected_entities
|
||||||
|
.iter()
|
||||||
|
.filter(|entity| {
|
||||||
|
world.get::<LevelObject>(*entity).is_some() && world.get::<BrushDesc>(*entity).is_some()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if selected.len() < 2 {
|
||||||
|
set_status(world, "Select at least two brushes for CSG");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(primary_bounds) = brush_world_bounds(world, selected[0]) else {
|
||||||
|
set_status(world, "Primary brush has no valid bounds");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match op {
|
||||||
|
BrushCsgOp::Merge => selected
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|entity| brush_world_bounds(world, *entity))
|
||||||
|
.fold(primary_bounds, BrushBounds::union),
|
||||||
|
BrushCsgOp::Intersect => {
|
||||||
|
let mut result = primary_bounds;
|
||||||
|
for entity in selected.iter().skip(1) {
|
||||||
|
let Some(bounds) = brush_world_bounds(world, *entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(intersection) = result.intersection(bounds) else {
|
||||||
|
set_status(world, "Brush intersect failed: no overlap");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
result = intersection;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
BrushCsgOp::Subtract => {
|
||||||
|
let Some(cutter_bounds) = brush_world_bounds(world, selected[1]) else {
|
||||||
|
set_status(world, "Subtract failed: cutter brush has no valid bounds");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(overlap) = primary_bounds.intersection(cutter_bounds) else {
|
||||||
|
set_status(world, "Subtract failed: brushes do not overlap");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = largest_remaining_slab(primary_bounds, overlap) else {
|
||||||
|
set_status(world, "Subtract failed: result would be empty");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
result
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_bounds_result(world, selected[0], result);
|
||||||
|
if matches!(op, BrushCsgOp::Merge | BrushCsgOp::Intersect) {
|
||||||
|
delete_entities_with_history(world, &selected[1..]);
|
||||||
|
}
|
||||||
|
set_status(
|
||||||
|
world,
|
||||||
|
match op {
|
||||||
|
BrushCsgOp::Intersect => "Brush intersect committed",
|
||||||
|
BrushCsgOp::Merge => "Brush convex merge committed",
|
||||||
|
BrushCsgOp::Subtract => "Brush subtract committed",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_bounds_result(world: &mut World, entity: Entity, bounds: BrushBounds) {
|
||||||
|
let center = bounds.center();
|
||||||
|
let size = bounds.size();
|
||||||
|
let new_brush = BrushDesc::cuboid(size);
|
||||||
|
let old_transform = world.get::<Transform>(entity).copied().unwrap_or_default();
|
||||||
|
let mut new_transform = old_transform;
|
||||||
|
new_transform.translation = center;
|
||||||
|
new_transform.rotation = Quat::IDENTITY;
|
||||||
|
new_transform.scale = Vec3::ONE;
|
||||||
|
set_transform_with_history(world, entity, old_transform, new_transform);
|
||||||
|
set_brush_with_history(world, entity, new_brush);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brush_world_bounds(world: &World, entity: Entity) -> Option<BrushBounds> {
|
||||||
|
let brush = world.get::<BrushDesc>(entity)?;
|
||||||
|
let transform = world
|
||||||
|
.get::<GlobalTransform>(entity)
|
||||||
|
.map(|global| global.affine())
|
||||||
|
.or_else(|| {
|
||||||
|
world
|
||||||
|
.get::<Transform>(entity)
|
||||||
|
.map(|transform| transform.compute_affine())
|
||||||
|
})
|
||||||
|
.unwrap_or(Affine3A::IDENTITY);
|
||||||
|
let mut min = Vec3::splat(f32::INFINITY);
|
||||||
|
let mut max = Vec3::splat(f32::NEG_INFINITY);
|
||||||
|
let mut found = false;
|
||||||
|
for vertex in brush
|
||||||
|
.faces
|
||||||
|
.iter()
|
||||||
|
.flat_map(|face| face.vertices.iter().copied())
|
||||||
|
{
|
||||||
|
let world_vertex = transform.transform_point3(vertex);
|
||||||
|
min = min.min(world_vertex);
|
||||||
|
max = max.max(world_vertex);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
found.then_some(BrushBounds { min, max })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn largest_remaining_slab(source: BrushBounds, overlap: BrushBounds) -> Option<BrushBounds> {
|
||||||
|
let candidates = [
|
||||||
|
BrushBounds {
|
||||||
|
min: source.min,
|
||||||
|
max: Vec3::new(overlap.min.x, source.max.y, source.max.z),
|
||||||
|
},
|
||||||
|
BrushBounds {
|
||||||
|
min: Vec3::new(overlap.max.x, source.min.y, source.min.z),
|
||||||
|
max: source.max,
|
||||||
|
},
|
||||||
|
BrushBounds {
|
||||||
|
min: source.min,
|
||||||
|
max: Vec3::new(source.max.x, overlap.min.y, source.max.z),
|
||||||
|
},
|
||||||
|
BrushBounds {
|
||||||
|
min: Vec3::new(source.min.x, overlap.max.y, source.min.z),
|
||||||
|
max: source.max,
|
||||||
|
},
|
||||||
|
BrushBounds {
|
||||||
|
min: source.min,
|
||||||
|
max: Vec3::new(source.max.x, source.max.y, overlap.min.z),
|
||||||
|
},
|
||||||
|
BrushBounds {
|
||||||
|
min: Vec3::new(source.min.x, source.min.y, overlap.max.z),
|
||||||
|
max: source.max,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.filter(|bounds| bounds.size().cmpgt(Vec3::splat(0.001)).all())
|
||||||
|
.max_by(|a, b| a.volume().total_cmp(&b.volume()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(world: &mut World, status: impl Into<String>) {
|
||||||
|
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
|
||||||
|
scene_io.status = status.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn bounds(min: Vec3, max: Vec3) -> BrushBounds {
|
||||||
|
BrushBounds { min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intersects_overlapping_bounds() {
|
||||||
|
let a = bounds(Vec3::ZERO, Vec3::splat(2.0));
|
||||||
|
let b = bounds(Vec3::ONE, Vec3::splat(3.0));
|
||||||
|
assert_eq!(a.intersection(b), Some(bounds(Vec3::ONE, Vec3::splat(2.0))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtract_picks_largest_remaining_slab() {
|
||||||
|
let source = bounds(Vec3::ZERO, Vec3::new(4.0, 2.0, 2.0));
|
||||||
|
let overlap = bounds(Vec3::ZERO, Vec3::new(1.0, 2.0, 2.0));
|
||||||
|
let result = largest_remaining_slab(source, overlap).expect("remaining slab");
|
||||||
|
assert_eq!(result.min, Vec3::new(1.0, 0.0, 0.0));
|
||||||
|
assert_eq!(result.max, source.max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
//! Viewport chrome, editor camera, selection, gizmos, and render views.
|
//! Viewport chrome, editor camera, selection, gizmos, and render views.
|
||||||
|
|
||||||
pub mod actor_icons;
|
pub mod actor_icons;
|
||||||
|
pub mod brush_csg;
|
||||||
pub mod brush_edit;
|
pub mod brush_edit;
|
||||||
pub mod brush_tool;
|
pub mod brush_tool;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
@ -14,6 +15,10 @@ pub mod viewport_mode;
|
|||||||
pub mod visualizers;
|
pub mod visualizers;
|
||||||
|
|
||||||
pub use actor_icons::ActorIconsPlugin;
|
pub use actor_icons::ActorIconsPlugin;
|
||||||
|
pub use brush_csg::{
|
||||||
|
intersect_selected_brushes, merge_selected_brushes, selected_brush_count,
|
||||||
|
subtract_selected_brushes,
|
||||||
|
};
|
||||||
pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection};
|
pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection};
|
||||||
pub use brush_tool::{BrushToolPlugin, BrushToolState};
|
pub use brush_tool::{BrushToolPlugin, BrushToolState};
|
||||||
pub use panel::*;
|
pub use panel::*;
|
||||||
|
|||||||
@ -55,7 +55,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
- **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live.
|
- **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live.
|
||||||
- **Rendering** — `GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD.
|
- **Rendering** — `GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD.
|
||||||
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/source-material selectors, visibility, and shadow flags. Actor material authoring lives in the `Authoring Material` component, whose texture refs use Asset Browser picker/drop controls. Empty source material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
|
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/source-material selectors, visibility, and shadow flags. Actor material authoring lives in the `Authoring Material` component, whose texture refs use Asset Browser picker/drop controls. Empty source material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
|
||||||
- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `brush.draw`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands.
|
- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `brush.draw`, `brush.subtract`, `brush.intersect`, `brush.merge_convex`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands.
|
||||||
- **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active.
|
- **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active.
|
||||||
- **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`.
|
- **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`.
|
||||||
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders.
|
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders.
|
||||||
|
|||||||
@ -17,8 +17,18 @@ With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip
|
|||||||
|
|
||||||
Vertex, edge, and face selections can be dragged on the viewport floor plane. The editor moves matching duplicated corner vertices across all brush faces so simple cube/prism brushes stay welded, recomputes face planes, and commits one undoable brush edit when the drag releases. Clip mode currently provides element visualization/selection only; split application is future CSG work.
|
Vertex, edge, and face selections can be dragged on the viewport floor plane. The editor moves matching duplicated corner vertices across all brush faces so simple cube/prism brushes stay welded, recomputes face planes, and commits one undoable brush edit when the drag releases. Clip mode currently provides element visualization/selection only; split application is future CSG work.
|
||||||
|
|
||||||
|
## Boolean Operations
|
||||||
|
|
||||||
|
The command palette exposes `brush.subtract`, `brush.intersect`, and `brush.merge_convex` for selected brush actors. The current implementation is a conservative bounds-based MVP intended for cuboid/prism blockout brushes:
|
||||||
|
|
||||||
|
- **Intersect** replaces the primary selected brush with the overlapping bounds of all selected brushes and deletes the other selected brushes.
|
||||||
|
- **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.
|
||||||
|
|
||||||
## Current Limits
|
## Current Limits
|
||||||
|
|
||||||
- Only convex authored faces are supported by the mesh builder.
|
- Only convex authored faces are supported by the mesh builder.
|
||||||
- Subtractive brushes are stored as markers but are not evaluated as CSG.
|
- Subtractive brush markers are stored but not automatically evaluated.
|
||||||
- Face, edge, vertex, clipping, and primitive brush creation tools are future roadmap work.
|
- Clip split application and arbitrary-face CSG remain future roadmap work.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user