From 5d04e1fbdd989e31e59f34fff86f5473e5a98351 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sun, 19 Jul 2026 12:01:28 +0200 Subject: [PATCH 1/3] editor | Integrate Atlas script workflow --- cli/src/lib.rs | 2 + cli/src/main.rs | 6 +- cli/src/script.rs | 67 +++++++++++-------- editor/application/toolchainInstaller.cpp | 52 ++++++++++++++ editor/project/projectStore.cpp | 13 ++++ editor/views/editor/editor.cpp | 45 ++++++++++--- editor/views/editor/viewport.cpp | 25 ++++++- editor/views/general/contentBrowser.cpp | 25 +++++-- .../editor/application/toolchainInstaller.h | 6 ++ include/editor/views/viewport.h | 1 + 10 files changed, 194 insertions(+), 48 deletions(-) diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 1fadced1..3f44b4b9 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -59,6 +59,8 @@ pub enum ScriptCommands { Compile, New { path: String, + #[arg(long)] + component_name: Option, }, } diff --git a/cli/src/main.rs b/cli/src/main.rs index d0983023..132e4d80 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -22,6 +22,10 @@ fn main() { } Commands::Run { .. } => run::run(cli.command), Commands::Clangd { .. } => pack::clangd(cli.command), - Commands::Script { .. } => script::script(cli.command), + Commands::Script { .. } => { + if !script::script(cli.command) { + std::process::exit(1); + } + } } } diff --git a/cli/src/script.rs b/cli/src/script.rs index 2803f89d..36019987 100644 --- a/cli/src/script.rs +++ b/cli/src/script.rs @@ -598,7 +598,7 @@ fn script_template(component_name: &str) -> String { ) } -fn init(branch: String) { +fn init(branch: String) -> bool { let project_dir = match std::env::current_dir() { Ok(dir) => dir, Err(e) => { @@ -606,31 +606,31 @@ fn init(branch: String) { "{} {e}", "Failed to resolve current directory:".red().bold() ); - return; + return false; } }; if let Err(e) = ensure_directory(&project_dir.join("assets/scripts")) { eprintln!("{} {e}", "atlas script init failed:".red().bold()); - return; + return false; } if let Err(e) = ensure_directory(&project_dir.join("lib")) { eprintln!("{} {e}", "atlas script init failed:".red().bold()); - return; + return false; } if let Err(e) = ensure_directory(&project_dir.join("dist")) { eprintln!("{} {e}", "atlas script init failed:".red().bold()); - return; + return false; } if let Err(e) = update_package_json(&project_dir) { eprintln!("{} {e}", "atlas script init failed:".red().bold()); - return; + return false; } if let Err(e) = update_tsconfig(&project_dir) { eprintln!("{} {e}", "atlas script init failed:".red().bold()); - return; + return false; } let types_path = project_dir.join("lib/atlas.d.ts"); @@ -690,9 +690,10 @@ fn init(branch: String) { .to_string() .bold() ); + true } -fn compile() { +fn compile() -> bool { let project_dir = match std::env::current_dir() { Ok(dir) => dir, Err(e) => { @@ -700,26 +701,26 @@ fn compile() { "{} {e}", "Failed to resolve current directory:".red().bold() ); - return; + return false; } }; let mut entry_points = Vec::new(); if let Err(e) = collect_typescript_entries(&project_dir, &project_dir, &mut entry_points) { eprintln!("{} {e}", "atlas script compile failed:".red().bold()); - return; + return false; } entry_points.sort(); if entry_points.is_empty() { eprintln!("{}", "No TypeScript entry files were found".yellow().bold()); - return; + return true; } if let Err(e) = ensure_directory(&project_dir.join("dist")) { eprintln!("{} {e}", "atlas script compile failed:".red().bold()); - return; + return false; } match run_esbuild(&project_dir, &entry_points) { @@ -735,14 +736,16 @@ fn compile() { .to_string() .bold() ); + true } Err(e) => { eprintln!("{}\n{e}", "atlas script compile failed".red().bold()); + false } } } -fn new_script(path: String) { +fn new_script(path: String, requested_component_name: Option) -> bool { let project_dir = match std::env::current_dir() { Ok(dir) => dir, Err(e) => { @@ -750,23 +753,25 @@ fn new_script(path: String) { "{} {e}", "Failed to resolve current directory:".red().bold() ); - return; + return false; } }; let script_path = normalize_script_path(&path, &project_dir); let default_name = infer_component_name(&script_path); - let theme = ColorfulTheme::default(); - let component_name: String = Input::with_theme(&theme) - .with_prompt("Component Name") - .with_initial_text(default_name.clone()) - .interact_text() - .unwrap_or(default_name); + let component_name = requested_component_name.unwrap_or_else(|| { + let theme = ColorfulTheme::default(); + Input::with_theme(&theme) + .with_prompt("Component Name") + .with_initial_text(default_name.clone()) + .interact_text() + .unwrap_or(default_name) + }); let component_name = component_name.trim().to_string(); if component_name.is_empty() { eprintln!("{}", "Component name cannot be empty".red().bold()); - return; + return false; } if script_path.exists() { @@ -775,26 +780,26 @@ fn new_script(path: String) { "Script already exists:".red().bold(), script_path.display() ); - return; + return false; } if let Some(parent) = script_path.parent() { if let Err(e) = ensure_directory(parent) { eprintln!("{} {e}", "atlas script new failed:".red().bold()); - return; + return false; } } if let Err(e) = fs::write(&script_path, script_template(&component_name)) { eprintln!("{} {e}", "atlas script new failed:".red().bold()); - return; + return false; } match find_manifest_file(&project_dir) { Ok(Some(manifest_path)) => { if let Err(e) = update_script_manifest(&manifest_path, &component_name, &script_path) { eprintln!("{} {e}", "Failed to update .atlas manifest:".red().bold()); - return; + return false; } println!( "{} {}", @@ -812,7 +817,7 @@ fn new_script(path: String) { } Err(e) => { eprintln!("{} {e}", "Failed to locate .atlas manifest:".red().bold()); - return; + return false; } } @@ -821,14 +826,20 @@ fn new_script(path: String) { "Created script:".green().bold(), script_path.display() ); + true } -pub fn script(cmd: Commands) { +pub fn script(cmd: Commands) -> bool { if let Commands::Script { command } = cmd { match command { ScriptCommands::Init { branch } => init(branch), ScriptCommands::Compile => compile(), - ScriptCommands::New { path } => new_script(path), + ScriptCommands::New { + path, + component_name, + } => new_script(path, component_name), } + } else { + false } } diff --git a/editor/application/toolchainInstaller.cpp b/editor/application/toolchainInstaller.cpp index 78b84ea1..7239503a 100644 --- a/editor/application/toolchainInstaller.cpp +++ b/editor/application/toolchainInstaller.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -233,3 +234,54 @@ bool ToolchainInstaller::install(QWidget* parent) { } return installToolchain(toolchain, parent, true); } + +QString ToolchainInstaller::executablePath() { + const ToolchainPaths toolchain = paths(); + const QDir applicationDirectory(QCoreApplication::applicationDirPath()); + const QStringList candidates{ + toolchain.bundledCli, + toolchain.installedCli, + applicationDirectory.filePath("../../target/debug/atlas"), + applicationDirectory.filePath("../../target/release/atlas"), + applicationDirectory.filePath("atlas")}; + for (const QString& candidate : candidates) { + const QFileInfo info(candidate); + if (info.isFile() && info.isExecutable()) + return info.absoluteFilePath(); + } + return QStandardPaths::findExecutable("atlas"); +} + +bool ToolchainInstaller::run(const QStringList& arguments, + const QString& workingDirectory, + QString* errorMessage) { + const QString executable = executablePath(); + if (executable.isEmpty()) { + if (errorMessage != nullptr) + *errorMessage = "Atlas CLI was not found. Install the Atlas toolchain from the Tools menu."; + return false; + } + + QProcess process; + process.setWorkingDirectory(workingDirectory); + process.start(executable, arguments); + process.closeWriteChannel(); + if (!process.waitForStarted()) { + if (errorMessage != nullptr) + *errorMessage = process.errorString(); + return false; + } + process.waitForFinished(-1); + if (process.exitStatus() == QProcess::NormalExit && + process.exitCode() == 0) { + return true; + } + + if (errorMessage != nullptr) { + QString output = QString::fromUtf8(process.readAllStandardError()).trimmed(); + if (output.isEmpty()) + output = QString::fromUtf8(process.readAllStandardOutput()).trimmed(); + *errorMessage = output.isEmpty() ? process.errorString() : output; + } + return false; +} diff --git a/editor/project/projectStore.cpp b/editor/project/projectStore.cpp index c3b4c3eb..366d7bbe 100644 --- a/editor/project/projectStore.cpp +++ b/editor/project/projectStore.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -260,6 +261,18 @@ QString ProjectStore::createProject(const QString& name, return QString(); } + QString initializationError; + if (!ToolchainInstaller::run({"script", "init"}, projectDirectory, + &initializationError)) { + QDir(projectDirectory).removeRecursively(); + if (errorMessage != nullptr) { + *errorMessage = initializationError.isEmpty() + ? "Atlas could not initialize project scripting." + : initializationError; + } + return QString(); + } + addRecentProject(projectFile); return normalizedProjectPath(projectFile); } diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 871577db..51f21f6a 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -1576,19 +1576,46 @@ void EditorWindow::runProjectCommand(bool buildOnly) { QDir().mkpath(settingsDirectory); QSettings settings(QDir(settingsDirectory).filePath("project-settings.ini"), QSettings::IniFormat); - const QString command = - settings.value(buildOnly ? "project/buildCommand" - : "project/runCommand", - buildOnly ? "atlas pack --backend METAL" - : "atlas run project.atlas") - .toString() - .trimmed(); + const QString settingsKey = buildOnly ? "project/buildCommand" + : "project/runCommand"; + const QString defaultCommand = buildOnly ? "atlas pack --backend METAL" + : "atlas run project.atlas"; + const QString command = settings.value(settingsKey, defaultCommand) + .toString() + .trimmed(); if (command.isEmpty()) return; if (viewportPanel != nullptr) viewportPanel->saveRuntimeScene(); - QProcess::startDetached("/bin/zsh", {"-lc", command}, - QFileInfo(projectFile).absolutePath()); + if (!buildOnly) { + QString error; + if (!ToolchainInstaller::run( + {"script", "compile"}, + QFileInfo(projectFile).absolutePath(), &error)) { + QMessageBox::warning( + this, "Script Compilation Failed", + error.isEmpty() + ? "Atlas could not compile the project scripts." + : error); + return; + } + } + const QString workingDirectory = QFileInfo(projectFile).absolutePath(); + if (!settings.contains(settingsKey) || command == defaultCommand) { + const QString executable = ToolchainInstaller::executablePath(); + if (executable.isEmpty()) { + QMessageBox::warning( + this, buildOnly ? "Build Project" : "Run Project", + "Atlas CLI was not found. Install the Atlas toolchain from the Tools menu."); + return; + } + const QStringList arguments = buildOnly + ? QStringList{"pack", "--backend", "METAL"} + : QStringList{"run", "project.atlas"}; + QProcess::startDetached(executable, arguments, workingDirectory); + return; + } + QProcess::startDetached("/bin/zsh", {"-lc", command}, workingDirectory); } void EditorWindow::takeViewportScreenshot() { diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp index 9163a3ed..53647c50 100644 --- a/editor/views/editor/viewport.cpp +++ b/editor/views/editor/viewport.cpp @@ -8,6 +8,7 @@ */ #include +#include #include #include @@ -29,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -613,16 +615,24 @@ void ViewportPanel::startRuntime() { frameTimer->start(16); emit sceneOpened(currentRuntimeScene()); emit runtimeStartupFinished(true, {}); + if (playAfterRuntimeStart) { + playAfterRuntimeStart = false; + runtimeContext->setEditorSimulationEnabled(true); + playbackState = 1; + emit playbackStateChanged(playbackState); + } } catch (const std::exception &error) { qWarning().noquote() << QStringLiteral("Failed to start Atlas viewport runtime: %1") .arg(QString::fromUtf8(error.what())); runtimeContext.reset(); + playAfterRuntimeStart = false; emit runtimeStartupFinished(false, QString::fromUtf8(error.what())); } catch (...) { qWarning() << "Failed to start Atlas viewport runtime"; runtimeContext.reset(); + playAfterRuntimeStart = false; emit runtimeStartupFinished(false, "Runtime initialization failed"); } #else @@ -1199,9 +1209,18 @@ void ViewportPanel::playRuntime() { qWarning() << "Atlas editor could not checkpoint the scene for play"; return; } - runtimeContext->setEditorSimulationEnabled(true); - playbackState = 1; - emit playbackStateChanged(playbackState); + QString error; + if (!ToolchainInstaller::run( + {"script", "compile"}, QFileInfo(projectFile).absolutePath(), + &error)) { + QMessageBox::warning( + this, "Script Compilation Failed", + error.isEmpty() ? "Atlas could not compile the project scripts." + : error); + return; + } + playAfterRuntimeStart = true; + reloadRuntime(); } void ViewportPanel::toggleRuntimePlayback() { diff --git a/editor/views/general/contentBrowser.cpp b/editor/views/general/contentBrowser.cpp index 85a1bef9..fbbe96ea 100644 --- a/editor/views/general/contentBrowser.cpp +++ b/editor/views/general/contentBrowser.cpp @@ -8,6 +8,7 @@ */ #include "editor/views/fileExplorer.h" +#include "editor/application/toolchainInstaller.h" #include "editor/styling/icons.h" #include @@ -29,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -480,14 +482,23 @@ void ContentBrowserPanel::createScene() { void ContentBrowserPanel::createScript() { const QString path = uniquePath("NewScript.ts"); - const QByteArray script = "import { Component } from \"atlas\";\n\n" - "export class NewScript extends Component {\n" - " init() {}\n\n" - " update(deltaTime: number) {}\n" - "}\n"; - if (writeNewFile(path, script)) { - gridView->setCurrentIndex(model->index(path)); + const QString relativePath = QDir(projectRoot).relativeFilePath(path); + QString componentName = QFileInfo(path).completeBaseName(); + componentName.remove(QRegularExpression("[^A-Za-z0-9_$]")); + if (componentName.isEmpty()) + componentName = "NewScript"; + if (componentName.front().isDigit()) + componentName.prepend("Script"); + QString error; + if (!ToolchainInstaller::run( + {"script", "new", relativePath, "--component-name", componentName}, + projectRoot, &error)) { + QMessageBox::warning( + this, "New Script", + error.isEmpty() ? "Atlas could not create the script." : error); + return; } + gridView->setCurrentIndex(model->index(path)); } void ContentBrowserPanel::createMaterial() { diff --git a/include/editor/application/toolchainInstaller.h b/include/editor/application/toolchainInstaller.h index 6e81b899..8fad2620 100644 --- a/include/editor/application/toolchainInstaller.h +++ b/include/editor/application/toolchainInstaller.h @@ -1,11 +1,17 @@ #ifndef ATLAS_TOOLCHAININSTALLER_H #define ATLAS_TOOLCHAININSTALLER_H +#include +#include + class QWidget; namespace ToolchainInstaller { bool ensureInstalled(QWidget* parent = nullptr); bool install(QWidget* parent = nullptr); +QString executablePath(); +bool run(const QStringList& arguments, const QString& workingDirectory, + QString* errorMessage = nullptr); } #endif diff --git a/include/editor/views/viewport.h b/include/editor/views/viewport.h index c0935f8c..0bf47aa3 100644 --- a/include/editor/views/viewport.h +++ b/include/editor/views/viewport.h @@ -162,6 +162,7 @@ class ViewportPanel : public QWidget { bool runtimeStartupEnabled = false; bool shuttingDown = false; bool sceneDirty = false; + bool playAfterRuntimeStart = false; bool leftPointerMoved = false; bool keyboardTransformActive = false; int keyboardTransformMode = 0; From 6c5e854104701b88dc1f4a03b71fae45c9a279d8 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sun, 19 Jul 2026 12:01:28 +0200 Subject: [PATCH 2/3] Added input actions and further fixes to the editor --- docs/pages/editor.md | 2 +- editor/styling/dark.qss | 12 +- editor/views/editor/editor.cpp | 9 + editor/views/editor/hierarchy.cpp | 20 +- editor/views/editor/inspector.cpp | 29 +- editor/views/editor/viewport.cpp | 7 +- editor/views/editor/viewportTools.cpp | 2 +- editor/views/general/inputActionsDialog.cpp | 834 ++++++++++++++++++++ include/atlas/runtime/context.h | 1 + include/editor/core/themes.h | 12 +- include/editor/views/editorWindow.h | 1 + include/editor/views/inputActionsDialog.h | 111 +++ runtime/lib/context.cpp | 5 + 13 files changed, 1029 insertions(+), 16 deletions(-) create mode 100644 editor/views/general/inputActionsDialog.cpp create mode 100644 include/editor/views/inputActionsDialog.h diff --git a/docs/pages/editor.md b/docs/pages/editor.md index 5b00545d..43dd4913 100644 --- a/docs/pages/editor.md +++ b/docs/pages/editor.md @@ -25,7 +25,7 @@ Scenes open as tabs above the viewport. Creating a scene uses the Atlas scene di | Redo | Command Shift Z | | Cut, copy, paste | Command X, Command C, Command V | | Duplicate selection | Command D | -| Delete selection | Backspace | +| Delete object selection | Backspace | | Select all | Command A | | Move, rotate, scale | G, R, S | | Reset position | Command Option G | diff --git a/editor/styling/dark.qss b/editor/styling/dark.qss index e45929f7..8b8b586b 100644 --- a/editor/styling/dark.qss +++ b/editor/styling/dark.qss @@ -650,13 +650,19 @@ QTreeView#hierarchyTree { } QTreeView#hierarchyTree::item { - min-height: 24px; - padding: 3px 5px; + min-height: 26px; + padding: 3px 7px; +} + +QTreeView#hierarchyTree::item:hover { + background-color: #2B2E31; + border-color: #43474B; } QTreeView#hierarchyTree::item:selected { background-color: #343C43; - border-left: 2px solid #8498A8; + border-color: #66737C; + color: #FFFFFF; } QListView#contentGrid { diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 51f21f6a..f90a3971 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -78,6 +78,7 @@ #include "editor/views/fileExplorer.h" #include "editor/views/hierarchyPanel.h" #include "editor/views/inspectorView.h" +#include "editor/views/inputActionsDialog.h" #include "editor/views/materialEditor.h" #include "editor/views/postProcessing.h" #include "editor/views/viewport.h" @@ -550,6 +551,8 @@ void EditorWindow::setupMenus() { addCommand(toolsMenu, "Project Settings…", QString(), [this] { showProjectSettings(); }); toolsSettings->setMenuRole(QAction::NoRole); + addCommand(toolsMenu, "Input Actions…", QString(), + [this] { showInputActions(); }); addCommand(toolsMenu, "Install Atlas Toolchain…", QString(), [this] { ToolchainInstaller::install(this); }); addCommand(toolsMenu, "Command Palette…", "Meta+Shift+P", @@ -571,6 +574,12 @@ void EditorWindow::setupMenus() { aboutAction->setMenuRole(QAction::AboutRole); } +void EditorWindow::showInputActions() { + InputActionsDialog dialog(projectFile, this); + if (dialog.exec() == QDialog::Accepted && viewportPanel != nullptr) + viewportPanel->reloadRuntime(); +} + void EditorWindow::setupDocks() { viewportPanel = new ViewportPanel(projectFile); connect(viewportPanel, &ViewportPanel::sceneDirtyChanged, this, diff --git a/editor/views/editor/hierarchy.cpp b/editor/views/editor/hierarchy.cpp index 27c08682..3c30f205 100644 --- a/editor/views/editor/hierarchy.cpp +++ b/editor/views/editor/hierarchy.cpp @@ -124,6 +124,9 @@ HierarchyPanel::HierarchyPanel(ViewportPanel *viewport, QWidget *parent) treeView->setAnimated(true); treeView->setEditTriggers(QAbstractItemView::NoEditTriggers); treeView->setSelectionMode(QAbstractItemView::ExtendedSelection); + treeView->setSelectionBehavior(QAbstractItemView::SelectRows); + treeView->setIndentation(16); + treeView->setIconSize(QSize(18, 18)); treeView->setContextMenuPolicy(Qt::CustomContextMenu); treeView->setUniformRowHeights(true); treeView->setAcceptDrops(true); @@ -182,8 +185,10 @@ HierarchyPanel::HierarchyPanel(ViewportPanel *viewport, QWidget *parent) }); moreButton->setMenu(moreMenu); - connect(treeView, &QTreeView::clicked, this, - [this](const QModelIndex &) { focusSelectedObject(); }); + connect(treeView->selectionModel(), &QItemSelectionModel::currentChanged, + this, [this](const QModelIndex &, const QModelIndex &) { + focusSelectedObject(); + }); connect(treeView, &QTreeView::doubleClicked, this, [this](const QModelIndex &) { renameSelectedObject(); }); connect(treeView, &QTreeView::customContextMenuRequested, this, @@ -214,6 +219,13 @@ HierarchyPanel::HierarchyPanel(ViewportPanel *viewport, QWidget *parent) &HierarchyPanel::deleteSelectedObject); addAction(deleteAction); + auto *deleteWithXAction = new QAction(treeView); + deleteWithXAction->setShortcut(QKeySequence(Qt::Key_X)); + deleteWithXAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(deleteWithXAction, &QAction::triggered, this, + &HierarchyPanel::deleteSelectedObject); + treeView->addAction(deleteWithXAction); + auto *renameAction = new QAction(this); renameAction->setShortcuts({QKeySequence(Qt::Key_Return), QKeySequence(Qt::Key_Enter), @@ -480,7 +492,9 @@ void HierarchyPanel::deleteSelectedObject() { if (viewport == nullptr) { return; } - const QList ids = selectedObjectIds(); + QList ids = selectedObjectIds(); + if (ids.isEmpty() && viewport->selectedRuntimeObjectId() >= 0) + ids.append(viewport->selectedRuntimeObjectId()); for (int id : ids) viewport->deleteRuntimeObject(id); } diff --git a/editor/views/editor/inspector.cpp b/editor/views/editor/inspector.cpp index c5294485..11d07987 100644 --- a/editor/views/editor/inspector.cpp +++ b/editor/views/editor/inspector.cpp @@ -624,6 +624,7 @@ void addSyncPicker(QHBoxLayout *layout, const QString &path, button->setSizePolicy(matched ? QSizePolicy::Expanding : QSizePolicy::Fixed, QSizePolicy::Preferred); + button->setMinimumWidth(matched ? 180 : 22); button->setToolTip(matched ? QStringLiteral("Matched to %1. Click to change") .arg(name) @@ -633,11 +634,12 @@ void addSyncPicker(QHBoxLayout *layout, const QString &path, }; showMatch(provider.matchedName ? provider.matchedName(path) : QString()); auto *menu = new QMenu(button); + menu->setMinimumWidth(360); auto *searchAction = new QWidgetAction(menu); auto *search = new PickerSearchField(menu); search->setPlaceholderText("Search properties"); search->setClearButtonEnabled(true); - search->setMinimumWidth(240); + search->setMinimumWidth(340); searchAction->setDefaultWidget(search); menu->addAction(searchAction); menu->addSeparator(); @@ -1206,6 +1208,7 @@ InspectorPanel::InspectorPanel(ViewportPanel *viewport, const QString &projectFile, QWidget *parent) : QWidget(parent), viewport(viewport) { setObjectName("inspectorPanel"); + setMinimumWidth(360); setAcceptDrops(true); const QFileInfo projectInfo(projectFile); projectRoot = projectInfo.absoluteDir().absolutePath(); @@ -1582,13 +1585,31 @@ void InspectorPanel::showObject(const QJsonObject &object) { searchableActions.append(action); } QDirIterator assets(projectRoot, - {"*.ts", "*.js", "*.amat", "*.material", - "*.wav", "*.mp3", "*.ogg", "*.flac", - "*.m4a", "*.aac"}, + {"*.ts", "*.amat", "*.material", "*.wav", + "*.mp3", "*.ogg", "*.flac", "*.m4a", + "*.aac"}, QDir::Files, QDirIterator::Subdirectories); while (assets.hasNext()) { const QFileInfo info(assets.next()); const QString suffix = info.suffix().toLower(); + if (suffix == "ts") { + QString relativePath = QDir(projectRoot).relativeFilePath( + info.absoluteFilePath()); + const QStringList pathParts = + QDir::fromNativeSeparators(relativePath) + .split('/', Qt::SkipEmptyParts); + bool excluded = false; + for (int index = 0; index + 1 < pathParts.size(); ++index) { + const QString directory = pathParts.at(index).toLower(); + if (directory == "lib" || directory == "dist" || + directory == "node_modules") { + excluded = true; + break; + } + } + if (excluded) + continue; + } const bool material = suffix == "amat" || suffix == "material"; const bool audio = suffix == "wav" || suffix == "mp3" || suffix == "ogg" || suffix == "flac" || diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp index 53647c50..e9f2af14 100644 --- a/editor/views/editor/viewport.cpp +++ b/editor/views/editor/viewport.cpp @@ -519,6 +519,11 @@ void ViewportPanel::keyPressEvent(QKeyEvent *event) { event->accept(); return; } + } else if (event->key() == Qt::Key_X && + selectedRuntimeObjectId() >= 0) { + deleteRuntimeObject(selectedRuntimeObjectId()); + event->accept(); + return; } else if (event->key() == Qt::Key_G || event->key() == Qt::Key_R || event->key() == Qt::Key_S) { @@ -1401,7 +1406,7 @@ void ViewportPanel::finishKeyboardTransform(bool commit) { keyboardTransformAxes = 7; transformUndoBefore = {}; emit transformHintChanged( - "Tab Frame · Right-Drag Pan · Middle-Drag Orbit · G Move · R Rotate · S Scale"); + "Tab Frame · Right-Drag Pan · Middle-Drag Orbit · G Move · R Rotate · S Scale · X Delete"); } void ViewportPanel::pushTransformUndo(int objectId, diff --git a/editor/views/editor/viewportTools.cpp b/editor/views/editor/viewportTools.cpp index 8616c1ce..47728aea 100644 --- a/editor/views/editor/viewportTools.cpp +++ b/editor/views/editor/viewportTools.cpp @@ -148,7 +148,7 @@ ViewportTools::ViewportTools(ViewportPanel *viewport, layout->addWidget(toolbar); layout->addWidget(viewport, 1); shortcutHint = new QLabel( - "Tab Frame · Right-Drag Orbit · Shift + Right-Drag Pan · G Move · R Rotate · S Scale", + "Tab Frame · Right-Drag Pan · Middle-Drag Orbit · G Move · R Rotate · S Scale · X Delete", this); shortcutHint->setObjectName("viewportShortcutHint"); shortcutHint->setTextInteractionFlags(Qt::NoTextInteraction); diff --git a/editor/views/general/inputActionsDialog.cpp b/editor/views/general/inputActionsDialog.cpp new file mode 100644 index 00000000..144d11dc --- /dev/null +++ b/editor/views/general/inputActionsDialog.cpp @@ -0,0 +1,834 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QStringList bindingValues() { + QStringList values; + for (char letter = 'A'; letter <= 'Z'; ++letter) + values.append(QString(QChar(letter))); + values << "Space" << "Enter" << "Escape" << "Tab" << "Backspace" + << "Up" << "Down" << "Left" << "Right" << "Left Shift" + << "Right Shift" << "Left Control" << "Right Control" + << "Left Alt" << "Right Alt" << "MouseLeft" << "MouseRight" + << "MouseMiddle" << "Mouse4" << "Mouse5"; + return values; +} + +QComboBox *bindingCombo(QWidget *parent) { + auto *combo = new QComboBox(parent); + combo->setEditable(true); + combo->addItems(bindingValues()); + return combo; +} + +QString kindName(InputActionsDialog::ActionKind kind) { + if (kind == InputActionsDialog::ActionKind::Axis1D) + return "1D Axis"; + if (kind == InputActionsDialog::ActionKind::Axis2D) + return "2D Axis"; + return "Button"; +} +} + +InputActionsDialog::InputActionsDialog(const QString &projectFile, + QWidget *parent) + : QDialog(parent), projectFile(QFileInfo(projectFile).absoluteFilePath()), + actionsFile( + QFileInfo(projectFile).absoluteDir().filePath("input-actions.json")) { + setupUi(); + load(); +} + +void InputActionsDialog::setupUi() { + setWindowTitle("Project Input Actions"); + setWindowFlag(Qt::WindowContextHelpButtonHint, false); + resize(940, 680); + setMinimumSize(780, 560); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(16, 16, 16, 16); + root->setSpacing(12); + + auto *heading = new QLabel("Input Actions", this); + QFont headingFont = heading->font(); + headingFont.setPointSizeF(18); + headingFont.setWeight(QFont::DemiBold); + heading->setFont(headingFont); + root->addWidget(heading); + auto *subheading = new QLabel( + "Create project-wide names for keyboard, mouse, and controller input. " + "Scripts can use the names without hard-coding keys.", + this); + subheading->setWordWrap(true); + subheading->setProperty("muted", true); + root->addWidget(subheading); + + auto *splitter = new QSplitter(this); + splitter->setChildrenCollapsible(false); + root->addWidget(splitter, 1); + + auto *sidebar = new QWidget(splitter); + auto *sidebarLayout = new QVBoxLayout(sidebar); + sidebarLayout->setContentsMargins(0, 0, 8, 0); + searchField = new QLineEdit(sidebar); + searchField->setPlaceholderText("Search actions"); + searchField->setClearButtonEnabled(true); + sidebarLayout->addWidget(searchField); + actionList = new QListWidget(sidebar); + actionList->setSelectionMode(QAbstractItemView::SingleSelection); + sidebarLayout->addWidget(actionList, 1); + + auto *sidebarButtons = new QHBoxLayout(); + auto *addButton = new QPushButton("Add", sidebar); + auto *addMenu = new QMenu(addButton); + addMenu->addAction("Button", this, + [this] { addAction(ActionKind::Button); }); + addMenu->addAction("1D Axis", this, + [this] { addAction(ActionKind::Axis1D); }); + addMenu->addAction("2D Axis", this, + [this] { addAction(ActionKind::Axis2D); }); + addButton->setMenu(addMenu); + duplicateButton = new QPushButton("Duplicate", sidebar); + removeButton = new QPushButton("Remove", sidebar); + sidebarButtons->addWidget(addButton); + sidebarButtons->addWidget(duplicateButton); + sidebarButtons->addWidget(removeButton); + sidebarLayout->addLayout(sidebarButtons); + + auto *editor = new QWidget(splitter); + auto *editorLayout = new QVBoxLayout(editor); + editorLayout->setContentsMargins(12, 0, 0, 0); + editorLayout->setSpacing(12); + + auto *identity = new QFormLayout(); + nameField = new QLineEdit(editor); + nameField->setPlaceholderText("Action name"); + kindField = new QComboBox(editor); + kindField->addItems({"Button", "1D Axis", "2D Axis"}); + identity->addRow("Name", nameField); + identity->addRow("Type", kindField); + editorLayout->addLayout(identity); + + bindingPages = new QStackedWidget(editor); + auto *buttonPage = new QWidget(bindingPages); + auto *buttonLayout = new QVBoxLayout(buttonPage); + buttonLayout->setContentsMargins(0, 0, 0, 0); + buttonBindings = new QTableWidget(0, 4, buttonPage); + buttonBindings->setHorizontalHeaderLabels( + {"Source", "Key / Mouse", "Controller", "Button"}); + buttonBindings->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::Stretch); + buttonBindings->verticalHeader()->setVisible(false); + buttonBindings->setSelectionBehavior(QAbstractItemView::SelectRows); + buttonBindings->setSelectionMode(QAbstractItemView::SingleSelection); + buttonLayout->addWidget(buttonBindings); + auto *bindingButtons = new QHBoxLayout(); + auto *addBindingButton = new QPushButton("Add Binding", buttonPage); + auto *removeBindingButton = new QPushButton("Remove Binding", buttonPage); + bindingButtons->addWidget(addBindingButton); + bindingButtons->addWidget(removeBindingButton); + bindingButtons->addStretch(); + buttonLayout->addLayout(bindingButtons); + bindingPages->addWidget(buttonPage); + + auto *axisPage = new QWidget(bindingPages); + auto *axisLayout = new QVBoxLayout(axisPage); + axisLayout->setContentsMargins(0, 0, 0, 0); + auto *directionForm = new QFormLayout(); + positiveXField = bindingCombo(axisPage); + negativeXField = bindingCombo(axisPage); + positiveYField = bindingCombo(axisPage); + negativeYField = bindingCombo(axisPage); + directionForm->addRow("Positive X", positiveXField); + directionForm->addRow("Negative X", negativeXField); + positiveYLabel = new QLabel("Positive Y", axisPage); + negativeYLabel = new QLabel("Negative Y", axisPage); + directionForm->addRow(positiveYLabel, positiveYField); + directionForm->addRow(negativeYLabel, negativeYField); + axisLayout->addLayout(directionForm); + + mouseAxisField = new QCheckBox("Include mouse movement", axisPage); + controllerAxisField = new QCheckBox("Include controller axis", axisPage); + axisLayout->addWidget(mouseAxisField); + axisLayout->addWidget(controllerAxisField); + auto *controllerForm = new QFormLayout(); + controllerIdField = new QSpinBox(axisPage); + controllerIdField->setRange(-1, 15); + controllerIdField->setSpecialValueText("Any"); + controllerAxisXField = new QSpinBox(axisPage); + controllerAxisXField->setRange(0, 31); + controllerAxisYField = new QSpinBox(axisPage); + controllerAxisYField->setRange(0, 31); + controllerAxisYLabel = new QLabel("Controller Y axis", axisPage); + controllerForm->addRow("Controller", controllerIdField); + controllerForm->addRow("Controller X axis", controllerAxisXField); + controllerForm->addRow(controllerAxisYLabel, controllerAxisYField); + axisLayout->addLayout(controllerForm); + + auto *processingForm = new QFormLayout(); + deadzoneField = new QDoubleSpinBox(axisPage); + deadzoneField->setRange(0.0, 1.0); + deadzoneField->setSingleStep(0.05); + scaleXField = new QDoubleSpinBox(axisPage); + scaleXField->setRange(-100.0, 100.0); + scaleXField->setSingleStep(0.1); + scaleYField = new QDoubleSpinBox(axisPage); + scaleYField->setRange(-100.0, 100.0); + scaleYField->setSingleStep(0.1); + scaleYLabel = new QLabel("Y scale", axisPage); + processingForm->addRow("Controller deadzone", deadzoneField); + processingForm->addRow("X scale", scaleXField); + processingForm->addRow(scaleYLabel, scaleYField); + axisLayout->addLayout(processingForm); + normalizeField = new QCheckBox("Normalize 2D value", axisPage); + invertYField = new QCheckBox("Invert controller Y", axisPage); + clampField = new QCheckBox("Clamp values to -1…1", axisPage); + axisLayout->addWidget(normalizeField); + axisLayout->addWidget(invertYField); + axisLayout->addWidget(clampField); + axisLayout->addStretch(); + bindingPages->addWidget(axisPage); + editorLayout->addWidget(bindingPages, 1); + + auto *exampleLabel = new QLabel("Use in a script", editor); + QFont exampleFont = exampleLabel->font(); + exampleFont.setWeight(QFont::DemiBold); + exampleLabel->setFont(exampleFont); + editorLayout->addWidget(exampleLabel); + scriptExample = new QPlainTextEdit(editor); + scriptExample->setReadOnly(true); + scriptExample->setMaximumHeight(86); + scriptExample->setLineWrapMode(QPlainTextEdit::NoWrap); + editorLayout->addWidget(scriptExample); + + splitter->addWidget(sidebar); + splitter->addWidget(editor); + splitter->setSizes({270, 670}); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Cancel | QDialogButtonBox::Save, this); + buttons->button(QDialogButtonBox::Save)->setText("Save Actions"); + root->addWidget(buttons); + + auto markChanged = [this] { + if (!updating) { + storeCurrentAction(); + refreshList(); + } + }; + connect(searchField, &QLineEdit::textChanged, this, + [this] { refreshList(); }); + connect(actionList, &QListWidget::currentRowChanged, this, [this](int row) { + if (updating || row < 0) + return; + selectAction(actionList->item(row)->data(Qt::UserRole).toInt()); + }); + connect(duplicateButton, &QPushButton::clicked, this, + [this] { duplicateAction(); }); + connect(removeButton, &QPushButton::clicked, this, + [this] { removeAction(); }); + connect(nameField, &QLineEdit::textEdited, this, markChanged); + connect(kindField, &QComboBox::currentIndexChanged, this, markChanged); + connect(addBindingButton, &QPushButton::clicked, this, + [this] { addButtonBinding(); }); + connect(removeBindingButton, &QPushButton::clicked, this, [this] { + if (currentIndex < 0 || buttonBindings->currentRow() < 0) + return; + actions[currentIndex].buttonBindings.removeAt( + buttonBindings->currentRow()); + refreshBindingTable(); + }); + connect(buttonBindings, &QTableWidget::cellChanged, this, markChanged); + for (QComboBox *field : + {positiveXField, negativeXField, positiveYField, negativeYField}) + connect(field, &QComboBox::currentTextChanged, this, markChanged); + for (QCheckBox *field : {mouseAxisField, controllerAxisField, + normalizeField, invertYField, clampField}) + connect(field, &QCheckBox::toggled, this, markChanged); + for (QSpinBox *field : + {controllerIdField, controllerAxisXField, controllerAxisYField}) + connect(field, &QSpinBox::valueChanged, this, markChanged); + for (QDoubleSpinBox *field : {deadzoneField, scaleXField, scaleYField}) + connect(field, &QDoubleSpinBox::valueChanged, this, markChanged); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, this, [this] { + storeCurrentAction(); + if (save()) + accept(); + }); +} + +void InputActionsDialog::load() { + QFile file(actionsFile); + if (file.exists() && file.open(QIODevice::ReadOnly)) { + QJsonParseError parseError; + const QJsonDocument document = + QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || + !document.isObject()) { + QMessageBox::warning(this, "Input Actions", + "The existing input-actions.json is not valid " + "JSON and could not be opened."); + } else { + const QJsonArray entries = + document.object().value("actions").toArray(); + for (const QJsonValue &entry : entries) { + const QJsonObject object = entry.toObject(); + ActionDefinition action; + action.name = object.value("name").toString(); + if (object.value("triggerButtons").isArray()) { + for (const QJsonValue &binding : + object.value("triggerButtons").toArray()) + action.buttonBindings.append( + parseButtonBinding(binding)); + } else { + action.kind = object.value("singleAxis").toBool(false) + ? ActionKind::Axis1D + : ActionKind::Axis2D; + for (const QJsonValue &trigger : + object.value("triggerAxes").toArray()) { + if (trigger.isString() && + trigger.toString().compare( + "mouse", Qt::CaseInsensitive) == 0) { + action.mouseAxis = true; + continue; + } + const QJsonObject axis = trigger.toObject(); + const QString type = axis.value("type").toString(); + if (type.compare("controller", Qt::CaseInsensitive) == + 0) { + action.controllerAxis = true; + action.controllerId = axis.value("id").toInt(-1); + const QJsonArray indexes = + axis.value("indexes").toArray(); + action.controllerAxisX = + indexes.isEmpty() ? axis.value("index").toInt(0) + : indexes.at(0).toInt(0); + action.controllerAxisY = + indexes.size() > 1 ? indexes.at(1).toInt(1) + : action.controllerAxisX; + } else if (type.compare("custom", + Qt::CaseInsensitive) == 0) { + const QJsonArray directions = + axis.value("triggers").toArray(); + action.positiveX = + directions.isEmpty() + ? axis.value("positiveX") + .toString(axis.value("positive") + .toString("D")) + : directions.at(0).toString("D"); + action.negativeX = + directions.size() < 2 + ? axis.value("negativeX") + .toString(axis.value("negative") + .toString("A")) + : directions.at(1).toString("A"); + action.positiveY = + directions.size() < 3 + ? axis.value("positiveY").toString("W") + : directions.at(2).toString("W"); + action.negativeY = + directions.size() < 4 + ? axis.value("negativeY").toString("S") + : directions.at(3).toString("S"); + } + } + action.deadzone = + object.value("controllerDeadzone").toDouble(0.2); + action.scaleX = object.value("axisScaleX").toDouble(1.0); + action.scaleY = object.value("axisScaleY").toDouble(1.0); + action.normalize = + object.value("normalize2D").toBool(false); + action.invertY = + object.value("invertControllerY").toBool(false); + action.clamp = object.value("clampAxis").toBool(true); + } + if (!action.name.isEmpty()) + actions.append(action); + } + } + } + refreshList(); + if (!actions.isEmpty()) + selectAction(0); + else + refreshEditor(); +} + +void InputActionsDialog::addAction(ActionKind kind) { + storeCurrentAction(); + ActionDefinition action; + action.kind = kind; + action.name = uniqueName(kind == ActionKind::Button ? "NewAction" : "Move"); + if (kind == ActionKind::Button) + action.buttonBindings.append(ButtonBinding()); + actions.append(action); + searchField->clear(); + refreshList(); + selectAction(actions.size() - 1); + nameField->setFocus(); + nameField->selectAll(); +} + +void InputActionsDialog::duplicateAction() { + if (currentIndex < 0) + return; + storeCurrentAction(); + ActionDefinition copy = actions.at(currentIndex); + copy.name = uniqueName(copy.name + "Copy"); + actions.insert(currentIndex + 1, copy); + searchField->clear(); + refreshList(); + selectAction(currentIndex + 1); +} + +void InputActionsDialog::removeAction() { + if (currentIndex < 0) + return; + const int next = qMin(currentIndex, actions.size() - 2); + actions.removeAt(currentIndex); + currentIndex = -1; + refreshList(); + if (next >= 0) + selectAction(next); + else + refreshEditor(); +} + +void InputActionsDialog::selectAction(int index) { + if (index < 0 || index >= actions.size()) + return; + if (currentIndex != index) + storeCurrentAction(); + currentIndex = index; + refreshEditor(); + for (int row = 0; row < actionList->count(); ++row) { + if (actionList->item(row)->data(Qt::UserRole).toInt() == index) { + actionList->setCurrentRow(row); + break; + } + } +} + +void InputActionsDialog::storeCurrentAction() { + if (updating || currentIndex < 0 || currentIndex >= actions.size()) + return; + ActionDefinition &action = actions[currentIndex]; + action.name = nameField->text().trimmed(); + action.kind = static_cast(kindField->currentIndex()); + action.positiveX = positiveXField->currentText().trimmed(); + action.negativeX = negativeXField->currentText().trimmed(); + action.positiveY = positiveYField->currentText().trimmed(); + action.negativeY = negativeYField->currentText().trimmed(); + action.mouseAxis = mouseAxisField->isChecked(); + action.controllerAxis = controllerAxisField->isChecked(); + action.controllerId = controllerIdField->value(); + action.controllerAxisX = controllerAxisXField->value(); + action.controllerAxisY = controllerAxisYField->value(); + action.deadzone = deadzoneField->value(); + action.scaleX = scaleXField->value(); + action.scaleY = scaleYField->value(); + action.normalize = normalizeField->isChecked(); + action.invertY = invertYField->isChecked(); + action.clamp = clampField->isChecked(); + if (action.kind == ActionKind::Button) { + for (int row = 0; row < buttonBindings->rowCount() && + row < action.buttonBindings.size(); + ++row) { + ButtonBinding &binding = action.buttonBindings[row]; + binding.source = + qobject_cast(buttonBindings->cellWidget(row, 0)) + ->currentText(); + binding.value = buttonBindings->item(row, 1)->text().trimmed(); + binding.controllerId = + qobject_cast(buttonBindings->cellWidget(row, 2)) + ->value(); + binding.controllerButton = + qobject_cast(buttonBindings->cellWidget(row, 3)) + ->value(); + } + } + bindingPages->setCurrentIndex(action.kind == ActionKind::Button ? 0 : 1); + const bool is2D = action.kind == ActionKind::Axis2D; + positiveYLabel->setVisible(is2D); + positiveYField->setVisible(is2D); + negativeYLabel->setVisible(is2D); + negativeYField->setVisible(is2D); + controllerAxisYLabel->setVisible(is2D); + controllerAxisYField->setVisible(is2D); + scaleYLabel->setVisible(is2D); + scaleYField->setVisible(is2D); + normalizeField->setVisible(is2D); + invertYField->setVisible(is2D); + refreshScriptExample(); +} + +void InputActionsDialog::refreshList() { + const QString filter = searchField->text().trimmed(); + const int selected = currentIndex; + updating = true; + actionList->clear(); + for (int i = 0; i < actions.size(); ++i) { + const ActionDefinition &action = actions.at(i); + if (!filter.isEmpty() && + !action.name.contains(filter, Qt::CaseInsensitive)) + continue; + auto *item = new QListWidgetItem( + QString("%1\n%2").arg(action.name, kindName(action.kind)), + actionList); + item->setData(Qt::UserRole, i); + item->setSizeHint(QSize(0, 48)); + if (i == selected) + actionList->setCurrentItem(item); + } + updating = false; +} + +void InputActionsDialog::refreshEditor() { + const bool enabled = currentIndex >= 0 && currentIndex < actions.size(); + nameField->setEnabled(enabled); + kindField->setEnabled(enabled); + bindingPages->setEnabled(enabled); + duplicateButton->setEnabled(enabled); + removeButton->setEnabled(enabled); + if (!enabled) { + updating = true; + nameField->clear(); + scriptExample->setPlainText( + "Add an action to create a project-wide input name."); + updating = false; + return; + } + + const ActionDefinition &action = actions.at(currentIndex); + updating = true; + nameField->setText(action.name); + kindField->setCurrentIndex(static_cast(action.kind)); + bindingPages->setCurrentIndex(action.kind == ActionKind::Button ? 0 : 1); + positiveXField->setCurrentText(action.positiveX); + negativeXField->setCurrentText(action.negativeX); + positiveYField->setCurrentText(action.positiveY); + negativeYField->setCurrentText(action.negativeY); + mouseAxisField->setChecked(action.mouseAxis); + controllerAxisField->setChecked(action.controllerAxis); + controllerIdField->setValue(action.controllerId); + controllerAxisXField->setValue(action.controllerAxisX); + controllerAxisYField->setValue(action.controllerAxisY); + deadzoneField->setValue(action.deadzone); + scaleXField->setValue(action.scaleX); + scaleYField->setValue(action.scaleY); + normalizeField->setChecked(action.normalize); + invertYField->setChecked(action.invertY); + clampField->setChecked(action.clamp); + const bool is2D = action.kind == ActionKind::Axis2D; + positiveYLabel->setVisible(is2D); + positiveYField->setVisible(is2D); + negativeYLabel->setVisible(is2D); + negativeYField->setVisible(is2D); + controllerAxisYLabel->setVisible(is2D); + controllerAxisYField->setVisible(is2D); + scaleYLabel->setVisible(is2D); + scaleYField->setVisible(is2D); + normalizeField->setVisible(is2D); + invertYField->setVisible(is2D); + updating = false; + refreshBindingTable(); + refreshScriptExample(); +} + +void InputActionsDialog::refreshBindingTable() { + updating = true; + buttonBindings->setRowCount(0); + if (currentIndex >= 0 && currentIndex < actions.size()) { + const auto &bindings = actions.at(currentIndex).buttonBindings; + for (int row = 0; row < bindings.size(); ++row) { + const ButtonBinding &binding = bindings.at(row); + buttonBindings->insertRow(row); + auto *source = new QComboBox(buttonBindings); + source->addItems({"Keyboard", "Mouse", "Controller"}); + source->setCurrentText(binding.source); + buttonBindings->setCellWidget(row, 0, source); + buttonBindings->setItem(row, 1, + new QTableWidgetItem(binding.value)); + auto *controllerId = new QSpinBox(buttonBindings); + controllerId->setRange(-1, 15); + controllerId->setSpecialValueText("Any"); + controllerId->setValue(binding.controllerId); + buttonBindings->setCellWidget(row, 2, controllerId); + auto *controllerButton = new QSpinBox(buttonBindings); + controllerButton->setRange(0, 255); + controllerButton->setValue(binding.controllerButton); + buttonBindings->setCellWidget(row, 3, controllerButton); + connect(source, &QComboBox::currentTextChanged, this, + [this] { storeCurrentAction(); }); + connect(controllerId, &QSpinBox::valueChanged, this, + [this] { storeCurrentAction(); }); + connect(controllerButton, &QSpinBox::valueChanged, this, + [this] { storeCurrentAction(); }); + } + } + updating = false; +} + +void InputActionsDialog::refreshScriptExample() { + if (currentIndex < 0) + return; + const ActionDefinition &action = actions.at(currentIndex); + if (action.kind == ActionKind::Button) { + scriptExample->setPlainText( + QString( + "import { Input } from \"atlas/input\";\n\nif " + "(Input.isActionTriggered(\"%1\")) {\n performAction();\n}") + .arg(action.name)); + } else { + scriptExample->setPlainText( + QString("import { Input } from \"atlas/input\";\n\nconst axis = " + "Input.getAxisActionValue(\"%1\");\nmove(axis.valueX, " + "axis.valueY);") + .arg(action.name)); + } +} + +void InputActionsDialog::addButtonBinding() { + if (currentIndex < 0) + return; + actions[currentIndex].buttonBindings.append(ButtonBinding()); + refreshBindingTable(); + buttonBindings->selectRow(buttonBindings->rowCount() - 1); +} + +QJsonValue +InputActionsDialog::serializeButtonBinding(const ButtonBinding &binding) const { + if (binding.source.compare("Controller", Qt::CaseInsensitive) == 0) { + return QJsonObject{{"type", "controller"}, + {"id", binding.controllerId}, + {"button", binding.controllerButton}}; + } + if (binding.source.compare("Mouse", Qt::CaseInsensitive) == 0) { + return QJsonObject{{"type", "mouse"}, {"button", binding.value}}; + } + return binding.value; +} + +InputActionsDialog::ButtonBinding +InputActionsDialog::parseButtonBinding(const QJsonValue &value) const { + ButtonBinding binding; + if (value.isString()) { + binding.value = value.toString(); + binding.source = binding.value.startsWith("Mouse", Qt::CaseInsensitive) + ? "Mouse" + : "Keyboard"; + return binding; + } + const QJsonObject object = value.toObject(); + const QString type = object.value("type").toString(); + if (type.compare("controller", Qt::CaseInsensitive) == 0) { + binding.source = "Controller"; + binding.controllerId = object.value("id").toInt(-1); + binding.controllerButton = object.value("button").toInt(0); + } else if (type.compare("mouse", Qt::CaseInsensitive) == 0) { + binding.source = "Mouse"; + binding.value = object.value("button").toString("MouseLeft"); + } else { + binding.source = "Keyboard"; + binding.value = object.value("key").toString("Space"); + } + return binding; +} + +QString InputActionsDialog::uniqueName(const QString &base) const { + QString candidate = base; + int suffix = 2; + auto exists = [this](const QString &name) { + for (const ActionDefinition &action : actions) { + if (action.name.compare(name, Qt::CaseInsensitive) == 0) + return true; + } + return false; + }; + while (exists(candidate)) + candidate = base + QString::number(suffix++); + return candidate; +} + +bool InputActionsDialog::save() { + QSet names; + QJsonArray entries; + for (const ActionDefinition &action : actions) { + if (action.name.isEmpty()) { + QMessageBox::warning(this, "Input Actions", + "Every action needs a name."); + return false; + } + const QString normalized = action.name.toLower(); + if (names.contains(normalized)) { + QMessageBox::warning( + this, "Input Actions", + QString("The action name “%1” is used more than once.") + .arg(action.name)); + return false; + } + names.insert(normalized); + QJsonObject entry{{"name", action.name}}; + if (action.kind == ActionKind::Button) { + if (action.buttonBindings.isEmpty()) { + QMessageBox::warning( + this, "Input Actions", + QString("Add at least one binding to “%1”.") + .arg(action.name)); + return false; + } + QJsonArray bindings; + for (const ButtonBinding &binding : action.buttonBindings) { + if ((binding.source != "Keyboard" && + binding.source != "Mouse" && + binding.source != "Controller") || + (binding.source != "Controller" && + binding.value.trimmed().isEmpty())) { + QMessageBox::warning( + this, "Input Actions", + QString("Complete every binding for “%1”.") + .arg(action.name)); + return false; + } + bindings.append(serializeButtonBinding(binding)); + } + entry.insert("triggerButtons", bindings); + } else { + if (action.positiveX.isEmpty() || action.negativeX.isEmpty() || + (action.kind == ActionKind::Axis2D && + (action.positiveY.isEmpty() || action.negativeY.isEmpty()))) { + QMessageBox::warning( + this, "Input Actions", + QString("Complete the directional bindings for “%1”.") + .arg(action.name)); + return false; + } + QJsonArray triggers; + QJsonObject custom{{"type", "custom"}, + {"positiveX", action.positiveX}, + {"negativeX", action.negativeX}}; + if (action.kind == ActionKind::Axis2D) { + custom.insert("positiveY", action.positiveY); + custom.insert("negativeY", action.negativeY); + } + triggers.append(custom); + if (action.mouseAxis) + triggers.append("mouse"); + if (action.controllerAxis) { + QJsonObject controller{{"type", "controller"}, + {"id", action.controllerId}}; + if (action.kind == ActionKind::Axis2D) + controller.insert("indexes", + QJsonArray{action.controllerAxisX, + action.controllerAxisY}); + else + controller.insert("index", action.controllerAxisX); + triggers.append(controller); + } + entry.insert("triggerAxes", triggers); + entry.insert("singleAxis", action.kind == ActionKind::Axis1D); + entry.insert("controllerDeadzone", action.deadzone); + entry.insert("axisScaleX", action.scaleX); + entry.insert("axisScaleY", action.scaleY); + entry.insert("normalize2D", action.normalize); + entry.insert("invertControllerY", action.invertY); + entry.insert("clampAxis", action.clamp); + } + entries.append(entry); + } + + QSaveFile file(actionsFile); + if (!file.open(QIODevice::WriteOnly) || + file.write(QJsonDocument(QJsonObject{{"actions", entries}}) + .toJson(QJsonDocument::Indented)) < 0 || + !file.commit()) { + QMessageBox::critical(this, "Input Actions", + "Atlas could not save input-actions.json."); + return false; + } + QString manifestError; + if (!updateProjectManifest(&manifestError)) { + QMessageBox::critical(this, "Input Actions", manifestError); + return false; + } + return true; +} + +bool InputActionsDialog::updateProjectManifest(QString *errorMessage) { + QFile file(projectFile); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + *errorMessage = "Atlas could not open project.atlas."; + return false; + } + QString contents = QString::fromUtf8(file.readAll()); + file.close(); + + const QRegularExpression sectionExpression( + QStringLiteral(R"((?m)^\[game\][ \t]*$)")); + const QRegularExpressionMatch sectionMatch = + sectionExpression.match(contents); + if (!sectionMatch.hasMatch()) { + if (!contents.endsWith('\n')) + contents.append('\n'); + contents.append("\n[game]\ninput_actions = \"input-actions.json\"\n"); + } else { + const int sectionStart = sectionMatch.capturedEnd(); + const QRegularExpression nextSectionExpression( + QStringLiteral(R"((?m)^\[[^\]]+\][ \t]*$)")); + const QRegularExpressionMatch nextSection = + nextSectionExpression.match(contents, sectionStart); + const int sectionEnd = nextSection.hasMatch() + ? nextSection.capturedStart() + : contents.size(); + QString gameSection = + contents.mid(sectionStart, sectionEnd - sectionStart); + const QRegularExpression valueExpression( + QStringLiteral(R"((?m)^[ \t]*input_actions[ \t]*=.*$)")); + if (gameSection.contains(valueExpression)) + gameSection.replace(valueExpression, + "\ninput_actions = \"input-actions.json\""); + else + gameSection.prepend("\ninput_actions = \"input-actions.json\""); + contents.replace(sectionStart, sectionEnd - sectionStart, gameSection); + } + + QSaveFile output(projectFile); + if (!output.open(QIODevice::WriteOnly | QIODevice::Text) || + output.write(contents.toUtf8()) < 0 || !output.commit()) { + *errorMessage = "Atlas could not update project.atlas."; + return false; + } + return true; +} diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index 785ecd6f..63aff29a 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -43,6 +43,7 @@ class ProjectConfig { std::string renderer; bool globalIllumination; std::string mainScene; + std::string inputActions; bool useUpscaling = false; std::vector assetDirectories; }; diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h index b3f15739..5be376c1 100644 --- a/include/editor/core/themes.h +++ b/include/editor/core/themes.h @@ -657,13 +657,19 @@ inline constexpr const char* DARK_THEME = "}\n" "\n" "QTreeView#hierarchyTree::item {\n" -" min-height: 24px;\n" -" padding: 3px 5px;\n" +" min-height: 26px;\n" +" padding: 3px 7px;\n" +"}\n" +"\n" +"QTreeView#hierarchyTree::item:hover {\n" +" background-color: #2B2E31;\n" +" border-color: #43474B;\n" "}\n" "\n" "QTreeView#hierarchyTree::item:selected {\n" " background-color: #343C43;\n" -" border-left: 2px solid #8498A8;\n" +" border-color: #66737C;\n" +" color: #FFFFFF;\n" "}\n" "\n" "QListView#contentGrid {\n" diff --git a/include/editor/views/editorWindow.h b/include/editor/views/editorWindow.h index f6bf24c1..3a6b8235 100644 --- a/include/editor/views/editorWindow.h +++ b/include/editor/views/editorWindow.h @@ -63,6 +63,7 @@ class EditorWindow : public QMainWindow { void openScene(); void saveSceneAs(); void showProjectSettings(); + void showInputActions(); void showExportDialog(); void showCommandPalette(); void showGlobalSearch(); diff --git a/include/editor/views/inputActionsDialog.h b/include/editor/views/inputActionsDialog.h new file mode 100644 index 00000000..ac652eb6 --- /dev/null +++ b/include/editor/views/inputActionsDialog.h @@ -0,0 +1,111 @@ +#ifndef ATLAS_INPUTACTIONSDIALOG_H +#define ATLAS_INPUTACTIONSDIALOG_H + +#include +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QDoubleSpinBox; +class QLabel; +class QLineEdit; +class QListWidget; +class QPlainTextEdit; +class QPushButton; +class QSpinBox; +class QStackedWidget; +class QTableWidget; + +class InputActionsDialog : public QDialog { + public: + enum class ActionKind { Button, Axis1D, Axis2D }; + + explicit InputActionsDialog(const QString &projectFile, + QWidget *parent = nullptr); + + private: + struct ButtonBinding { + QString source = "Keyboard"; + QString value = "Space"; + int controllerId = -1; + int controllerButton = 0; + }; + + struct ActionDefinition { + QString name; + ActionKind kind = ActionKind::Button; + QList buttonBindings; + QString positiveX = "D"; + QString negativeX = "A"; + QString positiveY = "W"; + QString negativeY = "S"; + bool mouseAxis = false; + bool controllerAxis = false; + int controllerId = -1; + int controllerAxisX = 0; + int controllerAxisY = 1; + double deadzone = 0.2; + double scaleX = 1.0; + double scaleY = 1.0; + bool normalize = false; + bool invertY = false; + bool clamp = true; + }; + + void setupUi(); + void load(); + bool save(); + bool updateProjectManifest(QString *errorMessage); + void addAction(ActionKind kind); + void duplicateAction(); + void removeAction(); + void selectAction(int index); + void storeCurrentAction(); + void refreshList(); + void refreshEditor(); + void refreshBindingTable(); + void refreshScriptExample(); + void addButtonBinding(); + QJsonValue serializeButtonBinding(const ButtonBinding &binding) const; + ButtonBinding parseButtonBinding(const QJsonValue &value) const; + QString uniqueName(const QString &base) const; + + QString projectFile; + QString actionsFile; + QList actions; + int currentIndex = -1; + bool updating = false; + + QLineEdit *searchField = nullptr; + QListWidget *actionList = nullptr; + QPushButton *duplicateButton = nullptr; + QPushButton *removeButton = nullptr; + QLineEdit *nameField = nullptr; + QComboBox *kindField = nullptr; + QStackedWidget *bindingPages = nullptr; + QTableWidget *buttonBindings = nullptr; + QComboBox *positiveXField = nullptr; + QComboBox *negativeXField = nullptr; + QComboBox *positiveYField = nullptr; + QComboBox *negativeYField = nullptr; + QLabel *positiveYLabel = nullptr; + QLabel *negativeYLabel = nullptr; + QCheckBox *mouseAxisField = nullptr; + QCheckBox *controllerAxisField = nullptr; + QSpinBox *controllerIdField = nullptr; + QSpinBox *controllerAxisXField = nullptr; + QSpinBox *controllerAxisYField = nullptr; + QLabel *controllerAxisYLabel = nullptr; + QDoubleSpinBox *deadzoneField = nullptr; + QDoubleSpinBox *scaleXField = nullptr; + QDoubleSpinBox *scaleYField = nullptr; + QLabel *scaleYLabel = nullptr; + QCheckBox *normalizeField = nullptr; + QCheckBox *invertYField = nullptr; + QCheckBox *clampField = nullptr; + QPlainTextEdit *scriptExample = nullptr; +}; + +#endif diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index 746619fd..c235eb1e 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -5717,6 +5717,8 @@ void Context::loadProject() { if (auto *gameTable = configTable["game"].as_table()) { mainScene = (*gameTable)["main_scene"].value_or("main.ascene"); + config.inputActions = + (*gameTable)["input_actions"].value_or(std::string()); assetDirectories.clear(); if (auto *assets = (*gameTable)["assets"].as_array()) { @@ -5926,6 +5928,9 @@ void Context::loadScene(Window &window, const json &sceneData) { } const std::string baseDir = sceneDir.empty() ? projectDir : sceneDir; + if (!config.inputActions.empty()) { + loadInputActionsFromJson(window, config.inputActions, projectDir); + } RuntimeEnvironmentDefinition environmentDefinition = loadEnvironmentDefinition(sceneData, baseDir); scene->setEnvironment(std::move(environmentDefinition.environment)); From 4941e580fa92618e47c2c31690fdc7f511d451ee Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Sun, 19 Jul 2026 14:10:30 +0200 Subject: [PATCH 3/3] Fixed some issues with Atlas --- atlas/application/window.cpp | 122 +++++++++++++----- atlas/object/compound.cpp | 218 +++++++++++++++++++++++++------- cli/src/create.rs | 8 ++ editor/project/projectStore.cpp | 10 +- include/atlas/component.h | 20 +-- include/atlas/window.h | 4 + photon/gi.cpp | 71 ++++++----- photon/path_tracing.cpp | 73 ++++++----- runtime/lib/context.cpp | 93 ++++++++++---- 9 files changed, 444 insertions(+), 175 deletions(-) diff --git a/atlas/application/window.cpp b/atlas/application/window.cpp index 35f80db2..e0e8117c 100644 --- a/atlas/application/window.cpp +++ b/atlas/application/window.cpp @@ -852,6 +852,12 @@ void appendShadowCaster(Renderable *obj, std::unordered_set &seen, } return; } + if (auto *compound = dynamic_cast(obj)) { + for (auto *child : compound->objects) { + appendShadowCaster(child, seen, casters); + } + return; + } if (!obj->canCastShadows()) { return; } @@ -1481,6 +1487,14 @@ bool Window::stepFrame() { } this->pendingObjects.clear(); + for (auto *obj : this->pendingInitializedObjects) { + this->renderables.push_back(obj); + if (obj->renderLateForward) { + this->addLateForwardObject(obj); + } + } + this->pendingInitializedObjects.clear(); + DebugTimer cpuTimer("Cpu Data"); DebugTimer mainTimer("Main Loop"); @@ -2531,6 +2545,9 @@ bool Window::editorSelectionBounds(GameObject *object, glm::vec3 &boundsMin, void Window::moveEditorObjectChildren(GameObject *object, const Position3d &deltaPosition) { + if (dynamic_cast(object) != nullptr) { + return; + } auto childrenIt = editorObjectChildren.find(object); if (childrenIt == editorObjectChildren.end()) { return; @@ -3422,7 +3439,10 @@ void Window::addObject(Renderable *obj) { std::ranges::find(this->renderables, obj) != this->renderables.end(); const bool inPending = std::ranges::find(this->pendingObjects, obj) != this->pendingObjects.end(); - if (inRenderables || inPending) { + const bool inInitializedPending = + std::ranges::find(this->pendingInitializedObjects, obj) != + this->pendingInitializedObjects.end(); + if (inRenderables || inPending || inInitializedPending) { return; } @@ -3440,12 +3460,49 @@ void Window::addObject(Renderable *obj) { this->ssaoUpdateCooldown = 0.0f; } -void Window::removeObject(Renderable *obj) { +void Window::addInitializedObject(Renderable *obj) { + if (obj == nullptr) { + return; + } + const bool inRenderables = + std::ranges::find(this->renderables, obj) != this->renderables.end(); + const bool inPending = std::ranges::find(this->pendingObjects, obj) != + this->pendingObjects.end(); + const bool inInitializedPending = + std::ranges::find(this->pendingInitializedObjects, obj) != + this->pendingInitializedObjects.end(); + if (inRenderables || inPending || inInitializedPending) { + return; + } + this->pendingRemovals.erase(std::remove(this->pendingRemovals.begin(), + this->pendingRemovals.end(), obj), + this->pendingRemovals.end()); + if (this->physicsWorld != nullptr) { + this->pendingInitializedObjects.push_back(obj); + } else { + this->renderables.push_back(obj); + if (obj->renderLateForward) { + this->addLateForwardObject(obj); + } + } + this->shadowMapsDirty = true; + this->shadowUpdateCooldown = 0.0f; + this->ssaoMapsDirty = true; + this->ssaoUpdateCooldown = 0.0f; +} + +void Window::removeObject(Renderable *obj) { removeObjectInternal(obj, true); } + +void Window::removeObjectFromRendering(Renderable *obj) { + removeObjectInternal(obj, false); +} + +void Window::removeObjectInternal(Renderable *obj, bool clearEditorState) { if (obj == nullptr) { return; } auto *gameObject = dynamic_cast(obj); - if (gameObject != nullptr) { + if (clearEditorState && gameObject != nullptr) { setEditorObjectParent(gameObject, nullptr); auto childrenIt = editorObjectChildren.find(gameObject); if (childrenIt != editorObjectChildren.end()) { @@ -3455,18 +3512,26 @@ void Window::removeObject(Renderable *obj) { editorObjectChildren.erase(childrenIt); } } - if (selectedEditorObject == gameObject) { + if (clearEditorState && selectedEditorObject == gameObject) { selectedEditorObject = nullptr; editorDragging = false; editorActiveGizmoAxis = 0; } - const auto pendingObject = - std::find(this->pendingObjects.begin(), this->pendingObjects.end(), obj); + const auto pendingObject = std::find(this->pendingObjects.begin(), + this->pendingObjects.end(), obj); const bool wasPending = pendingObject != this->pendingObjects.end(); if (wasPending) { this->pendingObjects.erase(pendingObject); } + const auto pendingInitializedObject = + std::find(this->pendingInitializedObjects.begin(), + this->pendingInitializedObjects.end(), obj); + const bool wasInitializedPending = + pendingInitializedObject != this->pendingInitializedObjects.end(); + if (wasInitializedPending) { + this->pendingInitializedObjects.erase(pendingInitializedObject); + } this->lateForwardRenderables.erase( std::remove(this->lateForwardRenderables.begin(), @@ -3488,10 +3553,11 @@ void Window::removeObject(Renderable *obj) { this->lateFluids.end()); } - if (this->physicsWorld != nullptr && !wasPending) { + if (this->physicsWorld != nullptr && !wasPending && + !wasInitializedPending) { if (std::find(this->pendingRemovals.begin(), - this->pendingRemovals.end(), obj) == - this->pendingRemovals.end()) { + this->pendingRemovals.end(), + obj) == this->pendingRemovals.end()) { this->pendingRemovals.push_back(obj); } } else { @@ -3510,6 +3576,11 @@ void Window::addLateForwardObject(Renderable *object) { return; } + this->pendingRemovals.erase(std::remove(this->pendingRemovals.begin(), + this->pendingRemovals.end(), + object), + this->pendingRemovals.end()); + if (std::ranges::find(lateForwardRenderables, object) == lateForwardRenderables.end()) { lateForwardRenderables.push_back(object); @@ -3621,6 +3692,7 @@ void Window::applyScene(Scene *scene) { this->pendingRemovals.clear(); this->renderables.clear(); this->pendingObjects.clear(); + this->pendingInitializedObjects.clear(); this->preferenceRenderables.clear(); this->currentRenderTarget = nullptr; this->modeScreenTarget.reset(); @@ -5011,32 +5083,16 @@ BoundingBox Window::getSceneBoundingBox() { bool any = false; for (auto *obj : renderables) { - if (!obj) + auto *object = dynamic_cast(obj); + if (object == nullptr) continue; - - const auto &vertices = obj->getVertices(); - if (vertices.empty()) + glm::vec3 objectMin; + glm::vec3 objectMax; + if (!objectBounds(object, objectMin, objectMax)) continue; - - glm::mat4 model(1.0f); - - if (const auto *coreObj = dynamic_cast(obj)) { - model = glm::translate(model, coreObj->getPosition().toGlm()); - model *= glm::mat4_cast( - glm::normalize(coreObj->getRotation().toGlmQuat())); - model = glm::scale(model, coreObj->getScale().toGlm()); - } else { - model = glm::translate(model, obj->getPosition().toGlm()); - model = glm::scale(model, obj->getScale().toGlm()); - } - - for (const auto &v : vertices) { - glm::vec3 p = - glm::vec3(model * glm::vec4(v.position.toGlm(), 1.0f)); - worldMin = glm::min(worldMin, p); - worldMax = glm::max(worldMax, p); - any = true; - } + worldMin = any ? glm::min(worldMin, objectMin) : objectMin; + worldMax = any ? glm::max(worldMax, objectMax) : objectMax; + any = true; } if (!any) diff --git a/atlas/object/compound.cpp b/atlas/object/compound.cpp index e8a073fb..b9be964a 100644 --- a/atlas/object/compound.cpp +++ b/atlas/object/compound.cpp @@ -13,6 +13,7 @@ #include "atlas/window.h" #include "opal/opal.h" #include +#include #include #include #include @@ -47,7 +48,7 @@ class CompoundObject::LateCompoundRenderable : public Renderable { parent.setLatePipeline(pipeline); } - bool canCastShadows() const override { return parent.lateCanCastShadows(); } + bool canCastShadows() const override { return false; } bool canUseDeferredRendering() override { return false; } private: @@ -64,22 +65,74 @@ Renderable *CompoundObject::getLateRenderable() { return lateRenderableProxy.get(); } -void CompoundObject::initialize() { - init(); - for (auto &component : components) { - component->init(); +void CompoundObject::addObject(GameObject *obj, bool childInitialized) { + if (obj == nullptr || obj == this || containsObject(obj)) { + return; + } + objects.push_back(obj); + if (obj->renderLateForward) { + lateForwardObjects.push_back(obj); } + if (childInitialized) { + initializedObjects.insert(obj); + } else if (initialized) { + obj->initialize(); + initializedObjects.insert(obj); + } + syncLateRenderableRegistration(); +} + +void CompoundObject::removeObject(GameObject *obj) { + if (obj == nullptr) { + return; + } + objects.erase(std::remove(objects.begin(), objects.end(), obj), + objects.end()); + lateForwardObjects.erase( + std::remove(lateForwardObjects.begin(), lateForwardObjects.end(), obj), + lateForwardObjects.end()); + initializedObjects.erase(obj); + syncLateRenderableRegistration(); +} +bool CompoundObject::containsObject(const GameObject *obj) const { + return obj != nullptr && std::ranges::find(objects, obj) != objects.end(); +} + +void CompoundObject::syncLateRenderableRegistration() { + if (!initialized || Window::mainWindow == nullptr) { + return; + } if (!lateForwardObjects.empty() && !lateRenderableRegistered) { if (!lateRenderableProxy) { lateRenderableProxy = std::make_unique(*this); } - if (Window::mainWindow != nullptr) { - Window::mainWindow->addLateForwardObject(lateRenderableProxy.get()); - lateRenderableRegistered = true; + Window::mainWindow->addLateForwardObject(lateRenderableProxy.get()); + lateRenderableRegistered = true; + } else if (lateForwardObjects.empty() && lateRenderableRegistered) { + Window::mainWindow->removeObjectFromRendering( + lateRenderableProxy.get()); + lateRenderableRegistered = false; + } +} + +void CompoundObject::initialize() { + if (initialized) { + return; + } + init(); + for (auto &component : components) { + component->init(); + } + for (auto *obj : objects) { + if (obj != nullptr && !initializedObjects.contains(obj)) { + obj->initialize(); + initializedObjects.insert(obj); } } + initialized = true; + syncLateRenderableRegistration(); } void CompoundObject::render(float dt, @@ -93,7 +146,7 @@ void CompoundObject::render(float dt, "CompoundObject::render requires a valid command buffer"); } for (auto &obj : objects) { - if (obj != nullptr && obj->renderLateForward) { + if (obj == nullptr || obj->renderLateForward) { continue; } obj->render(dt, commandBuffer, updatePipeline); @@ -117,20 +170,31 @@ void CompoundObject::renderLate( void CompoundObject::setViewMatrix(const glm::mat4 &view) { for (auto &obj : objects) { - obj->setViewMatrix(view); + if (obj != nullptr) { + obj->setViewMatrix(view); + } } } void CompoundObject::setProjectionMatrix(const glm::mat4 &projection) { for (auto &obj : objects) { - obj->setProjectionMatrix(projection); + if (obj != nullptr) { + obj->setProjectionMatrix(projection); + } } } bool CompoundObject::canUseDeferredRendering() { for (const auto &obj : objects) { + if (obj == nullptr || obj->renderLateForward) { + continue; + } if (!obj->canUseDeferredRendering()) { for (auto &forwardObject : objects) { + if (forwardObject == nullptr || + forwardObject->renderLateForward) { + continue; + } if (CoreObject *coreObj = dynamic_cast(forwardObject); coreObj != nullptr) { @@ -141,6 +205,9 @@ bool CompoundObject::canUseDeferredRendering() { } } for (auto &obj : objects) { + if (obj == nullptr || obj->renderLateForward) { + continue; + } if (CoreObject *coreObj = dynamic_cast(obj); coreObj != nullptr) { coreObj->useDeferredRendering = true; @@ -150,8 +217,11 @@ bool CompoundObject::canUseDeferredRendering() { } std::optional> CompoundObject::getPipeline() { - if (!objects.empty()) { - auto shader = objects[0]->getPipeline(); + for (auto *obj : objects) { + if (obj == nullptr || obj->renderLateForward) { + continue; + } + auto shader = obj->getPipeline(); if (shader.has_value()) { return shader; } @@ -161,32 +231,40 @@ std::optional> CompoundObject::getPipeline() { void CompoundObject::setPipeline(std::shared_ptr &pipeline) { for (auto &obj : objects) { - obj->setPipeline(pipeline); + if (obj != nullptr && !obj->renderLateForward) { + obj->setPipeline(pipeline); + } } } -Position3d CompoundObject::getPosition() const { - return position; -} +Position3d CompoundObject::getPosition() const { return position; } + +Rotation3d CompoundObject::getRotation() const { return rotation; } + +Size3d CompoundObject::getScale() const { return scale; } -Size3d CompoundObject::getScale() const { - if (objects.empty()) { - if (!lateForwardObjects.empty() && lateForwardObjects[0] != nullptr) { - return lateForwardObjects[0]->getScale(); +void CompoundObject::update(Window &window) { + updateObjects(window); + for (auto *obj : objects) { + if (obj != nullptr) { + obj->update(window); } - return Size3d{1.0, 1.0, 1.0}; } - return objects[0]->getScale(); } -void CompoundObject::update(Window &window) { - updateObjects(window); - changedPosition = false; +void CompoundObject::beforePhysics() { + GameObject::beforePhysics(); + for (auto *obj : objects) { + if (obj != nullptr) { + obj->beforePhysics(); + } + } } bool CompoundObject::canCastShadows() const { - return std::ranges::any_of( - objects, [](const auto &obj) { return obj->canCastShadows(); }); + return std::ranges::any_of(objects, [](const auto *obj) { + return obj != nullptr && obj->canCastShadows(); + }); } void CompoundObject::setPosition(const Position3d &newPosition) { @@ -197,7 +275,6 @@ void CompoundObject::setPosition(const Position3d &newPosition) { obj->move(delta); } } - changedPosition = false; } void CompoundObject::move(const Position3d &deltaPosition) { @@ -207,48 +284,99 @@ void CompoundObject::move(const Position3d &deltaPosition) { obj->move(deltaPosition); } } - changedPosition = false; } void CompoundObject::setRotation(const Rotation3d &newRotation) { - for (auto &obj : objects) { - obj->setRotation(newRotation); + const glm::quat oldQuaternion = glm::normalize(rotation.toGlmQuat()); + const glm::quat newQuaternion = glm::normalize(newRotation.toGlmQuat()); + const glm::quat delta = newQuaternion * glm::inverse(oldQuaternion); + rotation = newRotation; + for (auto *obj : objects) { + if (obj == nullptr) { + continue; + } + const glm::vec3 offset = obj->getPosition().toGlm() - position.toGlm(); + obj->setPosition( + Position3d::fromGlm(position.toGlm() + delta * offset)); + const glm::quat childRotation = + glm::normalize(obj->getRotation().toGlmQuat()); + obj->setRotation( + Rotation3d::fromGlmQuat(glm::normalize(delta * childRotation))); } } void CompoundObject::lookAt(const Position3d &target, const Normal3d &up) { - for (auto &obj : objects) { - obj->lookAt(target, up); - } + glm::vec3 forward = target.toGlm() - position.toGlm(); + if (glm::length(forward) < 0.000001f) { + return; + } + forward = glm::normalize(forward); + glm::vec3 upVector = up.toGlm(); + if (glm::length(upVector) < 0.000001f || + std::abs(glm::dot(glm::normalize(upVector), forward)) > 0.9999f) { + upVector = std::abs(forward.y) < 0.9999f ? glm::vec3(0.0f, 1.0f, 0.0f) + : glm::vec3(1.0f, 0.0f, 0.0f); + } + glm::vec3 right = glm::normalize(glm::cross(forward, upVector)); + glm::vec3 realUp = glm::cross(right, forward); + glm::mat3 matrix; + matrix[0] = right; + matrix[1] = realUp; + matrix[2] = -forward; + setRotation( + Rotation3d::fromGlmQuat(glm::normalize(glm::quat_cast(matrix)))); } void CompoundObject::rotate(const Rotation3d &deltaRotation) { - for (auto &obj : objects) { - obj->rotate(deltaRotation); - } + setRotation(rotation + deltaRotation); } void CompoundObject::setScale(const Scale3d &newScale) { - for (auto &obj : objects) { - obj->setScale(newScale); + const auto factor = [](double next, double previous) { + return std::abs(previous) < 0.000001 ? next : next / previous; + }; + const glm::vec3 scaleFactor(factor(newScale.x, scale.x), + factor(newScale.y, scale.y), + factor(newScale.z, scale.z)); + const glm::quat orientation = glm::normalize(rotation.toGlmQuat()); + const glm::quat inverseOrientation = glm::inverse(orientation); + scale = newScale; + for (auto *obj : objects) { + if (obj == nullptr) { + continue; + } + glm::vec3 offset = obj->getPosition().toGlm() - position.toGlm(); + offset = orientation * ((inverseOrientation * offset) * scaleFactor); + obj->setPosition(Position3d::fromGlm(position.toGlm() + offset)); + const Size3d childScale = obj->getScale(); + obj->setScale({childScale.x * scaleFactor.x, + childScale.y * scaleFactor.y, + childScale.z * scaleFactor.z}); } } void CompoundObject::hide() { for (auto &obj : objects) { - obj->hide(); + if (obj != nullptr) { + obj->hide(); + } } } void CompoundObject::show() { for (auto &obj : objects) { - obj->show(); + if (obj != nullptr) { + obj->show(); + } } } std::vector CompoundObject::getVertices() const { std::vector allVertices; for (const auto &obj : objects) { + if (obj == nullptr) { + continue; + } std::vector objVertices = obj->getVertices(); allVertices.insert(allVertices.end(), objVertices.begin(), objVertices.end()); @@ -299,12 +427,6 @@ void CompoundObject::setLatePipeline(std::shared_ptr pipeline) { } } -bool CompoundObject::lateCanCastShadows() const { - return std::ranges::any_of(lateForwardObjects, [](const auto *obj) { - return obj != nullptr && obj->canCastShadows(); - }); -} - Window *Component::getWindow() { return Window::mainWindow; } void UIView::setViewMatrix(const glm::mat4 &view) { diff --git a/cli/src/create.rs b/cli/src/create.rs index 2b2c740c..cd7c61aa 100644 --- a/cli/src/create.rs +++ b/cli/src/create.rs @@ -74,6 +74,14 @@ const SCENE_TEMPLATE: &str = r#"{ "environment": { "automaticAmbient": true, "atmosphereSky": true, + "atmosphere": { + "enabled": true, + "globalLight": { + "enabled": true, + "castsShadows": true, + "shadowResolution": 4096, + }, + }, }, } "#; diff --git a/editor/project/projectStore.cpp b/editor/project/projectStore.cpp index 366d7bbe..4e890656 100644 --- a/editor/project/projectStore.cpp +++ b/editor/project/projectStore.cpp @@ -118,7 +118,15 @@ QByteArray starterScene(AtlasProjectTemplate projectTemplate) { ], "environment": { "automaticAmbient": true, - "atmosphereSky": true + "atmosphereSky": true, + "atmosphere": { + "enabled": true, + "globalLight": { + "enabled": true, + "castsShadows": true, + "shadowResolution": 4096 + } + } } } )"); diff --git a/include/atlas/component.h b/include/atlas/component.h index 6d22b32b..b54b7c6b 100644 --- a/include/atlas/component.h +++ b/include/atlas/component.h @@ -23,6 +23,7 @@ #include #include #include +#include #include class CoreObject; @@ -549,6 +550,7 @@ class CompoundObject : public GameObject { * synchronized before rendering. */ virtual void update(Window &window) override; + void beforePhysics() override; /** * @brief Updates the objects within the compound object. * @@ -590,6 +592,7 @@ class CompoundObject : public GameObject { * child. */ Position3d getPosition() const override; + Rotation3d getRotation() const override; /** * @brief Collects the vertices from the first child CoreObject for quick * queries such as bounding-box generation. @@ -646,12 +649,9 @@ class CompoundObject : public GameObject { * @param obj The component instance to add. \warning It must be long-lived. * This means that declaring it as a class property is a good idea. */ - inline void addObject(GameObject *obj) { - objects.push_back(obj); - if (obj != nullptr && obj->renderLateForward) { - lateForwardObjects.push_back(obj); - } - } + void addObject(GameObject *obj, bool childInitialized = false); + void removeObject(GameObject *obj); + bool containsObject(const GameObject *obj) const; /** * @brief Returns the late forward proxy renderable, if any children @@ -665,11 +665,13 @@ class CompoundObject : public GameObject { class LateCompoundRenderable; Position3d position{0.0, 0.0, 0.0}; - std::vector originalPositions; + Rotation3d rotation{0.0, 0.0, 0.0}; + Scale3d scale{1.0, 1.0, 1.0}; std::vector lateForwardObjects; + std::unordered_set initializedObjects; std::shared_ptr lateRenderableProxy; bool lateRenderableRegistered = false; - bool changedPosition = false; + bool initialized = false; void renderLate(float dt, const std::shared_ptr &commandBuffer, @@ -680,7 +682,7 @@ class CompoundObject : public GameObject { std::optional> getLateShaderPipelineInternal(); void setLatePipeline(std::shared_ptr pipeline); - bool lateCanCastShadows() const; + void syncLateRenderableRegistration(); }; /** diff --git a/include/atlas/window.h b/include/atlas/window.h index a08026dd..9c0958f2 100644 --- a/include/atlas/window.h +++ b/include/atlas/window.h @@ -513,6 +513,8 @@ class Window { * idea. */ void addObject(Renderable *object); + void addInitializedObject(Renderable *object); + void removeObjectFromRendering(Renderable *object); /** * @brief Removes a previously registered renderable from the window. */ @@ -835,6 +837,7 @@ class Window { std::shared_ptr activeCommandBuffer = nullptr; CoreWindowReference windowRef; std::vector pendingObjects; + std::vector pendingInitializedObjects; std::vector pendingRemovals; std::vector renderables; std::vector preferenceRenderables; @@ -864,6 +867,7 @@ class Window { void setupSSAO(); void applyScene(Scene *scene); + void removeObjectInternal(Renderable *object, bool clearEditorState); glm::mat4 calculateProjectionMatrix(); glm::mat4 lastViewMatrix = glm::mat4(1.0f); diff --git a/photon/gi.cpp b/photon/gi.cpp index f71571b2..19e91cee 100644 --- a/photon/gi.cpp +++ b/photon/gi.cpp @@ -173,43 +173,54 @@ uint64_t computeDdgiLayoutSignature(const std::vector &objects, return signature; } -void collectDdgiObjectsFromQueue(const std::vector &renderables, - std::unordered_set &seen, - std::vector &objects) { - for (auto *renderable : renderables) { - if (renderable == nullptr) { - continue; +void collectDdgiObject(Renderable *renderable, + std::unordered_set &seen, + std::vector &objects) { + if (renderable == nullptr) { + return; + } + if (auto *object = dynamic_cast(renderable)) { + if (seen.insert(object).second) { + objects.push_back(object); } - if (auto *object = dynamic_cast(renderable)) { + return; + } + if (auto *compound = dynamic_cast(renderable)) { + for (auto *child : compound->objects) { + collectDdgiObject(child, seen, objects); + } + return; + } + if (auto *model = dynamic_cast(renderable)) { + const auto &meshes = static_cast(model)->getObjects(); + for (const auto &mesh : meshes) { + CoreObject *object = mesh.get(); + if (object == nullptr) { + continue; + } + bool hasAnyTexture = !object->textures.empty(); + if (!hasAnyTexture) { + object->material = model->material; + } + object->material.useNormalMap = model->material.useNormalMap; + object->material.normalMapStrength = + model->material.normalMapStrength; + object->useDeferredRendering = model->useDeferredRendering; if (seen.insert(object).second) { objects.push_back(object); } - continue; - } - if (auto *model = dynamic_cast(renderable)) { - const auto &meshes = - static_cast(model)->getObjects(); - for (const auto &mesh : meshes) { - CoreObject *object = mesh.get(); - if (object == nullptr) { - continue; - } - bool hasAnyTexture = !object->textures.empty(); - if (!hasAnyTexture) { - object->material = model->material; - } - object->material.useNormalMap = model->material.useNormalMap; - object->material.normalMapStrength = - model->material.normalMapStrength; - object->useDeferredRendering = model->useDeferredRendering; - if (seen.insert(object).second) { - objects.push_back(object); - } - } } } } +void collectDdgiObjectsFromQueue(const std::vector &renderables, + std::unordered_set &seen, + std::vector &objects) { + for (auto *renderable : renderables) { + collectDdgiObject(renderable, seen, objects); + } +} + } // namespace void photon::GlobalIllumination::init() { @@ -866,4 +877,4 @@ void photon::GlobalIllumination::render( frameIndex = std::min(frameIndex + 1, 1 << 30); } -#endif \ No newline at end of file +#endif diff --git a/photon/path_tracing.cpp b/photon/path_tracing.cpp index c8fa6a4f..a2126932 100644 --- a/photon/path_tracing.cpp +++ b/photon/path_tracing.cpp @@ -100,44 +100,55 @@ int findTextureSlotForType( return -1; } -void collectPathTracingObjectsFromQueue( - const std::vector &renderables, - std::unordered_set &seen, - std::vector &objects) { - for (auto *renderable : renderables) { - if (renderable == nullptr) { - continue; +void collectPathTracingObject(Renderable *renderable, + std::unordered_set &seen, + std::vector &objects) { + if (renderable == nullptr) { + return; + } + if (auto *object = dynamic_cast(renderable)) { + if (seen.insert(object).second) { + objects.push_back(object); } - if (auto *object = dynamic_cast(renderable)) { + return; + } + if (auto *compound = dynamic_cast(renderable)) { + for (auto *child : compound->objects) { + collectPathTracingObject(child, seen, objects); + } + return; + } + if (auto *model = dynamic_cast(renderable)) { + const auto &meshes = static_cast(model)->getObjects(); + for (const auto &mesh : meshes) { + CoreObject *object = mesh.get(); + if (object == nullptr) { + continue; + } + bool hasAnyTexture = !object->textures.empty(); + if (!hasAnyTexture) { + object->material = model->material; + } + object->material.useNormalMap = model->material.useNormalMap; + object->material.normalMapStrength = + model->material.normalMapStrength; + object->useDeferredRendering = model->useDeferredRendering; if (seen.insert(object).second) { objects.push_back(object); } - continue; - } - if (auto *model = dynamic_cast(renderable)) { - const auto &meshes = - static_cast(model)->getObjects(); - for (const auto &mesh : meshes) { - CoreObject *object = mesh.get(); - if (object == nullptr) { - continue; - } - bool hasAnyTexture = !object->textures.empty(); - if (!hasAnyTexture) { - object->material = model->material; - } - object->material.useNormalMap = model->material.useNormalMap; - object->material.normalMapStrength = - model->material.normalMapStrength; - object->useDeferredRendering = model->useDeferredRendering; - if (seen.insert(object).second) { - objects.push_back(object); - } - } } } } +void collectPathTracingObjectsFromQueue( + const std::vector &renderables, + std::unordered_set &seen, + std::vector &objects) { + for (auto *renderable : renderables) { + collectPathTracingObject(renderable, seen, objects); + } +} + } // namespace void photon::PathTracing::init() { @@ -796,4 +807,4 @@ void photon::PathTracing::render( frameIndex++; } -#endif \ No newline at end of file +#endif diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index c235eb1e..c2bce8a0 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -4434,18 +4434,52 @@ std::string editorObjectType(const Context &context, GameObject &object) { return "gameObject"; } -json editorObjectBoundsSize(GameObject &object) { +bool editorObjectWorldBounds(GameObject &object, glm::vec3 &minimum, + glm::vec3 &maximum) { + if (auto *compound = dynamic_cast(&object)) { + bool found = false; + for (auto *child : compound->objects) { + if (child == nullptr) { + continue; + } + glm::vec3 childMinimum; + glm::vec3 childMaximum; + if (!editorObjectWorldBounds(*child, childMinimum, childMaximum)) { + continue; + } + minimum = found ? glm::min(minimum, childMinimum) : childMinimum; + maximum = found ? glm::max(maximum, childMaximum) : childMaximum; + found = true; + } + return found; + } + const std::vector vertices = object.getVertices(); + if (vertices.empty()) { + return false; + } + glm::mat4 transform = + glm::translate(glm::mat4(1.0f), object.getPosition().toGlm()); + transform *= + glm::mat4_cast(glm::normalize(object.getRotation().toGlmQuat())); + transform = glm::scale(transform, object.getScale().toGlm()); + minimum = glm::vec3(std::numeric_limits::max()); + maximum = glm::vec3(std::numeric_limits::lowest()); + for (const CoreVertex &vertex : vertices) { + const glm::vec3 position = + glm::vec3(transform * glm::vec4(vertex.position.toGlm(), 1.0f)); + minimum = glm::min(minimum, position); + maximum = glm::max(maximum, position); + } + return true; +} + +json editorObjectBoundsSize(GameObject &object) { + glm::vec3 minimum; + glm::vec3 maximum; glm::vec3 size = glm::abs(object.getScale().toGlm()); - if (!vertices.empty()) { - glm::vec3 minimum(std::numeric_limits::max()); - glm::vec3 maximum(std::numeric_limits::lowest()); - for (const CoreVertex &vertex : vertices) { - const glm::vec3 position = vertex.position.toGlm(); - minimum = glm::min(minimum, position); - maximum = glm::max(maximum, position); - } - size *= maximum - minimum; + if (editorObjectWorldBounds(object, minimum, maximum)) { + size = maximum - minimum; } size.x = std::max(size.x, 0.05f); size.y = std::max(size.y, 0.05f); @@ -5116,6 +5150,11 @@ bool Context::setObjectParent(int childId, int parentId) { } GameObject *parent = nullptr; + GameObject *previousParent = nullptr; + if (auto previous = objectParents.find(childId); + previous != objectParents.end()) { + previousParent = findContextObject(*this, previous->second); + } if (parentId >= 0) { parent = findContextObject(*this, parentId); if (parent == nullptr || parent == child) { @@ -5135,6 +5174,23 @@ bool Context::setObjectParent(int childId, int parentId) { } } + auto *previousCompound = dynamic_cast(previousParent); + auto *nextCompound = dynamic_cast(parent); + + if (previousCompound != nullptr && previousCompound != nextCompound) { + previousCompound->removeObject(child); + if (nextCompound == nullptr && window != nullptr) { + window->addInitializedObject(child); + } + } + + if (nextCompound != nullptr && nextCompound != previousCompound) { + if (previousCompound == nullptr && window != nullptr) { + window->removeObjectFromRendering(child); + } + nextCompound->addObject(child, true); + } + if (parentId < 0) { objectParents.erase(childId); objectParentReferences.erase(childId); @@ -5147,9 +5203,9 @@ bool Context::setObjectParent(int childId, int parentId) { objectParents[childId] = parentId; objectParentReferences[childId] = std::to_string(parentId); - editorObjectSourceData[childId]["parent"] = - objectNames.contains(parentId) ? objectNames[parentId] - : std::to_string(parentId); + editorObjectSourceData[childId]["parent"] = objectNames.contains(parentId) + ? objectNames[parentId] + : std::to_string(parentId); if (window != nullptr) { window->setEditorObjectParent(child, parent); } @@ -5274,10 +5330,7 @@ bool Context::deleteObject(int id) { if (compound == nullptr) { continue; } - auto &compoundObjects = compound->objects; - compoundObjects.erase( - std::remove(compoundObjects.begin(), compoundObjects.end(), object), - compoundObjects.end()); + compound->removeObject(object); } if (window != nullptr) { @@ -6293,12 +6346,6 @@ void Context::loadScene(Window &window, const json &sceneData) { for (const auto &[childId, parentId] : objectParents) { GameObject *child = findContextObject(*this, childId); GameObject *parent = findContextObject(*this, parentId); - if (auto *compound = dynamic_cast(parent); - compound != nullptr && - std::ranges::find(compound->objects, child) != - compound->objects.end()) { - continue; - } window.setEditorObjectParent(child, parent); }