diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7c714c7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + +- + +## Validation + +- + +## Documentation + +- [ ] I updated the root README for user controls, run commands, or troubleshooting changes. +- [ ] I updated `docs/editor/` for editor workflow or feature-design changes. +- [ ] I added or updated an ADR for architecture, policy, scene format, rendering path, PIE semantics, settings schema, or netcode decisions. +- [ ] I indexed new documentation in `docs/README.md`. +- [ ] If this is Jackdaw-inspired work, I identified it as design-inspired, API-inspired, or code-derived. +- [ ] If this includes copied or closely translated source, I included source URL, upstream commit/release, license compatibility notes, and required file-level notices. diff --git a/README.md b/README.md index 36f78d7..6787f5b 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**. | Select Project Sun | Inspect project default lighting; create a scene sun override | | Asset Browser project/file views | Browse `assets/`, search/filter/sort, switch grid/list, expand model subassets with generated thumbnails, inspect staged import/material settings, drag assets/submeshes into the viewport | | Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` | -| `Ctrl+P` | Command palette (`scene.reset_lighting`, `selection.group`, `selection.focus`, play commands) | +| `Ctrl+P` | Command palette; type to filter, use arrow keys to select, Enter to run (`scene.reset_lighting`, `selection.group`, `selection.focus`, `selection.reset_transform`, play commands) | | `F7` | While paused in Play: advance one sim tick | | Shift/Ctrl + click (Hierarchy) | Additive selection | | Hierarchy context | Reparent to other selection / Unparent | diff --git a/crates/editor/src/ext/extensibility.rs b/crates/editor/src/ext/extensibility.rs index 06c8893..8a85bcc 100644 --- a/crates/editor/src/ext/extensibility.rs +++ b/crates/editor/src/ext/extensibility.rs @@ -8,7 +8,7 @@ use crate::state::{EditorMode, PlayPossession}; use crate::ui::helpers::{ reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused, }; -use crate::ui::selection_ops::focus_editor_camera_on_selection; +use crate::ui::selection_ops::{focus_editor_camera_on_selection, reset_selected_transforms}; use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM}; use crate::ui::UiState; @@ -127,6 +127,8 @@ pub struct CommandPalette { pub open: bool, pub filter: String, pub pending_run: Option, + pub focus_filter: bool, + pub selected_index: usize, } pub struct ExtensibilityPlugin; @@ -160,6 +162,7 @@ fn register_builtin_commands(mut registry: ResMut) { registry.register(Box::new(ResetLightingCommand)); registry.register(Box::new(GroupSelectionCommand)); registry.register(Box::new(FocusSelectionCommand)); + registry.register(Box::new(ResetSelectionTransformCommand)); registry.register(Box::new(CreatePostProcessVolumeCommand)); registry.register(Box::new(FocusActiveVolumesCommand)); registry.register(Box::new(SelectVolumesAtCameraCommand)); @@ -272,6 +275,18 @@ impl EditorCommand for FocusSelectionCommand { } } +struct ResetSelectionTransformCommand; + +impl EditorCommand for ResetSelectionTransformCommand { + fn name(&self) -> &str { + "selection.reset_transform" + } + + fn execute(&self, world: &mut World) { + reset_selected_transforms(world); + } +} + struct CreatePostProcessVolumeCommand; impl EditorCommand for CreatePostProcessVolumeCommand { @@ -322,6 +337,8 @@ fn command_palette_ui( if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) { palette.open = true; + palette.focus_filter = true; + palette.selected_index = 0; } if !palette.open { @@ -339,25 +356,71 @@ fn command_palette_ui( .color(TEXT_DIM) .small(), ); - ui.add( + let filter_response = ui.add( egui::TextEdit::singleline(&mut palette.filter) .hint_text("Type to filter...") .desired_width(f32::INFINITY), ); + if palette.focus_filter { + filter_response.request_focus(); + palette.focus_filter = false; + } + if filter_response.changed() { + palette.selected_index = 0; + } + + let filter = palette.filter.to_lowercase(); + let filtered_names: Vec = registry + .names() + .into_iter() + .filter(|name| filter.is_empty() || name.to_lowercase().contains(&filter)) + .collect(); + if !filtered_names.is_empty() { + palette.selected_index = palette.selected_index.min(filtered_names.len() - 1); + } else { + palette.selected_index = 0; + } + + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) + && !filtered_names.is_empty() + { + palette.selected_index = (palette.selected_index + 1) % filtered_names.len(); + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) + && !filtered_names.is_empty() + { + palette.selected_index = if palette.selected_index == 0 { + filtered_names.len() - 1 + } else { + palette.selected_index - 1 + }; + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + if let Some(name) = filtered_names.get(palette.selected_index) { + palette.pending_run = Some(name.clone()); + palette.open = false; + ui.close(); + } + } + if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + palette.open = false; + ui.close(); + } + ui.separator(); egui::ScrollArea::vertical() .max_height(280.0) .show(ui, |ui| { - for name in registry.names() { - if !palette.filter.is_empty() - && !name.to_lowercase().contains(&palette.filter.to_lowercase()) - { - continue; - } + for (index, name) in filtered_names.iter().enumerate() { + let selected = index == palette.selected_index; let response = - ui.selectable_label(false, egui::RichText::new(&name).color(TEXT)); + ui.selectable_label(selected, egui::RichText::new(name).color(TEXT)); + if selected { + response.scroll_to_me(Some(egui::Align::Center)); + } if response.clicked() { - palette.pending_run = Some(name); + palette.selected_index = index; + palette.pending_run = Some(name.clone()); palette.open = false; ui.close(); } diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index 6c6851c..c95d768 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -637,9 +637,42 @@ pub fn set_transform_with_history( if transform_nearly_eq(&old, &new) { return; } + if let Some(mut transform) = world.get_mut::(entity) { + *transform = new; + } push_history(world, EditorCommand::SetTransform { entity, old, new }); } +pub fn set_transform_group_with_history( + world: &mut World, + changes: impl IntoIterator, +) { + let mut entities = Vec::new(); + let mut olds = Vec::new(); + let mut news = Vec::new(); + for (entity, old, new) in changes { + if transform_nearly_eq(&old, &new) { + continue; + } + if let Some(mut transform) = world.get_mut::(entity) { + *transform = new; + entities.push(entity); + olds.push(old); + news.push(new); + } + } + if !entities.is_empty() { + push_history( + world, + EditorCommand::SetTransformGroup { + entities, + olds, + news, + }, + ); + } +} + pub fn set_actor_kind_with_history( world: &mut World, entity: Entity, @@ -1219,7 +1252,7 @@ fn capture_gizmo_transform_edits(world: &mut World) { let start_group = active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform)); - let finished_edit = { + let (finished_edit, live_edit) = { let mut tracker = world.resource_mut::(); if tracker.active.is_none() { if let (Some((entity, transform)), Some(group)) = (active, start_group) { @@ -1228,15 +1261,26 @@ fn capture_gizmo_transform_edits(world: &mut World) { } } match tracker.active.take() { - Some((primary, old, group)) if active.is_none() => Some((primary, old, group)), + Some((primary, old, group)) if active.is_none() => (Some((primary, old, group)), None), Some(state) if active.is_some() => { + let live = Some(state.clone()); tracker.active = Some(state); - None + (None, live) } - _ => None, + _ => (None, None), } }; + if let Some((primary, old_primary, group)) = live_edit { + let Some(new_primary) = world.get::(primary).copied() else { + return; + }; + if group.len() > 1 { + let changes = transform_group_changes(old_primary, new_primary, group); + apply_transform_group(world, &changes); + } + } + if let Some((primary, old_primary, group)) = finished_edit { let Some(new_primary) = world.get::(primary).copied() else { return; @@ -1245,34 +1289,36 @@ fn capture_gizmo_transform_edits(world: &mut World) { set_transform_with_history(world, primary, old_primary, new_primary); return; } - let translation_delta = new_primary.translation - old_primary.translation; - let rotation_delta = new_primary.rotation * old_primary.rotation.inverse(); - let scale_ratio = new_primary.scale / old_primary.scale; - let mut entities = Vec::new(); - let mut olds = Vec::new(); - let mut news = Vec::new(); - for (entity, old) in group { + let changes = transform_group_changes(old_primary, new_primary, group); + set_transform_group_with_history(world, changes); + } +} + +fn transform_group_changes( + old_primary: Transform, + new_primary: Transform, + group: Vec<(Entity, Transform)>, +) -> Vec<(Entity, Transform, Transform)> { + let translation_delta = new_primary.translation - old_primary.translation; + let rotation_delta = new_primary.rotation * old_primary.rotation.inverse(); + let scale_ratio = new_primary.scale / old_primary.scale; + group + .into_iter() + .map(|(entity, old)| { let new = Transform { translation: old.translation + translation_delta, rotation: rotation_delta * old.rotation, scale: old.scale * scale_ratio, }; - if let Some(mut transform) = world.get_mut::(entity) { - *transform = new; - } - entities.push(entity); - olds.push(old); - news.push(new); - } - if !entities.is_empty() { - push_history( - world, - EditorCommand::SetTransformGroup { - entities, - olds, - news, - }, - ); + (entity, old, new) + }) + .collect() +} + +fn apply_transform_group(world: &mut World, changes: &[(Entity, Transform, Transform)]) { + for (entity, _, new) in changes { + if let Some(mut transform) = world.get_mut::(*entity) { + *transform = *new; } } } diff --git a/crates/editor/src/ui/selection_ops.rs b/crates/editor/src/ui/selection_ops.rs index 4b3b395..408d4e9 100644 --- a/crates/editor/src/ui/selection_ops.rs +++ b/crates/editor/src/ui/selection_ops.rs @@ -5,8 +5,11 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use shared::LevelObject; use crate::camera::EditorCamera; -use crate::history::{delete_entities_with_history, duplicate_entities_with_history}; +use crate::history::{ + delete_entities_with_history, duplicate_entities_with_history, set_transform_group_with_history, +}; use crate::selection::SelectedEntity; +use crate::ui::UiState; pub fn focus_editor_camera_on_selection(world: &mut World) { let Some(entity) = world.resource::().0 else { @@ -58,3 +61,19 @@ pub fn duplicate_selection(world: &mut World, selected: &SelectedEntities) { let entities = selected_level_entities(world, selected); duplicate_entities_with_history(world, &entities); } + +pub fn reset_selected_transforms(world: &mut World) { + let changes: Vec<_> = world + .resource::() + .selected_entities + .iter() + .filter(|entity| is_level_object(world, *entity)) + .filter_map(|entity| { + world + .get::(entity) + .copied() + .map(|old| (entity, old, Transform::default())) + }) + .collect(); + set_transform_group_with_history(world, changes); +} diff --git a/docs/README.md b/docs/README.md index 3a3ea17..69e15ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [0017](adr/0017-normalized-static-mesh-assets.md) | Normalized static mesh assets and renderer placement | | [0018](adr/0018-componentized-actor-inspector-and-materials.md) | Componentized actor inspector, imported asset refs, collider split, material overrides | | [0019](adr/0019-local-bevy-render-timeout-patch.md) | Local `bevy_render` patch for transient Linux swapchain timeouts | +| [0020](adr/0020-jackdaw-inspired-editor-roadmap.md) | Jackdaw-inspired roadmap source policy and attribution requirements | ## Editor framework @@ -58,6 +59,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans | `rendering_unification_*.plan.md` | Unified rendering stack, Hybrid Auto GI, emissive materials | | `static_mesh_asset_refactor_*.plan.md` | Static mesh renderer, normalized model artifacts, inspector redesign | | `component_system_refactor_*.plan.md` | Component registry, imported asset refs, collider split, material overrides | +| `jackdaw_feature_roadmap_*.plan.md` | Jackdaw-inspired production roadmap; tracked in Gitea as `BS-JD-*` issues | ## Crate responsibilities (quick reference) diff --git a/docs/adr/0020-jackdaw-inspired-editor-roadmap.md b/docs/adr/0020-jackdaw-inspired-editor-roadmap.md new file mode 100644 index 0000000..2850a28 --- /dev/null +++ b/docs/adr/0020-jackdaw-inspired-editor-roadmap.md @@ -0,0 +1,49 @@ +Status: Accepted + +Context +======= + +Blacksite is evolving from a game-specific editor scaffold into a reusable +Bevy editor sandbox for future game work. The Jackdaw editor provides useful +public reference material for editor workflows such as brush geometry, material +browser organization, terrain tools, physics placement, project launchers, and +extension boundaries. + +The roadmap should use those references as product and workflow inspiration +without accidentally turning Blacksite into a fork, importing code without +attribution, or overriding Blacksite's existing authoring/hydration model. + +Decision +======== + +Treat Jackdaw as an inspiration source, not as an architecture or format +authority. Blacksite keeps its current foundations: Bevy DynamicScene RON, +schema stamping and migration, authoring-only validation, hydration into runtime +components, in-process PIE, and the unified viewport. + +Each Jackdaw-inspired ticket must identify whether the work is: + +- Design-inspired: adapted from public behavior, screenshots, docs, or workflow + descriptions. +- API-inspired: adapted from public API shape or concepts without copied code. +- Code-derived: containing copied or closely translated source code. + +Code-derived work requires a source URL, upstream commit or release reference, +compatible license confirmation, and preserved file-level notices when required +by the source license. Prefer clean-room implementation for geometry, terrain, +operators, and extension APIs because Blacksite's scene and runtime ownership +model differs from Jackdaw's. + +Consequences +============ + +The Jackdaw roadmap can be used for planning and prioritization while keeping +Blacksite's architecture coherent and auditable. + +M0-M5 planning details live in `.cursor/plans/` until implemented. Durable +decisions move into `docs/adr/` or `docs/editor/`; user-visible workflows move +into the root README. The Gitea issue tracker is the production task board, but +docs remain the long-term source for accepted policy and architecture. + +Reviewers should reject Jackdaw-inspired changes that do not declare their +inspiration level or that copy source without license and source attribution. diff --git a/docs/editor/README.md b/docs/editor/README.md index 57afbc2..59b9d05 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -34,6 +34,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a ## Design intentions (stable) - **EditorOnly** helper entities never appear in hierarchy or saved scenes. Visualizer proxies are editor-only but selectable; selection resolves to their source entity. +- **Jackdaw-inspired roadmap work** follows ADR 0020: use Jackdaw as design/API inspiration only unless a ticket explicitly marks code-derived work with source, license, and upstream commit notes. Clean-room implementation is preferred for geometry, terrain, operator, and extension systems. - **Dedicated egui `Camera2d`** at full window — never attach `PrimaryEguiContext` to a viewport-cropped 3D camera (NaN layout panic). - **Unified viewport** uses one render-to-texture target for both the editor fly camera and the possessed player camera so HDR/atmosphere is not broken by sub-viewport cropping. - **PIE:** F8 possess/eject while sim runs; **F6** pauses/resumes simulation in Play; project settings drive shared rendering for the active viewport camera. @@ -48,7 +49,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live. - **Rendering** — `GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD. - **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and a pinned bottom Add Component footer (`actor_inspector`); only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/material selectors, visibility, shadow flags, and per-actor material overrides. Empty actor material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`. -- **Command palette** (`Ctrl+P`): `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, and play commands. +- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands. - **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`. - **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders. - **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.