Compare commits

..

No commits in common. "a048233fceab86649f8c1004137a59f0cd62ac12" and "62b538999d436a55b21596fb00912b2e69a77d6f" have entirely different histories.

31 changed files with 247 additions and 2525 deletions

View File

@ -1,62 +0,0 @@
# Guarded Shutdown And History Savepoints
Date: 2026-07-13
Issue: BS-PR-709 / Gitea #55
Milestone: M7 - Production Readiness
## Goal
Route every editor-exit surface through one non-blocking dirty-document decision and make scene
dirtiness follow the authored undo timeline. Closing the native window, choosing File > Quit, and
requesting an editor exit programmatically must never silently discard dirty scene tabs.
## Decisions
- The editor disables Bevy's automatic close-and-exit systems and owns primary-window close
requests. A guarded-shutdown resource is the single authority that may emit `AppExit`.
- The final dirty-state recheck and `AppExit` authorization run in a dedicated schedule after
Bevy's `Last`; session clean-marker persistence runs after that finalizer.
- Native window close, File > Quit, and internal editor quit requests enqueue the same shutdown
intent. Repeated requests while confirmation or Save As is pending are coalesced.
- Clean sessions exit immediately. Dirty sessions use the shared `NativeDialogBroker` and a single
Save All / Discard / Cancel decision. Cancel preserves all tabs and live editor state; Discard
exits without rewriting authored files; Save All exits only after every dirty tab is saved.
- Untitled tabs and other asynchronous Save As work keep shutdown pending until their dialog result
is applied on the main thread. Cancellation or any failed write returns the editor to an idle,
dirty, recoverable state and does not emit `AppExit`.
- Each scene tab retains a canonical authored-content checkpoint established only by a successful
load or save. The checkpoint is keyed by stable actor identity, canonical component order, stable
parent identity, and scene composition rather than transient Bevy entity numbers.
- Push marks the active document dirty. Undo and redo serialize the authoritative authored
projection and compare it with that tab's checkpoint, so saving at nonzero history depth,
returning to that point, branching, tab switches, and direct non-history mutations remain exact.
## Implementation
1. Add per-tab canonical clean checkpoints to scene I/O and reconcile `SceneIo::dirty` after push,
undo, redo, save, load, and document switches without persisting Entity-ID-based history stacks.
2. Add focused history tests for undo-to-clean, redo-away-from-clean, save at nonzero depth,
branching before/after the clean point, and tab isolation.
3. Add a guarded-shutdown plugin/resource that intercepts `WindowCloseRequested`, accepts menu and
programmatic requests, coordinates non-blocking confirmation/save completion, and emits the only
final `AppExit` for the full editor.
4. Make Save All report complete, pending, cancelled, or failed explicitly. Resume pending shutdown
after untitled Save As completion and preserve the originating tab plus dirty state on failure.
5. Add File > Quit and route Switch Project's exit half through the same guard without spawning a
replacement process until the dirty-document decision succeeds.
6. Update ADR 0023, native-dialog/session-recovery documentation, editor architecture guidance,
root README controls/checklist, and production-readiness evidence.
7. Run formatting, strict Clippy, workspace tests, and focused headless state-machine tests. Keep
packaged tests deferred by project-owner direction.
8. Launch the exact editor commit under Hyprland. Exercise Cancel, Discard, Save All, clean close,
File > Quit, and the compositor close button; verify the editor remains responsive during native
dialogs, no state is lost on cancel/failure, and no process or warning remains after exit.
## Acceptance
- Native close, File > Quit, and programmatic editor exit share one guarded implementation.
- One or many dirty tabs cannot be lost without explicit Discard; failed or cancelled saves do not
exit and preserve the session.
- Undoing exactly to the saved state clears the dirty marker; redo or a divergent edit restores it.
- Saving at nonzero history depth establishes a new clean point without deleting useful history.
- Headless state-machine tests and exact-commit native Linux QA pass with clean logs.

View File

@ -57,9 +57,8 @@ cargo run -p editor --bin project_launcher --features dev
```
The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its
desktop action menu. In the editor, **File > Switch Project...** uses the guarded Save All / Discard /
Cancel shutdown path before opening the same browser; choosing a project starts a fresh editor
process with that root.
desktop action menu. In the editor, **File > Switch Project...** performs a clean shutdown and
opens the same browser; choosing a project starts a fresh editor process with that root.
### Hot reload (gameplay iteration)
@ -137,7 +136,7 @@ deep-stale variants.
| Click empty viewport / `Esc` | Deselect |
| `Delete` / `Backspace` | Delete selection |
| `Ctrl+D` | Duplicate selection |
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo; returning exactly to the last loaded or saved authored state clears that scene tab's dirty marker |
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo |
| `F2` in Hierarchy | Rename selection |
| `W` / `E` / `R` | Translate / rotate / scale gizmo; multi-selection uses one grouped gizmo and undo step |
| `X` | Toggle world/local gizmo orientation |
@ -166,8 +165,7 @@ deep-stale variants.
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root |
| Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions |
| Hierarchy context | Group selection, create authored local children below linked prefab roots, remove/reparent generated members through same-layer structural overrides, or unparent |
| File menu | New, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes, Switch Project, and Quit |
| Window close / **File > Quit** | Clean sessions exit immediately; dirty scene tabs use one non-blocking **Save All / Discard / Cancel** decision, and cancel or failed saves keep the editor open |
| File menu | New, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
| Status strip / Asset Details source-state chip | Inspect compact clean, modified, untracked, conflicted, read-only, and optional ownership status; hover for the source path and provider details |
| Authored File Not Saved dialog | Resolve an external edit, read-only target, or ownership lock with Reload, Compare Metadata, Save As, or Cancel; the editor never offers force overwrite |
| Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
@ -412,8 +410,6 @@ crates/
- [x] Transactional physics placement with real Avian gravity/colliders, paused Edit-mode physics, prerequisite diagnostics, isolated non-selected bodies, exact cancel, and grouped transform undo ([ADR 0041](docs/adr/0041-transactional-editor-physics-placement.md), [workflow guide](docs/editor/physics-placement.md), [Gitea #25](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/25))
- [x] Collider authoring health shared by the inspector, Collider viewport, Diagnostics panel, and physics placement, with scaled shape overlays, cooked mesh bounds, missing/stale/invalid/oversized findings, and undoable dimension-preserving shape switching ([collider guide](docs/editor/collider-authoring.md), [Gitea #26](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/26))
- [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52))
- [x] Guarded native/menu/programmatic editor shutdown with asynchronous multi-tab Save All / Discard / Cancel and canonical per-tab clean checkpoints for exact undo/redo dirtiness ([ADR 0042](docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md), [Gitea #55](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/55))
- [x] Scoped egui action safety for toolbar/navigation spawns, Inspector state, cross-panel selection/tab requests, diagnostics selection, and full-slice selection precedence ([evaluation](docs/editor/evaluations/scoped-ui-actions/), [Gitea #57](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/57))
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
- [x] Unified viewport render-to-texture target + Play session bootstrap

View File

@ -78,12 +78,7 @@ impl EditorCommandRegistry {
}
}
/// Custom inspector body for level objects (game crates register via
/// [`register_actor_inspector_section`]). Synchronous [`Self::ui`] callbacks run while
/// [`UiState`] is scoped out of `World`; implementations must not request that resource. Keep
/// extension-owned transient state in independently registered resources, use
/// [`crate::ui::request_ui_selection`] and [`crate::ui::request_editor_tab`] for host UI changes,
/// or defer other cross-panel work through extension-owned commands or events.
/// Custom inspector body for level objects (game crates register via [`register_actor_inspector_section`]).
pub trait ActorInspectorSection: Send + Sync {
fn id(&self) -> &str;
fn title(&self) -> &str;

View File

@ -19,6 +19,7 @@ use shared::{
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::selection::SelectedEntity;
use crate::state::scene_tools_active;
use crate::ui::hierarchy_ops::{
apply_sibling_change, apply_sibling_change_new, hierarchy_drop_violation, next_sibling_index,
@ -1630,7 +1631,7 @@ pub fn apply_command_undo(world: &mut World) {
undo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_redo(command);
world.resource_mut::<EditorHistory>().set_redo_status(label);
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
mark_dirty(world);
}
pub fn apply_command_redo(world: &mut World) {
@ -1651,7 +1652,7 @@ pub fn apply_command_redo(world: &mut World) {
redo_command(world, &mut command);
world.resource_mut::<EditorHistory>().push_undo(command);
world.resource_mut::<EditorHistory>().set_undo_status(label);
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
mark_dirty(world);
}
fn prepare_prefab_source_history(
@ -2871,15 +2872,32 @@ fn despawn_entity(world: &mut World, entity: Option<Entity>) {
}
fn select_one(world: &mut World, entity: Entity) {
crate::ui::request_ui_selection(world, &[entity]);
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
ui_state.selected_entities.clear();
ui_state.selected_entities.select_replace(entity);
}
world.resource_mut::<SelectedEntity>().0 = Some(entity);
}
fn select_many(world: &mut World, entities: &[Entity]) {
crate::ui::request_ui_selection(world, entities);
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
ui_state.selected_entities.clear();
for (index, entity) in entities.iter().enumerate() {
if index == 0 {
ui_state.selected_entities.select_replace(*entity);
} else {
ui_state.selected_entities.select_maybe_add(*entity, true);
}
}
}
world.resource_mut::<SelectedEntity>().0 = entities.first().copied();
}
pub fn clear_selection(world: &mut World) {
crate::ui::request_ui_selection(world, &[]);
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
ui_state.selected_entities.clear();
}
world.resource_mut::<SelectedEntity>().0 = None;
}
pub fn is_level_object(world: &World, entity: Entity) -> bool {
@ -2921,28 +2939,6 @@ mod tests {
use super::*;
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
use crate::operators::{ActiveOperator, OperatorPhase};
use crate::selection::SelectedEntity;
use crate::ui::PendingUiSelection;
#[test]
fn history_select_many_queues_exact_selection_while_ui_state_is_scoped_out() {
let first = Entity::from_bits(1);
let second = Entity::from_bits(2);
let mut world = World::new();
world.init_resource::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.insert_resource(UiState::default_layout());
world.resource_scope::<UiState, _>(|world, _ui_state| {
assert!(!world.contains_resource::<UiState>());
select_many(world, &[first, second]);
assert_eq!(
world.resource::<PendingUiSelection>().0.as_deref(),
Some([first, second].as_slice())
);
assert_eq!(world.resource::<SelectedEntity>().0, Some(first));
});
}
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
#[reflect(Component, Default)]

View File

@ -30,7 +30,6 @@ pub use project::project_io;
pub use project::samples;
pub use project::session;
pub use project::settings_ui;
pub use project::shutdown;
pub use scene::scene_io;
pub use scene::scene_schema;
pub use scene::scene_view;
@ -77,7 +76,6 @@ use play::PlaySessionPlugin;
use project::collaboration::CollaborationPlugin;
use project::native_dialog::NativeDialogPlugin;
use project::samples::SampleCatalogPlugin;
use project::shutdown::ShutdownPlugin;
use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin;
use scene_io::SceneIoPlugin;
@ -104,7 +102,6 @@ impl PluginGroup for EditorPluginGroup {
.add(ProjectIoPlugin)
.add(SampleCatalogPlugin)
.add(NativeDialogPlugin)
.add(ShutdownPlugin)
.add(scene_schema::SceneSchemaPlugin)
.add(net_editor::NetEditorPlugin)
.add(AssetDbPlugin)
@ -146,7 +143,7 @@ impl PluginGroup for EditorPluginGroup {
/// Shared Bevy app wiring for the in-process editor (game sim + egui shell).
pub fn configure_editor_app(app: &mut App) {
app.add_plugins(launch::editor_plugins("Bevy FPS Editor"))
app.add_plugins(launch::default_plugins("Bevy FPS Editor"))
.insert_resource(GameInputEnabled(false))
.insert_resource(GameInputFocused(false))
.insert_resource(SimEnabled(false))

View File

@ -8,4 +8,3 @@ pub mod project_io;
pub mod samples;
pub mod session;
pub mod settings_ui;
pub mod shutdown;

View File

@ -11,7 +11,6 @@ use crate::project_io::{ProjectWorkspace, UserPreferences};
use crate::scene::recovery::{atomic_write, default_state_root};
use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::settings_ui::ProjectSettingsPanel;
use crate::shutdown::ShutdownCoordinator;
use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel};
use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel;
use crate::viewport::CameraBookmarks;
@ -110,8 +109,8 @@ impl Plugin for EditorSessionPlugin {
.add_systems(Startup, initialize_session)
.add_systems(Update, persist_running_session)
.add_systems(
crate::shutdown::EditorShutdownFinalize,
persist_clean_session_on_exit.after(crate::shutdown::finalize_authorized_shutdown),
Last,
persist_clean_session_on_exit.after(bevy::window::ExitSystems),
);
}
}
@ -312,7 +311,6 @@ fn persist_running_session(world: &mut World) {
#[allow(clippy::too_many_arguments)]
fn persist_clean_session_on_exit(
mut exits: MessageReader<AppExit>,
shutdown: Option<Res<ShutdownCoordinator>>,
scene_io: Res<SceneIo>,
workspace: Res<ProjectWorkspace>,
prefs: Res<UserPreferences>,
@ -325,10 +323,6 @@ fn persist_clean_session_on_exit(
if exits.read().next().is_none() {
return;
}
if !shutdown.is_some_and(|shutdown| shutdown.authorized_exit_sent()) {
warn!("Ignoring an unauthorized AppExit for clean-session persistence");
return;
}
let Some(path) = session_path() else {
return;
};

View File

@ -1,752 +0,0 @@
//! Guarded editor shutdown and the handoff to scene persistence.
use bevy::app::{AppExit, MainScheduleOrder};
use bevy::ecs::schedule::ScheduleLabel;
use bevy::prelude::*;
use bevy::window::{PrimaryWindow, WindowCloseRequested};
use crate::native_dialog::NativeDialogBroker;
use crate::scene_io::SceneIo;
const SAVE_ALL_LABEL: &str = "Save All";
const DISCARD_LABEL: &str = "Discard";
const CANCEL_LABEL: &str = "Cancel";
/// The user or editor surface that initiated a guarded shutdown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownSource {
NativeWindow,
FileMenu,
Programmatic,
SwitchProject,
}
/// Observable state of the single editor shutdown flow.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShutdownIntent {
#[default]
Idle,
WaitingForBroker {
source: ShutdownSource,
},
Confirming {
source: ShutdownSource,
},
Saving {
source: ShutdownSource,
},
Authorized {
source: ShutdownSource,
require_clean_scenes: bool,
},
ExitSent {
source: ShutdownSource,
},
}
impl ShutdownIntent {
pub fn source(self) -> Option<ShutdownSource> {
match self {
Self::Idle => None,
Self::WaitingForBroker { source }
| Self::Confirming { source }
| Self::Saving { source }
| Self::Authorized { source, .. }
| Self::ExitSent { source } => Some(source),
}
}
}
/// Coordinates all editor exit requests so only an authorized flow emits [`AppExit`].
#[derive(Resource, Debug, Default)]
pub struct ShutdownCoordinator {
intent: ShutdownIntent,
coalesced_requests: u32,
last_blocker: Option<String>,
}
impl ShutdownCoordinator {
pub fn intent(&self) -> ShutdownIntent {
self.intent
}
pub fn coalesced_requests(&self) -> u32 {
self.coalesced_requests
}
pub fn last_blocker(&self) -> Option<&str> {
self.last_blocker.as_deref()
}
pub fn authorized_exit_sent(&self) -> bool {
matches!(self.intent, ShutdownIntent::ExitSent { .. })
}
/// Queue a shutdown if no shutdown flow is already active.
///
/// Repeated window, menu, or programmatic requests are coalesced until the
/// current flow is cancelled, fails to save, or sends the authorized exit.
pub fn request(&mut self, source: ShutdownSource) -> bool {
if self.intent != ShutdownIntent::Idle {
self.coalesced_requests = self.coalesced_requests.saturating_add(1);
return false;
}
self.intent = ShutdownIntent::WaitingForBroker { source };
self.coalesced_requests = 0;
self.last_blocker = None;
true
}
#[cfg(test)]
pub(crate) fn set_intent_for_test(&mut self, intent: ShutdownIntent) {
self.intent = intent;
}
}
/// Emitted once when the guarded dialog chooses `Save All`.
///
/// Scene persistence owns the actual save sequence and must finish it with
/// [`complete_shutdown_save`].
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShutdownSaveAllRequested {
pub source: ShutdownSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShutdownSaveOutcome {
Saved,
Cancelled,
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownDecision {
SaveAll,
Discard,
Cancel,
}
/// Runs after Bevy's `Last` schedule so no editor authoring system can mutate
/// a document after the final dirty-state check in the same frame.
#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)]
pub struct EditorShutdownFinalize;
/// Queue a guarded shutdown from an exclusive-world UI or workflow callback.
pub fn request_editor_shutdown(world: &mut World, source: ShutdownSource) -> bool {
world
.get_resource_mut::<ShutdownCoordinator>()
.is_some_and(|mut coordinator| coordinator.request(source))
}
/// Queue a project-browser handoff through the same dirty-document guard as editor exit.
pub fn request_project_switch(world: &mut World) -> bool {
request_editor_shutdown(world, ShutdownSource::SwitchProject)
}
/// Resolve the dirty-scene confirmation through the shared coordinator.
pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) -> bool {
let (save_request, status) = {
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
return false;
};
let ShutdownIntent::Confirming { source } = coordinator.intent else {
return false;
};
match decision {
ShutdownDecision::SaveAll => {
coordinator.intent = ShutdownIntent::Saving { source };
(
Some(source),
Some("Saving all modified scene tabs before shutdown"),
)
}
ShutdownDecision::Discard => {
coordinator.intent = ShutdownIntent::Authorized {
source,
require_clean_scenes: false,
};
(None, None)
}
ShutdownDecision::Cancel => {
coordinator.intent = ShutdownIntent::Idle;
(
None,
Some("Shutdown cancelled; unsaved scene tabs retained"),
)
}
}
};
if let Some(status) = status {
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
scene_io.set_status(status);
}
}
if let Some(source) = save_request {
world.write_message(ShutdownSaveAllRequested { source });
}
true
}
/// Finish the save-all handoff. A reported success is authorized only after
/// every scene tab is observably clean.
pub fn complete_shutdown_save(world: &mut World, outcome: ShutdownSaveOutcome) -> bool {
let scenes_are_clean = !scene_has_unsaved_changes(world);
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
return false;
};
let ShutdownIntent::Saving { source } = coordinator.intent else {
return false;
};
match outcome {
ShutdownSaveOutcome::Saved if scenes_are_clean => {
coordinator.intent = ShutdownIntent::Authorized {
source,
require_clean_scenes: true,
};
coordinator.last_blocker = None;
true
}
ShutdownSaveOutcome::Saved => {
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker =
Some("Save All completed while unsaved scene tabs remain".into());
false
}
ShutdownSaveOutcome::Cancelled => {
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker = None;
false
}
ShutdownSaveOutcome::Failed(error) => {
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker = Some(error);
false
}
}
}
pub struct ShutdownPlugin;
impl Plugin for ShutdownPlugin {
fn build(&self, app: &mut App) {
app.init_schedule(EditorShutdownFinalize);
app.world_mut()
.resource_mut::<MainScheduleOrder>()
.insert_after(Last, EditorShutdownFinalize);
app.init_resource::<ShutdownCoordinator>()
.add_message::<ShutdownSaveAllRequested>()
.add_systems(
Update,
(capture_native_close_requests, drive_shutdown).chain(),
)
.add_systems(EditorShutdownFinalize, finalize_authorized_shutdown);
}
}
fn capture_native_close_requests(
mut commands: Commands,
mut close_requests: MessageReader<WindowCloseRequested>,
primary_window: Query<Entity, With<PrimaryWindow>>,
mut coordinator: ResMut<ShutdownCoordinator>,
) {
let Ok(primary_window) = primary_window.single() else {
return;
};
for request in close_requests.read() {
if request.window == primary_window {
coordinator.request(ShutdownSource::NativeWindow);
} else {
commands.entity(request.window).despawn();
}
}
}
fn drive_shutdown(world: &mut World) {
let intent = world.resource::<ShutdownCoordinator>().intent;
match intent {
ShutdownIntent::Idle
| ShutdownIntent::Confirming { .. }
| ShutdownIntent::Saving { .. }
| ShutdownIntent::ExitSent { .. } => {}
ShutdownIntent::WaitingForBroker { source } => {
let unsaved_count = unsaved_scene_count(world);
if unsaved_count == 0 {
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Authorized {
source,
require_clean_scenes: true,
};
return;
}
if world.resource::<NativeDialogBroker>().is_pending() {
return;
}
let action = if source == ShutdownSource::SwitchProject {
"switching projects"
} else {
"quitting"
};
let description = if unsaved_count == 1 {
format!("One scene tab has unsaved changes. Save it before {action}?")
} else {
format!(
"{unsaved_count} scene tabs have unsaved changes. Save all before {action}?"
)
};
let title = if source == ShutdownSource::SwitchProject {
"Switch Project"
} else {
"Quit Blacksite Editor"
};
let request = world.resource::<NativeDialogBroker>().request(
move || {
rfd::MessageDialog::new()
.set_title(title)
.set_description(description)
.set_level(rfd::MessageLevel::Warning)
.set_buttons(rfd::MessageButtons::YesNoCancelCustom(
SAVE_ALL_LABEL.into(),
DISCARD_LABEL.into(),
CANCEL_LABEL.into(),
))
.show()
},
|world, result| {
resolve_shutdown_decision(world, decision_from_dialog(result));
},
);
if request.is_ok() {
world.resource_mut::<ShutdownCoordinator>().intent =
ShutdownIntent::Confirming { source };
}
}
ShutdownIntent::Authorized { .. } => {}
}
}
/// Final dirty-state recheck after editor UI and authoring schedules have completed.
pub(crate) fn finalize_authorized_shutdown(world: &mut World) {
let (source, require_clean_scenes) = {
let coordinator = world.resource::<ShutdownCoordinator>();
let ShutdownIntent::Authorized {
source,
require_clean_scenes,
} = coordinator.intent
else {
return;
};
(source, require_clean_scenes)
};
if require_clean_scenes && scene_has_unsaved_changes(world) {
world.resource_mut::<ShutdownCoordinator>().intent =
ShutdownIntent::WaitingForBroker { source };
return;
}
if source == ShutdownSource::SwitchProject {
if let Err(error) = crate::launcher::spawn_project_launcher_process() {
let message = format!("Switch project failed: {error}");
{
let mut coordinator = world.resource_mut::<ShutdownCoordinator>();
coordinator.intent = ShutdownIntent::Idle;
coordinator.last_blocker = Some(message.clone());
}
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
scene_io.set_status(message);
}
return;
}
}
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::ExitSent { source };
debug!(?source, "Editor shutdown authorized");
world.write_message(AppExit::Success);
}
fn scene_has_unsaved_changes(world: &World) -> bool {
world
.get_resource::<SceneIo>()
.is_some_and(SceneIo::has_unsaved_tabs)
}
fn unsaved_scene_count(world: &World) -> usize {
let Some(scene_io) = world.get_resource::<SceneIo>() else {
return 0;
};
let tab_count = scene_io.tabs.iter().filter(|tab| tab.dirty).count();
if scene_io.dirty
&& scene_io
.tabs
.get(scene_io.active_tab)
.is_none_or(|tab| !tab.dirty)
{
tab_count + 1
} else {
tab_count
}
}
fn decision_from_dialog(result: rfd::MessageDialogResult) -> ShutdownDecision {
match result {
rfd::MessageDialogResult::Custom(label) if label == SAVE_ALL_LABEL => {
ShutdownDecision::SaveAll
}
rfd::MessageDialogResult::Custom(label) if label == DISCARD_LABEL => {
ShutdownDecision::Discard
}
rfd::MessageDialogResult::Yes => ShutdownDecision::SaveAll,
rfd::MessageDialogResult::No => ShutdownDecision::Discard,
_ => ShutdownDecision::Cancel,
}
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use bevy::ecs::message::MessageCursor;
use super::*;
use crate::native_dialog::NativeDialogPlugin;
fn confirming_world() -> World {
let mut world = World::new();
world.init_resource::<Messages<ShutdownSaveAllRequested>>();
world.insert_resource(ShutdownCoordinator {
intent: ShutdownIntent::Confirming {
source: ShutdownSource::FileMenu,
},
..default()
});
world
}
#[test]
fn repeated_requests_are_coalesced_behind_the_first_source() {
let mut coordinator = ShutdownCoordinator::default();
assert!(coordinator.request(ShutdownSource::NativeWindow));
assert!(!coordinator.request(ShutdownSource::FileMenu));
assert_eq!(coordinator.coalesced_requests(), 1);
assert_eq!(
coordinator.intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::NativeWindow
}
);
}
#[test]
fn cancel_returns_to_idle_and_discard_authorizes_exit() {
let mut cancel_world = confirming_world();
assert!(resolve_shutdown_decision(
&mut cancel_world,
ShutdownDecision::Cancel
));
assert_eq!(
cancel_world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Idle
);
let mut discard_world = confirming_world();
assert!(resolve_shutdown_decision(
&mut discard_world,
ShutdownDecision::Discard
));
assert_eq!(
discard_world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Authorized {
source: ShutdownSource::FileMenu,
require_clean_scenes: false,
}
);
}
#[test]
fn dirty_cancel_preserves_the_session_and_discard_emits_one_exit() {
let mut app = App::new();
app.add_message::<AppExit>()
.init_resource::<SceneIo>()
.init_resource::<ShutdownCoordinator>();
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
app.world_mut()
.resource_mut::<ShutdownCoordinator>()
.set_intent_for_test(ShutdownIntent::Confirming {
source: ShutdownSource::Programmatic,
});
assert!(resolve_shutdown_decision(
app.world_mut(),
ShutdownDecision::Cancel
));
drive_shutdown(app.world_mut());
finalize_authorized_shutdown(app.world_mut());
assert_eq!(app.should_exit(), None);
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
app.world_mut()
.resource_mut::<ShutdownCoordinator>()
.set_intent_for_test(ShutdownIntent::Confirming {
source: ShutdownSource::Programmatic,
});
assert!(resolve_shutdown_decision(
app.world_mut(),
ShutdownDecision::Discard
));
drive_shutdown(app.world_mut());
finalize_authorized_shutdown(app.world_mut());
assert_eq!(app.should_exit(), Some(AppExit::Success));
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
assert!(app
.world()
.resource::<ShutdownCoordinator>()
.authorized_exit_sent());
}
#[test]
fn file_menu_and_programmatic_clean_exit_use_the_same_coordinator_path() {
for source in [ShutdownSource::FileMenu, ShutdownSource::Programmatic] {
let mut app = App::new();
app.add_message::<AppExit>()
.init_resource::<SceneIo>()
.init_resource::<ShutdownCoordinator>()
.init_resource::<NativeDialogBroker>();
assert!(request_editor_shutdown(app.world_mut(), source));
drive_shutdown(app.world_mut());
finalize_authorized_shutdown(app.world_mut());
assert_eq!(app.should_exit(), Some(AppExit::Success));
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::ExitSent { source }
);
}
}
#[test]
fn save_all_emits_one_persistence_handoff() {
let mut world = confirming_world();
let mut cursor = MessageCursor::<ShutdownSaveAllRequested>::default();
assert!(resolve_shutdown_decision(
&mut world,
ShutdownDecision::SaveAll
));
assert!(!resolve_shutdown_decision(
&mut world,
ShutdownDecision::SaveAll
));
let messages = world.resource::<Messages<ShutdownSaveAllRequested>>();
let requests: Vec<_> = cursor.read(messages).copied().collect();
assert_eq!(
requests,
vec![ShutdownSaveAllRequested {
source: ShutdownSource::FileMenu
}]
);
}
#[test]
fn clean_native_close_emits_the_authorized_exit() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>();
let window = app.world_mut().spawn(PrimaryWindow).id();
app.world_mut()
.write_message(WindowCloseRequested { window });
app.update();
assert_eq!(app.should_exit(), Some(AppExit::Success));
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::ExitSent {
source: ShutdownSource::NativeWindow
}
);
}
#[test]
fn secondary_window_close_does_not_request_editor_shutdown() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>();
app.world_mut().spawn(PrimaryWindow);
let secondary = app.world_mut().spawn_empty().id();
app.world_mut()
.write_message(WindowCloseRequested { window: secondary });
app.update();
assert_eq!(app.should_exit(), None);
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Idle
);
assert!(app.world().get_entity(secondary).is_err());
}
#[test]
fn occupied_dialog_broker_keeps_shutdown_waiting() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>();
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
let (release_sender, release_receiver) = mpsc::channel();
app.world()
.resource::<NativeDialogBroker>()
.request(
move || release_receiver.recv().expect("release sender dropped"),
|_, _: ()| {},
)
.unwrap();
assert!(request_editor_shutdown(
app.world_mut(),
ShutdownSource::Programmatic
));
app.update();
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::Programmatic
}
);
release_sender.send(()).unwrap();
}
#[test]
fn successful_save_requires_every_scene_tab_to_be_clean() {
let mut world = World::new();
world.init_resource::<SceneIo>();
world.insert_resource(ShutdownCoordinator {
intent: ShutdownIntent::Saving {
source: ShutdownSource::Programmatic,
},
..default()
});
assert!(complete_shutdown_save(
&mut world,
ShutdownSaveOutcome::Saved
));
assert_eq!(
world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Authorized {
source: ShutdownSource::Programmatic,
require_clean_scenes: true,
}
);
world.resource_mut::<SceneIo>().mark_dirty();
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Saving {
source: ShutdownSource::Programmatic,
};
assert!(!complete_shutdown_save(
&mut world,
ShutdownSaveOutcome::Saved
));
assert_eq!(
world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::Idle
);
assert!(world
.resource::<ShutdownCoordinator>()
.last_blocker()
.is_some());
}
#[test]
fn a_new_edit_after_save_reopens_the_guard_instead_of_exiting() {
let mut world = World::new();
world.init_resource::<Messages<AppExit>>();
world.init_resource::<SceneIo>();
world.insert_resource(ShutdownCoordinator {
intent: ShutdownIntent::Authorized {
source: ShutdownSource::Programmatic,
require_clean_scenes: true,
},
..default()
});
world.resource_mut::<SceneIo>().mark_dirty();
finalize_authorized_shutdown(&mut world);
assert_eq!(
world.resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::Programmatic
}
);
let messages = world.resource::<Messages<AppExit>>();
assert_eq!(
MessageCursor::<AppExit>::default().read(messages).count(),
0
);
}
#[test]
fn last_schedule_edit_is_seen_before_final_exit_authorization() {
let mut app = App::new();
app.add_message::<WindowCloseRequested>()
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
.init_resource::<SceneIo>()
.add_systems(Last, |mut scene_io: ResMut<SceneIo>| {
scene_io.mark_dirty();
});
assert!(request_editor_shutdown(
app.world_mut(),
ShutdownSource::Programmatic
));
app.update();
assert_eq!(app.should_exit(), None);
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
assert_eq!(
app.world().resource::<ShutdownCoordinator>().intent(),
ShutdownIntent::WaitingForBroker {
source: ShutdownSource::Programmatic
}
);
}
#[test]
fn custom_dialog_labels_map_to_shutdown_decisions() {
assert_eq!(
decision_from_dialog(rfd::MessageDialogResult::Custom(SAVE_ALL_LABEL.into())),
ShutdownDecision::SaveAll
);
assert_eq!(
decision_from_dialog(rfd::MessageDialogResult::Custom(DISCARD_LABEL.into())),
ShutdownDecision::Discard
);
assert_eq!(
decision_from_dialog(rfd::MessageDialogResult::Custom(CANCEL_LABEL.into())),
ShutdownDecision::Cancel
);
}
#[test]
fn only_the_final_coordinator_state_authorizes_clean_session_persistence() {
let mut coordinator = ShutdownCoordinator::default();
assert!(!coordinator.authorized_exit_sent());
coordinator.intent = ShutdownIntent::Authorized {
source: ShutdownSource::Programmatic,
require_clean_scenes: true,
};
assert!(!coordinator.authorized_exit_sent());
coordinator.intent = ShutdownIntent::ExitSent {
source: ShutdownSource::Programmatic,
};
assert!(coordinator.authorized_exit_sent());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -47,11 +47,6 @@ pub struct EditorComponentRegistry {
inspectors: HashMap<&'static str, ComponentInspectorFn>,
}
/// Synchronous inspector callbacks run while [`crate::ui::UiState`] is scoped out of `World`.
/// They must not request that resource. Keep extension-owned transient state in independently
/// registered resources, use [`crate::ui::request_ui_selection`] and
/// [`crate::ui::request_editor_tab`] for host UI changes, or defer other cross-panel work through
/// extension-owned commands or events.
pub type ComponentInspectorFn = fn(&mut World, &mut egui::Ui, Entity);
impl EditorComponentRegistry {

View File

@ -13,6 +13,8 @@ use crate::history::set_brush_with_history;
use crate::history::EditorHistory;
use crate::project_io::ProjectWorkspace;
use crate::scene_io::{SceneIo, SceneIoEventSeverity};
use crate::selection::SelectedEntity;
use crate::ui::UiState;
use crate::viewport::collider_diagnostics::{
ColliderDiagnosticSeverity, ColliderDiagnosticsState, ColliderOverlayStatus,
};
@ -121,7 +123,11 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
.iter(world)
.find_map(|(entity, id)| (id.0 == actor_id).then_some(entity))
{
request_diagnostic_selection(world, entity);
world.resource_mut::<SelectedEntity>().0 = Some(entity);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(entity);
}
}
}
@ -244,7 +250,11 @@ fn collider_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
ui.colored_label(color, row.shape_label);
ui.label(&row.actor_name);
if ui.small_button("Select").clicked() {
request_diagnostic_selection(world, row.entity);
world.resource_mut::<SelectedEntity>().0 = Some(row.entity);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(row.entity);
}
});
for diagnostic in &row.diagnostics {
@ -294,7 +304,11 @@ fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
ui.colored_label(color, row.status_label());
ui.label(row.name.as_str());
if ui.button("Select").clicked() {
request_diagnostic_selection(world, row.entity);
world.resource_mut::<SelectedEntity>().0 = Some(row.entity);
world
.resource_mut::<UiState>()
.selected_entities
.select_replace(row.entity);
}
if ui
.add_enabled(row.error_count > 0, egui::Button::new("Reset Cube"))
@ -309,10 +323,6 @@ fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
}
}
fn request_diagnostic_selection(world: &mut World, entity: Entity) {
crate::ui::request_ui_selection(world, &[entity]);
}
#[derive(Debug)]
struct BrushDiagnosticRow {
entity: Entity,
@ -387,37 +397,3 @@ pub fn diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut boo
diagnostics_ui(world, ui);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::selection::SelectedEntity;
use crate::ui::{PendingUiSelection, UiState};
#[test]
fn diagnostic_selection_reconciles_while_ui_state_is_scoped_out() {
let before = Entity::from_bits(1);
let requested = Entity::from_bits(2);
let mut world = World::new();
world.init_resource::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.resource_mut::<SelectedEntity>().0 = Some(before);
let mut ui_state = UiState::default_layout();
ui_state.selected_entities.select_replace(before);
world.insert_resource(ui_state);
world.resource_scope::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
request_diagnostic_selection(world, requested);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
super::super::reconcile_operator_selection(
&mut ui_state.selected_entities,
&[before],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(ui_state.selected_entities.as_slice(), &[requested]);
});
}
}

View File

@ -37,6 +37,7 @@ use crate::history::{
set_primitive_with_history, set_rigid_body_with_history, set_static_mesh_renderer_with_history,
EditorEntitySnapshot,
};
use crate::selection::SelectedEntity;
use crate::ui::theme::{
panel_heading, BORDER, ELEVATED_BG, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED,
WIDGET_BG,
@ -50,8 +51,9 @@ use crate::viewport::collider_diagnostics::{
use super::component_registry::{
EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry,
};
use super::dock_tabs::open_and_focus_tab;
use super::helpers::create_scene_sun_override_from_project_settings;
use super::EditorTab;
use super::{EditorTab, UiState};
use crate::assets::asset_db::{find_asset_by_path, AssetRegistry};
use crate::assets::static_mesh::{
load_static_mesh_manifest, material_id_from_label, part_id_from_label,
@ -133,7 +135,6 @@ pub(crate) struct InspectorPanelState {
add_component_scroll_selected: bool,
add_component_selected_index: usize,
add_component_target: Option<Entity>,
collapsed_components: HashSet<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -275,9 +276,13 @@ pub(crate) fn component_card_context(
state_key,
);
let collapsed = world
.resource::<InspectorPanelState>()
.collapsed_components
.contains(&component_card_key(world, entity, options.type_name));
.get_resource::<UiState>()
.map(|state| {
state
.inspector_collapsed_components
.contains(&component_card_key(world, entity, options.type_name))
})
.unwrap_or(false);
let pasteable = options.copyable
&& world
.get_resource::<InspectorClipboard>()
@ -321,11 +326,12 @@ fn apply_component_card_response_for_type(
) {
if let Some(collapsed) = response.collapsed {
let key = component_card_key(world, entity, type_name);
let mut panel_state = world.resource_mut::<InspectorPanelState>();
if collapsed {
panel_state.collapsed_components.insert(key);
} else {
panel_state.collapsed_components.remove(&key);
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
if collapsed {
ui_state.inspector_collapsed_components.insert(key);
} else {
ui_state.inspector_collapsed_components.remove(&key);
}
}
}
@ -2356,15 +2362,18 @@ fn locate_asset_ref(
return;
};
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
}
fn reveal_asset_in_browser(world: &mut World, folder_path: &str, selection: &AssetSelection) {
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.current_folder = folder_path.to_string();
assets.select(selection.clone());
assets.current_folder = candidate.folder_path.clone();
assets.select(candidate.selection.clone());
}
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
let panel_nodes = ui_state.panel_nodes;
open_and_focus_tab(
&mut ui_state.dock_state,
EditorTab::AssetBrowser,
&panel_nodes,
);
}
super::request_editor_tab(world, EditorTab::AssetBrowser);
}
fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
@ -4616,7 +4625,10 @@ pub fn project_sun_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
if create_override {
let sun = create_scene_sun_override_from_project_settings(world);
crate::ui::request_ui_selection(world, &[sun]);
world.resource_mut::<SelectedEntity>().0 = Some(sun);
if let Some(mut ui_state) = world.get_resource_mut::<crate::ui::UiState>() {
ui_state.selected_entities.select_replace(sun);
}
}
}
@ -4869,7 +4881,18 @@ fn locate_texture_asset(
return;
};
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
assets.current_folder = candidate.folder_path.clone();
assets.select(candidate.selection.clone());
}
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
let panel_nodes = ui_state.panel_nodes;
open_and_focus_tab(
&mut ui_state.dock_state,
EditorTab::AssetBrowser,
&panel_nodes,
);
}
}
fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) -> bool {
@ -4901,80 +4924,6 @@ fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>)
mod collider_inspector_tests {
use super::*;
use crate::history::{apply_command_undo, EditorHistory};
use crate::ui::{DockTabRequest, UiState};
#[test]
fn component_collapse_persists_while_ui_state_is_scoped_out() {
let mut world = World::new();
world.init_resource::<EditorComponentRegistry>();
world.init_resource::<InspectorPanelState>();
let entity = world
.spawn((ActorId::new("collapsed-component"), Transform::IDENTITY))
.id();
world.insert_resource(UiState::default_layout());
world.resource_scope::<UiState, _>(|world, _ui_state| {
assert!(!world.contains_resource::<UiState>());
apply_component_card_response(
world,
entity,
ComponentCardResponse {
type_name: COMPONENT_TRANSFORM,
collapsed: Some(true),
..Default::default()
},
);
let context = component_card_context(
world,
entity,
ComponentCardOptions::fixed(COMPONENT_TRANSFORM, "Transform", icons::CUBE),
);
assert!(context.collapsed);
});
}
#[test]
fn locate_texture_requests_asset_browser_while_ui_state_is_scoped_out() {
let path = "assets/textures/scoped-locate.png";
let folder = "assets/textures";
let selection = AssetSelection::File(path.to_string());
let mut world = World::new();
world.insert_resource(EditorAssets {
folders: Vec::new(),
assets: vec![EditorAsset {
label: "Scoped Locate".into(),
path: Some(path.into()),
folder_path: folder.into(),
kind: EditorAssetKind::Texture,
}],
current_folder: crate::assets::ASSETS_ROOT.into(),
selected: None,
dragging: None,
status: String::new(),
});
world.init_resource::<DockTabRequest>();
world.insert_resource(UiState::default_layout());
let candidate = TextureAssetCandidate {
label: "Scoped Locate".into(),
path: path.into(),
folder_path: folder.into(),
selection: selection.clone(),
texture_id: None,
};
world.resource_scope::<UiState, _>(|world, _ui_state| {
assert!(!world.contains_resource::<UiState>());
locate_texture_asset(world, Some(path), &[candidate]);
});
let assets = world.resource::<EditorAssets>();
assert_eq!(assets.current_folder, folder);
assert_eq!(assets.selected.as_ref(), Some(&selection));
assert_eq!(
world.resource::<DockTabRequest>().0,
Some(EditorTab::AssetBrowser)
);
}
#[test]
fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() {

View File

@ -9,7 +9,6 @@ use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
use crate::project::samples::{SampleCatalog, SampleCatalogEntry};
use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
use crate::shutdown::{request_editor_shutdown, ShutdownSource};
use crate::state::{EditorMode, PlayPaused};
use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
@ -40,7 +39,6 @@ pub fn top_menu_bar(
selected: &SelectedEntities,
dock_state: &mut DockState<EditorTab>,
panel_nodes: &mut PanelNodes,
viewport_rect: egui::Rect,
) {
egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
@ -150,11 +148,6 @@ pub fn top_menu_bar(
}
});
}
ui.separator();
if menu_item(ui, "Quit", None, true).clicked() {
request_editor_shutdown(world, ShutdownSource::FileMenu);
ui.close();
}
});
ui.menu_button("Edit", |ui| {
@ -210,7 +203,7 @@ pub fn top_menu_bar(
("Create Link", NavigationActorType::Link),
] {
if menu_item(ui, label, None, true).clicked() {
spawn_navigation_actor(world, actor_type, viewport_rect);
spawn_navigation_actor(world, actor_type);
ui.close();
}
}

View File

@ -26,6 +26,8 @@ mod toolbar;
mod viewport_chrome;
mod widgets;
use std::collections::HashSet;
use bevy::prelude::*;
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use bevy_egui::{egui, EguiContext, EguiPrimaryContextPass, PrimaryEguiContext};
@ -69,6 +71,7 @@ pub struct UiState {
pub selected_entities: SelectedEntities,
pub renaming_entity: Option<Entity>,
pub rename_buffer: String,
pub(crate) inspector_collapsed_components: HashSet<String>,
pub(crate) panel_nodes: PanelNodes,
last_mode_tab: Option<EditorMode>,
}
@ -76,56 +79,6 @@ pub struct UiState {
#[derive(Resource, Default)]
struct DockTabRequest(Option<EditorTab>);
#[derive(Resource, Default, Debug)]
pub(crate) struct PendingUiSelection(pub(crate) Option<Vec<Entity>>);
fn replace_selected_entities(selected: &mut SelectedEntities, entities: &[Entity]) {
selected.clear();
for (index, entity) in entities.iter().enumerate() {
if index == 0 {
selected.select_replace(*entity);
} else {
selected.select_maybe_add(*entity, true);
}
}
}
/// Requests an editor selection change from synchronous UI or extension callbacks.
///
/// Returns `false` when the editor selection resources are not installed. A normally configured
/// editor provides them through [`crate::EditorPluginGroup`].
pub fn request_ui_selection(world: &mut World, entities: &[Entity]) -> bool {
{
let Some(mut selected) = world.get_resource_mut::<SelectedEntity>() else {
return false;
};
selected.0 = entities.first().copied();
}
if world.contains_resource::<UiState>() {
{
let mut ui_state = world.resource_mut::<UiState>();
replace_selected_entities(&mut ui_state.selected_entities, entities);
}
if let Some(mut pending) = world.get_resource_mut::<PendingUiSelection>() {
pending.0 = None;
}
} else if let Some(mut pending) = world.get_resource_mut::<PendingUiSelection>() {
pending.0 = Some(entities.to_vec());
}
true
}
/// Requests that an editor tab be opened and focused after the current synchronous UI pass.
///
/// Returns `false` when [`EditorUiPlugin`] is not installed.
pub fn request_editor_tab(world: &mut World, tab: EditorTab) -> bool {
let Some(mut request) = world.get_resource_mut::<DockTabRequest>() else {
return false;
};
request.0 = Some(tab);
true
}
pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool {
ctx.egui_wants_keyboard_input()
}
@ -173,6 +126,7 @@ impl UiState {
selected_entities: SelectedEntities::default(),
renaming_entity: None,
rename_buffer: String::new(),
inspector_collapsed_components: HashSet::new(),
panel_nodes,
last_mode_tab: None,
}
@ -184,7 +138,7 @@ impl UiState {
)]
fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) {
apply_editor_theme(ctx);
let selected_before_ui: Vec<Entity> = self.selected_entities.iter().collect();
let selected_before_ui = world.resource::<SelectedEntity>().0;
let mode = *world.resource::<State<EditorMode>>().get();
if self.last_mode_tab != Some(mode) {
@ -215,15 +169,9 @@ impl UiState {
&self.selected_entities,
&mut self.dock_state,
&mut self.panel_nodes,
self.viewport_rect,
);
editor_toolbar_panel(
world,
root_ui,
&mut self.selected_entities,
self.viewport_rect,
);
editor_toolbar_panel(world, root_ui, &mut self.selected_entities);
status_bar_ui(world, root_ui, &self.selected_entities, mode);
let mut viewer = TabViewer {
@ -285,13 +233,11 @@ impl UiState {
world.resource_mut::<ViewportUiState>().shortcuts_open = true;
}
let pending_selection = world.resource_mut::<PendingUiSelection>().0.take();
let requested_selection = world.resource::<SelectedEntity>().0;
reconcile_operator_selection(
&mut self.selected_entities,
&selected_before_ui,
selected_before_ui,
requested_selection,
pending_selection.as_deref(),
);
let selected = self.selected_entities.as_slice().first().copied();
world.resource_mut::<SelectedEntity>().0 = selected;
@ -300,24 +246,16 @@ impl UiState {
fn reconcile_operator_selection(
selected_entities: &mut SelectedEntities,
selected_before_ui: &[Entity],
selected_before_ui: Option<Entity>,
requested_selection: Option<Entity>,
pending_selection: Option<&[Entity]>,
) {
if selected_entities.as_slice() != selected_before_ui {
return;
}
if let Some(entities) = pending_selection {
replace_selected_entities(selected_entities, entities);
return;
}
if requested_selection == selected_before_ui.first().copied() {
let selected_after_ui = selected_entities.as_slice().first().copied();
if requested_selection == selected_before_ui || selected_after_ui != selected_before_ui {
return;
}
selected_entities.clear();
if let Some(entity) = requested_selection {
replace_selected_entities(selected_entities, &[entity]);
} else {
selected_entities.clear();
selected_entities.select_replace(entity);
}
}
@ -372,7 +310,6 @@ impl Plugin for EditorUiPlugin {
.init_resource::<material_library::MaterialLibraryState>()
.init_resource::<material_library::MaterialLibraryCatalog>()
.init_resource::<DockTabRequest>()
.init_resource::<PendingUiSelection>()
.init_resource::<inspector::InspectorClipboard>()
.init_resource::<inspector::InspectorPanelState>()
.init_resource::<animation_inspector::AnimationInspectorState>()
@ -539,7 +476,7 @@ mod tests {
let mut selected = SelectedEntities::default();
selected.select_replace(before);
reconcile_operator_selection(&mut selected, &[before], Some(spawned), None);
reconcile_operator_selection(&mut selected, Some(before), Some(spawned));
assert_eq!(selected.as_slice(), &[spawned]);
}
@ -552,62 +489,8 @@ mod tests {
let mut selected = SelectedEntities::default();
selected.select_replace(clicked);
reconcile_operator_selection(
&mut selected,
&[before],
Some(requested),
Some(&[requested, Entity::from_bits(4)]),
);
reconcile_operator_selection(&mut selected, Some(before), Some(requested));
assert_eq!(selected.as_slice(), &[clicked]);
}
#[test]
fn direct_ui_secondary_selection_change_beats_pending_operator_selection() {
let a = Entity::from_bits(1);
let b = Entity::from_bits(2);
let c = Entity::from_bits(3);
let d = Entity::from_bits(4);
let e = Entity::from_bits(5);
let mut selected = SelectedEntities::default();
selected.select_replace(a);
selected.select_maybe_add(c, true);
reconcile_operator_selection(&mut selected, &[a, b], Some(d), Some(&[d, e]));
assert_eq!(selected.as_slice(), &[a, c]);
}
#[test]
fn menu_scope_multi_selection_request_preserves_all_entities() {
let before = Entity::from_bits(1);
let first_duplicate = Entity::from_bits(2);
let second_duplicate = Entity::from_bits(3);
let mut world = World::new();
world.init_resource::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.resource_mut::<SelectedEntity>().0 = Some(before);
let mut ui_state = UiState::default_layout();
ui_state.selected_entities.select_replace(before);
world.insert_resource(ui_state);
world.resource_scope::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
request_ui_selection(world, &[first_duplicate, second_duplicate]);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
reconcile_operator_selection(
&mut ui_state.selected_entities,
&[before],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(
ui_state.selected_entities.as_slice(),
&[first_duplicate, second_duplicate]
);
});
}
}

View File

@ -858,16 +858,10 @@ pub enum NavigationActorType {
Link,
}
pub fn spawn_navigation_actor(
world: &mut World,
actor_type: NavigationActorType,
viewport_rect: egui::Rect,
) -> Entity {
pub fn spawn_navigation_actor(world: &mut World, actor_type: NavigationActorType) -> Entity {
let rect = world.resource::<crate::ui::UiState>().viewport_rect;
let settings = world.resource::<ViewportSettings>().clone();
let translation = snap_translation(
editor_spawn_ground_position(world, viewport_rect),
&settings,
);
let translation = snap_translation(editor_spawn_ground_position(world, rect), &settings);
let (name, bounds, obstacle, area, link) = match actor_type {
NavigationActorType::Bounds => (
"Navigation Bounds",
@ -1025,49 +1019,8 @@ pub fn sync_navigation_state(world: &mut World) {
#[cfg(test)]
mod tests {
use super::*;
use crate::history::EditorHistory;
use crate::scene_io::SceneIo;
use crate::selection::SelectedEntity;
use crate::ui::{PendingUiSelection, UiState};
use scene::navigation::NavigationGeometryInput;
use shared::{ActorKind, LevelObject};
#[test]
fn navigation_spawn_works_while_ui_state_is_scoped_out_of_world() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.init_resource::<ViewportSettings>();
let mut ui_state = UiState::default_layout();
ui_state.viewport_rect =
egui::Rect::from_min_size(egui::pos2(100.0, 80.0), egui::vec2(1280.0, 720.0));
world.insert_resource(ui_state);
let entity = world.resource_scope::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
let entity =
spawn_navigation_actor(world, NavigationActorType::Bounds, ui_state.viewport_rect);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
super::super::reconcile_operator_selection(
&mut ui_state.selected_entities,
&[],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(ui_state.selected_entities.as_slice(), &[entity]);
entity
});
assert!(world.contains_resource::<UiState>());
assert!(world.get::<LevelObject>(entity).is_some());
assert!(world.get::<NavigationBounds>(entity).is_some());
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
assert_eq!(world.resource::<SelectedEntity>().0, Some(entity));
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
}
#[test]
fn world_affine_uses_current_authored_parent_chain() {

View File

@ -8,6 +8,7 @@ use egui_phosphor_icons::icons;
use crate::history::spawn_with_history;
use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::state::{EditorMode, PlayPaused, PlayPossession};
use crate::ui::UiState;
use crate::viewport::brush_tool::start_draw_brush_tool;
use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings};
use shared::PrimitiveShape;
@ -29,21 +30,15 @@ pub fn editor_toolbar_panel(
world: &mut World,
root_ui: &mut egui::Ui,
selected: &mut SelectedEntities,
viewport_rect: egui::Rect,
) {
egui::Panel::top("editor_toolbar")
.exact_size(TOOLBAR_HEIGHT)
.show_inside(root_ui, |ui| {
toolbar_ui(world, ui, selected, viewport_rect);
toolbar_ui(world, ui, selected);
});
}
pub fn toolbar_ui(
world: &mut World,
ui: &mut egui::Ui,
selected: &mut SelectedEntities,
viewport_rect: egui::Rect,
) {
pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedEntities) {
let (toolbar_rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 2.0),
egui::Sense::hover(),
@ -70,7 +65,7 @@ pub fn toolbar_ui(
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
left_ui.spacing_mut().item_spacing.x = 6.0;
left_toolbar(world, &mut left_ui, viewport_rect);
left_toolbar(world, &mut left_ui);
let mut transport_ui = ui.new_child(
egui::UiBuilder::new()
@ -105,7 +100,7 @@ pub fn toolbar_ui(
);
}
fn left_toolbar(world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect) {
fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
brand_mark_frame().show(ui, |ui| {
ui.set_min_width(118.0);
ui.set_min_height(30.0);
@ -131,16 +126,16 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect)
toolbar_group_frame().show(ui, |ui| {
panel_toolbar_row(ui, |ui| {
if icon_button(ui, icons::CUBE, "Spawn cube").clicked() {
spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube", viewport_rect);
spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube");
}
if icon_button(ui, icons::MOUNTAINS, "Spawn ramp").clicked() {
spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp", viewport_rect);
spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp");
}
if icon_button(ui, icons::SPHERE, "Spawn sphere").clicked() {
spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere", viewport_rect);
spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere");
}
if icon_button(ui, icons::LIGHTBULB, "Spawn point light").clicked() {
let pos = camera_ground_spawn(world, viewport_rect);
let pos = camera_ground_spawn(world);
spawn_with_history(world, light_snapshot("Point Light", pos));
}
if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() {
@ -162,7 +157,6 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect)
super::navigation_inspector::spawn_navigation_actor(
world,
super::navigation_inspector::NavigationActorType::Bounds,
viewport_rect,
);
}
}
@ -238,68 +232,14 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
}
}
fn camera_ground_spawn(world: &mut World, viewport_rect: egui::Rect) -> Vec3 {
fn camera_ground_spawn(world: &mut World) -> Vec3 {
let rect = world.resource::<UiState>().viewport_rect;
let settings = world.resource::<ViewportSettings>().clone();
let pos = editor_spawn_ground_position(world, viewport_rect);
let pos = editor_spawn_ground_position(world, rect);
snap_translation(pos, &settings)
}
fn spawn_primitive_at_camera(
world: &mut World,
shape: PrimitiveShape,
name: &str,
viewport_rect: egui::Rect,
) -> Entity {
let pos = camera_ground_spawn(world, viewport_rect);
spawn_with_history(world, primitive_snapshot(name, shape, pos))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history::EditorHistory;
use crate::scene_io::SceneIo;
use crate::selection::SelectedEntity;
use crate::ui::{PendingUiSelection, UiState};
use shared::{LevelObject, Primitive};
#[test]
fn primitive_spawn_works_while_ui_state_is_scoped_out_of_world() {
let mut world = World::new();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
world.init_resource::<PendingUiSelection>();
world.init_resource::<ViewportSettings>();
let mut ui_state = UiState::default_layout();
ui_state.viewport_rect =
egui::Rect::from_min_size(egui::pos2(100.0, 80.0), egui::vec2(1280.0, 720.0));
world.insert_resource(ui_state);
let entity = world.resource_scope::<UiState, _>(|world, mut ui_state| {
assert!(!world.contains_resource::<UiState>());
let entity = spawn_primitive_at_camera(
world,
PrimitiveShape::Box,
"Cube",
ui_state.viewport_rect,
);
let pending = world.resource_mut::<PendingUiSelection>().0.take();
super::super::reconcile_operator_selection(
&mut ui_state.selected_entities,
&[],
world.resource::<SelectedEntity>().0,
pending.as_deref(),
);
assert_eq!(ui_state.selected_entities.as_slice(), &[entity]);
entity
});
assert!(world.contains_resource::<UiState>());
assert!(world.get::<LevelObject>(entity).is_some());
assert!(world.get::<Primitive>(entity).is_some());
assert_eq!(world.resource::<SelectedEntity>().0, Some(entity));
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
}
fn spawn_primitive_at_camera(world: &mut World, shape: PrimitiveShape, name: &str) {
let pos = camera_ground_spawn(world);
spawn_with_history(world, primitive_snapshot(name, shape, pos));
}

View File

@ -6,7 +6,7 @@ use bevy::app::PluginGroupBuilder;
use bevy::asset::AssetPlugin;
use bevy::log::{LogPlugin, DEFAULT_FILTER};
use bevy::prelude::*;
use bevy::window::{CompositeAlphaMode, ExitCondition, PresentMode, WindowMode, WindowPlugin};
use bevy::window::{CompositeAlphaMode, PresentMode, WindowMode, WindowPlugin};
/// Runtime switch for the HDR camera pass.
///
@ -40,27 +40,6 @@ fn find_assets_directory(start: Option<PathBuf>) -> Option<String> {
}
pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
configured_plugins(WindowPlugin {
primary_window: Some(primary_window(title)),
..default()
})
}
/// Default plugins for the editor, whose shutdown coordinator owns window-close handling.
pub fn editor_plugins(title: impl Into<String>) -> PluginGroupBuilder {
configured_plugins(editor_window_plugin(title))
}
fn editor_window_plugin(title: impl Into<String>) -> WindowPlugin {
WindowPlugin {
primary_window: Some(primary_window(title)),
exit_condition: ExitCondition::DontExit,
close_when_requested: false,
..default()
}
}
fn configured_plugins(window_plugin: WindowPlugin) -> PluginGroupBuilder {
DefaultPlugins
.set(LogPlugin {
// Blacksite intentionally replaces Bevy's `.scn.ron` loader with a
@ -69,7 +48,10 @@ fn configured_plugins(window_plugin: WindowPlugin) -> PluginGroupBuilder {
filter: format!("{DEFAULT_FILTER}bevy_asset::server::loaders=error"),
..default()
})
.set(window_plugin)
.set(WindowPlugin {
primary_window: Some(primary_window(title)),
..default()
})
.set(AssetPlugin {
file_path: resolve_assets_directory(),
..default()
@ -104,8 +86,8 @@ pub fn hdr_enabled_from_env() -> bool {
#[cfg(test)]
mod tests {
use super::{editor_window_plugin, primary_window, resolve_assets_directory};
use bevy::window::{CompositeAlphaMode, ExitCondition};
use super::{primary_window, resolve_assets_directory};
use bevy::window::CompositeAlphaMode;
#[test]
fn primary_window_is_opaque() {
@ -115,14 +97,6 @@ mod tests {
assert_eq!(window.composite_alpha_mode, CompositeAlphaMode::Opaque);
}
#[test]
fn editor_window_defers_close_and_exit_to_the_editor() {
let plugin = editor_window_plugin("test");
assert!(!plugin.close_when_requested);
assert!(matches!(plugin.exit_condition, ExitCondition::DontExit));
}
#[test]
fn resolve_assets_directory_finds_workspace_assets() {
let cwd = std::env::current_dir().expect("cwd");

View File

@ -56,7 +56,6 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport |
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
| [0042](adr/0042-guarded-editor-shutdown-and-document-savepoints.md) | Guarded native editor exit and canonical per-document clean checkpoints |
## Editor framework
@ -95,8 +94,6 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
| [editor/evaluations/operator-invariants/](editor/evaluations/operator-invariants/) | Source acceptance results for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
| [editor/evaluations/sample-regression-pack/](editor/evaluations/sample-regression-pack/) | Exact-implementation source and native acceptance evidence for the editor sample regression pack |
| [editor/evaluations/guarded-shutdown-savepoints/](editor/evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
| [editor/evaluations/scoped-ui-actions/](editor/evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
## Working plans (not canonical long-term)
@ -122,7 +119,6 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
| `guarded_shutdown_savepoints_*.plan.md` | Native close coordination, asynchronous Save All, and canonical history clean points |
## Crate responsibilities (quick reference)

View File

@ -34,19 +34,12 @@ assets, mark a scene clean, enter source control, or silently replace authored w
or Discard removes that scene's recovery generations. Save Recovery Copy As writes the snapshot
to a separate transactional scene file without changing the active scene or recovery lifecycle.
- A successful manual save retires recovery generations for that scene.
- A successful load or manual save establishes that tab's canonical authored-content checkpoint.
Undo and redo compare the current stable authored projection with this checkpoint to derive dirty
state. Failed writes, cancelled Save As, and recovery restores do not advance it.
- Native editor exit is authorized only after all dirty scene tabs save successfully or the user
explicitly discards them. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
## Consequences
- Interrupted manual writes retain the previous complete destination on filesystems with atomic
same-directory rename semantics.
- Recovery is machine-local and does not pollute builds or source control.
- Undoing exactly to the last loaded or saved authored state clears the dirty marker without
weakening recovery after direct edits or failed saves.
- Autosave still performs authored serialization and hydration rebuild work. Performance
instrumentation and background snapshotting remain follow-up work under the production roadmap.
- Unsaved new scenes do not yet have a stable recovery identity; they require a later session-ID

View File

@ -20,10 +20,8 @@ untrusted serialized ECS state.
- Session schema v1 allowlists metadata only: project root, active scene path, saved dock-layout
text, hierarchy expansion paths, non-destructive panel visibility, and finite camera bookmark
transforms.
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; only an
editor-shutdown-coordinator-authorized `AppExit` writes the final document with
`clean_shutdown: true`. Direct `AppExit`, forced termination, and process failure retain the
abnormal marker. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; normal
`AppExit` writes the final document with `clean_shutdown: true`.
- A clean prior session may restore its active scene and non-destructive UI state automatically.
An abnormal prior session starts from the normal safe scene and requires explicit confirmation
before opening the prior scene.

View File

@ -1,57 +0,0 @@
# ADR 0042: Guarded Editor Shutdown And Document Savepoints
## Status
Accepted
## Context
Bevy's default native-window policy closes a requested window and exits after the final window is
gone. That policy bypasses Blacksite's scene-tab and project-switch confirmations, so a compositor
close request could silently discard dirty authored work. Reading `WindowCloseRequested` in another
system is insufficient because Bevy's default close system has its own message cursor and still
despawns the window.
Scene dirtiness was also independent of editor history. Every undo or redo marked the active scene
dirty, even when undo restored the exact state that had last loaded or saved. Undo depth alone is
not a valid savepoint: editor commands contain runtime entity IDs and are intentionally cleared when
tabs switch, while saves may establish a clean point in the middle of a timeline.
## Decision
- The full editor disables Bevy's automatic close-on-request behavior. The game and Project Browser
keep their existing native-window policy.
- One editor shutdown coordinator owns native window close, **File > Quit**, project switching, and
programmatic editor-exit requests. It coalesces duplicate requests and is the only normal path
authorized to emit `AppExit`.
- Final authorization runs in a dedicated main schedule after Bevy's `Last` schedule. The guard
rechecks dirty scene state after every editor authoring system, then clean-session persistence
runs after that finalizer in the same post-`Last` schedule.
- A clean editor exits immediately. Dirty scene tabs enter one non-blocking native decision with
explicit **Save All**, **Discard**, and **Cancel** actions. Save completion, including an untitled
tab's Save As picker, is applied on the main thread. The coordinator exits only after every dirty
tab saves or the user explicitly discards; cancel or failure retains the live session.
- Clean-session persistence accepts only an exit authorized by the coordinator. Crashes, forced
termination, and unguarded `AppExit` retain the abnormal-session marker.
- Every scene tab owns a canonical authored-content checkpoint established by a successful load or
save. The projection uses stable actor IDs, stable parent actor IDs, sorted component identities,
and scene composition; it excludes transient Bevy entity numbers and runtime hydration.
- A committed edit marks the active tab dirty. Successful undo or redo compares the current
canonical authored projection with that tab's checkpoint. Equality clears the dirty marker;
divergence sets it. Failed saves and recovery restores do not advance the checkpoint.
- Editor command stacks remain active-document runtime state and are still cleared on tab switches.
Checkpoint ownership belongs to the document tab, so dirty truth survives those clears.
## Consequences
- Native close can no longer silently discard authored scene tabs, and the editor remains responsive
while confirmation or Save As dialogs are open.
- A normal exit requires an explicit editor-owned authorization. New code must request shutdown
through the coordinator instead of writing `AppExit` directly.
- Undoing to the last saved authored state clears title and tab dirty indicators, including after a
save at nonzero history depth. Redo or a divergent branch marks the document dirty again.
- Reconciliation serializes the authored projection after undo and redo. This is more work than a
depth comparison, but it is bounded to explicit history navigation and remains correct across
direct non-history repairs, entity respawns, and tab switches.
- Operating-system process kill and power loss cannot be confirmed; recovery and abnormal-session
handling remain the safety boundary for those cases.

View File

@ -42,8 +42,6 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
| [evaluations/operator-invariants/](evaluations/operator-invariants/) | Source acceptance record for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
| [evaluations/sample-regression-pack/](evaluations/sample-regression-pack/) | Exact-implementation source and native acceptance evidence for the five-area editor regression pack |
| [evaluations/guarded-shutdown-savepoints/](evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
| [evaluations/scoped-ui-actions/](evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
## Subsystems (code → doc)
@ -113,10 +111,6 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
components on load and component transactions, and does not prohibit compatible composition such
as renderer plus light. Component validation owns requirements and geometry-source conflicts.
- **Scene persistence and recovery** use transactional same-directory writes for authored scenes and bounded user-local recovery snapshots for dirty saved scenes. Recovery runs every 120 seconds by default, keeps five generations, never marks the scene clean, and appears as a status/File-menu restore or discard action when newer than the authored file. Restore leaves the original scene dirty until manually saved. See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
- **Guarded shutdown and clean savepoints** route native close, **File > Quit**, project switching,
and programmatic exit through one non-blocking Save All / Discard / Cancel coordinator. Each tab's
dirty marker is reconciled against its canonical last-loaded-or-saved authored projection after
undo/redo. See [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
- **Collaborative file safety** records exact content revisions for loaded scenes, prefab source operations, editable materials, and Project Settings, then rechecks them immediately before atomic publication. External changes, create races, read-only files, and optional ownership locks keep the existing bytes intact and open Reload/Compare Metadata/Save As/Cancel recovery. Git status is asynchronous, observational, and absent without noise outside a repository. See [collaborative-file-safety.md](collaborative-file-safety.md) and [ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, gameplay markers, volumes, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Spot lights show inner/outer cones and cameras show near/far frusta. Viewport options independently gate visualizer categories and include actor icon 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.

View File

@ -87,20 +87,11 @@ or Cancel. Generated artifacts remain under their subsystem-owned regeneration p
[collaborative-file-safety.md](collaborative-file-safety.md) and
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
`project/shutdown.rs` owns native close, **File > Quit**, project switching, and programmatic editor
exit. The editor launch configuration leaves native close requests open; the coordinator coalesces
them, retries the shared native-dialog broker when busy, and authorizes `AppExit` only after clean
state, successful Save All, or explicit Discard. Scene I/O owns the asynchronous per-tab save
transaction and reports completion back to the coordinator. Final authorization and clean-session
persistence run in an editor schedule inserted after Bevy's `Last`, so material drops, brush/gizmo
finalizers, and every other authoring system finish before the last dirty-state check. See
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
`project/session.rs` owns the independent versioned restart document under the user state
directory. It snapshots allowlisted paths, panel visibility, dock/hierarchy metadata, and camera
bookmarks, writes a running marker at startup, and records clean state only for a coordinator-
authorized `AppExit`. Abnormal sessions do not auto-open the prior scene; the explicit safe-resume
prompt is the only path back. See [ADR 0024](../adr/0024-versioned-editor-session-state.md).
bookmarks, writes a running marker at startup, and records clean `AppExit`. Abnormal sessions do
not auto-open the prior scene; the explicit safe-resume prompt is the only path back. See
[ADR 0024](../adr/0024-versioned-editor-session-state.md).
`project/diagnostics_bundle.rs` exports a separate, transactional support report from an explicit
metadata allowlist. It summarizes build/renderer identity, project paths, dirty state, aggregate
@ -209,7 +200,7 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
1. User edits entities with `LevelObject` + reflectable components from `shared`.
2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots, canonical clean checkpoints, and independent dirty/recovery state. Undo/redo reconciles stable authored content rather than transient ECS entity IDs. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md), ADR 0026, and [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots and independent dirty/recovery state. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md) and ADR 0026.
5. Hydration systems in `game` spawn meshes, colliders, lights, static mesh renderer parts, and
dedicated skinned-model hierarchies at runtime. Level-object roots are initialized with Bevy
visibility hierarchy components before generated children are attached, so authoring

View File

@ -12,8 +12,6 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
| Hydrated components in committed `.scn.ron` | CI | `scene::validate_scene_authoring_only` + `repo_editor_scene_has_no_hydrated_components` |
| Production mutation without commit/cancel/failure/undo invariants | Done | Shared operator harness plus typed history projections cover palette, asset, brush, terrain, physics, grouping, lighting, material-drop, and transform paths; see [evaluation](evaluations/operator-invariants/) |
| Representative editor coverage can silently disappear | Done | Versioned five-area manifest, typed scene gate, `validate-samples`, and native protocol; see [sample pack](sample-regression-pack.md) |
| Dirty editor exit can bypass an explicit decision, or transient entity IDs define document cleanliness | Done | Guarded shutdown and canonical authored-content checkpoints pass source validation and all seven native workflows at source implementation `4b33f32`, including dirty untitled Save All through Save As. See the [evaluation](evaluations/guarded-shutdown-savepoints/). |
| Scoped UI callbacks depend on temporarily unavailable host state or lose direct selection precedence | Done | Explicit selection/tab requests, panel-owned state, captured viewport data, and full-slice reconciliation pass six scoped regressions, independent re-review, and native toolbar/Inspector/diagnostics acceptance at `9e23ae7`; see the [evaluation](evaluations/scoped-ui-actions/). |
| Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor |
| `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` |

View File

@ -1,72 +0,0 @@
# Guarded Shutdown And Clean Savepoints Evaluation
**Date:** 2026-07-13
**Issue:** [Gitea #55](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/55)
**Branch:** `codex/guarded-shutdown-savepoints`
**Implementation commit:** `4b33f3235761fd0dd3daf219e5bbbaee884eb626`
**Release-candidate commit:** Not nominated
**Implementation validation:** **Pass**
**Native acceptance:** **Pass**
This record covers editor-owned shutdown authorization and per-document authored-content clean
checkpoints. The permanent architecture contract lives in
[ADR 0042](../../../adr/0042-guarded-editor-shutdown-and-document-savepoints.md). The automated
results below apply to the exact implementation commit. Native rows exercise the same shutdown
implementation after the unrelated toolbar fix disclosed below. Neither replaces the clean-checkout,
soak, or independent-signoff requirements for a future release candidate.
## Automated Verification
| Command or suite | Result | Evidence |
|------------------|--------|----------|
| `cargo fmt --all -- --check` | Pass | Formatting check completed without changes. |
| `git diff --check` | Pass | No whitespace errors. |
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass | Workspace and all targets completed with warnings denied. |
| `cargo test --workspace --all-targets --no-fail-fast` | Pass | All workspace targets passed; the editor crate reported 282 passing tests. |
| Guarded shutdown regressions | Pass | Native close, menu/programmatic exit, cancel/discard, broker contention, late-authoring mutation, and secondary-window behavior are covered by 13 focused tests. |
| Save All regressions | Pass | Named and untitled tab sequencing, cancellation, conflict retention, duplicate destination rejection, and active-tab restoration are covered by six focused tests. |
| Project-switch regressions | Pass | Three focused tests cover guarded handoff, cancellation, and browser-spawn failure. |
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed. |
## Native Acceptance
The guarded-shutdown implementation under test is the source commit named above. The native debug
editor was rebuilt at `9e23ae731f95dc351ecc744d6ca61f6bc4467317` after the independently scoped
[UI action-safety fix](../scoped-ui-actions/) ([Gitea #57](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/57))
made the untitled-scene toolbar workflow reachable, then launched as
`target/debug/editor --project .`. Each row records an observed workflow rather than inferring
behavior from the automated suite.
| Workflow | Result | Evidence |
|----------|--------|----------|
| Undo back to the clean checkpoint | Pass | A scene mutation added the dirty marker; undo restored the canonical authored projection and cleared the marker. |
| Native window close, then **Cancel** | Pass | The dirty-document decision appeared; Cancel retained the dirty document and kept the editor running. |
| **File > Quit**, then **Discard** | Pass | The same guarded decision path ran and the editor process exited with code 0 without saving the dirty draft. |
| **File > Switch Project...** guard | Pass | A dirty document was intercepted by the shared shutdown decision before project-browser handoff. |
| Dirty named document, **Save All** | Pass | The named scene write completed before authorization and the editor then exited with code 0. |
| Clean untitled document close | Pass | No dirty-work decision was needed and the editor exited with code 0. |
| Dirty untitled document, **Save All**, then Save As continuation | Pass | **File > New Scene** created an untitled tab; the toolbar Cube action spawned authored content and marked it dirty. Native close followed by **Save All** opened KDE Save As in `assets/levels`; saving as `issue55_shutdown_qa.scn.ron` wrote a 1,986-byte scene and the editor exited with code 0. The QA scene was verified and removed. |
## Evidence Status
![Native guarded-shutdown decision over the Blacksite editor](https://git.spacetrainclubhouse.com/attachments/b9538ef6-25e0-46b0-9997-9a4311584893)
- Native modal capture: `/tmp/blacksite-issue55-shutdown-modal-4b33f32.png` (temporary staging;
3426x1384 RGB PNG, 1,656,602 bytes).
- SHA-256: `2ddb2571325e0d99f5559efd1ba3164845ffba64767833d4e5ad8d2925aedfb5`.
- Gitea #55 native attachment: asset 36, uploaded through the issue attachment API.
The Gitea copy was uploaded as an ordinary issue attachment. No repository PNG or LFS raw URL is
claimed by this evaluation.
## Release Use
The #55 source and native feature-level acceptance record is complete. A future production candidate
must still rerun these workflows from the exact nominated commit; this record does not nominate a
candidate or establish production readiness.

View File

@ -1,6 +1,6 @@
# Production-Readiness Acceptance Matrix
**Matrix version:** 0.5
**Matrix version:** 0.4
**Last audit:** 2026-07-13
@ -30,20 +30,20 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| ID | Requirement | State | Current evidence and gap |
|----|-------------|-------|--------------------------|
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49`, non-blocking native dialogs `#52`, guarded shutdown/savepoints `#55`, and scoped UI action safety `#57` have feature-level source/native acceptance. #55 passed all seven native paths at source implementation `4b33f32`; #57 passed toolbar, Inspector, diagnostics, selection, and guarded-exit paths at exact implementation `9e23ae7`. Candidate-specific end-to-end reruns remain. |
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Fail | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49` and non-blocking native dialogs `#52` passed live acceptance, but `#55` records unguarded native dirty shutdown and a missing clean-history savepoint. Candidate-specific end-to-end reruns also remain. |
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Partial | The [five-area sample pack](../sample-regression-pack/) passed source and native acceptance on exact implementation commit `d52cc2e`, including Brush, Material, Terrain, Physics Placement, and Rendering. No release candidate is nominated, brush acceptance `#37` remains open, and combined animation/audio/navigation/PIE/build evidence is incomplete. Packaged testing is owner-deferred. |
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` closed at `c55f347`. A clean, exact-candidate rerun is still required for `Pass`. |
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS and includes both project and sample content gates. Local implementation checks have passed, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, and the follow-on scoped UI action-safety defect `#57` have complete source/native feature acceptance at `d52cc2e`, source implementation `4b33f32`, and `9e23ae7` respectively; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Deterministic imported-asset fingerprints `#56` and property-block application `#53` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Fail | Sample-pack gap `#32` has exact-implementation source/native evidence at `d52cc2e`, but guarded native shutdown and clean history savepoints remain the open P0 `#55`. A consolidated candidate limitations ledger is also missing. Deterministic imported-asset fingerprints `#56` and property-block application `#53` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
## Deliverables
| ID | Deliverable | State | Evidence or next action |
|----|-------------|-------|-------------------------|
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.5. |
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.4. |
| D2 | Release-candidate representative project and reproducible validation commands | Partial | The committed [sample regression pack](../sample-regression-pack/), commands, and exact-implementation native evidence exist. Nominate a release candidate and rerun from a clean LFS-hydrated checkout. |
| D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. |
@ -69,8 +69,6 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| Build/package/runtime | Implemented foundation | Deferred by owner |
| Collaborative file safety | Implemented; `#49` closed with live external-change acceptance | Not rerun as one candidate |
| Native file/folder dialogs | Implemented; `#52` closed with live 12-second compositor-responsiveness hold | Not rerun as one candidate |
| Guarded shutdown and clean savepoints | Implemented; `#55` source validation and all seven native workflows passed at source implementation `4b33f32` | Feature-level acceptance complete; not rerun as one candidate |
| Scoped UI action safety | Implemented; `#57` source validation, six focused regressions, selection precedence, and native toolbar/Inspector/diagnostics workflows passed at `9e23ae7` | Feature-level acceptance complete; not rerun as one candidate |
## Candidate Validation Commands

View File

@ -1,68 +0,0 @@
# Scoped UI Action Safety Evaluation
**Date:** 2026-07-13
**Issue:** [Gitea #57](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/57)
**Implementation commit:** `9e23ae731f95dc351ecc744d6ca61f6bc4467317`
**Release-candidate commit:** Not nominated
**Source validation:** **Pass**
**Native acceptance:** **Pass**
This record covers synchronous editor actions invoked while the egui host scopes `UiState` out of
the Bevy `World`. The implementation keeps callback-owned state in independently registered
resources, passes viewport data into toolbar/navigation actions, routes cross-panel selection and
tab requests through explicit APIs, and reconciles the complete selection slice so a direct UI
selection takes precedence over a stale deferred request.
## Automated Verification
| Command or suite | Result | Evidence |
|------------------|--------|----------|
| `cargo fmt --all -- --check` | Pass | Formatting check completed without changes. |
| `git diff --check` | Pass | No whitespace errors. |
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass | Workspace and all targets completed with warnings denied. |
| `cargo test --workspace --all-targets --no-fail-fast` | Pass | All workspace targets passed; the editor crate reported 290 of 290 tests passing. |
| Scoped-action regressions | Pass | Six focused regressions cover history multi-selection, diagnostics selection, Inspector collapse and asset reveal state, primitive spawn, and navigation spawn while `UiState` is scoped out. |
| Selection precedence | Pass | Full-slice reconciliation preserves a direct primary or secondary UI selection over stale single- or multi-selection requests. |
| Independent re-review | Pass | The post-fix review found no remaining blockers. |
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed. |
## Native Acceptance
The exact rebuilt debug binary from the implementation commit was launched as
`target/debug/editor --project .`.
| Workflow | Result | Evidence |
|----------|--------|----------|
| Toolbar authoring actions | Pass | After **File > New Scene**, Cube, Ramp, Sphere, Point Light, and Navigation Bounds all spawned. Selection, transform gizmo, viewport overlay, hierarchy, and Inspector stayed synchronized. |
| Inspector state | Pass | Collapsing the Transform component persisted across subsequent UI rendering and selection work. |
| Cross-panel diagnostics action | Pass | **Window > Brush Diagnostics > Select** kept the editor live and synchronized the selected actor across hierarchy, viewport, and Inspector. |
| Guarded shutdown | Pass | The dirty native-close decision accepted **Discard** and the editor exited with code 0. |
## Native Evidence
![Scoped toolbar actions with authored actors selected in the live editor](https://git.spacetrainclubhouse.com/attachments/d265651b-aa8e-4ea5-841c-1fa8eb622766)
- Gitea attachment asset 37: `blacksite-issue57-scoped-ui-actions.png`, 3426x1384 RGB PNG,
890,021 bytes.
- SHA-256: `4225aca19268e4817148507e95cdad56a5f18734fd69e2fe5702df1dc304142e`.
![Brush Diagnostics Select synchronized hierarchy, viewport, and Inspector](https://git.spacetrainclubhouse.com/attachments/ba176d82-fb2f-403b-a71c-108de9649f11)
- Gitea attachment asset 38: `blacksite-issue57-diagnostics-select.png`, 3426x1384 RGB PNG,
1,418,849 bytes.
- SHA-256: `40422be59766a3e61005973c315d0fd6f663dd2d227aa92055fa2aa2e91320c3`.
Both images were uploaded as ordinary Gitea issue attachments; this record does not use repository
raw or LFS pointer URLs.
## Release Use
The #57 source and native feature-level acceptance record is complete. A future production candidate
must still rerun representative toolbar, Inspector, diagnostics, selection, and guarded-exit paths
from the exact nominated commit. This record does not nominate a candidate or establish production
readiness.

View File

@ -6,11 +6,6 @@ save/discard/cancel prompt when needed. **File > Open Scene...** opens another t
discarding the active document. Save and Save As affect the active tab, while project switching
offers to save every modified tab.
Each tab also retains a canonical checkpoint of the last successfully loaded or saved authored
projection. History commands remain active-tab runtime state, but undo and redo reconcile against
that document checkpoint. Returning exactly to the checkpoint clears only that tab's dirty marker;
redo, a divergent edit, failed save, or restored recovery remains dirty.
## Composition workflow
The **Composition** menu beside the tabs edits the active scene's persisted `SceneComposition`:

View File

@ -16,13 +16,6 @@ loop or editor rendering.
- Collaborative Save As performs the same revision and destination checks after selection.
- Close or project switch never treats an opened Save As picker as a completed save. When a save
remains pending, the transition pauses and can be retried after saving.
- Native window close, **File > Quit**, project switch, and programmatic editor exit share the
guarded-shutdown coordinator. Dirty scene tabs present exact **Save All**, **Discard**, and
**Cancel** actions. Save All remains pending through each untitled tab's Save As result and exits
only after every dirty tab is clean; cancellation or failure preserves the live session.
- A close request received while another native dialog owns the broker remains queued. The editor
rechecks dirty state and retries acquisition after that workflow completes instead of dropping
the request or opening a competing dialog.
## Covered Surfaces
@ -31,8 +24,6 @@ subscene selection, collaborative conflict copies, Project Browser folder select
confirmations. New workflows must use the broker instead of calling `rfd` from an egui/Bevy system.
See [ADR 0038](../adr/0038-non-blocking-native-dialog-broker.md).
Guarded exit ownership and document savepoints are defined by
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
Live native-Wayland acceptance is recorded in the
[native-dialog responsiveness evaluation](evaluations/native-dialog-responsiveness/).

View File

@ -151,7 +151,7 @@ with implementation sequencing in
| Milestone | Exit condition | Status |
|-----------|----------------|--------|
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#49 are complete after source, headless, and native acceptance. P0 #55 is complete at source implementation `4b33f32`, and follow-on P0 #57 scoped UI action safety is complete at `9e23ae7` after workspace, focused, independent-review, and native acceptance. No known P0 implementation blocker remains, but #50 is still the final candidate gate and production readiness is not established. Deterministic imported-asset fingerprints #56 are P1 follow-up work. Packaged acceptance is deferred until requested by the project owner. |
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#49 are complete after source, headless, and native acceptance. Final readiness gate #50 remains blocked by open P0 shutdown/savepoint work #55; deterministic imported-asset fingerprints #56 are P1 follow-up work. Packaged acceptance is deferred until requested by the project owner. |
Production readiness is not inferred from feature count. Gitea
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)

View File

@ -13,14 +13,10 @@ or credentials.
## Restart behavior
- **Clean shutdown:** after the guarded exit path saves all dirty scene tabs or receives explicit
Discard confirmation, the last authored scene and non-destructive UI state are restored.
- **Clean shutdown:** the last authored scene and non-destructive UI state are restored.
- **Abnormal shutdown:** the editor opens its normal safe scene and displays **Recover Editor
Session**. **Resume Last Scene** opens only the previous authored scene. **Continue Safe** keeps
the startup scene. Modal tools and dirty preview state are never restored.
- Native window close, **File > Quit**, project switching, and programmatic editor exit use one
non-blocking Save All / Discard / Cancel workflow. Cancel and failed saves keep the session open.
Direct `AppExit`, process kill, and crashes are not recorded as clean shutdowns.
- Dirty scene data remains governed by the independent scene recovery workflow in
[ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).