Start M0 editor workflow polish

This commit is contained in:
Rbanh 2026-06-06 02:15:14 -04:00
parent 3e053a81ab
commit 19c4d017dc
8 changed files with 236 additions and 40 deletions

16
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,16 @@
## Summary
-
## Validation
-
## Documentation
- [ ] I updated the root README for user controls, run commands, or troubleshooting changes.
- [ ] I updated `docs/editor/` for editor workflow or feature-design changes.
- [ ] I added or updated an ADR for architecture, policy, scene format, rendering path, PIE semantics, settings schema, or netcode decisions.
- [ ] I indexed new documentation in `docs/README.md`.
- [ ] If this is Jackdaw-inspired work, I identified it as design-inspired, API-inspired, or code-derived.
- [ ] If this includes copied or closely translated source, I included source URL, upstream commit/release, license compatibility notes, and required file-level notices.

View File

@ -117,7 +117,7 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
| Select Project Sun | Inspect project default lighting; create a scene sun override |
| Asset Browser project/file views | Browse `assets/`, search/filter/sort, switch grid/list, expand model subassets with generated thumbnails, inspect staged import/material settings, drag assets/submeshes into the viewport |
| Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` |
| `Ctrl+P` | Command palette (`scene.reset_lighting`, `selection.group`, `selection.focus`, play commands) |
| `Ctrl+P` | Command palette; type to filter, use arrow keys to select, Enter to run (`scene.reset_lighting`, `selection.group`, `selection.focus`, `selection.reset_transform`, play commands) |
| `F7` | While paused in Play: advance one sim tick |
| Shift/Ctrl + click (Hierarchy) | Additive selection |
| Hierarchy context | Reparent to other selection / Unparent |

View File

@ -8,7 +8,7 @@ use crate::state::{EditorMode, PlayPossession};
use crate::ui::helpers::{
reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused,
};
use crate::ui::selection_ops::focus_editor_camera_on_selection;
use crate::ui::selection_ops::{focus_editor_camera_on_selection, reset_selected_transforms};
use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM};
use crate::ui::UiState;
@ -127,6 +127,8 @@ pub struct CommandPalette {
pub open: bool,
pub filter: String,
pub pending_run: Option<String>,
pub focus_filter: bool,
pub selected_index: usize,
}
pub struct ExtensibilityPlugin;
@ -160,6 +162,7 @@ fn register_builtin_commands(mut registry: ResMut<EditorCommandRegistry>) {
registry.register(Box::new(ResetLightingCommand));
registry.register(Box::new(GroupSelectionCommand));
registry.register(Box::new(FocusSelectionCommand));
registry.register(Box::new(ResetSelectionTransformCommand));
registry.register(Box::new(CreatePostProcessVolumeCommand));
registry.register(Box::new(FocusActiveVolumesCommand));
registry.register(Box::new(SelectVolumesAtCameraCommand));
@ -272,6 +275,18 @@ impl EditorCommand for FocusSelectionCommand {
}
}
struct ResetSelectionTransformCommand;
impl EditorCommand for ResetSelectionTransformCommand {
fn name(&self) -> &str {
"selection.reset_transform"
}
fn execute(&self, world: &mut World) {
reset_selected_transforms(world);
}
}
struct CreatePostProcessVolumeCommand;
impl EditorCommand for CreatePostProcessVolumeCommand {
@ -322,6 +337,8 @@ fn command_palette_ui(
if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) {
palette.open = true;
palette.focus_filter = true;
palette.selected_index = 0;
}
if !palette.open {
@ -339,25 +356,71 @@ fn command_palette_ui(
.color(TEXT_DIM)
.small(),
);
ui.add(
let filter_response = ui.add(
egui::TextEdit::singleline(&mut palette.filter)
.hint_text("Type to filter...")
.desired_width(f32::INFINITY),
);
if palette.focus_filter {
filter_response.request_focus();
palette.focus_filter = false;
}
if filter_response.changed() {
palette.selected_index = 0;
}
let filter = palette.filter.to_lowercase();
let filtered_names: Vec<String> = registry
.names()
.into_iter()
.filter(|name| filter.is_empty() || name.to_lowercase().contains(&filter))
.collect();
if !filtered_names.is_empty() {
palette.selected_index = palette.selected_index.min(filtered_names.len() - 1);
} else {
palette.selected_index = 0;
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))
&& !filtered_names.is_empty()
{
palette.selected_index = (palette.selected_index + 1) % filtered_names.len();
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp))
&& !filtered_names.is_empty()
{
palette.selected_index = if palette.selected_index == 0 {
filtered_names.len() - 1
} else {
palette.selected_index - 1
};
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
if let Some(name) = filtered_names.get(palette.selected_index) {
palette.pending_run = Some(name.clone());
palette.open = false;
ui.close();
}
}
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
palette.open = false;
ui.close();
}
ui.separator();
egui::ScrollArea::vertical()
.max_height(280.0)
.show(ui, |ui| {
for name in registry.names() {
if !palette.filter.is_empty()
&& !name.to_lowercase().contains(&palette.filter.to_lowercase())
{
continue;
}
for (index, name) in filtered_names.iter().enumerate() {
let selected = index == palette.selected_index;
let response =
ui.selectable_label(false, egui::RichText::new(&name).color(TEXT));
ui.selectable_label(selected, egui::RichText::new(name).color(TEXT));
if selected {
response.scroll_to_me(Some(egui::Align::Center));
}
if response.clicked() {
palette.pending_run = Some(name);
palette.selected_index = index;
palette.pending_run = Some(name.clone());
palette.open = false;
ui.close();
}

View File

@ -637,9 +637,42 @@ pub fn set_transform_with_history(
if transform_nearly_eq(&old, &new) {
return;
}
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = new;
}
push_history(world, EditorCommand::SetTransform { entity, old, new });
}
pub fn set_transform_group_with_history(
world: &mut World,
changes: impl IntoIterator<Item = (Entity, Transform, Transform)>,
) {
let mut entities = Vec::new();
let mut olds = Vec::new();
let mut news = Vec::new();
for (entity, old, new) in changes {
if transform_nearly_eq(&old, &new) {
continue;
}
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = new;
entities.push(entity);
olds.push(old);
news.push(new);
}
}
if !entities.is_empty() {
push_history(
world,
EditorCommand::SetTransformGroup {
entities,
olds,
news,
},
);
}
}
pub fn set_actor_kind_with_history(
world: &mut World,
entity: Entity,
@ -1219,7 +1252,7 @@ fn capture_gizmo_transform_edits(world: &mut World) {
let start_group =
active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform));
let finished_edit = {
let (finished_edit, live_edit) = {
let mut tracker = world.resource_mut::<TransformEditTracker>();
if tracker.active.is_none() {
if let (Some((entity, transform)), Some(group)) = (active, start_group) {
@ -1228,15 +1261,26 @@ fn capture_gizmo_transform_edits(world: &mut World) {
}
}
match tracker.active.take() {
Some((primary, old, group)) if active.is_none() => Some((primary, old, group)),
Some((primary, old, group)) if active.is_none() => (Some((primary, old, group)), None),
Some(state) if active.is_some() => {
let live = Some(state.clone());
tracker.active = Some(state);
None
(None, live)
}
_ => None,
_ => (None, None),
}
};
if let Some((primary, old_primary, group)) = live_edit {
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
return;
};
if group.len() > 1 {
let changes = transform_group_changes(old_primary, new_primary, group);
apply_transform_group(world, &changes);
}
}
if let Some((primary, old_primary, group)) = finished_edit {
let Some(new_primary) = world.get::<Transform>(primary).copied() else {
return;
@ -1245,34 +1289,36 @@ fn capture_gizmo_transform_edits(world: &mut World) {
set_transform_with_history(world, primary, old_primary, new_primary);
return;
}
let translation_delta = new_primary.translation - old_primary.translation;
let rotation_delta = new_primary.rotation * old_primary.rotation.inverse();
let scale_ratio = new_primary.scale / old_primary.scale;
let mut entities = Vec::new();
let mut olds = Vec::new();
let mut news = Vec::new();
for (entity, old) in group {
let changes = transform_group_changes(old_primary, new_primary, group);
set_transform_group_with_history(world, changes);
}
}
fn transform_group_changes(
old_primary: Transform,
new_primary: Transform,
group: Vec<(Entity, Transform)>,
) -> Vec<(Entity, Transform, Transform)> {
let translation_delta = new_primary.translation - old_primary.translation;
let rotation_delta = new_primary.rotation * old_primary.rotation.inverse();
let scale_ratio = new_primary.scale / old_primary.scale;
group
.into_iter()
.map(|(entity, old)| {
let new = Transform {
translation: old.translation + translation_delta,
rotation: rotation_delta * old.rotation,
scale: old.scale * scale_ratio,
};
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
*transform = new;
}
entities.push(entity);
olds.push(old);
news.push(new);
}
if !entities.is_empty() {
push_history(
world,
EditorCommand::SetTransformGroup {
entities,
olds,
news,
},
);
(entity, old, new)
})
.collect()
}
fn apply_transform_group(world: &mut World, changes: &[(Entity, Transform, Transform)]) {
for (entity, _, new) in changes {
if let Some(mut transform) = world.get_mut::<Transform>(*entity) {
*transform = *new;
}
}
}

View File

@ -5,8 +5,11 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
use shared::LevelObject;
use crate::camera::EditorCamera;
use crate::history::{delete_entities_with_history, duplicate_entities_with_history};
use crate::history::{
delete_entities_with_history, duplicate_entities_with_history, set_transform_group_with_history,
};
use crate::selection::SelectedEntity;
use crate::ui::UiState;
pub fn focus_editor_camera_on_selection(world: &mut World) {
let Some(entity) = world.resource::<SelectedEntity>().0 else {
@ -58,3 +61,19 @@ pub fn duplicate_selection(world: &mut World, selected: &SelectedEntities) {
let entities = selected_level_entities(world, selected);
duplicate_entities_with_history(world, &entities);
}
pub fn reset_selected_transforms(world: &mut World) {
let changes: Vec<_> = world
.resource::<UiState>()
.selected_entities
.iter()
.filter(|entity| is_level_object(world, *entity))
.filter_map(|entity| {
world
.get::<Transform>(entity)
.copied()
.map(|old| (entity, old, Transform::default()))
})
.collect();
set_transform_group_with_history(world, changes);
}

View File

@ -34,6 +34,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0017](adr/0017-normalized-static-mesh-assets.md) | Normalized static mesh assets and renderer placement |
| [0018](adr/0018-componentized-actor-inspector-and-materials.md) | Componentized actor inspector, imported asset refs, collider split, material overrides |
| [0019](adr/0019-local-bevy-render-timeout-patch.md) | Local `bevy_render` patch for transient Linux swapchain timeouts |
| [0020](adr/0020-jackdaw-inspired-editor-roadmap.md) | Jackdaw-inspired roadmap source policy and attribution requirements |
## Editor framework
@ -58,6 +59,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
| `rendering_unification_*.plan.md` | Unified rendering stack, Hybrid Auto GI, emissive materials |
| `static_mesh_asset_refactor_*.plan.md` | Static mesh renderer, normalized model artifacts, inspector redesign |
| `component_system_refactor_*.plan.md` | Component registry, imported asset refs, collider split, material overrides |
| `jackdaw_feature_roadmap_*.plan.md` | Jackdaw-inspired production roadmap; tracked in Gitea as `BS-JD-*` issues |
## Crate responsibilities (quick reference)

View File

@ -0,0 +1,49 @@
Status: Accepted
Context
=======
Blacksite is evolving from a game-specific editor scaffold into a reusable
Bevy editor sandbox for future game work. The Jackdaw editor provides useful
public reference material for editor workflows such as brush geometry, material
browser organization, terrain tools, physics placement, project launchers, and
extension boundaries.
The roadmap should use those references as product and workflow inspiration
without accidentally turning Blacksite into a fork, importing code without
attribution, or overriding Blacksite's existing authoring/hydration model.
Decision
========
Treat Jackdaw as an inspiration source, not as an architecture or format
authority. Blacksite keeps its current foundations: Bevy DynamicScene RON,
schema stamping and migration, authoring-only validation, hydration into runtime
components, in-process PIE, and the unified viewport.
Each Jackdaw-inspired ticket must identify whether the work is:
- Design-inspired: adapted from public behavior, screenshots, docs, or workflow
descriptions.
- API-inspired: adapted from public API shape or concepts without copied code.
- Code-derived: containing copied or closely translated source code.
Code-derived work requires a source URL, upstream commit or release reference,
compatible license confirmation, and preserved file-level notices when required
by the source license. Prefer clean-room implementation for geometry, terrain,
operators, and extension APIs because Blacksite's scene and runtime ownership
model differs from Jackdaw's.
Consequences
============
The Jackdaw roadmap can be used for planning and prioritization while keeping
Blacksite's architecture coherent and auditable.
M0-M5 planning details live in `.cursor/plans/` until implemented. Durable
decisions move into `docs/adr/` or `docs/editor/`; user-visible workflows move
into the root README. The Gitea issue tracker is the production task board, but
docs remain the long-term source for accepted policy and architecture.
Reviewers should reject Jackdaw-inspired changes that do not declare their
inspiration level or that copy source without license and source attribution.

View File

@ -34,6 +34,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
## Design intentions (stable)
- **EditorOnly** helper entities never appear in hierarchy or saved scenes. Visualizer proxies are editor-only but selectable; selection resolves to their source entity.
- **Jackdaw-inspired roadmap work** follows ADR 0020: use Jackdaw as design/API inspiration only unless a ticket explicitly marks code-derived work with source, license, and upstream commit notes. Clean-room implementation is preferred for geometry, terrain, operator, and extension systems.
- **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic).
- **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping.
- **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera.
@ -48,7 +49,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live.
- **Rendering**`GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD.
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and a pinned bottom Add Component footer (`actor_inspector`); only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/material selectors, visibility, shadow flags, and per-actor material overrides. Empty actor material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
- **Command palette** (`Ctrl+P`): `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, and play commands.
- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands.
- **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`.
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders.
- **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.