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
+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),
}
}
}