#![windows_subsystem = "windows"]
extern crate alloc;
mod app_context;
mod asset_loading;
mod auxiliary;
mod dev_tools;
mod drag_drop;
mod engine_editor;
mod messages;
mod mosaic;
mod panels;
mod popups;
mod project_io;
mod render_graph;
#[cfg(not(target_arch = "wasm32"))]
mod settings;
#[cfg(not(target_arch = "wasm32"))]
mod themes;
#[cfg(not(target_arch = "wasm32"))]
mod webview;
mod widgets;
mod windows;
use app_context::AppContext;
use engine_editor::{
ComponentInspectorUi, TreeCache, ensure_bounding_volumes, gizmo, input, picking,
};
use messages::EditorMessage;
use mosaic::Mosaic;
use nightshade::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use themes::{nightshade_dark_preset, nightshade_light_preset};
use widgets::EditorWidget;
type EditorMosaic = crate::mosaic::Mosaic<EditorWidget, AppContext, EditorMessage>;
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
enum PendingMcpUndo {
#[default]
None,
Transform {
entity: Entity,
old_transform: LocalTransform,
},
Hierarchy {
entity: Entity,
hierarchy: Box<crate::engine_editor::undo::HierarchyNode>,
},
BulkHierarchies {
entities: Vec<Entity>,
hierarchies: Vec<crate::engine_editor::undo::HierarchyNode>,
},
}
const DEFAULT_IBL_HDR: &[u8] = include_bytes!("../assets/moonrise.hdr");
fn main() -> Result<(), Box<dyn std::error::Error>> {
launch(Editor::default())?;
Ok(())
}
#[derive(Default)]
struct Editor {
context: AppContext,
mosaic: EditorMosaic,
inspector_ui: ComponentInspectorUi,
tree_cache: TreeCache,
#[cfg(not(target_arch = "wasm32"))]
toasts: crate::mosaic::Toasts,
#[cfg(not(target_arch = "wasm32"))]
settings: crate::mosaic::Settings<settings::EditorSettingsData>,
project: Option<project_io::EditorProjectFile>,
project_state: project_io::ProjectState,
theme_state: crate::mosaic::ThemeState,
#[cfg(not(target_arch = "wasm32"))]
webview_state: webview::WebviewState,
scene_browser_dirty: bool,
#[cfg(not(target_arch = "wasm32"))]
pending_mcp_undo: PendingMcpUndo,
}
impl State for Editor {
fn title(&self) -> &str {
"Nightshade Editor"
}
fn initialize(&mut self, world: &mut World) {
world.resources.user_interface.enabled = true;
world.resources.graphics.atmosphere = Atmosphere::Sky;
world.resources.graphics.selection_outline_enabled = true;
load_hdr_skybox(world, DEFAULT_IBL_HDR.to_vec());
self.context.ui.visibility.left_panel = true;
self.context.ui.visibility.right_panel = true;
let focus = Vec3::zeros();
let radius = 10.0;
let yaw = 0.5;
let pitch = 0.4;
let main_camera = nightshade::ecs::camera::spawn_pan_orbit_camera(
world,
focus,
radius,
yaw,
pitch,
"Main Camera".to_string(),
);
world.resources.active_camera = Some(main_camera);
spawn_sun(world);
self.mosaic = Mosaic::with_tree(crate::project_io::create_default_tile_tree());
#[cfg(target_arch = "wasm32")]
{
world.resources.graphics.show_grid = true;
}
#[cfg(not(target_arch = "wasm32"))]
{
self.settings = crate::mosaic::Settings::load("nightshade-editor");
world.resources.graphics.show_grid = self.settings.data.show_grid;
self.theme_state
.presets
.insert(0, nightshade_light_preset());
self.theme_state.presets.insert(0, nightshade_dark_preset());
self.theme_state.current_config = self.theme_state.presets[0].clone();
self.theme_state.selected_preset_index = Some(0);
if let Some(theme_name) = &self.settings.data.theme_name
&& let Some((index, preset)) = self
.theme_state
.presets
.iter()
.enumerate()
.find(|(_, preset)| &preset.name == theme_name)
{
self.theme_state.current_config = preset.clone();
self.theme_state.selected_preset_index = Some(index);
}
if let Err(error) = self.settings.save() {
tracing::error!("Failed to save initial settings: {error}");
}
if let Some(startup_project) = self.settings.data.startup_project.clone() {
let path = std::path::PathBuf::from(&startup_project);
if path.exists() {
self.load_project_from_path(world, &path);
} else {
tracing::warn!("Startup project does not exist: {}", startup_project);
}
}
}
}
fn ui(&mut self, world: &mut World, ui_context: &egui::Context) {
self.process_pending_project_load(world);
crate::mosaic::apply_theme(ui_context, &self.theme_state);
self.theme_state.preview_theme_index = None;
#[cfg(not(target_arch = "wasm32"))]
self.update_window_title(world);
let mut root_ui = egui::Ui::new(
ui_context.clone(),
egui::Id::new("editor_root_ui"),
egui::UiBuilder::new()
.layer_id(egui::LayerId::background())
.max_rect(ui_context.content_rect()),
);
root_ui.set_clip_rect(ui_context.content_rect());
self.top_panel_ui(world, &mut root_ui);
if self.context.ui.visibility.left_panel {
self.left_panel_ui(world, &mut root_ui);
}
if self.context.ui.visibility.right_panel {
self.right_panel_ui(world, &mut root_ui);
}
self.render_mosaic_and_scene_browser(world, &mut root_ui);
self.process_mosaic_messages(world);
#[cfg(not(target_arch = "wasm32"))]
self.update_webviews(world);
self.handle_popups(world, ui_context);
self.render_quit_confirmation(world, ui_context);
self.render_auxiliary_windows(world, ui_context);
self.render_overlays(world, ui_context);
}
fn run_systems(&mut self, world: &mut World) {
nightshade::ecs::text::systems::sync_text_meshes_system(world);
nightshade::ecs::navmesh::debug::navmesh_debug_draw_system(world);
nightshade::ecs::physics::physics_debug_draw_system(world);
update_animation_players(world);
apply_animations(world);
nightshade::ecs::transform::systems::run_systems(world);
nightshade::ecs::script::run_scripts_system(world);
world.resources.user_interface.hud_wants_pointer =
self.context.editor.gizmo_interaction.hover_axis.is_some()
|| self.context.editor.gizmo_interaction.is_dragging();
match self.context.editor.camera_mode {
engine_editor::context::CameraMode::Orbit => {
nightshade::ecs::camera::pan_orbit_camera_system(world);
}
engine_editor::context::CameraMode::Fly => {
nightshade::ecs::camera::fly_camera_system(world);
}
}
ensure_bounding_volumes(world);
if gizmo::update_modal_transform(&mut self.context.editor, world) {
self.project_state.mark_modified();
}
picking::update_marquee_selection(&mut self.context.editor, world);
picking::update_picking(&mut self.context.editor, world);
if let Some(position) = picking::check_context_menu_trigger(&self.context.editor, world) {
self.context.ui.popup.entity_context_menu = Some(position);
}
if gizmo::update_gizmo(&mut self.context.editor, world) {
self.project_state.mark_modified();
}
world.resources.graphics.bounding_volume_selected_entity =
self.context.editor.selection.primary();
world.resources.graphics.show_selected_bounding_volume = false;
if self.context.ui.tree_dirty {
self.tree_cache.mark_dirty();
self.context.ui.tree_dirty = false;
}
}
fn update_render_graph(&mut self, graph: &mut RenderGraph<World>, _world: &World) {
let _ = graph.set_pass_enabled("compute_grayscale", self.context.assets.grayscale_enabled);
}
fn on_keyboard_input(&mut self, world: &mut World, key_code: KeyCode, key_state: KeyState) {
let result =
input::on_keyboard_input_handler(&mut self.context.editor, world, key_code, key_state);
self.context.ui.tree_dirty |= result.tree_dirty;
if result.project_modified {
self.project_state.mark_modified();
}
match result.signal {
Some(input::InputSignal::QuitRequested) => {
self.context.ui.popup.quit_confirmation = true;
}
Some(input::InputSignal::AddPrimitiveRequested(position)) => {
self.context.ui.popup.add_primitive = Some(position);
}
None => {}
}
}
fn configure_render_graph(
&mut self,
graph: &mut RenderGraph<World>,
device: &wgpu::Device,
surface_format: wgpu::TextureFormat,
resources: RenderResources,
) {
render_graph::configure_editor_render_graph(graph, device, surface_format, resources);
}
fn on_dropped_file(&mut self, world: &mut World, path: &std::path::Path) {
self.handle_dropped_file(world, path);
}
fn on_hovered_file(&mut self, _world: &mut World, path: &std::path::Path) {
self.handle_hovered_file(path);
}
fn on_hovered_file_cancelled(&mut self, _world: &mut World) {
self.handle_hovered_file_cancelled();
}
fn on_dropped_file_data(&mut self, world: &mut World, name: &str, data: &[u8]) {
self.handle_dropped_file_data(world, name, data);
}
#[cfg(not(target_arch = "wasm32"))]
fn handle_mcp_command(
&mut self,
world: &mut World,
command: &nightshade::mcp::McpCommand,
) -> Option<nightshade::mcp::McpResponse> {
use crate::engine_editor::undo::capture_hierarchy;
use nightshade::mcp::McpCommand;
let capture_transform = |name: &str| -> PendingMcpUndo {
if let Some(&entity) = world.resources.entity_names.get(name) {
if let Some(&old_transform) = world.core.get_local_transform(entity) {
PendingMcpUndo::Transform {
entity,
old_transform,
}
} else {
PendingMcpUndo::None
}
} else {
PendingMcpUndo::None
}
};
self.pending_mcp_undo = match command {
McpCommand::SetPosition(request) => capture_transform(&request.name),
McpCommand::SetRotation(request) => capture_transform(&request.name),
McpCommand::SetScale(request) => capture_transform(&request.name),
McpCommand::DespawnEntity(request) => {
if let Some(&entity) = world.resources.entity_names.get(&request.name) {
let hierarchy = capture_hierarchy(world, entity);
PendingMcpUndo::Hierarchy {
entity,
hierarchy: Box::new(hierarchy),
}
} else {
PendingMcpUndo::None
}
}
McpCommand::ClearScene(_) => {
let entities: Vec<Entity> =
world.resources.entity_names.values().copied().collect();
let hierarchies: Vec<_> = entities
.iter()
.map(|&entity| capture_hierarchy(world, entity))
.collect();
PendingMcpUndo::BulkHierarchies {
entities,
hierarchies,
}
}
_ => PendingMcpUndo::None,
};
Option::None
}
#[cfg(not(target_arch = "wasm32"))]
fn after_mcp_command(
&mut self,
world: &mut World,
command: &nightshade::mcp::McpCommand,
response: &nightshade::mcp::McpResponse,
) {
use crate::engine_editor::undo::{UndoableOperation, capture_hierarchy};
use nightshade::mcp::{McpCommand, McpResponse};
let is_success = matches!(response, McpResponse::Success(_));
let spawn_name = match command {
McpCommand::SpawnEntity(request) => Some(request.name.as_str()),
McpCommand::SpawnPrefab(request) => Some(request.entity_name.as_str()),
McpCommand::SpawnLight(request) => Some(request.name.as_str()),
McpCommand::SpawnWater(request) => Some(request.name.as_str()),
McpCommand::Spawn3dText(request) => Some(request.name.as_str()),
McpCommand::SpawnDecal(request) => Some(request.name.as_str()),
McpCommand::SpawnParticles(request) => Some(request.name.as_str()),
_ => None,
};
let transform_name = match command {
McpCommand::SetPosition(request) => Some(request.name.as_str()),
McpCommand::SetRotation(request) => Some(request.name.as_str()),
McpCommand::SetScale(request) => Some(request.name.as_str()),
_ => None,
};
if let Some(name) = spawn_name {
if is_success && let Some(&entity) = world.resources.entity_names.get(name) {
let hierarchy = capture_hierarchy(world, entity);
self.context.editor.undo_history.push(
UndoableOperation::EntityCreated {
hierarchy: Box::new(hierarchy),
current_entity: entity,
},
format!("MCP: Spawn {}", name),
);
}
self.context.ui.tree_dirty = true;
} else if let Some(name) = transform_name {
if is_success {
let pending = std::mem::replace(&mut self.pending_mcp_undo, PendingMcpUndo::None);
if let PendingMcpUndo::Transform {
entity,
old_transform,
} = pending
&& let Some(&new_transform) = world.core.get_local_transform(entity)
&& old_transform != new_transform
{
self.context.editor.undo_history.push(
UndoableOperation::TransformChanged {
entity,
old_transform,
new_transform,
},
format!("MCP: Transform {}", name),
);
}
}
} else {
match command {
McpCommand::DespawnEntity(_) => {
if is_success {
let pending =
std::mem::replace(&mut self.pending_mcp_undo, PendingMcpUndo::None);
if let PendingMcpUndo::Hierarchy { entity, hierarchy } = pending {
self.context.editor.undo_history.push(
UndoableOperation::EntityDeleted {
hierarchy,
deleted_entity: entity,
},
"MCP: Despawn entity",
);
}
}
self.context.ui.tree_dirty = true;
}
McpCommand::SetParent(_) => {
self.context.ui.tree_dirty = true;
}
McpCommand::ClearScene(_) => {
if is_success {
let pending =
std::mem::replace(&mut self.pending_mcp_undo, PendingMcpUndo::None);
if let PendingMcpUndo::BulkHierarchies {
entities,
hierarchies,
} = pending
{
self.context.editor.undo_history.push(
UndoableOperation::BulkEntitiesDeleted {
hierarchies,
deleted_entities: entities,
},
"MCP: Clear scene",
);
}
}
self.context.ui.tree_dirty = true;
}
McpCommand::Batch(_) | McpCommand::RunCommands(_) => {
self.context.ui.tree_dirty = true;
}
_ => {}
}
}
}
}