V2 live editor #1
Generated
+2923
-64
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ members = [
|
||||
"crates/bevy_forge_core",
|
||||
"crates/bevy_forge_editor",
|
||||
"crates/bevy_forge_cli",
|
||||
"crates/bevy_forge_runtime",
|
||||
]
|
||||
exclude = ["compat/bsn-smoke"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -24,6 +24,20 @@ the output easy to review, diff and check in.
|
||||
Live in-game editing (a Bevy plugin with a remote protocol) is a roadmap item, not part of this
|
||||
editor.
|
||||
|
||||
## v2 live editor (M0 spike)
|
||||
|
||||
The `v2-live-editor` branch moves editing into the game as a **Bevy 0.20 plugin** with native Bevy
|
||||
UI and official `bevy_ui_widgets` — no egui in the runtime crate. M0 ships the `EditorPlugin`, a
|
||||
fixed editor shell, live hierarchy, reflection inspector, translate gizmo, editor camera and
|
||||
deterministic screenshots at 1440×900 / 1000×640.
|
||||
|
||||
```bash
|
||||
cargo run -p bevy_forge_runtime --example editor_spike
|
||||
```
|
||||
|
||||
See [docs/m0-spike.md](docs/m0-spike.md) for controls, architecture and M0 limits. The v1 standalone
|
||||
editor below remains the current released workflow.
|
||||
|
||||
## Two viewports
|
||||
|
||||
| 2D scene editing | 3D wireframe preview |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "bevy_forge_runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
description = "Live in-game scene editor plugin for Bevy 0.20 (M0 spike)"
|
||||
|
||||
[dependencies]
|
||||
# Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use
|
||||
# `Tonemapping::AcesFitted` instead of the LUT-based default.
|
||||
bevy = { version = "0.20", default-features = false, features = [
|
||||
"std",
|
||||
"async_executor",
|
||||
"multi_threaded",
|
||||
"bevy_asset",
|
||||
"bevy_log",
|
||||
"bevy_color",
|
||||
"bevy_image",
|
||||
"bevy_camera",
|
||||
"bevy_light",
|
||||
"bevy_mesh",
|
||||
"bevy_pbr",
|
||||
"bevy_render",
|
||||
"bevy_core_pipeline",
|
||||
"bevy_ui",
|
||||
"bevy_ui_render",
|
||||
"bevy_ui_widgets",
|
||||
"bevy_text",
|
||||
"bevy_input_focus",
|
||||
"bevy_window",
|
||||
"bevy_winit",
|
||||
"bevy_picking",
|
||||
"ui_picking",
|
||||
"bevy_gizmos",
|
||||
"bevy_gizmos_render",
|
||||
"default_font",
|
||||
"png",
|
||||
"x11",
|
||||
"wayland",
|
||||
"reflect_auto_register",
|
||||
] }
|
||||
@@ -0,0 +1,155 @@
|
||||
//! M0 spike demo: a small hardcoded 3D scene plus the Bevy Forge editor plugin.
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo run -p bevy_forge_runtime --example editor_spike
|
||||
//!
|
||||
//! Deterministic screenshot:
|
||||
//! FORGE_SCREENSHOT=$PWD/.swarm/m0/evidence/m0-1440x900.png \
|
||||
//! FORGE_SCREENSHOT_SIZE=1440x900 \
|
||||
//! cargo run -p bevy_forge_runtime --example editor_spike
|
||||
|
||||
use bevy::{prelude::*, render::view::Tonemapping, window::WindowResolution};
|
||||
use bevy_forge_runtime::{EditorPlugin, Selection};
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Bevy Forge — M0 Spike".into(),
|
||||
resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
}))
|
||||
.add_plugins(EditorPlugin)
|
||||
.add_systems(Startup, demo_scene)
|
||||
.run();
|
||||
}
|
||||
|
||||
fn demo_scene(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
mut selection: ResMut<Selection>,
|
||||
) {
|
||||
commands.insert_resource(GlobalAmbientLight {
|
||||
color: Color::WHITE,
|
||||
brightness: 600.0,
|
||||
..default()
|
||||
});
|
||||
|
||||
let ground = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.16, 0.20, 0.17),
|
||||
perceptual_roughness: 0.95,
|
||||
metallic: 0.0,
|
||||
..default()
|
||||
});
|
||||
commands.spawn((
|
||||
Name::new("Ground"),
|
||||
Mesh3d(meshes.add(Plane3d::default().mesh().size(14.0, 14.0))),
|
||||
MeshMaterial3d(ground),
|
||||
Transform::from_xyz(0.0, -0.02, 0.0),
|
||||
));
|
||||
|
||||
let hero_mat = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.85, 0.55, 0.25),
|
||||
perceptual_roughness: 0.65,
|
||||
metallic: 0.0,
|
||||
..default()
|
||||
});
|
||||
let hat_mat = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.25, 0.45, 0.75),
|
||||
perceptual_roughness: 0.5,
|
||||
metallic: 0.0,
|
||||
..default()
|
||||
});
|
||||
let hero = commands
|
||||
.spawn((
|
||||
Name::new("Hero Cube"),
|
||||
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
|
||||
MeshMaterial3d(hero_mat),
|
||||
Transform::from_xyz(0.0, 0.5, 0.0),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent.spawn((
|
||||
Name::new("Hat"),
|
||||
Mesh3d(meshes.add(Cuboid::new(0.55, 0.18, 0.55))),
|
||||
MeshMaterial3d(hat_mat),
|
||||
Transform::from_xyz(0.0, 0.62, 0.0),
|
||||
));
|
||||
})
|
||||
.id();
|
||||
|
||||
let red_mat = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.75, 0.18, 0.16),
|
||||
perceptual_roughness: 0.7,
|
||||
..default()
|
||||
});
|
||||
let blue_mat = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.18, 0.35, 0.78),
|
||||
perceptual_roughness: 0.7,
|
||||
..default()
|
||||
});
|
||||
commands.spawn((
|
||||
Name::new("Red Cube"),
|
||||
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
|
||||
MeshMaterial3d(red_mat),
|
||||
Transform::from_xyz(-2.6, 0.4, 0.6),
|
||||
));
|
||||
commands.spawn((
|
||||
Name::new("Blue Cube"),
|
||||
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
|
||||
MeshMaterial3d(blue_mat),
|
||||
Transform::from_xyz(2.6, 0.4, -0.6),
|
||||
));
|
||||
|
||||
let crate_mat = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.55, 0.38, 0.20),
|
||||
perceptual_roughness: 0.9,
|
||||
..default()
|
||||
});
|
||||
let stack = commands
|
||||
.spawn((
|
||||
Name::new("Crate Stack"),
|
||||
Transform::from_xyz(0.0, 0.0, -2.8),
|
||||
Visibility::default(),
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent.spawn((
|
||||
Name::new("Crate A"),
|
||||
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
|
||||
MeshMaterial3d(crate_mat.clone()),
|
||||
Transform::from_xyz(0.0, 0.4, 0.0),
|
||||
));
|
||||
parent.spawn((
|
||||
Name::new("Crate B"),
|
||||
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
|
||||
MeshMaterial3d(crate_mat),
|
||||
Transform::from_xyz(0.0, 1.2, 0.0),
|
||||
));
|
||||
})
|
||||
.id();
|
||||
|
||||
commands.spawn((
|
||||
Name::new("Sun"),
|
||||
DirectionalLight {
|
||||
illuminance: 12_000.0,
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y),
|
||||
));
|
||||
|
||||
// Placeholder camera. T04 adopts it for the editor orbit/pan/zoom controls.
|
||||
commands.spawn((
|
||||
Name::new("Editor Camera"),
|
||||
Camera3d::default(),
|
||||
Tonemapping::AcesFitted,
|
||||
Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y),
|
||||
));
|
||||
|
||||
selection.0 = Some(hero);
|
||||
info!(
|
||||
"demo scene ready: hero={hero:?}, stack={stack:?}, selected={:?}",
|
||||
selection.0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
//! Translate gizmo and editor camera.
|
||||
//!
|
||||
//! M0 scope:
|
||||
//! - adopt the host app's placeholder `Camera3d` (or spawn one) and drive it with
|
||||
//! right-drag orbit, middle-drag pan and wheel zoom;
|
||||
//! - draw world-aligned RGB axes on the selected entity's `GlobalTransform`;
|
||||
//! - pick an axis in screen space, then drag it with a ray/plane calculation and
|
||||
//! write the result back to the selected entity's `Transform.translation`.
|
||||
//!
|
||||
//! M0 limit: the world-space drag delta is converted into parent-local space
|
||||
//! with the parent's *inverse rotation* only. Parent scale is ignored, so a
|
||||
//! scaled parent makes the gizmo travel at the wrong world-space rate. Generic
|
||||
//! component editing, local-space gizmos and snapping are M1+.
|
||||
|
||||
use bevy::{
|
||||
input::mouse::{MouseMotion, MouseScrollUnit, MouseWheel},
|
||||
prelude::*,
|
||||
render::view::Tonemapping,
|
||||
window::PrimaryWindow,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
plugin::{editor_active, Selection},
|
||||
ui::EditorLayout,
|
||||
};
|
||||
|
||||
/// Screen-space distance (logical pixels) within which a click hits a gizmo axis.
|
||||
pub const AXIS_PICK_RADIUS_PX: f32 = 12.0;
|
||||
|
||||
const GIZMO_LENGTH_PER_DISTANCE: f32 = 0.18;
|
||||
const GIZMO_MIN_LENGTH: f32 = 0.8;
|
||||
const GIZMO_MAX_LENGTH: f32 = 3.0;
|
||||
|
||||
const MIN_CAMERA_DISTANCE: f32 = 2.0;
|
||||
const MAX_CAMERA_DISTANCE: f32 = 40.0;
|
||||
const DEFAULT_CAMERA_DISTANCE: f32 = 12.5;
|
||||
const MAX_CAMERA_PITCH: f32 = 85.0 * std::f32::consts::PI / 180.0;
|
||||
|
||||
const ORBIT_SENSITIVITY: f32 = 0.005;
|
||||
const PAN_SENSITIVITY: f32 = 0.0015;
|
||||
const ZOOM_STEP: f32 = 0.9;
|
||||
|
||||
const RAY_EPSILON: f32 = 1e-6;
|
||||
|
||||
/// Orbit/pan/zoom state of the one editor camera.
|
||||
///
|
||||
/// The camera position is derived from `focus`, `yaw`, `pitch` and `distance`
|
||||
/// instead of being stored directly so the controls stay a simple orbit model.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EditorCamera {
|
||||
/// World-space point the camera orbits around and looks at.
|
||||
pub focus: Vec3,
|
||||
/// Rotation around `Vec3::Y`, radians.
|
||||
pub yaw: f32,
|
||||
/// Elevation above the focus plane, radians, clamped to ±85°.
|
||||
pub pitch: f32,
|
||||
/// Distance from `focus` to the camera, clamped to `2.0..=40.0`.
|
||||
pub distance: f32,
|
||||
}
|
||||
|
||||
impl Default for EditorCamera {
|
||||
/// The M0 placeholder pose from the demo scene: eye `(6, 5, 10)`, looking
|
||||
/// at `(0, 0.5, 0)`.
|
||||
fn default() -> Self {
|
||||
let placeholder =
|
||||
Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y);
|
||||
editor_camera_from_transform(&placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the three translate axes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GizmoAxis {
|
||||
/// World +X.
|
||||
X,
|
||||
/// World +Y.
|
||||
Y,
|
||||
/// World +Z.
|
||||
Z,
|
||||
}
|
||||
|
||||
impl GizmoAxis {
|
||||
/// All axes in RGB order.
|
||||
pub const ALL: [Self; 3] = [Self::X, Self::Y, Self::Z];
|
||||
|
||||
/// Unit world-space direction of this axis.
|
||||
pub fn direction(self) -> Vec3 {
|
||||
match self {
|
||||
Self::X => Vec3::X,
|
||||
Self::Y => Vec3::Y,
|
||||
Self::Z => Vec3::Z,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Active translate-gizmo drag. Reset to `Default` when no drag is running.
|
||||
#[derive(Resource, Default, Debug, Clone)]
|
||||
pub struct GizmoDrag {
|
||||
/// Whether a drag is currently in progress.
|
||||
pub active: bool,
|
||||
/// The entity being moved.
|
||||
pub entity: Option<Entity>,
|
||||
/// The axis being dragged.
|
||||
pub axis: Option<GizmoAxis>,
|
||||
/// `Transform.translation` of the entity when the drag began (parent-local).
|
||||
pub start_translation: Vec3,
|
||||
/// First ray/plane hit in world space.
|
||||
pub start_world_hit: Vec3,
|
||||
/// World-space gizmo origin when the drag began.
|
||||
pub origin: Vec3,
|
||||
/// Unit world-space direction of the dragged axis.
|
||||
pub axis_world: Vec3,
|
||||
/// Normal of the drag plane (perpendicular to the axis).
|
||||
pub plane_normal: Vec3,
|
||||
}
|
||||
|
||||
impl GizmoDrag {
|
||||
/// Ends the current drag and clears all stored state.
|
||||
pub fn clear(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the editor camera controls and the translate gizmo.
|
||||
pub struct GizmoPlugin;
|
||||
|
||||
impl Plugin for GizmoPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<GizmoDrag>()
|
||||
// `PostStartup` deliberately runs after the host app's `Startup` scene
|
||||
// so the placeholder camera exists and can be adopted.
|
||||
.add_systems(PostStartup, ensure_editor_camera)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
editor_camera_control,
|
||||
log_selection,
|
||||
begin_gizmo_drag,
|
||||
update_gizmo_drag,
|
||||
draw_gizmo,
|
||||
)
|
||||
.chain()
|
||||
.run_if(editor_active),
|
||||
)
|
||||
// Always clean up, even if the editor was disabled mid-drag.
|
||||
.add_systems(Update, end_gizmo_drag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopts the host app's placeholder `Camera3d`, or spawns one if there is none.
|
||||
///
|
||||
/// Adoption never touches the existing `Transform`, so the initial framing is
|
||||
/// preserved; only the orbit state is derived from it. A spawned camera uses the
|
||||
/// M0 placeholder pose and `Tonemapping::AcesFitted` (no LUT assets).
|
||||
fn ensure_editor_camera(
|
||||
mut commands: Commands,
|
||||
existing: Query<(), With<EditorCamera>>,
|
||||
cameras: Query<(Entity, &Transform), With<Camera3d>>,
|
||||
) {
|
||||
if !existing.is_empty() {
|
||||
info!("gizmo: camera ready");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((entity, transform)) = cameras.iter().next() {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(editor_camera_from_transform(transform));
|
||||
debug!("gizmo: adopted camera {entity}");
|
||||
info!("gizmo: camera ready");
|
||||
return;
|
||||
}
|
||||
|
||||
let editor = EditorCamera::default();
|
||||
let transform = camera_transform(&editor);
|
||||
commands.spawn((
|
||||
Name::new("Editor Camera"),
|
||||
Camera3d::default(),
|
||||
Tonemapping::AcesFitted,
|
||||
transform,
|
||||
GlobalTransform::from(transform),
|
||||
editor,
|
||||
));
|
||||
debug!("gizmo: spawned camera");
|
||||
info!("gizmo: camera ready");
|
||||
}
|
||||
|
||||
/// Derives orbit state from a camera transform by looking along its forward ray.
|
||||
///
|
||||
/// The distance is estimated as the camera's distance from the world origin,
|
||||
/// which keeps the placeholder framing stable while giving the orbit controls a
|
||||
/// sensible pivot. Returns the default distance for a camera at the origin.
|
||||
pub fn editor_camera_from_transform(transform: &Transform) -> EditorCamera {
|
||||
let position = transform.translation;
|
||||
let mut distance = position.length();
|
||||
if !distance.is_finite() || distance < 0.01 {
|
||||
distance = DEFAULT_CAMERA_DISTANCE;
|
||||
}
|
||||
|
||||
let focus = position + *transform.forward() * distance;
|
||||
let offset = position - focus;
|
||||
let horizontal = Vec2::new(offset.x, offset.z).length();
|
||||
let yaw = offset.x.atan2(offset.z);
|
||||
let pitch = offset
|
||||
.y
|
||||
.atan2(horizontal)
|
||||
.clamp(-MAX_CAMERA_PITCH, MAX_CAMERA_PITCH);
|
||||
|
||||
EditorCamera {
|
||||
focus,
|
||||
yaw,
|
||||
pitch,
|
||||
distance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a camera transform from orbit state. `looking_at` keeps roll at zero.
|
||||
pub fn camera_transform(camera: &EditorCamera) -> Transform {
|
||||
let (sin_yaw, cos_yaw) = camera.yaw.sin_cos();
|
||||
let (sin_pitch, cos_pitch) = camera.pitch.sin_cos();
|
||||
let offset = Vec3::new(cos_pitch * sin_yaw, sin_pitch, cos_pitch * cos_yaw) * camera.distance;
|
||||
|
||||
Transform::from_translation(camera.focus + offset).looking_at(camera.focus, Vec3::Y)
|
||||
}
|
||||
|
||||
/// Gizmo axis length for a camera distance: roughly screen-constant, clamped.
|
||||
pub fn gizmo_length(camera_distance: f32) -> f32 {
|
||||
(camera_distance * GIZMO_LENGTH_PER_DISTANCE).clamp(GIZMO_MIN_LENGTH, GIZMO_MAX_LENGTH)
|
||||
}
|
||||
|
||||
/// Right-drag orbit, middle-drag pan, wheel zoom while the cursor is in the viewport.
|
||||
fn editor_camera_control(
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
layout: Res<EditorLayout>,
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
mut motion: MessageReader<MouseMotion>,
|
||||
mut wheel: MessageReader<MouseWheel>,
|
||||
mut cameras: Query<(&mut EditorCamera, &mut Transform)>,
|
||||
) {
|
||||
// Consume input every frame so a dragged-then-released button does not leave
|
||||
// stale deltas queued for the next drag.
|
||||
let motion_delta = motion
|
||||
.read()
|
||||
.fold(Vec2::ZERO, |total, event| total + event.delta);
|
||||
let wheel_delta: f32 = wheel
|
||||
.read()
|
||||
.map(|event| match event.unit {
|
||||
MouseScrollUnit::Line => event.y,
|
||||
MouseScrollUnit::Pixel => event.y / 100.0,
|
||||
})
|
||||
.sum();
|
||||
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let in_viewport = window
|
||||
.cursor_position()
|
||||
.is_some_and(|cursor| layout.viewport_rect(window).contains(cursor));
|
||||
if !in_viewport {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok((mut camera, mut transform)) = cameras.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut changed = false;
|
||||
|
||||
if mouse.pressed(MouseButton::Right) && motion_delta != Vec2::ZERO {
|
||||
camera.yaw -= motion_delta.x * ORBIT_SENSITIVITY;
|
||||
camera.pitch = (camera.pitch + motion_delta.y * ORBIT_SENSITIVITY)
|
||||
.clamp(-MAX_CAMERA_PITCH, MAX_CAMERA_PITCH);
|
||||
changed = true;
|
||||
} else if mouse.pressed(MouseButton::Middle) && motion_delta != Vec2::ZERO {
|
||||
let right = *transform.right();
|
||||
let up = *transform.up();
|
||||
let scale = camera.distance * PAN_SENSITIVITY;
|
||||
camera.focus += (-right * motion_delta.x + up * motion_delta.y) * scale;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if wheel_delta != 0.0 {
|
||||
camera.distance = (camera.distance * ZOOM_STEP.powf(wheel_delta))
|
||||
.clamp(MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
*transform = camera_transform(&camera);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs `gizmo: selected {entity}` once per selection, not once per frame.
|
||||
fn log_selection(selection: Res<Selection>, mut last_logged: Local<Option<Entity>>) {
|
||||
if *last_logged == selection.0 {
|
||||
return;
|
||||
}
|
||||
*last_logged = selection.0;
|
||||
if let Some(entity) = selection.0 {
|
||||
info!("gizmo: selected {entity}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the world-aligned axes at the selected entity's global position.
|
||||
fn draw_gizmo(
|
||||
mut gizmos: Gizmos,
|
||||
selection: Res<Selection>,
|
||||
entities: Query<&GlobalTransform>,
|
||||
cameras: Query<&EditorCamera>,
|
||||
) {
|
||||
let Some(entity) = selection.0 else {
|
||||
return;
|
||||
};
|
||||
let Ok(global) = entities.get(entity) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let distance = cameras
|
||||
.iter()
|
||||
.next()
|
||||
.map_or(DEFAULT_CAMERA_DISTANCE, |camera| camera.distance);
|
||||
let length = gizmo_length(distance);
|
||||
gizmos.axes(Transform::from_translation(global.translation()), length);
|
||||
}
|
||||
|
||||
/// Picks the nearest axis on left press inside the viewport and starts a drag.
|
||||
fn begin_gizmo_drag(
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
layout: Res<EditorLayout>,
|
||||
selection: Res<Selection>,
|
||||
targets: Query<(&Transform, &GlobalTransform)>,
|
||||
cameras: Query<(&Camera, &GlobalTransform, &EditorCamera)>,
|
||||
mut drag: ResMut<GizmoDrag>,
|
||||
) {
|
||||
if !mouse.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
if !layout.viewport_rect(window).contains(cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(entity) = selection.0 else {
|
||||
return;
|
||||
};
|
||||
let Ok((target_transform, target_global)) = targets.get(entity) else {
|
||||
return;
|
||||
};
|
||||
let Ok((camera, camera_global, editor_camera)) = cameras.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let origin = target_global.translation();
|
||||
let length = gizmo_length(editor_camera.distance);
|
||||
let Ok(origin_screen) = camera.world_to_viewport(camera_global, origin) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut endpoints = [(GizmoAxis::X, Vec2::ZERO); 3];
|
||||
let mut endpoint_count = 0;
|
||||
for axis in GizmoAxis::ALL {
|
||||
let world_end = origin + axis.direction() * length;
|
||||
if let Ok(screen_end) = camera.world_to_viewport(camera_global, world_end) {
|
||||
endpoints[endpoint_count] = (axis, screen_end);
|
||||
endpoint_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(axis) = pick_axis(
|
||||
cursor,
|
||||
origin_screen,
|
||||
&endpoints[..endpoint_count],
|
||||
AXIS_PICK_RADIUS_PX,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let axis_world = axis.direction();
|
||||
let normal = axis_plane_normal(axis_world, *camera_global.forward());
|
||||
let Ok(ray) = camera.viewport_to_world(camera_global, cursor) else {
|
||||
return;
|
||||
};
|
||||
let Some(start_world_hit) = ray_plane_hit(ray.origin, *ray.direction, origin, normal) else {
|
||||
return;
|
||||
};
|
||||
|
||||
*drag = GizmoDrag {
|
||||
active: true,
|
||||
entity: Some(entity),
|
||||
axis: Some(axis),
|
||||
start_translation: target_transform.translation,
|
||||
start_world_hit,
|
||||
origin,
|
||||
axis_world,
|
||||
plane_normal: normal,
|
||||
};
|
||||
}
|
||||
|
||||
/// Updates the dragged translation while the left button stays held.
|
||||
fn update_gizmo_drag(
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
layout: Res<EditorLayout>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
parents: Query<&ChildOf>,
|
||||
globals: Query<&GlobalTransform>,
|
||||
mut transforms: Query<&mut Transform>,
|
||||
mut drag: ResMut<GizmoDrag>,
|
||||
) {
|
||||
if !drag.active {
|
||||
return;
|
||||
}
|
||||
if !mouse.pressed(MouseButton::Left) {
|
||||
drag.clear();
|
||||
return;
|
||||
}
|
||||
let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else {
|
||||
drag.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
if !layout.viewport_rect(window).contains(cursor) {
|
||||
return;
|
||||
}
|
||||
let Ok((camera, camera_global)) = cameras.single() else {
|
||||
return;
|
||||
};
|
||||
let Ok(ray) = camera.viewport_to_world(camera_global, cursor) else {
|
||||
return;
|
||||
};
|
||||
let Some(hit) = ray_plane_hit(ray.origin, *ray.direction, drag.origin, drag.plane_normal)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let parent_rotation = parent_rotation_of(entity, &parents, &globals);
|
||||
let translation = dragged_translation(
|
||||
drag.start_translation,
|
||||
drag.start_world_hit,
|
||||
hit,
|
||||
drag.axis_world,
|
||||
parent_rotation,
|
||||
);
|
||||
|
||||
let Ok(mut transform) = transforms.get_mut(entity) else {
|
||||
drag.clear();
|
||||
return;
|
||||
};
|
||||
transform.translation = translation;
|
||||
}
|
||||
|
||||
/// Ends the drag on left release. Runs even when the editor is disabled.
|
||||
fn end_gizmo_drag(mouse: Res<ButtonInput<MouseButton>>, mut drag: ResMut<GizmoDrag>) {
|
||||
if drag.active && mouse.just_released(MouseButton::Left) {
|
||||
drag.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the parent's global rotation for a selected entity, or identity.
|
||||
fn parent_rotation_of(
|
||||
entity: Entity,
|
||||
parents: &Query<&ChildOf>,
|
||||
globals: &Query<&GlobalTransform>,
|
||||
) -> Quat {
|
||||
parents
|
||||
.get(entity)
|
||||
.ok()
|
||||
.and_then(|child_of| globals.get(child_of.parent()).ok())
|
||||
.map(GlobalTransform::rotation)
|
||||
.unwrap_or(Quat::IDENTITY)
|
||||
}
|
||||
|
||||
/// Screen-space distance from `point` to the segment `start..end`.
|
||||
pub fn point_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> f32 {
|
||||
let segment = end - start;
|
||||
let length_squared = segment.length_squared();
|
||||
if length_squared <= f32::EPSILON {
|
||||
return point.distance(start);
|
||||
}
|
||||
let t = ((point - start).dot(segment) / length_squared).clamp(0.0, 1.0);
|
||||
point.distance(start + segment * t)
|
||||
}
|
||||
|
||||
/// Picks the nearest axis segment within `max_distance_px`, independent of order.
|
||||
pub fn pick_axis(
|
||||
cursor: Vec2,
|
||||
origin: Vec2,
|
||||
endpoints: &[(GizmoAxis, Vec2)],
|
||||
max_distance_px: f32,
|
||||
) -> Option<GizmoAxis> {
|
||||
let mut best: Option<(GizmoAxis, f32)> = None;
|
||||
for &(axis, endpoint) in endpoints {
|
||||
let distance = point_segment_distance(cursor, origin, endpoint);
|
||||
if distance > max_distance_px {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(_, best_distance)| distance < best_distance) {
|
||||
best = Some((axis, distance));
|
||||
}
|
||||
}
|
||||
best.map(|(axis, _)| axis)
|
||||
}
|
||||
|
||||
/// Returns the drag-plane normal for an axis: camera forward projected onto the
|
||||
/// plane perpendicular to the axis. Falls back to any perpendicular vector when
|
||||
/// the view direction and the axis are (nearly) parallel.
|
||||
pub fn axis_plane_normal(axis: Vec3, camera_forward: Vec3) -> Vec3 {
|
||||
let axis = axis.normalize_or_zero();
|
||||
let projected = camera_forward - axis * camera_forward.dot(axis);
|
||||
if projected.length_squared() > RAY_EPSILON * RAY_EPSILON {
|
||||
projected.normalize()
|
||||
} else {
|
||||
axis.any_orthonormal_vector()
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersects a ray with a plane. Returns `None` for parallel rays and hits
|
||||
/// behind the ray origin.
|
||||
pub fn ray_plane_hit(
|
||||
ray_origin: Vec3,
|
||||
ray_direction: Vec3,
|
||||
plane_origin: Vec3,
|
||||
plane_normal: Vec3,
|
||||
) -> Option<Vec3> {
|
||||
let denominator = ray_direction.dot(plane_normal);
|
||||
if denominator.abs() <= RAY_EPSILON {
|
||||
return None;
|
||||
}
|
||||
let t = (plane_origin - ray_origin).dot(plane_normal) / denominator;
|
||||
if t < 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(ray_origin + ray_direction * t)
|
||||
}
|
||||
|
||||
/// Scalar distance a plane hit moved along `axis`.
|
||||
pub fn axis_delta(start_hit: Vec3, current_hit: Vec3, axis: Vec3) -> f32 {
|
||||
(current_hit - start_hit).dot(axis.normalize_or_zero())
|
||||
}
|
||||
|
||||
/// Converts a world-space delta into the parent's local space.
|
||||
///
|
||||
/// M0 limit: only the parent's inverse rotation is applied; parent scale is
|
||||
/// intentionally ignored. A scaled parent therefore changes the effective
|
||||
/// world-space speed of the drag.
|
||||
pub fn world_delta_to_local(world_delta: Vec3, parent_rotation: Quat) -> Vec3 {
|
||||
parent_rotation.inverse() * world_delta
|
||||
}
|
||||
|
||||
/// Computes the new parent-local translation for a drag from two plane hits.
|
||||
pub fn dragged_translation(
|
||||
start_translation: Vec3,
|
||||
start_hit: Vec3,
|
||||
current_hit: Vec3,
|
||||
axis_world: Vec3,
|
||||
parent_rotation: Quat,
|
||||
) -> Vec3 {
|
||||
let scalar = axis_delta(start_hit, current_hit, axis_world);
|
||||
let world_delta = axis_world * scalar;
|
||||
start_translation + world_delta_to_local(world_delta, parent_rotation)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_approx(actual: f32, expected: f32) {
|
||||
assert!(
|
||||
(actual - expected).abs() < 1e-4,
|
||||
"expected {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_vec3_approx(actual: Vec3, expected: Vec3) {
|
||||
assert!(
|
||||
actual.abs_diff_eq(expected, 1e-4),
|
||||
"expected {expected:?}, got {actual:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_segment_distance_inside_and_outside() {
|
||||
let start = Vec2::new(0.0, 0.0);
|
||||
let end = Vec2::new(10.0, 0.0);
|
||||
|
||||
// Perpendicular projection lands inside the segment.
|
||||
assert_approx(point_segment_distance(Vec2::new(5.0, 3.0), start, end), 3.0);
|
||||
// Outside the segment, nearest point is the start cap.
|
||||
assert_approx(
|
||||
point_segment_distance(Vec2::new(-4.0, 3.0), start, end),
|
||||
5.0,
|
||||
);
|
||||
// Outside the segment, nearest point is the end cap.
|
||||
assert_approx(
|
||||
point_segment_distance(Vec2::new(14.0, -3.0), start, end),
|
||||
5.0,
|
||||
);
|
||||
// Degenerate segment.
|
||||
assert_approx(
|
||||
point_segment_distance(Vec2::new(3.0, 4.0), start, start),
|
||||
5.0,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_axis_is_picked_within_the_screen_threshold() {
|
||||
let origin = Vec2::new(100.0, 100.0);
|
||||
let endpoints = [
|
||||
(GizmoAxis::X, Vec2::new(160.0, 100.0)),
|
||||
(GizmoAxis::Y, Vec2::new(100.0, 40.0)),
|
||||
(GizmoAxis::Z, Vec2::new(40.0, 100.0)),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
pick_axis(Vec2::new(140.0, 104.0), origin, &endpoints, 12.0),
|
||||
Some(GizmoAxis::X)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_axis(Vec2::new(100.0, 44.0), origin, &endpoints, 12.0),
|
||||
Some(GizmoAxis::Y)
|
||||
);
|
||||
// Far away from every axis segment.
|
||||
assert_eq!(
|
||||
pick_axis(Vec2::new(100.0, 130.0), origin, &endpoints, 12.0),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_plane_hit_returns_expected_point_and_rejects_parallel() {
|
||||
let hit = ray_plane_hit(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, Vec3::ZERO, Vec3::Y)
|
||||
.expect("downward ray should hit the XZ plane");
|
||||
assert_vec3_approx(hit, Vec3::ZERO);
|
||||
|
||||
// Parallel ray never intersects the plane.
|
||||
assert_eq!(
|
||||
ray_plane_hit(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 1.0, 0.0), Vec3::Y),
|
||||
None
|
||||
);
|
||||
|
||||
// A hit behind the ray origin is not a valid hit.
|
||||
assert_eq!(
|
||||
ray_plane_hit(Vec3::new(0.0, 1.0, 0.0), Vec3::Y, Vec3::ZERO, Vec3::Y),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_delta_projects_a_plane_hit_onto_the_axis() {
|
||||
let start = Vec3::new(0.0, 0.0, 0.0);
|
||||
let current = Vec3::new(2.5, 9.0, -4.0);
|
||||
|
||||
assert_approx(axis_delta(start, current, Vec3::X), 2.5);
|
||||
assert_approx(axis_delta(start, current, Vec3::Y), 9.0);
|
||||
// Non-normalized axis directions are normalized before projecting.
|
||||
assert_approx(axis_delta(start, current, Vec3::Z * 2.0), -4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_delta_to_local_uses_the_inverse_parent_rotation() {
|
||||
// Parent rotated +90° around Y: a world +X delta is local +Z.
|
||||
let parent_rotation = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
|
||||
let local = world_delta_to_local(Vec3::new(2.0, 0.0, 0.0), parent_rotation);
|
||||
assert_vec3_approx(local, Vec3::new(0.0, 0.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_helper_changes_transform_by_expected_axis_delta() {
|
||||
let start_translation = Vec3::new(1.0, 2.0, 3.0);
|
||||
let start_hit = Vec3::ZERO;
|
||||
let current_hit = Vec3::new(2.0, 7.0, -3.0);
|
||||
|
||||
let translation = dragged_translation(
|
||||
start_translation,
|
||||
start_hit,
|
||||
current_hit,
|
||||
Vec3::X,
|
||||
Quat::IDENTITY,
|
||||
);
|
||||
let mut transform = Transform::from_translation(start_translation);
|
||||
transform.translation = translation;
|
||||
assert_vec3_approx(transform.translation, Vec3::new(3.0, 2.0, 3.0));
|
||||
|
||||
// Under a parent rotated +90° around Y, the same world delta becomes +2 on local Z.
|
||||
let parent_rotation = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
|
||||
let translation = dragged_translation(
|
||||
start_translation,
|
||||
start_hit,
|
||||
current_hit,
|
||||
Vec3::X,
|
||||
parent_rotation,
|
||||
);
|
||||
let mut transform = Transform::from_translation(start_translation);
|
||||
transform.translation = translation;
|
||||
assert_vec3_approx(transform.translation, Vec3::new(1.0, 2.0, 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gizmo_length_scales_with_distance_and_clamps() {
|
||||
assert_approx(gizmo_length(2.0), GIZMO_MIN_LENGTH);
|
||||
assert_approx(gizmo_length(12.5), 2.25);
|
||||
assert_approx(gizmo_length(40.0), GIZMO_MAX_LENGTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_camera_keeps_its_position() {
|
||||
let transform =
|
||||
Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y);
|
||||
let camera = editor_camera_from_transform(&transform);
|
||||
let rebuilt = camera_transform(&camera);
|
||||
|
||||
assert_vec3_approx(rebuilt.translation, transform.translation);
|
||||
assert_vec3_approx(*rebuilt.forward(), *transform.forward());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//! Live hierarchy panel.
|
||||
//!
|
||||
//! Collects the scene tree from `ChildOf` / `Children`, renders it as an
|
||||
//! indented, scrollable [`ListBox`] inside the left [`HierarchyPanel`] and binds
|
||||
//! row activation to [`Selection`].
|
||||
//!
|
||||
//! The panel is rebuilt only when the scene snapshot changes. Editor-owned UI
|
||||
//! entities, observers and window entities are not part of the scene snapshot,
|
||||
//! so rebuilding the panel can never feed back into itself.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy::{
|
||||
picking::hover::Hovered,
|
||||
prelude::*,
|
||||
text::FontSize,
|
||||
ui::{ScrollPosition, Selected},
|
||||
ui_widgets::{ListBox, ListItem, ScrollArea, ValueChange},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
plugin::{editor_active, Selection},
|
||||
ui::{HierarchyPanel, UiTheme},
|
||||
};
|
||||
|
||||
/// Largest depth a hierarchy row may have. Deeper descendants are not listed.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// One row of the hierarchy tree, in render order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HierarchyRowData {
|
||||
/// Entity this row represents.
|
||||
pub entity: Entity,
|
||||
/// Display name: the entity's `Name`, or `Entity#{index}` when missing.
|
||||
pub name: String,
|
||||
/// Zero-based depth. Roots have depth `0`.
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
/// Marker component on a list row, mapping it to the scene entity it selects.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HierarchyRow {
|
||||
/// Scene entity selected when this row is activated.
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Marker for the single list entity inside the hierarchy panel.
|
||||
#[derive(Component)]
|
||||
struct HierarchyList;
|
||||
|
||||
/// One collected tree row including its parent, used for snapshot comparison.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TreeRow {
|
||||
entity: Entity,
|
||||
name: String,
|
||||
parent: Option<Entity>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
/// Snapshot of the last rendered tree: `(entity, name, parent, depth)` in render order.
|
||||
#[derive(Resource, Default)]
|
||||
struct HierarchyTreeState {
|
||||
signature: Vec<(Entity, String, Option<Entity>, usize)>,
|
||||
rows: usize,
|
||||
roots: usize,
|
||||
}
|
||||
|
||||
/// Adds the live hierarchy panel.
|
||||
pub struct HierarchyPlugin;
|
||||
|
||||
impl Plugin for HierarchyPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<HierarchyTreeState>().add_systems(
|
||||
Update,
|
||||
(rebuild_hierarchy, sync_selected_rows, style_hierarchy_rows)
|
||||
.chain()
|
||||
.run_if(editor_active),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the full world tree: roots first, children in `Children` order.
|
||||
///
|
||||
/// Roots are entities without `ChildOf`, sorted by [`Entity`]. Unnamed entities
|
||||
/// fall back to `Entity#{index}`. A visited set prevents duplicate rows and a
|
||||
/// depth cap of [`MAX_DEPTH`] prevents pathological trees from stalling the UI.
|
||||
pub fn collect_rows(world: &mut World) -> Vec<HierarchyRowData> {
|
||||
collect_tree(world, false)
|
||||
.into_iter()
|
||||
.map(|row| HierarchyRowData {
|
||||
entity: row.entity,
|
||||
name: row.name,
|
||||
depth: row.depth,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collects the scene tree: only named, non-UI spatial entities may be roots.
|
||||
///
|
||||
/// Children of a scene root are listed regardless of whether they have a
|
||||
/// `Name`, so unnamed scene children still show up with the index fallback.
|
||||
fn collect_scene_rows(world: &World) -> Vec<TreeRow> {
|
||||
collect_tree(world, true)
|
||||
}
|
||||
|
||||
/// Shared tree walk.
|
||||
///
|
||||
/// When `scene_only` is set, roots additionally require `Name` and reject
|
||||
/// `Node`, which keeps the editor UI, global observers and window entities out
|
||||
/// of the hierarchy (and out of the rebuild snapshot).
|
||||
fn collect_tree(world: &World, scene_only: bool) -> Vec<TreeRow> {
|
||||
let mut roots: Vec<Entity> = world
|
||||
.iter_entities()
|
||||
.filter_map(|entity| {
|
||||
let id = entity.id();
|
||||
if entity.get::<ChildOf>().is_some() {
|
||||
return None;
|
||||
}
|
||||
if scene_only && !is_scene_root(world, id) {
|
||||
return None;
|
||||
}
|
||||
Some(id)
|
||||
})
|
||||
.collect();
|
||||
roots.sort();
|
||||
|
||||
let mut rows = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
for root in roots {
|
||||
push_subtree(world, root, None, 0, &mut visited, &mut rows);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Whether an entity is a root of the editable scene rather than of the editor
|
||||
/// or of an engine-internal helper (UI nodes, observers, window and gizmo
|
||||
/// renderer entities, ...).
|
||||
fn is_scene_root(world: &World, entity: Entity) -> bool {
|
||||
world.get::<Name>(entity).is_some()
|
||||
&& world.get::<Transform>(entity).is_some()
|
||||
&& world.get::<Node>(entity).is_none()
|
||||
}
|
||||
|
||||
/// Depth-first, in-order push of one entity and its descendants.
|
||||
fn push_subtree(
|
||||
world: &World,
|
||||
entity: Entity,
|
||||
parent: Option<Entity>,
|
||||
depth: usize,
|
||||
visited: &mut HashSet<Entity>,
|
||||
rows: &mut Vec<TreeRow>,
|
||||
) {
|
||||
if depth > MAX_DEPTH || !visited.insert(entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = world
|
||||
.get::<Name>(entity)
|
||||
.map(|name| name.as_str().to_owned())
|
||||
.unwrap_or_else(|| format!("Entity#{}", entity.index()));
|
||||
let children: Vec<Entity> = world
|
||||
.get::<Children>(entity)
|
||||
.map(|children| children.to_vec())
|
||||
.unwrap_or_default();
|
||||
|
||||
rows.push(TreeRow {
|
||||
entity,
|
||||
name,
|
||||
parent,
|
||||
depth,
|
||||
});
|
||||
|
||||
if depth >= MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for child in children {
|
||||
push_subtree(world, child, Some(entity), depth + 1, visited, rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the panel contents when the scene tree snapshot changes.
|
||||
///
|
||||
/// This is an exclusive system so the old list is despawned and the new one is
|
||||
/// spawned in the same tick: `sync_selected_rows` never observes stale rows and
|
||||
/// a rebuild can never run twice for one snapshot.
|
||||
fn rebuild_hierarchy(world: &mut World) {
|
||||
let rows = collect_scene_rows(world);
|
||||
let root_count = rows.iter().filter(|row| row.depth == 0).count();
|
||||
let signature: Vec<(Entity, String, Option<Entity>, usize)> = rows
|
||||
.iter()
|
||||
.map(|row| (row.entity, row.name.clone(), row.parent, row.depth))
|
||||
.collect();
|
||||
|
||||
let previous = {
|
||||
let state = world.resource::<HierarchyTreeState>();
|
||||
(state.signature.clone(), state.rows, state.roots)
|
||||
};
|
||||
if previous.0 == signature {
|
||||
return;
|
||||
}
|
||||
|
||||
let panel = world
|
||||
.query_filtered::<Entity, With<HierarchyPanel>>()
|
||||
.iter(world)
|
||||
.next();
|
||||
let Some(panel) = panel else {
|
||||
return;
|
||||
};
|
||||
|
||||
let theme = world.resource::<UiTheme>().clone();
|
||||
let selection = *world.resource::<Selection>();
|
||||
|
||||
// Drop the previous list (and its rows) before re-populating the panel.
|
||||
let mut list_query = world.query_filtered::<Entity, With<HierarchyList>>();
|
||||
let old_lists: Vec<Entity> = list_query.iter(world).collect();
|
||||
drop(list_query);
|
||||
for list in old_lists {
|
||||
world.despawn(list);
|
||||
}
|
||||
// The shell's placeholder ("Waiting for scene...") is replaced by the list.
|
||||
world.entity_mut(panel).despawn_children();
|
||||
|
||||
let header = world
|
||||
.spawn(hierarchy_label("Hierarchy", 14.0, theme.text))
|
||||
.id();
|
||||
world.entity_mut(panel).add_child(header);
|
||||
|
||||
let list = world
|
||||
.spawn((
|
||||
HierarchyList,
|
||||
ListBox,
|
||||
ScrollArea,
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
width: percent(100),
|
||||
flex_grow: 1.0,
|
||||
flex_direction: FlexDirection::Column,
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
))
|
||||
.id();
|
||||
world.entity_mut(list).observe(hierarchy_row_activated);
|
||||
world.entity_mut(panel).add_child(list);
|
||||
|
||||
for row in &rows {
|
||||
let is_selected = selection.0 == Some(row.entity);
|
||||
let row_entity = world
|
||||
.spawn((
|
||||
ListItem,
|
||||
HierarchyRow { target: row.entity },
|
||||
Node {
|
||||
width: percent(100),
|
||||
height: px(22.0),
|
||||
flex_shrink: 0.0,
|
||||
padding: UiRect::left(px(6.0 + row.depth as f32 * 14.0)),
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(if is_selected {
|
||||
theme.row_selected
|
||||
} else {
|
||||
Color::NONE
|
||||
}),
|
||||
Hovered::default(),
|
||||
))
|
||||
.id();
|
||||
if is_selected {
|
||||
world.entity_mut(row_entity).insert(Selected);
|
||||
}
|
||||
|
||||
let text = world
|
||||
.spawn(hierarchy_label(
|
||||
row.name.clone(),
|
||||
13.0,
|
||||
if is_selected {
|
||||
theme.text
|
||||
} else {
|
||||
theme.text_muted
|
||||
},
|
||||
))
|
||||
.id();
|
||||
world.entity_mut(row_entity).add_child(text);
|
||||
world.entity_mut(list).add_child(row_entity);
|
||||
}
|
||||
|
||||
let count_changed = previous.1 != rows.len() || previous.2 != root_count;
|
||||
*world.resource_mut::<HierarchyTreeState>() = HierarchyTreeState {
|
||||
signature,
|
||||
rows: rows.len(),
|
||||
roots: root_count,
|
||||
};
|
||||
if count_changed || previous.1 == 0 {
|
||||
info!("hierarchy: {} rows, {} roots", rows.len(), root_count);
|
||||
debug!(
|
||||
"hierarchy rows: {:?}",
|
||||
rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a plain editor label bundle.
|
||||
fn hierarchy_label(value: impl Into<String>, size: f32, color: Color) -> impl Bundle {
|
||||
(
|
||||
Text::new(value.into()),
|
||||
TextFont {
|
||||
font: FontSource::default(),
|
||||
font_size: FontSize::Px(size),
|
||||
..default()
|
||||
},
|
||||
TextColor(color),
|
||||
)
|
||||
}
|
||||
|
||||
/// Observer on the list entity: a committed row activation selects its target.
|
||||
fn hierarchy_row_activated(
|
||||
change: On<ValueChange<Entity>>,
|
||||
rows: Query<&HierarchyRow>,
|
||||
mut selection: ResMut<Selection>,
|
||||
) {
|
||||
let value = change.event().value;
|
||||
let Ok(row) = rows.get(value) else {
|
||||
return;
|
||||
};
|
||||
if selection.0 != Some(row.target) {
|
||||
selection.0 = Some(row.target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the widget's `Selected` marker in sync with [`Selection`].
|
||||
fn sync_selected_rows(
|
||||
mut commands: Commands,
|
||||
selection: Res<Selection>,
|
||||
rows: Query<(Entity, &HierarchyRow, Has<Selected>)>,
|
||||
) {
|
||||
for (entity, row, selected) in &rows {
|
||||
let should_select = selection.0 == Some(row.target);
|
||||
if should_select && !selected {
|
||||
commands.entity(entity).insert(Selected);
|
||||
} else if !should_select && selected {
|
||||
commands.entity(entity).remove::<Selected>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles rows from [`UiTheme`] using the `Selected` marker and pointer hover.
|
||||
fn style_hierarchy_rows(
|
||||
theme: Res<UiTheme>,
|
||||
selection: Res<Selection>,
|
||||
mut rows: Query<(
|
||||
&HierarchyRow,
|
||||
&mut BackgroundColor,
|
||||
Has<Selected>,
|
||||
&Hovered,
|
||||
&Children,
|
||||
)>,
|
||||
mut texts: Query<&mut TextColor>,
|
||||
) {
|
||||
for (row, mut background, selected, hovered, children) in &mut rows {
|
||||
let is_selected = selected || selection.0 == Some(row.target);
|
||||
let is_hovered = hovered.get() && !is_selected;
|
||||
let background_color = if is_selected {
|
||||
theme.row_selected
|
||||
} else if is_hovered {
|
||||
theme.row_hover
|
||||
} else {
|
||||
Color::NONE
|
||||
};
|
||||
if background.0 != background_color {
|
||||
background.0 = background_color;
|
||||
}
|
||||
|
||||
let text_color = if is_selected || is_hovered {
|
||||
theme.text
|
||||
} else {
|
||||
theme.text_muted
|
||||
};
|
||||
for child in children.iter() {
|
||||
if let Ok(mut color) = texts.get_mut(child) {
|
||||
if color.0 != text_color {
|
||||
color.0 = text_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Keeps only the rows for entities spawned by a test.
|
||||
///
|
||||
/// A bare [`World`] can contain internal entities (for example the reserved
|
||||
/// index-zero entity); they are valid hierarchy roots but not part of these
|
||||
/// focused ordering tests.
|
||||
fn rows_for(world: &mut World, entities: &[Entity]) -> Vec<HierarchyRowData> {
|
||||
collect_rows(world)
|
||||
.into_iter()
|
||||
.filter(|row| entities.contains(&row.entity))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_rows_walks_three_levels_in_children_order() {
|
||||
let mut world = World::new();
|
||||
let root = world.spawn(Name::new("Root")).id();
|
||||
let child = world.spawn((Name::new("Child"), ChildOf(root))).id();
|
||||
let grandchild = world.spawn((Name::new("Grandchild"), ChildOf(child))).id();
|
||||
let sibling = world.spawn((Name::new("Sibling"), ChildOf(root))).id();
|
||||
|
||||
let rows = rows_for(&mut world, &[root, child, grandchild, sibling]);
|
||||
let order: Vec<(&str, usize)> = rows
|
||||
.iter()
|
||||
.map(|row| (row.name.as_str(), row.depth))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![("Root", 0), ("Child", 1), ("Grandchild", 2), ("Sibling", 1),]
|
||||
);
|
||||
assert_eq!(rows[0].entity, root);
|
||||
assert_eq!(rows[1].entity, child);
|
||||
assert_eq!(rows[2].entity, grandchild);
|
||||
assert_eq!(rows[3].entity, sibling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_rows_falls_back_to_entity_index() {
|
||||
let mut world = World::new();
|
||||
let root = world.spawn(Name::new("Root")).id();
|
||||
let unnamed_child = world.spawn(ChildOf(root)).id();
|
||||
let unnamed_root = world.spawn_empty().id();
|
||||
|
||||
let rows = rows_for(&mut world, &[root, unnamed_child, unnamed_root]);
|
||||
assert_eq!(rows.len(), 3);
|
||||
let row = |entity: Entity| {
|
||||
rows.iter()
|
||||
.find(|row| row.entity == entity)
|
||||
.expect("row for entity")
|
||||
};
|
||||
assert_eq!(row(root).name, "Root");
|
||||
assert_eq!(row(root).depth, 0);
|
||||
assert_eq!(
|
||||
row(unnamed_child).name,
|
||||
format!("Entity#{}", unnamed_child.index())
|
||||
);
|
||||
assert_eq!(row(unnamed_child).depth, 1);
|
||||
assert_eq!(
|
||||
row(unnamed_root).name,
|
||||
format!("Entity#{}", unnamed_root.index())
|
||||
);
|
||||
assert_eq!(row(unnamed_root).depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_rows_caps_depth_at_64() {
|
||||
let mut world = World::new();
|
||||
let mut parent = world.spawn(Name::new("0")).id();
|
||||
let mut chain = vec![parent];
|
||||
for depth in 1..70 {
|
||||
parent = world
|
||||
.spawn((Name::new(format!("{depth}")), ChildOf(parent)))
|
||||
.id();
|
||||
chain.push(parent);
|
||||
}
|
||||
|
||||
let rows = rows_for(&mut world, &chain);
|
||||
assert_eq!(rows.len(), MAX_DEPTH + 1);
|
||||
assert_eq!(rows.last().expect("rows").depth, MAX_DEPTH);
|
||||
assert!(rows.iter().all(|row| row.depth <= MAX_DEPTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_change_selects_the_row_target() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Selection>();
|
||||
|
||||
let list = world.spawn(HierarchyList).id();
|
||||
let target = world.spawn(Name::new("Target")).id();
|
||||
let row = world.spawn((ListItem, HierarchyRow { target })).id();
|
||||
world.entity_mut(list).observe(hierarchy_row_activated);
|
||||
|
||||
world.trigger(ValueChange::<Entity> {
|
||||
source: list,
|
||||
value: row,
|
||||
is_final: true,
|
||||
});
|
||||
|
||||
assert_eq!(world.resource::<Selection>().0, Some(target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_change_ignores_entities_without_a_row() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Selection>();
|
||||
|
||||
let list = world.spawn(HierarchyList).id();
|
||||
let stranger = world.spawn(Name::new("Stranger")).id();
|
||||
world.entity_mut(list).observe(hierarchy_row_activated);
|
||||
|
||||
world.trigger(ValueChange::<Entity> {
|
||||
source: list,
|
||||
value: stranger,
|
||||
is_final: true,
|
||||
});
|
||||
|
||||
assert_eq!(world.resource::<Selection>().0, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
//! Reflection-driven inspector for the selected entity.
|
||||
//!
|
||||
//! M0 inspects three known components (`Name`, `Transform`, `Visibility`): they are
|
||||
//! fetched with typed queries and then walked with `bevy_reflect`. Generic discovery
|
||||
//! of arbitrary component types through `TypeRegistry` is explicitly **M1** work and
|
||||
//! is not implemented here; this panel only shows the three M0 components the brief
|
||||
//! promises.
|
||||
|
||||
use bevy::{
|
||||
prelude::*,
|
||||
reflect::{enums::VariantType, PartialReflect, ReflectMut, ReflectRef},
|
||||
text::FontSize,
|
||||
ui_widgets::{
|
||||
Slider, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick, ValueChange,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
plugin::{editor_active, Selection},
|
||||
ui::{InspectorPanel, UiTheme},
|
||||
};
|
||||
|
||||
/// Minimum / maximum value of the translation sliders.
|
||||
const SLIDER_MIN: f32 = -20.0;
|
||||
const SLIDER_MAX: f32 = 20.0;
|
||||
/// Step used by keyboard interaction and by the widget's step notifications.
|
||||
const SLIDER_STEP: f32 = 0.1;
|
||||
/// Above this delta the slider value is considered stale and is re-synced.
|
||||
const SYNC_TOLERANCE: f32 = 0.001;
|
||||
/// Maximum reflection nesting depth for the field summary.
|
||||
const MAX_SUMMARY_DEPTH: usize = 4;
|
||||
|
||||
/// One flattened, displayable reflection field.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReflectedField {
|
||||
/// Dotted display label, e.g. `translation.x`.
|
||||
pub label: String,
|
||||
/// Formatted value, e.g. `1.5`.
|
||||
pub value: String,
|
||||
/// Path of field names (or tuple indices) below the summarized root.
|
||||
pub path: Vec<String>,
|
||||
/// Whether this leaf is a scalar numeric value a generic editor could edit.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// Flattens the reflected field tree below `value` into display rows.
|
||||
///
|
||||
/// Named structs, tuple structs, enums and scalar leaves are handled. The walk is
|
||||
/// depth-limited so opaque wrappers (for example the hash wrapper inside [`Name`])
|
||||
/// cannot recurse forever.
|
||||
pub fn summarize_reflect(value: &dyn Reflect) -> Vec<ReflectedField> {
|
||||
// `Name` wraps a pre-hashed `Hashed<Cow<str>>`, whose reflection is opaque and
|
||||
// whose Display output is a debug dump. Present the readable name instead; the
|
||||
// value still comes from a reflected downcast, not from a hard-coded string.
|
||||
if let Some(name) = value.downcast_ref::<Name>() {
|
||||
return vec![ReflectedField {
|
||||
label: "Name".to_string(),
|
||||
value: name.as_str().to_string(),
|
||||
path: vec!["name".to_string()],
|
||||
editable: false,
|
||||
}];
|
||||
}
|
||||
|
||||
let mut fields = Vec::new();
|
||||
collect_fields(value.as_partial_reflect(), &mut Vec::new(), 0, &mut fields);
|
||||
fields
|
||||
}
|
||||
|
||||
/// Writes `value` into an `f32` field at `path`, e.g. `["translation", "x"]`.
|
||||
///
|
||||
/// Returns `false` (without panicking) when a path segment does not exist or when
|
||||
/// the addressed field is not an `f32`.
|
||||
pub fn apply_f32_path(root: &mut dyn Reflect, path: &[&str], value: f32) -> bool {
|
||||
apply_f32_in(root.as_partial_reflect_mut(), path, value)
|
||||
}
|
||||
|
||||
/// Slider attached to one translation axis of the selected entity.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InspectorSlider {
|
||||
/// Entity whose `Transform` is edited by this slider.
|
||||
pub entity: Entity,
|
||||
/// Translation axis edited by this slider.
|
||||
pub axis: TransformAxis,
|
||||
}
|
||||
|
||||
/// Translation axis edited by an [`InspectorSlider`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransformAxis {
|
||||
/// X axis.
|
||||
X,
|
||||
/// Y axis.
|
||||
Y,
|
||||
/// Z axis.
|
||||
Z,
|
||||
}
|
||||
|
||||
impl TransformAxis {
|
||||
const ALL: [Self; 3] = [Self::X, Self::Y, Self::Z];
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::X => 0,
|
||||
Self::Y => 1,
|
||||
Self::Z => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::X => "x",
|
||||
Self::Y => "y",
|
||||
Self::Z => "z",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the editor inspector panel behavior.
|
||||
pub struct InspectorPlugin;
|
||||
|
||||
impl Plugin for InspectorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<InspectorBuildState>().add_systems(
|
||||
Update,
|
||||
// Sync the current sliders before a selection change rebuilds/despawns
|
||||
// them. The reverse order queues a SliderValue insert for an entity that
|
||||
// the rebuild has already despawned, which panics in Bevy's command queue.
|
||||
(sync_inspector_from_transform, rebuild_inspector)
|
||||
.chain()
|
||||
.run_if(editor_active),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Value label next to one translation slider.
|
||||
#[derive(Component)]
|
||||
struct InspectorValueLabel {
|
||||
axis: TransformAxis,
|
||||
}
|
||||
|
||||
/// Tracks what the panel currently displays so it only rebuilds on selection change.
|
||||
#[derive(Resource, Default)]
|
||||
struct InspectorBuildState {
|
||||
initialized: bool,
|
||||
selection: Option<Entity>,
|
||||
}
|
||||
|
||||
/// Owned description of the selected entity, built once per selection change.
|
||||
struct InspectorView {
|
||||
name: String,
|
||||
translation: [f32; 3],
|
||||
rotation: String,
|
||||
scale: String,
|
||||
visibility: String,
|
||||
has_transform: bool,
|
||||
has_visibility: bool,
|
||||
}
|
||||
|
||||
/// Rebuilds the inspector body when the selection changes.
|
||||
fn rebuild_inspector(
|
||||
mut commands: Commands,
|
||||
panels: Query<(Entity, Option<&Children>), With<InspectorPanel>>,
|
||||
selection: Res<Selection>,
|
||||
names: Query<&Name>,
|
||||
transforms: Query<&Transform>,
|
||||
visibilities: Query<&Visibility>,
|
||||
theme: Res<UiTheme>,
|
||||
mut built: ResMut<InspectorBuildState>,
|
||||
) {
|
||||
if built.initialized && built.selection == selection.0 {
|
||||
return;
|
||||
}
|
||||
let Some((panel_entity, children)) = panels.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
built.initialized = true;
|
||||
built.selection = selection.0;
|
||||
|
||||
if let Some(children) = children {
|
||||
for child in children.iter() {
|
||||
commands.entity(child).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
let selected = selection.0;
|
||||
let view = selected.map(|entity| build_view(entity, &names, &transforms, &visibilities));
|
||||
|
||||
if let Some(entity) = selected {
|
||||
let name = view
|
||||
.as_ref()
|
||||
.map(|view| view.name.as_str())
|
||||
.unwrap_or("<unknown>");
|
||||
info!("inspector: selected {entity} ({name})");
|
||||
}
|
||||
|
||||
commands.entity(panel_entity).with_children(|panel| {
|
||||
panel.spawn(label("Inspector", 14.0, theme.text));
|
||||
|
||||
let Some(view) = &view else {
|
||||
panel.spawn(label(
|
||||
"Select an entity in the hierarchy",
|
||||
11.0,
|
||||
theme.text_muted,
|
||||
));
|
||||
return;
|
||||
};
|
||||
|
||||
spawn_read_only_row(panel, "Name", &view.name, &theme);
|
||||
panel.spawn(label("Transform", 11.0, theme.accent));
|
||||
|
||||
if view.has_transform {
|
||||
if let Some(entity) = selected {
|
||||
for axis in TransformAxis::ALL {
|
||||
spawn_slider_row(panel, entity, axis, view.translation[axis.index()], &theme);
|
||||
}
|
||||
}
|
||||
spawn_read_only_row(panel, "Rotation", &view.rotation, &theme);
|
||||
spawn_read_only_row(panel, "Scale", &view.scale, &theme);
|
||||
} else {
|
||||
panel.spawn(label("No Transform component", 11.0, theme.text_muted));
|
||||
}
|
||||
|
||||
if view.has_visibility {
|
||||
spawn_read_only_row(panel, "Visibility", &view.visibility, &theme);
|
||||
} else {
|
||||
panel.spawn(label("No Visibility component", 11.0, theme.text_muted));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies a slider edit to the selected entity's `Transform` through reflection.
|
||||
fn on_slider_value_change(
|
||||
change: On<ValueChange<f32>>,
|
||||
sliders: Query<&InspectorSlider>,
|
||||
mut transforms: Query<&mut Transform>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let Ok(slider) = sliders.get(change.source) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut transform) = transforms.get_mut(slider.entity) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let path = ["translation", slider.axis.name()];
|
||||
if apply_f32_path(transform.as_reflect_mut(), &path, change.value) {
|
||||
// Keep the widget's own state in step with the edit; the gizmo-facing
|
||||
// sync system below handles external Transform changes.
|
||||
commands
|
||||
.entity(change.source)
|
||||
.insert(SliderValue(change.value));
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the selected entity's `Transform.translation` into slider state and labels.
|
||||
///
|
||||
/// This runs every frame while the editor is active. The small tolerance prevents a
|
||||
/// slider drag (which already applied the value to the `Transform`) from being
|
||||
/// overwritten by a slightly stale widget value.
|
||||
fn sync_inspector_from_transform(
|
||||
mut commands: Commands,
|
||||
selection: Res<Selection>,
|
||||
transforms: Query<&Transform>,
|
||||
sliders: Query<(Entity, &InspectorSlider, &SliderValue, &SliderRange)>,
|
||||
children: Query<&Children>,
|
||||
mut thumbs: Query<&mut Node, With<SliderThumb>>,
|
||||
mut labels: Query<(&InspectorValueLabel, &mut Text)>,
|
||||
) {
|
||||
let Some(entity) = selection.0 else {
|
||||
return;
|
||||
};
|
||||
let Ok(transform) = transforms.get(entity) else {
|
||||
return;
|
||||
};
|
||||
let translation = transform.translation;
|
||||
let values = [translation.x, translation.y, translation.z];
|
||||
|
||||
for (slider_entity, slider, current, range) in &sliders {
|
||||
let target = range.clamp(values[slider.axis.index()]);
|
||||
if (current.0 - target).abs() > SYNC_TOLERANCE {
|
||||
commands.entity(slider_entity).insert(SliderValue(target));
|
||||
}
|
||||
|
||||
let thumb_percent = percent(range.thumb_position(target) * 100.0);
|
||||
for descendant in children.iter_descendants(slider_entity) {
|
||||
if let Ok(mut thumb) = thumbs.get_mut(descendant) {
|
||||
if thumb.left != thumb_percent {
|
||||
thumb.left = thumb_percent;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (label, mut text) in &mut labels {
|
||||
let formatted = format!("{:.1}", values[label.axis.index()]);
|
||||
if text.0 != formatted {
|
||||
text.0 = formatted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the owned display model for one entity with reflection traversal.
|
||||
fn build_view(
|
||||
entity: Entity,
|
||||
names: &Query<&Name>,
|
||||
transforms: &Query<&Transform>,
|
||||
visibilities: &Query<&Visibility>,
|
||||
) -> InspectorView {
|
||||
let mut view = InspectorView {
|
||||
name: format!("Entity#{}", entity.index()),
|
||||
translation: [0.0; 3],
|
||||
rotation: "-".to_string(),
|
||||
scale: "-".to_string(),
|
||||
visibility: "-".to_string(),
|
||||
has_transform: false,
|
||||
has_visibility: false,
|
||||
};
|
||||
|
||||
if let Ok(name) = names.get(entity) {
|
||||
let fields = summarize_reflect(name);
|
||||
view.name = fields
|
||||
.first()
|
||||
.map(|field| field.value.clone())
|
||||
.unwrap_or_else(|| name.as_str().to_string());
|
||||
}
|
||||
|
||||
if let Ok(transform) = transforms.get(entity) {
|
||||
let fields = summarize_reflect(transform);
|
||||
for axis in TransformAxis::ALL {
|
||||
view.translation[axis.index()] = field_f32(&fields, &["translation", axis.name()])
|
||||
.unwrap_or_else(|| translation_component(transform.translation, axis));
|
||||
}
|
||||
view.rotation = joined_component_values(&fields, "rotation");
|
||||
view.scale = joined_component_values(&fields, "scale");
|
||||
view.has_transform = true;
|
||||
}
|
||||
|
||||
if let Ok(visibility) = visibilities.get(entity) {
|
||||
let fields = summarize_reflect(visibility);
|
||||
view.visibility = fields
|
||||
.first()
|
||||
.map(|field| field.value.clone())
|
||||
.unwrap_or_else(|| format!("{visibility:?}"));
|
||||
view.has_visibility = true;
|
||||
}
|
||||
|
||||
view
|
||||
}
|
||||
|
||||
fn translation_component(translation: Vec3, axis: TransformAxis) -> f32 {
|
||||
match axis {
|
||||
TransformAxis::X => translation.x,
|
||||
TransformAxis::Y => translation.y,
|
||||
TransformAxis::Z => translation.z,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns one `X`/`Y`/`Z` slider row with its value label.
|
||||
fn spawn_slider_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
entity: Entity,
|
||||
axis: TransformAxis,
|
||||
value: f32,
|
||||
theme: &UiTheme,
|
||||
) {
|
||||
parent
|
||||
.spawn(Node {
|
||||
width: percent(100),
|
||||
height: px(24),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: px(6),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn(label(axis.name(), 12.0, theme.text_muted));
|
||||
|
||||
row.spawn((
|
||||
Node {
|
||||
flex_grow: 1.0,
|
||||
height: px(12),
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Stretch,
|
||||
..default()
|
||||
},
|
||||
Slider {
|
||||
track_click: TrackClick::Snap,
|
||||
..default()
|
||||
},
|
||||
SliderValue(value),
|
||||
SliderRange::new(SLIDER_MIN, SLIDER_MAX),
|
||||
SliderStep(SLIDER_STEP),
|
||||
InspectorSlider { entity, axis },
|
||||
))
|
||||
.observe(on_slider_value_change)
|
||||
.with_children(|slider| {
|
||||
// Background rail.
|
||||
slider.spawn((
|
||||
Node {
|
||||
height: px(6),
|
||||
border_radius: BorderRadius::all(px(3)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(theme.panel_bg_alt),
|
||||
));
|
||||
|
||||
// Invisible track, inset by the thumb width so percent placement works.
|
||||
slider
|
||||
.spawn(Node {
|
||||
display: Display::Flex,
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(0),
|
||||
right: px(12),
|
||||
top: px(0),
|
||||
bottom: px(0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|track| {
|
||||
track.spawn((
|
||||
SliderThumb,
|
||||
Node {
|
||||
display: Display::Flex,
|
||||
width: px(12),
|
||||
height: px(12),
|
||||
position_type: PositionType::Absolute,
|
||||
left: percent(slider_thumb_percent(value, SLIDER_MIN, SLIDER_MAX)),
|
||||
border_radius: BorderRadius::MAX,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(theme.accent),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
row.spawn((
|
||||
InspectorValueLabel { axis },
|
||||
Text::new(format!("{value:.1}")),
|
||||
TextFont {
|
||||
font: FontSource::default(),
|
||||
font_size: FontSize::Px(11.0),
|
||||
..default()
|
||||
},
|
||||
TextColor(theme.text_muted),
|
||||
Node {
|
||||
width: px(44),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns a label/value row for read-only data.
|
||||
fn spawn_read_only_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
name: &str,
|
||||
value: &str,
|
||||
theme: &UiTheme,
|
||||
) {
|
||||
parent
|
||||
.spawn(Node {
|
||||
width: percent(100),
|
||||
min_height: px(18),
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::SpaceBetween,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: px(8),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn(label(name, 12.0, theme.text_muted));
|
||||
row.spawn(label(value, 12.0, theme.text));
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a UI label bundle with the split `Text` / `TextFont` / `TextColor` API.
|
||||
fn label(value: impl Into<String>, size: f32, color: Color) -> impl Bundle {
|
||||
(
|
||||
Text::new(value.into()),
|
||||
TextFont {
|
||||
font: FontSource::default(),
|
||||
font_size: FontSize::Px(size),
|
||||
..default()
|
||||
},
|
||||
TextColor(color),
|
||||
)
|
||||
}
|
||||
|
||||
fn slider_thumb_percent(value: f32, min: f32, max: f32) -> f32 {
|
||||
if max <= min {
|
||||
return 0.0;
|
||||
}
|
||||
((value - min) / (max - min) * 100.0).clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reflection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn collect_fields(
|
||||
value: &dyn PartialReflect,
|
||||
path: &mut Vec<String>,
|
||||
depth: usize,
|
||||
out: &mut Vec<ReflectedField>,
|
||||
) {
|
||||
if depth > MAX_SUMMARY_DEPTH {
|
||||
push_leaf(value, path, out);
|
||||
return;
|
||||
}
|
||||
|
||||
match value.reflect_ref() {
|
||||
ReflectRef::Struct(struct_value) => {
|
||||
let field_count = struct_value.field_len();
|
||||
if field_count == 0 {
|
||||
push_leaf(value, path, out);
|
||||
return;
|
||||
}
|
||||
for index in 0..field_count {
|
||||
let Some(field) = struct_value.field_at(index) else {
|
||||
continue;
|
||||
};
|
||||
let field_name = struct_value.name_at(index).unwrap_or("?");
|
||||
path.push(field_name.to_string());
|
||||
collect_fields(field, path, depth + 1, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
ReflectRef::TupleStruct(tuple_struct) => {
|
||||
let field_count = tuple_struct.field_len();
|
||||
if field_count == 0 {
|
||||
push_leaf(value, path, out);
|
||||
return;
|
||||
}
|
||||
for index in 0..field_count {
|
||||
let Some(field) = tuple_struct.field(index) else {
|
||||
continue;
|
||||
};
|
||||
path.push(index.to_string());
|
||||
collect_fields(field, path, depth + 1, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
ReflectRef::Enum(enum_value) => match enum_value.variant_type() {
|
||||
VariantType::Unit => {
|
||||
let label = if path.is_empty() {
|
||||
short_type_label(value)
|
||||
} else {
|
||||
path.join(".")
|
||||
};
|
||||
out.push(ReflectedField {
|
||||
label,
|
||||
value: enum_value.variant_name().to_string(),
|
||||
path: path.clone(),
|
||||
editable: false,
|
||||
});
|
||||
}
|
||||
VariantType::Tuple | VariantType::Struct => {
|
||||
for index in 0..enum_value.field_len() {
|
||||
let Some(field) = enum_value.field_at(index) else {
|
||||
continue;
|
||||
};
|
||||
let field_name = enum_value
|
||||
.name_at(index)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| index.to_string());
|
||||
path.push(field_name);
|
||||
collect_fields(field, path, depth + 1, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => push_leaf(value, path, out),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_leaf(value: &dyn PartialReflect, path: &[String], out: &mut Vec<ReflectedField>) {
|
||||
let label = if path.is_empty() {
|
||||
short_type_label(value)
|
||||
} else {
|
||||
path.join(".")
|
||||
};
|
||||
out.push(ReflectedField {
|
||||
label,
|
||||
value: display_value(value),
|
||||
path: path.to_vec(),
|
||||
editable: is_numeric(value),
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_f32_in(value: &mut dyn PartialReflect, path: &[&str], new_value: f32) -> bool {
|
||||
let Some((head, tail)) = path.split_first() else {
|
||||
return false;
|
||||
};
|
||||
let Some(field) = reflected_field_mut(value, head) else {
|
||||
return false;
|
||||
};
|
||||
if tail.is_empty() {
|
||||
match field.try_downcast_mut::<f32>() {
|
||||
Some(slot) => {
|
||||
*slot = new_value;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
} else {
|
||||
apply_f32_in(field, tail, new_value)
|
||||
}
|
||||
}
|
||||
|
||||
fn reflected_field_mut<'a>(
|
||||
value: &'a mut dyn PartialReflect,
|
||||
name: &str,
|
||||
) -> Option<&'a mut dyn PartialReflect> {
|
||||
match value.reflect_mut() {
|
||||
ReflectMut::Struct(struct_value) => struct_value.field_mut(name),
|
||||
ReflectMut::TupleStruct(tuple_struct) => name
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|index| tuple_struct.field_mut(index)),
|
||||
ReflectMut::Enum(enum_value) => enum_value.field_mut(name),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn field_f32(fields: &[ReflectedField], path: &[&str]) -> Option<f32> {
|
||||
field_by_path(fields, path).and_then(|field| field.value.parse::<f32>().ok())
|
||||
}
|
||||
|
||||
fn field_by_path<'a>(fields: &'a [ReflectedField], path: &[&str]) -> Option<&'a ReflectedField> {
|
||||
fields.iter().find(|field| {
|
||||
field
|
||||
.path
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.eq(path.iter().copied())
|
||||
})
|
||||
}
|
||||
|
||||
fn joined_component_values(fields: &[ReflectedField], group: &str) -> String {
|
||||
let values: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|field| field.path.first().map(String::as_str) == Some(group))
|
||||
.map(|field| field.value.as_str())
|
||||
.collect();
|
||||
if values.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
values.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
fn short_type_label(value: &dyn PartialReflect) -> String {
|
||||
value
|
||||
.reflect_type_path()
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.unwrap_or("value")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_numeric(value: &dyn PartialReflect) -> bool {
|
||||
value.try_downcast_ref::<f32>().is_some() || value.try_downcast_ref::<f64>().is_some()
|
||||
}
|
||||
|
||||
fn display_value(value: &dyn PartialReflect) -> String {
|
||||
if let Some(value) = value.try_downcast_ref::<f32>() {
|
||||
return format_f32(*value);
|
||||
}
|
||||
if let Some(value) = value.try_downcast_ref::<f64>() {
|
||||
return format_f64(*value);
|
||||
}
|
||||
if let Some(value) = value.try_downcast_ref::<bool>() {
|
||||
return value.to_string();
|
||||
}
|
||||
if let Some(value) = value.try_downcast_ref::<String>() {
|
||||
return value.clone();
|
||||
}
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn format_f32(value: f32) -> String {
|
||||
format_number(value as f64)
|
||||
}
|
||||
|
||||
fn format_f64(value: f64) -> String {
|
||||
format_number(value)
|
||||
}
|
||||
|
||||
fn format_number(value: f64) -> String {
|
||||
let rounded = (value * 1000.0).round() / 1000.0;
|
||||
if rounded == 0.0 {
|
||||
"0.0".to_string()
|
||||
} else if rounded.fract() == 0.0 {
|
||||
format!("{rounded:.1}")
|
||||
} else {
|
||||
format!("{rounded}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Reflect)]
|
||||
struct Pair(f32, bool);
|
||||
|
||||
#[derive(Reflect)]
|
||||
struct Point {
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
|
||||
fn field<'a>(fields: &'a [ReflectedField], path: &[&str]) -> &'a ReflectedField {
|
||||
field_by_path(fields, path)
|
||||
.unwrap_or_else(|| panic!("missing field {path:?} in {fields:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_summary_contains_translation_axes() {
|
||||
let transform = Transform::from_xyz(1.5, -2.0, 3.25);
|
||||
let fields = summarize_reflect(&transform);
|
||||
|
||||
assert_eq!(field(&fields, &["translation", "x"]).label, "translation.x");
|
||||
assert_eq!(field(&fields, &["translation", "x"]).value, "1.5");
|
||||
assert_eq!(field(&fields, &["translation", "y"]).value, "-2.0");
|
||||
assert_eq!(field(&fields, &["translation", "z"]).value, "3.25");
|
||||
assert!(field(&fields, &["translation", "x"]).editable);
|
||||
assert!(field_by_path(&fields, &["rotation", "w"]).is_some());
|
||||
assert!(field_by_path(&fields, &["scale", "y"]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_summary_contains_the_name() {
|
||||
let fields = summarize_reflect(&Name::new("Cube"));
|
||||
assert_eq!(fields.len(), 1);
|
||||
assert_eq!(fields[0].value, "Cube");
|
||||
assert!(!fields[0].editable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_summary_is_non_empty_and_contains_variant() {
|
||||
let fields = summarize_reflect(&Visibility::Visible);
|
||||
assert!(!fields.is_empty());
|
||||
assert!(fields.iter().any(|field| field.value.contains("Visible")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_struct_summary_uses_index_paths() {
|
||||
let fields = summarize_reflect(&Pair(1.25, true));
|
||||
assert_eq!(field(&fields, &["0"]).value, "1.25");
|
||||
assert_eq!(field(&fields, &["1"]).value, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_struct_summary_flattens_fields() {
|
||||
let fields = summarize_reflect(&Point { x: 2.0, y: -0.5 });
|
||||
assert_eq!(field(&fields, &["x"]).value, "2.0");
|
||||
assert_eq!(field(&fields, &["y"]).value, "-0.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_f32_path_changes_translation_x() {
|
||||
let mut transform = Transform::from_xyz(0.0, 0.0, 0.0);
|
||||
assert!(apply_f32_path(&mut transform, &["translation", "x"], 2.5));
|
||||
assert_eq!(transform.translation, Vec3::new(2.5, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_f32_path_rejects_wrong_path_without_panicking() {
|
||||
let mut transform = Transform::default();
|
||||
assert!(!apply_f32_path(&mut transform, &[], 1.0));
|
||||
assert!(!apply_f32_path(&mut transform, &["translation"], 1.0));
|
||||
assert!(!apply_f32_path(&mut transform, &["translation", "w"], 1.0));
|
||||
assert!(!apply_f32_path(
|
||||
&mut transform,
|
||||
&["translation", "x", "extra"],
|
||||
1.0
|
||||
));
|
||||
assert!(!apply_f32_path(&mut transform, &["nonexistent"], 1.0));
|
||||
assert_eq!(transform, Transform::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Bevy Forge live editor runtime (v2 M0 spike).
|
||||
//!
|
||||
//! [`EditorPlugin`] installs the editor resources, the fixed Bevy UI shell
|
||||
//! (toolbar, hierarchy panel, inspector panel, status bar) and the module plugins
|
||||
//! that provide hierarchy, inspector, gizmo and screenshot behavior.
|
||||
//!
|
||||
//! The plugin is designed to be added after `DefaultPlugins`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! App::new()
|
||||
//! .add_plugins(DefaultPlugins)
|
||||
//! .add_plugins(EditorPlugin)
|
||||
//! .run();
|
||||
//! ```
|
||||
|
||||
pub mod gizmo;
|
||||
pub mod hierarchy;
|
||||
pub mod inspector;
|
||||
pub mod plugin;
|
||||
pub mod screenshot;
|
||||
pub mod ui;
|
||||
|
||||
pub use gizmo::GizmoPlugin;
|
||||
pub use hierarchy::HierarchyPlugin;
|
||||
pub use inspector::InspectorPlugin;
|
||||
pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection};
|
||||
pub use screenshot::ScreenshotPlugin;
|
||||
pub use ui::{
|
||||
EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
//! The root editor plugin and the shared editor resources.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{
|
||||
gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, inspector::InspectorPlugin,
|
||||
screenshot::ScreenshotPlugin, ui::shell::EditorUiPlugin,
|
||||
};
|
||||
|
||||
/// Installs the Bevy Forge live editor on top of `DefaultPlugins`.
|
||||
///
|
||||
/// The plugin is usable exactly once per app. It does not overwrite the
|
||||
/// [`EditorState`] or [`Selection`] resources when the host app has already
|
||||
/// inserted them.
|
||||
pub struct EditorPlugin;
|
||||
|
||||
impl Plugin for EditorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<EditorState>()
|
||||
.init_resource::<Selection>()
|
||||
.add_plugins((
|
||||
EditorUiPlugin,
|
||||
HierarchyPlugin,
|
||||
InspectorPlugin,
|
||||
GizmoPlugin,
|
||||
ScreenshotPlugin,
|
||||
))
|
||||
.add_systems(Update, clear_dead_selection);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the editor UI and behavior are currently enabled.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
/// `true` while the editor is shown. Defaults to `true`.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for EditorState {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Flips the editor on or off.
|
||||
pub fn toggle(&mut self) {
|
||||
self.enabled = !self.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
/// Current single selection. `Selection(None)` means nothing is selected.
|
||||
#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Selection(pub Option<Entity>);
|
||||
|
||||
/// Optional bridge between the inspector and host-app-specific reflection data.
|
||||
///
|
||||
/// M0 does not need a concrete implementation; T03 uses typed `Transform` /
|
||||
/// `Name` / `Visibility` queries. The trait is part of the frozen public API so
|
||||
/// M1 can plug in a component registry without changing the plugin surface.
|
||||
pub trait InspectorSource {
|
||||
/// Returns the reflected component data for `entity`, if any.
|
||||
fn reflect_component(&self, entity: Entity) -> Option<&dyn Reflect>;
|
||||
}
|
||||
|
||||
/// Run condition: systems gated on this only run while the editor is enabled.
|
||||
pub fn editor_active(state: Res<EditorState>) -> bool {
|
||||
state.enabled
|
||||
}
|
||||
|
||||
/// Clears [`Selection`] when the selected entity was despawned.
|
||||
fn clear_dead_selection(mut selection: ResMut<Selection>, entities: Query<Entity>) {
|
||||
let cleared = clear_selection_if_dead(&mut selection, |entity| entities.get(entity).is_ok());
|
||||
if cleared {
|
||||
debug!("selection: cleared (entity no longer exists)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the selection if `entity_alive` reports that it is gone.
|
||||
///
|
||||
/// Returns `true` when a selection was cleared. Extracted so the cleanup rule
|
||||
/// can be unit tested without configuring a full app.
|
||||
pub(crate) fn clear_selection_if_dead(
|
||||
selection: &mut Selection,
|
||||
entity_alive: impl Fn(Entity) -> bool,
|
||||
) -> bool {
|
||||
let Some(entity) = selection.0 else {
|
||||
return false;
|
||||
};
|
||||
if entity_alive(entity) {
|
||||
return false;
|
||||
}
|
||||
selection.0 = None;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn editor_state_defaults_to_enabled() {
|
||||
assert!(EditorState::default().enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_state_toggles() {
|
||||
let mut state = EditorState::default();
|
||||
state.toggle();
|
||||
assert!(!state.enabled);
|
||||
state.toggle();
|
||||
assert!(state.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_defaults_to_none() {
|
||||
assert_eq!(Selection::default().0, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_selection_is_cleared() {
|
||||
let mut selection = Selection(Some(Entity::PLACEHOLDER));
|
||||
assert!(clear_selection_if_dead(&mut selection, |_| false));
|
||||
assert_eq!(selection.0, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_selection_is_kept() {
|
||||
let mut selection = Selection(Some(Entity::PLACEHOLDER));
|
||||
assert!(!clear_selection_if_dead(&mut selection, |_| true));
|
||||
assert_eq!(selection.0, Some(Entity::PLACEHOLDER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_selection_is_a_no_op() {
|
||||
let mut selection = Selection(None);
|
||||
assert!(!clear_selection_if_dead(&mut selection, |_| false));
|
||||
assert_eq!(selection.0, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_registers_resources_without_overwriting_existing_ones() {
|
||||
let mut app = App::new();
|
||||
app.insert_resource(EditorState { enabled: false });
|
||||
app.add_plugins(EditorPlugin);
|
||||
|
||||
assert!(app.world().contains_resource::<Selection>());
|
||||
assert_eq!(app.world().resource::<Selection>().0, None);
|
||||
assert!(!app.world().resource::<EditorState>().enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Deterministic screenshot plugin.
|
||||
//!
|
||||
//! Opt-in environment contract:
|
||||
//!
|
||||
//! | Variable | Default | Meaning |
|
||||
//! |---|---|---|
|
||||
//! | `FORGE_SCREENSHOT` | unset | output PNG path; unset disables the mode |
|
||||
//! | `FORGE_SCREENSHOT_SIZE` | `1440x900` | `WIDTHxHEIGHT`, each `320..=7680` |
|
||||
//! | `FORGE_SCREENSHOT_FRAMES` | `300` | frames before capture, `1..=1200` |
|
||||
//! | `FORGE_SCREENSHOT_WARMUP_SECS` | `10` | wall-clock seconds, `0..=120` |
|
||||
//!
|
||||
//! Unset means a complete no-op: no systems, no window resize, no exit. Invalid
|
||||
//! values log an error and disable the mode. Capture waits for both thresholds
|
||||
//! because the probe showed mesh/PBR pipelines need about ten seconds to warm up
|
||||
//! (a 30-frame shot is blank). Success saves the PNG and writes
|
||||
//! [`AppExit::Success`]; if no capture is confirmed within 300 frames, a fallback
|
||||
//! exits with an error.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy::{
|
||||
prelude::*,
|
||||
render::view::screenshot::{save_to_disk, Screenshot, ScreenshotCaptured},
|
||||
window::{PrimaryWindow, WindowResolution},
|
||||
};
|
||||
|
||||
const PATH_ENV: &str = "FORGE_SCREENSHOT";
|
||||
const SIZE_ENV: &str = "FORGE_SCREENSHOT_SIZE";
|
||||
const FRAMES_ENV: &str = "FORGE_SCREENSHOT_FRAMES";
|
||||
const WARMUP_ENV: &str = "FORGE_SCREENSHOT_WARMUP_SECS";
|
||||
|
||||
const DEFAULT_WIDTH: u32 = 1440;
|
||||
const DEFAULT_HEIGHT: u32 = 900;
|
||||
const DEFAULT_FRAMES: u32 = 300;
|
||||
const DEFAULT_WARMUP_SECS: u32 = 10;
|
||||
|
||||
const MIN_DIMENSION: u32 = 320;
|
||||
const MAX_DIMENSION: u32 = 7680;
|
||||
const MIN_FRAMES: u32 = 1;
|
||||
const MAX_FRAMES: u32 = 1200;
|
||||
const MAX_WARMUP_SECS: u32 = 120;
|
||||
const FALLBACK_FRAMES: u32 = 300;
|
||||
|
||||
/// Adds opt-in deterministic screenshot capture (`FORGE_SCREENSHOT`).
|
||||
pub struct ScreenshotPlugin;
|
||||
|
||||
impl Plugin for ScreenshotPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
match config_from_env() {
|
||||
Ok(config) => configure(app, config),
|
||||
Err(error) => error!("screenshot: {error}; screenshot mode disabled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Clone, PartialEq, Eq)]
|
||||
struct ScreenshotConfig {
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
frames: u32,
|
||||
warmup_secs: u32,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct ScreenshotState {
|
||||
frames_elapsed: u32,
|
||||
capture_requested: bool,
|
||||
frames_since_capture: u32,
|
||||
exit_requested: bool,
|
||||
}
|
||||
|
||||
/// Installs the mode only when `config` is `Some`; `None` is a complete no-op.
|
||||
fn configure(app: &mut App, config: Option<ScreenshotConfig>) {
|
||||
let Some(config) = config else {
|
||||
return;
|
||||
};
|
||||
app.insert_resource(config)
|
||||
.init_resource::<ScreenshotState>()
|
||||
.add_systems(Startup, apply_screenshot_window)
|
||||
.add_systems(Update, request_screenshot);
|
||||
}
|
||||
|
||||
/// Forces the primary window to the requested physical size at scale factor 1.0.
|
||||
fn apply_screenshot_window(
|
||||
config: Res<ScreenshotConfig>,
|
||||
mut windows: Query<&mut Window, With<PrimaryWindow>>,
|
||||
) {
|
||||
let Ok(mut window) = windows.single_mut() else {
|
||||
error!(
|
||||
"screenshot: no primary window; cannot set {}x{}",
|
||||
config.width, config.height
|
||||
);
|
||||
return;
|
||||
};
|
||||
window.resolution =
|
||||
WindowResolution::new(config.width, config.height).with_scale_factor_override(1.0);
|
||||
info!(
|
||||
"screenshot: window set to {}x{} (scale factor 1.0)",
|
||||
config.width, config.height
|
||||
);
|
||||
}
|
||||
|
||||
/// Requests one capture after both thresholds, then exits on capture or fallback.
|
||||
fn request_screenshot(
|
||||
mut commands: Commands,
|
||||
config: Res<ScreenshotConfig>,
|
||||
mut state: ResMut<ScreenshotState>,
|
||||
real_time: Res<Time<Real>>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
if state.exit_requested {
|
||||
return;
|
||||
}
|
||||
if state.capture_requested {
|
||||
state.frames_since_capture = state.frames_since_capture.saturating_add(1);
|
||||
if state.frames_since_capture >= FALLBACK_FRAMES {
|
||||
warn!("screenshot: capture was not confirmed within {FALLBACK_FRAMES} frames; exiting");
|
||||
state.exit_requested = true;
|
||||
exit.write(AppExit::error());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state.frames_elapsed = state.frames_elapsed.saturating_add(1);
|
||||
let warmup_elapsed = real_time.elapsed_secs_f64() >= f64::from(config.warmup_secs);
|
||||
if state.frames_elapsed < config.frames || !warmup_elapsed {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = create_parent_dirs(&config.path) {
|
||||
error!("screenshot: {error}; exiting");
|
||||
state.exit_requested = true;
|
||||
exit.write(AppExit::error());
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
"screenshot: saving {} ({}x{})",
|
||||
config.path.display(),
|
||||
config.width,
|
||||
config.height
|
||||
);
|
||||
commands
|
||||
.spawn(Screenshot::primary_window())
|
||||
.observe(save_to_disk(config.path.clone()))
|
||||
.observe(exit_after_screenshot);
|
||||
state.capture_requested = true;
|
||||
}
|
||||
|
||||
fn exit_after_screenshot(_captured: On<ScreenshotCaptured>, mut exit: MessageWriter<AppExit>) {
|
||||
exit.write(AppExit::Success);
|
||||
}
|
||||
|
||||
fn create_parent_dirs(path: &Path) -> Result<(), String> {
|
||||
match path.parent() {
|
||||
Some(parent) if !parent.as_os_str().is_empty() => {
|
||||
std::fs::create_dir_all(parent).map_err(|error| {
|
||||
format!(
|
||||
"cannot create output directory {}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the environment once; the path is checked first so unset stays a no-op
|
||||
/// even when one of the other variables is malformed.
|
||||
fn config_from_env() -> Result<Option<ScreenshotConfig>, String> {
|
||||
let Some(path) = std::env::var_os(PATH_ENV) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = PathBuf::from(path);
|
||||
let size = env_string(SIZE_ENV)?;
|
||||
let frames = env_string(FRAMES_ENV)?;
|
||||
let warmup_secs = env_string(WARMUP_ENV)?;
|
||||
parse_config(
|
||||
Some(&path),
|
||||
size.as_deref(),
|
||||
frames.as_deref(),
|
||||
warmup_secs.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn env_string(name: &str) -> Result<Option<String>, String> {
|
||||
match std::env::var(name) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be valid UTF-8")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Ok(None)` when the path is absent; `Err` when it is malformed.
|
||||
fn parse_config(
|
||||
path: Option<&Path>,
|
||||
size: Option<&str>,
|
||||
frames: Option<&str>,
|
||||
warmup_secs: Option<&str>,
|
||||
) -> Result<Option<ScreenshotConfig>, String> {
|
||||
let Some(path) = path else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(format!("{PATH_ENV} must not be empty"));
|
||||
}
|
||||
let (width, height) = match size {
|
||||
Some(raw) => parse_size(raw)?,
|
||||
None => (DEFAULT_WIDTH, DEFAULT_HEIGHT),
|
||||
};
|
||||
let frames = match frames {
|
||||
Some(raw) => parse_decimal(FRAMES_ENV, raw, MIN_FRAMES, MAX_FRAMES)?,
|
||||
None => DEFAULT_FRAMES,
|
||||
};
|
||||
let warmup_secs = match warmup_secs {
|
||||
Some(raw) => parse_decimal(WARMUP_ENV, raw, 0, MAX_WARMUP_SECS)?,
|
||||
None => DEFAULT_WARMUP_SECS,
|
||||
};
|
||||
Ok(Some(ScreenshotConfig {
|
||||
path: path.to_path_buf(),
|
||||
width,
|
||||
height,
|
||||
frames,
|
||||
warmup_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_size(raw: &str) -> Result<(u32, u32), String> {
|
||||
let Some((width, height)) = raw.split_once('x') else {
|
||||
return Err(format!("{SIZE_ENV} must be WIDTHxHEIGHT, got {raw:?}"));
|
||||
};
|
||||
Ok((
|
||||
parse_decimal("width", width, MIN_DIMENSION, MAX_DIMENSION)
|
||||
.map_err(|error| format!("{SIZE_ENV}: {error}"))?,
|
||||
parse_decimal("height", height, MIN_DIMENSION, MAX_DIMENSION)
|
||||
.map_err(|error| format!("{SIZE_ENV}: {error}"))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_decimal(label: &str, raw: &str, min: u32, max: u32) -> Result<u32, String> {
|
||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(format!("{label} must be a decimal integer, got {raw:?}"));
|
||||
}
|
||||
let value: u32 = raw
|
||||
.parse()
|
||||
.map_err(|_| format!("{label} is out of range, got {raw:?}"))?;
|
||||
if !(min..=max).contains(&value) {
|
||||
return Err(format!("{label} must be in {min}..={max}, got {value}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn path() -> &'static Path {
|
||||
Path::new("/tmp/forge-screenshot.png")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_defaults_are_1440x900_300_frames_10_seconds() {
|
||||
let config = parse_config(Some(path()), None, None, None)
|
||||
.expect("defaults must parse")
|
||||
.expect("a path enables the mode");
|
||||
assert_eq!(
|
||||
(config.width, config.height),
|
||||
(DEFAULT_WIDTH, DEFAULT_HEIGHT)
|
||||
);
|
||||
assert_eq!((config.width, config.height), (1440, 900));
|
||||
assert_eq!(config.frames, 300);
|
||||
assert_eq!(config.warmup_secs, 10);
|
||||
assert_eq!(config.path, PathBuf::from(path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_parses_valid_size_and_limits() {
|
||||
let config = parse_config(Some(path()), Some("1000x640"), Some("12"), Some("0"))
|
||||
.expect("valid values must parse")
|
||||
.expect("a path enables the mode");
|
||||
assert_eq!((config.width, config.height), (1000, 640));
|
||||
assert_eq!(config.frames, 12);
|
||||
assert_eq!(config.warmup_secs, 0);
|
||||
|
||||
let min = parse_config(Some(path()), Some("320x320"), Some("1"), Some("120"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!((min.width, min.height), (320, 320));
|
||||
assert_eq!((min.frames, min.warmup_secs), (1, 120));
|
||||
|
||||
let max = parse_config(Some(path()), Some("7680x7680"), Some("1200"), None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!((max.width, max.height), (7680, 7680));
|
||||
assert_eq!(max.frames, 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_rejects_invalid_sizes() {
|
||||
for raw in [
|
||||
"",
|
||||
"abc",
|
||||
"1440",
|
||||
"1440x",
|
||||
"x900",
|
||||
"1440X900",
|
||||
"1440 x 900",
|
||||
"0x900",
|
||||
"1440x0",
|
||||
"319x900",
|
||||
"1440x319",
|
||||
"7681x900",
|
||||
"1440x7681",
|
||||
"1440x900x2",
|
||||
"-1440x900",
|
||||
] {
|
||||
assert!(
|
||||
parse_config(Some(path()), Some(raw), None, None).is_err(),
|
||||
"{raw:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_rejects_invalid_frames_and_warmup() {
|
||||
for raw in ["", "abc", "0", "1201", "-1", "300.0"] {
|
||||
assert!(
|
||||
parse_config(Some(path()), None, Some(raw), None).is_err(),
|
||||
"frames {raw:?} must be rejected"
|
||||
);
|
||||
}
|
||||
for raw in ["", "abc", "121", "-1", "10.0"] {
|
||||
assert!(
|
||||
parse_config(Some(path()), None, None, Some(raw)).is_err(),
|
||||
"warmup {raw:?} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_unset_path_is_disabled_even_with_invalid_values() {
|
||||
let config = parse_config(None, Some("not-a-size"), Some("0"), Some("999"))
|
||||
.expect("an absent path must short-circuit validation");
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_unset_mode_touches_no_window_and_installs_no_systems() {
|
||||
let config = parse_config(None, None, None, None).expect("unset is not an error");
|
||||
assert!(config.is_none());
|
||||
|
||||
let mut app = App::new();
|
||||
configure(&mut app, config);
|
||||
app.update();
|
||||
|
||||
assert!(!app.world().contains_resource::<ScreenshotConfig>());
|
||||
assert!(!app.world().contains_resource::<ScreenshotState>());
|
||||
let mut windows = app.world_mut().query::<&Window>();
|
||||
assert_eq!(windows.iter(app.world()).count(), 0);
|
||||
let mut screenshots = app.world_mut().query::<&Screenshot>();
|
||||
assert_eq!(screenshots.iter(app.world()).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_enabled_config_installs_mode_state() {
|
||||
let config = parse_config(Some(path()), Some("1000x640"), None, None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut app = App::new();
|
||||
configure(&mut app, Some(config));
|
||||
assert!(app.world().contains_resource::<ScreenshotConfig>());
|
||||
assert!(app.world().contains_resource::<ScreenshotState>());
|
||||
assert_eq!(app.world().resource::<ScreenshotConfig>().width, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Editor UI: the fixed shell, its layout metrics and the shared theme.
|
||||
|
||||
pub(crate) mod shell;
|
||||
pub mod theme;
|
||||
|
||||
pub use shell::{
|
||||
EditorLayout, EditorRoot, EntityCountText, HierarchyPanel, InspectorPanel, StatusBar,
|
||||
StatusText, Toolbar,
|
||||
};
|
||||
pub use theme::UiTheme;
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Fixed editor UI shell: toolbar, hierarchy panel, inspector panel and status bar.
|
||||
//!
|
||||
//! The center of the window is deliberately not a UI node: the 3D camera renders
|
||||
//! full-window behind the panels and [`EditorLayout::viewport_rect`] is the
|
||||
//! authoritative rectangle for viewport-only input (used by the gizmo).
|
||||
|
||||
use bevy::{
|
||||
prelude::*,
|
||||
text::FontSize,
|
||||
ui_widgets::{Activate, Button as UiButton},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
plugin::{EditorState, Selection},
|
||||
ui::theme::UiTheme,
|
||||
};
|
||||
|
||||
/// Full-window root of the editor overlay. Hidden as a whole when the editor is disabled.
|
||||
#[derive(Component)]
|
||||
pub struct EditorRoot;
|
||||
|
||||
/// Left-hand scene hierarchy panel.
|
||||
#[derive(Component)]
|
||||
pub struct HierarchyPanel;
|
||||
|
||||
/// Right-hand inspector panel.
|
||||
#[derive(Component)]
|
||||
pub struct InspectorPanel;
|
||||
|
||||
/// Top toolbar.
|
||||
#[derive(Component)]
|
||||
pub struct Toolbar;
|
||||
|
||||
/// Bottom status bar.
|
||||
#[derive(Component)]
|
||||
pub struct StatusBar;
|
||||
|
||||
/// The status bar's primary text node.
|
||||
#[derive(Component)]
|
||||
pub struct StatusText;
|
||||
|
||||
/// Toolbar text that reports how many scene entities exist.
|
||||
#[derive(Component)]
|
||||
pub struct EntityCountText;
|
||||
|
||||
/// Screen-space metrics of the overlay panels.
|
||||
///
|
||||
/// The viewport is the area left uncovered by the panels:
|
||||
/// `x = left`, `y = top`, `width = window - left - right`,
|
||||
/// `height = window - top - bottom`.
|
||||
#[derive(Resource, Debug, Clone, Copy)]
|
||||
pub struct EditorLayout {
|
||||
/// Width of the left hierarchy panel in logical pixels.
|
||||
pub left: f32,
|
||||
/// Width of the right inspector panel in logical pixels.
|
||||
pub right: f32,
|
||||
/// Height of the top toolbar in logical pixels.
|
||||
pub top: f32,
|
||||
/// Height of the bottom status bar in logical pixels.
|
||||
pub bottom: f32,
|
||||
}
|
||||
|
||||
impl Default for EditorLayout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
left: 260.0,
|
||||
right: 300.0,
|
||||
top: 38.0,
|
||||
bottom: 26.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorLayout {
|
||||
/// Rectangle of the 3D viewport in logical window coordinates.
|
||||
///
|
||||
/// Width and height are clamped to be non-negative for tiny windows.
|
||||
pub fn viewport_rect(&self, window: &Window) -> Rect {
|
||||
let width = (window.width() - self.left - self.right).max(0.0);
|
||||
let height = (window.height() - self.top - self.bottom).max(0.0);
|
||||
Rect::new(self.left, self.top, self.left + width, self.top + height)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the fixed editor shell and its state-sync systems.
|
||||
pub(crate) struct EditorUiPlugin;
|
||||
|
||||
impl Plugin for EditorUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<UiTheme>()
|
||||
.init_resource::<EditorLayout>()
|
||||
.add_systems(Startup, build_shell)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
toggle_editor_f1,
|
||||
sync_editor_visibility,
|
||||
update_status_text,
|
||||
update_entity_count,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the editor overlay. Runs at `Startup`, before host-app scene spawning.
|
||||
fn build_shell(mut commands: Commands, theme: Res<UiTheme>, layout: Res<EditorLayout>) {
|
||||
commands
|
||||
.spawn((
|
||||
EditorRoot,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
width: percent(100),
|
||||
height: percent(100),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
))
|
||||
.with_children(|root| {
|
||||
// --- Toolbar ---------------------------------------------------
|
||||
root.spawn((
|
||||
Toolbar,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: px(0),
|
||||
left: px(0),
|
||||
right: px(0),
|
||||
height: px(layout.top),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: px(10),
|
||||
padding: UiRect::horizontal(px(10)),
|
||||
border: UiRect::bottom(px(1)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(theme.toolbar_bg),
|
||||
BorderColor::all(theme.border),
|
||||
))
|
||||
.with_children(|bar| {
|
||||
bar.spawn(label("Bevy Forge", 15.0, theme.accent));
|
||||
|
||||
bar.spawn((
|
||||
UiButton,
|
||||
Node {
|
||||
height: px(24),
|
||||
padding: UiRect::horizontal(px(10)),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(px(1)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(theme.panel_bg_alt),
|
||||
BorderColor::all(theme.border),
|
||||
))
|
||||
.observe(toggle_editor_on_activate)
|
||||
.with_children(|button| {
|
||||
button.spawn(label("Editor (F1)", 13.0, theme.text));
|
||||
});
|
||||
|
||||
bar.spawn(Node {
|
||||
flex_grow: 1.0,
|
||||
..default()
|
||||
});
|
||||
bar.spawn((
|
||||
EntityCountText,
|
||||
label("Entities: 0", 12.0, theme.text_muted),
|
||||
));
|
||||
});
|
||||
|
||||
// --- Left hierarchy panel --------------------------------------
|
||||
root.spawn((
|
||||
HierarchyPanel,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(0),
|
||||
top: px(layout.top),
|
||||
bottom: px(layout.bottom),
|
||||
width: px(layout.left),
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(px(8)),
|
||||
row_gap: px(4),
|
||||
border: UiRect::right(px(1)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(theme.panel_bg),
|
||||
BorderColor::all(theme.border),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
panel.spawn(label("Hierarchy", 14.0, theme.text));
|
||||
panel.spawn(label("Waiting for scene...", 11.0, theme.text_muted));
|
||||
});
|
||||
|
||||
// --- Right inspector panel -------------------------------------
|
||||
root.spawn((
|
||||
InspectorPanel,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: px(0),
|
||||
top: px(layout.top),
|
||||
bottom: px(layout.bottom),
|
||||
width: px(layout.right),
|
||||
flex_direction: FlexDirection::Column,
|
||||
padding: UiRect::all(px(8)),
|
||||
row_gap: px(4),
|
||||
border: UiRect::left(px(1)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(theme.panel_bg),
|
||||
BorderColor::all(theme.border),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
panel.spawn(label("Inspector", 14.0, theme.text));
|
||||
panel.spawn(label("Waiting for selection...", 11.0, theme.text_muted));
|
||||
});
|
||||
|
||||
// --- Bottom status bar -----------------------------------------
|
||||
root.spawn((
|
||||
StatusBar,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(0),
|
||||
right: px(0),
|
||||
bottom: px(0),
|
||||
height: px(layout.bottom),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
padding: UiRect::horizontal(px(10)),
|
||||
border: UiRect::top(px(1)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(theme.status_bg),
|
||||
BorderColor::all(theme.border),
|
||||
))
|
||||
.with_children(|bar| {
|
||||
bar.spawn((
|
||||
StatusText,
|
||||
label(
|
||||
"No selection | Editor: on | F1 editor",
|
||||
12.0,
|
||||
theme.text_muted,
|
||||
),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
info!("editor shell ready (toolbar, hierarchy, inspector, status)");
|
||||
}
|
||||
|
||||
/// Builds a UI label bundle with the split `Text` / `TextFont` / `TextColor` API.
|
||||
fn label(value: impl Into<String>, size: f32, color: Color) -> impl Bundle {
|
||||
(
|
||||
Text::new(value.into()),
|
||||
TextFont {
|
||||
font: FontSource::default(),
|
||||
font_size: FontSize::Px(size),
|
||||
..default()
|
||||
},
|
||||
TextColor(color),
|
||||
)
|
||||
}
|
||||
|
||||
/// Flips [`EditorState`] when the toolbar button is activated.
|
||||
fn toggle_editor_on_activate(_activate: On<Activate>, mut state: ResMut<EditorState>) {
|
||||
state.toggle();
|
||||
}
|
||||
|
||||
/// F1 toggles the editor; the toolbar button uses the same state.
|
||||
fn toggle_editor_f1(keys: Res<ButtonInput<KeyCode>>, mut state: ResMut<EditorState>) {
|
||||
if keys.just_pressed(KeyCode::F1) {
|
||||
state.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches the [`EditorRoot`] display mode to [`EditorState`].
|
||||
///
|
||||
/// The root is hidden as a whole; child panels keep their own display mode.
|
||||
fn sync_editor_visibility(state: Res<EditorState>, mut roots: Query<&mut Node, With<EditorRoot>>) {
|
||||
if !state.is_changed() {
|
||||
return;
|
||||
}
|
||||
let display = if state.enabled {
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
};
|
||||
for mut node in &mut roots {
|
||||
if node.display != display {
|
||||
node.display = display;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the status line from the current selection, the selected entity's
|
||||
/// `Name` and the editor state.
|
||||
fn update_status_text(
|
||||
mut status: Query<&mut Text, With<StatusText>>,
|
||||
selection: Res<Selection>,
|
||||
names: Query<&Name>,
|
||||
state: Res<EditorState>,
|
||||
) {
|
||||
let mut value = match selection.0 {
|
||||
Some(entity) => match names.get(entity) {
|
||||
Ok(name) => format!("Selected: {}", name.as_str()),
|
||||
Err(_) => format!("Selected: {entity}"),
|
||||
},
|
||||
None => "No selection".to_string(),
|
||||
};
|
||||
value.push_str(if state.enabled {
|
||||
" | Editor: on | F1 editor"
|
||||
} else {
|
||||
" | Editor: off | F1 editor"
|
||||
});
|
||||
|
||||
for mut text in &mut status {
|
||||
if text.0 != value {
|
||||
text.0 = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the toolbar entity count for named, non-UI scene entities.
|
||||
fn update_entity_count(
|
||||
mut counters: Query<&mut Text, With<EntityCountText>>,
|
||||
scene_entities: Query<(), (With<Name>, With<Transform>, Without<Node>)>,
|
||||
) {
|
||||
let value = format!("Entities: {}", scene_entities.iter().count());
|
||||
for mut text in &mut counters {
|
||||
if text.0 != value {
|
||||
text.0 = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::window::WindowResolution;
|
||||
|
||||
fn window(width: u32, height: u32) -> Window {
|
||||
Window {
|
||||
resolution: WindowResolution::new(width, height).with_scale_factor_override(1.0),
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_layout_matches_contract() {
|
||||
let layout = EditorLayout::default();
|
||||
assert_eq!(layout.left, 260.0);
|
||||
assert_eq!(layout.right, 300.0);
|
||||
assert_eq!(layout.top, 38.0);
|
||||
assert_eq!(layout.bottom, 26.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_rect_1440x900() {
|
||||
let rect = EditorLayout::default().viewport_rect(&window(1440, 900));
|
||||
assert_eq!(rect.min, Vec2::new(260.0, 38.0));
|
||||
assert_eq!(rect.max, Vec2::new(1140.0, 874.0));
|
||||
assert_eq!(rect.size(), Vec2::new(880.0, 836.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_rect_1000x640() {
|
||||
let rect = EditorLayout::default().viewport_rect(&window(1000, 640));
|
||||
assert_eq!(rect.min, Vec2::new(260.0, 38.0));
|
||||
assert_eq!(rect.max, Vec2::new(700.0, 614.0));
|
||||
assert_eq!(rect.size(), Vec2::new(440.0, 576.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_rect_clamps_tiny_windows() {
|
||||
let rect = EditorLayout::default().viewport_rect(&window(400, 100));
|
||||
assert_eq!(rect.size(), Vec2::new(0.0, 36.0));
|
||||
assert!(rect.size().cmple(Vec2::new(400.0, 100.0)).all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Dark slate color theme shared by the editor UI.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Colors used by the editor shell and by later hierarchy/inspector/gizmo work.
|
||||
///
|
||||
/// All colors are stored as [`Color`] so widgets can use them directly in
|
||||
/// `TextColor`, `BackgroundColor` and `BorderColor` components.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct UiTheme {
|
||||
/// Background of the left and right panels.
|
||||
pub panel_bg: Color,
|
||||
/// Alternate background used for buttons and rows.
|
||||
pub panel_bg_alt: Color,
|
||||
/// Border and divider color.
|
||||
pub border: Color,
|
||||
/// Primary text color.
|
||||
pub text: Color,
|
||||
/// Secondary text color for hints and read-only values.
|
||||
pub text_muted: Color,
|
||||
/// Accent color for the title, selection and gizmo hints.
|
||||
pub accent: Color,
|
||||
/// Row background on hover.
|
||||
pub row_hover: Color,
|
||||
/// Row background while selected.
|
||||
pub row_selected: Color,
|
||||
/// Toolbar background.
|
||||
pub toolbar_bg: Color,
|
||||
/// Status bar background.
|
||||
pub status_bg: Color,
|
||||
/// Warning / destructive color.
|
||||
pub danger: Color,
|
||||
}
|
||||
|
||||
impl Default for UiTheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel_bg: Color::srgba(0.074, 0.086, 0.102, 0.94),
|
||||
panel_bg_alt: Color::srgb(0.121, 0.141, 0.172),
|
||||
border: Color::srgba(0.258, 0.290, 0.337, 0.85),
|
||||
text: Color::srgb(0.878, 0.898, 0.925),
|
||||
text_muted: Color::srgb(0.576, 0.615, 0.674),
|
||||
accent: Color::srgb(0.309, 0.576, 0.949),
|
||||
row_hover: Color::srgb(0.168, 0.200, 0.247),
|
||||
row_selected: Color::srgb(0.196, 0.333, 0.510),
|
||||
toolbar_bg: Color::srgba(0.098, 0.113, 0.137, 0.96),
|
||||
status_bg: Color::srgba(0.086, 0.098, 0.118, 0.96),
|
||||
danger: Color::srgb(0.851, 0.298, 0.278),
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 253 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 543 KiB |
@@ -0,0 +1,163 @@
|
||||
# Bevy Forge v2 — M0 Spike
|
||||
|
||||
Status: **M0 spike**, not a release. It proves the v2 architecture: a real in-game Bevy 0.20 editor
|
||||
plugin with native Bevy UI, a live hierarchy, a reflection inspector, one translate gizmo and
|
||||
deterministic screenshots. No egui is involved in the runtime crate.
|
||||
|
||||

|
||||
|
||||
_1000×640 variant: [m0-spike-1000x640.png](m0-spike-1000x640.png)._
|
||||
|
||||
## What M0 delivers
|
||||
|
||||
- `bevy_forge_runtime` — a new crate in the workspace, added to a game with
|
||||
`.add_plugins(EditorPlugin)` after `DefaultPlugins`.
|
||||
- Fixed editor shell: toolbar, hierarchy (left), inspector (right), status bar; F1 or the toolbar
|
||||
button toggles the whole editor.
|
||||
- Live hierarchy: `Children`/`ChildOf` tree, indentation, scrolling, click selection, selected-row
|
||||
highlight.
|
||||
- Reflection inspector for `Name`, `Transform` and `Visibility`; translation X/Y/Z are editable via
|
||||
`bevy_ui_widgets` sliders and update the ECS entity. Rotation/scale/visibility are read-only in
|
||||
M0.
|
||||
- Editor camera (right-drag orbit, middle-drag pan, wheel zoom) and a translate gizmo with
|
||||
screen-space axis picking and ray/plane dragging.
|
||||
- Deterministic screenshot mode at fixed sizes with a scale factor override.
|
||||
- A small hardcoded 3D demo scene (nine named scene entities plus the editor camera) in
|
||||
`crates/bevy_forge_runtime/examples/editor_spike.rs`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust 1.85+ (the workspace pins `rust-version = "1.85"`; this environment used 1.99).
|
||||
- Linux with Vulkan/GL and a windowing session (Wayland or X11). The reference run is Wayland with
|
||||
an NVIDIA RTX 3050; the UI and screenshots also work through the probe on this machine.
|
||||
- No Bevy installation is required; Bevy 0.20.0 is a normal Cargo dependency.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd bevy-forge
|
||||
cargo run -p bevy_forge_runtime --example editor_spike
|
||||
```
|
||||
|
||||
The first run compiles the Bevy UI/PBR pipeline and is slow. The window opens at 1440×900.
|
||||
|
||||
## Controls
|
||||
|
||||
| Input | Action |
|
||||
|---|---|
|
||||
| F1 / toolbar **Editor (F1)** | Show or hide the editor UI |
|
||||
| Left click a hierarchy row | Select that entity |
|
||||
| Left drag a gizmo axis | Translate the selected entity along X/Y/Z |
|
||||
| Right drag in the viewport | Orbit the editor camera |
|
||||
| Middle drag in the viewport | Pan the editor camera |
|
||||
| Mouse wheel | Zoom the editor camera |
|
||||
|
||||
The inspector sliders edit `Transform.translation.x/y/z` in the range -20..=20 with 0.1 steps.
|
||||
A gizmo drag updates the ECS `Transform`, and the inspector sliders follow.
|
||||
|
||||
## Deterministic screenshots
|
||||
|
||||
The screenshot mode is opt-in and exits after saving:
|
||||
|
||||
```bash
|
||||
FORGE_SCREENSHOT=$PWD/.swarm/m0/evidence/m0-1440x900.png \
|
||||
FORGE_SCREENSHOT_SIZE=1440x900 \
|
||||
cargo run -p bevy_forge_runtime --example editor_spike
|
||||
|
||||
FORGE_SCREENSHOT=$PWD/.swarm/m0/evidence/m0-1000x640.png \
|
||||
FORGE_SCREENSHOT_SIZE=1000x640 \
|
||||
cargo run -p bevy_forge_runtime --example editor_spike
|
||||
```
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `FORGE_SCREENSHOT` | unset | output PNG; unset disables the mode |
|
||||
| `FORGE_SCREENSHOT_SIZE` | `1440x900` | `WIDTHxHEIGHT`, 320..=7680 |
|
||||
| `FORGE_SCREENSHOT_FRAMES` | `300` | minimum frames before capture |
|
||||
| `FORGE_SCREENSHOT_WARMUP_SECS` | `10` | minimum wall-clock seconds before capture |
|
||||
|
||||
The warmup is deliberate: UI/gizmo shaders are ready quickly, but the 3D mesh/PBR pipeline can take
|
||||
~10 s to compile on a cold run. A 30-frame screenshot is blank; the mode waits for both thresholds.
|
||||
The window is forced to `WindowResolution::new(w, h).with_scale_factor_override(1.0)`, so the PNG is
|
||||
exactly the requested size on HiDPI displays.
|
||||
|
||||
**Determinism caveat:** the hierarchy hover highlight follows the pointer. Park the cursor outside
|
||||
the editor window before capturing if you need byte-identical pixels; the committed evidence was
|
||||
captured that way. The screenshot mode fixes size, scene and frame timing, but does not currently
|
||||
neutralise pointer hover.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
crates/bevy_forge_runtime/
|
||||
├── src/lib.rs public prelude: EditorPlugin, EditorState, Selection, InspectorSource
|
||||
├── src/plugin.rs resources, plugin registration, selection cleanup
|
||||
├── src/ui/ fixed shell, theme, markers, EditorLayout
|
||||
├── src/hierarchy.rs live ListBox/ScrollArea tree + selection observer
|
||||
├── src/inspector.rs reflection summary/apply, transform sliders, live sync
|
||||
├── src/gizmo.rs editor camera, axes drawing, picking/drag
|
||||
├── src/screenshot.rs deterministic PNG mode
|
||||
└── examples/editor_spike.rs
|
||||
```
|
||||
|
||||
The editor UI is an overlay: the 3D camera renders full-window and the panels cover the left/right
|
||||
edges. `EditorLayout::viewport_rect` defines the center rectangle used by gizmo input.
|
||||
|
||||
## M0 acceptance
|
||||
|
||||
The full evidence is in `.swarm/m0/reviews/V01.md` and `.swarm/m0/final-report.md`; the commands
|
||||
are listed in `.swarm/m0/plan.md`. In short:
|
||||
|
||||
- `cargo check -p bevy_forge_runtime` passes; `cargo test -p bevy_forge_runtime` is **38 passed,
|
||||
0 failed, 0 ignored** plus one ignored doctest; the full workspace is **384 passed, 0 failed,
|
||||
2 ignored** (v1 + M0).
|
||||
- `cargo tree -p bevy_forge_runtime -i egui` finds no egui; a plain tree grep for `egui` is empty.
|
||||
The v1 editor still uses egui; the runtime crate does not. `bevy_ui_widgets` resolves to 0.20.0.
|
||||
- The example launches, logs `editor shell ready`, `hierarchy: 10 rows, 7 roots`,
|
||||
`inspector: selected ... (Hero Cube)` and `gizmo: camera ready`, with no warnings/panics.
|
||||
- The gizmo is visible on the selected entity; its drag math is unit-tested (including a rotated
|
||||
parent). Computer-use on 2026-10-27 clicked hierarchy rows, dragged the inspector X slider and
|
||||
the X gizmo axis, toggled the UI and zoomed the camera; selection, inspector, ECS and gizmo
|
||||
followed, with no panic.
|
||||
- Screenshots are exactly 1440×900 and 1000×640; both show the shell, hierarchy, inspector and gizmo.
|
||||
- No v1/core/CLI source changes; only the workspace member/lock, the new crate, docs and README.
|
||||
- Independent review: **pass_with_risks** (`.swarm/m0/reviews/V01.md`, local coordination artifact).
|
||||
All acceptance gates pass; the residual risks are right/middle orbit-pan (not scripted) and the
|
||||
disclosed hierarchy root filter.
|
||||
|
||||
## Interactive verification (computer-use, 2026-10-27)
|
||||
|
||||
The editor was driven through the KDE Wayland remote-desktop portal with the computer-use tool:
|
||||
|
||||
- clicked `Red Cube`, `Blue Cube`, `Ground` and `Hero Cube` in the hierarchy — selection, inspector
|
||||
fields and gizmo moved to the clicked entity every time;
|
||||
- dragged the Red Cube Translation X slider from −2.6 to 3.2 — the cube and gizmo moved and the
|
||||
inspector value updated;
|
||||
- dragged the Hero Cube X gizmo axis from 0.0 to 1.0 — the cube moved and the inspector slider
|
||||
followed;
|
||||
- the toolbar button hid the UI; F1 restored it; wheel zoom changed the camera distance.
|
||||
|
||||
This found a real bug that unit tests missed: on a selection change, the inspector's transform-sync
|
||||
system ran after the panel rebuild and queued a `SliderValue` insert for a slider that had just been
|
||||
despawned, panicking in Bevy's command queue. The systems are now chained **sync-first, then
|
||||
rebuild**; repeated selection/slider/gizmo runs are clean. Orbit and pan (right/middle drag) are not
|
||||
scripted by the portal drag helper and remain a manual check; wheel zoom is covered.
|
||||
|
||||
## Limits (M0, by design)
|
||||
|
||||
- No save/load of `.forge.ron`, no BSN export from the UI, no undo/redo, no play/pause, no asset
|
||||
browser, no docking, no multi-select, no rotate/scale gizmo.
|
||||
- The demo scene is hardcoded; `bevy_forge_core` is not a runtime dependency yet.
|
||||
- The live hierarchy panel lists named, transformed scene roots and their descendants; unnamed root
|
||||
entities are omitted in M0 (a full-world mode is M1 work). `collect_rows` remains the literal tree
|
||||
model for tests.
|
||||
- The inspector covers `Name`/`Transform`/`Visibility`; generic `TypeRegistry` component discovery
|
||||
is M1 work.
|
||||
- Parent scale is ignored during gizmo drags (M0); rotations are honored.
|
||||
- 3D only in the demo; the v2 plan adds 2D and scene loading in M1/M2.
|
||||
|
||||
## Next
|
||||
|
||||
M1 Core: `.forge.ron` save/load via `bevy_forge_core`, selection API/multi-select, undo/redo command
|
||||
stack, editor camera polish, and the first integration tests against the real scene format. Do not
|
||||
start M1 until M0 is accepted by the verifier and the actual request/token spend is recorded.
|
||||
Reference in New Issue
Block a user