feat(runtime): add Bevy 0.20 live editor M0 spike

- new bevy_forge_runtime crate with EditorPlugin, EditorState, Selection
- native Bevy UI shell + official bevy_ui_widgets 0.20 (no egui in runtime)
- live hierarchy, reflection inspector, translate gizmo, editor camera
- deterministic 1440x900/1000x640 screenshot mode
- demo example, docs and screenshots; v1 crates untouched
- interactive computer-use test fixed inspector command-queue panic
This commit is contained in:
Emil Shanaty
2026-10-27 22:14:21 +03:00
parent 1cc727f890
commit 59212c952f
17 changed files with 6315 additions and 64 deletions
Generated
+2923 -64
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ members = [
"crates/bevy_forge_core", "crates/bevy_forge_core",
"crates/bevy_forge_editor", "crates/bevy_forge_editor",
"crates/bevy_forge_cli", "crates/bevy_forge_cli",
"crates/bevy_forge_runtime",
] ]
exclude = ["compat/bsn-smoke"] exclude = ["compat/bsn-smoke"]
resolver = "2" resolver = "2"
+14
View File
@@ -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 Live in-game editing (a Bevy plugin with a remote protocol) is a roadmap item, not part of this
editor. 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 ## Two viewports
| 2D scene editing | 3D wireframe preview | | 2D scene editing | 3D wireframe preview |
+45
View File
@@ -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
);
}
+727
View File
@@ -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());
}
}
+510
View File
@@ -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);
}
}
+784
View File
@@ -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());
}
}
+30
View File
@@ -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,
};
+151
View File
@@ -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);
}
}
+374
View File
@@ -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);
}
}
+10
View File
@@ -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;
+377
View File
@@ -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());
}
}
+51
View File
@@ -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

+163
View File
@@ -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.
![Bevy Forge M0 spike](m0-spike-1440x900.png)
_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.