This commit is contained in:
parent
4b6617f7f2
commit
f09d35e54e
@ -8,7 +8,9 @@ use shared::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::camera::EditorCamera;
|
use crate::camera::EditorCamera;
|
||||||
use crate::history::{push_command, EditorCommand};
|
use crate::history::{
|
||||||
|
push_command, set_brush_with_history, set_transform_with_history, EditorCommand,
|
||||||
|
};
|
||||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||||
use crate::scene_io::SceneIo;
|
use crate::scene_io::SceneIo;
|
||||||
use crate::selection::ViewportClick;
|
use crate::selection::ViewportClick;
|
||||||
@ -105,6 +107,7 @@ impl Plugin for BrushEditPlugin {
|
|||||||
brush_edit_hotkeys,
|
brush_edit_hotkeys,
|
||||||
brush_element_pick,
|
brush_element_pick,
|
||||||
brush_element_drag,
|
brush_element_drag,
|
||||||
|
brush_clip_commit,
|
||||||
draw_brush_edit_overlays,
|
draw_brush_edit_overlays,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
@ -349,6 +352,44 @@ fn clear_drag(drag: &mut BrushElementDrag) {
|
|||||||
drag.changed = false;
|
drag.changed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn brush_clip_commit(
|
||||||
|
mode: Res<BrushEditMode>,
|
||||||
|
selection: Res<BrushElementSelection>,
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut scene_io: ResMut<SceneIo>,
|
||||||
|
mut commands: Commands,
|
||||||
|
brushes: Query<(&BrushDesc, &Transform)>,
|
||||||
|
) {
|
||||||
|
if !matches!(*mode, BrushEditMode::Clip) || !keys.just_pressed(KeyCode::Enter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(brush_entity) = selection.brush else {
|
||||||
|
scene_io.status = "Clip failed: select a brush face".into();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(face_id) = single_selected_face(&selection) else {
|
||||||
|
scene_io.status = "Clip failed: select exactly one brush face".into();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok((brush, transform)) = brushes.get(brush_entity) else {
|
||||||
|
scene_io.status = "Clip failed: selected brush no longer exists".into();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(result) = clip_preview_result(brush, transform, face_id) else {
|
||||||
|
scene_io.status = "Clip failed: selected face cannot define a clip plane".into();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
scene_io.status = "Brush clip committed".into();
|
||||||
|
commands.queue(move |world: &mut World| {
|
||||||
|
let old_transform = world
|
||||||
|
.get::<Transform>(brush_entity)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
set_transform_with_history(world, brush_entity, old_transform, result.transform);
|
||||||
|
set_brush_with_history(world, brush_entity, result.brush);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_brush_edit_overlays(
|
fn draw_brush_edit_overlays(
|
||||||
mode: Res<BrushEditMode>,
|
mode: Res<BrushEditMode>,
|
||||||
selection: Res<BrushElementSelection>,
|
selection: Res<BrushElementSelection>,
|
||||||
@ -373,6 +414,13 @@ fn draw_brush_edit_overlays(
|
|||||||
active,
|
active,
|
||||||
&mut gizmos,
|
&mut gizmos,
|
||||||
);
|
);
|
||||||
|
if matches!(*mode, BrushEditMode::Clip) && active {
|
||||||
|
if let Some(face_id) = single_selected_face(&selection) {
|
||||||
|
if let Some(result) = clip_preview_result_from_global(brush, transform, face_id) {
|
||||||
|
draw_clip_preview(&mut gizmos, result.world_min, result.world_max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,6 +492,162 @@ fn draw_brush_elements(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ClipPreviewResult {
|
||||||
|
brush: BrushDesc,
|
||||||
|
transform: Transform,
|
||||||
|
world_min: Vec3,
|
||||||
|
world_max: Vec3,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_selected_face(selection: &BrushElementSelection) -> Option<&ComponentInstanceId> {
|
||||||
|
let mut faces = selection
|
||||||
|
.elements
|
||||||
|
.iter()
|
||||||
|
.filter_map(|element| match element {
|
||||||
|
BrushElementKey::Face { face } => Some(face),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let face = faces.next()?;
|
||||||
|
faces.next().is_none().then_some(face)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip_preview_result(
|
||||||
|
brush: &BrushDesc,
|
||||||
|
transform: &Transform,
|
||||||
|
face_id: &ComponentInstanceId,
|
||||||
|
) -> Option<ClipPreviewResult> {
|
||||||
|
let bounds = local_brush_bounds(brush)?;
|
||||||
|
let face = brush.faces.iter().find(|face| &face.id == face_id)?;
|
||||||
|
let normal = face.plane.normal.try_normalize()?;
|
||||||
|
let (axis, sign) = dominant_axis(normal)?;
|
||||||
|
let mut min = bounds.0;
|
||||||
|
let mut max = bounds.1;
|
||||||
|
let center = (min + max) * 0.5;
|
||||||
|
match (axis, sign.is_sign_positive()) {
|
||||||
|
(0, true) => min.x = center.x,
|
||||||
|
(0, false) => max.x = center.x,
|
||||||
|
(1, true) => min.y = center.y,
|
||||||
|
(1, false) => max.y = center.y,
|
||||||
|
(2, true) => min.z = center.z,
|
||||||
|
(2, false) => max.z = center.z,
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
if (max - min).cmple(Vec3::splat(0.001)).any() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let local_center = (min + max) * 0.5;
|
||||||
|
let local_size = max - min;
|
||||||
|
let mut new_transform = *transform;
|
||||||
|
new_transform.translation = transform.compute_affine().transform_point3(local_center);
|
||||||
|
let brush = BrushDesc::cuboid(local_size);
|
||||||
|
let (world_min, world_max) = world_bounds_for_local_box(min, max, transform);
|
||||||
|
Some(ClipPreviewResult {
|
||||||
|
brush,
|
||||||
|
transform: new_transform,
|
||||||
|
world_min,
|
||||||
|
world_max,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clip_preview_result_from_global(
|
||||||
|
brush: &BrushDesc,
|
||||||
|
transform: &GlobalTransform,
|
||||||
|
face_id: &ComponentInstanceId,
|
||||||
|
) -> Option<ClipPreviewResult> {
|
||||||
|
let (scale, rotation, translation) = transform.to_scale_rotation_translation();
|
||||||
|
let local_transform = Transform {
|
||||||
|
translation,
|
||||||
|
rotation,
|
||||||
|
scale,
|
||||||
|
};
|
||||||
|
clip_preview_result(brush, &local_transform, face_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_brush_bounds(brush: &BrushDesc) -> Option<(Vec3, Vec3)> {
|
||||||
|
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())
|
||||||
|
{
|
||||||
|
if !vertex.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
min = min.min(vertex);
|
||||||
|
max = max.max(vertex);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
found.then_some((min, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dominant_axis(normal: Vec3) -> Option<(usize, f32)> {
|
||||||
|
let abs = normal.abs();
|
||||||
|
if abs.max_element() <= 0.0001 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if abs.x >= abs.y && abs.x >= abs.z {
|
||||||
|
Some((0, normal.x.signum()))
|
||||||
|
} else if abs.y >= abs.z {
|
||||||
|
Some((1, normal.y.signum()))
|
||||||
|
} else {
|
||||||
|
Some((2, normal.z.signum()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn world_bounds_for_local_box(min: Vec3, max: Vec3, transform: &Transform) -> (Vec3, Vec3) {
|
||||||
|
let affine = transform.compute_affine();
|
||||||
|
let mut world_min = Vec3::splat(f32::INFINITY);
|
||||||
|
let mut world_max = Vec3::splat(f32::NEG_INFINITY);
|
||||||
|
for corner in [
|
||||||
|
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),
|
||||||
|
] {
|
||||||
|
let world = affine.transform_point3(corner);
|
||||||
|
world_min = world_min.min(world);
|
||||||
|
world_max = world_max.max(world);
|
||||||
|
}
|
||||||
|
(world_min, world_max)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_clip_preview(gizmos: &mut Gizmos, min: Vec3, max: Vec3) {
|
||||||
|
let color = Color::srgba(0.95, 0.35, 1.0, 0.95);
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
for (a, b) in [
|
||||||
|
(0, 1),
|
||||||
|
(1, 2),
|
||||||
|
(2, 3),
|
||||||
|
(3, 0),
|
||||||
|
(4, 5),
|
||||||
|
(5, 6),
|
||||||
|
(6, 7),
|
||||||
|
(7, 4),
|
||||||
|
(0, 4),
|
||||||
|
(1, 5),
|
||||||
|
(2, 6),
|
||||||
|
(3, 7),
|
||||||
|
] {
|
||||||
|
gizmos.line(corners[a], corners[b], color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn selected_brush_entity(
|
fn selected_brush_entity(
|
||||||
ui_state: &UiState,
|
ui_state: &UiState,
|
||||||
brushes: &Query<(), With<BrushDesc>>,
|
brushes: &Query<(), With<BrushDesc>>,
|
||||||
@ -816,4 +1020,15 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(!validate_brush(&brush).is_valid());
|
assert!(!validate_brush(&brush).is_valid());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_preview_keeps_half_along_selected_face_normal() {
|
||||||
|
let brush = BrushDesc::default();
|
||||||
|
let face = brush.faces[0].id.clone();
|
||||||
|
let result = clip_preview_result(&brush, &Transform::default(), &face).expect("clip");
|
||||||
|
let (min, max) = local_brush_bounds(&result.brush).expect("bounds");
|
||||||
|
assert_eq!(max - min, Vec3::new(0.5, 1.0, 1.0));
|
||||||
|
assert_eq!(result.transform.translation, Vec3::new(0.25, 0.0, 0.0));
|
||||||
|
assert!(validate_brush(&result.brush).is_valid());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,9 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc`
|
|||||||
|
|
||||||
With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip modes. Element modes show viewport overlays for the selected brush, route LMB to element picking, support Shift+LMB multi-select, and display a `Brush: <mode>` badge. `Esc` returns to object mode.
|
With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip modes. Element modes show viewport overlays for the selected brush, route LMB to element picking, support Shift+LMB multi-select, and display a `Brush: <mode>` badge. `Esc` returns to object mode.
|
||||||
|
|
||||||
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, validates the result, and commits one undoable brush edit when the drag releases. Invalid drag results are rejected with operator status text and reverted to the pre-drag brush. 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, validates the result, and commits one undoable brush edit when the drag releases. Invalid drag results are rejected with operator status text and reverted to the pre-drag brush.
|
||||||
|
|
||||||
|
Clip mode is a bounds-based MVP for cuboid/prism blockout. Select one face in Clip mode to preview the half-brush result along that face's dominant normal axis; the magenta wireframe shows the pending trimmed bounds. **Enter** commits the clip through history and **Esc** returns to Object mode.
|
||||||
|
|
||||||
When one or more faces are selected in Face mode, the Brush inspector shows selected-face controls for UV offset, UV scale, UV rotation, material ref, and texture ref. Material and texture rows use Asset Browser-aware picker controls with clear, locate, browse, and compatible drag/drop assignment. These fields edit the persisted `BrushFaceDesc` data through undoable brush updates. Face refs cache loadable source paths when available; hydration groups faces by material/texture path and spawns separate generated mesh children for those batches.
|
When one or more faces are selected in Face mode, the Brush inspector shows selected-face controls for UV offset, UV scale, UV rotation, material ref, and texture ref. Material and texture rows use Asset Browser-aware picker controls with clear, locate, browse, and compatible drag/drop assignment. These fields edit the persisted `BrushFaceDesc` data through undoable brush updates. Face refs cache loadable source paths when available; hydration groups faces by material/texture path and spawns separate generated mesh children for those batches.
|
||||||
|
|
||||||
@ -37,5 +39,5 @@ These operations validate selected brushes before previewing results and validat
|
|||||||
- Only convex authored faces are supported by the mesh builder.
|
- Only convex authored faces are supported by the mesh builder.
|
||||||
- Brush diagnostics currently cover face validity, plane normals, inverted face normals, finite vertices, duplicate vertices, degenerate area, open/non-manifold edges, and UV scale warnings.
|
- Brush diagnostics currently cover face validity, plane normals, inverted face normals, finite vertices, duplicate vertices, degenerate area, open/non-manifold edges, and UV scale warnings.
|
||||||
- Subtractive brush markers are stored but not automatically evaluated.
|
- Subtractive brush markers are stored but not automatically evaluated.
|
||||||
- Clip split application and arbitrary-face CSG remain future roadmap work.
|
- Arbitrary plane clipping, split-into-two output, and arbitrary-face CSG remain future roadmap work.
|
||||||
- Imported source-material subasset refs persist for future material-preserving CSG, but only face refs with loadable source paths can drive brush material hydration today.
|
- Imported source-material subasset refs persist for future material-preserving CSG, but only face refs with loadable source paths can drive brush material hydration today.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user